pax_global_header00006660000000000000000000000064152672037100014515gustar00rootroot0000000000000052 comment=736fcf36c5acabe153e5b0b0b444caea8dc4c235 caronc-apprise-dfec414/000077500000000000000000000000001526720371000151175ustar00rootroot00000000000000caronc-apprise-dfec414/.codecov.yml000066400000000000000000000005221526720371000173410ustar00rootroot00000000000000codecov: require_ci_to_pass: false comment: layout: "diff, flags, files" behavior: default require_changes: false # if true: only post the comment if coverage changes coverage: status: project: default: target: auto threshold: 1% patch: default: target: auto threshold: 1% caronc-apprise-dfec414/.env000066400000000000000000000000641526720371000157100ustar00rootroot00000000000000LANG=C.UTF-8 PYTHONPATH=. PYTHONDONTWRITEBYTECODE=1 caronc-apprise-dfec414/.github/000077500000000000000000000000001526720371000164575ustar00rootroot00000000000000caronc-apprise-dfec414/.github/FUNDING.yml000066400000000000000000000001331526720371000202710ustar00rootroot00000000000000github: caronc custom: - 'https://www.paypal.com/donate/?hosted_button_id=CR6YF7KLQWQ5E' caronc-apprise-dfec414/.github/ISSUE_TEMPLATE/000077500000000000000000000000001526720371000206425ustar00rootroot00000000000000caronc-apprise-dfec414/.github/ISSUE_TEMPLATE/1_bug_report.md000066400000000000000000000017601526720371000235600ustar00rootroot00000000000000--- name: ðŸ�› Bug Report about: Report errors and problems in Apprise title: '' labels: 'bug' assignees: '' --- ## Notification Service(s) Impacted ## What happened ## Apprise URL(s) involved (redact secrets) ## Steps to reproduce 1. 2. 3. ## Environment - Apprise version: - Python version: - OS and distribution: - Install method: - If using Docker: image/tag: ## Logs (redact secrets) ```text paste logs here ``` caronc-apprise-dfec414/.github/ISSUE_TEMPLATE/2_enhancement_request.md000066400000000000000000000014051526720371000254420ustar00rootroot00000000000000--- name: 💡 Enhancement Request about: Suggest an improvement to Apprise title: '' labels: 'enhancement' assignees: '' --- ## The idea ## Use-case ## Proposed change ## Compatibility impact - Would this be a breaking change? Yes / No - If yes, describe what breaks and any migration path. ## Alternatives considered ## Documentation impact - Does appriseit.com require updates for this change? Yes / No - If yes, please also open (or link) a documentation ticket/PR in apprise-docs. ## Additional context caronc-apprise-dfec414/.github/ISSUE_TEMPLATE/3_new-notification-request.md000066400000000000000000000023471526720371000263570ustar00rootroot00000000000000--- name: 📣 New Notification Request about: Request a new notification service integration title: '' labels: ['enhancement', 'new-notification'] assignees: '' --- ## What is the name of the service? ## Proposed Apprise schema (service id) ## Proposed Appriseit service slug ## Provide details that help development - Homepage: - Official API docs: - Authentication method: - Rate limits (if known): - Message limits (if known): - Attachments supported: Yes / No / Unknown ## Example payload or curl snippet (optional) ```text paste example here ``` ## Anything else? ## â˜�ï¸� Documentation note If this integration is accepted, it must also include an apprise-docs update so the service page exists on appriseit.com. If you can contribute docs, open a ticket or PR in: https://github.com/caronc/apprise-docs caronc-apprise-dfec414/.github/ISSUE_TEMPLATE/4_question.md000066400000000000000000000006631526720371000232630ustar00rootroot00000000000000--- name: â�“ Support Question about: Ask a question about Apprise (prefer Discussions) title: '' labels: 'question' assignees: '' --- ## Please use Discussions for support questions https://github.com/caronc/apprise/discussions If you are filing an issue anyway, include: ## Question ## Apprise version and environment - Apprise version: - Python version: - OS and distribution: - Install method: caronc-apprise-dfec414/.github/ISSUE_TEMPLATE/config.yaml000066400000000000000000000007201526720371000227720ustar00rootroot00000000000000blank_issues_enabled: false contact_links: - name: Documentation (Apprise Docs) url: https://appriseit.com about: Documentation - name: Documentation Source (Apprise Docs) url: https://github.com/caronc/apprise-docs about: Documentation updates, fixes, and translation work belong here. - name: Support and Questions url: https://github.com/caronc/apprise/discussions about: Please use Discussions for questions and general support. caronc-apprise-dfec414/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000046041526720371000222640ustar00rootroot00000000000000## Description **Related issue (if applicable):** # ## Checklist * [ ] Documentation ticket created (if applicable): [apprise-docs/##](https://github.com/caronc/apprise-docs/pull/) * [ ] The change is tested and works locally. * [ ] No commented-out code in this PR. * [ ] No lint errors (use `tox -e lint` and optionally `tox -e format`). * [ ] Test coverage added or updated (use `tox -e qa`). ## Testing Anyone can help test as follows: ```bash # Create a virtual environment python3 -m venv apprise # Change into our new directory cd apprise # Activate our virtual environment source bin/activate # Install the branch pip install git+https://github.com/caronc/apprise.git@ # If you have cloned the branch and have tox available to you: tox -e apprise -- -t "Test Title" -b "Test Message" \ ``` caronc-apprise-dfec414/.github/badges/000077500000000000000000000000001526720371000177045ustar00rootroot00000000000000caronc-apprise-dfec414/.github/badges/loc.svg000066400000000000000000000016351526720371000212070ustar00rootroot00000000000000 Lines of Code Lines of Code 79,977 79,977 caronc-apprise-dfec414/.github/workflows/000077500000000000000000000000001526720371000205145ustar00rootroot00000000000000caronc-apprise-dfec414/.github/workflows/codeql-analysis.yml000066400000000000000000000017221526720371000243310ustar00rootroot00000000000000name: "CodeQL" on: push: branches: [ master, apprise-v1-release ] pull_request: branches: [ master, apprise-v1-release ] schedule: - cron: '42 15 * * 5' # Cancel in-progress jobs when pushing to the same branch. concurrency: cancel-in-progress: true group: ${{ github.workflow }}-${{ github.ref }} jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'python' ] steps: - name: Checkout repository uses: actions/checkout@v7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} - name: Autobuild uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 caronc-apprise-dfec414/.github/workflows/lint.yml000066400000000000000000000007161526720371000222110ustar00rootroot00000000000000# .github/workflows/lint.yml name: Run Lint Checks on: push: paths: - '**.py' pull_request: paths: - '**.py' jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v7 with: python-version: '3.12' - name: Install tox run: python -m pip install tox - name: Run Ruff lint check run: tox -e lint caronc-apprise-dfec414/.github/workflows/loc-badge.yml000066400000000000000000000024601526720371000230560ustar00rootroot00000000000000# LoC = Lines of Code name: LoC Badge on: # Manual only workflow_dispatch: permissions: contents: write pull-requests: write jobs: loc-badge: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v7 - name: Generate LoC badge (Apprise library only) uses: alexispurslane/GHA-LoC-Badge@v2.0.0 id: badge with: debug: true # Run from the root of the repo so the pattern paths are exact directory: ./ badge: .github/badges/loc.svg patterns: "apprise/**/*.py" ignore: "__pycache__" - name: Print the output run: | echo "Scanned: ${{ steps.badge.outputs.counted_files }}"; echo "Line Count: ${{ steps.badge.outputs.total_lines }}"; - name: Create PR (if changed) uses: peter-evans/create-pull-request@v8 with: # [skip ci] stops the PR from starting the test, CodeQL and # packaging workflows, since only a badge image changed. commit-message: "Update Lines of Code (LoC) badge [skip ci]" title: "Update LoC badge" body: "Automated update of the Lines of Code badge." branch: "automation/loc-badge" delete-branch: true add-paths: ".github/badges/loc.svg" caronc-apprise-dfec414/.github/workflows/pkgbuild.yml000066400000000000000000000060221526720371000230400ustar00rootroot00000000000000# # Verify on CI/GHA that RPM package building works. # name: RPM Packaging on: # master - v1.x branch # apprise-v2-release - v2.x branch push: branches: [main, master, apprise-v1-release, 'release/**'] pull_request: branches: [main, master, apprise-v1-release] workflow_dispatch: jobs: build: name: Build RPMs (${{ matrix.dist }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: dist: [el9, el10] container: image: ghcr.io/caronc/apprise-rpmbuild:${{ matrix.dist }} options: --user root steps: - name: Checkout source uses: actions/checkout@v7 - name: Build RPMs run: ./bin/build-rpm.sh env: APPRISE_DIR: ${{ github.workspace }} # Drop RPMs into dist/ inside the workspace DIST_DIR: ${{ github.workspace }}/dist/${{ matrix.dist }} - name: Show RPMs found for upload run: | echo "Listing dist/${{ matrix.dist }}/**/*.rpm:" find dist/${{ matrix.dist }} -type f -name '*.rpm' - name: Upload RPM Artifacts uses: actions/upload-artifact@v7 with: name: built-rpms-${{ matrix.dist }} path: ./dist/${{ matrix.dist }} if-no-files-found: error retention-days: 5 - name: Upload rpmlint config files uses: actions/upload-artifact@v7 with: # Upload the specific files needed for verification name: rpmlint-config-${{ matrix.dist }} path: ./packaging/redhat/python-apprise.rpmlintrc.${{ matrix.dist }} retention-days: 5 verify: name: Verify RPMs (${{ matrix.dist }}) needs: build runs-on: ubuntu-latest strategy: fail-fast: false matrix: dist: [el9, el10] container: image: ghcr.io/caronc/apprise-rpmbuild:${{ matrix.dist }} options: --user root steps: - name: Download built RPMs uses: actions/download-artifact@v8 with: name: built-rpms-${{ matrix.dist }} path: ./dist - name: Download rpmlint config files uses: actions/download-artifact@v8 with: name: rpmlint-config-${{ matrix.dist }} # Download files directly into the correct directory path: ./packaging/redhat - name: Lint RPMs run: | set -e RC_FILE="./packaging/redhat/python-apprise.rpmlintrc.${{ matrix.dist }}" if rpmlint --help 2>&1 | grep -q -- '--rpmlintrc'; then echo "Using rpmlint --rpmlintrc with $RC_FILE" rpmlint --rpmlintrc "$RC_FILE" ./dist/**/*.rpm else echo "Using rpmlint v1.x on older distribution" rpmlint -f "$RC_FILE" ./dist/**/*.rpm fi - name: Install and verify RPMs run: | echo "Installing RPMs from: ./dist/" find ./dist -name '*.rpm' dnf install -y ./dist/**/*.rpm apprise --version - name: Check Installed Files run: rpm -qlp ./dist/**/*.rpm caronc-apprise-dfec414/.github/workflows/tests.yml000066400000000000000000000111121526720371000223750ustar00rootroot00000000000000name: Run Tests on: # master - v2.x branch # apprise-v1-release - v1.x branch push: branches: [main, master, apprise-v1-release, 'release/**'] # Always test on pull requests targeting any of our long-lived branches pull_request: branches: [main, master, apprise-v1-release] # Allow manual triggering via GitHub UI workflow_dispatch: jobs: test: name: Python ${{ matrix.python-version }} – ${{ matrix.tox_env }} on ${{ matrix.os }} runs-on: ${{ matrix.os || 'ubuntu-latest' }} # Allow selected matrix jobs to fail without failing the workflow continue-on-error: ${{ matrix.continue-on-error || false }} strategy: fail-fast: false # Let all jobs run, even if one fails matrix: include: - python-version: "3.9" tox_env: qa - python-version: "3.10" tox_env: qa - python-version: "3.11" tox_env: qa - python-version: "3.12" tox_env: qa - python-version: "3.13" tox_env: qa # Test compiled libraries omitted from all-plugins - python-version: "3.14" tox_env: qa qa_deps: python-qpid-proton # The newest Python may lack dependency wheels, so this is optional - python-version: "3.15" tox_env: qa build_tools: true continue-on-error: true # Platform validation only (one version) - os: windows-latest python-version: "3.14" tox_env: qa - os: macos-latest python-version: "3.14" tox_env: qa # Minimal test run on our main Python only # this verifies Apprise still works when extra libraries are not available - python-version: "3.14" tox_env: minimal steps: - uses: actions/checkout@v7 # Run on the Python version this matrix entry names - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} # Install headers needed when dependency wheels are unavailable - name: Install build tools for compiled libraries if: matrix.build_tools run: | sudo apt-get update -qq sudo apt-get install -y -qq libusb-1.0-0-dev libudev-dev # Install tox for isolated environment and plugin test orchestration - name: Install tox run: python -m pip install tox # Run tox with the specified environment (qa, minimal, etc.) - name: Run tox for ${{ matrix.tox_env }} run: tox -e ${{ matrix.tox_env }} env: APPRISE_QA_DEPS: ${{ matrix.qa_deps }} - name: Upload coverage report if: always() uses: actions/upload-artifact@v7 with: name: coverage-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.tox_env }} path: .coverage include-hidden-files: true # The codecov job below consumes these in the same run, # Prevent them from lasting for the default of 90 days retention-days: 7 codecov: name: Upload merged coverage to Codecov runs-on: ubuntu-latest needs: test # Waits for all matrix jobs to complete if: always() # Even if a test fails, still attempt to upload what we have steps: - uses: actions/checkout@v7 - name: Download all coverage reports uses: actions/download-artifact@v8 with: path: coverage-artifacts - name: Combine and generate coverage run: | pip install coverage # Create a consistent temp dir mkdir -p coverage-inputs # Copy and rename each coverage file to .coverage.job_name i=0 for f in $(find coverage-artifacts -name .coverage); do cp "$f" "coverage-inputs/.coverage.$i" i=$((i+1)) done # Confirm files staged ls -alh coverage-inputs # Combine them all coverage combine coverage-inputs coverage report coverage xml -o coverage.xml # Upload merged coverage results to Codecov for visualization - name: Upload to Codecov uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml verbose: false # Used for debugging only fail_ci_if_error: false # Avoid failing job if Codecov is down env: CODECOV_PR: ${{ github.event.pull_request.number }} CODECOV_SHA: ${{ github.sha }} caronc-apprise-dfec414/.gitignore000066400000000000000000000015121526720371000171060ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # left-over-files from conflicts/merges *.orig # C extensions *.so # vi swap files .*.sw? # Distribution / packaging .Python env/ .venv* build/ BUILD/ BUILDROOT/ SOURCES/ SRPMS/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib64/ parts/ sdist/ *.egg-info/ .installed.cfg *.egg .local # Generated from Docker Instance .bash_history .python_history # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ #Ipython Notebook .ipynb_checkpoints #PyCharm .idea #PyDev (Eclipse) .project .pydevproject .settings # Others .DS_Store caronc-apprise-dfec414/.gitleaks.toml000066400000000000000000000010311526720371000176700ustar00rootroot00000000000000# Gitleaks configuration for the Apprise project. # Reference: https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml [allowlist] description = "Test fixture key files are intentional test data, not real credentials." paths = [ # PGP test fixtures under tests/var/pgp/ are synthetic keys generated # for unit testing only. They are not real private keys and carry no # security risk. '''tests/var/pgp/valid-prv\.asc''', '''tests/var/pgp/valid-pub\.asc''', '''tests/var/pgp/corrupt-pub\.asc''', ] caronc-apprise-dfec414/.vscode/000077500000000000000000000000001526720371000164605ustar00rootroot00000000000000caronc-apprise-dfec414/.vscode/extensions.json000066400000000000000000000002771526720371000215600ustar00rootroot00000000000000{ "recommendations": [ // Ruff – linter and formatter for Python (replaces flake8, black, isort) "charliermarsh.ruff", // Python language support "ms-python.python" ] } caronc-apprise-dfec414/.vscode/settings.json000066400000000000000000000020071526720371000212120ustar00rootroot00000000000000{ // Use the Ruff extension (charliermarsh.ruff) as the sole formatter and linter // for Python files. Install it from the VS Code marketplace or via // ext install charliermarsh.ruff "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnSave": true, "editor.codeActionsOnSave": { // Apply all auto-fixable lint violations on save (isort, pyupgrade, etc.) "source.fixAll.ruff": "explicit", // Keep imports sorted on save "source.organizeImports.ruff": "explicit" } }, // Ruff reads its configuration from pyproject.toml automatically, so no // separate ruff.configuration key is needed. Leave the extension defaults. // Testing "python.testing.pytestEnabled": true, "python.testing.unittestEnabled": false, "python.testing.pytestArgs": ["tests"], "python.testing.cwd": "${workspaceFolder}", // Python path "python.envFile": "${workspaceFolder}/.env", "terminal.integrated.env.linux": { "PYTHONPATH": "${workspaceFolder}" } } caronc-apprise-dfec414/ACKNOWLEDGEMENTS.md000066400000000000000000000021531526720371000177740ustar00rootroot00000000000000# Contributions to the Apprise Project ## Creator & Maintainer * Chris Caron ## Contributors The following users have contributed to this project and their deserved recognition has been identified here. If you have contributed and wish to be acknowledged for it, the syntax is as follows: ``` * [Your name or handle] <[email or website]> * [Month Year] - [Brief summary of your contribution] ``` The contributors have been listed in chronological order: * Wim de With * Dec 2018 - Added Matrix Support * Hitesh Sondhi * Mar 2019 - Added Flock Support * Andreas Motl * Mar 2020 - Fix XMPP Support * Oct 2022 - Drop support for Python 2 * Oct 2022 - Add support for Python 3.11 * Oct 2022 - Improve efficiency of NotifyEmail * Joey Espinosa <@particledecay> * Apr 3rd 2022 - Added Ntfy Support * Kate Ward * 6th Feb 2024 - Add Revolt Support * Han Wang * Apr 2024 - Refactored test cases * Toni Wells <@isometimescode> * May 2024 - Fixed token length with apprise:// caronc-apprise-dfec414/Apprise-overview.png000066400000000000000000017263251526720371000211140ustar00rootroot00000000000000‰PNG  IHDR8ˆ¶ pHYsÃÃÇo¨dtEXtSoftwarewww.inkscape.org›î<tEXtTitleApprise OverviewIÙa´tEXtAuthorChris Caron9“o?tEXtDescriptionAn overview of how Apprise Push Notifications work.b»ãtEXtSourcehttps://appriseit.comJ`�PtEXtCopyrightCC Attribution-NoDerivs http://creativecommons.org/licenses/by-nd/4.0/øU²• IDATxœìÝw|SåþðOÒ½÷¤´e•½e™*Q½êuáÞ{áO½^¯[q+ lDöVöÞ£ŒîݤM›Žìs~JK’&¡iÓ&Ÿ÷ëúºíyžóœoÂéiz¾çû<QEQ�R­ ZÁàì0š”^ 6¸ÖkòôF¨·¯³Ã "jv¤Î€ˆˆˆˆˆˆˆˆˆ¨9Ñ·K€ÞkI<¥¼NDd¯ŽDDDDDDDDDDµTê5ÎÁ) ¢ë%E=%¼NDd¯ŽDDDDDDDDDDFjƒ:ApvMN€Q”8; ‡c‚�ˆÈ<^‰ˆˆˆˆˆˆˆˆˆŒ*õZg‡àÁõ¦‰ë%=‰ˆ� B""""""""""cõ ^t¿êAйàô¢)$L™Å!€*ƒ{VJ¢ëUz29HDd„DDDDDDDDDäöÜuíAЋ¢1MèZ¤LYÄ!¹=w­ƒ‹N«Ê!‘eL‘[Ó·­DM¡ëñ�ðö7‘%¼B‘[«Ò»oõ (ºp¡ N›JDä(L‘ÛÒ hƒ³Ãp$.šHã£DD–1AHDDDDDDDDDn«R¯svNepÑéEÀƒ B""‹˜ $"""""""""·¤h½³Ãp*½‹N/ VÕ‹ B"""""""""rKUn^="\·‚�kYÆ!¹A¡6¸w‚Pˆ¢ë&ÑXAHDd„DDDDDDDDDävª :®�³�ëN.zÓƒDD–1AHDDDDDDDDDnE4&Ý�Á…× +‰ˆ,b‚�ˆˆˆˆˆˆˆˆˆÜŠÚ ƒ º{ý `pv�ˆ©A"¢ú1AHDDDDDDDDDn…Õƒ— .\AÈêA"¢ú1AHDDDDDDDDDnC+ \71f+D&шˆÜ„DDDDDDDDDä6X=x™èÂÕƒà£DDVy:;"""""""""¢¦ ˆ"4½³Ãh .¾£„)Â&wæÔidd¤£ /r™ ‚(ÂÇÛÞÞÞðöñ�Ÿ¿ÂÃ#‡èØX„„„ (8˜ÓÁ9‰D¹/¹¾J½•z­³ÃhÔ=ô.|kØK*E¬o ³Ãp[¢("#='ŽÃ�}ûP¦(Cdd$"##!•^�ØP"‘ 0(Im’Ñ:1­Zµ‚@€Sc'rL‘[�iªapñ©5m¥t0¸ð[á%õ@¬/MÍE~^vïÚ…=»vC«Ñ ::áááf«‘”œŒ6íÚ"1) ^^^N‰™ÈÕ1AHDDDDDDDDD.O#è¡Ðª�F³QeÐB]wjGV6_éظ~=öìÚ…ððp´jÕ ~~~fûzzz¢MÛ¶èÔµ ’Û´át¤DÄ!¹<…V �Àõ@�jƒÎÙa4*O‰q~L6gJ%6m܈ ëÖÃC*EBBBCC-ö@×îÝÑ£WONCJäL‘K3ˆ"dš*g‡Ñl¢ˆjƒk'K™ l9ôz=öîÞ�•Ë—£´¤­[·Ftt´ÅjA¤tꄾýn@xDD“ÇKä*˜ $"""""""""—V¥×¢B¯uv͆^ 6œF£ò�Hïäì0È¢(âð¡CXòûïÈÏËGRRbcc-& % Úwè€AC‡ 4,¬Éã%jé˜ $"""""""""—&×TC/ ΣÙÐ h×~?¤ Z1AØ"‰¢ˆ=»wcù’%�ËäHJJª·¢P*•¢G¯^0x|||š<^¢–Š B"""""""""rYZÁ€R­ÊÙa4+ƒ:¿-,� þL¶d¢(bûÖ­X´`!tZ-Ú·o�°z*ýüý1tø0tîÚµIã$j©˜ $"""""""""—U®Ó@eÐ9;Œf¥ZÐC\û¶°@‚°³Ã P«Õذn–/Y ___´oßû'&%aÔØ1 b‚˜¨>L‘K!¢X] ¼Z[µAÏZ3AØâ¬;v?lß‚s¹9ñ À¨=ðÌ­ ¹L†…¿þ†=»w#66mÚ´�§§§Ùq|||0bÔÍèØ©S“¿¢–‚ B"""""""""rIjƒe:µ³Ãhvª Zˆ¢ùõÜ\I‚_�Åuë¨yD¯-ù‹÷îD€hüŸˆÄˆH¬~á„Î�9‹¯¿üE……h×®âââ,ŽÛ©KÜ4êfxyy5á«!j<Þzë­·œ‘£Uèµ0ˆ‚³ÃhVDZ7yO‚¼|˜ l!>X½ ¿íúÛl[yu´:nìÚ …QcFC«Õbïž=P*• 5[M(—É��™‰6mÛÂÛÛ»Ñ_QK"uvDDDDDDDDDDŽ&ˆ"´‚ÞÙa4;¢±2Ëœ<¯EXº/~رµÞ>�­ó½··7fß7oÏ{©‡Bqq±Ù}‹‹Š±xÑï(.2ßN䮘 $"""""""""—£ôî’³‹;ÕÓ˜ löޤ§ãÍåK¬ö+P”âB~¾ÉönÝ»ã“Ï?CÏ^½pöìY\¸p‚`Z![UY‰•K—"7'Ça±µtL‘ËQX=hŽÁ�rfÜc*Õ–*£¸ÿü´zÛ~V÷œ?kv{PP^yãuÜ3ç^àØ±cÐh4&ý´Z-V¯X‰ô´´ÇNä ˜ $"""""""""—bEhƒ³Ã 'ã£ÍW^i)î�ÿJ++lÞçRA�Å6‰D‚)Ó¦áÙ_€F£ÁÑ£GQQa:¶Á`À†5k‘‘ž~ݱ¹ &‰ˆˆˆˆˆˆˆˆÈ¥°zÐ2wªªã£ÍS¾¢3¿þy¥¥ví—Q\dµÏà!CðμyðòöÆñãÇQjæW’„ù¹yvŸÈÕ0AHDDDDDDDDD.E#0Ah‰;åÌXAØü䕖ய?G^i‰Ýû¦Û� €”Nñμyðó÷Ç©S§ —ËMúèõz¬ùóOÈe¦mDî‚ B"""""""""r§%#L6'iÅE¸ó«ëK€¬¼ z¶“Û$ã�yóˆ3gΘMj4¬ùóO¨UªëЇ¨¥c‚�ˆˆˆˆˆˆˆˆˆ\†šÕƒõ“HœA“aaóq:7w}ý9 Ê×=† Š(Q*mîŸÜ&¯½õ¼½½qöìY”••™ô©P*±iýˆY™™8°w_ƒc#ji˜ ¤ë¢P© šþÃD™ZK¾‰ˆˆˆˆˆˆˆÈ N/JµÜ(Ú\-=¸÷ÿ0•µCÆ“)íKÀÀAƒ0óžYÐét8uêôzÓ‡8€üÜ<‡ÄHÔRx:;W&˜²à'UV"ÌÏkî}fJøw¤_Â+›Ö×Ùæé!E«àLìÜwôè]g¿) FaEE�þa~~臹ƒ†"!$´fûê³§ñÁ?ÛMŽéé!EÛ°<=ôFôŠ‹¯Ù^¡Ñ`ÌOó¿Í˜‰‘5mŠR|¶g've¦C­×C*‘ uH(fõî‹™=ûÔÿh~.[½Òâ{3º} Þ=Îb{mj½óîÅÊ3§ ¯ª„ûûcJ—îxtàøz^=�?Û»‹OÇð6mñá¸ÛêŒóЪe8UX€GÆìÞ76^HÅÛÛ6[<ö¬Þ}ñØÀ!5ß¿µm6]8oÒÏÏË ½ââñâ�#¸X"Ç쥿ÃC*Áú9"ØÇ·¦ÿ•—ä°0üqç=€¢ÊJL^ð`ÙÝ÷"!80žGƒ¿ùÜbŒ‘X3û_€¬2îücà×ÛïBJdàý¿·b͹³è�_¦ß 0nþñ[hôz¼qóŒOédñDDDDDDDD-�†ÉA«HŒwœ\ŸA!ˆ"¤n4­jsa|²q=¾û{àÀJ΢뜢têôéH={GFjj*ºuëV§]Elݼ3gßOO¦MÈ=°‚°ËÏÅÅ9”5²Ê8”›m¶ŸÎ`€R£FE­§(ÊÕjœ*,À¼[ñóáuúWj5PjÔÐ?ðèYe ¬M=‹ûV,FµN[ÓWk»J«©Ù&BDiu5çåà±Õ+P®V×iSjÔPjÔjU–«Õ˜½ôwl¹t!¾~–ÜmÃ#�U¦À¼[±ðø‘:1¡fœ†Ð ø÷ªeøîà~È«ªîï�ÿ”VWãÇÃðЪ¥Ð®~ðžÜJ�;Ò/ÕÙ®Ô¨±?' J�7µmoòþTÖzê£Òé Ô¨¡Öë®Æ(PTY�MÏãÅ�kk¶wˆˆD¨Ÿ*vgfÔg{úE(5j NjS³M¯¾÷×Îy}e»µ§ž’BÃàí ¥F�“…5Û�äåB©QãX~.ÔÆ'dÒJK «ªD…F�¾­lzýDDDDDDDDÍ™†ëZçfUu¬"lzeÕUxðçï/'LVnû„µI$<þä …\.GQQ‘IŸ2…÷íw@”D-Sá�hmêYÀX]¦Òé°áü9 l�d±¨Ÿvÿ{.`Lú½»}3–Ÿ>‰e§Nà�~Mú¿~ÓhLêrùI‡ÓE˜½ìä+•Ø›•‰QíSêôm†u÷>Pó}†¢SþŒ2µ ‡órps»õ¾–¿Ó/¡TU�6aáXq÷øŸ¢ørßn|s`/Ÿ<ŽY½úšÝwß#OÔ;v}~{¯l^�C¹9PjÔ5Õ‚ãR:⻃û±3#·tì “žû²³ f›­¾š8 ýZ×Ûghr,;ugŠ 1½[¨õz\,‘#ÈÇ Î¡w|+œ-¾üK°Cd¢튃ˆˆˆˆˆˆˆ¨9ÒXAh•DâЊ®æN/ ð‚‡³ÃpDz2ñÔ ‘¯(m”ñó%×½oHh(~ìQ|8ï}\¸p!!!ðõõ­ÓçèáÃHéÔ ‘Q‘Ç!r¬ l$zA¨™Šò©!Ã[.]¨SÕVO©ã�É£ÂÊ «EÿÝbâÐ&,PpÍô£æ´ G¸¿?@cfÎåk]©Jôòð¨S’?«w_¼uóXñ ˆ 2Æ÷Û\œõd6A¸-í"¶¥]¬³í†V­ñÙ„É&}Ǧt·ö⟌´Ë  c‚°£ýÕŒ§_2Ù\'Aèí�Þñ­p(7©²bœ)*„¯§R"£Ð5&' óaE¤ÊŠáëé…>ñ­ìŽƒˆˆˆˆˆˆˆ¨¹QszQ›HÜ(9&•^°hÿ^|ºyª´ê&9¦LYŽ µ AfîÙÚ*<"“§MÅâE¿#-- �JëN¶¸w×.̘9Ó5_L6‚*­ÛÓ.'r'&C©Qcpb2?Šíi— Öëàëée²ŸAjªÒ`LôÜÞ½æôégö8yÊrä)ËR‰ÝbâðîèqfÇðöÆPc¥˜Ap$/ÅU•XxìÞ=Φ×5.¥Æ¥tBV™§ p²0›/^@aEž]ÿ6Þ÷P�ê¹+~˜z‡É¶Q&ÛÌÅ|E©J…˜Àºc—ªªk>Îx]í+1Vþtä þIO«™µ¾„\¸Ÿ?>?Ád{„qÖk%„„¢kt @¥Óa_NŽäåàŸô´šu!¯W+A8½{O¤—–X\ Ñš†ß„”Èè:ÛZ‡šŽ34© åæàtQ!N Kt <¤Rôˆ�Ã�œ,ÌɆZ¯Ã°ä¶5ëIµTQ€�‰ ›¸[!ϋƑZP€×W/ljœœ&?�²Še薘ؠ1&O�Šm[¶BV\ŒÜÜ\$^3^aA!r²³Ñº�Ç!jΘh[Ó.@­×î[±¸N[µN‹¿ÓÓÌNuìã‹e3g<¥ˆ ªwêÎç†�Àèö)€pø×J’]+* ŸÜ:©æû½Ù™xpåRlº˜jS‚ðb‰òª*Ä!9,I¡a¸­SÌ4 cž�rµ§‹ q“™*ÂA‰IVÇ7§]xü¼¼ Òép ' ;w­Ó~ ' àçå…öu�—bLf¦A¥ÓÁC"ÁXãÚ„æx{zØçÀÖIx{ÔØšï?ß» óîæ‹çM„""Ñ.<ä2,=yhÀZˆ]¢cÑ/ÁziûÐä¶øßž�Ø—�‰LE)†+ {ÄÆŸ¼¼¶#§%"""""""W X=h+‰Dt§ü ô‚`LˆZ[ĉlQPV†¯ÿÙŽGÃट»ôâÂ'½½½1}ÆíøæË¯����øøxx^SHqüÈQ&É¥ImèCvZ—zyzÑøà`t�Ž­ù/Æ8Mæ† çÌîç!• !$ !¡ˆµ’„±êíJÿú’ƒæDú*µZhôÖçg_sî X¹/mZC­…Œ½<<àa,¿6W¹Ø^¸­SÀ—ûv#×X- ¹åeøbïnÀ„N]áåáQgß®1±H ÅѼ\œ)*D¿„DD_scˆ ¸<¶BUm¶ýJBxÁ±Ã�J$×Bt”ŽQш ÄÎŒ4ˆºÇ\N öˆ�ìHO 53-QK£e‚Ðî•(è7ʈ6’ÒªJüwË&Œýì¿Xzø ‚ó*33‹¾!Œ9aááÐëõ(((0iÏHO‡¢´Ô!Ç"jŽXAè`%ÕUØo¬lûbÂTtŠº:äÁœlÜ·b1vef B£A���Óâ”J®~PéuV§™œÔ¥~=z§ p×â–ÜElO¿„Òêj´ ±¸–ÝØŸ¿3Ùìãƒe3ïµçƒ‡agF:ò”嘾èôO¸üÄÆÁÜlTh4ˆ ÂÜÁCÍî;.¥~8´0¾cçz�#«ª2g»ð|=išÕ8%ÆV*�Îb,_íß­ÁPïZˆÖ¼°a ¼Íü[ÍŸ<ÉÆ5aü˜7$)ž= èn¬Œ @|p0ò•JÄ£MxÄuÅADDDDDDDÔœh¸þ ÍÜ+=x™V0ÀSÊZ™ë‘«Pà×ý{±ôð¨uz@t~²5½È1 B///Œ»e<þX¸¹¹¹hÕª•ÉZ„'�ŸÀ�#orÈñˆš^lÃ…TÉaáu’ƒÐ7¡5Âýý¡Ñë±=í¢Ób€Ø `x«î¶^²K»ð|>a bƒp¦¨ßØ‹oîù Ýbâðíä铌¹åe&ÿå+•6Åîç�3f¢_B"*4lK»ˆmiQ¡Ñ _B"̘‰p?óëŽ3N)ê)•ÖLÅj‰AÌÆY\UiSœI¡a€Ì2.•ÈMÚÛ†G %òòº‹·Ô³¢5ÅU•fãÔ™ybghòåiEC}ý�Pk½Ã+Õ„ƒ9½(µ|:ÁÑ�æÌl f�ßir:®Chƒ `_FžZ¶c¿øìßkÓ,tM%Kæ˜!Œ»åøùùA£Ñ@&“™´Ÿ?w>€@.J"Šîø+�®—A‘QZ‚}LÚ'M›Š¤d.ÕD®‡SŒ’]<$´�ˆDûˆÈ&?vë�P&‰ˆˆˆˆˆˆˆ¨®?hw,Ññ1KEœÌËÅÖó©Ørþ²KJj%ÿšÎ+“§bx§.—„6(«ªBYU%Brüa7Ƕ-[ T*¡R©àççW§ýü¹T&É%1AHDDDDDDDDD-’Èä�Ý$R `p¯,¡AaxH¸âVF‰‡s²q8;û2Ó!¯¨€3'ß³7î»q$*5j»öK/*BŸ¶ŽIvïÑá(-)Aqq1’’’êëÒ% x—ì"rLQ‹tyýA²‹›¾a:A€‡‡{%+4j¤álQ>Næåápv6dU$Î ˆ·§ÝôñE�¯/*T¶% 3‹‹Ð§m;‡Ä!‘H0løp¬^µ ………& B­V‹Âü´j�à�ã5LQ‹ÄéEÉV:Á_×¼^©Ñ «´Y 9²JJp±X†3EÈU(.¯áW;!ØR‹.'ÏD“ŠÀfž ¼†T"Áë“§C"©wLH˜Íc4´‚PEÈ•å(./G±²%Jhâ�•“…¢òy\M({I=à++@QpÚÆÅ¡kB"’¢¢Mâ¿^A€\YŽÂ²2È*Ê¡¨¨@‰ñ?½`@…JUÓ×ßÇÁ~þðõAÛ˜8tIh�Ö‘Q‰ôJ*”(P”A^y5–ÒÊJ¢€*µ:ƒ^òóG�Ÿ"‚‚Ð>&�['"ÈÏïº�-ˆ"”ÕÕVûùùxÃÇÓËæq+Õ*ì»p.¤â|^.ÒŠ QR¡DyuuÍú›A~~ˆ GLH(Râ[¡KëÖè×®#:¶jå°¸)ÐמX“ B"""""""""jqô\�ì`èžÒæ·¡ ŠEõÄÖ%.¾&AØÒMîÛ=M¶Ç†„Ø €BnÒ´sÅ’š¯|}Ñ=)£º÷ÂäþmNÒ],ÈÇüÍPTV†¢r ËÊ W–ÃЀ ×`?ôLnƒÑ={cR¿ˆ ·i¿™øõïm(.S °\�¢²rÈ•åÄ뻲J$´ŽŒÂ€ö)×§/F÷ìmW"/¯DŽ^xÊj¿�ïýî¹q¤Õ~{RÏâ·¿·aÃÑ#Ðèuõö­P©P¡ÊÃ…ü<ì:w¦f{Lh(Æôìƒé‡ JGHÍœo%Jt{êQ«ñ8ÒºWÞ í;4x&‰ˆˆˆˆˆˆˆˆ¨ÅiéSF:KóK�5�`hV B�^�E÷aÉáƒx{Âd lÓÎbß®ñ­°òøÑ&"RE’ IDAT�¯1øøà¹[&˜m‹ µyœJµ2e9¢C¬ïS Pà—[í Óª*µûϧbÿùT¼¿b Fõè…g&NEï6mëÝ/£¸ þÙîÐX”ªjì:w»Î�Á;ËþÀøÞ}ñÜÄiè”Pÿš‰©y9X´s‡ÃâEÙ²bdËŠ±lßnDcΈQxxì- ª,´×…ü<¼±x!vœ>ÙొÊʰàŸíXðÏvtŒOÀS&aê€Á‰³9h>WC""""""""""éÄ–=e¤Ó´¬Ù(J+4Ÿ©Ugf`ÂWÿ÷lD®¢¬Tv�³>ÕaKðȨ1ˆ¶P)cC²¯¶Ì¢bEÕ0‚(bó‰cÿÞxüûoP^]å´XôÖ>ˆ›Þ| /-üÕ�Ób‘+•øø¯•èÿÒÓXq`o“s푃ûîëI^ë|~.¶Ÿ<áðq�‰ B"""""""""jq¸þàõ‘@â¶IÂæ²áâÃpÿ‚‘W¦¨ÙvÞJ‚°cL ¼°æ˜3uiÕ Ô35d¬� ÂôâDå8¢(bÙ¾ÝñÆË8›“ãÔXQÄÏÛ·`ÔÛ¯"ÃÉSÓ–VTàÑù_aî�ó¡37­«ƒ,ß¿|ýy£&E§ Òhc;„DDDDDDDDDÔ¢¢Ãu®“åî$¤nš Ô  ¢s§¦]qôÞ[ÿ—ÉšsÖ*½=<Ñ!:º‘£k<žR)Þ¿ý.x֓䌱c BÈ,n„×Ê/-Á¤Þqz’Ò 0áÿÞA–ÌùïÕÒ=;ñïo¾€Ø×îcéxöçïeì+¢CB1¼K·Fߘ $""""""""¢EÛL*ÁZ*wέj±‚ÉšãÙÙxwÝ_f“i²b“¤áµºÆÕ¿¦\sö¯G¢[BëzûD‡ÀÃŽ5"�]W¥ª³¿ø/*T*g‡™²÷~ñ 4z�³CÁº£‡ðņ5SE¼öÇoPë÷õMê?Юó³%p­WCDDDDDDDDD.Oçä*°–N"qÓB'&—µz=^]½Ü⋽Y%òzÇè×HÑ5®6Qј;fœÕ~žR)"‚‚m7SVÔÀÈWŽ\†ÿ\îì0çrsð况Îðß¿V!×ʹn�gNáð¥‹Ï’i]kzQðtvDDDDDDDDDDöàúƒ ãÀ]S¬j'�;Ú‡Ìê[ò|qÚFYžF´CTl#E×x$ æM¿¾^^6õ� Fq­µë“U,ƒ Š�:(á}S·õöÁ¾½{!EÄÇÄÀÓËÕzª zÈ4*TØY¥öÛŽm˜;~bBí[_q\ネ ¾<媟·|¼<¡3 W*QT¦À¹¼È•J»Æüvãz<4j‚üüìÚoR¿�ö÷¯³ME)È)‘#½¨À®µÕZ-¾Ü°ÌšcW–¬=|Ðæ¾±aá¸cð0ŒéÙ­#£µN‹Jµ9r9.äaßùóØròh�÷·}lÁvNgbWÿŒÂB;£².ʤԙ™NT«Ñ¦‚ÇÆßfÓx§³³Qm&Ùè(Ü<w¿É¦¾GÒ/5Ú>ž^øøÞûѧ�m£ûϧ6ø˜%J%D^O¿vìçoµŸ9>ž^ôµï/¯ÁÇ«ÖÚ–x­jÄmKÆ!µ:Áô¦=ÙG ¤n|gX n¢óHV¡´izQ02KJêí“Ü*}|ðò­¶%‰®e÷£E�‘ ô³IäÚÛB0°CG›Æ,*W80BSñá螘dSßEãÆ2¬sW›úÉ”e >–·§m«èN»ˆ¹¬ÁÇs5nük€ˆˆˆˆˆˆˆˆˆZV:ˆèÞe„jƒéô‘�!ÛJÂïZiVÖ!LjN„<>j ¢‚ƒ¯kߘfPAèUOáµRâlS¥Õ68.k:Ø‹F߸ç~�Ÿ|½­¯ñ鈪>?o›úiõz<òÝW¨R·Œõ;›ŠméUº.+·lÅ#o¿wÝûûúø 4(a!ÁèÙ1ztÇøaÃr}WW0åñ'±÷ø “í>ÞÞÈÞ¾Ù)1Õ¦Ñj‘8rŒÅö©£Gá›7_³{Ü �<Žƒ§ÌÏUþÞ“�ãÁÛ§Û=fs±ûÈQL{ò³m¯=òæÞ=³Þý«T*,^¿wíÆÙ´t(++!•Jáëí ??Üzã0¼÷äÜ:ût¼e"Ê”J“±zuî„MßÛÀW䚟>ƒ[~ÌlÛó÷ÏÁs÷Ïiò˜Èñ6íÞƒÙ/½j¶íƒgŸÂ}S&7yLäžFÎùÎ\J3Ù�“«W8%¦æ¨@&G¯)æ?̼õüïåz¼~·ß…ì‚“íí[cÏï z¬ëQQU…öco5Û6iäMøî�7›<&rŒ†~^$"÷&+U ÛÄ)fÛfŒ‹/^{¹Écr´ì‚ü²j5v9Ьü”WV"Ðß Ä»O<†±C‡8;L‡D†&œòzÜùðS¨¨ª4ÛöÞ‹Ï w7û§]l R 4ï·²Q© z„5ÁqJª«ìêŸa¥Ê©YM1jFû˜XÜ;xØuïoï£Ùr ‚–Äú“N‚™õN%’º‰u±¾yVkñòh>©/©õ©HB©ª†Ú†„¨¯—Wƒ�„@_?TªUVûºt?xß?òÚÆÄ6øØ® ùœ•dB­Ñ P£A¡\ŽsiéX¼~#^ùô ÜuËx¼øàý rvˆd§U[·á¾©“Ñ¿{7g‡â2róp÷ó/!-'ǤM­Ñ ¬¢²F.™'""""""r–¿vü�'æ}Õ5U—eJ%ªTÖoš¶$-¡z0¯¨ÊŠ ³mêfT½"•Hê_DÎÅDZÁïFN–¨uöUŽ]’Õ_AØ:¼)Òš×ïÍI¶­…gI�Ÿü½}P­µígE§×#¯DŽÄ¨èë>æµô†Ëë@^› 4'5×ô¾¤9QÁ! ŽËšóy¶ÅÙÈHÛO�´-Ž †Ç!‘H0¸cgl>qÔ¦þ§³³pÓ›/ãѱ·â¡ÑcèÞ9&[•Z�ŸV®Â–}ûðÓ¼wÐ#%ÅÙ!‘DQÄ«ÿû›~øR›è=§ ÇRSͶõíÚ­¢÷KÆÅ}ï}ùU³ÉA²,¯¸GΜ5ÛÖ»S'´ŽãÓ3® #7§.^4Û6 GwÄ´€õ íÈ™³È+6ÿGÖøaCáeãœõD®`ÍŽÌ>å†A½z:%&r,•Z�-ûö›mKŠ�GÏŽü‚xž5é¹¹8}ñ’Ù6[>·ŸMKÃcï̃V×4Ó$6-!AØR¸Ö�©ë£2è=AhëiWd”Ô_AàíƒP??”5Ãäÿè®Ý0 m»�ŠŒâB›ûgÊŠš Ôùf�^µªÞ JK±ÿ‚ùû¡×jÛ¸÷Ü.äçátv–Õ~ÞžžHŒtì}ÚÚ4z>ùk•M};%´vÈ1ï>Âæ!¨µZ|²f¾Û²÷Ü8wŽN ¶MÏÚKöìÂ! ÷ëluÿÈÑÔ©³Ãbâݰ*§ w<ý<6ÿ0Ÿ7ô[˜“.à÷µë1kâmÎÅ¡ö;Ž'ßÿÀlÛü·Þ@«Q#z¼õ;wá|F¦CÇt‡NžÆ¿ßzÇlÛg¯¼„;ãÆ5yLäxÛöÀ«Ÿ~n¶máGÿ‡Ñƒ5yLÎ6É2¬Þ¾ÃlÛ¥Më˜ $·òЛo›�*gDÿ~Lºˆ’²r<øú[fÛî™8=_x¶Éc¢æ‡ç ‘ómÛ·¯}ö¥Ù¶Eÿù1ƒêO~ýû·K€� B‡‘@ À½ßÏj½!^¶­av½‚ýüíêŸSZ Që­^‹ iv B©O�ï�±¢ƒCìJfbxÇÍØ¦V_^Ï\‚ÐÛǸ>¡ àåE¿BmÃu89:¦Q+Õ4z^Zø fþλV·Ä$»“Ö¶ªR«ñðü/q>?צþS:9ä¸czõA·Ä$›¤µUªUøfÓ:|³iº´n�i‡`ê€ÁˆoœëOggÙãµF÷ì…Ap\‚�Š´`¥ååxðÍ·!6óy×ÉÔÿ}ÿ#Ê+ÍÏEO¶ùûà!g‡@DDDDDDä4rÏ¿‹u6Ü'ÛxH$°aE—¦hC£#!Ô¾)AU:äVîÆÛ·N_S¸µG/tˆ‰qÈXq¡ö½¾Ì¢"‡÷ �Ær‚ÐÇÇEeeø×WŸañÃ6�7ª{/‡ÆW[Ž\†YŸ~Œ=©æg »ÖÈîŽ(4³¸_¬_ƒA¯<‹Í'ŽÙ´O€¯/†uîê�ãK%üヌ´¦áÙœ¼»l1ú>ÿ$&ô–ïß�ÞõÂáãò-ܱ³ç°eï>Œ2ØÙ¡�ä >þé¼ûÄãÎ¥ÅÊÌË·Øöô½÷à_Ӧ  ¸ójÛDDDDDDä’*««!+U˜móõñÁÏï¿‹>�;¡¢ªAöU/5w¬ t,©0¸ù­“*½ÞÞ�7ÍhBX8‚}ý TÛ^ñ—«P *ÈrÅY\HóK>0l„ÃÆŠ±3©šQìØ¡ªº¨• Ez ÕÕ8º}þ¹�ŠjcÑÓuXlA@�¢G3Ò°ñÈa¬;zȦ*Fi·²?–§~úþÞ>ñ¨Ù¦Ñë�S"Cfq±]ïÅ3‡�€¿�ãªw{$%ãÛ‡ÇCß|­^Ý㢈}©ç°/õ^ÿcî9��¿ ~Þ�[iì,L:IûÄÖØóû‹íeÈÌÍÖ}ûñýÒåõV›ý¼òO&[ ŸWþ‰Ù' Cr’³Cqª¡}û h÷ßvïWi\pýZÞ^^xþ_÷Á£ž5ϯÿËîã¹»ºu½®'""jÿK>ù�³Ci‘ ¿”C‚ëMµtB=Uta!®•¬MÏY‚N"3ÍÒP<„M»÷`ìÐ!M~ìÜÂ"•” ´¼:½á!!ˆ AR«xx7`¾fw“SPXó>ê „#&2m2¾N¯GZvŠKKQQUƒ È?þ~¾è˜œÔ¢Ÿ•+È—É +U J¥BDHÂCB�ƒ €Æ½6ÀøoW —C®PÀËÓ¡¡èÒ®-|0½Aye%.ff¡¼²U*ü}}ˆ¤ø8ÄD4ÎBÇΠ¬¬BV~> år ¢ÂÃЭC{øx{;dì‹YY¨¨ª‚²ª R‰Á� FÇ6ɼN9ˆN¯Gf^>JÊÊPZ^/OO„‡„ :< ±1�öí­'Ï_@�\ŽNm’‘’œÜ¨Ç£¦#2òòP$/�¼¬ ¡AAè�”„¸¨Hg‡Ölå!§°ÊÊJht:„"$(’àççìðìf¤çä¢@&C•J�^�??øûú¢CR""Ãì›6Ê9…È+.†\Q†È°P´Ž‹E|T”C¯iÕj5r QRV†’²røûù""$±Q‘ˆ�äyn�²Š ä£@&G•J…�À@$ÄÆ mBB£ÿr¤üb d2È ¡ÁÁè�˜ˆ¨ðÆ;×a¼p"õ<äeeèÓ¹3b³öSs¡Öh�•_€œÂBˆ¢ˆÖ±±HŠ�ƒŸ¯¯SâÑêt¸�™…Òòr”WTB„ˆà€úû#%9¹Ñïq4…òÊJäA®(CYE‚ޏ¨H„‡4~Å’\¡ÀÉ QY]�›ú÷3ùÛЖéE‹drä¡´¬Á�ˆŽŒDb«8H­e¶ßÃwÎÀÓ÷Þcõxs^~ ûŽŸ0Ùîáá�³kÿ´ºÿ±³ç°`ÍZì8pùÅ2³}$ ÚµNÀ¨Áƒp×-ãÑ©m«ãÚB­Ñà—U«ñûºõ8Ÿ‘iÒžƒ»o»÷O›‚Ðzæòn*o|ñnпInvï;~Ë7oÁöý‘_\l¶�Ÿ¯/†ôî…уáÎ[ÆÕ›,ùûà!äŸ>c±ßÎ#GPQ}õi—ÛÇŽ©wÿ‰“¸÷¥WÍî÷Üý÷âÁÛ/?•qæRŽž½ºØnIY¹Ù}Ô-üµ¦æ{__L3ºNŸþ3f¢¼¢Âdßn)°â³O,¾Ž+Žœ9‹?ÖmÀ¶ý,¾�Qáa¸±ß x`ÚTôîÒÙ꘵•WVbÅæ-X¹eN¤ž‡¶žyÄ㣣0zð Ì77t3¿°ï®ÃG‘™Ÿ8vÎòÓjûއ®Öâ»SG�ª¹!yü\*îxæy³ûÍ�5�ß}—M¯íRv­Y‹í"5=ÃlOôéÒ#À=o³ùâì—^Å�'M¶{yyâô_«c2â»eËñ˪ÕÈ-4�~ÂÛË CûôÆÜY31¸·} HgåçcÑÚõXÿÏN\ÊιúdÜ5â¢"1¬o_ÜyË8 îÝËa7¾¶ìÝ�Bùåëí¡Ó§-öÛqà åòšïïºõxÚù„ÝÁS§ñòذk·Éùéãí�›À#wÝ�þÝ»Ù5îŬlü±n=6ìÜ�Œ¼<‹ï¡§‡:µmƒ #nÄŒñãe¶ß¢5ëjž¬ÎÌ˳xÜÅë7Â×çrR3<$·Þ8Ü®¸ ‚€þ3îB¥™iž|}|pdÅ‹ïqzn.Æ?øˆÙ¶ºuÅ¢ÿ|`ñ¸¯}ö–mÜl¶íË×_ÁèÁƒÌ¶éôz¬Ü²vîÆ®#GQYmþÔ˜ˆŒèßGŽÀ¨A-ÆQÛ�“§0ûÅW̶={ß½xhÆåkz•J…�ú þZ‹ ã”Ñ/>p?ž™Ó°¡\¡À”¹O£ØÂ¬ SG�Âÿ=ó¤]c)ã‡Ã�ãÇ¡MB+³ýRÓ3j®ÿ¥åJ‹Ç½˜•UçßyXß¾Hnå˜Õj5VoÛ�e›6ãèÙsP]yÛŒ¨ð0Üì?qÒâg´6 ­0¢_?Ì?}lüøìGcíŽ̶Xú»Õ¿[RÓ30é±'̶=xû4O`½ÍŽ“W\Œ‘s0[eà�K~·ésÎ�+Vá£Ì_¯ëûÝ‘‘›‡ßVÿ…�»ö =7×lŸÄ¸8L¾ù&Ìšx’â­¿‡«·mÇ ÿÏlÛÇ/<‡ 7Ý()+üùßcù¦-Ðhµ€/^{£ Ä�™³!X¸Ùÿí[oà¦ý¬Æ#î½fþž÷õñÁþÅ ¯;I7öÁ‡‘™kúÙ,!6Û~þ0þNúÏ�?cÕ¶í&×_Ü2|fM¸Õâ¹áHr…Ë6mÆŸ[·ãÌ¥4‹÷e`|À{ìÐÁ˜1n¬Å‡¸·ìÝWóyüÐé³fûÀø¹½@võýŸyۭغwŠKKcrÝ’KYÙu~†S’“1 Gw‹ý‹KK±ð¯µØºo?ŽŸK5û3)‘Hе};ŒèßwO¸Õæ‡bßùú[,Z³ÎlÛæç×ü\\ÈÌÄÛ_}‹íÖœ¿»þŠ €ÈJz÷lW ‹®þÌO»u,žzð>Àß{àç%ËqâlªÉç�˜¨(L3÷L›dò m~a1¾ÿc ¶îÜ ¥™÷56:·ß6wNº �.¶ž#H!�DbwÁ–Ë©Öëæ%6ÚÃ)þÞÞ˜5p0¾Ü±Õ¦þYeÖ„ÍdŠÑf$³¸¨Y%o< ÏNšêì0��G�uj ­#£ðá=÷áæî=›ì˜a�xpÔX<8j,NfebõÁýX}h?räæó¶xÅ ëÜ Ý“\cÙ0&[?__ÄEE™½ÑeÊ«Üé ‹ÕÆ?¬©R©ÌŽaí�«âÒR¼ôßO±îŸ�V�!Š".eçàRv¾]¼SG�Â;skЖ鹹¸ç…—q)Ûò¹…Eøð‡ŸðëŸá›·^Çà^MwQò�JM>hgæåãÛ%ËðĬ™�vÜsiéxûëo°ãÀ!«}Uj5¶îÛ�­ûöã¿.À³÷ÍÆ='˜ý€ôËªÕØ°k·Õ1­YWç‚qC‡ÖÜÔëõ6�¯<„w¾þÖê±*ªªðÜGÿ­ù>.*Ò$AX^Yiö˜–n’_‘[X„7¿ü ëþÙeñæê²R–oÚ‚å›¶`Ì�ÁøôålºµrËV¼öÙ—()3½)lN~± ¿þù~ýó/Üzãp|ôÜÓ& µ…k×âÏ­Û­ŽµxýF,^¿±æû›ú÷¯IÖ{]±¡T_VªÀûßý€¥6Z�']o0àà©Ó8xê4¾Xø;œ1OÏžeõF²¥ë–—1ùžž›‹Ù/¾‚‹YÙÇÐêtØ~à ¶8ˆû¦LÆ»O>nuúfµFƒÿüô ¾]¼Ô¦9à dr,ݸ K7nB¿îÝðŸçžAçv šì»¥Ë°óð«ý~\±ªÎ÷ÓÇŒ¶9A(Þûö;|ýÇ‹?­ëwî†]»1gò$Ì{ú «ÓþV«ÕxþøiÅJ³7#®¥7púâ%œ¾x Ÿ.X„§gÏÂ÷Ümr�zñ“O¡³a¡î×>û¢æëžSìNzH¥è‘’‚õ;w™6ñ#¶4 IDATVTàôÅKèÕ©£Ù}�œ9kñgkï±ã0‚Å÷oÏÑãW÷·pè¯ãýùß#ÃÌM¹k•”`Ɇ�X²a#nèÖo>öˆÕ¤¯¡žkÅ•õ²òóq׳/"-§aOï]K«ÓaÎ+¯ãB¦éÃA0¢?¼3÷Q»ÇíÝ©#tz=ªT*“¶�‡êÿ™Ûâ”Å÷ãÀÉS„yEE?Ë îÝ þƲ‚ Z~¿ÕW¯Íg.]ªó»Ñ’ ™™uú½òïíJ;{s^y½ÎC׺˜™…×>û¿¬Z�Þ}Û!׿+DÑòûQ­2MTéêù ¢±ñ3sEuµÙ1ü|ëÿ�•]P€g?üئë¶Ap>#ç32ñù‚E˜=i^ù÷ƒ ¬Óoב#xí³/­Ž·ÿÄIì¯õ@Íü·ÞpHâgËÞýxáãO,> d–¯¨õÐÜŠÍ[0¢?|ðÌS& òVÑÑh›Ð GÏž3Ù¿¬¢O�¶éï½-{÷Z|ÿF 6}H¦Z­Æþó—.·úY#» Ÿ/üß,Y†M›‚—üW½Ÿk5:�ÅX®<ÌwâüÌzþ¥šDQmá!!èÙ)Åâß~ví²)Ax13 çÒÒͶM¼¡oƒ*ø*«ªÌ¾Æ`ãum÷‘£¸ÿÕ7P^YivµFƒ•[¶bå–­˜Æ>ð°MÉAsVnÙŠ1 {O�–¹ˆrþçÏXý×ß ®�YYè=q £8(ÊΣÇ0GÂZAa!úNþ ë÷ì“«@tä“´> —îÜ•yßê`Åö�ذ÷©ž�@€�G�I% ñÝÔiØzð�Tâ (Ÿ ±tË6L^ñ«Ìû*“à@êºÈ'ôŒÎ¨Xñ Í2 ©™¶ùŸ?ãy y��›«˜pÀçó1ýF Ÿ3_*qP”û1±è9n"5ú[Z2³sÐgÒT¥‹ƒ�“–¯¤~×^.ÎØ¾xAyÀ€,p¹\4÷iL\—þé^¾yCÝ÷A,=»þÃoÿ€0Ñ]Ó½V�$¥¥¡ï䟤îÏ$¦¦¡Ï¤©Œßß·JlbÚþ0R*qP>Ÿ�?�GÇ£%Š+UÉÞS§ñý´R‹ƒ¢\¿ÿ]'WûT\\‚¡³æJ5ÙŸ²³1bÞB,ݲMªí?â‡9ó1{Í:©ÄAQÎß¼…#FáZä}™÷ýY²i Æ,\"óý{õ^$Úý0Wï‰�iz††P÷»q[Ɀáö#²àb §+–áš�—‡>“¦býž}R"•Áãñ°éŸè2zœBÏïË7oÐwòT¢8X†h€dE.DÜ–ª/wùî=êº ß¹¢$¦¦áûi3©â (Ç.^F›¡ÃeêçKËœµë1ý·ÕR‹ƒØözOš*×þUAú§Oè>n"Öü½G¢8(JqI öœ<�ö#F�›dáÊÝHŒ^°XêàQJJø˜µüwFq°"™ÙÙ;k^¾~ƒãç/aî/kÅÁФ¾~‹13çIõ>°b°ûµ²¨yVÀlÔKçÊ$¯X¾ûPZÔTT°´[o©êä}ÊÏc#³À‚E]¡)ïÅ�¢ªš&. q~îbLëVíâ`ˆ‡'®,XVív˜�}Ö¤yµŠƒa±XtvÁúá£ñø·õ˜Úµ'´d ܸ�ä÷ïd>·½¹9‚\Ýúg¦ä ¨:�°ÁÔ1¬æ:d_ŠŠÐoòÏrO ”ñ&ýÂ&L–z0P_ Àð9óñZÆAþçϘ°lÕ~EÙü¯gw8×·OËÿü‹7nQúùŽ_¾‚ÿÍœ#sg_”Ãáðý´™Uö=Õ4SÓÐcܼ—"‹ƻŒ ô›òUä:sý†ÔUL$¼x‰é¿“-‰ªƒgIÉè2f¬ÌϦ(Ÿ>C§Q?"“Á ‹Š@€ás-E%±óè1ܸE]?nñR©"x™øRT„!3fãI|‚BÇ©lÎ^¿‰5»v˼ßÞS§Пù�hi-Ïy‡_Pø8òÀ 2X0W´N&ñ�"ѬŸ $0@lÙä¿`ã?ûÏ% ^q1&¯ø[’û3V­Á‹×Ê„VïÚ�CçÉ¿}k+ìùe9´5å·ƒbü}Ÿ�ßôOŸÛ�7ééT1íA,ý¾ÿ}«›‚ÂB ž1[æþ[™óĉ¬œô�\ �ðÎãhª#Fç–-…Ü$¾!lüdÆàIDÇǣϤ©B.@Ò"0nñ2‰mHÇ Ô ¿7éð$Ar¿“ÖÓÕÖ"ö5”¯˜‡ÑóÉ<~}“þý§þ,×wJc×ñØzð°ÂÇyøôoRþX_Q>eg£ÓȘN{û�Gÿ(·H˜•“‹qK–Ê(XƉð‹8U¶v:7¯ç.ÆÂß×ÊüÞyÿ!ë·ï’¸�µ…q¹ºº:L e:gUÀ©E5_+“/üðø²ÿÊ‚—­-ævî&ÑÊ”/ࣄþµ…n�@X‘r6Ê@[]}šáÔÌù8öól4ª§Xé EÐÓÔ V!Ÿ»{'ý 'KryYÑÕÐD=S¸ÚØÀÆX¶ÚÞKïGƒ=wu¢¯¥…Ÿº÷ÂõE+ÐÔY¶Q§ÈžŒS©k /Sæ&äúKUÅûöSk.”Áf³Ë-±˜x“þK7o•éü<rN0Ç%§He“© T8,ž0ޏîPøÆ:~²’üê&/ÿE¡ŽvE®Þ‹Äj9Ä�Ú¯¸?.Z‚œ<Å£>Ó޾ Å–ñx˜³V²Õ“´œºz�jT•bø¼JùîP-=aÙ ™p¼âb©³ H¬ß»�¸üZä}œ’b ZfÓÊD�‡)+~Qxrµ2¹y_îë[õ×ßÄå‘ObpH‰¢Þ/;˜ë}U&Vffp´#[ Ñ& ¿|Al¢x�¬ŠÐ2ɘ2ÌZ‹d˜í=uûNŸe<�,,ذ‰*\2ñ8î9Ž^¼¤´ë(ãÄ•«XA©ebh€ý¿ÿ"u-SŒ¢”šŸLÙ¡eÐêÂFQBscc¸*Ñ’SY¼Iÿ€B>i¸|÷?�Wú5ÕTVnß)1#žÃfKUŸ1þÅWËÑêf檵J "»ýDæL»OÙÌö[L,ذ‘1haɦ­ •Q\R‚qK–Rë³ëÜŠzˆ ûþ¡®ç°Ùp´«7{èjkQ·ûRT„a³ç ý†Ô:t /^JÌTfrZéÑFØŠvé–mˆŽ§·YÚššpµ·‡£]=F›úø/0aÙ Æë"qúÚ ©q- ´oÞŒº>üfãþ……Ô®ŽA- ¦Jw7R„·2¿_&ÒÞ¾ÃrJÝ_YÉÎËSJðf»ŽŸT(ØTÙ” Í´6²’“—�óHUzB”õ{ö)ì€SÑV]’_¦Êå§.^Av½$XS²­-Ì*­Æ�"¨°Ø¨K"üJ^XvöôöŲîaËq¼g¨5 zÊ·W®Í¤|P,qE ´´àle…®¾˜Ö# '¦ÏEܺÍX7l$üȵg+6‹#]4´¶Aÿ&˜Ögf-À³µ›ðË ¡ð´«¯Ôó��³÷V¬Â•ËqåjDý²ÝüÄmØI¼ü�Ž çN)õz”�µ‘1L��ÞMšK½Ï½„oc[3ò:ë�SÖ†‘¾>ìm”ë=+ �{NÑ-Çt鄽Ã`ok®Š ò �˜š†Ý'Na÷ÉSÄ çΜÜÑ#åŠö×ÑÒB×àVpwt€Wq))8x>œQ`½tç®Ì5§ä%È×Z4%f­^‹³[6‚-¡ƒ —®`¬©çéì„}zë¡ ´55�œö ‡/\ľSg¨¢âo;ÿBçVAp²û ³aî¬òíG/^¢f¬šþ:Vø~uµèƒ~?„õÄ€.ÿ¦Ä÷;O“Ä'Ö�ôõ±ï_1‚ÍRì»üóè1ƨÊN-ƒ0 K'8ÛÙ�ËUAâËTì8r”* ¿€‘}{ ¬¿z/’še¢§­�ôGóÆÞ06Ї†º:223qóÁC¬ß³�:Ð<óVy]§UÓÆŠ)“g®]Ǥå¿÷Y>e"z´ù×.HžßIèx[·3N[ššbt¿>òõ��ž.Þ~ÈÀ¹7±íЪ Ïù›·p8ü"ÂÚÑm“˜ÐÒÐ@Ç ðjèMu5|ÊÎÁù›·p�! üæƒ(䈵GLV‹-|cÊÿ£±kC¨©ªâKQÒÞ½ÇÉ+W±æï=øLˆŠ~ü<÷cbá'¡Æ�?—-.wŸ8…E7·Û<.ZU¨;#Í$´(�<CdåæáìõŒßaY�=}¡å´w¸9ØcLÿ~hh߆zzà øxý>Ç/]ÆŽ#Lj“Ñ)¯^ãyÊ‹òLí˜ÿf†L\º‚ ux?´J_I4&‚ü‰÷ü›ôt¼NO‡•©©Ðòèø„òߌ-Ã�f]ijhÇÛ—÷?bæêuÄmQj­Ñ=$ý:¶‡“�¾!&!;¡ÞðŠ‹1vñRÜØý—Lï«ÊÆyô,ã/#ÞG:ZZØûë Ô³T¼f–­…ìmlˆÖ¨4˜É^´Œ¨Ø§èÐBxÂ+.F4%£88À_® ¥àÀ};´ÇÂñ?–ÿ-«�NEÅ=§Næ««©at¿> €±�>tµ´�‘™…Ș¬ß³�*Ì�¿�VþòÕåÐÖÔDßíàïáu55DÇÇcÏÉÓÔlÝ"‹6nÂöÅ ÅÖÝ�‰Å¦ý¨ç⪨`P·.è ;+Kääæ!26›÷¤ÚCgfç`ú﫱kù¹>Ÿ2¨Žû„W\Œ‰ËVR…äQýú`â÷a ÷Õ§„ÏGø­Ì\µ†(¨~ÌÊ‚ ±fæôòe=BC¨¶½á·ncxï^Ôë»IÍŒŒÐ¤BýÂGqϱå9‹^O[K'M@·�ÖåÂ`n~>v>Š•ÛwEˆs7náôµë2�Cey—†µ ¥¶á·"0uèê¾·¢R3”»‡Tž½¨(Í}£[p+X˜˜"ýãGœ¾v�Ñ™âï'©®=²púê5êü�‰¡&|?¾nn014—«‚ô�Ÿpùî=ü±÷b�<~é=ý}×.€]Ë—”ß»ŽŸÄJ†á–…óÐÒÏ·üoU.{~Y^¾ï»h9èÄ}{·k‹Åÿ NV«`³¾ïô\¼MÏhÖÕÖÂðÞahߢÌŒŒð)+×"ïcÓþT{ܸäüþç.Ì9œz\ÊìrUTÔžn.à°9HI{…á�›'Ù>´â¾l)ii8~þ"ò ÄkP£´]»÷(¡At!žf#j-g½Ýª€ ø¨¹A«UEA úµJr»4ò†±Ž¦9ˆ J¦à‡¼ô 0K}Å3¹ŽÄqimáÍÇ |áñ„Ú›ÍÆ¾ßVÂÇÍUh¹©¡!\ííÑÜÇ›:©“�úo Muõòì]Muz6›†šºÒ¾»¬Ü\ì>A�jéç‹m‹Eˆ›ÁËÅ:wBïIS¨uˆþØ÷�\ap€?6Ì�%Ö>ŒÐìÛOÌîDé}ÿèYœX 2Z]';;ìûm¥PÔ¸šª*lm0ið÷ðn肾“"î{æú ¹ŠÏ#“è§¥)ÿ󨣥…?æÎBÛfM…–�éßëöì¥Z%óù|D=}&dÓ(pö:yBÀÞÆ§6mË:·25…¿‡; tu©Ù‚‰©©å“B?§*ÃAW[: â(½Ç6ï?H\wÿI ¬B„³hYbIxñ’(PÓ²ÓZùû õ ¶:B¤QZ[oû¢ùh'’aPßÚ �[aõ®ÝX¾u;qßÄÔ4œ¿!&n1Qqb¸¡}´kÞ õ­¬ ¡¦ŠŒ¬l¸;:H},x�žŽAÓg£Õ¹\.v.Y„FNÊ�‚ý‰a\r róóÅî!¦,Ï2¢(±‰IÔ|ÑìPi᪨HõÜK»�°v¡øå§)bÏîè~}ÑcÜê¤"“¥ê·Äý˜XjVýȾ½±`ì¡ezÚÚðuwƒ¯» tu±ãˆxŸäçLD>‰A g#¨r¹åmS-%U.Wiï{Z6ÍŸ#ÖFÀ¥A} I¿�ÄzS /åËFu²³Ãî•K…‚Ú·h†‘}{cÔ¼…Ô÷öék7�úö-lE¬àÖïÙG b1ÐÓÅþß¶ÍŒŒàhW½Û†bê/¿áŸ3çˆû†ßŠ@ÂËT8Ö³•ës*JuÜ'goܤf*M2Ó~*´ŒÃf£C‹æðptD›aÉBÍ¡óðÓÐÿÁº´ÆW§–A˜öë*¢¨uñöª@ø.#ƒ:FìÜZ(fÛÁÃD‘S…ÃÁÞ_WÀ×ÝMh¹Ž–& [K Œš¿ˆxŽ5»vË$V<¿Ÿ‡;ZûûÁÆÂ\ddf ½K[úùÂH_ŸXß<:>o?dÀÂĘxžK·É"œ±�Zø’ëò*›�U3~Fßí…–èÒ '®\Ř…Kˆõ‹KJðçÑãX6y‚Bç§µmª\.Ž®[#öüšÁÃÉ^.Îè3i*qߊõè+öë4ÔèÙ˜ZâýöŠûæ&‹W ªJ~†ù|>6ì¥gó:Ö³ÅþU¿ ¶™¡¡}ôëÔÿ›1›DöçÑã˜0h T*¯¥¿yp`šz{ÁÜØ%|>>ffÁPJñÃØÀW,„Sጙ¡ýÃ0àÇ)xÇPÇØÀ›V,‚caaù}Ã0`ìd¤g�ƒr_J¨›K³¥-¯ pX,ðk°«MUÁ�_ƒ¶JådKW¤I}=k¯\Ä¡¨ûbûò˜3­¤Ì 4ÐÔ‚­¡!êÁÎ憆°50‚µž†ìÝ…x9ʘëé!ÌÛj\däæ"úU¢Ó^Ê�™« ø^~HWÈV“Ÿ™}^ ôõõa¥%ÜŽš››W™8^v ª4+Qf‡õEâ»78÷�¹¾z!�‡I;·âØ´9 fW6*–Œæ³Èóvù(!÷¶Ps�oœÌœ\¬Û³—øoÍß{°hãf ™1^=¨QŠe êֵʮ›Äk†Ž–è I”}èéô’,KEqiP-[,6ù�Ò(¿�ófS…ÔŒLñASeRÏÒ£úö&®[ºy«Â–ŒL÷LÛfM±hüX!q°"M¼<±~ÎLêþ‡Ã/(\7§¶pýþªHÐÈCL¬È¬‘ÃaJ©+ :à|F™”pwp+âjoK‘Œ¤2ªúže÷‰SÔk}k+ì\ºˆjecaŽ}¿®¤Š\±‰IˆxøH¦ëqwtÀŸ”ö¥—?ƒ0÷NdP˜“—OÍrôrqf´”jåïWžÝ)Ьí^U³~öL1q°Œq¾C�¯u_Ñ:ºo?dPë•õiß–Ñ’ºMº�EuÞûM¼<©õ~HuI¢ˆè½SÂçãqÜs¡e¯Þ½Gú§OÄóT¬ ô¥¨;A½Þ¥lj‰ƒe°X,Lü=¾ëL/f.©BB•ËÅú93qõ¯˜1|úul�n!ÁÖ«yH}œüÏŸ1hÚLb5›ÍÆúY3”>�ÙšR‡°¤T]&ú»‘Þ»�žÅ‰M6Ó²FU8Æg¬º òõÁºY3ˆÏn}k+üòÓê¾Lu¶¿%ûÌnÌ}æÑýûP×U练&¬˜3X™šÂÕÁž¸.ƒ fHBW[ û(ÃzÚÚØ±d!\�m–œ=/´ìå›78‹lÁÈb±°cñBjÖ+—ËÅïÓÊ<=ß¶CŠ×5«Mì�©ö«5å]wéÎ=!±²"ZØ÷ëJ1׋2ôutð×ò¥°2#gÆeçåaÿYrp¶6¸ºkv¯\†1ýû¢ghôn×£úõ�‘¾tâÇœIcÅÄA”Š?ùŽqßyS&ˆ‰ƒ`jl„ÑßÓ÷•4/BË ´±¬¹„˜¾%ªÂf´ }M-ÌíÔ §~œˆþM WAdÿ ƒÅ¨�¦<­mÐÕà ã[ãמa80tîN�Ž[“~¾Áð¼kwŒj„Ž®îp·°À‘'�ÏÐG¥a©o€Ã#Çb\HŒj…™�ºàŸ‘cpgÖ|¬ê?ݼ}` „ \yHI—]ì¬ÈË_kªÊTXÛØ(tìo‹…u?Œ‚™íõ½„x¬;#9Û°ºq´°„™¾ä2%YùÌ5Bk u„ÕÄǬ,j¦…,x5t©’>&r"OwŸ8…Öþþ°³"§ëhiaáø±øüE<»ÁËÙE¦ë˜!’ööròò˲¦^žB–€ex2O–çäå#3‡<(©Îˆ-”ÚöÒ˜;f”ĨÒúÖVÙ' k(µ•.ß¹‡¦Þ^R_ÏÄAßK¬•Ò¹UKªM¦¨UPn]Ä�¸�»ÑOÅŽ)CãÅqñÙܘÅ]hìÚí[Ð-t cËT{/Ñv¶ˆÇ+·Y¥½„¬4¦ZBÕi�¢¦ªŠfÞ^DË&’ ¥h†‹ÅBÇ–A8~é²Ðò¨gÏ„j+Ѭ+9l¶� Õý˜X¢Íx89b`—Î?ÓœÑ#püÒe¢à/ú ò?–)J|áøÑ»][©·'Áçó1fábÄ$$’Ï1n º‹ÔŒRM½<Ë-ƒE‰|#ôÝ?OI›Œnä섌Ì,¡,š¼‚Ä¿x)$\вC}Ü\•–ÍSL>ŒÑr6$¢ôÛÐ IDAT0ª\.1»‡vŸ~kÐK`Û¡Ãðóp£¾lÌÍ1{ôâ:g;ŬôÁ»¡ 1 ŠS¯¸oÞ“'£äéÃLø~`y& uu,?½'’…êS×® Ù-^¹IµÃìÒZb„ÃfcÑø±h3”lµwùY|ùÉ+(À]Jǰ¶mƒª Oûv˜»v±í¸-|Ü¡!Ä̯"×ï?@Ç bë®RÆM6æhìÚ°üï둨–›ý:v`ü 0°kgjÿøÎãh¸QsëçÌre`¢WÛPª#Jø­ î.lœ˜šFíkUÆûU”ÖÖI ¦-ãû®�ñçÑãÄ’™Ù9¸÷$†Z—R�!�àóÅ…ã�&d¡·ŒwÔû¤ºÇge\f§�øl$Ø_êjkaÖÈ0f!Ù*ùò�{Ú³‡Ô×£¥¡�}¿­Ëâ–3c´lB&Ü%,ÍMÑ"€€åêLw¸àó™Sc¨««£PÄÍÃÆ²&g,P—Dðø%øRRµ*̳54ÂÌ�ñSÛ¸“’„ˆä$èH°øv³°Ä�a£`kh]íÀßçäà�äò8L°X,,îÞÆ„±‰®†:5òB§F^à x’–†«qOq5.±¯^Qƒr”IÊ;Åê«æååÁ€!¡�ki-fâ·Œ®†&föìƒ ;$k¿�8‚`O4ªW}cI—” Ÿ¡DFZ .mµ‰:�°£¡®Žu³¦K±eåbl@�ˆIHDÓþÑÔÛ íš7C›&�¨/Ò˜þ Ah‘–Šs4L ‰autÖµ440{ÔHü¸H¼c½íð ìÚ¶²G¦Ä$$R' ZøIìð—ñ}·.Ô‰þ{Ožü'Bšõ¬ ‡ƒÖþ’å=BC¨™ ï32ÊÂy?Ê.¿z÷Sù•jX�ñxbÙ4e˜Q3–Dص3ÖîÞKì@ŠNI¢…�ä [†�Z‰Ha¤§›M¬×™•“ƒ®cÆÁÍÁ�Z!¤I 99 Mœ×ÆçGšzP&ä¬YÚY;+Küú3=£ˆÆÍQ˜³vƒÌûU­üýˆalb>–g¾ÿø¯El\X[#´i ¸@("Ѭ+½º”×o€Ûû'Ø¥³Tõ õôЩeœ;/¶®¸¤bŸJ�ÕfnlL…eañ¦-8wãqݘþ}1¼w˜Âç ¡¡®Ž&^žÄÉdÑ Q’ÈççáŽwÅlö¢ž=iv›ÒNWºÚZBé$Øl6Œôõˆ6£5eÒ´²aj#ï<ކoX?´ôóE»æM ”¡Áb±0nsDu0ið÷2ï“þéæ®Ý@­£,+\|×Y²=�¯µ–èó”BVÁ4û<,¥{‹‡“#¼ºkY§¾}Ëhïø-ñðiµ¾yor–eE´44àåâL âŠ|‹>¿Ü‘¦mÓ&ÐÖÔ$f ^¸u[L äóùÔñN÷à`¡ Êû”àu55‰íJƒLhÜ�~‚a½¤T¼º ´)ÝIA”Æ® QßÚŠèŠrãA Å2¿i’•™£ã†²èשƒDá˜Ífc@—N˜µz-q½hp•,°X,¬œ:YæýSÓ0~ ¹.rMâöcrû¦ÂáHÕ–@—Ö­0sõ:d‚U#cbÁçó¥®SÝ»}[…ÄAhèhÏ­©AMÜœ÷Õ�!N‹+sS$½HZ^“k²À ¨«BXJ^qÔ8U/p9´ppB Éå4UUá.gÍõeΣ€ü(‰.žhf/¹<›Å‚§­-�XŸ„L9Ú»W_G‡±gE4ÔÕ‰Á}O(cÒlU„Åb¡ghü¶ó/±u_ŠŠp-ò¾˜ðå»d�°{Hk©ÚzE‘6 &$0³(ë˜úèÊàýÇ�x–”Œ§IÉxž’‚ØÄ$Ä$$Öxqðsa!ÕÚÍÁfFFRG•ËEóÆ^8uU<ó(+çk_Æ^J>io%¡£¥-ÅV”}¥×+‚µ…¹�@Èa³aeF¶p­)¨°8(ü7‚·$ñ¹„‡�Ú7i½z39 áÏ%×L…ÃfcL+²}µ$Œutñ]“æø®Is$½‡#÷ïáàÝÛJ¯ã¦ �ÐÔÔTì�g[¯¸¥uœë‡Íba\‡.»m“ÄmŸ¿y…%‡`Qÿ�J½†µ§�ãè½Ûp·­‡~Í[¢‡K1X‘ÛqÏ0cÏ.©¶µ–ò½YÓ©}£Ì:¾Pž7§Ú­EËÐPWG¿ŽíñçÑãRmŸüê’ÂÖƒ‡ ¡®Žæ�½Ñ¦I Ú6k KS¹®�-å@åk,TÍ�ÅbaÉÄñè0b´ØdÑ¥;wq!â6B›6‘阴A.J#™¥EGK‹jÉZSê'T&…_¾ €’�goc]©ç~ñú N\¹ŠÈ'1x÷\iöU…²îApst „E<ròó¥ÚX•TüxhÏî¿d¹TÛfdfbÿÙsØö8l6|ÝÝÒ$m›6‘hÃVS`³+·ýäóù¸ó8á·nãAl,b“¤š4¬iØÛØ ž¥%QÈŒ|S.’2Ì|ÝÝ`oc }!ËÅwx“þ–¦&àñxÔàÑúFYÙÄíô´µeŠÔv'X —ñá“ôïksÅ£¥iY(eì @hÓ¦01”´Q¼Iÿ€“W®ânô<Š{.–)]ÈbÍH²t/£b�È�”ö³¡}©� ¡ßó�RKö[#�¡;{Íz…�ÿ)[ø·êBÓ?}Bôóxx5ü·ŒÅ5н¨c=[¸‹´¹´Ú¿ï220|Î|9¯þ+L}gQl(µß˜èÕ–,@øÍ!�ðsa!"’3Ìz´ ‘ùܲ¢ÂáÀ¹¾tAÐvV–ÐÒÐ öIÙêŠð<åN]½†±OñðYœØ}W[ø”“C1ÝÚGD�¥5Á¥™ì¡¥E‘á«’…ÑlA3Sc¨Ôp��S�µ€\^ôUe³î¬éó°èü¹ömïêŽúÆòÍÝVÄÞÌ?uêŠ í:âÌ£(lºŽ„·ŠYƒ–‘òî�ÂÇ0#¼oÈÐçü¯Ò= )–=„×%¿‡·]<‡Î>~prVʹ ¾|ÁùÇ_KEŤ¾Äì½»0wßßð¨g‡`wO»7‚‡�4TÅË“ñQt»pûŽ�@H«?Ø=D¼Î^eÖ¬ÏÉÏGqI‰TYöLV‰4ìmlàíÚ Y�á·…2+oF="ö3ëÙÊè':ÚZP•R~¸�?ÂÏ":õ¥BÇü�“�¼ÂBhË9þTUU…ŽÈ\‹Å‚]ýÊ Bý–àr8Õ¶=æìÛ-q[¾@€‰;·àÊ‚eJ±=ÿ8 "nf|��_¤àñ‹¬:u l 6Æ&0ÑÓƒf©P˜‘›�”÷éø\DvBc"¤s]r1©/£à=^ÆÈ¶’ë`KCÝLC5¡¯«‹q¾“øoþØÑX?g&.îØŠ‡GbÆða5NDi¶ÙÉ�ë1¦_èiËgïÀçóqån$:Žƒ='O+ýk23G/¯yR‘äW¯°ùÀ!™ŽÅ”q£¡.%Ám2—Vãð["7Ÿ<1„Rû"e~+¡?ŒÄÅÛw¤ÕÕÔ*åZ…é”õz™Œ9ùt·*`±XØ8w6–Nš T'JVb“0xÆ,Ìø}M­†å�Ïçcæª5øaÎ<™ÄAi,c«š}gdÌS””ZÙUDW[ NvõÞ&/ËxXjÑF«?ØÊßOL@*øL΂–õ] Å0°Ê©�¢pòʵJ;>Í +²´>Ö£gâоîn@©ë‚hÖpqIIùý@«[+‹%l5SCCœßº ƒ»w•[4/áóqúÚu„!µµaerçq4B†ÇñK—¥¹**ÐR ®SEÔdÈÄ`úÎËœ# ©ïc™ûÒ Ÿ17¯æµŸ•AN%ÎO9™\*º“'//ÜŠ(ÿ^A�XýØ2º‹dÊñù|Ʊ�¢‚J  WhâòŒÌL!ûÔ+T{QqÑ´2ÐP“­]¤µ#Êp¡Øö:�#“8¨­©©°�zeBsÈ�ÒÇiÿ�öM¬EA+Ëš[° •oH SÈ-–½N_MåUV&¶ß&×s—DgO8*0÷Á›ÅB[O›2ŽoÄ8�@€ÔäÞ____Ì^ÔÊÚš„yÛ:Ä C)ƒù“߿ú³'•rÞ£w"$nÃðòC:î'&àúÓ\ƒ§iir‰ƒí½}àh!_ýÏšF]a5a¬¯‡Ù£GT÷e(- Ìûq4¦ý0§®^ÇÁóá¸õPæ#¼âbLýå7Ô·±f,æþ-abh€ÉCaÁ†�bëVýõ·L6RLmžŒ[ZôtM©• Ó¤)ªVb“ðÜù�k¨§wG4rv‚�›+šz{¡ë˜qxžòB©×£(ºL÷ 垢‘ϰ½ŽfõwÎØl6†õê�ÿõè†ë÷àŸ3çp!â¶ÌŸv9  sŒéß·R®µ&²~ï?Ø~ø(ã6, õ,-àáäˆFÎÎðuwƒ±¾Z Re×)+Í{ƒËå‚Çã -ÏÊÉAâËT‹M\ù¸¹– |�] uK3Ëd�tu´‰ŲNræ2=‡ZÕ÷> Y¹Àü  ´i`¥Qúãï⃘² Qš½hÞ âI¼°¥ÈÃgqôl„‡OÅk&qØl´”ÑM Žš��ž.VN�Œ¹cFáø¥+8x>÷¢ŸH´Ðåsa!FÌ[ˆ Û6W›]uÚÛw<}–Ä,1=mm¸9:ÀÃÉ�6DsoŒ^°×ï?Pøò ¤˜ÞÏz¥:ššÔZ‘ÊêK£šÛϪD£’ƒÙJPÝÛcÇñþEt|ÞedÀÜØ˜:Nõpr„ƒ­°5"›Í†*—«ô1@E¤µ²’—î!Á˜¿þâyÂoE”²\ºC«À^ döhB ÓxDnE=Ĥe+%¶ËfFFps°G#gø¸6DSo/øõî_c-Hç d¿0¶o5`œVSµµ±¬ù„Àa±QŒº:„eäAGEõ›È"\q!…ÅŸ�¥›·âÔFÅëRÔ†‡õÄî§�”–&´<¯ @¦Îº�ÝþòMú©k:À[JÄ�¡~åXlÖ$ôu胧Ìåøæ­Û@�p014Àè~}Ѷi8–fU„_3Î ²»Þ¤ËÅÅ´½�^ÍÉ"c³Ùhåï‡Vþ~(âñpãAÎݸ…ó7oÉTCrÕ_»0¤G·*±b¬n>|Êį;þ¤®÷qsÅa=ÑÊ߆"í©.eMBKC��ÁUQ›X~ñú Ò?}"fy–M¼€·« v?!´>êéS$¦¦EOçjûžë¨\´551 K' èÒ ³²~ë6Îݼ‰«÷î£ð‹tQ¬<+·ïÄÎ¥‹*ýzI,Ûº�*êjkaTß>h×¼ÜìÅ"±•Õ‡©X;Po¶5,íW°Ùlèjk-òdïÇ0œï?П}]zùõ¿ÿ„ºšb–R$‹Yw¢´@ À¥Ûw1 K'\½wŸx¸|÷žØºó·"0säp$¥¥ËK4rrM+‹œü|äþ,U†q Ÿ�ô�äÚ�† Œf­YG­ÌÌ0¦_´i;+ñÌ�š8>+ƒéy”¥-ýº=Cÿð?Ò¾Iƒ•¹)X,Vyfzm°Ei¡ìù4ß.|�ùÅ<èp·A¬Nn¥$áR¼x@¢4 o =Ù¬ˆ%Ľš:¹`ë¥plºpŸ¥ì@†óv**Âr —Ë…½ŒuZÿë i‹MágÄ,?Iá÷“DZzèp¹Ïw6ê>¾ó¤ØR9¬4ÎVVUv¾Ê¦N ü�!­}�¬Y4ô´µÑ3´ z†¶)Ÿ4?{ý&ÂoEHœ4�|ƒŸ2eÊž«Íp¹\,ÿ#ü4]¡ã˜1ÔVy‡Öâ“È$ÞedP'AÌŒŒä¾¾Ú‚†º:4ÕÕ‰6,ÏS”ã ©oßâAD@i½�3[þ`¬³—Wí[Ì�é÷Ç£8Ù:£e¶Š¢¨«©Émg\Ù¨r¹ @H`VL™ˆ‡ÏâpöúMœ»yS¢°•“—�›¢Ð¶YÓ*»Þêâpøª0>¸{W,Ÿ<‘Zs­6Øø“Â'± zøyü›afbh 6±™ÿù3n?|D™¸s$N,š¯­ðËÄ%§ÀÝÑAªÏB{!¡žWeáãæŠKAS]Ó~б‹–·[¿gúwìksåÚàèhiÁÏÝ�h9ù$FȪ ¥ï· íà±Ï¨Ù¡!�J¹î:*ÑLay1Ò×GÿNпS|.,Ä•{‘8wãÂ#"$Ö~¾xûŠx<©ëv)‹¼‚œ¸|…¸ÎÄжo…… ¹‚û0ÑÏŸK±ÕWÅÑ·µ­0iknlDã_ü[3T>£÷{L Ñ銠¬{QÙ08¨«©¢ž¥ò­™X,z„„`Ýž½bëÂ#"0 K'\¹'.”±X,ª=©¡žQ ÌÍϯ”ÏPôjJã’SðòÍ\¾#¾z„VMö J…cÐÈCâ¶I©©TËLEìÿ£ž>ó¤dâ:o׆8²vµ Œ(Õ�¡žT¹\Ú Qû{I<ªÂö­6£®®ŽGÿ)ïÿ3YO×$X`�ÃfËìnð-“[üÚ*\±€§Ú¯¤ËÂÏɵ¯¥¾>†6m®ôk’ UUŒïÐ}š4ÃüûpþÑC©öÓÖPžSƒ½£#T•P#ï¿„±®.¦v뉅öIµý¡Û70¥kwØ›Èu¾£wo˵Ÿ¬pØlÌ ë‹ïZ´ª’óUu5ÿcÐꉒöN<_QÊ&Íýy =ˆ¬C7 µ Rkv–ˆ²iÓ$mš*t ú`êô5éëÔœºz�º®b&Ä·L#g'âòÄÔT¤"G«Väøå+0kÞŠø¯Ìôn4=~âà�Ìâ`AÞS¢f«[[j�¸k‘¤Îˆ½‹·ÈV>n®µ¢cÎf³áãæŠÙ£Gàæž]¸¹û/Œî×\†IÜøÊ k2´{ŸÍfcî˜QTq€X¦uM$8€V‡0b…$6›-f+êã*na²õÐab Okʹ*ŠŽ¢ÈR·Œñ}àVµï--ìY¹¼|b®WhªÐYøå æoø£R®£å;?~™™B˼]œÁ­…êhWO̪ûMz:õ7 ®«iúÌÅ%%Ô÷”"h¨«£cP ¬�5±'�áÀª_û‡E<^¾y«ôë�ăاԠÂQ}û0Šƒ|>_¦Ú³L$¦¦I•Y^ÂçãÜ rÍu554rú72¼¢-pEŠKJ~Sr}”~ÆÓ×Èí'‹Å‚� VUÒÔSKÉ–«)ÐúÓ¨Ù_Ê€&j]�|€ø/ˆ÷Ÿ�›+l,ÈõÁ9“3dqЍn:5§ [á·"p™`/Êf³Ñ-„,šVçoJWë ÃØÖ×]~+8¦ñÙÏÃþÇ �úö­Ò‚­+®Š ±Ö5¤¼zMFEù”��[×™ ^-±Ñ¬*t´µ «­ ]mí]£R”ºŒaJä—ÔÌ`iø;ò’å°ê€ŸCÛC½ŠÑD1×7À¦c°þ‡‘ëÛéhh ‰³xP¦¼4òòRÚ±þKŒí/)kIòJJ°ãò¹Ï5$¸ ºú@Cµò¬í- �p`ê üؾs¥�£º¨kï¿AÔíç))÷ü<^ÌŽ…FÚÛwøëØ âºN-[À›PG ¥�€Fhä5.Î�'nWT‰uj* Æýˆk÷È\ßÚ æÆÆÄ×èøxD‡_Àè~Ì~Ó´¨z5UUØ—Úô0M,JŠF¾zï>±>NuÃf³èéAœ„û\Xˆ¿Oœ”øÝÀÆöS×5ó®¾ÚW ämÄu^ ]йUu_G»z˜?v4êYY`úo«‰Û�"z¿EÞÐ,Œõô$Ö9¥E¸×$\Ô‡…‰±Ø3ž˜š&&n»Ô¯‘‚çÞ®.8.Ò†\¸EŽŠ£ H�^�¨×·çäiüø]?‰^1 ‰Ôaêjj2Mp+Muu!{a6›�Ù£F¢ß”ŸˆÛŸ¼r 7D¡¹Oc¥^Gp€?–nÞ*¶üÜ�›bËD…Z› OgÜŠŽ~%ý¾†zzðtqVÊ5×!ŽƒMTœõ}¯Þ‹”J¸€ç)/pð\8q]ŸmádG¶€/«AÙÒÏý¦ü„+w#‰ÛUÇ»ƒ©c+arøAìSdçå)íZ¶8„_~šÌ¸Íáð T1ÇÇÍU(x§©·'±Ö(l>pÝBZK T:yõuLådWO,ó[•KšÇ¥¼�˜%KàGUB[QšÑÉÌRFÂËTjéƒ&^žBAe¸9ØÃ¹¾�X­î‚ÂB,!´ß`°Eiæ?gÄ3/2³s�ööUX¬È�ÇÑÄgUW[^UÐÖkih CP Ÿ€;~ùªX}\”ÚµZ™Ê_Hþ9s}]mz»Â/_ð×qò3 A³’ Ýk$ _¤ ÍšF —'UÝ|à VϘ&ñ±$& IDATÛ¡ÎW4õòª�œuH†Ëæà ¿2«û†ÉåA‹Sû²?äåbã zÐ'>õê¡]Ú“$Щ±/š:¹`űÃ8p놘­3‹Å‚þ¡'aNAZ¬l¬a.Å;¾q¸þ?�—.ÀË’m¬�ܽ�Ùaýä:Wo_tðöE~a!Î?ŽÂ…Gq/1¯>*ÌYÏÄ#ÛvÀÀ–­¡¦R½ByeQ'~ƒè1DRD<|„—oÞP…‡"³V¯•ú\|ŸhÝ‚R‹Êõ�°"NõÅ몕aa"_jqmÆÁÖ?ôêÉ(ŽH¢s« l£|Ó~]…3›7ˆMFWdŶH~õЏN_WMegx%57Š’‰Ö~Ôû{Ýî}èÝ®-µ^HÚÛw¸q‡¸ÎÏýêëôt,úc3uÿ°¶m„þð§ZÍG=}†m‡cxï0êñÞed`Þº ÔõƒZˆ-£¹/À¾Ógí†o>ˆÂñK—©ëåAY÷‰‰¡<œ‰âÓ_ÇO`H÷®ŒÎ ¹ùùè5~QÜuiP×ví¤îÛ³M–mÝ.¶œÔGå°ÙèL·‹*k�Imõ–ƒ‡°hüXê¾pã~Â&’ß¹ ¬�zµmC#ŸÄ·ïѦêìEËø˜•…yëÿÀªéäàX²y+µÆ§£]=!koY)üBRNN{ {ò¦ °÷�û¼$*£�Ú¹Uu¹ÿìyt F+Bmë2žÄ'`ýºe©}û¯Ò¢GäPêônZ¾M|•ÀV|m÷jn]ͪ¦XÀG^-¬E¸<üò‹¾È,örØlÌnß¹Æ ¢ÚÚX>p0F†¶Ã®«—qýi 2²sPßÜã:vEH#rÐÂÀã:v)ÿ[®^¾Œ¬Ì,hkkàt~Ï@õßLq?éÊ4ÉJ}S3¡k¡aQÉ5ŠÀÅÊFªk1Ð’½Ì�©ž>NΘ‡=7®à}Vt$(gää@GCSªëq³×´ÔÕÑ3 )z|-Ûó>+’“™�§i©x™ñ¯?f ˆa^ÌÂÐ6õàiסžÞð´“. RSUMªëV&ʺ?êÂoc}ꀶ¸¤CfÌÁŽ% QßZ¸˜æ³¤düüëïÔÁ KSSê¹N\¹ŠAݻŸ!*4ýÓ'ì=y†¸NGK‹Xôû¿À”ÿ ¡ðp|ø”)ÅÖâŒè†�Gްñ/^ ÿÔiؼ`®X4(¯¸¿lßÉØáÚ³»ÔuWÊ�÷sT7M½½ˆ‘Ç(¸~?m&þ\¶X¬&cVn.FÎ_H@z·k[þ¦º,«wý�-š‰MbÒò•ˆŽ�—ãSÑù�©¼ß©{H0–nÞJ¬c™›Ÿ�>“¦bû¢ùÄ,ãƒçÃ1eůÔcøÃÕ^þA¿2p¬gK“ÒÒ°óè1ü¯Gwê¾_ŠŠðǾ¨ë=*¹øuMy (¡� 6lį?M³Mx™ŠAÓg*½ÎÒ‡ÌOBYiÊ¢u ¸@HÂÏC\ ôtv‚ ‡ƒâ扡–~¾Tá‹ÍfcdßÞÔlÕ]ÇO@]MsF�úÞüˆÑ 3Ö—û]Æk«J挅v?Œ$Z°Æ%§`ç‘cÞ»—ÒÎÇb±ÐÊß�ê€Pq;R–¥·+ÙÞK”à@²•ie¡Ì÷@mÀÖÜ,‹xßä`ÈŒÙØ²p¾˜øö ö)~úå7Ä¿�œeX†½� U\Ø{ê4úul—ôÁçË7opôÂ%â:KSSÆþ„(ÊzêÓϹùÀ!„µ Ëzãñx˜·þªÐ)/_Ç8³±zæ4±Éé'ñ 1wÕ^_W=CÛˆ-ëß©¶>JÜgÞº¯öÅ?„õ›4‹ñÃfϧfXª«©á‡°žbË+Ö@åä•køýÏ]7ð;¡l9�‡Ý§Ncá†MJ¯¥ÌþÂÐ^=0iÙJ±å /^büÒX;sQ$ÌÿüÃçΧf~éÑ�ñ¼ÝÛ„`ù¶Äg\”&Þ^ŒuÖ­ÍÍÚ¬ Q\Üzð0<œѧ};â¾Ï’’1n ¹^.WE:w”x}Ê¢¥Ÿ/Œ Äì°I¨p8èÒºe•\—({O�†ºª*æ� µ uŸx<–nÙ†-Q÷Ò½›B“ÙLmÛŠmÛѬ±—Øx8+7#ç-ÄË7âµ¢áC¦òmx99¡Yco±@%”:¥ ›=ëçÌD‡â™·¢bÄ<ú8·ž¥%:WÓ=SGå JP3Lk 9Å_ ¥Â»†‰f4n$%àì³X¹öíÓØ Íknö\}3sÌëû�ÔÛ[ e¨]½|ÓÞÃ�ÃA@#g±:ƒfæ°“Ò"SV-,åΖS6žvõ¥ÁäÁL_“»ô�ie}7fúèØØû -Ï.ÈGVþ× ©¦] Mêè€+g`±–ºz�ùMe¥N ü᪨ÀÇÍ•Iÿ4) Í ‚�›+l-,PÄã!.%ñ/^J5x=ͦäKQzŒ�€.­[!ÈÏfFF060@~AÞedàÆý(œ¾v�j/Ô¾y3¡ÁÀ --Ì9œ8�–†z––èݾ-Ñ¥¢- F×àVhìÚšêHLMÅÑ‹—ðâ5}P£¯«KœÐ 5úoµ÷ô´mÖ ¬­�•› =�*͈�‹…Ñýúbâ²ÄõQOŸ!hàôjÛž.ÎàñŠ‘˜š†#.R'2,MM…¢“]¢[_¼~ƒÀþ1 s'8ÙÕƒ@ðõù=zá’T5I¨«Ñý¸÷Ÿ=‡N-[ÀÁÖ9yyÐÖÒ’;ㆫ¢‚ñß  ¯ß¿GçÑc€ _èãÕû÷8}í>}FÜ¥‚ǤÁßËuMʤKëVDûY˜þÛjœºzƒZÀÆÜ Æà##3÷cbqüòU¼~O¶s°µ�‡“â¡Ãï¼ëø ´ôóE=K |ÊΆ¡ž^µDº9Ø#‚R»dÏÉÓxò<]‚[ÁÖÜ™¹9¸ó8§¯^—;’é}²æï=X<~t´µ�›—¯4±0È×G*‘�”A¨¡®—õ“�ȸ/­þ`ý:´Çú=û¨6w[™ë7гMíê�Ç+ÆÃgq8vé2rK;Ë$B¨6âÕ�§³º…´Æ±‹ä š_¶ïDÏÐj6˜<úKím¬‰Â )CT6›�Öþ•#ª«ªƒ»^¼Ä‘ Ñ©eŠKJ$ZþÖfôuuá\ßqÉd þ{Obàß§?|ÝÝ`cnŽüÏx–”"WT=]4kìM´ìÍÎËCè°kŠ@¯F032‚�žróòðæÃ\½wg¯ß þfÐ�PL�¡_vãAn>ˆB@#ðJÛ'Yƒ¿0ë|ÌÊBÐ÷ÿÃw�;¢¡}¨p8HxñG/]FÚ[å×:Gé÷ø¿™sÐоy€«ÂElBn?ŽfãŒПØ?ݯ/þ9sŽh#[ÂçcöšõØ}ò4º·F=K ääçãö£hœ½~ƒ1wH�nD škD+¶íÀŽ#Gáãæ =mddfâAìSdQ²S¤¡*î”Ö‹]¹mQ4=rá"â’S0ª_o4ñò„®–Þ~ÈÀͨ(l=x„*¸8×·ÃÀ.Ì5`ì¬,ÑØµ!Ä>•x�=ìEËÓ¿Îߌ»ŸÆ-^† ·nc@—Nhhßl/ß¼Á±KW°÷ÔiªñðÞ½$–P&*º‡´¦ºÍT$È×G©ïMYÙqä(Ž_¾‚ö-šÁÒÄï2>"üVcÝGKSôëØ^¡óºÚ7 ®{Ÿ€&ý¢§p°µEQ1Ñqñ8zé2²rrä:Óøl×±“háã£ô~ûÔÿ ÆíG�‰.e2�ž�Юy3X˜ãçL\¾{×"0–¸˜8x`­ª±W‡d8,X@Pg3Z_ @nqô¸•WëLYðŠ0ÿì)¹öÕ×ÐÄøÖUŸE^UäççcÏß_³©íììÄÄAhTðð­¢§©=M-Ôûï©¿Qzµ ¥ „(�²½ý„ê=O‹¦&1¢OŽ_ºLœ-.)ÁÑ‹—pô"9♆*—‹ ƒÊ´Ï·F¿íñ×Ñãx÷\®ý—L�{Ñ1T«ÐüÏŸ±ïôYì;}Vªã±X,¬Ÿ=ƒ:H4aˆ¸Mxñ;ûWÔ‰9q&†•Ÿ&¯ úul�“W®âÒ�»ÄõY¹¹Ôèr‹'Œš\ñswƒ¡ž>eg·ÏÌÎaÌè¤Q@™„052¤î“òê5Z Rþ÷ƒCûamÎ\!Ý»áò�{¿A\_\R‚ð[Ôõ$&$U­šÊ&¬](ÖìÚMjo–NªÉÊÔ¡C¤ØJ2L¿sLB"*DÙ½¸xrNø)B»fͰõàaêúèøx¹²d?!O 3e:‡Î tñtvBøö-2Ÿ—D™ýç=†Ì|#}}Ô·²"®ónØ�Q d±XhÀly¢¡®ŽÍóç¢ûØ TqõÕ»÷X»›l}HÂÔЫgJ®OSÕÌñN_»AÌ0ÍÎËÃÒ-ÛðÛÏS•v¾V~~ÔŒ°2Hâ/J'0iõ‚Ëðpt¨´w¥©‘!5 hô‚ÅåÿŸ9r8&|? R®¡¦Ð«m(–l¢?óE<5˜2ö™G÷ïK­éYÄãaïé3ReWDW[ #ûö[®§£U.—(Tñxè5á_«ÃÍóç¢{Éâˆ(¶6p°µAb*Y0Í+(`Ìò¡Qð™ÜŽK˳¤deR-Üe¹˨Šû¥A:«gLC¿)?¯ñiRÆ/Y.õñ4ÔÕ±vÖ bíAQz´ –(rUTЩ¥d‹ø€FÖ«U\;qå*N\¹*ñ8e¸9Ø+­ï' ½Ú†J%Êû{+“�YYØsò´ÔÛ/ŸÏCî=÷ž3÷žsî½çó-÷ét:^øøÓn{ØtÄŒN1;Zxï,Dkwîow¹ÑMWD¨ï¾ÀÑÎŽÅ/¿d1ÿYG„òÕË/vû¹ôÎÓÿâ¾›º—ƒÃb±˜×ÿg‡¡ô¶àYx¹Y4gV·�[¼ÝÝXÿŧLމ±¸_¯×óô{t9ßì­óæpçÂÝjƒ»‹3k>ù!,ÆÛòê£ÿà�'½¤1ÁÖo¿ê4¿úuÓ§qO†íñø]wôø›¡/ú‰�縟—y¨Çߌ..¬ùô\3¹ë"�‡‹ c¢;,ÓO9k++VòÑ%Íѱ#G°õÛ¯ðñ迸V‹ætÜ_fMßîó´7!âÓžëЫνn!¯?þèekÇþ~_·¿ýF gË×_:Èò¼R^UÅ«_|e¶ÝÅÑ‘[®™cñ˜ÞD*‘°ô�7¹Ñ¥=“NŸÊºÏ?é—þ"Ð7HD"®�t{}J½F�æ2çÿ½œ¤SÙÐ~ÊˆŽøïœùÞ;²§ÔTWóågŸŠÂºyìäIØÙÛ÷CëZãêèHˆ¿?Á�8ÚÙÑ T¡nj"9#“òª*h‰Ž—œ‘iÊýBQ b‘ˆááaùù24,…•5Å­rl75iHËÎ!9#Ó˜þ¬°´”ÔìÊ+«È)(@§Õ¢T©A$B«Ó‘�ŸORZçÎçXL™VXRJ“VÃ�à ‚ýýˆÄÅщ²ÊfOÞêº:R²²IJË ëBž1ÊÓÙÌ,JÊ+HÏÉ¥¦¾­NGNAgÒ3I=ŸC]CƒñøäŒLÊ*+9›‘Źó9íF<èWçh`hX(›Þ¥_ZB¦l^üE�½þ}ß½l^üE�­ÒÃYýñ‡<ÿÀý=:þjå¥Gº$ËÒAÞÞl_²˜y°[ ñb±˜ëgÎ`ï²ï¹en×>X^|øA&�²¼Øs¥ãáâ†/?ã…‡ÄÑήËÇ  dÅïòò?iw1ÄðqÖ•6©Dƒ·ÞÌÖo3ÈÛ›Ûæµ¿0¸~÷ZH>ÿìýë±enO°³±aÙ;oñåÿ½ÐéÂX[Æ ƺÏ?á¥GPâ �S§ppùRnš=«G ‡î.Î|øì3üôîÛÝ6ÌèŒ7žxt@„cíˆ@_6~ùi» )­qwq泟糟ÇÚÊŠ›çÎn·ì¿ßÿÈl›D,æû7^ÅÛ½s�»ËÉððÁæïÕ¾€СÇow=ÌæNšÈ�åK¹}Á¼nõW;ž¹ï^vÿ¸„ð žœô®NNzÑÄ'�aµ…¼É=eúØöC¼:ÚÙuè]Ѿ‡¨“½=£¢:ˆ/…aƒÃxçé y‚Z˜4*†5Ÿ|ÔåÅúYƳþóO{´*‹yû©'Xõñ‡=6<1„-‹¿à�[nî°ÜÜIù×=íÕ<³"‘ˆÏ^|žûnº±ÓzD"w,˜Ï®–­×Ìi÷˜ƒ'N²~w×C5¾÷ÌSÕUUxyyám!RØ €†�¢­ Ê««É¼p�”ÌóÔÖÕãïåÙ_ÝDqysºŸŠªj•(Úî×74`ogg|Ÿ‰DDG„3¤ÕûIyÖr9�J™¹ÍÞ‰�J•UÕä#‹QªÕT×Õ¡×ëÉ/*¦¸¬W'Gôz=ç2Ï›¥[«klÄZn…½íÅuœ!ADG„£k9F¯×áâè@iE%ùÅÅÐ’¾*§ u“"Ò²s).«ÀÉÁŽ&�†äôLÔMMhš4T×ÕQPR†��‚êš² .Ý;Q¤¿œfÔCÀ�{ö—˜d ;(‹qwv&:r·^3—y“']¶øìü6íÝÇ©”R2Ï“SP`6hìl‰fÄ�pNŸÖ£�=�îQSWÏ®£GÙÏÑ„DJ**hT6çB�ˆÅ¸:9Ìôqc˜Û¥Eû¶hu:Vþ±�•låÔ¹Tãùi±ŽÉW/¿xE[VÕÖ²õÀ!vÇ#>é ¥••F+w™TÊ oob£G2wR,³&ŒïÖÇj\b?®]Ïþã'(«l¶l‘Ëdúú2êdn™;§G÷¥-:�Ž5;vòËæ?8u6…†V÷ÉÉÁ�ÑQ‘|þâ»n©«4i4ì‹?ÎÞcñ˜ëgLçÆY3q°»ôy£²º†oW¯aËþ¤�Ï6ÉäçËÂiS/i¡õJ!·°�m³÷XýßÇìݽ'''FŒa¶V§°±áλÿŠÍ¼^y5 T©8™rk¹Vr ¢¶®ŽA>Þøx¸s¡¨ˆüâRF�$9# ;k3稸„$\œ-¦/ÓhµK:à o/æN«Õ IDATü<=É-($¿´” #†“u!�’ŠJÆ ‹B"SPRJvAã†#;?ŸÒŠ*Ü]œp²·Gam�µ\n¢¥$¦¥£×Èð0³zuz=u h5ê•JòŠJpqt`p`‡O'àáâLè Ahu:ⓌíSk4?“L�¯r©ŒÔœF„ÆV¡ %+•ZÍÈ!=3<5 „BTj5 �JìílûÔrÜP¯Hv¶}[·@û4(•¨Tjœì{År¸¦®�Vƒ��Íe÷�HT×Õ!BtY J•ŠF¥ê²‹t–èÏûÔ_sR_ Ñj©kh@§Ó ˆ1P]W‡V«ÅÞÆ¦ßEÁލ©«G,õz¾L­NGum-b±[Ûé¥Ú—TÕÔ “Éú%’@ÿ£T©hP*‘Ëd}–«örs©a[ ÏaG{»>� ÷¢7榥¥J…µ•Õ%…î uSõ �}~íZ£Ó騮­ëõ÷ C=ÖVò^õ¢©®«C,õ‰‘]_õKõªTê~í7—ƒÊê¤RÉ€7ˆ|â­wøuËV³í·Ì�Ãç/ý·WëîŠ@Ø–ÒŠJ´:-ž®®}æñÚ–F¥USN}zn ¼·7i4ÔÕ7`k£è÷ï€2U½ ö�Ú&´Â ²2±O+Û~›—:bÍ铼´yC«- „·ŒŒáÕWgê U¿ýÆ/ËÆÆÆ†˜˜¤mŒ-Äb1×/º ÿ«Ô{òJ x{ãëéÀ…Â"òJJ;l(�†gSòõ!» �ˆà ³çò©”TÄb±‰Pw¡¨±HŒ§›+Ç’Îä냷{³àx¡¨˜Ø‘#Ⱥ�GEuµÑY¡µ@¨×ë)*+£´¢ŠF•™DÊðð0“üâiÙ9TÕÔ2zXâ–9¡¤¼œ¥ ?/OSÓ‰šÃ§–TTâ`gký==ñ÷öBÝÔÄñ䳄úûááêŠ^¯çhb~žØXY“š“CtÄVVÆ£—*<�^ÇJ.7é¼W{½ccmÝ«þ—S0Èô†U³µ•U¯Z?ïÓÕ<7H%’>Y@è*Õú¾-}Õ%b1.ŽŽ}Rו€à%øç¦/Ÿ9W ýuMz³ÞÞ~ï3 —É�;öïb²X,î#«¾ª§/Ÿá}ÕOJ½—›¾è—ƒsç³-nï($kâîÒõp®½…ÂÚºÏÂi„÷v™T: ú³ Mõ/R‘­^ÓßÍp4étÔk›°“¼5�E#c(¨®æ«ƒû:-ëfgÇ“3z–Ïx ³uË~ýyÖÖÖ >ÜL˜¿€üâ´z6Ö Úˆ“.NŽøz¸SPZfÌ“hc­ Øß±XŒL"å\vr© ©TŠJÝdV�H$"Äß�´ì\â’СÇÍÉ gGGÊ+«ÌÊ_�P@@@@@@@@@@àŠD¥V£Õé�ˆÅ|»ju»åDba1K@@@ § €G^yÝâÙÂéS{5?¶€À%!èƒýŠH$B&Ó$dª2C§×SݤÂE>0¶[¢G1)8”ï�fmÂiš. …2‰„Wæ-`j¨yδ+�µkÖðÓ�K±³³cĈÃ5‡ Ì”éÓú¥}–±’Ë;l¨ño±Xl ×iÀ¦EÌn/±X,&<0€F¥ª&5Ör¹I„–±Ã†Ïéëé‰w‹ga€¯ƒ|.æ÷örwÃÃÕ‰XŒ—›.ŽŽ4(•H$ì ‹âd€OsèÒ¥ ¹LŠÂÊÊXndD8 J%¶-ÇÞÅÆj^ßÙÑ‘˜¨ˆæ R k«–íŒ6Ô˜8,À¿Ç‚gk„7?�+’¤´t®}äÑN?Œ®Ô¼Š—ʪmÛINÏÄÙÑ�´ìl6íÝ�R¥²XöÎ…×öyûºŠ Kõ?R‘„&„0£–¨×4a+‘a%˜KíÞŽŽ¼0wO͘ř‚²+ÊPÈåŒò„÷U–v£©©‰%_ÃŽmÛpuu%""ÂbXÑà�æ.˜? óGþ™‰Dæ/¯¬â|~¶ ìm;þÆSX[ŵִ>¿X$BÜò·¤MìÖû0¤QèBNÞöÊɤ–C¢Zú½–ÊŠÅbZ·°m{{ÊÀœµº@W¬&||ú¤-�Ô¬l¾úõ·NËEGF0"|pŸ´I@ ga#‰�ˆEh{)Ö•Ne“O±í€œ2cÐßMé*+*x÷í·IOMÃßߟ��‹åBBC™»`¾‰×–À•�BaM°ŸööýÝ”«A ¸ª™:ft7A@@@`@ó�<ÔßMèA’ÈEbÑöw3$M:uš&ìeæyîzŸ”ä³¼ÿî»ÔÕÖ…›››ÅrC‡gú¬™ZÈhkkl¬­û»W‚@( pÕ2wÒDÁ+F@@@ žà~b£Göw3®$"1b‘��‹Ð"ÕM*)RÁ3­ÏÐh4¬Y¹ŠU¿ý†££#cÆŒÁÊÊ<¬$À¸ ;¡ÏÛ( 0�B�«©D¢9³yëÉÇû»)¹LƸáÃxì®;/k�n!‹Qj/BKèÑS®nÄÓÚ<Ôz›¬Ì,>ýßGäç劷··EÏ@¹\ÎÜó î—v  dDú®$í`Ô76r"ù,ÕµuÔÔÕQS_‡•\Ž—›£‡FááâÒßMèWR2³ˆ?s†òªj¬­ä¸:9áëáALdŠ~ ѵ|Ã&*kk̶;ØÚqÏ ×õK›>Eʺþn‚€=õZ Šrû8ɬ°—Yöb¸tY³rÖ­ÃÑÑ‘°°0¬Ûy¦yxzpÍ‚89;÷y;®�°¨®­%·°Èâ>©D‚¯§vvÝ:gAI åUÕ„ º7éÖ\(,¤ª¶ŽÈ�`$I·êºœœÏ˧®¡��AþÆ>—už&�†¨Ðš4ZÒ²³»\‡\&#<(�äŒLt:]—�‹ A,q&=£Ý2.ŽŽøx¸_–¸Ô%åå$gdRQ] zpuvbhXnÎNfeU*2rr±U(ö÷ëôÜ­–”Ì,ä2)áAA—ÜÖ¾¢²¦†¼¢bü½Ïßý€R¥êòqß½õ:2©´ÓºÜœ�¸ëº…Œ1¼ËçnMum-ß­þ�ãg’i«Ç‹Åb¦�Ë=7^o"ú””•óÖâo â•ÇþÑiu ¼µøÜœ�øì¥zÔÎþ 15�Å¿üÆ#wü…)£GuZ>.!‘å6ñÔßîa̰¡ü¼qÉé,ÿ�«ú�¿a÷?Á/¿ˆ‹£c7Ç„=G�±a÷^y쟄öws(,-åµ/¾ÀÑÞž/^~qÀ‰'ý¸';{ÞÿÏ3ýRN~o-þ†›æÌâ–kæöKjµš·CtdÏþý¾þnN—XùÇVNžMá»·^’t_fªjjxkñ7L?Žo½¹ÛÇ×·<—ÇÆ“÷Þ ÀáS§ùuóžýû}DGFôB«&ÛbûÁüûÌSF±´7Ù¼o?{ŽÆñásÏâãáÀ[‹¿%, €Wÿg¯×/ ðg@&–Є}×íôÿTè� µR5zII>ËÒà|fƒ Â××q;k=®n®Ìœ=¯>øþ¸ÒÂ^$d�?c‡3ÙVUSË‘Ó KL¢º¶Ž—}¤K^OV2¶ E»ßÕ„½­-·_;ßd[£Rɺ�»±·µåÚéSMöEo¹fš61Ð×îØ…R¥âÖysÍÄ£Ö‚œ•\ÎMsf™ìWªÔœIO'=;‡O–-çù‡`Øà°ný–Úúz^þô ŠËË `Vì¼Ýݨ­«'§°�³ëÈQU*»ëŽn�ûψLÚ<¤W±8ÐyâÍwÐëu|úâ�Ûä-óÓ@á�<-|um-gÒÒ1$¼¿›e‚­µ¢ßB:H$blŠKòF]ùÇ6ÖîØ9`„áî°çh߬\Í£wÝÁĘèæ�"¶ Öry7¯ËX[Ya«P\ÒçæÝÏ>O Ÿ/¯=þèel™€%ä2ió\ù'{†ÉÀ;¬­�kk!Ä“€€€€€€€ÀåB„)bšÂöPë´Ô4©qB�^içRùåçŸ9›œŒ¿¿?ãÇ�o÷›J.—3jìbF�þÓ}w ôA ìEy{sÝŒéfÛo˜5ƒÿ¼ÿ©çÏ“–�Ý¥��w]¿�»®_ØK-XØ*f×­²¦†u;wcgccñšÌŸ:ÅlÛæ½ûQªTÌŸ:Õfª5M ´,^Y:ï­óæòýšµì8t˜õ»vw[ üú×U——3gR,÷Þxƒ‰ÉŒqcyþÃ�9|ò3Ç�#24¤[çÿ³1gR,s&Åöw3Ú°hîlÍ�ÝßÍ0rèÄ)D" §Oeý®=>uzÀ „¿ð\¿ÖÄ’7_ë×6 4VVWÜ5yì¯wöwºÁ¼)“™7er7£Ï¹sáµÜ¹ðÚ~m÷¯¿Ú¯õ \�ÈÄb4z��‹°jšT($R!ÔhHI>Ëï«W“˜�€ŸŸãÆ�CÚŽ‘³H$bHd$±“&bÛÍ´^v�°p°³cdÄö‹§°´¬]�0'¿=z}};<_}c# ‹‰ ÀǧÓü.U55ä—”bocÓ¥POUµµ–”"—Éðñô@Ñùc ¦MaÇ¡Ãdääv븬 yœHNÆßË‹»o¸Þ¢§¨£½=×ÏœÁ�k×±?þø€«jk)(.ÁJ.'Èï¢Û¾JÝDv~¾žžØÙX“«×ëÉ+*¢ª¶{[y{wê=PYScìk�~¾—ì)¨ÓéÈ)( Q©ÂÍÙ �˘k­º¶–‚’RœðtsíÔ¸¢ºš¢Ò2lmøxx´ë½ÕÐØHn˘äãsIc®¾±‘¼¢"¤i‡ãW§Ó‘_\LUmv6 Ü]\Ú½·½Eqy9e•ØÛÚâãéÑ£ûŸ‘“KQYó¼º`ÚT6íÙG|Òî¿ù&ä2s#�Ö””—SRQ�ƒ­þÞ^ÞO�NGv~�*Þnn¸8uöUÝÔdÌù×QˆX­NGiyåÕÕ8ÚÛáíæÖ¡Õ[iE%%ØuÒ§zBëþêëéÙíû¡T©È)(ôx»»w˜{W§Óq>/uSþÞ^]ê{uõ äìïgÒ¯kêêÈ/.ÀÉÁ/7·Ë’ŸÔð,‰Døyzv)DxIy9¥•8:ØããîÞîXVYEiE k+<ÝÜ.ÛsÖ0*Uj<Ý\»ž¸y/¦ª¶»–w†žx ×54�WTŒ^¯ÇÛÝ­K9g»Óÿ 9îœ:¼Î‘W\Leu ®NŽøxtžs°¬²ŠâòrlÖøxxt:¿\NŒã^¡ÀdzóqßÐØHv~ƒ|¼{e^/­¨¤¤¼ÜØGº;Þ”J.�?óš4rò hÒhðtuítîí*�J%¹…EˆÅ"|==; Ùkè› k+¼ÝÝû$¯¢€€€€€€@s�FA‡Xˆ!‰i⌶‹(W+ñ´²¹,ߥW;z½žãññü¾z5¹Ù9øúú2a„v¿ñD"¡aaŒŸ‹s;¹:Fû C®<çV‹d+6nfãž½|ùÊKüøû:Ž%&‹ÿx˜¥k׳õÀA“„Z�ŽŸ7lbÇ¡ÃÆÐšVr9·Î³œOJ£ÕòãïëØw �®ùáÀ·.âÙ÷?báôiܱp�±|um-KVÿΉV¹ó¬ärfOœÀ_æÏûÓ¸j[µ„›Óh4èõú.?Ð÷?ÀÜÉ;\P?r8ª&u‡‹ç}Afî^üøSî[t#Eeål;pmK?ñtuå¹ÿNFn.߯YK£R -ž—wßp3'Œ79×ɳ)ü°f-e••ÆmvvÜyݵs j4¾úå7?aìkv66ܱpÓÇ�5–Ûvà?®]g’ƒ°=ö‹ç—M[¨©»˜Èöú[x¹¹ñ·E7ðö×K(«¬ä¾E72{b,�¼üv66&9�&$²bã&J+.Žgîº~!±Ñ#MÚžW\Ì’•«I=ŸmÜæ`gË ³fvÉC)õ|6¯|ö…IÂÕÛ¶³fÛþ÷ßÿ°~×nö;n�nÎNøÏ¿qwqæžÿü×,aM]ß®\Í‰ä³ÆóŠÅbb£GrߢMBÅ~µâW�<Åׯ¿Â—?ÿÂÉV}ÊÏÓ“Çï¾Ë$�pÖ…<~ü}é99ÆmVr9×NŸÊ¢9³;}Þ|ôÃRâ“Î˜ä ¼÷¹ˆ‰Œ`ÞÔÉ|þÓ “~:~Äp¹ã/ȤR6îÙËŠ�›HÏÎáö§þÍœI±üí¦8�rŽÖ¬5OK»ýÚL;¦ÃvP©Õüøû:?a|žˆD"F ç�[o6,WoÝΚí;øø…çX·sW»ý¯5)™Y|·z�Q¦%/ñí ˜�÷ö8Ÿ—ÇW¿¬4 T´<+n�w�ÅòE¥e|»j5g32�Û Ñ ®�>µÃ{¶yï>–oØdÌAøÌ»ïSPRÊ'/<�»‹³IÙÂÒRžzû=|}xçé'¡EývåjÎe�7–³·µåúY3XÐ*Š‚N§ãÎgþÃŒñã˜4*†�~XJ]CÏ=øwÊ*+Y²j w,\ÀÂéÓLêTªT<üòk8ØÙòÉ Ï·û[VmÝÆïÛwòÞ¿ŸfåÖm&®1 Ïnz¡¤dft–ÔÑ޾ݰ©ýÁæ½ûÑéuÜË"ìmmÙ~ðIié¼¹økªjj™7u2ÁÁdäæ²nçn–®]Ϩ¡Q8ÙÛ�˜šÆ‡ßÿˆL*åºÓ ðñ¦ ´”?ödñ/¿!‰˜4*ƤÎ5ÛwPßÐÈu3¦áíáAv^>;á›ßV!“JÍÊwÆ®#GY²j vvÜ|Í\�HËÉaoÜ1^ÿr1¯>öÏ.y϶E§Óñá÷?’�ŸÏèaC‰X"&=;‡]GŽò¿—±øÕÿ3ó$Y¶nEeeÜ·èF\9‘|–]GŽòñ²Ÿøâÿ^4–_¶nÛÂÙÁ�¹“'âäà@BÊ9~Ù´¥Û^»ŽÆ‘ž�Ã-×ÌÅÏË“ÌÜ ¬ßµ›ïVÿNXP ~žžÐr¿¾ùmö¶¶Ü|ͼÝܨ®«cO\<ûâ�ãìèÀmóçèëËí×Îgãî½èõz®›9oww³zE"±Ñ#Ù¼o?ñgΘ¼´Ê8­e{ai)¯±¥ZÍÌ ã DYe%Ûfɪ5¨Ôj‹áƒ-¡Õé8r*™TÊø‘#?r‰©i>yª]Áà“¥Ë©¬©áÖù×àâèÈ™´t�<ÅÛ_/á�'7^+€ÒÊ*Þýæ;ü¼¼X8cZ�Ž}Çâ9‘œÌ»ßÖðêã�š(Õ*Þ_òz½žñ#†ãçåe± )™Y|ºl9ö¶6Ü:o.î..•–±yß~>_¾‚AÞÞøy5·£¬²ŠW?ÿ’ú†F¦�CxPÕÕì8t˜eë6РT²hNÏC¾.]»žâ²rî¿ù&“þúɲŸøüÿ^D&•rûµóILM#9=ƒÙ±psq6�,þu%Oœ$Àׇ)£G!—ɈO:ñÄ$òŠŠxë© /ò‹Kxç›%ˆÅbÍ��·»;É™üðû:¼Ý-/ø––²íà!Ü]œ ôõÅÑÞ½^ÏG?,%3÷1Q‘Œ…D"!37—�‡�òñÒŸøò•—PXYqÝÌœIKçXb±Ñ# ðõÁ±�&�†7¾ú† …… Ì„è‘hµZöÇŸàà‰“”WUñÒ?62€O—-§A©ä‘ÛoÃF¡`üqâ“ÎðùÏ+x÷™§ %Gí[‹¿A©V3oÊd‚ýý¨©«ã�ýY³mnÎÎ]âÚRÖÒOÇ�NtÄ”JÖîÜÅ‘Ó øzz²hîl"CB¸ýÚù¬Ü²gGfOŒ%ȯ9bArF&|÷R©„…Ó§èëCaY[÷äë_W"‰˜:ft‡mÐëõ|ôã2Ï¥2ÈŸéãÆ"•J9z:�Ó)çxã˯yûé‹}ÁÀ�¿¯3éÇÏ$³ûhœIÿHÏÉáí¯¿…–Ð��¾>””°íàa>]¶Ý�·kÈ/Ù%åå¼ñå×4(•Œ1œ‘C¨©«cÛÁC|öÓÏfå«jkyõó/©®«còèQD„S][ËÎÃGY±i3u f9”;"6:šU[·—�h–_ùðÉÓLÛúa)v¶ŒŽ«³A~~ü°f-q ‰fáñ3ɨÔj&�šÒ¥÷�/~þ…òê*Í��›³3)™ì?~‚w¿YÂkO<ÚiŒñ'È-,dÊèQD„†PVYɶ‡X½u;è11ŽYùÇVÖíÜ�‡«+7Ìš�½� ‰ié=�À+Ÿ}ÉÛOÿËÄ讫9�À¹¬óÜ0k>>ä°vÇ.~Z¿‘Á��„ ò‡/Ì×>ÿŠ’Š Æ�ΈðÁÔ64°çè1~ß¾“ªšZ¸õæn×/ ÐÂ�ˆr‰µV ;¢N£ÆJ,ÁFÚw‘G:z½ž”ä³lߺ•Ç�ãîîNP` ÖDópww'bhC"#;,' Ðu�°INÏà“Vâ›Z£¡¢ªšœ‚}}xì¯wZ ™•p.•wŸy ¯vGioÀœ IDAT ?ºûhŽöö¼ùäã8¶ˆ3ÓÆŽ1Zj·-¿÷X<áA�<ÿÐß�®Ù¢Gðä[ïš�óÞ}ä³`ÚTîºîbÞ˜9“byå³/8r:�™Æ[8-ñÛ[±ï@à¨ohèÒyz ­NÇù¼<“m JgÒÒØ¼w?³b»çÉTZQ‰D,Æý ³d©©«ãƒçþmôìˆ á‘W^£¬²Š»o¸ÎhQAIyOœ$· §p{ôz=ËÖ­G§Óñô}÷0lð`ãyGEEñÂÿ>áç ›˜0r„‰jEU5/þãa£ÇÁÔ1£:8”¾û‘73!zd—ÃÚ546²|Ã&ìlmxç™'� †ÓÆ�aHp_­ø•å6ñ߇èöµ),-#;?Ÿ¨°PžþÛ=ÆíSF�B§Ó±ûh¹…„ 29.ëB>÷o£ÈAEu5§Î¦p¡°ˆ�Aþä—°ýÐaìlmxó©'Œíž:f4n.Îlܽ·[mMLMãí§þeBGF A«Õ²vç.R³ÎE¯#§›¡ÿyçí&yú&�ÅC/¿jôTñóòÄÏË“]GâÐëuŠÚ±1ÑlÞ·Ÿ¸„D�P¯×sôt6ÖÖŒ> €ŸÖo¤A©ä‰{þj"àM7–gßû�•lcÒ¨˜.yØ&¥¦QSWÇØáðU(3l(ß­þ�Ó)çhhlĦe{k KKyïÙ§ñl A;uÌhü¼<ùuóü¶ùž¾ï^cÙ’òrƒ‚xéŸûäÌ ãyý‹Å¤egsèä)/ÙŒœ\¢##xòÞ»; xøÔiôz=Ýv+1Q‘ÆíVr9+6mæLzºQ \±quõ ŒÝóWãy§�Ë_.&%3‹¸Ä$“¾«Ó騮«ãõ'3Þó˜ÈþóþGäR×Ѐ�� §RÎQߨÈõ3gð—óŒÇûûóêç_’”šÖc�0='‡gÍäÖù½à‚üüxöý9›™É"f2ÈŸ�Aþ¬ÞºgGG“1½tíz´:ÏÜó7FF 1n;l(Ïø1?oØDlôÈûôɳgI<—JhÀ ^yôÆ9ê˜Ñ|¾|‡NžbûÁÃ,œa*RYêåUU&ýàûÕkÑhµ¼ô�‡‰ 6Í ÿû„eë72fø°ÛøÛ–­4(•fýiòèQ<ûÞ‡ÊÿAUm-÷Þts'M4nŸ5aÿùà#6ïÝÇ´qc,OXbâ¨hVoÛN\¢�ðÔidR)G5÷Ï_6o¡¦®ž¿ß²Èijzæ„ñ<ûþGlص‡icÇšx"žN9Ç¬Ø Ü·èFÁ/24”3éé”UVâæ|±¼Á€còè®åäóÎ3OáëÙ’uê˜Ñøú°lÝ~Ù´…çêø9›SPÀ_Ìãú™3.^“˜hžûà#ÖíÜŬØñ8ÚÛSXZÊú]{ðvwçÍ'7zíN?//VmÝÆª­Ûy°]¹T^~ô£·øÈˆ!HÄ~Þ¸‰³™™F�píÎ]”TTpó5sL 0fOŒåÅÿ}ž¸cÌ?ÎX^@@@@@@àò#!亠È� iA/DTèˆ µ¹X‚´)®&JŠ‹Ù»gûöìE«ÑàååÅèÑ£Û5Rtptdpx8aáá¸{tí[K@@ ëü¹g¤^¦¤¢‚£ ‰Æ'“Ï’�Ÿ�^¯§¢ºÆ$½^�R¥B.—_q!§FD 1 û¦°¶ÆÍÙ‘Hdôú2`Ÿ a¹òŠŠÈ/.!<(ÈDòóeôÐ(ªjkM£Ñ"BFµ 7**ŠÁ��TÖÔ�Ñ*ü^gK<ƒR¥bÁÔ)fÞSF�ÂÇÃ�³Æ6w'{þûðƒüý–E÷4ª”fûfOœ`æhXD4”?š�€^¯gÞäÉfí¾nÆt3/›Îˆ‰Œ0ó’ h©Syñ·_3iÿ}øA3oe[2©”Æ\§`?¼ÝÝ9“žA}c£qûÙŒL*kjˆ�‰F.“Q[_Ïé”s„ ò7óîsurbfìxTj5gÒ3ºT¯aq»µÇ©�� ÇѤÑp,ñŒÅã&�e ÌŸ:'{{N�M1†¾3pÓœY&‚µT"á–ys8z:Áìüw,\ÐiŽ0C¸º¶×{þÔÉ,yó5f´„…U©ÕK:ƒŸ—§‰8H‹7òÜI“hÒhHJMí°¾Ž˜=1ÖLH Ô,z·î;–8pü·ÍŸgb#‰¸­%üõɳg�¿õÔÙü½½MÄi€k¦Ln7Œµ�BÁÍmžMö66ü÷áyè¶[Íæ\ƒwsë°„]%.!À,| D,æÆÙÍ"gœ…{~í´©&÷\,ÔÚØ8÷´Üó¶sQxP KÞ|�¿_‚7’D,6Þü½½°’Ë:½‡%%\(l6th-6ŸÃ›±Ã‡Q[_ßî;Œ�£§›¯Ý�³g™ÝË›Z§Æ%&šוþw¡°ˆìü|F �2iy.�9‚êÚÚsk´ZŽŸIÆF¡0¨h íÛÖ(H«ÕräÔi<\\˜31ÖdŸ�­ ó§MA«Óq:å\‡×¥5ž®®„ ò'3÷‚1ì--a— JJ=4 ;ÔMMÄ%$âíînœ 8ØÙ1oÊ$4Z- çLë–J$Üqí|³11aäpôz=q-h1JJM#,0 ËglôH£8h`ÎÄXÜœ�HJK§®ã/;®™lÆÓËÍ�éãÆï-s»^¯ç¦9³LBú\7s: kkN&ŸíR›Û2$8È,¸áùÜzl8~;[nhÓW ^Éz½žS)]/è>WÖÊÊŸ ‘ä¢?G¢KA�žru£IJŒ? ���ìݽ›—_x‘ÿ<ý §ŽŸ $8˜ÈÈH\\\L¿·%ü˜é 2©G{;�àoN§œãð©Sf¢`&0ȤRr,1‰ …EF¯P‘HÄ�`ó¾‚D,&§ Àd»T"Á§ ‹íc‡c÷Ñ8¾Zñ+ÇÏ$Axp..FoH€Ü‚B´Z-ö¶¶¯YC‹ [XVÖi�íaðTl�Hܵ9,ëB"‘ˆÒŠ *kjÌö[[YQXÚܶ܂B´:!r‚:ÙÛãêäDIy¹Ù>w3ï{“±ÙDQY)ÅeåœÏËcçá£]j»%r ±Q(Lò0ÌI†y¯5–®aÛùrèà0l¬­Ùvð¹……Œ1œˆà`ü½½LîyOpuv6Rh™Ë;Ãð{Ú›sÃ8r:� …Ef®éyšsú 2øx¸ã`gGnA¡Ù¾®ô?Ãø–I$Ç�®åƒ»¨´Ôâø¦%— º©‰ð  ‹×*"$„u;wÿÎ/.A¥nÂÁÞÎDX3PW_ß|Þn޽Øè‘däär,1ÑNÙ^Ô`”s¡°ˆ&�;[‹u×74SkÜ\,÷ƒ1Æñ]K˜QCîÂ#§ÐêtL¶�'¸=,=»% C‚ƒ�ÑÚæ�lM°¿VróKDZõÀArZú‡!?mm}½ÅûmocCIE J¥1gW±4¶Û>Ÿ+ªª©®­ÅÏË“øѲ5ß‹{>ï tŽH1: ‘‰$4‰´è/ÂQÿIòêõz òóILHàø±x pttÄÅÅ…èè‹© ¤R)NŽøùùãë›N-N }ƒ ö"2©Ôâ"_ThOÞûWžzû=ÖíÚm&v%‡ŠÁÂ’˜àâäh"¬¸íÍÃô<Ÿ šJÊËMB¤¶¥;X +«<û{â·’Ë»•7¨+¸:9‘W\LqYy§¡ëwí¦¡QÉÂÓº�kîrs)w¢¡¥_:µÓ‡][ú«¡�h¯Ï»:5—ïÌã¥5†pµkwîê°\O<ãh G¶fû2rr�V_Nöö¸::Å™¶Ø*:ñ»8¦-‡…tqr„®ëƒ(ºP'-ãxõ¶8~‚šº:hYà öóëÔë­#&ÆD³fÛâ™:f4­–c‰Iø{y�êZúAbj‰©iížK¥ê\¨ŒO:c4_þô ‹e’32©ª©1ëŸí÷?§–v^ô‚±µQ˜å˜¤Eüqrp ®¡Þd»ƒ��Ùb³%†‡æ™ûîeåÛ8z:Áè‰èçåÉœ‰±ÌŠ�€H$2¶%%3˘çÔÊnŒ™¶\Š8Uߨl ùÅÏ¿´[Æp= ×ÊÒs‰–qeI tjã5o )-�5Û¶“–�c›Žöö8;:vêÉd ½^O£Je’ƒ²5Vr¶ …‰—¬K¡lÛââèÈÿ=ú+6næLz†ñ~:;80yô(nš3«ÛžÃº2ç´GcËïqv°|� óxg×´¡Q‰•\Öîµpqt º¶•ºÉD$êJÿ3Ô}ätG,xpKó¼aÎ5#9#“òª*Üœ�{Üþž`ø=¹íÎã&åÚ=�‡ùÅ%ä`²¯®¾�Šªj<Ý\»äaÛ^ë-Žƒóyù——3|p˜…£›ñtsC"“]P`Qôo…ÀËÝ ‘HÔîØ+)/'+/Ÿ` !s;#6f$IiiKJ¢ ¤‘HÄÔ±Ã!{·äÚ¨n§î¢²2²ó Œ¹»ÂèaQ,Y-!.!‘†F%z½ž)rRwDv^¾IÎW™¹æs§%r Ñétf}ÀRÔð<÷vw'9=�Vcñ÷ŸHNF"–XÜw9puvB&•RQ]c±ŽŠªjÒrräí�S÷m�ºˆ |D"r±Õ%®%üÐéõ”ªð´¶E,xÑ ô#W—™Â‚V«eù†Mhu:F ïÑâXtT$÷ì5&v>bò,&2€]GLs1i´Zö‹7Ù&‰ˆ‰Œ ¤¢‚8 ¹^VlÚÂ'K²˜_êj§¬²Š’òò.åf  bø�pJÊËùjů4i4feJ**XùÇVfOŒ½ìí­kh ¤¼¼Ïò;ûûádoÏÉä³fyãªkk9püVr9QmrÅŸI¦¸M(Áœ‚ŽŸIÆÙÁ��n,zŽj[ö0¸òŠ‹y÷›%l?xÈ(†Õ76RR^NU®‘!¬nÛB­VËé”s]n£%b¢šÇèæ½ûÍúÊÞ¸cÔÔ]~B¥ºù÷¸»˜ÇwONÏèqVFŽ@$±?þ'Î$‰ƒ�­q¿·»;Þîîœ8“lÖ_4Z-Ÿÿ´‚Ï—¯@Ò‰pnÈ�5zhT»^M£‡F!“JÉ̽`–#l×Ñ£Æpƒâ“È/.!ÈÏ×LÚ¼w¿Éßz½žµÛwi*Nu•å6òÆW_SÕf^uqrD&•¡ié.NŽúú’˜šf–ïP«ÓñÕ/¿ñé²å-ù9úžói÷^³}û�ÅóÉÒŸHÎÈ€–qäçåɹ¬ó䛊„ŽŸèVÌvǦNÇ©³æ¹t»Jtäôz=[ö0Û·iï>b"#{tî=qÇxkñ7fáu­ä2œ›ûœ¥çFoà냋£#§SÎ‘×Æ€¨¶¾ž}ñÇ‘ËdfFm‰ij·´/æg�®Ç×npP v¶68~ÂlînT©øàûønÕ£7¤%ä2QƒÃ¨¨ªæ@ü “}*uÛ2ÙfgcÃàÀ@Îe�'#'×dŸ^¯ç›•«ùdéO=2¬;|2©”çN—�@dh­ŒXœìí äORZ:çÛÔèt:ÿ²’O—-ïVÌ-;† #õ|6»�Æá`gËÈ!Ý›¿ö‹7{ßuwg&�Ý’{l;*u´ Euãì™KLbñ¯¿QZQItTMMv�cüqÂÌDž?¯~þ%e••>8y.‡N�FßîÝPTR‚#1g°jÛv‚€¼‚BL&Ǽ`€b£ …ÅÅÐët•ZÚUˆ^Ñ�qöÂÀøáܶ ‚€93¦áã…?âõϾÄý·Í@—ÈHäaÕ¶HJKÔ1£àïëëæJí?^Ú^tô �n÷ñöòÂÀ^=q$æ œ8…»§Lrl+6ñæ—_cÎôi * FÊÛÅÞ;Í¹í¯ Øwü|¼½0~ø0Ȳ‚�{öâÀÉS ¨sÐß«K4.¥\Å{ß.À½Ó¦¢]ëÖ(,)Æú]{Pb4bˆáŽ}ï›1 ï~óÞúòÌ�9Ý£:¡¨Ä€µ¿îÄ…+I¸eè�Ò9+XùWFzv6"Û‡ÁK§Ã-C†`Óî½Ø²o?dYÆøàÕjqê\<–oÚŒ?_§ÇrÿÌxïÛø`Á÷xøÎYm‚sñÓº ð÷õE±ÁP£Ñt鉣gÎbñº ˜7ë6øyûàrj*VlÚ‚‹É¥aNJF–ýXFJo3#;f‹¥RkØrÓÆŽÁŽƒ‡±~×nh4Œ8²"cÇÁÃØwüÚ†„àÖáC][�^ÑÑØ²w?¾ÿe5L ¢;t€ÝnǾ'p&á<:„…¡]ëª+°êƒ (,*.m‘«Ñ@¯Óáî)“ðÍŠŸñî×ßbÞ·£Sx{¤gç`ñú 0™Í¸sÒøùVÝŠrôàAX¿k7�Ž�1yÔHH ‡NžÆš¿Â×Û·�W§1k5Ü=y~X½¯}ú9î›1‘íÃ�•›‡›· ¯ ÷͘VmUþ=S&áLÂy,øeŠ� ìÕEÅ%X²a#ŠJJ*ý8aÎô©xýó/ñÎ7ßbîŒéè ƒÉˆõ;w#öÂE ï߯N¥>>ŸPÖBøzs¦OÃÛ_}ƒ·¿úsgÎ@�ÎQ(6°î×]H¸|cªuµéˆýq*.¹˜:vtµ?ƸžÉlÆŸ‰ûfLG›àV8sþ¯Û¸øìtE|½|% F#zu邬Ü\ü´v= ŠŠ0b@t ÊZ{öè…cgcñßaú¸±ðóñEÂ¥KøiÝè´ZL7¶Úû»³§MÆ©øx|üÃ"Ü;m ú÷è³ÅŠ-ûöãHÌôéÚÕ1¿®¢(xê�·ïýí¯nç1%""¢ÚaæÔ|è FÁÎ9#kÀ,ÛQh³ Hç™iBˆˆ6 ¬¼Ÿ/YæÔ³}Û¶øëoÅg?-Åâõ°xýǶžÑ�ñç‡çÕ©òñfèï�מz_¯ø'bÏá‡Õk�¶k5̼uœÛùŒŠ rQÅYnäÀõ>æ5yôHJ°zû¯øðûëAÀô[Æâîök£ IDATÉ“*óÛ{ïÁŠM[ðÁ‚…Žu’(âÞiS1¾B˜PSóçÜ YQpèÔiG8>ÞÞ˜ïlôïѽN�mƸq8Ÿ€#1gr``¯žxúчñÁ‚…øqÍ:dääV9O‘;O>ô Þÿî{Ä%^ŸåX?¬__tîÐK7l¬Ó¸Ý‘$ ¿¿ï^|ºx)>þa‘c½�—¹ëNœˆ=‡cgc1ÿ¥—ñá‹ChH¢"Âqèt þðò«5 žG ˆØ‹‰ D¿î•ƒÓýû¡ð®;±xý|úÓÇzApë°¡xxÖUÞ~FN.&'#ÀÏ}«h'ˆ²ŠÆ#1gpàäI§€ð¡Y·cíŽ�xó‹kç\«Ñà7÷Üå”ʵ ÀÔ±£±|ãflÝwÀ±¾mp0žyìG�Z[wOž„‹IÉ¥sé}ý­c½ Ö¯/æÝq›cÝ€ž=0Îlü°z-¾\ºÜißQàw÷ÞS§1ÔVTY›Õ/—.Ç—K—cá;oB¯Óâ…ßÿ,XˆmbÛ�ƒŽýÛµn�'œëÔztPï^xä®YX´v=ÞþêÇãxhÖí8xê4JŒÆµ^ž~ËXœŠ‹ÇñØX��u¬ï×£;þú›GñÞ· °dýFdæäbþœÙèA°rËV¬Ü²ÿùÛsh\9\ ô÷Ç �ÿ-ü«¶mǪmÛÛ:„…á™G®õœså†öíƒÉ£FbÛ�ƒø|ñR§m�;DàOóh”ù£"‘pù æ¿ô2¦Œ…Çî¾ GŽ@QI ~Þº½ÒçøÔ1£kþè´Zümþoñþ‚…Øqð�SƒÖ­Zᩇç¡Uæ‚-7mìŒ&¬Þáü]#IfŽ»³&N¨ö6ºuê„'˜‹oWþ‚Åë68Â-oo<ýØ#N·‹² ÿ‰æbÁÏ«ðÍŠŸ�¶ íÛ¼ßý¼ÍÕ5pŽÄœ��··Ë õëÞ ¿Ÿ; Y�¯–­p¬#ú÷Ãü9³k}ŸCûôÆ7’»,ã–!Cj}ü·ÍĦ={ï]ÐHšu;†÷ïWíñcB‘Á€oWþâ´~`¯žøý}÷:–AÀ3�=‚�þˆC§cœþ. ÀÌ­s{çšêާ}_,]^éo¹~Ý»áɇlôùB‰ˆˆn6¬ l>DA€V`ce\�ۭЈ"ü4uûK"¢!¨¬cnÖTUŹÄK¸œr*TtëÔÝ£¢Üî/Ë2Nœ‹Cjf&Úchß>8t:Ÿ/^Šùsf;Uv l®ÄÓñ HËÊ‚¯�"ÃÂнšùÀZºÏ/ňým[k*#' —¯ ¯°QB›�`ôŠŽvj¹ØNÇ'`ϱãxrÞ z?×ËÉ/À™„””ÀßÇ}»wChíÆ¬6Nœ‹Czv6ü¼½Ñ¿g§ku‘œ–ޏÄK0[­m‚>]º¸¬z‰K¼„�»÷àÙß|}1°WÏJsè=õÆÛ°Ûíøôå—�‘“ƒÓñ 0šÍˆ EÿÝë4ŸlEªªâBRR32QXR‚àÀ@DGF"<Ôõy(6Õ?�~~èÒ1ÒQqÓXbÎ#1%>^^˜4j¤£ÚWQœKLDRjiÔðÐPôíÖÕm…Rfn.NÄžƒ,+èߣ:„…9�ïšPUg/\@Rjé{³{T”£¢çä¹8$§g gtºu*ý»p% ç/A§Õ`ÜÐ!•*›+²Ùí8“p)™�DÃÃЫK—ZU7»“–•…ÄääÂÏÇíB«ü¯o%#ž:£ÙŒ�;;}Çç &á< Š‹-)k[Õ¨( b/&"9- vYF‡víз{7h5õóµœüÄ$$ ¨¤!AAèÞ9ªÖßE%%8q.ùEEò÷Ç�>½«¬\.1q:>ÙyyðóCt‡èÞ°•ã¾ FœŠ�GN~>ýü‰Èöu{ß›Ìfüñ߯¢M«`¼÷·¿Öø¸›·à—­ÛñÒ@t‡œˆ=‡Ì¼<øúb@Ï ªòø”ô <ÿÞû˜ô ôºk#ؘF€[‡õô0š´!}ú`ô �žÑMéÁÛgâJj*~\³+6o…—N‡‚âb‚€;&Œ¯TñND7îýï -+0{ꔚ ’ˆˆˆ¨±pÂæI'I0ÙYEXS6EA¶Åˆ¶z^§%¢FÁ€ð&rËÐ!èÓµ+ÇÄ 37:�Q"0¤Oïz› ¨¥kNá šáx=�çèæÕ%2GŽ@ ¿�ö5°?dEiðqÝL¼½¼ðÚSOâä¹8$¦¤ ÄhDë VØ»'"BC==<¢iƸ[�’‘�~ݺaPï^µ>¾s‡L9¢ÎÁ¢Ÿ¯&Ž�î�o®M"""jþD†%Í’ZQ�MaU\MY¹VBtÞ ‰¨ÁqB"""""""""j²ìª‚‹ÑÓà:PUÀ¨ØÀ+еã«Ñ"XçíéaQ Ç9‰ˆˆˆˆˆˆˆˆ¨ÉÙb´Ù@/JžF³c°ÛPh³xzDÔÂ1 $""""""""¢&‹-F›7� B#ò9¬­"›Å6«§‡AD-B"""""""""jÒ6ozAf„µW`3Ãh·yzDÔB1 $""""""""¢&�mF›7¶­»\«‰!!5„DDDDDDDDDÔ¤±‚°ù“>�uÂ��ˆB"""""""""jÒ$„-‚—¤…È^£uÂ��ˆêB"""""""""jÒD�—1[ ŸË:ËcHHDõDUU„DDDDDDDDDÔ´±‚°åÐ"´/K×…ÊJB"ªªª"×jb@HDDDDDDDDDM–E/ˆœWò0$$¢º*M²�!5mÛR¶0¼DTO¤Ùʳš``HHDµ ¨*²-¥á 8!5u"XmÖÒˆ‚½ ñô0š-µ,$,¶Y<="jdUA¶Å‹bw¬c@HDDDDDDDDDMš(¶8ZQ„†­FoH�Í‚«¹,2$"ªÌ®(È2aUd§õ ‰ˆˆˆˆˆˆˆˆ¨Éã<„-“^”À§öÆۭȵ˜¡ª ‰È™M‘‘e1À®*•¶1 $""""""""¢&O#òRfK$¼E CÂd”mȵš‘ƒE¶#Ëb„ìæs�ߪDDDDDDDDDÔ䱂°å:Qòô0š=SY 0$$ºé™d²«ù<`@HDDDDDDDDDMž$ðRfK¦DhY%zìåí•Êí‰èæPl³ ÇbªvfR~âQ“§a@ØâéE ‘•¢7ʦ(È´`–íž 5"UU‘g5¡Àf©ÑþüV%""""""""¢&�-FozQ f„7NQUd[Œ(ªaP@DÍ[ù{Þ`·Õø„DDDDDDDDDÔäI‚æF-Ÿ^¢|²ëG¡Í‚|« *ç%$j±lŠŒL³E®Õq ‰ˆˆˆˆˆˆˆˆ¨Yà<„7Qà-J ëI‰Ý†l‹²Êy ‰Z“lG–Å{ÞßüF%""""""""¢fA#òræÍBDèÉÓÃh1,ŠŒ,³¶ZVQÓUd³ Çb„RÇ a~£Q³ aáME'ŠÐpBÂzcWdš 0Ø­ž Ý€òù opŽQM½�ˆˆˆˆˆˆˆˆˆˆ¨1 ¼ùèE TØ!+œC¯>¨ò¬fX­´^°D͉U‘‘k1Õ©¥èõø�JDDDDDDDDDÍ[ŒÞ|Þ‚"ƒ¬ze°Û�i1°å(Q3Rl³"Ël¨—p ‰ˆˆˆˆˆˆˆˆ¨¹Ð"Ý„À[’À|¸~Ù¶%jÊ[ŠØÌ¨ÏZj~¤Q³Á*›“^‚NIX¿Ê[ŽæYMPU¶q%jj¬ŠŒL³fÙ^ï·-¨|×Q3Qh³À$Û<= ò*LŠõÔa�*Њ"‚uÞЉ’§‡BtÓSUÅv+ m–»„DDDDDDDDDÔleŠð‚)5}²ªÂ¬Ø ª,'lþµzœ÷‘È#lŠŒ<«Öž#”!5VEFžÕäéa�‡ÙUfÅŽz��‹XMHä%v+ ¬¨�ðáÆ€°|{ì0Âü0£{ϽŸ´¢"|uô ö\¾„|“mýüѯ]æ�n­ÛTÚÿ¹Më�”Ÿïöö¾¾{½¼§ÓÓðÆÎí€ÇO€°öŽý>=´»/%âÖÎÑxbÄhÇúo�Æ–ó noÿÙ±ã0¼CGÇòŸ×­BFqq¥ý|õ:Œë�‡†ä¦Çü®KñÙ¡€ß Ž)]»;¶©î[üƒÛq@϶¡xeÒTÀ©ôT¼¹s‡Û}¯œ®Äd¤ãõ_·Þœ:]BZ;¶ýeýj¤al§(<9j¬c½ÅnÇ¢SDZ..É…�ÝÛ´Áý¹|í¬Š=ƒ%§O¢[›6x}òtÇz“͆GV,¼3m&¢‚CÛþ¸z%r�ÆJ·å§×ãÖÎÑxpÀ`HµüeÐÂG±!>ðú”é._k²ªâþ%?VZäí�a0oàxi4Žõ ŽÁ¦„x§}µ’ˆŽ­‚ñÈÀ!èÞ¦­c}¡ÙŒù¿,w9¶`ÌîÓ“ºtuZÿï[p.3÷öí�{ûö¿6NEÁʳ1Xyö4R á�˜Üµ;4ú c€Ç~^ ƒÅõ$ÎZIÄO÷Ís¹­¢mÏãë#‡Ð> Ýv§Ó¶¹K„¢¨øç„ÉèÛ.̱ÞÝ{×G§ÃèŽ�ðØàaNó1Hº‚�öïA ·¾¾kNµc"""""""jÊT¨È4<= j쪳"3$l €­Z]Ù5»¢ Ïj‚¥�«+ÒÔ`º+ÏžÆûvcf�^ žÉHÇã«V Èbxi4¸ZX€«…Ør>ÿ™q‡Sh‰y¹HÈÎr{›ö /DƒÕŠØ¬ À›»¶cé܇ –I©E…ˆÍÊ@·6ÎÁPFq±ãWŠ,έ .äæ ¹Àu`y$%6EÆü¡#\n_y6Æq_+ÏÆTz¬U�|tZÇ[,Uîýãt¥o»0ˆ¢€3éøîø¼9eàDÚUl»x€—’( ç¯ZŽã©WZI‚ÕnÅÉ´TœLKÅÙÌ <Ëx§ûÈ6›•­äü+YUã7Ù�'.=Ÿ›í2„€Ã)IPU� Zíã+§øéÔ ¤ÖÇŸÃ3cƹØQu{N÷']ƱԫøâÎÙŽu™%®_;§ÒÓ°!þ–ßÿ°#$”¥Êçkß•KX0{.†FD:Ö]ÉÏGlVÆ¢�ö}mç6¬8s€NÁ!°ÙíˆÏÎB|v²³ðáÌYNûÇge9Þs×»>Lt'ÏhDlVŒöÊs'œËÊ„¬((±:‡�U½w�^M†Éfß+„Ïfb³2ÐÚ×·Fc"""""""jÊÐ"위Dx‰*ÌŠÂ�°¨Ž9?íÖyCë¦xƒˆnLcV VÄ€°�<»q-²³p9?¯ÁïËb·ãé kPd1cPû¼:i*¢‚C�k4à?{wam\,^ýu+n‰ŠvªÒ*÷ôè[0§ß€Jëýtz—÷›™�UçÎàžÞýj4¾ñ�»àÍ©3*­÷Öh]îÿ÷['⎞½6YÆÛ»ÅÆ„8¬�?ç2 ,0›°/é2$Q„¢(8”’„<“ÁÞ>@Ùo[þñÏŽýŸß´{¯\ÂÜ~ñÔèÒE*¹é5ìšÿD¥õÚ”Õ žu ~÷Ë2lLˆÃS£Æ"ÔÏ‹NLˆîŠ^mCûrpާ^…ŸN‡W&MÄ记Øíøáä1|~h?ž8Šá"1.*ºŠ{­¹—'NÁ´n=V»Œ7vmÇÖ X®VáÉ´«H-*„FaWl:‡§ÇŒ«ò÷D_Ü9ýÃÚÃ.+Øt>oîÚŽ½W.!1/Ѫ`\T4Þž6�^T„g7­Ãå¼\,:u¯U¨š,·ø¾yˆ Êè'×þ‚ó9ÙXÎ) tÅ`µâ—Ø3€¯îšƒQ;v_NÄÿ­ù[/$àja"ƒ*ûö´™•ž¡~UUñ½kW|¸o7~‰=ƒuñ±N!QK£EØe„h ^"6 «"#Ól@ V?�–sÕ»¢ ßf†Y¶×`ïúÇÈ¿�¤ÀG«C¨Ÿƒß׿ ñH/.‚¯N‡ÿÞ~—£¥dˆ�/^Ÿ<³zöưˆHd¹<^¯Ñ"@ïUéÑŽ$ðÑêðñþ½(¶Ôl2h­$¹¼ýë+ßÊyUOˆ�/Æwî(vS©µõBl²Œá‘èÖ²¢Tjmêê~uškãòÕé*Ý®pÝqåÿxk]›×Ù#:t„M–±ðÄ1dc{â€S‹R“͆¥1'ÏŒ¹Óºõ€N’à¯×ãÿFŒvy Ž­ÑýÖDÅsÜÚ×·v. ·ŠÍ®Ï±;ëãÏæö½iEE8�žZå1¾:ô^öñÁƒÁG[zµô¬H+‰ŽqvoÓÓËÎEJa¡ËÛöÓëûGbXY(x}µª+Å d¥ô,¢+´„�¿Œ¾¿2ÜíߘÞ]¥×‰¿ÞuÀ^Ÿ*¾wƒ½}0©K7Çc!""""""jÉ´çE£k4‚½(² fR¡¢ÀfF¦Å‹Üx-‰Z"UUQd³ Ã\â±p¬ l8Ëxðýñ£xoïν¯£WSc:F¡•··Ó6IñæÔ™U(% Öë^„�Zcbt×JûŠ¢ˆÇ‡�ÀGû÷àóÃ*µ½tåJ~¾=vØi�·V‹úr¹¿Ùns´m,2›±åBé\tc;uv¹yH5µ[w¬VœJOæóq¸¿ÿÀjÇV»¢T7ÌêÙ§Æ­ÿ2úÜ¿ôG¬<{f» ²¢`R—®èQa½Ói0ÙlÌêÕ»ÒmÜÙ«/6��ÇÉ´«°Ê2tn‚ÕÚ¨xŽ ÍfG :6Êõ9vÅ&ËŽãnïÙ Å3ÖÄÅbcB„…×è6ÒŠŠ`.k­ÙÖׯÚý/ååBý\ï[b± Èb†ª–¶)=�|¥ôq¹yíTÔÖÏm|ý�m(Á}K~À´nÝÑ?,½Ú†âwnZÛ–Û‘xIÎÕÂ}BÛ9ͱÙ,žÇb‹J炬Éã%""""""jÎtluH×Ñ Qàœ„ ̦(Ȳà«Ñ"P«wÙ™�ˆÜ3ËvØÌ°)ž¯‚g@ØäJ'e p¬+¶Xðô†ÕNû=9r,ú‡µ¯tü®K±ëÒE§uSºvwÀ#ƒ†âç³1øéÔqÜÛ§ú6£çs²ñÁ¾ÝNëZûúº ßÚµoíÚá´nF÷žxaÜÄJû¦áDêUH¢ˆ‰ÑÝ`¶ÛðÞž�8™–ŠŒâb´ó¯{§]Q*�FDv¬q@Ø·]&véŠí/`YÌ©ÒêÁá£�öÉ6”ZûúÁËEÛÕˆÀ@ÇxòŒÆzLå^Ù±¯ìØê´îŽž½ñ×±Õ¾åö']F�Ù„ð€@ô ÃÔn=°&.[.$àoã&BrÓjà“ƒûÐÊË6EÁÉ´«PT�ƒCЭuå¹Ïd¤ã™ kW‹ ›Y:×`yUåõX¶ÈiYüeô-¸»wßj�(øpæ,ücëF$äãÇ“ÇñcYKØð€@<6x˜ÛÐym\l¥u�Úàá‡û÷àÃý{œÖMêÒ/Oœêö""""""¢–@#JÀˆœi:Q…•ígœÁnƒI¶#@£‡¿V –oUMVÚ,0”Ì4 › +ùy˜¹ðÜÒ©3>¿svµûëËæÌ7ël²ŒƒÉINû=4pˆËã§wëQ)È/ ¥\ÑIž¿e<ž\· oïù!>U‡e½Ú†bN_ç9½´î_zíÐÊ«tþÀB³ W‹ q8%Y†t jå´ïÆ„sP �ˆ,«žôF¿°ö8�ž†ÍçãñèàšÏ§w=�(â¥ñ“+�Ïßý¹qåÉ‘c±3ñ"dUŤ.ÝнBõ ÊÚ}¢,ÔU]|•–X¯µ‹Ô»˜C².ÂäUZmZ`6!µ¨““�c4 " f�ÏQ¹Ùµ;##;Á_¯GŽÁ€£W“1ÂM8v$%Ùi¹spÞŸq$¿üË,)Á– ×ÚÅjD>ÊÑvöz]BZC/•ž£ôâ"䙌Ø~ñ8Äåü›×Ø>��ó9Ù8‘vg33p é R‹ ñúÎmhíë‹Éem<+šÛo SU(—�g}kçï�ïÒ÷_‘ÅŒ”»zéÅEèZ¡M*QK#”…„6…­É™N� HÛ`6E-m;j”mh¥ó‚Ndë_¢ë©ªŠ» Ev µéü¬E6IIBD`P�«Ôz´i‹íÏãxZ*ìŠ�("ÐÛ[{ðòö-8”’äöøþaḷoÿZ�qBtWŒŠì„}W.W[ÑT«Ûÿý°Q˜]V™¨˜·ì'œJOŶ •Z=–‡TÇÓ®bäçÿÊÚgÀÆóq7Öö¼¸Ò%¤5ƒ�\��1.Z?–Kf» ñY™èÙ6Ôiû‰´Ò9ýÚùû;µ�•ÄÒ(QQ�UqYã¦ÝÆŸFŽÁ={—í¯bîÒ›™�Ïã‘AÕŸ3ƒÕŠ�—KbNbåÙ€ÑVvîããÜ„¯NžæxÌA^Þ¯"�Ö!{kécDtl\eÐ÷ÁÌYˆ.›ƒ³ÄjÅ„o>ÃÙÌtœÉHÇЈU>&“͆\£QB·ÖmŸ]QðÇÕ+q ù ö]¹ä2 Ù “º¸®¸­Ny0ª(×?�*Pö…¡]ÿëÑAÃðÐÀÁŽåßý² ““°9!]G�­Óxˆˆˆˆˆˆˆš ­ †@T™V°(vV¶5«"#Ól€ŸF‡­ŽmG‰Ê˜d; ›H;ÑŠ$Q€· ß©MPD@ ¶<ö8^›<½FûÏèÞ’ àja¾8| "ƒ�VT„c©) 2În��("£¸¸Ane_߃9FƒÓ¶ó9Ù¸�›QUc(«ÈÄff © ¿ÁÆV_:9Z¿þgï.˜í׿ƒL-*ÄwÇŽnëÑ»Òqp%?«Õ±þlYN¡¬R°:¢ \;ÇCµûÀöÄó0ÛmЈ"´~ä]Þm»x67¿ÒŠjŒÞmÛ¡wÛvÕŽ/@¯wìÛ½MÛU–óÓéì]Z‰zýkǕج L]ð¦ÿòLתq5¢ˆHGåjýÿAYÞB¶¼â±\\v&ä²€0¢ìù©Nù8³kø<5g¬V¢ªhEÞ’†�hQ‰ÝŠt“Vs“ª”"jlVEF–Ùˆ‹±É…ƒQ„�¨XAØ2t j…G÷ÇãóÃp:= Û‡ãbn¶'^€N’`¯âE¸,æ$v_¾Xiýp[ÑÁ!˜Ûo �:^åøŽ¥¦àw¿,«´~\T§ê'wÊ‹§L6»ÓúòêÁ‘ñõ]sœ¶Ý·äGœÍLǦ„8üaø¨jïë,»w+o¼7ýö:ݦ;7­XŒC)I˜½x!n�ІÁjÅÖ (0›Ð> ¿2Ìé˜ÁáðÑêPd1cÞòŸ0!º+ŒV«c>¼!à«ÓÕèþËc/“Ý^Íž¥6ÄÇeóQ>3üoÇ IDATfœc½ÅnÇØ¯þ‡"‹û“.ãV7­@‹P6¢ÙV}_çaáˆÄÕ¢B<¼b îéÝÁ>>8—™‰gO€ËêAøôÐ>,�9Qiýó·L¨¶ÕhßÐ0{û ÏdÄÃËcJ×î°Ê2ÖÅ—>�ÝZ·A;ÿ€*oÃñxËžI“›>Ö…f³Ë×ô›Sg¢­¯_�ˆˆˆˆˆ¨©Ð2 ¤jH‚o�&YfNØHT¨(¶[a”mÐêá+i×èˆZ:YUPd³ ¤ Í3X‘V¡$ÇÇ!Ââé1ã •$|{ì0$_Á�ä+€®!­ñ×±ãñûÕ+Ü{9?—óó*­ŸÕ«Oµ÷û#GcCÂ9ä›Ln÷É3+͇ˆ ÕNÕ -kaz,5»z�ŠªbCBi@8­[�JÇLéÚ g3Ó±ñBEU]Ž;ԯꖪuÑ·]>›u^Úº—órq9/×iÛfÜ�½—Ó1!>¾øßwáo›7à|N6Îçd;¶  Ç;Ójb–PGR’`“eh%÷`ç 8TöúšÚµ»Ó6½FƒqQÑØ˜‡�çã=†úù#¹ û“.ã®Þ}«ÜW#ŠøäŽ»ñÔúÕ¸œ—‹ÿìÝ娦“$<5fÆtŠrylÅs_Q±Åâr}E¾:>�uþºq-.ççáË#Ûz¶ Ň3gÕ¸n±üµy*=«µR@ìjnRÔ0@%""""""jj$A€$ޱ‰—† ExÛ6ž5 ‹bG¾ÕìéaP aSeXd¹¬Ñ%5EzQ‚OYXȹ ©¦lŠ£lƒÑnköUç’(ÂK�p#/„�èø¹8œŒOÀÄáCîéáµxªªbË�ƒÈÈÍÃ]nE ŸŸ§‡DDD @…Š,³¡ÙV�PÓ£¨*ÌŠ …—Ï›4Qà-ià#iá%±;U¦”Í-h”m0ËvO§¨Ð×a¾AW6’å[·!2, #úöñôPˆˆˆˆˆˆn:6»?nØ„Qýú¢GT'O‡ˆˆ@¾Õ‹"{zÔ˜e;켄Þ,”· õ–4õžPó¥ª*ÌŠF»½Y·½žPÖRTª§ªY„ LV|óËjÌ;BC=="""""¢›Ú†½ûäï�Ñú{z(DDTÏŒ² E6‹§‡A-�]Ua–ml9ÚŒhÞ’Þ’zVÞÔ²ª_“lƒI¶·¸ê_�(B/j ÔcÜÉ€°�}»j f�¿­ƒ==""""""°íÐa„£_·®ž Õ#YU‘m1xzÔB)ª ³j‡Ò¼§-»)9‡…ƒÞDQU˜e»£ZPm1µ‚©ÐItBýWÅ2 l@÷îGt‡tïÔÑÓC!"""""¢ ®Û€™cF£u« O…ˆˆêQŽÅ»Ê‡ˆ ˜U;ì /©7W’ À[ÒÂK’ 5ë©U#5›"Ã,Ë0+vXd{‹ŒË à-i 6P¨-6È­òŠŠ`¶Z5AÍœ�5»v{zDDTÏØJ�TÙü_zIs¥æIVU”حȱ˜�j*F¦Ù€«fÙÖR5M²ªÀh·!ßjBš©f leÏ™§×€4à+i,~c6�5;wcÞÌéž¹ ŠzvŽBüå+èÕÉÓÃ!"¢z¢%°É(54­ B’D˜eXLؼYVEF±Ý Q %xIèE Z±þ[:RõU…U‘ËZ‡Ê°)²§‡Ô¸@/ŠÐ6@KÑë1 l¹……hÓª´žÞ†Ÿ” ‹ÍæXîÙz�ΣcjÊ’’a.;_á­[×iNLƒÉŒ‹©©Žå¾Ñ�oúü‚’$ed:–}}Ñ)¬�GÇä ŠªâLâ%§uý»D7ÚýÇ$&¢ªxuëo½¾ÑÆSUUqæÒ%ǘ;·ƒ¿�O­nãÜå+°É×þ8êÕ©cµß9¹…E¸š�íX @DÛ6µ>¹‘WT„”¬kç7ØßBÛztL åLâ%ÇDßQaað­Ýë—ˆO¡Á€+én·ë4ô¼É;žŒêß߯]Ï€�ˆ¨щDApüÍJÔPD>’VU†MQª¼6A̓¢ª0Év˜d;@h:Q‚¾,4¼Ù¯‡6YU`‘eGXkUä]XIà%i 4Ò `‚Õv;Žé£Gyz-Úï¾áÌìûòèáÑ15UqW’0ñɧ!+ ´ ö}ñ¿:„oýø¾^³0yØ,zù¥mó²çÔiÌë=Çò´ñðŸ÷è˜<Áf³aÊSÏ:–EA@úúU�vÿ3žy6»û_mýøýF ,«³nÿÇë¦]p0~ýY­oã¾¾‚¬ü|Çò‰ï¿Fx›ªÃ¾µûöã…Ͼt,?8u2>øóÿÕú¾Éµ ᯟ\{.çNš€�Ÿþ³GÇÔ¶>‚‡_}€Ãß|áé!Q��u¼g]éÚǾûªQÇÔùxéa±ZùƒC"¢ÄKÔÀ(Ûj°'Ñ�Ó 4’³lg0ÝÂ(ª ‹*ÃRVaZ±,0%èD A„ÀаÆÊ«­Jéyµ*2ß7õZÕj#ž„ Àh2ÃÇËËÓà ¼þý�•Òɹ›9½NnI™ø~Ã&€$Šxéчë}œÔø>^¶ÅF#ºFFྉ<=œ›‚Í.ã­…?9–Ÿ{p.¿/<èrZ:~Ú² pÏøq7}MudEÁëßÿèX~öþûª¬~={é2Žœ‹Cjv6 &3ü}|Õ> #úôBçöíiÔÔlvï.Z UU1¬WOL>ÔÓC"ª•±ƒbÿ©L6ÄÓC!"¢zâ%1 ¤Æ%ð–4°;ªŸµT6E�MQ`@égŒ@SÖ²ôß"´bé?¸‰_ªªÂ®*°* lŠ »ZzÞìªâé¡59’x‰Zx½0 l‰½‰Ú°^=ѱݵ ËGÏ ì®ì�9ƒíG�|}ðôÜ{ët;o.\›½´´~ÎÄñèÑ1²^ÇI�ïJzÞüa`Êð¡7Š¢ˆ[ôw,7ö¯¦ž¸ç.(òµ?.oÛŽÜ¢FCMý¸y .¥¥ºvˆÀÜÉí¾Ã[·vzžºu`ÕõŠ_wá“•¿úwírCa»�`çóÙ¡^ÆØ”,ݶç“SQíÃððôi.÷;‡|ùu¥ÖÃ�êÛ¯=þ;ôéÕ`ã¥ÆsàÌüwÅÏ€ÇgÝ΀° éܾ=žœ}·c9¿¤‹6oõ蘚¢°Ö­±óè1OƒˆˆêÛŒ’'´¢IaRìl9z“PËCC(@…WGP¨DH‚� @#Š�¡E„‡å! ]U!« ìŠ[Ù¿VO]#Í5èÂzf±Ù Ói==ŒïÏþÅÓChòTUÅ«ß-t,ÿiöݨõ휺pköîxétx~Þõ:NòŒÍ‡×Ûmi5¬xã•z»½ÚzñáyNËÛ�o’a‰É„÷—,s,¿ôèC�úƒ’)Çò¢ýu6Õãû`òÐ!˜<´åVž˜,¼»h‰cùïσVSùõ»tû¯xæãÿ9*×Ý9p&3žýüãL2¨AÆL�gÓÁú{/QýêÚ!/=v­óÃÅ«© ÝÑÓC "¢z¦50±Š�<@øHÚÒjB•sÞ¬T\k¥y=€$”…¥�aix( "D¢�‰ªªB� YU¡”ý#—‡€ª¹,ä�0êNx žŸÓ’a=ËÌÍE»�ÖžVïÙ‡S.ÂBBðø¬Ûët;¯~·jÙ‡ýü;nCûÖ!õ:Nò ^Ìm|Ÿþ¼ 9…€á½{bÚˆážÒM-93ç._ñô0š�/W¯EF^`@·®¸cLå¹–cñì?u %QD¿.ÑhŒ”Ì,œ½tÙ±ÍbµâO_† ?¿Fz$TßTUÅ–ÃG<= """¢J¼$„ä9ŽjBU€EU + Rè(«¼,.ÄŠDA¨–þ· ”džךR µÿ.�TUuLgW1Ì+_«¨¥ÿ­”‚åa _­ GTèDÉ£Uƒ1 ¬g…%øùÞÐm¤fgcÃþC8sé2ró ŠÚ¡}ëÖÛ¿Æôï[£~%%X·ïÎ$^Bzn.TEE»�`ŒÐÓ†…^§s{ì�3gq<á¼cyÚðaèZÖŽîì¥ËX¹s’Ò3a±Y€à€¼øÈžb£Ëwìĉ„ó(6šÕ> w�‹]»Ôº/tzn.ÖìÙ‡„ädåçC%D†¶ÅøÁƒ0~ð@·¿Ùvä⓯½>æL�ÐàV0šÍXºýW9‡ÜÂ"´ ðÇÄ!ƒpÏ­ã\V¦m:xSS¡È ŽÆÅ;Ö_NKw´Y¬¨ü~®—SPˆ%Ûw¸}œüiö]5:'(«fØtè0â®$!;¿IB»�` ïÝ ³ÆŽ®Ók·®2òò°aÿAœI¼„Ôì‚€6A� Á¨~}pë ÷ÏSU2óòñŪµ@Ùlÿ|ì‘j�Ùsë÷Djv‚üü0²ooÜ5nl�?[öžŠÁ©‹ÝnïÕ©Sµ•[É™™X³÷Úçyï¨N˜0x�ã1-Ù¶ É)È)(Dp@Zà¡éSݶ!VUGãâ±õðQ¤åæ";¿ÁèÕ©#fŒ�®5ûü-(.Áƃ‡pä\r a—e´ ÆàÝqÇØÑôuý=l³ËøbõÀ™‹‰NÛ6:Œ+Në‚ýýñàÔÉ.okß霼àþüöìØ“†®Ñ〓ç/`ûÑã¸põ*ò‹Šá­×£m« ŒèÓ ÓF ‡Ÿ··Ëãvž8éºÝ=n,ÂÛ´�ÙjÅŠ_wáàÙXdç ÐϽƻ¬ü«Jnaþ·r•cùåß<âòï�w~\ »|íl"CC±à¥œÚˆî=ƒ‡^}&‹�WT„ û:�ç5{÷#93Ó±Wß)ó︭Úϯãñ Ørø(R³³‘]Pˆ ?ôˆŒÄÌQ#нŠöævYÆç«Ö8–ƒüüðд)@Yõë/»öàpl²òóá­×£M« L2S‡sŸ” ‹­ö½´Ztï‰ÜÂ",Þ¶ÝiÛãwÜVåßÛß®ÛcÙ{n=ªÒ\ÑŠªbï©ÓØ}ò4Sӯߨöa˜FÜ•¤JÛÖîÛ�wZ‚{'ÜŠwžø=|¼\Ïk¨¨*þ»|%>XºÂåù]´y+"Ú¶ÁÇy²ÊઢMcýþƒŽå@¿ ¬ß丸ڽc$î›TûùådEqz^þrßl·ÝËÅ^¾‚?½ÿ‘˪œuûàÝEK0{ü8¼ópy±Û[¯Ç~Z sÙymˆÙãoE|R2f¿ø/dT:¦m«V.ÏsF^þôþGØ{*ÆåX?Z¶ÓF ÇÇO?é¶r%öòÌ{åu¤eç8­ÿbÕ<9ûn ì^}Àˆ²‹“ÿøò,ݶÃeÛ½¯×®G¿èh|öüÓ.’͇�8µÐ¥ JL&Ü÷Ï#%3Ëiß5{öaÙö�XòÊ?+]”\¹s—Óë¬Ü…”«.ßccúõufæçWùž…š„6»ÿøòü¸i‹Ëÿa[¼u;ÞZ¸_ÿý9Džª*>\º.[«› ÄŸ¬ü½¢:á‹çž©òBµ+ïþ´F³0cä íÙÃí¾²¢à©ÿ‹¿îrZ¿lǯøôçUXòÊ¿ ‰Õ·AÛrä(¾^³Îíö§N®6 ”eçÏßQ}{cÂàAØyâ$}í-Ç{µ¢‘}û¸ ã®$á�ï}àòówõž½xgÑb<2c^�ÿÛ*¬6áÍ…?¢È`¬´mɶxåÛïñêüßà�)“*m·Ùmn_»?ïÜ]i]׈·á¶£ÇñE…@âzs'M¨Q@˜ž›‹'?øØígÕ�›·"Èß/ÿæQ—�é×c'ðU…ç¹[‡(ŠŠ9/ýÛ1ße¹uû`ñÖíXþú¿Ý~ǹòþ’e(6–žowï üzì„Ӻϟ¦ÒƒcôÃý“'â»õëâ“’�öùyçn§Š´ÞQ��SPˆgþû©c>\Øvô¾[· ^z£ûõu9öÄÔ4Ìë]ĺ©ýì—Õ˜8d¾xþ¯ðõq¹ÏÉ ðÁ’åŽå�€Ì�<Ïýïs—­}¼¼\„…ž|ÿc·ÕvßoØŒî#ñýK/ sûö×=Žk—M:Ixù›Ží[ÁÞÏ?Áª={ñÆ÷?:Öo;r F³ÙåµvŸ<…§>üé¹¹•¶ý¼s7^_ðž{ð~·Ÿç߬]ïržÉ#çâpä\\¥õN™ä6 ¼põ*žx÷CÄ$&VÚ¶À{?-ÁÜÉñο¯L¡lþïo×np<­Fƒû&ŽGf^>îþû?�çrZIr }ý-\NKw9¾ªt‰Çþ/?E�¿¾[·Á),�o�#G¸}Ì/~ñµcÙÇË �Îpž×óJzû=œ¾Xù¼ ìï‘[ôÇ×/<‡ ÿ†«Â}ûÇÅN×?<}j¥ça÷ÉSNÿ�ðôÜ9.‚’<ûßO±áÀ!—�ܧ?¯Â¨¾}ðùóÏ ]pp½?""ºùx‰YEHM€F!A„U´Ã¦2ì!º™àU6?iSÓôFt{}Áødå/Ná ^§ƒ¿�ó«Sç/àî¿¿„¼¢Êsl©ªŠÇß~ï,Zì¯A@Çv¡N¿OÉÌÂܽ‚ƒgc]Žåú z§/&bݾnÃÁŠü}|pÛè‘NëÖU¨(¼Þê=ûœ–çNšP£‹ßµuúb"îüÛ?\^œ.§ª*–ïØ‰¹ÿzÅmHû‡wßÇ[?üäF††:U0\ÍÊÆý/¿æ¨¾kL…>XzíBæK�\º/|ö¥Ëp°\±Ñˆ§?þ_•á]S‘™—�Ï<ÿÿì�wXÇÇ¿»{�£÷ÞÄÞ{ï=öÞ{‹1±k4ÑØ’Ÿ�F�Æ–{‰½{콡" Ò;Çqpwûûƒc½½‚ ™ÏóðèìÍîÍÎÎÌÎÍwÞ÷5*æ‘’ž�ék×|u-lSÓ0xá=q0�û�Xsà�Ée {��gs-»šÆüQ# æËÈ”ch§Ø®-z4o†^-[ ~åJóênѵ˜“ˆùcÞ•‡�1}ízƒï�T™ cþ÷3çþT›äôtôýîž8(ôD‡KaòêµË ƒs¢×øóÄ©BÅqS³,Fý¸”'Ò;;Þý…G`ð‚%zýK{\ŒOMŪ}àjoÏYŽÉ l w�;¯W¿3ÄÈ.�ñ8(Kw¾wA[¯r%Ì>D¯ìå=Ü Þ“�‹ ¯Ofç(1tÑ�Ö…6¯"Þbß…÷nJšÆ�¡EíšÈÌÊÂð%Kqãi®h’*“aëñ“zϨ¸ÈÊÎæ¹O,çæ†Msf¢Vy?€Lž…-ÇO`ù®½€F¸^÷÷a,ûj‚I×ÿñ¯�œÛÅ¡�:­Wh6l:v‚w¬GófX4n,¤Rl?}Ëvî1É Ú¨.�ѱa}.}þî}Þ{ÆhŠB%o/< dÈ嘶æ7NT·67G£êUa%•")-1II�Jøï“T™ “VþÊ Eaþ¨øºo/P…̬,ÌZ¿‘³à;}ë6ö_¼¬÷.x„»ß�K†Á¢q£1 ]d)²±ÿâ%^Ýü´}':6jÀ³Ä‹D\Û]sàoÇÌÁѸ:ßÚ%?+»_tâY`^¸÷€gÉg 3Ö­ç‰ßÕËùbÙWP³¼SÓ°õÄIl<ò^è\¼mÚ7¨Ïó  ;>l8|¡Qï0â‹NèØ¨b“°ñÈ1„D½ ÷œ¿€ïF 5É-áÒ»¹ 3ýÛ¶ææ%º¸;:à—É“ ¼^bj\ºÌ;¦k.ò­'NÁÛÅëgNCí åñï㧘´r5Re2@c‘ôëþ¿ñ󤉼ó6=ÁÀªùúàÐÒ%°³²ÂóÐ0ôüö{n^pîÎ]ø‡„¢†_9½2ëΉî¾À™[ïã¸Vðð@u?_¨Tjħ¤@&ÏÒ»Æù»÷pËÿý¦,kssœ^õ3*xz >%½çÌGpd$ ±;xù*ç"ÄZïà§Á¯Q¯REœüe9–íØ�u!°¦_9œY½ž<�[·qדɳ¸¹ÈëÈ(|·q '´™‰ÅX;m2z¶lh6¡LXþ ·‰lÝ߇Сa}=ÁwÅב!—ãŸ;÷ðÇÉÓÜñ®M›`TW¾%XJõ½dfeaâÊÕ¼:›=t¦†¦¡ÈÎÆÜ�[°÷|®ëÎKá¯Sg0¶{Wýçäí‰ ÷pé¥;÷pîw…U« ;[¤Êdœ[îâf`»¶X÷÷an:ï>²²³ ZNêÎ!†tä[+ÿyâ47ÿ€Š^ž8ºì'8ØX#0<=fÇõ�+�ãA`�QQ¾¬0cíÞF¨nÍš`ʹɰ”JÁ²,þ8y ·nƒJ­Æ›èÌßò'~Ÿ5½TËL „OÍ€¡(¨ˆ›QB‚¡(˜1Bä°*ä¨Õ Í“@øüP€ˆ•y ="–ßDðvÆ÷nÕ‚·ge.ÅüÑ# Цàlk«çÂMͲX«e@S~ŸÍwYؤz5,ûjÆ-[h,­Ž]»¡·kn&�T"á\âÅ$%!&) "¡«&OÂ�Òº4­YNŽÜÂFDl,ž¿Ö‹ùvõѤddpéæµjÂËÙ™—‡¦(´¬]‹w¬0ñýàøõíÔÓsuË0ü1�°}þwܼ¨}ƒz˜3|Ï=ãÑ«×ñÓ„q<ÁôEX쬬 W( W(0¦[îR½œ/º5k‚ýß‹•7Ÿùmùï¥EQØöý\ncCA|?b¾ñÞëCAc^lc^-ÍüÃ^GÀÏë3ºý6%#NŽPªT�@  ƒ„@"Ê&Xˆi"ÐP¨UPªK͆@ ž<‚�¨˜ a¡’·oqÿUÄ[´þz*|\]дFu4®^�ªV…�«‹Ñk¼ ã¥Õ*5ç’T 33N | –eõ&tSjW¬`’8˜Ç€vm°zÿßÜââ‰7±`ÌHî{²²³qîÎûX<6–ø¢q#“¯_k¹¬„Öš6 Mç+ԽЉ­Ç²¬Ñú•šI¸gù4øµÁú-n^†½áD¡ à8gÆX½ÿ ç*´U�Úh]·v�çhøçî=ÎŤ�¥¦ è—oþüXwð0–îØÍs�;¾G7,7Z/þ !V©¢wÌÞÚ NŽxà zûÖ`Ÿ�ƪ:"6©2ä¨r­tu­j Å8„f|•ˆÄÈQ¾—:7nh²8¨KRZ‚ßF!5#Y9¹ UºïEcž£Æ’LW`Éó@ {\©¹·˜Ä$^\7Š¢püú Ðbk¿'•*üCÂôD¹EwîzçÅKDÄéÇ×}­S?O‚ƒõÊbhþðèU€-šaÞæ?¸>váþCÈ ®,Ëò6È L�ž9¢â‹ÔŒ ®ý¶Í”6ºÏúMt´Á9‘<‹?6=Ö™c�P„4 ECɪK»(‚Q(P�ШÀ"J¨Hs%> (R4ÄSÖ–\M‚„e3± ÇŽÂŒux ho¢cx.Óʹ¹aPû¶Û£+,ÌÌx×Ð<‚##ñÓ_; üî”ô ¤gÊ9÷ŠÆè­å~Ê|\]иZUn�12.�‚^q–Qï?äYôkÝ âBº5•„T~Ýèî¸7…¸d~ÌÃ�¨w&Õoš,)°µ,þX7Úü´}'' ëÔQÏÁÞDÇ`ûé³€FÐûaô“΋׊÷öV…¯_cx:;åkM§KœN?8rõŽ\½VàyÚñû´E6±j„Æ [þeá·™ÛÏ_pýÁÔ²Â݈«-Kiþ}¸¬ñôuþ·}®?yúÑÄ.C –L‹‰?ÿÂ+Gd\<þ¾t…Þ½œ�1 ]ŒïÙ 6ù^3G©Â²{¸ôÌÁõÆlC¤ê¶=1†��µ/ÎæÇ¤eíš­ ‘&ËÔ¢L�Ó§ÝtÇp;+«-› ¢´]·$¦¦ñÒÆêTwüQ³,R22ŒŽ?ÆÆSÚ_J• ?mßÅ¥§ è_d××î=à‰ƒ"¡+¾žhÐs�1\ìí ··¶æ „©é2Þç,ËbçÙðû‘c¼|ÅAï–- }Îå‡�°b÷¾7�˜BÞóÔu1›'BÑF\™è¾—X–ÅÒ» æÕ%"6Ö @hJLTCä(UHJKçÛvêŒIçôž„Æl»õŠT¶ÁR*Eצ�qHc}™™•… ÷ Góf€fs€¶Kâö êë¹G…¦^÷ž¿ˆõ‡ŽòÜäªèÎÏ.Ü{ÀyrÈ�ž5�@ ¦`Æ‘®Ì“�P`h fBI©‘ͪ &B!�PFa!dÑ4(¶ÌÙc˜ ËC:¶‡£� ~Ú¾�áó„¾{‡¥;wcÿÅË8´t ÏÝ—LKl+,陲BCqy b`û¶¤cûb+§!<��x±Zât×<î½ @vN,ÍÍ!•ˆáéäÄYoèZ Å&%ký•cÿ^ÇÓ×¹ON¶¶øªwÁqÎ ±xÛnñrt×/àéìdò¹žÎN¼E`]A;�û�Pdg¬_cMXÔÆ×Õ•ï.4=£ÐÏÉÅÞž—NLK‡R¥Ò‹•®GM·,Úħ¤”‰6SÚÂHØ»hG¾ï�EáÇ ãxVÌ‘bP™Ê‡Æû,ïáŽòîߣWÞÃWÿÅš9kä�AˆŒ‹‡‡wŽãÜÖ©XÝ›55ù»]ííy®' õ¥èÄD=7¸cú»ÀL,†«½=/ö]š,³ÐýÀËÙ™—–ɳ�ž™©'²eÈåx‰H ‰X{++ƒÏeÀŨ�« nù?çÒñ))ó麄´·¶*”»Ð²ùØ Î’­z9_.6kQ°67Çùµ«¸´½—¹¦`H´aYщ|‹;7‡÷c÷…{÷yc]›zuÑSÇUú‡X)3&ÄNÌãÎó—<‹7g;[Ì2�7V}ȘW¼]œy1L•*äŠlžGŠÂ¢»éÄÔ®ES¼]\xî3Se²b{O ™Òû™CSTn,î}¹›Eä nù¿€¹™„·éd`û¶ãp^¸ÿ€×~›×ª‰~møqÀ‹ÚfŠòŽÔµÄLHIÕÛŒ•¿¸êëæŠ�A\:15­L̉�ðßÂŒ„„OŠ¢ !M„Bá£ó™ ƒy�-™e™< þ!¡¸¨÷YÌ=۷厱,‹x­¸L�«Uåý芈0*Ô†G &)IÏõfI0¨C[^úòÃG8}ë6—6‹Ñ»UË-C“êÕxé?ÑË“�“ƒ�?,Fßï ãÔ™hþå7<‹ŽF:õü6’·è­MP!ë÷A`NܸÉýi‹'ù‘�“ƒe;ùqÎÌ p{iˆ ÷p ÕÖææ˜>¨¡Îo\­*/ýïã§zyr”* Ò©ß’ˆ¡¦÷¬Ÿè?kh!ƒßF´ÐªêãÃ[ SdgãúÓg¼<,ËâŸ{÷ó-Kc�²ÜzöÜ`›ÈQª�ø””�‹O(à»ëËRdÍ[Äê"¡PÏÅñ…ûyiu!‚É[éˆF¦ÜŸ\¡À‹°7cDúº¹bÖ�Aݵ ︮p1II<—p ÆŒ*ÔbluË™‹÷õã3�¿›»+k4®®;>î“qÉɉzdzFΣ’·—ž5ñµ'úãÌÕGO0`þ"ôøö;tœ:“~Ym´\"!_éÍgÏyéFU«~°nŒ„”T¬?t”KÏ=âƒb=J%Ô*ïÇýk âòÃGzž¿Ö³XÒî?ºñÎtbç(U¸úè1ï˜J]2s"}‘×Zïêöuu ±±°@oÞ1c}òmlÞÆÆé¹øÕEw#�"Ûô¾Ô¤FÁó3hÚæëÈ(¤gìZ´¨¸98ÀÛŹÐn†ÅÕ�íÚòžóåqúæûù/EQFcqênds°á·_•Z�Ëùí×Ô6£kÉWÐó…�¦w_ðÒocãpó™¾×047´a)3+ ¯"ÞêÅi%¡8Ó‚ŽeN ”&EAD10£14hÒœ „’ƒÊ5˜2g�ÐÌg)‚„e‡Q?-C¹~ƒÐ~Ê üa¢â æÓTš†“­-—ötvBë:µ¹tŽR…ï6ná¹n‚fq¹Õ¤)¨5| Â[ø¯]¡|‘‹ócP‡vŽý’ûýÓò"]'$êöüs�K³,‹MGOðòT/ç wÇ÷ïs ³ŒH(ïüå»vëõõ’$ÌuƼð˜Þ˜{üÚ œ¸q‹—'!µäbŒÿ‚uÅî}zžâ’“Ñeæ)Òx> IDATÔ3>}¢úÐQÅ{z–dƒ T™&¶êŽ{þ¹¨·Q.;'c–.G³/¿FùþCàÕ{ç9¡89²ìGÜûss¡ÿþo±Áëù¸ºð6P]~ø˜÷>jR½šž÷�<ž9�PÚ¡�@(A4 ”þ¼…Á<ˆ‹Ñ2BÏÍqöv®@!“gዳѯMkøº¹B"!!%W=æí¬îÔ¨¡Þ.à…cGáöó\¼½Ó·n£ó´ÙèÕª9$"ž½Áá«×¸üb‘3•lL4Š¢0°}.¦Ñ�üEØ!�‹ZG®^ãêE›x˳[¶é‰ß�Æ-¼Ô©X}Û´Âá+ÿñ´ß¼…Ô¾-\ììp? �‘¡i,;Jï{Œ‰Ïü9àŸ»÷ÐqêLôiÝ‘þ!¡¼ú …˜^Bõ›*“á×µîw¸Éqδ9pñ25‹Lnö˜Ð³{¡¯Q«¼ú·mÍ-n«Ôj ˜¿ƒÚ·…«½=î⽂ë·8ps°Çäþ}9·^0aùJôiÝõ*W‚Lž…Ó·nãQÐ+îóz•*¡CC~œÂ¯z÷ä톿òè1zÍ™‡ ë#(<G®^ƒ›ƒ=ÏU˜nL†¦ñ¿/Çaä�˸…²uãIðëܸˆ,‹Ϟ㟻ïaG[Œëѵ˜k…OooØYYqÖ7 )©hûÍt´oPæ Ò23“˜µZ�Ý‹æóÎÝpø(ž¼zÍ;¦+’³Æ/[©÷½¿Íœ ‰H„J^^°6çÇŠ²ðGtoÞá11šú Ð®~]\z�»˜šks'Z×­ƒµjä­kÓÆØ}î<—^sà Ž\½W@cA3kÈ@Tòö4ãïß—®r”JtŸ=ýÚ´†Ÿ»ÌÍ$HLMÃõ§Ïxm¸y­š^ø÷ñ\}Ä·4ËsÉžGêUðû‘c\Ú?$ƒ~XŒÊ>^¸ýü%ž¼ F튓�ȹT½òð1]¹ o4¨R¹He5DýÊyn=eò,ôš3­êÔFÀ›p\yô¶––ðswãıȸxl8|U|¼Ñ¶^ݾ¡p íØ{þ¹À‰¦Ñ‰‰h7y:†vìOg'„ÇÄààå«<ËÇÉýûumÛ¨Z†_†½A»ÉÓѲv-ˆ„¤dÈð.>NŽXùÍW¼sëT¬€�íÚr®ès”Jôùî îÐÕ|}�”–†£×nðİ.M¡Vy¿b­“’b`û¶œeº¶+Uà^_·ý…G`ÀüE¨^Îw^¼Ä£ W¨æëƒT™Œs•{óÙsü}é ¼]\ШZ£×¦( ]š6æDw–eÑeÆ·¨^ÎVææ 8ÙÚbÍ´ÉÜ9íÔã͉ÂÞE£Ù„¯Q³‚^E¼E\r Ætë‚?OžæòèΉ,ÌÌ0oäÞ˜7oó¸ñÔ-j×DvN.>xˆëOÞ{l(çî†íÚ½�@ Š‚”B¦ü¸ÞC„’"Ïõ¨ˆa�ͪ�êÀª?o1ƒ@(9XBÐÿ)ks"–zµlŽëOŸq?Öc“’±áðQ£ù+zybù¤/õŽWññÆæ931qÅjNÄz¢g­ÍõÍsf¡²f‘¼$Ю ~Ù{@o7q9774Ñq?§M`xNܸYàõ YMêË�Å·â뉈KNæ2³² î\ ¬žò�žÛLhê}ËœYørÅ*Îråyhž‡†éåµ03æog¢š¯O�å/ ëþ>Ä-׫\ ]›·”1†\¡àY«Í:¸À˜€ÆøyÒDÄ%§p"¶\¡À_§Ïêå0 ~™< MkT/Ò÷˜ÂÌÁñ.>û/æ.8ªÔj¼|UÏ:šÅÉ¿æÏÕ‹¯Ó¡a}ôkÓ ‡4¢24–my–‘Ž66X<~ OÈ2`áÖ©QC,7‹þø‹[¾öä©A÷ˆ®ööØ»ø‡"/¢›ŠP À졃ðÝÆ-ܱˆØX½þPÁÃCïÜû�E{mX–5Øo�ö�æûLØK¶íà}¿ö˜÷ãø±¨äíÉ „°õÄ)l=q Ïvýg;ãí´MÝ:Ò±=öž¿È»~žµ-ŒêÚ•4ÿoß &öîÉY-&¦¦aó±z×ÍÃÇÕkµN¹2oßɉ¥Úµ)ÒØ*0X=õôŸ·�[\MÉÈà¹}\>éKl?}i²÷ æÚnTs”J“ÆMÝ:)IVOùÉ鸡qÕ+W(x ÉÚtmÚË¿š wÜÞÚ ;~øCýÈYz…D½Ã/{÷ëåõprÄþæ+$·oP «VáY;ê›­ëÖæ¥s”*“ê7Ï=£6ºbú¨®�à Y*µZ3æü ]êW®„õ3§•˜{Ñ¥;vs.�{µj�š~eG€™Ô·¶?ɉHºtlÔýÚ¶Ö;V«¼Ï2êʣǸ¢q+jca�ßgMǺ¿qïŠ ¹_ÿ²ÍkÕÄá¥KŠ­ü¬åòО;k¦}ƒ¨øžõÜ’m;`af†�CÅkU. ñ×¼¹¼p g©›˜š†uëå¥( “úôÊwó�“­-ÆvïÊ7Ã#¸ÍGythPßàùË'M@Bj 7ÞgçäèYÎæÑ¾A=žpUÖéÞ¼)¾ß´•gí �èݽ¹ñø´mêÕA½Ê•xÞ.´Ç'+s)~Ÿ=ž<ÍYØefeaòêµhXµ N®\–o¹æ ‚»/^r3”*ž¿ßücci�5x_ÏÃ;wÄ'N#*þ}ÜÎT™Œ›SOèÙ-ëÔâ�ë†æD£ºvÆÛ¸Xî�Ʋ,Îܾƒ3·ïèå-ïáŽ}‹y^J ‚1Š‚˜f (!÷òBi!¢ˆ(J¨‘ͪHŒBÁD(Š‚€¢ úLc q1ZÌ04 ¶qc(ŠÂê)_cýÌi¨UÞÏè §³æ ‚s«WÂÙÎÖ`žÎ�áâºÕèݪÌÄb½Ï-¥R éØW7¬Eûõ ^£¸ñrv6( îØ®Ä;u±03þŠñÓ—ãx.Oó …èÔ¨!.®û5ßÝÊ5ÀÅu«Ñ§uKH%úñþ,ÌÌ0¸C;\^¿^ ûP¢âã±õÄûE˜cF©·;ɹ«ìíÅ‹qYXÌÍ$Ø»ø,�8ž³ÔÒF$¢CÃú¸°v•ј;Å…€a°vúl™;Ûh*çæ†Ç�ÅÉ•Ë� rk¦MƤ>½ôD†ú•+áäÊe¨âÍ�ãdh1 Ë–+—¡MÝ:­<mlðM¿Þ¸ºa-ª–� ¬Ë˜n]°eÎ,Tõõ1X?VæR”sw+±ïŸÔ§æ�®g‘âl—kµ0¡Ww´ªSzvçíÚ‘J$zb®.Eáשß`÷¢ùhS·ŽIq9��­ßÍF½Ê•Œîrs°Ç´�ýqaíj=÷¸·Ÿ¿àbJD"|;lH�ßiŒÆÕªbÿ’z®çl--±|Ò—Ýõ ž{iäÓöÊ VæRì_²˾š`Ô¥^í å±qö üùý· »ý©S±.ýö+†uîhКÉÑÆ_÷í�_oýq^¡€ÁÞÅ?àË^=ôâ{AÓŽ\ííálggò}……cGaÏ¢Œ¶=/gg,?G—ÿTb›q®˜…¾1´D¾§¨ØYYáäÊå¨_¹ï¸H(ĸî]±uîl½ºch{/@§F õƸU*ãÔªåðswÃÜCõ¬Ñ,¥†-å>„å_MÀøÝôâõ•ssÃîEóÑ©QC ïÜ]›6Ñ)Kɸ`÷tvÂÙÕ+0kÈ ƒóI†¦Ñ¢vMüi±IsŒ…cGaéÄñFû·­¥%¼ŒôI©D‚] çcÕäI7¦@ãBvíô)ؽpþ'%Y˜™¡[³&zÇ{·n‘ï}Ð…Ý ç¡kÓ&zu_·REœ\¹•½½ðí°Á¨S±ïsSÚ¯³�-.ÿö+æ��ª¾>zíRK©‡—.A=�>haf†9Æ`Éø1°’ò½š{/ý0z$ö-Y€FÕª|Ÿ»;:bΰ!¸°vU‰¸¢Ï��õ›„@ ¥�T@ÜŒ>_4 )#„#€€¢4~ž‚.4EAÄÐ�2 ÄÿW¢Æ XCÑá E&.) Ï_‡¢í Ci²LÄ$&"!5 é™2X˜™ÁÇÕºNvNÂ#��’ 3±npwt,’+ÊÏ�ˆØXDÆÅC&Ï‚£�5*xzš$$h“£T" <ñÉ©�ˆ„pwt„»£C� -JHÔ;¼˵<0‹�îÊ/ˆËq1…ªx{ë¹ôûrë72¹6Ö¨àéaÔ=YIŸ’‚Шh¤eÊà`m wG=�%?R22ð2,Šœløºº@ME&ÏB`x8’ÒÓa%5‡‡“#\ìí ½J’äôtÄ'§@ž� ‰P';ÛBÇ¡,*r…þ!¡HÍ�ÁÉÎU}¼õúOš,oãâ ‰àíâ S²ãW†\Žè„D$¤¦"M&ƒ¹DW×|*Ÿ¿æ¬ñœlm Z –eñ<4 ±Iɰ³²Dõr¾F…³O�ȸx¼�‹C†\;;x:9ÁÆÒ„3ߣT©�¸äd04 W{{”÷ô(’–eñ.!IiéP³,¬¤R¸9Øçk�X$¥¥áudRÒ3`!•ÂÓɑǴ¤ð EXt4ÀÁڦб3‹›ék×ó,€ž4£ºæºy}ƒÐwï •HPÅÇ[ÏÕº!b“’5Ë¢œ››žx̲,¢“Ÿ’W{»B½ Kš,ÏCC!“gÁÓÙ ½<õÚlRZ¢â`maO'§�"Z¼ŽŒBlR2 x99ÁÃÉ©Ðs¢<âSR��’Šl¥R‰ζv…Š5Ÿ€ˆØXdÈåp¶³…‡£c‰[Ö—eâ’“¥J_WWƒ"ltb"â’Sàlg —ÞÔõ¡ïÞÁÆÂ•½½>HÄNIÏ@pd$R22`ki 'G8ÛÚ–šP·ÿÜy *Á8¯�@([Ä+2¡b ¿ÁŸ@øÔP³,rX5”¬D †„` (ŵز‹•Z�Ã/a@>qõ�@ †ÉO $„’bßÙ0ø‹N¥] �@ |$2”ÙÈ ± ÿ!XÈ� J5Ë…À!þ3P€�¢! h0Äsƒ°˜ahJñcN �@ Ÿ,Ë‚%î·á?…”B¦Ì&£?á?E"01€Še¡ %«1¤%|ÎÐ!MAš„0K5‰K �@ ŸþÁ¯Q½|ùÒ.�@ >"4EAÂ!Wå”vQ„�CQ`@A 9Œ 9Ī�ðAQ€€f @SÄ�hA�*\ð.>¾´‹A �@ „xü5Êû•v1�ð‘1|1Þ „"CBŠ�”@*BHÓ (">MhŠ‚˜¡!eS4M„ÔR мNm\¹ÿ°´‹A �@ „|ÈÎQ‚eYârˆ@ þƒ("š)íbe€˜f ¥…Ó4D,$|ЕÛn™\¡[H1 @æõ…�bYb?\ºx­ëÕ…ƒ­Mi…@ á“!15 r9—¶³²„¥TZªe"Ÿ/Ÿ¿€�ÃÖʲ´‹B „R@¡V"9;«´‹A ”IXP�…’UC¥V�% ¡ @S,І�f@“6ùÁ ¢W›V8våjiƒ@ á“ÂÞÚ Þ.ÎÜ BI‘œ–– á?Œ˜€!nèƒP  (HhR�FšÊuMJ |L(š‚�¦  eDÓ"ä XBMjÕÄÙ·J»(�@ �@ÐB¥Vcÿ¹óèÛ¾mi…@ ¥ ‰EH  Š‚”œBÌÐ`h"JŠ„L®ûPsZ@DÁ‚„%H5¿r xXÚE!�@ –eñçÑãÒ¥ ‰=E ÿuÌ!h‹–@0 €�b`F aN !a( 4Q•SPÄÐ0c¹B4Eƒ&VÞ% ‰AøøçÖ0 �ö�–vQ�@ á?K–B�mÇO¢wÛÖpup(í⡌ Sf#]™]ÚÅ >yT, ÔPªY¨‰ì@(ŠŠCŠE,?:D üH<{Œ‡�Ø©¤Ii‡@ �@ þS¼ õ‡�0ªG7HÄâÒ.�@ Ê,XÄee‚Y&%Š –e‘jV Ë‚¨P€¡i0š8 -uˆ@ø‘g)pìÊUHÄbtiÞb‘¨´‹D �@ Ÿ5áÑ1¸x÷*ûø Y횥]�@ ”QÒ•Ù�+B¡Äȳ.T³,Ô`¡V—v‰šŠƒ\+ABÙ‚„¥@RZ.ݽEv6Ì$bTôö†‡“,Í¥$�@ �@ ‘̬,¤Ë2ñúí[¼y µZ /W´¬[‰/E „|P³,â2bCH |$´C«Ë’¹Ú§ 6×e(MçZ ‚™‚—mˆ@XÊd)xñïâã¡È&»”�@ áC°³²‚—«+¼\œ‰(H „B‘–£@¦*§´‹A ü'Qi, Ujuî¿,KbÒ•q(*×](Íâ½ËPòÈ>)ˆ@H �@ �@ þó¨X ÄŠ�@(°Ë^ÔP«Y¨¨X5 K Šh  A�¡Ðâ0ôÓ‡„%LRJ*¢ââPÁÛ ±XïóŒÌL„EFÁÛÍEáMÔ;T+ïš.¸{…EFB$ÁÝÙɤ²dÈ2š¦Q­¼_‘î§´P«ÕÈR( 53ãŽ)U*(•JƒõZZDÅÆ!)56––ðtu)¶ë¦¤§ãmt DB*ùúÛu?6™r9Ì$’ÞÉŽ,E6Ü�œ`gcm0O–B�àð@U¿r`>Ð}o\Rba!5ƒ¯‡Ôj5^¼”÷ö‚™‘v(W(ðZSSû¶)ä(•P«ÕFc™&¥¤âÍ»w¨äës­~ó) g{{£Ïö¿BfVB"Þ¢r9_½ÏóÞÚ¾£ãã!ÏR œ§Ç–˜�R¥B@H(Ü��`g­ÿ,#cc!ËÌ䯴¤ÔTDÅÆéå“J$pur„T"áŽåõoo7WXYXk¹?gTj5^¾�¯»;,Ì¥¥]�@ ¡LB¬ „² ˲`ÁB@ÍæZªY–¸'-F(  ÐhŠÎ) 4f?KˆÈ[¤ËdXºi n>zlðóËwîbÙæ­P*U~Ž¥›¶ G©4éÚ»OœÂÙk×¹ô£—ˆ‰O0šÿuD–nÚ‚•lË÷ºÉii¸tû.ß¹Ë;þ40—nßáÄŽ�É“€@¬Ð)÷Á³ÿàÈ…‹½,ùqöÚu,Ý´'._)Öë>Œ¥›¶`ýî½ï�ãÒí; +Öï*)RÓÓñõ’ÿA©R}ðµþ8xK7mÁƒç/Œæ‰OJÆÒM[°tÓdƒûÞëbé¦-Øsâ èò®Ÿ_¿‹KHäò)rŠïÆŽ£Çqîú ƒŸ½�ŽÁôe?cãÞýˆŽ‹/¶ïüX¬Ú¶_¾4)¯";wž>ƒL./ñr}l"cb°tÓ¤¦gš6}ßÿ9÷ù›¨¨"µïà7á¼qüÂÍÛØyìD1–<—L¹K7mÁã—??sõ6ìÙÇ¥¿ àúŠößü5ëðÕÂ%8~éý¸š×¿ƒÂÞ{¹?Oñî#ôMÝþ¡ÈÎÆÒM[ð:âã¿Ç �@ „Os�ˆ,ƒeŠ¢@S4„ 1-€-„9#‚”ÂŒ@ ¤iè\Q ÄØ(4 ÐEALÓ�0 ¤!Ì!¤ŒFÍ@@ÑDüŒ!a ãíîgÜzüÄàç÷žù£r9_ØÙXÃËÍãú÷…À€¥ˆ!º¶j‰æõêré?Æãà ±…á]\<þ8x:Â;~þæ-üqð0îùûðw–'�A¨U¹ïØÓÀ Ôª\ù£—%?Ü��P£bx¸8ëum­¬P£bžõà•»÷ñÇÁøùÈpÛ*k< z…*~å ZB} 8ÛÛ£FÅ ðñ(^+«¢òÔ@ŸÈÃÿÕ+ˆ„BlZ¼å½½>zÙ>”½z Š_9“ò¦eȰvÇ.Ä&$–x¹>6Îöö׿/,¤¹ !!Í•úOŒæõêáÀ™³HJMå¹Y‹OJFèÛHŒéÛà`k‹vMóÎU©ÕˆKLDvNœìíyn ëV«úAå {ƒØÄDxº¸À×Ãи„S(\™\‰XŒììl¨4–_99JÈ ˜‰Å} ™\g{HD"< z‰XŒ+B,âO¦äYYxõ&i°µ¶BEˆ„¦M¸žaêˆa\:9- qII¨äëËŒ�EdL,”J%œìQÞË‹se™�œ‚èøx˜‰Åðõp‡ÿ«`¤Ëd¨âç[îo££‘’ž{ØXYâi`hŠB�Jyîí^‡G@®PÀÕÑR3<DݪUP³R%x¸¸ÀÆÒ’Wþø¤d¼‰ŠBNN\�ák@dJHNFPØ0 ƒò^žp°µå>swqF÷¶m ævY¹B¥ÆÒT©TBž•3­òé¢ÈÎA@HÒ22àdo� >Þ`t\]fçä 04 ‰))°¶´Dår¾¼{ŽIH@|R2¬ÌÍáì`�§�AP©T¨Q©",ÍÍ‘’žŽÁ¯! Q­¼Ï,t­ °0dç(áéú¾®òÜ JD"Tðñ˲xüPÞË“sÕÙ°f £÷™!¡ˆOJ‚§« ¯þóÜKÄ"TðöæåWªTœ ʾ>°¶´äÄc°,‹Wo“�/WДáWkŽR‰€�P¤¤¥ÁÑΕËùò\¯>Ëå<=�£T"0$�k×4m]©TÂÛÍÍȵU°�JõÜNffe!:.±nNŽF]½FÇÇC*‘ÀZ§k“–‘�„äX˜Kádg§÷yvNb@Ñ4\ôÊ’£T"&>EÁÅÑ­Ï[6¨¯w½Ôôt$¦¤ÂÖÚ ¶VVFË¥MbJ RÓ3`gm#ç¨Õj¼�Ž�@ €«£ƒž X•J…·11`hîÎÎ]ÄÆ$$@ž¥€³ƒ=¯Ï|(Ö––zïcÈ ¼‹�ƒ¥…¹Áça*I)©HIO‡“½,¤ú.(3årDÇ'ÀÊÂŽv¶¯Qœ0 '{{ŒèÕ÷ýŸ#(ô *úø˜p¦qX–ETlr”Jx¸8Ü´—˜ˆtY&íl�º0eY‘1±X¸9;ë�©ò¬,Ä%&A,ÁÙÁÞ$×Ê,Ëâmt ílóÓuÉ�e"61Ö––¼wš.†1ئòÚ�X,‚»““Ѳ¦¤§#)%NvvÄE)�@ á³Æ\ ‚\•Cl�„OJcuˆ< 8?wYPP³,µ&î!ÀB PÔj,€esÇ `Õû. ,(š˲¹Â&›{_EƒbYîw< 4Må~nübIJ’ÀAÂ�@³ºupàÌYÜyò]ZµäŽßó÷MÓhT«& q9öóÖ?±}ùÿ ‰ü&ëwïE\R@,ad¯hÓ¸`馭p²·ÃÈÞ=1âÛï;��À¿÷`ùÌéù–ió�¿qõî}.ݾiŒí×wž<å,TÔj5ÆÍ[€E“'aÇÑ㋌œ»~ÁááøiÚì>q!¡hR»ü_##3ÐX¼Í7†Ÿaýž½È�erßici‰ÉÇ¢ªV<Äñó‚eY ïÕ­4"At|<² îZy׫VÞ†Ë]�Ü; IDAT²ø}ï~Üxøˆw�å<=0ï«/!•Hpßß;��€§‹ ¤ffœ[N†a0´{W|Ѳàè…K¸ýä)êU«†°ÈH$¥¦,¤RL9 Õ+T4Öa‘QèÕ¾-î ÄÖ¿ÁÍÉ «æÎ†R¥ÂÒM[£z÷Âþ3ga!5+’@¸åÀAÜ{öÞòµYÝ:øjÈ 04�‡/^`Ûá£ðtqÁŠogryVoß� Y&fŒ‰5ªãúƒ‡8tî<ª•÷ÃüI ~OŽR‰5Ûwâ‘–KECå�x�•þ…„ädÞ3™=v4WgË6ÿµZ�½zàй󠚟¡fåJñ·:‚ ·nϘ�&MDÕò~8ôÏy¿x™sñêæäˆ)ÇÁÛÝ�;O®PÀÒÜg¯]ǰžÝÑUk¼Ê#61¿n߉ð¨wÜ1?/O|;n '¤\»ÿ;��àÜÚY[cʈ¡œì­G�ñ×ÑcÜx`ci‰o†åb£~¹`úuî„M›@‘�ƒõ»÷ðÜÉÖ¯^ SGG`h(þ·1·�Ìûu-:5o†Q}z!øM8ÖíÚëߺժbÚÈá �+óÝ|L1 ûOçnÞÈQ*áéêŠù_Màîãi`6î;€Ôôt€½� ¾6•ËåÞGBr2ÖîÜ͹ë …èÖ¦úuêhðÙüyèüƒ^aͼ¹€ÆºkâÂÅðqwÇÒS�Ø8}éϘ<|(œíí1Í:üöÃ<\½{‡Ï_àžëÔ‘Ã9±úîÓgØuü$T*r”JÔ¯QÓG7(fÎ]õ+÷ìn=zŒ�+–aê¯#GqñÖ�Ü &€á={ có¦ÜçΜũ«×¸Í"•|}1eøÐ�+2O@>,–èÛèh¬Ù±‹sï)53ؾ½¹q.9- kwìâ\—R…–õëalÿ¾œ�8rÎ÷Ý·7Î^»Žè¸xä(•p¶·Ç÷Çsý÷Ð?çqâÒÎe¸‡‹3f� �5°çä)\»ÿ+¾�‰E¿ýŽš•*"8<OñýÄñ}…«wïá×ïçpå�OJÆ”Ÿ–âÓ§¢œ§X–žÓgpæê5¨4ï�š•+aÊð¡‹ŒÔ냻uÁ¨¹ó0güXÔ®’k�ôâ%=‘+«‹£&ÊŤÜsòbáæä„SWÿÍ­C´Þ��@ ÂçFž!‰EH |þP`ÁPxïlQûÿ–!X=€e¡uŽ–�ˆ÷y¸ ¬ŽjÇÒ (­ÜZ hb*ÒTîyùn:Öýˆh„B@¬F?¶6¨\ÎWÏÍèݧÏP£bXš›<ï÷½ûáéêŠÍKaûòÿ¡eýzØvø('Âä!ðÇÿ–ÀÖÊ ƒº~�…_•oyÙÙð F×V-áçå ¸xë6’SPÞÛ‹(ŠÂ¸þ}áìà€^íÛÁËÍÐ,>öj×–wÍÛOžÂÅÑÝÛ¶†��5’ÓÒ°iÿ�ÜÀ±,‹ß÷îC†,MëÖÁ¸þ}Ѹv-¤¤§ã·]{À²ïG-™\™\ÎYÇA#Õ¬T‘7j[£Ý~ò7>‚T"AÿÎ�ЯsGØXY!ôm$.ݺÃ+çÛ˜Ä$$ {›Ö¨\Î*• »ŽŸDtïó¼#…`äÞc©Ø}èp¿¯¬ »Rp,#‰SG]ƒÑI‰f�I3ÒÞ\þ%ªëê0*)/^„Øðp—Wà�Ï¿4 $ÀêM[ ‰Ì®]Z¦õò¢‹gÍļiSáîìŒe/¿ˆˆà ¤9е›·bÁuÓñÙË/âõ¿>…\�×?[M·yOæä"ïl!þºänŒ1LzZùó¯Ðh:ñÆßþ‚•o¼†Wž|UµµÂ|¨ MMøäûÕ˜6z¾xõe|øÂsðpuÁòu?Æïø‡ß~� ÇaùÒ—ðñ‹ÏÃÏËŸ÷(il²~ç.d�9Ó5 áõWñÌ÷!5#)‡� *$¯>Õ5âï=€E7\Âî.Îxÿ¹gñõë¯âéû– -+û�§™ŸË/¿âžóðåk¯à¥ÇEeM56ïéúÜV×Õá­/¾D|d>|á9|øü?àãé�w¾ü Z�z½¯¶:�ÿþË“ø|éK¸cÎlü¸m¶îÝoñ\£"QQSƒªÚ®ÀeV~W�ÿli©LÍÌ; ‰XÜëý�5y"îž? ¹ Ë^~C‡ÄÏmÜ�‚—Ÿx +þ½÷ß|ޤŸÄ‰ì‹ÇðχDRL4†ÇÅbÙË/ A¯¼ÂB´µw`ÙK/àóWþ…ñqXù˯BÐiSÊlØ•‚n¾ Ë—¾„Wž|-­­xãó/Ì~Ã/…–¶6|¿qìmm… ùùÐtvâßË–ÃÁÞï<û4>{å_¸&!~û=ªëêï}µ5õõxþOáó¥/á¡[nƾÔãX»y‹Ù¶¾ýuæM›Š/^}ÿyú/hmoÇÏ;vÆAk7oÅ’›æã«×_Å[ÿ÷7h4�X½q3Äb1–½ü"\�±àÚéxáч…mnÝ»r™ ð~„ú¬4ðÖ½û±qW î[¸Ë—¾„}ùEEønýF«ß�îöOê ›0gêd|öò‹xão��� Þøl9:ºÍmy*ï4*jjðÉ¿žÇò¥/ášÄ¬üeý€çL&""""ºÙIåqÞ-"ê¡+Q1D�ˆŒÿ`ü'C*ü“@&üC&îþX™XÔm]ñ¹m@,lWd8$ºø˜Aø;›œ„OW¯EEM <\\P[߀¼³…xpÑB‹ëëôzTÖÖbêèk ¶ë .¸n:¢ÃB-vÆÚªT‰E�Ëd*Köìƒ÷ÁËÍ íXòìsÐéõ(«ªDlx8c¢±~×nˆD"¡Ùð¸Xì:|…¥eðöÂÐØ!fÛópqÁs?™TŠñÆá/ÿ~…¥eÈ)(€‡‹ ›[‘A�7l(Æ&”ÁkëèÊòÝ3n×zÁçæKËÊ6›kQ¯×#=;·;;Åb1&_3A�;4ƒù…E8–ш²êê^çþ—{îBˆ¿tz=žyóm––aû¾¸uöLa;[¼ð§‡ R*qÝø±x쥥¨mhÀ±Sfa..øÇC÷÷*§iRTVP)HˆŒDˆ¿Âü±?5M(7»Í˜í5cÂ8L;0f¢}óËzlNÙk±\RL4ö¦ÇéÂ"øzzb¤1 µ§ƒ'ÒÑÜÒ µ�-ž¾ÿ^H%Œˆ�Ç£/½‚œ‚46· åðQ´¶·ÃÑÞÿ|äA(ärÌš4Ͼõ.ÎcÛþÂûcvÉÿÝ¿"‘°9e/¼ÜÜðô}]Ëô=¶ìهʚZ³÷ÐZ@k † ‰Á}7ßÔ«„Ÿ‰)¸b¢�ËÍ/¸îZÌž<0fFnÝ»[öî²q/–õÆìšE7Ìí ¹Ü,Ð{";55p´·ÇC‹B"‘ 9&÷?÷ŠÊÊ�y:1a¡Âúñ‘xdñ"¡Dg‡Fƒœ‚3xôöÅ�A!—A&“A,ÃÖø¹Üqà â"Â1kR×5°Q©ð࢛ð××ßĉ¬láûÜÚÞŽ?ß}GŸ¥E£"qíØ1ðñpùú ØÏ5õ]Ù¶•5µÐéõˆ�Š„B.‡B.Ç’óQRQ¨®­ƒV§C|d” ” îž?·W Ú¤¬ª ®ŽNöó…H$Blxžºç.8;8@"ÃFÙuŽ*… ¹ ƒã†EBd\…kèâ舚úz³m�NJDlxWVp×w3µÆóØyè°ñØÏeŽÝ6{&>XùªjkQÛÐØ• ú·§àëÑ5ç褑#�y:;2ﺋ …L*ÅÉÜ\L1™§ó1,vR32‘•Ÿ�䘜Ì;�ð @á½3‘ËdPÈdD½ž[tý x»»ÆŠ/Öý d?÷¤R*!•H •JͶ#—Ép×¼9Bë #†aï±T4·´ÀÁÞ›RöbÚèQÂoq�¯îš7ÿúà#ä .†'–¾&ü ®7ÐÖÞ¥Ïÿé¡——µäXF&jð�‡î?·Íž…‚’”TT SÛ‰ÌÓùxüŽÛ„l×1ÉIÈ/*ÆŽ‡póõ3„m%ÅD ÷owwÄF„ ×¼¼ª "‘IÑQ�J$ðtsÅ£·ßжŽvÀt¿õ¾_+ärŒš$”å  ļiS‘y:ßâ÷£{Ðvì?ˆèÐÌ™:~â©×Þ@jF¦�íììĽ æ ÷êÉ׌@Ê‘£¨olú]JÍ]b‘¶Ršµš¬MDDtõa€ðw2">Ë×ýˆ}©Ç1gÊdJO‡L*5ËéN"#9&+Y�Sy§‘�!áaVAƒ¡�ËàåÖÕ™¬T( �ËÑÚÞŽÎÎóÏHŒŽ:Ñ}<Üáëá�¢òr—W , žn®(¯ªÆçkÀÊ_~ELh(££0*)ÑlήžÁ0Ó¼xݲHX–{¶Žj{¡Srd|ü½¼pàx^_ö9rÏ¢©¥+ ‰ÁTGG¡#["cè�––¡È¼0&tÜ:ÚÛ#"(é9¹(*+7 ��µ€°€ˆÅb46·àï¼'µ Q‘;‰Æòn%]¥[¦¥#;¿«ôi£ñø‹+*„ žóQXZ Jô¹:9â›ÿü[X§ ´+0!ÖD"†Åv•Y5Ÿ‰‡Ë¹¹´”Æõ»Ï¯eÚF÷$ú´¬lÌ�6å¼ÏcÜðaVƒƒ�Z-–<ûœÙ²'î¼]øŒÀˆnïÙ5 ñغwJ**zeë]ˆúÆFás7"îÜ÷tx\¬Y€Ðt½5Z-^ûô3a¹Ø83raY™Y€pÜÐd³€AFÞiø{{[œΚ²ªê^¥ý¼¼ ¶³EYÕ¹ º§›kŸÁA˜8rZÚÚ�–•�Šêäž=‹“9¹¸&1àïíOWW,ýøSŒŒ�C\D8â""àm Bz^_¶#ããk|~„•ß¶a±C°÷X*žzíuŒJJÄ�°PÄE„[,Ÿ ãgwæÄ ¨olıS(«®Ff^~WIÜ¿�=æ•ÉÎÝKÊ+êïo6?�¯‡‡P ôDvdR)Ž�ÊÀ±SÂ:Í­­(­¬´xl ¹Q!Á8™›× ÌÏÇ´Ñ£ÐÔÒ‚S¹§‘�Œ¼Ó¸nܘ>߃ž»•_‰D�Jk÷rs5›ã¶ûywh:QU[‹ú¦&ü¼ã7a¹)û´¬ªê¢o�uƒY଺¶w§àãïWã_�ýé¼GΕ”WÀÕÑÑlŽF…\†—„÷±{Ùkˆ ÁÆÝ)hniæÝ îñÙ‘K¥0 S•B�'—þ£“1$<CÂÂz \è),0`PÁA�N‡²ª*Ì4~0¹vì\;v`Ÿ¡²ª*L9Ül™·»;œÔj¡ +Œ¿ûÝïu2éÀæ&""""ºÚÙJ»ÊŒê/qå""¢Ë�Â߉�J…Äè(ì?Ö <˜v Q‘}fû=~ÇmØ{,ÒNàëŸE[{;’¢£ðèí·A!¿�ι‹Ÿ–ܳãSiìhn×h ‹ñü#aËÞ}8püJ++q,#Ç22ñã¶xõ©'¬;2OçÃ×ÓCÈ¢„��)<>‘�ƒ7>[ŽN­þÞ^“œ„¢²rœÌ͵pœæ×M)ï:ÎŽe[•=ÎG!œOßëõàã�}[÷îÃÑŒ Ô56â·ƒ‡ðÛÁC�”ˆGn½:]W�ªSÛ‰æÖ®.f±HŒ ct÷�ƒe*ÿ&—Yÿ¼˜Ê¹Ê­¼‡=÷o©s¾¯ûæ–V–•!*$Äê:ýéï:÷§ûù+]ÛêÔjÑq×¶§ÖösŸ�îŸ3e·  Œ�ú@WðÚô~€·»‡ñµ=>{=ŸO6fk[l”Š^ËmT*´´�›´gfš%¿ì؉U7ÁÕÙ Á~~ð÷ò4 P)är¼òäcØqà Žœ<…�¾[½^�ãÇá–™×C&•â¥'ÅŽq8ý$>ù~5tz=¦�…;æÌáq±xù‰GñÛ�CØ}¸kHGG‚¥Í-­‹Å8STl¶\)—#9&+õá¢"ñÓößÐÜÚŠÂÒ2D…£®±‡ÓO¢´² õ��HŠŽ¶º_Kú < „\f}-m­0 ¨mh8÷ù5×oPÙƒ•¢øÉ11f¿ùàî⌷¾X�’ŠJøzzœ×þšZ[ RY¿ç¶¶weøõ¼/›²ðšÛÎe}|vÜœ�ðúߞŽýq,#[öîƒL*Åm³gZÌ7È÷¯û5kmo‡N¯7d3X]¿ ½_o£RšefË/¨ÍADDDDtõA[‰ MÌ"$"¢? Gc““ðŸå_"='Ùg ðø·Y]WÓÙ‰úÆFŒJLÀØ¡ÉÐëõ8t"ÿ]ù-v:„écFÿ®ÇÞŸ3Åç:É;4¡œ —«+ZÛÛÑ¡Ñ`ʨk°àÚé(­¬ÂþãÇñÖm¨©¯ÇñÌ,³¢ÝY †¤eeaáŒë„Çw§ S«ÅuãÆâögÞúb…ÅíUÕÖ¡¹µUHæ�»{¶Œóì™èõz{ººêºÔ76ÂÞÖ·Ï™�û.@VþlÞ³‡N¤cï±Tܳ`<\]�_TŒùÓ§a´qÞ7�^/Ì5©Rôì ”‹cWÓîMšÎN¬øñgÀX Ó4¯_~Q‘ÙkóÎÜœÎoÞ?“ÙÙˆêœí œtŸ¿ª©¥Õ«ûgšƒ³;…\޲nÙ/ùEEp6^‹¼Â®óTÛÙu][QïcéÔjÑÑ1¸Æ¿›³Äb1ôz=ò‹Š…ÏmÏëjšKÐÇÃ/>öˆ°¼­½zƒ¡Ï`.Œß‰Gn½eÐÇVQ]c¶¬S«EM]ý æu¬®«Ç·ë7`ñÌëqý„ñÂòœ‚³Âÿ[ÛÚÐÒÖŽë'ŒÇõÆ£½£ëwíÆšM[0">^î]sz^7n,®7šNlJÙƒïÖoÀˆø8DõÚ§›³3î6–¹-.¯ÀGß}�/üIÈüêîT^¶íÛ�¿,¹IÑQÂòm=æ#í�›³“ÙgÆk¶qw FÄÇu½ß"þtÛb³lÆæÖVh4�Vƒæ‰QQXñãÏØ¶o?ìíá忆!a¡X»y+�87g§ó€]*Nj5dR)&_3Rø�‚1Ø]S_'c¹äž” D"‘Õr§5u °U ,ÖôÝÐÔtÞ×ÇÍÉ {ëR{o·ìÙ‡`?_!+½²¦ÞîîÂóåÕÕ‹ÅpuX)ÍÆæfˆD"Ì¿væ_; �ÍÍøæ—õX¾ö\“˜pA½º†Fáÿö¶¶P*¨ª­5[çlI)Næå eŽûâêìÜë·A§Ó¿w,JDDDD6R9Zu�Ð1‹�ˆˆþ`,×h£K"!: v66øäûÕP*HìÖyÝSm}{ùUËÈŒóì%ÅDC!—CÛ#ƒãb;Nõz=š»mL3745÷zMzN.6ìÚ�¢²2,[½­ííPÛÙ"*4YùgðØË¯â©×ÞÀ™âb¸8:"."\ÈÊëÞ¹|ïßÿ‰%Ï>‡]‡�Æ``B·lÁÆæf”VV!*äÜ…¦ ·ªº:Ô56âÈÉSH5^·ž:µZ|òýj••aÛ¾ý8|"0–€íîli)VoÚŒâò ¬øégT×ÕA.“!)fp™=«6mÁc/¿Š·–¯@}S|¼…c·Q© ”˅̺�»SPרˆöŽ|üÝ*,yö9¼ÿõ7V·mêÞnl>÷~¬üåW,yö9<ÿÞ d"––aý®Ý¨klÄšÍ[°}ÿœÈΆ­J…ñ]å7OaÝ–m¨®«Çöýp0í`dÂ…•µMËÊFB� ¯iþÅßÂéÂ"N?‰M»SÎ{¶*•Ù?i�2}+Y�ÌÓù8ž™…µ›·& f<–®9Í*jjðÛÁCÈ/*ƲÕk͆!“J‘lü|¬øñ'dåŸÁ±Søaëv³õ"ƒƒ!‹qº¨‡N¤C¯×#-+÷?÷îûÇ󨪭³º�òêj´´µ!ØÏ×ê:–D!åè±s¥w�s—iu:a®µ�hnm�Á`@xP °¬¡© ù…ç‚ Ç22ñØËK… �R¡À°!]åEµ:Ò³sðØKK»J~³-‡Ç™žï}Íßýêk|òýjᱯ§}| Õšÿšæf5šÃ»e••÷ ¢ô'"(9gÍ?:‘ŽïÖo„D,Ax` Ú5ì:tXx¾S«Å‹ÿý«7o¶º]O7Wxº¹býÎ݈2CCüý!—ɰaWJŸ÷ã™ê<¬ofàÛ‰D ÄÖ½ûÍ27ìJÁ__³WV¡‰\&Cdpv:,Ìíh’ž“ƒŒ¼<¡ÔrLA؆¦¦wO‘ÁÁhnmÅÞc©Â²³%¥X¾î´´µÁßÛJ…v�û-êÔj±uï>„úû÷ú]±fÃ®Ýø¿ÿ¼-Ü«Õvvˆ‹‡Þ`TYc¹LŠú¦&thÎe:ï6ÞÏ�S~;xØ,Ó{Íæ-8xü„Ùz–æ.†q^à½ÇRÍ®ëöýÑÞÑÑ+Xß—ºÆF¤çäšµˆˆˆˆˆþ(Dì¥ç?x›ˆˆèJÅ Âß‘T"Áˆø8lßã†&›ÍñÔ“‡« "‚‚ðÁ7ßatR"ìmm�š‘‰DbuB™DŠýÇÓ`okk5#o Ü]œ…L¨‡^øž}ð>DÁËÝ8ÕÕAYU[‹ç~Ðìx¿úé³í,ºþz¨ …Ÿ—ŠÊÊð̛bÖ ÙÒÖƒÁ­V‹ÊÚZ46·˜Ímu";‘ÁAf×nè�dä�Æ‘ô“8’~±î..(«ª‚&Žj5Òsrq8ý¤°,): =:©=Ý\±nË6¬Û²MXvãÔÉpR«u-'�Ž}ÇŽádn.~á%³çæL™ ‘H„§L±S8]X„‡žÿD" \�q×Ü­nÛ”Qs0ížyóm¼òäãÐtv¢¥­ m]¥ò|¼1cü8lص_ÿô ¾6¾G±÷ÌŸçIœ9i~Ù±«7mÆêMç‚#âãÌ2†Ë`0àDvfO™d¶|Lr"r �{ö,þþö»€1ðS�ÿ}éÐhðâ?»»¸`æ¤ €èÐP8©Õ¨klQnÎNPÈåèÐ .‹pþôiHÏÉEie^x¿+HÛó3ãæì„ùÓ§bÕÆÍxë‹Âû ·Îž ow7‹Û†1Ø>è9ØæMŸŠÔŒL<óæ;‹º†H;�écF*ËÇÃî..øø»U?|››qàø Ø¨TÈÊÏGzN.â##à`g‡çÞy_ø­Ú< !þ~ñ÷ƒF£�³£žïŒLˆ‡D"ÆþÔ4øx[ VN> Ÿ®Zƒ¥Ÿ,CˆŸÊ«ªp íî]Ðõù5͸)eÆŠÈà Ø(•xã³å;5µ8ž™ µ½ŽžÊ@BT<ÝúÏ;4»ÆK|ŒÑÉI€Á€ÝGŽ`ÒÈpurÜ0q<–­Y‡ÔÌ,x»»#53 M�˜3µïù6£¢°qw ¢ŒsÝI%DáDV6£¬e2:4�X³y Fç|<2©Yùgðëo»0cüؼ¢k^ÀßÿϾý.’¢¢P^]�ƒ'Òqë¬z•ÑíîîùsñòãÉW_C\DÔvv(¯ªBÆé|„`V�ùó¬q°·ƒH$BÎÙ³Õí7éƒo¾³8?éÛÏ>Ý«tuˆ¿&ŽŽ�¾[…ôœ\ØÛÚ"åÈQD… ß«[f^�Ï׬CUm-|¼q<3•55øûƒ÷è8`Tb"6ìJÁ³o½ƒ„ÈH´¶·cï±TŒNJŽI&•âà‰PÛÛaÜÐd‹Û‰ Agg'–~üàƒÍ£ IDAT ’‡Ä ïl!ZÛÚ;ÿ·Üp=žï¿øûÛï!!*EeåHËÊÆÿÝ¿¤k?=¾aæeyçL›‚£§2ðì[ïbD|ꛚ°?õ8¦Œº�>>¨´¬l|üÝ*üíÞ{zÝO‰ˆˆˆˆþ”)d: :õ—vÐ>ÑïIòüóÏ?¹â‰�§|==0fhìmÍçX’ɤðvwG°¿Äb1FÄÇB*• ¼ºõMMˆ Â} Àű«sÚÎÆ!þ~BÙ3W'GWT@§Ó[œ£Ì´ý¤èh³9ÃÕj$DE""(6*l”Jøxx@«ÕÁÛÃCc‡ûêÔja£R!*8Ñ¡!Øuøªëê0cüXŒIN†V§E€·7n›=KÈ<“H$7l(Üœ�`§RÁÁÞ¡þ˜:zn�5Ó,+ÃQm�¤˜hD£¥µžn®f󌕗#*$جƒ?ÔßNjˆÅbúúbÉ‚y7l(¼½àçå wœ).AZV6¼Ü\ñÄ�·£­½ÎŽŽ˜:êÜ:{–��r0íŠË+0:)³&M„F£��‡\;SG]#ìÓÞÖCÂBl6ÿ–�J…° ƒ»‹3œ0vh2T %ìíáâèˆèÐPÜrÃõ;´+ˆ+—É0vX2T ä2ü¼<1~Ø0ÜwóMp4—ärü<=‘//@�Ÿ/´:TJ%"ƒ1$< 6JBü!~â##àí ‰D'5"#qß›Ì2¬bÃÉX µ�-Âpã”ɘ?}šÐ­R(ìç‹Øðpáú«”]Ëâ"Â…ò«*…!þ~ˆ ‡�J…Ööö^ë`?_x¸º@,ÃÃÕ³§LÂŒñãàïí…„¨HøzzBdÌ4LЉFX`€Ù2,\O$DF "(¶*T %‚|}/77ˆD¢^Çç`o�ñ±Ð °U©0,vîš7~žžHЉFˆ_×w;*$¡þþ‰DpR«Ž»çÍ5ËduR«‘…ð @¡ÌlQibÃÃû- ªR(àgüü€�R‰Ñɉh×t ¸¼‰s¦LÆ Ç ï¯�J‰ xõ ”ˆÅ;õ�MÈ/.†\&Ã’ó�…⊠تTˆ ÁÈ„8´k4(­ªBGg'F&ÄãÎ9³!—É “ÉpM|<4ÚN”UV¡­£Ãccq÷¼9Â{ì`o�ðÀ¨íìäë‹@ÔÔÕ£¬ºj[[,ž5Ããº2_• dRŠÊÊáâ舨�`$FE¡¢¦%%pvpÀƒ‹"ÐÇEeåðtu…¯‡œ›nsÒÙÙØ ØÏO(:*1r™ gKK¡ÓëqíØ1˜3u²pÍbÃÃáãá�ÒÊ*Ô64 2(.Z(üF[ãáâ?/O #×¼\]âCb 1þ&J%xºº"ÔßR©nNNhnmEqyBüááâow7ûù™ÉÕö ìu�é¾ÿòêjTÖÖâš„xØÙØ Ð×Û¬¬¦T*…—›+Büý!‘Hà¨V ¤Â²2ØÙØà–™30:©ïÁ(j;;L1 v66hmkGu]œpÃÄñXÂ÷;."±áa½þ…øûY &ÅDÃÅÑ…eehmkèÄÜ9÷Fᚇøù!"(åÕ5¨¨®A�Ÿî_¸>ÞÂ6œÔê^××V¥B�¯<]]á`o�¤èhÔ75¡¼º"‘ÓÆŒÆÜ©S„{�«“#ŠË+ Õj‘ {;„øû ÁgÓûŒÊš”VV"ÄßwÍ›G„˜~sì0"> ÍM(.¯€‹£#–,˜' ¼éùýò󅳃C×ý^©„J¡ÀèäDh:5(*+‡Ø8heöä‰ç~û• úøÀÇãÜçC"‘ÀÓÕ!þ�I¥�Ëäð÷ò¶KDDDDôG$‰Ñf¡ò ÑÕJd°VwŠh^üï‡È<��›®›ÞoÖÌå´qw Vüø3‚|}ðÊ“�[]ïÝ_cÿñ4L3wö‘½GDDDDDDDDÿ[ê;ÛÑÎ !ýApB"""""""""¢~ØKåܤDDDW.é‚8©Õpwq�­Êfk_>*¥î..prpès=µ½Ü]\`g{eŸý¾$"1l¤ò¬IDDtåc‰Q"""""""""¢0¨îh�Ž]ªDDt•c!шØK—û0ˆˆˆ.„DDDDDDDDDD¤”H!K.÷a]‰ˆˆˆˆˆˆˆˆˆA-c!]Ý $"""""""""©H [©ìrÑyc€�ˆˆˆˆˆˆˆˆˆh�ì¤ HDì^%"¢«ï`DDDDDDDDDDƒ$b©Q""ºŠ1@HDDDDDDDDDtb ”éå> ""¢Ac€�ˆˆˆˆˆˆˆˆˆè<©¥ ˆ ºÜ‡ADD4( �'±H{™ürÑ 0@HDDDDDDDDDtl$2ÈÅ’Ë}DDDÆ!ÑRË,4JDDW ‰ˆˆˆˆˆˆˆˆˆ.�T$†�”¥F‰ˆèêÀ!ÑE`+•³Ô(] $"""""""""ºHXj”ˆˆ® ]$,5JDDW‰ˆˆˆˆˆˆˆˆˆ."–%"¢+„DDDDDDDDDD™ƒL‹�ÑŠB"""""""""¢‹L"C-c©Q""º21@HDDDDDDDDDt ¨$2(%ÒË}DDD½0@HDDDDDDDDDt‰¨¥ ˆE,5JDDW‰ˆˆˆˆˆˆˆˆˆ.±HG™òr‘‰ˆˆˆˆˆˆˆˆˆ.!¹X;)ç#$"¢+„DDDDDDDDDD—˜�T¹Xr¹ƒˆˆ`€�ˆˆˆˆˆˆˆˆˆè÷á Sr>B""º"0@HDDDDDDDDDô;�ˆDPK—û0ˆˆˆ $"""""""""ú½(%RØr>B""ºÌ $"""""""""úÙs>B""ºÌ $"""""""""ú�9r>B""ºŒ $"""""""""ú�‰E"8Ê”—û0ˆˆè�R"e€�ˆˆˆˆˆˆˆˆˆèr�‹%°ç|„DDô;’ŠÄp�) $"""""""""º\l¥r(%ÒË}DDô?@,ÁI®„"‰ˆˆˆˆˆˆˆˆˆ.'™21»j‰ˆèÒr�)!uÝox×!"""""""""ºŒDe*ˆE¢Ë}(DDôe/U@!–� $"""""""""ºÌ$"eJ0DHDD›R"…­Tf¶ŒB"""""""""¢+€\,�½Lq¹ƒˆˆþ@db1dÊ^Ë $"""""""""ºBØHd°‘Ȱ&QßÄ"e*‹Ùé ]AÔ2béå> ""ºŠ‰ ‚³\‰•ùm $"""""""""ºÂ8Ê�ŠØ}KDDç§¿ûï0DDDDDDDDDDWDp’+!¶’ùADDd�½´ÿLt‰ˆˆˆˆˆˆˆˆˆ®@‘N2Dg�"""êÍF"ƒ­´ÿ¹l $"""""""""ºBÉÄb8Ê•—û0ˆˆè* K¡–)´.„DDDDDDDDDDW0…X‡vøÑÿ&™X2¨% ]áTì¤òË}DDt’ŠÄp’)U�šB"""""""""¢«€�T•¤ÿy¥ˆˆè‡X$‚“\ ±hpóÕ2@HDDDDDDDDDt•p�) ”H/÷aÑ@ œd*HDƒ÷ñNòÓ®íDqCR)<íì!“Hú}MUK3*[šá TÁWí`u½�:ƒ ‰ é¹�N§N‡6m'DÁ^q®ºÎ`@‹¦£×v¤b1ldý—C¨kkCyS#ì ø:8ZL�ÕêõhíÔX݆L,�JÖ{TU»V‹’ÆÈÄbxÙ«t�.�N‡¢†zè ø¨ÕV¯CSG 0@)•AÞDZéôz´Ï_­8W[Xo0 Ùµ7‘ˆÄ°•ŸÛw»¶�Îâ{c:™R±Øl™5=? &õím¨hj‚“� Ülí•îLDD–µuv¢S¯ƒX$†�Üò}ÅÒýR.‘@)µ<ò¸Y£�Þ 7[Öý>Ó�©Г\"…Rj¹©ÙØÑ°•É!÷nÀ6ut ¬©ö Üíì!±0ÎZ;Ãd í Ó=Кî÷?€&+Ç®ÑéЮí4{¬]›îºßëMïeO–®“i`¯0/!b:'k÷ãî,µzít ëëÐÞÙ /µŽJ•ÅõLŸ¿žÇc­ ­�hõz³Ï‘µëÓ��\ÑkÄd[g'J ”Jái¯ÞO""":¦>"“¾Ú~èÑa‰µþ›cÿ�X$‚·Ú¡WI÷ö™5ÝÛ6Z-:tZ ý"Zh,,G·>•TfµÿÈÔ~¶týµ7ÀV®Ú¼ýµ• ¡½eM�Ðè;ájoou½¶ÎNè zÈÄb(ºµý €Ð–VÉäÛÛM¨lj‚ƒJg›Ag¥Ñ¥'à(WBvžç2@x‰¬IOêôã(oj‚§½=fGÁ¢¸D«7í QÚØˆ×vïÀö¼!l#‹qmx$þ>qªÅΡ}…x#e'²«*…eÞj553#cz­ï«�VVŠ'F�Ã’a#…å›r³ðô¦õð°³ÇŽ% Ësª*1ÿ›/-¯‹�-î:w$ ëõ\Nu^Ù¹GŠ …sñ°³ÇÃ׌Ƽ˜8³užÅý?®¶z]nŒ‚—§Í—75áß»w`k^ô†®­KÄbL‹À³§XíØ²ä@ÑYì;[€'ÇŒïw]�N‡w÷¥`Uúq´hºÄ ©׆Eà¯ã'õÚïüo¾DqC=þ5õ:Ì�‰µºÝ£%Ÿkíw8ùø_…åE õ˜ñŧV_7ÔÇ_.X$<^ºsÖœLƒZ¡Äú;—ÀYe#<7áÓЮíć³çc\P0àÆ¯?GyS“Õíû¨°åîû…ÇŠÎâß»3û¬©JÜ=t8– É@!]õt–9ˆ3O¡¾­ÞöXŸÐë¾u)ÌÿæKÔÕB)•b÷}�ôêЀÃÅEX²îû^ËÝmípmx$þ4jŒY@èöÕߘýfÃÈŠrwÇSc' ÁËGX¾ýt.þ¼ág‹Çæ£vÀcÆãºðH³å×|ø.à‹ù7c˜¯¿°<«ª¯îÚ�ÃÅ…fû]‡?�™`Ö!“[]…y+¿°z]ÆáãX}ÞdéÎíXsò„Õç?˜=ãƒB�ííõQױߙ< ;QXouz^Ù¹ á®nøáÖ»[r³ñ×M¿ö¹ÿLšŠ›ãÿúm+~Ê8Ùk©XŒ!žxqêuqvÖ×aî×Ë¡3ðÎ s0%4 0¶AoúfN×Ö`fd ^½öú>÷_ÖÔˆiŸÜk¹“J…±�Áxjì¸ØØš=§3°ìð¬8võím€ñ�’a~þxf„¹¸š­ŸR��G~^;¹z\XnjCÀgób¤_€ðÜ“ëFJA>î:Bhk½°} ~É:Õçùl½ûx«Õ€±=ôê®Ø•Ÿ'´)e ®�ˆÂ3¦Xü®ý|w"ÛòrK§_7[»‹¾SQw ©IÞ>xtÔ8Äyz™=—V^ŠÛV}cu{ÓÃ"ðæõ³…ÇÕ--x=å7lÌ΄ÎÔ#abHž›4UhŸtoŸY39$ ïΜX‘zoïÝ�/o¬\x«°ÎG÷áÓà�Jñóí÷˜  Ÿ»r9JñÊ´˜=¤×ö›5Œÿä}´kµrvÁ¯·ßcö|íMXÇ:9Cg0må nA²�¯Ùzùµ5Xºs;�úµìrÌ‹�Çâ䡽}¯lߊ}p³³Ã—7/‚„mšNÌþü3À‡óç#ÜÕ]x͉²R|¸wrªÏý=`+—cA|nMÊ>"¢+DWpP¹øü“Ÿ ¼DV�8Žwl�L"A¸«N×TcéÎí¨niÁã£Ç]Ô}é <ðãjœ®­�—½#üüѬÑ`OA>~ÍÊ@³FƒÿΚköš§sñøúŸ Óë¡V(æêŠÂúz”66âéMëQ×Ö†Û‡^´côQ;@dl¤´h:PÓÚ‚ïþ Qnîw®cðL]-n_ý š:: ”ÊéæŽfMòjªñÜÖMhÑh,—€�ƒc¯åÝ]ý¼ÙU•ð°³ÇHÿ´j4H)8ƒ Ù™¨ooçsnð9mËË:éúbðç ?cÇé\ˆ9»ÀEeƒôŠ2ü”y ÙÕUøîæÛ.Y£—½ºWPÚÝÎò�íxoßüsò´~·)5þð4´·¡©£*™Lh {ÚÙ ëÔÕâ�× S§C¬§Â]ÜPØP‡#ÅEx{ïnØÉåXŸtΔˆèòyw_ –>[¹¡.®8YQŽç¶n‚D$Æ�:.–“e(¨«Œ£žwäçZäcÒý~Ù¡Õ¢²¥+R� MÛ‰ç'Oïµ¾£R;ã £Êæ&¤––àþVãçÛ—À£Ç½D"Á«[GJC{JðÌæõâá ? ÷éîš::°dÝ÷¨kkCˆ³ â½¼QÕÒ‚}gÏà«Ô£‰DøÛ¸I_Û½�aâÚ#¨Õ™Î66½–«¬dY®<~ ó‡Ä#ÈÉÙú6årøšÎÛ`@qcÀÃÎ2cI(;yïA\¶r9œºµaÊ›q¼¬Ùø Ö-¾ê⊢bðSÆI|~ô  ܶ§kk ‰ðÀˆku Lm­^‡Š¦&üœy ÍÍø|ÞB³õþ±u£Èôsp„‹�-²«+q¨¨·­Z‰¯oZŒÐAÂþ¼ºs;Ö.¾³Ï�tN6*áz¶uv¢¦µ¥×çÎôz­^�{×­BQC=|Õæç�Æöv¤äãÇŒ“h×jñŸ³uŒDDDWƒ²¦F¼¾{§Pi C«½¤ûsP*a¯PBoУ¼© û Ï"µô[|¿èv«í_µУíæÜ£íöد?àxY)\mm1: Z-vŸÉǶ¼Ô´¶àë›Æ9—|»µ3«ZšÑ¡ÕB­PB­ìª€Ñs°S_:´Z¼¾û7¼sÃ�~Ͷ¼´¯ó™ÚdVV ÊÝCxÞQe#c»¶Õ--BF¤‰t}BgjkpË÷_wë3sCm[ ëëðå‘Ã(nhÀ3“§X|mUs3¾MMÅ�Æ÷¹�²¦&ümý¯ÐhµwsG¨‹ J›‘VR‚/‚J&Åü¸„_""ºtdJ(. 8/�oÒŽÞ¸n¦„†!µ´·®Z‰¯R�âO£ÆZLÝ?_9ÕU8][¥TŠu·Þ)”;^V‚Å߯Įü<4´·ÃÁØ0jíÔà¹m› Óë1=,¯L¿J©zƒoîÙ…åGá?);1)$¬Ï’£ƒ±vñ�Bc§N‡›¾]�œê*ì9{Æ,@øê®íhêè@�³ >›{<Œ�¦�íÇ»ûRðþþ=X›`VâäR)6ßu_ŸÇp¦¶ÙU•�I$X»øN8©º²öNU”ã¦oW`ßÙT·´ÀÕv` Ç”‚33 {Úž—+_½öÜ g7÷²ª*ñkVæô‘)x!V.¼µW'n_ÖžLÃM±ñf�ÙžL qGÂ}}ü(FYl@o?�‹N�Ã|ý±|þÍÂH³÷öïÁG÷á§ÌS ÑUo�qDð×7-F¸«¾8z¯§ü† Ù™—4@¸>+0vÌ4´·ccvVŸB±Xlv¿üôð¼½w76dg⟓§÷ üÀˆQ¸-1PÙÒŒ9_-G}{vŸ9�±ñfë:¨TfÛnêèÀµË?A}{ö Ü_X€º¶6ø:8bíâ;…�3?ežÄ3›7àçŒSV„ÝÛçkrHX¿ÙvÝuêtxm×|tã|«ëL ÅÄàPÀÀM~ÿMÀÛ7Ìé5ª¾»iaxiêuÂ㔂|<ðãdWU¢²¥îÆ,€‡GŽÆÆìL¤••âHI†úøáëãG×GF#°�à¥%_.XcÛosn6ž\ÿ�5kíÌÏÃO'!‰ð¯©× ŸïšÖ<ôÓ:œ¬(Ã?·m2‘?¹5ÕøîÄq,N°Þ&øÛ¸IÂg`k^ÿõGØ+”Û€'+ÊQÔP{…ën½KÈ•G‰E"¬Zt‡Ð§qº¶7~õ9RKKPÔP?GØ+fm�;×|‹ÃÅE¸-1�}^ç´-/ŠÎšU7èË¯Æ B›<'ˬOå‰Ñãð„1Y`×™Óxè§µPJeýöcõôܶÍhêè@´»þ;{žÐ&ü%ëžÝ¼Ûss0>$£ƒ,¾~UÚq\O{{‹ÏÀ¾‚|h´ZÄxxâ�9s…¿V;†ÏÀ–œ‰ˆ®2åE™‹–p\"…õu€Q]�‰x/oHD"´k»F;_LZã\,zƒummÂò/|>o!>�» é¹HòÖÜÔµµA­Pâ…)× 5âÅ"ž3AÎ.Ðêõø¡ŸòçK&‘Xì$¬kkÃÞ‚3€?�/àΤa˜…1Afç8Z}Wm|½Á€ú¶VayŒ‡'–Ï»Ëæ.„�Ür–@Ogêj!‹ûíìD·†â¸ !8�NÎxlÔXL‹8�³¹4|ÔÐ Xºk{3 Žéº·vjÐÑm¦;’†bÙÜ…øë¸‰}¼šˆèÊg0%4‹âîêp±íÊþº”Ótè lÈé >=~2`,ÞÐÞ÷,Ý�èêv�9}É÷Ü­bA÷Ké£vÀM±]4Ÿ9„³õuH)ȇD,Æ#F]Ð>Gù ÿ¯îÖv]•žƒ˜Ýƒß.6¶xaJW�ùxY)rª«¼/{…j…ïïßsÞí¼žLíc�Þ ”A€~X6w!>¾qÁyMàNDDt%ÛWX€­y9øûÄ)u`ú@¹ÙÚ U³R ò{Í�=P¦v¡Á`@]·þ›g|1–Í]8¨)bÃË^ ±H„Wwn‡N¯ïwýª–f**„T,ÆSÆòós2/z»5¯¦ÇJ‹!ðü”éBpfFÆ`º±¤ÿ¯–˱{ÚÛ£C«ÅGûöö¹Ó9·i;ÑÞy®gvÌüû†YøÓè±錈ˆè|©e ¨.BpÌ ¼tRÿôg³ÇéåeÐ pP*ÍÊ^^ ¡.nð´·GySf­ø ‰Þ>ˆóôFŒ‡'Føù÷j4¥–•’}|{�¶—ˆDŒ3µ58Þ£–ü…Øq:Jã$͵­-Ø{ö R©YÀ,§ºRh@]ão>JK!•âõëfZݾV§Ã“ëêµü‰1ã… ^ “3|QÜP�Ù_/G‚—7¼|åî�‘~BFá@ì)8ƒ1–Gdõ”eœÃi¤ï‘g‹â“.yöÜ Û7÷š(üö¤¡fóG™$zû ÊÝÛòr±1;3"¢.xÿ£üñþþ=8UQމŸ~ˆa¾~âá…XO/$ûøöš`œˆèj#„ÒÌ5­-Øsö >8°J© ÷ \‰ÇÁ0evyÙ«13*+R� ³²[ó²1H|¿¯7ïÏàjkÛo&UIcƒ0/a€£S¯ç;u:lÎÍŸ©«E^M5œU6B]_’}|a#“£²¥S?ûC}}ëá…!žá•Ìú žg·l€´Ç(ô{†�@Œ»g¿û59ZZÔ«-álcƒ¿OœjqýÇG�Ãÿm^�×ví0 ¦] Å f×rcvW x|PH¯9„î>ëN¥c÷™ÓÐêõÐ ˜cñ=Œù]Ÿ ±Hd6 긱iš—±»H7wxÛ¤©¥%BÀ¼?629îJŽWwmÇ{ûSðܤ¾K�D”›\mmQÝÒ‚¾\†cûxˆ±}lª¸ADDôGÑ©Óá•ß¶azX„0ìrˆ÷òŒ�¦üÚ ñè]5á©�¿ôª\ñÐÈÑBIRoµ‚�]�_[ƒ›¾ùq^Þˆ÷òF´›®ñ´Xþb vvÁè€ ¬9™Öouؘ��Á€1�¸>" ¯íÚŽÒÆF¤••Xìw9_¦>:7[;‹m܉Á¡Ø��‰ÌŠ HDbè æÁÍÑAÁȪ¬@Ê™|/)A¸›{¯m@²�$âƒÈ¯©ÁÍ_w]ûwD¹¹#ÖË r)»’‰ˆ.'µLÉÀ’œ‚¿ê¿ƒœê*<±þGÀؙԜ—+X IDAT³Ë’Âú:4i:æâÖoE)•â³¹ ñŸ=»°ïì..Âáâ"Àð»-i(ž;Qh|™2 ¬ÍCg*ÝP×~qFpÀ3[6˜=–K$øï¬yfGM]#Ëìär(­Ì÷c�Î`0ëH3¹gèÀáÜ>—ͽ o¤ìÄž‚38ZRŒ£%Å€ñ:-ŠOÂÓ&h²å=góqkBò€Ž­Ù˜q)&K™ SÃ"�`¥²Ù_ÆMBJÁügÏNL é¿C·?±ž^xÖ\|p`2*˱ýt.¶;¤”]Y¬SCÃ/x?DDW‚#%Åxfs×=/ÚÝÞjõ€^7˜û¾É¯Y€1“K`zX2++°!;Ëj€P¯×cúòOMíB›à+ƒU>:¸_? ­^‡ª–èôzxØÙcRHX¯u›::zØ•*|6oá€GyØÙcÙÜ›ðξ-)ž‚3Øc¬, ”JñĘñVヲûJw³¢bËý•66¢´±Ñl™�Ú°’èêâŠEñ‰ø*õ(V¤tÛ¥/‡‹ q¸¸ÐlY²�¯Å9ó\llq{b2>:´{ÏžéÊ~~�é;V ‰X,Ìïãu4•ç4hì§éi× l;rQ|"V§Çšô4,Œ½ð²U¶r9>Ÿw3þ“²ŠÎâPQ!u]S‰XŒ{†Çc£.î¼àDDD—ÓŠÔ#¨liÆgón>¯×ŸO{Ô;¹b‘zƒ-Ë„[,ôß,ŠOþ/‰ðñ� ðæž�Øu&©¥%H-í¤$‰07&ÏMžvɲ$=›s³ðþþ=ýœþ5»«M>=, ©ãƒC±>+²3/j€°Áض²Ö·dj›5ut@-U QÛa$A„?�‹‡Ö®Á÷íÁ[³-ϱâêŠW®›�/�FVe%ö`_AÀN!ÇccÇcRh￈ˆèÒ³—Ê/jp ^zëN¥ãåß¶A«×á¹IÓ„2Pýyeçv¤äcÃ�÷hx “3Þ›9Z-²ª*qª²Ûòrq°è,¾8zÉÞ¾BgžiÄtU‹åR§¦åsdõí‰C!“HЩ×áxi N”—á…[ðëí÷s ™:ŸZ4htºA5He V.\ÜkyH·’\àçàˆwn¸�ÙU•8YQ†§ó°¯°_?ŠDo\k,Ë`M»V‹ôò2 óõïs=[¹ummfe1~OÝ8¿×躾J£úªpgÒ0||h?>=|ð¢Ãø Œ A}{2**�V^ŠŸ3O¡°¾ÿ·y=†ûú sd]ÍÆá›…·âû©ø)óùyÖ,¾³ßÁ'ƒ½ï·kµØž—tr©ÊrøÛ�¢ª¥Ùbç�@qC=`Ì|ôspÄœ˜X,:Ââ~êÛÛ„òŒvr9½}ñôøI³ù”R™0ºC«ÅÞœ©­ÁÒ]Û±|€Uñ^Þø|ÞB´h4Ȩ¬@zE6egáTe9^ÛµÃ}ý-f¥}1ÿfØÈÍ3 ý—A7605Æl™¬ŸÉ¾9ë³2ññ¡ýVƒ¬ç#ÜÕ cƒãÀ®Í¹Y8ZRŒUéÇ-Î|gòp|“–ŠÆŽvÜ ÿóÌ,k: õ°³ÇÔÐp<1æ\MÀ^¡DcG»Õ2³¦v¤Ã Û‘R±OO˜Œ{×­Â+;·C)»ð?SBœ]ðÁìyh×j‘UU�“åØš›ƒ#%EøäÐ óñǨ€‹›ýIDDt9T·´à£ƒû1#" խͨnm†ÞÐU£)·¦N*¡ÏÅšÁ¶G­©ooöí`¥ èw7߱ؼ…èh>w²·Z�7fÌB§N‡œê*¤W”aWþiì.ÈÇš“iHðòÆœ˜Øó>ξ8«lðÐÈÑxm×¼·?Åêzgêjqª¢"c ÏS•åuîʂܜ›�¿�Ÿ|Ñ‚˜¦>“þÚ`¶r9 œÅJÔhÌl…¹ºáºÈH¬ÏÌÄ/§,—"€¡~þêç�fM²+«�YY�m9Ù(ª¯Çë;C¢�œ.ru4""꛽T[iß÷òóÁá%¢7ðÂöÍXsò<ìì🳑è=ð‘CIÞ¾°‘É`ÓG9-“Üšj/-�‹� &…„!ÞXzá–ø$ܳö{(:‹“åB€0ÁËkN¦áhIš5Øuk$ê ì)È$z{›íGn¬k«é1ɵ鱢�2�-”3mëìÄðÞFqC= êëf,!áꑱó2µ´#ºMÝ®ÕbþÊ/Щ×ã�nDd�Rb‘¨ß2bùµ58ZR '• SBÃëÙUærQ|øq R òq²¢¼ßá‘â" ñðêU¶ÓšW77ÔãXIq¯ÎÃ7÷ìÂæÜlL‹À“cÆh{ƒîê.d…Ô½ÃFâÇŒt|qô:Ps¿/{ Π¬©Ñˆq÷Ĩ€@Œ Ä­ ɘðéhëìÄéÚj$yû^Ð~ˆˆ.�N‡Üš*ÈÄ„»º!ÞË‘nîø5;YU•¨lnî÷wx0÷}Ø™Ÿ‡fãˆì¶o1{Ng0`SN6nKì�m'‹qâѧ|nO�Ÿlq;–ØÈef÷²œê*Ìùz9Ž¡]«í÷¾™ZZ‚¼šj8:a¸Ÿ?†ùúa˜¯nOо\†¢†zdTVX Fºyô*›>XŽJÕ J’Â8wÞ££Æâùí›±òø± Úw1žf×Ò×ÁoïÝ�”‚|‹B{…¾ȨlGÄËzZ²åîû»²&ûïå�”‚|ì>“�™‘1fÏåTW¡´±0–-¬Qþ�˜†§sû-yÛŸ¬ªJ¤——ÁÝÎãƒB�àåƒ/,NHƭ߯Äñ²¤W”1@HDDÅ�õhíÔ`ÍÉ4¬9™föÜ#?¯ÃóáÿÙ»ïð¨ªô�ãß{§§MHè½#ˆ"ŠQTÀ^ëÏÞ{Wvu×¶öÞ{©Ò{ï„émú½¿?2™d2“JÂ$ð~öÙçáös'Ù�“óžó¾ƒÓÚV{�ºöG«²È¿bßb4ÕQ®¨WË Õd×JÏËeñî]ÄX,œÑ­½SZÑ;¥úä)?ó×–M¬ÍÜßhBü6¾]³ŠïÖ¬ Ll¯lŠ?£‡\÷Ã7AÇ²Š‹Y²gCÚ†–›©�~­JÇè±ñà��q±9þìQe)^ ŠJ’Ù†RiªâÕÇ aööm|¾|YØç,Û½›ý……tNN¦GË– JKcPZç÷íÇ„Ï>¦Èåf{vƒÒ$@(„‡KL#jÎu)êåÝ% ÁÁ¯/¹¢Îƒ$×7„Æž]«´”{òóxbÆT˜:…ý……�ý>]„®¸:봮ݱ[­ä;�ükæ_�Ÿ¦ë¼üÏ\¶dgaPUÎïÝ/è9Jg�ÍOß(­éz Ò1!x¶WUl&S s•_!ýTbTT (øÂ¼9¸œ�cŸ,_ÊŽÜrJJí¨«ŒÂž˜1•ûÿœÂ^ÿàUÙ;„ûœª2/};'u¨}>ÿ²tmÝÌ?é;ûwååòÍš•ìÉÏ«÷LÿÆb3™¸ë¤8½ÞZå®Î¬[ybÆTžž53è^N¯͟Y…BÑhºÎ%_~ÊŸDNIéjñƒÅEø4 ƒ¢`·Ö¸ªË÷>Ò‹öhÑ’1]»þ[Vãå�Íé�BY&M׃¾Ó«²zÿ>ž˜1•G§ÿ”ŽÊãóú*Mqµùy}úѳeJ /ÑÊ‚Ÿe)a#é¾¥ékÿܼ10(�SRÂãÓÿDúµj2hU[÷�<³ÁpÈŸçŽÜž˜1•§N!«BÖ Ÿ¦áôz ‰þ> !„õÑ2:–kŽ=>è¿e¸‹ú u-ÒÞ×µ?Z™OÓX¾o/Ì›Àé]»×;UivIqé8ן¿±#7'°_÷g�â0|�U•‡Ÿ‚O×qz½!Çõ }òcSÛõÉËÆÇÊêH7„nÉ-к :ðÄŒ©�tð¿oÚè—ULÕnPÔ�Œñ6—:‡Çö9‹vïâ…9³xeîÜÀØ€ËçÅã+]W‹¿o„B4ŒX£…˜F "+‡|¶²t&NfQ#Þ}#èøìën!9:ºÁžwbûŽtHHdgnç|ö'wìL´ÉÌŠ}{Ø’�E¬ÅÂéÝÊs¦G›Í6<@”ÉÌØî½jlûÜ�;xí¬ójý®§uíÎÐöø'}'7þü}SZk±°dÏnœ^Ý’[”ÖI ãÑiðè´?‚ö�Û»/OžzF­Ÿ?ê½7Âî_qÛÝÕvÖÇöèÅW«Wòü××E}ðÝÚÕ,ß·‡³>ý€cSÛâñy™›¾·ÏÇÀ6i!©`…¢9±� ëØ™YÛ·rù·_08­-sý«ñOîØ¹AkÓáOÛ4/½´6ßã§Œ¡_«ò¢²;r²÷Éû¬ÎØÇž‚|ÒjX Ö˜*¦�*q»¡†®Ï¸½x{ñöäç1îã÷Ú¾FUeÁ®t2‹ I‹³sB»ð«½†¼ùrÈ>ƒ¢°úŽ{kÝÞ_7®ã×�¡©–®8æXî>ªÊë ŠÂƒÃOáÊo¿@¯õÓꦬ~uUu|§S:wåÌî=ù}Óîûó7ÞZ¼€äèhÖeî§Øí&ÊdfòèÓë}ÿ¶öx®8˜÷–,<¤vŽìÔ™´8;{ ò9ëÓ÷9¹Cgl&K÷îf{N6v«•Óºt?¤g!„ME›¸¸�¬DŸ¯\†OÓ¸æØãµOøâü9¼8Nоäèhî8±ê,Iý^y>d_¬Å›î`@ëTz§´b]æ~.úâ†wì„Ýjcõþ}¬?�‰Åhd|ã( ihûŒìÔ…¿·o 9¶:c»óó0 ¼zÖ¹Aez¦mÝÌ�¿ýÄ´­›ydä©U®@¬«'FŸÎe_ÆšýœñÑ»ôNiEvq1Ûs²Ñý5ÉGwétMY�\­�êôœ>ýømÃvçæ†Ä Ç áŸô¸\U®°KïWÅï¨Õhâ½ó/æ_3þbÁ®�AÁß­SytÔ©!5š…Bˆ#Ijœ—χ±†šÊõÕ":&¤e21(µ-× >>dÊb¨~ü¦bùUQxóìóù×Ìiü½m lÞ8Ö-¹�]å¸LY»âªèCÄY¬¤ÙãCVJÆYK÷WžÐÿðQlËÉFÓõ :Ž‹vï"ÍÏÀ6©AÁA€a:Ò1!�¦±zƒRËKª”õËmÕ¤ß/ï+ÿìº&%óéE—òÔßÓYºw7‹ýé\£Íf.0ˆ[†œr¯¤¨hÒìñ¤DEcTT¼º†QU¹u艼4·4°kRËÛÒ6!�§ÏÇ«óæ°#'‡}¥cUåäN�¹}ذ�1>!„ Ïn²`käà €¢ëzcMvàðxØS��×ç#Í_«�½=ùyd—”gµÒ!!1$ØÎîü.¯—Ô8»¤÷Ë.)fO~>6“‰Ž ‰µZ¥¨é:9n^½v¥dò�N2‹ °L´Š�Å,c8BqXÄ›¬X ‡çÿs%@(„B!„B!„G8M×Éó8qk¾H7E!D% `?ŒÁA$@(„B!„B!„GÈs;pI�P!š …³s#¥¯ò¹ B!„B!„Bˆ£ƒä{œ8}ÞH7E!Žzª¢�`²b:ÌÁA$@(„B!„B!„GŸ�‹Ÿ'ÒÍBˆ£–AQH0Û0*jDž/B!„B!„B!„8 {ÝzÝ‘n†BuŒŠJ‚Ù†AQ"Ö  !„B!„B!ÄQÊáó’ïqFºBqÔ0«ÌV"D„B!„B!„Bqtsk>rÝNtd¨X!“Í`Än²Fº B!„B!„B!„M#×ã@“áb!„hÑF3±Fs¤› B!„B!„B!„øt�<��¦Eº)BqD‰3Yˆ2˜"ÝŒ  B!„B!„B€ŽNžÛ…KóFº)BÑ쩊B¼ÉŠY5Dº)!$@(„B!„B!„"H¡×M±×éf!D³ePTÌVŒŠ馄%B!„B!„B!„!J| <®H7C!š³j ÞdEU”H7¥J B!„B!„B–[ó‘çq¢É0²BÔJ”ÁDœÉéfÔH„B!„B!„B!ªäÓuò<<šé¦!D“¥q& 6ƒ)ÒM© 6>MÃíóâõùÐtù‘ !„hŠ¢ * &ƒ“ÁˆAmšùÐEíx5¯Ÿ®¡éZ Ï ³|…BQ ¥}CUQýD£Á€Ù`Di©±„‡�xœ8|ÞH7E!šƒ¢o²bR ‘nJ­#Ý€#Õª¢lfäîc—³€öÖNIH¥_Lb�×z5Å.�§Ç�¦iýƒ·e�õÒX´BQ?:¥Á#Ÿ®áõùðø|T›ÉŒÍl&Úl•€aÊ÷~}x|>·�Ï‹Ûç• DB!„h4FÕ€ÉhÀj4c3™±šL4ld%N' V¯aõæ-lݽ‡-»v“�—G^a%Nn�h„B~&“›ÅŠ=6š»�ŽmÓ蘖J¯.�ا6kÓO«ÙšC½Ápda#øáàN¦åì {lLbç´è²_Š]N %8½žÀ­ÍdÆl4I8P!D£Òu—׋ÃãÆáváôzˆ2™‰µEm>:;wµUŸïýúpxÜ9”¸Ýx5_ƒÜS!„¢>EÁj4m±c‘‰e eV6ßϘɔ¹óX¾a¯…B4&£‘>Ý»pêI'p戓i‘˜é&Q±&K³ŒáH€°�­*Êæ­½ª=ç¦Ô^A+ œ%ä• v[”t®…BDœWóQät’ï(AU¢bˆ±X#ݬ&§>ßûuáñù(t–Pèt† ªŠŠÅdÄl0b21 UC -XYŠ0!„BˆÚ¨˜¦Ü§éx5n¯7�±À&`¥Qf 1V›ôëiö²å¼þÕ·ÌZº ŸÔwBq0¨*CÃÿ]p6Çèéæ4 »É‚ÕÐ|uJ€°�½°{ [Jò«=§[”�ImûâôxÈ**@×u¢eàU!DÓ££Sät’S\„É` 96s3îø4´º|ï×…Ûë%·¤ˆ"—3h¿‚RšaÀŸeÀblE¯…BqdðiZiÆ �›—+d“É` >*šX« ¥YΣ?¼f.^Ê?øˆå6Fº)B!D£éߣ·^9‘¡Dº) ƨ¨Ä›­•æ½ÐK„ ìÎ- pÕ�ö˪x´M_òÅ$DÅ`�Š–n³Bˆ&MÓurKŠ(p”�‹Ýé&5 µýÞ±ë µºŸÇç%»¨�b·+h¿Åh"Ö?+_² !„¢©pxÜ:»œh†—Œª�„¨hâ¤ÏÖþ¬l&¿ýßü5-ÒMB!›CŽåá[®§MË–‘nÊ!±LÄ5Ó”¢•É€VÓ !€SóQìv’–�„IVa!„hTE!):–‹•Ì‚<7)±ö£>}em¿÷k¢û°y%%è”®ÅX¬ÄGEËJA!„B4I6SiV-&Žg ù%%x5^ÍÇÁ¢ œZÄÆI_¦‚¯§NãÞ_¡ØáˆtS„BˆÃjÖÂ¥,Y½ŽÇn»‘q£†Gº9u¦ g²`;‚b:GΛ43iñIGý ªBˆæÇb4‘–�Äþ‚<öåçÐÚž€ÚÌÓ)DZ‰ÛÅÁ¢¼¾ò@b¬ÕFBT´L$B!D³ * ñ¶hìÖ( ]r‹‹ñj>\^{r³±Û¢HŠŽ=ªÇAN÷¾ô _þ15ÒMB!"¦¸ÄÁýϼÈâUkxøæë°X,‘nR­˜T•x“Ã6vd½M3r4wŠ…B4oª¢ÒÚž€Q5°7/M×"ݤf+§¸ˆŒüÜ@pÐb4‘ŸDËX»…BÑì(ŠBœ5Šv‰É$DÅÆ>ò%ìÎÍÆíõFº‰‘WXÈ÷Ü/ÁA!„Âïû?§sÕ}�’WXé¦Ô(Ê`"ÑuÄ‘¡B!êCA!%.³ÁHF~nPÍQ3¯æco^¹%EàLKމ#-! «IRp !„¢yS…Äè߯ þZË{ò²)t]©5÷ge3î¶I,\³6ÒMB!š”Õ7sÕ=s0'7ÒM KU̶#¦Þ`8 B!D½µŒ+­Cx°0?ÒMi6Ü>/{sspzܘ FÒâ“°Û¢"Ý4!„Bˆe6I�O$!*üu—æ“UTé¦ÅÅ\|ÿClر3ÒMB!š¤-;wqíƒ�“_X馱Œ$›£°¨†H7¥QI€P!„õ¦ Ð*.§×C�³$ÒÍiòœ7{ssðj¥)Ec­6Ú&$a6J:Q!„B¹£chmOÀ –Cå;J8P˜Ï‘œƒÂátqñ}±vë¶H7E!„hÒ¶îÜÅ­�?…ËåŠtSPPˆ3Yˆ7YQ�‚2q B!Ä!Q•”X;ÙE…xüµôD¨·‹}ù¹�š�‰Ñ±´ŒµK]b!„B¢ÌRã“0Jgâ:dæç¢¡aÂ{_z…Åk×EºB!D³°|Ýþóæ{mƒY5�l‰"Êpô”~‘¡B!™Õd&Öj;jÒEÕ•Óã!³ Ý_«±El QÑ‘n–B!Äae2H�OÂb,x+v»8Pp䥪ÿaÆß|ùÇÔH7C!„hV¾ûc¿Î˜uØŸ«±F3‰f†£l·…BÑ £cpy=”¸#Ÿ¢©ÉÈÏEóSâ≳J½A!„B� ªJ›øD¬¦Ò a‘ËyDM2Û“y€;Ÿ{!ÒÍB!š¥'_›ÙÙ‡íyÕ@²%šh£ù°=³)‘¡B!„ª¨ÄGE“[Ò´ K7eiE[ÄÆc±Fº9B!„¥* ­â0Jë0ç;JÈ+)Žt³ÄC¯¾A±Ãéf!„ÍRQ±ƒgÞú ÑŸSVk0á(\5X‘…BÑ`â¬Qx|>w¤›Òä$FÇÊÊA!„B¿Ò•„ ÕÒš„ÙÅ…8ÜÍ»9sñR¦Ì�éf!„ÍÚŸsæ³pÅêF»¿Õ`¤ÅQVk°* B!DƒQ…X«�B§Ìš®(Öj“šƒB!„•U­ìñ(þ™û óðiZ¤›UoÏ~ôI¤› „B^ù賿§ª(Ä›¬Ä›¬¨GñªÁŠŒ‘n€B!Ž,±V{s³Ñbâ¤Ãå—Ñç{u �æÃ«ëøt ¯¦¡:z 6¢ª(”þŒªŠAQ1* &Õ€Q‘9eB!„h£‰¤èX²Š ðj óimOˆt³êèiS IDATlö²å,Y·>ÒÍB!Ž«6nfÉêµ î×§Aîe0k2ûG=D  !„¢A™ FŒ·‹h©·þà[c)r:Y¶c™yyd`PURìñ´H°Ó£m;|‚€Õ)=§ô<¯/x澪(˜UÕ€E5JàW!„ Ên‹ÂéqSärRâvQètkµEºYuòúWßFº B!Äåƒo:ä¡IU‰3Z1©2ñ9  !„¢ÁÙL·Ñì ëørÞ\–lÛ‚Çç {N¬ÍÆ�=™0lm[´¨÷³4]ÇéóâôyQpa1±LXü5ƒ„B!U‹Ø87>M#»¸�h‹µ™d1ØŸ•ͬ¥Ë"Ý !„âˆòϲdåæ’œP÷̪¢c4K�ÁH€P!„ Îf6“S\éf‘¶eîçɾeÙöm5ž[èp0mÅrf®ZÉéƒqý˜3°Ẏô|ÁB£¢m4c3D¶K¹eg:oó]`;Êfå_·ÝÑ6Õןsç3}ÁBR’“¸÷ê«"ݤ-[·ž/§üØn•œÄ=5´û‘—_ÅérWy<:ÊF‡Ô6œz ¤µJ :6ퟅL�7¿ü^7]O|lì!½ƒBˆÈS•¤èXæãÓ4rŠ‹"ž¦½¶¾›>£Y×NB!š"¯Ïdzærù¹gÕé:›ÁH¬Ñ"Ù�jA„ H:ƒB!D)‹ÑˆÇëCÉîÞ€æn\ÏýŸB‘Ó @ŒÕJr\;àœ!'°jçvvìÏ {j*[öíçiLY²„õ»w1yâe´ªÇì»p¼ºF¾ÇI±W%Öd‰ØŠÂÏ~›Â§¿ü´ï¼SG3 G÷ˆ´çP¬Ø¸1ð.];´oÂ÷¾û�¦Ml«ªÊ%ãÎ$µeË*¯ùê÷?),.®ñÞ«¯pí…çóø-7að§„Y³ysÐÏ{ÒU—K€P!ޱVNN�›|G v[¦ODª�ß+L\B!DÙ6oa­„&Õ@œÑŒI²ÕZóÈÕÐL¸}ÞH7A!„hŒªE)�í%Æìõë¸íÃ÷(r:Q…K†�̧wß‹Ï__0!&†+OÍMgŽ \Ó»}Þ¿ãNúwìÀŽý™Lz÷² ò´m^]#×í ×í ´çpÑ4�Ÿ¦Ï ÙnŸhxÅÎ�´OÓ4~žñwƒÜß§i¼ýõ·<ûþ‡ r?!„M_rLù¤�Ü’š'“DZ‰ÓÉŠ�›"Ý !„∴zÓfNWµç•x“•$³M‚ƒu$Â$ƒ B!D9£Á ß� dëþ øâ4MÃj2óŸË¯äÿN;�5;w²ûàA&A´Õ€Ž�8®[éʹ?—-!>:†gþïj.6 €ìÂBûìs\žªÓ;Ö—Kó’å*Áy'MÍ_¾’ýYY!ûž9­fw8ã¤yþ¾»yþ¾»›ÅêÁ?æÌ£Ä¿¢µ¢Ÿ¦Ï¨õ=ôìxççï»›Çn¾‘ÓNtÎ[_}C~QQƒ´Y!DÓf1šˆ6[(r:š|rá굸=2a\!„h �—ë7„=¦PZg0Ù…µdhŠäSk@Úaž1/„B4eª¢ éÍ/@ÓÔèºÎcß|I±Ë…ªª<>q"»t`檕˜M&F÷¸æÌc�eñæM”¸Ü,Ú´‘QýûsÍicðø4~øg>[3öñåœ9\uÊè†o/:y'6ÍHœÉÚè)f+¦¶L°Ç‘›_À¾Y´z ' èr�Çë¥ØálÛcbP…}²sï^ívºuh�ª†Î¥s8�¸<ðÁc¢¢Ðu� Û·“WXD‡6mhÓ²EضRÒ[Íf¬ š¦±qÇl+ÓRéÑ©#ÒR)5ÃÙ•‘Ážý™èºN«ÉtLM ÛÞŠÜwì ¨¸„Ô”–¤¦¤`4Úìʪ>ÿU›6³}Ï:¥¥Õx�©m¸ü¬ñAûn™8�‡^|™÷¿ÿ§ËÅâÕk9uè�Cj¯Bˆæ!>*†b· Ès7éZ„«6oŽt„Bˆ#Úú­Û:p@о(ƒ‰£Yê " 6  !„åTE•ïÆ0uõJÖîÞÀ¸Áƒéž–J‘Ó�ÛëeÑ–ÒtVƒ»tEWtŠœ¥A¯^íÚg‹¢ÀQœuk9®{7&œ|2Ë·megf&?ü3ŸñÇOR#Õnsø¼xu &k£uØ]n7¿ÍžؾóòËxý‹¯8�“ÀO3f† Î]¶œKî¾/°½äÛ/yò­wùeæßèþßÙÔ”ºþZ.sjе½ø _Lù€“ ä‰[nâÆ'þÅÖ]»P…¡Ç à¿“î {ÇA׎½ñ6íØ À—_Ê%cÏäÚGgí–­ÜvéD¹éz^üä3^úøSð× œ÷ÙÇA÷ønê4žÿð#vìÙ´?9!�«Ï;‡Û.›ˆÙd :VTRÂ3ï}Àç¿N Œ¶n‘ÌM.æÚ ϯ6Y•¬Ü\f/YØ~ì¦xðÅWpºJÓ¿ü4}&w]uE�ï[f܈á�!Àþ¬ƒõ¾—BˆæÅj2a3›q¸Ý:$EÇ¢4ÑÀ-þ>€B!ÇÎÝåÿZ FbŒfŒŠ$Çl l@º ‚ !„Š5™84ŸÌ.¯åöË¢Eü²hQÈ9ó7¬gþSëÃ^ÿφ œ÷ÔS!û�n¿/]Â…'c{æ~vde�ªEŠ=ž4{X¾œbŸ‡¶ IÛ¶-ǵiCÇøÄz­ôê9nIf[ƒ¯$¬˜ÞòØÞ½hÓ²ãG�³óò˜»t9#�\í}æ-[N¢ÝÎY£FPâpòóÌ¿q¹Kk4þ÷�÷sÒ‰ti×6äº�ÛwЯ[7†ÀêM›ùgEiÚׂ¢b&=ý¾ófØßüñg�jêóßwßlìÕ“‹Îƒ‚ÂÜeËømViPnÚ? ùcî<Î<¹´æäSo½FÛlÜqù¥tHKeþò|òó¯èºÎÏ3ÿfPï^Üpñ…µnÀ÷M ü{ô ÇeµrÖÈ�áæ�;Y¿m[•AÒšL�7?h»sÛП�Bˆ#W´ÙâÏH¡Qät4ÙafvNDžÛ2!�mZ×êÜãûöá¢ÓFó¯›oà…ϾàåÏ¿jÒ“Ü�½»÷6nßHó^&9Þô$Æ5ÝT´½:uÄTaÂÓνûBúƒqÑÑAï”™�}XÛ(„MQN^>‰f[¤›qD’áNÓu\š—æÃ­ùj•ê­ôœÒó¼¾àÚQª¢`V XTÕ(9~…BˆF¢£3}ÃÚÀàÍE' #6**p|ÞºulÚ»›ÅÌÄá#C®Ï),àÇ Õ¿?SZŽmؽ‹6l`Ûþ r‹ŠHˆ‰Áj61 ]{´kÏUnsvnç×íÛøzãz¾Ú¸Ž‰IŒíÚ•SÚ´«s:JŸ?H˜Ø€A¼ÂB¦/XØ>kÔŽï×—–‰‰ҌΨ1@Ø"1�_ßx�ŽþÚÎ<�‹&݃×çÃãõòÑ�?óä·†½vÌI'òþ““1�èºÎ#/¿Ê{ßýÀÊ Yºvƒûö ¹.¿¨ˆñ#‡sûå—’–’Rãç²zÓæ@êN€÷žœLjË–\uîÙœÇ]�U�³—,åÌ“‡‘WPÀç¿ý¸æÅîåìSFpö¨‘جVÞúê›ÒcŸ|ÊÕç�4hU��{÷±¼B¡øñ#K?ÿSO<«Åhë�ÓfÔ Ü›y€_þžØÎÍ/`ÁÊUü<³|õl«ädú÷è^«¶ !„82(ŠBŒÅB�ÓA‰Û…OÓê•»±9J"Ý„Z‹¶Ùxôºkhתw=ÿb¤›S¥Øè(f¾œ5aà„ËHÏØ±65„/ž~’¶))�í+}‚ßæÌ‹h›„¢9(*n>ßµÍ��P.͇ÃçÁåó6hr7M×qú¼8}^\X FlµúÔ]B!„¨=Èu;Éð§®4�üß©§ ŠmÝ·�M{÷�ËÅÆ…Ücnn @xL§ÎŒXž‚jÙÖ­ü³¡4¸s ?Ÿ„˜˜ kmfcºugTç.üµu3_mÝ̦’"–,[ÈÚ¼,ÎnÛ™Îö„:½“W×Èó8I0Ûhˆá”Yspûg‘«ªÊØáÃ0¨*c‡ŸÌ‡?þÀïsæòì=wa©&]êõ]œ8ðÆ?9 ú{Ñ" 4@¨ª*OÜzS 5§¢(½kÕž¦M¯mV+§=!ðìS†XEøÓŒ¿yè†ëª­µdÍZ–¬Y[íóî¿öÿêœU!Dóm±Ràt �»I®"¬Xß7’\³‚ö%ÄÅb¯ÔϸrüX¬ZÍ·ÓfÆ6¼¬¼|vî+OמSPÑö4„‚ââ wÚ/+…‚¢ 6ù+ûãÒ¼yÝx4­gÁB£¢m4c3ȯ”Bq¨ =.Üš�ìÂÒA{TtÈŒùBÿlõø¨è°÷ˆ«°¿°ÒÌö¤ØØÀ¿³ 5•pLc»÷dh‡Ž¼¾j9¿ïÙF¶ËÅ�»¶Ó'>‰ámÒêôÝïÖ|z\Ä™,µ¾¦*ßUHoY–^´ÌY£F„EÅÌ\´˜3†�T彆 <&dßÉÇ ·íÞƒÛã ©�—šÒ’NiiAûb££Ø«'³—,… iH+;¡ÿZútí´2²,¨fPUôìÁè†pú°ƒVëmܾ=è—Ü}_ж¯Rqszz­„áÒ‹–©˜ftWFË×o`Pï^µ~׊Œwÿß•L7¶^× !„hÞl&3Š¢ ë:Î& t{¼‘n›v¦3òÚCöwhÓš['\ÄUg� š°óŸÛoaÊÜù”8�‡¹¥ gò[ï2ù­w#ÝŒõÕŸñÕŸEºBѤTN1-ŽDsŽ.ÿ€›WoüÀ`8^]#ßã¤Ø«k²ÈŠB!„¢žœ>/%¾ò•q@Ø1ŠRõ1½Bð§rúÊŠ�!µ©º,V|=“[°±0�¦±./‹O §§v$ÑRûZ%>fÕ€õ&í;p�…«V¶‹îyö�mŸ¦ñ§¹¬.@˜’œ²¯U‹ò}º®SPTDrBðªÉVa®«¼¿ª:ƒ q±a÷W%Êjå£ÿ>É]Ï<tôiËÖ­gÙºõ<óÞ |,o?ñ ö¸ g;œNæ,]Ví3rók7ë~Õ¦ÍlÝU^3ã`VÐç_y ñÇé3« ¶iÙ’c+·Y­tn×–ñ#‡‡a…B=EÁj4áð¸qøë‹ºÙ¹/ƒ{^x™¬¼<î½òòÀþĸ8.=ŠO*¤#¯,ÊjåÄýi›Ò«ÅBfv‹×®cwff­ž­( ½:u¤O—Î$Úãðù4ròóYµy [vz�ív­Z‘`/¯;˜•›ËÞÛIv;i­ÊSz:œN6§ï UrÃŽ@‹„JœNÖmÛβ дšópuïО¾]:“h·c1›È+,bç¾ –­ßpÈ×䄸@yüýÜ­5|VÚ´f`Ï$ÇÇ£é:srYº~}ÐgNU5UUá„~}éÚ®6«…ýYÙÌ[±Šƒþì&Õ±˜L èÑ�®íÚ‘K‰ÃIFv6Ë7ld–¬†Bˆ¦F„Íœ¦ëz]8|McÆšW×Èu;°¨FâL R£P!„¨5Ÿ®“ï)¯3—SDÊ/)Æëó­8‹õ¯ØÊ/){¯ü «clQAÇr ËŸšv*UQ8¿SWÒ óù~ÇVE¥ÐífÚÞœ”ÒŽÔèÚÝ ßã¤êÝOøqú ´ AÎu[·±në¶*ÏÿëŸ;�Ô›•å­@ÈÉÏÚŽ‰Š¢²œ¼ü�}•¯­ê™õ1¨w/fò!+Öo`Êì¹ü1wnÈ€Ñì%KyàÅ—xû‰Ç°VH«ÚºE2×\p^µ÷Ò¿_­ÚQqõ ÀÒµëXºv]•çÿ2óo&ßvs•u£Žëׇ·Ÿx¬VÏBqô±™Í8o“­CØüï“Ϲè´Siߺ¼.õ%gŒ  ´ÇÄðÀÕWråø±!iÚu]gÎò<öÆÛ¬­¦ÿuÎÈá›v¦ó̇Ÿðó¬Ù�}mSRøé¥çQÕÐ>â//¿€×ç㵯¾áßàÞ«.câ§Îy¸ÿå×ÛãN>‰î™Ø^µi3gÞ6‰'o¹‘+Æ� ù]Ú´3�ŸzšÕ›·„mó©CŽç‰¯£GÇa�;œ.¾øãOþýîûV¨—5÷Ãw‰²Zi]irÙówÝÁä›n`Ú‚E<ðJi»Ï5’ÿÞ~KàœEkÖræ­w†}Þ˜¡Cxðšÿ£o—ðõž—®ßÀSï}Àœe+ÂWñ@N.o>üÚ´:×íñòþ�?3ùíwñxCÇ MF#·N¸ˆ›.:Ÿ$»=丮ëÌ[±’'Þz—•›6‡m�BˆÃO„͘Óç¥ÀãB Se°¨°ˆ�6à8Äü¼û÷eðÅÇŸ�Wi–PœÝÎÄ+/§s·nôèÙ“˜Jƒ‹.ÍK–ˇÝd9¤B!ÄѤÐëB¯ð½ÞÊ¿bÍëóñÚ”ßAA€ÝÙ¥5f² Šxÿ¯©!÷Ê)*þ³a=»loÍ(­k¢* ) Õ×\²};Ÿ.ü‡Ö Ü6êÚÇÚ¹¬kO~ßµ—æ¥ÄëeÞ�½œœ’Jë¨Ú uJ'8Å›ê—&¬r€ª&§“©óæsÞ©£Ã_±~½+ ¬¬X¿1ðï6-KgÍW¶kÿ~²óòHŠ�ìóx½¬©0¨Ô¹]Û:µµ6ŽéÕ“czõä‘›®gKú.~ý{ï}÷ÙyyÌX°€Ô >ƒ�Û.�xÈÏöi?MŸY§k2³³Y°b%' X‹³…Bˆ`cù '·Ï‹M­º®°¨šÇëå‡3™tYy`@÷îXÌf\Vg¶JNâ‡ÿ=K÷íÃÞGQ†È_o¾Ê�O=Í/³æ„œsÏ•—ñàÕWUÛžîÚóÁäGyéó®üû�÷0�!�©2i)¥«êì1áÓë׆¢(¼ó背­"³D÷íùéÅçqÍ�ìÚ¿?èØecÏà¥{襁®²ÍjášsÏf`Ïœyë�ô³íZ¥„�lÖ"!� ¥«ëê±®åމª=çØ^=ùáÏòä{ðÒg_ÖxÏÝ»qÓ…ç‡í÷šMFnºè|Ì&#÷½ôjÐ1£ÁÀ'ONæ´ޝòÞŠ¢0là1üùÆ+\;ùI~›3¯Æö!„h|¹i†tm¢²ôcóäc�ñåÇŸͬo +––ÖÖQU•‰W]ÁÓ']¡£¦£“çqbӌę¬4ÆZ½™™ü0½¼¨¶ÍbåÚfÇ7U V®béºÒÙ÷‰qv.ß´jí,\µš%k×í³š-\wáùaÏûëoq{ƒGõîÍÐý©>Mã�/¿ lŸ~ÒItmß®V׿äçóùoSÛÎ8ƒ‰¥ã‹×¬eÑêÒtuö˜®8û¬Cj§BÔ•KóᬔàØn]1¨*>Mã÷%KÂ_çqóõܹÕÞûŸ Âîï‘–Fœ-t°¢Ì†Œ}<úÛ¯ºœ°o/qÖ(n9’–¶(ƵïÈ/éÛüßø:Ës29NQiQÍý*rú¼¸ ¾:§%ß´cgÐjÁ‰cÏd`ïžaÏýÏÛïVóý8}f•·¾þ†³OIlti?fçÞ}|õû�ã#ö:�ÇËÆ“wÜØ÷ñO¿°?+«Âµƒëô~U™ôôsÌ_^:û{ô ÇóŸIwе};îºê ’â¹÷¹p{<èºÎÐcÊ¿s÷ìÏdãöôèÔ1°oÇž½Ü÷ü �íg﹋ŽiáëQ–™¿|™Ùåé¡n¸øÂ°ßú®óðK¯âö׊øqÆL  !„¨S…Iǯ›I„õµ¸ÒгÉHß.�Yº¾´¯¨ª ï>öpPpЧilß³§ÛM÷öí1›J³™·yˆ�û2‚VÜõëÖ•û¯º2è9^Ÿ�½b6i™˜´rïÎK'0oÅJþ^²Mó‘WXˆ¢(Øc‚'ž£iNwýëPõéÒ…~ݺV{Ž=&†».ŸÈ�Ï•÷‘Z''óôí·†=^/.·;$øwL�î\væ|à_é˜_Tš $.:&hud±Ã�Çë­sZÒkÎ=;lpÐåñ  ~Føs�^w »32ù~Fõ“¼î˜xIØÕ›]uöxÞüæ{vìÛØwåYãB‚ƒN—‹Œ¬,b£¢ƒ &£‘׸�%ëÖ“™�S«÷BÑx$@ØÌhziÐÍ­ù¿åšk™ù×4UÅœÖÅ\6ëG'ÌBÃPêUyz…s|N'ÚÁl>ûà#2öîåïBg$9|^¼ºƒ“5¤Ò¡úä—ßxéãOƒö 4�îU¤{hÊf-Yx—®Ú7¹áì%Kyá£OBöŸ}ÊHZ&&íÛ›™Éc¯¾rî—_zÈBÍçãÉ7ß l·kݺÖƒ9¹A׎<8 œ³d)Ï}ðRÛH€Pˆ&d{N6:Ð)1©Q&›Ô—¯�'âVH-Z&ÎEïvíY½³´æ\”ÅŒª”Ôt4Š�¥×˜M&Ì•Vì;Ü.|š†ªªD™Ëg»½ÞÀŽ¡½ª® —ž•Å?üPôð›2ËgR'[£8-­³÷ïBQÀ­i¬Ê9À -S‰®Pˤ¦w¶XjP,Sqõ ªªÜÝÕUÖ\±a#ŸÿZ:1äïÅKÈ+,$>6´öß–ô]œuóí\}þ9—8xã˯5ªªrõùçVÙžw¿ýŽÜ‚|†>–Õ›6óþ÷?ŽuíОáƒÕéýªÒ¦e Òý1ýø31QQŒ8n0f“‰-é»xµÂ¬ðãúöAQºuèÀÐýùgå*®zè¿å&ºuhφm;ø÷›o±soé=õîE‡Ô65¶£âçeµòàu×`³†_ ú×üLûg¿ÍšÃÓ“îªs#„BÔ†É`ÔvûÂ�…ˆÚÙ¾w_Ⱦ”¤¤À¿Ï5’¡RŽçpéƒ�‹mSRøä©ÉôëÚüƧo¿%(æÄÓÇ™VnÚÌÄ ƒÒRZòÖ#rB¿¾�s®>{</YJzÆ~:�;—„¸X¶þZÞ§qÍ ¤g¯ê«+UUpº\¼öÕ·üéOA?´?¿ñ:â¢Ë'¼�:$èº N…ÍZÞŸÎÌÎáŠGV[''óÖ#pÒ1çœ2ä¸@€°ß…—”~ß|”ÒóæÿhßžÌLzþf-]†‚Âé'å…»ï Ìýçö[˜2o>NWèß?ŸU›6óü§Ÿ³9} ±±ÜyéN?qhàƒª2zÈq¼ûÃO�}—ž1&è>_üñ'÷¿ôZ ?}L�î|öÔ¿h•\ú»Å…§Žæµ¯¾©Ó» !„hx lF4]'ÇíÀ«‡�œ1u3ÿš†!ÊF›oÇܾ]é¬~�@€P +ì÷ÏòöÿÃ8ôü mÿùÞì\ë6RøÓ̘:�¿§Mgd˜ÙùÍGŽÛA‚ÙÖ u 3ûéÇé3xàºkì¢zK×®ãÌ“‡…ìBˆC•ïtp×”_X¾o¹nðÆtíÑàNêÃÓ€õ�>o•ßïŽê?€ÛÇ—N`ðú|\üÌÓ: îÒ•Ç'–§‹r{=LxæYŠœFöíÇý\þÕ†7¿ù&»$ÆjãŒAáWÆíÉË㎯¿ä@a!j î¡NFQ~PퟴèX&¥°:7 E/]¹67‹Á-RP•šëyu §Ï[ët亮e8®oŸ*ƒƒãGŒ=¿ÏžÃÄq¡p:·mËúmÛ¸çÙÿ…»íÒKè[ÅLóÔ”æäðÝÔi|758í©Ålæ¥îCm :Iל.Ÿýòû³²ði/ú9/úyÈy&“‰¯¿6°ýÜ}÷0ö†›É+,dÇž½\õà#!×´JNæÍÇ©6eþ™àSf—¯V=uè UÆ�æ0kÉ2N­4à&ŽˆO IDAT„BÔ†I5àöy´ÿu4*,­]�W>yꪳÇ{öÃO‚VîÎÌäÖÿ>ˬ÷Þ�ïÛ‡îÚ³ig:]Û§Wÿsþ‚ •b{2ðÄ›ï0õÍò4•}»ui�÷«�;Ÿ{�o§•÷'7§ïÂl2Õýk™˜H”Õp ìÑ#è¯}õM 8�‘•ÅL ¶¬!�}]>`¥Ëíæ‚{îgK .µÎ”¹óÈÈÊbê¯~NÉñvÎ5‚/ÿ-MPfsú.μmRPñêÇÿÍâÏ?¤xBÒÀVž´ýÝ´™A«"WlÜÄß|Ç¿n¾!°¯o×Ã÷3BQ5 6š®“ívà«bð`Ù¢Òz3ÑÇ D‰ŽÆs0;8°!Ãò€ ÿhå@a¥aàú²a~M˜ºvƵf=K.  Ä?˜ãv�d¶5ÈÀîÒµëØ±goÈþŸfÌl–Â!ýújÕ'ÿ|¤,Y³6$@¸l}øTv‘–h·Õ_*[=(„hš^š?'Ø‘“ÃCSç�Å ¹þ¸È }Zm–æ×N‘×]å±Áݺ1 S'VnßÎo‹Ó§]{Fõï�Ñ``H�žL[±œE›7‘W\L¼æó¼õë)r:8±WiúMM×yéç_Ø}ð —Œ8™X›-äy›²³xäçÉ,,@A¡tù Ž‚Jva EN'ö i”zÄ'“åt’å*}^¡×EzQ>ck÷ÿ±E^w­„ë·mGUTÚ·)]évÑécª=ÿ¤AÇгs'J¥KÖ¬  |㱇¹õ©ÿ²Å?°…?ýѤ+/箫®¨òþÓRyüæ¸ý?Ï ¤´iÙ’×y�cûô:¿MË8]¥?k{˜•Œñ±1�÷kÓ¢E`¢ÝÎoo¾Æƒ/¾ÄŒ…‹Ã¦’ïÒ®-Oß5‰A½{íûå�W™ôô³,[·>è|UU;|ÿºíVÚ´lr¿Ê¯YK¢ÝN¢ÝÀù§…ïó•9ý¤¡tLKEóÿoeáªU�aÛV­(v”þδ¨ãš=66ðá¯{#„âÈfPUð�V͘ˆ¨™¦ÿZÖŸŽ²ZÜ;8»D¸Õmë¶mgË®]AiH‡öï•8‚οcâZ&%òý´,]¿¯ÏÇÒõH^}?¢1äñ]˜ZÊóýÙ*жÙA®_}�Éo¿8–™œ36:Š‹Çœ´ÏÐ@“Ä*qlpvН¦N«,·|ÃF¦þ³€3N*_ý7|ÐÀj„_üñgÈ C—ÇÃ’u냄ѕþ†(v8ˆª0iì­GàƒŸ~å·9sÙ°c'¯ý-¯ým�ÞU!Dã“a3 £“ë©>8°ÏŸvÊ :”¬ÛHÉÚ�èRpèU¤Õõp; “hTÄ Õ€¹}jL˜Œ ëìݰ«Èç&6@�ðû¿¦þ]qf׎={Y¹qzt¯õ½\n7{ !6Ž{\�Ú‘“ŸO~amZ¶Àb®[-„¼‚¼>É Œ<~0#�¯¹NQ‰ÓIfV6::Éñ ÄÕ²@÷�œŠKõjg8Ñ6ÅK¬,€,;§6ܲspyÜ´JNépÖÄãõ²7óV‹™”¤¤°« Z$&ðÈMׇ½¾.Ï9���®—ô6ÔÊ!D¨%{BÿÐØ™[(¼nðNï™@¡Nà P¹4_•«ËÜ}îyÜúæ›ä—óìßãñù3p §À´Ëñú|ü±l—œ|2¿-^ þ¥ƒ»vÅíõðÒÏ¿0cÕJŽéÜ™óN8±ÒûèÌØ¾�7Íg_Æ>Õ>W]Wðùt²Š ƒ„ 009…™ûÒ)`wÓÂML-jyu —V»Z„½»tfñ7_Ôx^“ÑȬ�?¨ñ¼vmZó÷Gï3kñ¶ïÞCb¼=( uuÎ>e'<†™‹“›_@§¶m1xPØTš_ýï¹ïwÓ„‹¹iÂÅa�µmÝŠÏž}š99¬Ú¸‰ýYY8œ.âbbèÝ¥3}ºv ûý×½c~û 6nßÁêÍ›)(*¦EB<Ç÷ïWí ÌÊN>vP�>ÿø¸8~ºÊàï�߯õ}*»æüs¹¦š´¯B!ŽW°ôêÜ)ðïÅk×1î䓂νúìñ\}öxò ™µt9Ó.bÆ¢%ÌÍm�÷ª­ôŒýaÇ¿r «½nVyýeƒªrL�îôïÞ�Þ�;Ñ«SGútîò™4–>]‚WÞ-XµºÊsç¯Z ì_CýÅ�û2Âî/û©Êâµë;¬ügÞ21‘®¾’®¾’=™˜¾h1Ó.fβåu®·(„¢qI€°‰Ó�\·Omë饋ûÜ™d¼ùQP½ÀF±p) ç�«°â°f^]#Ïã$Ál«w-)�×ËÏ3ÿlßzé%¼óí÷ä€?Íh¸áܥ˹öÑÇË·?ÿ˜×>ÿ’�ú%0Kj`¯ž�6S7œp3°ëÃáóÔxNJ|<�OœÈ}„Ûëá?þÀ²-[¸öô1ti݆­ûøfîlN8�õ»w±6½t÷øã�g˾ ^þågÒ 59‰G.žôY¹Ý|²n ³wídÿŽtÐtUGWÊÞ±t¡¦ëìËϧsË” öEMt·'²­0]�¦‘^”O¯øäÓV–}µ 6&“ÑÈ©CO¨×µÉ 5®flH-ëÕÖ�:Ò£SÇFi“BјÊþÖm¨þ×Ѫ}ëV!ûæ”é’âíAû�WŽͼNb\ùDë÷ú… F�¢_˜`T|l,çŒÎ9#‡£i: V¯æ•/¾fú¢Åõx›ºÓ¡†xYÐküðaAï{8T{LLоêê2V>V–¢*aÔ¿ßyŸãûô › +-¥%W�5Ž«Î‡ËãaÊœy¼ðéç�•…B!"K„M\¡Ç…[«cn]Ç“�ºÎY^ÀéãÇáÕ´ yDC.ÚUù°ª8qÔH,KฦiL¾ç^~øì ¼ùùu~/·æ£Ðã"ÎT¿V³—,%;//°}Þ©£Ù³?“/¦üÀ/3ÿæñ›o ˜ù¼äU˜ùôà /ñÛ¬9Aç,_¿� 'ÝÍKÞÏ„3Oìw8]�k÷<Èùwܺá_…øÝÔiÌ]ºœ_ß|5(õUaqqàÚ¢’.¾ëÖmÝô\§Û8§Ð€*óíÔ¿¸íÉÿ†tÖt]gÝÖmLzú9¯YËKÞ_þ®W<ð03+u´u]gåÆM\ÿØd–¬YË¿o¿µVƒ·• îÛ‡¹Ë–ãöxXµqƒûö`Íæ-¸=¥ƒÝƒûô®6@øÍŸS™ôßgÏ2š¦±rã&nù÷SÌ\´˜×y°Ê•zo|ù5+7n Ú·+#ƒ{Ÿ{�µ[¶òì=wÝ·âÏßç«Ý[ÒwqÞmwr R·ÇÃÏ3f2uÞ|Þr2£O�ºJBDžü|žü{-_|X… 1ƒ]G¯1Z¦Oûö<Í5Lþâs² ™µv ó6¬'ÉŸª²Øéâ­?~gÃîò•—³Ö¬âóYåjúuèÈc—\H-ªé:ów§óé†õäº�çäàõxPUƒ¿? TÈ àÓ5öUøþ­¨S\<{K ñøtT \nмbk±ŠÐåó¢õ&Q[R!„M�"+ÄÀžÁµô¼>kücVsýWÀÙ+d6rº\œ~Ë\1îL.{}º„Ÿ”«ª 'èÏÐþýxèÕ7xçûëýüÆÖ:9™©o¾Jj˜”ìùEE¬Ü´™ƒ¹y\0zT£¶Ã`P5˸SÄÁNmÚpí˜Q¨U*¶'%³÷Ðaæ¯\Í ãÆ22nÿü‹"j5î¾þZ:¶‰d{R kw$ðýo‹éÑ¥3·OžärêØ«—òyïá#Š@h›ß¨qî%[Oç¹÷?RÄAO®9_ooÖ&$rê̯[OÏ.�yâö[�¶³?5 I’ˆ�í�Ÿ¯Ûö&)!+fý±ŒñCâ/Ø‚õÓï¿©³ˆ°Pî¾þ:Ôj5 W¯!=ç$:½ž_û7»ÌiV¨6�@вüÙBaK¼žÒ™ŒÍj'ºM¾xô1¾]½š­G,Žò36‚Ýæƒö?²O•X®]žînÜ4t8·Ž�F­Æ,Ë(8Íï™ÇI=[†ÉdÂTWGYnž%ª¨Êò4 !ƒd H K «àTƒ4’Š6Þ~dWœE’dL’™SÕ•DŸw»d@o6⩾4/-�@ üµ± z~YûñWF¥’˜:Ö>ŠÐÑÌ,%-H}DžzŠÊʸ埯ºÔveµ½¥¯«ãû%ðý’?èܶ “‡ eâ°! èÑÃAà’$‰×~�%›6S\æ| ÚåæÍǶ‹ËÎòÑ/sذ{/ÙÖT;׎~ÉÂ:ƒ‘ªš»P±íZ·RÞQ5&"ÔþýD©5êÖ¥ °´”w~øÿýégúEG3iøP& J×vmêzyxðß§Ÿ`ä�\²þ�À5„@x…b’eÊ zj6B•§‡M�½(7*·ÿ_£0¡2¸{xÐ5º›½¸(7ª+I¨.0Öz¹A�V¥FÝ Ç@�NÇêmÛ•ékÇŒ`xÿ~øù)aFÿظ鼡Ÿ�7¿Íœ¡„#]ºi3�¼ñdY¦V§cÖïKy呇œ.;8¶7ó>zoOOŒ&O¾õ.¿o°—;’’9t<�^NBj––ß'–Çn½™ˆ°0¬Î�¦ØŸšf÷ ¾`ÆŠ;ñ÷ßË5�=©ˆrë7€ªš~X´DYæÝéOs÷õ×ðÐM7òìßgþÊÕ|ðÃOÜ2qB³_¢wïÜ_oo*««-"åm–|Iû¬}qws£w·¨&—ÿdÖl Ö‘n>^^ü6ócúõèÀ¿{˜©OOgÿ±T>Ÿ;�¦Ý€‡»óó샦+Ûw43“iO?G©õö—ó\”@¸.!‘£™Ö•îî,ÿê ÚXóû§Naø�÷�_XD­NÇ'³fóþóÓ/x]� e¸\ŽÂ AßÜ(@°Ÿ¯Ür 7æcÙ®]ìJK£¢‘󼞶¡¡ ëÞƒ)ññúøP©×±3+‹•ÇSÉÓ×¢Òª-÷qµšÒS§1 ¨4�,/kd¹þÆ/YþdÈmÂAÐÆË—¼j‹S[–á¬^‡ÁlBëBøP½Ù$B�@ ‚KĽ×^C÷Žìʮߠ|>Y`ŽÒÏLJƒÇÓ/8ìd=™¹y|6ÿW>›ÿ+¡��L6„§N¡‡MØsOwúEwcÝÎݵ®K�F­fâÐ!ve�½û›öì³+k×Ú1|ë¥ ýd.}mRêô‰ŠbùÖíNëëÛÇn:37ï’÷Ïl–ÙwôûŽã?ßþ@—vm¹näšz=aAAJ½˜.�ñ÷ñq¦�@ðç"Â+”J£¾±\ç:’d·¬½8èÌ5ØhŽM•î±½P[CXtA‡VœN»ŠŒL¥QO€ÖÃ…ÚVoÛn—ÔøÚQ#Áê’›8|¨"|-ß¼•wž}Ú.ÉvcîŸ6Õ.WáõcFóÛšµ¬OÜÀÚ„Ä&Âw§?ƒ·5D›F­æÝéO³zû%�ÞúÄ�N¶á­YðñMŠ]�iBå�Ͽ⡛¦1°w/´ ß½ù%V1,ÐêMLÙ¯ŒàsÓj¹uÒÄFÛ}ƒ²ŸNs8=ƒØsˆyÎP«ÔôëÑ�­{÷±÷ða¥|ßá#ô‰î†›Æù%¦Î``㮆ÿ{¦\§ˆƒÞžž¼öèÃL{Æ´¬¼‚Ý)îH[âzÅ(â @�Î�yüö[xûëïØuà eåú_XŽ€u ‰Êç1ƒ*â Vaó–‰ødÖl6ìÜuAë—†z¡pÎþ$ÏÕQÑ.,õçÒì0â6DEDòÂÔi˜Íf2 NST^AQy9înnùxŒ�—§+ÊÙ’™ÁžÜ“++Å(©Ñ¸kѸ»YîßfCu55%% ªA%Ixi4ûø‘[a¹Ï¨�9S^Ž,ËNÃS{j4¹{Pª¯IÂ,Ë”êô´²å|)öEs‰�íÍž…ó”iÿó Ö±å_�=̳÷Ü Ö�#�@ W2Þžž<4õz^~à>»òêÚZæ¯^§LgŸÊ§¸ì¬’GÎ]«e@�î쵉TÏ=×N¦}D¸2½.q»f@�î¼ò`Ãz KËxôíÿ*ÓEeeÌZ¾’9+W³á\"B\y¾^Þ\‰øãåaÿÎÊ™Ðvõ�?'åÈŽ”vá�“'òù‚…véT°æþ›:ft£e÷·xîœ<ÑΙº-9…™sæ+Ó's™1{.K6n"iþl»eý¼½…@(—!^�èÍ&t.æ#rFý‹=eÚæßú�M…µ�ð  m‡Vaб®£+ñÂЙŒèÕ&Ü]pÐDxÑz®=J¾JΞeû¾dFŠk²­ñNrÆ�2DÓOä`0Ñ6º"ÂÂèÙ(Ž~�¿?ý{ö !9€#N×9¼ÿf½PŒéÒÅnTÕêí;X½}¾ÞÞŒŒëÏØÁƒ?˜VÁ !Ülój5žøÏ;vmŒöçױ̬f „{Űuï>ŠJË8q*w7%4h}ÈQgdžÌU„T€qN~CûõÅÓÃZ«|$#Ó©@èìN6LeYæpzÃôkööNo8ŽÇOœà¡×þm7ßv”e^Á*««ñõ¾2Ø.ÛNdñÝž�T蛊ù¯ŽÉlF’¤ Î+WVëÜ×\N”•òÒÚ•¤—ódü°i³%0ÊæÊcUU”TTPP~–‚Š ÎTWq¦ªšJC&� ´ZT* *�µFFãî…$¡„¥YÙ–Ñá’Å)(™A%ã£Æ�ö,¿¦$�5ähIu%uFc“9L‚Ý=)Õ×"É`– XWã’@h–eŒ²�téÝžžv¹‚›Cp@Á-Þ'�@ ‚‹¡SdKf|`WìïO—¶mœ¾ƒøÏw?Ú‰J²,³`í:ž¼õf¥ì�'ãæ_±«wØQ|ôܳJ¨P£ÉÄì–÷0UµµŒìoÿÛ{õŽD–nÙjWÖ£SGBíÊÒmòh;#ÀÏ�œÓç¬s)ÐéëÊîœ<‘wüY–Q©$½qCÏÁÊ�~ÍÈ<{å*ž¸å&eÿ‡0û�7yàßo+©Q:D„óËÛoâiíK_WǼÕk›½¾ó¡’$»cÞ§[kv’š}®ވ~}í¦+«k(()iñþ� y�ð ¤òBB‹Ú!7rú9'J®Áú�½úöµ¼(”� ƒ ^üËÍJƒw÷ó¿<,9{–-{ö*Ó*•¤A“ÑÍÒ§?6n<§@î$Át„MÎA“ÙLye%!���ê8.Øå+,-wÛ=$À¿Éþ8ÃÏÇ›/_ÿÓß{Ÿ¢Ò2¥¼²ºš[¶±bË64j5Ó®ÏûÏ?‹§‡e6qå«kkY¶yË9×QVá<ŸÔùØÛ6áa»Quq1M „e�F¶9Kô-I‘a¡dœ´üH8ÛD¬|gǰM+û¼‘¥¸}€Ý¾Ì8™«ôç\õ…@(°åß×rÖš×CÐ<.6œQcÚE „† tÌ�FoÝÊÕƒàãkÉÛòóÏ”ê@£F­Ñ‚›•Öò'™%T²Œ,ÉȨµ$›L>Õ%%諪A­$•%AtHSúôeÁ>K>[Ézß×™L”TUè\$ tó@em_B¦ÚT‡I–] 'n0›Ñ¨¯Ìp°�@ W2>^^â\S¬ÚžÀO,u(ÿú·ÅÜ1i¢�¨�îì™û3kvRY]Mßèn j4ø›ß+yøR³Op8#“›Õ?½ùG2ï ýd.£‘Ž‘ô‹Ž¶ËEx4+›C6ƒsË«ªÐ×Õáîæ¦”}4ýi¶ìKb[ò~¶[fÿ”WU‘•wŠN6ƒÓŸ»ë®5’‚’¢Ú·#´Ñ;#­“Át…%¥´mÕ•è¹»n§]ëV¤ŸÌeẠõ�‘™›Çÿ–.ã�®WʆÄöfÿ¹¤¤¦¡V©èÝÍ!šÖ̹ (*+sÒâű*!‘wŸzB#ý}|ØþÓ÷ì>|˜Ü‚3¸»iéÞ±#QíÛÙ-·hÃF‡Áë�@ øóá†ÎdÄ(›/®‘ËŸ�èÔ5èDoÛ† Ðçâ ìÄux‘/q�²�Ɉ‡úܧäÒM›1š^¨îOMcjZ“õWmÛÎ/oçx `Òˆáçl?ªC‡sÎoŠ~=º£V©0™Íì=tX ¹*Iq½z6¹œW£Ñ‹g+«hï¤ÞÙʆýÙÔ1<[QéPÖ8DESÇÂl÷eŸîÑôêê6ÖnNИ`/o!^!ø^aßOãÜ;«u:^úñ;¢W§Žøø¢’$ZQ|æ4 K–Œ�Š(ÙÜä%�»[}:Ad£‰²œ“ 9­U=Ü´LŸt µ/�‰†ðåf³‰¢ªÊ&B/�­J…Y6–{D­Ñ€�ÖùuÜ~Ÿ\ä3�@ � IŒ&?ý±Œ×¾ü³ÙñY´ ¸„'ÿû?ýçu%ZD°¿?wLšà´½ÛvðÖw?Ú•MÿèþøäCåý@ÏÎ�èÙ¹“Ó6juzžýp†Ýà@³YæhV¶](Í~Ý£é×=šêÚÚ?U øbÁBf¼0Ý®¬sÛ6tnÛF™Þuè0ƒmÄÓ.mÛ@qYCþîÙYô·I¯Ò®ukž»ë~ß´Åe�൯¾¥K»¶v‚°»Vk·~[–nÙÊÇ¿Ìq¹ýæP\v–W¿øŠ�ŸVyߥRIÄ÷îE¼Í r[NäŸæ¿?þ|Iú#‚æ!Â+Œ*£cè‚æ"˲½^g“k°©p¢¶áBU*=zÅ8q ÊvB£½àxñ.�*cÝyÂÅÍx`Â*ämÚ½‡‰Ã�;F§gm“àÐñtåshP Sq)'?ŸŠªjü|œbf³Ù.´g‡È ]ÖZ­–I#†3iÄpŒ&;S°ló®Y«„ë\¾y ½ø<­CB”å|¼¼øèÅç[´/¶m÷èÒ™CÇÓÙ{è¾ÞhÇÈH‚štýµ omçô<|<Ý!Äé©ÂBŠmF·µ wžðÛ6h=ÓŽÛMwˆŒt¨ã*á!!dçYFCéËOM»ð†âÅżöéÊôË�NÛúcã&^¸ÿeû F#_ÌmHˆÙª]Úµ½ mÛ›ÄýHHNA–e»«·ï`‰U¸ö÷õ½db¬à¯K„Ÿ?O9·‹÷ïJAy^îîøyœ?„´3¶fg^´@¨Q©¸:*šGÆÓÆÿÊÊg47ïž¿/-•µI{‘Í–{rž5ç+@Dp0² v¯+ä†AB²5 ¨P!Ë2ÆÚÊóN!©TÈs!­üýyhä¥_ë YB–,­œnbH=j5²l]/è\ §*B�@ ׸óÕ×/YÛ‰2ü¾‡Ö·#ú÷%<$�ZMAI IG�±vç.juM§Ç9”‘ɤ'-ãð¾}ˆjߎ ÔjÕµµdåå³óÀAv>Ò¤pµ=9…¡÷>È cFÓ©MÞžžä³io’Rç©÷>â©÷>j²³–¯dÖò•çÝÞü¢"‚GŽkr¾,Ëüë˯Y¸~×�Aûˆpjt:ö§¦±tË6E슼jò9דsº€a÷>Ä�ãÇÒµ][‚üý(-¯`MÂN¥Îw‹ç»Å¿Ÿ·Ï£‘ÿþô3ß,Z̘¸8ú÷ˆ&8 Y–),-eÏá#lÙ—LUMÓyÝûÜ|Çy×ð�Ÿò�Ÿž³ÎÊí;X��@\Ï ŒéI‡ˆ%Tmiyé'sÙ¼wÇsNº´N�@ ü9�ð ¢Ödh™†lBŒºNÔÖ èæîNWk¸Åuè°\£œ†2-£Z÷ASá’õí_üQÚG8wé¥fe³hízÖ%¶Ö鈪iǹù¹ðøm·PS[Ëû?üÏÎõvßÔ)MöõƒÿGum-#ãp0í8ïÿð“2/²U+Ɖwq«Ï�§»¬Ž¸Ôì„1¼¼<Ü9~â$³—-Wêö‰î†$IôŽŠ¢wT�Çl6óÀ«oðá‹ÏÓµ};ŽefñÊ'Ÿ*mFwêØä~t…�½zñãâß1›Íèë,Øs…­ç¡›¦2ý½8~â·½ðOž¾ë|¼¼˜³|smâ'�N¤Mœ~[ôuu\÷øS¼øàýùùóãâ%ì>xH™ÿà�Ž#üšÃm“&òÅœyM&¤ç¥3yúÎÛññöf]B"/Ïø”Êj‹ýÌÝw^ðzAËr% ƒõ4×}¿xÛ6Læ†ûû©â"e^ë€@ûk�µš�™ÐZÖ*´$#Ì6ª¢dÉLøÄ˜±ØäRõqw·iÉâ0<}¶!T’3ÜUËú¬‹Ô™\z�Dº�@ \Œ&[ö%±e_’ µ�s<çäE B'òOóÉœy¼|Ksðx:m¢N]%åå|»hI‹õ©¬¢’Å7±xã¦kóB1›ev:ÂîCG.wW�@à"B ¼B�‘Ñ»øòÌ¥Ö�À:�k°žè^1¨µZËËæ‰6~y'Ë-¥¢71kdTN�ÅëÖ+ŸûDw;§¨uͨ‘Š@X«Ó±vGSÇ;ŽkBbÊ~Sö;Ì»ãÚÉ j"^zhP ¥gËùbî|;·€Z¥âÃo½]:³}î,–oÚBV^þ¾¾\=tˆSÁ©1Þ8•ú±z{UUtŒŒdòÈúû9ÔÝ6ûçó¶÷òCðòC8�×+ª+~úžÃé¤;F~aƒ?ºuèÀȸ¸»¹9,78¶7;æþBBr ÒŽSYUM«�`Åö¢wTÔyûdË?¼Ÿ>x¿Óy)K:-ðó;çqÓjµ¼ùäãU’D˜¯ŸÃà�±d³Lun>²]>@ · ŠÇd2¢¯«Ã,ÛßÕÕ’„J®-F£™ÂÊÊ&ûª7í¹©Õ.m£p �@ �@ ü9အÅs�:w ÖwìÒ¿ÐI8QÙ¦À™7±¥SÌf4j¡_ Áÿgîé‡ÑlfÉáƒe3c;wåÞ~qiQЋæú ô†áØ·y#gÊÊÀ*æ—Ó§«%GmDp0Oç[*×ßÿe ISeºÒ2T­�ph¹Ç¼j%ŸmX‡¤’Ðh´hµZ¼ÜÝѨÔH*Õk~`Y²<Hgª*œöS–ejͤú‡IÂCíÚ#§ª™úàì¥Ë9–™ÅUC‡0jЕ딿P’�å��›øÏÓO:�_z¶œ�~ú™[&M 6º›Ãü…«Ö�_Tij÷ÜÅ×ó%÷tÁ9×wÓ„«H9zŒŒ“¹ç¬wýØÑDuìÀ‡?üÏ®\£VDlt7†÷ï'¡N�e™_W­áHFw^w Ý:ž?¬ýåæ›ù¿Ò¦uk®=€´ìl­YOxX(÷O»�EkÖQ«×s×õ×^’õ›L&^ûô î¸îzvé|IÖq©øð‡ŸˆïÛ‡aýû]î®(¬Þ¶�œSù ‡›VKxhÃú÷£§5RMYEþð?n¼z<ýz:Ö�@ ü}ေ¥Õ5GŸmË¶Ž¿z´nntíÙÃ¥p¢v9 måA'"äÅ"„�@ PIŠ桸Á—»+—�J…Ñäúý.ÀÇ—7ç¿û†Z}²,sª¸X™ß6, �‘ê�ƒ’%’¨d¶ˆ„Õg�lB}JÖeŒfÆ:3¨5¨ÌfT&åuz�Ô¨Ôd$dIB¶Š�2PXYå´Ÿ&djŒF«×ÐòŸ—Æy®Âƨ%×ÕêtlH܉Áhd}bâ%¶”`RYSɼSMÎ7˜ŒdçåQUSãt~ai)yQ°sÛ¶øú*óVoÛN»ˆpzvi]àçGÇ6‘xzx(eë &¶[ƒà�ÁhYw¿Ýic So4™È<™Ë×ó�~"‡nœzQÛ>¬\À­“']Òõ´$RÓXºq7M¸šÐ  Ëݧ̜5›ØnQŒ<¬bA€_ùóÍ‚…øú׫'åUUÔêt-¶þªšÞýæ{¾åF:DFZ®7%%èëêZl'OУKÓnëËAÉÙrò‹Š.w7Ø�¸‹Äädî¸ö<==(-/§°¤³ùòüL:|˜ôœ$I"!y?×�m7¿ÎPGv^ƒc{ €Áh$-;›™³’¹aÜXnž4€¬Ü<§ƒ6lYŸ�HZö ž¼óöÛ†„äNœ:…,Ë$=Æ`Ñ º¦–ì¼<Æ „�—º:=GÒ3زg/�Üz3£Æ)×÷Ê&î-�@ ‚¿B ¼0] 1¬‘è(7QÖµgÜÜÝ”Y�EÀúBÁÐq…-×÷KµO€¸˜ž¬ûñ;eÚÏÇÇåeŸ¿ïî�:/ÏKÒ?�@ üÿ¡9bX=ÃzÇ2ãÑÇykî\N‘_T¨Ì  ´8¶d P«ThµZÔHTŸ)¤«E’$ìL]Ö¨¡²,#)ý‘¬O*KžDÌJUd²dñ>ž¶†7mL…^‡I­µ9M3„ÍÙ'{Æ`4rÍè‘,ß´…ÓEE„‡†º¼ü¥¤¨´”êÚ–LZ‚«† ±›^Ÿ¸“�;sÛ5öâZÇ6‘vÓ;’’éÚ¾½C½ÂÒR§î¨ï.bãÎ]L»z¼�(ÙÒœ))½dm_*JËËñòôdêUã.wWš$¯ €vááÊôË�^Þ$¦¤8„õŒGŸîÑÊ´,ËÌœ5›e›·0eüX×^S”•WpêL¡ 5]'!)™�½{q"ï‰É)a=“G�$"¬á~i2™xý³/X¼vý�·^ �@Ðò�ð ÀØÂ£$eÙâÀ6§ CØPðòñ¦}çÎ�ÄÁs»q(oiiÐÂ¥}½½‰íuA˶iÝŠ6­[µxŸ�@ðÿ͆_Ó‹¯¾ÊŠ„TèõJytD$wIh@!~~ûøˆ—›†;Þü7Uf‹C«Ø§–$›0�Á@ÊÉÒŠ Й¬aÏ%œ„‡”W"2T81Z¬ÓYDG«{ÐWë†ÊÅmÕ4C L° W׎Ū-ÛHHNáÆ«¯:ïré'rؼ{å••D¶jÅ„áà ðWæ—UT°fÛòΜÁÏÇ›aýûÛ…5Ü‘”Lë�$Ib}B"ÕµµDuìÀ„áÃÐj4ìHJ&· €:ƒ�e›63qÄp2rNR^UE—vmY¼v==»vaXÿ~TU×°y÷ròó1�´iÝŠñCâ ðûk‡Ñí×=šÍ»vsº°¨I�°º¶–å›¶�[P@d«0¦ŒˆÄ� ëß� ËñHÏÉa÷�CãåéALTW%té¶½ûÈ/´¼\_¶i3“GŽ@­VSz¶œ5;vpêL!þ>> Пî�;�UmLoë³`iy¹"€Ö×),)a×�ƒŒП??2Oæ²ëÀ ŠŠñôð G—ΌЕJŦ]» ¢WTWe»jjkÙ°sƒb{ÓÊê2ªçXfRÓ0™Œ,Û´™¸^1„‡†RUSÃÚí dååá®ÕÒ§Gw†õë‹Êêø=–™åôüiÌ®ýô÷ÃMëÆº„*«jèÒ¾-GŒÀÝMk×�íû’8[YIXpã†ÄÓ¦•åwÅæ­TV×�–�ÍÆ�»?˜IÉøùÒµ}{ÖîHÀl6“rô*•Š¡ýú’rô£‘�½{�Ulظs7‡ÓÓQ©Tô‰ŽfD\e{ F#[÷ì%+7�Šªj‚ƒ3xmZ·¢¬¢‚õ ;Áê†ÒéõÄFwcùæ-Êþ¨ª®aíË>óps#¶{´]XÛÔ¬lÎVTЭSG–oÚ™’ÂCB˜4j„r~ɲÌîI9–JMm-mÃÙ0|h³êôz6îÜEÆÉ\T’D·Ž?M£¼«ÉG�‘�”ŒÑd"¦kÆÆVö‡ÉlfûÞ$Òsr(¯¬"8ПуÒ!Ò"Ö×êõ¬OHd\ü`6[÷ÛSV×YæÉ\6ïÞCYEá!!ŒOëÐe½£‘ ‰;9’ž‰»»Ã] ušs*Ÿõ‰;)+/§UHW Bë�†v‹ËÊX³=�üÂB|½½Û›~=º+ó“ŽÁMëF�¿?«·m§¬¢‚Žm"™4r^ì9xˆ¬Ü\JËËY¶i3c¢Î``GR2W ‚‡»»r7îÜ…¾®ŽaýûDN~¾"bÕÔÖ²>q'Ùy§Ð¨ÕôìÚ…qP«š7§²ºšƒiǹýšIxzxðݯ¿‘{º€¶á­Ï»¬$IôíÞ�=QXRêÒïŤ#G8~âå••,Û´™Qãðóñ¡´¼œµÛÈ-(ÀÇË‹1=•ïÕùÈÎ;Å©3…Ü:y­CBXµu5µµxyž`«Z­¦w·(–nÜL�ÁàÒú�@ D’·+ùHl¶Âž3qPzöéƒJ¥² j *Û»e›ÊrC€QÛ�¥-%Õ|)TG�@ ® ´*µ µœàãË�WOäñë¦(e�""xlâ$nŒ¨ž1ôjßžVþ~¬Ý»—<«˜'É(ƒˆ†Ewçž1cytÂD¾}øQ=5�çÇO¢GDµÆò0`–­ÈõvC‹Ç° â¬C¿dÙL¡¾¦~d™`×]÷Z_ê–UTp$#“aúáëíMÏ®]HLÞÞåv8ÈŸIqYá¡¡$9ÂË3>¡Ôê†,(.æ¥�>aßáô ¡¼²Šw¾þ–ÕÛ¶+mlHÜÉï6òÅœyúûèçǯ«Ö0û�e–¾•WP«×£¯3PXRŠÙlæPÚqÖlÛÁÛ_}Kfn²,c0yãó/Y³}~>>°uÏ>ÞüòëËb¯¥È²†E ôw.tŒFÞüü+’S $ód.ï~óóW¬¢¨Ô’cóÐñãüûó¯8‘—Gd«0dYæ» ùcÃFJÊËÑéëÐéë(,)ENñÒÇŸ�|ä(­CB(«¨à­¯¾aÝŽDަg°hí:¥IGŽ2Å*–mÚ¬”íÜ€%ëÖãíåÅÑŒLÞøì 2OænuÛüðÛb¯[VqfîòvÛ¶sÿ­Yç4‡Zum-UU˜Í2…%¥èëꨪ©á_Ÿ|ÆúÄ�ûû#©T|»`!ß,X¨,çìüqÆæÝ{Xºq33gÍÆßÇ—� –¬ÛÀO‹+u¶íÝÇ[_}CAq1­CB8š‘Å«3>%-;¬ÎP£ÉDUM %e–ïøÆ�»H>r ³Ù¬ì늪*ÎZ¯+»ö`ÛÞ$°Šnýô3 V­ÆÇÛOw~Z¼„ï.Rúðùì¹Ì[¾•JExXG32yý³/(9{ƒÁ@±5ÏjYyÕÕÌ_±JÉ¡YU]ë3?cCâN‚÷Ùñã¬Ø²•·¿ú³l¦}x8Û“’yçëï”ý·põ¾š·µJE«à`˾ùòŒ&×òÛÍfÞýö{VmÝN ŸžÌ[±‚Ÿ—üaW/!9…ù+VЇ›?-þ�å›·*óømÿ[ò;²,JæÉ\^ÿô E¯Õ阿b³þXÆÒ ›0Xœ=ñúg_PPTDë�ŽddòòŒ™¤ŸÈQŽÅÇ?ýÌÜe+pÓjpÓjølö\ö:|Îí:œžÎ¿f~F^Aá¡¡ÍÈ䕟*ýÉ/,äå�g’tø­‚ƒ©¨ªâÃ~âwëw 1y?Ë7máƒïÄËÓƒð�VlÞÊ—sæ�5,mM­ƒÁHaI)&“‰¢Ò2æ¯X…Î:ðeÿ±TÞúê*«« ò÷çKþàÇE‹Y³mÔ_G¾üš�;w€V«á§EKX¸j�KÇÏ–]û Ë2Cúö!.&µZMbJŠËËgååÁ9®y�©¨¬¢ºV‡Ñd¢°¤£Ñ²ý¯Ìø”�û÷D­NÇÌY³•PÊç#!)o/út�&¾O,£‘ݹ¼ Ùy§ðöôtÙ)�@ ø{ „W-­…Y„NBŒ*ó!¤U­""ì„ÁúŠÎ‰Ê6ú+Ë-¾—B4�àJB#©PIæ–ecCÑÙ2¾\¼³Ùlãâ“ñóöfú Sí\‚!~þÜÏ´Áƒ9r*�¹;w‘˜“� Ù*ZÄEÙê,«®¡ÎhÄMÓð8Y¬ÓQk4¢QYÄDµ¤"ÐE�P%I.;Sö£’$âccÜ'–ï~ý�¬Ü<:µmãtƒÑÈÿÿÎȸp�©i¼ùÔtíЀ¨öíùþ·ÅÜ2iînnì=t˜Çn»…qÐ]¥çÁ½AZV6CúõåŽk'³}_׌It§Ž‚èÒM›¨¬ªâþ „=»váû…‹7d0]Û[Ö›y2—ç¸^1tjÛ†�ÿ7‹¢²2‚‚ؼ{/×�ô«Çƒõ:òùœy»ä;u¦�ô9üû©ÇéÖ±#áa¡$$%ÛÕ«¨ªæ£¾ ¸âjôz¥¥qýØÑ˜ÍfvìKæök'3qÄp°æò|äõ79’žADXÃuàdþif¾ú^ŒF~\´„±ñƒ¹Ú `u"¾óõ·üüû¼3ýö:Ì�Ô4žºë†ôíÀ¸øx^ûôsü|�»$eYæ§E¿Íó÷߃$IÔ ¼ðþ‡¬KHäÞ¦0Å*¼==yç¹g”ó|ÞŠ•,^»žÑƒ*®á#ü÷¹g•뛿Ÿ/ W­A–eƉçtaÙyyÊw¡ ¸Ä®óV¬bXÿ~5+�5¦N¯'-;›m{“èß³'Þ.¸õFDQi)ÇR•íÿrî|T’ĻӟÅÇÛ’pɺ ,Z»Ž1ñƒ•ï´3Ìf3‰)û‰ïÓ�ZMûÈÂCCILÞÏèAêHMåäiËõ³¦¶–©iHMcÒÈ.ï3�@ Áßá ¼hjpK´)7ùdÙ¥{l¬½P¶Õùä†åê䳑 IDAT���„4V6[v.Å>�àJÃí"\„®ðݲe”VUY­ù "ß�#FÒÆ&\œ-*I¢W›¶¼wÓMüp×]Äwè„Zªw6 ª5)¯­±[6§Ê6/¡Dˆ§§Ë¢_söEBR2}ºG+/QãzYÉM;>ò ¨¨ªbˆaJ™—§'ÏÝ{}¢£‘e™Ã錯ˆKcÆ`4rÜêÊèÝ-JÂCB©3ÏÙgwwEèÞ¹óg|¨¼<7šLäœÀ`l^ˆ7?_‚ƒþê‰KÉ–Ý{ùaá"~X¸ˆ_~_Ê�Ô4ÆÆâÅïs¦ÖÂÞÃGèÓS$IbìàAvu»ýVÞ™þŒÒFEU•Õaæ|?˲̑ô Æbü⣯3�~"‡Øèn˜eYŽffqýØ1èêêÈÊÍÅh2qüD6}{Xò‹=|ó�¼÷Ât›>T[ú`=>=ºtÁÏÇ›Ý,.�’³gIÍÊfø€þ.ï¿Ô¬lõƒ#ãàãíEjV¶RÖøüiŠî]:Û ­CB1XÏͼ‚3T×Ö2iäe›´ W JvÞ© -x8=ƒÎíÚ*â Ö<•Oß}'µOæÏøPeYæäéÓȲL]Ƕ1©YÙ êÓ[±æ…óöô´Ûg^žžŠ8ÐÚž´þòôpç@j9'ÁšïÓW_r9¥€‡»;’$±eÏ^Š­nËÉ#GðîsÏÚÕÛÛî»Ø*(H¹^¨T*føžrleY&÷t&“Éáx\=|¨"È�Ì?MEU“lÎ µJŨAÉÎ;E�NGjV6­CBˆï«ÔéÜ®-=lB&7¦äl9§‹Š¸zøPåqÓjyê®;”vR³³=x��>yäL&6×ÉŽm"•ë@xHF“É¥A1…¥¥äž>͸øÁJYXp016á|ë÷éÆ�»(³ºYo¼ú*^{üÑó¶o·®’Òsrì¯îKai)é99õ×îHP®ys–.çXF“GŽà©»nwÚ¾Áh¤º¶Vù34q¯HÍÊfäÀÊ} `âHËñ=ž}¬.äú¿Z]CžÛ#™”UT8lÃÑÌLeߨòÛšuÊ6Ì[¾’ü3…ÜvÍ$n¿v²‹{M �@ðwA8ÿŽ8ùÑe[Ô¶cüýíD>'!  8:±• eÙé:�@ œw•�ɵ—âÍåPFK·#©¤†!C´oÆí£Æ¸ÔFT«p>¼ñ&¶§¥ñÕöíä–•Õ�6Âl2Q\YI¨¯E(+ÖÕP¤¯Ek%•ŠHoçùçœáî¢@xêL!Ùy§(*-ã™wÞ³›—˜²Ÿ;®»Æ©0u¦¤ÀÁÕVŸ{île%:½ž9ËV0…cH·Êª*ås€ŸëÛUO�¿¿’s¬žýÇRÙ¶wyg((.v)W”3FÈÐ~}Ê¿˜3�§N]P›®òàMÓœ:ÉÎEQI)�ÚDÚ•…ÚMëëêX·#‘iiœ).¦¬¼/OO;QÈ–²ò ê fý±”9Ë–+åõO¨•ÕÕøúÒ±M$‡­Î¬òÊJ÷‰eíöŽfda2™©3éÛÝ’K­Î`°ô!5�‚âbÊÊËñöòTœ¤j•Џ^½Ø}à ×�Mbò~ü||èÝÍõ}QZFßîÑve*•а  %Ü*Mœ?Îl"ç#VÑ uˆýw uH0²,STZÖ,çªÓu””Öè;æáîÎàØÞÊô‰S§X·#‘§NQP\â�¯ï|•–Ñ¿g»2µJEXp£}vž\ž�Þz ßýº�×>ýœ??zEuelü Å x>Bƒ¹Ú ,X¹š­{öJŸèh&Œf'ø:ämt}:u¦�ÕÛ¶“�—§¸èœ…µ~ÏðêÌϰmÍd6#Ë2UÕÕ••êp=  %'?ßé6�)±¬¿U£s¤Þ•Y«ÓQU]ã0ßß×Oåð½ð\ª…Ö~4¾.„QVn¼ÚGDpûµ“ù}ÝÖîH MëÖôíÍđÛÌ}ꌄädYfÁÊÕü¶Æ‚Øh½/'&ïW\¼õ±lÚµ›ÂÒRv$'3´__Ô.æÒðòpG×hÛtú:e›prþ4…e@€s<­ç½®®O÷—Þ.Ñ£×…¦pÓº�Ó‰˜wæ o|ö%1Q]™võxÚ…GÀ¯¾îò:<]ÜgœçkÚ­c>~éENœ:Å‘ô ’Sxó‹¯ùçCë¢È;nH<£ äXfGÒ3غw[÷íã㾈Ÿ�åœ;×墸¬Œ×f~F—öí˜2n,í# âé·ÿëP×ö¼ª¿†½ùôvažë ÀÇÓ“«�hK}Ž?g(×É:çÇÐÍÍ µJ…Ng߆Ùl¦Î`PÎ1Îs.ž7­¥�¯ �ûuíèQL>Œ£™NOgËž=ìHJ棗þa×—s±#)…ÎíÚ:„âܺg;÷à®)×5ë;ݘ‘㈷†xÅêÈlŒZ¥Â]«uzl,ßW˶|ÿö›Jyýý¯Î``Ï¡ÃôéÍ€˜žvË.ß´…D'¡@ �@P�1zÐòR˜ìDp´8:w�ÆÃÃÓy8Q%� l'Êõ Ü¨nãæ[éì�@ ®4$$ÜÕ-?^kõÎDžÈ¶>eÈH’åÎß-ša=c\hÁþqõDÞ½æZnîÕ‹®ŸBˆ�Å¥‘SUA©¾©^ˆT©hïãïrÛîj��Ø×²,“�œBïènŒÏØøÁÊß­“& ÕhHHÙïtÙz·Dš5T[=ï|ý_Î��§»;Aþþ—•Ù…é¬3™¿bU‹„_´eÏÁCtmßžû§Ý@¯¨®øûúRS[Û¢ë¸R  UÂ:Öc;m0I9zŒ›'^ÍUÆÐ!27­–ªsì/OOü}}).;kwüôuuÌ_± £É@ŸîÑä�9ÃÎý÷h�.�IË>Á¡´ãôëaqšÌfö>´«Ç3aø0:¶‰ÄÝMKu£>tïÜ ?¯YÇÉüÓ Ð<7eëÐPŽffÚ••œ=Ë™’"B[ÖáSŸÏîh†ýúޤgâa=ÿ/z­BÉÌ9©ìo¬n°ÛžûiÙÙ¤=†Z¥âùûî¡Ïž„b4™04ãûîdŸ—�¥°¤„ˆP×�UÕüðÛbò ÎÐ!2’É£FòÎôg â@jšKm¤feóÃo‹‘$‰^Q]¹uòDÞ|ú ªªkÈ^žž ìíÚo/Ovî?Àü•«(-/Çd6³ÿX*¥åå´ ·äÍÛ¸s¯ÌøÔ© @­V±qç.–nØD­^�¾ÎÀ¶½ûP©TDº˜Ç0ÐÏÏâÀ¶æN¬¨ªâëù¿R][ëÔ%YOxh(Ý;wâÇE‹IÏÉÁd2q$#“-Q\”Cúöd¾˜3�ÂÒR*ªªùiñ¬lz¿xz2(¶7‹Ö¬ãXfF“‰¤#GØsè°r�=x )ûÙ¼{u9ùùü´h mZ·"Ê&÷äÅàçãC\¯­YGΩ|tz=‹×­çLq‰ÝyºfûVoÛŽ¾Î@­^ÏŽ¤d´�"¨}:k6ŸüüK“ëÙ‘”‚›Vk—«²žþ={ ÕhΙÛöb©Õé”�½cdßá#¬Û‘ˆ¾Î@Þ™3|ûëB‡Ü‹Î¶!$0Ð!*@|_KÞÈ„K¸ ‚ËKXp0Ïÿ�ÿ~™&ŒwÙuÞ¼õ�g‰éårý‡ï¸…‰cF^Ò> þ¼ýâ³ô<Çõí¯FHP o¿ø,­Dç¿ q±½xíÙÇ/ÛúGÄS÷ßuÙÖ/øÿƒ1z Q©0šóL\8öÂúOÝzÇ R«íå½f„mXFvtš[V TKçÖ®WlÞJæÉ“ í×—N~Ð5fÑšu„12n@ öÒu6îÜEEU7ŒwYÖÿW`ÑÚuùû3fð ËÝ• âëy 10Žž]:_²uìÚ€ÝÒ­SG& vÉÖãŒ/çÎgÔ �—tûl™·b%E%¥L1œn_x|úˇðÆ�þþtëØ��½{)/�~]µw77¦Œû§ô[ ¸ÜUj4’ £ÜrÏ=;wâȉ,dµÊšzPâÎ1ãéh}ñÝR˜Ìf rÑ›Œxª5È2´òô¤�—ëbšFR¹œðˆ5w\ÿFaÔêן܂ÒOäÐÃÉõêÉ;n㓟góê'Ÿ‚5lã”qc•—ÃSÆ�¡¬¢‚™³f+ÏR]Û·çé»ïPÚôóÃ×ËÛ®]OOBmr޵ gÛ¾$îå5>í¼½< °wg]3z$Ž¥òÔ[ïâîæ†»›÷M�ÂO‹—ðþ÷?òÖ3OáéîNXpM¡V© n24¤Ÿ�AþÎsö…âíåÕdÛõØ…W­G£¬Ûµ0~¶ÄtíÊ7Nåç%ü{çÅÕ¶ák ,m齩 ‚ ET,Ø{ï±Æn4¶DSM5ñMLoš¨‰©Æ5±`CšŠ€`WlôÞë.ì~°Œ,ÅžúíõþòþdöÌÌ™3egÏó<÷Íw¿íÃÐÀ€ ƒ²u×äFƈD"fŒɦ_vw•JEÛ–îL:˜o÷ìå�ϾàÅ…óq±·gÇÁCÌZûŸ¿ú2£ûSPTÄû[¿Î_ëæÍµÎŸH$"Ð×—˜‹—)R##¼==È/,¢¥««Ðvúè‘|ñãÏœŽO@­Vã鿯ä¡CÙ²k7¯~ü)¯.Y€¿�7!‘QôìÜé¾Çnh`€M_µNíÚ1yèv<ÄÎC!X[˜³rÖLL4ç§±ë§1ÌårL�M´–È´ö·hÊD>Øö¯|ô©0}˜8d�ÐÆÅÞžßO„rñz"o¯X†…©©Ð4>puÏ»ÜÄ©¦*ÊÙÎŽES'³ù—_9uª¦½¥%KŸšŠžTŠ¿�7á1±¼üá'P]]ÍÄ!ƒ(-+cûÞý˜ËåøyuÀÒÌŒ÷·l#¸›?ÓG�ÀÖÊJ�îÜ¡=‡ fç¡ÃüªñŒ³¶°`åì‚�§±¡!–õü*¥’škV*• ‹Y0q<_þ²ƒEëÞÍó �W‚ºÔü^P(•dææ6YÝØªY3F÷aWÈv<škééñc±ÕøZ[˜7ð512ÄB#ÏìÓÆo/Þþr†(•JFè‡o»¶ì 9‚¹©œÎíÛakeÕ òlÉ´)|°õ;^þðá\öèäËøA…}/™6…/~ü…¥ÉÒfŽŽLsÆ�á£o·óÚ§Ÿ Ûí HpêIN~>›~ù•¯~Þ!lwÙôi‚O¦©‰IƒD ™L[++!5ÖÄÈH}=)¶VVÂ6æNLJ۶óü{AS­Û½³/¹ù5IÞž èÈö½ûùvÏ^a¿ §Lä]Ë*+ÉÓ$aÔG­Vs#)‰�;iIîÖbh`@÷Î�¸™”Luu5RIMÿ“­��•Æ÷ñ”uv°'óH.³Ö¾ÄkÏ,¦o72ssùvÏolÙµ';[VÍžÙ¤OgEe%éÙÙôéÖµQékG[[¼ßT¦UÇ߃gK7>{ëÒ³² 9AiY9îÍ\Y:û)ºwíĪ×ßAõ„çi£w@WND�âBÅ×+æÏâTÌ9"¢k¼2};´Å@&ãÀŸÞ3¨�ý�›óí¯{šl3mìHJKJÙu0ä‘÷c&—³jÁl¾Øþ)iô ôçhD¯=òfÿQÒ'Пï~ÝdÿÝÝùGðÔ˜”–•³óÀá¿»+�„£½-=»úýmûwsqÁ¿£ÿm=Ðñÿ‘úIšßý?'¯´„R—µªÏçw'÷‹«”V5�!ú ,›¿�]?ÿ‚ñˆAˆÌääõs–-¥mGoÌ­¬ìÛ[;XkDX?X§’�ºË5ÑÄÚå?~µ™·—¯Â¨OwÔ¥å(Ï�gÌÄ |ðÅg�},ÆR}äÒÆ'»J%ó_y�òŠ <ÝZðÊâûgs¬ûä3Z8;óÔÈá�Ý·GaóŽ�dçå±fÞÓËþÿ ¼öéç8ÙÙ1{ìè?u?N†¡R«ò^ ¥’Ïø‰Qý‚qu¬™lŸµö%ž1œ^âËÚ÷>àvj*¦&Æ|öÊKifèŒç×2cÔ¨?õøjÉ+(dñëo¢V«éÞÉ—ES&5h3yÅjœìlq¶«ÉÐWéÙÙ$¥¥ãס=ËgNà¥?ÆØÐ�ççÎùÓû­£!…ùÉd˜Ü?ño$¿¬”µÉ–•_÷{¿1*ª«(P>ZÅNcT**ùáH[ ¸¬w'g¶­Y‹±aÃIÐGE¥V‘žDbq!zzˆ%XÈ °sBÿ~æzü 2«÷"=;›¢’Rœìlµµ“‘“ƒ¹©)¶––�ä9x'5 eUî®.M®¯R©HÉȤR© ¹“zR)…ÅÅdåæÑÜÙ©I)º;E%¥¨TÕˆÅbÒ³³q±·çFr o}ñß¼õº0Y_V^NrFf&rìm¬¸�šŠ¾ž¾ {+%•J…›‹³0ÎÅÅdæä`ajŠ­•Õcõµ¦™È��…}ÞIKCO*$;Ï$œçÃo·óÉË/h<†òŠ ’Ò3�éëáêà Iþ,2rrÈ/,ÂÎÚª�´huu57’“120Äù+áê£P*¹“š†�L†£­Mƒw–š{°Œ (¯¬$)-{Œ (+/')=K ¬Ìr—UT�œž��¾>.ö�4f• %9Ù(”Jll<¶íþ�AA=„€_c””–‘ž“�¾ž66@ªOVn.ùEE8ÙÚablD¥BÉíÔÔ&ŸQuÉÈΡ°¤LML|®¬ªâvJ*zzRš9:>ðó¬¶O޶¶�& •”�š™…©‰ NvOFZ´µZMv^¦&&dææ¢R©iîäÈÆ­ß"Ó××z7,*)%#'C™ {›ÏÍ _oaÕì™O´OŠÔÌ,JËËháì,ô»¸´”ÔÌ,LŒŒp²³}¤ïŸÿ*9%E–—!‰haýhϦ? « ¿>xË«iîìÈÔ¥«µü:»tôæƒW×ðâ»p,âÔŸÞ�ðÝ?òÊ{r4¼ñ}íÚü1;öâÇ=ûøbý:.]»ÁGß4]Ý«ã¯ãùEs±µ±bù« ½okùßËÏ‘—_È[ñÈû±³¶b÷ןòôê—¸xµFº9ò·ŸxáÝ��8ýÈÛÕñÏfË«),*æ��>ÿ»»òHŒЗÅ3¦ÒoÒßó1{âXúveÊ’UËþÿ‰ä†ù»»ðŸä¿9ëð/CúÄ_úïFñjÅ?ÛuôÖ– Õ|ÚXÕ`ÃÀ`SË5KTO^bTz� ÂZŸ…Þþ]9qú 9ùX[4>q ãßÃË‹ü%ûI¼“DµJ�¡jR]]Í©øzûwÅ•'[�Ó)™™ÜNM¥·WŽŸ:Íùk×ñiãù—ì`ëú·þ²}EÄÅ!‹ ðíÈ™„óT*”ÈôN¶ù{{3f@?­eßïÛÏþã¡ÜLNÁÍÅù/ë³�‹�DŠ´êÉUÊôeÌ<”Á]»qþöM:µö|¢ÁA€ˆôT.ä!Ó«™ø7�Hñµ²{¨à T$þ˃ƒhäùîáïf&—?´¤h}š99Þ·�X,Mžä¾ÿé„�=ËÞcÇyï¹Õx´hAuu5‡Ã#pwuѪä124Ä£E ­uëKx¶pn(éi.—cþ„ư¦Ú•ìÍïž[eUûO„âåÑú‘ƒƒhª•«˜ÿ³°·¶ÆÞÚºÑÏ$I£r…ƒ¾ž­î!7Yÿ4”É´ŽßÈÐP¨ðl £'0f2}=­óY—IÉäjU7†‰±­ŒOZ³ÖC°n¿ôØìm¬…zcèI¥÷<Ú§ú˜š˜4�|ˆD"ÞübÛx2côHÐÓâ¯\eæ˜Qõúa,T Ö'$"ò‰Éžþ4X•ß÷ÚסÀÕÑ�ˉ·´‚ƒgââ ?sY½ P?ïtîˆX,&áòNDES­ñ‹uv´§�»;Ç£N3,¸7-[¸’�›Ç¾�ãBÕ. Å¡Á½°³¶æVr ûBŽ7Ù?±Xİ~}040 }ëVôìªjîìÄà¾AÊô¹xí!'#j~§k°³¶bPŸžØZY‘–™ÅÇN6Y\‹GK7‚ºú!—“’–ÁïGNPRV¦µÍ n]pq°£R¡$îâe¢bâ„JËN^íQ*•0$¸††„Ÿ9ËéØ:{·'Èß…²Š�“\IÔös ôó¥‹�jµšØ —?sö¾œ®ÎŽ èÙ339I)i8FqI æ¦ôìêÇ©˜sdæä í-ÍÍéѵ‘Ñqdçåah`Àà>A¸5s¦¨¸„#a‘ܸ“¬Õ§ì¼|JKKÖ¯Ɔ\¼v“�“áT«TúùÒÌÅ Sc#F èËÁa ®§¾Ýý±·±ÆÄȈýûðÛác ùŽÔ§'ö6Öääçs(4\¨ ¬�•…9ýƒj’%{t팞D¹KW„Ï;{·§g?”ÕUDDÇ{þbƒóÚ7ÐC‰·îp04¼A?ë"‰ìì‹O;Oôôô¸|ý!a‘Âõ`n*gpß^¸8Ø“•›Ë¡a¤eÞ­úëß3�‹×o`.—3°ww¶ü¼›@¿Ž$ÞNâòõ»~±b±ˆ¡Á½¹zó6)ié÷ 4ê E5rÞ¦r†÷ösðxéYw÷#‹éØ ¯¶­Q(”DÅžãlü­ãñnãA@çŽr;)•ýÇN4yü{õ )-�K×…eÆF†÷àtlÙÙ2¸O-\œ)-+çÄ©3BР«¯JeµÖypr°§S‡¶ìÕœÿÀÎÉ)(¤¼¢‚Ñû±/ä˜ÖµGíµckƒ™© #ôå·CGA£<1¤oOš99‘“—Ï¡“ ¯�ÎÞíñòôÀÊÒœìÜ<ŽœŒ$%#³fÌ$†÷"4ê �½ÛãÓ¾-¥%¥ìøýÅ%¥ŒLsg'R32ÙsðˆÖ3 >2™>}»áîꌾž>7’’8ѤtŸ@nÞIF_¦Ï°¾Alß½ŸÌ윚{±o/Ü]�)(*æHX$7“´Çû�>íÛbgmEn~Gã¸�’ªÕ¦G×ÎtÊ„o IDATñé€RYÝ Á£w`Wòó µî}=)ƒú{áÉ© ý—»ùúPQYIÜÅËÂ2'¼<=øýh�½ƒH$" sG|Ú¶A__�Ë×o¡u¿˜ÊMÒ·®Ždçæq84\8R©”àîÝðléFµJEìù‹BՏޒW_}õÕ¿»ÿÊ• Ž5þ…XŸ¡Öwå‹D"eÕ�K×< ÷ÿÎå‹Ñ÷h‰È@ŸŠØ¼ý:Ó®£NÍšQ¥¨B©¬¢J©D©TR¥¬¢J©Y¦¨]^Uoy#m•Jª”J.Æž#"ä(zÍ]AY…*3‹¶íÛ3pè�Ç>¹ž~“I?ý~}}=ž?–?BÃ0“›4˜<ªOè™h,LM±±´`ßñÄ\¸DIYÎövZY™WoÝ",&–ˆ˜8n¥¤b&—kýà<~ú fr’32øýÄI®^C"kýxV©TDÄÆq$ê—oÞÄÌDÎ�ädÊÊËéѹ'£Ï¢R«µ&‘JËË ;ƒ¹©¼Q‰€ÌÜ\œ #*.žÌÜ\œìì´2d‹JJ9IØÙ®ß¹ƒ™‰‰Öã¹ËW(«¨ ¨´„ýÇN{é22ÖÄ^ºÌ�Ð0.߸‰¹©)fòšûo�WPˆø#ô$Qçâ),*j�Ù^X\LhôYÂcb9í:• 66ÂØ^½u‹Ìœ\ d8Fdì9²òòp±w$sâ.]¦°¸ë:“0ç._ápD$1/QTZг½�°ß‚âb"ãÎÑÜÉIïë·ï`eaÞd¦uôù Ä\¼$¼ 4s¬9ŽÂâbÍØÅ’˜”Œ™©¼É �âÒRÂbb‰¿r¹±±F®Ê’ߎÇÛÓƒ …B¸6ÐLÀÕ%)-�'Ã9sþ98ÚÚ>P…ÈÁ“áÜIMcÍü§9q&šJ…Bð‚È-( úüœíí8~ú ÇOŸáVJ vVVi®©´¬,.^OÄÚ‚�È(BÏœ%%3G[[A¶+¿¨ˆSšq ‰ˆâbb"-Z}}==!ƒ<;/ŸÃ‘DÆžãNZ¶––Â~Ðd~Ç^ºÄáˆH®^£ºº{ëʂ޲s7î®. êIHD$.ö¸8ØkµÙy8„¶îî $ÍårŽDFáÑ¢9Íœ9~ú úzztïä{ßýêxò”TV '•"“>y�½J%Gá{¿)$b1Õ ½ó##Ü�ž¨L˜J­&4-…¸ülÄbÐK�ëé`猹ìá‚�fú÷L Òñߤ™£ W¯ñó‰½t‰_&¯°ˆ%S§äÒ¶¥;³&�#+'—ë·n°zÁlZµhÆŒñ£)(*ÆÜÌ”icF`ldÄé¸xFôïƒO;OLŒ�1•›pòôY*Ú�§)#‡ÑÚ½ÆF†X˜™rðD-ÝØü¿7psu¦°¨„®>^L;’K×o�Z'hP‹“½-F ÁÞÆ}== ŠŠ¹x-‘Ù“Æbf"g@PwÒ³³iÓÊ�ãGs%ñÉš€Ñ¨�ýxgíJ$1jUMEWp�ŽEœjÒ«wÕ‚ÙÌŸ:‘Â’bŒ ™4r(Í]9y4’oÞ{Ow7ò ‹ñóéÀôq£¸šxSzlxi5¦Æ&<;w:–ff�`ƸQøûzóDZPa_>mÛðöš8 jøðµ8In~v6Ö|óÞ›´óhI~A]}¼xjÜHΜ;ON^>‰„ÿ½ô<ㆠ$¿ {fMƒ™\ΩØuº ónåR*+•¨�‘úÒݯ¿ ¥1AÀéãFÔÍ�?ŽÞí〠7›owîÁÈÈ�¯ÿ÷þ¾ÞäÑÚ­93Ç�¦¸¤T*.�5�fÎNœ<-l£›¯7Ï-œËÖ_v°rþlš99²húdœìm9sŽŒlmÏæI#†âÙÒ c##ÌÍL9x< [¾~ïM:´ñ ¯ ßm™1~ 7î$‘”šÀÌ £Y»x>*µ e•’€N¾L3œ‘g(,.F__�Mï¾Ak·æx·mCAAƒûô¤_�út÷ÇÆÚŠÊÊJÆ ˆG+7‡†7z�H¥R¾Z¿Ž~A�cddÀèAýéÖÙ‡ýGj‚gž-ÝèâãÅw;à�ÕK±°0ãÙ9Ó±´´àDÄiDb1_ox“®>^äÒ®uKfMKFvŽð3xo<· ‰DL¥¢?ïöL7ŠÓqñÂ3mÅüY<3kj•CCfOƒ©©‰XÌ®!Œ:�Qƒú³ûÀ]©ßÀ.�xyÙ"~Úó{£�Ðåófâ`kCxtŒ°¬{—N¬˜;S�^1w ¦O¡¸¤C™“FÁ½™« tr°gË{oѶµ;ùEtònÇŒq£¹~ë6ÉiHÄbÞåy†õëMn~¶ÖVÌ7‘H$<[Ÿ$ÏÍ|ê‰oS‡®‚ð�T$F,¡zBj¯ju� BµWw7ÊÊÊȉRGf´)9Qíeš¶š?ª5¾jµª�Øã ‰šœ,,)-#þÊ&Œ¥Æo,2öÃûô¾ïvo$%s:>/ÏÖ””•sø‡ŸHNÏ`ÒÐÁ�dëî=´qwÃÊÜœSññì9r”W—,ÂÝÕ4�‹ž~�¹pí:í[·"%#ƒ?BO²tú4ü½k²Å>Øög/\ÄÓ­R‰„CaØY[caZ¬;“p�ˆ¸s¬©#yÇû÷à۱Ѿ_J¼Á;›¾ÆÖÊW~;zœ'ÃxcÙRLMŒIÏÎæÕ�?C,ãÙ¢9×nÝfï±,™:Ÿsúƒaá(”Jò iߺ×ïÜ!$2 on§¤Ò®¥;§âã9uŠ�kVc&—sòL4Ùùù$§gàâ`�T"åøé3„ÅÄòÂü¹H$r XóÞÈôñts£¨¤„Caáô `ú¨5Ç{ŽÌÜ\ròòhîä„T"áû½û‰»tY�~ù=©”ÖÍ›qõæ-ö=ÆÒéÓOªº”–—}¾æ‡Ëå71“›Ð®UKÂccÉÎÍ£ƒGkÒ²²8p2Œ%S' ç5<&–Ïü™æNŽØZZòëÁCüz’—-¸ç„¨Z­&"6Ž.^0”ÉèâÕ�°³1Z•uÉélݵ‡ˆ˜X²rópqtàLB„†ñêâ¸88põæ-v9ÊoG�SU]…�•5Qçâ9ÁkKcenNZfßìÜMRz'ÎDÓ©][ÐHl=5b8޶6ÜHJfÝ'Ÿálo‡“�aÑ1ì?v‚õ+—cci�Z­æ³~"ê\<^­Q«Õ� «·‹§N¾g�09=ƒ;iiŒê×wWl,-ˆŒ=G@GŸ&שKzvM6žI#RX:tüÓ‘‰%H¤O‚7˜½� GÃNñé¶ïØrŒoÞ{‹v­[ž¥OO§R¡àéU/’“—�T*åíç—ãáÖxµsµJÅÒ—ßd׿�Ù}èˆ 1 àéîÆÔ¥«„ª¡ž™OÏn]øü»Ÿ‰D¼¸lq.³fýûTUU!Ó×çÃ×^à¹s˜¹bm£û›0l �Ÿ¤ [6wåÓ7_¦m+wâ.\fpß ÊÊË™¶tµ[:û)úvïÆ×?þ*lÇíS–¬ -3}=)»¿þœŽíÛ0eÑJJÊʰ47gï–Ïñi߆”ŒLý|Ü'ˆå¯½ÍéØÐT"~ôÚ wàðɈ}•éëóÂ3 8x"Œw>û •J�ÜØ˜Mï¾ÎÒÙÓXýÆB£ÎÐ;°+_}ÿ³°^ÿž� ?EyEËçÎ@ª'eÊâå#‰X6g:+æÍ$":F¨`ëÝ­+3—¯ª»VÌŸEïÀ®|ôÍ·|²õ{LŒ�ï)1úÆGŸ7�]5o©Y,Z»Ž²òrôõ¤lxé9ž[8‡ñó–jU‚$ÞNâÕÿ}Äî¯?åýM[µªÕ¬­,xjÙsTTV"‹ùö£wééß…ˆ³qØZY±lÎt6ýð [©ñfµ³¶bëë™9a 7mmÐ_C†÷ïˆÏ7 Õn#ôeþ´‰ÈdúTV*X5o6Y9,~ñõšýJ$¼±j)kž™Çè9K„Ê©aýz³ø…×¹x­¦¿!a¬^ø4æfäÜ3€;©iœ¿r­ÁwÔ’™SQT*™½êEŠKJ�J¥lÚðÓÇŽäù·ßcä€`:¶oÃÓ«_äº&�4¨O/-]À±ð(â/_eâðÁü¸g¿p_vòjÏ[Ï-£™“cƒ 5€£‘¼¾j–ææBÅ퀠îDFÇRT\Âs‹æbldÈôgŸ'SÐ[1w‹gLåhø©ûVéÖePï ^þ߇MJ¿õñ $FŸ�;ƒ¢âR¬y…Ò²rÄb/-[Äó‹æq&.�J…‚ñÃñýî½|þí� ¹_vmþ”?_îh‚ˆ• Ë^}µZMÌù ¼¾jgãø`ó6’Ó2˜=q,±¸Á5 àÕÆ�–nÌ^ù‚PÚÕ׋�¯¬ÅÎÚšŒìÆ}$‡÷áÙuo –/-]€H,fòâ×܋˟žÎÊy3‰ŒŽ¥°¸˜ ñ7ä8ï|úhª üì}‚üý¸|ýí=Z3fP¾Üþ3ÛvÔ\ëÞm<øô­W¹“R“|q,ü£öÃÕÙ‘¤”šqг;ñ—.7Ù×û!Ó×gÄ€`6nÚÆn�ÇèÐà^,ž1#CCÊÊËY9w¹yù,X»ŽòŠ $b1ëV>Ú%ó5g1-œ�ðóîÀÂ5¯ Õ�‹¦O¡W@W¾þé×ûô@Ç?]Êö?„‡‘ãzj3I,m¬‘ AÀº^ƒ5mÔÚËÐD Õ5ÿ«']z·Á“ Þk,NÅÇ£R« Ô ü}¼¹“–FJfÃ,¥úÜJIáÅ…ó˜7a<+fN§W?!Ðp ,ŒÞ]»ðÒÂù,œ<‘õ+—cenÎÙ Úåý W¯òÖŠeÌ7†W/¤¹“gâk^Ï$œ'úüæNÇË‹°vþ\NžHrúÝRo/.^OÔÊî��ůC‡F+<Ôj5_þ¼ƒö­[ñÎÊå,™6…õ+Ÿ¥´¼‚c§j¾ˆ¿Ý³C™ŒwW-géôi¬_ù,~Úóõ¯»P(ïV§&§g°î™EÌ7†W/DO*ávJ o¯XƬ±£y~îÊ+*¸~'IXçò�›ŒÐOsËœqcxiá|Ü]]„k¬¤¬ŒÍ;v28¨o>»”¥Ó§±aõJ %?ÿ~o‹ök·ï�•—G÷Î5UpÝ|¼©¨¬$ö’v6NEe%*•Š Ï­dÅÌélX½¹‘Ûvÿ&´ÉÎËÇÁÆšwV.gåì¼½|)ʪ*~ªÓ‡j•Фôt>}ùO�Ü ?G"£p¶·çÍg—²hÊ$Þ\¾™Œ¸K5²Ñç/Çš¹sX=gÏ==›Usfu.^ëžkŒˆØXŒ ñmבH„¿·7ñW®PZ^Þ mVn.ç¯]çüµë$\½Æ�“a|³s7fr¹NšIÇ¿¹T†ˆ¦ßPZi Ÿ^‰çln&Õj5‘#9ƒœÝ:8(F„\úäªuüû‰D¸»ºèÛ/�Öÿºà ‰Eo/]pP‡Ž? #CC|ÚxèÛñ‘ý0uèø¯r;%•ñóŸeõ8Ak·¬]2ŸÝ_ʺ•Ke¢n~¾ÜJJ֚Ŀ|ý1 —ðï¤�„¹cÿAá߉·kæ �ˆÅtjߎ_öª]ªªªØª™È~X¢bã´$oÜIÂ@3âìhO3'G¶þ²›*ÍïñJ…‚ûàÑÒ óÆkÕ@3gG\�…þ˜2‡¸ 5¿Q¿ùy'“®‚ƒ‰=}½ N¯]¤&Ê*Ò33‰:{N˜¿É+( ¸´Dèo`g_Î_¹&b.péZbƒñ­¥�G+ÌMå|óÓ¯‚ iqi){¡‹·‰„Ã'#iæäˆ›&YÝÍÕ÷f.ì?Rô è܉_?$Õj5ÛwîÅ@&ûma_±ç/jI?&ÞºóXÊ"ÆF†´mÝ’Ÿöì§Ló]¡¬â»�¿á`kƒs=åŸû±ÿÈ *4sBÕ*w’Ó00¨é_—ŽP©UlßµOhŸ™“KHh$þ½›Þ¨Z�—§f5Iú¿:Ê ©OSY©@&Ó§“W;¾ß³ïî~««ùaÏ~l,-qo~WõåXä)!8p"ò ÕÕ*zùwMZï~×T›Õ¥V¶ñ×?Q\Rš{æ£ÍÛˆŒ©ñ°ìâKèé3BpàÀ±Pò „kG�šÖnÍpÐø`Ç$\`À”9�¢bΡP(èåï€�¥%Û·ãÀ‰“ ‘þýÈ !8ˆæÞ�J%øvhÓè6›âÒõëås*‘HðïèÍ�{öSZVsí¨Tj~ؽ 39mZº0~þ³lþq‡°ž‘¡ Ôj-Å*€#aQÂÜ÷MÀ,$ìî|á�”4ôô¤ |°k9å*¦ÌÑ’Œ5Ðü¦5¼‡5GäYmÜ?_vþq�Ââ:÷â®}àÕÖ€Ù+_`ãWßëÔÞƒµÏ?ŸöóÞ»×züå«Â\3@üÅËäл[WÐ(úùòDZ“MöõAéЦ•ðlÝä§Î‚ÿ~>ø~÷~AvµZ¥â‡=û±47§U‹æB4 ³Oáyúé¶ï™öŒÎ7ñß„®‚ð‚L,ybjµ‘æÕ—Éy¨ªAÍÿ7±\øç´ ¼W5AxLí[µÄ\SmÕÕÛ‹o÷ì%2öã ¸çv½==´¼N\쉿rUøûÍeÏ WgrªöűR¡-ÿÚÓ¯³ðÅ$‰put ¤´FZâüµë8ÚÚä×YhßÕÛ ‡»þB�ÚµE,qöÂEzuñ#+7—Ä;IŒé¯í¥VKjfY¹¹,š2Q�×4—Ë™3n 2}}Ôj5¯'2iè`AþQ,3ª__NÇ'p+%E�`mÛÒ]�540ÀÂÔŒNíÛ “rµ² ue%ÌärúwlÛ†6înÄ_¹Jßnþ ìÑ�~Ý„/•ªFÒ£¢ž4…•¹¹VEž«£jµ…BÑàõµë˜Ëåø ËÜ]]ðñôàüµë ê),ïß=P¨H3—Ë131^ôîGµJÅ¥ÄL5B�%•ˆÅŒ îË‹|DRZºP=ú ôôë¤um¸ØÛS yI¸”xeU£ûÝ5°751!¨‹ÇNÝÛ”;"&K33Úµ¬©Tôtk�…©)‘±qtóÑ~1ÜW�,551aHï ¶ìÜ­ì= Ÿp-ÙZYÜÍŸ#Qw_ìÔj5£ûcdhØhD"EE$ÞI¢UófÊd|úÊ‹Âçq—.ÓÆÝM¨¬Dsÿ5stäÜå+ZÒ¨u©©”ÿWl¬­Øòž¶§¼½� iYÂßÕ*•Våa]ùB++ôô¤Bа–õþ~P²ròšüÌž&À´záTu<°jç,ÍÌÈ/(j°ÞG_˺UÏðã'ïq3)…³ñç9p,”kšà‹B¡$¨›��}qÕÞô¤Rò‹´·UV/1U­VSV^QoY�þ:ÚÓÜÙ©Áø::Ø5Ø–°ŽƒjµšõkVh-711A__##b.Ô»r3)™þA�$¥¤‘pù&FFX˜É5¨ý{4ؾ•ùÝ øÌ{Œõ£Pë�z«žwZ­Ä¬£�­V•×ýȬWùT÷ºsq°G$³é�×´ÚXZšcdÐø\EyEﵕgfM£_Ï@.'Þàt\<ûBŽ““—/Ø ==yñ^T*¼÷å7,�3�àî\º~ƒ3q ì?r‚¬Ü\íì‹EÌš4†É#ïÚ‰ÕÞ/–ææ„Ÿ9Ëw;cúØ‘L9”ó—¯s®�¬Ž6ºá?‰”"e勳5Vé÷8r¢Ôj5T=±¡�‰¿,³óò¹vû66––¼õÅWÂr©DBdlãö¿§t¡•ù}²»E"‡Gpíö2srIÉÌl´†ÃÚÜB{µ:ÿÎÊÍÃÕÁ¾A?\ì… ¢¡�Þm<9“pž^]üˆˆ�Ã\.§CëV�v+3·&³§þ—um`(¯ eUUƒÏmm‰Ddææ Â™b"0ﻬ±ñs´µARoò×ÕÁ�+·n ¯§GìõDÎ^¸HzVÉé(”ÊY:õÇÿ^ç*+/[›“ÎNvvÄ\Ô®š«Ÿµÿ wµäæçS­Ráh§�‘\›¡œ•—÷PBëú×X�®dää ‰xý³/µš—–PTRRØo¤ïUÕÕDÅÇ#‰yûËMÂòjU5ç.×TÖ× äÕÏ®vu¨ ÄÖþØ“ˆÅZ�r4ÁÚâÒR-#f»{H2� îÃÍä^þèl--iÛÊ�€Ž…k8=;‡´¬,Ö¾ÿ¡ÖzY¹¹X˜5ýÂuåæ-ròó¹~ë¶Ö=.‰ˆŒ�k Ñ·7ÃêÈ èë7™¦CÇ¿ ‰#•Þñ'~ªUÕœÊHå§«W¸Z�‹‰¡N†F´7·¤·£+¶†�û½Þc©Ý+¨:tèСCÇ£àloǪ…sø`Ó6n%ßõþKÏÊæ³m?àhgK@§› eu·“SØôýŽÛÑ |©Õ�zš¡ ºÈêUûëë?ZõuuC¹¿Z”š à–_vRZÚ0ÀVß笖s—®0zÎ|Û·¥³w{zú3vè@Ö¼õáÑ1,™9�QƒÙuà0ßïÞÇͤdºûù2aø�F·÷ (««¹zã¦V•[-Åš9 ÆÖ©®®æ3�„b}Ê+Ê©V©8EŸ@¾ùi'}»ðÛ¡# ™#P«ÕÜËjIY]MAQa£ãt¯àÏîƒ! Çß×›N^í˜0l0‡fƳk¨ªª¹¾vì;Øh€+ñÎÝj¾Ú¶u 9ɺK07•Ü3€¨˜¸Fe9Uªšu¥÷(€PVWs!á‚–_`-¹ù5ÛŒ8ÇÈY èìÕ�NÞíé×3�ñóô¥×‰¿|µ‘G;¾ð IDAT­ÖHQ¾±z)frúuçÈÉHªªª‰DTUW78wR=I£ Zµè5²ŽBñpçºöÞÞu0¤�g)Àͤd䯯lÙ¸ž²²2ö:Ê/û�x;™/Þ~å¡öõ tóõaÃKÏu†Çù•”„J­fÛïÜû8êñjåh å•ë Ú&¥¥£¯§Çæ o ‘ˆÙuð;ææ�;¼ùüÝ�òÊŠFÏ‹¾ž¾ÖßÇÂO1fðœìÔ�ÐÓÑM&"4E}Õ–ßãðÉHü;yÓÉ«=ㆠdâÈ!Ì|öyáœýºÿ�àY—D�úÜçßþÈO¿í§[g_:{µgþ´‰ŒÜŸ§–=¯5Ϩ㟋nv悲'å;¤Vk¥4 ²¡õ$F�Þ��U j ë,Ð’}B‘M™DÚ¤é|Dl,"‘ˆ^]ý´Vfr9á1±$&%ѪY³&·}¯ eU/}ð*•šàúàâàÀÆ­Û´‰›@Édú(ùR­ª·¬›·Ÿÿø3eååDÄÆÑ½“o“ý“h^(šz±«}¸WÕ»vT*jµécL{I¨ª®¶»óP{Ž¥o7õì�³½=×nßÖ’µßcÜê£'•jI£ÖÝoýÐÃë#Œ]½c¬Ò| >ìØ‰šðÎDSÝf(“ ž—J•«””–1¨gÌ5>–®ŽŽü~"´Ae]ýóU{,µã¦R«Q©TZ÷PUu5"‘q�—W=iÓÇnkeÅÛ+–q39…ø+W‰½t‰·NÅÄ!ƒÑ·7R©wW­JÏZj+5#"6#zøuÒZ.‘Hˆ¿r•¼ÂB-? =©žVpT‡Žÿr=Uj Uæ6¹e%D¥$qàÆu2¨ô 042ÂÏʆIíiþ™•M¡/–`¢“Õ¡C‡:tèxdŠJKñíÐŽ@?_­a-2}}Á¢!%-ƒ¶­Zsþ‚ g 0aøà®*,.¦¸¤ŸvžBµ"€—§Ç9žº$§Ö·ŠKʈI¸kOáÝÆƒÎÞˆŽ?ß`}=)†!<:–èøóDÇŸçËí?ñõ†7éÛ£áÑ1 ë×›ïvþÆ7?ïÖ“é?þ;iJjÚz4è×´±#¹|=±Éc”J¥dææjU‚têH3gGa[!a‘Œ6ˆÁ}‚°·±âÀñšJµŠÊJròóQ£ÖÚ¯­•c†ô×’}ÒddfQ]]M;�–ZÁÉž5ÉÂÉiWUv/’SÓ1“˹’xS+Ø: ¨;’&æ+œìméØ�ÝB8qŠc§øâ»ŸÙ¿õ ºùú°ÿèqª««©P(´ÆÎÍÕ…þ½º¯ñPkŠˆèX* úõ ¤g×Î¬Ûøi£íò kîÏVîDœ�–�:�^]Y´v)iéhõC*•2{âNœŠFnlÌÈ�Á �$2&ŽÈ˜¸š@Ìgéèßd€0*&ŽJ…‚§ÆŽÂí9ï~V“h®V«IÍȤ�GKviüæš;;!76Ö ,Kë�¯[³OœoŠìÜ\* ª”UZÇìêìÈ�>A\¼–ˆ¿¯7vÖV Ÿù¢ g `ð'(D îÛ‹‹×®ó»…ež™Ó¥¬¼œÜüT*í{ÑÎڊуû“x' ¯¶¸:;2uÉ*-iX£:Éi î�¹©\� ðjÓZkñ—¯�“—Ïø¡騡-+__ß>ÖŸÛswuþí`kCp�ö:ÊñˆÓ�8ÍÛ~dß¶/èÖ©#{E©¬B¡Ô¾_Z¸83°w®\ų¥¾ÚñÃî}üq4”?ކòóo¿³eãzÚy´älü½m†tü3Ðyþƒ0”<ï­P Z“ Ö 8Ø0ˆ(x ªë5þƒµ«Õw4T7 4>*÷ƒˆ˜8Úº»3*¸/Ãûôþ›6b±˜ÈØs�¼ß«·n‘–•Íò™ÓÒ+ˆv­Zbjb¬%µù 8X[s#)Y+HS]]Mb’¶ô†o»¶ˆÅb~=Bjf=4Þr�aoSSÉu£žÆøW?ï`ë®=˜alhÈõzò×n×d>9Ø4] ö ¤ddRV'ãC­VsåæM¡ íÄ™3ôïÈŒÑ#éâÕG[ŠÇ«z±·¶&-3«A¦ÉµÛ·ûxêb&—c “q½N–ÀuÍØÙߣŠîaq°¶¦¬¢{:´n%üWV^N¦¦º°1Âcb±05eêð¡Z×ý”aC°47#26N«ýµ[·µþ¾|ãzR)Ö5•¯jµZ¸6î¶¹‰•¹²Ì=Å¥7qwuatÿ`ÞXö =ý: šq+)+Ó:έ[‘xçN“ÙCUÕÕœŽO ‹w­ãÞ§7D­V÷è÷¸ÿ6D€…¾z� ßù0¨Ôj Ëʸ˜šÊÎs±¼p` ÷îæÛ„ªÔàgcÇ\�¶l êÏšÎ�”ŠÄ˜ëüm‹o}±‰Èùs$¯ �µïHZVÓFô• %kßÿ�+7k*ýOFŸeÃæošlÿO#-+›µïH^Aá=Û•”²}ï~V¾³�9/¼ÌŠõïòñö´dv‡¹ïñŸ=�µï(x‡< /~ð‘ ]}– _oyäméСC‡÷¢¨¸„CÇØ=q,Cƒ{ ÞQ&FFL1„€ÎÙr€#a‘ØÛX1oêDdúúˆD"†÷â™YÓ¨¨|ðùŽÃ¡áŒÜ_¨Lt´³aÑÌ)MVÖåAgddgsîÒ–Î~ ;Ío'{[^\¶'‡Æ½HÊ*ÆÄ‚§& R¤ŽövØZ[“š‘ å'{[áwwÏÖŒ:}=ýÇJ:>t2œVÍ›ñÔØ‘HÄbÄbG fÞ” ””6.¯wéZ")i<·àiÁ'¯U‹f¼°t!uäA/]K$%#“¥sž"*öœPUpàxG &FF¬^4‡>�Ý((º÷;T}dúú÷o#«i£PVq,ò4ÓF�Àí9h|#Ÿž:�¸‹—µ¼íãa<§#cÎQY©`Õ‚9‚�ŽŸwž_<™^ã}!bþ´‰L9DHR÷òl…žž””Ì,MÿO1wò8Zµ¨)0°07ãÅ¥ èàѺÑDõºT*„žŠæé)㩬TÓôo‹Ã¡áŒ2�v5ÁSG;¦�ÁmMPÿà‰p:wbHß^H$¤R) ¦MdÒˆ¡äåP^Yɬ c˜3yœ0n-\œ°0“ß3¸¯P* ;ø¡ƒ¸�’ªå±wøD8Á=éÞ¥ÆÉL.gÕ‚9äæ�œüÂb¼Ûz ×fk÷ ítÏq¹2M L¥Rs84‚™ÇÐZ3öæ¦rÖ.žGg¯ö”•—S\RvÑxYJ¥RæL‡ƒ­ z�X±ÜÅ%%Xš› Ö:Ö–,™9²:úÀñ“L9„žuïŧéЕ¢"Š5ÏgÇšc’ˆÅL1„VnÍ…{/4ê Ï/š‹±‘!±˜©£‡ÓJs�Õ¢RÕHŠŽ<€ü‚BΞ»wð­ °�ŽÚ VSm[·d@ﻉüj5ÌŸ6‘)£† ÷K;Ï–èëé‘–‘EUUG#"™=i¬p¿[˜›²vÉ<¼ÛzRY©ÀÂÔ”Å3¦0°WÐqø´o‹Z­&Móü]ðÔ$VÌ�ñÀcªã¯GWAøB&– ‰©R?¦€Z]'v§~àªÁ†Õ�Úr¢õ+ ïnWõD&û¤"q“þƒ·SSIÉÌdH¯†_J¦&&´mÙ’¨sñL1쑼�j«�2srpq°G¥Rq ,œÛ©i¸¹ßïÝOs'§r�õ¹ßËc]Úµj…©‰ ›~ÞÁ¢)“024äê­Û|ùóF÷mt�òÊJb.^"8 [ƒëZ$ÑÍÛ›?N†‘Wx÷ÀއpsqÁÑÖ†‹×9F7o­óðÝo{Y=g¦¦DŸ¿@ØÙÆ hܳ1¢/\$¿ ’5óŸÆÂÔ”’Ò2’ÓÓ±³¶ÍõxìÔiöepPD"û�‡òë¡Þ^¾¬Ñmž»t™’²2};6ø¬¹“޶6DÄžkô CÇ",ô ÉU”Sý¸ï� ¬ª"%7—Œâ"*ª”X2У ¦2ìä¦X›˜ }BJ‰HŒ¥¾a“*Iéé�ú' ¬®âVJ å½&ôÔdi2t Š‹IzH��¿…RÁ­””{JTåðêÇŸRU­"¸›?VæædççqâL4/_¼Ä«‹ÒÌÉ€ìü‚ûqi)·RR„ªûGáfrŠà³QV^Nn#rS�ËGßn§]«–ôíæÿ­uèСCÇ™ _| Àó‹æ!‹P*«ÐÓ“RV^ζ{رÿ·’SxûÓ¯X5o6‡,*¾Øþ³V…ÞýøbûÏ´puá/?GIY&FFì>‚ø>³Aé™9L; ÷¬yûýÚ×kïÊÿ^^ͯ_}D~A!æf\¸z��›*<Õò¿/¿áåe 9ôýf ŠŠ±07#áÒ~ؽ€¯¶ï`í’¹tòn�J¥B©¨bç‡yzòx>~ýE¿øú�E].^½Î›·²xæ4¦ŽŽZ¥B__Ÿ�›·q%ñf£ëT«T¬}ç}Þya¿}ó9EÅXY˜q6–Í?Ü•‚U«Õ„„F0sÂhö9¡µ�-?í¤¹“#_¬�Üüä&&äæç³öí÷´*EïGZfCƒ{óãgï³pí«�ú;¦ge3²_Z~¼�)KV±ñ«-¼ûâ*¶l\O~a1frnÜIæõ�Ÿ5¹Ÿ‚âbJËÊycõ³ìüý V%gS—”ðâ†xmåRþؾ‰¢¢bÌLMÙrŒßit�”ŒL¶íØÃŒq£™uÉÈÊbäÀ~|ÿÉÿ˜²x%ŸlÙŽ£� [6®'7¿3¹œ´¬,V¾þ.±ç/r:6��^‘äô l,-9ušÓ± L:ˆ´ŒL…†?t?ã§}äïÇžo>%/¯ sS6ÿø+m[µdýšüLøúÇ_iæäÈ—ï¼FN^>¦r99yù¬yû¨Tj®Ý¸ÅÑðS¼õÜr’ÒÒ°²0çì¹ œˆ<à>A$¥¥óãžý¼õѬY2�ƒÛ7£P*)¯¨`û®½t÷Ó.(9Ÿ¡9t"œêûHÿ²ï �~�صùcòò 024äÛ{˜9a4h2¾ùy'3Æ�fü°AÂý²ûТ4E8lþ–·×¬`ËÆõÂý’œ–Ϊ7jÎÙ©¸xBÂ"yùÙE<;wj•™ŒO¶l'-³&©µ‹wŒMŒ�­�t®tüùè„ÿ0L¤ú(SŸ·žÄ(õdFµ–=”œ¨º^›:z„&Ò¦³–ÂcâГJéâÕ¾ÑÏ:úpþÚ5.\OÄË£u£mîEs''ü}¼yë·8ÚÚ�_TL‡Ö­èâÕ�“Ñgq°±~ ÀDs''ž9œí{÷}‰DŒ¹Ü”~�ÝhåwóñæLÂyzÖ“SlŒES'ñYüú›˜š˜PX\LÏÎ�ÒdýL2ˆôìlÖ}òrccJÊʰµ´d圙�• àîêBjf&s^x ±XŒ²ªŠQý‚ñiã ÀØýøè»ïY¸î ô¥RÔ¨éÀûç…�²~ų½OK æOšÀ¦_~%ê\úT;{kkž�6¥É±“éëcjbÌ—?ýµ®]˜<ìþ¾2}=ž�1��[¿cÞËë�SP\L oG÷j(Å �p…RI@GŸF?ïÖчßCOw'MºƒGkV¾³C™Œ²Š Z5oÆ´õúáêàÀâ×ÞÄ@&£¼¢Ÿ6ž êÙãG&Ìú¯¾fѺ70—Ë).-ÅÖÊ’©#j‚Æžn-˜2|(?íÿƒÝGŽ"ÒTÎ3ªIƒéðØ8,LMiëîÞø±úø°ópiYÙ8ÚÚ4ÚF‡Žÿ"b‘+}Cò•å(Ÿ°‡ˆžTJ ;;ZØÝ;âq‘ŠÄXüÍÁÁÿOÈôõÙüækw7þT>ÿñg$b o>»Saù�^A¬yï~úãÏ==ûoëß ž=ê{õAIÉÌÂÑÎöZêСC‡Žÿ:• o|ô9Ÿ~û=m[º£/Ó''7Ÿw’xRýq4”ˆ3±thã�Z­âêÿ±wßáqœÕâÇ¿³³]Òª÷bË’›Ü»�;�@B—N.½\.%?:\Ú… H�K„�Þ{â$Nœ¸wË–­f«÷²Ò®¶ïÌï­ÖZ5¯lÙ²�óy=¶fß™}gf¥]Í™sNíñ˜ò}/¾þ&/¾þfÌ:áp˜‹n8ðxùò÷JÙœR²3Òili¥úxleš±Üö‹ß°|aýîÁ›´>wÛè^b>õ<>õ|ôû¶ÎN>þÕo³hÞRR´vtQ9N°mÈÛvrã¡#Ì-�…Íb¡¹­�šaU–^|}3ûW0·¤¯ÏÏ�Â!­ŽÞVÿ£ßÜ>j»·~ëû£–½û£·Æ|ÿès/ñê–í,š7‡P0Ä‘Úcôž¤BM]·|á?X4‰ v›[cÊù¿ûæÿîxÔr Àm¿ø ³Š ™Y˜O¯³�ÃUÕ†õGûò÷:j½§^z•§^:\ú×ÏPyì³…�q2o¿û^vì9ýÞÙïâs·ý�ù³_ =½NÊ�VM¬ðû|ükßb^i Ç›b^_C†—{Ø}à7~æK,[0£ÉÈñúFš"YIãùË¿â™�¯Q2£"¢†g6ö¹\|þÿýˆù³KÈÉÌ ½³›#551�Õ?óåq·¿}ß�1çÞÔÖ³ÜãõòÕþœy¥³ÈÍΤ­£‹ÊÚc1Ïs×½ðä ¯0·´�×7XNÕíŽ>þì+¯³e÷>æÍ*Æh2ÒØÜJ]Só„û (ƒÙf#ƒiÁ`ˆþÏíÜóàãÌšQˆÛãåÀá#1ÕÓª�×ó�ÿ*‹çÏA1¨ì?T�Ûãá�'Ÿ�Žùê~vÒ9Ü~Ï}lßw0ÚŽÇ50ÀW~ð3攓Ÿ�EgwÕµÑ^~aMãë?ù æÌ&+#�ã�ÍohÄ‘”ÈŠÅ ¨¬9†ßuükëG-Û¼}טçiHCS 7}þk,š;³ÅÌÁŠ£8û]ì-?L~nÝ=½£~^>òåoŽÚŽ?àÛ?ÿJf2£`ðgñPUu´W¡®ë|ÿ׿c^é,ò²³hnm£òXv›�ÕËq,Ò�qã›[Ùu œÅeóÐÂ{UàñzùÓ8½J_|}ó˜Ë‡;ZsŒ~ö«,š7…}‡+ðx¹ïñ§£cî~àQž{å Jgþ¼ÔÔ5ÐÖy¢ZM¿ËÍ—¾ûæ–“—�EGWGªk¢?ﺮóÃÿùèq ós øT?s³Á'ÿó;'�«˜^ŠO=—ž7ßmНtÕŸæ^2îc]~Ï)e~ís_àñ‡ÆzÍG"ý÷>Ì7~ñS®ÿȇb¢z1AÀQˇµú#kpøòçx�ß}÷‡X×­DñùVTñþÝÌïîÿ®¡ñ–ñ{“�-Ç›èèé!'#�™ùùxý~Ê+«(ÈÉ™TP¢§¯�šú,f3e¥%˜Œ£cñÛöàÎûîçÎ~/šî=‘p8ÌÑãux¼^ò²²ÈãQ]s3m]Ý$%$0·xæi÷üÓýÒ?0À7>ýIŽÖÃíñ0³ Ÿìôô˜q½ýýÔ64bTÕÁ?NL&ŽÔ# ²ä4ú x½T;N(fVa!©)§µ?DŽcyU5¡p˜åeó1 èºN]s3íÝ=8"Çnd¯Ã‘º�NjêÈÉȈf)Ä# rôØq�yÙÙSìÚä(ÿýwó·Ÿÿ=}ý4¶µ‘êHbÎÌ™Ñ@ç¦í;¸÷©g¸ç?�¾V2SS))š|=ù@0HM}®�ÒR’)),•åèt¹¨©«Çh42«°Gb”ì«8´õõb·XpX§ÿ÷û™Ðëà;�ûâ9ñû~<4]ÇôMKOÂÓa6¨¤š­(SXXtã–­,˜]JK{¯ïÜÍ­7½Ÿä¤$ºz�¼øæ[´vv’’”Äe«V2·øDY–Ïýð'ܰáÊhষ¿ŸMÛwÒÔÖ†¦ëÌÈÏãê‹Ö‘h·S^UEgOoL�W€W·m'+=�EsfO8G¯ßÏÆ-[¹|ÕJ^ß¹‹ªãu¤8\¿áJ,&O¿ö:í]]äfeqýú+c~?ÖÔ7ðúÎ]ôôõ‘‘šÊúµ«£ÕÚ»»ùÚÏ~É}õË9vŒ£ÇŽ“`³qñòeÑ÷Û°¦ñÜëo°fÉb²ÓÓyúµMlܲ�Û¿â³úæ^ݶ�î¾>²ÓÓÙ°n혟+&ÒÖÙÅ»vÑÒщÑh¤´¨�õk×Fˈ®®Áëó1³ Ÿg7½Ago/ùÙY\wÅå1A½ÃÕ5lÞµ·ÇÜâbæ—óÃ?ÜÉï¾{Û¨ÏDJ®ïwà?>ñ1V/^4êñß|‹�[¶ñó¯ ‹ÙÄ_~”òʪ˜ýiÓöüåáG¹ãß%=eìϺ®³ã`9»–ã™[<“w\rQ´4Їÿó[|ñ#·pñòeÔ64r¼©‰«.Z]¿±µ•�[·Óít’•–Ɔukc*<÷ÆfV-ZHCs [öîEaéüy\¶rŠ¢ðÜ›yöµ×)ÈÉaõ’E\}ѺÁ98Ⱦ#Gñx½æærÍ¥Ç_!„¸Pt¹ûéóz0( Ågöæ¦ÉJ¿üªéž‚B@¤ÚÔ?ý>¡p8î@ž8üüÛ_'/;“O|ýÿM÷T¦E÷cg‹Ó#„ç $“…Þ€7Ž‘ãÐõhxŸÇ3FpðäåDG�=±é(¯g(ÓQ#­pr’L§ßz*Ì*,`Vቆ­6‹eÌ‹O'“–œ<ázº®óÂæ7Y2on\ÁAUUYP:vvÕ�™ùù§]RtÌç6X0»tÜÇSV.\³l~ÉäšûŽ%Áfcù‚²ÓÞÎpªªF³‡(ŠBqAÅã®7RzJʸ'b6™N)Ëu2ò³Ç wº¯³ÉDÙI^�)II§�ñ)„dPRÍ6\A?žðéõy=[쪉$“eÊ{>ùÊkT×7°ó`yô=»©­�Ý~'iÉÉ,š;‡¦¶6~|ǹõƒïçÊA>"wô~÷·¿Çl2±ráBá0/n~“}‡�ð“¯~‰¯�¿>òË”‘yŸîèîæîGçŸ:yÿ¯ÏÇÏ>ÏŽÉÍ̤¸°€W¶nçPU5ƒ�Esf“Ÿ“ÍËom¥µ£ƒo~æSìóÁ÷³aÝZ4Mã�gŸ§ 'gÌÛþ#GùŸ¿ý�Ù3Š˜UXHåñ㼺m;ߺõÓ'ýœ1¤½»›ÿ÷Ûß‘�1,õúü<òÂKÔÔ7ðå�~€½G¨mh ÏåfYÙ| srxuÛvUÕðó¯€Í»÷p×Q\�Ïì3xk÷6mß1ás¬¬ìm1â½|È5—^Â5—ž^@~,<÷<Ͻ¾™µK—�‘–ÂÓ›6±ãàA~ò•/�ºA¦¢¶–W¶l‹Ë«ªøõ_ïaVa%EETÕÕñê¶í|ó3ŸdÑœÁÏ�¼ð"UÇëèèîaå¢4¶¶qׇY¿v �Ý=„Âa¼Þh¢‡_x‘ç^ßÌ%+–“�žÎæ]»Ùu°œ_|ã?Nû&1!„Bq~y×úËùÂÇn!Å‘ÄWÆÈ ç¯K׬äŸûé)©Üö‹ßL÷tÄF„ç ‹AŪñMÐ{e"ºß�All|ßÿLãñ: ª:ªœhÌ:'Y2r�°fË‹/ Tô ênŸŒU5ŽÛ{ðBôê¶í<òâË x<üàKŸŸîé!„8O(€ÃdÁlPéùÑÎÑ" É& VõÌ}ÌÜsè0ÿýͯG`wÜ÷ùÙÙ|ÿ‹Ÿ‹FþùÔ3üó©gX»l)6Kì�H«ªqº\Üñƒï’–<ø™iñÜ9üúî{pxX6>f“‰]˹úâ‹Ø²o?I “ÊŒŸ3s&ß d§§óÇûäcï½>šÅh6šxáÍÁÒ?aMãï�?Éò²ù|ý“GQšÆ/îú ÷>ù¿¹í[Ñí�F~ôå/`6™Ð4�ßÜóîæ9.Y±"Ú`~,aMãÿy”K–/㳺 E©3L5 IDATQÐ4�_ß}ì ~ýíoĵ_;”cTU~üå/E3 r²yø…—bÆU¯ã;Ÿ»5+ÈÉæÎ=@¿{›ÕÂ?Ÿ|šåee|ý“Ã`0ùÑíwNøÜÝ=$'%a6™âšëThnïàÙMoðéÜíÿwéŠ|ïw ¼ªzÂׄ®ëüõáÇX½x_üÈ-(Š‚®ëüæoÿào�>Áÿ~çÛÞ§�_üçD+Oüèö?ràh%ë×®á7¾—ŠÚc,_0Ÿ¼ólÚ±‹6¬�ö^»t ·ßw?m�]'í£,„B!.,åG¹ïñ§ÙwøUµÇ§{:b UÖç_�?CEU-‡*«¦{:â#ÂsT’Ñ‚?‘¿=BMq`š= gÍqžºïþ32Gc^›•P�ó”6a@!Éxndž-e¥%ܰáJÊJK˜‘iÊé°véüÁó#Såí./+“[®{7Æ1ÊÙ))*âƒ×¼ã¬ÎK1õ¬ª³AÅòã=Å›‰Î‹ÁˆÃdA=Ãý/Z¾, ‡Ã©­åóþPLÖÔ†ukxþ�ÍÔ64°pvlIеK³ö7¿Š~inïü(Hb‚�åeóÙ1<@¸g//_vÒÔÃ-+›ýfZÚèeéi#ï³ÝÝôö÷ó®Ë.�–†V ®¹ô~sÏ?ès¹¢ë½óÒ‹£2ƒÁÀ ®dïá šÚÚÆíõ ÐÒÞA�³�w]~â9 W®YÍÿþý^œ.W4cr"ïYïYEô{¯ÏG[WWt_†¤¥$Gƒƒ9CAºÛzq{<ÜpÕ•Ñ <³ÉÄ;.¾˜??4º×Î�`(„å,*jj°šÍ\±zUtYIQ!_ÿäÇÉÉ̘pݶ®.:zzøÊÇþ-zÌEaýÚÕüúî{èêuFK¨_¼|YLYúÜÌ œÃÎûH6«…G+Y2o.¥3Š(�QÄï¿{Ûì±B!„8ß4µµóÐÓÏÇ1Rœo:º»yø™¦{â%Âs”ª Þ}ï úâ=‚z(L»ÖcHL@1›O”Õ#å@u=2pØ÷C_‘»�c– Õö½ 8V� Ž�–‡³qñ\“›™InæÔô�;Ó†_Ä綬ôt®_å„cŠòr'¼p,„8…d“«ÆôŸRïâ©dT ÑìÆ³!3-5úÿÎÞ^šÆÝ�<Æ=�=]>ôÉÄ5àsÛdÛ¾ý4µµÓÑÝM‚=¶WæÚ¥Køý½÷Ñï Û餹½ƒ/~ä–IÍs¬,·ñ2ß:º{Fœ²#AµŽžžho¹Ücò²ËJwõöNø{¾½»€ŸÜyWLéW-ÒdÞåvÇ Ô4�7vîbgù!Z;:éêí%1atŸÙT‡cÜm ío~VlIìܬ‰nÉIIt9�hš6ª´'‘ò±«ª™=£è”J��¥½»‡ô””QÁáUq”Ñnï<æ?¿ë/Ñ!Ã�ù€; Ly¼Nòùsº™¿<ô0ßÿýí¤8,š3› ëÖ0·¸8î}B!„Bñö%Âs˜U5b×L“ï54Ä ihÎ>0™P¬6“qtP0"ÜÑ…DV|L1QÓÓ@5Ä u=@÷ûѼž6NR‚ÑtFË� !„:‹AÅb±ã×B¸C‚ÚÙ  F3¶³ü~® YÍf>sÓ(-*56)TnãÖmüýñ'¹îŠË¹æÒK(ÌÉ¡µ³“üáŽè˜¥óça1™Ø}è-�ädOªWídÙ­V|~Ìrä3šÕ|¢â‚×;Æç“4F�n8«eðX}ïóÿN‚Í6êñÔH¹Õ“yðùxù­­¼÷ªõÜxõUäçd³¯âw=ðPÌ8e‚›À#Y�Ï�}Ø\†öeò4áδ>š³­×I¸»—p¿ ÍçG÷xA×N)8h6¨$¾ÍJ‹ !„gŠÅ`$Ýl'Õlê'f˜%rSªÙF†Å~Öƒƒ#%'%a·Zév:ÉJO�~ x½<ðìóƒ7?�°ó@9Ë”qËuïf~É,ì x½1cÌ&Ë”±}ÿ¶îÝÇe+WžÑýÈÉÌ@Q*jjc–®©EUU²3Ò‡-«‰s°²EQÈËŽÍÆi¨‚AŸËs¬ZÛxò•×bÊ[Ndç�r6¬[Ë{¯ÚÀì™3°[­xF¿“šKyeeÌòCÕÕ®·|A)>ÿáq]×yuÛvR’’(ÈÉ™Ô|&’—•E—ÓIWúáp˜Ï|÷¼üÖÖ ×Í�œ×¾~WÌ1onïàñ—_‰)‹;ýîþúÈc4µµ33?Ÿk¯¸œŸýÇWÉJKãÀÑÊ8¶ „B!„âíN„ç8H5[1�QBi"z´l¨šùÒ ŒdÕ'19…�oy‰¾øïyæA®~üŸ\úðݬzäo¯G÷ûуA´P] nK×ѼÞaåI㟛Q1�b²žÑ‹—B!ÄÛ‘Å ’b²’eM ÙdÁª1LA)oƒ¢`U�$›,dYH1Y±œ¥r¢'£( W®]Ã_e_Åt]§¹½ƒ?=ð0®��hfÞp©ÉšÛÚq P]_ϽO> #2øÖ-YLyU5}n7¯X]~ÏãOðã;þ8¥û‘”�ÀÊ… xäÅ—9\SK0b_Åž~m—®XSšô•-ÛØ~à þ@€ýGŽòàs/°jÑ“–MOIaɼ¹ÜóØÔ64¢ë:•Ç�ó?ݾÇëå;¿ý=O¼òê¸ÛIMvp¬±Ÿß�®ë8ZÉã/¿‚¦ëøñeÚ¥¥$³xÞ\|þ­Ä°mÿ^ݶcÂõLF#Ÿùà�TÔÔòßù+•Ç�ãðPßÜÂï�ÃÕ5|ð]ïŒÉ^ôü¼ºmû¨¯Ã#‚±ãY¾  GBw=ø]½Nü� ÷?û<�`�¥eó&\79)‰ ÊøÇ“OQ]W�®ëT×Õó—‡Åh4N˜e9�w°ý@‚ÝÆ¶ýxà¹çééë#¬iì?r”ž¾>ŠrË̾ºm;ßùíïéèé™Ôs!„B!„x{�úŽç…T“�ð=†,ÖHF^8 âEKŠÂˆþƒ€ÅŒ·»›¦�{ÀfÁ â …ð‡B ô8 ÷:1ä�èG'ÊŒz} [®¡ ŸÃ8TÅ@šÙ6%+…B16›j¦}BºFPÓéáÈ—¦ƒŽͰSƒ2ø~­*ŒŠ“aðßsEzjJLIJ€›ß} ýn7¿¾ûžèþÌ/™Å—þíÃÑ1ÃÖ{ßÕøåŸÿÊçøŒF#ŽÄþíú÷ðÇûä{¿»�ßÞöMR–ÌŸ‡Íb¡tÆ Ò†•‚ôùü4·wŒ;GÕ` +==&#Ïd4’•žSÕf±�•žýþßoú øç}üô�wE—­\´��½ï†ÈvU²ÒÓ¹aÕÜõÀƒÑ`Ü‚Òn½éÑu²ÒÓ±D~ 6[´ÇÀ>ü!þpï}|ïw€Èy_»d1~ϵù¨çpÓ=,[n¤[®{7¿ùÛß¹õ{?DQr³2ùè ïáŽ=ÀW~úsþü“’˜`ÕƒÐdŠ=Ÿ¿åf~û·ðË¿ü"å8?õþ÷ñèKQ'@¯X°€ÿüÔǹ÷ÉgøÑí'µ‰ v>yãûX¿vMt™#ÁŽÝjãé×^µ�• ° ´›Õ:êÜ g³XøÆ§?Éïïý'_þ¯ŸE�ëçn¹™¬´Áó—�ž-áš`³‘>ì˜öæ›øÃ?ÿ-a«( «-ä£7\“™–†uÄçhGBlIÒÂÜ^Øü&‡ªkøÕ7¿Îç?t~ø¾øãŸ ª*ë×®áòÕƒÙ®�`�¯wÌ,Z!„B!„BÑå/Æ)Ó3àæ»Mûãû§¹—Lzûš®ÓôŽÛ_èÿw7ßÿÖ·Qós°\uù`�pxÏAôÈ·'‚‡á„›Z :‘¨&5?uÁÜØÀb¤|i¸«Â'„¡#Uèî~úë_ñ±Ï|jÌù©fª…âm¡­¯»Å‚ÃjŸî©œ½ž¾Ó¸/®±§ò¾/&¯·¿ŸŽînR““£�›ñC!šÛÛ ‡5fä£ ô8ûèé룸°Õ`À50ÀüSþýærÉŠå1ëÿú¯ã›ã|æ9]==t÷:ÉLK‹ î çñùhhi%9)1Z®sRÏÑÝMo?Ù£2Û:»Ø¸u½á=ã®ïhjkÇhT)ÊÍEQÚ:»ðúýäOj.Íí¸(ÊË3ãs<º®ÓÚÙEo?Ž;yÙÙãù¦‚¦iÔ·´:yYÙX̦8Ö:¡£§‡Þ¾>²ÒÓGOãÖ4j°Y¬æ–Põ‚´uuÉÍÌŒövBˆ M—»Ÿ>¯ƒ¢Pœ‘=ÝÓ‰‘~ùUÓ=!„â‚×ýÆ+Ó=… ’džG Ê`&¡3è# …G=~Íõ×ñ›Ÿÿgs�7¶¢Î(ƒ ê1¥@#AÂôTÔôÔHðox¬xp¬ÖÚ›�è4àĘ®4—›ÔÔ4®¹þº1çn6¨¤š­(RXT!„gHªÃwàÅd423?6�•–’LZÊ`¦ ®ë<÷ú�FV-Z3nû�ƒäœBP.^Yii' pÚ­VæÍ*>õçˆôÂ) ñÔk¯�´ç¢Ål¦¤¨0fYNfÆ)Í%ÿ$½Ç£( yY™äe�¹s1œÁ`˜tðs¸xÎëDTƒ�93gÆ,³˜MÌÈË;åm !„B!„xû’áyÆ (¤šm¸‚~<áØ/ÙÙÙüö�wðåÿ,u�„êÏÈF&�*ŠB’ÃÁoÿt'YY£/ðØUI&‹„…Bq^èêíåë¿øÁPˆ»á=XÌæ˜Çgä³fñ¢i›ß™dPÞÿŽ«ÉHM�î©!„B!„â ’áyH& fƒJÈ�6,`wÕ»ÞÉÆ-oòÀ?îåHÅaü>ÿ�‹Åjaþ‚…|øã%¿0ö.r…d“«*/3!„Bœ?R¾ò±#Õá•%G$ûîB¥ªª…B!„Bˆ·‰ÜœÇ¬ª³AÅòã ‡¢Ë Š ùæ÷¿;­s³Œ8Lé7(„BˆóŽQUY¹pÁtOC!„B!„⌑áyΠ($›¬XÕ0® Ÿ�®Më|ŒŠ!šÝ(„B!„B!„B!Î= ¼@X *‹¿Â ÔÎn Ð¨H0š±I9Q!„B!„B!„BˆsšDs.0ƒ‹Ùˆ_ ã ñ‡Cèq¬w*À¢±©&,’1(„B!„B!„Bq^�áÊbP±Tt“Ž/¯… ha4ýôÂ…EÁÙ¶U5¢ =…B!„B!„B!Î' ¼À)(ØT6Õ@H×j!]#ùÒtÐÑÑ#ÁCEQPP0( *TÅ€Q1`2 þ+„Bˆé§ë:�Pˆ`0ˆÝjÃë÷ácƨª�äÄĸ¶ ‡éwŒZžh·a6™âÚ†kÀC0ŠYf2IJ°Çµ~ ÄíñŽZžœ”ˆjˆï3HŸÛM8[jÝb6‘`³Åµ¾?`Àëµ<Õ‘„¢ÄwcTo¿+ú¹jˆÝjÁj±Äµ¾×çÇë÷Ç,SHu8âZ_Ótœ.רå v–8Ï¥Ûã!Œ=—F£Š#!!®õÇ{=%%Ø1ãû¤ß=@(ŽYf6I´Çùz …pxF-ŸÌëÉér¡i±çÒj1c·ZãZßç÷ãñùG-OKŽï\ôôõ�Z–`³b1›ãZßãóáób– )IIq­Ö4ú\îQËív̦øÎ嘯'UÅ‘ßë) áñùP ŒªŠÙdÂç9B!„B16 ¾ÍFUþ˜B!ÎG¾@€>— ·×‹Ïˆ¡J‹ qº\ôöÇ…,&SüÂPˆ–ÎÎQËg俯 ìr:ðÆøíö¸„þ@pÌ9$ج¨qCÚ»º ŒR¦:’⺽^Z;»F-ONJD�3@ØÒÙ9*@˜•–w€°Ïí¦ËéŒY¦ qÃZxÌ㘟•w€°·ßEŸ;6(dµXâ‚cŸËbs^ÜÂNg/ÞÁµ¤{ÜB¯Ï7æìqÛººG)Ó““ãº<^Ú»»G-�7à¬ëú˜û�“‘w€°Ï妻¯/f™ÉhŒ;@ �ýz*ÌÉÆlŠï÷Kw_®ÁZ»Õw€ÐÐØÖ³Ìb6“—™÷϶B!„BˆX œBñÞU.„B¼è‘Lv1u<^í=½u��&¤ëhºN‡ÇM¯ßƒÓïeøÇ³"Ù?:‹k,~¿ŸÞÀè콿ŸQ�tÛÐ#½/ž@lö]Ш�è�Å]wØfÝ/}Ááë>Øðb&—x{p»9°?C!{BK—/›Öy‰ ›&Å …8çÈÏ¥B!ÄÙ#Â3dqbs­I´8{ÈOMǬʡBqáò¸}^ S3¦{*ç¿>ímô;�ôtu£(J£Ç§ ÌŒ§·§ÏÀ6›ô˜S+ÓçtFÿ¯i¿º§%.pš.�!Î5ºü\ !„Bœ5µ:ƒ,FɶÚû�¤¤Ÿö…;!„â\Ö4:úûÈHt`T¥gÌd§ @(Ä…À‘œÌ»®»€½»wÓÔØ8ÝSB!„B!.h <ÃÒñt¸úÈv¤L÷t„Bˆ)¥ë:íýNlf3IVÉ*›¬ >ù>;®þ~\nì‰ 8’“'Þ~ @OwáP“ÙLjZ*F“i̱¿Ÿ¾^'áP[‚�ä””¸nnÒuW?&“›=öuàêïgÀ=€®ë$§¤`O°�»¾ÍfÃd6ãõxqöö¢(ì ö“îçÉ�A7á°†ý$ûåêïÇÕï !1GrrÌØþ¾>E!1)‘Îöv,V+)©©ü~º:;q$§�˜”€Ïë% Žd>¯—ÞînLf3é £[�º»»‡�¯4Lãœ/ÏÀ.—‹P0ˆÅj%5- uœ½×ã¡¿¿ŸP(DbbŽdÇ)ݸðz¼‘ã“€Ñû§D_?º®c4ª˜#çÑjµ i===ddd`±Zqö:ñù¼ddfŽÚ†B!„B!Îù‹ü,ÈIN¥ÙÙM—»ŸŒDÇtOG!„˜2®>tt2åý프' Ôu�Šå4ÖÕG—åŽèÑu�ÚÊ*ŽUןÈR4ª*³Ëæ3³dÖ¨±µUÕèÚ‰ù$$&²dår’S&¾Á©âÀAêê°X¬¬»ìÒèrÏ€‡{öÐÛÓ]¦( …3f°`Éâè¼Ea×Öíø}>æ”ÍÇl6søÀÁH‰1…Œ¬LV_|QÜÇ)f¿ªª¨©¬Ž9‰I‰,_µ2&èè÷ùØ·kÝ]]1ÛHt$±|Õ*’ƒ¯ïmo¾…QUÉÈ΢©¡€•kVS¾ÿ ~¿ƒÁÀeëד˜”Hýñ:ª++1›Ì,\º˜ý{öF�¯#9™u—\ŒiX¿Î꣕TWV¢ ;ƒ�yeeÌš}¢ßc8fÿž=´¶´Æ”a³˜Í,Y±œìœœè²P(ÄÁ}ûhmn‰›ž™Áª5«cž?¡`�Í›6¡ë: /bÖ°>”ž�Þxí5t]gñÒ%X­VvmßAÑŒ´¶¶ HHLdμ¹ìÛ½€Ìì,Ö]|ñ¤æ Äts xxõÍ·8V×@ŸËMRBó甲þ’‹°Y-44µ°qó›n#Ùáà×½ �×ÇO<ŠÂGÞw=V«€‡ž|·Ç3áv®¹ò ^Üôú¸cÒÓRyï5ïà¹W6¡®YŸã_zýMšZZÆ}ü²µ«™=«€Ž®.6nÞBs[¯�‡ƒe ʸdí*Ôa7A„5�í{ö±¯ü0=N'ªª2£ Ÿõ¯#/'{Üçêìéáé7R2sW\´vÔãûUp¸²š�¼ÿ†˜åí��¼¾uÇðù$'%1oö,._·–„ÈM,Ïl|�ŽÎÎqŸàÒµ«)ÊËå_�?ź•+(›3ø;ïé—6ÒÙÝ3îzŸ¸ù¨ªÊ#ÏW_v ϼü*&£:ê¼7µ¶±yÛêššøƒ¤$;(›;›ËÖ®Æj±Dǵ´µóÂk¯3N)­\1êyvìÛ“ˆmì IDATOCS |Ï»£Ëœ}ýlÜüõM͸Ý8’Y8oW\´³yì_„B!ÄÙ7úÖe1å ŠBnr*~?]îþ鞎BqÚ†2á¹É©RFûM¦ÏNGk�uudde²pÙÜ.WLnHS]=ÕG+ ‡Ã²xù2rrs …Ã)?D{k[tlc]ÕGSEÅ3Y²b9)©) ¸ÝìÙ¶ƒP08UWÓPW‡ÉhbõÅë¢Ù�áp˜ÝÛ¶ÑÛÓCJj*k.¾ˆ•k×b³Ùiˆ<ßpCÁºŽÖ6*Ê¡ ¤¥§“–‘Ž=!!îc4\c]=•G ‡ÃÌ(žÉÒ•+HIMÅír³cËÖè~éºÎîí;éîêÂd6Q¶h!‹–.Åf·ãîw±kÛöh€Ñ`0‡B”D‚cöì£dv)iééhšFGÛà±Êæ †‚TVeÁâÅÑ€Z_•GŽFçÚPWOå‘#hšFáŒ"–®XNn~š¦Qqè­Ã.Ö«®¡¥¹»ÝΊU+Y{ñE””–âؽc'Á@ :vÿž=´45c±ZX¹v5]v é™twv±g×îIS{B™YY£J€¶47£ë:ªª’WPÍ�ljldÁ¢…$&%1àvS]YŪµkP…®ŽÎH–¥ç‡î^'?ûß?°÷à!ÊæÎæW\Êœ’™¼¹}¿ºóÏø|> ª»Íýêqö±·ü0V«5ºÌj=|Ùwè0ûU°÷à!T‰.·ÙNŒ·X,ì-?Lo_̶UÕ@¿ÛÅÞòÄ4-æ±ès » ªö5u îã±úzªŽÕ�¹-»ÍÍú­>^ÇÏ~w'�-­¬X¼ˆ —\DnV&O¾ô2¾÷_Ñ÷7]×ùÓ?îãÑgŸ'+#�õ—\ÄêeKhjmå¿ïü3ÕÇëÆ�‹×ãcoùaîE›[G=ÞÚÞAù‘#1˶íÞËÏ~w'GjjY0w—­]E~n6›¶lã'¿ý Mƒ¿O­fsÌ~•©¤­³kÄñU †5ö–¦³»;úGk�Qר<î1r¸ªšºÆ¦˜ù=ÿÊ&î¼ç^|¾ëV,cé¸Ýî¾ÿ!žÙøÚ¨} ƒ¼°é öª`ã›oE—�Ƙçljm§¢º6f™%ä«ã¼oÚ²�_Þþ'Ž54°¸l>—®]EVF:/mÚÌOÿ÷Z;:¢cûÝnö–æ¡§ž£cÄ�4Í-m®ªŽ~ßÒÖÁýîŽTײ¸lW]~ 3 xyó[üö/w …Æ=çB!„âì’ Â³ÄhPÉOM£ÕÙK{¿“¬¤d¹˜*„⼤ém}Nt ?% ƒ"÷�ªøÃƒÐÒ4x‘Q5ª,[³UUÉÌÎáõ—^5öxM-éé,Z¾ €¼ÂÞxù¼^/ Ç�“�›ƒ®ë«ª�H6ׂ%‹HMOgûæ7Q�Fz{zÉÌÎõí-­TUAUU–¯9‘e7ô˜Ûí`éÊ•ÑÀ¡¢À®mÛ©¯=FéÜ9Ñ ’Ù2xÛÙÛKBR"«Ö Oý³RMUÕà~ee²péÒÒÓÙºy3F£‘Þž2³³éîêÂÙÛ @Ù¢E ¶;;·lÅëñÐÞÚJ^Aš¦1#’=S[Sƒ®ë—–ˆ”2õûý1óÐu�¹eóÈ+(@‰”1íìè ¹±‰…‘ã}¬º&:¿%Ë—�_XȦÞ�x<êj�‘——@_ŸG²ƒÜü|E!3+‹”ÔÌËàAÜ.7­-­(@Ù¢…äFÖ_¹z/?ÿ"�íô9ûNš!:Ò¬Ò:ÚÛqö:qõ»Hr$A$@�—Ÿ�yX@Âj³Q4c]�¸].223ÉÍËÃl1ã÷ù øý1ã…8—mÚ²�P8Ì÷¾øY‡Ý¼°når~öû?òÚ[Ûx÷UWrËû®�>öòoRרÄÍ7\7fŸÞ]ûP2£�ÏÇ®嬉üξþ�WGǶíÞ˲E ØpIlFuo_ï¼üRfLÉ~f¦¥ÅìÃXž{eY™|ãó·Æì×ÂysùãßÿIyÅQ/˜Oõñ:ŽTÕð©[nbÅâ…Ñqë/¹ˆÿ½ën{önûòç'|.«Õ¿žx’o~á³1™‰#ÕÖ×sÿO³fÙRnyß{bÊ.¿{ÃÜqϽüùŸ÷óݯ}‰«/¿$fÝ}‡3¯´„÷_{MÌr×ÀØYœù¹9'=F#­©áù×^çý×^Ãúaçñº«Öóøó/ñò뛹hårÒSOü^.?R‰Ïçã²µ«Ù¼}'�ÝÝd¦§S6§4šÑðÏGž ¶®>®9®¬æ±ç^䊋Örã»ßSòú]®àö»ÿÁŸï}€ï|å 1Ù~³‰ûš¯ÞúÉ ¯el|ãM,&ßú­ьX€UË–ðßwÜŶ]{¹lÝêI9!„Bq¦È½³h(HÖ4šœÝÂrçœBˆó‹/¤±§Õ` /9U‚ƒg‘Ç=ÓkÎbµÄæü>?‘±ééÑ劢�’–@ï`�Éçõáõö”KËÈˆŽµÙm\yÍ;¸ìªõc]ýýìß½'+%=33æñžH¦…Á` ±¾žÊŠ#TV¡³}0#! Ò¹¨M$bÈü… Gõ). –D­¬¢&ò5`âõx£½òFî׆kÞÉWm 3{°¬�³§7úxú°±©‘cÐçtÆlßf³c6ffX¬VE‰fjhÃÊ™Iv†ž#  ~¹Ý®QãE!5=mÔó;ƒÙ–­-­¼òâKÜ¿Ÿ¦†2²²ÈÈÌŒö,ìé>‘åÑÙÞÁÑÃ=\Á±šZT£s"#&^™YY$$öYÊ"ô àŒ¼¦ŠfΈ=V‘r~CåLm¶Á‹ÅCÇ+<Æñâ\ÕÕÝMFZZLpKD®[¹ ƒar75ô8û¨©«gÕ²%¬X´€ªšc¸"7Wœëºº{(ÊËô,›SÊ‚¹³ D²´»"YîÅE±ÁKÕ`àò‹Öàˆôm�È{ßõZÚ:xí­­Ž{öå×ÈHKãC#‚ƒI‰‰|ú–›èw»ysûޏ÷s*½´éÍÁÒª—Œ.›ýŽ+.ÅfµP1,`çþƒäæpíUW¢ ìÞ_~ÚóxæåW(ÈËåý×^3ªnjr2Ÿ¸étvw³}ßþ˜Çn¼öjêêÙ)=žÎîr³³c‚ƒ…y¹¬Z²xRÕ„B!Ä™%WõÎ2ƒb /%�D‹•æÞnz=è“ÊB!Î>M×épÑÚ×Cª=�lGŠdŸeC¥Gö�3�èå ‡=6b¬i07TÞ+û ÿWˆé é:n�·Çƒ‚òÉãÉÉÊ¢©µ�WßÚ:*[øæë¯›°·ßXv8ˆQUY¾h!Ë/$¬iì=xxÒóš9Ù™ì?ñÑ“>W~v.½˜ç_ÝDçeµ¼^5uõ¬]±lÌLM€¬Œ æÌ*æà‘Ê1?“|~?µuõ,.›?æã‰v;¿úþÿãÒ5«¢Ë<^ŽTU³zÙ˜=«˜ÝO/@ØÛ×GcK+¯Z1îûVQA…ùy”WÄ–o-)*âÒÕ+yâ…—èw¹Æ}Žœ¬Lª�gË®=£>|üæ÷sùEkNk„B!ÄÔ‘£Ó$ÕžˆÝl¡ÓÕ�Ëç%-!‘D‹5Ž5…Bˆ³GGÇåóÒ;0€É¨R�šŽI•�ÓÁl6ã÷ùbzÌøFyŒÆA¦‘ý‡]CÁÀá™{“é ”�˜ÈÂ¥Kس}'^¯‡ê#G™¿èDé8c$ÐeT�¼ã=ׯ½]³ÅrÒ’ªHÏ̺´9ruØk4x’ ÀP6@8ŠéB¡š¦Á°Àê©Ðu�` 0XþsÄ9±XÌ1™#/¤pUU�^È5 ,]±‚EK—ÒÓÝMG[;-ÍÍø|>ª*+q¤$“›—3çË7¬Ç‘›iz: gÌàhÅ<ô÷õÓÑÞ@ÑŒ"¹q@œÓ�u-ƒ½ì2RRHIMžÔúW]~ µu <þÜ‹<óò«”ΜÁ¼ÒÊæ”’—“=éùìÚ�óæ`·Y±Û¬äå°ëÀÁS  <ýÒ«$ØGÿ=¹`ÞÖFÊ–Æ«£»‹»ïhÌÇnºþZ’yÿµ×p×?îçîÆn³2§dóJKX0w6iÃJÏ**bÃ¥óÚ[[9Xq”‚Üæ•–0oö,f�Êôϵ®`ß¡Ã<øÄ3|éSõ»¦³»g°�k^î„Û)ÌËåÍ�“ïÁ:RCSó˜Çè]ë¯$/gtæ}WO/aM##-5îçØ[~M×£ÁÖå‹rÿãOÑØÒzÒýÏPÁ‚¼œ Çæår´ºvÔò®¹šƒG*yèéç¸õ#sÝk¯¾’Æ–îü){îEæÏ`ni esf“�™1æ:B!„bzȾid1š(HMÇåóÒ3à¢gÀM²ÍN¢Å:ao!„âL ia\>ý^ªÁ@Fb r#Ë”éíï§¹£“.¿‡Ü¬ ’“’NºŽÕnÃÕßO_º®£( ¿ïˆþH««ÕŠÏç£wX¦…®ëôG²7‘þF6»³ÉD Ä9ll(äõ—_AQæ”ͧpDÙÈÔ´4Ò22˜=.GÊQwìùE…8’/¶§¤¦ÒÔÐ@(¢¿¯/º<,-j¶X°'$Œ ìÅ“]f±ZYsÉÅ0A‡B›Ý†Ùl"ÒÛÝ ÌŽ>ÿ¦—7¢( sçϧ¨xfL>g¯“›-òÿ¥G'Û§o¤¾¾>2³/÷D޳ª‡•Ý´ãõzcÎ@_¤lçP¹S]×p00à&-=�̬,2³²([´�7^} —ËE¿³�ܼŸÑhä#ﻞßÿõïìØ·TÐ3ºf‣Él:é�#gB0’Ùn�“xìÚw�9³fE?#,]PÆCO=Ëžå§ ³=�'¹Ùkè³ÁHV«•›o¸Ž?ÿó~VeqÙ¼QcR““¹íËŸ§¦®žŠª*kkyüù—xôÙ˜?§”�ßô’&(%.„B!Î ž’¬6’¬6Ü~ý>Ý.l&óà—ÙŒÙhD÷˜Bqút]Ç â ðøCAìf3™Iìæø/f‰3''?�ζvþ‡ö ¯ Ÿã#zï ™YZÂÑC‡éîì¢æh%©éi´46E{Î,)�H)¸¢YÅÔTVÑÑÖNMe©i©ÔÕ# b0ÈÈ� 1dƬY4ÖÕãv¹8´ÿë.»EQÈ+ȧêÈQ?ûwífÞÂ… Çk�ÑÙÞNRR_9¹R|“¡( 3KfQu¤’Žöª�%5=�ºÚcATÕíA˜™•E¢# w¿‹Šòr@ŽFJ«ÙÈÉË;­¹T,gþ¢…¸û]t´µ�W�S\ZBEù!º»º¨¬8Bzf­Í͸#½Èf–ÌŠŽÝµ};n·›¼üN‹Ëæ±tá`°rö¬â1Ç(ŠÂìâ™Ì.ž \…Ûãaë®=<»ñ5|òéq³ÅÔËLM¥¤ ›ÕŠcDÿT1}üÁ ^Ÿ�–ÎNê[[ ã¯ä1,&Åùd§¥a·Y1�øü(¦Ÿ/àgÀ룹½ƒ†¶6‘J(ç*»ÕJia©I vTC|ÄÙ3àõâñù¨om¥¹£sº§#Î žC-V-VBZ�ß�7Àé ¬i˜T£ª¢* Š2Á=óB!ÄÉéèèºNXÓjaBá0FƒŠÍlÆaµa7§H6û9&¯ €¶æ:Û:h®o ¹¾�ÌœlÒ³2élk�–Ã$Pò¸Ýƒ½çŽžèµ¤ª*³çÏ#3ûDЯdî<Zšš¨ÖËÏd2±tÕJlvÛ¸sR…²Å‹Ø¹e+ÎÞ^êê˜Q\ŒÑdbÕEkÙ»cn·›ÝÛ·G×q$'³|õê3Þ‹®dÎà~554R5¬ß”ÙlbÙÊÑýR…•kÖ°{ÇÜý.öìØkOH`åÚ5§=×Üü|vnÝýôf·'0AYôñâ’Yx<Ô?Nue%Õ•ƒó5 Ì™7�œÜÜè\—¯^ÅÎ-[iin¡¥¹%º EQ(ž5‹ÜaÁÌ«V²kÇz»{Ø¿g߉c`1³rÍj¬¶ñÏíÉ$§$“š–JoÏ`¦åŒH_©PXTD{[;­--TUV ÎY„bš…Âaþô÷ûØpéÅÑ€€Ífåò‹Öàò ðâko£Y°ãÑu�ÝûËùÿìÝw\ÕÕÿÀñ×�\öAAp/pàqïmæj™Ù´aÃÊ2mYöõWf–•V–3·©™[Ľp@”){sÇïðÊõ2.CÁ:ÏÇÃÇC>Ÿóùœs¹îø¼Ïy¿½=<ðmm¸ëÈÉ3œ¬B€ðaŠOLbÝÖíŒ>g§{uGílmÖ¿/·’¸Q”žrËßÿ •ÉRb¥¤¬8°hm9†Ï¿]ÂÕÑtlëcRß#õç•pþܶƒzÎ÷jÖ:98`kmÍù+tnß®ÔcÕ —""iÑ´I©û$'{{¬,-‰Ž‰…uKú~ÅJš7nÌ�‘œ<{‰DB`7Ã÷ËxŽ�9ËÕ¨èâà[åÔwsE¥RqáJ8>-›—Ú&??ŸÈk×éêסÌó<6l0s.bËÎ]õs3³søeõ:†ô ÆÛÃC¿ÝÊ‚~�=IMMãè™s•·`:3…‚þ=º1,0ÿömq¶7=­­P; ÕjN_¾Âžã'øóŸ=DÇÅ×ö�hâéÁ¸¾}èÕÉ�¶Í›‰ï†��ܼ|Ž_¼Èß¡GY¿g/É©eONy˜Ú·hΘ>Áuô£¹WCQšà’ž•Å‘sal;t˜­‘•“cÂQ£Bë ¹T†�¹6æEi74Z-5j�­®ø†®®nÏAê6™D†T"A"‘ �ÉQÊeH%âK_]&‘HèÐ¥3Iñ‰dgeaie…s=W2ÒÓ©ïá¡OWy·mëvmñjÒ˜”¤d Q©T8¹¸`¦2\*•JiÛ±�š5!íN � ��ôµïjåëƒF­Á¼Dj0Ggg:uëŠZ­1¨{gkgG@Ÿ`’“ÈÉÎB*“amm�½££Ñ—Á =±wt4¹•)¤R)mý:ШiR鸞V«1·°ÀÙÕE_ƒñ.K+K‚{‘œ”DVF& ÁÊÆG''ƒ³¾íÛ¡ÓiQ(•È ü:wBV|.gWWü:wÂÒÒÊàÜ:�ަ-šãèìDzj*• —z®7T% mÚúâݸ1ÉI‰bnaŽ£““QÏÖÖ–Þús'9™ì¬l4 *•Y©m•ffôèIÊ�;d¤g Óé°´²ÄÙť̠§³‹ ~]:ë�/�¥¥©)©XXZêS¨ÞeggGÇ.�õçððôÄÞÁ›âUB­}|(,,Ä¢”Õ R©”Î]»��•ENN®þ÷(µI.“—�Èɳa»òòòQ(È+Hq põF©ééŒ2�vmZì“H$lÚ±‹[q Ôw«|]ÇÁœðk× »NoÿîFûóòó±0/ZQœ››ÇÙKWèà�²ÄûTQ»¢º•˜¬`a®bì°Áü¼r Þž÷P‰„€®�økÏ~"¯ß(ueÛßû’‘•E`×Εz¼5A"‘Эc{ö>JŸÀ¸Ý÷šy9ò*ié4oR4îgÏÓÔÛ›÷гsr9vž“gê ”ÉdôèäÇ�УôèÔÏÆ+ä·ìÚCAA!=»t,ó<6Ö֌؟U·àUbò†…ÊŒë11œ9É @xW^~�þÚj–ÊÌŒi£FðÂccDPð£�ËéâÓ†.>mxç©'Ùr˜Ï—ýÂåQµ2ž-[ðöSSèݹ“à<¢ÌUfúu Яs¦Ocõß»XðëŠZ[Ö¯[Þ|b2Z§¥ ¶VV èÑ�=ºóÙË/òó¦-,Zµ†´ÌÌÚšPD€ð “J1—*A¬àA„ÿ4‰D‚‹[=ƒm¶vveÖȳ´²ÂÒʪÔ}÷³¶±ÑoÊr7-§©Ûe2®÷�·46¶¶ú´’5ÍÚÆkR×I$}M¿²25¸º¹Qò>‰[ý{iB-­,±´*;u—£“NNN厡襧k+I*•�µÂ¦H$�œp¬ ï{c¸wÍ”wK(-5�Û·nиIc£Hf*î%~?6¶6ú:ˆ"+3A¨ J…‚&Åi8e2šJìß�M;wáè`Oçö¾8ØÛS�_ÀÙ‹—9v‚�;š´âøøÙ0ÌÍU¥ÖÞóómÃæ�ÿp*,Œún}M[øÕk¤¤•ž^Ó·es}ºàŒ¬LNŸ¿hÔÆÍÕY´ÊÊÍ)µ €½� Þtjë˶ö 23£eÓ&ØÛÚ�™•Å¡c'‰¼~ƒI£GЭ+GN�aé«Ú§7n®ÎH$bnßfõæm8;9Ò¬qů{%uðiÍñ–Í99Ü –]ï.†_eÉo+Ú7˜Žm}°²´$1ù{CB 9~’~A4ô¨_îù”þ�œ=‰E?ýʘ¡ƒhÓ¼ \ �dõæm¸:;áçÓ†›·ãˆKL$¸g7£sXZ˜Ó¢icÎ\¸È¸aƒ«4±fHŸ^\¹z•o–ýÂðþ}iïÓKssâ’øç`ÇÏœeÄ€~¸–X¡Yšî;pâÌ9"oDéW’Êd2‚ºueÏ¡ÃØXYÑΧ¶¶ääåqòl'ÃÎ3(øÁ¥ÿ¯êÑ®-_¿ýÞÕH‰.Ô R©„!þ èÑ�ï×­çÓŸ–“ÿ�ê¦Z¨TÌ{q:S† F*�Á ¥BΔ!ƒÓ'˜O^Î÷ë6<´‰wÎööüß[¯3 »ñû™ðè²¶´àÕ‰ã™4ho½ˆMÅ©ò…G— ‚ ‚ õîÙ�ÂÂBv<Äö=ûôÛ Ý;û1b`?£cr¹ÁŠ)�FCĵtòõ)µÆ§ƒ�Í5âbD$Ãúß J°0W­~¦8 ca®b÷¡ÃeŽ»Y£XÉ嘙)¹��Àª�›�Úô ÄÍÅ3¥’ìììRÛ´mÕ o&Œ†\.gõ¦­)®ÍÌÌÖ¯7Ý:¥§tsufúXµa+_|÷ƒÁ¹¼=<˜2n$ò2‚\R© sR™qÐuüð¡Ü¼‡¢D:W…\ÎËÏLaãÎ]lÞùnÛ¡ßgmiÁ˜! êÞµÔ¾ÌUæ«àï’H0úÝ›)ÍP™)�ÚÞOef†²Äjqss3¦=Åoë6òóÊ5m›z{1yì(är9a—¯`eiIûÖ­K=o§¶¾\�Ž!òF-šÕV*¨ÌKÿ<¡23ìÄx•J%¯=û ë·ïdÝÖí¬Þ´U¿ÏÆÚš‰£‡Ó½£Ÿ~ÛÝkLr_À@"‘ðøÈaü þ®†öë�V§eÛ?{ظãoýv¹\NP÷®ôïPáïN0ÝŒ ãyïÙ§EúǹLÆKãÇáß¾ß�M|ò�ÚŸ—»«>ÿ„f =h?Bí±P©øøÅçñoߎis?%;7÷�öשu+~ûx.´¡ö8ÙÛñóœÙtoëˬo×ùº—BÙ$:‘¯GA¡ÒRs²y÷æZÂ’æþ¥n�ÏË*u{ùsv+Ø[� ¿Uí·:sŒËoÕãæ) IDATu~Õ9®œÇjb§WÃ#¸réƒG G"‘Tïy5­[ãã*<°rÏkdx8—/^B&“áàèˆo»¶¥®ò«�k¸ZÏkÕº,:ö_z W¾ßGã.)O£&G]ù•…DߺEnN.66Ö¸:9aþM›˜�“KlÜmòó ±·³ÅÕÉÑ(•(Åi‚cãâIKOGef†£ƒ=e¬~¯ ùùùÄÜŽ#;;;[<ÜÝj4�uu%§¤Ÿ˜Œ¨çähPËñaÊÍËçæíÛäææaokK}÷z5hÊËÏçæ­¢sÛÙÚââä€JUñ߉B*ÅZaìLÎÊ =7©D‚·SÝJ¿ëاVú�÷ât^7¦JÇjàJ‚ޏ ýZTî9�HÔŸ©£m})¶ÿÍ—¾‡*&>žá3fÿ`j6÷jȦ…_Öx G­…|­K¹X�Xל¾|…Ño¼MFvö9 _~ÿt.&¼æ ÿÛ†ðÌœy¨5•ÍÏQ9wì~ çÿ¯BAA„*BS�« ÏipE«Õ¢Q­Nº[òß\¹ûõ¡¼º4"@hÚ™ëò5\ù~�kø®ªAxpD€Ð4¯OžÈ{SŸªÔ1á‰:v]Ѳ?B˱h-ùj�Jàà«Jš»˜þ*=òÇBB®k‘H ™³„ ¦R‚›Iño,EYwâðÿ*×nÆ2à…WHÉȨÑóº;;ó÷’op¯ ¥pE’ò´ì¿]ȉd5i¢³4”XL䬒âm-¥�£œî® üœäˆ,¦µëðÙ0ÆÌ|‹‚ÂÊeS¨H»æÍØòõWXV¢¾°ðï°jÇß¼ôù—´ |0DŠQAAA¨aR©i)«hþ Ê ‚ ‚ð`wîȬ§Ÿ4©mr–Ž?NjYNÃåxãõZüo¯šEcÜN/ZQ˜SÙEûå2°T‚³•7 á‰ZB®E~tº¢ cx¢†k°·€Á­eLì(¥£§HyZ“{4à»÷ÞæñwÞ¯±úqr™ŒŸ>|¯ZÁÁIj–…çq8¡M9ÃJÊÓ’”§åx’š¥Wòp5—òX#3&41ÃZ!>WÖ†í|ùð¹gyïÛ%5vN;kk–Ïý@ÿ£ØŸS—¯°|óVZ u‰ ‚ ‚ ‚ ‚ BgeaÁ7oÏDZÁò+µ^Û fÃ9 ,Ú¦eù|“ú/¯ÛÔøý„†ßOhh[_—#´o ‚?5¥o×.LØŸ?¶ï¬‘ó½0n ]|ÚT騍L ŸœÍ%4¡j«ðrµ|s1—_"ó˜ÑÚœq�ÌÄŠÂZðܘQl åðÙs5r¾¹/<‡g½z¶[´h‡ÂÝÝ—黪t:ÑÑÑdddðÌ3Ïзo_ŽÊòñ‹ÓÙ{üÑq&%²ð`ˆ£�µVC¡¦èŸN§C«Ó¡Õ‰ÂŸ‚ BÕÉ$R$ R‰¹L†R&¯±š3ÿ"Ũ©ÇUЧHÏX{Ë9î¦g¬ÚYMØ+RŒVÜg5Zˆkø‘bTê‘b´|ï?û4¯MšPa»Å‡4ÌÙ^³©+«±“„3”˜‰¥ 5&9-�ã'‘�›[­ó8ÙÙrzõïUZéµþF>ŸžÍ%¯¼%ƒ•ÔÍEÁüÎ8ªÄ÷ч-,ò*ÁÏ>_í•©¾Íš²wéwfÙ¹s'ï¿ÿ~µúzP$ ûöíê”úê‚é6îÝÏÔ�>~ ç)F ñ6ý€OI`Ûí\ÍJ ‰µCݼèäPñYµFCvA>¹…ùä¢Ói‘Ëä(d2¤ R‰xÃAªG‹ºx‰ŽB�šB�™TйB‰¹R‰¥R%†‚ ‚ ‚PGØXZòÌÈá¶»•®ãËݵ¸–¬cÑ 3{‹Â„5ÅÉΖ'†滵Vë<ÓF�ªtpP,<ŸËÏáyÕê»4G ¿7“¥=­ð¶×ËÃäÛ´ }»vfבcÕ:Ïk7© Á•+WŒ¶ÙÙÙñÜsÏѽ{÷j�ÁT�†õë׳víZ ïMÓét$$$ˆa5  äóå¿r5æfmE0‘>¿E_aó­ëÛ.¦ßábúFÖo̤†Í�ŽÑYy¹dæå’§.Ä\¡ÄBi†ƒ…5 ¹¼Z³šA¡":täªÉ+, +/�ä¬LÌJlTXšÏâAAááÕ»6––¶›µE­¯!XÛîW3¦�/GqW«¦<5|hµ„2©”IƒVú¸ÏÎæðÇUÓRÑVÅí-Sögò{/Z‰‰ªÓCW+@èdoÇ@Ó‚{Mš41Ú–––Æüùó æí·ßÆÑѱÊc©ÈåË—™7o¥î·°°x`}ÿWH¥& Èœï—ÖöP‰WÜv<%Á(8XÒÆ[×8™’h°-#/‡›)I¤ådci¦ÂËÑ7[{lÍ-PŠà  ‚ðH� R(°³°ÄÝÎOgÌJ’³3¸™šLV~ÍÏA¡îÑhµÄ%ßázì-R33k{8‚ Ñ+°Â6'¢µì¸TwJÒ¨á‹=šÚÆ¿J£õiÓ¤q•�ïÖÖWG‡J³<"ï�ïJÉ×1íP&¢ÖÃÔ»Kg¬ªÐ…Ü´5HÒr²íÝ»—1cưaÆj§<½_AAK—.å©§ž*38(ÔœÁ¿_ u‡Ö°m·oTØfk\Q›¼ÂBbSï�ž›ƒ£¥5NØš[ˆ¢‚ B­“K¥ØYXâéàŒ�¹%w²3¹�–B�¦öÓ uËÎ�#tŸò,gÍ©í¡‚ ÔŠ[‰Iôxr£Þx‡BuÝ»Ÿ@×)Sé:e*ûO–_;÷·­;ðò9‚§½ÄàWfÒý‰iøŒ�„¶oÔ-ݰ™®S¦òãÆ-•>övR²þ±üsôx��Iê:3¥’ÎmZWØnþîº÷´þ¬†‹q"àS“zvhWåc:´¯Tûs)jþïBõjVFl¶–÷Of?´þPÈåtoëSåã{Vòš*)((ÈàçÌÌL>ýôSž{î9bbbª|Þ’Î�;Ç„ Xºt)jõ½ûr¹??¿éC0äáêŠwýúµ= ÁD"Uîe§WÜ&+�”ìLâÒS°R©h`ï„¥™ê¡ŒOA*CX«Ìñ´wB¥Pp«xb‹ Üåä`GW_Ú6oZÛCA(W~a!WoÆrõf,Éii5vÞÿ[¹†”ŒLž>…üáÔNzþ“/xbö\“ÚJ¥l,-±±´D^ÎøŸ ã³å¿‘–•E»fM™:r(“`P�n5–Õ&%=ƒ¥ë7#“Jy¼ŸJ¯ÕiIÏÊ&=+›Bµ˜´$˜æzʾ =ÄÿBp>>®¶‡S%Í<=0S*Ëms,JË�«ugõà]Z,Ø+þ^k’oSã4�¦ò©Ä±|t*õC¾¬öÜ.äŸ[u$OîD›RRšÊ§YÕ��2e _ý5®®®ÛOŸ>Í„ øõ×_Ñj«væåå±hÑ"ž}öY¢¢¢ ÇìãÃÊ•+ZÝÃÿ"Ÿ¦U_é,<\¢a ËÓT<[+W£&§ €öN(d¢ø® ‚P÷I$,­±4S‘�‘Fna.Ö¶HM(D.ËËÏ'=+‹¤üll,-1{„ëžþ]»T¬ꆓ·nòâæ ú,+Μ¤›§Ó»t÷ž{mÏdÞ *^‰±è`Ý[=x׎KZbÓt4°ßjBuVæx×7ýº_#ŸðôÚ¹®„åì®D&.™‡¢Q%®‹’dR)žõêU«ï=z°víZ–,YÂÚµkõÁ»¾0{öl¼½½M>ghh(Ÿ~ú)ñññÛÍÌ̘6m“'OF*•rèСj�]([U¯)ááÂZRßΉ¸©*‚ I©iÜÉÏA©P” ={že›¶q#öj�/w7&DŸ.�ômt:÷ìgÝ®½Ä&$b®RÑ»kG^~|,–æ*}›§ÞŸG^A f¾Âþ“gXù×N2²²iÚГåóÞçç�[ÙzŒ¶Í›òîÔ'ôçOËÌdú¼/ÐétÌí%¼ÜÝX¼úOžº—ªÎ£ž+_½ñŠÑø¯ßºÍ¢Uë8uñ ‰„æ^<7v$~-[ŒÞ¬Ùµ‡[ ‰¨”fôéÒ‘—'Œ5¸�|5&–ïÖ¬çlD$¹yyÔsrdD¯ž:ÈèsÕÉKWø|Ùo˜)•üñ©H*ƒñåo+Ñêt¼1y‚Á¤™í!¡dåæÑº‘7ͽ<Ùuä—®Gáê`ÏРžØYYœ§P­fÏñ“\ŽŠF£ÖàæäHp玸99êÛ¨56ì=@XÄUt:Y9¹¬ýg¯~PÇö¸ØÛë9Æí¤dƒ~º·mCƒm‡#=;›S—Š„fJ9›„è÷;ÙÚܹ#Ç/\$*.™TÂèÞ½ Α”𯾓§èæÛWÃ>¢ãâX»k7®.Œ¯`õ`dL,ë÷ìcëÁRÒ3x}òãå#cb9^TS¨OçŽ8ØÚè÷ÝNJfï‰S$ܹƒ¹™=Û·3˜Õ~ââ%nÜŽG&•0*8Èàý$9-�½'î=®˜¸Þþú;>¶\ΠÝHÛfb}]öý±P£ùGb¢8õH mï{͸ßít»ÃëÞêÁ»4Zøõ˜†÷ú‹Û�5ÁÎںǖ-Ý¥ÑÁÏá5_‡^._9-ídlŽ. «°ôô³±ÙZ¶Å0¼¡˜Œñ0TôS+ ‹¹`iiÉÌ™3éÓ§óæÍ#::Z¿/,,ŒÇœ‰'2}út E™çÉÌÌdÑ¢ElذÁh_ûöí™={6žžžÕ¯P1›*^SÂÃ'Þ™k‰ ‚ �*©DB=[{3Ò¸�žB};Q?÷¹tý/¶+ útí„¥¹9Û†òÆ—_³øÝ7ñïЀoþXËÏ·àéæÊ“Ãq%*š?¶í$"*†Ÿ>z©D‚D"!_­æâµ,Y³�-û!—ɰ³¶&95€¶Í›²pÅj"oÞâ• ã°²(ZaóÏÑ\¸z�ÆõñrwÀÞÆš®®ÜNJâ|ä5 �Æ+1‘I³>$=+›îm}p²³e{ÈŽ_¸Ìò¹ïѾEs¾^¹–¥nÆ£ž+O DxT4+þÚÉ•¨–Ï{©DBfv.O~0�Ììõ쎫ƒO�fþòßÉÍËçùq£ úÎÊÉå⵨áÕ™‚ Ôm‡ÎœãhغùúÐã¾àÕwë6ríf,OÌâµ²¯8Ȱ|Ëv¶~ý–Å«câ˜6o>Ñq†©çÿò;>÷´>WXXȇK~ÔïOÍÌ4øù·y³ „+wìb߉Sçüæ­×Œ„‹×þIdL¬þçôÌlƒó¶kÖ”àÎINK×ooæéidÛ°gÿ·r-J…‚ý?-.õ÷µð�5ª5¼:ñ1rã[éYÙüÊÆ½û¹põ:r™Œ^�:àß®m©ç¤¸äS~Ì�ôt& è˸¾Áú}Ë6ocáï«Q—Èô³hõŸLÔŸ÷¦> @rZ†þq5ñh`ðÛ°÷ _�B.gÿ�‹iÛ¼)ƒü»³ûØ ÖìÚÚ]{hìÑ€QÁ� ôÇÉήÌq µãfzÙé„ï »{zñ\V´buåI-šº`Å -oõE%i=FA‰tÂJ¹G;;{x •Þ»¯–ž•ŹðTfJ:·iSÓC¯É©i\º~KKÚ®*ój|¾5uõó±ÄBb³kî¢ò´’2ÖÛŒ^f8˜=wY…:6G—�JtÃ�ü2„Ù¹¹œºt¹Ü>ë»8ÓØÃ£Ü6‡ÏœE£ÕÒ©ukÌUÿÝï U}ì*³š à¶k׎•+W²téRV¬X¡_M¨V«ùõ×_ eöìÙ´jÕÊèØƒòÙgŸ‘””d°ÝÊÊŠW^y…‘#GVë~üÙ+ádd—]³s›Öì»gjFç#¯binŽ_«–r4ì<~HŸÕe!Ê©=2D€PA„J“®6v$f¦s;-w;‘nô8~á…j ½:û1û¹§س§/E`o[4s8%=ƒ_·üÀ¢Y3iÔ èæÖ�ÙsâÂ%Ž…] [qÑùzŽ\ˆ¼Æ–ý‡Ñ+€÷¦=ið%Ưes<ê¹p3>‘ý'O1$ (]ÞÎ�£Œ ¾·bd þLÔŸ-ûq>rI©ã_¾é/Ò³²éâÓš¥¼ƒD"ÁÕÙ‘µ;w³ýÐÚ·hNjz&Ë7mà»wgÒØ£(eRJF&ÇÎ_$ôÜyüÛùEJzͽ<ùôåéH$&éÏÖ‡iTJÚ-O7ž9 Y)7¡AªK«ÓñÕŠ•H%fNyÜh¿²¸Öß¶ƒ‡ièæÊÿÞ˜Á±óX³kqÉÉl>„}xcá"¢ãâpup`ÎóSq±·cÑêuì?y†�~ø¿–-ðrwC©T²lλü¾}{�ŸÄÙÞŽù3^Ð÷Ù¬aCƒ1¼òø8&îOü�Þ]ô}™�eÞ ÓÈÉËÓŸ×ÁÖ†¯½¤ßom^´’»o×Î8ÙÙ‘œ–Æ–‡ „Û wg?ìKYÙr."’]GŽÓ¦I#öèf°ïâµ,Û¼�ÝÇORPPt£Ø³ž+£ƒƒÑ;Ð èy¿Œìžûä ӯ[gÞ-úQÀ]ðÛJt:ïO}’ѽƒ¸t#Š—>ûŠß·ÿMûÍäß�>]:áboObj*[„w?®àÎ~8ØÚà`kÃW¯¿LZV[÷bÝî½DÆÄò寰ð÷ÕôìЖ'‡¢s›ÖeŽYx¸´ºÒW'•EhLíÜêób·t¬_~@¡6Tô){Õ©º›^ô®;Ù:_×ÔÔô‰…Ï|øwÒÒ�¶»9;óå¯Ò§k®ÜˆbÜoáåîÎÑ•¿Õè¸kÊñ xzö:´lÉö%‹j{8Úk<ù¯²äRvW2®‘’®. £ë8³ŒÕƒw�JV“˜«ÅÅÜøš‰º}›qo¼UîñO�Á§3^*·Íï}@VN‡W,¯0˜(<fff¼üòËôïߟ¹sçråÊý¾ÈÈHžzê)ÆŽËK/½„J¥âÎ�;ÌŸ?Ÿ½{÷�ËßߟY³fÕ8¬ŠÙß~lj ËÜlå O¦­içÂ#ÿæ;´ðöbÿòŸHIÏ`Üo!‘HˆÛ÷ÏéSøïwLAA¨2gkâÒSIÊLÇÕFÌš¯i�Ü‹_vïçâÕëtkëCç6­yl@äÅuŒÏ†G ÖhPÈelÞ{�»qڂ¢ÙÖ#¯ë„–ª¢Õ*®|0ýi£ô,‰„áA|»úOv>Æ�’ÓÒ8yñ2 ¹œaA•«¯uùzQ1øîí|õ³5_�ð¯NxLßælD…j r™ŒÍûê·çß$¾yÿv¾4¨çŠB.'<*†ÞÓ^&°c{:µjÉcýzcan<;±Qýú¼^ÊM{Aþ»¤R™>…Vug¼o9p�ð¨†ô U#ãš8’â•õù…ù|÷î[ØXZЧK'þ 9BVN7bopáêu.D^àùq£òkÀœéS ~ö% Õ¶<ÌËãÇ “JéæëÃîc'‹ƒRI7_Ÿ2ÇØÂ«(…VÔí¸2Ûú€ØžãE« Í¥ŸW!—3¶_0KÖn`{H(o=9…\ÆõØ[DDß`l‰Õ{%-øµ(P7sòãF³÷·8Äö�P”J%Cz0¶O/:µnUá,µFÃŒ/ríf,�[·äË/¤9[¹ãot:mš4bâ þthќɃðÍêu¬Úñƒü»£�ËÛ¯7‹×üÉŽ�#¼óÔrQ·ã¸|£(ÅÙØ>†�ËÎÊŠÉC2yÈ@Â"¯²îŸ½ì8|”}'N£T(D€ðu6îÏnX[§…¥¹¯#&µâ@h]°ýRå„wøu ¡»ÙÙ»@\ROÏžÃ??.¡¹—.öL:G±Š·ÆM¬z€ÐÁLÂH/3&46£žEÙÏwtVù+uÀñ$5C<�ß³ll˜®v-šãÓ´‰ÑvkËŠk+W•»³3“‡ÆÕÑфւP9"@(‚ B•I�PÏÆŽ›©wÈÈËÁFõà>ÿ›H¥RdÅÿ¤åÔlèØŽwž™Âš¿wCxT ¿lþ‹zNN,yïM{6 ;·¨6ˆV«cבcÇ7pu!§à^Úž»÷ZÛ·j^fí†á½zòÝšõ>{žÌì\þ=ŽF«¥×}5�L‘XœºÔª8�^i²sŠÆ¯ÓéØj<þ¼‚¢/‚õX4k&KÖ®çBä5ÖìÜÍš�»±²°àóWž§w—Ž•› ÿ=J…�z÷f�çÝW—ÌTù|½rJ…‚Æ•ÛÖ§ISlŠo)ä2\ìÉÊÉ!#«(EÕåQú¶­½½ôÿwupÀÎÚŠ”ŒL®Þ¼ixRVD=(�õëÍ�ë7“’‘Éá³aulÏŽâUv®.tñ1Ní·ïÄ)N^¾BÏömKÝW»fMìß¿V-MJöͪuÜJ,J#öò„q(ïK›w¥8¸w;)™§ç|ªß~§8ådصëhu:¤ ãú³týFR33 9sŽ^�:èWÖwqÖO´)�O“Æäåç“’‘ÉÞã'+·P÷=j�ÂÝ•KÙ©¡”g»Éhä$!&UÇò£]3펖^ �ÑÅKB�º(à÷ËQ &.`Ü~QËüa÷>“šjòÐÁ  +'‡Ñ¯Íä\xÿ·b%Kf¿‹¥¹9¾ÍšÜ ×étì;~‚°ˆHZ7iLï.] S“ff²õÀ!bââ¨ïêÂ@ÿ¸88ô}ô\¡çÂ(,,¤…·7ƒzú£PÝN½�˜ÄžcDZ·±&¸KgVþµÿíiáíE¡ZÍæ}û‰ˆŠÆ§i¤ÒJäV­eé:nU!½hK;“š¨ä©DYâ+NF�޳)jêÝûþ¡ÕaR Ó‹©¥‹V‘¾¦ÿùà©Ódfç0®?4@¿]­Ñ°3ä0®^ÃÎÚš�þ=*\å};Ží‡BÈÌΦe#oø÷0J�½÷ØqN]ºL�öíp°µåÄ…‹´ðö¢S›ÖDDEsìüÔs¥m³¦¬ük£ûõÁÍɉäÔ4¶÷ö;ŽV«ãÆ­Û¼ùÕB¼ÜÝÜ ­VÇÓ³?dçáPƒc»¶õeí‚ù( ®ÜˆbÌë3IN½W§òã~dÕŸÓ±u+´Z/}úv¦-læÕ�u_}‰«£W¢¢xó«…´ðöâ§ ›8z.ŒŸæ~Hc�Œ}ã-Žž 38îQq#Óô”µR ÔS0©©Ý\Œ' Œ/äë ¹|ÞÙÒ`{B®–|MÅïg72«^17/Ÿq3ß2H ùñ?òÕ›¯óØ€þ¥³#ä0Ï}ô±A}uŸ¦MX³`>¶¶èt:^ÿò+Vmß À¿ӭm[Ÿ9˳cFÑ©MkBÏ…ñί ðë@lB×coØÑ�;ié ùU²ss úüøå™:z$± ¼ùÕBl­¬°·±!êöm¾þc%ý»wgÛ�{ÙVþض§¸ä IDATƒßk°rþ߬qãÆ,_¾œU«V±dÉòòòŒÚ¸ºº2kÖ,üý+—õ¦&åðæW X±õ/Â""X¸â†÷ bó¾ýèŠ'z-Y³–¿üŒ­•ÛdúÜO j&Ïùî{þ˜ÿ)�ý�ŠÖ¿Ö ¨¥G'ü[‰¡ ‚ Õ¦R(°V™“œ•�›mÙµ‚„ÊÙ¸ç¡gÃxrø Z7i̘¾½ðrwãé>¦ °he`³† 1W©ÈÍËcÛ�F}aø~íR32`”þ®¢[2#zp,ì"›÷â\x$õèÑÞ·Òã÷mÖ„3WÂÙuô8ÏŽ†B¡à»5Xó÷n‚:u`ñ¬™4÷òÄÂ\ENn›÷btŸ¢@ä’µIIÏ`x¯Ú4iÄ‘°ólݘnm[34°'ý»w! }[:M|šµ­V‹´Dpñ\x$_¯\‡™BÎ’÷˯O"‚`ª´¬,~Ú°kKs¦�YíóÙ•¨×—’™I£âÿët:Ò2‹&‘ØÛNê¨Å„LЗ¿C�qàÌâ’“‰Œ‰E.“1²”‰$öàzì-†м8åéýF÷îÅ`ÿîü}äë÷àä¥ËüðçF–®ßDç6­Ó;ˆ~];­|rØ`ûwgü;³9ɺöòX¿Þúý6ÖdçæÒ¹UK–¼WñûÀøýØqø(‡NŸ%þN W¢b�I¥�K­ÑpðÔþܳ�ƒ§Î¢ÑݸöiÚ˜QÁA òï®_1ZÓbRSÈS×ý:sÕ%—J°�¯JËÉ&+?©DBn%âjmÕƒ wÝ öhèÍÁýp)NU\h´pú¦i�ÑÅZ‚‘÷‚ƒwI$0w°œ}‘Z®&•ýóÅp¹Apð®�¤¼ØSÆÂ}¦]Ÿ§b´4u®Þ„»5WÓ³²HNK5ÚæÊvÅÍÙ™es?DefƌϿäè¹0¶ aDp/Þüj!É©iŒí×—icF±íà!¾þ}%³þïþùñ{Öìü› »÷bkmÍ—¯¿Š«“#³}GXD,þŽ>x_ß_Dt4�4`Ú˜Ñ4¬W�?þÚÁÑsa¸::ðÉŒ—IMÏ`ö·Æ+�êªÔüŠßh¬Fx)™ÒT…{)iD3 u|u>—¿c ø©§5Mm Ÿó˜ Ò‹Vf,eY¼z '.\¤w×.,xãUÎG^å™>â­ÿïÜœ� Úgçæòêüh4–Í›Cû–-˜µðvåÓfÁÌ×Ùr˜UÛwbkeÅ—o¼Æ­ÄD>þáÇRû9sßf͘6f4¶¶ÌYòÙ¹¹L=’IC³ïø >ZòË7mfêè{Ÿ)Ò³²?°?�ý˜³ä"¢¢ÙwüßÌz�NËÌ ‹ˆ üF­7*µï#©TÊĉéÕ«ü1Ç�Mä‘H$ 8�™3gbcS¹Œ7U±dí:Vnßa°­{»¶,|k¦Á¶F ê3ëÙ§Y¼j !§Ï°eÿ~>zq:õ]\™¹àÄ'ßáÐ©Ó `þ²_Pk4|üò‹to×–ß¶nã—M[X±e�ýøcþÛD€PA„á`iMLJ9ùX(Íj{8ÿ *3v9Nè¹0:´j�\"ãô•p$ cŠo‚Z[šóúäÇùô§_˜óÝ�l;BrZ:×bbiáÝ�W'=Va?÷ëÛµŸüø !gÎ0<8Ð`vêúÝûøqýfrŠgoFÝŽ§ÿó¯04П—Æ�aêÈ¡üsä‘Ñ7òÊ›ØÙXs!òÖ–æÌx¼(-Ÿ•…9oN™Àܥ˙³ä'þ:t˜ä´t®ÆÄÒÜË“×&�ÀÉΞ]G�ñסÃlÞ‚�¥—®ß@«Ó1¦wÐ}+!53‹#açQ™‰kQ„šóýº�ddçðÚÄǰ·®~š'¿V-�J$hu:Ÿ=OÇ–-8¡_aЩu ƒc”Å)¢Ó²2K9ãƒ×©u+šx6àjL,ß®^�N§£g‡v¸ØNÊÍËãÛÕëP*•̘0¶ÜsªÌÌÀð n&$²qß6í=ȱó8vþq“çÙ‘Ã ŽñiÒˆ6M1¦O/ÖìÚÃÂßWѧKGm‹ª�Û´æfB"'/‡“ž™�­uÑê•ã/s5æ& \] èÐV¾Î­[ÒÔ³‘1±,Zµ®èqùµ£žã½tƒ;BŽðÖ׋p°µaX`OFÑijA üfËö¿ƒûØ~å�öño¥«ÁˆúáèÌþg?Œ,ÿz~˜"’täšX*n¤¯óÒ3Ì#—Âcíe|²«ôe€ÎVú4/{¥Ò?Ó„'btŒ¯æýî’+®K®ôº+-#€ÔŒ þ:BÿîÝX>owÒÓput";7—“/!•J˜ûÒóØÛØÐ¢‘7é™™hu:}zP€—Œa½ŠÒ›~5ó5úN{ž‡ôkn¦â¯ïéëܾ÷Í·Ìšú4Czp5&†Ö­¯ÞHòÊYÙ礒2½¥Š‘^JT²Ò§î¾UÈÜ39èt:–XÓÂÎ8 “eÚõ’kÂ*òì9V@zjÄ0,T*ºø´¡«¯‡NŸáïУ<9|¨AûS—.“ž™I‡–-éÑ®èýá¹qcØy8”­ñ寱eߦީ¿.Î…G°iï>£þ¸º²mñ7úï)ÓÇ�aòÐÁ´mÖ”ø;wôYVJ®b¥8�䜦#‘H8r.Œˆ¨hø3®_¾ùc×nÆdiù/qwwgñâÅìܹ“°°0ú÷ïO»víŒÚi4"""HJJ"//'''<==qrrªVÿi™¤e~kâi< ꣟ÇÕÑ�ø¤dBNŸ¡}‹L3€Õ;vòÏ‘£Ü)~?�ñ2:�Ž.>m¸tí:éœĒÒÒŒÎ+5MAA¨R‰; KRs²D€°†ôïÑ{k¶ì?Ì­ÄDt:�;0,(€Ž%n?6  Ýë±qÏ~b’¨ïìÄÈ^�Œí×sÕ½ç¢uÓÆäæçÓÜ»üG*33ž7аˆ«Œêm˜ÆÄÑÖ–ÖåÌVus**žî`kÃÚ/?á·­Û9s%�VËÄAý™2t \ï¥å+¿ë÷ì#6! wg'F1®_oý  ¦ž Xýù<ÖíÚCäÍXRRhÕÈ›ÇÑ¿{W£1¸8Ø1 {W” ñqW„š›˜Èª�ÿàêàÀ”!käœîÎNŒèȆ½ûùyãf °±´à�»ðt«Ç`ÿîÇ4kX”æ03;——çÿŸÆ�HÎÈÀËÝ� ŠnþzŒ‹×®CñªÇ»¶á|ä5zuö£}óf•³D"áñý˜·t›Šo”ŽílÔî—-ÛIJMãéáCp«ÄÍ8W^?–—ÃѰólØ{•ÒxEÙ]3&>ÆÎ#GIÏÌfþòßùâÕxfÄþ %+'‡)|Ę>Ádeç°lË_dåä0sʃ!À„�ýù臟Ë|\r¹œ^�üD€_{£”±Êžk×J?BÅÎÅß®í!»eú ÉÆNåç�h\NÚÏFN’rë6t� —‚Ú„á\Н~Ð6>)™TŠ£­�¾é]=ý:ØÑ�'OñíÊÕ|»r5ö66 döôg‰‰‹G§Óáhg‡}ñŠ#…\Îç¯ÍПãfqê`Ÿ¦MôÛZ5iŒL*¥ °�Ä”ýö®®úà À�[E׉o³{¯±%ÿ_×™•øxº™�7.ý»^J¾ŽOÏæ°ãfõ,¤üÔÓoëÒ_'o˜¸‚PU�—Ù¨âçaÒ;ïí»?õ4ŵN_¾Ló¡†YÒ33ÉÌÉ!&.€V�ë÷µoÙ¢Ôas//ƒIŒ®Ž|¸x ûŽŸ$'/¯Ì‰Œ–ææúZ¼wëÕ[YÜKÑjJ�Þ»»«4þ<˲eËØ¿?Å“JײeKÆŒÃ�!CªÔ÷¬©O—Yƒ°$+ s(.¥`myï9”Þ—ÖÚ‚y?üÈñó(T«Å$Wá¡wLAA¨16* Òr²É-,À\QöÍ<Át�}ZÓÙ§u‰-¥!ìêÛ†®¾mʽy3~@ÆècR¿O Tæ¾ NêÔ¡‚±·±fÆÄŠW1võmMWßÖ÷m5Øè\Ãüùßï+ÉÌÎÅÅÞž¿öûöèÊÀÆ“R´Þ�‹„uD;·úµ=×’M¶U´Ò03¯ìså”ßON¡iÁA€è”êÿ:@›¦M &ÃÝ¥T(X³`>QÑì9ÌöC!œ �à×-[1S*™6vרÓétú€ËÍøx´Z ê¹ê'©edeëÏ›•“£O-lnvo£ì¾à6V–$¥¦’Qb‚FÉÿ×uvʲ?e/8ŸË¾¸BúÕWÒ£ž‚†VR4:Ø]ÀÿÎç�’¯£¡•”Ÿ¬q+%õè]‘馭 ´SV½Æž…¹ŠÔŒ þï홸ßW3¸~)5„-TEÏiŸ6¼ñä£ýæJ3l­Š‚<%ëfåä”ÚÿýõŸŸ÷)'.\dÜ€~<9|(Væ<ùL�p?�NÇ�?þÈòåË),eeñÝ6—.]bîܹ¬\¹’®]þ{úýòòó™4ë=óê¤ ëÄõØ[<óÁœÚšð!„‚ ‚ Ô©D‚µÊœÌ¼\ Aþ5t:cz÷btï :·¹2ƒ±ïÞ�IA¡‹ûn\ÿÞÛªÕXªîÝXV*•|øÜ3¼:q<11h5<ÝÜô«±ï§�Ëùß3˜=-“Û‰E«hœì R|¾1eB…5í­ ë¨½øØhž:¹¬â›±j�VC}Tã4Ïj­†Eo¿†£­�þfjM¨çèÈ®%_àX¢ÎÐcýûгCQz1KssýöŽ-[°}ÑW\�½Ebj*¶VÖ4iànTÏðÞ¸ïE8F–’¾º¶¼Ћ1>mE Ââ„îv¥[š·¬'­Ä�üêð÷*ªAX—Äe˜Ð¨ØáëZ^èYö5}øzÙ�»Ë :RsÀ¾Œ›‡¯›¾’19[GNXTâ«Bôí8Â""ÈÍ/àÀ‰“,Û° €'† -µýÚ�»øçè1ôèÎŒI˜1i ~ù�¿üF\r2îÎ.X[Z�™�ÞcÇéÓµ QÑ>5[Îoø“N­[s>"’ß·ýÅ ä2Ë7¥ØoÔ >Žv¶¥ö ЮEs®ÝŒå—Í[éâëC~A«wþmú®e^e¬úÐêàD’šIEéh­Ô:ÈU]?.æR~ ²ÆYUþ{ÉÕ Ó^ϼ¬« lîåÅ­„Dd2~E“¿]¹šè¸8&5ž$ÒÜÛ €Ä”Tºúú T(¸vó&߯ý';;ü:вq#ö�8ɆÝ{Ó·9y¹lؽ·Â±èt:Î^ àµÉñ®_Ÿß·m¯òc iµZÞÿ}víÚeò1W¯^åæÍ›•î+þÎÂ""Œ¶×wqÕ§�¯Œ›ñ $§¦aeaÁONA.“ñû¶¿*}A¨* A¡FY«Ì¹•z­• R‘þDAø�H$tõmcr{W‡Ò%ëÙÝÏÖÊ’N­ZšÜ‡½µu™ul¬q°©\�ÄòÎWR^~>ó—¯ +'•™“ 0jãbooT“°&Èe2<\�W}ȤÒR·SüÜ5öh@c�òëæåçóÅ/¿“™�‹Ê̌Ƀûר¸k‚§½éA±G™B*ÅZa¼",9+ƒôܤ ÞN®&ŸO.­zpá®vnõy±[:Ö÷¨ö¹jZB†é«ñv‡k9}SGãÏç‰:6…•°)ÔÀÂ}jæ6¾�¨ÖbrýA�n¥ëhZNJÓû}¼ô'Xj¸mPOÆ4~ýp²·cÛ�ƒü}8”­û Õé8z. €=º#•J˜6f4_ýº‚gçÌ¥›¯/a‘‘èt:ž9©TÂôqcøs×?îXÕ³F¦”“—žKÊÕd’®$ +þ}çççWº¯å7ë' ”ôÅë¯2²·qÊõЏ»8c¡R‘•“ÃÀé/bnfÆ©K—‹ÆWPPéó Be‰¡ ‚ 5J)“#—ÉÈ-ÈDzDêAAm�½=›‹×®ëSì½:a\¥‘uÑãï|Àù«×ô�ë•ñcq´-{u�ðßP—ƒjMé¸;Ù¦´:˜²¢�å“ätò¼À¹¯còo…Tãûþ°gk /øË¸»Ð83ÞØXÈÉÓWd›xO¾�îdf¦ot´³%À¯zôÐ×ör´µehP ÎÅ‚»tfñ{³øaÝŸ8u µZƒ§[=Þ™ú£ûöà�'¦`¦TòËæ-ì;q’®®¼7m*/ŒÅ5`7}³�yß/%ô\7nÅÒªQ#^›2‘!�Eõ¹]ˆ‡«aÐÚ§YS~üè>Yú3‘Ñ1´läÍOLfÿ‰S4jPý4µ…u•�-ëZº_W9›£+œ8_È{'²y¯½–òÒƒÀ‘&®èâbÚ­ëÞ]º�”šŠ§[=ý¶ÀŽ~ü<÷C¾Xö ›÷íÇÁÖ†‰ƒ1kêÓúô݃zö 7¿Kó¢å±ËæÍáÃÅKØvà ×nÆÒÜ«!Ÿ½ú2#‚{PßÕ…_>™ËìE‹‰ˆŠÆ¿C{& ÌW¿®Ð÷ëåîÆÐ @ÚÝ—ÆûÿÞ~“×¾XÀÕ›7qqp`ýÂü¼a­–ó‘‘ØZ[14(»+ü[x{14(РfpçδlÔ§œˆSXÅêê:°²ýܹsüñÇúŸ% ^��ñèê…Dzï´õ°Ãµ�žÉÞ\ÞpžìäÊ¥ýõoߎzåÔTöªïŽB.chP Ojð¨W�¡A�´jÜHß¶s›Ö( ¼Üݱ47çÛ÷Þaö·ßqõæMüÛ·çÏ… X¶q3R©„Ä”ýkM}gÌÍ” ,·¼Hm3õµF¨}�N'¦(Ô Ñ¡¦-_ß½ìº>‚ ‚ð¨KÎÊt8YÙ˜ÐúÑ”š“Í»7ϘÔvIsÿR·Çç•þ¥¤üÏùì­Æ—„ªö[�ï%å�·:¿‰êWÎc­…ßoÅ{Ë9®ÂÐóZ�½Uì¸ZÏkÕº,:V\Ã5°·œãà5œ§Q“£® (—`àýÅK9õ*ö6¶ ògd¯ÀÚR�ø`É�œ‹ˆÄÞÆ–a�=TÛCúϪé„}—}Orv¶ -ï©l`Ð1дúÎÕ1®_–¼÷ŽÑö€ÿ+àrBånëI%Щ¡”&NbRu„ÞТ©D|ÏÓ^B7o)yj¯jI-½üZ¹¶>§ «WõWwþW…E^¥×ÔéU:öÌšßñ¬W¯Âvûã yñpÕë&º[H™ÛÑ‚n.Æ)W]Ëçã3_8¾rV×­I(çÂ#ˆ‰‹ÃËÝŸfM˜ýíwüøç>x~/<6®¶‡X%öìãÙ¹ŸTú83¥’Ûÿ˜ž*uûöí|ðÁúŸ—-[†¯oùµ†+2mÚ4NŸ>­ÿ¹qßæ4èäiÔN&•!A‚Z«F�[È©åÇÈK3LA½uëVÜÜܪ5¡È¿üÆüå¿Õè9ïØ]£çŠˆ„‚ ‚ Ô8s¥’”ìÌÚ† ‚ è©52²Š*¥©R|®¬�_œVÛCx æ>ÿlmA¨êÒŠÁûÅ&$–º½¢U¥ÑêàX”–cQUKLªŽ˜Ôê­ És3ªåV׃IÇ&&™ ôwU˜œf´4·s´<{0‹ñ�ÍxÝÇ‹« ÃÓM»~F{×½šöáQѼòÙ|ìl¬™:j$Ù¹¹ü²y ¶VV ¨íáUÙÍ„„*—_P@rZ:NåÔä|�âãã9sæÞ¤];OûRƒƒ­ÝZ2¡ã¸ÿgï¾Ã£*ºÿ¶ozï•NBïUz•.ˆ¨4 "¨(X±|6lˆ±¡H“*R…Ð{o)@BB: �Þ6Û¾?’,ÙLƒœ×‡GîÝ™{gï.Éî=3ç •ÊXyj ¡WÎÑdh3B–ŸºÇ#þï(ï÷–P÷ˆoD5¨xAsAAø/SÉåhuzŒÕ\™ó 2�ètz´Z2¹LÔjA¸t:W““p¶·Ç^)RH ƒ®"Ÿ±ºúú3µSZº{Þ“1UEd|,�  l¶’ùcäŒl)Åզ⡥:øIy³ŸœíÓ•œz]ɰR>Ò3ÿ¿vëà+eß %.Ö"üSzƒ�««P™¿jM¥Ú¿hÁ(ÿ»¿¢ÏZ!áÇnÖ8©Åíê{%#;›åÿl«öqæ¯\]¡vjõÝÍ/œ–� €‹µË¿Ò Û¸ºº¢TŠ•«Õu1.ž�ÇŽ×ö0„J+k�ñ>¸ ª3ÐôèŒFôF:ƒ#`Ähº‰+•H(øäR)2‰¹D‚B*C.¿¤A„Š‘þ~„ŠJIÏàë¥+˜8d ÈÄGUAjŽJ©¤±ŸR©�ø%Â}æã_§oÇ(䟑äR˜ÕGÆ @)¯­×q2¡üìÑ7�L]¥å[ —3®�Œqí n §ä‰¸n$>ÅHfdi@£7b­’`§gk M]%ø9J�Þ2aòr-Aáþív˜� ^ë+gj7™©¿Py˶låRBbµ�s&<‚Íû2¬çCj/>lg…L"á¯Øª¯^¼{¥„ݬ °å)î¥//#½œ:­•ñ×ÎÝL;† ܱ]×®]iÙ²%¡¡U¯yx'—OÄãÜÄ•K7£‰¾Kçz„_‹`éñ¥‚ƒ©±·ÈN¾ýÜ»wï~WÆô_óá/ 1ÔÄuážw]p£�A‡Æ 'ß ¯ÐJŽ‚6ít%’ÈK%”R*© •TŽT"f} ‚ BeYYXâãî†J“�Z©ªíáÔ>n.¬�÷ r…¢¶‡#ÂF"‘˜Õ\ÒéEmAî/±q,Xµ–WÆ?n¶?Ð]ÂÖi ¶‡x÷q·Ê¿÷£5@ ‡y”ÎÑRB×zºÖ«øX¤hä"aÛ…òÛÈ¥ðD{oõ—‰UƒÕtíæ->Y¸¨ÆŽ÷öüy¨mkìml*Ô^&�ÚYâo#åÛs¹èj°’D {`ø« IDATG9_u²ÂËJD�ï¥�‹‘üºn}�Ko0ðêWßòÏüoLÊ¢V«Y´hYYY 5ó&š9s&!!!¤'¦qùD<ÞýøñÀ¯¨äJ4ºüR}´9Z"·†›í4hP�Œç¿lã¾ýl?r¬-…ºDPƒž\½�¾fçÅŒFòô:òô:$hPÉäXȨ¤b†� ‚ T”R!G©°&·>‰EÅ'v)š|­Ž†¾Þ´ljöøþÓgÉÓäÓ¡Y¹š<öŸ ÆÚÒ‚þ�;bQ˜æfZ§/D`©¶ {›– #&ñ � ëÑ.àöñÒ³²9†R¡ w‡v\»•¡3Áh´ZšÖó3µ�OJ"<6ÞlMëùQVñ‘ÈøDÎGÇ�‘��—‹3=Ú¶FU"ŒÑhälD$qñH�Ò±ySøx—y=΄_äFj*~Ôó«ú…AA¸>ÿc �[µ s‹æ¥ ¥w#%kƒõü|HOÄõÒw„Þ(æ†æ§½ÜKΪ3ndšŸÇB�µ•1­»ŒúÎ"0X]Z�Ž)}BJFF�ójr2/|ö%Ëæ|ˆTZ±×H<ÕXM'W�É!,¥zmÔ2 SÔ<ÕX�BÄï©´ÌLžù`N�–u8}!œ�ý��¦Oý×¶ÖÖÖ5vÞW^y…§Ÿ~Ú”Ù/zw$z�Ÿ®þh(̺‘É…¿CÉKÏ5íëÖ­mÛ¶­±1ýÅ^¹ÂÌ/¿©íaU „�AG–.m ͸#˜‚…r‰+¹ ‘ LAê¬Ï-ãÏ-AP¸’Æh42 kGæ½:IaV€¹‹–Ÿt�IÃfõöÝäi Ò-Þ´•Ÿ}ˆZ¥"æòU^�÷=vÖÖ êÞ™ÕA»Lçxuâ<=r(¦˜�5ï{$ |ô.Ó?ý’œÜ<tíd 8Âg¿-5kðêÅÈJÔùø×E¬ Úe–ÖÝÉÎŽß>x›¦þ¾¤gfóâçó8u!…\ŽÁ`À`4òÜ葼òäØR×ä·õ›Ùsò4“†&à™‰5t¥AAî­NÇ„·ÿÇ–ùßÐÄ¿ôä&¥žl/ã‰v2öFXvBÏ΋4:èÝXÊãíjn‚·­¾%gÂÒ‚Ô}M\%Œi#cb)ŽV"0X #3¾˜Ç‘�šOÉtø³ç/à‹—_¬T¿@{+z۰窖ß/æZÉ@¡�BÂ(O5Váj!"ƒ÷ZžFÃø·ÿGìÕ«5~ì«×âçáÎ3£FÔø±ËÓ¢E &L˜ÀÒ¥…ß'�w0škaWqoት» r•œÜ´\R.Ý$ùâuН¦qttdöìÙ÷l¼¢äÔT}ýíIW+Ü{"šó€Ðôdj5èŒw?0X�Ñ@º6�l�…J¬(A„:&2.�?·!‘HØðíØX[1rÆëì8r‚SƒÂéÐ<¥² µçƽYüñ»�Fžÿø "ãØ´ÿcôEUØ&#;›ÔŒLö/ú‘•A;ùyÍz~^û7côÅÚÒ{kkílIIÏ൯æã`cË”Qñ²´ÀÅÞÞ4¶!ݻҶi._¿ÁÌ/¿+süщ—Y¹m'¾îüöþlÜœœø{×^¾X¼œUA;øàùgøô÷ÅœºAÿΙ7ó²r5LùèS~ùk=�[Ò¹eéÙö‚ ‚ ÷“”Œ †¿<‹U_|J›¦MÊl#‘@ŸÆRú4–’‘[ÏëéÑPJMWŠ eþ9Í=¥4÷AÁš¤Ñjyñ³¹ü½{ï];Çoo@§Ó1wæ dÒŠë¤è祠Ÿ—‚˜ ={®j9‘¬#2]Oržù½I•L‚¿µ”ÖNrºº)xÈ]ŽJ&Þ+µ!-3“'g¿Ç±°swío~÷¹ /Ž+=9ónyñŹqãAAA¦}yi¹ÄŒ¾c?[[[æÍ›‡»»û=åƒ)îj�¾þ±W®ÔöP„*ÂûœÁh$S§!·ŽÔÏÐ ¤æç¢’ʱU¨�‰…‚ ‚P'Ô÷öäÀâŸÉÍËÃÓÕ€>Þœ�ˆäRâS€°h%áˆÞѼa}víÌš»Ùò cô5eþ4�¼ôøXœíí™úÈoÜJvn'Ï_ w‡vØÛX“’ž�N§c÷_à`[ºÎ‰£�-Žv¶¨K¤ -.=+»àÿ™™;OÏöm7¨ãõ3µÉÍË#èÈq¦<2¥R‰£RÉÄ¡ƒyëûŸØrðH©á¼™/¢ÕëQ)ÄÇbAAî7ÓÒ:cŸ¾4�IÆܱ­­ÆÕàÊÁ’îæ±ÿ«â®&ñì‡s8qñ®Ÿkñ¦-D%$òë{oãîìTéþõmeÔ·•ñla¥�\�‘L­‘|Ø*$X+$T0‹©p��¸È3|L|Òµ»z£ÑÈû?ýʹKÑÌ›õ2Ö––wõ|R©”�?þ˜úõëóÛo¿‘Ÿ_:µhIMš4á³Ï>Ã××÷®�ïAtä(/~6—ÔŒÌÚŠP âNÈ},O¯#C«ÁP£Uk†Æ ã¦F��B…Z¤A„Z'•ÉØ°g?[áÚÍòuùää¤Õjµ¥ÚûyÞžEéëá@Ò­[fmä2Þn®(•J܉OJ"éæívòÂ"õ#ûö(38XQ­š4¢CóNž 罿PÏË“a=º1yøÃX¨Õ\OI%¿ð¹Œ}ãÝRÇH¼Vú˰¥…ºÊcAA¨My ³æ}Ãú=ûøäÅi4kP¿¶‡$TSN^¿üõ7_-û“Ü<Í=;ïáàºL|š·žžÄäÃP)U>–…\‚…\DëŠ[ééÌ[²œE6ÕhÍÁ³vçn‡òÁ´)Œêݻµ.«J"‘ðôÓO3hÐ /^ÌÞ½{IMM5k#•JiÞ¼9£G�fðàÁH+±jV¸-áÚ5>üy!öî¯í¡5@DnîCF S«!G_úf^]bÄHš6 ƒ[…ñÑ@A¨¬Äô4¶D\ wý†4uq­í!Ý·VlÝÁ×KWââ`ÏO�ÇÅÁž¹,'*>±ÌöÃí HE_$2Y‰6Šp(j'—•žEîïéY­ñˤR~ÿàކžãhp‡‚C¸”p™ïW®%$2ŠŸß}Ó,Ø7û鉸:9˜ã^Ì^AA¸×ž9KÏg¦2°Kg& LŸŽQŠì÷•K ‰¬Ù¹‹%›¶p3-½VÆ�‘�ÍÛódþÊ5<5b�苯H½xß:q‘ÕÛwòçÖ ròòje W““yî£O™·d9O�Ψ>½pv°¯@Ϫóôôäí·ßæ­·Þ"::šëׯ£ÑhpttÄÏÏGGÇ»zþ•Þ`àHp+¶mgýž}huu#›¡P}âÓÂ}Æ`,ºåîÝŒ�êÊÕëÐsqP¨‘V#åhDL,;�5m�0ç2Û.Û´™´Ì‚åÍÎ.ŒØŸ ÑÑì>v¼ÒçmZ¯>Z4c٦͕îkkeͤ‘ÃIMÏ`ù–-wl«V* lЀŽ-[ �WíŸfN^É))¸99¡V©ÊlsàÔiB.ÞNQñèÀ¸;›_Ç3Â9|ö¬iû±Áƒp-ñ ´d›AÝ»ÓȯòËò5ùùüºö/ÓöðÞ½ð«àMäk7o²vûÓö„áð·©úê”»-42’ý'O R(ynì˜Ú’ ÔiÁIW˜¶ayº‚ 1 O¥G½LíØ…W·ÚÞ}çXhA�‰¾�Ú3¬gwt:©é¿+ ÆÒÙ"ãL¿”p³6£‘ÈøDš7¨Gfv.7RRðru¡$e5f!�ëÚ­[4¯_Ÿ‡Ú´â Æs.*šqo¾Ç�3!huz\ìí±²° ;7wg'té@fv.yùš2W0FÄ%�š‘Ž‡³3þžÕ£ ‚ Bm1�9JБ£XªÕtjÑœæ ëãã­í]_½#TNNn×o¥™�Àñ°sÄ]Mªí!™$ݼɧ¿ÿÁ§¿ÿA#_:6oFC_\°P—}¯I¨}Y9¹\MN&"6Ž£¡a\¿•RÛC2‰ŒOà­ïàíмaCÚ4¥�·7Növ¦úöw•LNVz ¡awÿ\�´Ì,¯]'".Ž£!a¦{íƒEï#£‘”ü\tFCZ×-Zƒž”ü\”U®KÉœŸ~5mwjÙ²Üá�+Vs¹àffÇÍ3°?ÁÍúWÔØAiàë]¥¾¾L9œ›©©îïéêÂÜ×^¥×Ε>ßO+W3÷÷?8³n5^neß<¿vó¦ÙX\ylð ³6Ë6maÅ–LÛEAÖâþÜòË7ÝzŽèÓ»Ò㥰^TññÔ¯_ááÕÉf}‡ôìQ§„§Î�7�ׯÊJá_üxì°)8Há:µý±Ñˆ��Â*°·²àtøE‡²qïÓ¬¿ 1±¤fdâX,€¶iÿ!:6$_§eDZ“ înþ»I*‘ðÅ¢¥¼üäXþÚ±­N‡“�›Tx\©é™?ÀìKìÎc§�ÉeØ[[Ó¹esÖíæ£_§}³^~â1쬭��Ç`4RÏË…¼`Õâ˜~½Y²y+s/G©P —Ëø~Å¢¢™ýôD&lvþïW¬aÏÉÓL:˜ÙÏL¬Â•AA¨[ròòØ{ò{ '¨ BUE%$•PvÆA¨,ƒÁHhd¡‘Qµ=A ‰á}Â`4r+?ý] �FR³³ ðbuVú•Gg4�’Ÿ‹“Òâ®ÿAqõF2“g¿Ã_ß}M—Ö­*ÕwÏñ4ö÷/78лcG$ ÆÂÕ"ÁáKƒÃ#̶Ï\¸P*@iú{}oo|=Ī AjV|Zj™ûE °j&�ÂÁ³ÁDÅ'òüG_СYS¾›=‹)ﶃGQÉÌyiª©ýØ}øxá"Ò3³èÛ©};u0;¦\¡ }³&½û1Ö!|wÊd•X-xéòef}ù}©ý¯ó-5`õÜ9Œîß‹sÑÑlÜ{€ ï|`jWßË‹/g½`Úž5þ1Ò²²Ø´ï Ó>™ €J©dê˜QŒ:¨ÔyAAAAþ‹D€ð>`ÄHª¶æƒƒçâã9xþ<'"#¸ž–nª$“Jqµ·£Sã¦t  u½š+r­/ :ÖB�0°A^zò ³}yù®]gÚnß¼]Z™åZ4i„½�M©¾Ë·l!5= Wþ�îoD³/#•@÷vmiÓ´©i;#;‹ÝÇŽsùÚu(¬áôé/ ÙüÓ~~iœ½Îsc½c;GZ6nDÈÅ‚ßÙó``n^‘qqfûN_7ÛÎ×j ��5m÷.qÃX¡&”•ö²¸¢@áþØh:ùøñR—î4s52ÊÓÐ×›­?~Ã…˜8,TJš5¨‡D"aÛÏßrýæ-ü=ÌWo7ö÷eûOßq>:[++ê×+uL£ÁÀŒ'Æ2¢×C\M¾IS?ílÍÚüôÎèt:mmKõhÙ¨;~þ¶œQKPÖÏQÈåÌyq*3'Œ#îÊ5òuù¸;:RÏËI±ÏJ¥’ÏgLcÖ„qD'^F)WÒÈÏ[«²ë~4ýYfk&b#ê ÂO“ŸOÌå+8ÙÛakWöÏ%AAA„ÿ ¬ãŒ@j~ZCÍ/^¹Â  BãbË|\o0�”’ʆcGÙpì(Í|}yvà šùV¾¾\YtFiÚ<”ÜËaë¦MhÝ´‰Ù¾”ôt³a÷vm™=å™2û¿;í¹Rû‚6}=<ÊlS–]»0õ1ó@^žFÀg§r1¶ 8wêüÒ³²°³¶®Ð1÷Ÿ<…Þ` OçŽÿÚ¶wçN¦áù¨KhµZÓj�ÐÈ(S°ØÅÑ�ä”TÎ_ŠF“Ÿ�J©àBt Zíí´½;•}δŒ R22psrÂÊ¢BÏ£ˆV«åòõXZ¨qsrª\_�ޤäd¬,,p²¯XñcM~>7n¥ ÕëðpvÆB­®ðùÒ23¹•–†…J�«“#r™¬Rã¡úŽ'Æs<1þ¾ fçæ’’žA²&{[[,-*þ3§:¬,ÔthÖÔlŸ›£#nåj·¶´ S‹fÿz\O�rë÷¹;ݹ¼J©ÄÛ͵œGKRp²³ÃÉήœGosupÀÕÁáŽçp®àï AîF£}á÷*ƒáΓPAAAt"@XÇej5äô5v¼­'O2ËfÓãŠ8Ÿ�À¬ß2¶{wžî?Àl–~Uåôdj5Ø*Dqã"j•ŠGúõå³…¿`0Hº‘\áážã'°T«éܪ忶íÓ©#ß.Y…«/ÄÄÒªIcB".šÚM>Œ¯/E«ÕEûæ7‰C/Þn£P(èÚ¦µiÛh4²vû~\¹šðèÓþúÞÞ<÷ØÆŠB^þ�ƒÁÀ¿-báÚudfg›ú¾x�´ìlf�U#A½¥T†Z&Þ†EÔ*ó€©TZ±ël4Ù{ü$ÝÚ¶AY�ºOíšbgmMzVAÝÉàðS€ðlaýA{FèÏ׋—pæBx±áíbÂ[47[8ûëïøcý†R猹|™·¾ú–ÝG�óûœM«Kúê�% Rõ}ùÓϹËû/L+÷y}¾ð76ïÝo¶/1é/ÍùŒÜ< “F7{lÑßëyçÛùJÌõ§Î�çÙsï3iäp¾xu¦Ù{>_«eÒ[ï°çø‰RcÈÊÉaý®Ý:ÌÂ�Þ§×.厷ø'¿ý·ÒÒÒ³o–³’U„й_…uÑø!IÍȤY)E‹¸»83sÂãȤÒ{:6AAAA¡fˆÈL¥7I×jjìxë�¹cpÐËÉ O''$H¸šr‹Ë7o–Ùnû™3x::ñxϲWUUVºVƒB*CV…€ã㯾Qn*nj•g÷›§N›þ.—Éðrs«P¿sQ—¸~ëV…Ò‹»G‡v¦`Zpx„)x\ lÕ´ |¼±·µ%-#ƒÓ.˜ú_AX¼þàß;w™éß�¶�M9)šÕÛ¶c0Øyä(Ÿ/ü½Ü@_pÄE$ ÝÚ¶ÁR­æÀ©Óäi þ-ü¸r5ý»v1[±XÜæ½û±·±¡e“Æ\¹~ƒèÄDÓcß.]f <ƻłƒV ëݵJŶ‡¸~ëK6l¢y£†Lq»ïÇ?ýblàãCŸÎ�¸~ó&ÛB«Ó‘›—Ç´ç°oÉ"¼ÝË3³³ÿæln¦¦Ð¥u+~zÿ]qÓý“˜žF¦¦æ~¦ß/nee V*±RVmuœ®ÒkOŒçDb<½ê7ä�Þýp²´ªö1t�ôíõ¯m\xfÔ°{2AAAA¡æ‰a•©Ó`¤fêbÄ\»Æ/ÛƒJí—J$ôoÓ†qõÄËÙ¼ÆÛÕ”Ö:HÐéÓ¥VW-Ù½‹6 ÐÔÛ»Úc3b$S§Á^Qù›ÇY99Õ>m‰NL4 ¦gf±yß~v;nÚשUË ×í+ VõéÔ©ÂcèÓ©£)@x6¢ (˜–™Iì•+´hŠD"¡m@Sö?Á™óáPXðBÌíú•½:�F#ß,YnÚ?åÑ1ÌyùEÓv#??>úñg~_·žçÇ�-³¶ D"aÁ{o3z@( X>:óU2² ‚¾ V¬*7@Ø: )«æÍÅÁ΃ÁÀ‹s>cÝŽ�\½‘Ì�”\ ëlÍ[´Ø”j×ÞÖ–õó¿!°AÞž:…áÓ_"¢ðy~³dO ‚\&ãú­[,^¿ÑtÎíÛ±äóO°,¬Yx48„1/ÏB§×“™�ÍÏ«×0çå—ʯÞ``êû™ÎР>K>ÿ¤ÜÕ•Âýé³ý»YRÛø/�5ó{Ðì�¹DžNÇ�#F×È1A„û�L&ÃÅ¡ î¨¥ºrõ±AAA4b‰J¤1èÉÓëjìx¿ïÜQ*ȧV*øà‰ñ¼:ê‘RÁAOGG^>‚OÆOÀJmžöÒ`4²°Œ€cUåéuhj°Îâý`ɆM<úÊ«¦?Ͼ÷>wï1=.•Jyí©I>ÞÞã'hàヿ—g…ûôêØÑ”6326Žœ¼ÿQñ 52¶{M!—óÉ+3Ê]%WRFV6§Î�gò¨•:�§« Më×#<:½Á@Xd”)½(Å„Eÿ§°aQêM WÏGE›ÿóß~G*1Ÿƒ P(ЦY �‰)s\ýºt.µo`÷n¦ZˆZ�Žð˜Ø2_û’R+Ëï©Â÷pDl¬iõ @¿2êöíÜ ©Tj ®Ÿ¿t‰¶�\¸tûyÖóò¢‘_éý»vfý®Ý\¾v�ô¬¬R�¿¬œ®]gÚ–H$ØÛˆààƒ¨_ÃFl‰Aº …›GmÁÄÆÊ+K¬ó²j{(‚ ‚ ‚ ‚ Â�Ö1yz:cõk.9t¾ôMéÍšÓ- дm0Øzê§.Ea©RÓ¾QCz·hi üthܘ>­Z³+ø¬Ùq_¸PcB�Ñ@ž^‡ZVñ·¤µ¥%ö66e>&­ãõÛøøàåæj¶O¥TØ ㆠ¦~%Ò·<}­NGïN«?X\ŸN �.Ô‡GQP[ÐÃÅwggÚ ‘H0�œ¾έÔ4Sÿ^o×LËÌ0;ö–}îxî”ôŒ2÷{ºº”Úç]âZ¥¤§—ÙWRÁZ–iæçöru-ÕF©Pàâà` ˆ¦fdöͼãX�Rµ#SÓ3JK¦N¼rý:ß/_Á›Ï>]¡ç Ü?>ì7ˆ mÚ£Õÿ·VJS5_Ø´Ž´ÜÜKGo_>ì?¨FŽ%‚ ü·¥¦§“pù*YYXYZÒ¸A=¬ '¦efçp%)éŽý•Jõ ¿G�F.FÇ ‘@“b-¢ããÑjïœUÆÇ³`r\âÕ«å¶Q«Tøûx£Óé¸_n;kK+¼=ÝIKÏàZr2�êù#+£Þ{TL,ö¶¶¸f¡)¹]W¯]'3;‹Æõë—ùù=2&gGííÍöëôzbââ¹~ór¹ WWü¼½LÇÈÍÍ#¾°\Byd2�êù“|ó©4®_€ìœÜ;^G;;Ü\œÉÊÎærÒ5êùx£R™gÚÉÊÉ!2:–¬œl­ ®§sayƒ’ôQ1±È¤RŽ *6ý¿|fôóö£‘ø+WðñôÄÊÒ<]®V«åR\<É)©(r¼ÜÜðöô(u­¯$‘«É3]ƒâŠÞ—î®.ØÛÚš�;&.žä”¤R)î..f¯� ‚ ‚pÂ:&K—_£Ç;Yjß#]»šþn4ù`ÅŸ»xÑ´oWðYNFFñæ˜1¦}cºu+ <Ésƒjîfk–.¿RÂûÙ¤‘ÙúØ£5r¬=ÇO V©èVÁ‡ÅõéÔ‘+Vp6<‚àÂZ„­›Þ^5èhg‡¿—'±—¯pæü2² V»H$zuloj'/öÚÉe2òðÏÝ®Y`™û‹àФg™¯°)žÒ³*,TæýÓ23M©I‹�F³óª ëZ;wQÊÒ’2JŒW­*»¦ ½­-š7cç‘£PX_ññ!ƒñõ¨;«œ„ê“�œœk{µâšJ‰¥J…­ºjiÜä50Ù£µ‡“Ûu g½h-‚ å3�¬ßº�ýG�£R)±µ¶!5=­Nǰ}éߣ;q ü¼tÅ�ãæâÌÿfÍ &!�ù¿/à�éSñóñ`ñª¿HI+{R\‘—ž™ˆÑ?,ZZnOÞzi™ÙÙ¦ó”¥Y“FLŸ<�Ð ¬Þ´…¹ïÍ.xøyÙJêØž‘ƒ”¹]ÛöîçLè9ÆBÏ®¥k¨ÿ¼t÷éE¿ÝLû΄�gíæ­dfeace…Þ ';'WWž=‚z¾>\½qýŽÏ‘ÂüŸ¿û‡NžâÀ±“|óá»\IJºcßÞݺ0fè`.ÅÆ³ðÏUÌž1owÓã;ö`ëžý`4baaAfá÷�‡:u`ìð!¥h/Űà„([ý IDAT�‚×íÝ™/âQ8aqáò•dçÜyrÔkÓ¦ Óë™ÿûf<;É,°|ìÌYÖÿ³�ìÜ\l¬­Ñê´äææáãéÁøÑ£ðö¼=æ A;‰¸t‰Ic¡c‰ï’£‘ù¿/áÉGFеC;(|¯.]»ž”Ô4œÑäkHÏÈÄ×Û“çž|ûß§„ºÁÅÁ�Þ^X¨ÕØZYÕöp„B­–ܼ<®&'Ÿ”Dþ¿L©m*…‚zÞ^¸9:bi¡F)WÔö�„òò5dçæqåú ®]3ËšUYªÕ4ôñÆÁÖ+KdÒÒ“’„Ú•�›KN^ñII\¹‘\ÛÃî¢ÿF4æ>¡1èktõ Àõ´4³m š[�v8<Ü,8XdwH0½[¶ cã‚4¤õÝÝq²µåV±•W×RÓJõ«�рƠG%~)TÊžc'èÒº%ê3X+¢SËXYX��›Ëþ“§L+óÚ”HßÙ60�ØËW8�&¿ ˆÝ¢Q#œLm<\n@ôŸÎœ�RQù�ç¢.•ÚWTÿ¯ˆ¿—W¥�[œO±/óaQQÔó6?fd\ÅûF'&’�›‹•…ùÍ“�bãµP«q*1û™Â êŠ/?ÇÛÝ�Î�=IN^šü|ÞŸÿ#|úqµžŸ "0(‚ Ô¼£§Ï°çðQÆ�F·í�H$èôz¶ï;À†m;ðts#°q#¾üßÛ¦>{�e뮽|þÎÈ 'ÔI‹ÅŠNž - äè8j ¾ýò‹¦ŒùÚ|ÞùlÃô¥GçÛ5•RAdL,/=3 ß2>#KK,ìzlÄPÚ·jYª�¬dÃ{`ÓŽ]´lŠ£½ÝÛ�8ÂÒµÓ¶E3F`ZY˜x%‰Õ›¶ðÝo‹™õÜ3øûø˜]ûãgÎò×–müoÖ l ³yüÛ³|ᩉøû”Îæ"/c5e‘}GޱiÇn†õïCïn]Q*äçç³ûà¶ìÚƒ‹“#}ºw5ësòlŽövå"BÎ1¬>|m&EyF²²³øð«ï3t0�Ú¶1õU©”Ä”QNãÀ±¬Þ¸….íÛ2l@_ì ³íÄ$$°zã¾Y¸ˆ×§MÁ½D”u[‚hÜøŽu9µ: —¯ÆÅÑ�YÏ=�má±/']ãç%²díß¼üìärû ÷ŽJ¡``·. ïÙƒîmZáRì;»P7iu:΄G°ûÄIþÚ¹›ø¤kµ=$úú0¶?zwhG«&�‘ÕñL]Âm¹yNœ?Ïö#ÇX·{7køþmUµiÚ„1ýúЫ};šøû‰Õç÷‘ô¬,ކ„²åàa6ï?HVNNmI¨Aâ§{’«×Öèñtz=%þÁºÚÛ#-ö8,>®Üþ!1æ�¹—rh´ùdçæÕØx¹ ×àAËÕ7èÛ¹ôÌÛŠP(to×J¤í,^w°øvfv6ùڂרøêA ƒŠ yÁM£ÑÈá3Áf�kòóyiÎgLy寮÷‡Ï˜¯H-²&h;×nÞ4mçkµü´rµi»‘¿_¹©=+ÊßËÓ,з`ŪR³«¾Y²Ìôw…\Nç–7R*öå<;7—E¯7ë—–‘Áo0mwiÕÒt]гP«i×,7''³Õ¤[dÿÉSÕz~‚ð_ÖÚËo‡Žä�1ãDpð :t”®§ðäìj{(B-˜1ï[:O~Žë)©µ=” ûvåZ=1™7çÿXÛC`íž½´x|’ÙŸŒlñ¥».:‰»«3Ý;¶7ÝÔ’Ëd éÛGGN‡„!“ɰ´P›þ}´°°0íSf¤Ðéõœ ;GûV-hДӡçLõ¯-ÔªÛÇ)l¯P(ÌŽ]<¨Z¥2{¬ä¹Š(•Š2Û•L—y·ùûxcmiÉê�›ïØ.77�5›¶Ð2°)O�{Ô,í¨�—3ž™Œ³ƒ®ß€L*5{NÊ¢ ÅöYXÜ9‰J¥,óú(•eO|ÌÉÍcÓŽÝôîÖ…�½{šÚ)•J÷íEËÀ¦>yÚ¬O~~>!átj׆À&�8j ›�µ¼×½Œ›ô™™¬û'ˆÎmÛ0~ôHSp ¾¯/¯<û4–j5«6˜_ïFõë!•JY·eë¯Ëå«Iddf2°wOSpÀÛÃ�A}zCVvö�!Ü]j•Š�?FÈÚüñáÿÕ§—Þ'r9�Z4çígžâÔŠe,ùøêù×ÚxÚ4eõÜO9¶t¯N|’¶MEpð>c¡Vѳ][>}i:akWñÍë³ðªæ}´êÐ¥;þ�]¿,àùGGÓ´ž¿Þg쬭Ô­+?¼õ:çþZÅ{Ï=[nÙ/áþ#~Â×FŒhô5›R@*‘@‰¸­yîNéÛJÎ"ÕêJ×B¨éZ½C‰ÚlBùö?@ïNU R˜f´8‰DB«¦MÌö••´dÍC;kk†÷émÚ~uî<öŸ+'‡aÓ^bMÐv¶8ÈØ™¯™­È{vô#U~®ÅM3Úô÷àð&½õ6‡ÏœåÔ¹óLÿèÖïÚmzüÑ�L)HÛоy3Óc_,\Ä—‹r1’‡�0äù¸Ulåî”±·Sõ–ç…'Æ™­2|÷»ÐêêvŠA¨kD`°ö8;ÚÓ¹e Z5iTÛCî±S"øçàFô쎛ãýsÒÏÃ�î­ZÒÄׯ¶‡@#ožØŸ¡Ý»’–•EZVFª÷yxÜ»ï3dÖ56Æ£7hþÄ$Þøá§{vÎÚ �ËÉÌÌ&³Œîô§Æ3¸_¯J/<2Šìœ\:µiEÛ–ÍÉÈÌ4­|Ð)•JÆ�ƹˆHN‡–ÛîtØ9ró4Œؿ̛‰J¥‚A½{pùê5âï\ðn>w�FCß+‹ í×—Þݺ˜}¶¹�F£¡cë–´kÞŒä”â._®Ö8ŽŸ Á`00|P¿2·°P3 ×CDÅÆ‘|ó–i¿•¥�{˜“Á¡œ¿Uîñ‹ÊI\½v½Ôcí[6ç­Ÿ¿çAfá¶n­[qhñBÞ~Š Þç¤R C{tgßï¿ðá´çPU!+SUYªÕ|õê+lÿq>ý:uœ„R!gâЇ9¶ì¦�}O_WþüìcV~þ mK,Dî_6V–¼òä8Ž/ûƒ‘½{Ööp„ RŒÖyz]5o”&•Jq°²æVæí´ 7ÒÒ1 ¦À^·Àf¬=t¨Ìþgcb0�¦‡Ï ÀÅ«WˆIºFìõkÜÌÈÀ¢œÚjUe42‘ϼ"ö;ޝ‡ }}ª|Œ’«øxcW˜‚§HóF Q*¦ÕƒVt($+ò¿éS9pêÉ)©\¹~�±3_+ÕF.“ñýÛoš»/ÎR­&!)‰—æ|Vê±Î­Z2aİJ?Dz<3æ¶>bZɸóÈ1v9Vª�¿—'ï¿ð¼Ù¾og¿É�©ÓIÏÊB«Ó1oÑbæ-Z\ªïÄÃK`ËbceÅÌIx÷»ùDÆÅ±hÝú«S)÷+i¾¼´óòæùN]iïUõŸƒBõ´  C‰ÔÔÂÃKþÄÖÊŠicFÕöP*eT¯žŒêUw¾Ì¶nÜ�Ö�’pí:˃vTûxéÙÙ?�Àzõjd|qâ|8)ÅJ<¨:·kÙ°óÌùú{:´iE`ãF4ô÷C©Tàê\ùzÃ'‚CñpuÅÇËw� 5§BÂhZÎDº;¹žœ\æ kk+ìn§ð¼•šFâ•«fm$©Ymº{% QC:¶iÍÚ-Ûhܨ̚‡±ñ‰8ÚÛãæRþõ h\0A%61Ñ”¢µªn$ßDY"û‡B¡(•–³H\âe¬,-Ê­¿çåᆗ‡›Ù¾“Á!øùxáêìŒ�� …‚SÁaÔó©úg™˜ÄD<Ý\ÍV–Ш�©­‹³“iÛ–Í9ª ›y÷•Ê ôy{ºãçãņ \ŒŽ¡uó@š6¬�³£#jµ/Ï*�]¨ž—ŸÇ;Sž+¼0r™ŒÇ�¥{›Ö<ùö{\+Ø¿ü==Xùù'4öó½«çj�¥ZÍœ¦Ñ½MkžûèS²sï\ﶺ:4 déœput¼«çj�³ƒ=¿ð][µdö÷ ê|ÝK¡|"@XGh ¥WçÕwG³a¾N˱‹éPp#/ÐLJ§úõgñî]¦´&E.^¹Ìγgض e› hS¬ºNwƬ1èE€°²ss9ÆC®Öq|<ÜiäçKTa‹ÖeÌêQ*4kÔ�³ÂèÖ¶ Š2f²¹;;³qÁ÷Lÿè‚Ã#J=ÞÐׇ¹¯Í¢[±4�%ýï…çY¸fщ‰fûtëÊ‚÷Þ©±/>E5ßÿáG–oÚRêý,‘HüPw¾|}V©`f#?_¶üü3?ÿ’SçΗ:¶•…/OÏŒñOTx<“GgáÚuÄ_-¸a3ï�ÅŒÐϬΣ ü×xÛÚs#+«ÌÇD`°ö-XõNßNíãîÆW¯Î0mGÆ%ðî‚_Ó¿÷àzJ*_¿>ƒ {°óØ †öèΛO�‡ÂÏ¿®ÛHÐác$§¦âloϰžÝyfÔp³Ÿûá1q|þÇR¢¯ÐØÏ—÷ž›Ì[ßý„Á`à§wÞÀÅ¡`5ö‘�Pþ½™¨ø‚ß%[òê„ÇËL­óÙ¢eœ¾Î�‡ºñÔˆ!U¾ïÙÏŠ­ÛI¸v[jÛŠÇ�ÁÞæö¤›˜+Wù~ÅÎDD¢Óë©ïíÅÌ'£]àí•û‡ƒCùjÙJùúðöÓyï§…œºŽD"á…±£yrð2²³ù~Õ_ì9qŠŒìl­,éß¹#3Æ�1ÕÆýbñrŽ…�gÊ#ÃÑäçóãšõdåæÐµu >šú Ö–·ëM­ß³Ÿ¥[·“�t {kz¶mÍŒqcp(gBOБ㜉¸È›“Æ—J/³~ï–þÄå7°V«y¨Mk^›0Î4é\L ïüð v66¼ýÔÞûù7¢ ¨çÇ/MǯðfúW®fÿ©3Œíß—|­–E›·�—¯åѾ½y}â¦IKÿ â¯]{Ô¥»v⽟~#">•BÎ7³^¢K‹æ¼òõ÷D'Þ^¥Ó£mk^ŸPúwõº=ûXòO‰7n —Éô÷gƸ1´+–aA«ÓñËßÙxà7RRq´³atŸ^L3ªÔg•Ìœç}¾m&õ«x#=îj_._ÅɈtZ ¼=yõ‰ÇéÒâv¦‡9‹–²óä)´:=‘ ‰<<óux{1ÿÕWŠ�_ÏÏë6°þÀA®ßJÁÑΖ1½{ò£�˜ÕZ;vŽùk×q1.‘|�?w¦ ÊÈžÝMm&~ô bãØzä¡—¢Ô¹#¯Œ[¥çZW4jÈ+Ï=žÃÇ8vú {E!—ÓÀß�‡:u uóÒY7Ê“——GXøEîS¬VÈå´  øüÆ�мŒõw²tíú2÷÷îÖ…1C›¶·îÚËÖ]{ÍÚX¨UÌ{ÿ�J�¯¦Œ:ˆ _E²îŸ &>Zz²AvN6¶6Öeö-bei�\.'3«ú).—¯ÛPjŸ¯·'o–˜0X$+'Ç,ûo2³sˆˆŠfô�‚×D¥RѬI#N‡žcô�AUÎГ�•ƒÍ¿\§¢ï4™™¥¯Óc#†2ç›ؼs�Ùû¥ˆD"aÆÓ“Ø}ø(gÃαrý&œiÓ"�¾u¿c Cáîøø…ç™^�Œ5åÉÐØ›¤!1[‡³ZJWW¾Væ?{ F#ƒ‰H‰G!•ÓÆ­mÝš”{L¡fµnÒ˜m ¾cÄ˯‘píîÔ&lâïdžo¾¬ñ@ÎÏw‘˜«Gsí2ŽÖjÜÝÝP(U�”pµVÓ¾y¶å|ÖîŽA]»°á›/ýê›dÜ¥ÔÐ=Ûµeù§™R¤ ¶gF�ÀÍɉg>øø®Å „»Këˆü» ìÒ´)çããÍöýuø�›65ÝTy¼gOº4mÊÂÛ9iÖö÷Û逵ºôlÎ;j¯Žò®ÅØA;h`…Žq`YéÕ\eq´³ãú¡}•_q‡–/©P»Fþ~Õ:OY´Z- þ÷­š4®ö±ý¹ô_ÛýZ±”Q ||úõ'N�;ÏÉsçIIKÇÞÖ†vÍéÔ²E©/½ö¶¶¥®ÍãfÛÁCDÆÅ£V©èÞ¶M™iNÛÜñº¶jÒøŽ�«U*¾xu&³&Od߉“$&]Co0àáâL÷vm©ïí]n߯þþüóóB##9Frj**�üüx¨}ÛR«0ž~dO?Rö* …BÁ‰5+Ê=Ÿ üMíÔ…—6ýM~±™"0Xw8ØÚàíæÆÕäd¢¢M«Ì‹hõzÎGÇ’ts-ƒºv&82Š×¿þ�f ꣔ËY¾eÏ>2 g{;¾^¶’Å·âëáÎø‡±n÷>¾]¾¥\Áä �V«åù9_�œšF=/Oíl™þÉWÄ'%Aaºv€GOðÊ—ßbm©fü�Ádfe³jû.Ά_dÃ7s±³±2gâµëœ‹Ž¥]5ÒÞ,Úð_,^†•…ƒ»u&2>�eÿ|1’•ŸŒB.#O£aâ»s#5•‡»uÆÁÖ–A;yöãÏÙöÃW¸;¬æputà\t ÑW®’“—Çö£Ç±±²D‚„ôÌÌ‚k¡Ó3ùƒO ‹ºD//wëÌáàPmÜÂùèh–}ü>‰„¤›)œ‹ŽaÍÎ=\Œ‹§±ŸÇî³iß!¼œ]xuÂ8þØôŸü¾+ îÖ…È„D–þÄÙˆHÖÎ�Sª–®V§gÞ²x¸81qè ³Ç¶>Ækßþ€µ¥%“† âpHí %3ƒoÌ@§ÓƒB.ã対£c`É))?w�—æ~ͦo¾àFJ aÑ1¤ggakiM×–-X¿÷?¯Û€�›+O ꀭ•%aÑ1H¥RÖï?À¥Ä+8ØÚ’‘�CnžÆ46w'´ZñqD_¾Š¿gé@Ýæƒ‡˜õíøy¸1²çC þÚ½�Çßý� ï¾¢¾—F£‘é_|ÍŽã'hݸSG�`ÿ™³|¹l%×o¥0gÚ³cêõB/Å�«É¯Ò{L“ŸÏØw? )ù&ƒ»tÆÃÉ‘?þÙÆÄç°wÁ·x»¹àì`‡uá*,•B�¯[A°ÕÃÉü¦ß´/æ±íèqZ7nÄ´Ñ#Ùwæ,s—¯$)%…ϧO *!‘'þ÷1–*CºwÁÞÚš û1ý˯‘Ëd íÞob.ü;´±´ÀϽ`%šãzï�Ÿ üü0 $\¹Ê…È(N‡±ðÏU ØŸ�½ªÐq‚/„£Õj‘Éåœ +˜pfi¡&77�s‘• 6<7þñR+Õ,J|�9xmJ["©½•GÖ––Œ6˜¥kþ¦c›–¥VO*•J4©w®qª×ëÑëõ¨j »Í3O<†¯—‡Ù¾;k• Ù9_…q&4 ½Á€D*1½îÖ––dfeq1:†€F «4n¥JAþ¿ü|ÑäüžV«K¯t°³cøÀ~üµeí[µ(sE Z­fHßÞ éÛ›ŒÌL"¢c½ÁîƒG8zž·fLÇ¢Œc wǬ OV+8xKc`NH:éù·W|쾚dz�­éäRð:j :>;¶ŒÈ”Û“w÷&œ¡‡Okžk5¼šÏ@¨(_wwþš÷9ƒ¦Ï¨ñÕúž..ü5ïó æçç³~ßQv_JBéì…S»ÁÜT[p³D;ƒNËê 0\ 94sPðHﮦڱÂÝÕ6 )Ë?ý˜1¯½A¾¶fËÛ´nÒ˜eŸ|X¡à ^¯'**Šì{\ÃV¥RÑ A,,Jßëªfh�î|ûú,^üüËÚŠP"@X茆»Vw¯{`3~ß±Ãluà¹øxVíßϽn×ÈðwsãµQ�ðôwß�]ìFJZv6Kvïæ…!CïÊøÊb0Ñ Èkñ‹êýÀÞÖ–á½+Wçä^‘H$thÑœ…3ö+K­R1ª_ßWyÜœœxlð  ´,­eãÆ´l\ý ­ ¥uôöeùcãÙ~½Á@߆�hëY~à^¸·žxx O<<�MûUzIÑ**OgÞ™2™ ûpõF2ÍûŒÏ~_ÌÆ}IJ¾…³½VŒî׋‡êJ—–ͱ·µæ“…KØsò´)@¸óØI’SÓP)•üùÙ‡ØÛXóûúÍ|µ´`r…\Z0qé«e+1�¼ûìS /¼Q¯V)ùmýfVmßÁÔé0ûwé@o/ÚTm6¼&?ŸkÖðÑ´gÚ£ùZ-Cg¼NÒ­‚/FÒ¡Y7RÓèÓ±š|-Ÿ¼ør™ŒÓቈ‹çXè9Sý†¢@an^ûNŸåÇٯѷc{³”»{Nž",ê–jÖÎ�ƒ�•%I7oÑû¹9~.œ3‘´ h‚\VÐç\t Aó¿ÂÅÁž¹Kþä׿7q,ìùZ-߯Z[0þçŸaD¯äkµ ™ñI·nq&"’N%‚ «vì"öjs_žŽºÄ�œ\�†qúѬ�?O@ÿNíùÚÛì9y½Á€L*E./x­´:=/Œy„á=»—”Dßi/ÃÙ‹‘´iÒØ˜ÌÎÓ°íû¯°T«q°µå׿7²lëvS€Ð½0µcHÔ%Ü�ÙüÍ´hP¿ÔkõÖ¤ |¾dÑ—7–ùz,¬…6qð žY�Ö¼o‡öÄ^M2½§Â/²ãø l­¬øóãÿaciÁ”CyhêK,ß¶ƒécFáY,¢J©`úè‚÷�³}Õ‚fÉ©iôm×–¼ü|æ¾4 ¥\ÎéÈHB"/q0$”ÇÔ{~Ômm™ù~îüüVétï§Â#Øvô8¶V–¬šó>6––L5œîϽÀŸÛvðâ˜GðvuáØ¹ äkµ îÒ‰¹/N`t¯ ÃÃùö�ÄO§=Ç{¿üFÌÕ«<Ôº•©m]¤ÓëIÍ(´[ªUÈ”ÏZ¢7ˆŠ‰ÅÓÝ [kk¤R)þ>Þøûx3°W~Zú';ö`@Ïîªïsòl(r™Œ =æ“Ù¤R)§BÃ* ´³µÁ¹7xm¬­*Ôî^êØº'Ά°rýfÞyù³Ç<\] >w�Ü“/h�Oˆ¿ŸÞÒÍÍÑo7W2óòI¿˜Ë°6‚çäáCôö«}´“yeUÚ苊©©ÓŽÜàÁúñÿ¹öŒIu ¬þe3�Þ^Ü4iB‡í §LdØ€@Î¥¤òÅïÛõQ�MÍÍÔÖÕcÓÎoL¨v1Q_77Ü�É-.!-'—!m†øëW!� Ä—¿ÿAFn®>W¶¥yë å—îºÝ¨8ØSúõÑæ/[õí÷ì�<ÅØÐÆ…†pçœÖ…D§’0‘Ëùì×-úãd"“¢ÖhˆÏÈ4ÍLLxñ®å½€‡‹3¯¯¸›ðsqü°{/� µº¨¥’ÊJƒºÝMF¶Œ_&gµNÜ�Ie¨5âÒ3ðpvÂO—Ëí�cá$gç0qH(ãCB¸sÎ,dRC'‘«³ÔñÊ£T*)*Óæqr´µÅVÞskH±Hĺï62gꦵ±X�H$ôó!95…BÑmDUM ©™YÌ™:™™í®A?nÙFÔ¹81ý ¬¹LMµc¯ª®é�°¡¡‘¦¦Î…ºËaÉMsYõñvxÐ@v<ÌñÈSL7rÍ8t"Kss}¯]ÞÍ èÏ®ƒ‡9}.ŽqaÃ;lO<ŸBEUî®.””—s!7—›çÌdòØÑõ¾Þô+ç’’¹M9÷’íe†äàñDÄœe‚‘ûÀáØÛÚàãi|±•H$bÙ›xoõçüy4Ü`Û©³±DFŸá�—Ÿë €øú ‹©¹Æ ÿV,ÍÍùô…g‹»Ÿ?�$ IDAT_ˆÐÉU £åõJ iÕ ÛËI(Íê´}|I† ^c¦�ÉÒY3ظ{ïéïáÅ‹ÙËEÝ{NFQPƒ‡LÅüQ!¬ÝAE¿0$—~ï‰%HÃØ\ZHÎÞ#,Ÿy}.†ÿ§ñÀ¢…ì?ɉs±=¨Ý=+~/×®s+•J.\ÈŽ­ˆT__Ϻuë8|ø0ááá8ö"—´@GÞ~äA�Ž"»àêX" \æ:@y•¢[¸oútLÛ­ŽÕh4l�ˆàÍM ’ˆÞ4j4¾n†–*jµšÏvî¼b«”z‚R#$6øçR[_Ï…ü.R•ÄÿÕX[h-=%1±vÒ³mŽ6�FÃ3¬fÉ ¯ñ¿ï~äHÔÒs:®VoÉ-eaÖ:éÐ~òº­ÍÛÉsñì=ÉÞ“‘¤\ÈÆÓÅõUHœ^TÞj}giÞùjÙ L}èI\õ>?ìÞKø¹8*kµâYg�XcB-/Ñ §VíV綬Ö-./7(·³j�Z“ɤº}jwZTÖvüÝOè|¹u;e•ÕõèŽ�–Îê>-Ô§Ÿ~ú—‹ƒmIIIá±Çû«‡ñ�ÁÔÄ„ÿÜß_= �K䟻\ýo„ê*‹aŽÖ6<·p«~ù¹ƒ•iäù"SÎ3v€ÖÂF"óèœyúëõ/„‰998w–iC†^Õ±¶pµ�‰€€€€€€Àµ¡»×Í„ôLö„G ‰Øöñqstä·ý‡xmízƒz-"QQY«ø•SPdP§�“#"‘�FÃêŸ&èD“¸9:è.«ªÆU—ë-13 �\ííp´³å›­;)©¨dü�PÖ¿ö""‘ˆEϽB\MF§};Ú�nr×í³¢ÆP¤(«Ò Xvír¿uõÒïÚÆ*²¼ª7]ä[bf¦nüöúèÃâòJ¾þc#ƒ2yø�})”*>øq*µšOž|„ÙcGsþB6³ŸxÎè¾Õ %•¸ØÛi÷¯Ë«ãdghóSÜFÄ,× 3&r9V:A±±H„ƒmÏ#ÂŒ!“JxáŽe¼pÇ22ró9Í÷»÷Ÿ‘Ég›·ðúŠ{ðpÖÚóù¸¹²oõ‡\�9ýø;ãËm;)*/gô `~Yõb‘ˆ…/¾ÂéÄÎsu&Úy9iÇïíæÊá5­ÓÂÒ™ÓX:sEåŽ>Ã�ûþälJ*/­]ÏÎ÷ßí°¯ºL¸hî,>ùòÞúh5þý|p´·£¾¡‘óié4)<~Ï�=ê'ê\,>žžF­,ýûõÃÒ‚èsñŒÚñï¬3¾úñçN#ÏžmgÙv66,˜5�-»ö’u1oD"™Ù9ä3ë†Ix¶ËÑÍY]dj[–.œK ¯o‡òΘ477“œ–Aeu57͜ΰ�fLšÀ™¸DŠÚؾùõõfʸ1lÛ»Ÿ˜ø<Ü\‘Ëe䓞u�1#†áßïÚGpþÛ°¶°àÞó¯H_!vrŽv. \Ÿ8ÚÚpçÜ9|þëo—ÕÏý7/¼,¡±¤¼±Z…£ßÔ*–Á#{ÔN¥hFs!ž~,e"Tj …�*J­½�Ùi]Gä¶NìÈ(B¹}Öæf 6 ;;»^�U kBüý˜6*Œý§.«Ÿ§–ÝÖ#¡qݺuúŸEb1n“Faáá†H,Ö=S‹@$Òÿ,‰t/”Ú2ý>Dºz-ï:ºò–ú"tíD"Dº¶ºÿ£¡´ŒÌmûh®Ñ.êÛ¼y3Ÿ~ú)NWÐn÷ßÌüIyïÛïŒ.ú¸>ÂëåUXÍÞžñAAÜ?s&ëÚÙ‡T·KªäímÔ–ô«}ûÓ ÁÊý«…  ü;h…$b1vVV( þÐY›µD2  �XI¹�Ãá¨FñõÖí}™Èå ðéKRf["È÷^¶9Ιó)L:˜)í&‘?Ý´™³çÓ˜>*ŒÛfM½äñ{ºº`gmEEu ;��à¾s©®«ãÞ7Þ¡¢º†µ¯<Ë”Ãõâ�ƒ­ "‘ˆÄÌ,’2/PÛÐ1Ž.ÄÕÁ:ûÍØÔ òŠKpwvâ\Jù%¥ˆD"½Õi�Æï₃­5e•Õl?~‚Χ¦®ž{Þ|‡²ÊjÖ½ôÓFiíê>Ùô+�MM�ÚeÖ5ÔÓ¬‹üiù~ý³Õ.°º¡£ÅèöcÇYqÓ…aýQªT¼¹þ[D",˜�»“#[å`T wΙÉÈ �,™~}¼™õÔó4éŽEû}æ÷òÈÿ=p´³ã•'ådT i²ÉÍ/ÀÚÊ’IcF26lD‡¨f€�þ˜›™é#nU*ãÂFàÙ奉XÌ]·ÞLEU�‘H„D*å¶óèçåÙ¡¾›³3·-˜×å¸Md2äRI§}´gòØÑôóò"&.žâÒR›ôóöbÉ‚¹øz{Ô½eî,”Jã)3œŒ–�1 …‘6b±˜{n½…”Ì,|<[Ç)“ɸwé­ŒËÈàl|ù…EˆÅb‚û0rè`<û?–~}½¸mÁgÖe�ÁÃÍ•�–Ï`ÍÏ[ ¯”à>l\·m”ùŒ3«eáâI´‹>Ç–‹©H<´ÏXV¾Á¤_8Åóó¦²ÿ~¦M›vUžû´Ü9wÎe „Žv¶Ì7¦Ûz���¤¥µF¹;�ÅkîTD"‘^ä3ü#e�×m[Þ]]¹¥qkݳYbb"“& Ö¶W±XÄòÙ³xcÝ—õPzˆ ^h®ÑZÛ*#6L¦r#;ZÄ<0s§RÎSרjQYWÇ÷‡òÐì9W}¬êúòc�¿1[æ«-Pߨ'.ä2ã¡'˜;qœÑÜyÆö뇅™)u �ÜüÌË46)2 SʪªxæƒOyï‰G4�A‰JLâ‘wÞ×ï§=/ß{'÷¾ù›ö >= ™DB\j:6V–Ü9wv‡úç³²‰ˆ‹'ÀËxN¦î�I¥¼|ï¼ðñç|øÃ&žŽ&·°˜ŠêÆ aÒðaŒÄ�SÑì8Nv~ç³/2oâ8~?t”ßÅÝɉÅÓoèÑ>G‡3sÌ(öžŒä¦g^$ÐÛ“¸tmž »çÍÁ§“Érãã—ðê=wñÌG«µã?M~I e•ÕŒ #µ‚jFn¿<Ìì±£ñ÷3Ú—­•�Þ^¤dçðèÿ}„½µ5"‘ˆ?âÒÒyé³u¼yÿ½úúb‘ˆÍs8ú,‰™Úü�“‡Å¿�€áâ`ÏÂç^ÂÕÁ‘³)©¬èFiÏ»~dÏI­]U¥nµð¡èh&Üÿ0O/½•“&¢T«ÙÉÙ”Tø£T)‰LHD.“qËÔ) èëÍ]7ÎbÃÎ=Üöê› Hf~>¹E%̦�"mA©T@mCçÑr™Œ)Çñçé(<ÿ .övøy¸óÝë¯0:$˜á'Ù~‚¼’b’/ä°pÒ~9pˆß�ÅÓÅ™;fkm�F è�©\NQyãxW{{òKKùí�·ðvs%ÐÛ‹{çÍáëí»XòꛌHF^¹EÅL9‚>:qÓÚÜ‚�á'9èA‘KeDëìoŸ9Ý`ìfíïpôÌ9&?ò&25õõœøòóKúžþ˜š˜0eܦô`" Ý$ªGQX"‘tí6 ÝߘD,î´�­�u�£ç.%ÊÎÛÓoOãbV[.%ʱ…þ~�G:9:àäh\X ôõ½¤ˆD''­->žž"¤�•U�Ž�£��ÑzR©”qaÃ;íC.—uÛpÃ÷r™¬ó6Ö]Œ7(П @ÿ.÷elmqsqê (ôó!@ˆü˸é2ó&·ÅJ&æ�!6D7q±N…ªÍŒ‹©¤«¦1ý<Ü öó%!½sŠ®‚K7‹!z‚T*Ema�g`÷NcŠŠõ‘0q„qfÂðÁ45G±½¦™•6Z0Kê@AQ1ãÇ�gåÊ•¼þúë—=fãÜ02 Kss£öý=aÞ„ñÈz�?·¡]j ™�UkôŸ¨M´`K@`7â`ûº�‹ƒúàA}]S{CÇ’ú^~vãÜ4e¢ þ�Âë€k¡…å••³åÄÉåK'MÆÞÒªC¹�¥%·OžÒ!âð�S§˜>t(¾®=Ÿxê ×J4¸tllòí×év7G¬-Ì™1fAºh°ñCBihÒ.<ò÷öbƘQØ[Ûà`cÃÿy� 즼ºš‘ƒòà- 8yš?Ž„ëíd¾|í¶:FAYÁ~ýöõa‡.ÚP®Ë­7l` [Þ_Å»ö’–“‹\&ãÞ›æ²tÎt\ŒØù ˆ\&#Чc„FO™7q{'G~{÷-~س�ü’Rä2)Aýúêó7¼¹â†²3ü…åùôå©[oá–©“õãŸ6r8›ß]ɯ“]PHµºž CBX4y†„Œ{âÐP¾yåE¶§¢º3Æ_B$ëµÂÔÄ„à6âT£Jù—ŽG@@@ §˜Èå„÷Î~¶3äb]¯¾C”À•güÐÁ½'ôba‡1Ôj5YõªM,»We1qz×ùé¦�Á‰_öQm€™‡‡ÎŲlÆ$²²²®È˜Œ#“J:¨×Q„ã{yN‰Úü·õgíÿhóßÖŸÛ·3¬+ê´®a¢n“`\.ž..ø¸»“•—÷WE ˆ4ÿ†Œò׈òºZVÄëQÝ-cZW°5Ö^u9ìÕ~àtªaÂsw¾|ô±NWy¨Ôj]·–Œ‚ƒò //>¼oÅU ï.¦s3ü}(¬ªÀÜÄkÓ®'Äÿ®TÔ×ñòų=ª»6Ð0ÒM¡TÒÔÜLQcr¹¬Ã½¸ë;l7[/ãöÜÛý^ÎA×ãmÝX[_OD\…¥e,™9 ™TÊñ3±<ðÖ{8ÚÚrüÛµ=ßçeÔø+Žo÷[»h×mëô½v±-!3‹yO½@æ›;¶Õ5~uíWlܳŸÛfLå�G¸¬}vWãZœÃWr¿Â9ܳž»kÚ¨RR¯TôfXW ™XŒ•¬£-lim5U õˆE"|]þ’±u†ÃÄK·(¿Tùùräë/z\¿I¥ály3õ†Ñ�Æ‹ÀÉTÂ9–ÒÖ+ç['7�Ržc´ÍTïáÜ5¨u^«´¡Šø’ j%vžôµ¹º‹ÊÿíüºÿOZõß^µÝôÞ*¦�îYÎÀ®())áå±rï>ªx|U< ¦NèP®V«)++Óç~Û¸÷01ƒõÛûäžåÙSgܸîmLzϪõßòá{Õ6zÓ÷øôéÓm½ŠŠ ìÛ,Øô˜1¯ÙSôÃ<|ðqpfGÒY”µQëЮmD;¯Û>ò°øL<§ß^­Ë®]»˜=»£ÛŒ@ï¹ûõ•l?Ò3�¤§”=pEûÐ"DþÍQk4dàçÖù…8<)©ƒ8ððì9]†€KÄbž=‡g¿ùš¶:rbNßþù'MJqY(¯­¡®±  sœ¬­ p÷`tÿþ„øøèsm´"“J‘I¥ÔŠ…œ»=ÅD&cÕ—(®¨ ".�°à�lܵ€…S®œå–€€€€€€€Àõ†O'ù+�Qؠ⃄jÊš.í9ó—,�´"ÀZvIíeÇðcÒ>šuQÙ"`Š÷0î ž-ä�»Jø¸÷ü|èØ¶{!§'•–!³´é¶^SM>.Æ­£Ÿ}öYêêêx衇âÑu먨­5Z¯Y¡à‹=»;”� bD@ç9ZÔ·/SBB;”ÿ|ü[#"È(, ¢¶–f¥‚’ª*’.^d[d/lø–;?ü�ÝQQ¨{¤*n®hÂ3À©ë(!¹¹%é… ï{ÀçŸNQQnnZKÚ¢úf0¡<޳ª�Šâ*úØ‹)+HFwì@àŠafÚ»sÊÔ¤gç“1:æ±Xû_S© O[Þ™q3)%…¬Ž�. â €€€€€€À¿„ž>õ4(/ÝÁ©=�º¤…–òÎyË$ÚéÄꦺNëÔ+.m‘9À“ÿýÛÅÌÔ„)#ÃP*•DÆÅ³yÿŸdææ²sͧˆD"j¨ª©A¡Pö ×žÑ"HöÂë…Z­Ö ƒ���ˆÅbäry¡P.—c#Va<6Ð�¤r㑆b±X/Ö74�P¡¤É¤’iÒó„,iT’sš{ýyÊ*«øeï>bSSQ)Ux»÷áÆ ã2 ¯ûlA¡Tòßo6ðì�·cªn÷„Ÿ 6%OW–ÍòÚµ`,´mD_Dv:kNäþ‘“�K$˜He˜Je rõ`ÍÜåœ)ÈaÍéÃÕÕtnÚE¾Â¶u¯tayU5ÙùTê®#©ÙÙ,š6•Ï^yñŠîO@àJ"„×R±¥ª{û‡f…‚7ú©ƒ8›•IMCƒA”ÞÚÝ»hV®swpàæÑc.yŒóFŽb ‹:l“I¥8XYQߨDuC½Ñöyee¼ñÓO|xï Ìz°ºD"Üoþ*4�`ë& ð7ÃÁTŒ�‰˜Š^XŒÈÅ"¼-´S…�ö^Ħ­äèÓm_û^Ò¾këëÙuô8Û>ùˆÐ@mZœ” ˜ºâAb’’IÌÈ$ØÏ×h{�FCV^>e••ØÛØÐÏýÃ5¬Y¡ 93 ±XŒŸ§g—QSõ��””Wàéêª�lú§ÒrÍW«Õ(•Jd22™Œúúzêëë‘ÉdÈårLÚD/º˜‰Éoi¯VëÄ—Žóy•}‚ùü·]<¸p–шĺúzÞßò'êþ�)¤W¨ P’~¡�¢J 7.¼»WŸéЩÓ<¸rÕu†BöšM¿°xÆ4>zá¹K²Ç|}ÍZ’3³xòöeŒŠJ¥â³Ÿ~à±¥K051aÿÉî~õuÆ ÂîhcªÔ¨Ùqþf$±(x8Ë�F,a"•b"•2ÜÝ›¯çßÉ; ¿¶úób(ö�êº:=õ¯>po­û’Ü¢b¼½xû±GàÏú-[9~æ ÛÅʱHÌö#GˆNLâõ5k™7y/}¼™TÂï �æ}ò¿ï“”‘É‹÷ÞÅ”‘aWå0 t… ^ôT [³{)yyÊMå2fnPž”ÄéÔŽrÏžƒLzé_»D,æå[nåÅ («©ÆD&ç†Á¡Ì…·‹³~AycϱådxKÒŒ‚>Ûµ“ç.ì~Ý“�‡�’‘“ÃØ¡C>(ø’?Ï¥’�—϶yxÙm�¿3IÉœILâ¾[n¾êãéŽÏú™Ia#ØÉƒ3À�ìÀ×ۋу�ø<üÍHËÎf÷‘cÿe3¿îû“Aþþ¬XÔýe çRR9¯Þq˜ IDATýd2_½ñÌMµvŠq©ÚtP7NÏÇ/>×ã¾ÿ•týפRòSÜ)¶§Ä²lð( ‚Z£ÁT*£ZÑ€©LÞ뼃—šƒP¥R—š À=ÿy{kšš›9ŸÀ½¯¿I䯍olÔG3757Sß �¼mlÒ΋7)ÔÔ×âïONa•Õ5œ8{Ž)#è­¯gË� 0àªj�®Âëi.JÙÅÅìÓ­Fh‹�« ¯-YŠ»ƒƒ¾¬Y¡à‹=í@'3"  ×ãôvvæû§Ÿ&§¤˜>öŽF#ÝìíX>y2³†ã��?‘’—k°ý๳,= ?·®=Ê¥]„Í ¿ÿy€†ÆF*kj®Š@˜˜–Îñè¼íV*kjˆŒ�ã�%‹; Š‹9›”|ÅÇÒ¢âò÷ë²NlJ &&r ÷áî£Ç˜=qB¯û¸”WVÇ£·/ !=�Ææf蘖󺡤¬œ¨„Dú«҆܂BâSÒàÆ+×gIE§cãàÂ?OFPßÐÀü¦\¹�\%ÖmÞJlj/Þ}û5ßúê[jëøïw[·©YAbFUµ�ÛÆ¥¥óð;ÿ‡Z£ÁÕÁž`¿šš¨ªë¼�€€ÀõÇ09/ ²æ`A#yõ*ÝJDàb&aœ‹ ÃZçydb ¯�¹‡áÄg KíÌ ^Ãôu,ef8›·¦’±�™âìË|¿qÈ%—6åhocÀ~>$gf1çáǘ0l(Ã2Èß�F…aaÖ¹åé˯&)#“i£G1úáG›2 ?›?üR‰öþØð=&r9V­ÔG,¾öÙZÎgeñð’ÅLºŽ·_I:Fî‰ òþ õŠf.”—Ò P —Jõ .7ï`o—;½ûäcÜ2}YyyŒY~9…””ðø²ÛÈÎ/`ã®ÝÜ2cOêæ&›š›ù໘1fï=õó&Mäûí;Ù2‚)#Ã8s…BÉô1£±µ¾¾sŸ üsÂ뙸û—È�‡£V>Õ¹;Øóþ=÷ØŠl¬5[ɼx‘ÊêæßpÕ†% pE¸XȚ½âîìÄm3;F¡\ *kkùuÿAÜzP<]]ˆü~=´‰p0ÆÉsq¨5ä2Û>ü?l¬,®Ø˜®-62ld—Ý�\"忀IÜ`\”™ï?žùþã/{?-lx{%¯|úG£c8yŠ‘§´ã�ɸcÞ�¼ñðƒz¡§-Ãò÷ãÉåK±47ÇÂÌŒ¨„D2sµ‹Ö?p…BÉ¢iSùïÓÚ‰z�ZÃÆÝ{8sÆ@ üø‡�üöç&ÆÇ/<÷¯±Z‹Å477£Tj#e2���ÔÕÕS_߀……nnnÔ××ÓÐЀR©ÄÂÌUs32s‹n¶Hä&HäZ«ÒƲ"¤%Ù¸šŠð´”áïb‡ÿô ¤ÒÖÅì¹¹¹xzzræô‚ƒƒqttì²µZÃ�í¹sÿ¢…Ι;çÍåÎys Ê^úøS6ü±g{{Ò38MRF&¯?ôÇ¢ÏP^U@tRc†„2rP0Çb´ÁJ•Š˜¤$ý<Ù…¼<ŽDŰtö,¢ihl"&)‰°à`êùnûT*Ÿ¿úr�ŽÙ?•Öè?C;бÞ~<2j 6¦æ˜Iµ×2�.ŠP©Q�<ìIÞÁÞØŒ¶0g‚öZçã¥%U55ÔÔOµÕ §Þ #"y÷ÉÇ9|: €›¦L¾äñ\)�ð:@*#‰Pw’ ¹Y©àTZG»Ðg.2šš9Ïo'Ot¨»lò$\l¯¬ˆ†.šïhB<ùåx»83¦äº ·…© /Þr �|þ¹Ág‹JK£¾©óNrŠE¢.#OÄœÅÛ½‹fNçÈé("Î�c^› iumQññLFdlç3³°03eüðáôqv¢ ¤„¤ô &…�@Òæ!!9#“êÚZíìHÎÌB¥Rr0"’�À@}µZÍ¡ÈSd^ÌÅÆÊ’‰a#p¶·àbA!ÙùùŒ6€ÈØ8<]]Ñh4‹Ž¦Y¡` ¯/#܈R².p*6‰DÌä‘aT×Ö¡R« ê´¸¼œèøŠÊÊ0‘É ò÷#$0 Ã .>5�˜ÄDDˆ4€AÝD�&¤¥q&1�FC€O_ÂBuêÇ~:.ž¢Ò2FD2id±˜ÊšŽEES\V޵¥%c‡ÁÝÅÙh-¨T*ÂÏœ%#;SÂBáçí¥ß®T©8GN~MÍ͸991~İ.'8º#;/ŸØ”JË+°47gXp¾^žFë¶œ3ㆠÃD®=¿ãRR))/×nt6³h4 ¨ÝG~>gc©olÄÓÍ•ñÆê“V·ÐØÔÄÁˆH JJq°³erØl;±Mi¡º¶–£§£)*+ÃÁΖñÆâh×ú÷}âÌYü¼½hllâxÌT*ƒ:°Õ®@£ÑŸ@bz:2‰”ÑC죰´”Œœ‹ŒÌáÈÓ\,,ÄÑΖI#ðµ²2è'&!‘„ôtíyÓ·/£‡êm? KKINÏ`ò(m‚ñ3‰IØÛÚ`fjÊ‘S§ihlÂÏÛ‹1C#‹9“˜DAq õ �ŒˆdüðaÈe—ÿb+  Ñhôï%Wjº÷ã�¿ P*yâ¶ÅÏ,gΧÐÔÜŒ§« ÎÎ$ef‘œuW‚ƒŒF¦f_$9+ ¥J…»“Cû —·¾3©ÔjN'$•t¥JEcs3qñúíûõÃÆ²UÔ;!‡Šê*ƒ}z{cocø¬›šF}c#ñº(CK3S’²Zmú¬ÌÌ ö÷%åBåÕUH$R‚ m°jêHÈHÀÏÓ';Û^O�+‡²¼ŠÆÌ<4*•¾Ì"4q›¼{ªº3."s¶Gîj\tѨšQTg Q5ÝÞ‘XŽÌÆ‘¤óÜ~]áÝÇ�ß[EM]=g’“9w>…C§£8Ïú-[ñóòâ®ùs;´{øÖÅ|·}®\ElJ*%ÚÜ�-SQ™ºt=ÃÛX,¿öÐý¼ö�6:°Y¡Ð—±y –æf½JÑów¥å^­VÓÐЀ™™™>ªP©TRVV†X,ÆÊÊÄb1õñ©˜:ºïP£1jãØP’�iùE\äàgkÊà/œF�êt\J¥’ÌÌL&L˜@êùx,,º_¼RQ]­Ï;èçÕ:·t&9™“gcõ¿›™šrï›8Ÿu� ì@"³å£÷Âá¨h–>ÿ_lþ�»æÏcê•Ìü)NÅųê±G™?e’ÞB²…U�?Š•…ÿ°‘[¦Oç�'`æØ±l=xˆ}'" æÄÙs4573iÄpÿÅ÷Kí@ƒ]=xtÔë MÍe¡>>µ¹É”Wðü·ßPTYÑ¡½‡£#‹Æz¿g�W^vYcV©Ô|è ye­ýx;;óî�wâhm€¯«Ãüý‰Òù5(”JÎf¦3vÀ@£ýÊ»ˆ¨¬­«'öüy–Ü8{}úròŒ¡@XR^ÎúÍ[ÈÎË'37—~žžDœ�eϱpþûÜÓˆÅbÖoÞ‚£�¡ý[Å¿/ÝÌàþýñêÓHæÅ‹(•ZQÊÓ­õaçãï~@©RááêÊÑÓÑì?q’^xkKKÒÒØyø¨^ ܲo?¾žž$¦g0(0€Šª*ö?Ám7ÎÖ�÷XT4ë~þ•þý|051áµO>ÃÃÕ'{»NÂì¼|^_½G;[úyz’WTÌÎ#GYzãnœÜÁx8ò4 àëKqY{�‡sóŒi,š1Ýh¿wìdבcûûa"—s(ò‡#OñüŠ{�®ÐKHMÓ¯ž:Ç„Ã)(.áÍ5Ÿ#—ÊðéËùÌL¶:Ì“w,ïÔ V©Rñκ/¹�—O°¿ÕìþnNNFÇž[TÄ[kÖ"—É èëMxt 8ȳ÷ÞM°¿6Ħ�» !-�A—•³÷x8÷,ZÈ´1Ú‡Ž¯6oápä)üûzcnjÊÊ5kqsvÒ›.dä\äÇí;Ùwüu õôqvæTl{Ž…³ò‰GqÑÙþÓÏœ<{Ž ??¤R ‡#Osôt4/Ü/±˜Œœ‹|³e«^ Üyä(ö66¤d] Øß�Ú†öÿô3òòY>ïF’Ò3(.+׋£‡ ¡€€ÀU%='—Ã1g°·²âæ©ÂÊM�K#=7�?OGakiÉÒSÿêátCSs3éµÑ$޶¶ØÚÙ\V é™ì9ÁÀ~}™3Áð}ë?ŸEfn÷-˜‹B©â»­) Büùáí×ôÏU5u<óѧœ8gЇ£­-ÿ{òF…hŸ£›››¹ç�wôÛK** ~ÿþ­ÿ0"¨õëÓM¿r8*Æ ÏOŸŠi£ÂÚ�õKÒrZSC”W×ô;8ÀŸMï­äÌùTV~ù5»V¿O?÷Öˆ›mGŽòÎ×ß!‰Ø¿ö“K8ŠWu}#ßýšò�Ç:l¸ícL¼Ý@£¡pýï~¹�R+ Z�Á{åÃÈ[ŸÖç¢*ñ ÔŠž[ ‹¤æØ¼s/ãs�Q^UEBzr©”Q¡!L>Œ‰Ã‡ñÄò¥<ºê=~ûó§ãã;„�†%ϽÀ©øùû±bÑB$b1o}ñ•¾NK~°Î@·%40€ôœ‹ì:α˜3LÐÍñü�ËåÔ××£T*Q(XZZPS£ÍÛ¨P(())E")ÇÔÔŒØü Ôæ ˆÄmÎK�j"D:‹YeS#"±˜†¤SL÷±g�¿+®Î=CÂÃÃ?^¹åæÞ³.¬f�¡j#�Ÿ<ËÛ_®×ÿîhg˽ oâXŒö^9qø0F…†0yÄp‚ƒ8ŸÀ‰³çðîãvIûmËÍÓn`ëÁCì?ÁXÁ!}ÔXÏìRÿ)tæÚ x#=ûñÆ ó1‘Êô ¹” ªXu„Óù.;ï AÝ¿‘HÄMS&óéÆM|²qyEÅ,¸aJ—ÊWA ¼N0éB ¼XVÚ¡lTÿþ¿ôÇV£â Àógˆ(ŸïÚŶȈ˳1²‹‹ùtûvV.¿]_6:°¿�@�[Ú¹8iÒ…@‹Z£a¬.ÚiÔàP6ü¾�Ü¢"<\\ êVÔÔðÖ�!‰¨©«ã‘7ß&69…éãÆàãáÁ©Ø8½Ø“‘s‘Â’RÆ/F?OÐhøqÇ.^ÖyÍÇž×Fp:ÙÛ³bñ"nœ4�'V½GRF¦þ¢=§ââùßóÏêW}´á{NÅÆ1oÊdššlܱ‹ù7LæÖÙ³�9Ú�›º´L=têv6Ö¼÷ìÓúïõË_#26Ö@ ÌÉÏgÕÓOàæä„F£á«Í[ØúçA& †³ƒ¡Qê… ì<|”Çn_ÆݱÍÊÍå?Ÿ|ÆÁˆHfŒë˜\üžE õ¡ôOܱ€ [·aciÉ›�?Š…™*µš¿ýŽõ¿ýÎà�ŒŠyž8IÚ…lÞyú <Ý´\ë7oá—Ý{™<2Œ‹…$¦¥óâý÷|_¯~ü)¹……ôm31ÑSö‡Ÿ Øß�—X¡/[¹f-‘ç⌠„®.8ÚÙ‘˜–Nhÿ@*ª«),)ÅÚÒ‚¤Œ ü¼½È-,¤º¶Ž!P[WÏ׿ýΜIXzãªjjxéƒ�ùeÏ^¸u1èòÁ¸99ñèò¥ˆD"ŠËËùÏÇŸòÓŽ]ø~¾ž‚@(pÉœ¿�Í{~¤oWA üò¿ïB£ÑðÌí·!n7Ód"Ó¾jŠ:ƒR©ä±%‹ˆINådl±©iì9Á¼‰Ú ÏW¯áĹ8ÌÍLy|É-8ØØðíö]$efñØÿ}ÄîÕàdg‹L&ã͇V°ëøIN'$bgeœ˗è÷Ù~òré¬éL>”²ŠJ>ýys§Ÿã‘Å‹¨ª«Ó÷kceÁÓmòG;ꮵó'ŽãÃ7Q[_Ïö£'xréb}�½'":wgã Ý® ¹ïgTlKéï)øüWƒ²šˆ8.¼¼ÿ/þ"Šš TÄ~U§ýC£¬§"~5Rkoä¶�=h¡%¿¤”ÅÏ<�X,âØ†oðkãð#Ó]S�M¢ç—p*>¹œ=ëÖ •Høfë6ƒ:}ݵï¹g’“¹}®ö}õO?³ãÈ1V,ZÀüÉ­BÍ7+ß`Óž½¼¿á{^]½†ƒë¿ø×DŠÅbd2J¥µZMEEÖÖÖØÛÛS^^®Û4 ;Žœ ÜÎ iC-"‰ÄÚb±�D¢xê ²˜c™I¥$�º4ÎT2iö�xxxu9€äädüýýõî_R©”¦¦&LLºŽPµ³¶ÆÆÒ’ªÚZÒ3¬›KšÎZ7¬–Åñ½eâða8ØÚ�–�CV^‡N�F.“1kü¸Ëê÷ï�aÞA© µFƒH1¨P«X&œ½é‰zºmÞAk¹ þ.Ú²6¢"0ˆlT+I©(Ñï³mžÃ+öItÝ—•w[wÑ´©|ºq;�j¯ÓóÿeB±ÀõGÏÌ¡®:¦i§—¦ŠšÚe}ìZž†¦fÎefv¨ƒÎ®sPß¾úß +*®š8ØBtz: e«ØéîÐ17FyMµÑ¶"ÀDÜùƒWxÌY‚ýýôX#CC‹ÅœÎÎzqÀÇÃAþœK>ßåxZP©T$gd2sü8ýC»D,fÁÔ¨ª©!'¿Àh»³Iç¤æM™ÄœIP(•¸»º°~ÕJB[íQ[ŽacSçǾ+žºë®©Y�F£¡±‹ïrÈ€þ$¤¥�ÎŽÖÙÁ�°�Ó3HLÏÀÜÔ”þ>}IÐý ,œÖ:QhceÅİáœK6´ ¾yÆ4ýyêloÏÔ1£õ}¶§¾¡�”¬,æN™¤�ª“H$Ì™8�ÒŠ r ‹ôuÇ ¬¼ÝÝôÇ->5 [++fOœ ß>ÐÏ—�F¢VNŸª_eiokÃŒñc‰KIA£Ñ��–Ž›““^àO@߾ħ¦uz,ƒýýô⠺ɬ®þŽþ‰(UJ›šhljB¥6¾@HÀ8>Îú­Û©¨6þ,q©L:”ßÞ‡Ož}âŠô'ðïbüàv~ô|õò õP®1GcÎq:!‘1¡!Œ1²XP¤K™�[T̆•¯òðâ›YóâÓ˜è,C“³²Ag{w$ú,ß²�;ç뾮 cyÿ)­-Ym}=9:k«ÅÓ¦àç©] ginÆâiSôÿœí ŽÂâiS˜5®ëH�cF²xÚü½´ïBæ&†ýN ®-73å¦IZQsç±p4:ツÒ2Φh„.š:¥×ÇT@@àòQ76Q¾ýh·õJÙg´¼6*‘º­]pÝ…]—,êÑh¨ËÞsIM‚|û1d@Ôj 7>ú8Ïø1ÿûö;îxù?ü¼g"‘ˆ›§u\Œcaf†H$¢©¹™w¾úšÕ?ýÌÿ}ûj�vÎgÞ¤‰ˆÅ"~Ý»Ÿ•k¿ä“âƒï~ 93“Ðv©X,ÌÌxèÖ[p²³#õB6ßnû£wÇàoЉ‰ 2™ �FƒF£¡¬¬Œºº:âR3±µµE"‘p69�èfK¤¦æÐÆÊµ…�Àºïþ¬t%½R„•­†Ü@ii9Ç�§ª á­²²’ššÜÛ, ÷ðð ;;»ÛÏ ‹˜¦³-ýüç_hhÔΗùúrûÜ9Xš ÍNºûg–Ί¶…––]-âï 2©”y:zí/›ÉÎ/à†‘aØè"4ÿ½hÚÙ�jóž6)üš%›¿bwZjhgªÿúÙ9ñÚØY¼~áyZZÔ Ä$|顸߼ù|úëïŒ4趪ggg‡F£1Ïܸk›óHHßM»æÁlÎV¡ðj ú*ε ÷¢¶¡�É l‹ªð�»” L�ˆˆ //OOOš›J¦`êW\½z5cÆX¦“ussãìÙ³´¨Ôµæ…¦òï®]Ä\Jdô3Óyå‘q´·gË�ƒ|û·å¬ú�Ý»"“Iì‹8Á²M[; ?‹7l$"2 ¥BA¿.–×¶¼Â‚«î¿¢ CóËŠUü¹Ö˜rü®A·ß€šÊ}¨–)@5z=k##ø-b?¹%Åæ”¡k –OZ¢Óâ ª~Fi¡NkµFaM8ØÙ±äÓ�PÈËÂ(¿üß;h´ZüM}ž�Ü9…\Îù„ú˜&_Ü3lÝÛµ¥©•BÃûôæäù Œªð¬% Aµ\i5@èæTyDIjVÙ({µ-ÁÞ>D_Nª´\+??Ô6eE]S2³ê´ÍÖTÜg²•}º—›ÙTžZ^õ—➣G‘$‰å:{0�¶Ø}ä(Ì#6$YÕßöžnnûÓŒêõzròòèÓéêyåk;ÂDVM~{¹LŽÖÊ�»®ª,“ÿYƾcÇÒ»ÝÛµÅ×Û‹=Gޱ÷Ø1‹åJƒF–¯iQÛV¾pÊåršzy2qÄðJïÙªT•^³¦ô‚V~ö(€ÎTÏ ªZ�r™ ½¾r¼TT\ï~õ-íZ¶ g‡öøøàhoÏô9ïW¹Nu |÷—SÓÒ»ºwÃÏÛ‹¿Ö®«vaëÐPr9§/\à\t cî@xh¨±–MBç¢c¸ßT A.“cgkËäÑ#«m‹^¯G§×[œ'Z�I’°µ’2£4Ø>r@?«�ÛÒ´)\å\µQ©(ÑVþ®©øo‡i–“ Êr¿›þ=er” ùEE•ÖÑétV”ªîïB„›Ç�“§yáãyää—= »:9²àõ™tha|À/*.fúGó*ÕÕ nÆwo½Š›³…ż8÷ æ¾ð,¯}ñ�ù»&,8ˆ%sßC.“qê| /Î-ÈòÑÏ¿›^=.ŽöÆ€]jf&¼ñÄ_¾l@¥R©øìÅçÔ½‹y�E6±|kÙû?þýb®E;®ý—ÿöäÙ{'°~Ï~¢MI¥BÁϳ_§[c­¯è‹—xò½¹$$§ R©())A¥RñásO2ªo/óö>ùm É)ÌeŸþö ¦™ß>µ ^¸x‘éN°oSÆõïüEe)ÂîЗ¹3Œ3�J4žúàv=n>’$ñܽw3ýÞ{XòßVþÚ¸‰�]:±ãÈ1är�Ý5Žï–­D§×})‘W4¥_³Ž~1Öc.Í¢Ð"ÀŸ…ï¼…§›e§Ù™Ø8žýxJ…‚È^‹jüÙÊÓhµ<þÞÇl?r̲ý“&ð‚)µ¡^¯çÙ�>à™‰ãùæŸèMÁ“¡=ºòý믰óØqfÿ3]ÂZ¡T(Øwò˜îO¾{íe›fH]JKçé�>£i“&¼=íAžþèSóöfLžh.Úð‹Ê Ö jêÍÀïX´¿D£á‘ÿû��Ç",^oèÏ¢ÿ›e­})‘Gæ|HLb’ù¸ª”J>zöI&ˆ”C�ÒÊm;8Ÿp‰1ýúTí²S!Û¨,ŸwróʾC�ìì,ÞsT„yù–‘.@ìëK÷¶­Ùâëví1�7îØÿ¶IÃ'7šµ¾kTM› ³µA_T\é=…³#JOãà%ƒ•�èfzãwŒÂÁR^k“Q:X8S/w7þšû!i™™Ä\J$¿ oošùùZô)¬œÿ™Åz³žz‚{G #5#“ö-[àdoÏk�>b±ÌƒcÇpçÀ�ŒB!—Ó¦y¨y—J©$yûf‹å7¦R½ÃÆBSE‰¢š¨É¹$INNNdff¢ÓéhâêŒ*9‰©^‰Â¶E'd6j@ יïÑŒA™Õor¥Šø”"ÊÍoß¾=©©©,[¶ ///Z·nMDD“'OæÌ™3DEEѽ»ñ>Yv•þ£òBüýùqöÛ<ùïq::šßx»l¿-[ÐÌחݦ~´ ¦M™ùЃ|üó¯<óÞ<ûþ‡æÏôÆã�šúx{sàÄI^Ÿ¿€üÂB3ºFmèÖŠf¾¾Ä&&b¯V›g86íµÍÖ^ãzP¹á�K1HŒ­¦~`広*¹‚M‰y{•w!³Ç™7S»„J…ÂjÒž²G(• ¦�¿Óâµ ¦M *WÆÓóMqI û#ŒÏÇ·r ¸¦×-¡á‰»÷FÄF&G!ÉÐ,/v^.•‡.D1º[YqùçÆŒáµ…¿PX.墣ZÍô±ã,Ö;]9í߀6mñº†‘Pz=ü{øWÛg…úƒÆÏTy IVmýÁ=GŽÂ]ƒY¼ž“—ǾcÇÙ{ô¸E€ðjz´oÏšmÛ)*.¦MóPܪ™íW¼<Ü).)árZš9hq‰†¤”ÔJ³ÊJéõzv:ÌäÑ#U.=d‰¦r`+*.žQå~¿”œBn~>>Vf|y7ñ *.Ž6ÍC-KëwîºêŒÆR®NNب”œ��·HeJÿàÝÄúgònâAÌÅ‹¯EÆÆñõ¢ÅüßóϲëðQ¼ÜÝyyÚÃæ¶¥^¹ö‘Œ)W®p6:†W}„Žáaæ×¯–âÒF¥$<4„½ÇŽ“˜šJXHNöøûx³nÇ.ò é`š�èÝă‚ÂB|¼-Ò|î�8A~A’©ãÜ`0p!>�°�`ó2g£cðts³Ú¹âåîŽ$IبT´mQ6º.>)‰}Ç",¶Sï&ì=vœüÂBs:XƒÁ@Tlœ9·©¨Ø8‹ãt6:G{{ìíðöð`Ëþh´Zs{ çãhÙ¬úÎ2Annù…E¼8w>9ù<9ñ.î6„¥›¶²`ñR^úä þûv>r™Œo–,gÏñ5õaÞÌç±±Qòʼ¯8u!†OþÉûÓŸBQîûîûe+ùòµÉÌÉåÍ/¿ãlL{#NÒ·c{º¶ gÉÇs˜øÊ›Ì{yþ^ÆkZ€wٵ퇥«ˆ¿|™®­ÃùiöëüýßfÞûáW>þõ‹á3“îfÊÈaì:zŒù‹¬×æ*ýn[¸fŽI�¶mxÿ§_9ÇÂ5ëèÖ&ƒÁÀósç“�œÂÓ“Æóܽ÷—˜Ää×fñÚ—ßÒ¥u+óLÂ?_’S˜óïóÄÝãhÚÄÃê �êÈM£W“Ò¯°ûø þœ3‹Ý'ùæŸå¬Ú±›—¸O7ÖïÝÏΣÇñpqfã‚ψ»œÌÝ/¿ÁW/gêða¸»8™gÿ5õ¡_§¼óýÏl>pˆmßE¿GŸâÀ©³=ÉœŸb¯Vó÷ïÐ20€ï–¯bîo‹˜ýýO|ý¿—jõjbýžýl?r wg65�øäî|é5ü½”ûGÅÃÅ•RinÚˆmß~Id|O~0—ÿöâðÙst k…­i Ó‘s‘<=á.¾˜ù<ïþ¸�5»vóáo2¨kg$I¢¹Ÿ1�URZo}÷mB‚Ó·7 ¹Ü"ôì=w3eøPv‹à£ßþ´Úþ�±óXý:¶ç³Ïáä`ÏG ÿdé–m¬Ü±‹ÇîkÜÖ'óˆILâ™ ãyå�)Ä%]füÿÞäÕßÒ³MkQÏ­) ó(îš‚«¨¸¤„‹—¢R*y¾\ ¾ª\­ÊÑ¡,(XZß»TN¾±ÔDŬ 8�€©#†²ÿÄ)¶<Êô)“8y!Ú˜úo�h B}É/,¬Ñr2[<ïEò�Ë+½ç5íNc­¸rGAüôÚüZµ@RØâ|W­×+ÕÄÕÕ<�¦¦ZÑ2¨úçPgGGúw¹ùg:çæ×~[©¼‚Ü®RÚS0ÎÕÕ•ŒŒ šúó ­ 6FŽ.7 ]~.’J…Li‹¤0Þgûl å.T–Á˜¤BëÁ½¢¢"ÂÃà #**Š®]»¢T*iß¾=EEEìÙ³‡ÐÐP¼¼¼ªÌžfÍ ÝÙýÛ¯,Z·žˆsÆr/ÝÛµåá;Çr>á"ƒz”õ«¾øÀ}t kŪmÛ¹œ–ŽŸ—'ã¢W‡öæe^|j[’RÓðñA¡P0÷¥°³1f=Þ»¾žž´lfÙ?)Icôã‹?ÿbxŸ^Vï7¤¼‚k;§òjøÝT©ÜOR¹º„R……—�ÎLçÍÝÿ—0Ï4-!•½V¢×•ÛFýÔ ¬©=ÇŽsïËÿS ºO¹RO·šÜ‚Ú_;„†!„�ŒƒBE–ÆrFNÇfÁ(ä–³ÍFFq1- Ón¸¿?ß=ó+öïãJvžMÕµ›ÅL½„´4FZëd’ijcÆT­ZS£»ucåþ}¤egäåUiŸÉ™™ì9w¶Ò>;…†ZýìU‰KLäRJ £ô¯ôž“ƒá¡¡ì;ažÁU=:´cÑÚÙ{ì8OMžTéýÒ¢Ìõ5Û)<4WþXµ†éÜ�B!çïuë),.®rö—L&CmkCJzY€ìô…h¶ì?`¾)uøÔiö=FÏŽÈÎÍåû%ÿàîâbX*Õ³CVnÞÊ_k×q÷°¡Ø¨”ì8t˜ßW­áµ'«ös”˜k’$Ñ«S'6ìÜM»–-hHzf&‹Ö¬¥™Ÿ~RU–êÓ¹ þXÄŽC‡é×¥3E%%ü³a#¶6*œ°W«É+( ¿ {;róóY¸ÂXÀÚ,É«)­O�œžn~m÷‘£;s–ÖÍ+Ÿ—åu cáÊU¸»8›sз eî݄øãd}ضEsííùaÉ2ž¼w"övDÆÆòýâ%Œ:ļ=I’ømåj^yì\9tò;殡•ë*`JKÕµmþþw=þÞÞøûx“•“Ã7ý�“½C•³4+êÞ¾ÿ¬ßÈK–òĽQ)•¬Þº�K))VÝ´hÍ¿øûxãáêÊÑ3gÙº?#Mu3{vìÀê­ÛøsõZãßž$±rÓ’RS™6a|�ÚR•ò)^NŸ¿Àæ½û¸kÈ`šúT»ž Ü,TJ%Δ(%”Š›/¥ÈÁS§ÉÎËÃÑ^ÍÆ£RÈytüXìlm°S«),*ÆÁNͦý‡˜:j-‚xpÌH^ž·€�¦úÁå/yOL¸“þ��i§®^Gd\M³ìœìí if^6Ôß—P+©«gÜ7‰iãÇb«R¡TÈéÕ¾ SRÍ3û|=›àëÙÄ\[ÄšÒëq« @ž�4€‰C1û»ŸHH6¦m?u!†ó QÈå<5á.d’D°Ÿ/côã·5ëØ¼ÿSGÀÅt�”ž•ÍâߥcËÊ×äš(=fEÅżûÔc„úûѱU ~X¾ ­NÇÅ”<Ý\Ö³;½Û·Eo0àìè@{ÇPìÕjò ‰IJÂÝÅÉüÛ7ÅÁt?Ø%<Œ¦MŒµ–sòŒŠe[v`0Ö£›9EéããÇñÍÒ•l>pˆÂâbÔåf¿÷j×–cþ|MŸ¯ÔОÝ8¸ð{ôã±sqtÄÑÞŽÜü.\JÂÃ4àÌÔÅ£wŽ!ÐÇ‹@/:‡µäЙsl9x„.a­ÌŸÓV¥bÆä‰( ¦OšÀš]»9Ÿp‘‹)ix{ââäh8zºº°ì£9VÓþ”ž?ñÕ¤ÔÎÉ3>_LIãàÙ³ôëÐ�Y�=̬Ç6/s&6žSѱÈ$‰é“î6ž?¾M¹çŽ�|»|%ë÷íçÑF:{áf¤�Ëq+7£ÏZö–«Y¸fÉW2xpÌÈ: Þ¶+WúèÙ(Ú›îÕ£/^2ÏÐn_áµôž/¯àú;å®Å€.�ðöp'9ý ?¯XƒN¯§kxÍ|›Ö`mA®Å¥”š—‹ñyj" WGRÿ\GÉåtlü¼ðzxîw¬ÁÚeä6nxô|Ÿì3?Pœq 5˜¹%I¨\Ãp ¹Z p©/‰µ8*­›šF@…’4U‘Éd¸»»“‘‘ÁѨXú˜b,Æû*½Fcœ�*“!ÉäH2Èä É�d2džsÒeöäåçãPnàKQQIIIôèaœQW1}¨­­-½{÷æäÉ“\ºt e-Ó1z¹»ñÂýS+½Þ¶y¨¹\©]»0 k—JË–òñðàãgX¼vÿ˜Q¿whÕ’­ZVZW¯7¦¦‘λ˜’rMë—”�ž•�G &]Tìë” U§­˜´bšQI’È×j8{%ÅjÒê®Õ$Â:ØÔ‡çï›B �£ô«óZˆ�Im®[BÃÂFÆV®@¡µœEh¯¶¥]PG££Í¯éôz>[¹‚¹�L3? z»ºòÔë) 5Z-óV®0N«.§}pð5|Ü\yj¤õ}ju:>[±¢RÚÂ0Ü,SŒ*$¶òªOÇÝGŽ¡T(èÖ®�Õ÷{uìÀɨ(N�¿`žu5®®„p19™®m-·ÛÄÝ��†?ø˜i÷\_ £*J…‚~�O~ú•ÇÞ|¹\N«àf´ldî ³æÞ‘#øeÙ Ž�9ƒÞ`ÀÞVÍ�^½X±i3sú…—§;�Æ ìÏÿ,ãÇ¥Ë)*.ÆÙÁ�yÐêÌ4_/OŸ8�Ÿ—-gýÎ]¨mm),*bâˆaVŠ¥š¸¹òïö�Ìüh.ï¿8ƒûÆŒ"%=�·ç/ÀÑÞž¼‚¼=öH•Û¶ÆÑÞžQúñÛÊÕlÚ³�üBcÊ’þݺ²íÀA~_µ¦Ê@s‡ðVüºb%a!e9­›‡°~ç.óìAL)Þ<2a< W¬bÛ�ƒÈd24Z-SF�"¼\gWmyº¹±ãàa^úp.³Ÿ{šÔ+WØq‚�=º€ ·{µ{µeQ^C7åšÄ' ½=<Ì3Д Ž-{@Öêt$™jx— ñõ2veæä’WPhñpÖ²\m9W'ã½BùÞ51œ•›ÇüEKˆˆeçeRz:ÞÆú¼¥LJnjº®š £z÷໫‰MJâé?E.“Ñ:8ˆ‰Cq߈aÈ$‰øäËè ZÞS¹ó*îòµuÖõ#37—W®ÆÉÞŽ'ï¹ö™1å…øûÑ·c{v‹àÛ¥Ëq´·ÃÁNÍwKWàêèÈØ�–µÒM¦2ss™÷çß´nƕ울:3¸{Wž>K\’ñüºR®äÃÞˆ“dæ¯=�Z¶¨T{µ&r9÷Äç‹–°píz& ©]àA„Ú‰Š�/Kãx52‰&SFÒdÊH z½1hs�”Î!xôüƒ^‹AW¹¼DE’ÜIvó ~»ÙDÆÅ_óºçbãèi¥–cU$IÂÝÝ�dÔÈ”* c¢k©\ªGå'e•ö”g׬5Û�ft¿²ôš»wïfð`냤KQ\\L^^aaaÕ.Ûí^¶Œã±1•Þ›Ô·ò,@GeõSëï;šûÆV�Û{@÷® è^v�ýë³¹•–ùü�ÿY]·[Û6•ê½µiÞœw§?KfN�M}qr°·ºÍ_>˜cþyD¿¾ŒèWöð>÷•™•–?t0ãM³Ãtz=žîîÌ}u&çãâQ(„‡óòÇŸàä`_iÝRƒzö mË$$&akkC«à`ä2-›™;±JÛ5j@"cbQ(„…cS®S´bûúuíB§ðp¢ââ@’hæç‹ëUÒNL1œð�´: ¹•RÉ›O=Aì¥DR32pvp EPàU/´÷�ÍÐÞ=‰KLÂÁÎŽÐÀógqsqæ“W_!26–¢âbZââäDÇðpSRðts#È××âßçÝéÏV»¿©cFÓ¯K.§¥áâäHóÀ@´:ÂZán%ým)/w÷JçAçÖ­­ž-›1ÿÍ×8KII ¾Þ^æT²T8gG èOjF>^e䵓ƒïL†è„‹¤geááâBH€¿Åƒâ‚·ß¨´Þ” £ÚúvéLûV­ˆŠ�E&—lµîá£÷ÜÍÐÞ½HJKÃÝřЀ‹}ÝÑ£;]Ú´!*.ƒÁ@h`€ÅyÓ»SGzwêhþýígžª´�Á½z2¸WOóïcÝA°¿¿©f¦-{tg`-js ‚Pÿä¦:¿ºjê Èd2d29 CW®ÎMùŒ:½E¹š¥¥ÁFLÉ_*ªIF¡g>ø„ȸwïÊ݃’™“Ãë_~[³VE5í*�Á¯R*ùø…g*­ëk¥(}P͆V(äßÉ; ßûi!ÿlÞFxp3Þ{æqííxòýO(´R?V)—›S�ʬÔq.€5 sîT9P“TUµ5çç…,Þ¸™ðfA¼ÿÌ8;ØñÄûsÉ/´ÞAY~ œÖTÓYYaš¡Ü2å—WX©�[>àx-\Ù0ÿ¶9Æ®cl?zœb8q!†ÄÔ4^{è~ìm�ÇU.“ñåË/TÍÜÔÝzŠv¡a|óÏ ró yá¾{q¹Îxy>ÿ 3æ~ΡÓgxëëïͯûxx0oæôJû׿+¶îàLL,ß/[i~}lÿ¾æáªm;Yn%˜¿¸\íÌ7}èš„†ÜÁWK–÷9Ø3¬§¸W„ú”ž•Íù„‹´ ¨Õz×4h 1ÊîÛd 5H7¦]¨Þžˆˆ,eÝþ'yø²äJ*î>è òÐæ›Öƒ)è‚„�² ŒÌJ?�$“q±°ì†þèÑ£t+W>©¼+W®�B¡ÀÖÖ–~ýúÕzö`cÑÄÕ•]»ìçËÔQ#‘×S¦²ë±·BÍøÚØq’Þ5ZàP>ÈW1`X}ð°êe¯<,ÎÉ#zÍ&‹vøû×¾fªPµ‚¢"Nœ¯\æLhœD€°²‘ɱ•+,RÞt ¡shsŽ\°üãÚ.’'|Á”þé׺5öê²4“ù…Eì<}ŠE;v�’•Yi?�C›Ó£eåiïÕ¹œ‘É«¿þŒ\&ç�;1°];lUeh�VËÞsgùuËfÓ+׉kÔŒ¦ÚÛÊÕÖ¬/çãâ9Ϥ‘í¾ß<¨æõ ¯E~A!OÎz‡çîŸJOÓ…ô|\<‰)©´lÖ¬Úu=ÝÜð4Õ3*ÕÞJ G{{º´µ>ëÒ{;:µ¯ñò ¹Übæ¦ ~°¿ÁVÒ¾UÇÓÝÏ*êÚ¨”u šz6¡éu¤uò÷1¦è,¥T(èÒ¦õ5oÏ•²r»­©íñ’$‰ÐÀBkùpX‘“CÍÎ�€¦>Õ¦ötr°¯Óc'—ɬžÏ‚ 4M�ߟ—RÓÉ/,ÂAm‹F£á™>E¡�óú´ñóò$ÀÇ‹óñ¹p1‘þ]Œƒ.$Szz¹¹áì`_ãš:Pö€g0(¶2ƒ,#;‡È8〨§&ÞExp36í?h~__u»‚üŒß�% mCƒñ3ÕúÍÈÎÅ`0àæìXiµ•õaï‰SL>˜á½ºs9=Ã4Ôò`43]Š5ZFö.Ôq)5w''Ô¶–©Î³rs9‡L’èYEˆ«¶?ÂØQ1yØFöîAJF&EÅè­¤9;OwÓ}ÌyÓyV1äÅÔ4r p´³ã|BY dk�\ÕõuBi´:.§_a@§ŽŒê݃ÁÀ²mÛyqÞþ;pˆ×ºŸÓÀ �^OxP yFè•ì0,Òa Ko0àÛă—îŸÂý£†]uùYO2n£ûU?4¨Bjù©#†2¨[gTʫϴvT«q´S“‘“˘~}," ‚P?ÖïÙ[ëáõH?8›’ŒÓæß%I��W\Z?!Ò‡6 œü|öFœ¼æõ·îî|³t.Ždåæq42’ö-ª¯½[Þø�ýùeõ:ö�8Å—/¥O‡vl=t”¯ÿYN¨¿k?Ÿk14â|4¿ó>J…‚Èe‹®­ýö@*ÛŽÅ×Óƒo–®ÀÙÁ�¬Ü\ŽE^ SË–8Ú—e´øvùJšù6åä…hNÇÄ"I£ûXHt:o~ó“‡ aÞŸ‹èÞ 냔¬‰KJæ´)KÆQS}ïüÂbÖîÙ €¿§훇ðÎ?óÛº�Ü5 �Œ��RÉ…„DZG)ûz6¡o‡öì:Á‹óðÚ÷‘•ŸÇ¬ï!)-�¿æÌ¢Oûv×tü„º%“$c½´‚5íª¨mݾšôýmBƒiZ³ç¹LF—°VPa°^©?Bj9`¯™oÓ×ü~Å2rrQÈå“—>�ÏúÝ{Y¿ÛiÀ§/M7ЦŽÎå+üùïžÿh˜:µ'Â#wÕn R©—˜Ì[_ÿÀŠ­;X±uOÞ3žö-›c£Rñ̤ñ|ñ×R~Z¹¹LÆ�>LÌ¥Kü±n#Ï~ø)¼7›S.ðá/Xl7ùJ3æÎàÞáChýÄ´·é³Ÿç…OçsèôYîó\ø`ú“ ˜>y3æÎgëÁ#l=x„‰Cî`ò°!Ìü|_üµ‡ÖmhàÏü™ÏóÆ×?ðù¢%|¾h [¶àó™Ó-‚ƒueÆ”‰<ûñ<¶:ÂÖCÆöO>„ç-`þ_K°·µá±rçÑ=ƒïàÑ9™ë+>=á.šWHŸØ*0�¢âî}ým\�œxëчkÕ®íG�1ëûŸ,^KËÌäé?`Ò�;hßüifÞ7™Ø¤dVlßÉŠí;ÍËvmÝŠwÊ�__¾ôƒ›“ãôå¹Éëe€€pûRÊd8*+÷¤çå�]X€L’hæáeu݆âÞð ÛW«fAlÿñ[«é‡kãx—)ª¨%ôáó¸ëEú¾×)¾b½.™cè=8µzðºÚ ÔÞ¯«×òÒ§Ÿ×ͶÞ�Řþ}«|¿¸¸˜å‹¾ }KR3òpòô&¶Ð�Õ‰ö¸7o['m(L»„Ï™5 hå‚d(!¤Ó=4õõ­“m 5óÖWßòõ’¥×½¹LÆ–¾¡m5YÚ~úé'}ôÑ*ßW«ÕôîÝ›ààúͪfkkK‡˜0aŽŽ•ËA×gÊko²Ñ”™§®]Ù±¹K µ%f6rŽJ´=%ú²· //¾zêiþؾ�µ¢Ñ^=§¯J©d\÷îLé7À¢NaM¹;91ï±Çù{×N–îÙC~QÑU×Q*ŒéÖ�©àXa„ºJ&ÇAAá¦&“$‚ƒ®Ú ïâèH÷¶Ök•Ê$ ?+³Oš¸ºTÙ½o¯VÓ®Eh•ûõóô4×ÄT»5<¸,å�£½ºRÐÑ’qËîÎN¸W¨§¶µÅßÖú½T³¦>A©ŠÜœ­Ö$,õЬ9մɨkx+ž�4ÁêŒ�¦M<*½æîâDOË€�åjðªm%\Ê=—ß®¯gåí©”J:Ö ¾®ÚƆ€:˜UäîâLOËN¨ ¦ÞU.ïáâBß.U¾_ªM5ÞÕ·ÛÑÎG»²�W `ùyyZ=Ç+ jêCP5ç� 4?Ïn¼5”ávp.6ޝÿÃŒû&_×vT~žÇ_¶úž�¿ñ:«p ¨2@(jÞxÉéWxël{¯ù5};u°¸,ÏÆÆ†;ï}–]Û×£ÓBÞ¥\Ü\� '‹KyÙ(œ¯kÿÚø³ÜíoËà™¯³iÝÔú$«5¦…úÅ÷ËVÔɶtz=/}ú9ÿ~9¯Ê ÑÑÑ¿Ëd2:vìÈàÁƒ„›OZf&#ž™Aì5”&« ‘b´~ˆaªÏ!¥@�åL›‰B’áªR#ÁAA„Û‚–Âj¶*‚+uðn5ëÝšmµú%Ä9\ûmüç°F«%ÍTÇÝ^­ÆFm+„‚ÐȈaÍy¸8³ø£÷éØªå5oC—_Hq\ wTÞîV—1èJÐd_@’Û t ¾¾ ‰P+Å Ï~ð1Ë·l«×ý<4v4¿0¹LvÝÛJOOçJV6:¨dM}¼±³»5ŸoFY¹¹L}í-öŸÆ³÷N¬·}�G\ÒeîyùÄ\ªßà "@Xo®ÿ›_¸ad’„«R�Jvó�WÉä¸Ûˆà  ‚ `zØÏÈÎ!+'—�èœA¸t:Ù9ddçPXTÜÐÍA¸.éYÙŒžþ" ×ü{ÍÛ�Û«±kRep@’«P¹…£tÁÁ(.é2£ž�QïÁA€_W¯å®^&9ýÊuoËÃÖ¡!„7!4$X‘cç"¹ã±§ê58`0˜õÍ÷<9çò êu_BÃÚ°wƒŸxú†…ú#„7™$áªRc'W6tSjÌN®ÄU¥FÕsA€‚ÂB’ÒÒHNK§°¨¨¡›#‚p[O'‚Љ¿ÌÚ(*.æÅOæqç /s::¦¡›#Ô�‚¢"æý±ˆ>?ʱs‘7l¿{ŽGÐó�GønérŠÅÆ[Ê•ìl^ûâ+†?=�øËÉ7l¿ÿlÚBϦ±lËVôz‘ÀðV’�œÌ´ÙÿÇÔ×Þ"3'·¡›#\'EC7@¨= pRÚ ’ÉÉÑ£o¤Yb%$œ•6ØÊÅi&‚ ‚ Bã"ê¢ Bã#þ.¯Í®£Çè?í †õìÁý£GpG·n¨”¢/æfr!á"K6mfá굤ge7Hròóyý˯ùò¯%<s–ï—®äLLzƒž??;’ÁÝ»š—ùmÍzÖìØÍˆ>=éÛ©s~ø…óñ±Qªøtæt:…µäÐé3|üËD%g¾ðÉ|l”ÆPŸÍ|žo/óö–mÞÆâÿ¶�”šŠ\&'<8ˆ§'ÞM»¡•>Ë?ÿΑ3gÕ·7�UÏGN„ÆH&I8(TähŠº)‚p[R+”b&¯ ‚ B¸y#EÂmG£Ó¡Óëº7œN¯G£«]šÃÛõXY#ŽŸp½®å„[�F«ãtt,N�åÕÏÐ*8{{œ<ÍÌϘ—;|æ,�Î~Ÿ½§èÖŠ¾;yžç?šÇúÝ{ÍË9ØÙq::–-ó܇ŸqèÔY$™DNA…ÅÆ{µÊ?/O4 Þnnø{yáïå…RQ6æmÕ¶�¼ùÕ÷äæ0ºo†ôèÆÁÓçxðí9\LI­ôY.&§p*:–äô+õ|ÔAhÌ2ŽJ$1‡In(µ\�Z¤AAhâ.¬I"]½*ÒÞé³óòHJMÅÕÉ o�:Ùf‘¶¥\]«å…2 }üròòˆ8EAq¾žž„‡†ÜR)+‹Š‹‰ML ™¯/¶66 ݤ:WÛsH02˜R] ·žÒl%%%<{ï= ëÕ� —7ã"¢Îs&&–ðàf|³d9­Ž‰C1ëÉiøzyòÝ?+øaùjFô逷‡ÇÎEáãáÁòÏ> U³@(wþ´iÂ'/MgãÞ©¼úÈý„úûUjÛÞˆ“Ü?zSG —N\²Ò³+!~¾t kYÇGI„›�R&ÃIeC¾¦­A „ú$Iöbæ  ‚ BƒÂ:t+uø76:ýÍSd÷‘cÌúâ+ÆÌëOÇ©>ßÉÈó|ùû"¦ŽÝ Â�»ö”ý¼{ï- ¤ç�PF¯7 “Ä1»•) œìÍ¿;Ø©IÍÌ$;'�VGv~>öve3pKƒ�­–ìÜ<Ü]œ,¶Ù³\0±¢šôÓO»k,¹ù…¬Û½—C§ÏrèôY,^F»¡|÷櫸8ÖÍuE„[›J&G¥’£3Ðèuè ôƒ¨Q-µ$IƾJ™…¸ŸAAh4D€°)ä"5F}ÑÕSÐK§×³q÷^"ÎE¢ÑjèΈþ}‘I'¢¢Øqà0ÃÃèÓ¹#[öíçÌ…†÷íMó @þ^·‘Ô+W˜8bk¶í Ð4k¡¾ÚZ³åêþXý±ú_2³³™2vîÎÎlÝ€Óç£éÕ©�[‡s.6–M»÷Ñ28ˆ¡½{Q¢Ñð÷º DÅÅ3ª?2rrˆŽO`òèx¸º°óж<„^§£sëÖŒèߥBÁÎCGÈÊÍ`ÓÞ}Ü5x®.×ýnôñ+ÑhøñŸå¼úØ#æ Ù}cFñØ[ïrüìY6ïÝÏÈ~}øáŸå8:ØóÐ]ãˆOJbõ–íø3ª_0Í\»mW23ñtwcÔ€þ„úŸ”Äñs‘œ��ãÐÉÓtmÛƒÁÀ–ý8~æ™Ù9„2~è sÀuãî=DÅÆ3¬o/v>JÌÅD:†µbÜà�,Ýð'"ÏãçíÉÔ±£qqt¬òs&¥¦…Ÿ·¹yù;s–´Œ š¸Ó^NMcéÆM4õÁÓÝ�u;váìàÀÝÆÐÌÏ€u;wŸÀÐ>½Øvà “’iÛª†A¡0~¯±lÓfÎÇÆac£¢cx8Ãz÷D~¿wE]ÊÚÓèuâÚx‹ÓhµdåæâjúžÈÈ1~{¸:£TÈñpq&-3‹,Óëå—Q*ä8ÚÛYlO©TâTá5KWï˜W*¼üÐT^~h*1—Ùzè(­ÿ�QøaÙ*^~hê5~ZAnGrIB.ê£ ‚ ‚ ‚p O:uH%ëM}¥Ì|ûólܳ…BŽB®`õ–í:ušÙÏ>ŹèX®\�Þ`0÷;Îê-ÛiDó @ÖíØÉé Ñœ:�#§ÏðøÄ øz{ÕK[kz êãX���çßí;ÍÀ?Výˉ¨(Ò22èÜ:œM»÷±påj^žö0Z­Ž§ßyŸãgÏ"“$¶ì;€§»—’SÖ·®®ü¾j óû'{{T*ÿîØÅ–ýû™÷ú«¬Û±‹ŒìlVnÞJ¯Žíë$@x£�ß™èc§¹“ã 4¿.Iï<÷$‰)iø4ñ H£aáÊÕø4ib&¥¦±påjvëÆ¨þ}9~.’'Þ~½^O77®df²èßõ|3ë ²óòØw,´Ïhv>J×¶­yç«oY»m2IB©T²qÏ^–nü�…¾‡‡« ;eãî=¬Þº�Ìœ0 ÿúw=ñIIæöž:�of¿Uåç\¿kƒ�!½z�‘�ê-ÛØ¼w?“G� åÊ®\�‡«+™ÙÙL£ïWmÛÎÂÞ#Øß—mû²íÀAÖïÚCzFzƒ��{örìÌ9>š9­VÇo¿ËÙ˜œÐéu¬Ø´•'xç¹§ëäß«&nöÔ½7šÖlWŠá-oíÎ=Ü?j8‡NŸ%+7I’ 6¦Pnߢ9›bÃÞýŒèÓI’ذg][‡£TXÞ;]­ž³B.G©P ÑjIL»bµá‚ÅˈŠOà�§%ØÏ—`?_l” Þÿé7Š5šJËñ×?;wž¡=º1yÄ­9Z#½^O‰é{@nú>A¸•5qu%Äϵ­-Nöö5XC¸Š5 ‹ŠHJK#þòeJ4Ú†nRµl”Jšùùâ忆�Ú•BÙÐM*(*)&¿°ˆÄ”T’“ýg;[[BýýpurÂÑÞ¹¨GÛèäRPTDüåË$¦¦5ts„z$žˆê�H=Wê#•Ïé ظg/þ>Þ,üè=”29S_~�µÛvðĤ µÚVQq1s_}‘NF]¨ó¶ 5»¸×DZêÕ±=ÿnßɉÈó ìÞ�ȸ8” §/Äpòüór›÷íçøÙ³´ åëYopøÔif~ü©y[9yy|ÿÏ2¼<ÜY2ïlmTLïCö=ÎŽC‡øpæ î›ùçbcùêí7òmZ'ŸáF¿”ôt|¼+ÍróõòÂ×ËH.�-Y�õ;w£ÓéøßcÓ˜0|{�EðÑ?³ëðQžð>2²²ùàûš´oq IDATŸwÇ ^xè>NF�gí¶x¸ºòóûïàéæÎ¬/¿fãî=ü²|/O{ؼíÚñÖÓOðûêµ|³èoòòóYóí—èõz&L‰£§Ï¢ÕêÌ3ù*Ú¸k7Cz÷äJV6«¶lcî½æa©ôÌLæÌxŽ;ztã³_cé†M,\¹Ê"Àçä`Çïsß'/?Ÿ'Þz—-ûö‡ÁgcbhÄŸŸ|@‰FÃ3ï¼ÇÉÈóckcS«›kUÓsH0*ÖhÅÀ™Û€R¡`ñúMl=p˜31± ìÚ™ ¦ÆõÏß7‰CgΰåÀaÆ¿ôQq Ø«myé�)µÞŸ$Iôl߆�GŽóÒ'óiàGN~ãú÷áñ w ÑiÙ´ÿ '¢.жyZ­–ý§Î`£Rq瀾•¶y.6ž}'NÒ" r°Q„[K‰FÃ…‹—ðpqÁÛý¡›$‚P§l”J†õîÉØþýèÓ±=ML|„ÆK£Õrôì9¶<ÄÒM[ˆ¿œÜÐM 4ÀŸ‰C3°kgÚ·l!ú:†‡Ô[€�,áÞ½};cÚÕs‘DÅÅQ\R¨þ}Y·s7Ù9œ‰Ž!ÀÇo~^¾€{F ÅÞNMÿn]hÂ)S126žÂÂ"ü¼¼Xúß&TJãè³#§Î2°[·zú süê↧4Åç§¿,d÷Ñ£ôêØ�oßyŸ&M¬.Út¬ÇÞÑŸ¦žžL›0ž�»÷pªÂù9°{WTJ%­šgû´iÙܼ]G{;2²sÐj5V„‘±qÄ\L$ÀLJAAhµ:œ8MbJŠ9 äÛ”á}{ðè=w³tÃ&Ž�9g±½‰#†ãî쌻³3#ú÷å÷Uk8zæý»tF.“ǤfÒ§s'žž2‰áa7¸ö«¸y­�BM1j¥ª¡›Ñheçå‘”šFzqÞž8Þ¬#º%‰¯ßx™¯þ^Jh‰íZ6ç™Iw›ßömÊÒO>`ášõœ‰6*™:r÷�޹÷.N ïÕ¹âê¿ð¿¬\ËÙ˜4Úÿgï¼ã£¨¾>üÌlM/¤Bh�ÐCïH/bÁ†`ÅòbÅÞ~¢bAAED¥ ÒQé½z/IHo[gæýc7K"%›H€û|>ËîΜ¹sæîe³s¿÷œ£R%:Šú5c=û_z¸/q•cX¾a3Ééé˜ Fzwíă·v£æEDÀ¦ukc4¨]½j)tˆ@ ÁµÇl2ñÌ}÷0à�û/324öìWÉÉvMF„C�z: "¬\`Ðëi߀Vñ xóÉ'X¸z Ÿýð G�•‰?MëÖá�'£kËBÀ¹Nñ1›èج)›5eèsÏðûâ%Œœ<¥Ì¢Àz´iÅk�÷£iÝ:er~Á•äïOÏvméÙ®-ŸȤ?æñõoÓ¼ z”„@(¸.�$©Ô#ãòÓ*¨¯[¹±•+»­ €ÀRõíbxûÃìjôUp@ukÔ`ßÑ£lÚµ£Á@ïžÝYðÏ*æ¯ü«ÕJÛ&�Èͳx¾OÂCB=¯Ó2]+—Î$'3{ÉrÏöJ‘‘ÈÒÕ[‘v­û/:Â%²�8{EQ EnÛ›ÀΩW£qÕªÙÖ“÷Ý�N–ùkÕjVoÙÆê-Û9é'^|ìQ½ëŽ ìóÿ@ü *„�š‘YÈö¿iŠ“ÖaÑ¿«=çë÷ÚÛàŽÜÑ4�Å«×ñ½ïñØy^‡"K™9…H„ð7Ø•V6+;‡èˆp¾xó5~_ø›wïáç?æóóó©S½:?zÍ"ÅÍQñ°Øí„ú_º~åÍŽªª(î‡ZÎÓ¿\M£JtŸ¿ôÂ%M*†‡ñÖÿõ»l3U££õÚ‹^�2ÐÏ—Á�ô…KÈö’$qg‡vÜÙ¡�Wí=uo/¯ì�@ Ê#í7bô¯P½báì;šýë$1]BrÈè²]#ñ¨Æþ� õ[BÝF"­_yB–%îìp =ÛµaüŒY|2ñÇ‹¦È¿øšÍ|4ð9»ódYÜûÞ( z»óvîïÖ…O&ýÈø³¯J汋ÂW¯¡gÛ6×ä|‚kC€Ÿ//=ò �ÞÞ“7FÍ+ÿ)k—Wˆˆ\\�(¡(w”Ô±3§=Ûf-^Ê'ã¿çtR’G¬²;ìžýYe·2ÂÛ>¸ZUm›4BQf.ZJ�êÕ©_£f“‰é- uc—@˜}vä”+}“ªi›Ýæ±ÉÌÎ)ÔÖɳI´oÑ”Ù_Éô/GÒï®;Q5�©ó\ôÜ1îȽ}ît{ rtéÔÈT5�E«× ©*§“’\ÿoÜ7KÖ¬)düôiOÍŸƒÇ�£jšÇÏ|=?Nöuù^)*’”ôt*„ñÑàXúÃ÷|8ø"Ã*°ïèQ6ïÞ[*×ã ×6ZñúÆîtâT|E¡@ �@pÕüðƒÌùrÄâ ÀŒe '3d4I‡Š„S §CfÏ*ر¡|×¾»YÑët¼ð`_~;úš¤Ã®V1šåÆòÄ]w qðÅ×lfØÀçùå“ñ+fÖ´’Т~=þýá;!ÞÀ„…3iè{|þÒ ‘‚ø:GD–2f�«¢\ÖÆGÔd*6:Y‡³”#,Z4¨‡Ñ``Ñ¿k¨Uµ*¾fFý4³ÑÈ+ÿ÷8Qáa,Y½ŽØ˜ΞKaÍ–m¥êCqðöËöjô@›¦�ø~Æ,SRèÚ¦5:�Ž:Õ«±}ß~LF#ÍÔ sëL�ÿ'?Íú?³™í û8•˜äi§nlu*EFòÏæ-ü¹òBƒƒýó/>q’qCߣE|}O´ÝÖ½ D¸‹`_)׺ÿ z=Ï>ЇO'LâÓï&²mo•££Y»u;{&,$„íÚáçkÆ×ÇLZf_MžB¥ÈH&Ï™W¨­á`{Â>ž{°/�ëÖ!(Е•ŸÂ3¿¿Ÿ<ÁÙsçhݸf³™E«ÖP9:šÊÑ‘|3õwº´nuÅ×°uOçÒÒ¨Z±"³¾åÙîp:éñä3:~’#'Ny¶§geñΨ¯éت9¿Ì›Àío)Ôæ”ùàçKvnKV¯%À×—öÍ›²jóVÞó-�ëÖáé>½1ô˜�®¨Áüª×ñƒÇ{²müLfu)�@ \%>øúÞÑ}v*œË“ÐI2 €²š š( Ë2G6+T�Ó¿ÛË#�k×â¯oGs÷àW9‘xujÖ®V•?¾ADh¨Ö…9’©²ê”“-É G3UsU,N “NÂGÕ‚tÔ ‘i¥£]%=~1ÎÊšžmÛðÇ—#èýÊdåæ^•stlÖ”_>ù_ó•Ïå Ê?ýï½›È è?ô#œEh"‚ò‰PªJ™þÁìÉL½¬MMÿ Ëî\ˆAÖa£tÓ*DT¨Àû/<ǰñß3ò‡ÉàN}øÑà0�´iÜ�Æuë°=aŸN˜DD… 4©WçšF,ÄàeêÇ«ÑWõkÖ$Ðߟ¬œÖ® @½š5Ù¾o?MëÕõ¤yl\§6OÞw7?ΞË'ßM¤bDõjÄzjO Þø,¯�ø’¡ßŒÀl6ójÿ'h_€f ê²çÐ!>ÿ=Õ*FÓ´~½+ö¿,ú¯÷­Ý±;Œý}: þYåÙ^76– ÀßϵjëÙîgôä©ü2o:�ŽnmZ“¸:ÅcÿjÿÇymø(Æÿ>ݳ-¢B^íÿ k»êg­ß¾“±¿Nã£Á/ðéË/2ô뱞cdIâ�Ûo¥÷­ÝKåÚþú×u=ÝÚ z=Z6gÁßÿ²xÍZÚ4n@íêÕØwì(+7n·PÙ§ç­…ŽmϨŸ¦ i>>f>|q �~~ÜÚ®-«7oeÙºõ üðcôzÏô½¿D)�KŠ·cèfGÕ4²­¢ƒËÚÁU¤a­8vÏþµ¬Ý‚ba0<õOM¢ø–@ ¸ŽÒï‘KŠƒ»�kH²Œ¨NP�’ ²Óõ^S%@CÕ$v­sÐþŽ‹gþȳhlÙæ$=UCT­*Q?Þ€Xxí¨ÅÌ‘ŸÑsÀ‹¤¹Kå”ÃÙ9ò³b‰ƒNævðK‚�Ý)lŠF–’òœl8 Sö‚�^âÖjzú70R'TÜ[—%MëÖá—O>âþW_Çî(Ý(âÆµk1åã„8x“qg‡[øêµ!¼ðÙˆ²vEP$íZ%¾IØ”–Ägû¶\ÖæíºÍirí¢^n²mÔ«0\-+Nœ@SjW«ŽO�h5EUI8|„ìÜ\šÔ©�¹ŒþÀÉ’D€ÉûW«¯¼Áf·cs88�”DvNñq5yåó‘lܹ›yãÆx"¾lv;‡Nœ Ïb£vl5ýü×’�Òu¬Ã¥°÷2Ç•Ñå‹”œ,2-yÈ’Dõ°Ò)›PZTèØíšž¯KËæLþé%SAÚìß-P\™f4 Õ šMC¶IHv�ì®hƒtª‚Ÿ¿“ÞO]X×}ç^Ë׃âÙ.!Ù@v¨ú)Üó°‰�0‘aåZ²týzóÝR«§×é˜7ú ZÅ7ðú˜'œ|´ÁÊ©ì’g]’€^5 ¼ÙÒL¸�øK]–ŒŸ1‹wÜ ÷Kƒà€VNG•¨¨+j'Û©±=]eO¦Ê±\•4›F®¢!‰H³Du?™Æ!:êÉèÅ0*7¼:j4?Î�ÕÚOýgÙUkûfFD–2-B#¹·R æœ>|Ñý½cj q°„˜õFò6/,‹‡��™Fµk]tŸN–iW³ÔÏY\ÌúâÕñºZ}å K׬cè7ã¨S‰Á�=ÊÜå+Ù²{/±Ub<õ LF#õk^¼o%I"¾V\©ùTÖýg6™hR¯îemb¢"‰‰ºô�®N–©S½:T¿øþȰ Dþ§6�7ç½–øø˜¹¥Y“ËÚT�©Dõ˜‹GF……v•¼»<ÅC7+ª¦’aÉ%2@D…ÙdÂl2¡Y/œŒ�@ .†¿¯/cÞxõ²uârò44Eé¨HªÕš]r=;\Q„šìª%/©*’åB±çðq……kT$I‡¬�¢j JH d¦é™3ÙÎ#M˜ÌÞÍÌ�™ú'Ý)2›×¯Ç²ÉÌZºœõ;wÌýŸdÿ±cLœ5‡èðp†<öh zªl8—žÎ¯ þ"=+‹zÞJÝØKÜÀ—€î­[ñðm·2uá¢Rio@ßû½­ ŒØdåç½ö+>¯Ì;ì`åI'ŸÜb¦g5Ñ_V<{ÿ},\½–5Ûw”J{xöŠÄÁíé ³O9YŸªà¼”mÑØ—ÿ ÀQÁ‰®Q:zW6åå÷‘àê1làs¬Ø¸‰ãg¯NJdÁÕA,÷¹ ¨o×mÎÃU..D ŠÆ ÓÝ”5Àt²ŒAW¼ô eÙWwtêÀ ~c±ÚxùÓÏ?}:Z4ç«·^/“zd×[ÿ Ê%C7+©¹9˜ F|ŒBP�@ J›—y�è"MúKØsœØ³ì9NœV §ìvpØ@ÑÀ®¨XN,6ÉpaDÚ’ÕNœšU»ì »SÃá‡9Ù2kVx/-\µš)ó0eþ¾Ÿ9»Ð¾ÉsçyöÍ]ù7§“Ï1eþ‚B¥:®|ô ŸNü�ñÓgrèÄ >÷}_y�Õ[½Ë€û°vû¼­ç·Äã½î`ìô™WÔÎ3½ïóJh̰i<´ —C%O)z94àëm6ô2 h$î»Ë‚†q5éÞº%KÖm¸¢v^~ä¡b �š¿wðÓQÊN½95˜{Úɪs o×7Ò4Ä»¹H«ÍFfv69yyìËÊÉ%3;»Ôë4ÞèÜÝ©#Ÿý8™C'N–µ+/ªÕUĤ7ìëGRV1ÁÊ$zêFC–$| Frí7Ç #_ƒ¹„ã¦<ôUYŠƒÜýw# Óé Õ—¼>>fÊku¿+C7Šª’œ•I˜ zm)�@ ”:÷uí\ä½U>íšøuzš"£:e4;HHèd �$ ³A¥MÇÂmfå¨(ª†bQPì2š]Cµ�fÕÐi®ã§JVNñ'Íccb8|ò$[öÑ¡YSv:„Ín§FåŸ<å±;㎠¬Z1šŽÍ›�k±ðͯ¿³lÃFõkÖàå~�R³Je†Œø‚S‰I¼ÑÿIf/[NZfãÞ{€é‹–0}ÉRN%&Âmíoá©Þ÷bÐë=m�›6ƒ¿7m&3'‡ªÑÑf·ÝÒ®ØýV�'ïîuE¡N–yôŽÛŠ´s¨ðÌÒ¼«&ä«-6*úÉÜSS¤- ïuÇ „a!ÁÜvKÛbcWaØWÄ`i’f×xm»�AqF:²GN^GN�B¯ÓS«ZÕBs ©™ääå€É`àÀñㄇ„xJ.�8›Hv^.±•bð1Åmv;ŽŸÀ×l¢Z¥J×mf3Y–xôöÛ:~BY»"ð!^eB|ý±8ì$gg(j2•zY‡ŸÑLžÃvÃFwInqJ™¨+o¸úêbˆþ\)¥5†n4M#)+£‘sy•z�@ ‚ë›{:wôÚ¶A=­[Y¶8Õ!!Ë’$!ɲ*zô}(ˆ€€Â“ÐÁ�2ölŠ¢¢:uHŠŒbÍzYÅéTÑ©Â#Š -ãësøäI6íÞC‡fMÙ²7€æõëÓ²²øwËVêgÔ@U5}óÖíØ‰¯ÙLhP³–.gÙº ,Ÿô1‘‘lÙ“ÀþcÇÈÈÎaç�´hP€O¾ŸÄ˜©¿!I#ÂÙ²7��»÷°eo?ø/ Éü¿ÿ!Èߟ*ÑQü½i3Ë7ldâïsG‡[8{î=Ÿȹôtü|1 Ì^¶‚…«Ö0ç«/ˆ­ÃŽýQ—˜¶ÿèqR239tâ$¹+'Ξ%ñ\Jñ;í?ÄÆT¢AÍžhÅâÒ¦QC"+„i÷Åf+Û’KW¼¹ð¿µV†ëˆ º>E‘ë™®­Zâïë{Ñ(:o¸«C{�Øî NÞÙicKÚÕ_šcر«}«”žè¬¨*þûž‰³çàpG†…3lÐ@îéÒ€O'Nâ—?r_·.lص›ÓIÉH’ÄàGæì¹¦-Zì9nÊ'ÃhR·Ó/彯¿%3'€j+2ú­×½®ZÞ¸§KG!^GˆoÝk@T`vÅɹœ¬²vå†A/ËøÍ×íjŠË¡s_[i 7r_] Ñ‚+¥´ÇÐ�Nrv&áþ�eíŠ@nÑúû9óø~ÎÂøÙsY´öÊÒ �@p3c2ié¼¼åî{‚xð¡LF;Ö<+–\ yÙyädåªðÜ‹ÁÄ7¹p�ŸA/¦aI·aI·’�j!'ÕŠ-ÏŠ%׊%ÏJ®ÅF�Å¿_ªY¹2!��lÞ½€-{0 4ªwÙãþÞ¼™u;vY!”M¿Oeó´©<ÐóV2srøåÏ……lsòrùᣡ y0gSRûût~þä#¶Lû•ÅÆa2ùóŸÙ¸{7‡“E«×°dÂ8–~?ž±ï¾M•è(þݼ€Ñ¿üʹôtZÅ7`ç¬ìš=“ÞÝ»bµÙøtâùû³dÂXü}}˜øÁÿ¸³C{F¿ù k¹®íÍþÿÇ“^¤ˆõ†öM—øØM›i³;Eá§=%«9å's-µ53¶«/c»úòF 3õ+\~¼Xœזּ –f_{ z=mÅ—øøö^Œ©‚|–põÄÁ‚|wÈÁò$ï"�w8H«‡ûz¤ef²3õ7ÆM›�QoàùúpO—Τ¤g0pاlMH(d;wÅß´lЀN-š£i_M™Ê²õëyì®;‰�©DJz#~ú€m ûxyøÞ{öi<Ø—‰gyøõ·HJM+µþ¸–TŽŒ¤z¥Jeí†ÀKDá5@–$¢ƒB8�‘FJNabµT�% £‡¢`uÚQ¯ó/Y’0ë�®Bz¾­¯.†è?Á•r5ÇÐ�ˆ¦i$ggbWœT i´åI’;m6V›�ztcïÑãŒúù7îêÔžn­Z²M8zœ�»÷�–™M…� :5kB•¨È Ú\·s›÷ìCC£Q­štlÖä‚1Ÿ�›Ç¢µë9‘˜D�¿?=Ú´¼h[ûŽçïMÛȳY©Q¹=Û´Âd4–èZ÷;Îú�{HÍÊ",ØåÕè¨ ìÖîØÍæ½ûPT•ƵjÒ©ù%ü_·žãg ð§Gë–T»H[ûŽg妭äZ­Ôˆ©ÄííZ_àÿ·3f““—ÇÝ:“pôŸOžJ¯íèÙ¶Õí �@ (šZU*—è÷B»´nçÏþ½’ÎÚÑ$ªT3Qµúåë¿ß—m?GN††ê”@’Ðé$dIC–Ub«èÐÕ»t§‘$‰ ê³~ÇNTUcóž½4¬‡ÑpùkÛ}ðÝÛ´¦Bpï<ÓŸÞÝ»\8[׫O<Îíío`Îò8…øZqtoÓÜõ om׆y+ÿaͶ4¯WŸ??Ò23¹õ¹ôhÓš.­Z²ä»±°jëv<Ø×“ð¥GaÖÒå¬Û¹§¢\ÓR ãj–øØx/ŽýxƒµØ5ášGêØØD�`™å'œ$¤©X…¸`×1Ð?ÞÈ›m|·óÒe\6'),9æàÖj"Õ赦AÍš%N3_Ëûñ8÷´“I×.2õ‹}vâdªø^~Ñ¿Ínçø™³—nKÓ˜8k6£ß|�;;vÀÇlâ·…‹øqÎ<šÖ­ë±ïݽ+cÞzEU‰»ý.ò¬VF¾:„ÛniÇŠ yø�·9~æ ¿ýµEUôðƒ |èp§"ýóŸùý¯E ~ôáRé�kM|\ Žž>]Ön¼@„×½¬£Rp(g3ÓIÊÊ " HL¦–�ƒÎEUq¨ Šª¢jš¦–Û•G®œÿ2²$¡“e ²îšD¨]�}u1Dÿ ®”²C7 Šª’”•�T E–Dß Ê¾>Øìvü}}ðõqoó-d3æ·Œ›1§P éá?Laè³ýéÓ£«gÛ;ߌgÖò¿ {[ÛÖ|ùÚ`Ïo¹ÓÉçxø­÷I,°Âsô¯ÓóúKtnÑ̳mÊ‚E|KÖ®cÕÖ­œLLäÎŽí‹<&)Õõ%,$ij-"4”ˆÐ Se†…œ 3³]éú¢*T(d@zf²,ñã°ñãdÖmßÁôÅK™¾x)&£‘aƒñ–­e IDATÒ¯×d¹ÓþE¹�ˆw½v8œäZ,ùû»/JÊ•DæT¯Tñ²û7')l.†€ão�x¿�™UõŒØlã�U*ÈÄøËXœÃ7YIHSø¢£/73±ð¨ƒ“Ù—®k8n‡]„e@lãâRèd™*Q.*¼ÉV� ‡J™ZÃ_&Õ®‘a/Þì›Uq‰„_55s¹o¬¦uëòÛçŸÚÖá‰þž¾ÄÔTR32‘e‰mÛxlnm×–ß.bÏáÂ)«DGƒ»LF#yV+Õ*ºúØÇìZ ‘wè„«Né/.dÞßÿ�”êJG¼ÿرb]oy¢¤cJpíá5$_$LÌÊàTF*‘�Áuâ#(-t²,&ø½DôÕ•!úOp3cu8HÊÊÀl0ˆÅ.‚rK€¯›Ýîçš +(À%§§{ÄÁoÞ|…NÍ›0é�ù|ùË4†OžJïî]�%‰5Ûw2kùßúù2|ð@Œ¯�þ†¿Ö®çö méѺ%ÜBbj�š7eà½Y¹y c§Íæ½qß³²Ic zÉéé ÿi*ðÁsý©]­*þÿ‘݇�òýœy yô¡B×�œ–Á–} èd™­ GÞ�KÏðˆƒß¼9„®-š3é�ùŒœòŸþ8…û»uF'ˬݱ‹ËVàçË/½€Éhà•/¿aÑÚ ,^»�ÛÚ¹VÒöÓΞK¥c³Æ ~¨/oÞÆ˜ßgðÎØ thÚƒ^OrZŸü0 øð¹§¨[�'üÈÎC‡?ó^ü‘óýïãK†)ƒ^G€»ÿý¼Ÿ”�@P˜âˆOyv8� éy *®z\h€4' ‚äM�¡P; ÿÑd*W51ü«ªØ—Çñ#Vô‰ºõýˆ®T²Ìù4w§JýnúL×ûúõIϺ|IžŠááœNJölÛwôËÖ­'®jnm×ö¢Ç…»Ä#§N¡išçÞå {B>"Ô%8¶ŠoÀÌQ#HÍÈdéºuüö×b6ìÜŰ é×ëÂCC8—žÎ¡'iT»˜Ô÷1›¸Æ ¡‚Ý‘�%;öòciú~ïœp‰É=ýˆô“xô¯kÀšý°a?l9Û÷ÃÎý�pöÁêÕðëTعûÂ6% j×õ¥Ç¡térÅâ @“:µ1 ¬Ü´€ êyL³z®Ô} W­fîÝdåæòÞ×ß2lÂDŽ]&-à-Mák6søä)¾ùmÙ¹yÌ\ºŒ¿7mB’$º·mÍú;‰êÔ�>Šª©ô¹µÏõ½?÷ïÉüˆ¡Q?OáàñœMIáý±ãèÖº5²\ô}QVn®Wýã >&S‰�½ÜXr¨°ä¸w5Û 2|ß׸™·W[Ù•¢Ð·–Á#žÊVYzÜ@ÕÀóÓÏgÑs qi³óÀj÷º÷‚G§'Ÿâó~Âj»t*Ó’pûóƒ¨Ýë^ÖïØYªí–òSç³É»ï„3�^Ö,H|°ÌGñ&L2ÄøJ|ÙÄLSñç!¦w\Qæ¯È° úù¡¨*k¶m÷lÿgóVâªT.qÛ±îñ;;´gÄ+/3â•—yþ�¾ôêÔ‘Í›y|yÅ×tùTÖ‚òƒ_+#B|ýñ5š8—�E¶ÕJ¨Ÿ?þâ?Ž@ Êš¦‘mµ�ž—ƒQo &$LÔi,lv;Y¹y¤Úr ðõÁXÂt‚‹Ó§[rÜ"YLdOß{ÍêÔöì¯S­*µ«Uaÿ±|;m6ÝZ6ã—¿–УuKüÝéHÏ$»R»ÄDE�˜êå*GF€;­(@JFv‡k#"$”“IÉùûQ)"‚´ÌlN%'Ó’ºœ>—âñ'-3›\«Å³þTò¹B«Ú‹¢vÕªÔ©V•}ÇŽóõôYôhÕ‚_, {«žtŸg œ315 MU©á>g²ÛÿLlv×JñÈÐ�Bþ§ddr2)‰Öñõ9íî‹Ê‘¤ge‘c±R1µÂõô¹dTMó¤,íÛ½3î”^•ÂÃyî¾»iY¿.AY£* 9yy˜Å÷®@ ¸ŽðæŸ{`ûiW„ ¦º£óŸ5×KYs ‰çŸì6ؼÖ¬LEr¦¢×\ƒ½^�^oÀ “ññÑS]O­zzô%Ìi2iX+ŽÍ{öRµbôEÓ„þ—Ö�r÷nÌ\ºŒ»½äÙ^¯F,�õºó’ÇðéKƒòù|Xæ³Ff̦b|%F511d«�Ôb¤=’£²7S¥~PÉb¥t²Ì#wÞθi3xá“Ïxá¡ILIaÒì9H’D¿».ý=T½:udÖÒå ÿáGt:y #'ÿLZf&sÇ|å‚«…Ë“Þ@LH²­ÒrsHËÍ&ÈÇ“Y¤/A™âT²m²,yèdaþAø]ÁJUAaò¬V’RSI±å¡×… �°”éÓ£‹çuxH0C+<cÐë˜ôþ[<ÿÉHÆÏœÃø™sèÜ¢)#^è±KÉH Ðß�ç?NNž…7þ¯Éi®}çÒ30 OL¤ÏkïðJ¿‡<©ŸÎåÛ¹Ÿƒøâ—ߘ±tó¾€Ýá #;‡�ÀóimÚ5nȦ_&]T4Ôëtü8ôžûäsÆÏ˜Ãøçý5d�Çî\šË· ž6‚ŒœlÞ{êq’Üþä_£A¯ãTò9î}å-^yô!BýÝ×—áº^÷s°¿?#§üÎïK–ñçèàŽBLÏ̦Bp «ÿ»uñLd†J?*� ôÙ›ÛOH'ª� 5 $÷kUs¥Å­ÊàqòíaîìV³ÙÇýðÅd2a60› ˜�Â#ŒÜñ �¸úÞ-nzç™§ÈÈÊ¢^�X† ÈÉÄDO ÐöM›ðýÐ÷ðsg~hP³ß}¯PŠº1o½A×Ö-Y¹qŠªÒ¨v-ëu'f÷=ÊÐ�Ï‘“›KÝØê…Îý@Ï[©[�é‹—’˜’JhP ½:u¤½;{„Á gƨÌY¾‚-{HÏÊ&²B(wthOÛÆ�À�áÏoÇðû_‹Ù¸{7NE¡a\�ÞyÁ~¿}õæk8�ÎBµ ß~ª?þ>¾$¥¥R§zµ’}À׈Ý)Þ‹_wĺD¿9îšr·U7åçšËÌqhÌ<àZ@í'ÓÃ]Sð�C2½H™�¦àP]QŠÞ°cÖ4Ì&š¦ñÕ/¿2|Ò�L[¼„Ï^L®ÅBFV6þ¾¾T÷½Ø¹´tLF#QaçëSfåærôÔiLF#Õ+U¼d´efN�Ÿ &2²ÐñùddesðÄ Ì&uªWàwM¿§¤g�k±èïGH ëw³ªjœLL bD¸ÇöЉ“dæäP³rL¡Ô—™ÙÙddçàçKH` »¦vµªÿ›#¸œQÜèÁ†ó©ì+3²‰‰ ‡¼g$Ý®ñÂk‘m.OrR?¨ä÷Ýoö’¤ÔTf/[ÁPw±�ÙÄ;O?EÇ+ˆôëÙ®-/÷{„1SãÅO]÷‡A|õÆë´p§d®&B ,˜}0û�c³’mµ�š›�Ù`À×`ÂÇhĨӋ4n�@ ¸ª¨š†ÝéÀâ°“g·as:ñ5šÂ×(„AÁÕgèøI,^³Þó><4„ynª´±Ùí<÷ÉHv<Ì]o¡Iíڬش™•›¶2hø(Æ¿û&²$¡wß +N…ؘJX¬vT÷ÒW“û&ÜhpÛ( f£�5ªŒ¦©îý†Bϧ“Jáa4¨Q½P4@~;ùôºKÖ±Úí<ûñçì�ëÔ¦q�ìU+FSµb´ç}Dh(½:u,tŒ,KÜÛµ ÷víÂÅèÜ¢ù%Ïß°V-ÖªuÉý&£‘oëɃ·õ¼¤�Ùdâ‰{îâ‰{Ím·´»`[XH0¾ðü%�)OËò¾ŽšQç]ïÉúç…—éûä84$à³ö.‘'9Oc”"€¢ÂÉl•ØbF{I’Ä]�:2|Ò�8NRÒ3øý¯E|6éG¼­'_½ñ*Ë×oàé¡Ñ*¾s¿þ €‘?ý̘©¿y2ƒðþsÏðð·:ÇŠ yâ�ÿ‘k± “e^{òq^êçZ çTÞÿv“çÎÇéŽ4 dØ �ôîÞ•™K—1tìxÚ7mÂŒQ®ûœ-{÷Òë…Á°kÖ Ž�9ÃSÿû€Ý‡»úÙ`à™>½yçéþH’Ä÷³æ0ò§Ÿyèöž$¥¤²bã&¶ÏœvQ¡²¼p*O#Éê}´_|°Ì§nqðHŽÊ”cÞ©o"!SeáY'oÔ5RÕOæÝú&|tàï¥6º5íÂñýR¿G<ŸßÙ6ó÷BïMF#cß}›7þïIŽÅh0Ьn�BîÈW‡\¹š0ov¡÷m5$ñïe…¶½ÑÿI»»;÷ ÀÏ�FµkyLWq'_Žð7™ñ7™QT•\»‹ÝN†%EU1ètèe:YF’$$¯L�@pq44TMCUUŠ‚SUÐË:|ŒF;ø™L…Šr 7þ»†Ý[©Ã@’$zwëHË~OóïÖlص‡6 Â. ='‡/†¼À´%®›¹È0׊÷ðàU%²Bf}ñ)S,rÛ¹nÖ#C]vÙÙ|øüS è{» ÀÏ·X7€®ZË΃‡¨^1š/^~I’¸¿['š÷{ŠUÛv°nÇ.Ú5nHDˆËÇô¬,F½<Èíÿr¢ÜþG„ƒ{‘@DH0ŒúÌåÿ_Kˆ®à¶+àÿ'Ÿa@ŸûØsÄ忟�¢Æ @ ×”3ɰa›JF–“„Ó*ª¢ *š¢¢:TUESUPUPÁ`ÖR9IÒ¡˜'×4WêQEs �áQÁœë©}— ùÏ\‰˜Xð ¸À@p’^ 祕yôŽ3’aÓh¥#>̵0MQáç½®¨ÂÁMM´­¨'Ó¦ñܲmdbE’+%émÑzÚ‡ë™vÜA¶n�Ö³&E!Ѣѻrñd�y*¹N ?ý•Í©ÿwACiFt�èc�àZ!ÂrˆN– 4ûhvM´(ªŠCqâP4Mó¬ü�àJ�e Y’ÑË:Œz�eÊÐçú3ô¹þ×ä\ÙîdƒÎ“¥A§;ÿ:Ïêª_W¥ Ë6lf¯[ؼwµ«V 4(€°�`RÒ3Ø{ø(m5 +7—ýÇOP§š«`}\Õö=á©×·eOá¶ ’ž•MÂÑcÈ’Dë† íËÉuû¯×ò??¢/׿Z�]«je–¬ß@‘ãžc7íMp�³ZUp§<� &9-ƒ=‡�Ò®qC²sóØì¸Û—]í*.Žóø¿ÉÝù6AyGÖé<5FË{*.�@ ¸[÷Âì ›‚áàt8p8�(vÅéDu8ÑMu"© £¡—eòNeݨf³©@*ÒüÔ¤àhô¤)EÓ(4å~­¹£ívHØÍ.Ô'¸Î°*ÞÏ7ÊP¾Éõ›óÉúç3Î,9îÀâÔÞÞ‡ûâ ìMUò�…ÃÞG'X�ÞûÒë…ÁH’LžÕÂá“§xèöžÈ²wBÐ>÷oýР :·lN|\ucc9zêªZØï÷ž}šþ÷݃ªj4éóI©i>yаà&Íù€¯ß~ƒ[ÛµÀ 70uÁB~úc_¿ým5bͶíü½i½:uäïM›¸§kg¶ïßÇÞÃGˆ«Z…¯¼Œ$ITŽŠâá×ßâ‡Ùs „þ~¾,ùna! úå�S^ Ãõe†72á«“2¸>»s6�á:%jt�Òd”8cQ9š£bWÁ(ƒQ’4HdyQ?ódžF�@t#D„×:YF'1‹{X�@ ‚+¦Y½ºH’Äþc'ýë šÖ­Å² ›°X­˜ŒF×v¥ º¯k'&ΙÇâ5ëù4Ø•vóÏ× ×éx GWp§2êÛ£ c§Íæã¾çþn�Y±i §“Vñõ‰­äZqܾIc*†‡qììY†ŒMõ˜ŠLžïŠ2|äöø¸ãÀ!žö&£‘]Ó§Ú×¢A$IâÀ‰“Œš:�æuk³|Ófr-LF#MÜ)´zwíÈ„ÙsY´v=Mœ šÆ¼¿W£×éxèÖóþ?Ø£;c~ŸÁ»c'зGWVlÜ‚Ýá UƒzÔ¬ì6oiÒ�JáœHLbðˆ¯ˆ�©ÈOóÿàÑÛ.ô_ (�õzª]…ß�@p­™»ÔI®UÁît „N'N‡ÅáÄép 9œ(N—Hˆ¢ i “$ô:™3{NS­iu4tª+jPqGj*ÈÙ£zþÍ=â kñºS�“G„@x£`Ò_8©(ÓµÊùéå†á:–÷ñgwŠÂ«ÿXøóˆƒbèŽÌňòÚuð�çu€Ÿ/÷víÂ{Ï>íõñm7Âl2±ÿØ1z<3€¨° tiÕ’¾=ºS³JåB¶µÜ ãdY"$0�¤Ô4ì‰))dde£“eº´lé±ïÖ¦S,$áÈîíÚ…5Û¶³tý:4kʶ„}D„†Ò¶q#f-ueúHNMãÖg]uÑNWÊÓcgÎ`³Û=íÞÒ´Éu!¤Û‹ue†76áëþÜCM9�)Ç´Óa�\5)‡î²qGE=!F‰¡»lô©¢§IˆŽ�M¼ºÍFvÂr†"¢@p³!B�@ ‚kLH` !��$ZsÊÚ•›’øš±|ôüÓ|9õwÆÍ8_"&"‚wŸ~‚ A®|F•##ø|ð@Þ;�ɺÄ0_3Ã÷qìÔ®YϨ_\µ*Ô¨ÎÈ!/xlŒ_¿1„�Ÿ}ÁBw­E�,3 ï}ÜqKÛbù_?6–�>Ë¿üZÈÿJáüïé' wOÄDD0òå�¼õõwLž¿ÐãÿÇÏ?íþžïs/‡OŸfÁªµ|1å7Ï9F¹Óªæû?ö­Wxþ“‘,\³Îãÿó}î宎·Ë�@ %'=S#=Ý�CÕp:Š“Üœ¬yi(;ª]Aq:Pœ*šÃ�ÑŒdÍE¨i ŠêªC.ÕOv§†D½»¤�–F¨ihhhZÁ‡K$t(�™^–½!(MBLůg$?PoWŠÂ€åR-*Žâ ^‘/;fMÇltEUúû{2lxK�êÕX5yÓ/eÑê5ì:xˆ_üÅo ñÓ°<Ñ€—Cs‡ÚJ’„T rQ'»þ§)îHÄ;;´ç­ÑcX¾~ÝZµBQUîìØ�,cµÙ\×H£Úqž6š×¯î‡ù˜�çk>–wlEd 7I|ÞØT(õg´Yb~ª‚´×±ä¬ gà�SNå¨ô«f a°+½j\€Ì°†&o½|�K‹³t®I ¸‘¡@ �à¦ãþî�éÕ±GNŸ%=+“ˆ�PªUо †Çm·´¡c³&ìv¯úmP£úõ z=_¾öCKæø™D*Q§Z• &'âkƲ|üv>‚Åj¥VÕ*„_¼¸Jëøz,ÿîk.5¿Ñ§[gzuhDZ3gIÍÈ$"4„j+bÐöÿövmèÔ´ »´Kú?úÕ—xµßÃ?“Hhp u«Uõ¤,ͧAl,+ÆÍîÇɳڨU¥²GŒ�@pm°Ú4l'NEÁ¡ª(ª“­ÿåÈžß.j_¿ÑûDFtER]å44œÈ ÈîBU=Ÿ6Ô ¹Å÷{­@JÑ|aPÓ4TUCUTìQ¦àF¡ZPñ>Ë “Dï¸óéÎ~Øm'1÷ •A@'Aåï} ò÷Ãl2]tŸAïšúÎÈÊòl;rêt!›6r:ù÷víÂ+�÷ãdb"C>ÿ‚U[·ñï–­^ „Qáaúù‘•›Ëê­ÛèÔ¢9+Ý)Dó#ƒèÒ²%‹×¬eô/¿pO×Nžˆ¡!ŒxåeròòX¹qƒ¡X5ˈ">Êt»Æ+ÛlÔð—y½®KøwÈ�Q†³•î1•j×XxÆÉý• lNShêºïÉsj ÙfÃêE©CSù.×(” B �@ Ü”˜ŒFêV/º~ž¯�™–î•»—[�\92‚Ê‘—mË ×ѤÀŠàKa6™ŠlËl4zUÿÏ×ÇL«u‹´óÞÿZE¶%�à*!iØívœªŠSSÐ…ˆ˜èt~Ø,éœ88 �ÎLåØûÑ_ß*8T§Š¬Ièd· (�ŠK$ÔTL¥ÈZÔ¯@ËøD‡‡³iÏ^þ÷Í8Z6lÀ”yòÏæ-<~W/zz!T–G —�ètjp0[ÅX@Hg P/�¿s�«@“‰\'lLS¨xÞXd{'L#u­@p³ B�@ �@ �à:@Ò4ìNUASU4U¡b•DUìBfê—@¨÷%®î »Åát=;(ŠŠÃ Gͯ7¨žO-Šæzvªî(A ‹‚…BUCQ5$UEQ.4ö÷éü±âïËÚŒ{ïmjH.(…ëðÑKXЍã —ᑺç£'ïµã¼òàAÚV,=Á¹GÛ6´oÚ„U[·1qÖ|ÍfzwëÊÔ =6/=ú0{fÅÆM þl¸S…öéÑ�¾=½¯µýFÿ'8—žÎÌ¥Ë6a"~>>¼ÿü³´mÜÈcwk»¶øùø�k±pwçNž¬#Fƒ�?Êÿýo(fÎbÂÌYȲË�� (µ>¹ÖTô)¾(·6EA/A‹PÁF‰Ä,—€˜áÐ8–£l�X—â WE}±ÄäßKû’“—Ç‘S§¨V©�~~Åö»(v8@ÝØXOt«@PÖˆ‘(�@ �@ ”S ÖpZm(hhšŠ¦¨¨Š‚êt=òQ� ª¢¢) šâ²Sìf»+b°€8¨¹ÅAͽ­p½Aµ@ä †¦ºÓ‹ª*Šr>âðRœNNöLŠ_ ‹»öšÀ;JÉ ©ýw,Ä C÷ªzæ.:ðŽê¢ý\âp®CcúþÒ‰¸=ÖP¤MÓºuIü{Y‘vz�Žé_|ήƒINK§I�:Tâ‹×†xlü}}ùõóO9™˜È±3gQU•š•+S©@f�U“¸ í¿œXè½Éhäë·ßàÍþO’pô(f£‘Æujãïë[ÈÎ×læð_ó/êoã:µÙ0u Ûöï#'7�º±Õ‰÷ìõ‰Çxõ‰Çмîââpz‘Ÿó"8[Nê! IDAT½8.®ébó‰õ“Y‘ì$È ‘�¥Ò0XæÏ3Nî‰ÑSÕWFRlZ±ÄÁ F‰ã¥Âÿ˜ÇÇna÷jõs�g\Bïö™Óˆ «P¤ý¹ôtÖïØ‰¿Ÿ�ÝikK‹�»w“”’J|­8ªU¬XªmSÄw� |!Â맪àp*8TµÀ3�@ JŠ,I^§Ã¨Ó{ ¨ �@ ‚òC®ÅRà�†Ãáðwšª¢**ªSAù¯@˜ÿPò*ŠÍéÿ+j îÚƒ…ÅÁÿŠ„Šª"«®ôJá»Ï<ÍëO>Àú�»xü�ÿ!Ë sg{lüÝ‘:V›�Ìœ\"BC.¨åœOrZþ¾¾øšÍ—<§Õf##;§Ð|rZAþþ˜ŒÆ ì�ŠBZf&‚ƒ¯‹{¢ìܼ›“—Gh`à%÷÷­eðJ |¬þù~œqÀA–½tæ)ë„êhXÌT§E!I k�"¿rT•£¢®ø|•"# ‰‹ÅÅ`ÐÓ²Aƒ+ö£8ää•lLåúnº85ýeôÙ^D¦æå#QÝOfSšB·¨W;P¦~�L¶6¥)ô®ì’5¼m·IèåÇÕìeË=¯ç,_yUÂ^�:àcºð{èb$9ÊÓC?¢VµªtþiR©ú2ö·é,Z³–Ï^Ìw—¾@˜�—[êm ®B ¼J,?|�)Û6³;)€‘Ñ<Þ´9�c‹Î]íPríV¬v;‡ÝU$Z§Ã Ó!¹'s�@ ¸TܵC4 ‡âÄ©(ȲŒ�Áˆ�ÁˆŸÉ|]Ü �@ 7:§’’ ½wÚíçkæ „ŠŠ–/j :�¨Š³�8˜ièÝ©EóÅAÏ£ 8¨ºê ºR‹ª(ªŠN•QT—²XT�ˆ�Ù„�Ùît‹ùx^çZ,¼ùåþX¹‡ÃIP@ƒz€�=€$IhšÆç?üÄ„™³=bi\Õ*|>ä%Ú4jHžÕJlÏ;øøÅøpülv;õjÄòéK/òî×ß²ëÀAdYâÿaƒ�™“Ã룾bÁ?«p* ½ž»:wä“_(ä_yãôÆC±ŽM>G•ˈ`­¢õ4‰Ð±-ùÒl�`Ø�â²™¼×^bþËs�¼N¥Ëɤ¤g³ÛIÉÈ$,8è’6²í#t,<ã}äë9›Æ©<�ª~2§ò4’¬ªžv`�H²j(芑½´cÄ¥½‡��pä(!��ØŸ<É®ƒ‡ˆ�+<�*)‰Õ[·‘‘�CLT$Ý[·*´èÀát²|ÃFŽ�>C�¿?­Æ뮃 pW§àŽ6ÍçȩӬ߱“ì¼<ªUŒ¦K«–ôz²rs9rê4¸=ìÿ½>çqTöð`Ã7_Qû2É©i<3q2—sýýTÈå|8jc† `Äô™p¯Ä¨•+6næÃßæ)ÇY«j6,˜Ïé‹—xoþ7ÜŒŽ¡ûȱDïÙIÄíÛô?™Ä”Ë6r¹Œ¹oMáùÞ½ˆ‰O`Äô™ØZ[ÓÈÏ—#ag-í¦¼4œw_{…iß,äð™0þ¶šŒL-³&�/öy¼¥ùœ.14 Œí¾v%_8˜Û�ÇBÙqÕò»¤fj¸™GrfVÖø¸yàåä‚“�-j ‚ � VJζvx;¹RÃ͵šøŒ4n%Æ“ž¥­è. ‚ ‚ ÂR|r WnÞ2ÿ"Iuúý0êô˜ôÆ{M²ÃAƒ9(´üÁhÊ3‚0_8˜{¡)×Â\£ï�$4Íÿ/�˜ø6ïÝ�“½=«ç~Á#ß`ÉGæpîÛßW#I™™ôíÔ‘…ïOeÞ;SXñé är±‰‰Ä'%çÙßÊÏfò󬙼ÿÆkx¹¹¼l)~5‡¶Ù!Æ¥æ ×ÇÎ…0¤gþ7f«¿œÍ‹}{ç™óíQtèÌ™Þ64WXP˜Æî †×{¸#ù¬0³��¸ZABN‡=ð¶‡Ïÿžjà$§±sÑ1Ä]­Ä'ç²òý|žÅ�L‰qÆ×Ï¿TüÖçª+Qrx“Ib}v@Ø·SKÐ�{öšGjgûdñ÷èõfMÏŽ¥‹Ú«'ÑqqÌY¾€•›6s9ò�Z¶`Ó·_óãŒ�Q©TÌü~©e´^nF“‰K¾Ç`4²pÚT¶}·ˆ>Úq;Š…¿­¦M“ÆÌ~sÕ+{±cébd2óV®"1%…¡½z²aÁ|Þ~å%L&‰Åä½YB£ÕbokË’�¦ñ\Ïî|·f-’$1kÒ›4`ÂóC?lH±ÏáýÐhµ„]¹R¦ûÊ�AXÆV�:^l›•§ŽÓ©V´zq驸Û9ˆ‚ Â#C!—ãlc‡“�éÚL2ÒHÍÔànïˆZ)NA„Ç�I’Ðd—¦S©TX©Ä(A[…àW£º9´Ó1šŒ–£÷‚<£!gd‹QŸ s�")g®Á{�འÐ�=aög¦Iºsƒrä& d&@†ÉXºHçêÍ›æ‹ð2“fÏ1÷={4M|R21ññ¼ôLšÕ«ËÖY¶~#ÇΞÔ=¼íßeÝš>8Ú›ç5¬]½êìÏ{ç첡¦ì‰ûùråÆM>úv1_ýü �Z¶ {Û6<Ý>¨T�©<¥fdR‚@¦0ÁG�£ÓP«ŠþN7µ¥5§c�„ÅSC¶Œ|hƒŸ‹ÇRÎG\çfLÌo¿õPo¿<¼Øvc|ÕŒ=®E*›é*K¬’µŒ~U ?ç;zö,Qwcqqt$( ƒ;¢îÆr,<œV b2Iħ˜oF»|•º5kòѨ ìÚ[[Èžç”ì2¤‘·ïÐ¥u+þš?—ŒÌ̧ K×hÐhÍ7bŸºp oofMžÀKýúâêä„£��¥l¨µ••¥¼èÐ^=éÔŽ €fhu:Ë�± ‰yö¯T(øaúGØZ[ÓµMÖlÛ�F«%1%•ZU«àdo@•J•ʼ¼è®Ð£èô%/)+T,ñÉ[ÆÂKP³9ün iD§$á`mCUw ‚ �$à`mCuw¬Uj¢’HÉ“M—–$™K3M¦j6ìÙ‡6+ë�·OLMeÛ¡�bÛ©°´»->Žåéx\S+ù‰›v*Êêm;JµýéK—¹p=²ØvþrúWy¸7ËdðV]5V…Wå¯]»¨]­*[dçáPK0·a÷^È.á9æ¹ÁÈå2VoÝÆÀÉoÑæ…ùퟭ8fmÞ³WoÞbâì/iüì`¦}³�˜¸xìló×ÉÞž—ž1Ï�úÓú ô?‰¯¼ÎÚ;qut,´¿õjÕ$øè1:¾òÍ åó—ØÎÆÚ [ksÞ`k““;˜¤Ò�ú.‰ÕÛK÷ž.1 ŒiôÅkÎÐéÈÔé¨êâŽJQÄ'” ‚ <"d2®vöØYYq75™L½Ogä2QæA$§¥G|–†Êžî8eßÍ,‚ ‚ ¹|ã&Û‡Ò³m 5«Ûpù’ƒ>+{œ¹MF ÉhÊ™sÐ`.5*™$” .•½r‚Á—5AF:(•òœ²¢¹G�Èå²ìùd88•îqÕð® Ù¥@×Ìû²Kþ�»j.OWÙßÖmÄd’X:ýúthOrjÿûvI©Ž›©ÍâXx8õkÕâµýIHNaÓž½|°ð[Nœ¿@ä�;–�àQa4™XôÇŸ¥ÞÏÂÕkx¦S‡bÛ¹YËø­·¯o×p!±|FŽllÅä«rÙ·P¼ÔŒ ~ùgk©÷³ð÷?XfN‹B.gÚˆ×y±ooþÞw€m‡B8~.œ Á{8{å*‡V-§a�Úœ\ó;»B�²ýÐ!v>™K—™8ûK¼== h–ïø_N™Ì¨ÁƒØrà [âô¥‹¬Ù¾“ëQwØüí7öyÌÌYxººÒõ�Q¥~®ÊÊ¥Èì =RÑÝîƒAXAª8»ŠpPAxìX)UTuqG’àNrb©ç„ñõ/øâ|ò}ÁwK ‚ ‚ <‰f.]†Þ``ò¤:x¸Y£@ŽIo,`NB=F½Áòƒ*¥/Oj4¨�Qk˜eþ1éL˜ô÷~Œè´&·ôA’Lÿ �–Q„÷FŒ&ü”näWÝš>T­T‰K‘‘Ì_õ+g/_áãÅKè>r,/ú…\ν{·8Dð‘£LúbN©ŸK…B΋ïÈ€Éo±lÝ"nßF&—›çfW«ñöp/õ1ÊÚª¿·põÞ\”¥pòÂE6ï;P¢¶62~ïmKßÚe;ÂÏ^%ãëÎ6¼ÓB„ƒiΊUÎ�w¿ÖîÜÍ٫׊m§’Ãì&VTµ-ÿ�;WRðF­¢ß·»�%95 gGÆ??4Ï��µqII:ušk·nñμùü±mc† fã‚ùl^dðnÅÄ`2IÌ]ñ3.\DÝš>,x*a­¡EƒúDÞ‰Îwì³—¯ðμùl aÂóCÙ²d!?ÏúÔÜ>êNž¶÷Ê.K’Ä‘³æÃÓÇŽ¦K«–\*ÁèÍ¢˜Leþòý�ìl¡`baùwÉAAx\Èe2¼œ\ˆKKáNr"Þή(äâž#áá©áíE`ãFÔ©Vµ¢»R¡®ÝŽ¢çø)Ìž8–g»t¬èî‚ ‚PÎ.^�dÑê?™<|Ë–7'ôp·n¤ƒÌ„ ññÇNÑë$Ì}I`™¤ÀÞÉ çJ•@/Ã�e²¬Æ<•¡y¢0�£{5 “”3zÐd4b’1Ê ÈdæË‰¶v :t³)ÕcR*,ùh/Nû�/–-ç‹eËhP»6‹?šÀ«úqêâEÖîÜÅÚ�»xªMk\IJM娹stiÝê¾�«V©˜5i<ï½�|kY®R)™5i<ÖV�VpŸÀg?üTfû›¶p1íšZæe,Š�JÆWmèQCÉgG²ˆÎ(ÝM¢=}T|ÐÚ /;ñ²"�¹t™¥­/“}M&Þš÷5ÿ,œ�JYtÜଖ1¿™5SÏd‘^>7wõRòn=5Å]~ÿk§¹¼hŸíùpäyÖEÞŽâïýX¿;˜�G�bcð^R328uñÞîœ8€Ž-š#—ˈMLdÕæØ{ì]Zµ$61‘3—.£V©hÛ´I¾c»89òçŽ]dét?wGGBÃÌá_çÖ-°·5¾ÞŒŽáãEK˜>v4Õ½*síÖ-^ùðøùÔ`û¡Ãè�†ûš¾Ä.{ß¿oÙFOOz´k[âm ³qï>¶‡„–z?ÂÃ%BAAî› ðtp"6-…è”$¼�]E¹Qá¡Ð¥#ºt,rN™ÿ‚�¡G+º ‚ ‚ ýÄò<§‡�ŠÎÕT¬½¢ã— :®$•<ÜQ+à©ê*F4RÓÈ]TT«hÉii¼>ýS Ʋ=vâüf.]ÆŒ±Å—»t³’1¿™³/è8_v}PÈàÕZ*†ÕPû]Ñ`4’¥ÓÑ¡yCzöÈ·þùÞ½HÍÈ ]“‰“ƒ=¿}ù9Ÿ.ý‘ýÇO 7°R«é×¥ŸMÀ'cÇ P(X¿;˜•›6#“Éð«QƒF¾n)WÜ¡yVj^Žnüv4óÞ~“±Ÿ}Î…ˆëM&~š9�×®Ã`4rúâ%*¹¹Ñ¡y6Ö9óÊ�YŽ­Îp‡÷éÍî#GÍ%RO�)u@x=*Š7çÌ/Õ>„Š!“î'ZŠÕàë/KÔ.|ò»åÞAA(oÑ)I(dr*9:Wtwª$MÓn�*QÛ%þAy·MM-r¢¿Ì³¶©ÙýW¯×óúMl %>)7''úwéÄ«ý{[ÂâÙ?­âä…‹ŒØ�4�†Öm"C“I»¦�øpäkØÙXóá·ßqùÆ-<Õ‘ç{u·ìÿä…K|¾ìg¬­Ôü4ýT*oÍý†›1w-m:6`°Ayúuðt_ÿ²¿Õy÷•á|¼äNœ¿„L&cÜ�gÚ³+2 %=ƒE«×|ì$iš œìíyªu & „­�ùnÊÙËWq<ü<#ö'C«eéÚ ¤gjhÛ¤ÓG¿Ž�� ‹ÿXÇî£ÇxãÙ~Ìúa%þ5k0íõ—xsÎ7¤i4|1i,-Ô )%�…«ÿd߉Ӥi2¨îU‰×ú÷¡wP[Ëó;òÓÙÄ'¥0kÂh¶…á¯]{�ÉeôëØž)Ç"—ÉHLIãõ³¸u7–”ôtªxzà’ý?ôYžjÙ"çU“AJZ:/ü Þ�Lu¯Ü ÕKÀVÄ{¸øÍÊé±¹qiž‰ÒlWÄc}HŸ÷·¶ˆíŠÝ°ä¯«ÉdB§× P(нÃ]„GG|z*)™ä25Ý+•`‹‡Ç­cׇ~LWGGþ^8Ÿ®7%6ý­çС,Œ9¡ ä ó†Ù!¡$�g%ˆºv�Ô”,T*5J••REm;^çAm¿Gk”Ý“Èd’÷ù¬Ù±«\öÿÆÀþ|1iüm{!ÑÈÁ(#'︖b"V#‘¡—PÈÁI-£šƒœº® Z{)èPU‰“Õýö¾Gƒ6+‹Ao¿ÇáìÑjeíËÉx}@¿µ•€mÑ–^Õ“¢/]Dáç çM5þŽå;2U’$RÓÓó}‡ÏM£Õ¢T(P«JVš×d’,ßI ÛŸZ¥B™kº²Lm6Ö¥û –$‰tM&v¶¥ÚO\R½ÆMæzTT©öSœ„}åó9ø_'¾ ‚ ‚ðÀdÈðrtæVR©™mJwbù_akmM%77dYÖØÞœ»½Þ@ÿÎæ¡‘Q1DFGóÅŠ_¸}7–Ênœ¾t…ïÿÚ@#ßZôhÓ…BFµJ•¸u7W''ªfßéno�¿Ì—Áhâܵ´:]™¿áq!—˹’q‚ "15•g&Maõ³hV×?ßz…BÆ€~jôSs#ÒÈ…‹âMd¤›ò„„÷æ ³ùúûâêjD¥Ôãì,§f[|j[ÿû�B9ÈÒëÿù—¬Û½§ÜŽñ㺠 ¾|sâ}OQÏUA=W4R—[ÿ„²•œ–Æ ïDèÙsåvŒ©ß|KfVã‡>Wl[Ы²’Ž Öß6°1Ê@|Öý…þŽr†VWÑÁCQªáJJ&“’ý}ÿ~Èå²BÃÁÂöWÚp�ìÇRÚp0òN4ƒßy¯ÜÃA¡üˆ€PA„R‘ËäTrp&:%µ*…(S+µµ£öñ<;r6œƒ§ÎàâèÀ¿‡�•/õéI÷Ñ“ùõŸm¼> nNN(äæ÷ÂùˆH6/˜ƒ«“#_®ø••›þáØÙ 0úvbÞÏ¿qåæm®ÝŠ¢vµ*ŒFv6—ÏÔµ“å¸ï¾:€ÙË~æç¿óOôàåî@Ff&O‡±èý·èܲyžùŸw=ÎÙ«ØÙذæ‹ØÛÚÏS#'rä\8g._¥©¿/ ŽþGðÏÂy¸991gå/,Ûð7GÏ�‡ìysúth‡£�¡açð«V…^‰?¶ï"*Öâý±#˜Ñ1ÔõcáÔ)ôîЖ®£'1ÿ×5ôëÔ™Lf¹0“š¡áŸsQ«T¼ô¿™„†�ãèÙóôhÓ'{{¼;™o½OJz:/ôê^ä„6VjF 4ßÅëêèøÀ¯» ‚ �ŽøäúLœÂ¬ cy¹oïBÛÕðQPÃGœŸ?n"ïDóÆ'Ÿrêâ¥r?ÖŠMsåæ-–~4Ír.-݃.­ZäksñúMûÖ±|a­ìîNu//"££¹zóMý}súߢ9nÙsÕÔ«écîrÞ9`ªx¸£T˜O­+¹»¡V©°·µ!+{ÄÞ±pós–¥Ó3÷çß,ÛY©ÕDÇÇŸœ‚‡KN™Þg:YJÒÔ¯åChØ9âSò³¤ØÚXóÎËÏ—¬± ‚ � mVSæÎg}ð^>?†µkUt—„RÒhµ|¿vóVýJ¦6ë¡÷Ðé3¾ôï½ö2¯ôë‹U K# �¾„”æ®ü…Ÿ6l*Ó9‹óçÎÝ:Æô1#йs‰æº”Ë ¹«‚æ® ÀüÔ%2� ”��B†Zä�îfL Ÿ|÷öì«è®e@„‚ ‚ ” W;n&Æ¡Ñea«%Üžd-Iiél9bY.“AÕJž¤¦gäiïâ˜S‚åÞ¤è¹Ã­]:˜Âs@¸õ@Ÿê˜gäßý lÚ°ÀåqÉÉ8Øæ-Çy/\ŒMJγÜÙ1§Ü˽r§yÓ9'´Yæ‹8÷æ5Ëý%8#3€[wï²õPÎóãálRò„®¹Ÿ³{hþ•þ»‚ ‚ ü78yŠŽ¯�¢G`^ìÓ‹.­Z¡V‰K~�“«7o±fç.Vnú›øä” éCjFÓ.fáïkxµ__wŠê^^Ò¡ôN]¼ÄÛwòë–mh´Ú éÃ�¸8FΘÅÜ•¿ðZ¿gХ󔄭B†­]áŒ&!§ÏðÛÖí¬Þ‹Þ`¨è. eDœ-‚ ‚P&ä2.¶v$iÒE@ø„«âé€o�j¬þb&ÅExÅe|íšáêäȵ[Q\¾q‹ÝGŽ£�Ëé_DÙÌ’ps*øË§·‡¹tRRZÞR( ©æz.ÿšSBVì#,^UOŽ=Ûò騴¸ÿcH%B(‚ ÂO’$¶…f[Èal­­[sB IDATiݨ! ëÔ¢š—®ŽŽ%½#<<šL-w¹|ó&GΞ#òNÁåó+Bt|<³–-gÖ²åøV¯F«† ¨S½ž..e2ï™P>Ò5™Ü‰‹ãâõH‡�ånBbEwÉâò�›¼·à[¦}»ˆ†uêм^]jW­Š›³Vj1ZõQ•œ–έ˜»\ŒŒäð™³$§‰R¢O" ‚ ‚Pf­mIÒd�©×a£“Õ?©šøûpáÚu.DDR¿–zƒ�Ù?ýŒR©bÄÀ¾¸;—üÎP•RAßA¬Ü¼…¯VýNj††ŽÍ›QÉÕµTý,lôaS?sùÐ3—¯OewwN]¼LL|r™¬Ð‘‡¥Ñ´®ë‚÷±ûè1Þ>Gâ’’™óóoTruaÒóC,ó–”möš;Ùó&5#ƒ _Ì`Ö¸QT©äQŠG"‚ £N£Õ²çØqö;^Ñ]sWnÞâÊÍ[Ý á a2I„]¾BØå+ÝA²‰€PA„2#“Ép´¶!M›)Â'X#ßÚ<Ûµ3íÚÃ+Í ©¿·ïOÏ Àû ïéߥ+7oaÿ‰Ó ìšwôàìe?|ì)éæ;w†åäù‹¼ùâ0zµkS¢cµmÒˆîmZ±#ô(¦¼�_�j„]‰à•gzãS¹ò}÷¿8»tdãžýœ¸p‰Þߦ^MÎ^½FrZ:o½8ì¾ÃA€vMqâÂ%–¬]ÏÞ§ÑfeQ§ZU¾ygržvz½‘CgÎ�‘U1å…AAAAx´ˆ€PA„2eomCTR&{G䥘?Nx´MóÍë×eבcÄ'&S¿¶_xŽ>ÚYÚ4ñ­�Éd·FuË2/7z´m“g¾=Ÿê<ß«;‰)©(U :µhžg}/OÖ®Uh\³ç ”Édôlk íÿ5Çà=2™Œ¯ÞžÈÚ�{Ø{â$ñ)©thÂÓA�–mûÕÁ`0âïSò¬²›+½Ú¶±Ì�Q¯f zµmƒ“½¶VjzµmƒöãíÚª%v6æ>¨”JV|ò¿nÙÉ¡3a¤k4´oÖ„�;Ô¬±eÿAMð«Qo÷œQ~~Õ«Ñ«m Mý}ó<Ž1ƒâhoÏá3çÈÔeQÅÃÀÆùG?ªUJžnh~Nll }AAAAøï�Ibò’2Õàë/KÔ.|ò»åÞAA¨(·’âqµµÇÎʺ¢»Rn’4L»uªDm—ø¸Ô«ËÔW_â©V-E€óÐé ¬Þ¾ƒ¹+WUØ(°î�­yçå ¨W·BŽ/”­´ Ë6lbáï�œ–öP��°o×C=Þ…Aø„¸v7†kwcˆKI!#+ 7<�hRÃ'Ûœ ³É„ÞdÄ I%“ )»ܽy¢ä2æÿ@)—£�ÉQÊd¨ä ”2qÇ� Âýº•œÌšs§ÉÔë+º+•F—…R¡@­xðÓ�t]V©ûqoDá¾ëטѭ=| »¸-T´à£ÇIHN`ÀSQæzÁ…ˆœ %VjU==©_«6ÖÖ–å›ö@›•ówÍÝÙ‘j^^øV¯jYÏ�“a–ßrî..øûTÇËÍÕ²<ìòU.FÞÈÛÉìë$5½½iÕ°žeñíØXöŸ8MLb"޶v4ñ«CË9ëÃ#®~5¢ÐÇ> sGÔªû+×tîZ'Î_$.9g'Ú5mL�jU³cûOž.2!iÓ¨>>•½-¿=AlrÏvé„Zi~þ³t:ÖïÝ_d_üªW# ®?›öD“¥-°M5OO‚š6Fo0²6xO¡ûêÐ o7‚��ànbþ>Õ ð÷àBä N_º‚ƒ�}‚8{-‚sW#put g`ë"ûY””´tþ À·ZUZÖ7_ÄØz”ø”T쬭è×±½¥ý�è»ì>¢2µZÞþjÚ,s(\ÕË“ÀÆ�,ë÷;Éâ5åÛÎÁΆ™cGÑ£­9š³â7â“ó—ÑiâïË¢÷ßÂÍɉK‘·øß’òµQÈåôëÔžOÆŒ@¥T²#ô?®ß\`uíl ¿ù} K×nÄ`4æiÓ½M+æ¿3 ¥BÁÞc'YðûŸ…>þž�mJê F¦|µ€mÙaÖ=2™ŒñC1qè ®Ü¼Å‡‹—¹Ÿ9“ÆYB­NÇ䯾A“i÷¼ÝÝéÐ�VË‹ŠÞ×+}zPן9«~ãN\Áç¥=[›B½ž}_è¾~úßûx{¸ñãÆ8v–מém ÷Ÿ8Íì•¿P»jK@¸ûè ¾ù} kÕ*U@“”Äû‹¾ ™¿æÌBo00yþ22µx{xXÂ5»ö0mñwè 9¯ùçËWñí»“éÞº«¶lg{èÑ<ǰ·±aâÐAŒØ�6‹÷¾ý®Ð>½õÂ�G" ülôÒ4™TΠ—‡z>5¸°Ú¶1–¤‡|W² ÂãfÒóCù`ÄkeZþñÞùwîóð{#ÉĈ²‡C©P0~ès5kÊ Ó>"&>¡\�çã]™ßg†_�êåz¡âØZ[óé¸15kÊȳÈÈÌ,×ãµlPŸŸ?�ާëý�;ÞI2ñ×±,vžÓs<ҀΘý¹#Ã|§föŸ­Õ2ZÕRÒµ¾’gTx:ˆÏ¦‡ÍÝÅ™eÓ?¢m“Ƽ¿`Ñ#?ï¥P8>†L’Äò½»Y¼‹tmÁwhßc4™8~í*ǯ]¥Î~o&ôíK½jÕJul­Ñ€Öh@FV %6 Vb2YA„B¥2šFxø2 ÿ­Qœ�“}'O£ÍÊ¢‰¿/g.]a{È‘<á=ÞîŒ4€ŒÌLþ9pˆðk×™úÍbÚ4jˆ“ƒ�¥Ý3ƒ¨W—Ûwïòû¶]œ¹t…/—ÿ“ÇåÙß'£ßÀ`2r>"’u»÷².xMýýx®ûStoÓšê^^¤dd0ïçßóÜ@*»»YîÒ�ŒŽfñë�ËdÌ›2�– ëqöJo}µ�¡GÙr0„gr�:órwcÜsó=.k«?W;C�°-$g{M}‹êÞ^l=x˜Y?ýÌÂÕòt»6øV¯Æ§cG‚Ì7¨Lö%I&“ £ÑHVVZ­½^�ÉdB.—£R©°²²ÂÚÚ…B�\.aáCÐÔß�­‹¾¡ß¤·¹S>súûÔ`Ãü9÷ä�§žmÙ0Ͼ5•ÔRNåQ˜ŽÍøeÖ lsUy)Î…(_mѰédFé^M|s(�¹Î�LÊÏ"C«“ØÉÀþËflÎâÙovSSÛC +|Ø^Ð�Jnn¼>}f¾f…ǃ3YY¼÷ëÏì»nYV·JU.݉B’$llx6°-+‚Íswx:9£ÕéHÍÔp5úoÿ´Œ }Ÿ¡g@@©û"�%,TÊäØ)ÕØ”¢Œœ “jF×^,;~í,œÒ ÈeòRÝÑ|)>6ßhöÒx¡ió2Û—P¶v>À¨gû1yîv…㣑ùïˆwurdp÷. èÒ‘¶/�$K§#<âm›4¶´k^¿Ïe·s°·cþªÕœ¼p9ßqwïb©ˆ—@È™0Î\¹ÊsÝŸ¢±_ûÕ!&!ÑölM½š9�FäíhÔj5Ý[¡V©¨ÔÚ•/&�%5=ßêyïÄvq°gH÷§ xJ~‘-"êÕ¼¼hÕ°>¯>ÓµJ…�µ6VVTvwcH÷§�É :>ÑöhÛ’µò�HÛvø£ŸíÇäy Øuô8£¥B�•ZÍÐî9ó+}ýÛÒ5Z6¨—gyn-êù3¬�u¹uiÑ,_\Ñ×ëÖ¨Nè¹p‡�%ìZŽv¶x¹º’œn¾ˆ²aß~tz=îÎÎ,x{*¥‚Íš0üuR34l 9Âó=r{í*U=°?£ögÒ¼oX¿w? Öü•/ ìÖª%­?7ËGßÿÈ͘»Ì™0†aÝ, \³{S¿ý]®²e*¥’¯ßœHÿŽ9s¦Žùb®e€÷_Žïàœr½kvíaÙæ-tmÙœàã'‘ËåŒü, ׬Åh2qñÆM>ñN…ñþ’ïi^×[kkœ>Ù#$–¾÷6½ÛÜWÿ÷Ÿ>èÙóHJKÃJ­&K§ÃÅÁ�Ÿ?þ€–b~Aþ¦¼øB™„ƒ’$¡×ëIKK#!!�3gÎÂÕ«W‰��Çd2¡R©ðôôÄÏÏ�öíÛS§N\]]qttD¥R!/ÃÑ‹B~Õ½¼X;w6=ÇN$15µL÷ííáÁÚ¹³E8øP¯.¿ÌšÉ ·ßE§7”é¾›úû±ê³OJfé%>[ŸÎÒÝ ¦ì‘‚2™ùª³ �dÙcå ïE‡£ÄÇõ¬?e`üSjÞî¦F%Ʊœˆ˜2²´ü4i2[Žã×½{Ñ |µ~™YY |°Ð1H&RôZ2 rTVbD¡ B.�ÕkX½bGÄT„˜”$l­¬p´¶-Aë‚uûé;âËàÎF_wÆ´nKçZuJ½/¡ìeétì;q kkÚ5mDËúþ:}–“.Ò²AýB·3™r‚ SQÕh³WUöDo0r7Ñ\ øÞ¦’¨[˵J…6+‹ç§Mgp·.tiÑœžm .ui4I¤¤ç}O[©”X[•üÛ&~æ‘g¯\eäg_пSÚ7m ½º—x¹éôz‚�žÀZ­&¨YÚ4ªÏÞã§8rî<íšäÅYZ�Ž”ôô<ËìmmË´Zy©ëS�“—.vް«4õó%*.βþܵë´iX¥Â|ÎkocC¿:9wž°+Wó„¹=ߣë÷î'*6ލØ8ìml,ë2´™$ÿë9»Ÿ÷bII’ÄŒW`0Ø<÷sթùk×öÑ –¬ÛÀ3ÚYBóæ ÀßýÈÉ‹—òíK¥2�¬VÉ“ÙãFóî·Køûà!ޝø‘f/½Æásæ›­Ôæò¹'/]fÌÀþ|ûÖ$fü´’¿öìãó•¿Ò³Më�DINOgäì¹$§¥³ø�7Ø©#ûOŸáùÿÍ`ôóýñ;TJñ]D„'W—V-xÿµWJµI’0 ¤¤¤pþüy.\ÈîÝ»IJJ*r»/¿üooozöìÉ«¯¾J�:upqqA¥R‰…å¨vµª,þ`*ÃÞû°Ì¦`P*üøñx{xä[wðàA:@ëÖ­éÔ©S™³({÷îåÈó kíÚµ#((¨Øm„×®ic>5‚¾]Rfûtvp`ùŒÿa—ëü¶(‘±^^”LømCv0˜ZÀ†„ ¡3ÂW;tì½ddù+Öx;‰Ï¤‡iX¯œ¸p‘å ž¢Cxt‰€ð1!Iÿ¹Úvi؈ÙÏ¿ˆJ¡´„ƒ^..<Ó¦ r¹œ7ºwgÚÏ?“¡Õ²÷l/têL __>Xù3©™–nÛJuwš×ñ-æÈ÷Ç ™HÒeb%W⨲BQÎ'ˆQwï²n×nËï6VÖ¼1(Ù®ÇÁáÓg8n¾ˆâêèÄ }{Wt—Šu92’í‡B,¿»;»0¬w¯"·Yºf-Yz]¡ëU %5«V!°iSíó–‰:vö¡aa–ß_Ð{ÛAxòÕvuã•æ­xÚ¿ž˜7÷vàÔ4™Z:6o†J¥"(  ‡NŸeû¡#ù”ô ¶…„’ž®aýž}H’„“½�eŽº{¯]cËAknFǰ|ã?tn™¿‚›s¿Áh4ráú ¢bãpr°chÏnùÚÆËÍýF‰�Ù¾Y& Ìâ?×±çØIö;‰J© ¨Y& D£ÊRåà©0Ò5Ú7k‚µZM‡€fì=~Š­‡B8 œ½âf¯ø%ϲ ó>ÏW2sÙ¦øû@HžeS_y§rÆJJ­RàïÇùë‘\Œ¼ÁÄ!ƒøsw°e}Ršùî}·\eGÜ�œHNK§0U<Ü-ŽKNɾ4ýÓ|í¯oXc !ïÙúõ\$É„­UÉËÒæf4™HÏÔ`’$öž<�“½=Íüý¸¸æ—|mï½^¶ز쑯M|ëPÙÍ €–õëQÉÕ[[KH|Z¥bêða¨U*ÞúíÙǕ۷‰¸Mí*Þãß¶9JrZ:µ«x3°S:4mBP“Fì=yš£çÏÓ®€òÄ‚ O{[[L}¹üÁÏk%IB£ÑpõêUæÌ™Ã_ý…¶˜ékroŲeËX½z5Çgܸqøøø`gg'F–£nmZó|¯üºe[™ìoìsƒhݨa�ëvíÚ…Á` S§NT©R¥LŽWœ*UªÐ¼ysöîÝË®]»D@øŒ4�-C8”]Ù¡´fŒEu/¯´„ð[zÏK 6Å”SNô_�àƒ„„Èàä #½jX3Ò_Oñ™ô0}:n4ÁG�q#º|J" åC„�‰a§ÙrêêÕgîðW0 y•ȸXz4·Ü•PÇ—JÎÎÜMN&øLÛâ_¥*_¼ú*S–ý@f–޹ëÖ³|ò›XgßÑ[–²LⳌ8©¬°.Dz£«6ýÍü•«ò,kß<ÿš>åvÌò²÷Øq¾Î~,¾>5‹€pÉê?ùíï,¿+ ºµmƒ{®¹iþíËeËI+Áh {[[Þ~íeÆ bYvàÄI¾øñ'Ëï»u¡ Áàãe{ˆùnaÿšÕ¹}7ïÊì =Æ´¯äy oÅÜeÊœo,¿»:90oÊDlmòkv³fGN¨Ó¶Ic¦¼8´Ðc4ò­ÍWoM¤Z¥üsååé @Ú5mÌþ§>v’=ÇOræÒÆ|6—OƼÁÐ\£Éì²GšåV»Jåû:Àø!ƒxö©Î;Ážã'8x*Œ=ÇNrðÔVÍü˜æõüK¼¯­‡C¨_Ó‡[wc©™ýüïWêý £Ng4"!a%ær- >~ôz=ûŽŸ`éÚ�,]»Ñ².6)‰S.Ó¼~NØåíáΨAæPÀËÝ�V êb]ÀHªg:Ѽ~=Ô*~5jP¿VÁ7m_<Ÿm!¡Ìÿån܉¶„÷ËÉÞŽ¾ƒèÛ1ˆ”ô >^ü[CBY¹iKž€°º—'+>ùà�Žño•ÝÝx¡Ww^èÕ�¨Ø8ÆÎžÇùˆëü²e{‰B½ÁÀî#Çø~ÝF¾_—óüÇ'§püüEZ7,¼Ìka^êÝ“—z÷,¶ÝÌ1#hì›70uw6�̳Ï}µºœPM“•€‹ƒÃ}÷é~´oÖ˜/ù '{Ôª™g�Wvð—�gytB<•Ý ŸÇçòÍÛ�}®^­’'R®²·ÿ{ýµÍAXÞy8O·mÃÞ§Øwê Ç/\äç¶³ëè ‚}]`˜[¥2'D.ªÄ\î2¿&“Ér1J~“í²ß>•½˜öÊð|ówÖ«QxIoƒÑHjv‰_kµ:ß�‚ �2G;;^Ðï�·7™L$''³bÅ >üðC2³C&¥­Šº/7¢î«�QXåÿ<¶­d‡m%;Üxè IDATyà;¬>™±¿?EÄúËHF—.]âÅ_ä믿æé§ŸÆÕÕUŒ$,'îÎN¼Ü·7‹×¬-Õ~F>;°Äe …'[cß:tkÓŠ‡�” uáÞ|aX‰Ê gh%†Í‹#.Ù˜s WLH¨VÊhUG‰£�œÄ ‰#$©ø�0:^Y‘ÉÖ ¶¨Åe–‡¦_§ŽÌ^¾’«7oUtW„=Áág¹ož÷$1=�.3>Êׯ$I Ÿ;·Àí¥"Öýrˆ!íÛ—ëÉ[¦Ñ€AÊÄEe]¦kO„ŸçÆ�;ù–oØüX„m7f Ïàîâ\ÑÝ)֮áùæX¿sw‰B_Ÿôl×Îò»V§ãÈ™0Â._¶,›·b%/öë+N^…'Ú‰¨[Lþg#&Ibv�Þ´÷©UÑ]zìv¤gIdê%Üt¼ÓÛ–?Žd±â ŽL}á!aØ-ŸnÉbÆ3v¨pÿärßîÅôï–VtW„ác øÜÙrÛwªFù›7hìS­NOÄÝ®Çǃ\†£�-•œœ©ê䌭Z]ªãèMFu™¸¨mÊl^¿vì²üÙÖÚMvÍüë·£8}ñMë–¼´V–NGTl,.ޏük>™â$¦¤�’–Ž·§V÷ù<%§¦b0qwq¡së–tnݲØm4Z-wã��pwvÉ7O_abÉÐd>P? RØóì\8Q±±Tñ,¾<[ƒ:µùpÌÈ<Ë$IbÜÌYüµc'©é;{ŽN­Šnáqµ.ü,Ù#d>ܹ•߆ §Š£SEwë‘`­,úTÅ×݃Q­éRÛ·ÌGª—§”´4¢bãˆÏÒPÙÓ§r õ(Ûžýe´[`+>õšeùŽÃG™üå×ì=ÊÔ×^,×>¨” Þ}ùÆÌšÃ†½ûÜ­ õüÙz”ƒ§Î�™+ä[²æ/œ¨çOÿÎhÙ .«þÞÊ;v“œ–FS’3Òøs‡¹ÊA·ýÛŸÀG‹Èׇþ�:æ B #“ÉhìëËÁSa|ºl%áשéíÍ�¸8Öì4—ÁìتØýܳ-$ÔÒÏOÇåü›¼ëÈ1FÏšÃöÃGøhÄ«÷bo>¥țù–6nd�í Ò¡YVþ½•гçxó«˜$‰ÝGÍ%I{µ Ì×>*>ž÷}ŸgY‹zuy¶ËýWËPÈåônWð…Ž~ƒXðÇZbüþÿèДíGŽ!I¾Õ«Ò¹yó<í�_¸Èø/çsôübP)•¼÷Rþ÷ôwë6°nOޛļ=ܘ8dpže“ç/àfÌ]æLðî÷_úëÂõHºM| /WWæLKw7nÅÆ“®ÉD©PP+{®¡ý§Î�’]>6)€‹7n°ù yÎÈ ˜ŸR’$Þ]ô/÷êɼßÿ y]ÿËšÖõ©ÁíØ8~Üôé™™(• z· ¤S@3ªxxǤù yã™>\¾y‹¿ÿ€ƒßK�ò �A*BÿÎVýI’$´Z-‡æí·ß¶„ƒ¾ÃüŸ½ó�¢zÛð=[Ò{Bz¥$B'ôÞ”^D@l ‚bG¬(¢ XA°‹ E@‘Ò{‡„ ¡¤÷^7[æûc7K6ÙTýù�Î}] ;3çÌ™™l&³çyßç¥ÍK�d:]ua°.ÃAûæNô_3œS¯$+"�üü|fϞ͚5kèСÖÖÖ’Hø? ©¯aÍ›w£Qý»·k‹‡kÍnÿ=ví‚�� E%%�ê?ªOïjÖùæ8£bíáB½€W-óïŽHÐDδ�¶8Ùʘ¶,Ÿü‘¯÷–àh##§XGT²–7×óô@+T8pUÍíl]�"áGÔL쬤µ·”ÙüO1f@_I ¼‡�Â{€1zïíV~þtoy'"9¯¨ˆ�'ŽÐ/¬ ͼ«×íØyî ©9¹x893¢‹édÑÊ}{Ñêtœ‹½NÛÀ ¬,”´ò󣩇'×ÒR8›#"(Öªñsv¥³Ÿ]¼½ rriT¦ƒFÔ‘S^Š«…õ_ΔPk4l9pиüÜñì·�ä€ÁfÔœ@xô\OÎ�wgù—U|õ˯¬Ú¼•2Ãä_ÇV-™3c:½;w4é;çó¥FQ¬k»¶,xáY^úðND^ÀÒ‚‘ýû2wæSxV© 0üég‰‹×O’͘0žÇF�âÅ…±ÿÔižx2oÏœÁç«Öðݺ 4õóeײo«û¢•«8{9 �V †IÂVÍšòäøqL6´Z&¨Z­fÙoùiÓf bA ]H0ÏLžÄèý|íò‹ŠØg¨WðÆôi,ønjµ�NÇ–ýy桉µî£&A`Ò°û�!@rzF­}$$îu:ûú³+V¯/T©xs÷NVŒ›€RªßA7¿~Ï¿Tmý½* J˜¢Öh8h|zµok²­[Û0r9iYÙ\¾Þ¸ �†Ð?¼#ÝÚ„qêró—ýÈïŸ-äRìu6ì1µ3ß}ò ¬ÇôïÃ�n]XøüÓ|¾f»Ož1n·´°à‘á÷óêã“Múç±~Ou‹ô¶-š×K xñ!½`´Ú LVàì`Ϭ‡'2®ŸzíG­Ñ²÷ôYzwhg²­[›Ö(rÒsr‰¼C§– ³¾<}�óÑÕëÇ4D Þ‰'F gå¶�l>t bîô±£Ò­zàPnA¿îÞg²N§Ó5J ¬ ;kkVΛÃsŸ,&.)™[)ú Ç6Íšòí¯ T˜Þ»o$%s#) uß~b Cƒ«íwÕÖµnXM ü«´ äÝiSùä—µ<2o�q½«£Ÿ¿ô<.úZÒïÿ´š+7o™ôÝtè(› ?‹­Ÿ.lðØ!þ~úŸÉ›oƒÁ*vþô'̶�;å1Ò²²9rá"G.\ ~Óz¬,,øùÝ·˜ñÑgl9rŒ-GŽàëÞ„¥³^�ÄA ‰%–t kݨ¾Z­–ÔÔTžþy ðֳٌ:¡C¬”µÓpŽJº-íÏ‘©R—Ç­[·xï½÷øê«¯ Bi¨;+ñ÷Ò»cûF „}:vøÛ�GâÞF©PУ]›Fgö®çgêà ¹ˆ:ôâ` "áãÞÉš~ó2ÝÅš¥�;ÐÚ×T¾E8§æãí¥\JÒpò'n/cí)•Y‘P£ƒ�ÿ,gõ’sÄ?…Ÿ‡A>>ÜJNþÿ>‰z „w9%*y%úÈݾaaŒíq'’ùZR’Q о=ÝBªO*E'&�š“‹�•{÷6Ù¶ïB$ñ¤ç癬·²PÒÞ?€öþL)WsäöM¶Ý¼ÁúkWYwí �.® oÑ‚�ÞþÕ,—êBk ]þ¢Hxøì9²óî÷¸ÁƒHJKgí}­Æ­2ï™§« fZ y†b€7/aû¡#&m"®Fóà¬Ù,yóu& »S;§´Leì›–™É/¾lÝ0d!þ¾{/GÏE°íÛ/ ¨$Øû•”0ñåW¸Råa®¬¼Üئ°JÔÎo»÷ðüûV+,Š"Wân0ë£O9s9Š%o¾~ç\Õj{ã-œ>S­Ï…k1Ìxç=Î^ŽbÁ Ï58ªoǡè ÙNJ¥’‰ÃîçðÙsì?¥˜Ø¼o£B _~*#Õ/�ø·3¦UGoßàÐMý}!*=•¯NcVÏÿM�Ø{‰zô!¹ €“ zë¾`·&Ì�„Á¿/Ò‹ U¬lmØýÝ´ZNövx{1f@”ÊÚ_ײ­N‡³}Í6ƒÝÚ´bïwKÁP ®‚¯Þ˜Ena Š"ÓÇŽaâ}ƒM�ØÐÜÖêŽõõ¸}Ù§·SRÈÈÉÅÑÞžOìmïÔq{døýŒîgú,V‡ú;È�Y“'ðÔØQÜJM!¯ ˆ&.Îxz˜u hâìÈÁe_ÅŸŠsÙ´èC<«D’ÛÙØpà»/Í^Ë�Ÿ,D'š¿Æë?œoRgÎVV–Zö5îuتÏ}r O�ML| ¹ŒÐÀœ«\«©#‡2®_³÷»XH6õöâè²op°­nkþËüy&¹-Øûõ¢oÝ&§ BüLú|øì æN›b\v²·ÇÁÖ´¶Ÿƒ�-Çø¦Æc2�ýÛÂùhµZ\èzQ™'ÇŒ`òýƒ¸v;žÂ’2\íiáçkòùYùÎc–‰9<]œiæëÃcÃîÇÓÅA�qrùwÆsÜúÙGT}Ä�Þx•Ë7n’_XD»àæ5Ö; ö÷cï‹IÍÎFm8 ÃDsËÀûÑ·ãÉÌÍÇÕÑ�ÖMƒþÓvÍÿn‚ýýå$Š"%%%¬Y³†ëׯàÒ® mçt5±õ°l‚�ÌšÛ¥‰ˆuæš"³VþY_Ž<¶ uA9àÀ�Lœ8''')‹ð@ÕúÍ ¡Í_è+ñï%¬yóF „m‚ëþL]¸©âøÕÒ*Ö¢¦"áøîÖŒ·fââlV>ëÌÐæŸãº·P²y–’o÷—1vi›_r 1[ËÑë³"áŸWÔÄeXÐÜ]š_ü§hÓ¢™$Þ#Há]N†!#CToe Â!€S >ôކ/è%¥Õ¶¹98Ÿ‘AvA¡™žz¬-”Ü€fÍÙ˺¸XbJŠ8{þQyYŒökF3Gçû›C#êÈS—álaÝèÉÝ?öÞ‰–oL�¯#ô3 „)™œ¾t™îíÛÕ²Ø~èV––ôé܉2•Šc‘èt:DQä�ÅKèݹ£Y«Ì‹1úy>tjÕ’è[·¸~;€ôìl^þø36.]lvÌßwï%#'§ÞçZ¦RñÖç_ÅÁ¦¾¾ŒÐ¹LÆÑóœ½À¯;v1vÐ@ú†ëë7-Z¹Ú(* =’ _Žž�d÷1½°üÃoiÕ¼“‡«÷ñl¬týû…wÂÉÞž‘ýû‹1±ÜHL¤™Ÿ_-{©™£çΛ,øTÏŽ•�ø7!ï¼�ÉY¿�R ·qû9ò<½}éÔìÿûðþ_±·´ä›Ñ�RP€NÔáëx÷×h•¨?J…_3µÇ*𪒑ïX[튺uµaei‰Ÿ™qíll°³±1V7t´Wàho:fMs\J…œþ~´ð7ÿ·ÏÑΧZ�_`ʼ÷ë<öðV¡<7q<6ÖV´nZwÍR…\nö|kZ_AM×Òǽæk\ѧ¶g<™ ˜­9Wî.θ»Ôü¼éhg‡£�]�cþ´m§Ñš´6¾yc6þžæ�Ëܵ� ­›Õø™psrªãJ®…§G�ÇVõXþŽ©V++:ÖbÉïíV½¾aÕq--,p6Z$ T:s¶¡T­;Y掃ŠëAÒij„„Ä¿Ÿ _ŸFõÓét¤¤¤ðÍ7ú`A&öjg�c´äÖ—‚¦# °,a5[Ów7x+o[Z<ÆÕ%hµZ¾ÿþ{úö틽½=ŠzXJ4Œ ŸÆ}ô}¥ù‰ê4mäçB.“áïéYg»u‡ z�™z�‚H@·Ò5Lø,“¥Ó\èÓª~5g´ÂB£—`o-ÃÉZ ¯´ú¢Ndý95o “jþS4ö3%ñÏ#ý•¾Ë)Ñ–ßëªd� •¾¢WÍ,3®×‰ÕÚVíSŸ`.¥\Îð�–ô âë‹ìLºA¶JŦ„›„9¹ÒÏÛ+yý?Nå:-…jʆߘKÊÊØuä¨qyä€~ôîÔ'£ÍèæýêìlùmÉb£é–yjÞ|DQ¤´¬ŒU›¶0ç©éfûvk×–µŸ}Œ­µ5­–ç,dÓ>½pvì|—c¯Ó&¸Eµ~99toߎ™“&àíîŽSu§.\‹!¿¨È¸¼nñ'ÆìÄWŸ˜Âˆ™ÏqþÊUö?AßðΕ”°ü÷?Œ}Îz�ÇF�`úƒãyéÃ�ùuÇ.>Yþ#‡Þ_ïlÐÔÌ,£­*ÀÈþúë?´O/^ýl1jµZ-÷äå)�Õº¯ŒìŽTËT*ŽG^`ÅÆMÆuNtjÕ²†=HHü{p°´â£û†1í� ¨µZD`Þ¾Ýü:é¼ìŸ%òoÁ»ÙU÷*+ß{»Æm’Òx¦ŽÆÔ‘Ãj½ŠÒõýo`iaA˦A ýÌ%$$î1íjvF¨�²²26oÞLF†¾l‡÷Ð@l[8¢5dº[º13`ªqÎh´ÇP¶¦ï6É.¬/~4ãÖºk”¦•pùòebbbð÷÷—ÂÿuÍ#ÕÞ·qŸ%‰7�½ÇØÙØÔk>q×™bƒ�±9kQ�·Æ;“¬F££Þâ`ÓúZqચáí-HÍ×ñÑö2³c쎪¿@˜‘“Ã÷¿märìuròóñjÒ„ö¡!<9n Ž†ß¿#ç#X³míC‚yö/¸¨5†•›·rüÂEÆìϰ޽þѱë‹C#?Sÿ㽄ŒËk‹—×]�O däXBFŽåÔ¥ËÆõŸþ´Š�‘c÷ÒlúNy’�‘c¹Ü€9Œª”«Õħ¤’”žÞ¨þªòr:Nxˆ�‘c¹ÿég}kËÆgAýÕù�FCnn.¹¹¹‚Ù Ù¶m«W¯æôéÓ&?óôôt6nÜÈo¿ýFddd� �AE¾Z»Îøºrô”φ?÷·í=yêo·‚ ×bøjí:öÔ0ÿw/amոϔ•eÝŸ§øt5ÉY½@(VwwÞ;Ù é`ÅÚ#E¼4²qâ÷œQÖ|³·”‡»["P} D‘k©:²‹êþü]�O ÷cOðõ¯ë9r>‚è›·Ø{òŸþ´Šûžz–|Ã|úíä¶:Ì™¨+�:æ¿Bäµ¶:Ìõø„|ìúbc)Õ|¼W�ÂxîR´¢H¾Z…•…G[òKŠÙwá"¹•2É J;Ï�ኡ>SeâRRõmKJY±ÇÔ&")+ §†ÙµÉ�š¶ ¾0Ÿ�·â…ååìM¾E/|lë!�¯V¡”É‘7À“Þœ½h#û÷3fÆeçåqô\ý»†×¸¯ÁÝ»U_×£{Oè®ßŽG­ÑT«ÿâíîNëæ¦¶.ŽŽtjÝŠã‘\‰3ÿÀÚ»S'!±.š7ÇÑÎΘE¸ëè1v=†½­-}Ã;1°[WvïfR»©r}C¥BÁ³ó?0Ù§ºJ=™è7i\¯ãùcï>ãû~á�L¢Œ*ÛŒ^¿Ï•¸Õ®SCxlô(fNšÐèþ÷"�w çBJ2GnßàRZ*ߟ9Á³ÝîΨ0‰Æa¡T`¡´£Tz“��ø@ËÀ€ÿïÃ����¸giLÖ³(ŠäççsõªÞqÈÒÍû'cÀ/€³²ú|Ð@·>œÈ9רãtíéEÜ÷ú2(gΜ!//wwwär¹±MçÖ­Øzð0'.\4®‹ˆŽ6NºŸ¿r•nmÛpúrù……t4¸ “_Xˆ¶Ž`ðÚˆ¹ÏàéOÓÄٙ˛~kpÿ½'O‘’‘ �èÊ�´nöß) qîÜ9ºwïÀ Aƒ˜1cÓ§O'??ߨ¦G�ìØ±ƒ•+Wòæ›oRV)È¿]»vlݺÿ¿|,:QäýeË�Ëé99,xî´:o}ù…†„Šñƒ™�4Ç›K¿D£ÑðÆ´'pur¬±ÝÉ‹—xÙrÆ À�Ýÿòùü[‰N(7ˆuB¥šÞw2 -sVçÒ#Ô {ëÆpµöQ —Á»K±R@‰ZÔgFWÊ$u"×Ò´ôl^û—ðoÖo ¿¨ˆ¾�;ñÍÛspqt൞˜û.·SRX³mÏMžÄð>½i,eÊIÜóHÓRw)…•±0txp0û.D—šB\jŠÙö§bb8Sãþ² Xô¨Ùm]Bj®=R™³7o²æÔ ¼œ�y~À@ìy¤EKv&ÜB¥ÓP¢Ñp,#™>>xÙÔïæ("R¨Q᤬_TAv^‡Îœ5.Ëdï»Ì¸¬ÖjÁ‘´yÿþZB/3µQ¼+ÕÔêtäâæì\¥�ùš*•ëæä˜mãVËÃ…9ìlùú�·™õÑÇdæä׳ýж:‚B.ç�!ƒùxöKX[Y‘[)Û´¸´”­Õ:FnA~­Û+ˆ½}›Ë±×+í»ÌäúW¶Bؼo­agZV©�¤�Ë ôñaÌ t5|)��ø/!óá¡õ?“aø�úéüY:ùøÑÍOš\•����������¨QIKK£À0?àîŽÖ˜Y£']•U­_Dþå*NFõÏø²mf�…‹%å9*’““ÍfìÚ…w¾ú–›II—`okÃ…˜Xd2[kk"¢£�Ç_‘)دs§jcåp#1‰o/šT™³ÑhµÜJJ&¿¨O7W|=êWï7#'‡Ä´tœðö2k�¸Ñ´nkmMqi)›÷4‹JJÈÎËÇÚÊ7'g®ÄÅ¡P( D0Ç«ÊËIËÊF!—ããáN\B"E%%„µhŽ¢’˜z·ÅäÉ“ÑT B?qâýúõãâÅ‹Õú\¼x‘©S§²Ý®S ål¥L®ØÛñFq°¡üºóOÊT*ž}hb­a÷vmy{Æ“KAPµr;]­Ïä0+ëlM±J¤S³¿V°S�Kè¢d÷årƒ±¨©HŸ¥£góÚ÷“˜¦Ï.е‹ñçß¡e(ïÌœÁΣÇ�nr§.]féÏkéÞ® ï=;€ì¼|æ~õ5GÏGÒªYS9œ/~Ygls#1‰™ âì`Ïc£FòÑŠÉÉϧm‹|8ë %¥¢oÞâ£?q1&�VK ·7ÏLšp×Ú‰JÜÛHá]ˆJ§5±ÞìѲ%û.DþOÆr¶³£¥¯o�í¢SS˜»}…ª2HIÆÁʆgû÷ÇÝÚ†Al¹}à ÷‰Dä¤ÓE�ÑÄÚ¦Îýb°RUɵXÊê~Úrà ‰mè…k1\¸V³0ºóÈQ>yåå- ‹ŠÁ½ÊºbS‘ËÆººõ[AQ±ÙýTêkce^ô�-YÁà݈ܸ�½'O±ãðö�8Ež!ºÃÃïú]b¡TòÙk³M2]�œÖ§w­û ¬×qlÜkúw"ò‚I=ªlÞ�9OM¯ñœ{vìÀ÷ï¾S¯±%$þK8[ÛðÁàa<µù7t¢ˆNy{Ï.~�ôM�¥-!!!!!!!!!!ñßDEnT²\´ô°®V[ðdÎ9ž šŠÌ�£x8û$[Rÿlä€ãØPž£¢¨¨ˆââb´Z­IÂf~~ø{y’�糨XzvhÏÅk±4÷÷Ç×Ý�ˆ«×HHM#¿°++º¶1 Þvø+þØL™J…R¡`îÓÓ™1þÎ]¹ÊÌ ILK3¶ïÖ®-«>˜ÏÍÄ$&¼òÙùyzKÓ_VamiÉ3ïÈ®cÇ�‚fS_¾œó� Ù‹ù……ì?}™Là­Ó˜³ô+¶<ÄœéÓŒó[æåOѽ][4Z­Q´jÓ¢9+ߟ��‡;—¯Ç1âÙðñpgÞ¢ IDAT§[Û6ƹ_–¿÷ŽI)œ»™´´4:wîÌ×_�³³3/¿ü2Û·oƒÂ/¿ü‚��Ÿ~ú)+V¬ààÁƒdffÒ¤‰ùàûÆàëáÁ•¸”–©°¶²äÜ}æ¬�‡;Éé&m�FD²iÿÒ³spqp —pFè‡\&㫵댪«·nçÑ‘ÃÑh´,û}#®®Lz‹Wÿ̃CëíjSSqqÔ‹H_®]GBj*­š6eêØÑ¨Õæ}ój�šû{ö``·®fŽüßOa‰öŽå§‘0ØGIb¦7û¿fÿÞÄN†»£ŒÒrŒãU Jëxhîç˱ˆH¯ZCN~>=;´§]p ÆÀØ�Œí²óò¸‹W7Ôj Î~•«7nâìàÀåëqÌþt1ùEEÆ6¥*—bc±P*9ué2^nn—pðì9^üèS¶|ñ9…Å%LzõuÒ³shÕ¬)2AàüÕhf¼»€ý+¾'¤žs¸õEïB Õ*“å¡¡¸»Ÿ‘�ƒ� ß?÷<®†‚¨?íÝïGŽ +_z/—;QS«öïç—C�_^y7ý¬m§OóåömŒïÙ«ÎúñYY¼ñÇzqРóĤßyÐr³²á>ß§'"P®Óq1'ƒîî>Ø*•5ï¸Ê9[ZÖ-(nܳ¯Î6•)(*æÀé3 ­!Â"êz¡MƒLÖUÎ�kââlVè‹OI¡ ¨;[ã:�NgbíèãÝ c­ ¥Rɰ>½Ö§7­–“‘ÙzðþÜM™Jÿ™ÙvðŸ½6O77c?;>{mö__EþhàõOHM%âj4�Z·úËãKHü×èäãËŒ.Ýùî´¾jNi söìâû1ã‘5"Ð@BBBBBBBBBB⿃V«%7÷Ž ‘ÒÅÂÄ^ ©$…GäÞ'…:�ht´j .–ÆñËÊÊÌÚ�èÎÊ-Û¸p-†.aa\¹qƒ1úãëáÎ�3gIÍÌäRl,=;´G©4�¾\¹y+ïBL|<§.^bÁw?ðÐСØÛÚðüÂ�ILKcÂýCðòbëÁܺx‰~ÿƒ‡G câý÷±|ã&¬-­xþ¡‰X[ZòÍúߨyôm‚[ðà�AÄÞŽççí;yrÞ|"6¬5ŠÛ¥\­¦k›0&ÊËV��šFDô5!CvQ ·7ÓÇ�ãÏc'¸|=Ž×?_ÊÏÝ)“’‘É¡³ç˜6n g.Gqùz3,äØšŸÌf/Þm‚ÀºuëhfÈ üàƒŒ!À¢E‹èÔ©“ñýÊ•+Ñjµˆ¢Hbbâß*†‡µfÓþ\ˆ‰¡{»¶œ¿�\&£cË–&á¦ý˜¹`!\Ét:‘ßöìåfR¯LyŒ/]oLLøyÛzuh�\.gͶ4õõaãÞýÜJN¦_çÎ$gd°fÛŠKK™<|(íCCXøÃ r9}:wâÐÙsü¸i3^nn¼ýÔô¿í\ï5tZ} @ƒ:XM$,U‰X[«tuî«6JËõû)QéL,M+‹„šz ñÒ£�p>ú—c¯³ôçµ,ýy-!��Œ4€g&N¨vOØzè0WoÜÄÏÓ“ý+¾G!—3ê¹É�+ªÖ¶\­fÓÒÅto×–.2î¥ÙD\�F­ÑpõæM¬,-Þ§+æ¿ ÀC¯¾ÁÁ³çˆ¸zM%þv$�ð.£L«AS%¢K&“1eÐ Þ[»–‚’>Ùø;ï?ò(J…‚>aaüzä¢(²ïB$�ÐG2èt:öDFêëkc’“X¶[_‹°‰£#£»Ö½’”—Ç‹ë%£°™Ñæ@$µ(­Ng|`ñµs £ÆƒK¹Y¢> 2*7‹ð&È„ºj4¢Ž2­+yÍÉø”Î"€FôëCû–¡fÛ~±æc–ߦ½ûk—mø�Ñú¡4™Y¹¹¬5Ô0èÕ±£Ù~ªòr–ÿ¾‘—§õ$MëÈ$=u…„ÔTãòCÇÒÌ߯Z;�Nä“å?¨6íÛ/ „�äÉÎ]‰LIât¢¾ðôùäD–Ÿ;ÍŒðúÕN�����������øïR9 \”�V¬>;þÅÍtq逃žvŽ­yØo«^›Ï8N%KѪö¢ô¯cb‰¾uUy9BCðóÔ[�ž�¾ÆECðv¿ðÎÕúøâóL¸j�†Ö£  ¸˜ÛÉÉ´ðgxßÞX(¼öÄt:‘‚¢"bnßæVr2^nnF�ÐÆÊŠç&OàÚ­[xy1¬w/fŒ€�À@Tå唪TÆÀñ?öé3ýFöë‹•¥%ƒ»wcó�ƒl>p°š@¨T(øcé"¼ÜÜxrÜXz>:…ý§ÏëV\Ÿóߥ[Û6””•Ñõ¡G¹•œÌÑófÏûnÃÙÙÙ(øøø˜loß¾½ñ½££#FÑZ­Vÿ­ÇÒÅ ž»rÕ ^%$([[“v?nÚÀ—s^güàAl>p�™ ²óè1^™ò1Û6xßpÊT*öüð-�ÞÞ>w€›IÉ êÖ•~ˆv!Á$g˜f&öîØ�‡‡åçí;™÷õ·\0”‚úøåqüש³±îdš ¿ß™‡¥RÆÐðú9ÑÕDLŠ†Ý—Tä‹Õ,M+DBûzT¸òtsåÏï¾&âj4GÎGp)ö:ç®\!æöm>\þ#·SRøüµWªõ;i°Ô3 Ÿñs÷øèQ¼j˜³­Œ³ƒÝÛµ ]H0j�†2U9]Û„qlÕOœ¼t‰%k~áÜ•«9¯ŸãWýÍ¿7HáÝG‘¦Üìúž-[1¤CöDFyãóÖ®å­ iæåM ‡·ÓÓÙÁC}û¢�Ë9{�LC�àmÛÏÜŸW£R—£�Ëy}üx,jÉð‹ÉÎâí-›H/,ÐGZnâ2² K(*+ÃÑæÎÍ;ÔÉ�¬²2²T¥`¨£_”O�½s�cT=÷ÚÂ?öî7y¸|ç™§ ð6Ÿ¥wíæ-~ß½€='NR\Zjôˆ®ÌŘX&¼ü*Ï<4‘’ÒR>^þy•ê÷M7¦ÆãùdÅO—–Ò7¼3—bbùxù�Æm> þ› [[Zq1F9wíÖmÜ]]èÝ©6V–ÄÞN`ÍÖmƶíCC�¶ÁÁ´ æRl,:�ŽioÍãÓ×fÓ"ÀŸè7™óùRã>C›Õx+S9{ÐB©dþóÏ™dPVæè¹ó5üñÚzàï=ÿì=ý&!q·!Þ<Œ‡Ö¯!«Xô°ìÌIÚ{ùÐÅ·º@/!!!!!!!!!!!�At®T›O]P^Íb K•͇1_°°õ¦>B¦*›iÕ„”‚µ®ö jUV … ³®U½:v@©TpáZ ‘†²1íCCð÷òD"®FUQÐŒPVQóM©P`ogKAq1å V––̜𠫶ncÂì׸K¾¡®{Mb%@ÿðp¶<ÌöÃGØ~ø!�� ìÖ…GG7Šƒ)™œºt €°͉OI¥sëVl>p�­ñÞ33‘Éî8½´iÑ/ƒ»S€·Á�\½q“Øøxì siö¶6tk«ð¶±²¢WÇlÚ€Øøø{B TV™S”W©ŸhccSkû¿“ЦA8ØÚrærù……Ä%$òØÈ&eŠ~ùx!:­ŽÔ¬,~Û³�MÑ·>õ ííùiÁ{f³Ç*xgæSì?}–}§NðÀà� ù›æïU¼]ˆˆƬAS‘ÐÉFƆ·¼õ^*<æÚ¨1 KE®&©9¹À�6¯e™µ4/ÇúÍMÊe2ÂÃZÖ­NÇç«法«Ù|à ‹_­îÔVT¢Ÿwur2®ópu1»ÿʵF«–eÊ/,dô ³¸vë6®NŽtmÓ†àÀ¢oÞªßÅ��h ÒŒý]„J§­–=X™G�¦µ¿?ç®Çòô×_rîz,ã{ê³ã2òóøãä 4Z-+ö곬mèÕº5?îÙÃ+?® ¸L… <;|mƒÌŽ#"²ïæ æìÙIRj ‚¡�«q»( ÕŠdšô€Žn(ÁØ:±¸˜"µyѳ*Q‡JWÝz¢‚�{öß· ©UÔѯ¯ñ}iY»�7ÛÎÓÍ�‘xäµ7™1o>7�Û9œ®mÛ˜í×ÄÅ™ ðÕ/¿òàK³Yðí÷”¢8ä2Ÿ¾ú²IÀ¿ÂÔÆ#�Tåå¼¹x)½~ŒŽLdÒìW�Í2™ŒÙS7öûôµÙÆÚ‹—bc¹ïɧh:x(ß~Ö(:ØÙòõÜ·êïÔ [4.÷ë^£8H•럞�ÍÉZêJHHÔŽ«� ‡ 3ÚŠêD‘·öì «Ä|-T ™LF@@€q¹äV!:�Îä¥5¼dgù-½�ž€À›Á/2-`2‚(3¶ééÒ…}½~ãƒÖoÒ®)ZQWý¥Ó¡ÊÐO’ÛØØ`kk[M4°µ¶¦k›6$¦¥±ÿÔ,”JZ7k†‹£#^^œ¿Í¥ë×ñóô¤™_ínG•)-S1ì™çøäÇ•hu:^~üQžž0¾Î~ »Ÿ]ß~Å´qcðñp'æöm¾Y·�ÁÓg3þ68ˆN§Ǽ0‹®“åí/¿ =;ǘ=TAÕy–Šë «$TÊdr³}´Ú¿fµø_D:·nÅù«Ñœ¿�(Št«îfuáZ ý§Í ÿÓyù“ÏHÉ̪÷ž®®µŠƒ¶¶Lºˆqyúãx&ÿ>ZøX€h°. v£ÿ#“XNA‰Ž%{#ëjͱæH #:ZrôZ9…¥bµ1*Þ{V¿U¥ÃøIxöĆÝwæ¡å2Ãûô ¼\�VWýwÔ×�}3)ɸîÚ­Û >—õ»÷píÖmuëJäoëøqÁ»Æ` ‰ÿ’@xQª­= K©PðáãSéÕJ½�ž—ǜիùíØQc›µ‡òÓ¾½ÄÒÜ•JO~±„uG� Óé°P*y}üx†‡‡›£¨¼œo##øáòÒnŃND@Ä £yåå÷Äüž«öÀç`gËwï½ó·öqwgãŸÓ&¸E�mœøö�·éßõÎϵ}h>ÿ¬ÆZˆ�ÃZ³ýÛ¯ kѼÎc8tæ9•~Þ£Ô~ý‡õím%¸i¿ùë/!!Q?:ûø1µÓ�ßïì’ÞÝ·ÛäË¥„„ÄÝ�Z£ùÿ>³h´ZÔšš±þ-¨5šz=w–•×/€MBBBBBâ^E<<> ïÛgG‡jm�¬YYçþÞœ>�7§O3»­Mp öýøQ×㈌Ž&%#µZƒ£ƒ!��ô ïl̬L·vm9öËjŽGDr1&–¢b<Ü\éÚ® mƒƒë<¦ ÷èÖ ëïîâBêó¢`ÜîõÞOU^žò˜IÝG ‰ÿ3»öäRj*g“õ™Î'n³:òS:šø��¸™2÷}cïêÞÁ³RÏêm»øyÇŸÆe¥B�»‹ ÝÛ¶âñ‘ð²´`âësÉÍ¿óücgc�¿—' êOïzÛõS—£˜ûõ&c;;:èÏÔQÃhê£�TÿaÓV6ì®n¯0´g7f?6€Ô¬,¾Z·‘ƒçΓ�W€•¥%B[ðâCèªÿ{»fÇŸ¬Úº³Æsß´ø£j5RÌq&ê*o|ù-á­Zòñ‹ÏTÛþø¼÷ILKç§wß"ÀË“£øtͯDÅÝ@EBüyiòwÓß;ò ;ûM“}ØÛØèíÅ#Ã!òzÕŽ?ùiËvšûù²|î$§g2uþBÊÕj&ß?„ãF1ñÍwHÏÎÁÜüIïŽíYðô“œ¹r•×–~m²ÍÅÑ‘�¦�A ÿúg `˜ìY¾ykvî&1=K º·iÍ[S¥E¥zÉ7’RøàÇU½pµF‹W7¦ŽÆô1#�Ö>C_|…¢ÒÞ~âqî3|½÷Ã�ì;{ŽA]™÷äTNE]áÕ/ôÇÿø°¡L;€'|DlBËßzƒ¢’^úü Žª¼1åFöìÑ s­/…%%<2o™yy<;~ß7ø2Ž„„„„ÄÝ‹ ØÛÛÓ²eKNŸ>�*¹„¢ØV^d•å×åM7¾oß¾=Õìó*èß%œùß-C«ÓÑ>4ĸ¾Ch(‚¼ûvn˜@XaÝ™�šÊҟע*/g•¡,K…Pha"só Ø~øCztççí;�u¾îïÙƒ¤ôt ‹‹‘ÉZ5kFÌíÛ\¹q™L`óŸ›Øn;t˜éï.`ûá£,|ñyãú’²2&Ì~�}û°iÿÊÕju늯‡iYÙÆvSæÎãÑÃ9|î<Éé4õõ¡OçN :o =] v�ÛÁÝÅ…o/“í¥*9úÏk¿.úy´ß*¹•U¥LÕ°€2Qyå³Å—0eôHŽF\àÄ…‹¬Ú²�)cF5êœþ (ä}ÛÚ°ý”^¼ªÙ�Æ$©ˆK)gîà ³MÊÖòðÒ–Lqdùþ"nª¹S%ËtŒ­”õyß|ò ._�ãüÕèjõ¼½ødöKfûõìОû{öàÏã'xbš$\Ô‡û{ö`ñª5œ�ºB×É�bkmM�¯±·ãMœï$$þ.$�ð.¡L«¡¾ñ$‚ 0¶{†´ïȧNp$*ÊD$¬Œ…BI‡fA íN�–-«m׉"ÓRÙt#–ky¹hµZ´ååä&&éoš2½x)|›EQe�\à N!ÈðµuàVA‚ ¢t$êT÷M^T: Öòÿ�/yUíìxdÔˆFõ  $80ðo?¦škѼ^•Q*ôëN¿.’ˆ !q/#澟‡ÖÿL^©Þ¶çëSÇiïåM{/Ÿ:ûKHÜíÄ%$q&ê*¶ÖV—–±÷Ô>Ô¸½ ¨˜¤ô ”J%.Τeçp+9…Ó—£8qñ2?ÍŸ‹LHÉÈ"+/g{,J®'$}+ž='ÏðÑ‹3Õ·7e*5‰éúg'?wJÊT$eÜàRl[cýÇ ­¹­1Ô)ðtu1±/-+cÒóHËʦ…¿»„wƒ“£¸Ç‹>$ÈÛ‹‚¢bÓ3P*xÖP‹¢>x¸º�˜ž�…Âü³RdÌuÊËËñtuáÜÕ¦Í_ˆZ£ÅÝÅ ¹\AL|Ï~´ˆoç¼ÂÀ.�ÑjEãµðtuAEb¹zë6;�ŸdѬçÓ¯EE$¦g`o˜xÓhµ¼¸h)7’’éÂÔQÃHÉÌ"%3 gì­­Ìc©JEBÅõww§´¼œÄëq\ˆ½ÎæÃGùý“„5mZïkòñê_ø~ã¼<(U•sè|$Q7n±mñÇx¹¹�•—Ç„9sÉÎËÇÞÆ†/gn$¥°ð§Õ•–0{ò$’22)(.6ÖÈÌ+ !-ƒlƒð\R¦"!Mü«vîâÉ1#�ôìÒ2P©M1´:É{2OWWãÄäÿ‚„´t"bbY²îwâ öFùE’%ue*q‰úëâæä„§[ãêÜHHHHÜ ‚€££#Æ ãôi}=´Ìm‰ø½Ô²Öù§Ÿn­'*?†wZÎÂϦæÒ.Åš¢ âв£ÄrY»SÀ`§9lØ0œœœjCƒñjÒ„ÔÌL:„†×wl¥Ÿ»RÈåôîÔ¡AçÜ6¸Ãz÷bçÑc|¸üG<\]xtäp–oÜÄéK—).-%ÈLJ¦¾>ÜLJæÉyó¹ºe#‹^}™iï¼Ç¶C‡Ùvè0ÖV–¼÷ÌLšúú°ð‡tiÓ¦ZM±�ݺbeiInAGÎ�7®ïܺÅÅ|¸üGšùùñáKÏ›ôõjÒ„`>ZñnÎN|9ç ³ÙIuÓ±UKr9­–έ«Û‹ÚXYÖ¼Qq7èÿÄtl¬¬Ðì\ KŠÑhµ(ärÜ]œIHMcêÛóøüõWê=þº]»9pæ,¶¶¼:u ½;]fÚ;ï²àûØ­ ~žžëùÞK<Ø×�í' �b]U‘°;¾ÛžG¨Ÿ%:ÎÄ–ag%£[¨ùï[Ï–òîº|Þ�䈓­Œˆ›å†Úƒ¦5+Æ^wâ †Z“[¿ZÊÁ3g‰ºGZv6nNN„2¸{7c‚ÆàÝØàó ®NŽÆ¾ËçÏcß{¸K›à8;80í�wQ~§½½Ø°è,*}Ÿ´´°`âOÀpßiÙ4ˆß|Á懰·µeÌ€~(ärn%§`a¨ãṳ̀y`ð@‚jp�“�h’@x—P[í½š°µ¶âÑþx´ÿRrrHÈÈ$»°€’ò2œmípst$ÄÛkËê7ÀBU'Ù{�$U)2¥™\NNr*Z�™BF»Pý?†0 kÈ ðµ±'©X_£P!OU†Z§E)«û!G¥Óþ£¡„„„Ľ‚‡�= å…­ &�çìÙɯÁÑʺ{�¸[(*)!+/ŸLU .ŽØÚH?¿Ý'õW“îÄŠMÛÙsÂT ¬ $À� Ÿ~ÀÖƒGxã‹o9M\BÁw²Å^zx†  ¨¤„Ù‹¾àHÄEÖîÚ˨¾½MÇýv 2A 5+‹‡Þ˜GZv;�ž 40€écG1}ì(Ò²sè÷¤>[ï»·_§eÐñðbliYÙØX[±ñ³°´°@£Õ2é�wHÏÊáäÅËUŠ^nîçÖÏ?6sêÎ øz¸c¡T’œ™‰(Š&“n¹…”–•áïé�¥…Ÿ¯]‡Z£%¼U(«¼ƒ\.cæÂÏØæ‹Ö¬c`Û®¯^™ö!Á•”0óÃÏ8y)Šß÷bL¿>ÕŽcɯˆŒ‰ÅÝʼn¯_Ÿ]­öò‹“ÆóØðûë<Ÿ}ß.E©P�’™ÍÄ9o“œ‘ÅÖÃÇë-fææ²bóvf>0†×˜²òr|}.—oÜdÙ¦-Ì›>•å[¶“�—�»‹3;—|Š›“_¬û�Åk×ñÝÆ-<9j„±æs}‘  iœŽºJ·6­M¶unʱ¾2sséôØ“,›óíƒÍ{mØ�UÛwìïÏ糞7Û¦.&Ï[Àí”TZ7 ¢]‹æ\4Ôª–����¸·Ñh>g$666Œ1‚¥K—’““CÎî\ðÃÒצ־§³#u| C=û3Ög�œÛš8=¨ yýòkîÔ ËÜ�:So�FXX¶¶¶5 „‚ ùÛ¯ÕÖwjÕ’´Cæ̵?¿~­ÉòŠùó¸{�Ò²2Ú…„`meÉûÏ?kÒfßòï9s9 +KKííqqtä誉��'1-;Bqr°`ÎôiÌ©ÁñÉÆÊŠÛ•œ’ÖîлY9ØÙ±ñóÏ8qñJ¹œ®mÚT«_'—Éøùã…œ½Eqi)ÝÚµ5)—Sêz8�ÕDc>Kw+Ï‚¼|Ü›`ceÅœÓÈÉ/ wG½¸Ü¿k8ÎŽ´i®þZ1ÿ]._ABj�[·â¹‡&±â�MèD‘›‰IðéìY,Yó eååX*•¸»¸ðÜäI4qv6¿CËPž›<‰°æÍE‘¬¼<ž›<‰¡¡¸:92¬wOÞ˜6•¢ÒR®Ü¸yW „�-E ©g¿ûºØãßDAB¦¦šHèb/c|/{ÿžCÿ×u0¸“ §cThEÚÙ–6é#gÝ IDAT�8ÙÊÉ.Òy³œÍ§Kðo¢¤‰ƒŒ›é>ÝRH¹É¯…©Hê-§whýB ¿›ƒºueP-%¤¼ÜÜLjî:vœŸ6mÁÇÃ�OgÏBžûà#Z’=ìllèÓ©cµ±ª®«°0®LåÏOH` !ÿ`ÂJcø7ÝkþíHá]By#ÂÊx»¸àíb>]§ÓQXVFjA>ÑééœIL :7� Ga©Dai¡°Ð‰¨‹‹)ÉΙ`¬ã*l \íH,Ðg ÊIÏϯ61U�µB�‹¥9ªRt¢HN™ ›ÚBÿŽk!!!!ño¦§ �tèÄšH}tjZa!s÷þÉÒ‘cë)/HÜ ¨5ŠJJ(V•à`W÷߯ÿ{Ož`ÒýƒÙsò,‘Ñ1dææÑÄÙ©Æ>Ãz÷äÍ/¿CEÓÒM ìllè×¹3G".’˜–nv?¾à…�–�CV-APUQ2ùJJË8q�Á]ÃQÈåün1ÿnä2^ž\OH$37Ÿ=§N³dí>xv>îMhæëCYy9ç¯^àá¡CP*ôAZ� ¿�ýgΛ�HzN. Yõ¯v66t kÍÉKQdæV¿'/Eñýï›Q*|ýúlÜ]jþÕï&®´nDrF™¹¹õîwòÒ4Z-J…œ§Ç�ÀÊ‚…Ï=EfN.�[é3ŽD\`D¯¸9é�÷ñá÷³tÝÊÕjNG]eH·. :æöÁ-ˆˆ‰eþCÕÂÆ�™›Ë¥¸›5Öjª�»_÷& ïÂcï.øËÇ$!!!!qwP\ZZ�VÕ‘Ëåx{{3räHV­Z…¨ÉÞ–„çÓÍ©ËÆJ‡Ž­É{Øš¼‡&–®tqi�§µ;yåìM?B�ºÐض<¥”Ìuñ( f̘�¯¯/ Å??í(íBj/©bceE¿pÓ@)AþöIwK c]²š�Ëdtk×¶Aû-,.©G+ó•”àâP“ÍlÝ´hÑ‚ 6`eešÝekkkÜV±\™~ø•JeÜÏ_E&x{Æ“&ëž™8ÁdyDŸÞŒès'@0ÀÛ‹ïßyÛ¤MUñ·oçNô­bóZu€nmÛЭmãòó±²AxéчtNÿ_•4î3UTÏ{“R!0k‚³¾J5¬¹#¸ë¿O­{Ë›‚ËvæããªàûÉÊ×òÄ’ º†ZQVäiYóRÔZ‘×Wçòþ†|C>‹ k>Þ _a‹ìSPÐ�L„§û %/�õ‘çÁ`9š]\H¹Fƒ¥Ò|¶Ÿ«¥59ªRtd••ÔK Ô‰"Q‡Bhü䈄„„Ä¿™ç»÷æbZ*—Rõ>ÇâoñëÅ&·ëXg_ ‰»‘[ɩį'äã…�{zuh˯»ö²ïÔYZsí´½§NëŸ[€/ó¹ªòrŸ× ê�Uê�$¦¥S®Öy-–—®VÝ’¨&Ú…4§}H .Ä\çù�ãáâBÿðŽ êNÏöm«ÕUNÍÊfî7¦õÃ[·¬–ÙXM}¼¹ž�HRF{OŸ%¿¨ˆýgÎ1°«~ª©¯7)™Yhuzˤï;×&ÈûŽ MbZ†ÉrzN.7’’¹‘”Ìú½úÈýîU„¯üâbfþ%:QäÙÆÐ±RÍ Êl;z‚˜Û &ëžyp>în&ëÓ3ÐhµDÆÄE¼îmÂê}-Óõ¢¯›“#Žöw2Û4k Í*µ3ØñW>_G{;œÈÊË3Ú¬6„/O KKØyâ$óŸ~¢Áý«Ò)$„g‹w“ÆÛ^N=ÒðN ‘���ø7‘Ôˆ¿S ¥R‰²Ò¼�Es[ã3B}I+ËdkŠùZmºR- ó£ÐëÓwzöìIÏž=qtt¬1{P⯑ÜÈÏ@rF&þ!“ÍÕÕ•|Ðì6¥RYã6€Q£þ»uøîv*ž©Šª¼œ¬¼|Ü*ÙlÖÄ#CœYýg‘q¢¢^À‹¼QÊ‹_§QP¢#↊r�ÈÞˆbæü”MÏÖV—éÔÎ{)9Z>ý#Ÿ§¿É"òV¹!ÎA¸#B5‘°w¨’Ñ�-u~ ÁÕÉ‘+–±ûøIbããQÈå´lÄ€.]�ý¯ÕÉ»�ÆþÝ’øç‘»u#3æ4 æ¾®]p²³GÞY¹’lu9(äÈJ°P"Sê_‚N@&Šˆ‚>BC®T Tš<(ÎÎFUT r™>ÀB¦¿©†º¹1¦}Ö�ÓGö †n™VKvQ1Þ5Dö;[X!3ì_@¤X[ŽV‘×ãQ­Ó¡�ÿýaxXkö¬Xf\vh€•Ôì©�3eÜì$+8 ‰ÿG2 ÆCë&¿Loã³äøÂ<¼hëY]‘�¸ÛÙc°íÕ¾�þƒ@¸çä™jaL|"Cž~Uy¹1»mH÷.4÷÷5i·ä—u,ÿc Ù””–!ž_=ró¾gfß+är1´Ab�B.gÕü·ùaÓ6¶>F|jëvïcÝî}„5 âë9¯šÔÌ+,býžý&û�Éd ³™Ÿ/œúšÉº‰÷ ¬&zæEã{…\ÎãÇòÀÀ~õ¸ †sÐh çXû³™ª\=¬­L­…l Ëeªr³ýj£ ¨˜ôgáÊ5ì8vù_|víÖ¦õß’‰(!!!!ñï#6>¾F§ÚE‘’’Ξ=«_!€u{GtbÃÂšÐæ«Iù U¼>SÄÇLJW_}•€€Qò¿Ä€®]ذèœkÉÒ  `âOŒµÌJÌíøFßµ[·é^)ëMB 6>¡­Ìsû6n†ïqµ!`Ù«>ôñ&Ee÷ Q'²û\‘~Žº"�VÇáK¥ ÀŒ/2 ‚ú>i¹ÚJYƒ¢iíAŽ62–L©[¼ü»PÈå ïÓ‹áôúÇÆ¼[ù+÷)‰I ¼ Ð4"{°¸¬Œ7V,ãø¥Ë´i„“�=2AÀÃÅ…¬ôTcVÃ}SO¥û$‚ˆÂÒ¢¢œ ¢FKn|Â�šƒ†¦VJf �B.ÇFñìÝw|eþÀñÏlÉfÓ{%¡÷Þ{o‚pØÁST<Ï^ÏSïÖûyö^D¤÷N $@HH…�„ôÞ“-óûc—%KÚõyûŠº3ÏÌ<3»ÙÝÌ÷ù~5²y'F£�¼ò²F„*j…ÂüÅS�Áh¤J¯ÃIÝü }+}Y½’³£c³¥&äçK�Ÿo«÷Iájø9»ðÒ´Y<¶y2 7ya×V~¸e Nvm?:NZÓNsyÑè„Džxë=ªÌ¥‡"cã(*)ÃÝÕÙÒÖh0PVQ�J©¢_÷Pf�Õà\…U5µ�F\�= wÍŸk)7Y×M3¦“˜L|jÃúôæŸ÷ÜÙâì+{�†‡o]Ì÷.æÜÅL6ì;ÈÛvr&å<+>ù’OžÚÒ¶k€?ÿ}ò«í=뜟-BMöEFS«ÓqóŒ©ü¼sÇcÏšŽh5¯eIùåò.Ååå–ÿw¹bÀÓôQ鬪æÈ©¼ÝÝxï©ÇpÐZ—Ž˜5f$Nœ$áB_mÜÌýæÒžuÝ¿h>sÆ�±ZÖ-(°^»[¦O%&9…¸ó© éÕ“ÿ,_FKî}º˜«S––5ÙÎÉAKa‰ŽÒ2ëòIÅe¦kãähÚÏ¥òžÆ:_Ï æÁ|j¥õŸN£‘E“'ðæêøyÏ^ÜœZö< ןR©´”-n.¨,‚Бä—�”–N�Î�Z´�,Ë‘˜˜€¦‹# W5Æ:t²^FR5õáÛð=«š” ²WÆ£Ë5}osvvfÅŠ :ggç?mö Ÿ—'~^MWpqt¬7çXK9uꪷ�8ÃÝ–Š‚`vòôUo~*†±6BìxïÑî}#£N5=ë9 Mï9ÖsÖ¹¡Ý@@°á ¡Z¥à³ûÝèìU0£Ð¶*««9�”ÔÞÝl$j8v†ÃdYæåoWqðt Fd2ëÌÓä{)€%]ΰ6½ÍZÞKeó¿ujÁ§§c0ߌ»ü,3·_?ú˜næ8ØÙY½!Ë(É-+m´Ÿ’$áhg’d :Vém›H¹¥×DáÏh|—n0Èò8£¤„—öìl×> ¶Q«T898àèà€Jù篕–•ÍÙs©œI>Çö°œˆsf÷ÑãVí{‡t!|õúæÖ¾ù ËÌk0ƒíŸËî$bõìýü}>xöɃƒ/þí^þûÄè”J"bΰ#ìh‹úu6�Ÿwîád‚é �ÀžXr+ÏüÕ4ßHLrŠU{{�ýB»ZýÔ�ÜÞ!�¦2™á1gP«T,_h*×s€ÐÀ@¼½qÔš §“-ÛÆ˜ÿ_¥TrEÀîþE øÄœñ˜WTÌû?¯«wì Þ}ê1–/2óýב‘[¿|Œ¿—ýCC¬~´šúƒ^úÛ½¼÷ôã¨UJŽÅƱùБ]‹Þ];PTZJbZºeù¾È(æ?ñ,¯~ý-=‚MóSž¬ó‡êùÌ,J+Lžæ®¾æàQi�@ê¥9|êßìóvwgÒÐÁDÆ%P\Þt�Rhj• _OO|==qvó¿ ‚ðû²íHX‹·ÑétDDDPež'L;Ø ]A ¥{sÉy'‘Ô»Ž“ºì8Å›21Ö�ü«]~ 9ï&“þÄiKpÐÝÝ�+V0mÚ4<==Q*Å ù¶RZQAØ©˜«Þ~ï±Hju¶Ý›þâÎ�'-;ûª·oé{Ó‚q.¼õwSPB–Í?¦ÿ·Ü¿®³ «eòåÛòmë,S)dÞ[æÊÔþbðt{ØqL¼ÏüŽˆa oaí÷È„xvœ8Žl4b4ÊdÔ©àéI½„Äºï¥æK€² úÊJJ2.‚9ˆgŠJøººrßÄ)–Ý8_e+KÈH‘È*m<@`¯T"˲i~ ªm,§*„‚ ¶y|ìDzû\ÎnÞ�’ĺ3W?úO¸>œèàO°¿¯U¦×ŸÑÎSpdÿ¾„¯þÜòóÐm‹MëÃ[°»¡ÁAÜ<Ãô�絯WSe.Ý[RVAzN.Yyù–¶9¤çäRXb m=ο>úœ§þï}’ͪêš"ãLå5½­ƒµ:=é9¹õ~*ªª°UH`’$QQUÅÀ¡tö÷£³¿å••¸:;áájª,1kô¾Ü¸™øÔ \ÈÊæÃµ¿0~È@K±.­½=O,¹€¯7n%%ã¢ÕzGTJ%Ë- ÀÛ‹ªšV|úe½ý••‘–“[ï§±ó¹cÖLV~ým‹®Åˆ¾}ñ÷2•p}þ£ÏHËÎ%%#“—¿ü†ÓÉ)– ‰yælÆÇØ{üyEE¼öÍj|=<ÖÛ4—bh°©Tí¦Ãa—•q&Ž“‰¦ bŸ.]ìÃÍÓ'#Ë2§“R\o«�‡sÛ /òÂ'_\Ó~A„?¦·ï´Ì½l Y–©¨¨`×®Ës–îÌáÂ]‘侓DÙÞ<ôµŠtävžÔ¿F’÷^ åóÑeTa¬0 ëeôù5TÇ—Q²1‹ÌçcI»7Š²Ý¹–tûÐÐPÞ{ï=/^L@@ÀŸ¶´èõ²aߪ-ü[®°´”íWlþ¸~Ü~mƒŒO&$rö|j‹¶¹k¶;Ÿ>ˆ½Zjõ ¡“V=ìÅMcÄ`°öòã1pý÷äÏ=d½ƒ¸T²ÓV¿<ˆÁRBæbþå¹`üÜÜ­Ë8\*½\·X–9åúÒq ’SL_ì,¥HM3>8e*nŽŽ–]9i4uödzÏ*.n²¯…êrf¸µÛF[vIAþ´ì”J^Ÿ1—ÛþŽŠZÓZoÚG?_?zyû´w÷¡Y;Í{‡ÆÕéò÷Ž)ÇòÁšu;KIYE{hÝr›&;¿€�×þÊKoãó_7ðů›¬ÚÝÿÊ›,ž6™WºŸnZDxL,ÉiéÌ}äi´öÔÔÔb0±³³ã©;o·Ú>%ã"Sï·.1 ðêƒ÷sÓô)õ–7ÄQ«Å×Ã�ì‚BFôëÀð>½¸�•M¨9»àñ¥·s†¬¼æ=úŒe¹»‹3ÿ¼{i£û¿qòDVmÚNì¹s¬øìKV¿ôïzmìíìxö®¥<òÖ;싌fë‘pæŒmYÿîšµ¼»fm½íÎýösƒÇ|äÖÅüºÿ Ù…¼ûã:žk¢u©UJ^øî{åMŽÇÅ3aùƒ–u=:óØm7pËŒ©l:t„£±q,{ù5K•RÉÊ¿/GeÎt¸gþ\v=FtB"ï¸ÛÒn@÷nL1¼Á>L6 O7W ŠKlêscÒsr9|ê4åU•6´nØ�Ï—¯ëÜq£‰:OnQ1'Î&0{Ì(&BQ3óz»»[Žyé»§›³3+î[Æ®cÇÉ),¤¢ªªÁ džL<ˆÍï¼Á·[·“�š†V£axŸ^Ü=.Næ}¨UJ¾}ñ¾Û¶“QÑ”WVÀÒ¹³Ð-Ô²¯a½{ñÓÊ—øvëR3³pÔÚ3º__îž?×RÊÖ×Ã�¹cÇзk˾Ÿ¸íÂO›J¼º99YõÏÎÎŽycǘ×9Ò˜nAAÌ;†.þ6�wCÆ€ŸGÃó]š_QAøýzù³/™:b8jUó·óŒF#999¤§_.Á­Ñhð÷÷§_¿~Lœ8‘=z�““Ã÷ßOTTeeM†K’„‡‡ãÆ�ã®»î¢oß¾øûûãàà >g®ƒÕ›·Z*V\‹¨³ñl:pˆ&Žo•~ ¿_o}³š’:¥õ¯Öº]{xàæÅô¯ó½Ú}ºØ³÷!|±¹�ÿ­+ ¿ÔpUAB?7%O.rãÎ)Î(Å[Q»zñÓÏ­æ¹:>InI}¡I…åŒÿü#›ÚÆ>vywNu¹Íá°Šª*¦=ó$ÕÕ–[TÝX¿âe.ääpÇ;ÿE/I(Õ*P«Q¨ÕHj’J�B¥D©R!©U8ºº’…¾º”JJ%¨”8k5|µüAºøXߨÊ(,䎯¿D/˦"¥2»ºðÓòûíozE)çËJP¢@’d<4öôrk~ž ðµwj¶� ‚pÙËûvñkìåù(ftïÉë3ç¶[²KŠpÐhp±ÿc–ö(ª¬à¹ôh›Ú~Üs\ƒË³«þcLjp©�k›^}-{¾Êíš9f“_Ë•¸–íš8×6¼¾?íÜÓì¶A>ÞŒ4Àöc6Ûß6z^¯e­d*=[PÒô@4G{{æO¼ü»uMÏk³Û6±Wñn…µMl×N¯aA:–üòRJª*QH]½š,t½yNœv]�÷¯ûîá±%·5ÛN§Ó±fÍÞ|óM=z4#FŒÀ××www\\\ÐjµèõzòóóÉÊÊâøñã?~œsçÎQVV†N§C­VãááA×®]3f }ûö% P*•Ö•¬„6‘�_Àø»ï£°™©~làíÍ¡¯?ÃÍÙ¹Þº+VЫW/n½õÖV9VKüøã�ÄÇdzbÅŠë~ì?›S ‰ÌxàaôÛ¦ƒjÎÐ>½Ùòþ;6 `hHU�‘uJùy_ Gã«0È—ªÜI挗:ÿEB¥’ÓGËÍœY4Æ;µxjo¿í?À²ÿ¼Üfû/8°»Íöýg&2g”  ɪ`(9…ETU× µ×àåêŠ�� ƒNoi!ÉuÒ­ML-dŠÏ§¢«ªBR˜²‘dÀm£ÇÕ 8ÙÛ›F„Õùà(ÕÕR«Óa×H�yãñgIü™.‚Ðfž™0™ØœlÍ¥§w&%0¦Sæ÷îÛÞ]AA¡¼þõ*F ìϨþýšl§T*Y°`£G›²ö���qvvF£Ñ P(¬‚zÎÎÎÓ·o_/^Luu5µµµ  vvvhµZœœœppp@­V×Û‡Ðvtz=÷½ôj«2óòxðµ·XýÊ‹(õŸÇ3gΰ{÷n:wîL÷îÝ[í¸�IJJâÂ… œ9sÕU˜Û—•qÏŠWZ-8p"î,/ö%/ý½ñD’¦h5 –Îpcé 7J+�=[IBz-éyz*ªŒH pÒ* öQÑ+XÈžö8iEº`GqþâEë�öî†pÄ;n I’ÍMÛk4tòö%6í‚eYum-ù¥%Ûû ÕhpÑh©Ô•Õ™s°Î½y°…¬«¥,ýâå!¹æÿtööfÉøI [£R¡V*¨½ôá¡�¨Òë(¨¨Àß\ÚôJ:£Ñ¼kÓù©m,9!¾d ‚ ´œF©â�YóXòó÷–ù_Û¿‡^Þ>ôðj¼Ü¢ Ã-3¦6ºîÏöÍhƨ†çûA„?;�^ÏÒçþÍæ÷ß¡g—Î�¶S(¸¸¸àlλtŸ¥¡û-J¥¥R‰��næû;uïSÕÝVܯ¹¾ŒF™GÞx›°S§[}ßÛ�„ñÏ÷?ä�G²Z>nÜ8vïÞÍîÝ»™8qâu &''sàÀ&Mjø¾¤Ð:ªkjXòÜ¿9Ÿ™Ùêûþð§µtö÷ãž… ®i?. ¦ubúÐVëšÐ†òŠŠ¸ééçZ¥\­pý‰aÐÒ¯V3† '.=ÍòeÍ(Ë””ìãƒB’ðqv!ûRÝxùr‰æK�e£LEz&²Ñ– ¤%HÜ2r4ƒžšZµZmúògn¡”$²9àhÎbÔë�ä–•5 ¬Ñë/—Šìlœ°º¡LÃõ;w“ž•eµÌÑÁ��à &vM“a—WVòåÚ_¸yÎ,ü½¯þ&zLb"aÑ'¹ÿ–›¯zBÓ²òòøyëvî¹é/89Ô/[›œÂþ£ÇxðSÉ•oÖo O·PF èß½½vï~û³Æ�¥g×®×ýØ›öîÇÎNÍÌqcmjs†Øäþz�_›R«ÓñÉšŸ�e™e‹áìØø\N­íØéΦœkÓók �ÝÜyzüdVìÙ@�AÏó»¶±ú¦Û°W5œí-‚ ‚ ÂïGai)ó}‚ßXÉà^=m×Ò€žv,5:½ö&ë÷ìk³c|±~z½ž7¥yPÿ´iÓ˜6íú–Î�={6³gϾ®Çü3*.+ãŽþ‹ˆ˜3mvŒ¼ûU55|�[>þ�;¿ú‚ �ÞÜ^B–AF"§¼á²,SeÔ!ɦãØ+m‹I7PÝ€øsçˆKI±Z–•—ÇkáÕO>Ã`4Ú´o€ì¼|Þ]µšòŠJ0"N�¶<¶UXôI~ؼÅò8'¿€gâZ´¡eÊ+*‰8ušZ�®Áõy…�¹<Ûɳñ\ÌÍmó~ý¼m�G¶ú~�ž:M~Qq«ï× çS9×Äèû"ŽòËÎ]–Çss9ߦ}ŠŠ�#üä)"N�&¢ FP6%#;§ÍϯµÌïÝ—y½úX§äóÖÁ¶û£RAA„ë+¿¸„y�<ÁªM[lh-üÞ¤ff1÷¡ÇÚ48xÉ77³ðñ§ÉÎ/hóc í':>�)÷=ЦÁAÌ÷ƒÿóñgüí•×(¯lÙ}Vá÷e{X8Óîÿ;ç2Dpð÷Ldv*…½Áöà–›“3ÿY²”'?û„ªšZdYæbþåa°� #]Ê4'üIFS�°";©N©Os¡dôFúZ#(U(ŒF%µ5 )Q(UÈHÈ’„l6Ê@nYÃéÃd*õzÓü±ælÌ^QJ Ç®}}yô¯K­–>Ň߯át|ƒûô¶iÿå—D/° IDAT•¦ Ó’7àDý,4[ede“�ó®zBûßóÏ^—ãÄ%'Sc.éøgqþb&™×!øZ×áÑt ðG­RÍô1£¯Û±͘Ƣ×wåµøçĩĿæp¾ÐôGÞ¯qgÌœž¶½O ‚ ‚ ‚бU×ÔðÄÛïðëÞý¼úÐô iï. ר²ºšO×­ç¿«¿§ªºæº÷ÈÉSŒ¾sÏ.û+w-¸�ZTŸù£(()áíUßñÕ†�­:ç`sÖîÚÑ“§YñÀ},œ<¹Á¹.…ß§´ìl^üäs6ì;ÐÞ]Z�v�Ú2nÀ@þïoçåï¿'+?�̼Ë7éýÝÝM%!d P*¨Õj”HTääa¨®2—�¨³CSY–‘,ý1Á4ñ´ŒÑÒY�l.?šURÒ`Kkª1È 6ïN%I6g¶äš èi*§‘_TdY&Ë2‘gb‰MJFF¦g—.Œ4…BAv~>‘gL£eŽDEÓ¿G\��,Û�9CLbövvŒ4��à �{&)‰ó/RVQÁžðF`YWS«cOx8™¹yx¹»1eÔH\œ.Ã`0p8*š”´tì5v íÛ—ž]»4yžuÏI¡P0°WO^QJälÊ9Nœ‰¥F§£s€?㇠CcgúRWY]MxôIÆLøÉS¤¤¥ãîâÂÌñcQH;�„QP\B ¯SF�ÄÎüeðØé|}¨¨¬"âä)$Ibü°¡t üä)ΦœÃQ«eÊè‘øxxXúR\VÆÑS§ÉÊËC¥PÒ£k†õë‹ÂœŽ?wƒÑH°Ÿ{#"(,.!ÈÏ�I#‡[Ž p!3“#'¢©®­¥W×.xy¸7óаqò~Þ^t ¤¨´”“qg™4r‡OD‘t! '&ކ¯§§Õµ>Ç™¤$’‚~=º3¸w¯FK­>EqiéYY8ÉÄáÃÈ-,äPä ŠJJñpueü°¡x_ÑÿŒì¢¢)­¨À×Ó“ Çâjž#ÂV1‰‰$œO¥¤¬7WÆly.ôŽgÔ �œKOçDlj•ŠúÓ½óåù*ô‡ŽGr.ã"ÎL1¬Écž<OFv6%eeì �`ì�Á–uUÕÕì‹ § _/O¦Œ‰£VkY¯Óë9x<’ 3qÐÚ3ràº5ü{VWyE%§âã¹yÎ, ßoÜL~Q1^î—3ª/½^ z‡£¢ÐéôôëÞ�aýûYÚŒžžH’ÄžˆfŽKm­ŽÌ¼¾tu÷hv{AAø£Ñéõšÿvq°×âìxõA:’CQÑL¼ç~fŽÅÒy³™2bvjqËï÷$9-�ŸwífÕÆÍä7|Ÿ­­•VTðÜûñþšŸ¹{Á Ü4c*�üüÚ¥/µ‹ŽOà§»ø~ëv*««Û¥™yy,i%o¯úŽe æ³pÊ$«{8Âï‡Áh$ìä)~ضƒ_÷îGg¾Ï$üþ‰o €ê*ë»�íןŸžÍGSZsyTQ¯€@î?o77¼\\ðtrÆÇÍ;w¼¸‚r£)Cs°O)I<0k6Õ:ÑiHÈ˦úÒˆ©¡ ¬%KV"2d—6\b4¿ºÚt4g:«íPØx®ªO'$ I=º\°}ðý"Nž¢_÷n( ö†å@ä þqï2 ŠŠ9“” @TìY<\]-µÛwP\VN¯�®œJH`ë�ƒ¼ôèà  “.¤‘™“KeUGO�¦SpAo0ðòGãâ䄇«+[äÀñHÞ|úIÔ*µ:¯~üéYYôïÙƒŒì 6íÝÏâY3øËŒé�žçÇk~2�S�î ¶:Ìü)“¸uî~ݵ›Ÿ·í G—.¸:;±fs4;aÅ#â¨ÕRZVÎk±D¼ÜÝØ´oQqqÔÔÖèë‹J¥bõo›HËÌbù-7�yîGgGG KŠéÛ­1IIì �`Pï^äÒ­s'ö;ÎÁ㑼óܳhìÔäóÏÿ{­FC¯�JËËÙ~è03Æ�åÎçƒ98›WXH^a!]Q*•|·q'ããyæÞe`ö¼ûíwx¹»Ó)ÀŸoÛ„‡«‹Í¯�KÏé¸aCéHVn_®[OrZ:éÙÙt àð‰ì8t˜7ŸyO77dYæÃï×qê4zö@–ev>ÂÈAyèŽÛ žˆ�£¬²ƒÑ@ä™X&FÂùó¼öéç¸:»DLB"÷îãŸ÷ßg �9û«VãïíM�Ÿ/ÛbãØ´o?ÿ~è‚|}m:¿Ÿ·í`Ãî=ôïÑWgg‹dÓž}¬|ò1ü¼¼¨­­å‹µ¿�z1“³))ô %á\2[öä_ÿ½CCÐ ¼úñ§$]H£Oh( 9l?twWºuêÔàqãÏ�'§ €ÚZGO�fX¿¾T×ÔòŸ÷>Ä×Û GG~ݵ‡#'¢yíÉÇ�$‰Êêj^úàcò‹Šè×£;©™™lÜ»Ÿ¥ n`ö„ñMžkÄ©SŒFÆŒ,Ë|¿q3áÑ'¹aÊå Ì×ïÜ�§›+q)çèÒ•Šª*v>Âô±cXö—…|÷Û&zvíBlr ½CºRPR¶ƒ‡¸yÎ,N› Ào{÷Ñ9 €ð蓦 òðáœNH`ÿ±ãŒ4Y–ùïWßp6åƒz÷¢¦¶–‡3gÒnŸ7€¤ xí“Ïquq&$(ˆÝaá–×@cZ[7O/ž7‰•ûwP©ÓñìöÍ|{ÓíhTâ+@{ÒéõÔÔÖRQ]…��µx>AÚœÁ` Ï\ºÝË  áE–e¶‡…³=,{{FöïG¿n!ûùááâ"²w:˜Êªjr ILKãhÌR3³Ú»KYùù¬üòkV~ù5Ý;3¢__ºu ÆÇÝ­½¦½»'4¢¼²ŠÌ¼<âϧ~:†œ‚Âöî’Eâ…4ž}ïžûàCúuëÆÐÞ½ ÂÓÍÕ’Ø t<Åeå¤g矚Jø©ŠËÊÚ»KBw£:µByÕÛº99³d¦õ$¾!<P¯íºƒÈ0ó$Ùô0®Woþ:eª%ð‘_Z¾Ø8¶ÅÅ�T��A–Áh®W*)@6GeSŽaviýùÑdÙHnM奀Œ§½¶^»Æ¨ /æäðîªÕ–Ç¥$žOeùÍ7Ñ9ÐtÎQ±q„EEóðÒ;3x§yíÓÏ9|"Š Ã‡q×Âù×»ïóðÒÛñts³Œ$Ö ¼öÄ£(•Jjju<òÊJ¢bã¼™¿pÚTtµ:¢ÏÆóÜß–[–WUW3¬__n4FÈ+JzV6!ÁAlÞw€Œìl^{êqKÖÚÖùnãfF@�_ýÀPTÜYEžà÷Ýà޽شo??mÝμɓ(¯¨dÝŽ],œ>�›gÏóüŒÏýß»ü¶{/·ß0ײ/_/OîY¼€ ?_Vÿ¶‰Ûo˜Ë “MAG­–±±VÇÏ)ÈçͧŸÄ^£¡ ¸˜‡^z•’²2^1Ob=jÐ@^ùèÒ³²èÖ¹G¢¢@†7žz­½=ëvìdoøQK€àT|OÝsCûš‚K]ƒYýÛ&jjkQ(|¹îúvëÆÓ÷Þ�Z¥¢´¼‚½û~“¯�æŒFªkkyñá‘$‰¢ÒRzéUb“˜4b8ÇNÇ}’¸Ÿ>ÝBÁœ-÷æ_1jà†×ÉD»äÑ;—°âý íÔ‰¥ n@–e>ûi]yîo˱S«©©ÕñêÇŸòåÚ_xó™'Ñéõ|±vCúöáÑ¿.E©PP^YÉ¿ßý€Õ6òÏûï³é|¶<ÄÂéS¹i–éy¯©Õñà‹/sêl~ã½,íRÒÒxíÉÇQ«Tè _ù:ÇcÎÐ;4„½áGI8ŸÊ3÷.³¼¾v>Â7ë74 ¼uîl*««ÉÌ͵zý—–²hÆ4KéÏ�½zòÎ7ß’SP€Ÿ—ëw좨´”7ŸyWW0qØ´…áýû79’ìð‰húvëfÙ®{çÎ‰Ž¶ DŸ�çùùùûmÏ^~ܲ�ñÆX²&OÅ'ðêã�ìï�,ˬúõ7~Ù¾Ó*ûrÿÑcï9ȳ§\·>õ•WVr17�üšJü}¼Zœ9,‚ ‚ �©¬®fßñHö�l﮿sIié$¥¥·w7„?£Qætb§“Ú»+‚ ˜µ¼¶¥ÐêT’ÂæÌº«•W\ć¿üŒÑhDâÒ±d\y|á"«¬(/Wn=š/–ÝÇG·/eBHT*¥9(hž±P¾´‰¢ŠÊziÅùÕÕTéõæà ©d¨»�B…$ÙœAh¯Ñà µgû¡ÃäšJŒÆ$&àãc  èÙƒ]ºÓÌдѣP*M[��_OOª[8¯œ$IÌ?Îò8ØßÀ2?]T\ㆠ±*i9kü8Ô*•U¢®è¸³t ´*&�Áͳg¢×ë‰MNF©P°`êdËzooFÄéÄD«}Õ½.>>Œ|¹}> ½uWÌ.o£ÀØDF™Z­BßÀä°zƒÁ’½t=­Û±“�{ö1eôHfOO�Ÿ çϳú·MVí�”’¥NÐR§Ó[>:Ï–jêyV©”tëÜ©Áùðœl›§æRÖ NgÝW½Þ˜Q)•Ø™ƒ¥zƒ —ë­ëÍÁ›æ^«å•<ûö;8;92eäHæLO§^øß{õÚ6õº´·³kpra�¹¿-ÑÔ¼ IB!IŒ4�þ={Ô[ïëéÑè¶GNDÓ9 €¡ýúX-¯©©%,*š›fͰœã•×ó|ou—:½®~ƒe�÷Ÿææ„ûËÌéÌ7–è³g9“”̺;Ùqø0o<õ„åù[4cš%“¶®+¶×C_3�·í ¬¦†gwlá«E· n‡÷ A¨+*>�ÿûv�Õ²;çδ̙,‚Ð$I²|sq ‚ ‚ ‚ðg'JŒvöJÕÕ$«5Ko0ðöšïMåeÓÓ-#£V)xôÆEh5vÍî㠘г'_,YÂ�úag§FBÆQ뀷yî$Y–‰/.Äh”1W!ÅÇÞm37ÚëC£hùÍÀ¬¼<<ÜLY0~^ž¤f\´ xȲLÒ…4ü½[žÕÚü¼¼ÐéõôïÑÝòÓ£Kgb“’�e¹ÑmΧgX­/,.áÑW_'%-?// KJ-Yi—$¥^h—sÞwô3Æ�å®…72b@|¼ë•-mΥ쭳çÎY-O8ŸÚª}½’Ÿ—å••VÏO¿îÝHJ½@uM� {_OO$I"9Í:K4éÂ$IÂÏÛËr~I©¬Û¤^À^£Á݆òž'ãã)*-å…îgæø±ô ÁQ«µ”Kµùœ½½ˆ?wÎêõUU]MzVëN’.I~^^ŒF«ëÛ50�3‰IH�d_*#:kü8æO™lõ3mìhr ¬®uRê«s)¯¬$=+ÿ:‘W^÷œ‚ŠJJñónmÚ»­=㇠å�Ûnaå�‘_TLü¹óøy™Žå¨ÕZ�«�ZEâùÔƒ†×Ãm39¤›åqlN6Dn—¾B]·Î˜JøêÏYóú‹íÝAþÀ4vvôéJï�®–9‡AAAþ¬D€°ƒ��дÁ(ùmáaœN=o½ÉH’©àßèž½ׯ¯ {¨ÏÉÞž§gÎf弸¹Þ\p#žN¦á…òR kªLÓ"c§PÐÙÉöòu¥ªÉ쮲ò "N�¶ü‰Šæ³Ÿ×q*>�™ãÆ0zð J+*øaÓ £‘_vì";?ŸqC‡Xí¯¦…A«†ÔêZŒ?l‡ODqìt ˜3›VmØÈ®#ḻ6;d0¹……ü²sF£½ÁÀ�[·QU]M�ŸƒûôÆ^£áËu멪©A–eŸˆâDl㇠ipŸmÉQë@nA�%Hs>#ƒÍû`0šÌ„¬+È×—®A�|¿q3™¹yÄ&§°+,¼Mû>nè’/¤±åÀAŒF#²,³qï~~Ù¹«Ù ÂK™«NŽ ìÕ“õ;wYÊÚ¦ge³~çn÷é�£VK�Ÿ/�øaÓK`7áüy¶:ÌØ!ƒ›ÍD¥NFc¶¹¬¨Þ`àÇ-Û(*-m0‹®1c‡ æbN.?oÛŽÁ` V§ã«_~µiþÍ–~ÇžðpNÅ' Ë25µµ|õËz�ÄÍ¥áLãC'¢P«TŒЯ޺‘ P(‹:iY–ž�͆={1�TÕÔðùÏë0�Œ8ÀÒ&&1‰ýG�#Ë2¥å|öÓZìíÒ§·MçQY]Í›·°ïè10B8›’€�—'ÞîôìÚ•oÛH~‘éùÍ-(à£~²<_íAVL�I@�²¢ßEŸàÀù”vëÓŸ•³£#Ý‚ƒè€£�ÙÉ…N§cGX;Â"ê•ÆÎÈÉeGX"£­–ŸLLâ‡m;ùnËvÂOŸÁxÅ€µZ�«“c“ï³Iil‹ ºÎ|¢™yùl‹àðÉÓ¤ee³=,‚Â’268ÌÎpÓï艸~ܾ›Âëþ–WV²íH«6mcÛápÊ++µJu“ë3rrxwÕjËc¥R‰¯§'·ß0—É#GÐ9 €eYȪ_coÄQ :½žÛçÍ¥wh®®¨”J^ùèΘÆÐ¾}=fS¼=ÜÉÊËçÉ×ß䉻ï²i›)£Fráb&ÿ[µgGGjjkÑØ©yô¯Kqh$«¨[çNÜyã|¾ß¸™-û‚¹LåCKnGc§FƒšGï\Âû«¿ç¾çÿ�½FCeu5SF�dêèQWun×bñÌé|ðÝüýÅW,¥4§�ÍšÍ[yáï±ò‰ÇlÚÏCKnçõϾàÉ×ßÄÁÞž�Ž[æÌâ‡M[Ú¬ï½CC¸}Þ\ÖlÞÊú�»Á\žöÞÅ‹öo|þCo;NZf/=ú÷ݼ˜·¾øš§Þx 'GJË+ â¾›ƒ9›î‘¥wðÖ_óðË+qrp ¬¢‚þ=zpÇ smêkÿž=èÛ½/~ð1~^ž—0zð úuïÎæýðñô`˜ öêÉ¢Óøu×¶8„Ñh$ÐÏ—ñÆšJ 7ÂÇÃÝaá<ùú[<ÿÀr›ú!]ùô_ÿÀ£‘�1 Ùv8œOÖ®gú¨ îe*Sw–üï#BƒƒØüÞ[„�:ËŸ~ɤ¡ƒ9u£,ó÷›ÿÂçë7 ÓØ}ô8_üçŸ8q’§Þy�²Š*ìì쨭­ÅÓÕ…Oÿõ,ýÌßjjkYþò=cðëÕ¥3_¿ø®Ö¶‡å½5kÚ§'?ôÙ�‚ ‚ ‚ ‚ t\’ÜXMC¡Å +ÊÿùG6µ�}ì™—ç×T¢—m˰²Åk«¾áç=»‘UJ�H*5Ü0ŸåóæÙ°µíôF{.¦RX[ƒV©ÂN©$ÈÁ‰þ>6eB¨$^šÖË¢(-¯ 15d™n�;ávEÉÆŒœ2²s ÄÇÓóªŽ!Ë2qÉ)TVWÓ¿Gwì5›·ÍÎÏ'-3 ­=¡�:¡µaÛâÒR’.¤a¯±#48¸^0£º¦†øsç©Õéèà�Ÿ—måÛBQi))ié¨U*z‡†`§V—œ‚N¯g`¯ž6ïG§×îUÕ5„ãåÞð<�­­¸´”ä i¨ÕjºáâäØdûšZg’Q*” êÝ £ÑHÒ…4ŠJKñtu¥[çNõ~ô‰çS)­¨À×Ó“®A�-ê§©|î KJ öó#Ðׇ²Š â’S ÆÛÃö¹îr I͸ˆÖÞž>¡!ÍÎ_i49“”Lmm-{÷jvξº2ssIÏÊÆÉÑ‘n�:¡±kzp€­ž}ûõîÅÂéS‰KNAo0гk\œœ,m–ÿk™1�ñÆXJØö ±98xåydææ¡R*鈛³u°Àh4’˜z�Òòr¼=ÜéÔ gÙ:VEçݰC–ÇýýüùrÑ-¨š˜´¥²KŠpÐhp±ÿ}eÈÙª¨²‚çÒ£mh ÷×àòìêò—7ýÉÙÌÚkÿcëq¿ùm o¯úž~ÝCùñ�—‘Ì™…ã—ý�²Š*¾yù_ ïÛ›wV¯á‹_7Ñ%ÀŸwžz�FÍ3ï|È™äs,˜4ž•�<`Õß”ô‹ÜðÈÓœüéìì.6¾÷ÃZK€ðÝ<À¦‡¬„¿ìÞÇ ~ÆÈþýxèÖ¿°ôùqÐÚ³óãÿ±ðñg)©¨äÔO«(,)aæßŸ ¢ªŠ÷þñ8ÓGgd4_ù�üüØòÁQ*lüé—Ë•õ„×ô¼6¹¶‰íšÝ°ñ×2”¬=^ÃÍoÖFçÚäÆmô¼^C‹ëñÑòµMl×N¯aA:–üòRJª*QH]½|Û»;V<'Nkï.‚ Â^Á�ÝíÝ…?$‘AØÁ8©ì(ÖU·Úþž¸í6ü¼½øzÛ6Ê*«õõão„}Ç IDATŽ©­ûåÕ(Ë„eg�YY�½Z� 8¨Ôôr÷²98ˆùÜ[“‹“c“ÙSA¾¾–LŸ«%I}»w³¡e}~^^-๹¸0¼ý2‹—Øk4–àT{swq©wýût m´}cÔ*ý{ôhÅžÙÆÍÅ…aM\ë+iìÔ ík}¾ …‚ž]»4¹�J©¼ªër‰$Iôèb} gGG«rš¶òñðhÑ|< …‚=¯î¹ ðñ!ÀÇ窶µ…ÆÎŽÁÍ” uÐjë=g-ÕÜy( z…t½¦c´•;‡ çdÖEœ7Ic²³øôXŽj8�%uÍ›8–ÿ}÷#±ÉçÈÈÉ%Ø×‡Cѧ(«¨¢³¿?Ãú˜>‹vEDpÇÜ™ôèÒ €¿Þ0‡§ßù€ƒuJ·¶ÝBèäoúŒïÞ)OWW|¼ÉKH¢¦¶–�aÇ)¯¬¤w×ÎL9€IÃ3¸wNÄ%›|Ž=ºQRQ@ZvQg3°ÿ^~7ÿ^~wƒÇ½wá ,™; •RTñAAA¡cÂÆ^©B¥W´Z¡ÆNÃÝsæ1gähbRÏ1´G/µ —±¼ZG²._\ˆFmÊ6²Wªâé‹�¢é죺T’û6˜ƒQAh˜i>ÂYÜöÓwd—•ðõ‰ã fTpçöîžÐÁy¹¹1vðDF³#ì(÷.¼�­‡#X8u"’$¡7ÈÌ3ÍÃÚÉïò€œ@_oŠJË(¯¬ÂÙ±åÙ»ÍññòÀÁœ™ïhoÚ¿ƒÆôýÇ`0’–“ ÀÙóèµð¶zÛ§eç0 G7æŒê߶rîâEyóÿP*ô íÊâi“¹eÆÔz¡4vvhìZwÀ“ ‚ ‚ ‚ B[™ÈY­¡¨¶ªU÷éëé‰ïU–ÑlŒQ–9˜™ÁÉ¢<4J ¥’‘>¸ØÙ^jó9 ‚ \«YÆ5û^·hÆ4zt0W{{^ž6‹û7¬Å(Ëe™vncÍ­Kðvt²aŸÙ�“'r 2š]áGY2gû#£P)•,˜<Ì´ …0`0\ø¤7,ÿo0¶|@TÝíkjžÃQUg�RCÅ . – ä‘Û×[ßËœýíéêÂÆÿ½ÉÁè“9ᨓÄ$¥“”BvA!�Ý~s‹û/‚ ‚ ‚ ‚ЈúG�F¡ìðÙtÕz¿¥¦p¬ Y•Љ~�ðд,CÑ^©BÓ‚lCA„ÆL1œÞ¡!M¶™5~\‡š °½ bùˆÑ–Ç…U•<·sF1E±ÐŒIÇàêäDlÊy~Ù³ŸªêjÆ ˆ�»iîS…$YÊ|&§_´l—œ–€¯‡®WÌïê ½<`¨Z§·Zw)¨w17ײìTRòUõ½k@˜ç+ž:b³ÆŒbÖ˜Q êÙƒ1ÐÅßÌóàf0qÈ ^~à>ö~ö¯<¸€=G#ëí7#7—°S1Ä$Ÿ»ª~ ‚ ‚ ‚ ‚ \/"@ØA9«4HØ>ßõ”YQ·ñ§ˆ,ÈÁ Ë(%‰NŽÎÌ Å­…ÁAÎ*‘=(‚Оî6’‘Á�,�O\LçóãíÚ'¡ãS«TÌ›0Y–ùèÇ_øËÔIVmþ2ÅôxÕÆ-ì>zœÝG�óÙº Ü2sj½}úzzZ‚†ï~ÿÛÃ"ˆ8}€n�L�ýÄ é¼½ê>X³Î²N6¶, =iØ`¼ÜÜÈ/.æ_}ÎÉ„$~ܾ›<Æ”å“_\ À‹Ÿ}Åœ‡žäåÏ¿æLÊy’.¤‘’a vvïT Áoûq÷^嵯Vµ¨?‚ ‚ ‚ ‚ ×[ÇNSûSJ®j źêöîŠE�^ÏÚ¤³ìÊÏÁ×Þ�B�J)1ÊÛ�¡Þ~(ªáÕ µåUl'‚ ´…$ñêŒ9Üúãjò+*øüxƒ‚ÜÞÝ:°S&ðýÖ——ã忯„¡ƒ­Öß1oY…|¿e;�¾ñJ…‚ÛfMgÙÂùõö§�$^yè~^üä+ÖlÛÅšm»èß=”ŸÞ|…ñC1nП<ÍW6È?î^Â#o¼CumM‹úí¨ÕòÉ ÏðÔßgþƒlØwÌs%¾þÈx¹¹ðä’ÛHÏÊaýÞ¬ß{À²ýˆ~½yîž¿^Õ5AAAAè$Y5ÄZKaE9ã?ÿȦ¶±�=cS»R] •††ç×¹^ FÙù1!ž„✴ôòòf »“:á£u¸ªý:ªÔ"{P¡‰¼˜Îß6¬³”õtp`Í­KñrplvÛ†d—á ÑàbuŸ]QeÏ¥GÛÔöãžã\ž]]Þàò¦‡Î4³öÆÝ\Íq/ææ‚ ö;K`íJÅee$¤¦нS0®.Mö×`4’•Ÿ²„Z­Â×ÃÌóÇŸO¥ªº†=ºƒl$§°•R�¿—UU––áî섃VËÅÜ<ìíìðvw#¯¨˜êÚZ}¼-ƒ“ F# ©i•–âéæF�ÎÁæOÖ�ÊÈÍ%3·#Á¾>úx7xž%å”VT ±S[J­6[cmÛ5»aã ®eW{¼†›ß¬�εÉ�Ûèy½†×û=¶µMl׊¯aÙ<ßî¥u …(¨#¿ù奔TU¢�$ºzù¶ww¬xNœÖÞ]A„?¼‚»Û» H"ƒ°ƒsVkÐËFj�†ë~ì‚ÊrÂ3ÒØ–’DNm5F;{´ ÷ôæ¶žýèâìbÃ^f§Pât ÁÁÍû�’–ÆØ!ƒÖ¿_½õ_¯ÿ•Ò2ë›®ÎNN„v fÜÐ!(Í7¶8H~Q1wÞX?‹¡®˜ÄDöF#'?I’òõeê˜QôèÒåªÏ¡=l=p£QfÞä‰mvŒ5›·ìïǸ¡CÈÌÍcÓÞ}8;9rû¼¹mvÜëéÝU«™=qüïîù?y‚Ìœn�;€²Š ~ݵ‡¢ÒR½s 1‰‰„EŸäþ[nn³>\ùú:–a�ÁÜ=t_F  ²’»wðÞ ¯*K\øsôñiöÆ¿›³3#û÷µyŸJ…‚ Ÿz·÷’DŸ�®–ÇJ‚}},�µZµZËãºë¼Ýë/• }Bš/òñ1÷§é`…«“c½yAè8jjkIN7̓ê忆Ÿ—g{wIAA¡Ýˆa'îvöÖV¡3Ûì8FY¦¬ªŠŒ¢"âórˆÊºHrI1²J�»ƒ–áÞ¾ ô dD@öZöØ8•¤ÀMmÕ£ˆku:ÖïÚMUu5Åee OÆÅ|y~ óì:ÆžðþýਔJ’.¤‘ž•Õäñ~ܲ��{÷1¼?ÆJ­NÏ©øx^üàcþvëÍŒ6ô*ÏäúKº�†±Îëè‡Í[èȘÁƒZí1‰IȲ æËòñš©®®an%¯·ˆS§> ?=:x|pWX8•UU,˜:€´Ì,âÏ�³¬ÿiëv"ÏÄ2cìrò 8q&ni½>|³~ýztgX?S`àÊׇÐñ<0r §³29~1€°´T¾�Žä®!ÃÛ»k(¥dçç“_S‰·§'ÎŽÌ,KAAAA¡cÂß wµ–‚Ú* rë uz=d—•R­×á®Õ2«go\4öø:»àå䄪•Êï(%vÚkÊD‰Š�£ªºšÉ£F²ÿè1ò‹Šñj + O÷Ðz™P;á›_7Ljý›=Viy›öígþ”ÉÜ:w¶eù‚©“ùà»øæ×ß5h jÕïãWéÑ;—X=ŽIHB¥PÂàF7¹fÙùùÜ.NŽ„÷éM›€¨N¤gæäR¡«dã®Ý ïß�”ŒL KJУ;RX\ŒÕjeóîH®<ˆôìâSSQcVl\r »£a0«JôöíƒJ¥²µÇ&'s$6޼Â"Ü]]Ø£!�U1lܵ�¾’ø”TvŒfpï^ì=ƒ»«+CC z- }GŽ[5³0,4”!}zÛÊçÇð~}Ù¶w )©¸:;sÍ x×Xß++/�ˆý),)ÁÛÃ�«ôÇÝõâË ÿÛù9»ðþ 7ñìò%X«×i{}í*Ü;·FÎ"B!„B!„B´>Y•ý2¢TTÍ$Ô(U è}iÑ(UxiP529XV^Aôñã ïßO77:µkKÄþƒ Ÿ™›ÐàRn~^^¨T*–nØhKþœÒ©];Þ˜ü¸-9XZ^Î3f²hÍ:ÊÊˉ>q‚×?ÿ’Í»#mcVmÝÆº�;™úÕ·DŽ¡¢²’˜Ø8~XðgUÙÅjùEE|óûðëߢ6û¤gg“[PÈ·óæóéÿæ`®NŸHHdÑšuÌüu.ÿlÚBV^^½ç³9r"vu(† �ŽŒì=Àší;øï7ß‘˜–F…®’?W¯á½ogÙŽS®ÓñÖ_±põÊÊˉ‰�å�3Y±«Î±Ì ‘ч0™Í$¦¥LF“‰÷¿û�¹Ë–S\VƉÄ$Þžù5["£ :�ôñ?2gñró ()-ãÏÕk˜úõw¶¸’ÓÓ™»l9_Ï�ÇŽ}û¡:9õÆŒ™”•WœõõýiñVlÞÊg?ýÌ‘¸x,+©™™¼ôéglˆØ…®²’­Q{yùÓÏ8_ï>JËËyõ³,Y·�Òòr²óò˜õÇf/\@jfßÌ›�É|z†Pjf&³æ/¤R¯¯wŸûŽeé†�¼÷í÷œHLÂl6³xÍ:>ü~¶í>ÉÊËãåO?gÕ–m”WT°/æ(¯}þ%ª~ŽÆÅ““_@Qi)‘ч0šLDŽaÙÆMD;NIYU‰ÃÈC‡8ÏïËþ±Å±>bï|õ qÉ)”–•ñó_KøðûÙ¶2µ[öD1õ«o‰>~ƒÑÈÞÃGxcÆ—OH 2ú•z)™DŸ8À²M›ÙS}<€ïüÉŒŸ%#;‡Ü‚Bf/\ÄÇ?üh{mS3³øõïeÌš¿�­{ªî‰Í{öðÆŒ™””•UÇ�ÀKŸ|Æþ£G©ÔëÙ´+’W¦Ï ¨¤ä¬¯½8¿¡!my OÛ÷Ùe¥¼½~ ÖsŽB!„B!„B\dáeF©Pà¡q Ô¨§ÂllípÄQ¥ÆE­½è5kÚ�Åjehõšyƒ{÷â—%KIËÎ&øŒ�…Å%>y:)•“ŸÏòM[ÐjÔôêÒ¹AÇsuvæ�Ûnaî²xqÚ§„Ð#,ŒîW…Ñ-¬c­Ò¢ V®Æ`4ðÙ«/áêì ÀÜeÿðëÒeôëÞͶmëž½LyèÛ F³ÙÌÆ]»IHM³ÍšÛu ­–�={r4.ž5Ûwðü¤mcb““ùï×ß±%r× @fn.킃˜õßwTòô¥G'ñúç_Ò§Kgîs#T'0ÇŒÎÄÛn�ê$ÓŒŸ%=+›�À­^KYEÓ_y wæ¯XÅÜ¥Ëéß½5fliÔjÞ˜ü8ÿ÷ÖTn1Üç™ÖíŒ !5•�^œb{ g/\ÄÂÕk¸fÐ’ÒÓ9ŸÀ›O>n›e›”Ì;_}CFv¶mÆ\Yy.NN<÷ÐÜ4b/N›Îñ„„zשœÆÑ¸x÷îź;¹ªm(o?5…BAYE/Nû”C'N2b@ÿ:Ç ÷Ì�aDge�™ÀŽäDæGïgB¯¾­šBqÁìììôñÀ¾F… !„B!„ø7’„—!àªÖâ®¶ot¹Îæ¤@�»Ú×&JR�XéÖÑV:pP¯ž(•ÊzgF?ÁGß϶ý›ó×ß8;:ðÆäÇñõôlð1GÊ7ï¼É#w߉¿·7[£öòÉ�?ñÔ»ïך¸ÿè1nnKÜzÝ5è FŽÆ'ض]Õ6´VyÓ.ÚãéîÆîèhÛ¶ˆÒ§7Z�šýG�XkLXh(]:tààñ¶mV«•{ÇÞÔ¨õ(HÎÈ$§ €®;ð¿ß³•ŒÜô(#Çؒƒ·^-F“‰#±quÌGŽ2 G÷Z Þ[¯¿–›F Ãh2Àÿ>|�n;ÚÚ+ †ªÿê µöuãˆa¶¯}}QÛÙÙúžÍµƒÚ’ƒ¥ååÄ&'sëu×¢Q«P©TŒ¹z9ùùdææÖõÀ|ÿÞTÛënµZ1�ŒF,V+>žtiCdô![{Ä� ï߯N²±&­FSkÝÆ� @Û9[­Vbbã5,''”J%wÞp=å: ©©ç<熈9‹Åjå–ë®±m kÊCwÜf;æ;ÿy’'î=½Î§ÞXõÐÂù®ù)Gbãðóòbx¿Óɦ®;Ð¥C{Ÿ8Y«ï�ÆھöõòÂA«=ýú+䑘–€³£#?¼÷®$›€J©äãQcq«QÚöË�Û8T�0O«ÖàéæŠ‡› ;ù�Z!Z‚�J…§›+žn®8:Ø7`„B!„B\¹dáeÌ^e‡F©¢Ô¤Gg6µv8µh•v¸ªµ�.)ZSnA!'“’ðñôä£ïgÛ¶Û©TDì?À¸GÕJ¸ ë×—IwÞnû^£V_tòÌÝÅ…‡pCø¬V+ÇY°r5?.ú‹`ÚøS\ZÊÆ]‘ì>x¨ÖX…BAqI©í{?oï:íá}z³'ú0nKfn‰iéœFÛ @º‡…qõÀùùb4™È/*fëž(öÆ©{ŽÕå/Tv~>]:t¨µÍßÛ»ÖŒ³üÂ"þÞ»�Ô¬,2²s(.-­gOàåîQïösñ÷>} O•f�÷Ï þ\½Ö¶ÝhªJ|•”èë[k¼ÚÎŽã DF&37—Ô̬:¥C÷îÅÒõyÌlædR2y…E ïîXžnn¶5&©NÞžRTZŠÁh$èŒX}}Q(äœsvbCdçåãáêRkÍC€‡ŸNÂ* 6DìâdbYyù¤ee¡P(èØ6´aÇÈÏ'À×§N¢4È×—ø3’œuÖ€¬1æîÑ£øâ—_yó‹™ø{{Óµc†õëÛèk ªø»¸ðÞÈ™²b)VÀd±ðÚºUÌ?±VâP\G{ìQVÊšŽB!„B!„¢åI‚ð2§T(pSÛc¯2SjÔc²ZZ5;…Wµ¶YÖIܹ? …‚k @U#qâæâÂŽ}û‰KI!,ôtbÂÎN…“Cã>xݱ‹Øäž¼o¼m›B¡ K‡ö¼úø£<þö»<~œ¶ÁA ï×—NíÛÕÙO€Ïé¤`}IÊð>}X±y+Iééì;r”`?:†† R)iãïÏ]£o¨3ÎA«­q¾v眑Ö½»tæ›wÞâHl1'c‰8p�ÕÛ¶óÆäǹª][ Cúô¦[XÇ:cý¼½.ê˜J¥ Ë9îÛã ‰|0ëzuîDxŸÞ„øãèàÀ”§Õ³¯ ?;»Ó÷ª�ªê뛯½Æ¶¶dM!ÕåLkZ³}s—ýõƒrÃÐp‚ýüÈÈÉá‹_~³õÜ«'ó–¯àH\<{&,4´Vr·>ç:—S³M¦Ú˜Ì–ZçÑJ•‹åì«Í™Íf¦~õ-•úJnçúðÁ´ñ÷gÖü… >†F­¦BWYg»ÉbAuÆ9œëÞòóeú+/—œÂ¡'‰Š‰aÓîH&Ýq;£‡=ë8ÑpÃÛ¶çžž½Yx¨j¶vVi n^ϧ7ÝÒÚ¡ !„B!„B!.‚$¯Z¥ ­Ö½ÅD™É€ÑÒ²‰B;…'; ªæ»¥vî;@׸cäõµ¶—”•±ëÀA"ö¬• l f‹¥zvâè:3˜Ìf3‹{­µ��mö^�«Âl} KJX³mÁþ~uö]S»à }}Ø}ð{cb¸®Æz}>>¤dfÒ=¬c­$ÉÊ­ÛêÌ kŒŠÊJ6Dìbp¯žôîÒ™Þ]:sßÍcxaÚt"¢K‡öøzzbµZk�cqi)«¶n'Ð÷Ü ¯³ñ÷ñ&>¥öl±cñ |¿àO>œò,Ûöî#ÐLJ—™d;ÿúJ}6?oo öM­sLLK'2ú];v¨3fKd#ú÷ã±qwÙ¶%¥§×êãåîNXh(;÷ígÿÑc¶5ý.–“ƒÎNŽÄ&§ÔZ_1.9«ÕŠÿ³T/F€�…%%ãé~:YúÎWß0¢?BIÍÌäýçž±%³ô ,/JuR9úø F£-éiµZ‰MJ¢}›6 ÞÏÚí;iD§vm kÊ]£oà‹_~cçþ’ lBϽšCY™ËÉ`C|,‹b¢×½Wk‡&Ä-·°ˆ2�OÜ]œ0âßÍl±�SP„N¯Ç×ÓçF>,&„B!„B\©d Â+ŒVi‡—Æ�ö*»&[û¯>Šê2§¼µŽÍšLJO'-;›¡}ûÔisuv¦kÇŽì:�¥‰£ÃúõÅÁÞž?ÿJlR2zCU©ÉŒœ¾�7�ZMxŸÞ ïß�UÛ¶s$.«ÕŠ®²’ÿ-ú‹ˆqs>ÿzá}ú°!bÙùµÖdÚ·7¹ùü¹z £«ÕʦݑÌ[¾­¦ñëV�Z7N«V³dÝ­Y‡±zfZzv¥eeøyUÍÚ¯ëvFpød,V«•J½ž9ýÍŽ}ûk­Kx!†÷ëË¡'Ù±o¿íº-Z³'{œ�qvp ´¢‚ŠÊª™f%eeü¶t9SÓ–Öu´·§_·®,X¹Ú–„,(.fÖü…$gdÔš¹zŠ“£¹˜ÍfÒ²³Yºa#Æêk 0¸wO¶ïÛ�Þ``HïÆ'T†õíËúˆ]«^ß² ¸˜¹Ëþ!ØÏ�öm‚mý 5b¸½:wÂÙÑ‘ŸþZb+™ºvûNâ’Sè‚cõ®ÙùùP�Ø[¿3‚‰IµÎû\1 éÝ› �Žß—ýƒ©:á¾tÃFÒ²²ÖïÜ%XkÚuð ¿-]FIu™ÛâÒR2²sl³Z“Ó3˜ùë\ŽÄÅ_ÔµU4*ÓF�Å©Æï�Ï·oáxnN«Æ%DSšµh ƒxŒ=1ÇZ;Ž$$0æÙ—úðŒ~j x”;^|½N¿5»é?ñQîzùÍV‰óÙϾdð¤ÇÉ.(l•ã×´v÷†<2™!�<ÁuO>K÷ñòê׳êôûrþŸôš0‰W¿þ®UâB!„B!.2ƒð ¥UªÐ*UXÕV*Í&ô3‹‹õì%B©P ©ÞwU²9S�§íØwµ�{v¯·=¼OoŸ›ó Oý÷íí1�Œ½f¿/_Áë3¾ä³W^B¥R1¨WOæ.û‡>]»àìèØè8Ç�¹‘ŒÜ\ÞûvÎNŽ”WèðñôäåG¶Í´ôõôdëž½¼8m:ï>óÔíßA«å¹‡`æ¯syì­©8ÚÛSQYÉ}7�¡]uIÝ«ôçÛyóY²n%ee\Õ®-Cûöa÷Áh||¸sÔH|<=Y±y ‰ié¼üèõŽäçËc÷ÜÍÏýÍ–=QØ©TL&Æ�¹±Ö Îó¹ÿÖ›ùì§ŸyòÝ÷qsv¦¤¼œ@î;ª×lÜ}ˆ^�;C=³@Eõqsç�ëFñêšÌf^]»‚?î™X+q(Äå(¯¨ˆÿþ‡^aؽKk‡Ã+_~G\J*#úöæ¶k†£7ë-SîíîAxÏøy]ø:¼�µ÷èqVn�à±ÛoÆÏ³å�_SYES>ÿ �^ÏøQ×3¼WOJ+*¨§zh€Ãzõ¤SHÓVžB!„B!.' «µ‘#aSXQΰÙß6¨ï‘)¯4{<õ1Y--LV æê+X±rêVP((P T€J¡D¥Pb§P¢VVý÷ß*%#“ìü|TJ%>žûû×».ZZV6iÙÙ¸9;Ó14¤Þó.Fiy9'“’P*”´ ÂÝÕµÑû¬Ôë‰9‹V«¡ÇUU‰ÕâÒRÓÒ1™ÍøøäW·ŒiZv6iYÙ¸:9Ö6´IÎ1;?Ÿäô œéb+9IuÙʉIè ÂÚ†âîâBFNéÙ9t 눣½}£�_“Õj%.%…ü¢b|<rØöý¨°NL=¶N¿ÌâBœµö¸Ø_™%þ +Êy#õ@ƒúÎê4¬ÞíY•eõn?÷Ý{žÖFÜú{ÜÆü´�;ÞÆ\‰ ÷î?±píFMÿ�îçx§%®¯Áh¤ç=`±ZY1s:W…†\ü¹žwàŽ®V«•q¯¾M|Z:[~ø·3ÊŸ¶ô=|(6ŽÛ^|µ�Šã‹ÿ¨wÖÿ•~7´GküŽ8ë9Æ5Ó=,„¸¼ä••P¬«@©PÐÎûÜKx´4¯«G¶vB!Ä/ë†ÖáŠ$ Â&T¬« ü‡oÔ·µ„BˆÖ1ïŸl‹ÚË·Sß¾¨D�—½ÙĤŠ8Q£¼èÔëGq[—Ú³¼Ó‹òqwpÂIÛ´IüK…$:î<Ǽ’+ ééÜúÜ«ŒÄç/ùœWšÈãwÞZ«-2æ(ßü¹˜c‰)ØkÕôítÏOOû @Û+PZ^ÁóÿdÓž(ŠË+psrdä �þÏä:×âR¸‡/¤‡$…—#I !„ÿn’ lRb´ )e¶Šâ Éé|þó/äòàí·JrP\´*;>=–ûÿœG¹ÁÀ´-›èâãÇUÞ>¶~‹µA%–…hm3æ.@¡P0åþñµ¶gäæqßëïP\ZÎ�ê2ž+·ï$òðQ~}ïmúuíÀÔYÿcñ†Íx»»1:|0ÇY¶e;‘1Gùg槸:9Q\VNL|"j;;æ¯YOXHŒ&3»¢c˜:ë'~šú …‚6~~˜Ìf[‚0ÐÇ GmU¬æ kOW7Úøù‘‘—KôÉ8t•ú:ç•’•ÍÓŸÌÀh2Ó§Så•:ž�þ%é9¹x¹×­D�SPHL|B­Yüõ1šÌ|6÷|¼xðækµí>|„‡¦~€ÅbaìðpÊu:VîØÅî˜#,ÿüS¼=1™Í<ôî<Kû @ÆÂŽèCÌY¾‚Ãqñ,üø¿( ²òó9ŸÀŸë7s,)‰N¡!¤deùÖz{óêC÷£U« ñ÷#¿¤´*A¨TâúƒãSÉF/ŒFÇ““ˆOË m` gZ¿'Šÿþïg®Ð�}GO°:b7iÙ¹x¸^ÜzËB!„B!Ä¥H„MH>øBœÉÇÓƒ±W_�¿�7½:wjíp„h2!î¼2âZ¦nX Õ³ ß\¿š¹ãîÃÞ®*¹`´˜±SÊßFqiÛì"÷òàÍ7ÑæŒ²Ús–® ¸´œ�ݺ2çݪ^€�7¬ZË?ÛwЯk'R³søkã¾óz„uÀh2qÃä)dæå±|Ëv&޽ÑVòòàÉ8L{�>�ÂXµcS>›Éž#ǰZ­h5f¾2… ]%½ïÛÀ[�=Lh€�™Pnº� 7ÝÀ?ÛvðÂŒ¯ë=·…ë6`4™éØ&˜ùÿ;•Š)ŸEzNn½?›};wâ‰;o#Ð×§Þý�²`Ý32ùô¹§°?cýÑ/þXˆÉlfÒ-cxç±I<ñÑtö;ÁºÈ=<4öF6FíãàÉXííY2ý#\�œÈÊ/`øcOuôQÇŽ3°kÛ{ëÃqñ¬ÿv>|úÛÌZü7» Cpß¼úQG�sÏk1h5j¾­g&%Àk=À´_矶¬Þ>¿­\ÀÍÃÂùö•0™ÍŒ�ò2È{}!„B!„WI6!�J.§¢6GFÚÚaÑ,néÜ�¨´TV? @|~Ó·mæíëFa2›PËê¢�¶î=@l�ò�NööÜwÓ¨f=¦ÕjeúoàâäÀ“ãî¨Ó~4±jßÐ>=mëšN™pS&Ücës<)«ÕŠ«“#=ªÖZUÛÙÑ·ËU¬ÜžGlJZ­}vlLŸNatn Õkí–UèpqrlÒó‹OÍ ¼gw[rkä€~¬Ø¶³ÞþCzvgHÏîõ¶�R®ÓñõÂEt áökFÔj³Z­IH`xï^¶í?¼ñr­~Ç“èѱ®NNø{yÒ60€ø´tb“SØõt ×kû÷ÁÇÀ®íÚ�_\Üàëp!âÒª®Ùˆ>½¡:)xMÿ>ONi–㉖e0šÈÎÏÀÅÉw™*„B!„ø÷’ŒVRI5!„ÿ2¯_}=Gr²I,¨úÀõï£1ô jÃ5mÛ˃3ç 7)¯ÐQ¤/ÇÑÁþ¼%ÿ VíˆàŸ­§Wþ^žÍž \Å�ã'yþþñõ–�ÌÉ/„3ÊTž)¿°¨ºOíäž³CÕ˜œ‚¢ZÛ=\�m_kk¼îVš~Yð’òª5.�jÄïtŽsiˆÙ/'¿¨„OžùO�÷¾e:t••¸:�ý8¹…U×ÕåŒXl׬ðÌkvº꩟•æ¸^%eÕ×ÌáôÚ©.M›¸­Çb1S\ý«íì@òƒB!„Bˆ1ùäN!„ÍA­æ“Ñcy`ÑèM&>Þ²�€›o#ÔÍ£µÃ»dUètdäæ’§¯ À×[„À˜aá„…†Ø¾w²·?gÿÆ2šÌ̘»_ºå¦zûø{{’–“CAQ‰m[^Q1©YÙ8;:Òo/ŠÊʰX­¶5© Š«Æxº�™�h¹5«½Ý«~3óòlÛR³s.z9Eü´l%ƒºwåÚþ}ê´»89âìèHYEyŧ¯Yrf…¥¥xº¹âçK �7%¥µÆŸºfg&k[r•owÊt:²òòmÛR²³[0!„B!„¢eÈ”7!„B4JG/o^~�íû £‘6o@!3럺»ãÛ¿ûnº¡Qû‹>Ë#S?âÉ>­·}ÑúM$edòì}ã°×jëíÓ3¬#ëvïÁ`0ðí¿¸÷µwøì·ùtïе�Š ]%Ûö ¿¨„Șª5ò†öîÙ¨óhŒ¾�¯`ãž}œHN!» �y+×�µÿò-Ûyè�÷yÿÇŸëmŸ9ÿO*õz^›4ñ¬ûè}UÕ5[¾m«•—¿úŽ;_~“Eë7ØJ¬Ž‹#5§*a[õµB¡`x+^³~�;0ýr 9š˜ÄêˆÝ­�B!„BÑ\d¡B!í®n=ÙŸžÆê“ÇH**bvT$¯]}]k‡&þ¥ KËØuèp½É¿r�ŽoþE‡6ÁÜqýÕgÝÇcwÜÆÚ]‘Ħ¤2öÙ—pwuápl<ÎŽŽŒNo¨ÓŸ–Îâ�›3t°-qZŸWºŸ'bY½s7cŸ{ƒÑD|z:!þ~�ºîÛÝ�{öàíÉ3÷ÝsÎ5¤=Ý\X<ý#~ùg%Ž�Äd1sÿ˜ÑLºu !þ~¶~OŒ»�°¶Á,ß¼ƒôÜ\z†…1¬OOÆ�ºÞ¶ÿv�Ü>˜Ž!Á¶qZ{n  €Z¥²mW©”Ü>ÇzJ­z»¹Ñ½C‡³ÆìãÕë".ýìc–lÞB…NÏÐÞ=))+㟭;ê-kÛ±M0c†¡]P`�¶åÛvÐ¥m(/N¼ï¬Çè־˿˜ÆÏËVq,)Wg%7 Ì£·ŽÅÍ¥jýE…BÁ—/N!¼WO6Eí%·°˜}{16<œ±ÃÃmûêÙ±z½�.íÚÚ¶ù{y0vè<Ý\k×ÓÕ…±C‡ ÕÔ_®7Ø×‡ΞØôrs ,$˜•_LçŸí;P*Ü4t«vìbͮݨí¤ðåN¡TÚ°³S�·¿B!„B\ÉV«ÕÚÚA\I&-^@TZÊ9û jÊœ»Æ·XLB!DK‰ÉÎâÑ¿`´XpÑj™wÏD‚ÝÜZ;´&WXQΩÔwV§aµÇ–”�žsz B7— Ysí<­�X°íb�Û˜5âÎoc®DcÆ�ã\[áúž¿µ~Yì;z �ÞÀ¸‘×0kñß|>w#úöfÎÔ×›ïumLëE¸Q¯kõãRÓ‰Ž�ÅÅÉ‘QƒðŸOg°bGOÝ}¯>xÿÆ+÷pÃZÏ1î¼›çB\ZòÊJ(ÖU T(hçí×€-Çëê‘­‚BqÅËߺ¡µC¸"É Â&öPßþçM>Ô·‹Å#„B´¤`&õÀ�{#(ÕëymíJ~¾k|­Rÿvî..¸:;“UY†²1ŸÖ qz½�WfÎÂ`4�¯§?þ½€»®;{iÕ³¤ÌL^øò4j5OÝ}¥¬ŽØ�ÚÎŽ[G kÀ„B!„BˆËÃÙk*‰‹rmûŽ<6`ðYÛŸ8„«Û�½$”Bq¹²X-éÊy¨ï®mº”ßÑœ,¾Þµ£Uc»Ô( TJ%*¥…$E3 ðgÎÔ×éÓ)ŒE¶ðÝ¢¥´ñõá“瞪UÊSœ6r`fLyšÐ?¾ùó/®ÛHŸNWñÛ»oÒ¥mhk‡'„B!„B4)1ÚL¶$Ä1;j'óòP  ‡õí/ÉA!„W¬ÜÒÌV þ®î”êõLXø;é%ÅP]æmÆØÛ®¨¿ƒ�)1zJVeY½Û¥ÄhÃö,åpÌ-Vk=3V¥ÄèÙZê¿^õŒ•{¸ ZÏ1NJŒ !¤Ä¨Bñ¯'%F›‡”m&×´ïÈ��P2Š r÷Bc'—Z!Ä•Kg4P¦×ÑÆÓª×üxô]ò'F³+0uÃZæß;‘×ÖWˆ%)g{aäz !„B!„¸’I‰Ñf¤µSãîèDvi2QS!Ä•Êl±�SR„·³+vÊÓë v÷ à©A§Ë–è+ymíJLK+E*„B!„B!„@„ÍÏÃÑ•RINiqk‡"„B49«ÕJvI-.öuÚì;€«Ûµ·}8+“ï##Z8J!„B!„B!DM’ lþ®Ì&rËJZ;!„¢IeW?ãã\ÙPðîõ7â_£¬è/û£Ø•’Üb1 !„B!„B!j“a P*¸yPaГ'IB!„W«ÕJVI&³7çX«ËÍÞž÷GÞˆJYõ¶ÃbµòÖúÕä–—µ`ÄB!„B!„BˆS$AØBì”*‚Ü=Ñ d—a‘5 …B\¦Ì ™Å…X,Ý=Pž#9xJ¿ `0Øö}¡®‚7Ö­–¿‡B!„B!„B´I¶ SIB³ÕBza>“©µCB!.H¥Ñ@Za>*¥’7”І¿•x´ÿ µ ±}¿/=•£v7S¤B\¸Ø”4~ü{9G’Z;”&±9j??,YÆ®C1ÍvŒˆè~X²Œì‚Âf;FKZ¸~³þZʉ”ÔÖE4‹ÅB¥^O¥^�Qþ_L!„Bñ/' ¦T( tóÄÙÞžô¢| +ʰÊì !„—8‹ÕJ^Y)™Å…x89áçê~β¢õQ*|8j ÞNN¶m?FífOZJ3D,Ä…{gÖ�üø×2}¼/jüì¿–±)jï9û$fdòÙo�WT|‘Q6ÜÊ;™þëlÞ{ Yö_®ÓñüŒ™ü³u>î<¾Ò`àÓ_çq(.þœývÇeúÜù�ˆ´á~ZöÓ~ý�˜óÄ$.O£‘¸Ô4âRÓÈo�ŸA!„B!„¸”I‚°•x8:èîI…AOja¥•:$M(„âRcµZ)ÖU�R�‹Ñl"ØÃW{Njޟ§ƒ#�c+Kj±ZysÝ*ò*Ê›0êK_qYljML¦´üßuî—ªu»öpàøI¿ëvÜ]œ/x|Fn3~_À®è#çì÷÷æ­Ì^²œÂ’Ë]êÙ/'¯¨˜W'=РRÃgÚ~ šï—,åxRò¹�³d³—,kD¤B!„B!„8“]kðo¦µSäîEi¥ŽÂŠr +ÊpupÄEë€J)¹[!„­Çh6S¦×Q¬«ÀN©ÂÇÅ '�¶IöÝ?¨ ÷ÈO{#ȯ¨à͵«˜uûÝ•d¸Y,ÌÕÿ,Kk‡ó¯g2›ùbÞBü½ŠÊÊÜ£+‚ƒêô‰OKçDr .. éÙýœ×,§ ˆŸ–­$¼Ww†÷éY«­VüNNUñט•©7ص�¿6màp\<ÎÕq÷ë@ˆŸ/q)iON!âP V«••;w @Á˜¡ƒk³\§cëþƒäâãáÁðÞ=ps®›èÍÊÏggôaJÊÊiȰÞ=±S©êôS(–”°.²jFè5ýzãçéyÎk"„B!„B\N$Ax p±wÀÅÞ�2}%¥•: ÊʰרqPkqPkÐÚÙ]p7!„âBX¬Vô&#:ƒ�Q�ÞdÂQ£Å×Å Ç&J Öôä pef•^µÎWTz*¿î�âá~›üXÍEþ2_9­ÛLbz>ýZ�ƶÝ`4òäGÓÙyð …«ÕŠB¡àÙ{ÇñÔø;mýÞúîGò ‹Ø¶ï Ûö`âØéÚ~1qñ<÷é—¶1ÿ<×öõ?_~B§êaNA¼õ.‰™¶cj5>áiFdsŒÿ|òÅ¥e¶m®.|÷ÚËôïÚ©ÖùM&î|éMNV¯«§¶³ã§w^'¼Wí$àšˆÝ|1o!};w:o‚pæü?©Ôëy塉µ¶ç1áÍwIHϨÿÏ?Ã�áUñ—”—óô§3lcæ­^ǼÕ몮ËÓ“ u=«"vñÅÚú=ýIÕ¥BÁ˜e§·Š‹çÁw> ¸¬ÌvLw~ô.�BO¯{ºpÝFÞúþÇZkϵ à÷÷Þ!ÈǧÖy¤çärݦPP\5ÓÓÍÅ™Õ_L'È·v?!„B!„âr% ÂKˆ³Ög­=f‹…rC%:ƒ�b]9f‹;• µR…R©D©P ��%…B4‚+«‹Å‚ÑlÆd1c§Rá ÖàjV‹RÑ|³Ù• ïßp÷.œK‘NÀw‘ô ¢w@Ý™M—"yxçÊP¡«ä›…‹ æökGÔj[µc;ÂÛÝ�•_Nbz÷¾ö6ß,\Ì}7Ý€‡« s¦¾Î×ó³>2Š1ÃÂyôŽ[ðrsµí«×.,ùücî|ñu¾|y !þ~´ °õ›ý÷232Э ¿ü÷m®ÛÀ{³ç0mÎï¶¡®²’g§Nqi“ÇÝÁ7ÝÈ’�›ù|Þ¦Lÿ‚-ÿû¶Ö¬¸¿7oãá[ÆðîäGùè§ßˆ‰Oà—«ê$*>-�Å7sóðpzth_«mö’e$¤gпkgæ½ÿ®ßÄÛßÿ�þÕ– twqeÙŒOxéËoˆMIåÙñã¸~P‚kÌ4¼wÔHÚðÜç3Q)•üýùÇõÆóñœß(.+câ˜Q¼÷Äc¼ùÝlæ¯ÝÀŒy ùá�—ˆMIãÍY³1[,Ìxþ†õêÉÇ¿Ìåï-Ûxõëïùý½·kíóç•«yeâ}½yñËoÉ-,dÁ†�¼8áÞ‹ºfB!Çb­Z”¥9ߟ !„BüÛH‚ð¤R*qµw´­ñd®ñá­ÅjÁb‘Õ …B4žR©@©P¢V©P«ìZ¼¼§¯³3ïßpÏ._‚µúïÝëkW±àÞ‰¸Ù×_&ñRÒ˜ëU;¹(‰ÆÖ4gÙJò‹‹ùð™'ê”x¿iè`†öî‰+nÎNô½=ºÊJÓ3ðp­š©×©m(îÕÉBow7ºwhWç8®NNt«.7 Ö&ˆ°�6uú=?á»ýì5Ôv*†ö®*ß™ž›K¥^�½VË—âè`Ï3÷Ü…F­æÑ;nE£Qãäà@Ee%®5J–vnÛ†ç&ŒàÞGòÖ·³IÍÊ©sìÇn¿…‰cF×[r³¦Oû…BÁKÜW§mÊ„{xä¶[pÔªQÛÙ1ìTü9yTTVâho�ÚNE�íqÐVÍNôõ®“hðõô C›ª E½}~xóJËu¸»8¡T(Ú«'ó×n .-ÍÖgÝî=˜Ìfz…uäÎk¯àµIéÙ±Ž¶Ù¡§Ü2,œûF�à¦!ùmÕZRê¹fâò¢Ñhl yµZÝÚá!.À顼oB!„h*’ ¼ ¨”Êê¬äb…B\Y††´å�>ýùí@Õ:_Ùe¥¼½~ 3o¹ã’O›5æ*{�w{ì5ò÷àÝïbíÎݶï}<=X>sz³3¯¨ˆ9ËW0°[W®é×§N»Ñdâ§¥ÿ°ý@4y……TŒè ¨.?Z“Õzþ¸òˆWqY9_ü>Ÿ'b).+§¢RgÛ¿ÁdÂ^«%%3 €//4ÕIµ�ŠGn»¹Þ}viw:aé^½._¥^_§ŸV£©Ubµ>QG�±!2ŠI·Œ!Ø×·ÞøgÌ[À¾c'ªã¯´µ�&°¯Ñ¹A×ìü}’2³˜ùÇ"Ž%'Q¡ÓSZ^€¾Æk””™ @h€¿m›Ÿ§'ß:êIÓwi{:™{*ù[©7œ7qiS*8;:¶vBˆ‹`±V­Ù,„B!šŽ$…BѪž2Œè¬ ¢33Ø‘œÈ÷sï¾­Ú9)•_âÊ^«Å^«E©/ÇÜ€$‰h_/XŒ®RÏËM¨·ýã9¿ñׯ-tkߎ÷Ÿz'&8Ý–$¼P I"NþðSŽ'%3rÐÆ�¼Ž¢²R^�ù]­>§î=³ÙÒuÚY` IDAT ãªíN¿åoÌçªV«•i¿ÌÃÅÉ‘§ï¹³Þ>“?šÎ‘„Dn4€{F^GqY/Íüö¬û;ÿ1ÏÝ^\ZƃS? ¸´ŒGnˈ>½Øâ$_-X\«ß…_³Ó³(¥´¿B´¾S•”Μí/„B!.ž$…BѪTJ%�Ë} §¸z¶Ô̈môðó§g@`k‡wVêó”al•B‰Ùjn’x.wïN~”w'?Údû+,)åxR*¥’�Ý»ÕiOHOç¯ [¸1|=Â:Ô»�ˆèî=’QC’™—gKZÎÈ\�šÑp®ä¡B¡@¡P`µZ©4ë´WÅœ À“ãî GÇlˆŒ²µ[«?=U"1#/�’òr\�œ0šLLþh:*•’7y�¶uö>i99$gfãêè@�°ŽuÚ×DDrðÄI^šx®®uÚ‹KË8’�ÀSãî WXG6ìÙkk?5û£æõ žÙ˜5�š©k¶X0šÌµw1 —–¡T(x}Ò¨íT<ksJ»Àªß%Ç““lÛ32y{Ö�898ðÍËSl³1…B\zŒf4Ñû/!„BQE½B!D«ówqὑ£mótL ¯­[Eq�ò„—;eã? ²SÈ[±æ}2ŽG¦~ÄÔ_¦tÆÜ(0åþñg݇›sÕ:~[ödë¾¼ðùW¸»T•èÿŠÍ{÷ Q«yfü8” s–®àùÏfòÐ-c¸ãºkxzÚ ¢cãѨÕÌ~ëU:±jç.&¾õ. ×mäª�6üðæ+µJŠ6•ë6�”™És÷�ÃA«­·�ÚÎŽ)îA©PðÓ²<ûÙ—Lºe 㮿€§>ùœè“q¶þOÞ}'>ì;vœÇÞŸÆ“Oç׫kíÓÓÍ•ÿŒ¿ ¥BÁÌùòàÔxዯ9’P5°{ûöÜ<<€7¾›Ís~å“gždP·.˜ÌfF=ýå:Á~>|÷ê‹x»»3믥ÜûÆ;ìˆ>ÄðÞ½˜1åé&¿^B!šŽÑtºÚ‚Ì B!„h: kCÿB!„h³™‡ÿZÀ±œl۶ׯ¹žqÝ{µj\góä‰ ê7«Ó°z·[¬Vrôåu¶Ÿ{ųó´^äriçvö�Y¡íÜñ^ü•¨ÔëÉ-*F¡€`_ßZmGâIËÎfH�¸¹8�sÏùÅÅĦ¤âëáAûà  HÊ̤´¬‚Žm‚p°··õµX­$¥g�_\‚«“#A¾>8;:ÖÙk…®’ØÔ4Ì Þînx{×Jê¥åä�œ‘EÇ�`ü<=±Z­OLB©TÒ±M0vÕŽZ¬VN$%“_T‚�‡W…†ØÊvä• 3Tââèh›ù¨«¬$¯¸•RE �W­s-)/§¸¬�Z�Ÿ§G­ëCqY£† <ïúOéÙ¹$efÒ!8˜ïªø�VÇÖ&»³?Œ&3±©©”ëtx¸¸ÐÆÏ{­¦Î>ó‹ŠIÊÊB¥TâëáI€·g­s=‘œB~Q1Ý;´ÃÍÙƒÑȱ¤dœiPÝW�Ñd"&>�Ò mñ÷­õÚdæåc4™ñrsÅÉ¡êµ-.+£¸¬G{ Þîîuï–fº‡7î?¯�ø�mÔ曆=æy6Ïï&!Ä¥£¨¢œüòRB=}l/^W�lí„Bˆ+^þÖ ­ÂI„—“ÙŒÁlÂd±`±X°X­X‘—M!ÄÅSV¯…¦R(±S©P«TMR2³)¤1aáï”W—"Ô¨Tür÷}töñ=ïØ–ÖØ!@¡A‡ÞR{BI6lÏ’\iÀ1[)¹"÷pÃö,÷pŽ) B!þõ2‹ ©0è±Sªõº¸2ÚÍI„B!Dó“aó�âíÍdý‘Ãü¼c+‡ÓRèÂ#ïáú®ÝÏ;Öh6Sn¨Dg0Pi4`­.£¡V©P DÙ˜ÿ«B! V,V V«ƒÙŒÉlB©Tb¯Öà ÖवÇî<³„šK7wÞ¹n¯®YÕ³ _]»‚?“¦îÌ¢Ë�£�½ÁÜ€žB!„ÿ.V ÒXõИÃø>P!„¢5I‚°|²j9?nÝTk[dB‘ qL¾f$/Ýts�1V¬”UVRR©Co2â¨Ñâ¨Ñâå䂦Ö‘B!j²“�Á@…AO~y)j ®ö8ií°‡¦uCÇ«ˆìÖƒ%G�ZTÄû›×3môØ�¥¹i•v¨•JŒKk‡"„BqIÑ Xª _9¨%A(„BÑ”ZgjÀlý‘Ãu’ƒ5}¿e›Ž±}oŠu¤äçQ¤+ÇÅÞž¶^>ø»ºãæà(ÉA!„-BhíÔ¸;:àæA¨§Ž ù奤äQ¦×µxL/�¸–N5ÊŠ®‹=Á²c1-GKp±Ó¶vBqEªÔë‰OKCWYÙÚ¡!.B™^oûZf !„B4-I6±Ÿwl=oŸ9Û·@u™Œ´Â‡ÓRÈ//¥DW�§“ ®ŽÈª‚B!.E ÀÅÞg­=…å¤åãá䌻ƒS‹?Ä݃WF\ËÔ kЛM¼±n¿ß3{;u‹ÄÐÊ**ÈËÉÃd4‘‘•C›@ìTò!˜B\,«ÕJZvúê‡.œp´où’ÙBˆ‹§3è1W—`w¶whíp.;n.. êÙ�ݺÑ!4˜v�Axy¸áho�Vfc !„¨‡Þ` ¢²’üÂbÓÓˆOIgOÌ"¦¤¬¬µÃÍ@„M¬Â ?oŸr½�Á@°‡7jùðO!Äe@¡Pàé䌓VKvI•¾®î(ÍÿˆË-�»•–ÊŠãGH(ÈçÓm›yçºQÍ~ì–âäà€½F‹«ÅB‘QOZVÁþ¾-š$´V'„…âJ�–�Ciy9v*Á~¾(Zào–¢é”VVÍT(8k¥{C8;92vÄ0îuƒztG©”ß{B!N«Ñ Õhðpu¥chn‡ÉÜ�Ùb!êp ‹Ön`å¶”WT´v¨¢‰H-ËVäî)ÉA!„—­�š`o¬@FQ�í©îæöúÕ×ÓÎÓËöýÒ£1¬÷|û%¿¿ÅÏ}¸˜ ºuìHFr2=ÓÛcÑ=ó, !ÄáRuÃCû´Tâ,2o™G_ €#Ô^4ÎdÂ(ÀQuÌHçÓgŸâÙ{î”*!„‡�Ýfãá¯çãgž¤}ZZkG4‘„B!„h²´øtªJ~yéa9ߌþƒ8­WŸðòöž^²à°œûp1èõd¤¤ vƒ‰d£ƒ*mÉ…¢!ŠTC qT(t–SuëY¢E*â¢7|ß¼ú"#ôoí¡!„8Æ�8€o_{‘† ií¡ˆ&�Û°„BÑd �aO`q!¥® ,Ö?ç}“¦²%?�ßCíEgnXÇЙœz\ï?wk0¨:’�¼Á®€O @õzM‡Ó…Çç /+ èu:âc» æóù)s:k]T·Ûâbž³«¤ÌA ˆXg2±ÅÅV¹ãõz)¯pÕZŸdOˆyþœÂ’Ú!µÅdÄcõ�ËíÆéö„–”ðÿ¦$&Ä´¿¦i–”ÕZo5c2™b:†ÃéÄí�¬ˆÕ©*I ñ1íï()sD¬S°ÇÇÅ\}QæpàõUþ,«¾óFƒ.æ–eŸ�rGE­×S¢Ý†>Æù·‹KËjµ.¶˜ŒÄYcûýâöxpDy=¥$&ÆÜ¶· ¸¤Ö:«ÅŒ5ÆV•N— —ÛW©ªBrBl¯§ ¦Qå5gÅd4ÆtŒ2‡�ϱN¯Ó‘ãëÉç÷SR^ãõ$Øâ0b«n.)/Çç�|=™Œìq±µ t{½8**ÂË�@ @fFz­q !Ž.¯ghîA›ÉŒ9Æß7Ç’SO<�ï¿;æ÷!„¢¥%'$ðîãÿàö'žâ‹ù?·öpD#H@(„BˆC¢**éñ‰d—a5š0Ä4•Õ`à_§œÁå3?Ĩ SþùÓ HoGfŒß�DFU‡QÕ¡4¼Á ¾`0H®ÓI™ÃZÌ&ãc |~ ‹¡ÆEv›Ù‚¢¯¾¦îKðÅ¥¥x½‘AD¼-Žø¸†p¹}䇯P]b¼¥ž†ÕGTT\R+TJMJ$.Jhí™T¸ÜGl¡( ©‰‰ îK(Ð)(ª='¦NMÅl2×»oGE%eåëŒÉ ñ1 ¿Ÿü¢ÂÈñ*`10Åö–ãt¹"Ægµ’c@èóxÉ/*¬8ÇYPbüÝPXR‚¯FëàÄøxl „Uçt¹ÝäEùY$'$ ÖÖ\›_\ŒV£urzRq1„g%¥GÖ©jÌa  ·0ÊëI§kD@è Ôð™M¦F„y…5^O€ÕlŠ9 ,,-ÅÞ+Ùmq1„¯—ÜcÈHIƨ—Ð@ˆ£�†F¾£òfEQH‰‹í÷Õ±dÚä‰<ïÝ1ß@%„B.Fƒžçï½MÓ˜óÓÑÕééh&¡B!™Ù` Þl¥ÀQFû„¤?_¯Ô4n9aÿ^ø¯‡{¾ûŠ·gœßâekSP0©:L¡¶£ŠÕZX�fÒL±U[Yà0Ö=RLV,1V¾Íxjy F3)¦Øª÷£Ÿ Cís%Í1W�Ù ¦Za‚ÁDR”ç�×`Â]c Š¢�hŒí{±ék7vƒ‘„(Ï-šr½_�c˜ôì1îoB\”1ÄéMØô±ê3 éüµÖE;n4½/êÜ™V½SŒÇ0ëô¨ÁÚëb�“Ó¤3`Rkÿ™cÖéÑ©±Í°`TuµB£N�IÛŸOFU�±Fk`�ª†ÿÝ6DQ5 QÆZu£@,ôªŠ¾Æ1ôªŠ^‰í{ WTtQ¶ÕÕ±>�¢Ô eÕ(ëê¢* J”xÜçóK@(ÄQ¨ÈáÀºù+Ù{åù±bÜða<}÷� !„h³TUá™{´”%«×¶öpD $ B!D³H޳±·(Ÿ ¯kŒ¡Ê¡¸pÐPVíßÇO»v°9/‡–-æö'´ø¹KkÁx�2ÒñùýƒZ¨Ž°1ÔÌ&�ÚeÔZo4Æ~ñ½}jJ­p®1ïã,–¨cˆ5Ì蘞~þUÌ�h½•`³ÕªÌе%¡ #Ús°ÄXq�l·×ª’kÌ÷À`0DƒÙû÷!=9 ÀyÜ« U¯Fƒ¾ÇhŸšJ°f8׈ýmVkÔ1ÄJtj—Q+ lL+·${<s�À¹¿tªýõdŠýõ”’�€ÝY©×˜×“±Ž×Sc¾íRRð"Û7êõúý¤„BE�ªC¯×5êBˆ#ƒÓë¡Äå„ÐÍ1 ÖØ*��íÓÒxñþ»1ä÷ŸBˆ¶ÍhÐóêC÷qêµ7±?7·µ‡# Ÿ,„BÑ,TE!Éj£¸ÂqXB€‡§œÂ¶‚|”UÎÕõ¿µ¿2¬C&»÷¨µ­ÌÚ¸žÙ›Ö£Óúà¼ACËÜUÅNÉq±µhl,“ÑxHsÐèuº˜ÛGÖ¥¡Ö� 1ô ‡6†šAHcê÷QQ”Cþ>ZÌfb«¹ŒN§ª‡<†¸çl¬‹A¯?ä1ÄÇØ~².&ƒÓ!ÎYk Ì:Çpˆ¯'µ~–VKìab4Íñ»áP_OFƒ!æv¦Bˆ#—? ¿¼ò³œª(dØdnÑjtªÊ¸'æ6ÕB!DkKˆ�çÙ{ïâ¼Ûî"ÔbØC´–Øo!B!„h€ÝlÁàòyËùâM&?åôp[Q xøÇïÈ †UJÝ.n�;›Ç~šÇæ¼\¶äåò¯?òÎꕇeœÅÊÝ®Ãr.!„Bˆ#EPÓÈ)=8�pªÍŽ!ÆvÎÇŠKÎ>ƒ‘ú·ö0„BˆF=p�qZkC4@B!„B4EQ°›-‡5 �ўǜ^.ó¸ùÛ÷_ã]hZ“u€ó?|�…¿ïªµïÇëÖ¶qæ——Qáõ¶ó !„B´eš¦‘SZŒÇïÀn±o>´Êã£MrB½òòÖ†BÑ$w_uIv{ [ŠÖ"¡B!š•ÍlÁéqךC¬%]:tºu/oÈÉæååKù`Ýj®�ý ¹Žò¨ûå8Ê)rU´øø44rËJÂÁ„B!ŽU�[^î:g4‘j“ ˆ5]=ãì‡ØîY!„h-‰öx®œ>­µ‡!ê!¡B!š•Q§G¯Óá:ŒÕr ðð”SiðÂÒ[«–óï…?…+ ë²£  ÅÇ—a¯œ3&¨id•I%¡B!ŽYšVyÓ”ÓãÀl0�nO”yk°ÅY¹üœ³Z{B!Ä!¹âggµ¶ö0D$ B!D³³L‡mÂ* f3ÿ˜z*ªª 5¢zq{a~‹Ž Îd&-tW|PÓÈ)+Áº(&„Bq¬jA²J‹Ãá Q¯§}Bª"ñ`MgN'ÕƒB!Žx ññœ>þ„¶­AB!„B4;‹ÑxØ ¦±bß^‚�ú+kÚQØò„„æÕɰ' T»s¾¸ÂyXÎ-„BÑÚ¼?Š‹p‡>#Z F:&¦ *ri*šéSOjí!!„Íâ�S§´öDô­=€£IC-Ì„Bˆc…Q¯Çç …Ú¶´ÂŠ îÿþk~Ù·§Ñûn?„€PU”çZ¬~G¼ÍdA— #§¬˜ ¦Qä,Çåó�ŸˆN•‹cB!„8:•»]8ÊŸ›âL&2âQ¤r0*»Íƨ�ý[{B!D³=xv›�2‡£µ‡"j�+QÍÈð·ö„Bˆ6A¯êPð-~®ûörþ‡ï6)$TAØPÈW—d½©ÁmR ‘ÛXŒF:$&cÐépy½ì/.”y …BqÔ jAòÊKÉ+/ ÞJ´ÆÑΞ$á`=Æ$7� !„8jèT•Ñ´ö0Dòi£Ž‹ B!Ä‘B¯Óµø{ãÛ¿®àúÏgRx­:=~?ûJKš´ï°øÔ·e“Þ@fR*6“0@vi1Ù¥ÅòyB!„G…r·‹½E”»]º8Ø>!‰”¸øÖZ›7¢_¿Ö‚BѬF �÷¶¶HÂfÔÔê!„âhTÙ~³åÚoï(,àÅe‹iŽwߦÎCxJr&©s��§Íœœœõ1UQȰ'’j³‡Û�Vx=ì+. ¸Â!Ÿ+„BqDrù¼()$¯¼”@h*‹ÑHfR VcÃÝôèýó£Bq¤êÑIÞÛÚ" ›‘\ÈB!RµEß7äd7Ûñ·ä7i?«NÏí�ÒËšPë±ÞÖDnï4‹Zÿ”Ï +�’Óˆ7[ ôy¢Èé`Oa޲ð…5!„Bˆ¶Ìíó’URDVInŸBUƒéñ tHHF¯êZ{ˆGŒîå"ªBˆ£K�N�Z{"Šú¯X‰FÑ$ B!Â@k–ú¾è2›íXM­ HÒ›¸­Ó@öyìuWN¸ÝÅO¦).æcèCÏl&3…Îr¼~?AM£ÔUA¹ÛEœÉŒÍd–»î…BѦøƒn7å^¿?¼^QÌV’âláN "vI Ò†U!ÄÑ%É.ïmm‘„Ç_0ˆO Ђøƒ-ˆ††¦A0tWEAQ@AA§¨èÕÊÿ7(: 2I¶Bˆ6bDf'&tëÁ‚Ý;ùXÛ! ¬ÒÉd£“ÉvHǰMX�&œ7ÅN<~AM£Üí¢ÜíB¯ªÄ™ÌXŒ&,£\pB!Äaçõûqù<8=\>oÄcª¢`·XI´Ä¡“ëMg±´öD3()/gÇ�,JÊË1èõ¤&&Ò«s' ºc«š¶¼¢-xðÆQ[œµÎ¿c¼>n�7êcÕ)ªB¼ÕZï6Yùüž“ƒËã!-1‘~ݺ¢ocßûì‚vgçPêt’d³‘Oû”dl‡öwekÙ¾o?�¼õNxyÂÐÁ\}ö™­:&ÑvÄYå½­-’€ð‘SRœU+X»g7þ@ æý´P«² ¦ETq$ÆÙq\O&RëM9ˆV¹#~-ˆ§Zg3UQ0ª:Œª³ª— “B!Z�<}Æ4>Û´�7WþBŽ£¼ÉÇÚ_Z‚Ûïì74ë›*Îd&Îd¦Âë¡Ìí¢ÂëAÓ4üÁ ¥® J]˜ôÌ�£N�A§C§Ó!ïÎB!„8TA-ˆ/Ààõûñü¸}Þ¨íÏ :=ñf3v³U‚Áf`2[{¢‰‚šÆ—K–òÖܯٸsW­)¬fSF çÏ3þ@ïÎGg»½-{öðãŠ_Y¿sëwì$¯¸8âñåo¼BZRôn0ŸÌÿ™_{³ÁstÊHcÁK/D}lÅæ-<þÎû¬Ûy#©ÝÇgœÆõçLÃdlÝ¿ûæ­XÅÓ~ÌÖ½û¢>Þ)#�A=zòÈ5WQUW¥N'KÖo/wHKiÕñˆ¶Ål’ŽHm‘„G€9Ù\ùÊ‹”8�ÍzÜùë×±xóf¼ð¢˜ƒ¾ ¦áøqü”ãÁ¤ÓcÑé150¿’BÑTEá�1­oælÙÄë+!· AaPÓØYXHÿŒv-2Φªª( jÁPû.7Ÿ7|Ë�ÇïÃã÷ÕÚOQ”ÊNªD…B!„ˆ�ºÁ8–yžuªŠÍdÆf²`6´�›¬„hMŽ yö~úuu�ÛT¸=Ì]¼”o—-ç¾?]Æe§ŸrXÇx8|:ÿgÞþò›V9÷×KáÖg_ˆZ\QæpòÜÇŸ²ú·m¼þ·»0¶Òï­¾ÿ‘û_}½Þmöåæ³/7ŸÛ.<ïˆ …GIuŽ�Íþ4š LõT7h¡ÿmè³|…×C0dé–-Ì_·–“† mô¸4‡…:E%NoÀª“? „B~�Žqv((|må2òŽFc{aA› «¨ŠŠÝbÅn±VÞ¬ãóâòzqù¼xþZó k¡ÎÁØ›!„BÔK¯ê0 X F,F#F�\R¢J äæ§ŸcÁšµëÛ§¦Ò¯k*<Öl݆Û[Ù>Óðð›oc³˜™>iB+�ºå%ÛíT¸ÝáçÝ’äçs÷_ ‡ƒª¢pÖ¸HOJäë¥Ë9�ŸÀ¢uëyáÓϸãÂó<殬ln}æyn½à\&vHc,u8xü�÷"Öe$'Ó§KgTUe_^.ò py<‡tžÖÒ%#ƒÇ®¿&¼Ü½c‡V�¢aòi®�s¸Ý¬ù}wxÙãóáñÕ®8«¶ïhR@X]@ Ræóàôû°ŒRQ(„¢UÔ _]±Œ|glAáŽf˜‡ðpP%\YXÅà øñµ`å�ÿA� V»˜B!D]EAUEA§¨èu: :�L1"D}fþøs­pð¯_ȵ8;üo§¨¬Œ›Ÿ~že6†·yð�·˜4|X³W‰5�ݲ8PP@  #%…Þ�;5ºp™ÃÉ–½{)w:Ý¿_ƒóþŒ2˜}z3°Gw2ÓÓ9ãö»Ù²gO£ŸCûÔT–¼ú}�ˆ IDATbÌÛ¿2û œnwxùÖóÏå¦s§pÕYg0åæÛqº\¼9ç+®9ûLìqqõÓíõ²qWåuÙ’²¦OiQeé†�cœ>qûLî½ü’ZçšrËmì>� €Íjá—×_ ‡B^Ÿ�—?û‚÷¿›GaiiÄ~Iöx®:ó ®=ç¬Z;\ùØü¼zMxù‡žáó…‹xý‹¹x¼•… ³”¡½Žkð{1aè�·inAMcî’eëΙpbøëô¤$ŽØŸy+VA(øû~ù*þ8ùðVoæ•D,�<¨V¸eÐé˜8l(‡Õ]ÌñõÒ_xñÓÏømÏÞÈ}õzÎ:ñxî¾ä¢Zó<¾÷Í÷<ôÆ[á凮ú�2Ò¹÷å×ÃóDÞøÇ?0gÑböåVþmœ`³±âÍW0è##„7ç~Ícÿ÷nxùñ®ãü“&±ê·­œwßCáõçN™È¾>b_¯ÏÇë_|ÉûßÍ#·¨(â1UQè×­+³4✯�—gÎûßÎ ÿÛ©’l·sÍÙgrõ´3kà¿gçòÐëo²dý†¨­³Û¥¤pÑÔ)á Yˆc‘„mœ¯ZÏìã{õ¡OÇŽáe¿Ä„‹6Ö÷kÖPìpà4é ú)ðH0˜0KÛ!„­Ä OïÝ—�7¬å�Õ+) Ý5ZÓï%EQ× !„B!D]ö忇»*:ãÔ¨ÛÚãâ˜>qoÌù*¼nášµÜ|îtN4�Ìô4öçU†3s/åîK/Š=ÖïØq®³O<>:]..~ø¬ß±3깋ËÊyêƒ�X¹å7^ÿÛ]õÞ`øÜÇ3™»xiĺ¦]}lº2§“Ûž{‘¬üôz=íS’9~@N?a,f£1bÛßdSæ8xChZR"™ééÛ =î¸p@°nÇöÃÚ­–ˆåçg~ŠÑh`üàAÄY,uîWÝ?ßy/âõS�Ïï糟²tÃF>~ôïtÊH«ó8+6ÿÆcÿ÷^Ä|�Š¢pî¤I<ýÑ'ª¼ûiõN52bßÏ. g±pæ‰cc{yE—þý±:_£AMcã®Ý‚A Õö¹ø¡GÕœ5••ñÄû°rËo¼zÏ�á/n—üýQ²òëî”SXÈ–!«ÇInÚ¸ês M0�óÆž€”ù<¸‡ÖjtõÎ];1M>Þ%>7Ö �xƒ‰–hFr 7—Ï~ø1¼l1™¹ú�Gæ]ËÖ®cÕ¦M$Û¸ø¬3Z{HõÊ+*â›…‹ùeÝzr Q…ŒÔÆ ÄéãÇ‘’˜u¿÷ç|IqyåÝ>ÓÓ™>õ¤˜Î÷Óò•lܱ=¼|ÓE¢H‹!DŒ,6’óá£u«ywͯ”º#ƒÂ“¹ÕÆ'„B!„82mþ=2¸P…Þ�;Õ¹}Ÿ.]jì_Ù~SUÎ�<‰gBáLnQË6läÄÁƒÂÛ~±hqľçŸt°�㯽¼Œî×—ËN?½NÏßÿn�º`ÍZ^šõ9·œ7£Î1V…ƒ) ¤&ØÙ››×Àw¡ù9].¾Xù|?ûy!ÿùh&/Üñ†U«fÜ“›±]B”6• ñ¶ˆå=9‡ÿ9�ؽNåvgåp㿟A§ªtïØ�a½�cÂСŒ2ˆ8sí¿Oç,^¶KIáÏ3ΡSzK7lä­¹_É),âÖgŸçÓ>Rçµ³o–ý€Õl¢sF¹ÅÅhšÆ'Oäù™³ÂcübÁ∀pǾlªÖ�uâØ¨c�æž—^‹x�*ŠÂÔ‘#èß½N·›õ;vòËÆMûÜÿêáà˜ý¹ôÔ©èt:Þÿv‹×­`þ¯«yuöþ<ã­[^zÚÉL> MÓÈ.,bû¾}|_-0âX%á&¨iûÜø‚�¶n*>|Z�$ƒ¹Ùç,xoΗƒ¿^rz�Ž ¦QR^N²Ý΄¡CøqÕ¯Ì_½š2§3<_ãç‹E£zH]Ÿ�ûðí/ËÃËŠ¢ðÚ=w2eÄðˆíÖíØ®nÝ““Ë—ÕZÇöéÒ™wø[øu;qèNþËáŸËës¾ ·Ð-ª6g¤ª(ÜuÑ…Øj¼¸ârr‹šoê-!ŽDA4M£È믵̜„n¯�]¹9ìÌÏGSÁnµ’aO 3) ›ÑtHaŒ/ Èë"ÉhA×L¡Ž¦i|6ïÇZëgÿð#÷\sU³œCD ƒÜðð£|1ÿ§z·s¹ÝüóÕ×ÙµoÏÜóWÔFN‚-„ûKK¹þó™(«œ;#Þdââ!ùhð0âM�›x½¦8£‘kFŽá²¡#ØU\H§„DlÆC;¦B!„BŠv))Œ:„Ÿ~] Àw¿¬à‘k®Âj6±dý JÎawþI“Ã_/Û°9"ÀëÞ¾?¬ü5âØé‰Iူ¼¢‚õ;w1¼w¯¨ã¸ô´SÂá ¡Àðp°�œû‚õ;wE«ÓíæÉÿ}ˆ/àæÐüxyÅÅlÛ·/¼M»”¶íÛ±®f0½xý†:ÂÞ�;qÏe—„HUQH¶Û8ÿ¤Iá€ÐãõñõÒå\0u2š¦ñÅÂ%ácôíÒ…Á={Äô¼¬]ñ\§Œ^+"Ž·tÃÆˆ}ºµoÑ& #))–:lÚý;ƒ{ö [ûvám‚šÆ”[nclÿþôíÚ…þÝ+Ãõ›�v)É1�_ˆ£•„GgÀÛbá €Ùh _§Nôh×Ží¹¹ü²÷wÞ]·ŽrŸ‡‰)Œè”ɨ9.9¥I•€~-H‘·‚d£µYBÂ_7mfOVV­õŸÿ8ÿˆ Ç ÄÍ_@jRôöœ­í¥?Ž œv*ÇŒNUÙ¸}oÏþ"|ÖG_ËØ!C¸àôè½÷…¢>Ï.YÊ=^Y¾”ÿ­ýµÙ‚B“^Oß´Œfmåù¡ßé6[‹´Ö®�/  Ô´ð‡¦!„BéTEAAAUTTUÁ Ó7{7 !Ž&‰5ZW––âõùê¬"<šc°ŠÕlŠ˜Sï‚“&‡B§ÛÍ+Wrö¸ù¼Z»M‹ÉÄ´ñ'†—³«Uº|¿b%߯XY︳  Ž€pâÐÁõîÛRfLšÀŒI‘ó&Ùã¹éÜé zþõÞáõ ׬o�¬t¸kÏ;ï¨1}|\í6¤‡Ë)£GqÊèQd°`Íz~ݺ•¥6UþLªy}Î\®Ÿ> ƒNW«Òí@~>7ýçÙzÏ“]PXçcㆠ®ówûÄaCÉHN&·¨Bs^0u2+·üÆ�üƒ¯ßê!uCr ‹"–«ª_ëß'rüßü²œoªU!F“UPÀàž=Ö»Ý3;°kåµãüâæ,^œÅΑ}zsÿ•—Ç4!ŽVA‚‡éŸÉ``@f&23¹Ôçgáﻘ»{'³¶þƬm[ÈLJ挞Ç15³ úFV†4�b¯‹d£å�ÿÀ˜õýᯭf3¡v—»÷`ío[Ò§wÌÇòx½ÈË#)ÞNR‚½Qã(*-¥´ÜA‡ô4L�l}WRV†? 5)‰I£G2iôÈ÷©p»É-(DC#51 {”¾êÑäá¬p5iœ%åå<õö;áe›ÕÊÌgÿð~}Ãë¦M™Ì…gžÎ©W_O©ÃÀÞ~GB!D“lÊË�º¾%‚ÂCÐ4Þ^µœ÷×®&ßYù»/-ÎÆ¥C‡ó§á£š­r¾:�߇ËëÅã÷á øñèU½ªCQTåàE=!„BˆXi¡›Œ4M#  5|?ª¢bÐé0êõ˜ F,c­é$„8Võë9§ ¦iü¶g/ƒê¨¬Ú²gOÄrÿ®‘ÓäL>”ô¤$òŠ+¡Ù 3eäæU üN;~L­P¬±Ü_��¥'·½ªªIÇE„ùÅÅ‚AtªJ×ví#¶-)wÖÚ¿¤ZËI€®í"o½aú´ˆ¶”„‚×g~¡ðrX�@UUí÷`ûÔT.˜:™ ¦Vmßü²œ¿<ý|xþ?G…‹}Ù¹tÏìФ㻽Þ:«¯rN¯Ó1cÒx^šUÙÒ´*¬>'¤Ùhäœj!ucE«œln�BÕ”3ý;/Ïþ‚Ù QXZZkÛ•¿måÒ¿?ÆO/>K’=¾EÆ#D['aW߯Êÿýü 7mäúSOgh�ØÊ¹ËdÐ3õ¸^LêÞƒy;·óÑŽmìp9ytÍr6—2­SOz$$6ªJ¯)ö¹I6Zš\]áóû#*ÙnºøB^›9‹’²2µ�.Zµš«xèàòÿÞáÅÿ}È;ŸÏ ¿� ë×—{¯½†q#†Eì{ï3Ï…CÉуñè-7rëãO²44ɳÉhä¬Ixà†ëh—ÙOþŒëodÇž½\{Þ¹lÚÙüåŸÿâÇ_–sóÅqÿ ×òÌ;ïñJh"êî�2ùæµ—k�ý?ÿ÷+7l PP…~=ºsõ§sÁé§Õjåéóùxmæ,Þžý9û²sÂû îÝ‹?_tÓ&Ç>ßÖÌo¿‹˜sðÞ뮉«tÏÌäú Îã‰7Þ`ov6¹……d¤¤Ä|.!„ð70ßnUPøÞšUœ7p>»)¶ÉÑ›KPÓ¸ý«ÏùaÇöˆõùNO/^À†œlž>cÚ!ßÔ4œ7N�—χA§Ãb0g2“¤ÓcÐéd^V!„B´0€/Àë÷ãð¸È//C¯S±MÄ›,ë˜ÇLˆcAfz:=2;²sÿ�𺷾ü†go½©Ö¶e'Ÿ-ˆœÃmüÐ!Ëz�Ž“'ðr(œY²~|÷nOx›š•[j\‡ºúì3¹iÆê·Å\÷M–mñ€¢Ð5¿*Fƒ!<Î.Ú‘`³…oV/()aon^xŽB€5ÛwDì?ø¸žËÕ[ªVÙüûžp@8¶?¦×¨nl¬â²r,5*F«;mÌhžnß.âµTÕ:¶]�жO—Î|ôÈCµŽQ�¾žßÍ ýŒÏ›2™W>û"|ÓÈÌù?óÕ²_Žu옘‹Ú׸.¸aç®÷iWcŸëþp67œ3­Þ}ª¿®“ìñÜ{ù%Ü{ù%ìÏËcóï{ص?‹Ù ±}ß~ÊœN~Ù¼™ÓÆŒŽù¹q4i{¿íE§¿î;=f/[Æîœ\úà}~Û¿¿EÇ¡×é8­Wž›r2ÒÛ¡ýy<|¾g'ßîÛ�Ë_÷]GÑø‚Ê}ž¶ŒnÁÊUVë»>}êIœ>îà]+sæÿD0X»«/৤¼<üßßž~–W?žVoÞ¹·ÝÁG_±¯Ëí ï—“ŸÏŒ¿Ü U!~úÝݸ®V8XݼÛølÓ†&ßåõ’[VÊžÂ|œ^6³…ÎÉ©d&¥�b‹Çf2cÔë%B!D‹Ò«•7'%X¬´³'Ñ-5�ôørÊŠÙW\@‰ËI ÊßáB ®›vvÄòœE‹yþ“Y×pòŠ‹¹ö‰§(®V¥g±pñ)'Õ:Þy“'…?ãûþóáÇáÇzdvdd�›â�Ø?â¦Äï—¯@Q춸Zÿ¹}^¾\ú †6ìW¸=œÿÃü°rUÄ|Š„Z­>ýá'몷…T…³NñøìŸ†¿Î),dÙ†�áe³ÑÈÉ1tñjn+û�ñ7ܳÏ ·¾¬nõ¶íìÍ9ØIG¯ÓÑ>µ2LKJ¤O—ÎáǶíÝÇö¢þŒMF#s—,Ãï÷7y¬�3Ò;p@xù•Ùs(s¬Ì<ÿ¤Ø‹Æñwëü_W‡ç9¬nͶíá7' ±Ï÷ËW Ó©QŸs…ÇÃW˾®‹ËÊ#Úšf¦§sò¨‘\?}ÿ¸þšˆsÈ‹lí*ı¤m½ˆî€O°î_ä—LšÄK_}…Ûëãþwßåék®¡sZZ‹Ž)Ñdæž‘céŸžÎÆÒ"|Z�Í%…û\œÒ±+)¦ØÛT|T]ã_†ŸÍû1üõ ^½è–Ù‘³&O䃯¾ +/Ÿåë70vHý=Ó¿üy!f“‰ñ#†ãöxX¼z Á`MÓ¸çég7bÓÓkí·në6:fd0¼__¶ìÞÍöß+[DärûO1ë¹§£žóÓïæ‘WTõ±hÜ÷=ó|8Èëž™ÉY“'¢SUýºš•¡8~õ 8i FŽà?ÿ÷.󗯀ФɗM;‹n™Yôë¾ õÛ~}æ,úõìÁEgœÞà8Ö‡ž3À�¾}°šë®ÒiŸ–žOQ!—ª ð“ k[Eá§Ö7¸ÍÌ ëøã€A�:®Ã㦤¢ò�/»ÅBZ|ü!·¯B!„hN&½“Þ@rœ ·ÏK¹Û;ŠâM¬Vôª®µ‡(Äa3}Òx¾ýe9ó=xsõ³ÏäýïæÑ¿[Wœ.ëvìÄW#°y캫HŒ¯ÝÚ°K» Æ èµ¼¾ƒ7æŸ?¥ö¼oÒR9gÂ8> …b{só8çžû¸üÔSéÒ> À®Ù,ݰ‘%ë7лs'.:yJ³~ªüï»xý‹9áåìsÏMÿÛèBS! éÕ+¢Òrå–ßX¹å7:¦¥1ª__Ò“),-åçÕë(¨V(py�él®?g³^„3Ôýê…™³Ø••EzR"_-]^pÍ´3¹EkS””ðü'³xþ“Y¤&&Ò½C{,&ùÅÅlÙ³7âFþã Îb /ÿyÆ9Üòôóª,¼òÿâÒÓNfpÏž z² ŠXµu+?¬XE™ÓÉI#‡a§és-žÒ$–¬¯¼áµúk°[ÇöŒìÛ§QÇê‘Ù‘ÓÆŽæë¥•Uˆš¦qí¿žbʈáôïÖ• ¯‡�;w³lÃF6ø.z�ŽÎéœuÂñáywgåpÎ=÷sÙ©§Ð¹]:>¿ŸÝ²Y²a#K7l¤_·®\8µòu½uï^.yøŒП±úÓ!-…´ÄDŠË¼÷ÍwcËLoÙëéB´e¶Q-HivçŒKQY9-ZH™«‚ûÞ}‡·o½ ½®e?„+ÀÙ]z0Ì‘ÆÌ]ÛAQ)÷z™wàwNÌÈ$3.ö9üÊ|Œª®Qó3U¸Ý|³ð`;†³&O`Üða$Úíá6£Ÿÿ8¿Á€Ðn‹cæ³O‡Û‘~1ÿ'®{è4MÃåvóÎì/¸÷ºk¢î;fð >xê â,ü�7=úOfÿP\.þu5¶mg`¯ãjí—WTÄØ!ƒ¹á‚óè�žõƒ`ukÛn‘ðÑÓOÒ¥Ceïñ»®ügÞp¿nÚ À÷K–2aä¼ñégá}þyÛ-\º›íšsÿÈ­�?Á‡_}À“o¼Åù§�Ú`k�꽺«Î/„mÑá w5|§áŽÂü·©âð¸)r:Ы*Éq6¬ÆÖ›[Q!„"Vfƒ³ÁHrÐF‰ËÉþâBâLf’­¶6ÙªPˆæ¦* ÏÝ~ w>ÿß…nÖ&-¨Ö}ªŠA¯ç᫯àìquÏávþI“"ªÞµÕœ1q|Ôíÿ~Í•ìÎÊfͶÊ'»dóð›o³jšr§“½¹yu>~ ÿàßGê(t8�ŸÏìuÿuÅ™§qúñc"ÖuHKåÉ›oÏáÔ4æ.^Zkß C‡pcíW—‚’’ZÁg»-Ž®¸,bÝ™'φ]»yýó¹”WT„ç l '�I’=>¢êà‚)SšÔÅæñ®e^>ëwì„PHøÃÊUü°rU�ûü㺫Ù��nIºsÿ MkÔ� ¦±4Öå¸N™L6´ÑÏEˆ£…|Jk£Ê|^´zg ¬Ô¯s—ð/äb§Ÿ¿þùšŲíÛ¹á½wxä«/)s»È´Ù¹ä¸¾¤� )¸ü~–äeq ÂÃÑ*ih”û×jô›…‹¨¨v×ÏY+ûôzNwBxýÜŸD´rˆæÊÓ#æ*œ6y'�=Øsú»%µ?HTùçm ßÅ£×éøçm·`6¼�;o鲨ûujߎ�þó$§œx{G§öíêcͶ ½ðKV¯Áç÷£ª*¯ýý!¾ó5¾ó5®;ÿ\–®YK¹³²òÄh0pÁé‘í@¯¬öA(;¿€�5ú°×äóùðU»SÈZOŸz!„h+ª‚Â3ßyƒ×Wþ‚·�÷„¦pÇв%–m|?Y¥E”T8I‹·Ó!1YÂA!„BqtªJJ\<�“SQ…}Å”¹*bØSˆ#_œÙÌKwÝÆKwÝΈ>½£†ãñV+Ó'Žç›ÿ<®tªË©£GÕº©¼*°©ëü=ú½øBÚט“°J²Ýδñ'rËylÔs;LF�\{†Ád4DÝfpϼü×ÛyàŠË£>~Ú˜Ñ|øèC ïÝ«V€•d�çŽ Ïçµ{îÄhˆ~ü–6¦_?¿áºÊùûâ¢Wö Î8~,_<ñOzdv¬õøß.½˜Wモ!ÇõŒÒ™ŒÆ ÄýW\FRE 1 LŸ0.b�A¯gƤè!uCâ­V>ùÇÃÜuѵæ$ô2¸g�ˆ;6«…™�ý�;ëØ %!�?LÏÍçN¯KONfÂÐ!$ØlQ÷±˜LLŸ8žÿ=|�¯7!ŽRAØy‚�z[‹VÙ´gÿøä#4MCUîš>‹ÉH™«‚ùëÖáñ<†Ýbå¤!ƒ›Ü_|SVó%�—õ¹9$[¬Ü4y2i+§wêΜ½;� °¶(£ª’fŽ­TßðãÑù1©±�-Z{Ñ*gMš®Œ+,)aѪÕLª§§øÔ±cj¯;þxæ…ÊÝ·ÿ¾Ÿß_ëûÖ!=�þ={D¬KNH`xÿ~,Y½€M;¢‡nã†�2 gψ‰–¿Y´˜o-&>.Ž #‡3eÌh¦ŒCFµ7ÉM¡;q½qßøÈcǬÙÒbËÎ] îÝ«Î1 Ì&Sx®F§Ëóø…8ÖÍܰŽç—-Âé­{NY]¬s¤6¤Üãá¥_–�UVÊCSNi–c6 (v:(sW�dµ‘`i�67B!„ÍIU*ƒÂx³…‚ò2ÊÜ.Òã0¶±9Ï„hnŠ¢pê˜Qœ:fŽ ;³²(.+Ãl4’œ`§GÇŽ1WÕ Vÿßë�:¿A¯çúéÓ¸~ú4öææ‘•Ÿ�Çç#%!�´ÄÒ’’"æ*¬òÖ}w7ê<õ©:céT•KN™Ê%§LÅëó‘[TBnqŽŠ ¬ ½2;6Ø… `xï^Ìüç#ä±''—ÇCzr½:ujtEs¿®]Ø5ë£F?—ºØmqœÒ$Î?iAM£°¤”‚²RòŠŠÑ©*Iññ×)³Ásê¨L5‚’òr¶È¢Âå"Îb!#)™ŒäĨû_zÚÉ\zÚÉ�ó}ºŒûþtY [ˆ>½ü~ n˜q7Ì8'â5šO×öí¢§Fƒ�?Ï8‡?ר'5!�´¤DRk½®»whÏÛ÷߃¦id’_ZJIy9“‰ÔÄD2ÓR[-(¢-‘OfmPY­E«<1ëÓpÿçÏ8“ ðß/¿ä§õµçD*q:¸p„F�gw^›õ)�·²¿(°5/'üxªÅÂ)™]ø9gŠÞ`�µ…yŒMï€Í`Œée>/i¦†_Ž…%%ü¼bexEüÄQ IDATYUþñòkáe_À�¢(á‹ÊŸÿøc½aû(=¦;T›s0 RZ^NjRR�m¢·@¨>_aQiYÔmRêO4v[ÿ}ð~nû×ä‡×—;�|ùóB¾üy!z�Ž'Oå‰;nÅb6S\vðÜN—‹9?ý\ï9ŠËJë}œÐ�7{³³ؽÿ@£žƒÇ2 ÛŽï¶omS¡? ·¬UQè””*í·„BqÔ1êôtHL¦Üí"«´( q,°Y- ®qsùáÔ9#�Îé1lÙö :e¤Ñ)£ésÃe$'“‘œÜ¬ãjNª¢�–”HZR"}»tiÒ1ããY­3Ú‘¦)¯ÑÆî£( ÒRé�½ªVˆc�„mŒ+à' cÚ¶cj*¹%%\>y g�>Ø33¥ö/à`Ðh6HªÑs9¬|¼®ünsA>|ñùNGh  ¡ R䨠Ìå"±Z@Ô;!™B¯‹¼PûI§ßËg Ýã“¢Ÿ ‡ßÛ`@8ëûb:V•2‡“ùËWpZ“>oܾƒ>Ý»E¬ÛšÌ -9)jз'+‹2‡»íàóƒ­=»vìШ±6Ä`0púøqœ>~þ@€ekÖ1ç§ŸùäÛï­?çþô3OýõÚUë5o³Zyê¯wòùO;†Ï˜€Çëå­Y³ùËe—DÝö« Ã_+ŠRë{,ıäÙ3§ñó®�-2ÿ][WæªÀ¨×cޱš¼¦g–,ÀáiÜ<µuI‹³ñà䯷Si eî Š�NÚÙ“0KK!„B# :=™‰)8ÊÈ.-¢�=I:(!„B´" ÛOÐ�¿Fõ`õJ0_ rÞ8UUk…ƒUÒ®’ð†Ž§¯1€|·c;¯­XFvNŠª�H5M!Ð(p”G„Š¢0$)ƒùî½áXsŸÓAº%›¾á‹Ã~-ˆ'X÷\„{²²øuÓæðò™Ç3¤oŸ¨Û>ÿÞÿÂU~³çýXg@øÚ'3™6y"†ÐÚ‚âb>Íapâ°aQ÷óx½¼ñé,n¯Öƒûóç³/û`ëÕ† màÇæ¾gŸgå†�L3š{®¹ ½NǸÃ7bý{öàîÿ<€ÓíFÓ4FÞ_N{³³éܾ}x]NA<÷bxùo×]M÷ÌÌzÇ1mò$ùï+äðï·ß¡g—Μ1!rbâÙ?üž`ÔÀ�¥Ç›ÑÄ™}úµö0ZENi1V“ {ŒsÒÖôòò%‡&Y,\:tŠYßúa\I…“2w“1„+ó…B!Ž Š¢�Ÿ@I…“%…´O�ÏDB!„­EÂ6ÄU#hŸ”„A¯Çç÷³ì·ß˜4h0 Vk¸µgSøü~¶ee‘ z:WëÁ\æõðÞ¦ ,Ø»‡Üß÷@PCQ54BÕ‹ZeaPÓÈ*-£gF»ˆcÇ ôJHbWy šþ Æïee HŽ- rê?›÷cD僾ž.¢Wéý¶k7Ÿ~7€ï—.Ã𬷦u[·qÞíwñç ϧÂåâ‰7Þ¦¤Úü}WL?§Î±>ùæÛ8].&ŒÁú­Ûxâ�·Â�uÌÈ`êñcczÎ ±˜Ì¬Ûº­òyíþ�ô”dÆ ŽÕlbÛï{yoÎÜð¶CúôFQõêÅ ^½X¿mÁ`�«î{ˆÿõŽëÒ™-;wqï3Ï…�Ù§{·:¿�Õ þuÇ­\y߃ø|>®¼ïAŽ2˜‘ƒâñzùuÓæp˜I(ľïúkë<æÒµëÈ8qb½ç]9óÈpS!bÑÖ‚A€GnŸ�މ)r·¼B!Ži‰Ö8TU!«¤ˆ‰Iè&$„B!šŸ|k#‚š†'J@hÐëÝ«‹7ofÝîÝœÿÄ¿šýÜ'öíO@ ²hß^>ؼ‰bŸGQ!~ŸUÕ…¢A%T[XÙd4 É*)Žz¼ñ‰¨(ÇÐPU(÷z(÷y‰�¡Åœ'à'¨×¢ÎE0ëûyᯇôé]o¨uæÄ á€ÐåvóÝâ%LŸzR­íÚ¥¦²tÍZ–®Y[뱋Ï:ƒÑƒÖZO¨õhQI)/þïC^ü߇�éT•ßu{Dõ硸bÆ9¼ûÅJ<^/{ú¹¨Û©ªÊº<¼üï¿ÞÁÙ¾�×ËúmÛ8åêëjíc·Åñßî‹ùBõÆsÿ ×òØ+¯‡ÃÚ¥k×±tíº¨ãùÇ_n®ó{(„-¡-ƒÅ<~“e¾!„BÀn¶¢ �]Z,7P !„B´ùôÕF¸ƒ~´:»ñ̳èÖ.£EÎ{þ¸ñǃ‹ðÒú5zÜøÜnŠ÷íGQ*_!•Õƒ(•¡¦@PYÕ*íªÓ«*�âì h(ŠF@ ²ß}Ûš´P«ÕšÖoÛÆö={ÃËgMª¿êlÒ¨‘ƒ³CsçÕôßî%#%%b�¢(\6ílž¼óö:�ß»[7¹å¦ZÀØmq¼ò÷™2ft½ãkŒŽééÌzþö:®Îm’ì¼üàýL=2¼nHŸÞ|òÌSuÎ…8b@¾|ù¿ 8®g£ÆsóÅñþ“�s\×.un3¸w/f>óWÍøC£Ž-„M•d±pËñãøúO×pÅðQm*(w»hgO’pP!„¢šx³…KY¥EkL¹"„B!Z–¢UïÙ(I‘ÓÁ¨Gî�iÛO<±\âsãŽRAXÅëó±jǶîßO¹ÛEð~l&½‹ÉV « óÉòºÑôT&‚ Ûwâ,.BÕëQô:U‡†Š¢SMQÑ€±]ºð̹çE=‡;àcY^Š¢ CÁ êžš�Amxn³NO¢ÁÜäçWŸùËWpá /oùê t:sçÿÌ®ýûIˆ�ç”ާO÷nµö½íñ'ù૯8qø0f=÷4Û~ÿ�o-¡Ìá [ÇŽœ1an48¾o_ŽïÛ·Ñǃ”VT�]VÊ–¼\VìÝËÖ‚\ªƒÙ€Îh¬¬^ jøœNœEE *h¡ÂAUQ°Œ¤Äų§¬�œÒR4MC‰R aÖH2Z(ö¹…`0H¡ÛM;kÜ!/š[‚ÍÆ%gŸÙ¤}{uíJ¯®]›}Lup\ÏFWüôz&ŽÉÄQ#cغqö:®ÞÊF!„8©Ö¸:ÂÖKÂA!„Bˆº¥ÄÅ“SVL‘ÓArœ­µ‡#„BqL�€° ðkÁCª¬øuÛVö“]\L®£œœrù8~‚zM¯G§3 êuèô 3ZQB­C jíÜš®&T‚ jpä)ì/.bïêU•³jEN¼~?&Cô ³if+Å^  =®˜ ¦áׂèé€+„Dz åᾋXw¤ƒB!„"6éñ ì/.Äl0`5šZ{8B!„G= Û_+æ¼^/3üÄécÆ’oGQþþÞ»ú| ×¡ÓÀh@5Tþ§T@S44t= «ÿœ…xœ « æ*3B…~ééœ5h­\Q¹>4¡Ûï£Àá cRRÔñ%M(ŠZ9}¡¢áðy5tjÃó/ù‚Aô: …âX6­ïÌ:=³6­Ç rRÏ^Lï?P‚A!„Bˆ£�ª¨¤Ç'�[VBfR*:U® !„B´$ Ûª�.7w¾ñ*Ë7mdH�ž$ÅÛQ•v)©äd¡šBeüW•Ç)„JECo2B¨z0èP¼g( (•íE5Àb2pë©g ×é°ê ((h¡ƒµ Žò:B‹^�QQT‚š†+àǦ6|a×/““ !„NéÕ‡Szõiía!„BˆÃÀl0b3[(t–“ŸÐÚÃB!„8ªI@Øøƒ� Ã4Mãïï¼ÍÒ�QÐÈ..¦è±ÌŒ 6ædU¦�ÊÁpPCCѪ¾Í-¨?øã/Ý»—€×‹¢ÓAÕ¼‚šÆÒ·C¬&Cõ„ •ܲ²:Ç©* qå>o8Xt|ØêhIZ] …‘úóý›¯…—í¶Øç6¸ãŠËùÓôs°Y--2>!„B!„âX–lµ±¯¸—Ï‹Å`líá!„Bµ$ l†­øm óÖ¬F Ñ€ýyyáÇÚ'%WN!X}­ò?­*(DCTT4MÃ窠ô@Šª¢…3E…v ‰\;qrø0ñfkèx šRy”œòòzÇjÖé)ózBç—ßÓsllh«ø¸8÷îÕ¤}3Ûe�Ù.£ÙÇ$„B!„BˆJŠ¢�O�£ŒÌ¤Tž¤D!„B4…4to4×bô³… „4�áÇÚ''WVÿ<8QLZw°Uháö�Ôª*¨ŠÂ�S¦`·XDz™LÕŽTYa˜]RZïXM:}õ¢Ã˜ç[l|ÓU!„B!„B âLftŠŠÃíjí¡!„Bµ$ l†ùÖìÜYm�BNIqx)#Á^¹V!ÜR”ƒE„¡=*Û”j@E~>®Â¢jÙ`eŒ8¦GON0(âÜfƒ¥*fTMã@µsG£S"ïõ‹µ2°±¡©B!„B!ŽIq6J*œ­= !DH…ÛÍ]/¼DVµB…c•Ççã®^boîÁ®nN—‹·¿üŠû^}�7æ|Éžìîzá%ü�ØŠ%šêó‹xç«oZôÏ}2“ŸW¯iñóÔÇQQÁ[s+¿ÇoÍýªUÇ"ÄÑBÂ6@ÓbÃÜå.gD¬¸¿Ús‡”T :µY•ÓihŸ‡Â;#6Ó€8“‘ÛN9µF¸g2£*T&�Ze{ÓÜZŒü?{çÖÔùþá; IØ {ˆn'‚{�ZmÝÚÚe—µÖÚöÛa÷®vÙi§£µ®jÝ[ëVPAQ@P™²7$dýþH8ˆU[Ûß¹¯K/ÎÉïys’¼çý<Ïç1Hjó›+üÝHŸˆˆˆˆˆˆˆ4�Ö §J§¡L[M±FE¾º’Cp` [·¢´²’ˆ¸8ô·¨ŒRiE÷¿õ.W¯å˜­¿r-›Äôô[rަ89…¬¼æ‹ÃUj5÷¿õ.É™™·¬ o/YÆáè‚éкÕ-;®ˆÈÿgÄ„ÿ2¬d2dRi]ÃPr‹Š¨T©°µ¶ÆÕÉ … �F‹A‰Á$èÕÃ`L!¤8%MU©Ôx4‰)ð@¿´rwopnkd2Ú:uKÔjªµZV–o%}=¡O"V¹íè Ôz-j½Žj½®ÁïqcûÔ ´:ó‡X©D‚B*C)•¡”Z5"ú»8{1‘UÛvËžn®¼üØÌ¤-7ˆ½aš0 ôkÉœû§ÿÓMj’¸ËÉl=xˆÓâÈ+,ÂJ&ÃÇÃ�~=º3aøP|=ÿuš¿^¹šôìkøzzðÂ#5k¿e7“�œ@�Nyàž±¤ff±xõZa»×f=Ž«³ómi»ˆˆˆÈ‘¶vU–c§´þ§›""òÿG{{Žýø]³·¿”žAiEùmiKbZ¥O†qnQ¡Y¦ €�RÙ /Î$&2ÿ¡*¬»‘þº:�ŽøÔTÔšj³õ/LŸvËÎÑK_ŸCÛëõzâSS©R7OPn§/&òÎã3rËŽ)"òÿQ ¼�H$ÍΘ³V*iíîÁù«W…uªj ¥¥ØZ[c£T⤴¡RSV§æ`�I<‰Éb´ºš²Œ,£iÍJ ÐÓ ²xn…Ô +©ák]*A¥ÓRP^�·³“Å}4zƒ�;`%m^Òªäšx¹)©¬dá–�MˆÃÑÆ–)a}ylèp¤¦ßÕ éi¼ùûjòËÊxdØpîívKͺõ*�•N‹5J™629J©ìžåú,߸™u»÷ˉ„ûïCK¯Ÿ8q.–•Û¶з{·;V ¬¨ªbþç_²aߟ Æ«ñÉÉì?ÁÇK–1÷�ûyé±™¦@¶/»ŽãlÂE‚Û·k¶@x(2’?ODPVY!„y……Âû 0÷�ûE�PDDDä°U(É+/m20YDä怜²’¥ÛwpþR22™”ž;0óî»±Q*Øê4¯^¥O—.,ݾƒòÊJºtæ© ãQÈåÂq¢“’Xµ{/™yùø¸¹rÿ¨‘„vîÔä¹ãSSùm×R2³°³¶¦[ûv<7m ‰�NǪ=û8G•JEç7÷-¸xõ*¿îÜÍì‰Y¼aYyy´kåÇóÓ¦ÒÂÑX²¨¢ªŠŸ¶nã\Ò%TÕ|Ü\™1z$½;u¢R¥â�%Ëyáþiø¸¹¡ÓëYµ{/GbÎR¡RáâèÈØ~}7 ?‹Ö¬¥¼²’5ûösèÌY” 9>õ$©YÙ,ݾƒKé¸:92¶__Æöï'\ãçkÖÚ¹)Yüu ;[[î9œa½zðÉÊUT©Õ¬Ü½—}‘§°µQòÞ�7è+�NÇ«ßÿÄ#cïb÷ÉÎ\LÄ£… ³'�§³¿¿°]~q1Ëvì$!%¥RA¿  ¼k2™Œ§OsììYʫԼüí÷7eæå³|ÇâR® °’ÓÑ¿s§LÆÑÎŽØäd6(¼þÑ– ¤äæPZUÉw»v˜Õ'¾Õ•NKQuùêJªtÚfìuó¨Ôjv=fÞƒ�-6º�ÈÍQ©R1å¹ÿñÇÞ}M³Uk4|ñëo¼°ðSÑ&^DDDDä–ã ´¦\]ÕŒ-EDn?:�ާ?ýœ£1g¹Ô& Ķ£ÇyéÛÅÂ6)™YlŒ�‡�²¤NÐЉóçyú“Ï léËóÓ§Ò£}{æ}ñâ=÷™ÄDyÿCäVV<;u2ŽMiE¹0í¶`ÅJ–íØÉè°PfŽ»›ø+©<úÑB*UF›Þ¼âböEFñæO?Ñ¿k0�ß;ޏä+¼õó2á¯\MÌÅ$¿wóz€~ÁÁ”çý4Z-û¢¢(¯4~×ìý“µû÷3}äpæ?ü÷ èKq¹1«¯opr¹œà¶m°�^\;Æï}€BnżiSÆ¢µëX½÷O¡ á±çY°b%Yù<5i<�Zóâ×ß V•ý»vÅJ&£[;㱇öìi±¿ôû¢¢xý‡Ÿp¶³gÎä‰X+ä<ýé"¡OªÔj_°� —SxdÜÆôéÃò�»xo¹QŒ ôöÅÏÓ wg'F†…02,wg't:û¢¢(­(ÇÞÖ†‘a!XI¥tkߎ‘a!ø¸¹SX^ƾ¨(Áb43/Ÿï¼GV^OŽ¿—'Ç߃µRIiE% W®ÒÖ·%³'�çÑ{î&)-ƒ9Ÿ-Ü`j„¹¾]º02,„ÐNF191í*ç._®{é¶|²r• J––ñðû�SX@…Jž¨(^ùþ{:µnÍÓ“&�W\Ìs_|Õ¤óLä…8R³²…僧ÏðæO?#•HxzòÜœ�˜óÙ" K�÷ËPS{Ã:wfdX}ƒ:ŸÀ“ >ÁßÇ‹ç§O%¤SGþ÷ÍbŽ�='{ÿ©Ó¼þÃO<9þ^B:vddX‰„îŒ}ìíêJQY9…¥¥L>Œî›F»V~<õñgÄ]I޵õèqžûòk}}xþ¾©L6˜ì‚Z«Ô×ø‰MG3eØfOœ@Na�|øíßóœ+"òO#†_ÝŰæO¦L0�µ’]X¦=³òóéÑΡáãæÊ¹l“¿³ÁôŸÁ€‰mY9ª¢"¤VµQK˜DÀ/öì䛃û@"ÁJn…••[¥+© R*4Óö£ö(‘4*0P©­FRóã"‘`Ó̈?©¨Šˆˆˆˆü?Ç`0ðGÄ ¶œŠäBzZƒ×7Fžä¡!C)Ó¨)­SûBo0 ªÖü-mÔô”hTTh¥8È•·5£poø Ê,Øm9p�¹ÜÛÎ{»˜2z$Ý:Çnž®®ÿts,òö7ߟ ,·prdö´©„v F«Ó“p‘_OaI ëvïaxŸPÆö¶úŸá±É“= ?þ>¾ÿtsDDDDþSØ[Û�]\„‹�Ã?ÝN\¸@ü•T6²�Ö& w~ïC.¤\!(0€*µŠ/ž›+dæå³'"’g&Oàëu˜>r8sMË}ƒƒ(WU±dëvúu±xîïþØÄÀnÝø`Öºá&á-¿¸˜M‡�ðÉܧj´¸ìÕ¾=£_x‰�á'™:|(:½žÿÝŸ�åe£TòÌg_ Ñj‘[Y—r…iÇ1¨{wa;KÄ¥¦Ò/8¨ÖR³}í¶ý‚ƒQÊåíøqË6z´oÇÛ�=*¬³V*X°b%Œ)¬ëЪ¯?ò0ƒºw'ül,G¢ÏÒÆ×—ݺ"·²¢kÛ¶Œ í}Ý÷쮾}xr½ôîÔ™ÁO?CLÒ%úw fOD$9…ŬxëMœŒß1v6¼øÍwÌ™4‘_oü½½Ì®£F`°µ¶fTh(ï,YN÷ví„LÇÜzA›¿ì܉«£#‹_zApÝؽ›ðúôæcè°Î�0û’ÒÒèØº5Czß“¾]ƒhïçgñZµ:˶ï`Τ‰­aP÷nÌ�2€þÁÁ:Cd\}¸�’¾¨SlýôcáóÖ¥3ã^šÏþ¨SŒé×·Ù}!"òoEﬤÒµ~šÂÑÎŽwzˆÿýô•*5ƒ�ÌüÚÈ?À€¤&sPbt•˜ì>+³s�Ô±’˜þ7@«×£­®™Rƒ©Î@¹¦$2¤2+ H0HŒ‚¦DbÔsÊ-û‰kõF;2L"¨°–É-n[™Ä<¹uב£\J½Úèö}{t'Я%«·íà‘‰ãq6 Ân5ç“’8yŠÌœ\¬¬dxº¹1¸w]ë š²róøc÷&�‰Ÿi Q½^Ï·+WÓ·GwB»ß–¶ŠÜz6ìÙGfNÃúö!¸}»¿í¼9ü¾c3'MÀÉáö?«««ùñ÷õ`0ðÄ´)ØÙؘ½ž™“ˆ={ÍÖIe2Ü[8D[S±h�NÇâUkèß³!uì"þ.¯\a_øIž}pÆ-;fnAkw캭ß3""_ìÜ7@‚Ü IDATÆŠ#‡}=³°€¢j£0øô˜1¼½z5Z�Ž{ÂÂhíÑü‡º[�Ö §¨º ¥Ô G¹Ùm° ߸o¿ð·³ƒÅeeœOºÄå´tÚ¶jø�¬Ñh¨¨óð^óП•›GFNn-œ lÙÒâù*ªª„¨Q…\Ž­µ5z½žKWÓ()/§m+?\œ,[¬—”— ™t6J%J…½^ORêUìmmiéåI·íéhš@ª±²DZv6ÙyùH%¼ÝÝ›e§ª®®&9=�²ŠJZûxãåÖ¸UPc$§§³j{m½Gvü¸ß:÷Ö �^Œ<ˆ»žœ-ˆ·_¯\ݨ@X¥R‘’‘IIy9®NN´iå×èµWªTT›ÓäVVÂïPƵJ+ÊéܦMƒ}2srÈÈÉ¥…£­}|P*7|Ý•°®Áh´Æ(îºÖa�‘�—OZv6.ŽŽ´iå'Ø×E]]-Ôl‘J¤8ÚÛPP\Ljf½ºt6Û^¯×“š•EnA!Ö Þîx¸¸X´îo¬Ó³¯QVYAÇ€³º>J¹ë†õ¿JË+ÐôMn#"""r³(dVȤRT ÖÍøŽ¹�¤ddáçé.ˆ ÝÚ¶ÅÞÖ–äŒLA líå-ˆƒ¾î䙂ª´:—22héáÎWëþ¶I»v�K�žûbZ¯=ü åvee£7е«°ÎÑÞž.\ά=¦T"¡kÛÚqTKOôùÅ%x»¹2¨Gw¾ü}1II êÞ�þ]ƒ…1t}wëÊ?/%+¿€a½z2 k0>îîMö_ÂÕ+¸:8™]wiE9Åeeä—àf*aÔ£�ù|KKòLîi7JÝcÙ(¸:9‘_l|/’33é`v�ý‚�}x9#¯[Èw1õ*ƒ{voÔ’¿´¼œßöîãlâ%*L  3/�Ž­[7ëÙùTªTôïV{XÉd„vîÄåôL³m{tè üíëa|ßòJŠoH ìVg^L&“áåâBž©o-¡7HJKÇÝÙÙìÈÈÍmpï÷îÔ´Ýn û¢¢ØA^Qzƒ�Üâb:šæ¤r )*-eh¯^÷½˜š†�RÉæ#GÍÖˤ.gfZÜGD俆(ÞÔÚC¿ `¾|úÞ_µ’¬¼<² ò„×¼[8#‘H¨1•I¥( ¬€²œMOTÝ žzç=¡á„ÃøéÝ·-nW\ZÊ#‹¿cïñÚ{°¥—'¯Ïz‚É£Fšmûóøð‡Ÿðvw#zãzÞYü=Ë7nÆÕٙحÁ” °xõZ–ü±�¼BóHu''™p//Î|y�Iõç|ÂփƄÑú³øÍ×yæƒ�Ø~‚‘ýúòÛÇ1äáGÉÎ3!†u fÛ÷ßš»R¥"èÞ‰Â{úéKÿãSt¾ˆˆˆÈ­ÆF¡ JS- „"ÿ8•Õjò†AHÖ …Y`š²Þ½*•J«É*µ�N‡³½=Nv¶Â6ÁméÖ¶­Åóêt:ÔÕÕØ(•–Û¥R!•H*Ù(•TªÔ²L&3 Ð’š& ¦€Ÿç§O¥WÇö<}†Ek×ñþò_y÷ñG-fR�é×?/OöDD²r÷®XÉ“îeΤƟ{Ë+Uø{z›]·“�-ÏOŸŠ¢Îœaýë�J¥ôÍO®¨‹BÑð½ÐÕ¼*5Êz¯+äVH¥R!PëVQ¡Ra«°üþ<ÿÕ·XYYñĽãpsvÆZ¡`Ú›oS­i¾ÕeM›ëß6J%™ê|³uJyíóZÍs…þûXYï³ »Îû¤®®¦Z«Å©Þ½ïàO—³míëª[bgø þ¶Š—˜A»V-q´µcùޤçäP¥2ŽSë÷²Ê r¹Y[¦J'‹ûˆˆü×Â;«¿eß·sÖ½ñ;Â�SV]û£ÕÁ§%3ų…3®ö´ppÀËÙk+93Þ{‡r½L“v@&‘ðÌÝw£Öh‰¹šJBNvm=! ¦¨ãºm”Y‰ §Ô²Åh¾ªÊ(:šòå ¤ÍC닦wƒk—_ùì |==x®^äTͤÝí :.ž-ûò褉ŒP[<Ù`0ðÓïëY³}'zöľÞ�Ê¿•ˆs±ôíÑýŽÏ]¼ˆƒ)rþŸ"â\,z½ž!a½ ?CEUUƒÌºÛE›V~¬ý⳿å\ÇÏDÐÒh‘v"æl�°†)£GÑ©M­U„F«å£~æ�=ûÁü¿F@Ë–7ü^dçæŸ¢@(ÒLâ32šõpöÌ?˜2êþi (Ö¨°Ñ[á(·¾%U…·:ŒÆ”í¤�Ë™0|8GOŸaû!£ˆºåÀA‹a}f½ó>GN�6[w)õ*¾òß¼ñ*Óîmq¿ôkטñÒ|3‹S�^ÏîcljŽO`ç�ß5êP\VÆ”çÿ×@¸iŠ�—,ãË+-·%ûo~ý-ñÉ)|ùêËÂú¢’RÆÏ�g&’Õ�œžÎs >&61‘�žŸg1£¬>áÑ1Âß¾žžŒl"èiÖ´)tïÔQX®{nj—æSbÁq¢¬¢‚_7oeÿÉ6}ó%­}|=ÇSï¼Ïþ“ Öÿ¼~o}³¸Áúj�†ÕÛwr(2Šõ_.jTöO1÷Ã\ºjnœq-‡9ïDv^~“v¹ ^ÊÏë74XÿÊç_°jÛ‹û–”ð劕dæäò훯5zì'ß~—ÃQ§„e©TÊÄÃù~í:0½�…%%fY³áÑ1‚8(·²âž¡ƒ-YDDDäÖ`#WP\UA þÙgB‘–îîdäæQ¥®ÆFiG JJ(,)ÁϳyAI¶¶¸:9áïíÍCc,�?ë#“Éðõp'!5•½†ßúyz 7¸œ‘!d›é .gf0qРºÆ�ݺ1°[7tz=Ÿ¯^Ã�[¶4jµHP` /͸Ÿ ‡óÑ/+xtìÝØ(•ÈdRôzó’Jþ^^´p´çÑqco¨Mõ‘ÉdMÖËk.-=Ü ?�^/dö%gf¢Óé„L:©TzÙ%¼½‰Om8V(«¬äLb"ë>|Oxÿr …LBL×ÌuD¼–îH$.gf˜e&¥¥ V©RSù ]�öÚ(•xº¸ÐÚËó¦ï€#1g¹«oãÕÎ?åÔyîòõpC&“Ÿz…_ïûûûxSQUÅ”aCq°ýoÌ犈Ü(¢@x ¿‰z=ŽvvÌe>˜ôöæ)ï†_zë&£´Ôhøi‰éwtP§.<4x¨0YTX^ÆáøxöÄ] !/�Áz“_©D “:h0æ^+m˜Ah0ÈUUÔ,ܬ›/¤ÈI·o. „GÇ�˜r[kôêÕ ºÿlÂEb“0 tjHHP‹öN5lús?]Ú¶1Á( N5‚ÓâˆMJ¢_�î�ã¯PRVÆá¨ÓäâîÒ‚�!½Ì&gŠËʈ<Kv^VRíüÍ®åbÊtz=~^^Œˆ °¸„–^^ ëmÑ«R¥âdÌY0Ù¶) ºu4Ú\J½JÜåËäãìè@X×`üêÜk‡£Nܾ×òò‰<ËŒ{Æb­T’_TÌÁˆHÊ++éÙ¹¾žž$¤¤0(¤6Å¿°¤„c§Ï�WX„»‹ Cz ×y("’Š*)iéœ<{޾u<Éë¢Õéˆ<KZV6êêj¼ÝÝØ»—�Å�_TLÜåË ìÕ“£§Î�’žŽ£ƒ=CBCqká|Ý÷"üL4]Ú¶eìàÁŽ�J• _O‡†˜Ù©Uk4;}†ÔÌ,ìllëÖUý𢴼‚óI—˜1înt:=kw´ÔÌ¥1äVVôíÞ�Ä+W((.n¶ªºZÃÑÓ§IËÊÆÞÖ†>Ý»™MŸM¸ˆ£½=vvŠŒ¢¢ª’6~~ôïÕÓÌž#!9…Sç/ 7èéÞ±£Ù9Ê+*‰Œ�ePï"ΞãÒÕ4ìl¬Ò »äbÊNŸ¿€Z£¡•�7ƒBz }[sœ¾=ºckmMâ•+hµ:Zùxsà¤ñ3æëå)¼ï‰W®péjªj5NFгKçfõ¥ˆHs¸”en{"•Hp²½3&ΪtZ´†*ZÈ­…Èä¿Ê¦? ÅÑÞŽ{†ÂKWÓ¸pé2Aí,G\×päÔiÜ]Z0vð ò‹ŠØ}ô8:½ƒÁÀk_|Í�ÐÞx¸¸4د¦_�Î�èÙ©#gââ9{1LЯ}ù«>ýØâ9Ù¼•âF‚©,‘�—Ï·«ÖË}ºuåž¡C0ôì=~‚cg¢X³c'3ÆŽ¡·É¾ùõ¯¾ÄA'{{ž}hÞnîì?ÁæýÆþ[¶q3½ƒƒ˜8bx“mÐëõÄ]N–ú7)*z¹¹qïÐ! Ö—WVòÄ[ïâ T*eòÈ´óoÍé qì ?&�lö»°ë§ï-žçt\¼Å>ŒMJâ½ïjEò» dìàAäòÝšßÉ/*"+7�§ßû€}Kjrœ÷wqéjö¶¶Ü3t0Õ-;D¶…?/etÿ~´óoh!URVÎ�ul˜jHHNaõö�ÂrhpCB{#‘HØ}ô8±IIü±w¯?õ$Þî íf#cÏ[ìßÉ£F ¡N¯gÿÉ3ý`DmVï zãS‘[�µ\�º´ØäzÔüq…X}ö /Äb0Þ¶=uï…óßè)òßc`·®H¥R¾Y¿ž—fÜ�N¯ç³Õkqqr"¤có,&ÌÒí;èÒ&€ž¦Ú}Yyy$¥gµÛê3eÈ~ܲ•ÐÎ�ëb¬SxúâEzuè@ �í[ùñÅÚõ|õü³Ø(•ü²}'¹…EŒjF�¾vgh¯žB0´Z£Á©‘úŸNŸ¦[Û¶¸9çSª«5ØX[£°2N7{»¸“t‰ݺ"“Ép°µeò°Á¼ñãºtá.Ó|JqY'Ï_¸¡zoÞ..D'&Ö¹“pì¿Â°�¾ß´™åÛwòø½ãP©Õ,Zó;m|}ißÊ`æíêÆ¾È(2óòq°µ1sʸ&ļE_±íØqÆ è�T"!)=Ï-°³±ÁZ¡ *þ"[·¦R¥bÁŠ•fÏQ¶¶ØÙØ—€�«+VVV ÚbkmÍ îÝù~ãfº¶iƒ«“;ÃO“t‰§›Èì¼Ø(´ptäTüE¼¼�ÉdØÙØ0eÈ`–ïØEp›6„˜‚ ³ó H¸zU¨ßØ\œìí¹�œ"8˜ì>ÁÉ qô2•�RÈåÜ3 ‹7n¢}ëV´kÙ�^ϹK—éÙ¡=}ºáÞ™·^Ê{O<†£½=:½žSñ x¹ºáï}ýò""ÿvD�ðÀJ"E*‘Ü’È—ÆÈ-,äû�ëÑëõu~\ 8ÙÙóÂĉfl{&…†114”„¬lÖFDp4å2Z Fq£¸h0eUVQ­Õ °ª›r«*©Òê°’³e)ÎÍ¥Éu-F¯Ç/7S©RѦ•Q±Ø}ô8¿ôÞ&/ôå6q "’ öí°’É8p2‚ öíxé±™'�JËËIIÏà‘‰ã-žÏÃÕ•¥½Sm¶DVnï-þk…’¶­[q0"ŠmóÞ¼ghééI~Q1¯}ñ%6J%)-/gϱãŒП‡MOáÑ1ä’WXˆ¿¯/2™ŒUÛ¶söâE^yâ±çT©ÕDœ�“åhoG·Ž8Å’õèà�»‹ §Î_`ËŸxë™Ùt0zì¯Ø¼•¡a¡ì?qw—èõz²róxûëoqr°ÇÏÛ›ï×üN _KRÒ3�ðJFþð3¶¶´iåÇ¡ˆH¶8È«³ž �k"bÏ£R«HϾÆÙ„‹BƒÁÀÇ?/%9-�n; “J �ŽaÿÉ“,|ñ¬d2Ò²³ùmË6b“(*)¡¥§'‡"¢ØwüŸÏY¨íc‰ÜÂB’R¯òÔ}ÓhéåIK/OÂcΚ „§Î_ îòeŠJJqqvÂÞÖ–c§Ïp0"’wŸ}¥’ÑQ–”p53‹V>ÞXÉdŠˆ$üL4¯Íž…L*åL\< É)¨Ôjò‹Š˜4r$ÅÅ,ýc#AíÛacmÍÞãá¬Ø¼• vípr°ç�={9uŠž›‹L&£²ªŠwÿ@QI ]Úµ%53“m1sâ„"w}Nž=‹Á` _ÏTk4¬Ý¹‹1g;¸yQ‡ÙyyH$ìš9@/«¨à½ÅßSZ^A—¶m¸’‘ÁÖ‡x|Ê$†›,ÿö;Ž­µ5—®¦Ñ¹mTj5?þ¾žäôt5 rw=Æo[¶ÑÒËOW7¾]¹O·Úz%Æ>H%8ÚÙâéäLK'glo²†›F¯£°ºŠ ›¿\—03'‡ˆs±Âr�5²__l¬­›ÑÍû\W ôóöbÛwß A[bÖÛï�IÌZ»s7Ï=ô€Å}§ŒÉ7o¼†L*E«Ó1çý�Øzà ûOF’’‘a±žaqi)SG�âÉiSðtu¹n�¶S. ÕéÀ„´vѧƒÿã“'1ìÑ'HHNàPÔ)z‘q-Gh ÀÏï¿ÃÓDДÑ#‘J¥lÜ÷'Ÿ,]~]�°´¢Bh€_3êZbíÎ]‚E%À7o¼ÊÔÑ£„å/W¬äã%ËÀ$Â=}†Á"â‹KKéÞ©#¯Ïz‚þ¸õ‡µë…v éÅ/ >öÒ‹Q�ÏB§×s>黎gÜ�‹ž¿8ÚÛ±eñ7t1ýæv.¯,þž�³ŸÁZ©ÄJ&ã£Ù³ð÷i˜DÐëâ­Ÿ—âêìL•Z�G g>ž3Ûâ¶ÇÏÅòòℲ#2©”³g ÁÏÏN›Ì‚+1ïr9'~þ�1}úPRVÁÂßVñîÒåX+•”WV2ºOØ „ÏMŸÊ•«Øxèv66©g…Þ\Z{y²`öS|´b%¿îÚ�ººš@¾|îYaþôžþ} ?ËÔ7Þ¢¢ªŠo_|ž�zÉÍa`·n¼1ó>]µ†…¿­ÂJ&C&“±æÝ·p²·çÕ‡dን¬Øµ›Šª*ž¸÷ΛÆý˜ž ^}è~Ø´…¯Ö­'¸M +Þ~³ÁyÞ}âQæ÷#ç½€�­-Z­–W˜Ahçæ Ø·ŠùÜÏ·6òý¦ÍtðócíïòؽãPiªyö‹¯„€o­NÇCcFß°@øØ¸±Ìþìs†Í}…\Ž¿—$íZŽ°Í«=ȇ¿þÆô7ÞÆÉÞžòªJzwêÄ÷/¿ˆ�RÁ/¿Ì{K—1ä™y´pt¤¸¬ 7WÍ{ö–÷‡ˆÈ�ˆ(Þ!(¤2Tºæ{Jß(?nßJQe™dÆ/_‰2ŸF îJ�ÐÙLJ&M"97—¥áÇ9‘r­ÎP«J J«¥´ª 7‡ÚAzzE©Ù‘Ümm›-ú)n"£²¥BÁkO=‰D"¡¢ªŠ9ï~@ìÅ$¼ÝÝ9Ÿt‰?Oœä¥ÇgÒËq•x%•÷¿û�£§Î0$¬adUv^ƒÏ[Xœ¸9üºi .Nμ3÷i¬•Jª5^ýüKv<Ììû§ øä¥ÿ ŽöîãàÉHA 8w1‘›IH�ñzZú²rëvÔÕÕfg˜jÕ¼>ûIf¼ø “G�jþí9vœþ={ðŒÉúJ¯×3ÿ³/ˆŠ½ „GO�æ£æ ™…?¯ß€�§oÍ™�ÜÊŠ¼Â"æ¶+«Ú¯ŸŸ~ÿƒ¶­üxéñG‘[Y¡Ñjùäç¥ü¼þ>{å%^›õ/~ü)Bz5:©™ž}�¸K—yuÖBÆcrZ:o~õ ×®áïkÌœ«¬ªÂZ¡àígžàî!ƒxaÁ'$$'V§ˆs}£cPÈ­5ejôéÞ��{ÿl�Y—œ–Îøáøo¬±>Ýå«i¼»ø{v>ÊäÑÆÚBñ—“™9i£ôÓ¤ègK—s$ê”`å™�œÂ݃2ãž±H¥RRëGÞuäw  ¼Ï É),Zþ+×rhíëÃ{öQVQÁg¯¼ˆ³©}¿ïÜͪmÛ ê‚‹sãQþágbjßN(Òè×’Ñ1”Œ arV§Ó‘�œÂþ' îÐ¥�öúßkëvíA¥®æóù/áho¦ÚW¿mÙF¯ .B;"cÏóÑ ó„÷ò·-Û8s–G'M¤¨´”5Ûwë|MŸŠD"áZ~>o|ñuƒóåðÙ+/akmM•ZÍG?üIJ ù|þËäòÇž}L1L¨1x-/Ÿ×¿øŠ-ûòà½ã,^CìÅDþ÷è#B&O Ÿ+¶l¥J­æ®�Ðétì9Îë³g]·?Dþ£Öh˜¿ú7*šYï¢g›6 ÖéõzÖ‡'3?ŸQ=z|�Ú Ö 9�ýüôôââµ,N^Meyt4: ~-\ ñó#ÔLJg—¿” ¨5è)¬®ÂUaó—öß´ÿ€ €(ärF›‚l­­Ñ7ÌÌfôÍÙ³šÌ*˜÷à ³ŒáñÆòë¦-œ0Õ\=p2¢@¨�Ëyç™§…X+™Œ÷æÎaב£h´Z #¢œÒP ìÕ¥3ß¾ùZ³³êÖ„1 <þÆÛ<4þ÷ÁÎÆ†?¾\D¥Êh5ä`šŒ9)X÷xºº â` 3ÆŽÂ+™¤ffáïÛ¸�§ªÞýW¬Ð\ö…ŸþîÕ¥³™80çþé,Û¸I°_ýóÄI‹¡­µ5+?Y`–ÝiìóZ�jZ={® vm îО³ Áärp'„�Œ/ˆƒýztgÒˆá¬Û½L"œ%�à‡wÞÆ!5L>Œþ¦IDG;;AÔëõì8l^ÇTm²é­�½­-«>]ˆ«sC7…)£GñÑ�Æ:ˆ‡"OQ­Ñ �˹œ–.Xû[+•Œù�ÚŠ‹ˆˆÜYÈeVhtº·Æ_h°®R£á—3Q¬‹�…B‘¿DX—ÎìýjÙùXɤ¸× Л5á^fÕ«Ë;*4”Q¡¡Â²L&ã…éÓ˜7u YyùH¥RA]ØúÉÂFû “ÃQÝöÖ°s‘yÉ�‘¡½Ñ;„̼|쬕 zímmYüRÃñYýcŸ\ò£Ùr�víl3eØÆ@ZNJ¹o7WácâàAŒ í͵ÂB<[´ÀÞÖ–'êÍAÜ;p÷ÖwÍȼü’‹£‹ˆ…V IDAT#K^›Oye%¥e´ôp7s^jãëÛ ]2©Ôb_ÕeÍûï6y½k?0ßfL¿¾ „_™TʳS§ðÌ”ÉdæÌ=]Z˜ÝûõïÓ¢–/1[övseÓ‚ÉÈËG.“âãÞÐæ×F©ä£§ždþƒ3¸VXˆ«£#®uœ/ü½=ùå­×©T©È.(ÀÙÞÞìu‘ÿ:¢@x‡ ¼�áÙ¤$¶Ÿ8nŠ46Ú}"�@o/îܼZ!m<�€–-q C€?ü9{ñ¢E�P]mœP¹^Ôÿ­D£ÕráÒ%fß7 kSj…\ÎÌI(1Õ™3h £ôŠTëõz4-*“UU n-Zâ @k ÕM³'ýÞ™;ÇìǺÆK]ï\ƒz‡â F«åÔù Ì™qŸ°¯»K zcš°Ë/*æjV– bÄ�<�Ï—ýJ~Qq³ì?}½vL£vh -+‹ÜÂB<\\èÔ&Ð,‹5:>�}ûâ À½Ã†°íà!£Íj{׺äp9-�93îÖõíÞ�UÛv��—'dáÖ°jëv³ek¥’þ½zš Ô×#&>�Qú â ±­CÙyø É)BÆhÇÀAhíëáH£½YBr :½žécÇŸ}/77öîÅ�æ5«Æ$Ü'6J%Gçóe¿’WTDü¥ËȤRƯ}½ÜÝè׳çM6m–puvÄA€V>ÞÆÏXEäEþýnCæÿ±‹ 7TKð«m[¬»VXHt²Ñò`l,Ëæ=׬¬Bk…œî­ZÓ½UkfVk8ššÂö”dÖ]Œç÷‹qø»¸2¶];†û´2{Àl:“HèòDÂ�ûö  D1ÀÌf4ãZ§/Ä™}ëcI|Ú[/¦\±¸_ç6� ¬G½ÝÝhïßZ°âLHI±¸ïÀ�^7d…Ö;(;¡ÞÈÁÈ(FF!—ËéÓ5˜á}økÀ3ËèºíV©ÕL}þE³cÖÿ­NJMmR t²7·•UT6»ýu©Û®A~w” a]ƒÙqø(ñÉ–û°c`@ƒþÏÊË£¸ÎgeéY¿{¯Ù6™9µ‘ÃI�Ô{ù»Ô»a? ¬Ü<ŠKKÍ~¿1ý¾ö®3–«ÁÇÃw Š‹9Å÷kןœLâ•T³š™MѹM Eq`Òˆá,üy)z½žòÊJ£Ï24¬·™8;²_ìÅš-""" +ÕZ-ÜÀðºXUÕèkw²Ph)˜WäÎÃÛíæƒÈeR©Y­¸æ`£TÒ΂sE ÎB í_ÁÁÖ¶Ù–�6J…ÅÚnÍáVôß­B"‘ÐÒ£y5$o¹•m|-—±³±iôµÅÞÖöŽ£I%’¾÷-!“ÉhÝ ÇG;;!ÛÕ¶ÖÖ·¬ïE,S?UäÎ@ï¬eV”jÔÜê©F­NÇ¢µ«©®Ö ‘Y�ÔXŸO!“òüĉX+š?²– ¡_Û6tóóã磇Ù�F£ÁÞÖw“8hŠ Ðé Ô yÚÚbÝD”ù9ÀZvó„®Îõ&BëÌËeçå‘[PÀëõ2‹ò Ô)¬¡f22¿¨ˆ–31’ÓÒ‘J¥ÍªñÖr 1 øx˜ÿXv5ùhc £ãâ9Ovn.iÙ×Ðh4fÙy˜Ä‹ºÜÈDeÝ}öŸ8Iâ•Trò ȸv ‰DB‡À³íêZWåí6êe^z¹»�±œ9ùFë±_7o5‹4«Ö'3KÊÊš%ʤR ‹KØrúiÙ×ÈÊÉ„ÔúÔ?ÞõúãJF&™9¹ ˜¢èkÎY?³Îµ…³ èÖÐÊÇ[°ƒÃ4™W_økåãÍå«W…eç¦#gNšÀk×ñüGÓÚLJ ömН§Z�Žü¢"Žœ:Å™¸øû–”•7zÜãg¢1 ì?q’£§N“ÔáÑ1L©—ýñò�™}&l­­oèþRWk(*-å`D‘çΛ½&‘HÌj"56y‰IØt°³kðäçÕ°·¯§ù缕·q’<· €ÜÂB\œœ¼‡¾žœˆŽiôü·â3&òïCSÇ‚ñV‘SÒ°®oSì:uªÉ×5Z-—³³oØvÔF!gtû kÓ–}—“øýr‰•åœ:Á…â|Æûµ¡�Ó�SkÐS¬QÑBaCs?!ñÉÉfߟy……¼ôé"a¹Òd/ZÖ›½ÜÚ+z×™(­¨0Ú±×ûŽö´°¦ïó�°´‘ïÖ789ãîÒ‚%¼ËüE_’ž}MX¯Ñh8v&šcg¢yﻹwè¾z}>¶ÖÖ×9wIy9GOŸiò…%M×D´±¶ÆÉÞ^¨˜œžÞäöZ�ŽòÊZÑÞÖ+™ŒÒ:Uý“ÚõµãœÆ~·-Õ¶«ßß55!£ðê@ÞN,¹Qx×›Œ**-k ¶pthô·å»5¿³pÉ24õ2[89Rt�÷Ú¸]ãÒ-½< ëÌI“ˆ¾/ü„Q Œ¬c/zËZ‘[…\fE…ZÕŒ-oŒ¡ð÷Ø&tæñ�P\ÿášÎUU¢@(""""òŸ¢î3£È�ƒ(Þ!H� ”YÝò,ÂíÇ�sþjªI!3 A‚й };5ŒBnvJ/ŒÅ€¶m9•’̰.Á¸˜"ÍSËJ(T«�ˤ0 —ÊhmßüZ[J™•©…7GSÙ V2­|¼™8rDƒ×+4ìçí…R¡ %=C°qªKMý»~=»ÐòÖý­ÉΨ± ³Ä†½ûØvàÃú†1fÐ@Zzy‘xå +ëeuI¥7×§:�Žw¾^ŒªZÍÈþýѯ­¼½ùnõÚÛÖùjΫ­7‰®ÓÕ^S�7ý„áCq°o˜ij©VŽ%’RSyÿ»éÚ¡=}»w£•·7vvÌû°¡5Å�Š7ág¢QÈå ®gÙf­Trêü³Ì:­KM�V‡LV;ÙlÉvS«Ó } ·jZ(ïÞ©#‹ß~ƒøËÉœOºDÄÙsì9zœ×gÏ¢C€?R‰„~=ºÓ¥]»ûz6¥w":__zvél¶^¥®æDôÙ¡�R)/ÿ+H¥$ ý{ö S›À¯×�Ìoêsm­PXìWË}­µ¸,“Ê�[ÉÑXø®ÿþXº‘ÿhô·^ lyƒVÖ��Ý`]Nq;¢ŒÙµN¶vû·þËí‘ËdŒíЉ~þ|w.š]ɨÕlNK!ÈÙ•Á>-±‘58Y­×Q¦Qã(o^€Ò¦}Ì–£ãˆŽOhtûmóþ¼¹�f8–”6ø]©+ Ø(•3·‹JJ=^ v¶·.ã`xŸ0¢Ö­áDÌYv9Æžãádåæ ¯ ¶<„‡«+>7×l±�Ÿ÷�ÓäñƒÛ7üm¨O¯.�9hÊÒ>yö\“™ ëvíáŸÔZ5Z±ŒÎmÚ`gc#D‰[¿ ëômcã0K(æörON�‚§›K£ÛßLý­ÄR?Ö»¿n$Ò{ÃÞ?yÿûZ{§Á½C3h=:v$¨};|ß¼x7yÔˆZ�ðÄ Þšó'bŒËvvŒ0Õ ¹ÝXIehoÃø«†*�†µç¢Ùþ ‹JÊ,Ȉˆˆˆˆˆü[)*i¾[’È߇(ÞAØÈä·\ Üpà �LëÀÑÖ–yã'Þt†Kï€@zÔNè—kª‰Î¿†L"E"1ŠžþöN(nÀ2ÔFÖüZoww¢ãã j×Ö¬v:‚ŸwÃl#L¢Wÿž=8Å݃6È:Oye%]Ú¶½eít7y·§¤§›ehmþs?—ÓÒyùñG9ŨýÍê¢�OlÜñ¯r9-�ôk×øðùy´iå'¬¯oYÖàZ¯!53“Žu2 S22„¿½Lµ¶66f–—¯¦“�@P»æõé±ÓÑxººòòã� ïknAÁ \¥eôz='bÎÒ³Kgî6Ôìµkyùœ:�ÑgûÐÂ’Rò‹Šp«“­“�’‚O� ‰ŒìkT©T‚]©Á` 1åŠYß6E•JÅŸ'NÖ­+Ý:v [ÇÜ?în^úø3"Î�£S›@<\]Ñë f}ZZ^ÁÎÃGÍâHIÏ +7�§gÜ×À ÎJ&cåÖí\ÉÈ   +“Ene…›éóT·­Å¥¥ì>zßfÚMx»»S¥Rq53‹Öuló¯4´”KºrÕ¬nfBr ‰owŠJJ(*)m`m{)õj£ý&òÿ—Ûa1Ú¯}GZ¹»‘–—߬í§hq}XûŽdä3 KœnÁÄV ¥5¯÷îK'7w.–¡Ñë‰+ΧPSÉ]¾¸(›/ŽUê4(¤2¬¯#,êõz6í?Ðä6õÉ-,äDôY†X.nîbb�°Æòhô{8ñJªÙ÷6¦ìŤÔÚÌï[ùÝ •JЫ'zõdÁ ó8—˜Ä¶ƒ‡X±e›yùç‰|øÜ\|ëd 9ØÛñì3núü£ôÂ²Š –oÚÌÓ÷M·¸íÖƒ‡„¿m¬­ 4õ…¿¯� ˆÕíçôz=1u_ÿ°ôñrsC&• �Tw èÇ€^–ß÷;‰˜„‹ôëÑÝlÝ™ µÙþÎŽŽ¸6Q'¸>Ûþ¾k`V,üHXÎÊÍ»éöb²ò}ýËo¨Öhȸ–Ã’õ„1à݃Š."""R©ýmÕçN S23hÓêÖŽ-DDDDDDþIê΋Ü9ÜX‘ÛŠR*C~ƒ5}®G×vm� Æúƒ£h÷а‘´¶`»w3htz³өÖëxZÛâcÛühm¹TŠRzóö¢×£_Ïîdçå³qߟh´Z ž8Éš;Ø Öeê]£PÊå|øýOœ»˜ˆ^¯G§×sêü–¬ÿ?oozÕ˺JHN!â\lƒÍ™T–ÉdôéÞ�m“ž� @fN.»ŽÃÏd…jgcKnA�p¼+ì8|�N‡¾‰ÌÃæRm²ªªÉË1 nƒ�½ÇÃIJ½*lÓØ5ôëÑ��‡�ûž¾Glb’ â9;8ܾ=«¶m½ü¢"~Xû;™9¹f"®¦ºñsÙÙØP^YIE¥Ñ³¬¢‚›�µ¹4ÚÆ÷»q—“)*-¥½ÉB‚ºàìà@÷N±³±aÙ†�T©T £c8}!®QÁ¡¹ÔØæb²:þzÅJ³‰q‘zÃÍÏÖG*•2sĨflidݱcfÿŸ� ¬C{&öë‡û-Œ<—J$Llǃ�PJ¤H%2ʪ«ù3ó ™�[[¢D£Fw�ßÂÈØóf5䞘2‰Ï_y±Á¿Ï^þŸ™@±ù@ã¢â·«×˜ýn]L¹ÂŽÃG„eKuò0Ùv.Ù°ÉlÝ�¿¯7«ñf©¾á_áÑ×ß"tÚ B§Íà³å¿‚)ó½{Ǽ=g6s¸_ضæZjjµÄ]ºÌµ|s�96)‰©Ï¿(üË/*ºn;¦Þ5ŠNµ.ý´ÔLÄ”¥ýé²_8b²¤3p€0žªÛ'ûOF[¯–ëú=ûÌo¤íllèÚ±ƒ°| NM¼æ/úR¸æ¥õÞ¿æ Óé).+kò_SN–X¶q³YÖjfn.+·ï–‡„†4ZØumh½ÝÌY¶8£�áÆ}Z<ÖªÏ?iV[™8žÏ—ýÂ+Ÿ}�“ƒ¥ååtiÛV°G�2z$‹W­aÎ{¢0Õ«Ù¿/kwìâͯ¾aÁÿž¿�ž©E"‘àÖ™_6máòÕ4fNšÀ �^,^µ†�{ÿ¤¤¬ŒNmé×£;'cÎâíáÞhý™‡'ŽçË_Vð‚O°³µÁZ¡`XX(q—/ Û̾oŸ.[Î ?ÅÉÞžÒòrüZòø”IÂ6n..ìõ,ï|ý-zö ¶¾ž=S8êžÄ„»Dèã�¾oï¸zÎ�ï™4sãÃ�òð·Ñ­C¶îÛÇ{ßÌöl›Ð&–“&´è\ÝyõLÏD‹�çOXHW^2žÚúz>™ÿ=ß­Z îïͧ¸¿EûÆÝ³Ç´é§ÜfݬÏèÓµ‹ßûÌ+,äªûàîë¯Ãétòþœùž~»’$ñ7\ߢ16î8oÅJº´O SBÛ’’ùpÞüíëT®›z©çºlèýÖlP]„³«¤¶–RRk÷ÿw„_# ¨¬¯#Ìâ)æÚÓT½ñGC pIÊþ{ùtFwhþ¾ÁÙ²ËG/yAA¸˜í:x°µ‡ ø iç¢>ÖoTYm ßû·_Û¦¿òV³ëJlu8ÏrVBqy²³Ô­;a>ú¼�)MÓø9?�ÃUŘô:Œ:accnA¹!½$eòÿ—ü³¡ª¦–Ôìlt²L§v ~÷¦Ñ4�Ü‚Ï�¶¶±±~—A<š¦‘~ô(å•UDGDÐ)Á»üVyUG�aÐëéÕ¥3Fƒ�Cé8œN¿N¾”WU‘š•MtD�Û¹J›dåæQXZJŒ{™Íngßá#ÄÇÆ�ës?uõõètzŽÏ÷8ç¯XEVn.OÿùOžíTU%-ç(U'žgãìÁ:«•ƒié˜Íôi¦ì¨ÍîàHVV›�î;B~Q1y……ôéÚ…€_Ð+Ïs–.'ùH*/>òWR³³)¯ª¦]›6^×Çÿ¾�C]}=�üáNgdRSWG§„¶Ä´°ï@UM Y¹y8ÚÄDû| ò ‹È-( 80�®ÚcôqÃüB‘[PHna!¡AAtíЃ¾åsX¬6)™8…�:t¢PN~>�¿ö&¯ýãQ =YÇr  gçNM‚ûV›�ÙYØÚÇÅyJáþ‡3³¨ª©¡w×.-ê/u1+¨,'Àd"Äüë|¾åu5Œýø}¿¶=øÐc§ÝƦ*”ÛëIÊÊä±Ï?÷k¿W ;ÑuCò>êlvÞ½ï>z´=;%©RŽçóðwßQm³‚7Î]å–K¬u,ÉÉ@’4,#ÁÃ#ãˆnÁM»p£Ågõ‡ÃAß™×y'ÓÆ�eÖK/4»Ÿù+Wñ—^òüüõ+/2uÌh¾_µ†ž?Qr±cÛx²óò}îã©?ÝÇ7ÿÞóóí�?á :¶�‹#·°Ðgv~P@K?xÏ«�jŸé×x²ôžýóŸ¸ï÷74yÜßÿû:_/qG Àâ÷ÞàèñãL¾ënªjN�¥`6³ò“=“›v<ĵ>ìéùçKû¸8~üà]¯þ®§óþœy<÷þ‡§­~`1›ùì…g™Øìvú͸–ÊšÙÂw]s5/ÿíÌ&£ ‚Ð27Ïû†ÃÅE~l)œìlßòJ ãÇÛ½'¬F^rv'a„‘¼x¾ß&AáB¦¨*ý¯¾�ªš–Uj¬tãZ?¶ZJd^€B &ÊìõguŸÑáL �8«ûT5� ùGÙWV‚Y/�:=#câZÄýœÏ·� @†öíÓâÇI’D»¸8ÚÅÅ�“qù:^·š]ÒäyônÁLöSíwÄ€þ^Ë:%´õ PšŒF¯Ì8_þóáÇ´��ãÿntÝ­³ZÙ¹“rš²,Ÿ2ƒ3ÀlöYú²1“Ñ@ÿݽ–ÅÇDs~{ÇI’äÕãÎ�,7èôWHPÐiƒÀmccÎiûlJhKBß�f™M&õ>}F^LD1Í&šM&6“5z¦÷á_ª®ÀNßöhIž}Tš1Óó÷ÝiiÔÙ~ù,ù9%%<¾p¡'8p¤ðD9Ã(sS:²±à(’vUe_Y£bÚèçd„j‡ “� B›÷$�¦y&ï\sš2†—�CdXŠ;²vë6¦º3ªûâÅç¹åÑǽz³ þõw7Û_`P¯žÜÓ <ùö{ž` @‡øx>xúß^ÁA€�À@œîÌD“É÷ïD³Éóü‚NL`iÇ’ÿ½Ëão¼Å¶}É>Û£SG^ìï^ß›CúôfÁÛoðÈ+¯6鿪“efNžÄó>àWö`cºéFôèÎó~ì³,²,ËL7–ýß=tõÑÃñº©—“o¿GƱ¦åÒ†öíÃ+{¸IÏá‹Ùs~›™à#Ë2_¼ø<ϼ÷>_.^âõÚà~}žùóý\1Þw¯N_‚üž4†ûܺÆ{âqæãÕët^û{ïÉ'xâ­wÈÉ?¨6èõ,Œ¹¸×k6{JE Éå%ž^ÊQ¦†EÇ"Kþ?Ì`Ƭ;7sÖNÎ ,ܼ�z«•Õ‰[9v¼€èˆp¦ŒIdXX“Ç6Î œ9i"?÷4¹…ü´m;uÖzº¶ïÀ„áCÏ(ÛÙ_ÇŽ�tøEeeØvÂCBéÓµ ýºwkö1ªª²ïðedR][K\t#ðô¨ý%Ž?ÎÞC)••¡×ëIˆmÃÐ>½½zžj\ûÓÒ9”žAeu5a¡ îÝÛgPñL”VT°yÏ^ KJ±˜MôêÜ™Á½{ý¢÷Á¹bw8ظsY¹yD„†2~Ø�SNZñÇ‘¬l¶íKÆ©( ìÙƒÁ½{yUb8–¬ßÀ=O>@|ôó� IDATL »¿Ÿ{Až_Aø5Z�v„�vnÃqß#8_Š‚ÁGË„æäWU¢¨gï¶×mƒ†ðÈXï’Øç"ƒàÆiSyíÑGÎɾAá|zø•×ù~•ïV\þ„ç†È ¼@ëMØUå¼6àöWnm5³3�Pi³d0¢“$:…0!¾=ú–•/”%‰ýùÏΟ¾ÝºñÖ¿')åÕuµL>Œ={4)éøk0 gâOx5h ‡ã¼�I8!<$„›®º‚гXfYΖ§wæ_�¶mùà�?³zÏælÚ€ÕÞôscP£¬Ôù›ödíNO#£à8ÝâÛ6yŒ?Ž”–ðïQX]…„«BhHÈ”V×QcµÚh‚Gϰ(J¬VJl®êÕN95•t ö/S­Æi?gB_,f33'M<£Ç&´‰=eŸÂ³­]\ÚŵiÑcdYfPï^~eS·Tû¸8ÚŸaY–У;NÊô?["ÃÂÎøu=ߌ—ŽuV÷Ù£SÇSVb8ö>âùûÕ“'Šà  œGS»õ`j·³Óëöb—YRHç(ÿ«Ž\úù‡”œb•¿ÚóÇa#¹¶Ï©+èœMK7næÉûî!´™í‚ ‚p¡©¨ªfŦͭ= ¡"@x�ÒI¡åv«[Ÿõ'óRòSiq &YF/KŒŠ‰cpT,òÌRÑ›ÎèqÂÅ%,$„ #†ù±åÅÍŸÒ®ƒÏÁ [Á?!AA̸Hn ¿-6UñÙ{828˜›.¹„°À@ÞZòƒWùÀ ³…û¦]åù¹]t4îžwFƒ�q§£¡ñSf&lßBþñ|$Y�¾£5MBQ4Jjª½„08*–uù9 •H9V[K´9� ÃéKŽ;5›ªøìE(Bë:^\¶}û8�–ÎGó¾w)õß_1­µ‡&‚p^´ æ¶ÁCù]ßþÏã„&€Úº:f-ú‘¿Þ~óy=® ‚ œMŸ/ú�Úú³ÛNM8{D€ðf’õè Ô)­›mäP¶ÏeNj é•åY,èŤøöD˜Ï¬ñ8²,î­‚ UÕxèÅWÙš´¯µ‡"øI/2áF 5N;5N{kÇ/:Á“ ‚ B#Ž3œìãt:Y°q#—�NXP0’$ñÔ¬Y”:ì ×¡ÓÀh@6¸þHª„¬ih’†&�Π÷š°S[ZŠ­¦t2h É€$Ñ3*Š«bî®®~„€UQ(­©%><ÌçøÂ�fd÷þ%4j;Ц¡ó£‚€CUÑëD€PA„³#2 €on¼…”¢BœªJߨ6èä‹÷w�%ë7bw8yïßÿÀd4¶öpAÁÃîpòÈ+¯±tã¦ÖŠÐïoE¿qAz£ëÜ\®SB"Ì`&DA¡ çdÖZ­<üÑû¼2.ùÅÅà®0�æài _º�ÿ·áP’†Þdlh'ˆæT(Ï9êé9ˆ{S“ÑÀÃW\…^§#@oð (ªªBqMu³c Ðë1È2H®Þ…ŠªRïtøyNÎ~¯eAA~ÛdI¢OlÄÅ_ÔÁÁ+7oáŽ=EYeekEAÀÝsðÖ<Áë6´öP„ºø3ú 3Éz¢ŒXt^"¨IÖe À|ŽMA.J ƒaš¦ñüW_²)y?*ùååžu ±±î¿I®€ ;ž§¡y‚ƒšû?²þÄwsűc(6ž-$@Ó¸²o_zÇ· ÀhlaDCGQuÓ^> $I"ÐhIB’\y‡õN§_ϱ¥çDAá·hËž$.¿÷Ïlß µ‡"‚ üÆmÝ·ŸËï}@”½H‰áEN–$B f"Œ– ¢g�^’‰0Z7šý*%&‚ ¿UNµeÁ°]G³j÷N4UEU5r =ëâ##i’�¨¹þh �B4$@FFÓÀYWGen¸ƒx®ø Dlh(÷\2ɳ›`³Å½? ‰ãUÍÌ:š¦¹új`õ³œª ‚ ‚ øçxq1×?ô(½üšÈ&AλÊêjžù߇Üô·PPRÒÚÃÎ�Hïú•0Ê:¢LØT'5NÇ÷5:SzI&Po¼ ³AáB¤Ñ²£ 6mBQ£‘WRìY×&,ÜS"Ô½ðJ&t/“<Ç-MÏUkTŠÔUHô�I“ ôì*Èdj´' 4�ã§«IÖ»Žç~ˆ]ñ/ƒPmyÕUAA„ß,MÓX°z-ë¶íà×Îä®kfÜÚÃA~Å*ªªù|Ñ|¾ð*«›o?"\D4çWÆ$ë1õØU…zÅ�UQZ|Ò_f�‹Î€QÖ�“c‚ ¯UK¾�UeozF£%•'‚tmÂÂ\K%wë¿“ŽÑÞÓÜi†u%%Ô•”�NçZç.ŽèÜ…Kû ð:¶Å GBrõ6”$$U%¿âÔ³Ôu'U5ð7[ò\ýÎ"‚ ‚ðkV^UÅë³¾æÃù ¸bü~wédF èÿ«è¹(‚ ´>EUÙ–”Ì÷k~bŦÍÔÖ×·ö�„³D¥Œ²£¬#Ä aU쪻ª 4©?Ö2:Irï[�Y§CB”A„3¡µà;Ùj³SU_É´<ÈkTÂ#.2ƒN‡óä]6|MkhN‡�Ò´tï±A&_~%òI%ÂLfd TMME ªNWÆJC“~<ÃßEQ˜³lé9Gùãﮡ]\\ké´>˜3�~Ý»1vÈ`’�¤²týºwìÈï.ŸÊÜe+ àÊ —œ“ãW×Öòî×ßrÛÌéÅùj쿟~δñcé×½{kÅãÇu°Úm\ùe­:Žšº:¾]²”ãÅ%üãÞ?rìøq¾_¹šÇï½Û5ùä<«ª©å½o¾$ºó6Ìf¯õG�1où ¯e’$Ψ�ý=¯q½ÕÊ›³¾bê˜Ñ í×÷¼>‡œü|¾]²³ÉÄÃwÞÞä»s÷Áƒ¬úy‹×2Y–‰‰ˆ`âÈátJH ¿¨˜Y qôËéÚ¡ýy}‚ ‚ Â…ADŠNAªB½ÓAµÃF…­žj»�:§»ª4™/‚  *šÈ;…–ž™©C‡yÝT5�ÒJW©OY’ˆ 9‘1¨¹þœ‚¦jÔËGóªx !#qãˆQ(Š›Ýî®,p‚N’�µ†€£ë�Ó©RtŠfä6§Ó+{Ѩó¯W±¯ë¥¼ªŠÕ[9�–Κĭ~íç|™µh1 W¯ýÅû).+ãHfV³ëë¬õìOM£¦®ÖçúìÜ|²rsHˆkC§„:%$Сm<ûSÓPTÕ³¬SB³™¶±1^˦g`³;¼–Z,Ô[­ìOM#0 À³<.&Ь¼<^ÿ| V¯ùÅÏÿt>ýnKÖ­?çÇ9›¶&ícùÆM êݓࠠÖŽOÿùàcv$ï÷ü|ìxU®÷µ¦i¼ûõ·H’D�Î(,)¡¸¼ü¬¿¨´”½ñ6EeeàªfËÅj³Ÿµcœ/‡ÒÓ)¯jþ3±5ä�“w¼µ‡ÁÒõØuà #ôC¯ÓQWo%óXn«uœÝš”Ä�´tö§¦z]ÿ jêêØŸšFxHˆç3/&2’´¬l^üðVmvÞEej%>Žr¢µ?ñE“I¿ÌÆ»8�–ÎÎý8ì㻣´¼’ý©i$´9ñ}ÆÞ”Ã<ñæ;ì=”î çþÔ4ªk}·‚ ‚ ¿~"ƒð$ª¦áPœªŠCUZô—æf½ ‚ Âo‘¦iMúÙ 'Hîþ|þºfÜXf¯ÿ‰B÷ zUÓÈ/-a`·nÄGF’|<ßµ±æþ�¦!¡!I T×`-+GÖ]ÁÀ×—/ã�µ«‘d ½Þ€Á` ÀdB/ë�d™Z‡Ó½½äŠ=J…5¾³Ô4M£^u 5ª]nÖù÷+§ìãrIܳY–¹tô(Ö$n¥º¶–àÀ@¿ÏÛ¹TXRŠÝîhíax™>ñD&¨SQXº~#{ödú$ï Ñø˜h¯Ÿ—oÜDßî]›de=À„áÃг‡g¹¦i¼ñÅ—,^ó3&MÄ ?wÿ¬((.Fw‘UÀ()/'6*’™“'µöPš•“ŸOUM�ççù«çïV»�šº:¦�çɘúë·�ÕãÛN²rsq¸?_ÂBBøô?Ï�Õc­¯¤¼‚ž�;1mü8öêÙª¯ó–Ý{éÛ­+Uµµ$îIbÂða>·›:v ]Ú·óü¬ª*Ï¿ÿ!߯\ÍecÇø}¼Òò ŠÝAð³AUU¶&íã’aCÙuà [öì¥W—Î>·½vê‚ÜS~uÙÆMtmßž�\™oÅ%ìØ¿Ÿ>]»znøç²çÐ!¦ŒE€ÙÌ‘¬lv8HQY“‰A½{1¼?Vlú™.íÛѽcGÏ󪨪bӮ݌2˜ˆÐP¯ç¼ïðR2³¨«¯gɺõŒ<˜ˆ°P*««Y½%‘cÇ °˜L í×—a�J6wýœlãÎ]´�‹Ã©8Y·u;V»�ž�;1eÔHt�²u÷§¦’¸'‰ÚúzÚDG1uÌh¢ÂÃq8�¬Øô3v‡ƒä#©˜ŒFÆ ÂúmÛiÛ&–¸èhVoI`{r2N§“¡ýú²m_2f£Ñ3&‡ÓÉÚÄ­ÎÌÂd42¤Oo†÷ïç™4i³;ظc'ÙyyÔY­´‰Šdò¨QDG„SPRÂúí;X¿m;ƒûô¦Kûv¬Ú¼Ås¾*««Yµ9‘Ü×9Ö¯¯W9Ç}‡� ¨*íÚ´aùÆM”UVÒ®M.?Ös}©ªJâÞ$¤¦aw8èж-SÇŒÂrRiÉS©³ZY›¸•œ¼|ô:ýztgô �M¾ãv$ïgû¾d$Ib`¯žŒ<Ès>N'?ïÚMæ±\ªkk‰‰Œ`ÒÈÄEG»ß+5lر“iãDZ|ã&Ê+«¸óÚ«8’•ͦ�»¨©«£ml,SF�ôºîlvk¶$’š�M`€… Çûõ¼2ŽcýöTÕÔÐ66†ËÆŽ!,$ij¾ ¸„5‰[).+#80�±C{£÷&†Ùhdõ–DêêëéÒ¾=SÇŽÆh0°y÷r Q•%ëÖ3mü8JË+ر?Ó'Nðœ›iilܱ UÓ˜0|Fƒ�²ÊJF îò³k·rôx&ƒ��½{1¢Ñµæ¯ÂÒRÒ�å¾ßß@yUóW¬¢¢ºš°ààÓ>V–eõîÅáÌ,*««ÑɧόOÜ›Dv^µîÏ‚©cFc6™(.+gõ–-–”`aô AôëÞͯçp0=�Šª*&ŒpUØžœÌ�×^�Þ�L}“ÑHŸn]IÜ›ä×±AA„_¿ßì];§ªRçtPa·Rã°cSœ¿¸d¨¸ *‚ '8T½7Ð~«|ÃNglÿ¼qߟˆ�ŠFò‹‹<ëâÂÃ]7K5 ÐÉ2ƒ“,c/,F±Ö»†�狀«†jšÖh…+¨¹#Œ*  áDjT~ô¸»¼éɪlV $Ùµo} 2O>'¹……dçå1nÈ`âc¢éÐ6žÄ={O»ŸõÛ¶óÂQ^UEDX(ë¶mç‰7ß¡ÎjwæÔ?^{“})G !· €'ß~—-�ö½lÃF¬^ÃÇó¾#"4½^Ïgß/dñšŸ(­¨ÀfwPoµRèîå·sÿVoIä¹ÿ}@v^N§“z«•'Þ|‡Ÿ¶n%( €³™e6òÊ'ŸùuN.dÙù®ŒÕðß7×ë¬Vž|û]vìÛOhp0»俟~Μ¥Ë©ªq•´Û¾/™çßÿ�£ùlj�Œ¤º¦Ž7f}źmÛ(-/Çîp�ç†,œ¬Ü<íM’�¤BNÞqž|ë¶&í`ÏÁCü¸nƒg[“’˜³t¹'è°aûVlü‹ÉÄ®yö½÷ÉÎË#&"‚šúzÞúòkVovm(=ƒ«¼K©nܹ‹×mð™ÍZ][Km]NE¥¨´ »ÓAYe%ÿzãm6lßIhp05uu¼9ë+f/]æyœ¯ëÇ—•›6³xíO¼ÿí\Bƒƒ1�|µx ó–¯ôl³bÓϼøá'””WÂŽ}ûyüµ7É-(DÓ4ŠJËPU•êÚZÊ*]ÙÀË6nâ@j:NE¡¤Ì•©\QUM¥;ËpãŽ�ž’ŒŠ¢ðŸ>báêµàt:yçëo=cÐ4�W?ýŒù+V"Ë2¡¡lKJæÉ·ß¥®¾›ÝN™»4cie%5uuXm6æ,]î)9ZVQÉ?_‹�;wLu]¯ñ%s—-÷<Ï]²lÃFþóÁGDGD°|ÓϼñÅ—žm¾X¸˜O¿[€,Ë„µøýgw8xæÝ÷Y³e+¡ÁÁ¨šÆ‡sæ5)/¼zó®YK\t46»�ÿ};‡�;wyÖ¿óõ·|ýÃ�¨ªJdXI‡óä[ïRáîZ^UÅœ¥ËùbÁ"–oü™Úúz6ìØÉ³ï½OqY9á!!lKÚÇ?_“ÜÂBÏkñâ‡3ÅJ z=v‡“—>ú˜#YÙ§|^;÷à©wÞãxq1‘aalÝ»�¾þ–g½5p󖯤¤¼‚¨ð0Ÿû¨³Zùꇹâ’ñÜ:ã*fLœÈC/¾Lâž½L=Š/,¦C|OÜw¯'ãêãùß3káb†õë‹ÑàÊxLÉÈä�>湬( {SR¸î²K¹þòËÈ<–KxHw_�çøI)‡ù¿o`ÂWÙºÉû)«¬äÝ'ÿEd˜kÌ{õàµÏfQS[GP 7šæ,]Î�š,ÏÊÍ£]\¿÷s&ŽdfQï¾Þ‡3³Ø°c'ƒûô& ™LÈU›·`·Ûyõ'( EQxþý½¶Ù°c'ýºwãŸÿw�gÙë_|ÉÞC)L9‚ß_yé9GiÛ&–;¯qeS}±`�Û%ðÏ{ïF§Ó¡iÏûŽ/,bhß> ìÙ“ÅkBQUt²LJF&a¡JÏðã`z{õD’$6îÜE¯.�ù×}÷zÖ¿ýÕ7ìMIaêØÑŒ8€æÌ£¦®Îs-lÙ½—Qø,­:vÈ`ŠËËÙ¼k·çÚøxÞw8…ÿ>ú¡îl¥ÅkbþŠUL1‚6ÑQàãúiÎ�Ô4Þøçcž}!ÁÞ”nž~%5µuÌYºœËÆŽñd Õ[­üëÍ·™½t�Ýýî¾þ:v$ïgÌàAL9)#7<$„Û¯™É†;¹|ÜX¯Ò² ÖmßAzÎQ^þû#$´‰õ<Ÿ…«×ríÔ)TTUq0=ƒGÿx—çý{Õ„Kxà¹È8vŒ~Ý»sÝeSÙ¶/™ß]6•¶±1TžÔ×ô»•«P5�ÿ>ò0!î>ދ֬廕«™8r±‘‘àà>õÀý\ÜÚ‰ IDATžÌ¶¸è(>û~!V› ƒ^ÏÆ;¹eÆUž²�zö`Ö¢üÎKÏ9ʱãÇyí�z2e£ÂÃHÍÎñÚÎf³óâßò\O½óûaÂðaÔÔÕ±kÿþtóï7t¸?«îù÷ÓÎÊfä€þžý””ðö�c6™¨«¯gÖÂÅÌœ<‘¯˜î€å¿ßz—Ù?º^Ë�;w‘š�Íã÷Þíy­öJ῟~NBߟ NEáóï2vð î¿ù÷\wÙ¥<üâ+¬ß¾ƒk.�ÂW‹—ɳù3&£MÓøpî|¾ùáGFˆÉh`ÿ‘T^{üQb""À�¥Ö0Ùbú¤ ääçãp:½>'(ŠÂœ¥Ë¹|ÜXn¿z“G�äßo½Cßn]]¯oZ:…¥¥|ðÌ“žìÆ/.âpf–§l©¿÷ìehß¾XL&,&�Ú’¸7Éç~¤¥{znÚív¤¥³5iã†ñÊÔ=•+.O~Q1Åeežçÿî7³ â??èÉbý|Á"æ,]θ¡CN™Yn³;Ø™¼ŸËÇ�C’$útëJp` [ö&yÞg�í:p³É@}}=;$ãè±&¥¤AA„ß®_}€PÑ4ìŠÛ9 6&z ‚ ‚‹ÍéÀègÖØo•áL, æÖ˦y-ëÏýññM¶ý~ÓFrÝÁ Ÿ'`7b@æ.[Aâ޽̘4Ñç>Ò²s°Úl\qɉ¾a¡üý�wŒÍî 5;›‡î¸Íë&北G±~Ûv²óò<¥$‡õëë•)ͱ‚ÂS>‡È°0¯àÎðþý˜ýú=?;…‚WÆ¡ÝѲþ…a!!MÊYäùÜþlZ´ö'Ïß%I"*<ŒËÆŽá†iÍßhÞ™|€QƒzΡN§câÈ^™Mÿ¸ç�^�©¬®¦º¦“ÑwYÚz›�´œþö‡;=¯Ÿ$IL=Š ;vr4ÿ8{õdöÒed;Fç„R³s¸eú•|¾`E¥¥X,ääçsÍ¥“øÛ]wx£ª¦†êšO¶ìà>½ÑÉ2»dÂðaääçs¬ €{nü�ßçïpf㇠9Ц�Ç÷«Öp8+Ë <ùúiÎà>½½öMJz&éG�âp:¹ªQ?J‹Ṳ̀‘#XtRÖÛ™:�šF¿Ý=ÁA€)£FÑ&:UU‰‰ŒdίzÖišF^‘ë½ãïußp΂ƒ¸ÏÙ‚Uk8’™å ¶‰Šò*{�¦i8œNLF#ƒ�=ѧ[WbcÔ»W‹ú¯5Â6îØÉU'è Ö56vè`¯€q›¨(êê]YËAMÏG¡+›óäóqå„ñž Njvv‡ƒ+'\âYo4¸døPæ/_‰¦i¤ddÒµC{¯@î Þ½h×LpàØñ*ª«¹Â]²·aŒ�ÜuFƒEQHËÎá�¿»Öó^”$‰«&ŒgÓÎ]dçå{JøöéÖÕÄÝßÔáçkœ™›KIy9—Ž9¤îÜ.Á«Ü³Éä:ÿ?mÛÎåãÆh±p×µ×øµ¯cË%¿¨˜ÛfÎð,9ps—­ °´Ôs=5hœ©*I1\=y×L�âsÿ§Óëµ4�M‰š¦q83‹ë/ŸêUâöª‰—°fK"é9Géߣ»'Ûw5€†ëaÏ¡CÔÛlŒ2سnX¿¾lÙ³«ÍæÙ®ÁGsç{í'6*ŠÛ¯žÁåãÆ¶àÌ ‚ ‚ ¿f¿Ú;wvUÁ®8q¨þ÷öAáì¨wرŒ­=Œ š^’‘%é—8?•âŠrþ·`>ªª6ÊâÓ äák®õšÜÊõ£FqÝÈ‘ÌËåÛ­ÛHÌÉBAsÝFÍ�X^[‡ÝéÄØè†x‰ÕJ½Ó‰^vÕ.ÕI2á~eIòÊ LÍΦ¸¬œÝñ×ô—=Ë%I"qOR³¢²2ŒC“@ZÿÝ8vü¸+fÎ<>ž÷�g}ÃD²†Ò—á¡!^ûÀ�¹`ÑáM–í:p�Ä={É-,¢ ¸¸ÉM\M?–~Ý»7Yþ⇟àT|—£<[g'ù«¨´”qC{-‹÷>?õV+«6oá`Z%%”WUa6=½OVèî»ùþì¹^™©'^¿õîETx8ÓÒAs­›0b8߯ZÍ¡ôL,èdÙÓó«ÞfcõæD¦¥QPRBY¥k ں�½z²}_2†cËî½ÄEGÓ­C¿Îƒ¦i—•5 @˜ŒF"BC<¥Siæúñ%<$¤ÙuÅååôz"üßqÑÑÔÛl-Î\õ¥°´”^�;{- ðÊ„KÏ9ÊOÛ¶q4ÿ8Å%~g]ÑpÎÊË›œ3³ÉDxhˆ§ )¾Þ§�H’ÄŸo¹‰O¿_À£¯¼FLd$ýºwãÒ1£èàcB…/]Ú·ãúË/cɺu,Û¸‰ñq êÝ‹ËÆŽ%$èD‰Ù“_““?.ròóY³e+Ùyy—4;¹4:üD°­Ð=™à¡ÿ¼ìµ�SQ°;XívŠËÊhÓ´h\L4J3}nÊ"ÇDyŸß†@kQYŠªåµ¾Mt4’$Q\Væ žêZ<�âR×ëuÒçBtDµu®É&}»ucúÄ ü°v‹V¯¥cB[†ôéÍecÇ4›½ìËæÝ{øìû…žÖ v‡Ã5e÷^®=)ð÷ÂC6û9äË�ë6ðÝÊUžŸ¾óvOÓÕµµXm6Úœt^£ÃÃ1èõ””—S[WÏ=O>íYׯ{7OvóæÝ{�$É«Dn½ÕŠÍng÷ÁCŒ<Èk¿Ÿ¼ð¬ßåPAA„ߦ_U€PÑ4lŠ»¢p¢CŽ ‚ ç[½ÝNd�èqs:FY‡õv>^²„²š$�t®¢’·Ž¿„„“nP6�%‰~ íxùúv¤çãŸ7³#;Em!” Þ餲¾Žèà7‡sj÷%”ˆ²Xš” mŽñ¤ŒÊÍ»÷b1›¹ñŠË½–ÎÌbóî=äze/yöãÎ~i(/y²†l ;¯»†v>Ó( !ù<ÉÉA�õÛ¶óÉw ¸bü8&ŽA»¸6—•ñÔÛïµxߣÁ€Ín÷ZÖ8ÃFÓ4^þø3Ê*+™1y"]Û·'>&†¯Xâ4k¬áõûÃu×xÊ=6Öðú ìÕ“©iȲL·Ž0èõôîÒ…ƒééXèÙ¹³'ƒçÕO?§¨´Œ“&Ò­£k s–.çXA�g¿#ôçƒÙs©©«#qo“G�ðû›c1™p* NEñÊh³ÙíH’„Áà;;³%L#Žfz$¤åäðì{0¬__®�:…vqq®Ò¥�ýÓ¯ýK’„Ùhôyά'�³ÓUqÔ»ï=ùi99HMgóî=lر“çü³W¦Ú©\;u WN¸„ƒiiìOMcÕÏ[øy×n^}ìï'ÆrŠqäòä[ïÒ¯GwfNžDûø8b""¸ëŸO4Ù¶ñ5`40èõýÏsžu ßcÕµµì;|„ýûÑ×=¹¡Áü+Iܳ·I€PAANÇ¿;68§ªRã°Se·bSœ"8(‚ ­ÈætàT‘AèÓ9ì_¼?=�F’ÝM]µAéÐ&†›'LòkÝcãxõw×óÂôé´‹p÷üsgj©ŠBI£ža%Ö:Šmõž�š$Ë´ ô?HÜø\8…mûö1¼__&�éõç†i—»³÷úÜO|L4Šª’qô¨×ògÞýó—¯$2<ܸ²Ùè”�àùðÓÖí>{Êý[“’Ü»7·ÎœN¿îÝ ö”üµ‹‹‰&ãè1¯eé�^—òÊ*R³³¹ãš™\:z�Úb2¨­¯ovŸÑôzlv»×맪ë¶íðõêIjvɇ�x2ÝútëÊ¡Œ ¥e0°wOpg¦ddrëÌéL;šN ˜ŒÆ&cÜ»:�Ì·K–RVYéé#ç÷¹ˆŠ&%#ËkYna!U5µÄGG·h_§=–»Äæ‘,ïã¥ddÖlùÖ–ˆ�‰&5+Û«…CÆÑcÜýÄS””°sÿÂC‚yð¶[Ò§1Ô[›±N¥Mt)™ÞÏáØñjjëˆ�ñ–òéw (­¨¤{ÇŽ\;u ¯>ö7,&ÉGŽøµ�¤”Ã|ýÃ�˜Œ÷éÍ×Ìäñ{隸¬œÜÓ”n°ûà! »ë†õëKld$§§S9åãâc¢±; F¯ë=¯°ˆä#©èd™øèhŽde{ß­69G›ß¯ûšKÍÎöZþÚg³øüû…h±p8#Ók}Šûç8‹g¢�ûuLo4VEQÈÊÍóü¼}_2ó–¯Àb21¼?þø»kùëí·’[Xè•Iz*ÒÒ]%U'\Òä;eˆaä‘�—çÇžšgÐë ´X<|.²ÛO¾®gf¢iqî ÍÆûiÈ8ß¶/EQøÝå—5y#ú÷gß‘Tjjë~ÑsAA~{.ê¡]U¨¶Û¨vØp¨§þÇÕùb1ž¾dTP£~‚ ‚ðkScµh2‹Þ¼~0ëôg�£vzNEáµ9ßb³ÙAsýº§¡aÐËüõêk±˜üÞJÀø=øôÖ[¹º_ŒF�–¢Ý¥å4MãpEªzbšVŒÙŒÅÏ`›˜äÛ&>BMmc†4͆ˆŽ§kûölٛ䳿t×öíiÇç q¼¸EUY½9‘ÔìúõèŽN–¹dØP¾[¹šä#©(ŠB~Q1ïÏžKqy¹'Àä¯z멃}a!ÁäQã.——•›Ë7?ü€ÕÞ²€ÉÅfÒÈì9”ÂæÝ{P…”ŒLÖ&n@–%‚Ðét¤f»MŠ¢°fK";“÷cµyg.5—ô:ã†aþŠ•ž`E^aÌ™KiE…'ÀÛ§[W¦gxÊ&öéÖ•²ŠJŽ0ØÝƒ.ÀbÁ ×“–�ãúmÛÙš´Ï+ËÇl21°W/6ìØI¯.�›”D<� #†±çÐ!ÖlIÄátRTZÊGsçJÿ–n=�ÎíhǬ…‹9v¼EQؼ{›vîbâˆá^ÛúÊ<óÇ„ÃÈ-,仕«±ÚlTÕÔ0{é2ÂB‚‰�Œ$,8˜êÚZŽPR^Áÿ¾� àú\jäälªG g÷�ƒ¬MÜŠÃ餰´”�æÍ'*<Œþ=ü;g�lÚ¹‹ïW®¢®¾MÓØ}ðµõõ´‹sõè[ùóf^üðcÏ{ôdŠª°|ã&ÖmÛŽSQ°ÙlMÚ‡A¯'ö¤�Í Æf³‘“€ŠêjÞûf6ŠªúÌ’lЭCbcù`Î\ KKÑ4�¤”Ã|öýBÏwì¸aC¨®­åãyßQY]MUM-ŸÌÿ¾ÙóŠ;øÚ«Kg¾]²”œü|4M#qÏ^ö:äÉN›8r8«·$²kÿUõ|vuïØÑgöî™Hˆ�¥G§ŽÌ^ºŒ¢²2lv;s–.§ª¶Öóül?ü´žÄ½I(ŠB½ÍÆŽäýXL&Ïûð­/¿æ«ÅKš=ΖÝ{ d@�¦%šGà)]}®4ŽO1Œ�;v’¸g/Š¢pìøqf-ü�mãO™Ñºe÷:¶mëóÜ�4Å=±FAAZâ¢+1ª6ʼnMqžÓž=gªw|[vggžr›~mýïe ‚ UÓ¨²ÖÚ²›ç¿U&�þ¬—]±5‘äì,wèMC’\Å2GõèÉØ>}ÏhŸAf3�^6�1]»³3#�É}úå.#›SSE™­½,£¡a”ut =í>˜tz¯~r»¢Mt}ºvõ¹ýØ!ƒ™¿b%ÙyùtJhëµN’$¹óv^ÿb�¼ô_dYF'ËÜtåž@Ñ-3®¢²¦†—>úY–QU•^]:ó§›nôìÇb67 ôÌ'&ƒµ��eÙ†�zê>zîŒæ“&‹]séd^üðîú9×z³‰[gL磹óxòí÷xíǨ7pŠ d:YçÊHi&ãÔl2âT|¯ ´X0Nÿ+`@€ÏìIY– ´Xз \ƒ1ƒQTZÊû³çòþì¹Z,̘<‘Ù?.#( £ÁÀ­3®â›%Kù)qv‡ƒ�½zrõ¥“Y°j ï~ý-¹íÚ¶‰eõæDR22x÷É'¸mætªjkyî “eU¥w×.Ü÷û<Ç6� ìÕ“#YÙtëèêMÛØ zqî *½NÇmWÏà«E?°aûìýzt纩S˜¿boÎúЇï¼Ü�„íû’ýÊ4 ž¦¸ƒ]Å%ÌZô³ý€ªª´‰ŠâÑ?Þå¹Î|]?¾XÌ&צž‹ëx ï�7f}Åc¯¾Ž,Ëž>Œ3'ŸèÝ™Ð&–¹ËVp0-�Çï½Ûëš—Ü×Nã×Ýl4btgöèÔ‰»®»†o—üÈ?­CUU⢣ùë·"IGŽ qoå5-œŠÂ­3®¢¬¢’�æ}GpP ]Úµ#Àlæù÷?`úĉ\:f´û:wMj˜4rÇ‹Købáb¾X¸ØsÎþþ‡»<תÉ`hRÊQ§s]³®L¬»®»†¯/aãÎ]èdI’¸jÂ% éÓ€ÒŠ R22q*¾'œîÝ›‰#Gðéw øìû…hšFp`ºù÷žþnfK“÷�ÑhDQ]=GÈÏ»vóÄ›o€ÕfãÆ+§QQ]Í׋—D\t4� ²|â³P–eùüùÅW<ôŸ—ÑÉ2šûÜ\5á:ÄÇsÏ ¿cÖÂÅlqgVwíО«&N ¤¼¼ÙëèÏ·ÞÌ_|É㯽‰,ËHÀŒI<}ó®¿ü2Ê*«xý‹/=Ÿ“]Ú·ãÁÛo9qM˜Œ^å^ôîLº&£ÑÓóÏ×úo¿•7¾ø’¿¾ð²,Ó¿Gw†ôé�Þ}>ÇDÒ¡Þûf6ïKŠªƒ·ßê9ç9ùùT6ÊhoÌátr(#ƒ±CûÌê‹ £_÷nì;r„›®ºÂsýèt§™K-¹?_O3¦ml ?mÝÆÝO<Åk�?ÊÕS&SZ^Á{ßÎá³ç¢ª*âãyøÎÛ›�XUVQI^aQ³eP{vîD|L4{S3eô( ×9>ÝD­†Ï÷–ôAA~]$Í×Ôë �Ø'Ö 40Ø`Óáþ6û«SnóÉ]÷2±gïó6&AA8_*êj©³Ûˆ‹hí¡œsåu5Œýø}¿¶=øÐcÍ®³© åöæK*ž‰[ŸzŠý9Y IHzÝÿ³wßáQT]‡3ÛÓ{O ¡wBゥ(VÄ‚  ö®ˆ~6T,ˆ "Š(*E@¤÷Þ!„^Ò{O6[çû#ÉÂ’B A¼ïóð„ÌÞ¹sgvv³;gι RãáêÂO/¾Bdpp­n«ÀbfEâd$tjµ¤¢¹—á®ÕX»„·ÖPëåVE!.9™b“™° @§ Òe2²sHÍÌÄÏÛ‹@ßêe]¸�qq àBUÄbµ—”ŒÝn§aX(µšŒìl²rsiQá\‰×‚œü|$@’d’ÒÒhÌÞñ|3çwf}ü�ãâuN~>Éiéx{xäïç8®î®®ùùa·Û9�$I4náè?#;›ÔÌ,ü½½œæŽ¬‰¼‚SÓðtw'$ ¤Dçɸx\] Ž`âšmÛ™µpßLxË)øwiÛ)$15ƒ^GDp°Sश)ŠBBJ y¥%9½=œ_“f‹…“qñ¸»ºV8Ÿgu‹‹9“˜„›‹��À@§sYQâ“S(4i‚A¯§ °ˆø”†…bÐéÈ+( >%•`?|<+¾© ¯ €„”T\ z„„Ô(;½¨¸˜ä´ô’@f€¿#°WfÊì_sÇmÎÇW&;/�ÔŒL :Áþ—œm¬( ‰©i䌫Á@‘ÑÈÙ¤d„ãRÁ{Ôùë&¤¤PPd$407·rmÊž ƒ^wIÇ)!%•‚¢"Bpwu-÷xfN)éxyxÔZæ`›ÝNfv6žî¤ef`³ÛiÊ;S¦Æ}7ßä4Ž´Ì,\ Bœ‚׊¢ðÉ?òâ˜ku|µåtB"f‹…ÆáŽ`\Vn.Ééx”¾þDÕ…ê;•‘J”_ÍÞ³êŠoßË›ÃRA„‹Ë\¿ª¾‡pMúWdšþ�Á2}š·à�Þýøq㺠bÀ`A®I6»�c!�^õ=”�¬B-ÉX{­õÙªQ1gN¡¨äÒ©%î0¸Öƒƒ6»�Í)ñ˜lV *5Š�a.ÕŸ{P-Éu2£$I4 ©²�Ÿ·~Þ5?_%I¢IƒÊƒe4j5�"œ+Høy{_r™Ê›¥ë6°ãÀA>zñyšGEb±ZY³m;-›4vºîåû¹sæÂã*Ër…ØÚ<†nnNA— ƒ‘f‹…e6Ò¹M›K¶ãЇ[Ôe�·:$I"¼Š×¼V£qdÕÖ”A¯¯´I’ˆqÞ¾›«‹S{77Z5.ì:Ÿ‡›-/Òæb\ôúr¯Á2es5Vðöð(d½’$• ĺ Õz.ö\r‘ç¢* ûzyáëUw×_ýì †÷ïË-ƒp*>�cgÎ2¼ßj�cÑê5D·h^gc¼\fºøxzVAA„+åªþ›ƒç7x(m#0kÓzŽ&'!!Ñ.<‚‡úôÁAAáš•UX€^£Å ©þüvB 7µ–KÕsÙ]Šç ?f.[F~‘‘F�AÜ3°vïn·+ [RâI**D¯Ñ .j ͽý.)ÂM-ΗkÕ�ú±ïÈQžùß„ø“œž�Z¥âÕ±×÷Ð.É_k×1wÙ?¨ÕjÆß_}G¨eÞžžŒ=¯<­på¨d™1·ÝÂw¿Ïeç�ƒÈ²L\R2}:u¤CËêoîÒ¶ !µ›Ý(‚ ‚ ÿWe‰Q³Ý†Ñjù×/dµÙ((*$ÔËíEæ&A„3£ÅLJn6á>~¨ë ìjT[%FËd˜Šj5‹ 53“ƒgNѱis¼Ý«ŸÕW“ˆÍIG§Q¡UiðÒèèŠG5æP+£–düt.Õh)ü[ÙívbO�&#;OšGE]tή«MFvGN�¢e£Føx‰ŒA¨m9ùù;}“ÙLTxx­—2®-¢Ä¨ ‚ ü7‰£uãªúvnSìY-Xíµ{q¬¾¨U*tZ-Éy9„{û"‹yA„k�Ín'-/?7�ÿLp°.¸ktµ>a ¯o�æÖ«Š]QØ�”À¾ìtt* •Š®!—¤tŸ…k›,Ë´jܨ¾‡qYü¼½èÕ±C}C®Y^îîtiÛ¦¾‡!‚ ‚ ÿ9r5ÚÔ9EQ(²ZÈ3›®™à`ƒN�$Az~n}EAj�¢(¤äå`Ðêp×ê{8ÿj:Y…^uUÝ»UN±ÕÆ¢3'Ù‘™ €¢€‹ZMß |t—6/›^¥®“¹AAAA„‹«÷«PÅ6+ÅV ÿîb¢Us3¸�_THzAþn5ŸØ^A®6©ù¹H þ¾Õwµ“͆r~2J*,`ÎécdqÓhQIá.nôm€Û%Î;)#á®Ùƒ‚ ‚ ‚ ‚ õ¥Þ„6ÅN¡Å‚­–çÚ¹I’„›‹+ù… .¢ ‚ ×EQHÍÏÅj³âåƒ$Êh× •$á©Ñ‘c)®ï¡8˜¬Væ�eeF*�z=:YF­’èæDG¿@dùÒ Rxht¨Ä9#‚ ‚ ‚ ‚Po®x‰Q0––ý/ËÈ’„»«…f)y9Ø•«/3@AªÃf·“œ›�Ýn'ÄËG̱[Ëô*5.*M}›ÝÆæ¤8^ظ†YG‘_XˆZ’iíåÃý�ZÒ9 ¸FÁAWµ¦NK©¾ÿítbŽŸ¨³þ¯„ÄÔ4Þÿvù……•¶ÉÉÏçýo§‘”–Àê­ÛøaÞ‚+8ÊËs:!�÷¿�†Él©²]v^³.æÅ�?áÑ7'ðÊ'“™öû\Ò³²�ÚÍ[¾â¢û¿eï>ÞÿvV›­Fc6óþ·Ó8��Àª-[™¹àÏõ%‚ ‚ ‚ õíŠfZívЬflÿÑà˜,Ix¸¸Rh4’��I�‡Zu½WyA„j+¶˜IÍËÅ Õâïæ!2눻F‡U±c¶×,�q92‹ ØšDz“ÇI5c×ê1¸¸ÐÙן»šµ¦¡{Í+!henu\ZôÐñãôíÒ©N·Q׊Š�™À IDATkß®ÖûAAA„2W$:¥ Pdµ`®áݺג’r£.M&s2ñrqÅËà*.° ‚ W5»¢�U˜O~±_7<ô†úÒ5M¼µz²ÌF,öº«¸`Wò�F²³9’žÊžäDNäæ ¨5x»èìH» Pº„„ás™Ï¹Z’ñÒèŸxj‡Ÿ·7ßÿob}£Î(ŠÂ”Ù¿àîæÆ„§žÀE¯w¼òÉgÌÿg�Ýug½�qäõÃê¤ß¸¤dr ê¤oAAAA(SçB«ÝN¡Õ,Jj^À Ó¡Q«É7É+6âãâ†›Þ .š ‚ WEQÈ+6’]T€N­!ÌÛ�JUßÃúO��ðÖÈ4ë¤,»Åj%!3“”ü<Š­¼ †5k�‡NO »~nn¨kPB´"*IÆGk¸¤r´KÖ®§[ûv;}ší2îž»Ðj4¤dd°róV2²³ñöô ×.4 ©´ŸŒìÖïÜIRj²,ÊÀîÝÐiµì:ƒÉl¦g‡h§u–mØHTx8Í"V9Ƽ‚ÖíØÉ îÝX¶qg“ðñòäæA±Zm,Y·Ž¬œ\"B‚¹¡__ ºsÙ“±'O±q÷ ðñaP�îùû9õo¶XX¸j5§âpsu¥_—N4mX2&£ÉÄÊÍ[èÝ©#ÞgužŒ‹gíöäìïÏ ÝñóöªÖñ/“�’Êæ={HÉÈD«VÓ<*’>�;¡*}Øs8µJE€�Ë6n$7¿€ˆà`†õéåÔÛ{„­{÷Ql6ÓºIcü}|ªÜî�£Ç8��È›O<æÔ€·‡ƒ{ô`ÏáXE©ÕíEaóž½ì=‹Ýn§e“ÆôïÚuï{‡Oœ$#'‡>�::–�ŠO`íöäìïÇÀîÝYŽ6›�¿×o oçN>yŠ�¢V«éÒ¶ �Z·r�;a·ÛÙ²w‡ŽÇl±Ð 4”!=»c¸à¸‚ ‚ ‚ Â¥¨Ó9�V ù“VB­Ráîâ†^«%«°€¸¬trŠ ±Öa¦€ ‚ T‡Åf#»¨€³Yéä p÷$ØÓ[¯0Y’ðÕÐÔR î|µšÈÀ@º7nBÿæ-Ь½£Ó.4Œ �Z ªkø}é2æ¯XÁô¹óÉÍ/@QàÄÙ8^ýôsbNœÀÇÓ“Sñ ¼>ùKvŠ©°�ì¼<^ýt2[öìÃÓÝ�ZͼV2eö¯PZ"rúóœæÁ;��À¬…‹©Îp³óò˜³d)N›AZfÁþþlܹ›÷¿�Î;S¦‚>žž,Zµ†Ÿ.v¬·nÇNÞ�ú-Éiix{z°7ö¯~ö9'/(:ù§YlÛw77Ž�>Í;S¾açÁC�ÌY²´Ò²š[öîã­/§�’žŽ·‡»â•O>ãlbRõžàlb¯}ö9�Ç×Ó»¢0sÁB~ùëoG›­{÷ñ÷ºõ|ðÝtÔ*5>žž,^³ÖqŒ) ¸~4}É鸻ºò×Úuü´pQ•ÛÞä(½ž�¢*|üöaCxÿ¹gj½ Ç÷sçóÝo Ë2.¿,^§?üXaÛ}GްbÓfÇï;äÍ/¾"15oOvÇæ•O>s<¯V›�9K–ò㟋Xº~!äðé?²;&»¢�–™…]±“_XHfn.3,äû¹ó‘e77þ^·ž�¦Ï¨ÕýAAAþ{ê$ƒÐ®(ZÍ"ÐUMZ�­F‹Ùb!ßTLVQ:µ­ƒF‡N­%HA„:eWLV F³™"³ ³ÍŠ«VG »­¶¾‡÷Ÿ&K%™„9–âz™“ðrheÞZ=R k$ì‹=ʧ/¿ˆ�—'ßÏ›Oó¨H^ó *Y.)CùËfÌ[@‡–-�/jî:ƒÅjáÝñO9²Ðš4ˆà‡ùb·ÛéÒ® ?-\Äþ#GèÒ¶ ›vï%Èß�& T{œm›7åö¡Cðpseöâ%‚J¥Âl±ðÞ7ß1ká":µnUåxLf33æ-`h¯žÜwóMPš1úΔ©ü¼h1o<ñXµökóž½xy¸óÎSãƒ~Þ^lÛwÀÑ/ÀÁcÇyoüÓD…‡àëåÉœ¿—a³Û1óÛßËèÓ©#�Ýu'’$Qh4òÚ§ŸW¹íÔÌLü¼½¯ègàcgΰfÛvž¹4ÝÚµ SëV|üýœMLÂ׫òìK³ÅÂ÷sç3°{7ºí( Nüúf-\Ì;O�s´ÍÈÎfÂSãçð‹ÂÞÃGèØªßq»ÅÐ=º=C{õÄf³±~ÇNî¹i8C{õ ]ófüøç"ròóñrw¯óã"‚ ‚ ‚ \›j=@h²Ù0Z͈œÁK§ÕhÐj4Ø‹ÕB�ÙDnQ!6EA%«Ð¨ddYFBF%†‚ Âe°+vEÁ®(˜mVlv;• ½F‹—‹ .Zý%g| uG–$¼µò-&Šl–j¬Qÿ\TÜ5ºË*ŸÞ¿kGp0¯ �³‰IÜuÃõ¨J�’$1¸Gw¶ìÙKBJ*!ÁNëîÑ�Á=º;~7[,¤eeaµÙ°+ ^îî´ˆŠdûþtiÛ»ÝÎÖ½ûÔ£û%¦:¶:°++�Ù±UKDz_,‡­œIL¢¨¸˜áýú:¶¡Ójܳ;?ÌÿÛysv_߯¯#0§Õh¸±?>›ùÙ9Èråã;O‘ÑÈð~}Ë4j5ýºtæÇ?a±ZѨ/þ5àîoàîopün,.&+7³ÅùŒ v‚°ÙlØívNÆÅc¶X¸eÈ Çþº èÞ�ßþ^Zé¶-+ú+|sÂÁcÇñõò¢ki°˜Ò`Ü3÷�ÆÝÕµÊuÏ$&’_XÈð~}ËÔ*ºveÚs1™ÍŽå}:wr:‡ýü°X­ö+Ë2�†=1‡iÕ¤1a��D·lAt˵°Ç‚ ‚ ‚ ÂY­‹¬L¶Š¿Ü Õ'K:��¦ä¢ˆ¢(Øìvìv; Š]Á*ʶ^*YF#«ÄÜ�‚ \{$²$¡’dÔ*•Z¯rà¡Ñ¡•UäY¯ÞîžzÕåÌ,›» 5#€/fÍv:WËŽC~aa¹õEaËÞ}ì8p�ÄÔTR32ËÍÛÖµ};æüõ7«•#§N““ŸO¯N.iœZ�¦ü²J‚[éYYåæ ò÷Çn·“™s®dhðmB¡4-À·ò9üR33xiÒ§NËmv;6›�"£ÏjdžÙl6ÖlßÁÞñ$§§“ž•�‹^_.¸XÙˆeû«V©ôõuZà_å¶=Ý݈ON®ôñ¼‚Ÿ!À×—6M›0¸g÷*çÞAAA„‹©•¡]Q(´˜±*¢¤h]�$ µJbÞ§z¡’dÜ4Zqá\A¸*èUj´²Š|« ãUvc–NVã¡ÑÕZ¥ÕyŸ}t¥·±wÞQap,ÐϯܲE«×°`Å*†÷ïÇÐ^= âØ™3|2ãÜœr]Û¶áÇ Ù{„]‡bhI€OåÁ·ËeÐë0™ÌN�¶² 3Ýy�Åb“Ùiݲ6n..UnC§Ñ Ë2¯Ž}¤ÂLC׋¬_fÆü?Ù¾ÿ#öçæA dýÎ],Y»Þ©]UŸ‘\ lv;f‹�ö\ õüŒºŠ4mØ€Í{ö’–™IÀÁE€íûðÃü?™:áÍjíKuh5šJ3ù.F§Õ"I/?:¦äsû<Ýݰ—N¿p©/�è–-˜òæë?{–CÇN°i÷ÖíØÉħŸtÊÜAþ{F�¸žÖÍšVÙfÕ¦-¬Û²£Ê6�ÝwgãY¶vC-�ðêݺ%·]?Äi™ÉlæT\ÉÏórôÔ™Zéórøsß·`µ˜ùtZÅsQצnÑí¸n`_†…¢Øí$¤¤²jÓÖoÝé8æ55¤OOšF5dÊ�¿ Óiyhä­D„†ðþ”iäÔÒ^8Ô»;ýºw­ôñ-»÷²tõå�«óÞKãù}ñR9F×mÔ«ÿû²fçlã†<0òÖ*Ûdçåñé·?Ôp´‚Päj´©’Õn'ÏbÁAášeSJÎq‹˜SSA¸JÈ’„§F�·Ö€Zºì�s—M-Éøh xkõuV=ÐÏ•,c²Xˆ sü3[¬¬Ý¾Ó)°VfÛ¾ôîÔ‘‘× ¥eãF¸»ºRd,vjãáæFËF�ظ{;rÌXW‚ýK2çbO�rZ{ò$.ƒS6Üá“'�Ú8z �ZM _ù€ÙùB°ÛíȲät¬R3³Ø[a«"Û÷íçÆþ}¹i@š6lˆ‹Á@Qqq5Ö<'ØßEQˆ)�ƒ±LÌñU®×=º=:­–9/+waÄn·³vûNBªÌ^¼T!þ$¤¤Rd4:–M&ƾ5�]]dÝ€’q*8óŒìvŒ©VI׊¤efòýÜùdæäÒ´aCn2ˆI/=�A§ãÀÑ£5êSA¸vu‰nK«fM.y½Îm[Ó¸aD�ŒéjèÏ€žÝÐétŽe^ž<8òV~ÿæs¢[Ÿ+éíîêÊ€žÝpq1ÔÓh«Ö¡MKš4¬þÚéÙ)šÙ_M¢Id6ïÜÃ’Uk1šL¼9þ ^zâG»°� zu©½ÏÌ’,3 g7|½½«ÑºvE·jÁ[ÏŽsZöòÓ±m+NÇ%Öé¶%IâùÇâ³ ¯àÏ–]{ز{n.þ÷Ò³|9ñõ +•TeÄ�¶ìÝWåzn..Œq#Óÿ˜ŠÂMèëCZV V¬âtBÏ>pŸÓ:ù…¬Þº­\_ÁÁÕº`Ö½};æ.û‡éÌã¡ÛoE«ÑòóÂÅX­6šFFV¹nXP M4`úóxæþÑ„{òÓ~ŸË€n—~q¤Ød‚ÒlÏ ;waµZ¹ïæ›0èõìŽ9L¡ÑHxpË7nbOÌaÆ?p.”ÐA®m¿-Z œ›Ó÷ç/'q&>‘7'}^¯ãú·ùä›ïåÂ)ý ôÑë/ðÞKÏq׸gÉË/àè©3ô1ª^ÇY×»ïn;Á“¯½ƒí¼ï{†Äðò¸GY²j-1G�WÙÇ¿MP€?ý»weâä¯ËBƒƒY¼r –¯¬Ómß1|·Ì'ßÌ(·­¾ÝºðÞËã¹ïŽ›ùþ×¹Õî³i£HÂJ?#|óóo|óóoŽß#B‚Ùwøßž·¬®Øl6Þø¸þÞ‹jóõzâLœÓ¾Œ¼ñ:š>|?ï~1•Â"c•ë ÂÕ®FWs ÈbÆl·Õþˆá*Vl³bW\4Z1/¡ ‚pÕÐÉjtZ5&» £­dN躚¡Pt*5•�\wwLºº¸”˺zàÖ›™2ûW&|õ5*YÆf·Ó¦iS¾ã¶óÖ38îä9l(“fÌdì›P«Õxº»qçõÃøvÎï<ûÁGL}ûM4j5]Ú¶aæü?éÔº•S€%15•¬œÜJÇ(K2®ƒSOµZ�«ÁùÎr­ZãÔïS£ïaò�?ñÚgŸ#Ë2v»�ž¢yÝ0T² wWn2˜�¦ÏÀf+ùÌݲ÷ޏJˆ®*UI`T«Ñ8Ê—J’ijÜÇg3âù?F%ËØ…¾�;qËà�X¬NÄÅÓ8¢òl�{GÜȔٿòðëoaA�Ü5üz¾Ÿ;Ÿ—?™ÌÇ/>‡N«u”Î,£’eÇ1�$‰gî¿—OgüÈ›_|€‹^Ïý·Œà׿þ®²Þæ€n]Q©TÌY²”mûÏ}!÷twç±»î¤KÛ6Žez­c±‰9K––ëgpÏî4iØ­FSî¹9Ÿ—‡ÏÜ?š©¿þÆ£oN@–eÜ\ Œ»÷n<Ü\)(**9æ¥ç½V£Á ;÷¼>}ß½|þÓ,^øh’ã˜÷ìÍ× -m!9­ï»N‹î¼ oXP¿/]NÌñ¼öØ£CBRJ�ö§Yã(|==Ù{„›† $,8�„¤¯\Ca‘‘f�£èЦ%²,3bè@ví?H§¶­qu1ТQ{ucÇÞƒ èÕ�Í;÷8•–mJ»VÍY²j�ã³ú¥ÐjÔœŠK䯫1[Î%*µnÖ”mÎ�së¶lçäÙsçÜMC°ç@ )©uºŠnÕ‚^]KÞ;?ÁêÍ[�Þ;=ÝÝг ÂBP…ÃÇN°vëÇk¡E“Fx¹»sòl<#†ÄËÓ�}‡bY¹q ÍG1¬o/dYfãŽ]ìڮʉ—‡;× èKHPùù…¬Ú¸¥ÜkP*")—ø—TQ ¬f¬¢Ü¢ð¦–eÜÔÚjÝ9/‚ \›²‹ è5mjµÚÆŒ©ÎÇs>…b›“݆ÙnÃ~™NdIB+«ÐÉ*ô*5R=ß&“–•EzV6þ>Þ�/Ðd6—œŒ,É4 E%Ë$§§SPTDãˆ$I"-3“ñïÄK?DûÍ�ÖŸ4c&/Žy°Nö#!5•ܼ|‚ýýññò¬°M^Añ)©øzz:JäT—¢($¦¦‘_XHp€?^ç•/8~æ,‡OždÄÀ•öQT\L|r2z�Žˆà`$I".)EQhrIc9›”„±ØDðP ç•òº{é\,¹x¸¹TaÆhm±X­œIHD¥RxÉ¥�RSÉ/($È߯F%PÍ 'ãâqsqqd “œ–ŽÝn'8Àÿ¢óP ‚ \«Ne¤åXßÃpâÛwP}Á¡¢ BwWW¦~0ovˆ!Àχ–MóÙ´™¿´¤²ÂŒOþǾ˜X¾š9€‡ï¾ƒGÞÊÞC‡É/,¤kt;Žœ<ͳo¿�ÉlF’$&¿ý -›5a÷þC„‘_XHËÆ�xõƒOÙ¶w?��Eó&�ðôðÀßÛ›ïçÌeñÊ5|÷á;D„…°m÷~dY¢kt;â“’óÂë(ŠÂ�>=yfÌý¤gfr6) µ¬¦_�.lÞµ‡æ�¢Ø¶g?�~>tnß–�¦NgÑ?«k|¼®Ø—7ž~œ[ÆŒ«02ë‹�IÍÈäÅw?¢Qƒp~þr£Æ=G\B· Ìócb÷ÁCdåäÒ­C;R3²xäÅ×±X¬„ñ݇0[,‘ý±%eø7.ø…‰“¿fåÆ-ÜvýP¹û†Ýûp�öçB#o¼ŽñßÏà»tÊ 3êvuûbbÉ+( kt;Ž�:Ã3oÿ“ÉŒ—‡;?}ñ»ÂÝÕ•.íÛ²jÓÞý¼äÚÂØ{ï¤gçŽtZöŠ%8ÀŸNíZ³rãÚ·jΖ�{iÖ(’f�"yâÕ ì�=J Ÿ/?|ú…EE:vœÈ°PG5dÜk8[½óëß sýªúÂ5é’2íŠB¾ÅtÙ™áß®lîM7�®ÎæZA„š’�0¨4T%� «bÇb·cUìØJÿÙ•’@bÙI’���%PI2*IF-Éhdùª˜çð|>> –Ñiµ4ià\^²l@JW­]�—»;mš5uj·~ç."CCkiÔå…XõEN77Z5v«Qÿ’T’õWcq1+6oá†~}ªìÃE¯§Ùå5#B‚k4–†5<–²,×h›5¥Q«/kŸ°À@¸Œk×Z�†�¢œ–¹èõ4Нy§‚ ÂÖÃwß�—‡;÷<õ™Ù9<>zO=4š�Ûw‘–ékÕ�GÞÊS¾cɪuDE„óçïsÇðaÌ^°˜^]:Ò±mk|îUNœ‰C%Ë|øú‹ètÎsBwlÓšÙ.fÚìß°ÛÚ·lN˦�yô¥·8t´ä¢u§v­ùrâ„82}¼½<øvöþ*ÍŒ›ðüS éÓ“q¯Odï¡Ã|?é=ºE·»¬áŤ¤¥RÉg©Q7]ÏŸËþáÓi?BiÝ'o¾D£9qŠñßOvn>c_~‹"£­FÃÌÉpïm#˜ðéWÜwûÍðÀs¯—�À£÷ÜɃ£n㟠›IL.ÉšëÞ¡=?ü6�™,@Qºó6E„ñÄkï8KcFÝΘ»n¿ìýýôÛ|øú |9ñ Ò23Ù¹ï ;÷dǾääåWºÞȯcùº�އJ–™=åúuïÂÁ#Çðóñ橇Fóã2ý×?hÛ¢)ß~8‘ÎíÛ°/戣¯²ààžC‡ywr̓ƒeÚ¶hÆG_OcÑŠ5ìÚˆw^xŠ@??–­ÝÀK�?Ì3oý€½‡b™7í+­\Í/ þ`í¶íôïÑÍ 4èõôîÒ‰)?Ì®ñ¸ÂK?Û/Íþ+ããå…Aïü:JÍÈÂjµòÔƒ£±ÙlÜÿì+dçä"ËoŽÇócdÃö�|3kn®.„9öç|S~ü/<=Üyñ½�X½y+o?û$Þžîdç–<ǃûôdÃö]5–Q©T¼|ÞÜ•çûóŸUNYÌq‰IŽ×R\Rã/˜UZFŠŠŒt�nïhÇðëøgý&GiX•,3ëË�éÛ½«#@x¥5‰lÀƒwÞÆGS§³¸ô|‹ ã‡ÏÞçÎá×1kþ"ú÷膧›;·<2Ž·uUÊ IDAT으Ò}Æ÷ßÍ{_|ãønÞ¨A8c_~›CG�!I?Nþ�Þ]:rÏ“/�œ–ŽV£aÑÌ©t‰nÇþØ£ éÛ I–ýÌK˜ÌfdYbúÇïÑ¿G·k*@(Ô�j­v;Vs�¤î U³ÙíØívìŠÅ® ”fõ¯°ØˆFV!‹ ¡ ÿ2%� Y–ÑÈ*4jê:,)Ô/µ$£V]]A¾«Áé„Þ™ò &³™±£F–ËJkÒ }:u¬·ñÕ%Y–uÃuøzyÕ÷PAA¨C=:u`áòUŽà Àìqïm#èЦË×mpjß½S4i™Žà À©¸x6ïÜC÷Ží™½`1ƒ{÷dÇþƒœ8¥×­þøk=;E;õe±X˜1g.v{É5¬CÇN0ôž‡)(-Ñ  /-×m0œ+Û­( +7lqü~6!‘¬œGp°l™·wÕŸc¢[·¤¨¨ˆ£§Î\Â;§Ød©ô;’¢(D5h@XP )©•Ù¼s/V«�>];³hÅýýhß²9/•+¢Ñ¨iß²©™™NçoEÜÝ*.K«¹`û•Ï{½Ç—ô¹bý¹e§ãèÓµ“ã÷'^�Pá9W6åBM4m‰«AÏÞC±Õh]^÷ŽÑddg9‚ƒeãÞ´c];¶gÖüEü½z+7lv_eYB¯Ó¡Ój‘eÙQ®6%=Ãq…¢(œMH"7/Ÿä´t(}?INMG_zS†]±ã¢×Ó©]k¶îÞ‹Ý®0æ…×k|,„ÿ–jÍ6…VsÝ�æ²jçnfý½œC§Kî†hÅý7 c@§]×f·c±X°ÚlXl%xÕ*••$!I’H ‚ —Åf·cEÁn±c±Ù°ØlH’„A«Å ÑâªÓ‰€¡pÍ `쨑ùùVÁãþ®w-Ðiµè´Új´AáßJ£QàÇõûÒ«³óõ(Eïò%ÎÃpwseæ§ï;-ðó#¯°$˜È¡#Ç�OHJ.×WJF†ÓÜ[V«•à@†õ½•F ˆ ­°·¢”vÎWTT\®ÍÅ|øÚóÄ;Áø \¼qý}ÉÊ+ÜøìûŸx{üüöÍçœ8ÇîýY²z=§ãâ òóE¥Rqö‚àÉùó……ðÏÁYcq1©éŸ÷4%-ÝiŽ»ÐÀvís™ÌfSҪܗ&‘ ùbâëLúfV0ç�£/“™U·²jcIð+2<Œ»nÎíÇ’‘•Ŭù‹Ê­£ pëõƒiÓ¬¡!AD…‡RŽ)sp ÙÙŽ`i™¹K–Cé\ŒO=4šƒGŽÑ®E3Ú·nÉî‡Êmë|O�¹�ðà n|à±JÛ¤gd9k’øràð²rrèߣ?Ï[È�~½Øºkyù•®Ó¡u Þ}q<¯<™µ›·—{<'·$Èäçë4GÞ{ŸãÈ ŒŒˆàã×_€Òùå\] œNHtê'!)«ÕJHPì¿ä]ÃX\Ì–]{éߣ+‹V¬aHŸžääæ±mï�J×qsu勉¯3oÉ?|6}f¥íl6o|üy¥�;�ãÜë½,)æüùõÀ=4jØÀQ:÷R…‡áîZñ{gÙMf‹…v­Zп{„…î4W}™"ãïw(å^?çŸË‹–¯¦kûvLzã%²rrØs0–u[·³vËv‘ì%\ÔE„&›•"«åbÍ„óLúå7~Xü·Ó²‡cÙq8–Gn¾‘çîYn‹Å‚ÉbÆj³cÐêðÐ0hµhU—T VA.™˜­Œ3Ef™…ùè5<ô.¸éªw¥ ü›è´Zº·oW�–‚ ‚ ÿ>v›»ÍÎÊ�[ؽ¿|°%1%¥Ü2‹ÕFBJSgÍ)÷˜É\rßf·¡Ñ8_«R©Ëß\h6;_OìÝŽOßz™MÛw±|Ý&NÆÅcµX™ýÕ¤íßÅ|1c9doU‡N§¥Qƒp.¯xΫ{÷sóÃãèЦ%�Û¶a@¯nÜ1|ÏMü�„¤’€�¬ªü†K³ÙŒFSþf-µF�Ív. e¾àš¬ÑXŒF[~~d­ºêk‡gùhêtöÇTœmצyS¾ûÞšô%¹ùçʉžŽOàý¯¾%*"Œn�¢Ë55Ó>šˆN§çÏå+X°|%§ââ˜ðÜSŽ66› ¹óGÏ[²œ/ø™©Là¥ÇÆ0ú™—œ²Ä.ôëŸKps1TÙ§M¹¼¥”Þ\»vËvöèÊ’Ukèܶ5¯}4¹ÊuŽœ8ÅGS§sôÄ© ?XZö±[ÇöÌûûÇò¬œs™¾mÏ›ÝTúZÒ]ðÜ«T*$YÆRAfgu­Þ¼• Ï=‰‡»CúöâŸõ›�‚Ò**2òÑÔ霎‹¯ñ6/‡Z­fÚÇ1èõ,X¶‚?ÿYÉɳq¼=~ÜeõûÛâ¥x¸Õlj«ÕFBrj…ï�fsIâÕÇñô˜ûX²r-ó—®àt|"¡Á|ðÊs—5ö‚¢"ž~ë=†…Ò½c{ºwŒæÝŸaÙš ¼÷å7—Õ·pí«ò¯G±ÍŠQ/ɪ�»ËÏ7}á_D7mBÿŽ%e”Ò;}L&²JÆËà†›N/2A„+Jtj :µ/ƒ+6»�S1Y…ùdàí⊻¾ê/_‚ ‚ ‚ \=lv;Iii¨$Ù©ü ·§;£F '®‚¬¿øädzvîÀÞ˜X§r’·LfiÖS|R2M"�çëmÞ(²\_º~@Žž8Å«~æXÖ4²æóþ^ÌÒÕëk¼î˜Q·£Óéø{͆r�ôzn¿a(k·ngûžlßs€o~þ�Ù_Mb@�n|òÝ Lf3-›4bÏÁÇz£oA‡Ö­xö�÷IHN¥M³ÆNýøúàçK\bùç¥L|J Ñ­Z8-óõö*É «BFVv•ó5�tn׆Îí[;²ϧÕjÉ®`ÂVM΃Ͽæ ;¿Ôc|R2~>Þøûø�ž•åX>ýãwY²zË×m`羃(ŠÂÇS¿çÇÏ>`ôí#˜1g^¥cÞ´cW•û\›ÖlÚÆm×å¾Ûn&¿°ˆm{öUÙ>1%�Ä”Ê�÷™„Dö>½·ÝÄÊ [œ‚²”^ïºù\ÙXcq1™Ù9´lÒÄéùiÙ¸*Y®0ƒ·º6ïÚƒÕfãÁ‘·Ò¨A8ï}þu•íMfs�Îýy1-›DΘ^'öøIÇr}™x—bóÎ=—µ~\R2}ºvbßáX§Ìé[† &;¯ä½sø þ,_»��¦Nw<^ï�{uÃf·³nËÎ$$2gÑߌ¾ýfÆŒº�ÿ}õ­È"ªTéí"8X3³þ^~Ñ6?•¶±Ø¬ä`µZ ðð$ÂÛ½AA„z§’e< .DøøããêJvQ‰9Y˜/ãÎDAAA®¬Öm䶆Ð%º]É”z=/>ö×õïã”­Tfíæm¸»ºòÌC÷¢-ÍÜ»Ï�}ȑٶlí&Z6mÌ ûàçãÍ#o ª|­ü‚¼½®nx\®ÈØA¡&Üt\µz²‹ IÌÉÄÛÅ /—Š'AAáêñËŸÆç^%¿ �VGN~¯}8¹Â GRj:ï~ñ5¯>9–õÇXlÆÝÕÀ¬y [;÷àëŸ~á•'åé‡îE¯ÓóÃïó{ï�äå—Ï2+óÛâeôïÑ•?g|Mfv>ž|?g-›6æ/�çé·þW§Ç¢2/>>Æñ“Ù̉Ógyû“¯X¹qs…íív…Ͼ›ÉËO<Š_ 7?//vì?À¼%%å"¿úágBƒ™9ùC²sòððpãÀᣎŒ¸E+VÑ´Q$o?÷$Ï�}ƒ�‚¢"Þ˜ô%ù•Ïm·rãÚ4oÂ÷Ý͘Q·£Q«‰9v‚õ[w^öqxý£Ïyýé±|ðjI¹C‹ÅŠF£&;7ŸÏ¦Ídý¶åÖ9y6žåë6òÎóÏ0æ®;ðõòdï¡XVmÚÊ Þ=9“˜ÌÏóòÆÇ_ðÞKϰâ×qs10uÖ¯;}Ö1áùfþ¾€½ºóòãóäïÖY�#95 µZÍœ¯?cò´™lÙ½·Âvv»ÂÚ-Û¹cø0–­+ŸUZ§ã÷Ú;¼öäX>yëeEÁf³!É2KV­ã˳øóû)ŽöÓçÌ¥Ax_½û¹ùù¸»º‘–™ÁKïMr̳˜œšÎˆ!ƒ˜3õ3ž|}bµÇ²zóVõîβµµ³o”–?}ï¥ñ>–�”ÂÖÇ+~ìbNÇųlí&<ÿÝu;>žì‹9ªM[سg’ùi9úK—’žÎÄϿ浧ÆrÃÀ~‹Íx¸¹ðË‚ÅlØVòúüá÷|üú ,š1•"cZ�–9 —ðÔƒ£ùiò‡ÜýÔ 5ÚöOsÒµ}[~ýúS2²²ñps#¿°�7.R W$å‚wØ"«“ÖX‹;GW«ÝΙßäá…æ2ïðA„+Édµ�š—‹F¥"ÐÃë?�õž]T@¯iS«Õ6füKu>AA„kÝ©ŒT¢üë{N|ûªï!TKðP"„‘“›Ïác'0•ΉUw77Ú¶hЄıSgHËÌt<¦ÓiQÉ*<ÝÝhÚ(’gâpsuaæ§ïsËÃO’šžQi¿.mZ4A¯Õs 6–ìÜ|"#  b羃‹‹ku¿ë’·§;Í¢¢Ðê´Ä'¥”Ë6”$‰MèïKRj:ÇNž.è ¢qÃLÅfö>Ríý ¦IÃdçæqàðG€¨6úùÒ$²!*µŠôÌ,Nž‰»èùÒ´Q$aA�$¦¦qôÄ) z=]¢Û—�Äéø(-Íݪ²,sôäi§r£õ©U³&øù°/æÙ9¹•¶{î‘éÔ¾5w�{¾ÖÇNxh0V«•ÃÇO�]:w¦«‹cq±#  IdÂC‚ÉÍ/à`ìÌçúU²LÇvmÐë´lÛ½¯Ê9ÏׯGÞ}q<·ŒGFVv­ï_](;ç’RÓ8râz�Ž®Ú9�sõÁÝÕ•¶-›!I2ÇO�&5#ÓéqoOwZ5mŠ¢ØÙs(cq1mš7ÅÓÝ�Í»öÔ8®R©hÙ¤þ¾>dçäsìDµŸÿ‹ÌõÏ +\§¡^¾ê“ÎEâ¿{QUAø÷²+ ©y9Øìv‚=½QUcÂùk‘ ‚ ‚ \Y"@xuyãuäï@’%¦Mœ€ »¢`SJ~Ú�B~—Fqú¿Rº@)ÿ`) Y’P• URIàðRLýõ7T²ÌØQ#k<îóÅ'§ðΔ©¼;þ)‚ýýkÜω³q|8í{>}åE<ÝÝyåÓÉôí܉ëúô®•qVæé÷Þ§ÈXÌÓ÷ÝKÛfMË=^¶S'¼‰V£©ñ¶~]ò7‰)©¼øðCÕ^'%#ƒ7&É„§ÇXãm×µe62ÿŸ•|÷îTrù#+7—ÇF�¤S›ÖŽóè…1Ò<*²Öö¯6í:xˆïþ˜Ëôwß©q³.&-3‹Æë¶í¤¬¼âŽõÊC#‡òö§_ »ÇxÜ r™”áúß!/w7ªªk8q6�ek7RRVnª÷ï™Ï³dÍN¥]øKÇ÷è¸Ñ””—³zóöÛncäÀ~„ùóßoç7yÏÙÑž—ŸÂŠõ›9~æÜíŸKïÄz'&pòÜy–®ÝxGÛv°·cæS™ûë22rþþŸÁÝ:wb`Ïn\¼’É‚e+-Ö¹è ÚGE�|à[÷ì¿£ý?7e§Ï_`ûÞ”;Ú®€€À�Eî(2‰©D‚R­ÂVqïG¤ýÅÆÊ [kk‹ï ¶ìÝÏúädŠJˉDùú2nð@¢##È/*â_s¾à�'¦ñÃÒåX)¼õÔL}ã-Æ ÈÆÝ{É/*ÂÁÎŽ‡G�@§Ó±põZ®UUáêäÄ£cFÑ¡uÕJ%‹Ö¬å`ê)”µµÈ¤Rƒƒ˜Â¾£Ç),)A!“Ñ6<Œ±û›¢ÈWo߉›³‰„µ;vRU£$ØÏ— ÃîÃÍÙŒ¢ÿÊ-Û8xòZ­–èÈ‚ý|oØïªmÛñts£F©dëþT×(‰ fü�Á¸89ZÜæZU5+¶láRfתªqwqf@·Dâ�blIy9‹V¯eâˆa¸8:’|øÕ55ùú²lÓfŠËÊñõôàÁû†àãm±ýWüŽÞ``Óî½\-)aDß>¦ÏÁäÇ©ª©Á×Ó“a½{B­Jżå¿×®-�Û·c„çüå¿„­µ5ë�Ï£Ï,¤_bWZ· ýƒgM@ yBè߉Ý�˜Êœœy4± Gãé×ßå•Ì;Ò—»« ½xû¿_Þ‘öîä2ßü>Þž¬ß¶‹GNàîêÌ€¤n íÓ‹©/¿AþÕ"0ŠSÉýåalÛ(rò YÍí „¡ÁtŽmÇ-¼§�[Ñ;1�}‡�Á=.N5ŒˆV!tl߆ß7nE«ÕÞ±¶29½X¾nS‹ê‡‡3iôpÞ™ýÕ‡�� ¯>=í •�~ÞôNL ±S–¬Ù@­Êúþr ±V(PÈåü°ly“UûkvìbÑêµÔªTtlÛ{;’áƒo~@©ÕXÌ&¨×ë©Q*©1Z¶oA�RIÚ¥tª«ë~Ì•–’�—�²¶–èÖ‘1ûûù¤geDH`éYYÌùáG2²sÐjtÔ(•—•òý¯K¨¨¬ ¦M5J%y…WÉÎÏ@§Ó‘zæ5J%í¢"[|~ €Ö §V¯¥Z«F¥Ór!3‹‹Æó¦ÓéHI=É—‹~A£Ñ0iÄ0b£¢X¾y ɇê&PUj ïý-g/_fHÏ$ú%våÀñÌnæ|\ÌÌ4]õ””—s0õ¤éõ/k׳xý‚Õ¯Õ55|·d™Ù6'ÒÒȽz€kUU<‘ʧó~Ä×ÇGŽÀÃÕ…o/1ÝS•ÕÕ¼÷õ7¨ÔjF÷«+ÿùöRROÞtÒE!—Ñ¡uN¤š•§¤ž$,0W''³òM{ö²põZZøóÀ}ƒ‰�ndžä=¬Üz}BòÔù‹¬Þ¾ƒUÛv‰��­YÕJ%|ó=yW‹è•FÁiþŠ•ø{y1ª_�@ûÅÂ_̶۶ÿÿûe1ÞîŒ8oo¾[²”õ»’‰DTVW³3å ©þá“§HI=Éž£ÇLeû�� » k++r¯^eïÑc|µèWâ£Û3qøPjjUÌšû£é™s·Iˆ‰æÐÉSf÷óå¬lÊ**ˆk×îOésß±ã|»x >žîŒ<�q�Ø}ø¿¬]oªsáÊÖïÚÍÚ;Ò3‰‘ýú�–~…o/1ÕùvñÖìØIlT$Ãúô"» €E Ú°DZú–¬ßÈï[·Ñ£SGúwëÊ© yïß4™´¬gö� 8væ¬i1…‹£#Ÿÿ¼È$ž*kU¤¤žDY[·}fn»fþŠßéß™‡¡¨´”Oæýh²ùmˆ\&¥mx~Þ^ùÖ‰œ‹×mà—µëhÁ˜ý‘J¥|ðí÷œºp+…‚o¾[²ŒÒŠºÈ¬5;vrìÌY:´ŽÂÝÅ™ï:1²mxÎŽ·|žn•ZÛ³æ˜þ¿ôîGL|~&±˜G »Ûû§éŸ”Hxh0/¼ý!ŸÏû™6óíÂߘôü«ht¦Oºùßuøw<ÊJ åúúÑ*„5[vàhoO|lûlÕrŠJKé:üNœMkQ}wgz'&Üq×™\FïÄœþ¸%½Z­A¥VÓ½Q”1@‡¶­±µ¶âšñ»zKqr°§wbrÙ�ãŽ|ê%æ-^~Ëcøk"î8Ör9%Õ•w{7àû¥M¬}øÒ󸻸°fûNÆÀ¨þ}ÑéõÌžÿÇΞcéÆMÄ4rìíly{ì“M&AvïÆÄáCÉ-¼ÊŒ�?A«Ó1$©†%§ �—g}JMm-ùEWñóò"-ý ¶ÖÖ¼ùäãùúrµ¤„ç>øˆ«%%”]»ÆÈ~}©ªQ²!y7‘!Á7ÌAx!#ƒÞ@°Ÿÿ~þÄb1kvìdñº œ7 ‚Ñdåås!#“ݹ”•…L*E£Õr)+‹øèö&A(:2‚ÌÜ<”µ*<]]ùä•ȤRv:Ìw¿-5‰Œ ‘ɤ¼ùÄã¸89RZ^ÁŽ”ƒˆD"Þzú ü<=).+çÅ?æÄ¹4Î]N'*4¤I«¶mG¥ÖàïåÅ;Ï>…µBÁK—³óà!SµFÚí;xbâx¢Âè­­å_Ÿ|Fv^‡Nœ¤KÇØ›^!¸9;S\VÆéóˆïÙ ôóÅÍÅ…—®@­ÖÐ¥c,�Œ«Ë·öó²•ì=|„Õ[¶ñÜ”‡Mí••_£Wb‰�:âho�£ƒ=^îîqòlþÞÞ\ÎÌBY[‹“ƒ=!A7£% €Ú Gg¨‹,Ôèu¦(I/773 €mZsúâE.eeÑ3>ŽÝ‡SP\œ×_Å͹N(ssvæ¿óâjié-�ãZU5›öìå¾^IýŒäÃGÐ-Ѭ®F«E£Õ0eÌhÓçNרŽ�=Çå¬l“˜×˜Ü«W™õòKøzz€QütÞO\«ªj’ÿÓJ¡ O—~\±’¶a­ˆ‰Š¤´¼‚õÉ»yìþ1ôìÆëñ?ßþÀÒ�›hÆ�¤=}†¹ËV0~È`VnÙÆ‹�LÆÞÖ{[[Ú…‡±ý@ }º$´è¼üääpúüEBŒ6Çžn®$tŒaݶ]fùL÷Iâôù‹dåä™ê èÙOwWŠKËØ´s�)‚®©D°~½  ¢¸¬ŒuÛvQXTlz_.“1¨wÂB‚P«4ì;rŒ£'ëžù ±Ñ( ’S™µÙ¥C R™”=ÆHÈÖá­èÕ%+…Œ W2Ù’¼•1ß­«³‰qX·m'#ôÃÖÆšŸW¬¾­ãäçã�Z£åìÅKfå•UU,_·?o/SÙ°þ½9vò 9…(r†÷ïC°¿Ò3ذ#™�½º³sÿA®UVÑ*WGGRÏ¥1¬ü¼=ÉÉ+`ÍÖf9[}½<è‡�—ÊZ‡SOsøÄIî&b±ˆÞ‰ D·Žƒ�£§Ï²;å°)7³Ÿ�Q¡!8z‚QƒúáíéÁ¥+™¬Ú²ow7†öï��µ©gÓØ¾7Ål‘Fh ?ýz$bkkCFfwí¹a$Z=ý“º¡¬­å‹ù IŒë@¿‰uQ� °¶²bp¯ùûP]«bß¡£fŸ …œA½zàGM­ŠGŽ›A…\ÎÀ^ÝÙsð(¥åuÖ²­‚èÕ5GG{ró Y¿}×*« ð'Á(Pí×›£'O“‘“‹D"¡GB'"C‚±±±&· �M;÷P~­î·±³“#=â;±vëúvëJ»ÖTUU³~G29y8;92¨Wã:"‹8~úö£:uz»¡O·.MÄíÞ‰ ¤;AD+óß r™”^] ÀJ® 37�M»vS]£$ÀχnqÔ«GNž&¯ð*}»weÓ®=ôLèLhP_/ø•^‰ñäqþR:=âÐiµì;rÜÔODHá¡ÁlرûžÎ±, ðOGï zÃ?Ç]@@@@@à� —JÑhu Iž0�;Ï—‹~5Ë�Ò+¡3Iq�n¸M‡6­‘7ÊIfcmÍ…+¦ øzÁB"3¬O/Ž�=GFn^“ð�aѺ­Þ:´~"¸¾_ïe�©DÂ7ï¾…J­æBF«¶mçxƒÕ»j�刟‚âbæ/ÿݬlÚ¸û騦 s?x�Òò �:ÍÅŒL?al«Î(:2‚µ;wqáJz½žô¬l¢##8%ƒËYÙ”–WPZQ�•BAdH02©”¹¼GEe%Ç�Ô)Æè1�¦©­ÐƒC‡˜¬÷;Ž^¯ÇÖÚšóéW8oŒ,t´·§¸¬¬Y�0-½NXØ£6Fû¿ýú˜BƒÁÀ…¬ljjk‰D––Rd|ÏÁÞ�ŠÊ*.¦g´H ‰DÄÅ´gãÎdN¥�'¾C gÏ× „�cê&@Î….±XÌnc?bq�)ÉÅ+f“?öv¶<0ì>³œ‰ñ¢Y½y©gÓÒ§gÎ×MØtlßÎbnÅêš~øå7³²ÑCáoázÃ(ÖêuÔjë®—Æb¶ƒ�zã/efbb[Gñù¯âìàpS¡£1™yyèt:ú6D"=â:ÝP ¬ï·™TŠ�µ5z£ðtöÒe:¶im–C0>º]‹Âúcpâ\�Û·#+/Ÿü¢bâ£ÛsºÑdè¤×£L*««¹œ•Íå¬l­`�mÕŒ8øWrryñ‘É&»Êü«E(U*úv1${vŽ3 „Ååå\«ª¦{£çV�¸Žl?�ÂÕ’RbZGòãï+¹p%ƒ?2róxaòDþûãr ¯"—Ë(*-#6êú±´V(Ìî+ûº¼«úÛõ2Èâ5:¯ÑóçV°¶²":*’”©DGF˜ìEGöësÛmÞŒÑý¯çê¬V*ÉÈÉåÜåttz󉹰 @“8ˆñÞ©Ÿ¼»’“ƒH$¢W|¼é}©DB·Ž±&±9ÂÍ>ü½½  $=+»I]™TÊ/ÔÙ ŠÊÊ8}á"*µú†çÐÃÕÅ$8ØÖ�÷–й—³³1 „úûqµ¤ÄTȪ­ÛÑéõHÄb¦�Ç«Ÿ~Æß~ORç¸&Ï�»�D"ÁÇÓ“cÔzh`¯<ù›ví1›ŒaêÃ|>oY9y„òÝÇï‘•W@zfý“º1aÔ0&¿ð*9y¦mf½ù2b‘ˆŒÜ<†÷ïè�ýÿôK\«¬ÂÚÊŠo>z?/ޤžÆÑÑ�†fáòU|³ð7Z2uüX†LžfÉä2)ïÌx†åë6³çàÆÞ7�ç§NæTÚJJËyæÑŒ2�gÞ|ŸÊª*|½yyú‚ýýÖ¿7{½íã”�›�\&åé‡â§¥+©¬®6½·è÷5fu_ž>…÷>û9…Èe2¾ûð]ÜÝÜ8~ú ㇡[|GºtˆátÚ®UVÑ-®qÑíP(\½ÊµÊj¦>8–¾Ý»2õå71 D´ áÛÿ¼C^a!çÓ¯ìïÇC#‡òÅü…,Y³á¶÷ë� ‹øèõ$ÄFsøäiĈ1°{å�YŸa0hΓ“ä¡ÑÃÉÈÎA.•ñâã�ß¡=Qa­8pô^înŒ<+…‚uÛvЯ{"o½ø—Ò3È-¼Jïñcy`ø`ž|ý=Šn°@K$Ñ?©»¢F©d×þ†ôí…•BaŠB·µ±fÞ'`mcÅÑ“g bÒèáüë“9lß›‚•BÁÜOÿ�“ƒ=‡Nœ"$0€‰£‡óþœ¯Ù¸s766Ö¼òäcdfçRZ^N¯ÄxÞŸñ<©çÒ(,*æÁ÷1zð&=7oÚ¿¿õìGAQ9¹¼?ã9ºvŠ%åØ jÕj&ŽJ`ì}ƒ˜øìLj”J|==yåÉÇ ""8ˆ´ô+ôížÈØû2ñÙ™X)$¸ŽíÛPZQñ‡B€m{0ëõØÚX›î¹:!³3s~øÉL ‹E|öîD„‘r,½ÞÀ€ž‰ŒØ—‡_xww:´­û=×­s' ‹KP*kyåÉÇð÷öbÌ��¦†�Žî9)ƒÁÀG¯Ï`Ú+oqîâeìlløè�—Ùœ¼Wî2‚@xù'%hø#HÅD"ÐêtÈ$’»=œ<éYÙ__µÝÎh—v#¦Ý?¦I$`®ììÌ wg0NÒ^k0q4kŸ&±pî-•Õ³eï~­Y‹F['ûùÝt?jU*N„Ö£R«Qk4|¹ðŽœ>€�­ Nöö”]»fªŒ•BAqY§.\D©R„V§ãÜåtÎgdкU(2©½^Ïw¿-eÏÑc ¬­¬ðpqivlÎ×sjU-;«•Jæ.[ѤnE•åˆÛb£ðÛÐ ÒÍÉ ‘H„Á` V¯C]]÷Cß`0ðk£‰,€kÕ-· êkÏ_D­Ñ�vù "‘ˆŽÆ<[õ“ ûe_£É¸z £zœìš*ñbX³e;™9¹T\«ä´Q€Œ��¶8­VÇÙ‹—ÍÊÖÜ|…y½L)³¶¢V§E.–4±**+ÃݘO­‰XŒ‡«ëMÛ·D™Ñj°±mgC²9ìmmš}¯¸¬¬É=ídßrËB¹LFǶm8p"•ÎíÛ‘’z’ð  \›æ|+(*fñú œ¹x‰Zµow7‹¿qœšöŸ™›G§vm±¶²bÑšuDGF —ÉLŒ®�ŽCÃ×¥åÛ­?–%åå´�Ç×Óƒ3/¡Ñj±³±¦cÛ6¸:9qöÒeä26ÖÖD_�Dµ·½sQx½⑈›fèøqÅÊ?Ôn—èöÌ_±’)cG“‘“k´mû‡Ú¼¥,^·�“çÏS]£ÄÓ͉¸ésùFÇ®´¢{[[róÜ©®�î'K¸ZÈ5èîâBqy¹Åú‡NžbíÎ]dææ!“J ôõAz“ïô¼•–b0˜ùÉl‹ï«T*l¬­ñrs#¾};v9Ê€î‰ë üUH%⢯[ÛÙÙ0¸W¾^ü¶j]‹Û6 y…W™òâkèôz 9¿~õ_ºÇudñêë6™ÙyÌþáGV¬Û̯ÿû/í##Ø{ø(F #ÈׇG^z�+FñòØ‘L{è~6%ïcëî}LŸ8žnqÙœ¼€„ޱØÙذ~ûN¼=Üyæ‘ |óób“@çíáÎOs>bò˜á|õS�EµX,&<$˜Sž¦²êÖ,²e÷^’:1~Ä}Œ2�“gÓ8’zŠƒ©§8©ù>CûöÂ×Û‹ ϾLaQ1r™Œ¯ÿóv“zí£"øøß³zK�ÛÑÔÓ¼;ã<ÝÜ((*bx¿Þ3ù…×LyìÞ|ö útK¸ã¡£½=¯<ùX“rkkó¼ñýº'Ò-®#3Þû˜ýGë"¾z%ÆóÁÌè߉Ý)‡ÁøYþõϋٸ#€÷_~žÞ‰ñ<ùÚ;¤ž«‹Lÿiö‡ÄÇÖE®ÚÛÙñêÓ�±tíF¾œ¿gG{æÏþˆé“àý9_7;ö6áaøzy0뛹lß›Âè!éÞ¹[÷ì kÇXü|¼úÈtÊŒß->~}}»²}o �cÛàÏÈ)OQX\·ä½ÏÑ;1��;w7ésì}ƒØ¹ÿ ÿúd£&ç}òoÚG†“œr­NË'oÎä¥÷>F­ÑàáêJÏ®�yç¿_²ewݘ|½î¼÷Ü3MÞ—J,-wqt$¿¨Èdsd0È7N^cÂêÛ–ËåÌzcf“6,‰Íáëå…¯§'¹……lIÞKmm-á!Á¦>œì)(R1uüý´� o²½B.¿aûnÎ΄p)#“Ý““_€»‹ �~–­­­¬˜0z¸Y™Wƒè£– 1èÑèôHEb³\Œö¶¶T5І5 däæ¶X$¬ieèd·*ªªÌÄ·kUÕ·m)ö¶¶T7çÍr6¦KL4_,ü•ZÍÁÔTú%vmRG¯×óá÷sqv°çÕiS öóE"‘0ÅJòŒÑ'õXФóóòäùI(,)åÕOgóû–m<0dÐõãRY‰SƒE ×Læ:#ûªjª�ëç·>Š£>Z1&*ŠÓ/¡Óë‰ A$Ñ&¬g.]B.“Ñ>"ü†‹îE:´iÍ÷K—qòüÎ^ºL›°Vu׿-žã–2ûÇh´ZžŸ<‰V�ȤRVlÞJJjj ¶®ÃÉÁ�êšS$]=-™ ·t?TTVá`AÔ»œ•Íœ œÔƒg'NÀÝ¥N€œ~“Ï…?Šƒ�r™Œÿç†õ.ge³ÿø |<<øyåj^{ü1‹÷†€À_�B!çó÷Þ0++.-ãóy?óû¦­-oÈ`ÀÙÁ‘v‘áœ8›†J¥fôcM¿·,Y·ÑôwFN.:½k+q1mÙ±?Å$ü¶z=“Æ '.º-K×n$õ\½ºÆ›¾ݺrìÔò ‹9°j�†Å „Íü«Elßs€.cL¡H$â§¥¿ßôÙÓ&< ±Xdf3Ù�FËÌ>%"$ˆÄ¸ŽtŒnË”ñc™>i<éYÙ¼;û+.^Él²]ߤDvìK1Y«ª5VlÜJëðVfõjU*SôÀ¥Œº¶êE¹/\„Á`0‰ƒ2™‰T‚µ•õ ÷«!R©”Ø6Q\-.!37¯Ùzb‘{ Ö …Âìu\t;Î^¸dvî;È•¬l:Ƕ7 „z½�í l+3ss)*-5‰ƒWrrM¿/:´m�B®àÇß®/–+«¨dýö]ŒØÿ†û8 g"Å¥e3ZÕžL;OQi)ý’ºšBŒp bضg�–Wþó©é½úïÏ cظ#µFË[Ÿ~ÞlŸƒ_o‚ýý¸’�CN^šÚlý’²2<4•šŸáVÆcÛX„]¾a“É®µ¬ü%eå&÷…–Ò:¼R‰˜“ç,_Ûõèt:ví?HŸî]Laïn]H9z¢‰cÆåŒ,<4•ª Bëïí›]“‹W¯³(Ö3gîÏüüÅ,>zãebÛDñèKoXtaøkÂ;ˆ…œçÿo‹D‚ýö_D—ËÑW·CXP )ߺ�»Ù¯/:�ŽÕƼ„�¾>Š@XX\‚N¯G,ß¾2©”ƒ©Íç^©·µ·µµ˜ã)ß8YãíáNh€?:�ÎMØ�˜ÈŽ�9Ë© MQ‹ÊÚ:›¢zûÅh£m\~Q]þW'G¢BC0 >yªI›–ðñF$qµ¤”Êêj¼ÜÜÐhµ|»x •ÕÕŒì××¢ÅhdH0ùEElܳ�˜öm‘ʤ¬Ú¶Ã¬Ž§›2©µZMnA!aÁAèõz~[µŽ«%Åt‹ïL§ö-�HŠ‹mO¦ „N1×#ü|¼)(*æÒ•L:£þ§žbß¡Ãx¸ºñà¨aͶ[OçÑ\ÊÈdsòn �cÚ7;©.—Ëèß¹Åc¿Zƒ½1W¡Á` ÀÛ› »÷ ¬­5MȤgçðæœ/øhÆ Û¨U©Í^Ÿ½t=ºÑÏË‘HDʉT'õ0•·ôio/N�¿`ʳpêü�'‚ÓÎ(œ­Ú¶Ýd/Ú˜Šª*®–”0iÄPZ˜Êod÷Õ;»:;;w†÷éÍÊmÛéÖ±^îu×gʉT³¼‡�|#Ȭ Žœ>CxÐõÀ#§Î “Jñrs 6*’M»÷ TÕšîû6­BY´f2©”qCÝÒq¹°R(ˆ‰ŠâÀñ¤¥§3ª_ß?­/�^Ozv��mö¼).k~2ÏÞÞèôzŸFûßm»÷ó씉ØX[c0èÞ¹#ý~ÞžH$æ~òo³6\]œ‘6²ÇÏÉ/àfÌ|r* ™Œžzñ†õΧgp>=ƒùKV`cmMÏ®�yjÒCÌzs&ã¦?g²¨¿¾Ÿìo�O '/¿I»EÅ¥fÑø†F“‡jµš¾Ý»Ò9¶=¾Þ„ø#KÈnÁ¾ÕccmÅçï½ÁÊM[ùä›yÍÖ+»VÁ›³æ4)÷rw'qî—¦×Þ^dæä6©w%;×t1 O Ó j”æ×]ÃýõóñÄ`ÐóåûošÕqrtÀÉÁ‰Xlѹ@*•Ò;±+ Ì~çµëåñ1Ñ8ÚÛSQYÉ·è¾çÿzî ž{t"©gÓØsè›víE«Õ²ÿð16íÚÍÌ'¦òääI=›ÆÞCGÙ´sw“ó ðõ‚_yÿågùå«O¹’•ÍÑ“gÙ°3™´f"Kuz=Žv2üϬ IDATLy¡Á�øú4ë6Ò0g'`ÊŸ}+¼4í3­©ˆß˜íûR˜ýÖ«ØÛÙQ£T’”Çìïç7©§Õjñtsáá±#  ÐßÇ›»aäæÝøš­¬®fÎ øðµY°l¥Ù"�»‡ ÞAÈ üF,›r} ü}°³±aXŸ^¬Ø¼•¥7sàÄIj”JJÊˉDŒ2øOé××ÓÃ$LΘõ)Îd4X­6ZWÚZ×­\MK¿ÂÜe+šDÖhÌQwüì9^Ÿý9•ÕÕ& HµæºÈmŒ4 ùù"—É ð7Yxúx¸›~Ø×‹éÙ9Ìüd6­†¢’:ñD¥Q7CC¼ÜÜèÍþã'ø×œ/‰ !;/ŸÂ’‚ýü ´¸Ý�žIì9r”ìü|^ü÷GX[É)¯¨4�ãªï>ݺ²i×n¾˜ÿ3!A”–W�“_€›³³EáñFtŽ­Ë¨V«‘ˆÅtl{]\�Ô�ã§Ï²+å ÙùXYÉI»xгk—¶[O\»v,]½µºnB)®{Ñ? 7@µNC÷øNlؽ‡ÿÎÿ‰Qýû¡ÓëY´f-!þ~x{“•o>ÉçãáÎú]»I9‘J�¯/‡N�âL�ÐÅÑ‘.±1ü¶~#"‘ˆŸ:ÝÄ÷V’Ôƒ7ç|ÁK—Ó;!ž+99$>7±ìmˆL*%®mÖîØEdH°E‹PG;;ìlmØuð0Þî¨Ôj¶í?À© ñrs¥ªº»X¡6dXŸ^ì?~œ¹Ë–óöÓOÒ»Kt˜œÂ›n«Õé˜5w>c@§×³dÃFr9ý,CË¢x#‰Ì,å ‹Šyä¥×‰hBBl4Ý:w`ö;¯1ï·åÌ[¼ÜT¯>òÉ*�y# bŒ‘qõQr;ö¥ðÂc“Iì‹X,F­Õ°s�sƒF§£¤¬‚¯^ܤ�ÆâQs9ªòãÒ7tSxë…'I={žÕ›·›Êj”J6lOF£ÖòîŒg h" éôú&ù¼ ˜º›,˜|õéiôìÏŠ�[ê¢ô²³Ø«=»Äßp»†ÔÖÖòñ×?�™ÝTÔ»4j52YSW™ôú9¼´Z5J¥Ås{#âcÚáìhϲu›Ì"Ô.¦gðàÈ¡ôJŒgÕ¦mhµu�ßüü+];u sL;^yj’ºñì[ ÓëyﳯùnÑR;ÆÛŽ�?ÊàÞI<ùú;Mú=sþ"c§=Gt›(:Ç´£gBgF îÏÛÿýœí{›æ‚ ðgÞ§pæÂE¶ìÞÇ¥Œ,²ròز¸©w'Lè~^¾¹¬é½f‰§ÏRU]MRB' ‹K±V(Øwøx“z1­#ùâý7I9žÊÖ½û¸œ™MuU5K¿k>Ò²u ®�^‰ñ¨ÕzwMà§¥+ÍÒÜ�ðÿZƒ�^‡Ö`@gУÕë1WªÔOâŠE"êþ�T,F"#‰�‰%HE-·¨øÿ�è6WB üy8ØÛ™r™YšÀ¨gtÿ~ØÛز>9™ìü|D"¡þÜ?h í#êl%r¹©­Æ"E}¹m+�ëeuŸ¨A™µµŽööL7–…«×RZ^�­•5ï>ó«¶ï@¥Rq!#oo’:wâÀñärìÌYhF ìß™KYYì=rŒ¬¼<b¢IêÇÚu‘�yW‹êÄ?WWºvˆ¥²ªŠmZƒ1*±Kl •UUĶŽ2µÙ±Mk†$õ`ëþäÛ:ŠéŒcÅæ-\ÊÌÂÇÓô_�í6§�‡ƒ�»áÈ©ÓØÙØÐ¿[Wî4ÐbN-�Á€³›3O=<‘%kÖQPTŒX$bÂèá;uƒ^��MÝñ9¨?r¹œ�ûpòÜy¬ :Æ2r@l�¢Ž¿��w·:ÁÓÆÚšÖa¡0�Þssv¦[\'JÊJñöô4³¢ ôóå¹)³|ýF.gf"‹ ðóeX¿>´‰¨ÛogGZ‡…âæbÙ¦ÓÖÖ†Öaœ<›†¯——ÙJô?‚¯1ÿ ƼD­ÃB±µ1·B ðñÁÎÎ`mgËKÓå×Ukyÿëo‘I¥´‹ç‘Q#‰DX+´ 3�Ç¡½{‘•—ÏW‹~Eo0És“&ðû–m¦ö§Ý?¹LÊâuÐhµûù2cÊ#ü¾e«éž  ÂÝ(:ÛÙØÐ.<¬ÉýlÊ¿èëÃk�?ÆÜe+Øyð�>>L1ŒÏ,4YoZ¢u«PS?I�ã(­¨ WÂõ Gg{Ú­×Äb1ÏMšÀÜ¥+xé£YØXY18©ÏOžÈœ ùüçE¼ñÄ4Zàåf~n}Ü=ÌlreR)SÇŽaåÖm\ÌÌd°¡ˆ±zÛ–nØ„¯§3§<ʲM›M¶f÷€X$bÍŽ�üºn=¶ÖÖ éÙƒ±ƒšÚ•J$ôOìJV~>Þu‹\�œèÚ!k…Âìx¸»¸Þ !Æ|ŒíÂðVÔ=›l­­qqrB£Ó5{Ý]\hFsÆ‘mÂÃ,ZÒÖŸ[Kva~^^fyòb£¢êr+yx˜DX‰DB»ð0“`gkmM»ð04Z-WKJš´i0P(äx¸º Ö6?aþäƒðÝoK™ùÉQÈåôí’À+Ó¦òñ÷sùà›ï™5ó%}}›<¿œìivÝ6ï…‡'1oùïÌ[þ;:½ž¨Ð^xx"k¶ï²ÐëuºÄDcceÅçÿdºG^{ü1lŒŸ ¾ž&+³¶aaŒîß�¥7óóª5x»»3yäpŸ:͆äÝx¹»ivŸz»»5Y¸kmmE»ð03¤1�Ú¶aåÖm¤ggóê´©¯|ð©é~ð÷õ¾#ãíÛ½ IñqLímf½ñ2Ó&Œ3墸{á?½Á€J¯E¥×¡ÖëZÉQW§®žVg¾¤E,!KPˆ%(ÄRÄBŽ�¿ áAA¼>}ÚMë‰D"tOd@÷DTj5b±¸ÉĪ›³S³m5.‰DMÊÄbq“²n;ЭcTjµiÒõ¥G&›Õqurâ“Wf˜Õ±„D,æñq÷3uìôz½iüõâ]Cž1F„ܬL$1aøP:]ƒ6ïGsÇE&•2yäp&�ŽR¥jvòÇhô:Túº‰…6a¼÷ò ¨T*är9"‘ˆî�ãšŒí¾¾½¸¯o/jU*SÄUC†õ7·.ôóöâùǵ8†‰cFX,ˆl›Ï=…Z£A"7ˆcÚ¶&¦mëf·pv¨ËÑ×9¶©Õåí2¤O/Óß …Ââ¾�džßÆßׇWžzƒV‡­Ln¶/®®fçÒÆÊŠ—§>ŠNW÷�úúùìz¿r™éºÓjµ¦k´a;OŒgú;Ð×ÇâõòÜ䉦¿¯ää0ûµ™¨5ä2SO"‹M6�–h|ïD…†jÞW»ðpÚ…_Ï%Ù6,Œ9o¼ŠJ­F.“™¢ æÿç}ÓþN3ªI_ý»5Íiظ¿É#‡3iÄ0Tjµéúl¸ïb±˜û4åT­Œó€QK÷kרºÆÆ˜•9ÙÛ›õÈ î‰Øß`òÎR; ymšå\D¡þÍ> †7¸V1^7�Û±V(̶÷óò¼éóÛÏÓ“QýúRUÓ¼gh€?³f¾„J­A&• 6FÕÌûÏû¦¿ï4 ÉvmÃÂhvýùiokËó“'¢Óéê¢hŒ ëXB*‘ðè˜QL5Âì©gP�îf¯Ç ìϘ�ýÍž™Ñ‘L>Ôâýe)¿¦ŸçÍ�Ýó“'R«R™Ú´¶²â‰ñã˜þÀý¨5Z ”ºgÃcfÛKÄbžzh¼éµ›³_¿ýf³×»€ÀÝ ìÚ5’ºtfýö](äržyd‚™àÒµc,A~¾<÷ö—`ogGXH 9ùWoв9›’÷ðÆ3ÓÒ§'v$c¥PðÊSÑh5ì>tÄToëî}¼öÌãußÙ^}ÛT¾ïðQž›:‰ÓeÖ×s©U©Hˆ�æ•§¦ñé·sïØñ¨géºM|üúK¼<} ¿¬ZGAáUÄ1Ñ­#™>ñNœM#;·©uèÆ]{xí©i$vŠeß‘ãøûzsÿ°:§�Xš¡ÓÕEÔùû\`:E·eh¿^T\»³vþ·ÂÖ佌Ø�©ŽeîâåˆD"žšü ®NÎlÞ½¯-Xæè©3”•WðÊSÓxoöWTVWÓ6"œ7žy‚…ËWYÜÆÚÊŠ�ãX½e{“RƒÁÀö½x`ø}xº¹âïÏãÇ“�—ÇÉs�J¥´‹ §ºFIYÅ5’â½ynê$²ò 8~ú,‰„öQ¨ÔjŠJÊL‹ê0FÉïß›NíÛðæ¬Ï©ª©ÁËÝ OwÎ4Êgie¥@­Ñp­ªk+\)*-ÅÎÆ†—Ÿœ€¢…‘~ ‘ߢP|3vìMá‹÷ߤF©dÖ7–ï¥Êªjíí±³µ¥²ª g'žŸZ÷�²qdpÝ"¯Êõíæâ̌ǧðí¢ßH»”άoæñÑk/‘|ààMs( ü¹á?•^‡R§A¥ÓÞÑØ ½Á@­NK­N‹ ‰k‰ …¸eöF/n$ÂÝÍ>[:.‰X|C[©ÛA,›&Òo—æÄA�AO­N‡ÞÂ78E ' ,‰ƒ-µ4jÈ¥Œ Nœ9Çþ#G‘H$Äß@xù+I%( :¬ ¢›:fH$nöÍW"#¹C÷Εœ\­^ËÛÏE¥eì>r”ÇîsÓºŠF“|·{-I$’[Ýšmw‹÷HãgæŸñÙdéÚ‰DMŽUKiéõ. ðWqþr:ûã�g§3yÌ\œY»u'é™×ó€}·h sÞ{�?|Eiy9Žöö\-.æýÏ¿iq?wì&*4˜×Ÿyœg§LÄZ¡@Y[ËÛŸ~AYù5S½Ý�ðêÓÓÈÌÍçÜÅë–Ýå×*ykÖç¼óÒ3l\ôÕ55898²rÓÖoO¾ƒG¤Ž½‡Žðñ×?ðÔÃ1b`_4-2™ƒÁÀ¾#Çøø?4»Ÿ¡�|ò¯W¨¨¬ÄJ¡`Ñïk˜òÀ®U¶L41 |¿h)Ï?6™îñ‹Å\«¬få¦mL3‚_{‘×>œ}‡÷øæœÍ‹ï~HQq :�Žï>~—¹¿.ãÄés7ìW$ñÆ3O�™›Ç²uÁxßmßw€7žy‚IÏ¿"X� ÜED¥µ5‚÷×B©RÑa’å¤�)XÕ2k†[E¥×R¥U£¹fÖ·€T$ÆV*ÇZ"hÎuT”a£Pà`õçÙªÜMÊjªèöý×-ª{æù™úxþþ¨D þSY¿c«7mE,3|@_õJºÛCj‚L$F!‘ ÷ï>ƒ�_ÅŽ)H¥RTj5mÂZñôCãq²�KP åèõú?,ößKÜëûóÑ÷sq²·gzƒ(Z�[%½¸�7ÏÔüëpMêÛ‚Z÷"‘ˆÖa­ðtwåJv.W²²›Ô‘Ëd´‰h…“£EÅeœ»tÙd—x+øyyÒ*$µJCê¹4ªoÝl kk¢£"�ʤ¤gf‘[Ðò(ÆÛA.“Ñ&¼ÎNŽTVU“•›GaqSKçz¬­¬0 x¸»ìïGÚÅË„ññ3H3ñ–rõùzyÐ*(ˆªšRϦ¡×ëèÝžZ•ŠgÓîÐÞ:.NN´� Ã`0p:ío†B.§}ëH¬­dfç’Ù ïøE$Œ§»UÕ5¤]ºÜäÚ‹h‚—»Õ55œ¿t…ÊêêfÛ³·³#²U6VV\½Êùô ³÷ÃñóñætÚE®–” “I‰n‰�­ ç.¦SXTŒ·‡;á¡Áœ<{®ÅÇÐËÝ�Öá¡dæär9³é}úgb¥PÔ�…‚Si(-/'Ðׇ ?Žž�>^Nnaa‹Ó·9Ê{߯âã_`îÄ�ºîO^Ã÷›· [˜›ë „‰·RPi3Y¼Ü\q´³{ ë7`à�"U(8Ÿ•�èçé�•©éßÝ¥ÿ e••,ûâkÔj5Žöö¬|íeDMžÑu jÀÜÔ##M6…R‰T&C$ainþ@ûz6’=áÇðõñæ�EO?ж42¹¹B�‘XŒ¹™Ù=-£T«�(ÿõ•*R©´Ï”¦çÎ5 QÊ172ÆX$¾ïå·9Êíœ\Þ\¼ð�õé™÷?â±iSÐ#ˆ“.rèäi¾ykÙkÿ^¨¨ªâµ•_òæâ…tôöú?]÷½púÒe?ù¾_þ ’Óoó寿ñÝ;obeañwwç®Ä'%óãæ­üôÁ»˜š˜üéöjjëxiùg,˜:™áýú>�>þ]¬\û+>mÝ™3qB«ó¬Þ¶€gçΦª¦–WV¬äµ§ž S‡öw½Ÿ¬Ý¹©LÆ æýeÛ`À€ 0`àß�A ü— R«)•IP>`qP¥V“YX„B¥ÐûÝHlD»6m‹ïð£5je2 N¦$“P­V³÷؉f¿ï;~‚7>õ§Û7poT×Ö}^ï·z©”°È(gÈ"làZJ*s_ýõÚÁÔÖ¨®­ååÏ>ÇÔÄø�ea—•³jëö;Î3ñ™ç‘Êd|½ìõ•@mà¿KNe%ÏìßEnU%6ffÌïÑ›yA½°¹GAÆ@ëDÇÆSSW@­DBjV~>>zó¼´|¥æ¾ôø£ô ì®Y..žÕ[·ÓÆÑ‘ïÞyó�öI&—S+‘Üõ^ùO`wX‡N�¦w×®¼öÔãw�_¡VQÿ²sóXþÃjV-ÿãÆgÅ_}GïÀîL3ò/i_­uÇ0cr—÷äÕ[·S+©Î'{{¤²[" ¶®¹Bó/—+¨•Hhû÷‚J­¦V"A¥z°ß)þøí==yô¡)ª…âÎû%öF_¬[ϦÏWücÎãÖP*•ÔJ$¨ÿ%öÄVtðò|`b#1Žöö(•ÿ7�Ö¨—J‘Éwœ§�“£ðµZE­D"l{ÓûÉʵ¿bokÃâÙ³„eeòvI 0`À€?ÿì/  ­åW.ðâ À�‡qXÇR—q½zñê´kשԊ„Ž@$¼rý™yyÍ~ßâä¿R  dÉ|M„§³ƒýßÝ�{æÈ™Èr÷Ÿ8auølí¯Â~2‹yaÁ<&н�5%¥:u† ûö#W(P«Õ|ðãj¦Ž‰±6cçÏ�ž“ƒRgÐnÓgŸ2°g�?Ý®5ßF�ÄA€j©”Ÿ/F³%þŠA(|DÅÆ ‹Q©TDÇÆ7ÿJ¥•Z}Ç�v¥J…J¥ºë`¼Z­F®PÜ5óD*“ajb¢7-•É0»CÆ©T&ÇÔÄø®×*• å=ôõN(T*ê”ò{Ú–{å^û%W(‹ÅÝAÌ’ÉåˆD¢{ÚF™\þÀ¶áßL½Ja"¾÷çô˜AÿÒ>0ðOï�o=³è�µginÎàÞ=õ„³ÿ2w ¼Ûýd꨿&H€ 0`ÀÀ ƒ@øG ”Ëê‘?àˆ\´ƒs§¯&¶:ýìõk,™<Sã;¤P«¨�×ã`jÁŸ‘÷DþoinN]}=·sr‰O¾I�N÷Ü–T&#·¨[ìlï«e••TV×ж�Ë"[¢¢ª …R‰³ƒ#ú÷eDÿ»[åÔÕ×SXRŠ5Îö÷\¯®¨¬ŒÚ:Éêç�ØqLø¿îq8})†òʪûÞŸ•JE~q1õ2NvvØÛÞÿzs qv°of§V«)*+CR/Å£� &÷90ªT©8s9Fø{Ѭ™¼¥c�çÓ¶-ý»ãíîÆ»ßÿÚã{#‰~Ý»µØ¦\.§¨¬Œz™ 7gç;ÚLI´Ç¤�^]º`cõ×Ô9¼”*y…EÈr\ï»ö¢J¥"·°7g§fÇD©RQTZ†\!ÇÝÅåŸ` e®µlÇk ÿ<9……däæ"‰˜8|(‡NžæBÂU™:±XŒR¥båšuÂ=}OÄ1*««±²´äÐÉSTTW³üç5ÍúxèÔiö;A½TJ/OÎ|�¶|´j5Yyù„ Âõ”T’ÒÒ135eÊÈtõóå—í;É/.ÆÒÂ/®E™ IDAT‚‡Ç�!dè¡Ý¨Ø8v‡EPPR‚‰±1A�;ñÈÔÉ´qÔ 8¯Ûµ‡ ñ îÓ µZÍ© —PªTtðòä™9³ñpmÃ�'9Ÿ@Jf_þº¡Õ,´œ¶ E%¥\I¼Ž‘XLß�Ì™2 �çEô•XBOœ¡°¤77&�I¯î]¨®©åÝ/¾à…'°÷h’z) fLaÇÁ£B;?¬ßÈô�q´óòüg£™\Nè‰Ó$§Ý¦¼ª[kkôîɈ�Á :Û¾‹�‘Ãñjë.,zê,æ¦ ̺m;)«¬âÊÕDjjk˜ûÐÔj5g.\äÜ¥Xjêjp´s`Ôàôl|¾nܵ—½{wí ד055aܰ!tìÀŽCGÈÌÍÃÖÚšiãÇÐÙÏWXîÔå.Å&P\V†™‰ Ýüý˜9~,ææì çFZ:J¥’ï6þÎâ9³¸”x�‚¢bfM/lÓ�ã'¹˜p©\†Ÿ�3CÆáæì¬7ýZJ ¥•ØÙX3¨WOÆ|WaûÄù –”2OÇ@­Vóóö�ôîÚEÈnJì�$ŽGŸ'¿¨c##|Ûy3+d<:ïU§.^âXÔyªkkhçá¡w}¡}>9s–ø¤dŠËʰ²°¤O÷®<4zFb15uuüºk3CÆqøÔ®§¤bffư¾½™0l("‘ˆu»ö�[XDEu5µuu<;oh-+�:MQiv¶Œ¬wÿ¨—JÙÁ•7@ ½»uÅñï¢ñIÉ<¡¹§ý¸y+£ÓÝߟ’òrv††s3ý6b±˜.¾™9~ì]ß/O]¼Ä‰ó¨¬®ÁÝÅ…‰Ã‡¤óMRTZÊÞˆãdäå!©—âæâÌÔ‘#èâÛQ˜§¤¼œGÃHN¿�¹©)½»ueژј™6¾ë”VT°n×Ò²²±±²"dè`†ôéÝj¿ò‹‹Ù~ŒôìŒÄbºøudæøqzåÎ^ŽáXôy*ªªqsvfâð¡ôèÜ ´�?nÞÊÌ�ñ„Ež#îFŸ¿þ kvìbxÿ~zÛXXZÊöÃGyhô(Ô¨9pü$ÏÍŸ+¼‹EÅÆyŽŠª*\�œ˜‚ßø‰øŽ›Èâ>"#W?3øzjš^Ò²³9qá"}fÎ¥ÏÌ9¤çä6îëšZ>\µšîS¦ÓcÚÃô�9—v£Æ1ó¥W›�oþù(îòük C6¬áûèHª¤õwœß@#ÑW4÷n_o& ‚H$¢¢ªªÙ=ó^¹•‘Á�›·’WT„·»;^îî”VT²ùàab¯P^UÅG?­&ñV ¶VVtðò¤°¤”ÕÛvp¡Éõ™‘›ËŽ#¡XYhjÛ¥eeó£NUI½”Z‰„½ÇÉ)(ÄÊ©LÆ®°p–ÿ¼†šº:¬,,¨“HøýÀ!²òò¸�p•U[¶Q\VF¯.�quv"&ñþð“`·*•ɨ•H8}ñ§.\ÂÞÖµZMjf¿lßy_û¥ª®–/Ö­')5 {[[ºøûR/•qääiö @¥TR'‘P'Ñ?%Ru 2…æù!“Ë©“HHJMãð‰SØÙØ`$“œv›o×­l&ë¥Rê$ŽŸ;OÜõ$œPªTœ¿Ço;÷퟊ºÀ†{(,)ÁÍÅgGGr øeó6.ÇiއZ­ÖöMÂæÝHÍȼ¯í¿¶í?ÄÉè uíÄô�qtôñfû�ÃÄ$$ û)æê5ªªkô–KËÈ #KóèäÛsSœ�ñÕfÃ>~ŠmûÓÑÇ“)cGãhoË/›·qáJãûKÂ�d~ݾ±HÄœ©“pt°gî½|ñóZ¼=Ú2gÊ$T*5?oÞ.Øû]Š¿Êo;vӯʼn!cÚ·‘1Wøýàa:z{â`k‹­µ5Ýüý022"+/Ÿk©©ÂzÚº�£g#ܧÓÇŽ!·¨ˆ\�Dë8ðûþƒ=s–ž]:3oòD:uèÀïqîJó÷Ô¦ØXYqøô*ªª„ß’ÓosörŒ @6åVF_þú–ææ<<~,£ àÚ­VoÝ!Ì~.Š5;vÑÆÉ‘écFcanÎW¿mÔkgßñì<Fç˜7i"½»vaÿ±=s´çò…„«|¾v=•ÕÕ<4z$¾>Þl9t„GC5DzC{¬,,p²·§‹V”½t5‘Ï×­ÇÙÞž™!ãèèåÅ/;vqàÄIО¯ßlØDDT4ÁA�L>”äôt½ Â¦8ÙÛÓNxÐÕ×G;{jjëxÿ‡U¤dd2aøPÆH|r2®Z-ر·Ä¾ã'øu÷^:uèÀÌ�qØX[±rí¯\ÒdÊ >Yý é9¹Œ4Pã´¡V³rí¯TÕhÎë†ugçðÐè‘ íׇcÑçY³CÿÞóõo›ðñhË3¦áâèÀêm;ZtVA{Oûdõ/””—óИQŒ4€ ñWùuWãýàð©3¬Ù±K©ímmø|Ýz¢ãâAk#{!á*ëvíæÖí úvÇÂÜœâòrŽ7)5p.&–Ä[)´umCEU5®¢RiÞOOœ¿Àª-Ûhïéɬ�ñØXYñùºõÜÔ~sfççóþ«�ÉåÌ7†¾Ý»±ÿøIVmÙÚ:ç³&Œ¾7¾Z¿‘k·R?t0SGŽ$#'—/ÝÐê1ª¨ªâýïWQ\VÆô1£Ø«‡OŸaÃÞýÚ}%çãŸ~&§ €�!C˜1n 2¹œÏ~i ˜ÉÊË'"*Š6o%¨S“F £¸´Œ\MQi)hƒpVhß‹§�Íø!ƒIËÊæÛ ¿ëõ':.žðÈ(F fôÀ`â“’ùHç�š™EJ+÷]Ýû‰;l,-q°³¥»¿éYÙÂ~ظÿ;CÃèÙ¥3ÓÇ�A¤ ¶ixîß¼�Á¿þF''fO ¡»¿¿8ÄÙ˜+­îOî•öÞ^8ý]’¼<Üquvú»»ñ§ññhK§ÿv0`À€�¿CJÅ?˜z¥‚:å_W7à|ròÝç¹™Lo?¿¿dýuJ9¦b#Ì�îÿ4mj«ûØÀ¿“Z™Œß®\bgb<³º÷àñÞ}±53ÿ»»õ�E­V ÁÁA�ØÛÚÒ©C{’ÒÒ‰Ž‹§«Ÿ/Fb1o=³ˆEï~@um-3ÆŽ2ŽJ%«·nÇÞÆF°Ž;uKssúvgÑì™|õÛFb¯‘|;ƒ>Ý»q.ŠšÚ:Ú¶iÃòW–bfjÊîðö„#,2Š`íó@¥RóñK/ÒÞÓƒ³—cX½m¹EEÔK¥zYÞ]};²láSÈ ^útU5µ´oϲ…O"S(xñ“åÔÔÖ‘ž�ƒw[wv‡…£V«ylÆ4Æ €R¥âío¾#37�°Ès<íZÌT¨TÜÌʦ²ªc##Þe ¦¦¦ÜHIcóžýdê<óî™LƳf0 O/ò‹ŠùôûŸ(+¯äR\ƒú6f©U*Þ~ñ9<Ýݸ•u[w�p#™¼¢"\9 MF Èì)Q«Õ¬ß±›‹±ñì ‹ O�@½õš˜šðÊ¢§p°·ÅÕÙ™yMj.yò±?�©]USô cÜ€~=I¹�Éí¬lúµœíÖ”Á}ûpül>ôíD½TJøé³Œ6˜Úwº�½{iÍ"ŽÜ»§°lGoofNž@[wWÞZñ%ýz2qÔÌLMøfío”UVââèHQI)úôbîCš÷3±5uuÄÞH w×®\JH¤VRϨÁÍúz;'‡ ñ ¼úäãôé¦ÉØ êÀ }JÜ�$öìAyu5³&Œgü�Á èDJF&©YYwÌèѹf¦¦\N¼&Xž»‹�G[¼u20uÉ-(¤{€?ÏÏŸ+¼±åÐÐfçï� 8(�¥�=Àˆàþü´u;‘:"BiySG�äáñ�×SvA©™Yzës´·ãµ§ž@$1"¸?–ff=ɤÃÜ»'Î_ÀÛÝ�aÚ ÁÍ1¢?Îz€¡}zceaÁÞˆãŒ2˜ôì®Þ¼Åóóç2¸w/†ôéÃ+-¼;5àåîF`§ÂÏE1"¸&ÆÆì;~‚êšZ¾}û íì„cóúç_q6æ chÖN­D¾ˆãÌ›4� Úl¯¡}z£R*Ùq4”~�Ý)«¬ÄÚÒŠgæÌŽAP§¿÷!™yùt÷÷#,òõõR¾xýU,µÙ¾V¬Ûµ‡§µÛ 0nð@a=ÝýýxâÍw¸��«÷ýÑ@f^e•¼±ði¼Üݰµ¶&ñV ©”ÝáÌ ÏTíù>T{~í8ªgK¯P*ùô•¥‚eñ ^=Ùvø(© í½9:.ŽA½{6»'(•Jv††1vð@Ÿöƒ{÷bÙ—ßpær íÛ±3,owwÞXô´pz¹¹ñ͆ML5‚vzûüVFK{„à Íýʽ� ag#›=+8|J¼øîsÏŽ*&&&ì8Ê“3¦Q\V†ƒ­-K™‡»‹ hÅ꥟¬ · Pȶ+.+×»võêÉKŸ~Æ¡SgxêáéääàëãÃK�?*ì+k++¾ß´…R)”�Ô׳ü•¥BÆ`7??ÞýîÎÅ\¹/KâàAœ¹ƒ½­ #µ÷P]ŠJK‰8Í óç2°—æ¾7¬o>Zµš]aá¼÷ü³$$ßÄÑΖ§gÎ``ψDšgŽ–Ë^&ôt$wíû»»¢ÇÏ-$=3‹¯Ö´Xðoà�—ž%þZ2«6nù»»bÀ€þ…Â(JµšÊ?˜a—V��—³ó]­A/Þ¼»@x!ù&/Lš|Çyd 9iùxzÞw]ÁJ¹±F÷¹ÜÞc�‚�þþ´÷ô`òÈá‚@˜WTÌÅ«‰ Ðll‰Ã§ÏbnfÆÐ>½©—J9‡J¥B­VóÆ×ß2¤O/A¤Ð%áæ-<\]éÝ¥3I·o Ñ�…¥¥¼²òKö|÷u‹ëÜ~Œ¢²²{ÞÖz©”·¿ù^;xz2yäpŒÄb"¯Ä Ö¶#¡L=JDùjÃ&A416æÑ©“iïéAä•8ÂÏE°v׺øvdÞÄ ÷Ü]ökŒÈ3hæffL9\ÏÇ'PXZŠ«6šM,3¼o_v…Gèeë5 ¨5p;7W°(-«¬ÔË*køðýzÃ&A416fì �x»»“WTÄѳëµZ‰„·lã‡wÞ¤%¾\¿¡™8ðáªÕ¾573ãñiSiëâÂñó„HÚ•ëÖÓÍÏ—±wù€735¥`w!ƒízjýgÏ£_÷n ìÕ“ ºùùѶ�K«mh„àÏ….s33& Š­•5áQÑä Ǥ‹o–ÌŸ‡•…KæÏ#+?_/cô©Ó°4·ÀÜÌŒQ‚qqpdõö(´Y£‚ûÓ¥cG¼ÜÜ073c@�@N]¼ Àåk×X0e’ö˜ÝÐëãÕ[)È LŒ�IÉÌÄAÝcöéÏkqÐÌÔ”�¡ƒqwv!#7—ð¨hT*e••¬Ûµ‡÷ŸožI °â—uÍÄA€×>ÿJm¬¬xrúC8ØÙräL$—¯¡R©xãëoéîïGo­Èlà¿ÅÿÏBáêm;(¯l¬×8¤OïV„”ÌLŠÊʉDôתö $)-�KWybÆ´û®úðø±<<~,9…œ¹íÛ$Þ¼ €\›×p}ö fÇŒ§›[³õy¹»ÑÞS3С=h…ÍZ‰DoÐ70 ###,ŒŒp°µ£ª¦– Îú¿ÕÔÖ!WÈ©¨®&·Pǫ́©­åÄyM&¸­•µ¦M²'»ùû ¶Š�:tú ©¯¿k >…ZE½J�••f€_¡TòÖʯìÜ™n~¼÷ò -^ß Žöv‚°åÞÆ…^Ý»ráJ©™zaP—NxjÅ€~=9~œ¢ÒRÒ32©—ÔS§}öM¥±Ÿ‰D„ŒÆÅØxJË+(­¨Ð{—œ11„NÛßW_o¦Ý&´‰cÁÒ§Ÿh10iÉ�7å•U¤edRZ^ŽJýÇëbæ!“Ë ‹ÐKc'Z[[‡••Æb±ƒ�·0ÝZ+ÌèþÖ0ŸR© ö™4z„0M"‘�œ›Ïõ´TT÷èü‘š™……¹¹ÞóÈÁÖ–ïÞ~C†^}â1aZyU·ngPPRܪÀ§‹©‰ ½ºvábÂUÆ ˆ\¡àbÂU¦�Ýê2#‚û3Bû¼®—JÉÊÏ'öú a›ŠËʨ©­cÔ@}ÁsX¿¾zaC�Zá753‹ŒÜ¼fýÖ¯¯Þ¹0r@0GΜ%;¿€Î;èÍ[Q]MqY9�;v²´Úyz “ËÉÊË'=' 33=AËÌÔ„=‚î+ *#'—îþ~‚8жM:z{é³é-“›‡\¡ÀÏÇG¯¾í|¸�p•:‰W''–¿²´bYai)µÙ… N )YYôìÒY8ÐÞÏ»ùùbª#¸õìÒYø¿‰±1ff([9÷œìí162bãþL>ŒÎ;2 G�ð�”�Ÿ�T&ÿ]“¾{{sîJ,U55X˜kž¥ƒ{õÒ«g: G›&îú öêIFn.yEż°`~³~––QUSËà^�×£H$â�g¡Ò~ç¤dd2¢_Šu¾•<Ý\‰D¤ffé „æffØZ[³ïØqTJ%=:w" };!(°%R³²èÛ½›^¹…1з[WD"žn®zÇ(¿¤DZÔ­çmmi©wíZ[ZÜ#ˆ´¬lÐ v A/R™œœ‚._MD­Vë|vó÷ÄA€ŽÞ^ø´mËíÜ–³Aÿ(©™YˆE"Úyzècÿv>= €›³¥•ü¶wÃûõ£�G[æüÁoD 0`À€ÿ á?”j…5ê{˜³µZÍ�GsèâE\ììxtä(ÆôèÑ,‹ 5?�¼{©Š*+¸‘�M¯æ–ˆ*•Šˆøx~?y‚âÊJ†wä�‡nq}­ö5Õ )ö&÷>€[W_OèÙHá Cz÷ÂÞÖV°RÚâä]B[k+v}ûµP¯ðÀÉS,~ÿ#apã¾¼µxa‹Ë²õË•XYX P*yáã債é¹+k�‹]ŠÊÊÐ#ˆgçÌ¢m›6ØÛØÜ±�ñÉ7©¬i´ÓÚþõçBtðëO>Τg_àÊu�PͰ¾}¨©«cÝî½Â2Ë_~‘G§N`á̇yiÅJ¶ÑX8}¾n=³CÆë}ìß ©YÙÄ'ßþž2Bs&Æ{?ü„J¥B¥Rqðä)ÎlŒxÜOO T©TˆÅbaD"‘ðñ›”Ĩàþ\¹ž$|LÛZ[ ѺQ�–F¯>ñ/k£ÙÞÿñ'~ÖZÁ%¥·n™W\^ÎãÓ¦2yø0ìllðrs¥°´”­‡k+­z÷m& ×Di/šõ0�¾ñ¶�…·|ͺ» „/]Âô%/ ÇR¡TŸ g{éîâÌè˜2r¸¹ÝÀ77 6¥Vìúö+a`âÝç3ãÅ—‘õÇ-ÛyzÆt¬--yçÙEœ¾tYO |ù±GqqtŽÛ”ÃY³k· N6”ù:5‹Föïß(&6Šº1MŽY½TJRz:�þþšÚ?Z|Ú¶¥£—*•Šãç/ ¿/éEAlxáãå¹q§cVVYÉóóæ0zà¬,,ðrwãVFGµ÷‘HÄæÏWÑä‹fÍdÊsKˆ¹vµZÍŠ5ëØÝŠ€oà¿Áÿ�Bá­Œ Š-®Ú·.äœÓZ+š³ùÀ!Ðfb Ír¿zó½tŸï…â²r¾Z¿A°¸svpÀÆÊ©¬1˸ÁÂÓÁ¶ñ¹gme)\«ºèfžÜ)ËÝȨù³ËXܲ¸YSÛ˜�¿34¼ÙôÊ&Ö•÷Ú‡¦(Õ*ê• Ô€—»;³&O âL$UÕœ»t™s—.cjj¤Ñ#?¼y]©»á`g¯×Ÿ†Áe]�ÀÁ^ßÊËÙÑž¢ÒRÊ+«±³ÑŸ¦¦&XëÔ¤u¶wþ_SS‹£Nö¶w~_i‰Êêjn¤¤ÝÜ�š‘Á¡ˆ“Úv5m]]1náøÞ )Múîè ~ʪ*áϨ…ó¦¥ßtÛÞÁõ›·¨«“àâ䈑‘ñ=¿½——ã`kÓìÜrvh<)™™ì �àVFjµ/w·;ö©)‚ùfãïTÕÔpëvõR)ƒzõluþ:‰„íGørí:åUU¸88`£S¸aêž'š>ëŸkÙùì8FRZ2¹W}Ç„šÖlx7Ñu i A0j°šlJ­DByE%vv;Eî×Ö®´¢BOˆÚ±³£TÇ9¡¥þ½§­%Ý”‰K ŽEEs,úy……XX˜Ó¾ÉzJËËñq×RMŒ�›YÇÝO-i'{{^úIv‡EðùºõššŠ;2cÜhÚ·œ=>Zµºå¾×I�°é}ÀÞÖ–®¾¹x5‘�½z�OÛ¶B€‡. ®ºÂ+ÚlF´Ö³•ÕÕì?~’ýÇO6[¾¶‰ ³‘XÌ[‹²õÈQ~Úº•Z�¯·7†mñ¹‚öºëà¯÷›©‰‰°Õj5a‘ç8yá"ùEÅXYZàåÞ\�w°³kvíº8:p9Q#øJerv……séêUJÊ+p²·×«ãÙ@Ó}ÑÐŽ®5ðƒ ¸¼¥JÅ«Ÿ}Ñât¹BÁ�>½©ª©%"*ŠˆsÑXYXз{7fMßbß üw±²´ÀÚÒ’²ÊJäò–³¯Åb.NNÔÖÖ ïwº˜˜ÓÆÉ‘ÒòJÁ‰æ^‰D´qr¤^*£Rë cllŒ«³#%åH¥,£ÕÊÒ;kŠJËõJkèbkc�¹©%åe‚-òÝ073ÃÉÁŽÂ’2¡]{[LML[mÇÁV1¦ IDATÞ3SŠKKõt111ÆÁÖNOз¶´ÄÊÊ’ÒòŠV·Á€ ø# R•’zåý=ðUj5ßÜO¨6:¶¸²’¯öíewÔ9ž3ŽàN¨Ôj¢“np$&†¸´{¬xeí‚:t`BŸ> éÚ �œÄoÇŽ‘U\,Ìw:ñ*",›q"a½R�ÔH‰Ù=x„ž�j@L> ´Ï!C ÂסSgøô¥ï˜ñäŒé‚80uäv…… õ壢[—¿¼+mt¯±‘Ë_~‘ÐÈs ð±èó- „^înlÿêó{ÎP5±}ÿ‡ŸX8sý»cblÌšß§T;(Ø0Ou­&{ËÔÄ„9Bšl÷4a?å—p-%• JÊËÉ-*¦%ºtì 7Xª›=heaÁ(mÄyÛ6.ôéÚEÏfTW Ö·b±•Jcõ™š•…»v‚@8¤w/¢ãâQ(•ÄÝЄº™jCûôŽéGKž„ôA�bp½TÊ  ×ú;XãÌ?Ž•¯¾¬÷ÛÖ#G…L=G;;&"L‰D<9}š &¥¥“[XØêàWÝü|9¹áW¾Þ¸‰°È¨¿ò‹Køýà!~?xˆ©#G°ê½·116F.—ë k�N�¢µlinλÏ.fÚ’—@[_åBB"#ú÷½cŸî•ýÛIÏÉ¡´B3Ð{=I˜Þ�­{=‰@aÀÈþý@+ŠêfrïÛØnum­^}Ã;}ü-ž=“÷ž{Fï·°sÑÂÿý||ô†ŒÄbŸ6UÈX=Ÿ@­D"\¿þ»4…»¯%ðHÏ><Ö«/¦÷™÷oaVÈx!#  ƒ—g‹ó)•J.&hê€5å|\ü} „Û�†’™—G`§žš1�6NNü°y«ž ±¥¹æšÓ z©“HHËÎÁØÈ¨YÆÐƒFWŒøúÍÿ ƒÒ ܯ BK¨Ôj$Zq°�ÑC1jð@Ò2³H¸žÄå«W)+¯d_hý{4f:5µom𧼪µZ- N—WTj·O_i*–5Ìgo‡•¥æXÈdrjjk‘°¤BçxYþù{d{oO̸»­seu ß®Û@�.�yûÅçpsqF$ñÅêµw]¶N"ÅÚ²e±ÄÖFó{u]:÷üêÍ`¦�•u‹ËÝ ¿ü¾ ©LÆâsiïí…‰±1‡�Ÿâr|Jµ #Ñ�ß…m­¬©i¡Öpnaff˜˜³üçµu à“—^Äõ "‘ˆå?·nEÞ” ›ÑÄ[)v Àî�ikwí!%#“…³¦sǘ™šr6æ ëwkjÕ5ˆC•5Õ¸¹4Ö1Ô­ )W(XþËZ<]]yç¹gðn뎑X̪-Ûš½�Uë8  Í6¤ñ«áz]ñêK-Šw9…B-µ{¯ØY[ ïÑzý­­Å¡AG·W.o5Ã8:.žßöîgþ”I êÙ{[[ê¥Ržxóa++jš8[(”J²óóËË?B`€?�þTTWs#%•°È(–ÿ¼–oÞ\†­V^¿ücAlJÃ{qK÷É�={²aß>$R)ÑqñL1¬Å6Žk­¤GûÆýXVY‰¤^Їk,ÌÌxlúC‚#ÊÝðñhË›‹ž¦®¾žä´tNœ¿ÀwÇaÉs-ÊØXY5«ñ-W(È)( mWÎÇdzåàa}h*ýƒºcgcCeu5ÏèÔÒF{.4¥²ºFÈHßrð®²pÖÃtó÷ÃÂÌŒØI|±ný]Û©ª©¹ëwÅýbkm�¥…¿~úÑç›4b“F #§°�„¤dŽœ>KNa!/]ò@ûcàŸIÿ^�¼¶èI<´îõR)›÷dýÍ3 À·ßø¯~ô9¼º„¶®.¨ÕjÂÏœãÓïV bל©xzîL,-,�ÉlÛè®ë>²ñÖlÙÁ¬)hïå‰J¥f×áPb¯Ýà­%‹±³±A.WðýúßÙsTäµâ�W((.á»_7 íŒ6˜g™Ã´§_ÀÌÌ”eÏ-dÌÐA‰ÅÔÖIظk›÷–161åýW^`Ì�AˆÅ"ŠJKygåw\ÓºF5eâ¨áL3‚ÈK1,œ; SSÊ+*y{å7<2†Ñƒ"‹ÈÊÉcéË)Ôðµ÷öâ�Ÿ¡³_GÊ+«Y½i ‡�kÊ}øµ÷áÇOÞå•�V²â�W¨®©aþ’×±±¶æ­%‹ܯFb1Õµµü¾û€Þ60`À€ƒ@ø¤ú>­EU*_íßϱ¸ØfÓ2‹ŠxoËïtõö¦¦¾žÌjµÝµ}µš¸´4âÒÒðv9‰…©)7s[¶×9uõ*"DüoÆŒû «åRÌÌ,ïiÞ–ìE˜ŒÌ+¾@­V#Wʱ´Ð È+”J2²shçåIFvù…-¿³5Ôìß«Å%Äjƒ:¶÷Ö›ïê�dò ‹hëÚ†ØÄë— ‰èàã�³£–ÔI$9qZ¨Axô„f�ÊÑÁGÇfNk(äòkº8:⢠¹9yùÈdr¦�‡“£FèTªT”WTâí¡qMhDuŸu 9ùù´qrl±]7ŒÄb®'1fhã±�¿ž„µ•v6L T©TÜÎÎfþô)øwh!ÊÊ5Á?¥K£;ÛÐz¹»QUSCZV6½5NR™Œw¾ýžS&áæâB½Tʬ�ñ‚¹J¥¢´¢Wgç;¶Ý€‰±1½»váÌ¥2rsyvÞœ;Ο’‘É�>½ôž�ºV�.ŽŽX˜™q>>AO|Ñ­×\\VFEU/>2_ク¬\/±a¹�:�®"‰„:yº8;8`anN|R²ž@x!>�°È(Þyn1^înÔÔÕé¹x¨ÕjbtÜî/w7ŽG_Ы«WY]MJf–^�R½eÜ܉DÄ'% õZ�<Ííœ^|t©™Y´mãÂÄa�™Ã Ù{�ëv'îÆ ”J%FÚ€–«É7ùâ×ßøéƒwïk;¸�p•#§ÏðÁ ÏaocÃÀ^=éèãÍKŸ~F~q1žÚ¾Ç%%ëÙ³9s–[·3xù.õ¶ûvcýž½ì8JUM�ž…¨.î.Θ—”¬—•÷ó¶XYX°ô±Gðtw#>)YO ÌÌÍã—»xé±z™”éÙ9¬Ûµ‡—„6ŽŽôêÚ…ž]:󨲷ÈÌÍoQ ôvw'!9µz²pO¹|5‘U[·³ö“IÉÈÄ»m[Ænt))oÔWQUEJf&~> d¥RÉåÄktÒÞnefÒ?(�¾Ý» Ë·àÞsíV ’úzA˜-*+#5+›A½[Þ‡owwê$R22…:Š[A*—ñÄôilÚ•ZÅãÓÂÓÕOWWŒ�ŒÙrè°^PŠ�ÿ&f¦¦|ôê‹Ä^»Á«Ÿ|N½DÊ„QCY46g/^&5# c##l¬­yýÙ§ølÕ/$¥¤1nè`^{ö)Î\¸Ì™ó—9(˜Ÿ|”}aÇØs$'G–=·P/¨¤%¬­¬X46}·ŠëÉ©<=o&³§L`Hÿ¾¼úÑJró yó…E¼ðÄ|ö‡G©TbanÖl\ÅÄÄXÏaÙsOÜ«o®øš”ÛŒ6˜ç›Çíì¢.kÆÏ&ŒÊî£áL~b16ÖÖ¬Xö2Kž\ÀâeïµØWS:ùv¤ª¦†™‹—biaη¾ÅŸ¾Ç¡ˆ“Lzlžîî|ÿñÛÌœ8Ž7lÁÜÌŒ¯ß[FeM-‹—½GeM 3'�ç­%ÏPX\Êå„DÄÚýûÎÒg9xì$W®jž­Ÿüo)m]]yõ£•ääå1l@ž{luuö†k±� 0`ÀÀý`ÿaÔ+(Ô-Û ´„J­æó={8y5áŽó]ÏÊz½C/c°5N^MüoÆÃ÷�¯ÐÚq™Ýù”,­¨àô¥ËÂßb±ˆOV7FRË• Áî`ÿ‰wIÜ[¨ùÖV§æ R¥¢²ºZÏî m–\KèÖ+,«lÙÆÙ¾åÈãÖ°µ¶bÕ{ïðòg+õªkk9|ú,‡OŸÅØÈˆcǰòÕ—°07§\G ©•H8xêô×Q^UyÇéM‰½‘¤W°¼ªJï8—7~«ÕjöŸ8Å‹ æ ¿�ì߯Q ¼¦ù˜n°ÏìÛ­+Ùù�0) •J%Ì+‰¡3ÐXVYÉ«+¿¬%u111i±N]Sœí›ÛNéî¿òʪ¾ÿÄb1=:èe¯––²fçnVmÝ.œ¿;CÃXúÈ|½þÐäÕÅÃÍU¨…YQU}_}º#û÷gÓM”âå&m}ºu¥gçN„FžãÊ�$j%nÞÖÔ351a°Î G^Q1K>]Á¹+ÍþÌ1ÓÝÞ‚’’»³²lÝt' âà?‡ð”›ÿY�ð^ibñóñ²ü|¼9~þõR)qIIbeiAum-û�ŸD¥V3¸w/¬-5=UU|·i3OLŸ†�‡;·srØ{ì8 É7É+.B¢µƒ“É4×õ¸!ƒ8qá"Å%¼¼âsœìIÏÎA$1I<ðW3mÌ(ÖîÜÍš�»‰Š‹GR_OJF&Ö––<>}Ú}µÕ�…wóvkvîfÁŒ©¨šLº»¶!+7—¬Ü\RogacmIvž&¸ÂÛÃGGdÖ2¬¬,©­­ãóŸÖ`ogGYEÆÆF l^SÌÔÄ„õ;vsäÄ)JË+�+¸:;¬3°ßÀ§ß­ÂÉÁžÂ�EUß�»ϔ±£Ø~à0'ÎEsýf ¥’mmÊé!ãî:Ü`Í °vÛN¦�‹×=ÔÆk‰!ïDT4#SQUÅá§)¯ª¢¤¬™L†©©)ö¶„Ÿ‰ÄE+î ÇÔT_ˆ!¢°¤D¨c< w/DÇØØ„ö^$&ß"òÒe?æx‹ÅbÜ]\¸w•Ž>Þ¨Õp16ž‹qñ˜››QQ]ƒÈÆD"J+*(*-ÅÅQ_ÄìîïG;¾ÿ} �L�Œ½� NœÄØÈˆ>ݺ¢T©022""*Š�¡CëÅÂÒ2\˨—Jï)à,¸G _þºK Á¢½5<Ü\‰¹v�>ݺanfF̵k9u…BIAq n.ÎŒ<ˆƒ'OaeaA`@Éé霼pQ°ªw²wÀÜÌŒç/à`kK­DBXä9R³²ðië®—é|9ñ›¦o÷n¤ee±ãH(zölE"%%””Wàì`ÏÄaCÙq#±]|;’[TÄÆ}w‰nþ~ø´mË�›·²`Ê$œˆ8EYUÕ­ô¾WÒ³³ñvwgÔ€„Ÿ‹æ‹uë™1n,J¥’GC135eX¿–¿+\ dÝ®=ÔÔÕÑÞÓƒ©iì ãñiš,Z×6‹ŠæB|¼<¹�“ËÎÐ0D"yEEtóóeü�Aœ¾x‰o7mfÒð¡ÔIêÙ¸ÿ A�p°µ%¯•À�;áîâLzvkvìbôÀ¨QsäôY¬,,hçé�•…ƒ{÷â·=û¨“HèèíErúm¶>Ê|+øÖ°´° GçNDœ‹¦P ÖV-}š›™1zàöFÃÜÔ ¿vÞÄ^O"ñV Ë>Ú`Ïo6lbÓþƒô ¤¶®ŽßÂÁÖ¶™Íj'G JJX·s7SG�ÂÒÜŒ3—cP©Õ‚P×”�¡Cx÷»øqóVÆDum-[¡_÷nXš›ãéæJdÌb¯áÕÖ�´¬lv  ¿¨H×Äb1ßoÚÌ‚)“±²´àð©3”UV2e¤¦6©§«+ Éš@++ân$±7Bã¾’SP ˆÜjµšÏ×­gúØÑH¥2¶9Š��µÞ»ó½"‰(.-£¸¬\°ëm £·]|;òýï›ydê\¹rí:‡OŸá­ì`kËŽÐ0Ú8:Ò¹cGÊ«ªˆˆŠ¢«Ÿ/"‘ˆªšZ^Y±’‘ú3oÒÄVzaàߊ…¹ûÃO°/ô8Ú1Ÿ­û³hþl<ÜÜHÍhOÚ{4œ˜�xµ7ìOÍ}7M°ìŒ ã¸~3…/Vÿ @zV6Ÿ~ÿ«>}ÿ®}8xì$c5λ�†3krvîáÆ-MÀÙþ°ã î‹�� e-¸ñ4ÅÁÞŽ1CóùOk9w)€M»÷Ó½s�|;ana!«6lF¥RS^QÅÑSg™;õÎ縩‰1+~\#¸8œ»Ø¡ƒùzí UÕÄßHÆU›ù=´\œœXúÁr²r4ã(_ÿò��˜1a,—…¶ÃNE²q×>:´£oPwž{óâohÆD¶í?Œ¯�3§„¡L&onÀ€ Ü+�ðF�âþ‘“srî*þœLH`jÿ`:·P»°5j²» „Nžꤡ­Ï§[¯)GÏFòùk¯è¢×¥º¦šh-Õµú6D–-ØVµQ_¥³¬e+=d0jÌÀ`âöìäØù 9s–ãѨ¨nDJ%;BÃ051áËÿ½ªÅïdoÏ„¡CZiYƒ»v÷Õ]{Q€Ó—.ë ·MÙwìD3�ðë +�ˉׄlLcczt àvn.›¤¼²Šãç/Ù“�:´×gŸ|û=Îkkø™š˜0yÄp‚ƒºÓ»kWÎ\ŽáÃVj©Ü Ýý×¶MÁ>µ5jB¶FYe%Ù…Âß�;´of?åêäÄ»Ï.&¿¸DÈÎÌÈÍC­V7³{ª¬nYü«ÔÉÌÌZ>çÿ(#ú÷m¯]C¬­ å`g‹¯·½ºv!4òiYÙDÆÄ 3ÁA�µ T©˜ýÊëB¶¤•…SGŽ O·®ô ìÎö£¡üØJm¡»abÜhÙÑËK/¾%\› 2ý•8·!6/çÿl}ZÇÃöþ4þk¨T*¤2ÝýýZäv²·gDÿ~—• 5OfŒÃúÝ{É.( ;¿´ÂFwoÝ"&ñ ¦Lbö„ŠËÊIJK'3/�‰Ã‡ÒÆÉ‰¨+±T×Ö!“Ëq²·ç½çŸá÷ýIJ¿­±QkÓ†™!ㄌ%GGºûûáéÖ˜Adjb"d™kî�¾>ÞØXYêðøúxckm…“N ŽŸ�7ö6ÖB�¾‘Áý5�+ÇOp5ù¦6ê+s&†ÏO77ºûûááÚø‚`nj*ôÁXûŒÒ§7QWâÈ.( æÚuæNŸÒlŸvöíÈâs ?In~Ùy ÌÍÌè×#�B‰D˜™™±hÞl¶ì=HQi)µ 3'…PTRJaq1NM‚”:øxÑÎÓ“碑+tlÏc3§ ÙF éß—Òòrn$#‹é×#�yM¦�433SÂNž=Ýݘɯ{öñí†M¨�vmùßÂ'ÐE³g²åàaÂ#£°·±afÈ8üÙ´ÿ [áɇ§ÓÕÏW°˜w²·kfÕ€©‰ ÁA�-fyêòÄô‡øiËvÞýîŒÄböìÁ{/<ËÇ?ýÂ_}ÆÏ>eVÈ8T*¡g"Ùq7gg^}òq�:�……9f¦&,Y0�ßöîãå+±¶´dÊÈôè܉Ÿ¶nççí;y|úC<6ý!N_¼Ä‘Óg016fPïž<>­Q¨ïÙ¥3Ûå_|Åúå3}ìhÁ¡S§ÙzøÖ––ŒПÙ!ãAk)þ¿…O²vçn~Þ¾µZM`€?KÌã”Ö ½%ÚyzàhoÇ?üÄSOgôÀ¼õÌB6î;À'?ýŒH$" };Þ}n±`ÇÙ‹çÌfóÁClÚ©L†£½ó'Od´Öù`xÿ~¤eçðÓÖíÈ :z{ñüüyl;|”MûâáêJ`€?o,zš ûðÁ?afjB¯.]„kKKºûû5»Þ;wìÐb=;´ï©‹fÏdOxgc®`dd„¯·Ë=%Ø­/œõ0[fë¡#H¤Rlm™31DȪ‰Dt÷÷ö•¬ÛQ‚©—J›Õå¶±²¢»¿b±æûgþ”I‰Ù~4I}=Îö,š=SxôíÞ�gçÎfWX8¡g#135¥_`w™:¹Ù:­--yùñGø}ÿÿcï¬Ã£ºÒ?þ‰»»BbDHp÷B¡F•Ê–vëºm÷W¡Þnm륰•mK¡H‹ÁB‚„$hˆw—ñ߆L2I&¥Ýûyž'÷ιçœ{î½Ã�ó=ï÷ÝÌÛK—!‰ðrs㩻﷛ŠÞ^v yEÅ|öÓÏ(”J‚ýýyæ¾{øfݯ,_³Oíÿ Þ^ŒŒgùêµ´´µáêèÈ3÷Ý­‹~½cÞu|õó*^ÿb)" ."œ×”·–.ãåO>çûwß"ÐLJ)£ÈÌÊæ½åßê„Ígo¹Y¹êíáŽZûN-•J‰Ò-Òéø}=,”ÿ®ßÈ“o¿ËÊ�ÞÇÛÝ�6ùåˆëgÿþ¶�/V¬l_XâäÄC·ßJœ6ÊqÎÄñT×Õ±nÇ.ZÛ6cnfF\x˜nì%1�>Þ]r� ü5¨khdé�«°±¶&:l¾^ŒŽÕFxwš¾8••£·Ý*“siŠÃßÇ›_6mÕûüÄ©³zn8Ýq±¤L÷·\ÖþÿzQ‡}2Eû\™±Ó)þÞ^HÄbŽœÐ·ÓîÍ÷ô¶ÏfŸ×ËØ&ë}ÁjSK‹N¼tLYE¥žã†¬íòóçïãMiE¥ND»@ %-� £ôßÉ�g^^Œèç‹Z­!Ð×?ŸËÑóR�…·J­¦ª¶ŽêÃ9rzC¯!djUŸ¢†z¸ãjgOE}ï+¨®&N6¶º÷-w‚R£F¦î9á¯;wwû™!ššIL=Âìn,ÃNåœï²Âôdöå^GƒB_AI MÍz“jµZÏÚÓß«gѨ¯˜˜˜0gÂxæL�R¥âð‰ 6íMbÍö:»­Í{“øðùgqï`=emiɇÏ?kT‹o¾‘Å7ߨc¥Jņ={ûÔ÷3.��Ÿ¯"c†cocC]c# ‹t–®aAC±07'fØåÜWË׬Óý=¥Cô`QY¹Nøþ�7™ÖÁ2víö�}êcG:ŽŸ»³“Ñã×GOžâÛ^ùá{ÝŠŽ~&4ÌLMÛ­¶ÜÝô"cOæät±Ç,­¬¢²C®/÷®ö\a|lL{>D¥RO”� ŽH$"FÛµZÍ7Ú\Et°Ek‘ÕÑJuýŸ¬Û¾áÒÜ�/ ÇC|}|Í“OæÎ')÷r‘@uZ[0•J17éŸ`ýñ¡}4i=Û.VÖ,™bØî±X̳»·Ç2,¼Yo{\l cc¢‘+”:ËQ©”z¹B�T"ÑYŠ¿üðßQ(•HÄbݾÎy¤ü<=yù‘‡P©T¨5š.‚Řè]~{^|èA½}÷ø¿jñ-7õz>h'²/Mf_úžíÈü©“™?u²Þ>G{».}p´³ãý知¹­ µ´ûè¤ØÈpb#Û'`»‹ú4”·^x¹\މ‰I¯‹‰nœ3“y3¦¢Q«õlb;bnjÊ#÷Ü…\.G$‡ÆÆÅ26.¹B�H$êRÆÒ‚§ø[·ý¸ûæÜqÃ�\®@*½ü|üú˺¿c£"HˆŒ@*jÿ©�¬íímmyö¾{º}&ÄÅ2!.VÏê`rB¼nñÛ?\¬Û®›ì¿DiE%r…‚ #c Œº>îÎμñäc(”JÄ"‘îš.ãU]l¬X,æŽy×qûÜ9´Éåº~u&c†6¼ËµމTªsFðqwçí§ŸD&—#•J»DùÍ›<‰YãÇé"üD"7͘ÎM3¦w“K8ÚÙñÂ÷ë").�iO¶âö66|¹äe½:ƒüüxë©'º}6 afjÂý7ßÈßnº™\ÞåY—ˆÅ<¸ðf¸å&½ïÔ—~™\®»¦Ã‡áýçžA&W`j"Õ»Ïý¼<»|¡zbâÈ8&ŽŒëò½} ©”{o\À=7Ì7Øw©Db°ÝKD…†ÕÁãC|}ôŽ“ˆÅÜ9o.w̽¹Bapçø¸XÆÇuý.0DDp0ï?ÿ¬niO¹çõûú2¹ÓNß·R‰„Gî¸�‡n[ˆB©Ò]£7žxLw�.9iÌŸ:…ë§LÖ{.áhgÇË�<„BÙîns©__¾ú2jµ©T'¼��‹å¡Û¢R«», ¼aÚTÝßVzcÙùûdúØ1LJˆ×�ϼ)ú‘ùVõ—»§Lí²ÒÜZUŠn‚’½¼fs'MèöÇþg?ý¬‹ò[¿kO·áò5k™?e&ÚcUµµ¬Ôæ0ºÍ¡!“Ëùfݯ:K€ {uÑc;äT/}ò™.·Ë”Q üó�û‘J$Œ�‹a|\ aC‡ðÂGÐÜÖ†F£!!êr2½€h-_ùô Ýöÿý}1�ÞÞFõgÿ±ãTu¢îYp½Á\1r¹‚¾û¯îEyýîD^XÜ>±&‹™˦½Ih4ö;Z{Q´9ï¬--ijiá Ö�Na~§<˜¡òŒ¨Ôjv§¤u>†HˆŒÔý}æB®Î¦ì9yÿ›ïtÛo?ý®Ž†óÄ ®[Ýðþ·ß3zDTñ¹¾©‰Í¬1‡k#+íì:D—§qÙêµÜ6{–î¾øäÇŸtc-‹7€ûÏÐ�++F†‡‘œžA›LFjf» ÊÈðö‰È¨ÐÄb1jµºÓ5»,„hmõÐþ€ ö»œ/«µ­Mwô‡„¨~Ú´@›?M®7étâÌY¾Zµº½m±˜Oþïùn£|kS3憿*m]k”Õ×bif†­¹q9f;³4õЀB EÇq{T4æÒžs‚ F$u™HDÙ×c&ÑÑNDLN8}ÉÜj�Mâ`_ÛìNì3„±cmL�†®¥±3!ß:�‘¡þ›õ:Ž]ÏG$Jç¶:_•L%=ß#½=�Ňîœ1:"W(¨¨©aõïÛðtué“[Dçs0$þŠD"ƒ]Gº\ËnΧ»û¼·¶�}>z«³?φH$êñY7ô�jh }ï”ÞΧ·¾ícÐó}Ü—~ôç{¨§öÅb1f¦â^Ë÷öty–Äbƒv·‰dÀ‹, µgCçÖ™«qü•hëÇ<εƜ)™6n �½ø:gÛƒJÄb¼óV£ëÐh4”WVé"m/ajb‚“C×WÇ©(Ê*ÚŸºº8éEîÅF†c*•r8-ýªô  ¢¢ —Ñ£ôý¸¹:S^QÕíqe•h€g^ÿ—^İ©‰ ¦&&ÈdíQ•9ù””—w[�€€€€€@Oá5‚ 2Uÿ<çGG³áða.”•QúÊãåìÄŒ~Š2•µTc0wáo»öè½L-yä¡n­Ïåæ±nG»MãÎäÃ4·¶ê,t:’‘•ÍÂgžã‘Ûo¥¥µ•÷¾ùžº¹ÉîÓZ âýo¿§¹µ•‰#ãÚ­a:F^nnL×Ú  3s2²²ÛÏ+/W'GÆÇÆbinFvþE�(0"4‘HDdp0‘ÁÁdfg£V«¹ÿ¥Wùàùg òóåì…\^üøS]�¡�½Zdvä’ý%ZÑèÍ'ëööÎäúü�:„“GÅëòÄ]º®—B‰XLTh‡ÒNè>³¶´$!ò²ðic­oqôâ'Ÿñø�·ÓÒÖÆÖ®ÓåâëFÆâëáÁÅÒRÚd2þöÒ+¼óô“øzz�~ö/|ô19íyFEEö(¢�D½~ÊdÖïÞ@úÙs$,¼ƒ[gÏ"ÐÇ ±XÂ…‹Y»c'¥•— Ü5ïrÞ—Å7ßÄSï¶Û¡ä\äÖgŸç©»ïÂÚÒ’Ÿ·leŦ-º²³Ç�û�¼&R)2mž¼3. R«u+œ/1)!ždmÔ¦îši#l¬¬êëKv~¾î3/77B.OHv´jÓh4<ÿáÇÜwãjø|ÅJÊ«ûAxÝÄ ¼üéÔ54PS_ÏâW^ãÕGÆÝÙ‰”ŒLžûàß”T´çí™7yâUþ8aPàjЦVþ%"® M¥ÂR*BÔÙ§í R^UÅóüs33ž¹ïž~ç[è™få+z¸º`kkCNnjµ3SSü}½©ª®¡ZUæí鎅™9y†#»9;SÛP¯¢Â†,Û'³r˜3e?ýºIIxýŒ):{öÁD¡Rãìtùw¸X,bʘˋSs/ÒÚÖÆ¼©“ùò‡ŸA+¬½ôÄßI<˜rUÂSÙçyÈΆ‰£G’”ÜnymkcÍ´q£Ù}ðp·Ç�͹€T"aÖ¤ñlÝÓ>w"‹ø÷«ÿ¤¹¥•Þù°½\öyZZ{·q0„ ^#´©ú?Á$‰¸gÚ4–¬øi�{Õ?îŸ>ÓàÊHcÐ2µ I×ÉÜŽÂÔˆÐ�E­¹“&êÂÖ¶6v<Ä�Ó§u)çîìLò‰t’Ot}9¼sÞuz‚TG\¨©«ç‹ŸWuÉ™&‹ùà¹gúµrÙ÷Ý´€7n¢¾© ™\ÎÿýûSƒåÄb1ÏÞ{Ù^èƒçŸåúGG&—“™�ÍÌÅïrŒ­µ_¾ò’Ñ׫¥­�mûê¶gŒÓãêÛ¹“&èÂÜ¢"2²²u–’“ã㻬 ‹Ó „1Æq¨C$Ú¸Øh½ˆ¹ð ¡øyzRPÒ¾pÇÁCì8xH÷¹�•�Z�ÊêTjµÑç)‹ùè…pû³Ï£T©HNÏ`Ò=]-ÖœøôÅUç[O>Æ©œ�°XQSÃç?¯ì¶ü‚iS¸uöLÝömsf±íÀAÝ9J;¡7>—pwvæÝgž4ªOñ÷òÔE(~ûëz¾ýu=k?ùˆ q—-ɦ$Äóβÿè¶¥‰^oÌðP= ÑŽŸ£GDá`gKm}»¿fûÖlß¡û¼ã5+­ê~%¥!,ÍÍyï™§xèõ7Ñh4ì<”ÌÎCÉ]ÊùyzòÎÓ}�?‚0(pµ�©U¨`}ÕW&$Œ$jxH¯Ñs·ÏŸG›¬ [ÛË \=Ôh�©T½æÚL|<<øä¥bkeuE"'‚�µ5ï<ó$..F”¸¶˜3a¼ž…¾Àÿ6ýI3Ø,¾ãfOžÀ¤›!W+ðõòàû�ÞaÙŠÕü°v=Ï=t?!�̺k±Á:Žfœäž[ð£’yæÁ�þŒ‹�C&—3qÔHN�Ë6ª/߯ù�qñ±|ûÁ[ìØwgGfLKQÙàG·�å›UkuÇü²ñw–|ø)ŽvvL7†â’2þö�)«¬`Ãö=ì?’¦×—“ç²ø~͆nûz:+›ï~ÑËÃié¬ÙºCoßö¤ƒìÔ.®Öh4¼üÞÇ|³j-¡A�LϱÌSÜÿ�—¨ÐºèTVÕ°ôÇUÔ7èçLüüûŸøpÙwøyz2sâ8ªjj¹ÿ/‘Ýa¼ÖlÝqU#"þZˆjÚZg¤A¢U&#ænÃ+²:S¶a�Þv…¬õV¡+U*îýøc*êû–˜ØÃÑ�É‘QDøúáªõk/¯«åd~I§2)­©íµŽŽyzñåÃ÷é˜ÎˆE"\ͬTGw$¦áögŸ×mŸÝº‰DÂæÄ$r‹Š°³±aæØ1„t9öéwßgåÖßïŸþ›ìü|¶8DCS^^\7q‚^®ºÁæTÎyNœ=KIE% …;[kBüý™82®[ÑI¡Tr(íYÙ465ãæìDBT‘ÁÁW¬ŸW“ºÆF¶&í'¿¸g¦ŽÅP_ŸA«_&—³ÿX§rrhimÅÃÕ…±ÑÑzÖ™ýéó‘Ì“œ¿XHM]=&&RœJlØð^…ºêº:’Ž%¿¸…R‰›³㢣 ò÷ëwŸÐæ.Ø{ä(Õ5¸8:pÓŒi8;8 ¨NCTÖÔ²uß~ŠÊÊqwqfæØ1óXö—æÖV’Ž%+/Ÿ6™ /77&ÄÅàí5hmÇ@sNÿîkªš›{,ãdiÉ=1ñÜyÕEÁÚ–&Æ-ÿʨ²§ŸzÞˆR4Ú Aµ`.*ÐGD€•ÄD°û¹Uå:÷-�•@­ÑP!kO ›Ù}j�Á¡zßî?º I‹Ñk¥F= q`WzzŸÄAks ˜5‹™Ñш;EÁù¸¸Ì=S§²3=�eÛ¶ÒÜ&3ªÞœ’bÒ.\ fÈ�>ŸÃ%Ô J�©èê¸ÚY[s×õs�(Ù•`‚ýû/õ•ð ¡}ŠøC›[nRüH&Å�4¢ôŸ{îœwÝ«ßÌÔ”écF1}̨A«ÓÞÆ†cÇ0clÿŽw²·ç¦Ó­?—ò÷°Èh .ŽÜ{Ãü+V¿•…×MœÀu'\±6®ΖVÝ „¤0(ð¿�\­ÄA�~¡Ñ:e\M«Q�+C‹ê��(‚Åè5€b,ªv?ftYG>è!fÇÆv;"‹™×?Š{¢ˆ¶?ntÙîP\e &�k�Û¢¢»ìs²´ä™q“ØrÏbEÇ â ÀUE­Ñ ÞQ€B£þÃóU  œV•ò�î‚€€€€€€€À€Âkå¢Uj5)YYœ-*2ª¼µ¹ïÜ}/^}ÈQæéèÈ»÷܇•‘9LR²ÎRPQatý†&Næ ç_3¯c¤·Ñž^<7a² �¤#GYüÒxåÕ.Ÿí?zŒÅ/-añKKtylþùÑÇ,~i ‡Ó3úÔÎ+Ÿ~Îâ—–°ÿ¨ñ ˜®&GOžâéwßcñKKضÿ€Ñǵ©…ÉÀÁâû5¿òýšîó!}³r5+×o¾ª}(I)©,ùàÝöë}ÆÞC)]ÊÉT*ƒ1¨÷ìå•O?¿Â½ìÊú]»yíó/uÛÅ%<û¯¸ûùÿC£Ñ�œv‚GßxëŠöá_Ë¿aå–­W´�îØ•|˜çßÿÈèòyEE,~i Uµí©¾X±’ÿ¬Y×ëqj*aÎB@@@@@@à/€àos Ð×˦¶VŽåäpøÜ9ŽfçÐÔÖjô±ÌšÕ'qð^NŽ<8k6oXßkÙ6¹‚>ÿ OGGBBB„¿¯yÕ:"¼l Ì efpèÝ�¿ J¥’æÖVƒ9ÐÚÏ:ÒÚÚFsk+ eß„±Ö¶þwµøî×õÔ54àêä„�•qy�Û'kÑÁÂÙQß�âÍ�¿ *,”ëgL M&Cú'³âT*”´ÊÚtÛ­²6äÊ®tj4(Ô*LÅúïÆ ¥‚Ö¶¶.å›ï~]OIE/?üwììðr»œÏkKÒ>Äb/?òwD"ÖVVø{ nßçÞÿ�QQQÜ4³Ý2ÝÓÕgûÁÏ{l …‚–>Œ»J¥¦¹µU—"ÂÍÙ SaÑŠ€Àÿ‚!�¿ ®_ÝQ”FZUÉ ÞZó G³st+ûû‚‡£3£»ÚµËÌèhVíK¢L»Z¶7JjjX8™õ‡“±27cjÔ›;Ϩc¯”@82<Œ�ß.×mÛZ[}ì³÷Ýý7¶'·¶´¸"ý¸–x÷O£Ñh035íò™B©ÄDjÜ«¤B©D"÷hmÞßãÚd2ÌÍÌŒªËD*E­VS×ÐÀC·-dØ�À.e[Z[±´¸ü½ 2–ð�¥JÕeÔj5J¥ SÓË¢�J5ÖÝ‚)�FƒB¡Ô;¦;ärE�åTj5jµºÇë£P*‹ÅHúxM‹yÓ¦ü!í^+ÈÕ*LDbƒbýÕ¦s®ê¦–†úùêòlG†|Eûp÷‚ë¯hýW’[fÍü£» p•iìEþ"á5€Æ ÉPWò*ÊI9—Õïv&GFõyb®#b±˜É‘¬Ú¿¯ÏÇ6·ÉØ”šÊ}Óf`eÞû$žú -з±²"ªŸÞînx»»QR@@@@@à¯Á_.¥ª¦–ûo¹‰Ñ#¢(*/ç›5ëÈ)¸ˆ«“#·_7‡å«×b"•²ôõ%zÇ7µ´òÁ7ßqâì9¤ ãâb¹ïÆÝ Wïýç[rò ˜=a<ùÅÅ?}FwÜÝóçé„@™\ÎÏ›¶pðx­2v66L3š¦OE,£ÑhxàåvÕÇÝɶý(()奇ä‡õuíý¼y “â™:zMÍ-ü¼y ©™´Êd˜™š=|8‹®Ÿ‡•­5`ÛÞýìHÚOð�â"#Xµa3 fMÃÆÊš×­ÇÑގѱÑlÞ•H›LF€¯÷Ýz3;’ö“š–ŽZ£!8À�ûn[ˆƒ�-¹Y»e;EE(U*,-,HˆŽäæ¹s0‘J)©¨àý/Û7ýí¶[X³i+ÕÕXZX0kòfMš ;Ÿ¢Ò2ÖlÞJvn>�W''æN›LBÌ]™³ç/°nËv KJ�H$ ñóeá¼Ùø B¬­­�×­gâèQ„ íäiŽed2câxü}¼HMKçÌù Ü{Ë�$:ŒZ£aúø±|·zUuu¤ež¦±©…;o¼,¥?Á®É´¶¶âçãÅ-×ÍÆÑÁÞ`?’RR°±´bǾ445áçåÉ ³gâîâ¬+wòlI)©”WV!Kðõáú™Ót×äà‘£ÈJ<\]X³ySÇ�b\üH ŠŠÙ‘´Ÿâ²rÔj ^nÌ›>/ww£Æ©¹¹…M»÷p:+"B†p묙8ÚÙuü±“§È/.!6<ŒU[¶RZY…›“#·ÌžIH@ÀåóËÎaãžDÊ«ª°²°dtôæNžˆD,fÃî=džË¢U&ãÓ~â¾›näÌùóœ¿XÈ]×ÏeÅÆÍä—`Z.åÓ~≻ïâì…\’Žå‘;n­Ø½yoÉié´Êd ñõáæY3ðÖF!ªT*6íMâdV6•5µØXY2jDó&OB$ñÅŠ•T×Õ“’‘ACs÷ÝxëvìÄÙÞ�I íBeSs kwì 3+•JE�Ÿ7Ïš�‡‹ …¥elؓȢùóøiãfròó±¶´bÖ„qLˆ‹ív [ÛÚX³m'ΜE$¡·­X¿ýÀAö=Nsk ^nn,˜6EoŒ;²eï>¤R 3Ç�eùêµ È„‘qºÏÛd2–­^ËÄ‘qŒJM]=k¶mçì…\LMLˆ áÆÓ°Òöãàñ4jëò÷ã‡õ™02{[[Nfçðà›õÚþaýFü<=uã& påþJ9¯Œ}µè;h„¯ßÀ*Âü|t¼ÚÈÈ@cES�þñÍÚ_õþ<žÖ¥Lg‹Ñº†Þür)Yyù˜›™ÑÜÒʲ_ÖÐÜÚÚÅž`ý®]œÍÍÃÆÊ …RÉÞ”ÔsþµÉd4·¶²yoégÏáêèˆR¥boJ*߬½œ¯î³W°+ù0æÄ…‡¡P*Y·c'ßýzÙ ýRŸVlÜLF/ ¬J%o-]FÒ‘£(U*†øú"RÒ3xý‹¯hhi@©TÐÒÚJIi9ÿ]½Žæ––öý*Uûþò ~û}V–ˆD"ò.òÖ'_�rüv¶6h4Î]Èã·m;hljæóïâBAvvøûx#—ËÙ›œÊ–]‰hÔjZZ[iimeùÏ«�)äX˜›ÓÒÚÊo¿ï ;7€Úº>üúÎ�ÏÅÆÚ ?o/*ª«ùö—µ¤�< @~aŸûE¥¥ :_/O²sóøðëo)«¬ìqŒzÂÜÜœ¢Òrަgêö¥œHçXæ)Nœ:£ÛwàÈ1š››uc“[p€�!˜›™àää@PÀå÷Õ399$&§0kò®›6™ìÜ<~\÷[·ý((,fgÒAV¬ßHtxÓÇ�¥¨¤Œ–.§¾¡€Ü‹ùâ¿?aafƼiS˜8fg/\à¿«/ç�»X\FJZ:ËW®ÆßÛ wWêùàëohjnfÖä‰Ìœ<žªšZ>ûö4F¼¤+”Jþ½ü;2Ïœcê¸1L�0†ìÜ|^ýì ƒÏŽ!Š+*8x<�/V¬$!*’EóçÑÒ&ãýo¾G&o·2=™�Í¿–ƒ‰TÊM3gÂÚí;øN;nþ^^8ÚÛamiAxp¦&RŠÊËÉÌÊ`¨¿6––8ØÙ@em-GOžÒõã›u¿²aw"£FDqˬTÕÖòúç_ÑØÜþœ¬þ};ví!<(ˆ;ç]GdH«ßÎîÃíù†bnjŠ«“!Ú(Å3ç/�_R Úçéí¯—“’žÉ´Ñ£˜7e2KKyõ³/©ÕFÿ645‘šžÁ‡ß~�—«+÷Þ°W'G¾^µš¼¢bƒã§ÑhøàÛïILIeLL4s&N #+‹M‰{õÊ­Ø´…_¶n#zX(7Íœ�D,á­¯–qúüƒõæp¾à""‘¥JÅæÄ$½Ï�Ÿ>CjF&Þîî45·ðêç_�W\ÌõS'3et)é¼ùÕ×(UíQÊ…¥e$ŸHçãÿþ€�‡;>î8ÚÙ±7%•‚’]½%•l?pG{ãf�ÁÁ�£€€€€€€€€ÀŸ!‚ðÀ˜I…ÁÀÅÞðjë¾àl{u~€^­1ø_D£Ñ°G;Yß¶í?HCS3îÎμýÌ“˜H¥¼»ì?œ½�k°| �/<°©„-ÿ–“ÙÙ\¸XØk;"‘ˆ÷ž{OWW’R�²lõ§g°pöL›[H;ss33ÞyúIìllÈ+*æÅBbJ*s'OÄ­C¾e þùàbœìñvsãŸ_Ì]ÿx€{Ì'Èß�¤#G)()ÁD*åí§ŸÀÇÃƒŠš^üè*jjØ—r”™“Æëꬨ®æº©“‰ŽŽƒ­gµÂ�F£á…GÿN€¯›wíaó®DJ%ÿÔîÛ´s7[v怒°€ ùh4ü¼½ø¿ÇB,³mï>ÖoÛÉùü‚.ã2oúTfNO›LÆk}JM]=9yù�˜œLKk+žn®¼øø#˜šš°qû.¶&&±7ù01alÙ³¥JÅ´ñcY8oKü™§Î°e÷^ß¾°Ï÷Ä”0'5 IDAT%"BƒÉ8sN79yùøxz�u¡]À”+ä],dáõsº;6.–ćñóò$>:J·_¦Pòôâ{±ÐFVµ´¶²e÷Þ.Çw¤ª¦†<´˜àÀöh¯ØÈ^~ÿ#%sÃì™”–W2,h÷ß¾Pgï)•ŠY³y›^=ù…Ezõœ;Ÿ‹‡« ß}§®?î.μÿÕ¨­oèU¤9|<�òêjÞxö)ÚËÆ†‡óòûÿfç¡dn˜6µ×1(¯®fÉ£ë¬qíìxåÓÏ)«ªÄÏÓ“µÛwäçËó‹ÿ¦;?;kVlÚ‚iS1,”´3g‹ÅL=ªKý£¢"Ùwä(ö¶6?/©¨ )õ(�Þy;ãbcˆ Î#¯½É±“§˜<*�šúznœ1�ùS'·×9"м¢"Î\dú˜ÑLNˆç÷}û ôöfLL×ô©™äóöÓO¨�>="Š'Þz—mûpǼë@k¥›ɼ)“ˆ &íôr ðî›™•ÍÙ ¹‘®‹~Ì/.æ…îgİö¼�G{;R3Nâçé©«×ÑÎŽð ¡=öI@@`p‘ ùþBáÿƒ!ºi®Pn@�þóùŠ•T×Öé¶'�Šgb‹»ÎˆD"î¿ùF½}Ùyùì?v¼ÇvNåä01>N7¹=oò¤n„¨HÌ´¹ò‚ü8™�mT´T|dž®®º¶ÖnßAM}=9©©¯ÀÎÚšc§N뎱03£U&#+/_O ¼yÖL¢BCzlïŒVà‹ ÃÇÃWGGÆÆF³ó`2Y¹¹z¡§«+ógNëR�µ•%¾>¸9·[ZÚZ[uÙ'×FdŽÎ'áé©«'íäir/r,£= ïRÔfGÆhs33¼==¨©«§¥µ €œ¼vA162B—Ÿpʸ1xyz`ªµtÍÖŠu*µŠý©G0Õæ—ÌÉË386Ûöî'ëüyݶ¿�7 fÍèR.|X»¢¦¶ŽVYm­m̹a>߬ZC›LFîÅ"J%¡¡=\ }‚ü}ub€••%ê^Þg�u¢€½­ ‘ÃB)ÐF•�ËØ‘í"ŒL&£¨¬ŒŒ3çP«ô#Bœôê ÈKO<ÚkSZQAjZûµ2Æ!#7¿ÔU55ºý^îîœ/è]4¿„…™™^ÞL[›ö|Újµ�FC~Q1w/¸^/·á„¸8~Ú¸™‚âb\ŒnËç/"‘Ht–ÃÖ––|üâ : àÇîºC÷Y]c#çó (*/7:÷w^Q1Þîn:qðR1Çu‰Œ>L÷·‰TŠ¥…ênrµçaceE|d„nŸ…™ ‘‘¤�9«;?‘HD€�7ÕÕºrÁþþlJÜÛëï©È�`l¬¬Ú#gL§©¥…̬l¹óööú ð§©¹™&mÄ¥‰T‚³ƒç .êB[k+�8ˆö;{TT©™,œ=�Fá´Œ��P �¾¡ÖhPtó# ðgD¯D"ÑU‰˜«¨«ÃO;ÙÖ_*ë­?=ÑqRC@@@@@@ gr/RVU¥ÛŽÐZöDçè ‰XÒ«@ØÐÔ>¡íÔÁ•Àµƒ×©D¢û[„ñÿ·;wÈ1'‰pvp ¦¾žšúz��ayuµžíè%êõßUlm{mïR�Î�rÛÙksÃ5jÏûv¶6ëéx¾—�Øw ¹\Áò•¿�©�¼³²²ÄÎÚšºÃï[RÉe! ³(Тµ;µ³½,ÂØX[ZÛÆ6™ €½‡ºF�646wÙPR^ΙœËÖŠânÎ'(Às33Ξ¿€\¡ÀÇË“ˆÐ`DÀ…ü‹ääåáíáÞ';D+++£Ë^ÂPýNö—‚6݆í;I?s–ºúFœìí±¶îÚŽ½�þ}£R«ù}Ï^RÓ2¨¬©ÁÎÆ—îýÎT×Õr¡ €ÿõQ—Ï‚ü�®Ç¦‡1ilnF¡TvÉihme‰™© ÕuõF·ÓU5µØY[w¹¯;~'ä³ú÷ídåæ¡T©ðvwC܇wûšº:ƒyííÈÕFß^ÂÆÊÒøzµ‘ž�g8uxî+kjP«Õ<÷Þ‡ë�Éå=¶!‘HIjÆInš1�#™'173#.<¬½þÚZÊ*«8’y²Ë±-O8pm5"’ß÷í§°´ ™\NYUâº_" 0øö¢5�ðÀØŸË&’�­=UPÀÈààÕq2?@Ç‹�<Û¾L" ü¯³pÎ,½Éå@Ÿ+ÒŽƒ­-UµµzVM}]�Çô‡ŽÑ��†êºöm'{{L´ÑpaACyúÞ»»kjbÒçö.‰.U�Ú­¬m�ô²²´Ð+?Xë˜ö§!óÌ9\��X|ûBü}¼Ùs0™Õ›¶ö¹.smDgG1³¥µ•ü¢L¤‚ü±±¶¢±©™'ï¿—_ïj»ÌØ‘qzyí [ÖK%Bƒ†pîB.*¥’�ÀLMMñ÷ñáÜ… ääÑK$ggDýX?×ÔÔUèlllÆF+®X¿‰óyy,ºñ‚ý155%5-�×­×;¦³ˆô{b;ödÑ�󉂥…‹‹yëÓ¯Œê—�µ5qQÞ¨T*ÒOŸéW]CýÛE¼Ô´ ]¤àޤý|òŸïtyû|<Ü8Ÿ—�¥…–¤�<Åò«Ø¾w¿ÁzC‡0!!^÷/<´ûÅf¡ÁœË9Ov^>AZ{ÎaC9y6‹‚¢b"†õM 쥕•”VTê¶år9'³²ðvo?÷ÜüFE� <4Xg¯ZU[Ûk½¹<”„˜XjmO«jŒǽÜÝÈÎÍC.Wèö)U*>^þ)ÇO „Ig‰XŒ§«+Ç;Xï;u�Fƒ¯Ö>w øxx “+8}þrT©B©äÕϾ$5#ƒÒŠJšZZ¸yÖ |==‹Åh4ªjzc]îî\,)¥¢ƒ«B©$ãl¾Ú{¸¿}¯mh «ƒ�®Z­Ö³*öñð M&ã\®¾åî/[çÛu¿ÕNH€?Nööl?p�3r™”0²Cýîdfeëý&knmeɧ_�™•Ýc½"‘ˆ„¨HRÓ39|"C—#Q@@àê1ßÕ×Bá5€T,F©ê]¸‹Å,ž9“Å3gr¡¬”#YÙ>w–ìâb£V—ÖÔ²3=�Y11ýêç¶cÇ(¯ëÃDˆ“#ñÁ¡Œ !Âßß íVwHD‚v- p­qݤ‰ìM9BqyO¼õb‘ˆæ–Þs ö•ZÍó|„‹£¥Z¡pBüH\qqp 40€s¹y¼ðá¿ òóå|ÁEj1,/7×>[·��‰fûþƒ””ðò'ŸããáNaY9r¹g&ŽŠôsðöl�þÉGtxææfœ8u–IP©Õ”UVáîâl°^OWW§¥sî|.Žvd_ÈeãÎ=”•WêYlbÜÈ8vHæËÿþÄœ©“1‘Jضw?Å%Ü»ðfõ ¤˜;y"KW­æ§�›=‚¢òr~Ú°‰èáÃtÑf"ÔÖ×S^]�³CßrûûàÏW+WqÏ‚ù8ÙÛ³5iJ¥’„¨HÌÍÌ0‘JÙy(™ùS§ÐÔÜÂæ½I–•aanFk[ææˆQZYIM}};Ñ1Ñ#øm×n>üæ{î˜wf¦¦lÜ“HSK 3Ç�ë÷Ø\ú~øä‡Üuý\lmÙ¶ÿM--Xhó'x{Äç+~fÑüëqwvâøé3lJLâÉ»ï2ª‘HĘ˜lÙ»?OOü½./ؘ5~û�㳟~fÚ˜QˆDbÖmßAmCAþ~=Ö‹v1ÈÖ¤}H$ÆÆDëöùó*NfçðõëKú56½£ÖhP‘sV@@@@@@@àτ䅗_zí�îÄ_¥JŲõ›Œ*û�ÛêþVhÔ}^‰æhmC„¿?³ãâ˜Í–£G�ŠÌÌÏcBx86½–íHAEï®[ƒBÙ{?]íìùü¡‡¸{ÊTFááèØ%ONo˜I¤˜‰»·ìÝǶý�J$xº ,¯â•dWòaNeçª]EÿGR\^Á÷¿þFxpP¿ì×JN~+6maTTäUo»'§g°ÿè1"Cf¿ÛŸþ¸';»^'¯Ž�<ÅŽC‡ˆ6 €e«×`"•âîlx²´#›“ؾÿ&&R<˜ë´/Ô54°ì—5úxcei|¢Á`ãžD ËÊâãCV^?oÞzÕï&¾^µ//¬û�‡éjÑ$kÃD*ÅLzõ¿s®m 9ß?jTÙGG�½âý1†ºÆ&›špurê}RßÔD}c#nNNŒ��A$‘[X„��5Q¡!¸99aeaA �ÙùÔÖ7`kmÍ}7ÞÀ‘Ì“HÄbn˜> €¼âbl¬¬ˆ ÆÃÅ€êº:Úd2ü½½ºýÎÝwä(UµuÌ™833Sr ."•H˜˜Ͻ7ÌG"‘ ‰ˆ�§¾±‘üâ".––afjʬñã¸ÿæ‘hß9²óóqsrbdD¸¾-¡FCN~nNNÄi?“ˆÅŒŠŠ¢±¹™’Š *jj�HÄÄF„³øÎÛtÖŒ5uõÈå2|¼<4TWeSs õ õx¸ºў󯱹…ú†ÜÜ\‰�Óîk¦¡¡77bÂÃÚ#šêê)­¨ ¡±‘ˆa¡Ü|Ý,173#ÀÏJËËqqr$~DR­UaYy%R‰˜ @ü¼½°°0',$ˆòÊ*J+ª¨khÀÓÍ•E7.ÐEî¹8:âçåIQIùEÅÔÖ70Ôß�{ÞD𠼫X˜›QU[‹¯§qÚïC;[ò ‹ˆ�Ó‹Ä,­¬ÂÚÊŠm»õ ìK9Âñ“'™6~,¥•8ØÛé"#ÑÚ§667?"Ê`ûgÎbmiÁðà¡üúûN=†‰TÊßn_¨'ý|9u.‡Í»öp å(¶6Ö,ºiGÓ3IJNaÎÔITÖÔ`jbBXÈå\ž�¾Þä±yw"‰£P©xðÎ[9u.›¤Ã©ŒŽ‹A.W “Ë‰Õæ}Ì+*&Ð×O7WÌÍÌ "ãÌ9~OLâð±4¬,-¹ÿö[ðpkFjë@£Ñå«ëLM}= ¥’øÈÝ>…RÉÅ’â#"°¶²ÄÏË;v'fë¾ýœÌÎ&!*’o½Egs©V«IL9Âïûö39!ž–6�†Ø°á——ãìà@°{nƦ&šš�H$"6<Œ‹%¥lؽ‡=)©ˆÅb¾ã6ü¼<151ÁÃÕ•�±aw"ÇN�fâÈ8FF„³ó`25õõŒŒ§º®žÝ‡S8šyŠÙÆSXZ†»³ C|}0‘J‰6Œ³rÙ°{ûŽÅÜÌŒÇ@ŸvkÜÖ¶6Ê««­÷^{áâEBñpué2~b±˜˜°á\(,dËÞ}ì?vwgn›3›–¶6´ã>œ’ŠJ6ìÞÃ΃ÉTÕÖq×õs۾ȲM.£´¢’Q#¢073£¤¼;[½÷}{[Ê*+™9n >"7m­­ àЉlNL"9í®.¬Ë98ØÚ²7õ¡þL• Û±¤µF­ë£€À_™Ú–f,ûo7Ü_äm*¥ÁϾZñËUï�€€€€€Àÿ/Ü×5ňÀÀÕ´µ |©ª­21w/6ªlÙ†u—�S)¨WÈÔö«VrðŒq–Tî¼{Ï}x99U¾ ¢‚—úÑèèÁÛ'Là¾é3Œ*Ûv&æXH ¸Ê zõ ZÛÚ àÕÇP[ƒÉú]»±µ¶fêèöUêËV¯¡®¡‘¸@õ®Û¾'G&ŽŒëwçróxý‹¯øòÕ—»¬Ô¾¤¤gðÙO?³ò£÷¯zÛ=±vûŽŸ:ÿþñô©ÿŽgŸç‰Ew2ª›ÉÌk‰�{I<œÊ§/ÿ¾ò7͘ÎÌñ= 2¹‚‡_}�V™ŒaCYòèÃW©ÇPVYÅÓï¾Ç;Ï&r…©DÒ§…”µ <þÆÛ<¶èÎknÑ�€ÀÕ"·ªœ@竟³I)§Ii8º?læ‚«Þ�ÿ5ª÷íþ£»ð—D°½0é!RÎXæÅ'-–ÕÖòØ×_ñàÌÙÌŒ‰îöG©R¥bÛ±c|»k-2ãl®Äb13bžä‡ÊÇOŸ¡µ­�É ñ$9JeM-.Ž}³GºRœËÍÃÅÑ8áµ/œ>ž€6oiDa�«ÌñS§h•ɘ”0’}GŽQU[Ûg˲þâîâ̪pUÚꉱ1ÑzV_WŠÚ†zR22yô®;®x[†8rò$¿íÜ��•÷Þ0±XÌšm;H1ø“ÕfÚqÝ!‰]Tk4˜™—3y°�ˆÅƒ*ÔI$z{»4½B¢Ç@LH$u+b@ê,ÖuG籉D}îww¢” *�zЬö-z8cî‘Þèí¾íÜ~g¡ÍáM*‘@Ò‹1¨X,ÆÌôÊ è}¹o.YànÚ“ˆ•¥….ÚS@@àê!Ø‹ üÂk©HŒX$2*�`wDB˜¯/§/^4ª|s[o\ÏÊ}{™E˜Ÿ/.¶ö¨QSY×ÀÉü|öŸ>Ù§œƒ†‡áåäÔϳhG,!íabäÐñøyyró¬$9Êáôt®ï�7§ ¤„ªÚZBILI¥¼ªW&'Äc©�ðËÎÏG&WàãîFbÊjêëñóòdRüH½ ƒ’ŠJ§§S×Ј³ƒ“âã°³±1دäéíÖc {SR™ÜÁö§¶¡�=É)445áãáÎÄø‘z“…¥¥¤ŸÍ¢²¦+K b†ä×n©uðxu �\,-eßÑcÝFªÕjŽ�:M^Q-mmíöq±±ØjmÑ:s2;sS3ìmmÙqà £FD1ÔÏ•ZÍ¡´œ/¸ˆ¹©)ÑÇ1lH Þ±…¥e¤Ÿ=GeM –Ä„ ÓÙP]"+/�#™§P©TD††tj;…BAL§É�ƒÇÓðpi·—2„J­&9í9Ýô-+/¥R…‹£‰)Ghim%24„¸ð0²òòIIÏ@$‘AH€¾•ZCS{§P]W�§« “:Ü/h#ŽŸ:Í©ó瑈%„ eİP½Uùµ $¥¡º®/7W&'—³*ã\ç²PªTz{3..FÁPרHÚé3LNˆg÷áZZ[™?uŠÁzÚd2ö9FaY¶ÖÖŒ‹�Ö³ûlmk#%=ƒâò Ô ~^žŒ‰1àˆ•Ci'ðöâ–™3Ùwä‡Ó3˜7y’îó‚âªê´ÏäáTÊ««ñÔ>“Ú1ÎÊËG¡TàåæFbJ*µõ ]žÉÂÒRʪª‰â÷}pur$6<Œä´ÄGF`cÕ~¯çqôäišZZðpqfbüH½k©T©8x,�ÜÂBÌÍÍÞkÞ…RÉþ£ÇÈ/.ÁÆÊŠIñúÏaEu5§rÎëì¾ÒNŸÁÑÞ© {§0eTÞîn(”J;N~Q1–æÄGFêìÒ.Q]WǾ#Ǩ©¯ÇÝÙ™ #ã°µ¶¢°´”ŒsÙìMIeøÐ¡˜˜\¾ve•Uœ>ž‰ñ#õ"`ÎåæQר(D 7LŸF›LήCÉ|¾b%h'ùçNžÈÂÙ³þèî }µ}Läj5’k'ªSàÚ ¥µ•¼÷&R)Ý~ë E tfV¿/álI#õ­Š^Ë;XšékÇ­ñÞÄø]û)‚Òˆ”.6„_צbI·~öÆ ÖhhSôþß™òº:Víß×ïv;£P |RÍ´‡ˆÊ¦æ2Î�ã¶¹sp´³#$ÀŸä4}�0ã\ŽG©Ranj†ƒ�-‡ÒN°ûÐaÞxò1l¬¬HIÏäüÅ‹TV×àêä„…¹û�#)õ¯>ö¦&&œÊÉá_˾!ÐÇ7g'v'fÛþ¼ÿÜ3ú¹Œ.µ{öuM�´´µ‘šyR'V×Õ³äÓ/tyI~ܰ‰S9çyúÞvßäô³çøðÛïñóòÄËÍ�üâ6îÙË“wßEBT$'Μ¥¡¹…RÉÑÌSÝ „_¬XɱS§‰ ÁÜÌŒ-Úœpï=÷¬ž8r‰=‡S‘J$œ9‘XDxp ¥’w—ý‡¼¢b"C‚)niaóÞ$L›Â­sfëÆ÷ƒo¿Ç×Ãow7òKJظ'‘ÇÝɘèì8xˆÿþ¶o77Ü]œùòçU¸u°´-)¯à—ß·±ì�WuBiIE%_þ¼Š>hئ÷Rßr ‹ˆ ¡¸¥…-Iû¸~Ê$n»n‡ÒÒÉ-,¤±©™ðà ŠË+Ø•|˜±1ÑdååJv~;â�gžÄÏÓ€æÖ^úøSìll°±²âÀ±ãì<˜Ì›O=Ž�•�†/V¬$5ó$Q!Á¨5v8Èèè$;;6ìNdwòaÞyæiÌLM�ɼµôkŠË+ˆ¢°¬ŒÍ‰IÜ:g ¦M5xŽ ¥’·¾ZÆ…ÂB†¡¨¬Œíbgc�½mû‚�ÜÂ"¾Yû«N Ü’´wggŽdžÄÒœÑ#¢hmkã�/¿¦¢¦†ˆà òKJØ”˜Ä¢ùó˜=a>l;p€­Iûxó©Ç)­¬";/€ÔÌ“8;8àå~Ù^I,óíºßp²·gD‡ûä?kÖ,„ƒ‚T"aÑüyÜ>wUµµˆÅbœìí-úí�…· “Ëp°µ”úúƒBˆøÓ2oÚ”ƒð.úG¢Ò¨Ñ AÄÕ±·øs`ceÅÿ³wÖáQ]éÿÜñÉÄ]!!$@ Hî.m)RjÛv·¿*»ínu«»Ým·Þ­Ë¶…R£”+îîœ7➌Ïýý1“!C‚ö~žg˜;çžs®œ™›ó=ï÷ýä/¢Õhе1—¼„Dk°ÙDþýÛ)Ö+hÓ~¥5&¶œ*bË©"îÜ�ÇÇF·b¯ëQŠ ”������¸A‘Âkõ% „›’Ž�zî\»öébØyê$'²²èÞáâsa©›÷$%aE†8„¨�½{ñíâ¥ät~¢<'¿€1ƒòÀmÓ�œü^þðc¯ÛÀ§Ý ÀÙŒLn›8žãÇ�#ÊæµÏÿÇÚƒOY´ IDATí;¹eôHÖnßI·èh^šõ08"Ìž~û]Ž%ŸmÔJpÖÝwòæ—_àë˃3g8·gŸ;Ç‹�>L|l !Áü²z-V› ¹LÆÚ;éÒ)Š—g=âŒF{ý‹¯Øs$‰½zòø½àߟ~NTx8÷M�Òèy)¯ªb÷‘$fÝ}'Ãí¯Åeå<þÚëœÍȤ×|uì:|„gÎpŠË6m&='—ןü¡�¬Û±‹o—,e@¯žD†…±~çnb:vàŸ™åìï›_ÍfÏ‘$÷éMEUó–­`Xb_fÝu‚ PXR‹ïäl·¯x¾[úGOŸ!1¾;ÂÇÓÓyž.dÍö¤çäòÆSsFÅ­ßµ›¹‹–0 W/¢ÂÃÈÈÍãͧþFDH6›�'ß|‡¤Ógxï¹gðòð Ö``Ö+¯rülŠS ,.+gÊèQÜu³]hLÏÉá•�?cÉú�Ü7u {“޲'é(/Ïzıxøä)Þ™ý zõ$±Gw¾_º •RÉkO<Ž�§'V«•÷ç~GFnn£Ç�’™Åší;¸oê�è~ÂÚ;�"›(Фåäðé?_jrRháÚuÔêõ¼õÌ“øxz"Š"o~9›…kÖKÒéÓœ+*æýþN°¿¿ýú:̧óæ£7œ‘|meÏ‘$EÕ“ß-ù�¼ÂB—èÅìü|Æ Äý3¦!ÙçòùÇGŸ°xýç}�œ‘Áí“&0mÜXN¥¦ñÚçÿcÝŽ]Üš@|¸'Çr+h‹ÉÑ;3 óÑ2½oèåëèUÂ*‰ƒ7(’Í5‚F®¸èu«•7onç]_FEî¾yr“¹¢r É+, Ch³_Õyí�<Éð~‰Nq`òðaÈe2ŽžIn´ÎcÉgéÓÙ)Œ:¤ÑHâút‰ŠtŠƒ8Æ×è�œâ À-£Fb³Ù8žœLyUé99Ü…\Îö9q6…üâb²ÏÙWnv�éÜê6d2Õµµ¬Ü¼•ôÜ\ò‹Š)iEþÈ  ¢? Š‹)*-u‰öÃ!BWTU T(ؾÿÇSR)pôWE§SPR‚»›¾^^.uD»¼Ø«?¯Z�Ùb!#×n-9ÌýØùEÅ—4è›Ùb¡¢ªÚù^«V»|.4²íB5ÞÏÛ»A™!!8vü|Û%Å Ú..+#ÈÏ—ÒŠ ,V+BC\�9Äõ˜/¤¨¤”`ÿ€Ö|a��öȼzû7�ÛÓbµR\^Nh�kô[Tx8QáöüvJ…‚´ìlv>BnA!¹ùÔèõÍö¯% KJ8›™I`#crç¡ÃÜ6q¼s›‡Î­� Ö14„mû8Çd Ÿ_ƒœ:BC\ÄFw77ÜÝÜšìÓ½·Ná“æñÌÛïL�˜ÎŒè—HÇ0ûJæüâ*«k8�–(ŠÎ{¼±ãìÝÕÕÚU.“µéT_ŒµÙl–”°ãàa§Ø^‡ ”WWSP\ Ð Š±o÷îͶSŸ�½{òÓŠ•œHI¥GLgvIbâ°!­ØSBBI˜‘¸F°ˆ6DQlðÌ(!!!q9H+¬¡ÖhŸ‡sþoŽ!1~ÎçôÖRPi °ÒH°×Ź“\«Xo@ÑSBBBBBBBI ¼¶ÐÊ•mM3?mm¿‚íÅìõkéÓ¤Ø×Z¹²ÉÏv:„ ŒÐÏEPñòð`ÇÁC¤deÓ±#€3:¯>«Õ%:Íbix®-V GdRDH0ÿ}þY’329–œÌ¾£ÇÙ´g/Ü6�1ƒ¶ú˜Z:ïÎþ†Ì¼sŒ:˜áý‰æ×µk)¯l\¬hŒÜ‚B^úà#:E„3´o‚3?àý/¼Üì~Š1r¹œž]»0¨w¯eë¿÷æÌ%='‡ñC†0<±/áÁA,Y¿‘"‡H­Q©1[, &¶Ì×™u6£Ç’Ïrôô"ÚÔär9ñ±1N+ËÆúv±˜w«­Öþ‡­B!§s‡LtØ€ÖÇCç†FeÍf×zÌ�ÜcõQ* Î €Õfm!§P4ýu-‚ `³6=±½óÐa>›7Ÿa}= ?aÁAÔê ¼úÙÍö±9v:ì“õÇ–‡»;»&5+Û¹fnbLÖ?ÎÆÎ×…eš;8"�?þÇ‹œLIåxòYö&eíŽ�<}ÿIè‡\.#±Gwúöh(ºù7aM§V©05Ò·–®oý¾ ‚€\.c@¯ž�ZþúùRYmº­—ÁäïãCt‡ö&E ¢ªŠ! ]Ÿ„Äï É^TâZÂ"Š(%�PBBâ Pc´8…ÀÙ[Òyñ–® ÌÞ’Þf� Æ`�KûóìšCŠ ”������¸Q‘ÂkµLŽB�µiõzê¹|Š++.k¿.†ôüŠ*Ê òn}® … k6ÿà΃‡‰‹ŽfÚØ1.Û+««Ù}ø»q „£‘¬¼s.]§RÓðwŠV…¥¥”WV:íJN§¦∠Z½m;�;v KT$]¢"¹mÂxÞ�3—�‡·I lŽâ²rŽ%Ÿå©ûÿH?G.>ÀÅþ²5ì>rµJÅK³qЧcà�Ùlv±D5™Í,^·ž! ”UV’tú ûã½ ìÕó|ëµà�Ñd"='—NáÎígÒ3\Úòöð [§(v>ÂÑ3ÉL;ºÙ¾…øc¼ of‹…Ek×3$¡¡hØÊ**)*-#À÷üýz*5�0G$W°¿?y…….m‹¢È’õ‰ë�»Î �VË©´4ºÇ9Ë$_pÌìïÏÖ}¨Õëq«gšœžIH@ëópÉe2‚ü|IËÎfä€óV®;bÙ¦-¼ýÌ“lÙ»Ÿ>ݺ2ëî;�Ÿ7e©ÙZê,§^0&Ë«ªØs$‰�‡;B½Á@ö¹|øTj!õÇdI åUU.Ñ¿§RÓœc²%Ì «·m'!.ŽøØâcc¸ó¦I¼ôÁÇì>’DB÷8Bü0[,.×Ro4²týFÜúp:-ÝEô®ÕëÉÉ/h1J´Aò÷Çf³¹´]][ËòM[3x !þ‚@jv6aõ¢Aüm9•55ü¹ÞµkŽ�½{±lã&Lf3q�£›>%~Ÿ¬ßµ›+W;ß?óÀý.¹4_ûüdææà¡ÓñÁ‹ÏQQUÅÓo½ ÀOý�@¿¦#š/äùÿ~@qiÌœÑèâ“öäÈ©Ó|úãO¸i5|üò‹mÚ÷Á—þ Àãÿw�Zié{­ðêŸPZ^Á=Óo%±W|+öh_žó Ý}Ý»4žGøZà_ï}Äèaƒ> »Ô7û§hÔî™qk»Ôw!Vц² Ù íuî˜<‰¡}oìE!©YÙ¼ùå×¼ýìSøy{óÉóÐj4.ù¿›ãÐÉS|>o>_ÿçßW5BsÛþüºf-Ÿüã¥V•·Úl<ò�ñØ=w;ó KH´"8s .?œÇG.Bs3‹[ªïFCŠ ”������¸Q‘r^c¸+Tm*ß%<œ‘=®üdPKL4¨Mâ -{Fn.9�æÿótw'®³ÝJÏV/òæ›E‹©¬®AE¶í?ÀþcÇZ/’Æjµ2gábôF#V›�å›¶p63“±ƒ�#:ꇥ˩ª© ¼²’s…Eµ01ÚaÎM«AòÖ‚{Ž$qàø‰ÑIÍÕ«Ój1šŒ”WV‚CŒ™ýë"hE”S}†öM`÷á#N{K³Å¿-gíŽ]øxz¢U«Ái…°ïè1ö&u¶Ó-º¾^^ÌY¸ˆÒŠ DQdoÒQö&mÐÞÀÞ½Øyè05µµ-ævÚ7�½G’ØuØn7i±ZùqÙrÖlßá"ò^,³.¢Ö`Àf³±nÇ.ŽžIv FCû&�œ‘ɪ­Û°Ùì¶_¿mÜÄâuëq×¹9ˬ߹›#§ìù KK™·|e³“OýâãE‘¹‹—ÚsOŠ"kwì$9#ƒa‰m›àÚ7�­ûp,Ù.ú•WU±dýF§§sÓRRQá¼N…¥¥,Xe ‹Žk‰ôœò ÜÈuóöð .:ºÑ1YUc“[öíçÀñ ©7‘iqŒIƒcLþ¶q3©YÙŒsŒÉ–P*¬Þ¶ƒù+W9so—PR^î·Cû&°ýÀA?a?v³™ï/eãî=øx5~ MèCö¹s,\»«ÕŠÑdf΢%öü›m`hBwïáÈ©ÓÎÜ�s-a˾ýøxzâ®s£W×.,Z»Þù��–�Æݻéâj_kjæû``¯žTÕÔ²óÐa†×³íÝwô}÷Åe-ÛKܸ˜ÍfjôzçëtZšó3“ÙÌ™ô —Ïq,ˆ¨{o³µm‚L¯7P£×·é·èb±Ú¬Ž~Ú¼oÝñ]JïÕ¢Ö` V¯wæ½âíë�öö¯ñs§7Dú_ ~~øù¶íY·-´Õî¶FohÔA£Žgßy�Ek×·CÏš&éôîzêÙfó__*6›�½ÞÕäïO@½¼Â-a±XZ´XÏÌËã®§ž%¿¨¸Ùr—‚‡NGÇа6íS£×7ê€RŸÇ_{ƒ•[®=w‰kŸúyM+&‹µÍùë^7"7b^E ¤Âk�\�ÂÒú(B™ ðÜÌ™l9~¬Ér]ÃÑ '³³/ºo‚ Ð?6†ü²r2 ›,7uà �4¹Mu+yӷ㎃‡Q*ôïÙ£ÑÏ÷éͱädŽŸMÀÛÓ�ŽYÿzµR‰ÞhdpŸÞL>Ô¹OxpU5µ<ôÒ?Q(˜Ìf& BB\7î™r ÿ�û-�¾ò*^îîTÖÔÄ“'6ÙÏ__¶ìÛOö¹|ÞzæÉ�ÛM£áÖ1£˜¿b›öì¥Vo 40€Qû³~çn¾]¼”?MŸJ ¯/Ûö 3/�ÿ<ñ×õŒè—ÈÆÝ{xú­wð÷ñ¡¤¼‚›F '$ €ï–þ†�§gËÊÆÑ/‘ÌÜ<>þas/Ád¶ PÈùë½p aÓÇ�eÁª5lÙ»½ÑH°¿c díŽ�ÌY¸˜n›Î_ﻇ÷ç~Çc¯¾ŽV­Æj³1}Â8~]½Ö¥½þ=ã™»x)½ºvi�ŸîB†%ö%#7�Oü‰ï–,u雇N×â±5G|l f³™‡_~¥R‰ÑdbÒða vØ™ÆuŽæ®›'óÓŠU,Z·ó3gÀ“'’“ŸÏÛ_ÏÁM£¡Ö``ìàAÍþ‘ìïãͬ»ï䫟aÏ‘$TŽ{uÊèQ ¨¡Ùn=Ьsù¼ñ¿¯ñÐé¨5 ä�Sí‘ SF�â�ÿ}ÅŸÿõ:5úZ¦�GfÞ9^ÿüK^{âñ6µç“ñ��ÉA}zñõ/ 9‘’ X7­–G_©7&ú0qèùüx!!TUWó`½19iø°6­”¿úT¾˜¿€_~O�ŽŠêjºvŠâ¦Q#7dYçÎñþÜïðÐé0šŒhÔžøÓ½ sU:è×�[ÇŒfÉú�¬Ü²«ÕFDHH›£4&�vþÑj±X,è´ZžüÓ}N»ß‡ï˜ÉÛ_ÏáÉ7ÞÆÛÊêjúöèîüî ðñEž{ïþpËMtrDhÖÇÏÛ›˜ŽÉÌË£_Ïó Hrò Ø“t”éãÇRTáï™L†(ŠœÍÈtnKÏÉÁjµ"—É\Ä/f¿þ*8~·ÃêX<¡¸à·æÍgžDEÔªÆY¬ö Èú–×f‹¹Læb]|a[6›­�Mv[1�hš÷õ1�¨[QÎd2£T*š\"Š"f³•ªi;õúu5W®¥¶Î—3¡T*[,ךsÚÔyxû…gvÌÓ~KÇz­r넱—µ~ѱ"—lF›åöI®v.Š>qÝèãø›CBâjàÆË·ÆµXnñþNä¶Â½èÓ$�PBBBBBBâFE(5ÔJO:í„Þh$á¾[U6éÂ&?3Ú¬”™š_Ùz!V›�·ýµ�HØ! €ûÇ�cp×n‚À±Œ V<ÈîÓ'©1´nu¯·NLjøx&õM¤Sp06›��IGø~Óf ÊË\ÊN8ˆY“'·Ù²ÇG¥mÖ^´-,Û´™õ;wóñË/�š•Mqy9!þþt u–ù~é2R²²ø÷ãátZ:ÕÕt !ô+C½ÑHZV65z=þ>>D…‡5{lf‹…cg’AœBckÈ)( ¯ /wb##±Úl$�>��§�"Â1šÌ?›Œ\&oR,±X­œIK§F¯§SD8þ>>—•‘–�C×NQ- põ),)!#7­FCçh/˜Î-($· OwwºDEb³ÙH:}/§¥¤ÁhätZ:f‹….Q‘�¶_^UÅc¯¾Îc÷ÜíbYÚlßJKÉÈÉE«ÑÝ!¢ÉÉê¶"Š"gÒ3¨¨ª¢ChH£Ÿå••¤df¡T*‰ ÇÓ]× ç=àOÇÐÐu4F�^Ï™ô ,‹óÚ],ÙçòÉ-,ÄÏË‹N"\òuÖèõ$§g`mtëÔ 7­–ôœJÊ+èÝ­kƒ‰ýöâ·�›Ø´{/¾ô<)YY””W48?ß.YJFN.¯<ögç˜ìØÄuh‰êšZÒrr0šLùù¹X ב_TLÖ¹sè´Z¢;D´J$(,)!='�›–nÑÑ.ç¶-ä‘}îî:�;t@}ÁĸÍf#9#“ÊêjBü‰¸ z0#7—üâb#;6™ó•�?#ÐÏ—¿üᮋêc{‘_Q†›Z�§ÆíªöãrQV[ÍЯ>oUÙOüý²÷§5¬Úº�~[ŽZ¥ÄßLJŠêj¾zõ_‚ÀŠÍ[™·|Qáá¤çäà¡ÓñÕkÿjÔbô£ï䨙dÆ DiE%;Q$6*’YwÝé´l¾ÐbtÑÚõ¬Þ¶�ž]bqÓjÙ²o?6›�ÄÝùÔ›ùfábŽ%ŸE.“1°w/¾c¦S´Ú“t”…kÖr®¨›Í†Ÿ·7ã‡fÊèQaêí�„„„„„Äï‘’­®vnH¤ÂkµLŽF®À`m½ ’\&s‰$ ðòâžQ£™Ð§�Ë üøÈHâ##IË£Ÿ}Úb½2Aà«ÇÿŠw½-™LƸ> ŒŒïÉŠýû˜·e •µµ-jäŠvë#�;v s3¹„A [t§&?תÕt�éÜê6• …KºÖäŒDPÈåôíÞÝù^­Rº¼o …\Þ ¯þ>>%6úù5›c*,(Ð%Gš\.opܵºÅȯuÛw¢Q©èÛ†sèëK`¬¤Z‹ .y¸ÃÛÓ“Ä&"æhå=×:­¶M‚rsD„7™O§Õ6X­NTxx£åÛAˆéØ‘˜ŽÍ—inL¶w�=»Ä6[&8ÀŸàÿ6ÕÛÒ¸h-¡� #ÔG&“5{/F†…Ö´-YJfÉÜ6qü%÷UâÆ&6*ŠÍ{ör®¨ˆÐÀ@ÎfÚ£ c#;’ž“ã,Wg1 8-F F#5z=«·mGÁÇÓ“â²2N¥¦ñÍ¢Å<÷ÐЈŨÉaqº÷è1” 9ž:e••ì?vœ¤Óg»MqYe%;"2<Œ›F '%3‹O~˜‡Íf£cX(6›HN~>óW¬"Ðϯõ‹LJJxçë9˜Ìf´ J…‚/þ¥A¹7³|ÝF4 ½»w#+/�û�“ŸÏóy™Læ´ö\²f= âëãMiY9k·nG&˜6iV«•�æ|KVn�þ~„r2ù,ó—.Ã`40iÔlˆÔ:ÎA«Vã¦ÕR«×³|ý&"ÃÉïÖ½^χ_Ï¥ªº•J‰ÎMâUk,VØ}àßþ²¥BAϸ®sàèqÒ³sxå©¿¢Q«1™LÔêõlÞ½Ñ&âééAYy)ü¸h)O=l¿~‹W¯eÝÖÈe2"#Â))+cß‘£—ðü_A.—S«7b0œQ§{aî‚…(ärGû%:v‚Œœ^yò¯h5ÖlÙÎòuQ)•t‰Ž¢ºº–Ó©é|8ç;þõä_�BæÅ�’‘ÁG³çÒ1<ŒI£FPRZÎçßþàby]^YÅ›Ÿþ?&�‰^odíÖm¤ddòÌ£"“ÉøaáR22™8jµšÍ»öðÑœoyý¹§Q)•¤ff¡V©…] üß÷ó(,-cˆáMF¾Y°¹L`ôÐÁôvާ´¼’¸Øhîºõö=Æ�‹~#$0�˜¨HLf3ÿýî7ÿæ IDATjƒ‘q#†¢�ÉÙ¸c'ÿüèSÞ~æIÜ´ZŽžIæÝ9séÉÔ±cÈ+,â­¯æ4k{Ý=¦3'SR ôó£Kd¤ýÚ®ÛÀ¢uë7d�""8zú ~÷¾ûN†Õ³§®ÏÒ ›X¶i3ÓÆ�ÁßLJ=G’xwö7¼÷Ü3øy{Æé´tºw·76›�7¿ü™xñ9B8pü}ºÇ9-±Í ‹×mh�s¼)’32ѨTLÀ.~0÷; JJ¹eÔH &#Ÿÿô3r™Œ‰Ã‡’Xo¿wgÏeôÀŒ8€õ»vóñ÷?òþ ÇÃ]GLdŽ%'Ù‘ ??ôFÛä–Q#ˆ ¡´¢‚�»÷0mÜü¼½É-(dû�ƒ îÓ›ÍÆ;_Ï!¿¸„[÷Ïú�»ùÇGŸðÖ3OáãéIQI)û�Ÿ`–£?k¶ï`Þò•L5_ÖíØÅ¦={ññòâözýþqÙ zÇu㾩SHÉÌbéÆMøzy1nÈ zÄÆp<%…àb®³\ª——9ü}þ²Kk¯\£5GÀpÒu[ÙPGAðs jÝBÊk…Õ:UBBBBBBBB¯Q<jŒV+bR|Ëe2ž¿ýv¦ LtH0*EÓvM�‚ƒ ôò¦°¢ù\T±aa.â`}” Ó fBB"9%EĶ1�€ EË‘;79ù¼3{E¥eÜ}ËM—l'!!aç_Ÿ|NZv6];EÑ£ ‹$~Ÿt‰Šdóž½$§gÚÂŒLýüðrD†µ?ooþýøc¸ëÜømãf~^¹Š3i鈢Øì‚!7­†wŸ}/ÞŸûŽŸ@­RñÎߟÆËÝ�÷¾ù–C'N’–e·FO:s­ZMßÝ™u×|øÝìM:Ê™´ôV „KÖoÄd6Ó1,”Wû3•Š/æ/`û�ƒÎ2F£‘u[wðç?ÞC×è(jõz^~ç2²s8xìýz�·ïÕ¹ixñ±?ãåéÁòuY¾a›wíeÒ¨œHN!+7oOþùÄã¨TJŽ?ÉçßÏcåÆÍŒ4À¥†ãæq£1[,¼öÁ§“œžN|·.lر›ªê|½½xá±Yxyz°tÍ:Vm:ŸoLE~[¿€{¦ßÊ Ä,V+¯¾ÿ ùEElݽ� #‡9Ë{êÜyáñYx¸ëظc –­$5# ›Í†Þ``óN»rßÌé êÛ‡êš^zû}2srIÍÌ"¶‘Å ËÖÙÛ¿{ú†öKÄbµòŸ?%¯ �Í»ö0yôHŽž´çë�0b·Œƒ(Š|úíœ+("=;ïæE5Çòõ› ðõáéGp>_øx{1érg™Õ›· VªxæÑQ)íÏË]¢£xã“/8zò4½{Äqôt2S'ŒeÔ`»Õ!œek7P]]ƒ¯�«MóéÔtΤeðÏ'#,ؾHÇ×Û›Ù?-hп¸Øh¦Œ·Û“öèËÑSgÈÈÎ!&*’]QXRÊ«Ï<�¯·0¤WOž|ãmÖïÚÍ­cF³pÍ::„„ðÒ¬Gœâ°§»ŽŸW®nòœŒПU[·Ñ)<œÁ }Ð�,Û´™›G�àî›o‡Í¼Ùbá×5k›Ÿ:Åà„>Ü:f4‰=ºó¿ù («¬¢KT$½»ueÍöŒèŸˆF­¦¼²µJÅý3¦ÓѾ:¨_|<zþE2rrIŒïA�Øö&u „‡OžÂ`42¤oó¹©ãDJ*'SÓxû™'�Ñ÷þ>>|òüe§ŒÅHG®éبHzù2róèß3žþ=ãY¼nƒz÷&8À‹ÕŠV­æøÙ"BB8�šF ¯/ÕµµœLIeXb_N¤¤ V)‰ëÍþcÇ9›‘É[Ï/аľMNò×QIt&#�±1”UV¶9·fŸnÝœ9jë"_õF#¶r§uŠˆÀÛa™Xg%Ý!o—m&GäáŒñã˜1~9lÛ€äŒLŽœ²‹Luщ­áTj“‡sæ½uÌ(�0#'£Ñˆ\&£°¸ˆÂâ"päb¬®©álZ†‹@841Ñ)ªN9œU›·b0ÉÉ/ 9ÍÞž§»{Àjµ‹§&“™ŒÜsøûž›&öATJ%"Â((.F¯·[ŸMO`HÿóíM5‚5[¶;£ãJÊÊ(-³/þª¬®aÛ^ûsž§‡;ùEE$§§»„=ºvÁÃa—ÕÑy>Íf3Ù¹˜-” ýzÛÅw�Ž¿üé*«kðñnhq\ZVAQi)UÕµ.íç’œžÁd@§³ÿ&¬Ø¸™äôtztéÂôI nÚ2îÛ‹(¯<�HèàFD¤¬Ü<Æ�æ²øhpß>üüÛ çûô¬b;EQYUåܦsÓâá®#=;›Þ=âôóeãŽÝ¨Õ*zuëFp@ßÓ¸móÙÔ4‚üý�â @B|÷F¿‡ã»vqþ_&“á®Õ:£/Ó3³éŠÍf¥Øqµ2!!¤de#Š"¹¹üaÊÍ.‘£#ú÷kV ¼�ÜüLf3Ã]¶OìËþcÇ©®©uŽëúùù±ÿè1BüÔ»þ>>üí�÷6ÙŽ·§'o<õ7p¤C(*)åàÉ“Î÷8„³Ù¿.Bo0 ÕhØyè0=bc\,U[Ë©”TB\¬¹û÷Œoô:ÔwnpwsC—(Óú(ärâ»Är<ù,“†ãdjq�£©ª©ådª] <™’J÷˜TJ%g3³ @¥TPXR⬧SD8)YY êÏÉ/ ²ºÚEÔóÐéèíŒÞvöû‚çfw]“ý–�øïª3Tꛎ0¾\ˆØ€FBK”ÎC|ìJwë¢�ÄA ‰I ¼†ÑȸٔÔZ/Ïý€Ø®- „êMb´7:…�¼ýoÁÖX<öˆ‰i÷v%Z�V£aÂÐ!W»W»­h3¾¢@|¬4&Ûƒ�½{]í.H\%’32È/*v¾ïÕ¼e2@�¿žîî$§g’Rg/Iumë-ÈõDYDiE#9<åÍØ�—•óßo¾%#7o<Ýu•šZÝ&@I¹]<ó«g¿íëëÁP]SãÇE¿5¨£²ºÊå}};L•J‰§»Ž²ŠJÊ+*©®±ŸËÌœ\2srÖUUå"Ö?žÏÒ² GßÏ—W«Õ¸ëܨ¬ªvôýüµ[´jM#íU»¼W(ê�ó Ú«qÜ:7­KŽÚÆ¢ë¨Ö×8ÿ¿dõÚŸW9Ú¿cÊMX¬VN%§p&5�3©é,Zµ†aaüù¾?8sÖ'-+Ëå�Žlø»b2›©©­ÅïñRí°m­£¤¬ŒŒìv<Ô ŽZ‡ ûè}w³håæ/YÎ÷¿.!"4„ƒú3¬¿‚Sqy9ž®¹–ry£"›»{ãÎö~•“’‘Á‹oý·ÁgÝ¢;QUSƒÙbÁÿ‚Fo�6å.u,&ðõráêî­’ŠòFûþ§iSùyÕj–nØÄOËWèçLj~‰L3ªÉö·ìÛÏÚí;ÉÎÏG«V7°5OìÑ�Ù¿.âà‰“$ÄuãðÉS��—'E¥eN¡Çdþ…eÜ´ZÞxþéu\˜ó¯¬¢Òù³ÅBUµ]$óñötFIJLàŽ[&7¨K¥RQ^YÙ`{cx{yRXRâ L&35õDAïzv„o½øw4j•KmqëÜjõöü‚uÇ�•›Ku­��À繪ÃÇó|ûo>ÿ,Z­ë³^]û¾¾<ñÀŸ¨ª®!éäIŸ8ÅñÓÉdåæ²zóVþ0}JƒþL?–ZÃù{<²'•R‰V£¡ª¦Æe»ÅjEo8ïÊá¡Ó1rÐ@n7ºÉãðõåÑ{ïÆd2‘š™Å®ƒ‡ùqÑohÔú÷vµotws#;7Ïe›(ŠÔêÛæâᮣïž(Ïk^ 47ýY3\ ©NJ?(!!!!!!q#Ópù¶Ä5…ø¨4(Yi©¨”J†Ô³×¹�ѽz^‹:… Ã[©A2¿“����¸ž¹–l\õîŘA�¯ £tš¢Kd$¢(²ûð´j5Á—gaÐ¥r®ÈnóI�Ÿ5z='Φ´¹žºÈÊÕ[·£7E‘e6»” @!—S«×SPTŒ›V‹F­fÑʵ|õã|N�Mu)¿óÀA§`³nëv{¾2�†°à`ÂCíV‡é™Ù(ärÜ´Zj ¾üñg¾š÷3Cë…‹GÄÜŽýû©p´·zóV§U#á§Î24-+7­7­–µ[¶ñÕ�óÙ—t´ÕíEF„#—Ë1™ÍìvDÚUU×ðÞ—ßðá×ßPVÞ0�µ‡»Îiš–™élýÖ|õã|öIÂd2ñúÇŸóŸ�>§º¶–¡ýûñøý÷1 ¡7&KãΉ½â> ¿óÕ!¬q«ø°à`ö'sÙväÄ)Æð�`N$Ÿu‰ª©¨¬â�O¾ 53“Òò þóÑç¤ee¡R©èÓ™¿¯/¹çÎ5l3$ˆs…”•Ÿ{Ϥ¦cmƒhÄ™Ô4LæóçÀhµðŸ/¾dÇÁCÈd2ƒ‚Ø}Äõ:î;v¼mí"—É8xü„ËöÇOà¡Ó9­~ë#Š"¯|ü;B.—Ó%*’û¦N!>6†Ì¼¼åÎffáëåÅÔ±cœâ`QiYƒr{÷"éô6íÙË€^=Q«šÎ£Þ!ÁäæPZ~^D?™’ÚæëÐ>žžt eáÚõøy{àëCDH0žî:®]OÇÐPü¼í˜B‚IÉÊr‰ÆE‘÷æ|˺»ÔˆL&ãXr²s›ÙbátZú%÷[â÷�(Úï½+ùzxTg”†£`.tä!lêuqãREi&ABBBBBBB¢Ý�"¯|”ZJLz¬bûæ˜øûôÌ2ÛõªJ:8òÿ´'rA†¯JÛ¦ì×"7ÂoY—({B‹ÕJ·èNÈ.¤ö ch()™Y,ݰ‘cÉg9WT䌘4šZo3z˨‘ì>’DFn.�ýû?hÔjÊ«\-CµZ-#dÃö�|ðõ\b££())#· €@??ºD»ZlVW×òÒ;ïãåîîÌ76~ÄP4j5}{ö`ÕÆ-äñ¯÷?!"4˜ä´ jjk1pî:†Vöä�Alܹ›Ò² ^zû¿¸ëÜ(-+G&“9Å/A˜üîî¼i£‘yËW¢nãu¨ûnÏÌËÃ]熻›Ý¢´O·®,Ù°Ñ™ßUâ:wfÏ‘$¦Ž=�:$¡K7lâ�¯¿áÖ±£ðr÷`Íö?{–»‰$ötwgDÿ~Ì_± �J…Ÿ�Ë7mAÅ6YÇâp Î+,¢¬²OO¾Zð+ûŽãëÿüûšZÔ"qå¯PÌ�R.ãÑÑ1Ü?,[Æ+Mçtrq2ŸM´!“]Ù{Y:72’@x� üTZÊÌzÌ혈^&“Ònõ5‡B�á#‰ƒ72áÚÓšÃßLJøØgîÀÈð0zv‰EE9rXúúã …3O¨ÊÙŠÕj%ÈßÏY·V«q–«û¥��Š$ÈßGTY°¿ñ±1t¬ýäÜvþy$$Àß¾ÍñŒrû¤ –”p"%•̼\& FH` ;¤Ö`Àh2ã©s'>6µºi ó¡!Ÿ7³Å‚·§'·ŒÅÄaö{92,o^ýì þ4m*ã‡&93“9¿.Âd6Ó14”‡î¸�å›¶°`ÕBéß3­FC÷˜ÎääÐ-ºS³×·î{¦î:u Aé¸j•ŠüùQþ7ÿgÞøßW¸ët<|ûm|1žŽÈZ/÷Fó÷ˆ‰qæ ôó'<(ˆ¿û�icÇpûd{nÁ~=ãIÉÊ¢Ïxç~ƒz÷¢¦¶–þ=ÏÛκiµ¼4ëæ.ZÂûs¿wž›çz€ÐÀ@pXÇÕ›ÿoÆ4ÔJ%³]„ÙbaÒðaè´çÁ‘¯ùÂ\—õ� o÷î¬Ù¾ƒ³™Y¼óìSøù¶ÊrZâÆ¥-ö˜‚ñ|ˆóÂ]Óº©"…\N°·†‘qA)Ï`KLÙ­i­õ«‡M¯ø|‚±(!!!!!!q##”j%GõvBo4’p߃­*›¿táEµaEÊ͆˒“ðr¢’ÉñQi¤‡k ‰ßùe¸©ÕxjÜ®vW. 5&#ý?ÿ¨UeO<ñ÷ËÞŸßf‹¹LÖ.‘Žz£•RI­ÍÒl9ƒÑˆ¦Ññù7ߥ´¬œîœÉ€„ÞŒF” ò&"~¬6V‹Õ)²^ &“ …BÑâyhªïÃÅÔe0Q«TÍF.™-d‚Ðäy»ôC‹‘u&“…BÞä¹´ÚlØl6ñöBJË+HËʦo|wÌ J…‚ÚZ=O½úO<ø'ºÅtnsßëŸ7¹y# j —œu-atký<š¢(¢7ÛT�(Š˜ÌfÔ*U£Ÿë�F´õî•ÆÊM&ç{›ÍÆãÿyƒ‰‰N1îb()/çlfzÆ;¯CM­ž‡ÿù/^xäAâccÛTß…Çq1X¬VDQlöþ±Úlì?vœØÈŽx{x`sD¾òñgD…‡ñ§éSÛÔ¦ÑdFÙÌ=-ñû¡¤ÚÄÀ®nUÙž|xýö^tõ¯F¬Þ–’VD:°V"ÖCj+ ×! ë¾» åí-fŠ«+ óökEéö#ßPÝìçÝ'´mœJHHHHHH´�’­®vnH¤Âë ™ à£ÒRe6RkmÁV²àŠõëâB Ê«0¿ ¬Ò3ƒ„„„„„„Ä ˆ¤Ü\Çhä T29U#zkóöXWµL�§R}E#d,¢ ³ÕŠY´!Š"6ñFžN¹:Ž?ŽA@)ÈPÊå(®R.«(b±Ù°ˆ6¬¢ Q´O ]¿‚Ñõ…àˆ„³ÿ+ �Éì¯Ë|?è­ ó 1¶­¢ ½Å†ÑjA+W –S\w4g›(q}p)1�ß/V«Õ™?Nâ÷Í�~}�ЛâÒR”r¥3ßc{p#?Û¼ùôXm6|<›ÎŸØFèÇÐÄ KJP*”ø^ã³ch(ïüýiJ+*Ð�ûû·*úPB¢%Z{DøíoÅzj>ˆW2�ÉÅýÍ`²XÑ©[Eßž´$Êd6Û�û]-!!!!!qµ‘ɤù—Ë…4y�#¼”4r+Ufc«¬².' AæŒn¼R˜lVô³´¢ï :„9D 6ôV rA@«P^‘knEŒV Æë:rìÆ@E{VÇu¨Ë‹**¹u;‹Ç¢(Rm1a¶]Ýï¸Ë�M©±˜1Ûlè”*)šPBâ r)„aÁÁíÚ‰ë…\Np@@»×{#?ñxº»·{� ¹œÐÀÀv¯÷r#~Þí',KHx¹)QÈÌ–¦ŸŸC½µˆÆT°¯hßP^\A{á•Ïm-´ð„®Q«©Õ®X$$$$$$~oè´Ú«Ý…I ¼APËä¨Õnm–«2‰®dè*´W0Æ*ŠÔ˜MW]ý½cEªÍ&û= T]–¨Q«h£ÖbÁl»’«Z%.ì"®Õ‚B�¡U(.9O›M©4_»vÊí…ÉfÅf2â¡’r¶JH\)nìo‰ÉCBBâbPÈFv aíÑÜ&ËȬ¦+=‚çˆ6ïcEÌV ª«d1Ú:­V%$$$$$.#îÚ+¿@è÷ÂÕñ”¸l¨e üTnø¨´—=¯•à°9õQiñW»]QqÐb³Qy DLJœÇ"Ú¨4ÛUœ½ÅL…É(‰ƒ×!ÑF•ÙD•ÙtÑâž—´ÿõ†E´Qm6]ínHHün�„‰ë�ßÉO „„Äeàù[»ã©U"Šbã/gIë•{)ý‘ý¥Í}^ ˜ IDATÇb0›P+•WÅâ½¥E°îº+Ö ‰ß#^íï<"aGoPÔ29ÞJ �^J5¹¢ÅUo­A&hä ¼”j5:¼•ÔWÐNÀì/ÅLâò "Ren‘Ð&ŠTš ×\~M‰¶c¶Y"oÛ ëïl!€Ùf•î{ ‰+„ô(!q= ÙK§@æýe‘î� „öBQ´]‘ÚnÈ¢¿EÛ-FõfZ¥ú²œ§–�·�:!"$èŠõEBBBBBâ÷HdhèÕî ‹d1zƒ#  •+ÑÊ•àˆN1ÛlXDVÇË&Ú'ê7A� öa¹ C!ÈPÊdíšSìb°9ò�I\ÛT›MxªÔm7juDžý^¢Æ~Ô‰ÇîJU«óUZl6gnÃß‹µLÞ. ;$$ê“}.Ÿúyƒíj•а  ¦ŽM\çè6ÕYQUÅÓo½ ÀOý�@??ÞüòkR³²™1a“†»¤>?ûö{”UVòð3éß3¾Uû|»x);bpBþoÆ4ò ùçGŸðÑË/8ó\.᥸¬Œÿ|øÿ~úoxyz\–v$.«7oãȉ“¼ðØ£<÷ÆÛL›0ž�}û\ñ¾|ÿ묢�¿Þ}W»ÕùÛÆÍ8~œ×þöx»ÕÙ^ì;zŒ¹‹–àëûÿìÝwXTgöÀñï� ½‹ {ÃÞc‰1–ÄôÄôM3ÙÔÝÍ/»ÙTS6=ÙMb,11ÍÞ{‹½W¬ Ò¤Ãôv£HW”âûyž}6Îܹ÷�;Ãåòž÷œÃÔ—žoèá\•"½�¿ýçc^{ê ¢Ú´¾âýì9Ë�K—1cê;õ:>áÆÑ½µ›ÞÆ£çØ›�MvñÅŶ=ÛøÙµ*1*¹µ�ÎW0%’:I7É{è¯S7Y­zy_Ñk¯VM×¶iÁV^·ñ‚ Â�&ªUDC¡ÙÂŒJR R6ÝÄQ½Í*2›™’þ�Þšº¯ðtÈ2EV«X-ßLémÖZ �ÛuSc$&‡ O•¦¡‡"43NÙ‰Ádªð¸Ád"¯°�“gÎðÆsSèÙ¶Öû”eÙµO§³äÚm²X0˜LØìWŸ k4›1˜Lص_0`±Y1˜LX­V×¸ÊÆx=î#dYÆXvNÉ}Ë¡c'˜þË\f|ü~ƒ”gkJìvfóÅ^RF“{ev[mVõÜ[ÜÏÇ›ˆÐºe»Lýö;Z…‡ñØ“êu,—[°z-í[·â¾q·^Óã\réõöj??/OÚDˆ áꨕ ÆõŠ`\¯Šß%¹øBízjÂ�tƒjÜLás+¨|¯t¨•²:ì8œNÜÕêzÝomÕ”AØ®uËë6AA¸E·nÕÐCh¶D€Ph2¬N‡è9Ø„Øå’ì¯Úf‹•ÑÛDp°¹3ج¨4îÕfÇ9d'özžmj,*®i/YáÆöú”§‰lY2QXX\Ì7¿Î%9=ƒ5Û¶×)@x-X¬64jU��,‹ÕŠ›¦æ@zxpß0w÷:�Çáp *eÍ¿Ólv;�~~|õîh+9žÅbÁÍ­úE4V« �¦a&B…ÆG¦þ~ ëÓ›a}z×ÓÞê—Ád¢{‡è:0›³®QQt�ŠjèaÍ^ÍB¹h rÑ–·“:Å Õs€Po6¡»‚ßßõ¥¦aŸ®W’Y)‚ Bm êÑ£¡‡Ðl‰¡Ðd˜ìuË&ziê‡ì9Ë›ÏOaÂÈ®Ç_x÷öÅå­ŸcüMÃkµ¯ýG�!I}ºuà³ï`Ñêuö}­ü´t9Ó~�Ǹ›†óö‹Ï`4™˜òæ»Ä'%sûè‘üëÙ§]F€ÉnC£©}€Ðä°×©ßÜ΃‡ùÛðÚ3Orç-7_Ñ8¯Ô°ûÆj³±gÉüzݯ,ËÜù시»p�ö­[òÛ—Ÿ•{~êÿ¦±jËV>üÇ+Œ4 ÊýÌýc%Óç.Àbµ²öÇY|ýÓ¯µzݵ&» �ºêI}ë ,c»‚ {câDF!ÎFKëææ*µé©ÕÒ-:šäô ‹õ8œNžy³¤¤Ý¿žyŠv­JVÇ/Y¿�5[·Ó­C4/=òP­Ž•™“ÃßÍÄd6óàÄñŒпÒíR22˜µp1IiéhÝܸ«Šëúæ½ûXº~#9ùù¨U*útíÂC·OÄßǧÊã_Zbtóž},߸‰@þýÒs®í¾œù©ç5x ÇŒ"#3“ù¿¯ätR aaÜ1öfºvŒ†Ò¬¼Ÿ/ÃW§ã¡»oçûy‹èÕ¥3£†ªPbÔét²jÓ¶ìÞC±Þ€»»;zõä®qc\ÁB“ÉÄ’ÕëÙô&“ w77ºvŒæ®[Çàå“­±'O±aÛNfþ6Ÿáб][ròòø}ý&Î$§ T*éÔ¾=o‰w5 àM&+6næLJ*Ez=þ>> Ð�þ1=°Z­ÌY¸„Icǰe÷^bOœB­VÑ»{7&ŒA~A‹W­f˜Q„»ö»ïÈQâÎ$ñÈÝ•g¨Y­VVmÚLì‰8Ì6 aaL=’Ö-È/,bÑŠUÜ{Û®XEAa1ÿ÷ÜS•î+!9…�Ûwr.ó’$ѪE·�Mp`Ý{b•Ùuð[ví¥H¯'$ €›‡ q}ON'³ç.`˜Qì9p˜CÇO T(èÕµ3ÇŒF©(™xÎÉËcéšõ$�=‹§»Cö½xYfß±ã¤efÒ)2’ÅëÖ“�—OXP÷Ü:†¨ÖKXffç°hí:N§¤¢V©èÖ!Š»oƒÎÓ³ä\=F\R2�Lº €Ù‹—2¤w/Τže˾ýغFE1yâx´nnÌ^¼”Œ¬,ò ‹˜fšÏs“ïÇf·³âÏ-8~ƒÑHXpwŽMt›6Už#“ÙÌâu8r*«ÍJdË–Ü}ËZ†…âp8øæ×¹M&6ïÝGv~>“'Œ/÷z‡ÓÉôy Ó‹ž�:^üŒ2ÈuŸüóò?èÙ¶\Éâ½±GIHIå¡Û'ðÃ’e ŽéÉÉÄ$¶ìÝÏ¿§t^še]ÿ‹=‹-f¼ýê}¿µ%•VcªjÁrëˆpBÉÊɹîcA„æ."$˜6áa =ŒfKü…P�[p¦9)陨pYe/Mý�W>øØõïÖá-èÛ½�þþ 2»ÝÁ¿>ý’ø¤dFÀkSžj”ß?‡,×:ëSFÆ\ÇRtë¶ïpý÷†»ê<¾ÆêØéÎ]¸À™Ô4’Φ—{¾mËúvoåáe~^ú;«•Çî¾­{Ý˽^K6§£Ú AÛuì=X¤7—˜Äù ÙëÙuèñIɘ-v:Â�cÇ+”JtÊ2ÉiélÝ»Ÿ“ gÊ•4�Ä%&‘‘•…ÍngçÁ«•ýG�±óàaò‹ŠªÛõ<ׂ,Ë¢�b#v$Iâ–¡ƒ]ÛL& &ÎK~^m6;“ ³ÅR«ãM&>ýþGrò󹩿*ƒƒz£‘¦Ï$)-7�O-¿þ±’‚âârÛmÞ»�™ QX\Lï.]ôóc÷‘X¦~ókå}ŠËJŒL&dY¦w×ÎL&R32¸�›[ò^�Fâ“’1šLtëÔ�üÂ">›>›øÄdÚ´Œ c»HÒÏŸç›9¿pòôìF“‰B½ž¿Ì#/¿.)1j4™\׆%«×±bÃ&œN™ž]:á®Q³e÷¾�ó‹ëúðëÒßÙ¶w I¢sT;<<´ˆ=ÆgϹª2…þ~¾D„…Ð)ª¾Þ:Šõ>ž6“Ô´tÆ ÆMû{ò$ŸÏø«­êEY³æ-dÿ‘Xú÷êÁ]cÇÄ sºôºép88pô8³æ.Àn³sßmãéÖ±+6lb×�Cøùz“xö,û)·ßu›·UyLY–ùî—¹ü¹k/}zvg¨è F>�>‹ŒÌL(½^8zœïç.  °˜®+ϸº�›Ë—3Àf·3aôFDú¹L¦ýôë�[€u[¶óË¢e´oÓšÛÇŒÆÛ[Ç×?þÌ�Øc®ñ8zœç/Fo0rÏ„[‰éÚ…5›·±mÏ>ôŸL›IRêYF Lÿ˜ž¬Üð'Çâ\ÇIÏÌâÏ={ùü‡9tŒŒä¶‘7¡7xÚ Îž;@~Qoý gÏŸç¶Q#5°?{Žåƒïfº~�¥gfq4>ÞµßÃ'Oñóò?8•˜Ì}·ŽeôÀlÝ·ŸEkÖÐ1²-žZ|}\=Jç­\Íê­ÛÓ“{n½§ÓÉG3gS¤×WzŽœN'Ïú�­ûö3¬o&�Ev^oý-Y¹¹H’D×è(T*áÁÁ´oU±„�R¡@o4²~gù{¾�»v“�Ÿ�§VËî#±|1çg|u:î{ Ú¶á§å¿³pÍZ×öGããIÏÌ*·�ôÌ,b/;' V¯åÏÝ{‰éÜ©ÒL`Gé{Þwôc† bˆá��ç?3¿/WÊøûEKP)•\òý·Úl¼?m‡NœdÌ�AŒ¿i8‰gÓxûßR¤7�“_ÀÞØ£®ý­Ü¼•Y Ó12’{o‹Z­âÃé3IÍ8¥ÁÁ÷¾�ŽB¡àî±cèÙ© V¯eö⥕~6‚rI�¯ŽÿS„þu�ÔÝO ð}ñ¹zf´ZP)hT »¾]UC€}po‘Ù ‚ ×ÂȾ}zÍšÈ ¬GbôÚ±Õ¡ïP]ý¹k…#ôcû¾ä2 gO:D–¬€>xü²,#;�ì?zŒ˜.�‰ŽlƒJ­"<(€­{÷“WTÄMýû²÷p,Y¹¹ Љ¡}ë–lÝ»Ÿ”ô ÚµnÅ�>1®@žÃá`ëÞý¤ž;G�¿#ôÇÓãÆñʲ̇Óf°çH,ýztgêË/¸Vž¯Ù¶³ÅÊ„ÃQ«T˜-ÖlÛ�—VËÍCqîÂöÆ£ux:O/öÆÆâçãÍ-C†p!/—?wíA£V3jÐ@ü.f*ÄÆÅs2á 6»�¶ êÝËuÌšØTªš·µ:u*-j6›Ùºïž¨•JŽœ×ó]¢ÛáååIp@É{ݲwùEÅŒ8€Ã'Nâ¡-™ÄÒ�%™¯]»@v½ÁľØXÒ23 ¦o·®øùx»ÎŸÎÃу°vëvLV+7„—gÉ ý´Ì,zwéD«ððZve,N;*EåY„׳g×ÁcÇùç§_Ð9ª=™²É+,àÎ[nfï‘£dd•L"vˆlÃ÷¾‡F£!+'—¿ø1 )©®ý´oÝ’ÏþõaÁA�Oàå÷>dHŸr 8u&‰]‹æ’�’Êß>ø˜Ü‚’ ‚F£áÅGâžq·T:¶ÆÒ»ìJ9�²ÈVhĬ^[á±±C‡0°gýMp9œN¾úéÎ]¸À�Þ1<8q|•Û®Þº½ÁH Ÿï¿ü>:sW®bÅŸ˙ɲÌâuëxú¾{Ò;›ÝÎÿ}ú9™Ù9lܽ‡ñÇÕ8®°  "[F�”–αSñŒ2ˆSg’p:�ùûÓ:¢KV¯Å`4Ò­S^xü–®^ÇÚ-ÛXºf=�£Û»ög0Ø»#ÄÛK‡ÝY~A�Þ``óÎݼòÔã´jNa±ž7?ù‚¸ÄdNÄ'Ð¥C±§J�Þ}=»vÆjµòá×ßaµÙ¹�“KXpP�?€–aatŠjÇÖ={Ú¯/’$±bý&Lf3o¾ô¼+c°ST;¦~ù ûŽÄ2¤’?ÂJŸfî»}"}º—TUèÛ«'NŸ&9-�èKJÓ†0ùÎ’ µî�;rêL"ÉgÓÜ·7½»uãàÑL;€sYH;žû'M¨tü É)œˆOà¹G¤gi·þ1½xëÓ/Y½yO=p¯k[�ON¾¯Ê…Sç2/ЪE ž}äA×ïc_�7ßÌù¥V¥_/g6›Y±q“nÃ-Ç0°w/œ²Ì²5ëéÓãb–š¯·7�Üs=:w">)…ä´tF[vïCo02õÕ—] Ð:Eµãýÿ~[îxy…Lyà>†—~>Ãúöáo}Âò�›xñ‘‡X³m;6»ƒ·ÿú^ž%÷•�Úµãß_þ—½±GÒ;¦Ò÷a³;øû_u�·³ç3I<›Àà˜^lÚ½‡ˆ°PnêW’ÕxøÔ)FìïªÆÑ5:ŠŸ–.'¿¨o¯Š¨Gââ‰ONæ�gŸ¡KT{×~_úà#VnÞÊwßɨ�X´v=Ú¶)—Ýw©�={ðý¢%Íf<ÜݱXmì?vœûÆ—ô,\¸f-½:wâ¥G`xß>¸»iXµeFÜäÊš®�¬œ\>ûç«h«øNì?zŒ3gÏòñ?^¡eXÉŠæVáa¼ý¿oIJK'°ô^ºGÇíznÚ¯óùæ�7Ë—*3}ÞVmÙJ×è(>ù¿¿— 6}=çWr 3x j• ½ÑÈGßͤeX7D|b²ëßY99® �UnåTbF“ €/cÁ×_âíåÉ´ßæñÓ’ååÆ0°W¾zóõZ�ßÚfÚ꘱eï~Ìf3c‡ ÅM£æ÷�²qçn(�|ÎÎÏç£ïfäÊp1³þ3•!!ì?zŒWÞÿ¨\fX€ï<~ùücü|9u&‰Þ}�bƒÑõ|ç¨öÌxïm4—ô¼šòÖ»Ä%&0í·ùÌúp*]¢Ú“›_Àÿzƒó²]Û¶kÕ’o§¾…Ÿ·wåçËî`Ó®=(•J^îižç}6ìÜU.@¸rÓVW©Ð!!ü´t9'‰=Çš­ÛxøŽÛøeÙ®íŸç}~ýE…c%žMãù·ßsÄ<´Z¾ø÷kt‰jÏçüŒÃî`X¿>Øív¦~= ‡ÓI�ŸCúÄðÍ/¿qêLs¿ú¬Â¾k£ºÏ¼!c É)<ûàL&f/\ÌÒu5h¹çfÌ[D|R ŽŸdPLO¦~=�„”TF”NŽ®Ý¶��;w3õëi|÷ÞÛ®}î9KÛˆn- X¼ÿít õÅL}ùü|ùàÛ|>ûGºuˆ¢ã%¥ÁÊ4õ¡Íé@Õ„K¤6%ßÍ[@þ%?ËCûôfh =ƺFEáåQ2Y^l0—œÂº; âæÁƒêe\k¶n§Ø`@©PðÐmªÍv�K*¹ŽŽØßUžoÒèQ¬Ü¼Õ•�“™“C^AÉû,,.fÓî=øêtdfç—˜T«!À ^=IJKçèÉ8F äÊ ìÛ³’$ŸXrOà¦Ñ°moI–WY_Ú¹sX.É T«T<|÷®…Ùyy厕˜z»Ã�F£!%=�”ô’Ìpo�f‹…„ädºvŒÆËCKžÕʬ¹ èE·NѼø—Çð÷«¥ÁÂü‚Bâ“ÐŒå²LºuìPîß:/O×5­O�®lܾ“ôó™D„…²÷Ðýühߦòq§�;�V«¥G—N®ÇÔ*}ztãÈñSå¶Ø;¦ÚïYÏ.�èYº«ÍƹÌ,;¥Ynu•ž™‰Õj£]ë–ä\ò¹G¶Œ`ÿáXô†’r²Ý:E—{­·ÎÓu̳tŒjW®:EËð0Z–•Ò)}K*¥’!1½\Û¸i4 ‰‰qe˜¥¤gÓ¹“+8X6–ˆÐRÒ3ª öìÔ¡ÜyóñòrU3¨LH@Û÷$ÀÇ—~=ºáçíí ÊU&9=�__WpÀÝÍ�~Ý»‘œž^åë.×§k¾_´„C'N2¤w ‡NžÄf·3¨WOLf3™Ù9Ü?îÖr¯Ú§Ë7þIzfÚV]õr}»w­28p&õ,­ÂÂ\ÁA€¨Ö­ùï¿ÿ‰�NçʲîÕ¹S¹×éŸ2¾:Ý;v 9=£Â1Ó3³0Y, ¾äsT*¼ûâ_QH²,sæìYî¸y´+; UX²,“x6M…Š´@¥{åÀ•’ÁY°…ÉBgÁzÀ�¤i�¤©¿`&›§Ó‰§[Ãõ,SÓ=m¿Ý $3[”A„úÌÀ* õC둘½väë0Iît:ùñã‰OIá“鳘öË\ÆÌÊYßqË£O T*Y5{†k2µ2íZ·âíŸgƼùì>|ûî[(•/¿÷»Ç–ËÖ«ö¼Öò³«m ±Ìú%½”F à ®ß±Ë ,s>;›{ÇßÊm£F0gÉ26îÜÍì…Kyë…gùyÙØìvÞÿÛKôèÔ�¯þ�õÛw²ãà!n=’Of}O±ÁÈK�=̨AùjÎÏü¹k¿oÜìÊø*ë7óú³Oóâ¥lÙ»�µ[·Ó%ª=³æ/äü…lþþäãL=’Y óó²ß™9o!¯=ód¥ïkoìQò‹Šè×£;}»w£eXiçÏs2!‘ÎQíª='‡NœàÉûî¡o÷.^^L}éy4 ý{vçàñ:q²\@9<8„Ÿ>û¥BABÊYRRéÙ©#];””¾»yè ~Z²œ�»vW lÊáA{iæ·Z)~7^§SRÊMHuhÛ¶Úíî7–ö­/–ñÛuè0_ÿ:—¹+W3¬o_Tª«ÿìŠ $IÂát²lÃ&»³òþr9ùùú]ì1äá‡}éÏ•þ’Ÿ¯_ÿXYaeýkc`¯žü¶b É)˜ÍfN&”,ÚéÛ³;†Òcˆ=æ*YF–eŠ�Çâåéá V¦lÜV«•_—ü^áù¢â’R‚¹ÿ®XÃÙŒ bOž"öä)$I¢G—NWÞß17¯ä\}2mV¥ÏMfW€ÐË£ês’WPDÛ–‰••/ëçê£ÓU¨dèïçÊLÏ-(¤u‹ŠYý¾¾®m*£«æóªÌ”ûïeÞÊÕÌ_½†9Ë–ÌÈý¸uØÐJMæVÚ#Ôßׇý¥ÚÚðÐjéåʆÜqð=;uDçéIziÆÿåÇ ð-ù|«{ÿ•©j1W™ìü|ü|Êo#I’«w`Y€°ºs›[Xˆ_%ç%ÀLJ¸ää �—]'ý/;®o颊Ââb,VóW­aþª5^_¶PÊQx¢ ~ǹÿÔéeŽÄGq^ ÎbdCÉ¢XEøß뵓M¾A�o5×ÎëISÂp…Bâž[ÇðõÏs¯Û˜A¡¹{tâx Q¶ñZÂzÔ{À5×#‹fÊäûéÕŽÎQíøs×öÅåØéú–­R�$¼½ªÿãdòÄ tˆlSZ2è07õïKL—ÎtŒlË'3¿Go(™ÜuðQmÚOp@înn9‡Þ`*WâòRy……® ž§ÍäÇ�?¬rÛêôëÑ�¥%äÜÝÜ0[,`V—jþ>>DG¶!>1™�;v“—_HxH0-BKzõùúx““ŸÏ·O¤LÅk�ÖÝ�¤Ô´ZË·4ÐàïçÃ[/¿Páù²àbtd[Þxé9²óò8rü$�ž éìYŽ?ɞÇÖ¿_¹×)UJºëör�µ¬eÉg�—'úJ‚zƒ‘� Ê3ŒÎ]¸À·s~ah¿¾8is–,/·`PA„+£óôà‰;n¯Å–ÂÕ3w §,uùŸü-B‚]ÿÝ2,„}±WéÖÖå•—–¡¼TfNIÉŸ�¦W\mžW�_eЯMD³>x—©ßLcûþƒ¼÷Í4>ú¿¿Õ98­Q_2®Ò×–�]ºą̈TJ–¬]Ï‚Uk�å’ò˜nn˜+™D¹ž6îØ�Ãá@ëæÆ³ç@i ³ ¸˜ ;vòøÝwº¶ ðweÁøy{ãå¡%¿¨»ÝA\bïO›NQ±ž°  Wï>JW…;e™@?W†[‹�¹ì“$I¸•~ΗNL;œNròòpÊ2¯¼_~5nNAåß+“©¤¯"¥}mâÎ$’™S8Ú¸s7/<úP…l»K…Õ¾7UvéØBƒËe E„“’žNVNŽ+ˆ|,î4NÙI—öíéÕž¥ëÖs,¾$Óf`¯^UãjH¥e²£²l�Ü‚òY+eA oùEÅH—¬r*+÷6°W¦L¾¿ÜkuULZ6å…'&›­ºòk Pÿ®¤o`bZ¦Ò —b½žÕÛJúŸúz{àëƒ$I®E$§SRèж zƒ‘C'OÕ°ç‹útëÂ�Þ1lع›Ó))ÌYö;o>7¥Òm;´mK|r ›vïá¦~}ñÑéø}ã¦rׂ_¼<=ÐŒ$¤ž¥o·®È²Ì¼•«IÉÈ``¯žŒ¸,ˆV�~={Ÿ˜ÌÚ-Ûà’ìA€–ᡜII%!9™›õ/É Ž;ÍÆí;Ðétüå¾»k}œa¡H’DAa1EÅ„‡cw8øyñ2ŠŠŠ¸å¦axzzðó¢åhÔ*^~ò1n6„›‡ áÝ/¾›­b`V©PTÖzL!!ì>t«ÕæÊLÌ/,"5=ƒÞ¥ý/—z6‡ÓÉÝoESzß`µÚ* HÕ¤o�nlضƒß×m$²U«jƒW-BC(ÖHJ=KdiÖ«,Ë9qªÜ¢ ÚHJM£gçNåÞc^¿—k#<$I’8wº\¿Á Ûv�t6�gz VûiÊæÝ{1�®žÔy…¤¤§ÓÓçb‰J‹ÕFl\<=;u„Òó°7ö(­JK‘F„†²ÿØqlv»« ý…¼<Ξ;Ǩ�ý¯ø}^Êbµñî7Ó¸{ìb:w¢S»H:µ‹$íüyRÏ�¯ô5a¡dmú“´ó™´ + Â;�NŸ~]¾‚‡ï¸­ÜvŽÒ?ZÌfK{ª�ø¤d~û}%"Ûòåÿ$À×—G_ýW¹žu5©. u©º„Öm+ɲËÉÏgÙ†ò�à7ìØU.@˜•›çš$Ë/*Bo4àë‹J¥ä£é³0šLüøñ‡tŽjÇôy ]=$|}‘$‰ÜüWÌŒ¬,6íÚCT›65fÎ) |½½1˜Ll�÷s­=[öïw_7îÜ]î¹ ¹¹ÄžŠ«ÐÇæJ �$Îgç¸Î�,Ëœ=_’ý@Dh-ÃÂ8�$IÜ6z$۵姥FÖlÙŠJ¥¤O·«[�^U†œJR`•kθ¸Ü§3g“_TÄ”É÷Ñ*<œ´óçùî·ùøèt®²®«·leÇ�C íÛÛÕ°.ýü $íüyVþ¹™ #G°jËVR32ò÷'40�”ôŠí[µÂÝÝ�ä´ B‚ñÕé˜1o!»á‘;n§EHÅIne­V½Ä$dc6géò �) »ãvׂ‡Ž‘m9r*Žy+W³qçn Š‹¹’¯åä‰ãyçëo9y&‘=Gb] .5vØÖíØIv^>/}ð:OOròóQ(®>m’$1iÔH~ýc%_ÿò]¢ÚS¤×“”–ޝNÇÓ÷EWrôªõíÞ•yËW`- ôíq1@8jÈ`v8Ì�£Ç¹�û¾Þ^œJHÄîpðè½wÖ)€ïë­cpŸÞìØ€�§Í¤Cd2/d“™�C‹�Úµi ²L^Azƒ�·>ûŠðÐ.äär!'�FC·Njq¤ª•ý>NMÏ 48ˆáƒú³uï>¾�ó ãF�Àn·³tÍ:´ZwÄTžÍVº˜jãö�ôëÕƒìÜ<þX· «ÍFvN^¹ž¾5iÑ‚?_â“+dA^®St{"ÂB™9w>÷N�·Î‹­{ö‘~>“ûn_ík+¾‡ N&$��œ‚·Î‹“§ÏðGé½DFÖÚ]Rv·6ü|½é׫¿-û£ÙD›ˆœNJaéšõÜ;q\­÷3|@?þܵ‡ÿÎþ‰ÛnÀÒ5ë*,6S(LŸ¿�‡oŸˆŸ·7ëvì$=+‹‡'Mà–!ƒÙ²wŸÿ0‡ÛG�Äbµ2wÅ*|¼u êUu–fM$I"+'—œüý|‘e™ßþX�³´7ñÉÄDΜMcd%nûtíBH@_Ìù‰‡n›€§ÖƒÕ[·‘�—çʬ-wwztìÀ�ã'¸yÐÀrý¸'ŒÎo+VáïãC¯Î�HJOgþª5 íÓÛU†3,8ˆmûЭC¾ÞÞlܹ›¼Â"t—ôm¬�Á1½X¶aŸý0‡ûÆ�E­R3wå*BƒéÙ¶Vå<‡öîÍò�òÉ÷?p߸[Q«T,]¿«ÍƘÁ+lïíåŰ¾½™·r5J¥’¶-Ø}8–¤´tº­ä;0qä¦Ï[€�NG¿î]¹�›Ç/¿ÿA«ðpj¤ð�¯n×Õk!ϨÇ]­it‹Í4 %F‡­Úmž¼ï~߸… ¹5XAA¨\°¿?ÏßoCㆠ„B“ IÔ1@سsVmÞ¼«Ðº»äïÏÞØ£œÍ8‡»›.ë÷5cÞ|ü}}ˆKLæÀÑcøúÒ¹}û’ã+$ì6…z}½¬\è×£û�cL™ü‡Nœà?ÓfÒ"4„Ù½_sI#�ïýý%¦¼ñßÍ�O—èvÄté‚¿¯¹Ì]¹šÁ½{òëòW5ÎÂâ’,(§Ó�Ñdbì1N'Uì‡R�ÚkJÏÌâxBžÌùäWÙ)óøk¯“x6Í䣴ìÖçüÌÄ‘#™³t²,3´ooN'z£Y«ÝƉ„3¬Ù¼Õõ:w77:D¶%.1‰9‹–rËð!|õãÏlÛw€ÿõ™Z½§þ=º³fÛv¬\øÃYµy ?,ZÂ�>}xûÅç*l¿¾4ðù£1bÀÅŒ�y+V±hõ:6ìØYoB7�†®ÑQ�?ÍçüÌ½ãÆ²~ûNÎfœ£EHá¥{õ`áêµtëMtÛ’2©gRÓˆéÒùŠJÜ–QHR•ßµB‰ÕY÷áÎC‡9!›J'Œ‹ô6íÚS.Ø&õ,›ví)ɸ~ecÿ¿gžäµ�?ã½o¦óñŒÙXm6Ô*¯=ód•?¿Þ:/ž¼ç.¾ùå7nú¯hK³^»EG3¸ŠÉxMímk±Ûp8�nRG­›Ý¢£*<îåáA�¿?ƒzõ,׿ìñ»î`ú¼Ä'§�[XÈ�˜^´kÕ’ýÇŽÓ¦t;•JåÚgY&Z»–-q×h\¥¨;´møáÃH;žØøxú÷è^ágÅÏÛ›=ó?.YFê¹sH’Ää ãIÉÈ Ø`pMî�¿i8 …‚náÈ©8Ü4júuïÆÆèWÒk,"$„nÑQ®’Ðîn×/íßæåáɈÁÉ8?_‚/¹Vð÷§ÿ‚«I>›Fúy‰¡!Œ5‚˜n] ´\ç¨vúí©UjWYhui–öä;oÃÓCËö}8râZ­–aýû1iìhW6Þߟy‚6q2!‘ãq§Q)•D¶jŤ[Ç\uyÈÖøzëøðëïxôž;Ü·7/?ù8ó_Åç3¾G’$¢#ÛðÄä{]l—kÓ2‚»Æ�eõ¦-,_»�@??î�8Ž3©©¬ßºƒà F ìOç¨vzß¶ /WT’$zvé̶=ûèSC)D¥BÁ‹O<ÆÜå0kî!��<ûðd¢#Kúmº©5tŽj‡¶Š2Ÿeî;†¼‚B¾˜1èÞ¹#¯>ûÿ�õ#Ÿ~7‹/ßù7�þþ®LE€íÚâë]uÉȇïšÄâ•kY´r-‹?oîw #JË…K’Dç¨vxëÊOZ„†º²øýý|yé‰G™»ìþ÷ÃO¨U*†ôïÃÈð0 ‹\¯ ðgìÐ!ÌY¶½Áˆ¿¯}ðºE—Ç[„óϧŸäçå0õÛïP(tiߎWÄU’28ÀßUö CdÛ eãC°Ú.N‚ÇtîÄo+Vñê'ŸñãÞç…‡'3{ñRþûÓ/Øý|y`ü­ ëÓ»Òs¤Q«y}ÊÓü°d)_üø3N§“ˆÐ^}ò/åJ"wŠlëêX�Þ]»pàø †ö-¼qÇáp:Yµe+KÖo@ëæÆMýúòàm\ÛUi‹Ké<´$Ìýåº�©90Ùm˜µ_•Ni‰Ç�§Ïâ�M›Ëe¦yzxðö Ï1¼_|åœIM£Cd[âKƒ_�†÷^y�›JËv=ò�׈OJ`ã/?2cÞ|­^Ç›ÏOaÂÈüåµs"á ¿}ùí[·dñšu|:ëšt/<ò F“‰>†¿�k~œ‰Åjåé¿M\b’k\¾:_½ù:�ÚGr¹Ÿ–.gÚ¯ówÓðr�¥²Çý}|øå‹�Ù¼{Ÿ}±‡N·èhŽ�>M˰0û›wï域~Á­Ã‡ñÎK`øäG1›ÍìY2I’øtæl¯]Ï[/>ÇÈþýyàåp>»¤7J ŸZ-gÏ�ãý¿½ÄÍCUëå´JZ•ºÆí,;{õ«1~\´„éórë°¡¼óòóåž›ú¿i¬Ú²•Çモ›‡ bòËÿ ,(£ÉD¡¾¤¯Oë-ønê[øùòÎWß°¦´¤žJ¥¤KûöÄÆÅ»ÎóÉ„D^zïŠ.)�Ö¿g¾ü÷k(•J†Ýÿ0V›�=Kæ°óàaþöÁGŒ4€ÿüã2srxê_oq!÷bkë-øúíWÈNÍ/,bü“S�$XýÃÌr�è“ ‰<þÚëøy{³jöt>øv«¶låü¨Axüµ×9™�ȼ¯>'²ÕÅI¶¡÷?ŒÝfcwéøÊÎOÙë’ÓÒyáÝÈλ¸ºÕ[çÅ篿F÷ÑåÞ“$I¬Ÿó=Þ:/&<ù,Ùyy<÷ÐÝ»âãåEQ±žýÇŽãîææêϘ�’ÊÙsçiNT›V\ÈÍåX|aA�tŽ*Y°eï>§«d|R2é™Ytnߎ°à’R®Éié¬Þ²�ÌœB¹uø0×ùÏ-(àÈÉ8‚ý]“¶ev:Âþ£Ç0�tëÍ-Ƹ‚—RH¾÷ �7¹úbœ²“ ]íz05e]¾ú¤VÛ�xùÿ®ùX®%›ÝŽB’Ê×®%‹ÕŠZ¥ª±§ Éb©1 T³ÃŽ­š^Belv;’$¹z^-³Å‚{ ã¶Z­¨Õêz-5,Ë2«µÂ±­6’$•ËĪÉåïÁjµVYZ½*ŸÏø/OÏZ—á¤ôþÎa·×ùX—³;ȲìzÏN§‡ÓY§sp9Y–±Z­Uö"¬-«ÕŠR¥rõ°UIZ¥š¥ë7²ýàA¾ü×kPÚ7ÏýêßV› …BQoßÛÊ®²,c³Û+ý=V‡Ã�ÝáÄMSû×\îçåpäT_ü«êk«ÑlFëæVåÏP}^×êc_6»Y–k}.eYÆj³W{¯ö)×›Sv’žŸK —7šÆùÝ͵š°Õ°�Q–ež}ó}¶ï?xÝÆ%‚ ÍÅØAùí?ï5ô0n"@XÏ}÷CöÕЛgH÷n,žúöuSs`—�Y¯¬\fjF'Ï$’_XD‹�bºv)WB¨,@¸àŸs"á yE îS.Ð’“ŸÏŸ»ö P*™4z¹äŒ�—©˜ÌÚ¶ŒÀM£!¿¨ˆ¬ìü}}Àát’�œ‚R©"ªMÉŠt»ÝÁ¾£ÇHNK'ÐÏ—a}û ÕV>É“[P@vn>:�+8Ai¥,£/(ÀŸ__Ÿ<ÅÉ„D‚üýÞ¿/Éié¨ÕjÚµjI‘ÞÀ¹¬¬rû‰OJA–�t,ͨÌÌÉ¡ °ÈõÞ Š‹ùs×Ü4j÷é�Õj%¯ o]­zÞùhÜPJÕOðÈÈX,ÈTIJ?Ÿ‰Þh$$0ÐÕ ®L~aY99xzh±ÚìL~ùtŽjχÿx™M»öà©Õ2fÈ Wf„ÃéäÏ]{((*¦O·.ø“v>³ÜçTP\\Z^.—ÈV-Ëe½\~îôé™çÑyyºÊEM&ö9ʹ ÙD„†0¸wL¹že F#iç3]ŸU¹s#Ë®àud«–äæPX\LxHÞ^ž¾e.ßù Ùå^GißÃmû�•›K€¯CûôÁ[w18i³ÛIL=‹R©teœÍ8‡Ñl&"4ìª2½Õn¨ª™ü7ØmXê¸0 9ñP©qW6½D§,“š›M˜�îu˜0nªn”aseq:®([Y¸:yù…$$'óÂżðø#tíX·Ò°7µ¤À]©ª ¼sþB6Ÿ~ÿ“nÅm#G4ô�A¨G™…ùhTjü=o¿!½ÝŠÞn­q»Âb=w=÷ ç/d_—q ‚ BsÄ–ÙÓ ðiþ Î  ¬g8Ä_?ý²Úm~}ã_Œ®¢üŽPµB«¹Î}k£,@¸dÚÿˆ­ØL¸rJI§P&‡S-²k#ñlš+@øãÇÔË>…ú£V(ÑÕP~Ò)ËZÍ5„Œ›§¦œ=X`4`´Z÷­¾4[sQÛaì‹GÕDKÆ6g6§³^w_ÌœMÜ™$úöêÁ“÷ßS¯Y’Í‘›B‰F¡Bà—ßW°zë¶’2œÏ>#²ã¡É5c±Ùý=¤Íé$×j¬Õ¶�Ÿä‰¾…Ívã.zA„ÚrS«YþÕgôëÚ¥¡‡rCiz© �ÜÈ>1<5i"³ªèýöò=w‰ààÒªÔèm5¯Ô�Ú”-·½R…ÕáÀQ‹roBÓ%A•¥E/¥�$´*5Æz 7%µ9?�‘Ãé¤Àd Ä»æþQ7šsy´ð p• ˜jÏ=ò z£‘Àz¼ %$J¾§#ô£o·® =œuÿø[=h¡��âçWš‘B“£ÕB‹FP+(%E­þfíݵ3_¼þ*/¿÷1§øWAª¢PH|÷Æ?Ep°ˆ ÂkdóÁÃÌ^±ŠSÉ)HHôŠŽbÊmDpð*Y-Øë9x”œ–ŽÅj%²UË:õOª§R(ðV×}U·C–)²Ö\j´&«•ä´t´în´nÑâªö%Ô/�Zƒº™T7Z©Ñ¦ZZ »¸§ì¼¡„µÍ Üñôs­Â|üQˆIíFÃ)Ë7Þ"¡iñPªjU®]¡)*6›È7ê ÷ ¨¶ý@cRl·b¨E™Ñ2óþXÍÓf!_ƒŠH‚ ‚ÐÔI’Ä'¯¼È_nŸØÐC¹!5ÍÈ&`Dï^ íÙ½Ñ@ ß4*qªëƒ—ZC¡ÍR¯7Öm[FÔb+¡.$$÷&û¾dY&³¨�:w1áQ_O|=<9W�‡Ù&J[6"ø"4fâû)Bs#Ë2Š 1XÍ´ð @}… Ú•ü�zÓ€>,›þ_ºwŒ¾&cA„¦¢{t›g}Çèþýz(7< ¼<µZ,vÙÅE =”f£¬¿]]Wí ׎J¡( Õßg"Z•ºIOnd*I�Ní†N­©· N¥¤ÀK­Á[톻RÕd'N•’ÂðôªÇós½É@VQäåÝÐÃi2¼Ý=Òy“Y”O�ÑÐÐùá‰lu¡1SŠ WAš›ÃNzA.á>þMºäºV©¾¢+t‹�`~ú잸çÔ¢� ‚pƒQ«T¼üàý¬�ö?Ú¶hÑÐÃ)Ïlµ/¯Y–)2èñÔ¸¤“¨õÉêt`²Ûpˆ2® B)IhUêëÀsÊ2‡‹Ó!Êö6R QªpS*¯[ß)Ë8‘‘å«éZyí••‰S 5‹ hY–É*.ÄîpîëßdƒœW«ËWŸÔj»/ÿ_…ÇìNYE…($‰`�O“ž$kÊìN'&§è$4Nî åUgà ‚ 4Åf¹†b;¡VûÓ[Ìäô( ü<<ñиÕÓH…ê˜vlrýLî B}QIZ¥º¡‡!‚P'§“SI`ÐÓͯf]FÝä°Sh3×ë>íGNƱ÷È1öÅãä™DŒ¦ú=† ‚ Ô'O­–ž¢Ò«'CczÑ·KgTJÑ*¡)Âäp81ZLȲŒ¿‡^îÚ+jr-‚ ׊,Ë™Mäõ¸«Ôxy£7y.‰y¹Ü;÷'ÌöÊûϸ«Ô,žümýê´_½ÅL�Ñ€·V‹—›; QÂ÷š±9˜�ކ† ”ã¦P^—Ï‚ õÁl³Rl6c°šñrÓâëá�깆e[ 8®q«‹¬œRÒÏ‘SP€Ñh¦È`@í5n( $n,ÁF#ϼûÛSÓØ¹7ƒ�  ¾ß¸‰M»w3{Êd4ŠÂÓo¿KYUK׬#:"‚0‹…ÿ¾þ—Ÿ}&-v;÷=ýUµµ˜‚ƒY»u;Òö2sÒ¾Y½†ÿ}º€Èð0*ªkxÿ‹¯èß§qQQdääòöç 9oæé¬Ü¸™¬üô:=ñ1QDGD´{/r‹ŠyðÙçÑh4(ŠÂÇß,A§Ó2l@jêë¹ÿ©g)«ª"Øhä»õ?°=-�™“&¢( ï-ú’U›·PXZ†9$„/–Ozv›w§ÎÚ­ÛÙµ7ƒY“'õÂYvj8~²'¡m){xÿ³Eô‹�ãþ?ÝÖ£ûZ·eÅ¥eÜ|ýµÝXûèJÙ›ÁÇ‹¾ÆévN}ƒ�‡�óçÎæ¬™§¡œDj/¿û×_q)#‡ 9ìzE%ex}^úÄñ>l6 KÊHJÚéòµ›¶’ž™É½¸¥Ã²§^ý7þâ*ÆŽIv^>¯¼÷a»„…ËåÆãõrÆÔ)üöŠËP…µ[·±nëvž¼÷®nÅ·rÓfÞ]øgL›Êo.»ä^鑳55±/7�‰£’:]^RQÁÏô:%ç]ÌÈÉ¥¹¥…ç¸EQ(«¬âíÏâpºhqØùìÛeh |ù5ÌšÉË¿'.:Їn»�FCCc#ýïãlNÙÃÔ1£6qïM­mQµZ-ËÖoà…‡ Øh$iè{ýM|>ßI•c8žH£ë´eç%$PXRJyee�îkÊø±\pÆÜÝGg2²²yãÃO˜=}*OX°ˆm»S¹àÌ9�å{öîcÈ€þìÍÌ¢¤¢‚ø˜ÆŒŽªª8�.Ò3³9t›wîÂÖØÄ ý>x0N§“ôÌlF%eí¦- î߃A�Ãål¿¢ªŠÝé8].÷O`ø�Á�e¯—�)©—WÊÄÑ£0þ̨ /eÚÄñœ3ûôv¿7j$---Ì_¼„Ó&O$$8ˆ]©éŒ6”=û()+'6:Šñ£G¡ú? |>©û2É+($$$˜Ic’1¦Íe½Íƶ])4Û Ø¿Ýkli±³{ï^ªkëˆ�ŠbÌÈá�¶ ù…Å�Ün»ÒÓ1èôL?&ÐRÓãñ°sO¥•UXL&&ŽI&$¸u„‡ÝîÀãõâMÙ»�¢’RŒF’GjµPTZFyU�—Œ¬ì@\™¹yìËÎÁ ×3.9)ðEÒ`³QRQAB|6íØI‹ÝÎÈ¡C˜Ð�›�œü|ìN';ö¤1zäp´þÍGƒVÑ0eÌh­V Ëʺ¬¼ëJf~>•µµh4öåæ’8p`ëù•–N~I)áV F%Žmyu5 ¶F¬VoÞÊù³N'¿¤”¨ðp JKÙŸ›G|L4§OOnQ1Ûö¤b6…0{ò¤@›^ŸÏÇöÔ4òKJÑjUF'&2°o›šIÏÊÂíñ°iw c‡'¶káXQSCvA!N—‹-){˜<:9ðöfç ÓjI2$0zÇát²gß~FOdù>p ƒúu855ìLßK}ƒ�èȦ�ƒA¯Ççó±9eãFŒ`sJ UÕ ê×—q#G ( �MÍd0t@Ön݆Ãé$iȆèØvM]=›SRhlnf`Ÿ>L•ÔíÑ2m㊉Šdê˜Ñ�V²»öfпO}p¹ÝlKM£¸¼œèðp&ŽJ"Ø?ꩨ¼·Û�‚ÂÖ=©™;e2-vk¶nEaƸ±íÚìÏË##'—ËÍ ~};b8Š¢°yw »÷íà×ãñz°;~®ýX.—›�à 6ìØ‰L3¦Ó¾è]IÍÌ$+¿�×ËðAƒHòÿíÚR÷g2fÄpÖmÛNUmCû'0®Íˆª†Æ&¶¤¤PÛÐÀ¸#³—ÖsËhh}/ò‹KPUEQØ´{7:­–ãÇJQY9…åå457ûGŸ%²#-�ჱ#-�VË”1£q8�lÚ�ByU5æ�`&%'Ø_f~>©™Y(À˜áÃØ·5uõdæÐl·³iw ““G±/7�èˆpJ**(­¨bö”IìHßˤQI�ÞðeUUÔÖ70bð À{²aÇNÊ*+‰�‰aú¸±hU•íiixÜ­ýé-&ZUKyu£†¬„Þ—›‡9$˜øèh KKñù@Q6îÚÍÕç�@V~»22ÐiµLJèÑ/DoVu4»]¸¥˜8Bª¢HkQ!„h”Ö$a�³E®/NZEC˜Îˆ"éA!Žš#�jëÐé•B-êm�„‡Zù¿?ßÎÒµëyéÃ�i±Û™1~7^yy»ùûj0t�‡êººÀ}0‡ÓÉkŸÌgwÆ>ââÒ?¡C§¥C‡šÍÔÙlmâl¿<Ìb!§¨(ðØÚÅýó¾11<|û­|·nϾû>.·›ÙS&ñëK.¦ÎfÃétòñâ%íž“«ÉDM}}à±¢iÿ¹¨ª2xæX’£ÝC¶íÞCxh(ƒú1!y[w¥´[>oá—¼öÁG,_¿�ºzŸ~¹˜—ß�‡Ï磱¹‰·?ùŒgß|›=û¨¨ªæÕwç±xÅJššxï³…|ðùü°}'�ÍÍìLKgÅÚ àOD=ò«—–Ó`käó>eå†�àO®¼ôÖ{,^± »ÃΆmÛùï ¯RßæC§»*ªª))¯`F›mM?¯ÇKúþLÞþä3Þ�¿€]i鸽æý ‹–|XÿÃ…_2oá—457“ºw?ÿzöEŠKË;Ývyeÿ~öEÒ3³ijjâ��>囫hjná¿/¾ÂÊ ±56ñõ²ïyêµ7ñxZ{ë6oeÁ7KùpÑx<¶§¦òÄ˯ãv»xùÝy,]½‡ÃÁ¶Ý{xôÅWIŠO¾\LQIYk¼ ¾`Áâ%´Øí¤gfñŸç_¢®¡�Šªjjë¨kh ·°õÃú›«xíýy4ØÉÉ/à?ϽD†¤GAq)ó¿Z‹ï¼Gm]=UÕµ<õÚ›ìÏÉ¥±©™Â’R\n7YÙxÂ(˜îÐj4467ÓØÜD|TÔO~~Qi¶Æ&Ê««É+.Áçóñä›oó΢/¨³ÙXµe+w=þ55à¯6üté·<ö¿7IÍÌÂíñ²hù÷<ûî{¬Ú¼·ÛÍÛ >çñ7ÞâÝE_àÃÇ’5ëxéÃ�û|ê­wøð«Å4µ´PPRÊÃÏ¿HZfÍö² Šðz½¤îÏÄér·‹µ¶¾�âòršZì¤ffðÍšµüûå×(.¯`n<ó›víú•�>áõO?cíÖm467wxý9¹Üûä3ddç`w:ùzå*ã­Àòçßû€Çßx“Õ›·PU[ËËó>æµO惿?ùëŸ~ÆßŸ‘ý¹yä—ðÏ—^aù†Àßõ®ÇŸ$#'—¦–Þ\°�y_-îÖûˆ+'»ÓÉ—+V¶‹ë½/¾$§°0𸪦–>˜@vA-;Eeå”TTâñxøÇ /3é·446±dÍ:î}êY›Z�ÇæÝ)¼öñ|Þú|!^¯—o×®ã‘×þÇSo¿‹ÝádWzÿxáeœ.øÛ<þÆÛTT×PÛÐÀKó>fÑòïïKV^>u ìÎØÇÂe+À�õÏ_fþkÛDZe÷æ}õ5ϼóv{ë$Ô<û|» ŽÃùð«Å¼øÁGÔ64P][Ç“o¾Í÷7µž ��¼òÑ'<óÎ{äÑÜÒ³ï¾X^U[ËýÏ<ËŠ�›¨k°ñì»ï³sïÞ.÷õÅŠïÙ‘Þº|ÍÖm|ðÕ×¼<ï#\.7;Ó÷òà³/Ðl·S^]EeM u 6öçåðÆü|ôõ7|ºd)Uuu´ØíÜûä3|¿qN§“i{¹÷©g¨õ�öZ¹i3�¾þ&Õµu”VTòðó/²gÿ~ê),-Åát’º?ŸÏÇ¢å+øü»å¼2ïŠ+Êijiáù÷>ÀáïvïÝÇ¢åíß“oV¯Áîh=ÏþóÊkø|>öfåàöxÈ),¢ª¶Ž½ÙÙ|òÍÒvÇáë•«Ùº' € ;vòù²å<ñæ[ìÏmE¶lýüçÕר®«#¿¸„ûžz–-){ºõ~ ÑSd®q4˜µ¹9(„$ õA¨ŠÜž;™i áú 4Ò=@ˆãJyuu»ÇeUUD„†Ro³aÐëùÃu¿àõýƒo»™]{3X¿m{‡mlKMcB›‚‹ôììÀ j€ï7n"¯¸˜ºŸþé\ñ…Dø«¨¨®é4ŽƒËÛÇYzÈò®Ô64`5™ùó¯¯çÿþ'wÞxk·ng{Z:V+¦à`¸õæÀÏ�7ÞÀµ^Эm`1™ÐiµTÕÖ~רÜÌÇ‹—àr» µRYÓþõ•×ÔÖý}ˆž%„=dË®&��¢(ŒOÅÒUkÈ/,¦¿öí)ï¾õ&4 Uµ3ù×3/’¾?‹¸˜(Ün7Cú÷çŠ Î`\ro<ŸÓ§´NÈép8 å·×\ þ>ÊlÝ•ÂøQIüæêÖ>Ńú÷ $µ¶íÞCeM-Ýq;FƒŸÏÇso¼Ã·«ÖpM›ÌÝQUS @LTD§ËµªJDxx»±ðÐP®¼ð<¢ÂÉËÜÂBvîIãá¿þ‘0kë‡äûóòÅw˸ý†_uØöWË¿'yøðÀkLJÆ[ŸÌçܹ³X±n!ÁAÜuÛÍhU•»ƒÿ{æ6ïØÅtÿ$¬%eüóÎ?£Óé8kæéÜýïG)­¨Äb6‘‘•Å¿îù+‘aa¸=^ÿà#ªkêèØ¿ÇëeëîþrÓ îߟÏÇ;Ÿ. ¼²ŠñÉIääàt¹8oÎ,ªkëX¶v=wÝú{úù«Ó¯XÉg_/áá;þ@YE%wÜô[† jíãlkjdof6—œs&³¦M¥²j ×]vñOzºòÅwË7¥|7{229cÚ´@EÝO1gêveìc@ŸxÎ9m;Ò÷²/7�gᅦP‹ŸÏÇcÿ{“EËVpË/® =+›‡n»…‘m*>íVîüíoðú|lܹ‹ç¼ƒ^Çà„^õ· mln¦¨¼‚n½‰ÿHßû²{ß>®»ð.š;›ŒœœNç}K8€Ó&ŒgÃŽ�ÜxÅe467³àÛeÜþËk™âïyýõÊÕÌûzqàX´8D‡‡ñ§ë¯ëôõ§ff2kÒÄ@�í’Š©ÜõøS8œÎÀÈ!«ÉÄŸ}=Š¢pöi3xè¹8ßßÿ¼®¡� g_ȳg°ü‡�Ì_ú-3'MdKÊôíÃ_oø5ãFŒ`ýöÝz_R33™5y"7\Ö×Ó¦r÷Oápº0è_±pÉsغg³&Mdú¸±|¿qu <}ßÝ�¸=zî¾Y³†kÎoý[¶55ñì_ZÛ% èÛ‡gß}ŸÇïú ñq465sóßÿIyUýââHÝŸÅ﮼œéãÆ{½ø} IDAT�Çú;¸üì3ùÝ•—óýÆM\qÎÙô��i7jíÖmÔÔ·Æì�ãïÏ¿ÈâÕkøÅ­qdòÜ÷j±àõz¹ý_��™—x'=+‹?^m %D¸ÕÊ®Œ}œ1m*øÏ… I#9szk�y�FÃnÿòÏ¿[NTX8ÿáTU¥º®Ž;{¢[ï@^QÏ>pÁF#N—‹›þ'ùÅ%LHJ"3/ŸÚW�{N`ýÊšž{à>TU%-+›à  þqûm¨ªŠÏçãŽG#3/ŸÉ£“Y»u;—�yF`âì˜ÈHJÊ+9çôaÌ�:……ËV´û{Ù›�ÃÓ÷ßC°ÑH2veõ–­46·ðÄÝwbÐëhljæ�ÿ~„ŒœÜÖþôÛö§_»õÇ[.ïLßËcwý�øè(›šùdÉRîúÝoU‡ß­ÛÀ;Ÿ/bRò(é;/z�^£¶Vz\ÝX[ˆŽŒª£*ÿBq�ª(D襒ðd¥ÓhÓIrPˆÞp  \àñ!…KÖ¬crò(‚ƒ‚ؼ;…Ü¢bþpÝ/Ø—›Çÿ>ýŒgî¿‹ÉÄ€>}Ðëõhüݕҳ²)(-åÜÓOcgú^þøËkÛܺ'�3¦M <¶;œ˜CB0PXZJf^>ÃL"¦ee‘ž•ÍÈ!ƒ©mhàÛµë™9ib`ùêÍ[9cÚT"ÃÂÈÊ/`kÊî¼ñǧ¢Ù‘–΢åßóÄ=wl42¨__tZU£aøàA4;ì¬øa#gNŸ†ÏçcÞW‹)*/çï·ßÖùñô¿šúzÖoßÁé'fií÷ÕÊU 韀VUY¾á¶ìÙõžÏ¤äQ,Ûð3Æ�Ãb2‘•_ÀÎô½<ü‡[4~qlÈ¿N{@ye%Å%\|ö™TÕÔ`0èµ²uwJ»áÔñcýw#ÃÂH6„ÌÜ<âbZ+¹¦·©Ì3r8AAAäÑ7>€“Æwºÿ˜È–¬ZÃÒ•«3r$“ÇŽaŠÿfü¾¬$ô¥¢ê`Ò®_|Yyù�nËn·³pé2¦MÇÀ~Û+ø|]‹C¯’G$þßj6*â2²r胭± [cñ±1,]½¦ÓíîÏÎáw×^xœ”8”‡þr; �•›Ï¤±c­8ƒŒÆ�AV^A A8|è`tþN�Ñ€^¯Çãñd0b æãE_3kÚd†Ði‚RÕhˆcá’eÌ�1�aƒqã/®ê4Ö¬¼Å­‰ÜèˆJË+±Ûí�$¬K‡/±£¥¹¹EiM*¾ÖR÷ÚnVYý˜ŒœF' ôäV…™“&²à»e�ub"#Ú%†µé5m1…0 oŸ@"Ëj6ª?MÁÁ<ÿà}¸ÜnJ**È-*&·¸8�Tþ)òŠ‹Q¥]btÖäI|´øªjk¿›3uJ[€+Ï9€†ÆFJ**Ù’²ŸÏׯÿüš=er ™1¨__õëËÞœûÿžfO™|p_S&óÞ¢/),-#6*Š…ËVðÉ7K˜�„¨Åd"È`´j5…£Ñh=à¸õ&|>•5µ••µ¶¢ôüø¹ž‘“ËäÑ£=ȵªÊŒ ãÙ¶'5°Îà„~�sO£Ñ`5›pwcÛÿýÛx¼^Ê««),-#eÿþÀÅÛm[ð†Y,”WUµÆ–›ËegžhÃÊèÄî½W#¼.½NGHpn�»ËõOŸ8!°¯¤!ƒùïßþ‚Ýá °¬Œ¬üê¯;62‚å?ü€A¯gìˆD.?ûÌÃÆ2eÌè@,?&53‹)£“«¦�`ž¹ï‚ƒ~^ËêáƒÝúý—]XˆªÑD®?QA�ÍFeMM»–µBkf�‡×�çp@BtBAÁ¬•*T!„i…p}µ®\2×ÒIC˜sP’ƒBkV‹™U›· Q4\{áù„Z,dääðì»ïã÷‹‹åÏÿy«ÙLyu5×_|!ñÑÑD…‡“Ç_yŒøè(*jjÔ·/ÓÆŽ`wÆ>VmÞÂé' øç?Äß¹¯ ¤”þññ�8N›8žå~àž'ŸÁ¨×ƒ‰ƒòݺõŒÜz/xäàA¼úñ'õ*kk6` çÏžØÆ°�¸ÿéç5›)­¬äìÓftëáôñãXþÃFþô¯Gˆ‹Ž¢´¢’äÄaŒ1U£á¦«®ä•�>añª5x}^î½ù÷�n+Ôbæ­ ¹æüs±šÍ|¼x # "Ìbáú‹/ä©·ÞåOÿzSH0u 6îðãófžÎžý™ÜñÈcDE„SR^Á³f¶›wPô.Iö€-»ö ª*oû[ ¸=^¶¥¤rùùç’‚¦CúïZÌfl¶ÆÀc“©ýÍi«ÙDCc›å‡<ÿ€93¦ddËΖ®\ƒÉÂeçžÍ¤±£©o´QQYÍß.o÷œø˜˜N·åñ¶&µÆŒì8§V´¿r°´²‚A –»\.*«kˆkÓº2¨‹Ï6[#U5µâзoÇíºÝ45·`9ØÛX£Ñ@cs!!í��)8„Êêƒ-»º®×ë¸ë¶›X±nŸ|¹›­‘Ñ#‡sÝe—æ!<àŽ›~Ëò5ëùê»TÕÖ’8x¿ºòÒɪ[#Íö–¯mø�Á¸ýþ]—žpÝe£( :�“Î@Ye}ôq.9sn»/°ŸÃÖÔÔ¡·Åd¢¾Íym éØ[sÈÄì‡>>Àãñðöç‹Ø¸k7}b¢’�Ðéöº£¡± spp»J$³?™Uok ôζtñw†?iõÆüϰ;œ éŸÐî\? ³>áu ­-}UUmw.jUSp0u6SÇŒFVmÞ·ëÖ£×é¹ì¬38oæéöq¨½Ù9¼ùÙ‚ÃÆÕ] ��ÄD¶OÆX̦vm‰ÕCÞ/¥‹÷ØçË–c5™œÐsHH§í[;ÄÑÔÔ!)d=äÜ >‚ÞóŸ·œ¥kׯ�þ XM¦=׃Œ�oßÖØ„Õln÷»Cÿç§ÆÝ¶¯û�V¸Y… ìÛ‡¡ýÚõàÿí—óÝúõ¬Þ²•÷¾ø’øè(~wå o3 ¡}Ü]Ÿï‡ª·Ù‰îÂC»Ÿ¬÷Ñ>¹Òö˜ÕÙl¸=žN{áKJFô6‹Î@­ÓÞÛ¡ˆŒE§G•ê!„]80'a�ËŽ³‡ ‹cǠѪ7JjPˆrÛµ×tøÝ{�ÿ7ðÿWŸw.IC†ê¿_3}ÜX"BCñµèyÞé§ñË / ¸¢‚~±1�Aç:­–‡ÿp+yÅ%ÔÛlD†…Ñ7öà½ók/<Ÿk/<Ÿ¦–~uéÁŽon�‡ß]yY»˜¢ÃÃyæ�{É.(ÀB¿¸Xœ.9¹ÄGEÐ/.Ž»#9…E˜‚ƒéÓî~å¤äQüîÊ+È/)!62’¨ð°À²'î¹³ÝþâãøðÉÇ2xôow�SX„­©‰ØÈHb£"ëN�Lò°¡ä— UUöë(¸;b8/<ô@`݇n»…Ü¢bââµòñ3O–E„†òŸ¿þ™ÂÒRšš[دoà~§A¯ç¡Ûn¡´²ŠêºZúÅÆŽ3Àßüå8çôœsúŒÀã~q±íö%Ž>Ie>Ÿ�­»R8wÎL.8cNà÷¥å•üû¹ÙŸ›ËðÁ­ÕS‡öß­¬ªaЀƒ7[«ªk“xz<ªkkÛ%ŸººÈ(¯ª"yĦOœ€Óéb冸`Á"ÆŒN˜ÕJTD8W_tA`ýz› —«óVY!ÁAÜ÷ÇÎK~#ÃÂHèÇÚ�[;M®Ü°½NˈaC»ˆô 0«•¾ñ±íªõšš[°µIˆ Ój1‡„PS_hûétºX²r5çÌ:�0«•êêÚvÏ©®­ëV•Ys‹—ËhçYTRÆÛŸ~ƪ6rá™së9�Nêl\qÁ¹\yáyTTUóþg‹X¼b¿ºâÒ¯Íj±ðçßüÀ³;TÕÔv¨Pú9¬3õ ç�´55áöxꢚG«iýÀ��ŠD¯ÓQ[ßpÄ Âk(™ùí«Q+ª«ÛõÖ>’ûb›w§°-5•§ï»›0ÿ—ÉëŸÎÿÑçukh(µ ¸ÜîÀÃUµux½^"ÂBsR®•áó0yt2WŸw.Š¢PSWϗ߯l·NyU5Û$ºË*«£|L\ÖúÅÞÜÒBCc#¡¡”UU1t@¦Œ�ÛãaýöüïÓϘ�4òG«§ÞülSF�æªóÎAQªëê:ÄÕÖáZI†‡†vè…Þúžþô^áÕuu¼÷Å—7"4´CÏõòŸÇ¡r‹ŠX¸|�þíŽ@åã×+W³7'§[Ï� k÷¾â?FÇ¢ÊíËïWápºxå*ù¶¥¦–—T”söŒé\^¼„7æÆÓ÷Ýó³ö×ö\‰ ¥ª®ýgíwë60¤ƒ:Vœû©¶ª;ä¼kû·nµb4¸ÿ–›¿wº\——í "Do2h´ÒjTü$FUK�úÓªø…BœzÌIØà²Ór˜®"âø¬êdîj!z™VUÛݧQ¥ÓÁÒá¡ÖN;+ŠÂÀ¾}:ü¾­�  vƒ´uZ-ýââ:¬d0¦O0 Œ1¼Ý:½¾ÝÜ…‡²˜BHîÆ=öC)ŠÒé=š@lFãa÷{€Õlîs[ªFÀ>�/EQˆ�Ž t�Ç™ù(Ë-,¤ª¦†‰‡ÌçE|L4Ûvl‡·vãlM­•3éû³Ø—“Ãè6hKW­ ´Þûnõ:Ea耎‰¸C-ZºŒÏ/Åçó¡×ëè�¢(h4Æ&�dë®JÊÊhi±ó›ï±;mïÏz½W]t>Û÷¤²èÛe8ðÏÏ÷ÃÖí|óýj.=÷l‚Œ?~Q”<"‘ý9yddgƒÄÅ[Ïgõ›:]LÒ¾_·!�Øüaû6m߉Ñh`ÌÈlض=p󹤬œ©©ŒMù£qTVUóèK¯RáoM�ÑØ!IÔl·óØK¯Z³FE„c1‡´«¤:P}”8d05uõlÙµü7Éçù _-[ñ£ñº­Î$@ƒ­±ÝùÒbw0ÿ«oˆ gP_Ú6“ «ªJ}›d¬Ûã¡¢ººÝOÛÉf»2aTi™YìËÍ ±©™o×®gÒ¨QÝ~­‡ãp: 6*�*jjس/o›ããíf;–Aýúb1™‰3ŸÏÇçß-cpB¿@òñÇØ�b£¢PŸÏÇ÷›ZÏWo›DÈâÕkhñÿm¬Ýº�²ªªv_¨Ÿ·ŸÏ‡Ïçã“%ßNB\,KV¯å•�>Áãñ UU†$$àóùÐhTZìv¾Z¹Š¢òòÎãr8‰�РĵrÓæÀkÄÑ‘çowëñzY¶~C‡m8ç&�Jbóî K[ç1­®«cÕ¦-LJþéï©ÃéÄçóÙ:Z©Åá`Ãö�x½íG�µ×�8*‰Í)㨩«gå¦ÍLLNê°ng²ò øjåªN—ÙNT�&0ËÖÔÄæ””nŸK“F�bÉÚu�Ïœô¬lR3³ºõÜ#ew8ˆµ’ƒiYÙ”UVÞëgÞyŸïüï¯9$„¸è¨vº‡ûl1èõ(ŠB¾ÿ\ihlbÃŽƒó`N•Ć;‰ÛÂÒR>üêk´Zõàöý­e�55´øÛ*ïËÍ%·¨¸Ë}Ð|>–®]þs÷Ó%ßòÖ‚…2ÿ 8n˜u†vߥBtEU,ÒZT!D7)€UgĤÕ÷v(âg0iõ’BqÂ� £lËÎúÄÓ¦\÷€ c’Y±n×\ÒZ½×¯Oÿ÷ôsXÌf*«ª¹ð¬¹$ô‰§¦®5l â�ǞƠÓakn憫¯Àh4ÒØÒrØÎ�3“WÞý�‡ŸxSH0e•Õ\qÁ¹hµZF„ÑÉ<úÒkÄEGQSWÇàý™5}êÏz½ƒû÷çöß^Ï'‹³jÃ&ÂC­ØÑh4\qþ¹œ>eR·¶Å…gÎá•w>$*"‚¦æf¬\ÔÅ|Y�}¯½ÿ=þ4f³™Úº:n¸æ*EaÆä ¤íßÏ?Ÿ~�èˆpÊ+«˜6aI‰?>Ê"¡o&Šº†FB-&fOk|B-æÎ˜Æ o½G\LÍÍ-hµZþtck• ÅlbãöèT•_\z×\|>ó~Å’ï×àr¹ÐétüéÆ_wëØXÍ&jëëyî�w¸í××a0´¿ÐŒ‰ŠâÆk®dþâ¥,_»sp0u ÄFGqËõ׿ZlK´m’ÑálÞ�Â,ÿ¸eUUüå‘ÇÚ='*<¬]iygöíÃ%gÎå?¯¼N|L4•5µô��㢹³»õZ̤äd¾ü~w?þ“ »ÓAâ �¬ß±ƒäÄaÄGGaw:ù狯ð—ß\ØDŸN«å÷W]Á ÌcÃö�8Ý.|^÷Þô»n' .˜5“·,dÝÖmT××3!) £ÁÀ ïÏãþ[Z{vÇFFòçÿsH•55üöŠËˆ £¶¾½NG³ÝÎÿý.—›¿ýö7¨ªÊù³fò/¿Êÿõá¡VŠË˹ì¬3‰ ¥ª¶Ž�/!Üj¥o'í�ÏŸ=“·,dm›¸ z=/¼ÿ!÷ßrgN›Ê; ¿ %c?õ6ÓÆ�a÷¾ý�燚-,\¶—ËÍÙ§MgÆøq<øìóÄGGSVUŨ¡C™{˜¹»Å„¤$îæ9úÅÆP]WÏø¤‘|·n=_¬øžKÏ<ƒ0‹…—?ü˜ë/¹¨ÝsÇŽÎi&´‹#ièΘֽϮôìl>^¼„‹æÌîðþПAýúr×OA½ÍFÒ�!¬Ùº�?l$9qØa·}þ¬™ìÎØÇÿ}Œ¨°plÍÍÌ?î'ŸŸãŒiSyäµÿñÐs/àóù0…„08!�ß.cP¿¾\}Þ9¼1k·nG£Q¨­oàO¿ú%ø?Çʪªø÷+¯qÿ-7uØv°ÑÈôqcyü�·Ô¯/ÕµuL5ŠbbzòèdÒ2³¸û‰§‰�ަ´²‚‹æÌT"‡™-�þô£†ÁÂ��=ItDv‡ã°Ifƒ^ÏM×\ÅKÌã»u@Qðz=Üóû{ìX ñS¸yWãl–Ö·â°¬:#Ü „â'2iõh õ.G‡öüâø£ `Õ0ªr«UˆÁ+ÿx¸Ã=ÖÞðëK/9ìòþñ�ÞÛâhQjìÍr•Ñ î{ä n¸ærâcb()¯ 6:*з¦®Ž‡†gþñ ��ÔÖ7�ße«ÈδØ•–âñxè‡ù�9ùêê(­¨ Ôb%.æÈË{}>ÅeåÔ7صXˆ‰Ž ô,þ)lMÍ—–L¿øØÃ&j¼^/¥å•ØšéßaŽÀÊšªªkˆ‹Ž&ÔÚ½Š°JË+©©«Ãb6Ñ7®ë8*kj¨¨¬níÝ'.PAèñzÉÌÉ%È`¤¿Öò껃‚’ :ýâãPÂñ),)¥ÁÖȈ¡ƒ»œŸÏívSZQ‰­±‰ˆ0+Ñ‘‘]Æ­Ó¨˜t=7±Îf£°´ŒˆP+qþ »£Åér‘™_€Q¯g@Ÿx¼>éYÙ$ÄÇf±PXZFu]IC‡Z‡ŽÝá ¿¤­ª%!>®[Ïi«¬ªŠŠêúÅÅf±PRQImC#âº;ïáÉ{îÂh0PZYIÿø¸@õcf^>ÿ}ý Þþï¿).¯ÀÖÔÄ€¾}jsqât¹È-*ÆîpÐ/.–ð6mr·¤ìAQ”.“,�ÅU×ÐÀHkÏŠêjJ**‰�Š$6²ý€†»�}¹yíz“W×ÕQ\^ALd1GÐ:Óçó‘SX„Ãéd`¿¾ dääb ¦oL 5õõ””2 oŸ@Ÿø¶jêê)*/ÿYq¼<ïcnÿåµ�.óz½dàõúØ·/z�–ô¬l"ÂB;ŸÃ½.[SÃô?¢ù*[SyÅÅ„Y­ô‰Ž¦±¹™œÂ"†ˆA¯÷ÿ=–¢Uµα1�NÖåßinQõ¶F†$$` 騹²¦–’Š úÄDÚåâo_ݶ?½Ëí&3/�8p@·¾#Zò‹KP…Aýúþä¿O!Ž…f�‹—£·ÃÇ)³VOˆT€!„8nŸ—:§·¯{Nı§U4„é�¨Ò]B!Ä F„½ä@‚pø�!–µM�ÝO Ñ]ÁZÕÖ£|>_ AØv"ã$ßyô??yÛ-v;o¾ˆ.¿´]¯sѵ½Ù9ìÙ·Ÿ«Ï?··CBœ„ê]Zd>BqƒFK˜^®å…B9>ê]ì2/áq'È?ß ô Bq"’ �§ �æ§WwŠã‡Ñ`è²Ntnø �ÝštY!~‹Î€ÛçÁÕ͹KÅÉOU4„ê{¿e‘Bˆ“ƒ‚B¨ÎH‹ÆMƒ´=.((Xt‚dðµBˆ˜Tö’»½NÛi›IŸÏG‹ÝA�ÑpT[3  * Í~L4µ´d0tÚÖãõâp8Ži+J!„=ÇëóQílÆã“KëS�‚B„!­´BÑÜ>/õ.» LêEz�ŠUgD•{vB!Np’ âcPµker[!„âhsû¼T;ZdTÿ).\„^º5!„èaÍ6—S®;Ž!“V'ó !„8iȰV!N1Ò^T!„èZEC¨TéŸÒ¬:£$…BÁªŽHƒ J9V -‘†`I !„8©H‚PˆSˆ‚‚®“v—B!„8: þ–SâÔcÖÊŸ§Ï‹K®CºM§Ñ WTI !Äq'ÒdéíNJ’-â¡*2÷ Bq¬é4*( �»·CG™$…BÏEÁ ¨è .Ÿ·×#­G;¡  ÓhÐ)I !„8åH‚ð( 1õvBtʬ3 ÕH’P!„è -7õ.{o‡!Ž³Ö s !„8¡x}>š=.š=.¼>IªŠB°ª#HÕI+Q!„§,i1*Ä)@£(XõÆÞC!„8¥9¼êœv|2~ÿ„¥�‘ iÛ.„âæôzhö¸°Ÿ‚ô•`U'S°!„€”cnwç_nO×=á»ZÖbwüèöݾCF†ºŽÇëÅëõv¹ïCŸïêâ5t¶m—K&ÆîmÒþJ!„è}�J„!UF©Ÿ�4ŠB˜>H’ƒB!Nxz�J¨ÎH”!“V�ö$Ÿ’D§Ñ`Öˆ6„®’ä Bá'„LjÏçcof6Ùù\tÖÜv˾øv9��M4;ìüþÚ«ÑøÛ@ÚÞùtA#ýúÄqÆiÓÛ=oå†�Ì�1­u]»�÷>[ˆÙdÂÖØÈ�×^MZÆ~¶ìNÁétrÝe£ª*~þaV+N§‹ß\}9»Óö²3-�†×\r!±ÑQ�íïLMg«ÿù×^z1>||öõÌ&‘a¡œ;gV`]»ÃѺÿ�êm6~íÕèt:Zì}ñUþu÷=|„ÅáXõFi™!„B'<>u®\] ÐÇUѦ7žô7P…BœºÜ>/v�‡×}R\£è4*F�ŠQÕ¢Ê÷·BÑ)2ÓœN'Ù9Œ1œ›�òªj¼>ë·n#.:ºÝº�ÍÍÔÕ7pÃ5W°rÃF2²r9l;ö¤2eÜXÆ''ñÊ{2gúÔ@òÐëõ¢is�“‘�KòÈáLŸ0žoV¬"¯°ˆõ[·qû ¿¢°¤Œõ[¶j±0{ÚT’‡òÑ¢¯¨ª­eãŽ�Üöë_R[_OSsK»Ø6øŸ_\ZÎú-[Q…óçΡßxÞýôsƒ€Ìœ˜—ƒ�tZ8Þ4Z¬:ƒ\O !„8©i &­z¼>¯‡×ƒÓë9!æ,Ô( �ŠA£E¯Qå{[!„èBÓôz=k6nÁçó±iÇ.|>‰ƒrþœÙÖ­®©#*"€Ø¨(*ª«Ë*«kˆ�ŠÀb6ÓØÔX–�_À þ �Çc“F0}Âxêm6r ‰�‰AQE!6*’ŠªjfN�LRâP*ªª©®«Ãh0ÒÐØÈŸ-âËoW` n[àùÑ‘”WUc ¡´¢‡ÃAiEu ¶ÀºÉ#™1i ��dçåËÖ]) 8‹Ét”�°ø) Ò:C!„8î(€UgÀ¢3 ·¯Ž_!Z=aÒ‰A!Ä)F£(©:BuF¢ !D‚±ê ©ºã¦š^«hVuXý­R£ !XuFŒªV¾·…Bˆn’ÌA2°?¹……äsÖÌÓº\OÕh#±Z«^Äh”ƒËðúP4/ KJ™3}j»mý°m;û³óøÕå—„ ‹{wIDAT„âß–×ë T.[³ŽÊên¼æªÖ ×_y)…%¥¬X»�ø¸ò Š˜;cZàùOëóOŸ2‰åkÖóåw+³ZøhÑWXÍf.8swì$#3›_^~ :UÃÚM[˜3cõ6{3³1tÈÑ;À¢[EA'ó !„Ç­`U‡NÑPçràñ�øí¼Nª¢`Õeg!„ŸŒÓª‚ü_‹>Àíõâöµþx?¾£Zm¨QTEƒêÿ¯NÑ ÕhŽ›$¥Bq¢“a™8&™ù_}àþ �D[[¯—¦æf¢"Â(­¨ ¿¨˜ÃÓbw (Ð'.–‚âbúÄÆPßhÃܱÂï€ÌÜEÅ¥üöš+p¹\ÌñÏ�(z�A£JU‚BqœÓiT" ÁØ\š¥åh¯3ªZ,Zi)*„BtEt ºN“ùhMz}>¼øðúZçó'}þÅÿÃ�V(h�?1¨Q4r?C!„èaJ�½ùøo$~‚úpá—\xÆB­*kjÈÊËgÚøqØššY¹þ.9çL6nÛAnaæ�.d‹–.£Åá`ìÈ$�H ¢ª [Sƒû÷ìkWÚ^¶íN <¾àŒ¹8�NÖlÜŒªU¹üüsÙ•šFÚþÌÀ:W_t>Å¥lÛ½�¢pÙç` ,Ï/,fÍÆÍh´*Wœw( ó}‰V£2}Òx† X7eo[vî<>oÎlúÄÅðõò•\tÖÜ;΢k½Unn !„' »ÇMƒÛqBÌõs²Ñ `Öé Ru½ŠB!„BÑã$A(ÄIJ§Q1éô½†B!~"¯ÏG£Û)Õ„Ç�A£Å¢3ÈÀ*!„B!„§ i1*ÄIʨʟ·Bq"Ò( �£ª¥^æ&ìQª¢`ÖäºI!„B!Ä)Gþ%,ÄIHõOÜ-„Bˆ—^£e¦ÙãÂærâC- ¬ÕcÒêPd†#!„B!„§ I q2jåO[!„8Y«:Œ-6·“i;zÄŒª³VÚ‰ !„B!„8µIAˆ“ŒFQÐkÔÞC!„G‘FQ°ê ˜´:šÜ.™ŸðgÐkTÌZ:é² „B!„BH‚Pˆ“�Ì¡#„Bœ¼TEƒEg X«£ÑíÄîq÷vHÇ=ƒFŤգ“TB!„B!D€d„8‰(Š‚^„B!ÄIO«hÕñh½4¹]´xÜ2GaŠÐTˆV�V‘ŠA!„B!„âP’Iâ$bTµÈl:B!Ä©ã@E¡I«§Åã¢ÙãÆãóövX½FU‚TÁª�Ì1(„B!„BtI„Bœ$4Š‚Aª…BˆS’FQÑê Ñêqz=4{\8<îS¦¦Ð¨j Ru¤�¨BˆÿoïÞzG‚ŒVU·� üÿ¿: $ö>LíŽöÂ%Äv8G²¬ÀýÒR¤�êàMÔ¸¦€ˆˆ±ZŒÕb"žÎ§øq>Åót±p¬ûÖc_Ý´ ¼“@wÀô ð»×{øö­Ç4Ïñ4�ãy:ÅÓtŽiÞ^.¬Ì«Å®ZìDAøEîÀ^þCeÆ¡õ8\¾3¼LS,½`ãzVôVqøË�Þ4Ïqš§8Ïsœ/ïižcŠŸïùòþ?•—çòÏM-ëòä¯7p;!lX¿»pm•c¾í{ÆsüÞ Ýë%†™Ö #C€íp±lÔ®õhi ï£.Àedš`àýBØ Cï‘Îñ>@ „�iY±3=|�@ó0 K/Ø0�6dßz´´m€�S`#*3öÝô ð9!lı�‘K/Ø<�6`×Z e»Ÿ§8ÀÊUfú¸ô2€;!ÂÊ=8Z¸"�VlßztG‹W¤<ÀJõ¬8ôaéewF „ʈxÜ;\Ÿ@+tìcTºy¸>�Vf×zŒ­-½ àN „°"½*Žî¾�@+‘™ñÐÝ;|-�VâÁ½ƒÀ „°‡>ÄP¶#ðõ XØX-ö­/½ à›aA½*Žƒ{€Ûa!•�} ··$Â23‡]dʃÀm „pc�}Œ& áÆŽÃ½l=`*Üб1V[zÀ7VÇ>,½ø}ˆ]ëK/øæj×zìE øR{û X‰ŠËd“x_c×zLê+ñëBÇÂõ�­…c|€5ù[| OçÓRë�»±k]V§~ÿÁÑq£ðiâ °V9ÏóüO¿øã|Ч—Û¯6nïÎA`ÅþuTðuŠP$„·;˜ÀVîO ‡y:ŽìIEND®B`‚caronc-apprise-dfec414/CONTRIBUTING.md000066400000000000000000000120331526720371000173470ustar00rootroot00000000000000# ðŸ¤� Contributing to Apprise Thank you for your interest in contributing to Apprise! We welcome bug reports, feature requests, documentation improvements, and new notification plugins. Please follow the guidelines below to help us review and merge your contributions smoothly. --- ## ✅ Quick Checklist Before You Submit - ✔ï¸� Your code passes all lint and style checks: ```bash tox -e lint ``` If it reports issues, auto-fix them with: ```bash tox -e format ``` - ✔ï¸� Your changes are covered by tests: ```bash tox -e qa ``` - ✔ï¸� You followed the plugin template (if adding a new plugin). - ✔ï¸� A new or changed multi-target plugin records each success with `mark_delivered()`. - ✔ï¸� You included inline docstrings and respected the BSD 2-Clause license. - ✔ï¸� Your commit message is descriptive. --- ## 📦 Local Development Setup To get started with development: ### 🧰 System Requirements - Python >= 3.9 - `pip` - `git` - Optional: `VS Code` with the Python extension ### 🚀 One-Time Setup ```bash git clone https://github.com/caronc/apprise.git cd apprise # Install all runtime + dev dependencies pip install '.[dev]' ``` (Optional, but recommended if actively developing): ```bash pip install -e '.[dev]' ``` If you use [uv](https://docs.astral.sh/uv/), one command sets up a `.venv` with every dev tool and plugin dependency: ```bash uv sync uv run pytest ``` `tox` also works through uv: ```bash uvx --with tox-uv tox -e qa ``` --- ## 🧪 Running Tests ```bash pytest # Run all tests pytest -n auto --maxprocesses=8 # Run tests across up to 8 CPU cores pytest tests/foo.py # Run a specific test file ``` Run with coverage: ```bash pytest --cov=apprise --cov-report=term ``` --- ## 🧹 Linting & Formatting Use `tox` to run through the same toolchain as CI: ```bash tox -e lint # Check for lint violations and style issues (read-only) tox -e format # Auto-fix lint violations and apply consistent code style ``` If you prefer to call ruff directly: ```bash ruff check . # Check lint violations ruff check . --fix # Auto-fix lint violations ruff format --check . # Check code style ruff format . # Apply code style ``` --- ## ðŸŒ� Translations Apprise presents its human readable strings (the names of the settings each service accepts) in the language of whoever is running it. Every language lives in its own directory under `apprise/i18n/`. Start by seeing where help is needed: ```bash python packaging/i18n_status.py ``` Each language is listed with how much of it is translated, followed by a bullet for every string still waiting on someone. The report also names the exact file to edit. To improve an existing translation: 1. Open `apprise/i18n//LC_MESSAGES/apprise.po` for your language. 2. Fill in the `msgstr ""` under each `msgid` you want to translate. Leave the `msgid` lines alone; they are the English source text. 3. Remove any `#, fuzzy` comment once you have confirmed the translation below it. A fuzzy entry is a machine guess and Apprise ignores it. 4. Rebuild and check your work: ```bash tox -e i18n # refresh the .pot and every .po file tox -e compile # build the .mo files Apprise loads tox -e i18n-status # confirm nothing is left behind ``` To start a brand new language, let Babel create the catalog for you so its header carries the right `Language`, `Language-Team`, and `Plural-Forms` values for that language: ```bash tox -e i18n # build apprise/i18n/apprise.pot first pybabel init --domain=apprise -i apprise/i18n/apprise.pot \ -d apprise/i18n -l ``` Name the directory after the language code, such as `de` or `ja`. A region may be added when a language needs one (`pt_BR`), and Apprise falls back to the plain language when no catalog exists for the region. Then translate it the same way as above. Only text a person reads gets translated. Anything a program reads, such as a status code or a key in a JSON response, stays in English. --- ## 🧰 Optional: Using VS Code 1. Open the repo: `code .` 2. Press `Ctrl+Shift+P -> Python: Select Interpreter` 3. Choose the same interpreter you used for `pip install .[dev]` 4. Press `Ctrl+Shift+P -> Python: Discover Tests` `.vscode/settings.json` is pre-configured with: - pytest as the test runner - ruff for linting - PYTHONPATH set to project root No `.venv` is required unless you choose to use one. --- ## 📌 How to Contribute 1. **Fork the repository** and create a new branch. 2. Make your changes. 3. Run the checks listed above. 4. Submit a pull request (PR) to the `master` branch. GitHub Actions will run tests and lint checks on your PR automatically. --- ## 🧪 Need Help with Testing or Plugins? See [DEVELOPMENT.md](./DEVELOPMENT.md) for: - Full setup instructions - Tox environment descriptions - RPM testing - Plugin development guidance --- ## ðŸ™� Thank You Your contributions make Apprise better for everyone — thank you! ðŸ“� See [ACKNOWLEDGEMENTS.md](./ACKNOWLEDGEMENTS.md) for a list of contributors. caronc-apprise-dfec414/LICENSE000066400000000000000000000024771526720371000161360ustar00rootroot00000000000000BSD 2-Clause License Copyright (c) 2026, Chris Caron All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. caronc-apprise-dfec414/MANIFEST.in000066400000000000000000000010531526720371000166540ustar00rootroot00000000000000include LICENSE include README.md include CONTRIBUTING.md include ACKNOWLEDGEMENTS.md include SECURITY.md include pyproject.toml include tox.ini include babel.cfg include requirements.txt include win-requirements.txt include dev-requirements.txt include all-plugin-requirements.txt include apprise/py.typed recursive-include tests * recursive-include packaging * recursive-include apprise/i18n *.pot recursive-include apprise/i18n *.po recursive-include apprise/i18n */LC_MESSAGES/*.po global-exclude *.pyc global-exclude *.pyo global-exclude __pycache__ caronc-apprise-dfec414/README.md000066400000000000000000002034271526720371000164060ustar00rootroot00000000000000![Apprise Logo](https://raw.githubusercontent.com/caronc/apprise/master/apprise/assets/themes/default/apprise-logo.png)
**ap·prise** / *verb*
To inform or tell (someone). To make one aware of something.
*Apprise* allows you to send a notification to *almost* all of the most popular *notification* services available to us today such as: Telegram, Discord, Slack, Amazon SNS, Gotify, etc. * One notification library to rule them all. * A common and intuitive notification syntax. * Supports the handling of images and attachments (_to the notification services that will accept them_). * It's incredibly lightweight. * Amazing response times because all messages sent asynchronously. Developers who wish to provide a notification service no longer need to research each and every one out there. They no longer need to try to adapt to the new ones that comeout thereafter. They just need to include this one library and then they can immediately gain access to almost all of the notifications services available to us today. System Administrators and DevOps who wish to send a notification now no longer need to find the right tool for the job. Everything is already wrapped and supported within the `apprise` command line tool (CLI) that ships with this product. [![Paypal](https://img.shields.io/badge/paypal-donate-green.svg)](https://www.paypal.com/donate/?hosted_button_id=CR6YF7KLQWQ5E) [![Follow](https://img.shields.io/twitter/follow/l2gnux)](https://twitter.com/l2gnux/)
[![Discord](https://img.shields.io/discord/558793703356104724.svg?colorB=7289DA&label=Discord&logo=Discord&logoColor=7289DA&style=flat-square)](https://discord.gg/EGg4rhmpC2) [![Python](https://img.shields.io/pypi/pyversions/apprise.svg?style=flat-square)](https://pypi.org/project/apprise/) [![Build Status](https://github.com/caronc/apprise/actions/workflows/tests.yml/badge.svg)](https://github.com/caronc/apprise/actions/workflows/tests.yml) [![Lines of Code](https://raw.githubusercontent.com/caronc/apprise/master/.github/badges/loc.svg)](https://github.com/caronc/apprise/actions/workflows/loc-badge.yml) [![CodeCov Status](https://codecov.io/github/caronc/apprise/branch/master/graph/badge.svg)](https://codecov.io/github/caronc/apprise) [![PyPi Downloads](https://img.shields.io/pepy/dt/apprise.svg?style=flat-square)](https://pypi.org/project/apprise/) Visit the [Official Documentation](https://appriseit.com/getting-started/) site for more information on Apprise. # How It Works Apprise provides one consistent URL format and notification interface for your apps, automation, and infrastructure. Use the CLI or core library directly, or relay through Apprise API, to deliver notifications to a very [large amount of supported services](https://appriseit.com/services/). [![Apprise overview showing notification sources, entry points, and supported destinations](Apprise-overview.png)](Apprise-overview.png) # Table of Contents * [Supported Notifications](#supported-notifications) * [Productivity Based Notifications](#productivity-based-notifications) * [SMS Notifications](#sms-notifications) * [Desktop Notifications](#desktop-notifications) * [Email Notifications](#email-notifications) * [Custom Notifications](#custom-notifications) * [Installation](#installation) * [Command Line Usage](#command-line-usage) * [Configuration Files](#cli-configuration-files) * [File Attachments](#cli-file-attachments) * [Loading Custom Notifications/Hooks](#cli-loading-custom-notificationshooks) * [Environment Variables](#cli-environment-variables) * [Developer API Usage](#developer-api-usage) * [Configuration Files](#api-configuration-files) * [File Attachments](#api-file-attachments) * [Loading Custom Notifications/Hooks](#api-loading-custom-notificationshooks) * [Persistent Storage](#persistent-storage) * [More Supported Links and Documentation](#want-to-learn-more) # Supported Notifications The section identifies all of the services supported by this library. [Check out the wiki for more information on the supported modules here](https://appriseit.com/). ## Productivity Based Notifications The table below identifies the services this tool supports and some example service URLs you need to use in order to take advantage of it. Click on any of the services listed below to get more details on how you can configure Apprise to access them. If you're having trouble constructing your own URL; try our [Apprise URL Builder](https://appriseit.com/tools/url-builder/) out. | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [AMQP](https://appriseit.com/services/amqp/) | amqp:// or amqps:// | (TCP) 5672 or 5671 | amqp://hostname/queue
amqp://user:pass@hostname/queue1/queue2
amqps://user:pass@hostname/?exchange=alerts
amqps://user:pass@hostname/?version=1.0&to=/queues/name | [Apprise API](https://appriseit.com/services/apprise_api/) | apprise:// or apprises:// | (TCP) 80 or 443 | apprise://hostname/Token
apprise://:password@hostname/Token
apprise://user:password@hostname/Token | [AWS SES](https://appriseit.com/services/ses/) | ses:// | (TCP) 443 | ses://user@domain/AccessKeyID/AccessSecretKey/RegionName
ses://user@domain/AccessKeyID/AccessSecretKey/RegionName/email1/email2/emailN | [Bark](https://appriseit.com/services/bark/) | bark:// | (TCP) 80 or 443 | bark://hostname
bark://hostname/device_key
bark://hostname/device_key1/device_key2/device_keyN
barks://hostname
barks://hostname/device_key
barks://hostname/device_key1/device_key2/device_keyN | [Better Stack](https://appriseit.com/services/betterstack/) | betterstack:// | (TCP) 443 | betterstack://token/email
betterstack://token/email/team
betterstack://token/email/team1/team2/teamN
betterstack://source_token@ingesting_host | [Blink(1)](https://appriseit.com/services/blink1/) | blink1:// | USB | blink1://
blink1://serial/ | [BlueSky](https://appriseit.com/services/bluesky/) | bluesky:// | (TCP) 443 | bluesky://Handle:AppPw
bluesky://Handle:AppPw/TargetHandle
bluesky://Handle:AppPw/TargetHandle1/TargetHandle2/TargetHandleN | [Brevo](https://appriseit.com/services/brevo/) | brevo:// | (TCP) 443 | brevo://APIToken:FromEmail/
brevo://APIToken:FromEmail/ToEmail
brevo://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ | [Chanify](https://appriseit.com/services/chanify/) | chantify:// | (TCP) 443 | chantify://token | [Delta Chat](https://appriseit.com/services/deltachat/) | deltachat:// or deltachats:// | (TCP) 25 or 587 | deltachat://user:pass@smtp.example.com/friend@example.org
deltachats://user:pass@smtp.example.com/friend1@example.org/friend2@example.org | [Discord](https://appriseit.com/services/discord/) | discord:// | (TCP) 443 | discord://webhook_id/webhook_token
discord://avatar@webhook_id/webhook_token | [Dot.](https://appriseit.com/services/dot/) | dot:// | (TCP) 443 | dot://apikey@device_id/text/
dot://apikey@device_id/image/
**Note**: `device_id` is the Quote/0 hardware serial | [Emby](https://appriseit.com/services/emby/) | emby:// or embys:// | (TCP) 8096 | emby://user@hostname/
emby://user:password@hostname | [Enigma2](https://appriseit.com/services/enigma2/) | enigma2:// or enigma2s:// | (TCP) 80 or 443 | enigma2://hostname | [Evolution API](https://appriseit.com/services/evolution/) | evolution:// or evolutions:// | (TCP) 80 or 443 | evolution://apikey@hostname/instance/ToPhoneNo
evolution://apikey@hostname:port/instance/ToPhoneNo
evolution://apikey@hostname/instance/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [FCM](https://appriseit.com/services/fcm/) | fcm:// | (TCP) 443 | fcm://project@apikey/DEVICE_ID
fcm://project@apikey/#TOPIC
fcm://project@apikey/DEVICE_ID1/#topic1/#topic2/DEVICE_ID2/ | [Feishu](https://appriseit.com/services/feishu/) | feishu:// | (TCP) 443 | feishu://token | [Flock](https://appriseit.com/services/flock/) | flock:// | (TCP) 443 | flock://token
flock://botname@token
flock://app_token/u:userid
flock://app_token/g:channel_id
flock://app_token/u:userid/g:channel_id | [Flowtriq](https://appriseit.com/services/flowtriq/) | flowtriq:// or flowtriqs:// | (TCP) 80 or 443 | flowtriq://apikey@hostname/webhook/path
flowtriqs://apikey@hostname/webhook/path | [GitHub](https://appriseit.com/services/github/) | github:// | (TCP) 443 | github://token@owner/repo
github://token@owner/repo/issues/42
github://token@owner/repo/pull/1807
github://token@owner/repo/discussions/7 | [GoAlert](https://appriseit.com/services/goalert/) | goalert:// or goalerts:// | (TCP) 80 or 443 | goalert://hostname/integration_key
goalerts://hostname/integration_key1/integration_key2
goalerts://hostname/path/integration_key | [Google Chat](https://appriseit.com/services/googlechat/) | gchat:// | (TCP) 443 | gchat://workspace/key/token | [Gorush](https://appriseit.com/services/gorush/) | gorush:// or gorushs:// | (TCP) 8088, 80 or 443 | gorush://hostname:8088/device_token
gorush://hostname:8088/device_token1/device_token2?platform=ios
gorushs://user:password@hostname/device_token | [Gotify](https://appriseit.com/services/gotify/) | gotify:// or gotifys:// | (TCP) 80 or 443 | gotify://hostname/token
gotifys://hostname/token?priority=high | [GroupMe](https://appriseit.com/services/groupme/) | groupme:// | (TCP) 443 | groupme://bot_id
groupme://bot_id/access_token | [Growl](https://appriseit.com/services/growl/) | growl:// | (UDP) 23053 | growl://hostname
growl://hostname:portno
growl://password@hostname
growl://password@hostname:port
**Note**: you can also use the get parameter _version_ which can allow the growl request to behave using the older v1.x protocol. An example would look like: growl://hostname?version=1 | [Guilded](https://appriseit.com/services/guilded/) | guilded:// | (TCP) 443 | guilded://webhook_id/webhook_token
guilded://avatar@webhook_id/webhook_token | [Home Assistant](https://appriseit.com/services/homeassistant/) | hassio:// or hassios:// | (TCP) 8123 or 443 | hassio://hostname/accesstoken
hassio://user@hostname/accesstoken
hassio://user:password@hostname:port/accesstoken
hassio://hostname/optional/path/accesstoken | [HumHub](https://appriseit.com/services/humhub/) | humhub:// or humhubs:// | (TCP) 80 or 443 | humhubs://token@hostname/container_id
humhubs://user:password@hostname/container_id
humhubs://token@hostname/id1/id2/id3 | [IFTTT](https://appriseit.com/services/ifttt/) | ifttt:// | (TCP) 443 | ifttt://webhooksID/Event
ifttt://webhooksID/Event1/Event2/EventN
ifttt://webhooksID/Event1/?+Key=Value
ifttt://webhooksID/Event1/?-Key=value1 | [ilert](https://appriseit.com/services/ilert/) | ilert:// | (TCP) 443 | ilert://IntegrationKey
ilert://IntegrationKey?priority=high
ilert://IntegrationKey?action=resolve&dedup=key | [incident.io](https://appriseit.com/services/incidentio/) | incidentio:// | (TCP) 443 | incidentio://source_id/token
incidentio://source_id/token?action=resolve&dedup=key | [IRC](https://appriseit.com/services/irc/) | irc:// or ircs:// | (TCP) 6667 or 6697 | ircs://user:pass@irc.server/@user
ircs://user:pass@irc.server/#channel?join=true&mode=nickserv
ircs://user:pass@znc.server/@user1/@user2/@user3/#channel1 | [Jellyfin](https://appriseit.com/services/jellyfin/) | jellyfin:// or jellyfins:// | (TCP) 8096 | jellyfin://user@hostname/
jellyfins://user:password@hostname | [Jira](https://appriseit.com/services/jira/) | jira:// | (TCP) 443 | jira://APIKey
jira://APIKey/@UserID
jira://APIKey/#Team
jira://APIKey/\*Schedule
jira://APIKey/^Escalation | [Join](https://appriseit.com/services/join/) | join:// | (TCP) 443 | join://apikey/device
join://apikey/device1/device2/deviceN/
join://apikey/group
join://apikey/groupA/groupB/groupN
join://apikey/DeviceA/groupA/groupN/DeviceN/ | [KODI](https://appriseit.com/services/kodi/) | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname
kodi://user@hostname
kodi://user:password@hostname:port | [Kook](https://appriseit.com/services/kook/) | kook:// | (TCP) 443 | kook://token/channel_id
kook://token/channel1/channel2
kook://token/@user_id
kook://webhook_key?mode=webhook | [Kumulos](https://appriseit.com/services/kumulos/) | kumulos:// | (TCP) 443 | kumulos://apikey/serverkey | [LaMetric Time](https://appriseit.com/services/lametric/) | lametric:// | (TCP) 443 | lametric://apikey@device_ipaddr
lametric://apikey@hostname:port
lametric://client_id@client_secret | [Lark](https://appriseit.com/services/lark/) | lark:// | (TCP) 443 | lark://BotToken | [Lauther](https://appriseit.com/services/lauther/) | lauther:// | (TCP) 443 | lauther://Token | [LFG Alarm](https://appriseit.com/services/lfgalarm/) | lfg:// | (TCP) 443 | lfg://Token | [Line](https://appriseit.com/services/line/) | line:// | (TCP) 443 | line://Token@User
line://Token/User1/User2/UserN | [Mailgun](https://appriseit.com/services/mailgun/) | mailgun:// | (TCP) 443 | mailgun://user@hostname/apikey
mailgun://user@hostname/apikey/email
mailgun://user@hostname/apikey/email1/email2/emailN
mailgun://user@hostname/apikey/?name="From%20User" | [MailerSend](https://appriseit.com/services/mailersend/) | mailersend:// | (TCP) 443 | mailersend://APIToken:FromEmail/
mailersend://APIToken:FromEmail/ToEmail
mailersend://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ | [Mastodon](https://appriseit.com/services/mastodon/) | mastodon:// or mastodons://| (TCP) 80 or 443 | mastodon://access_key@hostname
mastodon://access_key@hostname/@user
mastodon://access_key@hostname/@user1/@user2/@userN | [Matrix](https://appriseit.com/services/matrix/) | matrix:// or matrixs:// | (TCP) 80 or 443 | matrix://hostname
matrix://user@hostname
matrixs://user:pass@hostname:port/#room_alias
matrixs://user:pass@hostname:port/!room_id
matrixs://user:pass@hostname:port/#room_alias/!room_id/#room2
matrixs://token@hostname:port/?webhook=matrix
matrix://user:token@hostname/?webhook=slack&format=markdown | [Mattermost](https://appriseit.com/services/mattermost/) | mmost:// or mmosts:// | (TCP) 8065 | mmost://hostname/authkey
mmost://hostname:80/authkey
mmost://user@hostname:80/authkey
mmost://hostname/authkey?channel=channel
mmosts://hostname/authkey
mmosts://user@hostname/authkey
| [Microsoft Power Automate / Workflows (MSTeams)](https://appriseit.com/services/workflows/) | workflows:// | (TCP) 443 | workflows://WorkflowID/Signature/ | [Misskey](https://appriseit.com/services/misskey/) | misskey:// or misskeys://| (TCP) 80 or 443 | misskey://access_token@hostname | [MQTT](https://appriseit.com/services/mqtt/) | mqtt:// or mqtts:// | (TCP) 1883 or 8883 | mqtt://hostname/topic
mqtt://user@hostname/topic
mqtts://user:pass@hostname:9883/topic | [Nextcloud](https://appriseit.com/services/nextcloud/) | ncloud:// or nclouds:// | (TCP) 80 or 443 | ncloud://adminuser:pass@host/User
nclouds://adminuser:pass@host/User1/User2/UserN | [NextcloudTalk](https://appriseit.com/services/nextcloudtalk/) | nctalk:// or nctalks:// | (TCP) 80 or 443 | nctalk://user:pass@host/RoomId
nctalks://user:pass@host/RoomId1/RoomId2/RoomIdN
nctalks://host/RoomId?secret=BotSecret | [Notica](https://appriseit.com/services/notica/) | notica:// | (TCP) 443 | notica://Token/ | [Notifiarr](https://appriseit.com/services/notifiarr/) | notifiarr:// | (TCP) 443 | notifiarr://apikey/#channel
notifiarr://apikey/#channel1/#channel2/#channeln | [Notifico](https://appriseit.com/services/notifico/) | notifico:// or notificos:// | (TCP) 80 or 443 | notifico://ProjectID/MessageHook/
notifico://host/ProjectID/MessageHook/
notificos://host/ProjectID/MessageHook/ | [ntfy](https://appriseit.com/services/ntfy/) | ntfy:// | (TCP) 80 or 443 | ntfy://topic/
ntfys://topic/ | [Octopush](https://appriseit.com/services/octopush/) | octopush:// | (TCP) 443 | octopush://APILogin/APIKey/TargetPhoneNo
octopush://Sender:APILogin/APIKey/TargetPhoneNo
octopush://Sender:APILogin/APIKey/TargetPhoneNo1/TargetPhoneNo2/TargetPhoneNoN | [Office 365](https://appriseit.com/services/office365/) | o365:// | (TCP) 443 | o365://TenantID:AccountEmail/ClientID/ClientSecret
o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail
o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail1/TargetEmail2/TargetEmailN | [OneBot (QQ)](https://appriseit.com/services/onebot/) | onebot:// or onebots:// | (TCP) 80 or 443 | onebot://hostname:port/UserID
onebot://token@hostname:port/@UserID/#GroupID
onebots://token@hostname:port/@UserID1/@UserID2/#GroupID1/#GroupIDN | [OneSignal](https://appriseit.com/services/onesignal/) | onesignal:// | (TCP) 443 | onesignal://AppID@APIKey/PlayerID
onesignal://TemplateID:AppID@APIKey/UserID
onesignal://AppID@APIKey/#IncludeSegment
onesignal://AppID@APIKey/Email | [Opsgenie](https://appriseit.com/services/opsgenie/) | opsgenie:// | (TCP) 443 | opsgenie://APIKey
opsgenie://APIKey/UserID
opsgenie://APIKey/#Team
opsgenie://APIKey/\*Schedule
opsgenie://APIKey/^Escalation | [PagerDuty](https://appriseit.com/services/pagerduty/) | pagerduty:// | (TCP) 443 | pagerduty://IntegrationKey@ApiKey
pagerduty://IntegrationKey@ApiKey/Source/Component | [PagerTree](https://appriseit.com/services/pagertree/) | pagertree:// | (TCP) 443 | pagertree://integration_id | [ParsePlatform](https://appriseit.com/services/parseplatform/) | parsep:// or parseps:// | (TCP) 80 or 443 | parsep://AppID:MasterKey@Hostname
parseps://AppID:MasterKey@Hostname | [Pingram](https://appriseit.com/services/pingram/) | pingram:// | (TCP) 443 | pingram://ApiKey/Target
pingram://ApiKey/Target1/Target2/TargetN
pingram://MessageType@ApiKey/Target | [Pinglet](https://appriseit.com/services/pinglet/) | pinglet:// or pinglets:// | (TCP) 80 or 443 | pinglets://apikey@hostname/namespace/topic
pinglets://apikey@hostname:port/namespace/topic | [Postmark](https://appriseit.com/services/postmark/) | postmark:// | (TCP) 443 | postmark://APIToken:FromEmail/
postmark://APIToken:FromEmail/ToEmail
postmark://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ | [Prowl](https://appriseit.com/services/prowl/) | prowl:// | (TCP) 443 | prowl://apikey
prowl://apikey/providerkey | [PushBullet](https://appriseit.com/services/pushbullet/) | pbul:// | (TCP) 443 | pbul://accesstoken
pbul://accesstoken/#channel
pbul://accesstoken/A_DEVICE_ID
pbul://accesstoken/email@address.com
pbul://accesstoken/#channel/#channel2/email@address.net/DEVICE | [Pushjet](https://appriseit.com/services/pushjet/) | pjet:// or pjets:// | (TCP) 80 or 443 | pjet://hostname/secret
pjet://hostname:port/secret
pjets://secret@hostname/secret
pjets://hostname:port/secret | [Push (Techulus)](https://appriseit.com/services/techulus/) | push:// | (TCP) 443 | push://apikey/ | [Pushed](https://appriseit.com/services/pushed/) | pushed:// | (TCP) 443 | pushed://appkey/appsecret/
pushed://appkey/appsecret/#ChannelAlias
pushed://appkey/appsecret/#ChannelAlias1/#ChannelAlias2/#ChannelAliasN
pushed://appkey/appsecret/@UserPushedID
pushed://appkey/appsecret/@UserPushedID1/@UserPushedID2/@UserPushedIDN | [PushMe](https://appriseit.com/services/pushme/) | pushme:// | (TCP) 443 | pushme://Token/ | [Pushover](https://appriseit.com/services/pushover/) | pover:// | (TCP) 443 | pover://user@token
pover://user@token/DEVICE
pover://user@token/DEVICE1/DEVICE2/DEVICEN
**Note**: you must specify both your user_id and token | [Pushplus](https://appriseit.com/services/pushplus/) | pushplus:// | (TCP) 443 | pushplus://Token | [PushSafer](https://appriseit.com/services/pushsafer/) | psafer:// or psafers:// | (TCP) 80 or 443 | psafer://privatekey
psafers://privatekey/DEVICE
psafer://privatekey/DEVICE1/DEVICE2/DEVICEN | [PushWard](https://appriseit.com/services/pushward/) | pushward:// | (TCP) 443 | pushward://hlk_apikey
pushward://hlk_apikey?level=critical&volume=0.8 | [Pushy](https://appriseit.com/services/pushy/) | pushy:// | (TCP) 443 | pushy://apikey/DEVICE
pushy://apikey/DEVICE1/DEVICE2/DEVICEN
pushy://apikey/TOPIC
pushy://apikey/TOPIC1/TOPIC2/TOPICN | [PushDeer](https://appriseit.com/services/pushdeer/) | pushdeer:// or pushdeers:// | (TCP) 80 or 443 | pushdeer://pushKey
pushdeer://hostname/pushKey
pushdeer://hostname:port/pushKey | [QQ Push](https://appriseit.com/services/qq/) | qq:// | (TCP) 443 | qq://Token | [Reddit](https://appriseit.com/services/reddit/) | reddit:// | (TCP) 443 | reddit://user:password@app_id/app_secret/subreddit
reddit://user:password@app_id/app_secret/sub1/sub2/subN | [Resend](https://appriseit.com/services/resend/) | resend:// | (TCP) 443 | resend://APIToken:FromEmail/
resend://APIToken:FromEmail/ToEmail
resend://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ | [Revolt](https://appriseit.com/services/revolt/) | revolt:// | (TCP) 443 | revolt://bottoken/ChannelID
revolt://bottoken/ChannelID1/ChannelID2/ChannelIDN | | [Rocket.Chat](https://appriseit.com/services/rocketchat/) | rocket:// or rockets:// | (TCP) 80 or 443 | rocket://user:password@hostname/RoomID/Channel
rockets://user:password@hostname:443/#Channel1/#Channel1/RoomID
rocket://user:password@hostname/#Channel
rocket://webhook@hostname
rockets://webhook@hostname/@User/#Channel | [RSyslog](https://appriseit.com/services/rsyslog/) | rsyslog:// | (UDP) 514 | rsyslog://hostname
rsyslog://hostname/Facility | [Ryver](https://appriseit.com/services/ryver/) | ryver:// | (TCP) 443 | ryver://Organization/Token
ryver://botname@Organization/Token | [SendGrid](https://appriseit.com/services/sendgrid/) | sendgrid:// | (TCP) 443 | sendgrid://APIToken:FromEmail/
sendgrid://APIToken:FromEmail/ToEmail
sendgrid://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ | [SendPulse](https://appriseit.com/services/sendpulse/) | sendpulse:// | (TCP) 443 | sendpulse://user@host/ClientId/ClientSecret
sendpulse://user@host/ClientId/clientSecret/ToEmail
sendpulse://user@host/ClientId/ClientSecret/ToEmail1/ToEmail2/ToEmailN/ | [ServerChan](https://appriseit.com/services/serverchan/) | schan:// | (TCP) 443 | schan://sendkey/ | [Session Open Group Server](https://appriseit.com/services/sogs/) | session:// or sessions:// | (TCP) 80 or 443 | sessions://public_key:seed@hostname/room
sessions://public_key:seed@hostname/room1/room2
sessions://public_key:seed@hostname:port/room
session://public_key:seed@hostname/room | [Signal API](https://appriseit.com/services/signal/) | signal:// or signals:// | (TCP) 80 or 443 | signal://hostname:port/FromPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Signalgrid](https://appriseit.com/services/signalgrid/) | signalgrid:// | (TCP) 443 | signalgrid://CLIENT_KEY/CHANNEL
signalgrid://CLIENT_KEY/CHANNEL1/CHANNEL2
signalgrid://CLIENT_KEY/CHANNEL?critical=true | [SIGNL4](https://appriseit.com/services/signl4/) | signl4:// | (TCP) 80 or 443 | signl4://hostname | [Simplepush](https://appriseit.com/services/simplepush/) | spush:// | (TCP) 443 | spush://APIToken
spush://APIToken/Topic
spush://Password@APIToken/Topic
spush://IntegrationToken/@Member
spush://IntegrationToken/?broadcast=yes | [Slack](https://appriseit.com/services/slack/) | slack:// | (TCP) 443 | slack://TokenA/TokenB/TokenC/
slack://TokenA/TokenB/TokenC/Channel
slack://botname@TokenA/TokenB/TokenC/Channel
slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN | [SMTP2Go](https://appriseit.com/services/smtp2go/) | smtp2go:// | (TCP) 443 | smtp2go://user@hostname/apikey
smtp2go://user@hostname/apikey/email
smtp2go://user@hostname/apikey/email1/email2/emailN
smtp2go://user@hostname/apikey/?name="From%20User" | [SparkPost](https://appriseit.com/services/sparkpost/) | sparkpost:// | (TCP) 443 | sparkpost://user@hostname/apikey
sparkpost://user@hostname/apikey/email
sparkpost://user@hostname/apikey/email1/email2/emailN
sparkpost://user@hostname/apikey/?name="From%20User" | [Spike.sh](https://appriseit.com/services/spike/) | spike:// | (TCP) 443 | spike://Token | [Splunk](https://appriseit.com/services/splunk/) | splunk:// or victorops:/ | (TCP) 443 | splunk://route_key@apikey
splunk://route_key@apikey/entity_id | [Spug Push](https://appriseit.com/services/spugpush/) | spugpush:// | (TCP) 443 | spugpush://Token | [Squadcast](https://appriseit.com/services/squadcast/) | squadcast:// | (TCP) 443 | squadcast://WebhookKey
squadcast://WebhookKey?region=eu
squadcast://WebhookKey?action=resolve&dedup=key | [Stackfield](https://appriseit.com/services/stackfield/) | stackfield:// | (TCP) 443 | stackfield://WebhookToken | [Streamlabs](https://appriseit.com/services/streamlabs/) | strmlabs:// | (TCP) 443 | strmlabs://AccessToken/
strmlabs://AccessToken/?name=name&identifier=identifier&amount=0¤cy=USD | [Synology Chat](https://appriseit.com/services/synology_chat/) | synology:// or synologys:// | (TCP) 80 or 443 | synology://hostname/token
synology://hostname:port/token | [Syslog](https://appriseit.com/services/syslog/) | syslog:// | n/a | syslog://
syslog://Facility | [Telegram](https://appriseit.com/services/telegram/) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID
tgram://bottoken/ChatID1/ChatID2/ChatIDN | [Twitter](https://appriseit.com/services/twitter/) | twitter:// | (TCP) 443 | twitter://CKey/CSecret/AKey/ASecret
twitter://user@CKey/CSecret/AKey/ASecret
twitter://CKey/CSecret/AKey/ASecret/User1/User2/User2
twitter://CKey/CSecret/AKey/ASecret?mode=tweet | [Twist](https://appriseit.com/services/twist/) | twist:// | (TCP) 443 | twist://pasword:login
twist://password:login/#channel
twist://password:login/#team:channel
twist://password:login/#team:channel1/channel2/#team3:channel | [Vapid (WebPush)](https://appriseit.com/services/vapid/) | vapid:// | (TCP) 443 | vapid://subscriber/target
vapid://subscriber/target?subfile=path&keyfile=path | [Viber](https://appriseit.com/services/viber/) | viber:// | (TCP) 443 | viber://token/target | [Webex Teams (Cisco)](https://appriseit.com/services/wxteams/) | wxteams:// | (TCP) 443 | wxteams://Token | [WeChat (WeCom)](https://appriseit.com/services/wechat/) | wechat:// | (TCP) 443 | wechat://CorpID:AppSecret@AgentID/@all
wechat://CorpID:AppSecret@AgentID/UserID | [WeCom Bot](https://appriseit.com/services/wecombot/) | wecombot:// | (TCP) 443 | wecombot://BotKey | [WhatsApp](https://appriseit.com/services/whatsapp/) | whatsapp:// | (TCP) 443 | whatsapp://AccessToken@FromPhoneID/ToPhoneNo
whatsapp://Template:AccessToken@FromPhoneID/ToPhoneNo | [WPUSH](https://appriseit.com/services/wpush/) | wpush:// | (TCP) 443 | wpush://APIKey
wpush://APIKey/Topic1/Topic2/TopicN | [WxPusher](https://appriseit.com/services/wxpusher/) | wxpusher:// | (TCP) 443 | wxpusher://AppToken@UserID1/UserID2/UserIDN
wxpusher://AppToken@Topic1/Topic2/Topic3
wxpusher://AppToken@UserID1/Topic1/ | [XBMC](https://appriseit.com/services/xbmc/) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname
xbmc://user@hostname
xbmc://user:password@hostname:port | [XMPP](https://appriseit.com/services/xmpp/) | xmpp:// or xmpps:// | (TCP) 5222 or 5223 | xmpp://user:pass@hostname
xmpps://user:pass@hostname/jid
xmpps://user:pass@hostname/jid1/jid2@example.ca | [YouLMK](https://appriseit.com/services/youlmk/) | youlmk:// | (TCP) 443 | youlmk://ylk_token
youlmk://k_key
youlmk://ylk_token?priority=critical
youlmk://ylk_token?failure=critical&url=https://example.com/runs/1 | [Zalo](https://appriseit.com/services/zalo/) | zalo:// | (TCP) 443 | zalo://BotToken/ChatID
zalo://BotToken/ChatID1/ChatID2/ChatIDN | [Zoho Cliq](https://appriseit.com/services/cliq/) | cliq:// or zcliq:// or zohocliq:// | (TCP) 443 | cliq://Token/Channel
cliq://Token/@Bot
cliq://BotName@Token/Channel1/Channel2/@Bot?region=eu | [Zoom](https://appriseit.com/services/zoom/) | zoom:// | (TCP) 443 | zoom://WebhookID/Token | [Zulip Chat](https://appriseit.com/services/zulip/) | zulip:// | (TCP) 443 | zulip://botname@Organization/Token
zulip://botname@Organization/Token/Stream
zulip://botname@Organization/Token/Email ## SMS Notifications SMS Notifications for the most part do not have a both a `title` and `body`. They consist of a single `body` which is usually no more then 160 characters in length. When using Apprise, the `title` and `body` are therefore combined into a single message prior to their transmission. | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [46elks](https://appriseit.com/services/46elks/) | 46elks:// | (TCP) 443 | 46elks://user:password@FromPhoneNo
46elks://user:password@FromPhoneNo/ToPhoneNo
46elks://user:password@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [800.com](https://appriseit.com/services/eight00com/) | eight00com:// | (TCP) 443 | eight00com://Token@FromPhoneNo
eight00com://Token@FromPhoneNo/ToPhoneNo
eight00com://Token@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Africas Talking](https://appriseit.com/services/africas_talking/) | atalk:// | (TCP) 443 | atalk://AppUser@ApiKey/ToPhoneNo
atalk://AppUser@ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Automated Packet Reporting System (APRS)](https://appriseit.com/services/aprs/) | aprs:// | (TCP) 10152 | aprs://user:pass@callsign
aprs://user:pass@callsign1/callsign2/callsignN | [AWS SNS](https://appriseit.com/services/sns/) | sns:// | (TCP) 443 | sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo
sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo1/+PhoneNo2/+PhoneNoN
sns://AccessKeyID/AccessSecretKey/RegionName/Topic
sns://AccessKeyID/AccessSecretKey/RegionName/Topic1/Topic2/TopicN | [BulkSMS](https://appriseit.com/services/bulksms/) | bulksms:// | (TCP) 443 | bulksms://user:password@ToPhoneNo
bulksms://User:Password@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [BulkVS](https://appriseit.com/services/bulkvs/) | bulkvs:// | (TCP) 443 | bulkvs://user:password@FromPhoneNo
bulkvs://user:password@FromPhoneNo/ToPhoneNo
bulkvs://user:password@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Burst SMS](https://appriseit.com/services/burstsms/) | burstsms:// | (TCP) 443 | burstsms://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo
burstsms://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Clickatell](https://appriseit.com/services/clickatell/) | clickatell:// | (TCP) 443 | clickatell://ApiKey/ToPhoneNo
clickatell://FromPhoneNo@ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [ClickSend](https://appriseit.com/services/clicksend/) | clicksend:// | (TCP) 443 | clicksend://user:pass@PhoneNo
clicksend://user:pass@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [DAPNET](https://appriseit.com/services/dapnet/) | dapnet:// | (TCP) 80 | dapnet://user:pass@callsign
dapnet://user:pass@callsign1/callsign2/callsignN | [D7 Networks](https://appriseit.com/services/d7networks/) | d7sms:// | (TCP) 443 | d7sms://token@PhoneNo
d7sms://token@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [DingTalk](https://appriseit.com/services/dingtalk/) | dingtalk:// | (TCP) 443 | dingtalk://token/
dingtalk://token/ToPhoneNo
dingtalk://token/ToPhoneNo1/ToPhoneNo2/ToPhoneNo1/ | [Exotel](https://appriseit.com/services/exotel/) | exotel:// | (TCP) 443 | exotel://sid:token@FromPhoneNo
exotel://sid:token@FromPhoneNo/ToPhoneNo
exotel://sid:token@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [Free-Mobile](https://appriseit.com/services/freemobile/) | freemobile:// | (TCP) 443 | freemobile://user@password/ | [httpSMS](https://appriseit.com/services/httpsms/) | httpsms:// | (TCP) 443 | httpsms://ApiKey@FromPhoneNo
httpsms://ApiKey@FromPhoneNo/ToPhoneNo
httpsms://ApiKey@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Kavenegar](https://appriseit.com/services/kavenegar/) | kavenegar:// | (TCP) 443 | kavenegar://ApiKey/ToPhoneNo
kavenegar://FromPhoneNo@ApiKey/ToPhoneNo
kavenegar://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [MessageBird](https://appriseit.com/services/messagebird/) | msgbird:// | (TCP) 443 | msgbird://ApiKey/FromPhoneNo
msgbird://ApiKey/FromPhoneNo/ToPhoneNo
msgbird://ApiKey/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Mobile Message](https://appriseit.com/services/mobilemessage/) | mobilemessage:// or mobilemsg:// | (TCP) 443 | mobilemessage://ApiUser:ApiPass@SenderID/ToPhoneNo
mobilemessage://ApiUser:ApiPass@SenderID/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [MSG91](https://appriseit.com/services/msg91/) | msg91:// | (TCP) 443 | msg91://TemplateID@AuthKey/ToPhoneNo
msg91://TemplateID@AuthKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Plivo](https://appriseit.com/services/plivo/) | plivo:// | (TCP) 443 | plivo://AuthID@Token@FromPhoneNo
plivo://AuthID@Token/FromPhoneNo/ToPhoneNo
plivo://AuthID@Token/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [RingCentral](https://appriseit.com/services/ringcentral/) | ringc:// | (TCP) 443 | ringc://SourcePhoneNo:Password@ClientID/ClientSecret
ringc://SourcePhoneNo:JWTToken@ClientID/ClientSecret/ToPhoneNo
ringc://SourcePhoneNo:JWTToken@ClientID/ClientSecret/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [SerwerSMS](https://appriseit.com/services/serwersms/) | serwersms:// | (TCP) 443 | serwersms://user:password@SenderName/+ToPhoneNo
serwersms://user:password@SenderName/+ToPhoneNo1/+ToPhoneNo2
serwersms://user:password@SenderName/%23GroupID | [Seven](https://appriseit.com/services/seven/) | seven:// | (TCP) 443 | seven://ApiKey/FromPhoneNo
seven://ApiKey/FromPhoneNo/ToPhoneNo
seven://ApiKey/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Société Française du Radiotéléphone (SFR)](https://appriseit.com/services/sfr/) | sfr:// | (TCP) 443 | sfr://user:password>@spaceId/ToPhoneNo
sfr://user:password>@spaceId/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Signal API](https://appriseit.com/services/signal/) | signal:// or signals:// | (TCP) 80 or 443 | signal://hostname:port/FromPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Sinch](https://appriseit.com/services/sinch/) | sinch:// | (TCP) 443 | sinch://ServicePlanId:ApiToken@FromPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [SMPP](https://appriseit.com/services/smpp/) | smpp:// or smpps:// | (TCP) 443 | smpp://user:password@hostname:port/FromPhoneNo/ToPhoneNo
smpps://user:password@hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [SMSEagle](https://appriseit.com/services/smseagle/) | smseagle:// or smseagles:// | (TCP) 80 or 443 | smseagles://hostname:port/ToPhoneNo
smseagles://hostname:port/@ToContact
smseagles://hostname:port/#ToGroup
smseagles://hostname:port/ToPhoneNo1/#ToGroup/@ToContact/ | [SMS Manager](https://appriseit.com/services/sms_manager/) | smsmgr:// | (TCP) 443 | smsmgr://ApiKey@ToPhoneNo
smsmgr://ApiKey@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [SMSC](https://appriseit.com/services/smsc/) | smsc:// | (TCP) 443 | smsc://login:password@ToPhoneNo
smsc://login:password@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Telnyx](https://appriseit.com/services/telnyx/) | telnyx:// | (TCP) 443 | telnyx://ApiKey@FromPhoneNo
telnyx://ApiKey@FromPhoneNo/ToPhoneNo
telnyx://ApiKey@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
telnyx://ApiKey@FromPhoneNo/ToPhoneNo?profile=MessagingProfileID | [Threema Gateway](https://appriseit.com/services/threema/) | threema:// | (TCP) 443 | threema://GatewayID@secret/ToPhoneNo
threema://GatewayID@secret/ToEmail
threema://GatewayID@secret/ToThreemaID/
threema://GatewayID@secret/ToEmail/ToThreemaID/ToPhoneNo/... | [Trigv](https://trigv.com/) | trigv:// or trigvs:// | (TCP) 80 or 443 | trigvs://APIKey
trigvs://APIKey/channel
trigv://APIKey@hostname/channel | [Notifyre](https://appriseit.com/services/notifyre/) | notifyre:// | (TCP) 443 | notifyre://ApiKey/ToPhoneNo
notifyre://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN
notifyre://ApiKey/ToFaxNo?mode=fax
notifyre://ApiKey/ToFaxNo?mode=fax&from=+FromNo | [Twilio](https://appriseit.com/services/twilio/) | twilio:// | (TCP) 443 | twilio://AccountSid:AuthToken@FromPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?apikey=Key
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?method=call
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN?method=call | [Voipms](https://appriseit.com/services/voipms/) | voipms:// | (TCP) 443 | voipms://password:email/FromPhoneNo
voipms://password:email/FromPhoneNo/ToPhoneNo
voipms://password:email/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Vonage](https://appriseit.com/services/vonage/) (formerly Nexmo) | vonage:// | (TCP) 443 | vonage://ApiKey:ApiSecret@FromPhoneNo
vonage://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo
vonage://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ ## Desktop Notifications | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [Linux DBus Notifications](https://appriseit.com/services/dbus/) | dbus://
qt://
glib://
kde:// | n/a | dbus://
qt://
glib://
kde:// | [Linux Gnome Notifications](https://appriseit.com/services/gnome/) | gnome:// | n/a | gnome:// | [MacOS X Notifications](https://appriseit.com/services/macosx/) | macosx:// | n/a | macosx:// | [Windows Notifications](https://appriseit.com/services/windows/) | windows:// | n/a | windows:// ## Email Notifications | Service ID | Default Port | Example Syntax | | ---------- | ------------ | -------------- | | [mailto://](https://appriseit.com/services/email/) | (TCP) 25 | mailto://userid:pass@domain.com
mailto://domain.com?user=userid&pass=password
mailto://domain.com:2525?user=userid&pass=password
mailto://user@gmail.com&pass=password
mailto://mySendingUsername:mySendingPassword@example.com?to=receivingAddress@example.com
mailto://userid:password@example.com?smtp=mail.example.com&from=noreply@example.com&name=no%20reply | [mailtos://](https://appriseit.com/services/email/) | (TCP) 587 | mailtos://userid:pass@domain.com
mailtos://domain.com?user=userid&pass=password
mailtos://domain.com:465?user=userid&pass=password
mailtos://user@hotmail.com&pass=password
mailtos://mySendingUsername:mySendingPassword@example.com?to=receivingAddress@example.com
mailtos://userid:password@example.com?smtp=mail.example.com&from=noreply@example.com&name=no%20reply Apprise has some email services built right into it (such as yahoo, fastmail, hotmail, gmail, etc) that greatly simplify the mailto:// service. See more details [here](https://appriseit.com/services/email/). ## Custom Notifications | Post Method | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [Form](https://appriseit.com/services/form/) | form:// or forms:// | (TCP) 80 or 443 | form://hostname
form://user@hostname
form://user:password@hostname:port
form://hostname/a/path/to/post/to | [JSON](https://appriseit.com/services/json/) | json:// or jsons:// | (TCP) 80 or 443 | json://hostname
json://user@hostname
json://user:password@hostname:port
json://hostname/a/path/to/post/to | [XML](https://appriseit.com/services/xml/) | xml:// or xmls:// | (TCP) 80 or 443 | xml://hostname
xml://user@hostname
xml://user:password@hostname:port
xml://hostname/a/path/to/post/to # Installation The easiest way is to install Apprise from PyPI: ```bash pip install apprise ``` If you use [uv](https://docs.astral.sh/uv/), you can install the `apprise` command in its own isolated environment, or add Apprise to your uv project: ```bash # Install the apprise command uv tool install apprise # Or add it to your own project uv add apprise ``` Apprise is also packaged as an RPM and available through [EPEL](https://docs.fedoraproject.org/en-US/epel/) supporting CentOS, Redhat, Rocky, Oracle Linux, etc. ```bash # Follow instructions on https://docs.fedoraproject.org/en-US/epel # to get your system connected up to EPEL and then: # Redhat/CentOS 7.x users yum install apprise # Redhat/Rocky Linux 8.x+ and/or Fedora Users dnf install apprise ``` You can also check out the [Graphical version of Apprise](https://github.com/caronc/apprise-api) to centralize your configuration and notifications through a manageable webpage. # Command Line Usage A command line tool named *apprise* is included. Provide one or more service URLs to send a notification: ```bash # Send to several services. The -vv option shows additional details. apprise -vv -t 'my title' -b 'my notification body' \ 'mailto://myemail:mypass@gmail.com' \ 'pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b' # If you don't specify a --body (-b) then stdin is used allowing # you to use the tool as part of your every day administration: cat /proc/cpuinfo | apprise -vv -t 'cpu info' \ 'mailto://myemail:mypass@gmail.com' # The title field is totally optional uptime | apprise -vv \ 'discord:///4174216298/JHMHI8qBe7bk2ZwO5U711o3dV_js' ``` ## CLI Configuration Files No one wants to put their credentials out for everyone to see on the command line. No problem, *apprise* also supports configuration files. It can handle both a specific YAML format or a very simple TEXT format. You can also pull these configuration files via an HTTP query too! Read more about the expected structure of the configuration files [here](https://appriseit.com/config/). ```bash # By default if no url or configuration is specified apprise will attempt to load # configuration files (if present) from: # ~/.apprise # ~/.apprise.yaml # ~/.config/apprise.conf # ~/.config/apprise.yaml # /etc/apprise.conf # /etc/apprise.yaml # Also subdirectory handling allows you to leverage plugins # ~/.apprise/apprise # ~/.apprise/apprise.yaml # ~/.config/apprise/apprise.conf # ~/.config/apprise/apprise.yaml # /etc/apprise/apprise.yaml # /etc/apprise/apprise.conf # Windows users can store their default configuration files here: # %APPDATA%/Apprise/apprise.conf # %APPDATA%/Apprise/apprise.yaml # %LOCALAPPDATA%/Apprise/apprise.conf # %LOCALAPPDATA%/Apprise/apprise.yaml # %ALLUSERSPROFILE%\Apprise\apprise.conf # %ALLUSERSPROFILE%\Apprise\apprise.yaml # %PROGRAMFILES%\Apprise\apprise.conf # %PROGRAMFILES%\Apprise\apprise.yaml # %COMMONPROGRAMFILES%\Apprise\apprise.conf # %COMMONPROGRAMFILES%\Apprise\apprise.yaml # The configuration files specified above can also be identified with a `.yml` # extension or even just entirely removing the `.conf` extension altogether. # If you loaded one of those files, your command line gets really easy: apprise -vv -t 'my title' -b 'my notification body' # If you want to deviate from the default paths or specify more than one, # just specify them using the --config switch: apprise -vv -t 'my title' -b 'my notification body' \ --config=/path/to/my/config.yml # Got lots of configuration locations? No problem, you can specify them all: # Apprise can even fetch the configuration from over a network! apprise -vv -t 'my title' -b 'my notification body' \ --config=/path/to/my/config.yml \ --config=https://localhost/my/apprise/config ``` ## CLI Template Variables A YAML configuration can leave a value out of a URL and have it filled in later, which lets you keep the configuration in version control without the secrets in it. Write `${NAME}` where the value belongs and declare every name you use in a `template:` section: ```yaml template: # A default is used unless the call supplies a value smtp_host: smtp.example.com # No default: the call or environment must fill this name api_key: urls: - sendgrid://${API_KEY}:noreply@example.com/you@example.com: - tag: alerts ``` Apprise looks for each value in this order and stops at the first answer: 1. a value passed with `--template-var` (`-tv`) 2. the default written in the `template:` section 3. the environment variable `APPRISE_TEMPLATE_` The environment is the last resort, so it only ever fills in a name the `template:` section declared without a default. Values supplied for a call or through the environment are trimmed. If one is blank afterwards, it reads as not supplied and the next source above applies. A default written in the configuration stays as written. Use `name: ""` for an explicit empty-string default; `name:` means the value is required. ```bash # Supply it directly apprise --config=apprise.yml --tag=alerts \ --template-var api_key=your-secret-key \ --body="Hello" # Or leave it in the environment export APPRISE_TEMPLATE_API_KEY=your-secret-key apprise --config=apprise.yml --tag=alerts --body="Hello" ``` A service missing a required value is skipped while other services continue; Apprise then exits with status `4`. Use `--dry-run` to check required values without sending anything. Only names declared under `template:` are replaced. Names ignore case, values keep their original case, and each value may be up to 1,024 characters. Undeclared `${...}` text remains unchanged, preventing similar text in an existing password from being replaced by accident. Extra supplied names are accepted and ignored. Variables may be placed directly in a URL or in a named YAML setting. URL markers can change any field, including the destination; whole-host markers also accept `user@host` and `user:pass@host`. A named setting changes only its option, so prefer it for values supplied by less-trusted callers. Variables cannot replace the service before `://`, a setting name, or a `tag:`/`tags:` value. Services and tags must be known before values are filled in. Template variables apply only to YAML configuration; TEXT configuration is unchanged. ## CLI Tagging Support Apprise allows you to tag your services in your configuration to organize them (e.g., `family`, `devops`, `critical`). You can then filter which services to notify using the `--tag` (`-g`) switch. It is important to understand how Apprise handles multiple tags: * **OR Logic (Union)**: To notify services that have *either* Tag A **OR** Tag B, specify the `-g` switch multiple times. * **AND Logic (Intersection)**: To notify services that have *both* Tag A **AND** Tag B, separate the tags with a comma. ```bash # OR Logic: Notify any service tagged 'devops' OR 'admin' apprise -vv -t "Union Test" \ --config=~/apprise.yml \ -g devops -g admin # AND Logic: Notify only services tagged with BOTH 'devops' AND 'critical' apprise -vv -t "Intersection Test" \ --config=~/apprise.yml \ -g devops,critical ``` Tags also support an optional **priority prefix** and **retry suffix**. In your configuration file you assign a priority to a tag like `1:alerts` or `5:alerts`. A lower number means higher urgency. * **Escalation (no prefix)**: `-g alerts` dispatches priority-1 entries first. If they all succeed, Apprise returns early and never runs the priority-5 fallbacks. A failure in the lower-priority group triggers the next group as an escalation chain. * **Exclusive (with prefix)**: `-g "2:alerts"` notifies *only* services whose `alerts` tag has priority 2. No other priority levels are triggered. * **Per-call retry**: `-g "alerts:3"` retries each matched service up to 3 times on failure (overrides the service's own retry setting for this call only). * **Combined**: `-g "2:alerts:3"` selects only priority-2 services and allows up to 3 retries. ```bash # Escalation: priority-1 first; skip priority-5 if all succeed apprise -vv -t "Alert" --config=~/apprise.yml -g alerts # Exclusive: only priority-2 alert services apprise -vv -t "Alert" --config=~/apprise.yml -g "2:alerts" # With retry override: retry each matched service up to 3 times apprise -vv -t "Alert" --config=~/apprise.yml -g "alerts:3" ``` ## CLI File Attachments Apprise also supports file attachments too! Specify as many attachments to a notification as you want. ```bash # Send a funny image you found on the internet to a colleague: apprise -vv --title 'Agile Joke' \ --body 'Did you see this one yet?' \ --attach https://i.redd.it/my2t4d2fx0u31.jpg \ 'mailto://myemail:mypass@gmail.com' # Easily send an update from a critical server to your dev team apprise -vv --title 'system crash' \ --body 'I do not think Jim fixed the bug; see attached...' \ --attach /var/log/myprogram.log \ --attach /var/debug/core.2345 \ --tag devteam ``` ## CLI Loading Custom Notifications/Hooks To create your own custom `schema://` hook so that you can trigger your own custom code, simply include the `@notify` decorator to wrap your function. ```python from apprise.decorators import notify # # The below assumes you want to catch foobar:// calls: # @notify(on="foobar", name="My Custom Foobar Plugin") def my_custom_notification_wrapper(body, title, notify_type, *args, **kwargs): """My custom notification function that triggers on all foobar:// calls """ # Write all of your code here... as an example... print("{}: {} - {}".format(notify_type.upper(), title, body)) # Returning True/False is a way to relay your status back to Apprise. # Returning nothing (None by default) is always interpreted as a Success ``` Once you've defined your custom hook, you just need to tell Apprise where it is at runtime. ```bash # By default if no plugin path is specified apprise will attempt to load # all plugin files (if present) from the following directory paths: # ~/.apprise/plugins # ~/.config/apprise/plugins # /var/lib/apprise/plugins # Windows users can store their default plugin files in these directories: # %APPDATA%/Apprise/plugins # %LOCALAPPDATA%/Apprise/plugins # %ALLUSERSPROFILE%\Apprise\plugins # %PROGRAMFILES%\Apprise\plugins # %COMMONPROGRAMFILES%\Apprise\plugins # If you placed your plugin file within one of the directories already defined # above, then your call simply needs to look like: apprise -vv --title 'custom override' \ --body 'the body of my message' \ foobar:\\ # However you can override the path like so apprise -vv --title 'custom override' \ --body 'the body of my message' \ --plugin-path /path/to/my/plugin.py \ foobar:\\ ``` You can read more about creating your own custom notifications and/or hooks [here](https://appriseit.com/library/extending/decorator/). ## CLI Environment Variables Those using the Command Line Interface (CLI) can also leverage environment variables to pre-set the default settings: | Variable | Description | |------------------------ | ----------------- | | `APPRISE_URLS` | Specify the default URLs to notify IF none are otherwise specified on the command line explicitly. If the `--config` (`-c`) is specified, then this will overrides any reference to this variable. Use white space and/or a comma (`,`) to delimit multiple entries. | `APPRISE_CONFIG_PATH` | Explicitly specify the config search path to use (overriding the default). The path(s) defined here must point to the absolute filename to open/reference. Use a semi-colon (`;`), line-feed (`\n`), and/or carriage return (`\r`) to delimit multiple entries. | `APPRISE_PLUGIN_PATH` | Explicitly specify the custom plugin search path to use (overriding the default). Use a semi-colon (`;`), line-feed (`\n`), and/or carriage return (`\r`) to delimit multiple entries. | `APPRISE_STORAGE_PATH` | Explicitly specify the persistent storage path to use (overriding the default). | `APPRISE_TEMPLATE_` | Supply a value for a `${NAME}` used by a YAML configuration. For example `APPRISE_TEMPLATE_API_KEY` fills in `${API_KEY}`. A value passed with `--template-var` (`-tv`) takes priority over this. # Developer API Usage To send a notification from within your python application, just do the following: ```python import apprise # Create an Apprise instance apobj = apprise.Apprise() # Add notification services by URL. # A sample email notification: apobj.add('mailto://myuserid:mypass@gmail.com') # A sample pushbullet notification apobj.add('pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b') # Then notify these services any time you desire. The below would # notify all of the services loaded into our Apprise object. apobj.notify( body='what a great notification service!', title='my notification title', ) ``` ## API Configuration Files Developers need access to configuration files too. The good news is that their use just involves declaring another object (called *AppriseConfig*) that the *Apprise* object can ingest. You can also freely mix and match config and notification entries as often as you wish! You can read more about the expected structure of the configuration files [here](https://appriseit.com/getting-started/configuration/). ```python import apprise # Create an Apprise instance apobj = apprise.Apprise() # Create an Config instance config = apprise.AppriseConfig() # Add a configuration source: config.add('/path/to/my/config.yml') # Add another... config.add('https://myserver:8080/path/to/config') # Make sure to add our config into our apprise object apobj.add(config) # You can mix and match; add an entry directly if you want too # In this entry we associate the 'admin' tag with our notification apobj.add('mailto://myuser:mypass@hotmail.com', tag='admin') # Then notify these services any time you desire. The below would # notify all of the services that have not been bound to any specific # tag. apobj.notify( body='what a great notification service!', title='my notification title', ) # Tagging allows you to specifically target only specific notification # services you've loaded: apobj.notify( body='send a notification to our admin group', title='Attention Admins', # notify any services tagged with the 'admin' tag tag='admin', ) # If you want to notify absolutely everything (regardless of whether # it's been tagged or not), just use the reserved tag of 'all': apobj.notify( body='send a notification to our admin group', title='Attention Admins', # notify absolutely everything loaded, regardless on whether # it has a tag associated with it or not: tag='all', ) ``` ## API Template Variables After loading a templated YAML configuration, developers can supply its values when sending: ```python apobj.notify( body='what a great notification service!', title='my notification title', template={'api_key': 'your-secret-key'}, ) ``` The per-call mapping takes priority over `APPRISE_TEMPLATE_` environment variables and defaults in the YAML file. Names are case-insensitive, and extra names are accepted and ignored. Templates are enabled by default. To turn them off, use the same disabled asset for the configuration and its Apprise object: ```python asset = apprise.AppriseAsset(allow_templates=False) config = apprise.AppriseConfig(asset=asset) apobj = apprise.Apprise(asset=asset) apobj.add(config) ``` When disabled, the `template:` section and template environment variables are ignored, and `${NAME}` remains unchanged. A configuration cannot enable this feature itself. ## API File Attachments Attachments are very easy to send using the Apprise API: ```python import apprise # Create an Apprise instance apobj = apprise.Apprise() # Add at least one service you want to notify apobj.add('mailto://myuser:mypass@hotmail.com') # Then send your attachment. apobj.notify( title='A great photo of our family', body='The flash caused Jane to close her eyes! hah! :)', attach='/local/path/to/my/DSC_003.jpg', ) # Send a web based attachment too! In the below example, we connect to a home # security camera and send a live image to an email. By default remote web # content is cached, but for a security camera we might want to call notify # again later in our code, so we want our last image retrieved to expire(in # this case after 3 seconds). apobj.notify( title='Latest security image', attach='http://admin:password@hikvision-cam01/ISAPI/Streaming/channels/101/picture?cache=3' ) ``` To send more than one attachment, just use a list, set, or tuple instead: ```python import apprise # Create an Apprise instance apobj = apprise.Apprise() # Add at least one service you want to notify apobj.add('mailto://myuser:mypass@hotmail.com') # Now add all of the entries we're interested in: attach = ( # ?name= allows us to rename the actual jpeg as found on the site # to be another name when sent to our receipient(s) 'https://i.redd.it/my2t4d2fx0u31.jpg?name=FlyingToMars.jpg', # Now add another: '/path/to/funny/joke.gif', ) # Send your multiple attachments with a single notify call: apobj.notify( title='Some good jokes.', body='Hey guys, check out these!', attach=attach, ) ``` ## API Loading Custom Notifications/Hooks By default, no custom plugins are loaded at all for those building from within the Apprise API. It's at the developers discretion to load custom modules. But should you choose to do so, it's as easy as including the path reference in the `AppriseAsset()` object prior to the initialization of your `Apprise()` instance. For example: ```python from apprise import Apprise from apprise import AppriseAsset # Prepare your Asset object so that you can enable the custom plugins to # be loaded for your instance of Apprise... asset = AppriseAsset(plugin_paths="/path/to/scan") # OR You can also generate scan more then one file too: asset = AppriseAsset( plugin_paths=[ # Iterate over all python libraries found in the root of the # specified path. This is NOT a recursive (directory) scan; only # the first level is parsed. HOWEVER, if a directory containing # an __init__.py is found, it will be included in the load. "/dir/containing/many/python/libraries", # An absolute path to a plugin.py to exclusively load "/path/to/plugin.py", # if you point to a directory that has an __init__.py file found in # it, then only that file is loaded (it's similar to point to a # absolute .py file. Hence, there is no (level 1) scanning at all # within the directory specified. "/path/to/dir/library" ] ) # Now that we've got our asset, we just work with our Apprise object as we # normally do aobj = Apprise(asset=asset) # If our new custom `foobar://` library was loaded (presuming we prepared # one like in the examples above). then you would be able to safely add it # into Apprise at this point aobj.add('foobar://') # Send our notification out through our foobar:// aobj.notify("test") ``` You can read more about creating your own custom notifications and/or hooks [here](https://appriseit.com/library/extending/decorator/). # Persistent Storage Persistent storage allows Apprise to cache re-occurring actions optionaly to disk. This can greatly reduce the overhead used to send a notification. There are 3 Persistent Storage operational states Apprise can operate using: 1. `auto`: Flush gathered cache information to the filesystem on demand. This option is incredibly light weight. This is the default behavior for all CLI usage. * Developers who choose to use this operational mode can also force cached information manually if they choose. * The CLI will use this operational mode by default. 2. `flush`: Flushes any cache information to the filesystem during every transaction. 3. `memory`: Effectively disable Persistent Storage. Any caching of data required by each plugin used is done in memory. Apprise effectively operates as it always did before peristent storage was available. This setting ensures no content is ever written to disk. * By default this is the mode Apprise will operate under for those developing with it unless they configure it to otherwise operate as `auto` or `flush`. This is done through the `AppriseAsset()` object and is explained further on in this documentation. ## CLI Persistent Storage Commands You can provide the keyword `storage` on your CLI call to see the persistent storage options available to you. ```bash # List all of the occupied space used by Apprise's Persistent Storage: apprise storage list # list is the default option, so the following does the same thing: apprise storage # You can prune all of your storage older then 30 days # and not accessed for this period like so: apprise storage prune # You can do a hard reset (and wipe all persistent storage) with: apprise storage clean ``` You can also filter your results by adding tags and/or URL Identifiers. When you get a listing (`apprise storage list`), you may see: ``` # example output of 'apprise storage list': 1. f7077a65 0.00B unused - matrixs://abcdef:****@synapse.example12.com/%23general?image=no&mode=off&version=3&msgtype... tags: team 2. 0e873a46 81.10B active - tgram://W...U//?image=False&detect=yes&silent=no&preview=no&content=before&mdv=v1&format=m... tags: personal 3. abcd123 12.00B stale ``` The (persistent storage) cache states are: - `unused`: This plugin has not commited anything to disk for reuse/cache purposes - `active`: This plugin has written content to disk. Or at the very least, it has prepared a persistent storage location it can write into. - `stale`: The system detected a location where a URL may have possibly written to in the past, but there is nothing linking to it using the URLs provided. It is likely wasting space or is no longer of any use. You can use this information to filter your results by specifying _URL ID_ (UID) values after your command. For example: ```bash # The below commands continue with the example already identified above # the following would match abcd123 (even though just ab was provided) # The output would only list the 'stale' entry above apprise storage list ab # knowing our filter is safe, we could remove it # the below command would not obstruct our other to URLs and would only # remove our stale one: apprise storage clean ab # Entries can be filtered by tag as well: apprise storage list --tag=team # You can match on multiple URL ID's as well: # The followin would actually match the URL ID's of 1. and .2 above apprise storage list f 0 ``` When using the CLI, Persistent storage is set to the operational mode of `auto` by default, you can change this by providing `--storage-mode=` (`-SM`) during your calls. If you want to ensure it's always set to a value of your choice. For more information on persistent storage, [visit here](https://appriseit.com/cli/persistent-storage/). ## API Persistent Storage Commands For developers, persistent storage is set in the operational mode of `memory` by default. It's at the developers discretion to enable it (by switching it to either `auto` or `flush`). Should you choose to do so: it's as easy as including the information in the `AppriseAsset()` object prior to the initialization of your `Apprise()` instance. For example: ```python from apprise import Apprise from apprise import AppriseAsset from apprise import PersistentStoreMode # Prepare a location the persistent storage can write it's cached content to. # By setting this path, this immediately assumes you wish to operate the # persistent storage in the operational 'auto' mode asset = AppriseAsset(storage_path="/path/to/save/data") # If you want to be more explicit and set more options, then you may do the # following asset = AppriseAsset( # Set our storage path directory (minimum requirement to enable it) storage_path="/path/to/save/data", # Set the mode... the options are: # 1. PersistentStoreMode.MEMORY # - disable persistent storage from writing to disk # 2. PersistentStoreMode.AUTO # - write to disk on demand # 3. PersistentStoreMode.FLUSH # - write to disk always and often storage_mode=PersistentStoreMode.FLUSH # The URL IDs are by default 8 characters in length. You can increase and # decrease it's value here. The value must be > 2. The default value is 8 # if not otherwise specified storage_idlen=8, ) # Now that we've got our asset, we just work with our Apprise object as we # normally do aobj = Apprise(asset=asset) ``` For more information on persistent storage, [visit here](https://appriseit.com/library/persistent-storage/). # Want To Learn More? If you're interested in reading more about this and other methods on how to customize your own notifications, please check out the following links: * 📣 [Using the CLI](https://appriseit.com/cli/) * 🛠ï¸� [Development API](https://appriseit.com/library/) * âš™ï¸� [Configuration File Help](https://appriseit.com/getting-started/configuration/) * âš¡ [Create Your Own Custom Notifications](https://appriseit.com/library/extending/decorator/) * 🌎 [Apprise API/Web Interface](https://github.com/caronc/apprise-api/) * 📖 [Apprise Documentation Source](https://github.com/caronc/apprise-docs/) * 🔧 [Troubleshooting](https://appriseit.com/qa/) * 🎉 [Showcase](https://appriseit.com/contributing/showcase/) Want to help make Apprise better? * 💡 [Contribute to the Apprise Code Base](https://appriseit.com/contributing/) * â�¤ï¸� [Sponsorship and Donations](https://appriseit.com/contributing/sponsors/) caronc-apprise-dfec414/SECURITY.md000066400000000000000000000005771526720371000167210ustar00rootroot00000000000000# Security Policy ## Supported Versions | Version | Supported | | -------- | ------------------ | | 2.0.1 | :white_check_mark: | | < 1.13.0 | :x: | ## Reporting a Vulnerability If you find a vunerability, please notify me at lead2gold@gmail.com. If the vunerability is severe then please just open a ticket at https://github.com/caronc/apprise/issues caronc-apprise-dfec414/all-plugin-requirements.txt000066400000000000000000000015661526720371000224550ustar00rootroot00000000000000# # Note: This file is being kept for backwards compatibility with # legacy systems that point here. All future changes should # occur in pyproject.toml. Contents of this file can be found # in [project.optional-dependencies].all-plugins # Provides fcm:// and more cryptography # Provides encrypted spush:// sends PyNaCl # Provides amqp:// support for AMQP 0.9.1 pika # AMQP 1.0 requires a source build on Linux. Uncomment this when a compiler # and the OpenSSL development headers are available. # python-qpid-proton # Provides growl:// support gntp # Provides mqtt:// support paho-mqtt >= 2.1.0 # Pretty Good Privacy (PGP) Provides mailto:// and deltachat:// support PGPy # Provides blink1:// support hidapi # Provides smpp:// support smpplib # For xmpp:// support (slixmpp 1.16.0+ needs Python 3.11 or newer) slixmpp >= 1.16.0; python_version >= '3.11' caronc-apprise-dfec414/apprise/000077500000000000000000000000001526720371000165625ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/__init__.py000066400000000000000000000101361526720371000206740ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. __title__ = "Apprise" __description__: str = ( "Push Notifications that work with just about every platform!" ) __version__ = "2.1.0" __author__ = "Chris Caron" __email__ = "lead2gold@gmail.com" __license__ = "BSD 2-Clause" __copyright__ = "Copyright (c) 2026, Chris Caron " __status__ = "Production" from . import decorators, exception from .apprise import Apprise from .apprise_attachment import AppriseAttachment from .apprise_config import AppriseConfig from .asset import AppriseAsset from .attachment.base import AttachBase from .common import ( CONFIG_FORMATS, CONTENT_INCLUDE_MODES, CONTENT_LOCATIONS, NOTIFY_FORMATS, NOTIFY_IMAGE_SIZES, NOTIFY_TYPES, OVERFLOW_MODES, PERSISTENT_STORE_MODES, PERSISTENT_STORE_STATES, ConfigFormat, ContentIncludeMode, ContentLocation, NotifyFormat, NotifyImageSize, NotifyType, OverflowMode, PersistentStoreMode, ) from .config.base import ConfigBase from .locale import AppriseLocale # Inherit our logging with our additional entries added to it from .logger import LOGGER_NAME, LogCapture, NotifyLogEntry, logger, logging from .manager_attachment import AttachmentManager from .manager_config import ConfigurationManager from .manager_plugins import NotificationManager from .persistent_store import PersistentStore from .plugins.base import NotifyBase from .result import ( AppriseResult, AppriseResultStatus, NotifyAttempt, NotifyResult, ) from .tag import AppriseTag from .template import NotifyTemplate from .url import PrivacyMode, URLBase # Set default logging handler to avoid "No handler found" warnings. logging.getLogger(__name__).addHandler(logging.NullHandler()) __all__ = [ "CONFIG_FORMATS", "CONTENT_INCLUDE_MODES", "CONTENT_LOCATIONS", "LOGGER_NAME", "NOTIFY_FORMATS", "NOTIFY_IMAGE_SIZES", "NOTIFY_TYPES", "OVERFLOW_MODES", "PERSISTENT_STORE_MODES", "PERSISTENT_STORE_STATES", # Core "Apprise", "AppriseAsset", "AppriseAttachment", "AppriseConfig", "AppriseLocale", "AppriseResult", "AppriseResultStatus", "AppriseTag", "AttachBase", "AttachmentManager", "ConfigBase", "ConfigFormat", "ConfigurationManager", "ContentIncludeMode", "ContentLocation", "LogCapture", # Managers "NotificationManager", "NotifyAttempt", "NotifyBase", "NotifyFormat", "NotifyImageSize", "NotifyLogEntry", "NotifyResult", "NotifyTemplate", # Reference "NotifyType", "OverflowMode", "PersistentStore", "PersistentStoreMode", "PrivacyMode", "URLBase", # Decorator "decorators", # Exceptions "exception", # Logging "logger", "logging", ] caronc-apprise-dfec414/apprise/apprise.py000066400000000000000000002572571526720371000206210ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations import asyncio import codecs from collections.abc import Iterator, Mapping, Sequence, Set as AbstractSet import concurrent.futures as cf import contextvars from functools import partial from itertools import chain import json import logging import os import re import threading import time from typing import Any, Callable, Optional, Union from . import __version__, common, plugins from .apprise_attachment import AppriseAttachment from .apprise_config import AppriseConfig from .asset import AppriseAsset from .common import ( JSON_COMPACT_SEPARATORS, ContentLocation, ) from .config.base import ConfigBase from .conversion import convert_between from .dispatch import ( CallState, RetryRunner, ServiceCall, call_with_retry, compute_deadline, configured_max_attempts, prepared_failure, service_crashed, service_metadata, timeout_result, ) from .emojis import apply_emojis from .exception import AppriseImproperlyConfigured, AppriseTemplateError from .locale import AppriseLocale from .logger import NotifyLogEntry, logger from .manager_plugins import NotificationManager from .plugins.base import ( _PAYLOAD_PRECAPPED, NotifyBase, ) from .result import ( AppriseResult, AppriseResultStatus, NotifyResult, ) from .tag import AppriseTag from .template import NotifyTemplate from .utils.cwe312 import cwe312_loggable from .utils.json import AppriseJSONEncoder from .utils.logic import is_exclusive_match from .utils.parse import parse_list, parse_urls from .utils.template import ( normalize_name, resolve_values, validate_overrides, ) # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() # Extra seconds of patience for notify() calls. _ABANDON_GRACE_SECONDS = 0.1 # The backslash escapes interpret_escapes turns into characters. Anything # else after a backslash is kept as written, so a Windows path such as # C:\Users does not make the whole message fail. ESCAPE_SEQUENCE_RE = re.compile( r"\\(?:U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|N\{[^{}\\]+\}" r"|[0-7]{1,3}|[\\'\"abfnrtv\n])" ) def decode_escapes(content: str) -> str: """Return ``content`` with its backslash escapes interpreted.""" def _decode(match: re.Match) -> str: """Return one escape as its character, or as written if invalid.""" try: # Let Python turn the single escape into its character return codecs.decode(match.group(0), "unicode_escape") except UnicodeDecodeError: # An unknown name such as \N{NOPE}, or a \U value past the # last Unicode character, is kept as written return match.group(0) # Only well-formed escapes change, so all other text is left alone return ESCAPE_SEQUENCE_RE.sub(_decode, content) # Shared pool for service calls. Coordinators use a separate pool so they # cannot occupy the workers needed by the service calls they await. _shared_executor: Optional[cf.ThreadPoolExecutor] = None _shared_executor_lock = threading.Lock() def get_shared_executor() -> cf.ThreadPoolExecutor: """Return the shared service worker pool, creating it when first used.""" global _shared_executor # Avoid taking the lock after the pool has already been created. if _shared_executor is None: with _shared_executor_lock: # Another thread may have created the pool while we waited. if _shared_executor is None: _shared_executor = cf.ThreadPoolExecutor() # Every notification call reuses this same bounded pool. return _shared_executor # Coordinators dispatch tag chains and synchronous batches from this pool. # Their child service calls run in ``_shared_executor`` above. _coordinator_executor: Optional[cf.ThreadPoolExecutor] = None _coordinator_executor_lock = threading.Lock() def get_coordinator_executor() -> cf.ThreadPoolExecutor: """Return the shared coordinator thread pool, creating it on first use.""" global _coordinator_executor # Avoid taking the lock after the pool has already been created. if _coordinator_executor is None: with _coordinator_executor_lock: # Another thread may have created the pool while we waited. if _coordinator_executor is None: _coordinator_executor = cf.ThreadPoolExecutor() # Keep batch coordination separate from service calls it waits for. return _coordinator_executor def submit_with_context( executor: cf.Executor, function: Callable, *args: Any ) -> cf.Future: """Submit work with a copy of the caller's logging context.""" # The copied context lets worker logs find the current call capture. context = contextvars.copy_context() # Run the function inside that copied context on a worker thread. return executor.submit(context.run, function, *args) # Services abandoned on timeout that are still genuinely running. Lets # cli.py poll instead of just sleeping out its whole grace window. _abandoned_futures: list[tuple[cf.Future, str, str]] = [] _abandoned_futures_lock = threading.Lock() def track_abandoned_future(future: cf.Future, name: str, url: str) -> None: """Record one abandoned-but-still-running service call.""" with _abandoned_futures_lock: # Remove completed work before adding the newly abandoned call. _abandoned_futures[:] = [ entry for entry in _abandoned_futures if not entry[0].done() ] # Keep safe display details beside the future for CLI reporting. _abandoned_futures.append((future, name, url)) def any_abandoned_calls_still_running() -> bool: """True if any abandoned service call is still actually running.""" with _abandoned_futures_lock: # Completed calls no longer need to delay CLI shutdown. _abandoned_futures[:] = [ entry for entry in _abandoned_futures if not entry[0].done() ] # A non-empty list means at least one worker remains busy. return bool(_abandoned_futures) def abandoned_call_descriptions() -> list[str]: """Return "name (url)" for each still-running abandoned call.""" with _abandoned_futures_lock: # Do not show calls that finished since the last check. _abandoned_futures[:] = [ entry for entry in _abandoned_futures if not entry[0].done() ] # URLs were privacy-masked before being stored here. return [f"{name} ({url})" for _, name, url in _abandoned_futures] class Apprise: """Manage and dispatch messages to Apprise services. A service is any loaded notification plugin. It is not necessarily a push notification server: email, IRC, XMPP, desktop integrations, webhooks, and other custom protocols all use the same service interface. """ def __init__( self, services: Optional[ Union[ str, dict, NotifyBase, AppriseConfig, ConfigBase, Sequence[ Union[str, dict, NotifyBase, AppriseConfig, ConfigBase] ], AbstractSet[Union[str, NotifyBase, AppriseConfig, ConfigBase]], ] ] = None, asset: Optional[AppriseAsset] = None, location: Optional[ContentLocation] = None, debug: bool = False, log_callback: Optional[ Callable[[NotifyLogEntry, Optional[NotifyBase]], None] ] = None, log_level: Optional[int] = None, ) -> None: """Initialize the manager and optionally load one or more services. ``services`` may be a URL, a URL dictionary, an instantiated notification or configuration plugin, an :class:`AppriseConfig` object, or a list containing any mixture of these. URL-based services are instantiated with ``asset``; when no asset is supplied, a default :class:`AppriseAsset` is created and shared with them. ``location`` applies an optional :class:`ContentLocation` restriction when attachments are prepared. For example, callers can prevent a remotely sourced request from attaching local content. A value of ``None`` leaves attachment locations unrestricted. ``log_callback`` receives captured entries synchronously as ``callback(entry, service)``. ``service`` is the plugin associated with the entry, or ``None`` for orchestration messages exposed through :meth:`AppriseResult.call_logs`. ``log_level`` controls the minimum captured level and defaults to ``WARNING``, or ``INFO`` when a callback is configured. Both settings may be overridden by an individual call to :meth:`notify` or :meth:`async_notify`. """ # Store notification services and configuration sources. self.services = [] # Assigns an central asset object that will be later passed into each # notification plugin. Assets contain information such as the local # directory images can be found in. It can also identify remote # URL paths that contain the images you want to present to the end # user. If no asset is specified, then the default one is used. self.asset = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) if services: self.add(services) # Use the asset language, or let Apprise detect the system language. self.locale = AppriseLocale(language=self.asset.language) # Set our debug flag self.debug = debug # Store our hosting location for optional strict rule handling # of Attachments. Setting this to None removes any attachment # restrictions. self.location = location # Default log_callback for every notify()/async_notify() call made # with this instance, unless overridden per-call. self._log_callback = log_callback # Default log_level for every notify()/async_notify() call made # with this instance, unless overridden per-call. None defers to # _ServiceLogCapture's own WARNING default. self._log_level = log_level @staticmethod def instantiate( url: Union[str, dict], asset: Optional[AppriseAsset] = None, tag: Optional[Union[str, Sequence[str], AbstractSet[str]]] = None, suppress_exceptions: bool = True, ) -> Optional[NotifyBase]: """Create a notification service from a URL or URL dictionary. A string is parsed as an Apprise URL. A dictionary supplies the parsed constructor components directly and must contain at least ``schema`` so the matching plugin can be selected. ``tag`` replaces the tags in the parsed data, and ``asset`` is passed to the plugin instance. Invalid, unsupported, or disabled services return ``None``. Plugin constructor errors are also converted to ``None`` when ``suppress_exceptions`` is true; setting it to false lets those errors reach the caller. An example of a url dictionary object might look like: { schema: 'mailto', host: 'google.com', user: 'myuser', password: 'mypassword', } Alternatively the string is much easier to specify: mailto://user:mypassword@google.com The dictionary form is particularly useful to callers that use :meth:`details` to discover the fields supported by a plugin and then build those fields programmatically. """ # Initialize our result set results = None # Prepare our Asset Object asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() if isinstance(url, str): # Acquire our url tokens results = plugins.url_to_dict( url, secure_logging=asset.secure_logging ) if results is None: # Failed to parse the service URL; detailed logging handled # inside url_to_dict - nothing to report here. return None elif isinstance(url, dict): # We already have our result set results = url if results.get("schema") not in N_MGR: # schema is a mandatory dictionary item as it is the only way # we can index into our loaded plugins logger.error('Dictionary does not include a "schema" entry.') logger.trace( "Invalid dictionary unpacked as:{}{}".format( os.linesep, os.linesep.join( [f'{k}="{v}"' for k, v in results.items()] ), ) ) return None logger.trace( "Dictionary unpacked as:{}{}".format( os.linesep, os.linesep.join( [f'{k}="{v}"' for k, v in results.items()] ), ) ) # Otherwise we handle the invalid input specified else: logger.error( "An invalid URL type (%s) was specified for instantiation", type(url), ) return None if not N_MGR[results["schema"]].enabled: # # First Plugin Enable Check (Pre Initialization) # # Plugin has been disabled at a global level logger.error( "%s:// is disabled on this system.", results["schema"] ) return None # Build a list of tags to associate with the newly added notifications results["tag"] = set(parse_list(tag)) # Set our Asset Object results["asset"] = asset if suppress_exceptions: try: # Attempt to create an instance of our plugin using the parsed # URL information plugin = N_MGR[results["schema"]](**results) # Create log entry of loaded URL logger.debug( "Loaded {} URL: {}".format( N_MGR[results["schema"]].service_name, plugin.url(privacy=asset.secure_logging), ) ) except Exception: # CWE-312 (Secure Logging) Handling loggable_url = cwe312_loggable(url, asset.secure_logging) # the arguments are invalid or can not be used. logger.error( "Could not load {} URL: {}".format( N_MGR[results["schema"]].service_name, loggable_url ) ) return None else: # Attempt to create an instance of our plugin using the parsed # URL information but don't wrap it in a try catch plugin = N_MGR[results["schema"]](**results) if not plugin.enabled: # # Second Plugin Enable Check (Post Initialization) # # Service/Plugin is disabled (on a more local level). This is a # case where the plugin was initially enabled but then after the # __init__() was called under the hood something pre-determined # that it could no longer be used. # The only downside to doing it this way is services are # initialized prior to returning the details() if 3rd party tools # are polling what is available. These services that become # disabled thereafter are shown initially that they can be used. logger.error( "%s:// has become disabled on this system.", results["schema"] ) return None return plugin def add( self, services: Union[ str, dict, NotifyBase, AppriseConfig, ConfigBase, Sequence[Union[str, dict, NotifyBase, AppriseConfig, ConfigBase]], AbstractSet[Union[str, NotifyBase, AppriseConfig, ConfigBase]], ], asset: Optional[AppriseAsset] = None, tag: Optional[Union[str, Sequence[str], AbstractSet[str]]] = None, ) -> bool: """Add one or more notification services or configuration sources. A string may contain one or more URLs and is split with :func:`parse_urls`. Dictionaries are treated as parsed URL components. Existing :class:`NotifyBase`, :class:`ConfigBase`, and :class:`AppriseConfig` objects are stored directly. ``asset`` overrides the manager's shared asset for services created from URLs or dictionaries. ``tag`` associates one or more tags with those newly created services so they can be selected by :meth:`notify`, :meth:`async_notify`, or :meth:`find`. The return value is true only when every supplied item was accepted. Valid items are retained even if another item in the same collection is invalid. """ # Initialize our return status return_status = True if asset is None: # prepare default asset asset = self.asset if isinstance(services, str): # build our service list services = parse_urls(services) if len(services) == 0: return False elif isinstance(services, dict): # no problem, we support kwargs, convert it to a list services = [services] elif isinstance(services, (ConfigBase, NotifyBase, AppriseConfig)): # Go ahead and just add our plugin into our list self.services.append(services) return True elif not isinstance(services, (tuple, set, list)): logger.error( f"An invalid notification (type={type(services)}) was" " specified." ) return False for service in services: if isinstance(service, (ConfigBase, NotifyBase, AppriseConfig)): # Go ahead and just add our plugin into our list self.services.append(service) continue elif not isinstance(service, (str, dict)): logger.error( f"An invalid notification (type={type(service)}) was" " specified." ) return_status = False continue # Instantiate ourselves an object, this function throws or # returns None if it fails instance = Apprise.instantiate(service, asset=asset, tag=tag) if not isinstance(instance, NotifyBase): # No logging is required as instantiate() handles failure # and/or success reasons for us return_status = False continue # Add our initialized plugin to our service listings self.services.append(instance) # Return our status return return_status def clear(self) -> None: """Remove all directly loaded services and configuration sources.""" self.services[:] = [] def find( self, tag: Any = common.MATCH_ALL_TAG, match_always: bool = True, template: Optional[dict] = None, resolve: bool = True, report: Optional[list] = None, ) -> Iterator[NotifyBase]: """Yield loaded services that match ``tag``. Services from configuration sources are flattened into the same sequence used for delivery. Top-level tags are alternatives (OR), while nested collections are intersections (AND). For example, ``[('a', 'b'), 'c']`` means ``(a AND b) OR c``. When ``match_always`` is true, services tagged ``always`` are yielded even when the requested filter would not otherwise select them. ``template`` supplies values for pending ``${NAME}`` entries. A value not given here falls back to the configuration's own default and then to ``APPRISE_TEMPLATE_``. Names are case-insensitive, unused names are ignored, and a blank value reads as no value. Unresolved entries are skipped and may be added to ``report``. Set ``resolve=False`` to return pending entries unchanged. """ # Build our tag setup # - top level entries are treated as an 'or' # - second level (or more) entries are treated as 'and' # # examples: # tag="tagA, tagB" = tagA or tagB # tag=['tagA', 'tagB'] = tagA or tagB # tag=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB # tag=[('tagB', 'tagC')] = tagB and tagC # A match_always flag allows us to pick up on our 'any' keyword # and notify these services under all circumstances match_always = common.MATCH_ALWAYS_TAG if match_always else None # Turn down unusable input before anything is logged or built. template = validate_overrides(template) # One value table can cover several configurations. Extra names are # harmless, but acknowledge them in the local debug log. loaded = None if resolve and template: # Reading a configuration can mean a network request, so keep # what each source returns and use it for both the check below # and the matching further down. loaded = [self._entry_services(entry) for entry in self.services] self._log_unused_template_names(template, loaded) # Iterate over our loaded plugins for index, entry in enumerate(self.services): # Without a value table nothing was read ahead of time, so each # source is still visited only as it is needed. services = ( loaded[index] if loaded is not None else self._entry_services(entry) ) for service in services: # Apply our tag matching based on our defined logic if not is_exclusive_match( logic=tag, data=service.tags, match_all=common.MATCH_ALL_TAG, match_always=match_always, ): continue if resolve and isinstance(service, NotifyTemplate): built = self._resolve_template(service, template) if built is None: if report is not None: # Track the entry itself, never the value # it was waiting on report.append(service) continue service = built yield service return @staticmethod def _entry_services(entry: Any) -> list: """Return the services one loaded entry stands for.""" if isinstance(entry, (ConfigBase, AppriseConfig)): # A configuration source may hold any number of services. return entry.services() return [ entry, ] def _log_unused_template_names( self, template: Mapping, loaded: list, ) -> None: """Acknowledge supplied names that no loaded template uses. ``loaded`` holds the services each entry already returned, so no configuration source is read a second time here. """ known: set[str] = set() for services in loaded: known.update( name for service in services if isinstance(service, NotifyTemplate) for name in service.template_names ) unused = [] seen = set() unused_count = 0 for key in template: # Names arriving here already passed validate_overrides(), so # nothing unprintable can reach the log line below. name = normalize_name(key) if name in known or name in seen: continue seen.add(name) unused_count += 1 if len(unused) < 20: # Keep debug output bounded for large caller mappings. unused.append(name) if not unused_count: return # Names may help the operator find a typo, but values may be secrets. # Keep this out of result logs that an API can stream to its caller. logger.debug( "Template variable(s) %s%s were supplied but not used", ", ".join(unused), ( " (+{} more)".format(unused_count - len(unused)) if unused_count > len(unused) else "" ), extra={"apprise_capture": False}, ) @staticmethod def _resolve_template( service: NotifyTemplate, template: Optional[dict], ) -> Optional[NotifyBase]: """Build a pending service, or return ``None`` when values are missing. Missing variable names are written only to the local log. """ try: values = resolve_values( service.template_schema, template, names=service.names, ) except AppriseTemplateError as e: logger.error( "Template variable '%s' is not available; %s:// was skipped", e.variable, service.schema, extra={"apprise_capture": False}, ) logger.warning( "A %s:// entry was skipped; it is waiting on a" " configuration value", service.schema, ) return None return service.resolve(values) @staticmethod def _filter_entries(tag): """Return top-level filter entries in a stable order. Sets are sorted because they have no order. Lists and tuples keep the order supplied by the caller. """ if isinstance(tag, (str, AppriseTag)): # A lone filter is the only entry there is. return [tag] if isinstance(tag, (set, frozenset)): # Sets have no fixed order and may appear differently each run. return sorted(tag, key=str) # Lists and tuples keep the order the caller wrote them in. return list(tag) @staticmethod def _parse_filter_tokens(tag): """Return a flat list of parsed tokens from any supported tag filter. Filters may be strings, :class:`AppriseTag` objects, lists, or nested AND groups. Match-all filters return an empty list. """ if tag is None or tag == common.MATCH_ALL_TAG: # Match-all does not name any individual tag. return [] # parse_list() sorts comma-separated tokens. Use separate list entries # when their order decides which retry value wins. tokens = [] for entry in Apprise._filter_entries(tag): # Split comma-separated text and flatten any inner group. raw = ( parse_list(entry) if isinstance(entry, (list, tuple, set)) else parse_list(str(entry)) ) # Parsing once here lets later helpers reuse the same metadata. tokens.extend(AppriseTag.parse(tok) for tok in raw) # Callers can now inspect names, priorities, and retry counts. return tokens @staticmethod def _filter_has_explicit_priority(tag): """Return whether a filter selects an exact priority. Exact priorities use flat dispatch; other filters use escalation. """ return any( token.has_priority for token in Apprise._parse_filter_tokens(tag) ) @staticmethod def _service_priority_for_tag_name(service, tag_name): """Return the dispatch priority stored on *service* for *tag_name*. Missing tags and plain strings use priority zero. """ for stag in service.tags: if str(stag) == tag_name: return stag.priority if isinstance(stag, AppriseTag) else 0 return 0 @staticmethod def _parse_retry_filter_tokens(tag): """Parse retry-bearing filter tokens once for all matched services.""" # Keep only the tokens that actually carry a ":retry" suffix. return [ token for token in Apprise._parse_filter_tokens(tag) if token.retry is not None ] @staticmethod def _match_service_retry(service, retry_tokens): """Return the first retry override matching ``service``. Plain tokens match names; priority tokens also require that priority. """ for ft in retry_tokens: tag_name = str(ft) if not ft.has_priority: if tag_name in service.tags: return ft.retry else: for stag in service.tags: if isinstance(stag, AppriseTag): if ( str(stag) == tag_name and stag.priority == ft.priority ): return ft.retry else: if str(stag).lower() == tag_name: return ft.retry return None @staticmethod def _inject_per_service_retries(all_calls, tag): """Add the first matching retry override to each service call.""" # Parse the filter once instead of repeating the work per service. retry_tokens = Apprise._parse_retry_filter_tokens(tag) if not retry_tokens: # No service can match when the filter has no retry override. return all_calls # Build new call pairs without changing the caller's list. result = [] for service, kwargs in all_calls: # The first matching filter token decides this service's override. retry = Apprise._match_service_retry(service, retry_tokens) if retry is not None: # Copy kwargs so other services keep their original options. kwargs = dict(kwargs, _retry_override=retry) result.append((service, kwargs)) # Keep service ordering exactly as it was received. return result @staticmethod def _resolve_call_level( effective_log_level: Optional[int], effective_log_callback: Optional[ Callable[[NotifyLogEntry, Optional[NotifyBase]], None] ], ) -> int: """Resolve the explicit level or the callback-aware default.""" # A level supplied by the caller always takes priority. if effective_log_level is not None: return effective_log_level # Live callbacks usually need progress messages as well as warnings. if effective_log_callback is not None: return logging.INFO # Preserve the quieter result-only default when no callback is active. return logging.WARNING @staticmethod def _build_tag_chains(all_calls, tag): """Group *all_calls* into per-OR-token escalation chains. Returns {chain_key: {priority: [(service, kwargs)]}}. Each service joins its first matching OR chain; unmatched services use ``""``. Match-all filters create one ``""`` chain. """ if tag is None or tag == common.MATCH_ALL_TAG: # Match-all has no tag priority, so every service uses one chain. chain: dict[int, list] = {} for service, kwargs in all_calls: chain.setdefault(0, []).append((service, kwargs)) return {"": chain} # Flatten OR tokens while keeping true AND groups in the catch-all # chain. The CLI wraps a single --tag value in a one-item list, so # treat that form as an OR token rather than an AND group. # Use the same order as retry matching so sets behave consistently. or_tag_names: list[str] = [] for entry in Apprise._filter_entries(tag): if isinstance(entry, (list, tuple, set)): flat = parse_list(*entry) if entry else [] if len(flat) == 1: # single OR token wrapped in a list (CLI convention) or_tag_names.append(str(AppriseTag.parse(flat[0]))) else: or_tag_names.append("") # AND group placeholder else: for tok in parse_list(str(entry)): or_tag_names.append(str(AppriseTag.parse(tok))) # Reuse these names when assigning services without an OR chain. filter_names = { str(token) for token in Apprise._parse_filter_tokens(tag) } chains: dict[str, dict[int, list]] = {} for service, kwargs in all_calls: for chain_key in or_tag_names: if chain_key and chain_key in service.tags: p = Apprise._service_priority_for_tag_name( service, chain_key ) chains.setdefault(chain_key, {}).setdefault(p, []).append( (service, kwargs) ) break else: # No named OR chain matched, so use the catch-all chain. # Choose the most urgent matching priority, or zero if none # of the service's tags appear in the filter. priorities = [ stag.priority if isinstance(stag, AppriseTag) else 0 for stag in service.tags if str(stag) in filter_names ] p = min(priorities) if priorities else 0 chains.setdefault("", {}).setdefault(p, []).append( (service, kwargs) ) return chains @staticmethod def _split_and_dispatch( batch: list[ServiceCall], call_deadline: Optional[float] = None ) -> tuple[bool, list[NotifyResult]]: """Dispatch sequential and parallel subsets of ``batch``. Results keep the caller's original order. ``call_deadline`` is passed through to the dispatch helpers. """ # Remember positions while splitting so results retain input order. sequential: list = [] parallel: list = [] for index, (service, kwargs) in enumerate(batch): # Each service's own asset decides how it gets dispatched. group = parallel if service.asset.async_mode else sequential group.append((index, service, kwargs)) # Run services that require ordered, one-at-a-time delivery. seq_ok, seq_results = ( Apprise._notify_sequential( *[(s, k) for _, s, k in sequential], call_deadline=call_deadline, ) if sequential else (True, []) ) # Run services that allow worker-thread delivery. par_ok, par_results = ( Apprise._notify_parallel_threadpool( *[(s, k) for _, s, k in parallel], call_deadline=call_deadline ) if parallel else (True, []) ) # Restore each subset's results to their original positions. merged: list[NotifyResult] = [None] * len(batch) for (index, _, _), result in zip(sequential, seq_results): # Put each ordered result back where its service began. merged[index] = result for (index, _, _), result in zip(parallel, par_results): # Do the same for results from the worker pool. merged[index] = result # Both subsets must succeed for the complete batch to succeed. return seq_ok and par_ok, merged @staticmethod async def _split_and_dispatch_async( batch: list[ServiceCall], call_deadline: Optional[float] = None ) -> tuple[bool, list[NotifyResult]]: """Dispatch sequential and asynchronous subsets of ``batch``. Both subsets run together without blocking the event loop. Results retain their original order. """ # Remember positions while splitting so results retain input order. sequential: list = [] parallel: list = [] for index, (service, kwargs) in enumerate(batch): # Each service's own asset decides how it gets dispatched. group = parallel if service.asset.async_mode else sequential group.append((index, service, kwargs)) # Use the caller's active event loop for the blocking subset. loop = asyncio.get_running_loop() # Run the blocking coordinator outside the leaf-call pool it awaits. executor = get_coordinator_executor() # Start the blocking batch in the executor so both subsets overlap # without blocking this event loop. seq_future = ( loop.run_in_executor( executor, contextvars.copy_context().run, partial( Apprise._notify_sequential, *[(s, k) for _, s, k in sequential], call_deadline=call_deadline, ), ) if sequential else None ) # Start and await services that support asynchronous delivery. par_ok, par_results = ( await Apprise._notify_parallel_asyncio( *[(s, k) for _, s, k in parallel], call_deadline=call_deadline ) if parallel else (True, []) ) # Collect the blocking batch, which may already be complete. seq_ok, seq_results = await seq_future if seq_future else (True, []) # Restore each subset's results to their original positions. merged: list[NotifyResult] = [None] * len(batch) for (index, _, _), result in zip(sequential, seq_results): # Put each ordered result back where its service began. merged[index] = result for (index, _, _), result in zip(parallel, par_results): # Do the same for asynchronously delivered results. merged[index] = result # Both subsets must succeed for the complete batch to succeed. return seq_ok and par_ok, merged def notify( self, body: Union[str, bytes], title: Union[str, bytes] = "", notify_type: Union[str, common.NotifyType] = common.NotifyType.INFO, body_format: Optional[str] = None, tag: Any = common.MATCH_ALL_TAG, match_always: bool = True, attach: Any = None, interpret_escapes: Optional[bool] = None, template: Optional[dict] = None, timeout: Union[int, float] = 0, log_callback: Optional[ Callable[[NotifyLogEntry, Optional[NotifyBase]], None] ] = None, log_level: Optional[int] = None, ) -> AppriseResult: """Send a notification to all the plugins previously loaded. If the body_format specified is NotifyFormat.MARKDOWN, it will be converted to HTML if the Notification type expects this. if the tag is specified (either a string or a set/list/tuple of strings), then only the notifications flagged with that tagged value are notified. By default, all added services are notified (tag=MATCH_ALL_TAG) Delivery and message-validation outcomes return ``AppriseResult``. ``bool(result)`` preserves the previous True/False behavior, while its status distinguishes success, failure, no match, and timeout. Iterate the result to inspect each service actually dispatched: result = apobj.notify(body="hello") for service_result in result: print(service_result.name, bool(service_result)) ``result.logs()`` combines service and orchestration entries in time order. Orchestration-only entries are also available in ``call_logs``. A filter tag may carry an optional priority prefix and/or retry suffix: "endpoint" -> match all entries; escalate by priority "3:endpoint" -> match ONLY priority-3 entries; flat dispatch "endpoint:2" -> match all entries; retry each up to 2 times "3:endpoint:2" -> exclusive priority-3 match with 2 retries When no priority prefix is given, matched services are grouped by their configured tag priority and dispatched in ascending order (lowest number = highest urgency). If every service in the lowest priority group succeeds, Apprise returns success immediately without running higher-numbered priority groups (escalation chain). When an explicit priority prefix IS given (e.g. "3:endpoint"), only services whose matching tag carries that exact priority are notified, and all matched services are dispatched as a single flat batch. Attach can contain a list of attachment URLs. attach can also be represented by an AttachBase() (or list of) object(s). This identifies the products you wish to notify Set interpret_escapes to True if you want to pre-escape a string such as turning a \n into an actual new line, etc. ``template`` supplies values for any YAML configuration entry written with ``${NAME}``. A value not given here is looked for in the configuration's own defaults and then in ``APPRISE_TEMPLATE_``. Blank call values are ignored, while an empty configuration default remains valid. An entry still missing a value is skipped, and the overall status reports PARTIAL rather than SUCCESS. ``timeout`` limits the entire call in seconds; unfinished services report TIMEOUT. The earlier call or service limit applies. A value of 0 leaves only the service limit active. Values must be finite, non-negative numbers; invalid values raise ``AppriseImproperlyConfigured``. log_callback overrides the instance default for this call. It must be synchronous; schedule any async work from inside the callback. ``service`` is ``None`` for a call-level entry (see ``result.call_logs()`` above). log_level overrides the capture level for this call. It defaults to WARNING, or INFO when a log_callback is active. Use DEBUG or TRACE for more detail. """ with CallState(self, tag, timeout, log_callback, log_level) as call: try: all_calls = list( self._create_notify_gen( body, title, notify_type=notify_type, body_format=body_format, tag=tag, match_always=match_always, attach=attach, interpret_escapes=interpret_escapes, template=template, report=call.skipped, ) ) except Exception as e: # A single service that fails is handled per service, so # anything reaching here is a problem with the call: a # message Apprise cannot use, or nothing to send at all. return call.unprepared(e) if not all_calls: # Tag filter matched nothing, or no services are loaded at all. return call.result(AppriseResultStatus.NOMATCH) # Apply the first matching retry suffix to each service. all_calls = call.prepare(all_calls) if Apprise._filter_has_explicit_priority(tag): # An explicit priority sends one flat batch without escalation. try: outcome = Apprise._split_and_dispatch( all_calls, call_deadline=call.deadline ) except Exception as e: # Hand the crash over as the batch outcome. outcome = e return call.flat(outcome) # Without an explicit priority: # - Each OR tag becomes an independent chain. # - Each chain starts at its highest priority. # - A failed group advances only its own chain. # - Active chains run concurrently. call.escalate(all_calls) for active in call.rounds(): if len(active) == 1: # Single active chain: dispatch directly, no thread # overhead. chain = active[0] try: outcome = Apprise._split_and_dispatch( chain.batch, call_deadline=call.deadline ) except Exception as e: # Hand the crash over as the batch outcome. outcome = e call.settle(chain, outcome) continue # Run active chains in the coordinator pool so their # service calls can use the shared worker pool. executor = get_coordinator_executor() future_map = { submit_with_context( executor, Apprise._split_and_dispatch, chain.batch, call.deadline, ): chain for chain in active } # Collect in submission order so results are stable # even when chains finish in a different order. for future, chain in future_map.items(): try: outcome = future.result() except Exception as e: # Hand the crash over as the batch outcome. outcome = e call.settle(chain, outcome) return call.finish() async def async_notify(self, *args: Any, **kwargs: Any) -> AppriseResult: """Asynchronously notify all loaded plugins. Arguments and results match :meth:`notify`, including ``timeout``, ``log_callback``, and ``log_level``. """ tag = kwargs.get("tag", common.MATCH_ALL_TAG) # Pop these -- none is a _create_notify_gen() parameter. with CallState( self, tag, kwargs.pop("timeout", 0), kwargs.pop("log_callback", None), kwargs.pop("log_level", None), ) as call: # Entries held back because a template value was never supplied. kwargs["report"] = call.skipped try: all_calls = list(self._create_notify_gen(*args, **kwargs)) except Exception as e: # As in notify(), but this signature takes *args/**kwargs, # so an unknown argument lands here too instead of being # rejected before the block above runs. return call.unprepared(e) if not all_calls: # Tag filter matched nothing, or no services are loaded at all. return call.result(AppriseResultStatus.NOMATCH) # Inject per-service call-time retry overrides (same logic as # notify). all_calls = call.prepare(all_calls) if Apprise._filter_has_explicit_priority(tag): # Explicit priority prefix: flat dispatch, no escalation. try: outcome = await Apprise._split_and_dispatch_async( all_calls, call_deadline=call.deadline ) except Exception as e: # Hand the crash over as the batch outcome. outcome = e return call.flat(outcome) # Use the same independent escalation chains as notify(). Active # groups run together; only a failed chain advances. call.escalate(all_calls) for active in call.rounds(): # Run active chains together. Keep one failure from cancelling # the others; gather() hands a cancellation back as a value, # so settle() receives it like any other outcome. gathered = await asyncio.gather( *( Apprise._split_and_dispatch_async( chain.batch, call_deadline=call.deadline ) for chain in active ), return_exceptions=True, ) for chain, outcome in zip(active, gathered): call.settle(chain, outcome) return call.finish() def _create_notify_calls(self, *args, **kwargs): """Creates notifications for all the plugins loaded. Returns a list of (service, notify() kwargs) tuples for plugins with parallelism disabled and another list for plugins with parallelism enabled. """ all_calls = list(self._create_notify_gen(*args, **kwargs)) # Split into sequential and parallel notify() calls. sequential, parallel = [], [] for service, notify_kwargs in all_calls: if service.asset.async_mode: parallel.append((service, notify_kwargs)) else: sequential.append((service, notify_kwargs)) return sequential, parallel def _create_notify_gen( self, body, title="", notify_type=common.NotifyType.INFO, body_format=None, tag=common.MATCH_ALL_TAG, match_always=True, attach=None, interpret_escapes=None, template=None, report=None, ): """Internal generator function for _create_notify_calls(). A service that raises while its content is prepared is still yielded, carrying its exception, so dispatch reports it only if a chain actually reaches it. """ if len(self) == 0: # Nothing loaded -- same as an empty tag match: NOMATCH, not # FAILURE. logger.warning("There are no service(s) to notify") return if not (title or body or attach): msg = "No message content specified to deliver" logger.error(msg) raise AppriseImproperlyConfigured(msg) try: notify_type = ( notify_type if isinstance(notify_type, common.NotifyType) else common.NotifyType(notify_type.lower()) ) except (AttributeError, ValueError, TypeError): err = ( f"An invalid notification type ({notify_type}) was specified." ) raise AppriseImproperlyConfigured(err) from None try: if title and isinstance(title, bytes): title = title.decode(self.asset.encoding) if body and isinstance(body, bytes): body = body.decode(self.asset.encoding) except UnicodeDecodeError: msg = ( "The content passed into Apprise was not of encoding " f"type: {self.asset.encoding}" ) logger.error(msg) raise AppriseImproperlyConfigured(msg) from None # Normalize falsy values. Each service applies the cap from its own # asset below, which may differ from ``self.asset``. title = title or "" body = body or "" # Tracks conversions conversion_body_map = {} conversion_title_map = {} # Prepare attachments if required if attach is not None and not isinstance(attach, AppriseAttachment): attach = AppriseAttachment( attach, asset=self.asset, location=self.location ) # Allow Asset default value body_format = ( self.asset.body_format if body_format is None else body_format ) # Allow Asset default value interpret_escapes = ( self.asset.interpret_escapes if interpret_escapes is None else interpret_escapes ) # Iterate over our loaded plugins for service in self.find( tag, match_always=match_always, template=template, report=report, ): # If our code reaches here, we either did not define a tag (it # was set to None), or we did define a tag and the logic above # determined we need to notify the service it's associated with # One misbehaving plugin must not stop the rest, so # anything raised while preparing this service's content # fails only this service. try: # Resolve this service's actual output format for this call. # A single-format service always uses its declared format. A # multi-format service may resolve differently for each call, # based on its URL's ``format`` option or the ``body_format`` # supplied to notify(). target_format = service.resolve_format(body_format) # Apply the service's own cap before conversion. Its asset may # differ from the top-level asset and have a smaller limit. capped_title, capped_body = ( service.asset.enforce_payload_max_size(title, body) ) capped_len = len(capped_title) + len(capped_body) if capped_len < len(title) + len(body): logger.warning( "%s payload trimmed to stay within the configured " "payload_max_size of %d characters.", service.service_name, service.asset._payload_max_size, ) # Cache by the resolved format, title handling, and # payload cap. Services with the same output needs can # share converted content. key = ( target_format if service.title_maxlen > 0 else f"_{target_format}" ) if service.asset._payload_max_size: # Allocation settings can produce different capped content, # so each combination needs its own conversion. key += ( f"-cap{service.asset._payload_max_size}" f"-buf{service.asset._payload_buffer_threshold}" f"-min{service.asset._payload_min_buffer}" ) if service.interpret_emojis: # alter our key slightly to handle emojis since # their value is pulled out of the notification key += "-emojis" if key not in conversion_title_map: # Prepare our title source_title = capped_title if capped_title else "" source_body = capped_body # Apply escapes and emojis before format conversion so # their source markers are still recognizable. if interpret_escapes: # # Escape our content # try: source_body = decode_escapes(source_body) source_title = decode_escapes(source_title) except TypeError: # Must be of string type msg = "Failed to escape message body" logger.error(msg) raise AppriseImproperlyConfigured(msg) from None if service.interpret_emojis: # # Convert our :emoji: definitions # source_body = apply_emojis(source_body) source_title = apply_emojis(source_title) # Conversion of title only occurs for services # where the title is blended with the body # (title_maxlen <= 0) conversion_title_map[key] = ( convert_between( body_format, target_format, content=source_title, ) if source_title and service.title_maxlen <= 0 else source_title ) # Our body is always converted no matter what conversion_body_map[key] = convert_between( body_format, target_format, content=source_body ) except AppriseImproperlyConfigured: # Bad input from the caller is not this service's # fault; it stops the whole call as it always has. raise except Exception as e: # Hand the failure to dispatch rather than reporting it # now. A fallback that escalation never reaches is then # never blamed, and an optional service stays optional. yield (service, {"_prepare_error": e}) continue kwargs = { "body": conversion_body_map[key], "title": conversion_title_map[key], "notify_type": notify_type, "attach": attach, # Pass the resolved target format; downstream notify() # calls use it directly without resolving again. # Preserve whether the caller omitted a source format so # downstream code skips automatic format conversion. "body_format": target_format, "body_passthrough": body_format is None, # Confirm the source was capped before conversion. The private # token prevents direct callers from skipping their own cap. "_payload_precapped": _PAYLOAD_PRECAPPED, } yield (service, kwargs) @staticmethod def _notify_sequential( *services_kwargs: ServiceCall, call_deadline: Optional[float] = None ) -> tuple[bool, list[NotifyResult]]: """Process a list of notify() calls one at a time, in order. Calls with deadlines use the shared executor so Apprise can stop waiting on a blocked service while preserving result order. """ success = True results: list[NotifyResult] = [] for service, kwargs in services_kwargs: # The outer wait needs a deadline before submission. deadline = compute_deadline(service, call_deadline) # Preserve retry metadata before the worker consumes its override. max_attempts = configured_max_attempts(service, kwargs) if deadline is None: ok, notify_result = call_with_retry(service, kwargs, None) success = success and ok results.append(notify_result) continue # Allow a small grace window before abandoning the wait. abandon_at = deadline + _ABANDON_GRACE_SECONDS wait_start = time.monotonic() wait_for = max(0.0, abandon_at - wait_start) logger.trace( "Waiting up to %.3fs for '%s'.", wait_for, service.service_name, ) executor = get_shared_executor() future = submit_with_context( executor, call_with_retry, service, kwargs, deadline ) try: # Keep a final guard for unexpected executor failures. ok, notify_result = future.result(timeout=wait_for) logger.trace( "'%s' finished after %.3fs: %s.", service.service_name, time.monotonic() - wait_start, "success" if ok else "failure", ) except cf.TimeoutError: # The service took too long; report TIMEOUT and move on. wait_elapsed = time.monotonic() - wait_start # Track only work that already started and cannot be cancelled. cancelled = future.cancel() if not cancelled: name, url, _, _, _ = service_metadata(service) track_abandoned_future(future, name, url) logger.trace( "Stopped waiting for '%s' after %.3fs (%s).", service.service_name, wait_elapsed, "it was still queued and has been cancelled" if cancelled else "its worker thread may still be running in the " "background", ) notify_result = timeout_result( service, wait_elapsed, max_attempts, ) ok = bool(notify_result) except Exception as e: # Safety net; one crash must not sink the whole batch. notify_result = service_crashed(service, e) ok = bool(notify_result) # One required failure means the whole batch cannot succeed. # Optional failures already count as SUCCESS in NotifyResult. success = success and ok results.append(notify_result) return success, results @staticmethod def _notify_parallel_threadpool( *services_kwargs: ServiceCall, call_deadline: Optional[float] = None ) -> tuple[bool, list[NotifyResult]]: """Process a list of notify() calls in parallel via a thread pool. Each worker handles retries and deadlines for one service. Timed-out workers may keep running, but Apprise stops waiting. """ n_calls = len(services_kwargs) if n_calls == 0: return True, [] if n_calls == 1: service, _ = services_kwargs[0] # Only take the no-thread-pool shortcut when this service has # no deadline whatsoever -- there is nothing to abandon, so a # plain blocking call is strictly cheaper and behaves # identically. Otherwise fall through to the thread-pool path # below so the deadline can actually be enforced. if compute_deadline(service, call_deadline) is None: return Apprise._notify_sequential( services_kwargs[0], call_deadline=call_deadline ) logger.debug("Threaded notification mode") logger.info("Notifying %d service(s).", len(services_kwargs)) # Keep output ordered by input, though threads finish out of order. # This is the shared, process-wide pool (see get_shared_executor()), # not a fresh one per call -- never shut down here, it persists for # the life of the process so a chronically hanging endpoint cannot # leak one more permanently-running thread with every call. executor = get_shared_executor() success = True results: list[Optional[NotifyResult]] = [None] * n_calls # Snapshot every service's deadline once, right at submission # time (they all start at essentially the same instant here). deadlines: list[Optional[float]] = [ compute_deadline(service, call_deadline) for service, kwargs in services_kwargs ] # Preserve retry metadata before workers consume private overrides. max_attempts = [ configured_max_attempts(service, kwargs) for service, kwargs in services_kwargs ] future_to_idx: dict[cf.Future, int] = { submit_with_context( executor, call_with_retry, service, kwargs, deadlines[i] ): i for i, (service, kwargs) in enumerate(services_kwargs) } for future, idx in future_to_idx.items(): service = services_kwargs[idx][0] # Give each expired service one grace window, not one per loop. abandon_at = ( deadlines[idx] + _ABANDON_GRACE_SECONDS if deadlines[idx] is not None else None ) wait_start = time.monotonic() wait_for = ( max(0.0, abandon_at - wait_start) if abandon_at is not None else None ) logger.trace( "Waiting up to %s for '%s'.", "no limit" if wait_for is None else f"{wait_for:.3f}s", service.service_name, ) try: # future.result() re-raises any exception that escaped # call_with_retry (should not happen given the inner # try/except, but guard here as a safety net). ok, notify_result = future.result(timeout=wait_for) logger.trace( "'%s' finished after %.3fs: %s.", service.service_name, time.monotonic() - wait_start, "success" if ok else "failure", ) except cf.TimeoutError: # Stop waiting once this service's grace window is spent. # NotifyResult keeps optional services successful. wait_elapsed = time.monotonic() - wait_start # Still queued -> cancel() succeeds, nothing to track. # Already running -> cancel() fails, so track it instead. cancelled = future.cancel() if not cancelled: name, url, _, _, _ = service_metadata(service) track_abandoned_future(future, name, url) logger.trace( "Stopped waiting for '%s' after %.3fs (%s).", service.service_name, wait_elapsed, "it was still queued and has been cancelled" if cancelled else "its worker thread may still be running in the " "background", ) notify_result = timeout_result( service, wait_elapsed, max_attempts[idx], ) ok = bool(notify_result) except Exception as e: # Safety net; one crash must not sink the whole batch. notify_result = service_crashed(service, e) ok = bool(notify_result) success = success and ok results[idx] = notify_result return success, results @staticmethod async def _notify_parallel_asyncio( *services_kwargs: ServiceCall, call_deadline: Optional[float] = None ) -> tuple[bool, list[NotifyResult]]: """Process a list of async_notify() calls concurrently via asyncio. Each coroutine handles one service, including retry and wait logic. The outer wait_for() keeps one stuck service from blocking its peers. A timeout means Apprise stops waiting and reports TIMEOUT. If the plugin is running sync work in a worker thread, that work may still finish later. """ n_calls = len(services_kwargs) if n_calls == 0: return True, [] logger.debug("Asynchronous notification mode") logger.info("Notifying %d service(s).", len(services_kwargs)) async def do_call( service: NotifyBase, kwargs: dict[str, Any], deadline: Optional[float], ) -> tuple[bool, NotifyResult]: """Run one asynchronous service with retries and waits. Plugin errors become failed attempts so retries can continue. Reuse the caller's deadline so the outer wait and worker agree. """ # A service that could not be prepared is never called at all. failed = prepared_failure(service, kwargs) if failed is not None: return failed runner = RetryRunner(service, kwargs, deadline) try: for attempt in range(runner.total): started = runner.begin(attempt) if started is None: break # Treat validation errors and plugin crashes alike, # matching synchronous delivery. with runner.capture() as capture: try: result = await service.async_notify(**kwargs) except Exception as e: runner.crashed(e) result = False if runner.record(attempt, result, started, capture): break delay = runner.delay(attempt) if delay > 0: await asyncio.sleep(delay) finally: runner.close() return runner.result() async def do_call_bounded( service: NotifyBase, kwargs: dict[str, Any], deadline: Optional[float], max_attempts: int, ) -> tuple[bool, NotifyResult]: """Apply an outer asyncio timeout to one service call. The service still owns its normal retry deadline inside do_call(). This wrapper limits how long this batch waits for the result. """ remaining = ( max(0.0, deadline - time.monotonic()) if deadline is not None else None ) # Add one small grace window so a nearly-finished service can # settle before it is reported as abandoned. wait_for = ( remaining + _ABANDON_GRACE_SECONDS if remaining is not None else None ) logger.trace( "Waiting up to %s for '%s'.", "no limit" if wait_for is None else f"{wait_for:.3f}s", service.service_name, ) wait_start = time.monotonic() try: # timeout=None makes wait_for() behave like a plain await. # This keeps the bounded and unbounded paths together. ok, notify_result = await asyncio.wait_for( do_call(service, kwargs, deadline), timeout=wait_for ) logger.trace( "'%s' finished after %.3fs: %s.", service.service_name, time.monotonic() - wait_start, "success" if ok else "failure", ) return ok, notify_result except asyncio.TimeoutError: # Derive the outcome from NotifyResult so an optional timeout # remains successful. # Apprise stops waiting here; it does not promise delivery # work has stopped. A send already running in the shared # executor cannot be cancelled, so NotifyBase tracks that # worker until it finishes. wait_elapsed = time.monotonic() - wait_start logger.trace( "Stopped waiting for '%s' after %.3fs; it may still " "be finishing in the background.", service.service_name, wait_elapsed, ) notify_result = timeout_result( service, wait_elapsed, max_attempts, ) return bool(notify_result), notify_result # Snapshot outer wait deadlines before launching the async workers; # do_call() reuses the same value instead of recomputing its own. deadlines: list[Optional[float]] = [ compute_deadline(service, call_deadline) for service, kwargs in services_kwargs ] # Preserve retry metadata before coroutines consume private overrides. max_attempts = [ configured_max_attempts(service, kwargs) for service, kwargs in services_kwargs ] # Run all coroutines concurrently. return_exceptions=True ensures # one escaped exception is reported without cancelling other services. cors = ( do_call_bounded( service, kwargs, deadlines[i], max_attempts[i], ) for i, (service, kwargs) in enumerate(services_kwargs) ) gathered = await asyncio.gather(*cors, return_exceptions=True) success = True results: list[NotifyResult] = [] for idx, item in enumerate(gathered): service = services_kwargs[idx][0] # gather() hands back BaseException, so a cancellation lands # here too instead of being unpacked as a result pair. if isinstance(item, BaseException): # Safety net: an exception escaped do_call's own try/except. notify_result = service_crashed(service, item) success = success and bool(notify_result) results.append(notify_result) else: ok, notify_result = item success = success and ok results.append(notify_result) return success, results def json( self, lang: Optional[str] = None, show_requirements: bool = False, show_disabled: bool = False, indent: Optional[int] = None, path: Optional[str] = None, ) -> Union[str, bool]: """Returns a json response associated with the Apprise object.""" details = self.details( lang=lang, show_requirements=show_requirements, show_disabled=show_disabled, ) if not path: return json.dumps( details, separators=JSON_COMPACT_SEPARATORS, indent=indent, cls=AppriseJSONEncoder, ) # Accented characters are written out as they are (see # ensure_ascii below), so the stream has to be able to hold them. # UTF-8 is named here rather than leaving it to the locale of # whoever is running Apprise. with open(path, "w", encoding="utf-8") as fp: try: json.dump( details, fp, separators=JSON_COMPACT_SEPARATORS, indent=indent, cls=AppriseJSONEncoder, ensure_ascii=False, ) except (OSError, EOFError) as e: logger.error("Apprise details dumpfile inaccessible: %s", path) logger.debug("Apprise details dump Exception: %s", e) # Early Exit return False finally: # Reduce memory del details return True def details( self, lang: Optional[str] = None, show_requirements: bool = False, show_disabled: bool = False, ) -> dict[str, Any]: """Returns the details associated with the Apprise object.""" # Use the asset language when the caller does not provide one. lang = lang if lang else self.asset.language # general object returned response = { # Defines the current version of Apprise "version": __version__, # Lists all of the currently supported Notifications "schemas": [], # Includes the configured asset details "asset": self.asset.details(), } for plugin in N_MGR.plugins(): # Iterate over our hashed plugins and dynamically build details on # their status: content = { "service_name": getattr(plugin, "service_name", None), "service_url": getattr(plugin, "service_url", None), "setup_url": getattr(plugin, "setup_url", None), # Placeholder - populated below "details": None, # Let upstream service know of the plugins that support # attachments "attachment_support": getattr( plugin, "attachment_support", False ), # Differentiat between what is a custom loaded plugin and # which is native. "category": getattr(plugin, "category", None), } # Standard protocol(s) should be None or a tuple enabled = getattr(plugin, "enabled", True) if not show_disabled and not enabled: # Do not show inactive plugins continue elif show_disabled: # Add current state to response content["enabled"] = enabled # Standard protocol(s) should be None or a tuple protocols = getattr(plugin, "protocol", None) if isinstance(protocols, str): protocols = (protocols,) # Secure protocol(s) should be None or a tuple secure_protocols = getattr(plugin, "secure_protocol", None) if isinstance(secure_protocols, str): secure_protocols = (secure_protocols,) # Add our protocol details to our content content.update( { "protocols": protocols, "secure_protocols": secure_protocols, } ) if not lang: # Simply return our results content["details"] = plugins.details(plugin) if show_requirements: content["requirements"] = plugins.requirements(plugin) else: # Emulate the specified language when returning our results with self.locale.lang_at(lang): content["details"] = plugins.details(plugin) if show_requirements: content["requirements"] = plugins.requirements(plugin) # Build our response object response["schemas"].append(content) return response def template_vars( self, tag: Any = common.MATCH_ALL_TAG, match_always: bool = True, ) -> dict[str, dict]: """Summarize the template variables used by loaded entries. Returns a mapping of variable name to a small summary:: {"api_key": {"default": None, "services": 2}, "smtp_host": {"default": "smtp.example.com", "services": 1}} ``default`` is ``None`` when no configuration default applies, either because none was written or because two configurations disagreed on one. A value may still arrive from ``APPRISE_TEMPLATE_``, so ``None`` does not always mean the caller has to supply it. Defaults are configuration content and should only be shared with authorized users. """ response: dict[str, dict] = {} for service in self.find( tag, match_always=match_always, resolve=False ): if not isinstance(service, NotifyTemplate): continue for name in service.template_names: variable = service.template_schema.variables[name] if name not in response: response[name] = { "default": variable.default, "services": 0, } elif response[name]["default"] != variable.default: # Two configurations disagree on the default. Keep # the stricter reading so nothing is quietly sent # with a value its author did not choose. logger.warning( "Template variable '%s' is declared differently" " in more than one configuration", name, ) response[name]["default"] = None response[name]["services"] += 1 return response def urls(self, privacy: bool = False) -> list[str]: """Returns all of the loaded URLs defined in this apprise object.""" urls = [] for s in self.services: if isinstance(s, (ConfigBase, AppriseConfig)): for s_ in s.services(): urls.append(s_.url(privacy=privacy)) else: urls.append(s.url(privacy=privacy)) return urls def pop(self, index: int) -> NotifyBase: """Remove and return the notification service at ``index``. Indexing uses the flattened service view: directly added plugins and plugins discovered inside configuration sources share one continuous sequence. Configuration containers are never popped by this method. When ``index`` identifies one of their services, that service is removed from the container while the configuration source remains loaded. An out-of-range index raises :class:`IndexError`. """ # Tracking variables prev_offset = -1 offset = prev_offset for idx, s in enumerate(self.services): if isinstance(s, (ConfigBase, AppriseConfig)): services = s.services() if len(services) > 0: # Acquire a new maximum offset to work with offset = prev_offset + len(services) if offset >= index: # we can pop an element from our config stack fn = ( s.pop if isinstance(s, ConfigBase) else s.service_pop ) return fn( index if prev_offset == -1 else (index - prev_offset - 1) ) else: offset = prev_offset + 1 if offset == index: return self.services.pop(idx) # Update our old offset prev_offset = offset # If we reach here, then we indexed out of range raise IndexError("list index out of range") def __getitem__(self, index: int) -> NotifyBase: """Return a service by its index in the flattened service view. Configuration sources themselves are not addressable here. Their discovered services occupy positions in the same sequence as services added directly. An out-of-range index raises :class:`IndexError`. """ # Tracking variables prev_offset = -1 offset = prev_offset for idx, s in enumerate(self.services): if isinstance(s, (ConfigBase, AppriseConfig)): # Get our list of services associate with our config object services = s.services() if len(services) > 0: # Acquire a new maximum offset to work with offset = prev_offset + len(services) if offset >= index: return services[ ( index if prev_offset == -1 else (index - prev_offset - 1) ) ] else: offset = prev_offset + 1 if offset == index: return self.services[idx] # Update our old offset prev_offset = offset # If we reach here, then we indexed out of range raise IndexError("list index out of range") def __getstate__(self) -> dict[str, object]: """Pickle Support dumps()""" attributes = { "asset": self.asset, # Prepare our URL list as we need to extract the associated tags # and asset details associated with it "urls": [ { "url": service.url(privacy=False), "tag": service.tags if service.tags else None, "asset": service.asset, } for service in self.services ], "locale": self.locale, "debug": self.debug, "location": self.location.value if self.location else None, } return attributes def __setstate__(self, state: dict[str, object]) -> None: """Pickle Support loads()""" self.services = [] self.asset = state["asset"] self.locale = state["locale"] location = state.get("location") self.location = ( location if isinstance(location, ContentLocation) else ContentLocation(location) if location is not None else None ) for entry in state["urls"]: self.add(entry["url"], asset=entry["asset"], tag=entry["tag"]) def __bool__(self) -> bool: """Allows the Apprise object to be wrapped in an 'if statement'. True is returned if at least one service has been loaded. """ return len(self) > 0 def __iter__(self) -> Iterator[NotifyBase]: """Iterate over the flattened collection of loaded services. Configuration containers are not yielded. Instead, each service discovered inside them is yielded alongside directly added services. """ return chain( *[ ( [s] if not isinstance(s, (ConfigBase, AppriseConfig)) else iter(s.services()) ) for s in self.services ] ) def __len__(self) -> int: """Return the size of the flattened collection of loaded services. Directly added notification plugins each count as one. Configuration containers do not count as entries themselves; every service currently discovered inside them does. Calling ``len()`` may therefore cause a configuration source to be read or refreshed according to its cache policy. """ return sum( ( 1 if not isinstance(s, (ConfigBase, AppriseConfig)) else len(s.services()) ) for s in self.services ) caronc-apprise-dfec414/apprise/apprise_attachment.py000066400000000000000000000341541526720371000230160ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from collections.abc import Iterator, Sequence, Set as AbstractSet from typing import Any, Optional, Union from .asset import AppriseAsset from .attachment.base import AttachBase from .common import ContentLocation from .exception import AppriseImproperlyConfigured from .logger import logger from .manager_attachment import AttachmentManager from .url import URLBase from .utils.cwe312 import cwe312_loggable from .utils.parse import GET_SCHEMA_RE # Grant access to our Notification Manager Singleton A_MGR = AttachmentManager() class AppriseAttachment: """Our Apprise Attachment File Manager.""" def __init__( self, paths: Optional[ Union[ str, AttachBase, Sequence[Union[str, AttachBase, "AppriseAttachment"]], AbstractSet[Union[str, AttachBase, "AppriseAttachment"]], ] ] = None, asset: Optional[AppriseAsset] = None, cache: Union[bool, int] = True, location: Optional[Union[str, ContentLocation]] = None, **kwargs: Any, ) -> None: """Loads all of the paths/urls specified (if any). The path can either be a single string identifying one explicit location, otherwise you can pass in a series of locations to scan via a list. By default we cache our responses so that subsiquent calls does not cause the content to be retrieved again. For local file references this makes no difference at all. But for remote content, this does mean more then one call can be made to retrieve the (same) data. This method can be somewhat inefficient if disabled. Only disable caching if you understand the consequences. You can alternatively set the cache value to an int identifying the number of seconds the previously retrieved can exist for before it should be considered expired. It's also worth nothing that the cache value is only set to elements that are not already of subclass AttachBase() Optionally set your current ContentLocation in the location argument. This is used to further handle attachments. The rules are as follows: - INACCESSIBLE: You simply have disabled use of the object; no attachments will be retrieved/handled. - HOSTED: You are hosting an attachment service for others. In these circumstances all attachments that are LOCAL based (such as file://) will not be allowed. - LOCAL: The least restrictive mode as local files can be referenced in addition to hosted. In all but HOSTED and LOCAL modes, INACCESSIBLE attachment types will continue to be inaccessible. However if you set this field (location) to None (it's default value) the attachment location category will not be tested in any way (all attachment types will be allowed). The location field is also a global option that can be set when initializing the Apprise object. """ # Initialize our attachment listings self.attachments = [] # Set our cache flag self.cache = cache # Prepare our Asset Object self.asset = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) if location: try: self.location = ( location if isinstance(location, ContentLocation) else ContentLocation(location.lower()) ) except (AttributeError, ValueError): err = ( f"An invalid Attachment location ({location}) was " "specified.", ) logger.warning(err) raise AppriseImproperlyConfigured(err) from None else: # do not set location if no initialization was made for it self.location = None # Now parse any paths specified if paths is not None and not self.add(paths): raise AppriseImproperlyConfigured( "One or more attachments could not be added." ) def add( self, attachments: Union[ str, AttachBase, "AppriseAttachment", Sequence[Union[str, AttachBase, "AppriseAttachment"]], AbstractSet[Union[str, AttachBase, "AppriseAttachment"]], ], asset: Optional[AppriseAsset] = None, cache: Optional[Union[bool, int]] = None, ) -> bool: """Adds one or more attachments into our list. By default we cache our responses so that subsiquent calls does not cause the content to be retrieved again. For local file references this makes no difference at all. But for remote content, this does mean more then one call can be made to retrieve the (same) data. This method can be somewhat inefficient if disabled. Only disable caching if you understand the consequences. You can alternatively set the cache value to an int identifying the number of seconds the previously retrieved can exist for before it should be considered expired. It's also worth nothing that the cache value is only set to elements that are not already of subclass AttachBase() """ # Initialize our return status return_status = True # Initialize our default cache value cache = cache if cache is not None else self.cache if asset is None: # prepare default asset asset = self.asset secure_logging = ( asset.secure_logging if isinstance(asset, AppriseAsset) else True ) if isinstance(attachments, (AttachBase, str)): # store our instance attachments = (attachments,) elif not isinstance(attachments, (tuple, set, list)): logger.error( f"An invalid attachment url (type={type(attachments)}) was " "specified." ) return False # Iterate over our attachments for attachment in attachments: if self.location == ContentLocation.INACCESSIBLE: logger.warning( "Attachments are disabled; ignoring %s", cwe312_loggable(attachment, secure_logging), ) return_status = False continue if isinstance(attachment, str): logger.debug( "Loading attachment: %s", cwe312_loggable(attachment, secure_logging), ) # Instantiate ourselves an object, this function throws or # returns None if it fails instance = AppriseAttachment.instantiate( attachment, asset=asset, cache=cache ) if not isinstance(instance, AttachBase): return_status = False continue elif isinstance(attachment, AppriseAttachment): # We were provided a list of Apprise Attachments # append our content together instance = attachment.attachments elif not isinstance(attachment, AttachBase): logger.warning( f"An invalid attachment (type={type(attachment)}) was" " specified." ) return_status = False continue else: # our entry is of type AttachBase, so just go ahead and point # our instance to it for some post processing below instance = attachment # Apply some simple logic if our location flag is set if self.location and ( ( self.location == ContentLocation.HOSTED and instance.location != ContentLocation.HOSTED ) or instance.location == ContentLocation.INACCESSIBLE ): logger.warning( "Attachment was disallowed due to accessibility" f" restrictions ({self.location}->{instance.location}):" f" {instance.url(privacy=True)}" ) return_status = False continue # Add the initialized plugin to our attachment list. if isinstance(instance, list): self.attachments.extend(instance) else: self.attachments.append(instance) # Return our status return return_status @staticmethod def instantiate( url: str, asset: Optional[AppriseAsset] = None, cache: Optional[Union[bool, int]] = None, suppress_exceptions: bool = True, ) -> Optional[AttachBase]: """Returns the instance of a instantiated attachment plugin based on the provided Attachment URL. If the url fails to be parsed, then None is returned. A specified cache value will over-ride anything set """ # Prepare the asset before any URL can be written to a log. asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() loggable_url = cwe312_loggable(url, asset.secure_logging) # Attempt to acquire the schema at the very least to allow our # attachment based urls. schema = GET_SCHEMA_RE.match(url) if schema is None: # Plan B is to assume we're dealing with a file schema = "file" url = f"{schema}://{URLBase.quote(url)}" else: # Ensure our schema is always in lower case schema = schema.group("schema").lower() # Some basic validation if schema not in A_MGR: logger.warning(f"Unsupported schema {schema}.") return None # Parse the attachment URL into constructor arguments. results = A_MGR[schema].parse_url(url) if not results: # The attachment URL could not be parsed. logger.warning(f"Unparseable URL {loggable_url}.") return None # Prepare our Asset Object results["asset"] = asset if cache is not None: # Force an over-ride of the cache value to what we have specified results["cache"] = cache if suppress_exceptions: try: # Attempt to create an instance of our plugin using the parsed # URL information attach_plugin = A_MGR[results["schema"]](**results) except Exception: # the arguments are invalid or can not be used. logger.warning(f"Could not load URL: {loggable_url}") return None else: # Attempt to create an instance of our plugin using the parsed # URL information but don't wrap it in a try catch attach_plugin = A_MGR[results["schema"]](**results) return attach_plugin def sync( self, abort_on_error: bool = True, abort_if_empty: bool = True, ) -> bool: """Itereates over all of the attachments and retrieves them.""" return ( False if abort_if_empty and not self.attachments else ( next((False for a in self.attachments if not a), True) if abort_on_error else next((True for a in self.attachments), True) ) ) def clear(self) -> None: """Empties our attachment list.""" self.attachments[:] = [] def size(self) -> int: """Returns the total size of accumulated attachments.""" return sum(len(a) for a in self.attachments if len(a) > 0) def pop(self, index: int = -1) -> AttachBase: """Removes an indexed Apprise Attachment from the stack and returns it. by default the last element is poped from the list """ # Remove our entry return self.attachments.pop(index) def __getitem__(self, index: int) -> AttachBase: """Returns the indexed entry of a loaded apprise attachments.""" return self.attachments[index] def __bool__(self) -> bool: """Allows the Apprise object to be wrapped in an 'if statement'. True is returned if at least one service has been loaded. """ return bool(self.attachments) def __iter__(self) -> Iterator[AttachBase]: """Returns an iterator to our attachment list.""" return iter(self.attachments) def __len__(self) -> int: """Returns the number of attachment entries loaded.""" return len(self.attachments) caronc-apprise-dfec414/apprise/apprise_config.py000066400000000000000000000462641526720371000221400ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations from collections.abc import Sequence, Set as AbstractSet from typing import TYPE_CHECKING, Any, Optional, Union from . import common from .asset import AppriseAsset from .config.base import ConfigBase from .logger import logger from .manager_config import ConfigurationManager from .url import URLBase from .utils.cwe312 import cwe312_loggable from .utils.logic import is_exclusive_match from .utils.parse import GET_SCHEMA_RE, parse_list if TYPE_CHECKING: from .plugins.base import NotifyBase # Grant access to our Configuration Manager Singleton C_MGR = ConfigurationManager() class AppriseConfig: """Manage the configuration sources from which services are discovered. Apprise supports a simple text format containing service URLs and a richer YAML format. Sources may be local files, remote URLs, in-memory content, or any other registered configuration plugin. See https://appriseit.com/getting-started/configuration/ for the supported file formats and examples. """ def __init__( self, paths: Optional[ Union[ str, ConfigBase, Sequence[Union[str, ConfigBase]], AbstractSet[Union[str, ConfigBase]], ] ] = None, asset: AppriseAsset | None = None, cache: bool | int = True, recursion: int = 0, insecure_includes: bool = False, **kwargs: Any, ) -> None: """Initialize the manager and optionally add configuration sources. ``paths`` may be one source string or a list of source strings. A source can be an explicit configuration URL such as ``file://`` or ``https://``; a path without a scheme is treated as a local file. If ``paths`` is omitted, the manager starts empty. Default configuration locations are selected by higher-level callers such as the CLI, not by this class. ``asset`` is shared with configuration plugins and with the services they create. ``cache`` controls whether a source is read again: true retains parsed results, false reloads on each request, and an integer gives the cache lifetime in seconds. Caching matters most for remote sources, where reloading requires another network request. ``recursion`` is the number of nested ``include`` levels to follow. Zero disables includes, one loads sources named by the top-level configuration, and larger values allow the included sources to include others. Keep this value low when loading content you do not control. Configuration plugins declare whether they may be included from other source types. In strict mode, a local ``file://`` source may include another local file, but a remote ``http://`` or ``https://`` source may not reach into the local filesystem. ``insecure_includes=True`` relaxes this strict same-type boundary; it does not override sources that prohibit inclusion entirely. This option is also required when trusted in-memory configuration must include local files. See https://appriseit.com/getting-started/configuration/ for the configuration syntax and include examples. """ # Store the configuration sources used to discover services. self.configs = [] # Prepare our Asset Object self.asset = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) # Set our cache flag self.cache = cache # Initialize our recursion value self.recursion = recursion # Initialize our insecure_includes flag self.insecure_includes = insecure_includes if paths is not None: # Store our path(s) self.add(paths) return def add( self, configs: Union[ str, ConfigBase, Sequence[Union[str, ConfigBase]], AbstractSet[Union[str, ConfigBase]], ], asset: AppriseAsset | None = None, tag: Optional[Union[str, Sequence[str], AbstractSet[str]]] = None, cache: bool | int = True, recursion: int | None = None, insecure_includes: bool | None = None, ) -> bool: """Add one or more configuration sources to the manager. ``configs`` accepts a source string, an instantiated :class:`ConfigBase`, or a collection containing either. Strings without a URL scheme are treated as local file paths. ``tag`` is attached to newly created configuration sources and can later select which sources :meth:`services` reads; it does not tag each service found inside. ``asset``, ``cache``, ``recursion``, and ``insecure_includes`` are passed to sources created from strings. ``asset``, ``recursion``, and ``insecure_includes`` fall back to this manager's values when omitted. An already instantiated :class:`ConfigBase` retains all of its own settings because it is stored directly. The cache setting may be true to retain parsed results, false to reload whenever services are requested, or a non-negative integer cache lifetime in seconds. ``recursion`` limits nested ``include`` entries. ``insecure_includes`` relaxes strict cross-source inclusion rules; use it only for trusted configuration. The return value is true only when every supplied item was accepted. Valid sources remain loaded when another item in the same collection is invalid. """ # Initialize our return status return_status = True # Initialize our default cache value cache = cache if cache is not None else self.cache # Initialize our default recursion value recursion = recursion if recursion is not None else self.recursion # Initialize our default insecure_includes value insecure_includes = ( insecure_includes if insecure_includes is not None else self.insecure_includes ) if asset is None: # prepare default asset asset = self.asset secure_logging = ( asset.secure_logging if isinstance(asset, AppriseAsset) else True ) if isinstance(configs, ConfigBase): # Go ahead and just add our configuration into our list self.configs.append(configs) return True elif isinstance(configs, str): # Save our path configs = (configs,) elif not isinstance(configs, (tuple, set, list)): logger.error( f"An invalid configuration path (type={type(configs)}) was " "specified." ) return False # Iterate over our configuration for config in configs: if isinstance(config, ConfigBase): # Go ahead and just add our configuration into our list self.configs.append(config) continue elif not isinstance(config, str): logger.warning( f"An invalid configuration (type={type(config)}) was" " specified." ) return_status = False continue logger.debug( "Loading configuration: %s", cwe312_loggable(config, secure_logging), ) # Instantiate ourselves an object, this function throws or # returns None if it fails instance = AppriseConfig.instantiate( config, asset=asset, tag=tag, cache=cache, recursion=recursion, insecure_includes=insecure_includes, ) if not isinstance(instance, ConfigBase): return_status = False continue # Keep the initialized configuration source. self.configs.append(instance) # Return our status return return_status def add_config( self, content: str, asset: AppriseAsset | None = None, tag: Optional[Union[str, Sequence[str], AbstractSet[str]]] = None, format: str | None = None, recursion: int | None = None, insecure_includes: bool | None = None, ) -> bool: """Add raw configuration text as an in-memory source. The content exists only for the lifetime of the resulting in-memory configuration object. Set ``format`` to ``yaml`` or ``text`` when it is known; otherwise Apprise detects the format. The method returns false when ``content`` is not a string or its format cannot be determined. ``asset`` is passed to services created from the content, while ``tag`` labels the in-memory configuration source for filtering by :meth:`services`. ``recursion`` and ``insecure_includes`` fall back to this manager's defaults when omitted. Enable insecure includes only when trusted in-memory content must include a source, such as a local ``file://`` configuration, that the normal security boundary rejects. """ # Initialize our default recursion value recursion = recursion if recursion is not None else self.recursion # Initialize our default insecure_includes value insecure_includes = ( insecure_includes if insecure_includes is not None else self.insecure_includes ) if asset is None: # prepare default asset asset = self.asset if not isinstance(content, str): logger.warning( f"An invalid configuration (type={type(content)}) was" " specified." ) return False logger.debug(f"Loading raw configuration: {content}") # Create ourselves a ConfigMemory Object to store our configuration instance = C_MGR["memory"]( content=content, format=format, asset=asset, tag=tag, recursion=recursion, insecure_includes=insecure_includes, ) if not ( instance.config_format and instance.config_format.value in common.CONFIG_FORMATS ): logger.warning( "The format of the configuration could not be detected." ) return False # Keep the initialized in-memory configuration source. self.configs.append(instance) # Return our status return True def services( self, tag: Any = common.MATCH_ALL_TAG, match_always: bool = True, *args: Any, **kwargs: Any, ) -> list[NotifyBase]: """Read matching configuration sources and return their services. ``tag`` is matched against tags on the configuration sources themselves, not tags on the services defined inside those sources. This lets a caller choose which files or remote locations to poll. Top-level tag entries are alternatives (OR), while nested collections are intersections (AND). When ``match_always`` is true, configuration sources carrying the reserved ``always`` tag are read even if the requested filter would not otherwise select them. Each matching source applies its own cache, recursion, and include-security policy as it builds the returned list. """ # A match_always flag allows us to pick up on our 'any' keyword # and notify these services under all circumstances match_always = common.MATCH_ALWAYS_TAG if match_always else None # Build our tag setup # - top level entries are treated as an 'or' # - second level (or more) entries are treated as 'and' # # examples: # tag="tagA, tagB" = tagA or tagB # tag=['tagA', 'tagB'] = tagA or tagB # tag=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB # tag=[('tagB', 'tagC')] = tagB and tagC response = [] for entry in self.configs: # Apply our tag matching based on our defined logic if is_exclusive_match( logic=tag, data=entry.tags, match_all=common.MATCH_ALL_TAG, match_always=match_always, ): # Add services discovered in this configuration source. response.extend(entry.services()) return response @staticmethod def instantiate( url: str, asset: AppriseAsset | None = None, tag: Optional[Union[str, Sequence[str], AbstractSet[str]]] = None, cache: bool | int | None = None, recursion: int = 0, insecure_includes: bool = False, suppress_exceptions: bool = True, ) -> ConfigBase | None: """Returns the instance of a instantiated configuration plugin based on the provided Config URL. If the url fails to be parsed, then None is returned. """ # Attempt to acquire the schema at the very least to allow our # configuration based urls. schema = GET_SCHEMA_RE.match(url) if schema is None: # Plan B is to assume we're dealing with a file schema = "file" url = f"{schema}://{URLBase.quote(url)}" else: # Ensure our schema is always in lower case schema = schema.group("schema").lower() # Some basic validation if schema not in C_MGR: logger.error(f"Unsupported schema {schema}.") return None # Parse the configuration URL into constructor arguments. results = C_MGR[schema].parse_url(url) if not results: # The configuration URL could not be parsed. # CWE-312 (Secure Logging) Handling secure_logging = ( asset.secure_logging if isinstance(asset, AppriseAsset) else True ) loggable_url = cwe312_loggable(url, secure_logging) logger.error(f"Unparseable URL {loggable_url}.") return None # Build a list of tags to associate with the newly added notifications results["tag"] = set(parse_list(tag)) # Prepare our Asset Object results["asset"] = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) if cache is not None: # Force an over-ride of the cache value to what we have specified results["cache"] = cache # Recursion can never be parsed from the URL results["recursion"] = recursion # Insecure includes flag can never be parsed from the URL results["insecure_includes"] = insecure_includes if suppress_exceptions: try: # Attempt to create an instance of our plugin using the parsed # URL information cfg_plugin = C_MGR[results["schema"]](**results) except Exception: # the arguments are invalid or can not be used. # CWE-312 (Secure Logging) Handling loggable_url = cwe312_loggable( url, results["asset"].secure_logging ) logger.error(f"Could not load URL: {loggable_url}") return None else: # Attempt to create an instance of our plugin using the parsed # URL information but don't wrap it in a try catch cfg_plugin = C_MGR[results["schema"]](**results) return cfg_plugin def clear(self) -> None: """Empties our configuration list.""" self.configs[:] = [] def service_pop(self, index: int) -> NotifyBase: """Remove and return a service from the flattened configuration view. The configuration sources remain loaded. ``index`` addresses the services discovered across them as one continuous sequence, and an out-of-range index raises :class:`IndexError`. """ # Tracking variables prev_offset = -1 offset = prev_offset for entry in self.configs: services = entry.services(cache=True) if len(services) > 0: # Acquire a new maximum offset to work with offset = prev_offset + len(services) if offset >= index: # we can pop an notification from our config stack return entry.pop( index if prev_offset == -1 else (index - prev_offset - 1) ) # Update our old offset prev_offset = offset # If we reach here, then we indexed out of range raise IndexError("list index out of range") def pop(self, index: int = -1) -> ConfigBase: """Removes an indexed Apprise Configuration from the stack and returns it. By default, the last element is removed from the list """ # Remove our entry return self.configs.pop(index) def __getitem__(self, index: int) -> ConfigBase: """Returns the indexed config entry of a loaded apprise configuration.""" return self.configs[index] def __bool__(self) -> bool: """Allows the Apprise object to be wrapped in an 'if statement'. True is returned if at least one service has been loaded. """ return bool(self.configs) def __iter__(self): # type: () -> Iterator[ConfigBase] """Returns an iterator to our config list.""" return iter(self.configs) def __len__(self) -> int: """Returns the number of config entries loaded.""" return len(self.configs) caronc-apprise-dfec414/apprise/asset.py000066400000000000000000000725641526720371000202710ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from collections.abc import Sequence from datetime import datetime, tzinfo import math from os.path import abspath, dirname, isfile, join import re from typing import Any, Optional, Union from uuid import uuid4 from .common import ( NotifyFormat, NotifyImageSize, NotifyType, PersistentStoreMode, ) from .exception import AppriseImproperlyConfigured from .locale import AppriseLocale from .manager_plugins import NotificationManager from .utils.time import zoneinfo # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() class AppriseAsset: """Provide shared application settings, paths, and delivery limits. Private attributes are excluded from configuration. Callers use their public constructor arguments; ``_uid`` and ``_recursion`` remain internal compatibility exceptions. """ # Application Identifier app_id = "Apprise" # Application Description app_desc = "Apprise Notifications" # Provider URL app_url = "https://github.com/caronc/apprise" # A Simple Mapping of Colors; For every NOTIFY_TYPE identified, # there should be a mapping to it's color here: html_notify_map = { NotifyType.INFO: "#3AA3E3", NotifyType.SUCCESS: "#3AA337", NotifyType.FAILURE: "#A32037", NotifyType.WARNING: "#CACF29", } # The default color to return if a mapping isn't found in our table above default_html_color = "#888888" # Ascii Notification ascii_notify_map = { NotifyType.INFO: "[i]", NotifyType.SUCCESS: "[+]", NotifyType.FAILURE: "[!]", NotifyType.WARNING: "[~]", } # The default ascii to return if a mapping isn't found in our table above default_ascii_chars = "[?]" # The default image extension to use default_extension = ".png" # The default image size if one isn't specified default_image_size = NotifyImageSize.XY_256 # The default theme theme = "default" # Image URL Mask image_url_mask = ( "https://github.com/caronc/apprise/raw/master/apprise/assets/" "themes/{THEME}/apprise-{TYPE}-{XY}{EXTENSION}" ) # Application Logo image_url_logo = ( "https://github.com/caronc/apprise/raw/master/apprise/assets/" "themes/{THEME}/apprise-logo.png" ) # Image Path Mask image_path_mask = abspath( join( dirname(__file__), "assets", "themes", "{THEME}", "apprise-{TYPE}-{XY}{EXTENSION}", ) ) # This value can also be set on calls to Apprise.notify(). This allows # you to let Apprise upfront the type of data being passed in. This # must be of type NotifyFormat. Possible values could be: # - NotifyFormat.TEXT # - NotifyFormat.MARKDOWN # - NotifyFormat.HTML # - None # # If no format is specified (hence None), then no special pre-formatting # actions will take place during a notification. This has been and always # will be the default. body_format = None # Always attempt to send notifications asynchronous (as the same time # if possible) # This is a Python 3 supported option only. If set to False, then # notifications are sent sequentially (one after another) async_mode = True # Support :smile:, and other alike keywords swapping them for their # unicode value. A value of None leaves the interpretation up to the # end user to control (allowing them to specify emojis=yes on the # URL) interpret_emojis = None # Whether or not to interpret escapes found within the input text prior # to passing it upstream. Such as converting \t to an actual tab and \n # to a new line. interpret_escapes = False # Default number of retries after a first notification failure. # Individual plugins may override this via their ?retry= URL parameter # or the 'retry:' YAML key. Clamped to [0, APPRISE_MAX_SERVICE_RETRY]. default_service_retry = 0 # Default wait (in seconds) between retry attempts. Individual plugins # may override this via their ?wait= URL parameter or the 'wait:' YAML # key. Clamped to [0.0, APPRISE_MAX_SERVICE_WAIT]. default_service_wait = 0.5 # When True, as soon as any independent OR tag chain exhausts all its # priority groups without success, notify() / async_notify() return False # immediately without running further escalation rounds for the surviving # chains. When False (the default), all chains are allowed to complete # regardless of whether another chain has already failed. abort_on_chain_failure = False # Defines the encoding of the content passed into Apprise encoding = "utf-8" # Automatically generate our Pretty Good Privacy (PGP) keys if one isn't # present and our environment configuration allows for it. # For example, a case where the environment wouldn't allow for it would be # if Persistent Storage was set to `memory` pgp_autogen = True # Automatically generate our Privacy Enhanced Mail (PEM) keys if one isn't # present and our environment configuration allows for it. # For example, a case where the environment wouldn't allow for it would be # if Persistent Storage was set to `memory` pem_autogen = True # For more detail see CWE-312 @ # https://cwe.mitre.org/data/definitions/312.html # # By enabling this, the logging output has additional overhead applied to # it preventing secure password and secret information from being # displayed in the logging. Since there is overhead involved in performing # this cleanup; system owners who run in a very isolated environment may # choose to disable this for a slight performance bump. It is recommended # that you leave this option as is otherwise. secure_logging = True # By default, HTTP redirects (3xx responses) are followed, matching the # behaviour of the underlying requests library. Set this to False to # disable redirect following globally across all plugins without having # to add redirect=no to every individual URL. Individual URLs can still # override this via the redirect= parameter. http_redirects = True # Optionally specify one or more path to attempt to scan for Python modules # By default, no paths are scanned. __plugin_paths = [] # Optionally set the location of the persistent storage # By default there is no path and thus persistent storage is not used __storage_path = None # Optionally define the default salt to apply to all persistent storage # namespace generation (unless over-ridden) __storage_salt = b"" # Optionally define the namespace length of the directories created by # the storage. If this is set to zero, then the length is pre-determined # by the generator (sha1, md5, sha256, etc) __storage_idlen = 8 # Set storage to auto __storage_mode = PersistentStoreMode.AUTO # Internal settings are not loaded from YAML configuration. # AppriseAPI passes this internal counter between chained servers to # prevent notification loops. Its constructor support is retained for # compatibility with https://github.com/caronc/apprise-api. _recursion = 0 # AppriseAPI preserves this internal source identifier across chained # requests. Its constructor support is retained for compatibility. _uid = str(uuid4()) # AppriseAPI still passes these private arguments. All other internal # settings must use their validated public constructor arguments. _KWARGS_INTERNAL_ALLOWLIST = frozenset({"_recursion", "_uid"}) # Language for user-facing text. Codes may include a region, such as # en-CA or en_CA. Apprise tries the base language, then English, when a # matching catalog is unavailable. None uses the system language. _language = None # Default timezone to use (pass in timezone value) # A list of timezones can be found here: # https://en.wikipedia.org/wiki/List_of_tz_database_time_zones # You can specify things such as 'America/Montreal' # If no timezone is specified, then the one detected on the system # is used _tzinfo = None # Default budget for one service, including retries and waits. Apprise # reports TIMEOUT after this many seconds; 0 disables the service limit. _service_timeout = 60.0 # A safety ceiling on the combined title + body character count, checked # before any format conversion or splitting begins. 0 disables the check # entirely, which is the default. _payload_max_size = 0 # A title or body at or below this length may remain whole when the other # side can retain its minimum. _payload_buffer_threshold = 10 # Minimum reserved for the other side. If it cannot fit, title and body # share the cap proportionally. _payload_min_buffer = 25 # Keep result logs in memory unless disk-backed capture is configured. # Apprise reads this setting from its existing asset when it notifies. _result_log_memory_size = 0 # A zero disk allowance preserves the existing memory-only behavior. # When enabled, both limits cover the complete notification call. _result_log_disk_size = 0 # Whether YAML may declare and fill ${NAME} values. This internal setting # belongs to the host, not the configuration. When disabled, template # sections are ignored and markers remain ordinary text. _allow_templates = True def __init__( self, plugin_paths: Optional[Sequence[str]] = None, storage_path: Optional[str] = None, storage_mode: Optional[Union[str, PersistentStoreMode]] = None, storage_salt: Optional[Union[str, bytes]] = None, storage_idlen: Optional[int] = None, timezone: Optional[Union[str, tzinfo]] = None, language: Optional[str] = None, service_timeout: Optional[Union[int, float]] = None, payload_max_size: Optional[int] = None, allow_templates: Optional[bool] = None, payload_buffer_threshold: Optional[int] = None, payload_min_buffer: Optional[int] = None, result_log_memory_size: Optional[int] = None, result_log_disk_size: Optional[int] = None, **kwargs: Any, ) -> None: """Initialize shared settings and delivery limits. ``language`` accepts codes such as ``en``, ``en-CA``, or ``en_CA``. ``None`` uses the detected system language. Zero disables ``service_timeout`` and ``payload_max_size``. ``payload_buffer_threshold`` and ``payload_min_buffer`` control how a capped payload is shared between title and body; see ``enforce_payload_max_size()``. Result logs use disk after their memory allowance; a zero disk size keeps them in memory. ``None`` keeps each class default. """ # Assign default arguments if specified for key, value in kwargs.items(): if not hasattr(AppriseAsset, key): raise AppriseImproperlyConfigured( f"AppriseAsset init(): An invalid key {key} was specified." ) if key.startswith("_") and ( key not in self._KWARGS_INTERNAL_ALLOWLIST ): raise AppriseImproperlyConfigured( f"AppriseAsset init(): {key} can not be set directly; " "use its dedicated keyword argument instead." ) setattr(self, key, value) if plugin_paths: # Load any decorated modules if defined self.__plugin_paths = plugin_paths N_MGR.module_detection(plugin_paths) if storage_path: # Define our persistent storage path self.__storage_path = storage_path if storage_mode: # Define how our persistent storage behaves try: self.__storage_mode = ( storage_mode if isinstance(storage_mode, NotifyFormat) else PersistentStoreMode(storage_mode.lower()) ) except (AttributeError, ValueError, TypeError): err = ( f"An invalid persistent store mode ({storage_mode}) was " "specified." ) raise AppriseImproperlyConfigured(err) from None if isinstance(storage_idlen, int): # Define the number of characters utilized from our namespace lengh if storage_idlen < 0: # Unsupported type raise AppriseImproperlyConfigured( "AppriseAsset storage_idlen(): Value must " "be an integer and > 0" ) # Store value self.__storage_idlen = storage_idlen if isinstance(timezone, tzinfo): self._tzinfo = timezone elif timezone is not None: self._tzinfo = zoneinfo(timezone) if not self._tzinfo: raise AppriseImproperlyConfigured( "AppriseAsset timezone provided is invalid" ) from None else: # Default our timezone to what is detected on the system self._tzinfo = datetime.now().astimezone().tzinfo if language is not None: if not isinstance(language, str): raise AppriseImproperlyConfigured( "AppriseAsset language must be a string." ) from None if language.strip(): # Store the normalized form, such as en_CA self._language = AppriseLocale.normalize_language(language) if not self._language: raise AppriseImproperlyConfigured( "AppriseAsset language provided is invalid" ) from None if allow_templates is not None: if not isinstance(allow_templates, bool): raise AppriseImproperlyConfigured( "AppriseAsset allow_templates must be a bool." ) self._allow_templates = allow_templates if service_timeout is not None: # Store all durations as floats and reject bool values. if not isinstance(service_timeout, (int, float)) or isinstance( service_timeout, bool ): raise AppriseImproperlyConfigured( "AppriseAsset service_timeout must be an int or float." ) if not math.isfinite(service_timeout) or service_timeout < 0: # Use 0 for no timeout; infinity is unsafe on some platforms. raise AppriseImproperlyConfigured( "AppriseAsset service_timeout must be >= 0 and " "finite (0 disables the timeout entirely)." ) self._service_timeout = float(service_timeout) if payload_max_size is not None: # Reject booleans even though ``bool`` subclasses ``int``. if not isinstance(payload_max_size, int) or isinstance( payload_max_size, bool ): raise AppriseImproperlyConfigured( "AppriseAsset payload_max_size must be an int." ) if payload_max_size < 0: raise AppriseImproperlyConfigured( "AppriseAsset payload_max_size must be >= 0 " "(0 disables the cap entirely)." ) self._payload_max_size = payload_max_size if payload_buffer_threshold is not None: # Reject booleans even though ``bool`` subclasses ``int``. if not isinstance(payload_buffer_threshold, int) or isinstance( payload_buffer_threshold, bool ): raise AppriseImproperlyConfigured( "AppriseAsset payload_buffer_threshold must be an int." ) if payload_buffer_threshold < 0: raise AppriseImproperlyConfigured( "AppriseAsset payload_buffer_threshold must be >= 0." ) self._payload_buffer_threshold = payload_buffer_threshold if payload_min_buffer is not None: # Reject booleans even though ``bool`` subclasses ``int``. if not isinstance(payload_min_buffer, int) or isinstance( payload_min_buffer, bool ): raise AppriseImproperlyConfigured( "AppriseAsset payload_min_buffer must be an int." ) if payload_min_buffer < 0: raise AppriseImproperlyConfigured( "AppriseAsset payload_min_buffer must be >= 0." ) self._payload_min_buffer = payload_min_buffer if result_log_memory_size is not None: # Booleans are integers in Python, but are not valid byte limits. if not isinstance(result_log_memory_size, int) or isinstance( result_log_memory_size, bool ): raise AppriseImproperlyConfigured( "AppriseAsset result_log_memory_size must be an int." ) if result_log_memory_size < 0: raise AppriseImproperlyConfigured( "AppriseAsset result_log_memory_size must be >= 0." ) self._result_log_memory_size = result_log_memory_size if result_log_disk_size is not None: # Keep this strict so later storage calculations are predictable. if not isinstance(result_log_disk_size, int) or isinstance( result_log_disk_size, bool ): raise AppriseImproperlyConfigured( "AppriseAsset result_log_disk_size must be an int." ) if result_log_disk_size < 0: raise AppriseImproperlyConfigured( "AppriseAsset result_log_disk_size must be >= 0." ) self._result_log_disk_size = result_log_disk_size if storage_salt is not None: # Define the number of characters utilized from our namespace lengh if isinstance(storage_salt, bytes): self.__storage_salt = storage_salt elif isinstance(storage_salt, str): try: self.__storage_salt = storage_salt.encode(self.encoding) except UnicodeEncodeError: # Bad data; don't pass it along raise AppriseImproperlyConfigured( "AppriseAsset namespace_salt(): " "Value provided could not be encoded" ) from None else: # Unsupported raise AppriseImproperlyConfigured( "AppriseAsset namespace_salt(): Value provided must be " "string or bytes object" ) def enforce_payload_max_size( self, title: str, body: str ) -> tuple[str, str]: """Apply the configured input cap to ``title`` and ``body``. Checked in order, the first match decides the split: 1. An absent side never competes for room: an empty title leaves the whole cap for the body, and vice versa. 2. A short side (length <= ``payload_buffer_threshold``) is shown whole if the other side can still keep at least ``payload_min_buffer`` characters (or its own full length, if shorter) out of what remains. This is checked for the title first, then the body. 3. Otherwise, title and body share the cap in proportion to their original lengths. When the cap allows, each nonempty side keeps at least one character. Very small caps may produce short fragments, but proportional sharing avoids dropping one side when space is available for both. """ if not self._payload_max_size: return title, body cap = self._payload_max_size total = len(title) + len(body) if total <= cap: return title, body if not title: return "", body[:cap] if not body: return title[:cap], "" threshold = self._payload_buffer_threshold min_buffer = self._payload_min_buffer if len(title) <= threshold: # A short title is worth showing whole, but only if the body # can still keep its own minimum out of what's left. body_reserve = min(len(body), min_buffer) if len(title) + body_reserve <= cap: return title, body[: cap - len(title)] if len(body) <= threshold: # Same guarantee, mirrored for a short body. title_reserve = min(len(title), min_buffer) if len(body) + title_reserve <= cap: return title[: cap - len(body)], body # If neither guarantee applies, share the cap proportionally. title_budget = (len(title) * cap) // total body_budget = cap - title_budget # Rounding can zero out a small, nonempty title entirely; borrow # one character from body's share when it can spare it. if not title_budget and body_budget > 0: title_budget = 1 body_budget -= 1 return title[:title_budget], body[:body_budget] def color( self, notify_type: NotifyType, color_type: Optional[type] = None, ) -> Union[str, int, tuple[int, int, int]]: """Returns an HTML mapped color based on passed in notify type. if color_type is: None then a standard hex string is returned as a string format ('#000000'). int then the integer representation is returned tuple then the the red, green, blue is returned in a tuple """ # Attempt to get the type, otherwise return a default grey # if we couldn't look up the entry color = self.html_notify_map.get(notify_type, self.default_html_color) if color_type is None: # This is the default return type return color elif color_type is int: # Convert the color to integer return AppriseAsset.hex_to_int(color) # The only other type is tuple elif color_type is tuple: return AppriseAsset.hex_to_rgb(color) # Unsupported type raise AppriseImproperlyConfigured( "AppriseAsset html_color(): An invalid color_type was specified." ) def ascii(self, notify_type: NotifyType) -> str: """Returns an ascii representation based on passed in notify type.""" # look our response up return self.ascii_notify_map.get(notify_type, self.default_ascii_chars) def image_url( self, notify_type: NotifyType, image_size: Optional[NotifyImageSize] = None, logo: bool = False, extension: Optional[str] = None, ) -> Optional[str]: """Apply our mask to our image URL. if logo is set to True, then the logo_url is used instead """ url_mask = self.image_url_logo if logo else self.image_url_mask if not url_mask: # No image to return return None if extension is None: extension = self.default_extension if image_size is None: image_size = self.default_image_size re_map = { "{THEME}": self.theme if self.theme else "", "{TYPE}": notify_type.value, "{XY}": image_size.value, "{EXTENSION}": extension, } # Iterate over above list and store content accordingly re_table = re.compile( r"(" + "|".join(re_map.keys()) + r")", re.IGNORECASE, ) return re_table.sub(lambda x: re_map[x.group()], url_mask) def image_path( self, notify_type: NotifyType, image_size: NotifyImageSize, must_exist: bool = True, extension: Optional[str] = None, ) -> Optional[str]: """Apply our mask to our image file path.""" if not self.image_path_mask: # No image to return return None if extension is None: extension = self.default_extension re_map = { "{THEME}": self.theme if self.theme else "", "{TYPE}": notify_type.value, "{XY}": image_size.value, "{EXTENSION}": extension, } # Iterate over above list and store content accordingly re_table = re.compile( r"(" + "|".join(re_map.keys()) + r")", re.IGNORECASE, ) # Acquire our path path = re_table.sub(lambda x: re_map[x.group()], self.image_path_mask) if must_exist and not isfile(path): return None # Return what we parsed return path def image_raw( self, notify_type: NotifyType, image_size: NotifyImageSize, extension: Optional[str] = None, ) -> Optional[bytes]: """Returns the raw image if it can (otherwise the function returns None)""" path = self.image_path( notify_type=notify_type, image_size=image_size, extension=extension, ) if path: try: with open(path, "rb") as fd: return fd.read() except OSError: # We can't access the file return None return None def details(self) -> dict[str, str]: """Returns the details associated with the AppriseAsset object.""" return { "app_id": self.app_id, "app_desc": self.app_desc, "default_extension": self.default_extension, "theme": self.theme, "image_path_mask": self.image_path_mask, "image_url_mask": self.image_url_mask, "image_url_logo": self.image_url_logo, } @staticmethod def hex_to_rgb(value: str) -> tuple[int, int, int]: """Takes a hex string (such as #00ff00) and returns a tuple in the form of (red, green, blue) eg: #00ff00 becomes : (0, 65535, 0) """ value = value.lstrip("#") lv = len(value) return tuple( int(value[i : i + lv // 3], 16) for i in range(0, lv, lv // 3) ) @staticmethod def hex_to_int(value: str) -> int: """Takes a hex string (such as #00ff00) and returns its integer equivalent. eg: #00000f becomes : 15 """ return int(value.lstrip("#"), 16) @property def plugin_paths(self) -> Sequence[str]: """Return the plugin paths defined.""" return self.__plugin_paths @property def storage_path(self) -> Optional[str]: """Return the persistent storage path defined.""" return self.__storage_path @property def storage_mode(self) -> PersistentStoreMode: """Return the persistent storage mode defined.""" return self.__storage_mode @property def storage_salt(self) -> bytes: """Return the provided namespace salt; this is always of type bytes.""" return self.__storage_salt @property def storage_idlen(self) -> int: """Return the persistent storage id length.""" return self.__storage_idlen @property def allow_templates(self) -> bool: """Return whether a YAML configuration may use template variables.""" return self._allow_templates @property def result_log_memory_size(self) -> int: """Return the result-log memory allowance in bytes.""" return self._result_log_memory_size @property def result_log_disk_size(self) -> int: """Return the result-log disk allowance in bytes.""" return self._result_log_disk_size @property def tzinfo(self) -> tzinfo: """Return the timezone object""" return self._tzinfo @property def language(self) -> Optional[str]: """Return the language, or None when the system one is used.""" # None tells Apprise to detect the system language. return self._language caronc-apprise-dfec414/apprise/assets/000077500000000000000000000000001526720371000200645ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/assets/NotifyXML-1.0.xsd000066400000000000000000000017321526720371000227340ustar00rootroot00000000000000 caronc-apprise-dfec414/apprise/assets/NotifyXML-1.1.xsd000066400000000000000000000033361526720371000227370ustar00rootroot00000000000000 caronc-apprise-dfec414/apprise/assets/themes/000077500000000000000000000000001526720371000213515ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/assets/themes/default/000077500000000000000000000000001526720371000227755ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/assets/themes/default/apprise-failure-128x128.ico000066400000000000000000002040761526720371000275250ustar00rootroot00000000000000€€ ((€ !8KVdpwwpdVK8! Gƒ³äíðóõ÷øúûûúø÷õóðí䳃G  J™ãÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿã™J =q¸üÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿü¸q=,‰Äîÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿîĉ,DÂõýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ )ÿ 1ÿ8ÿ?ÿCÿEÿCÿ?ÿ9ÿ1ÿ )ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýõÂD BÑÿÿÿÿÿÿÿÿÿÿÿÿÿAÿfÿ&&„ÿ,,™ÿ00§ÿ33³ÿ77¿ÿ::Èÿ;;Îÿ==Õÿ??Øÿ??Øÿ??Øÿ>>Öÿ<<Ðÿ::Èÿ88Àÿ44´ÿ11§ÿ,,™ÿ&&„ÿfÿAÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÑB R¿ÿÿÿÿÿÿÿÿÿÿÿ<ÿ$$„ÿ55ºÿ::Êÿ<<Ïÿ>>Ôÿ@@Øÿ@@Øÿ@@ØÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿ@@Øÿ@@ØÿAAÙÿ>>Ôÿ<<Ïÿ;;Éÿ66¹ÿ''…ÿ<ÿÿÿÿÿÿÿÿÿÿÿÿÿ¿RN¾ÿÿÿÿÿÿÿÿÿ ÿ6ÿ''Žÿ==Óÿ??×ÿ??×ÿ@@Øÿ@@Øÿ@@ØÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿ@@Øÿ??×ÿ??×ÿ>>Óÿ))�ÿ:ÿÿÿÿÿÿÿÿÿÿÿÿ¾N¶ûÿÿÿÿÿÿÿÿ4ÿkÿ11«ÿ99Àÿ==ÎÿAAØÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿBBÙÿBBÙÿBBÙÿBBÙÿBBÙÿCCÙÿBBÙÿCCÙÿCCÙÿCCÙÿCCÙÿBBÙÿBBÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿ@@Øÿ??Øÿ88Áÿ##{ÿ<ÿÿÿÿÿÿÿÿÿÿû¶]ùÿÿÿÿÿÿÿÿ[ÿ*)�ÿ55¶ÿ88¾ÿ::Àÿ;;Ãÿ<<ÆÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿBBÙÿBBÙÿCCÙÿCCÙÿCCÙÿDDÙÿDDÙÿDDÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿDDÙÿDDÙÿCCÙÿBBÙÿBBÙÿBBÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿ@@Ùÿ>>Òÿ11©ÿkÿÿÿÿÿÿÿÿÿÿù]*¥ÿÿÿÿÿÿÿ ÿiÿ22ªÿ65µÿ88ºÿ99½ÿ::¿ÿ::Áÿ;;Ãÿ;;Åÿ??ÓÿAAÙÿBBÙÿDDÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿDDÙÿCCÙÿCCÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿ??Õÿ::Æÿ$$|ÿÿÿÿÿÿÿÿÿÿ¥*eÝÿÿÿÿÿÿ ÿOÿ21«ÿ65³ÿ66µÿ88¹ÿ99»ÿ99½ÿ::¿ÿ;:Áÿ<<Äÿ=<Æÿ??ÌÿDDÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿCCÙÿBBÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿ@@Øÿ<<Ïÿ`ÿ ÿÿÿÿÿÿÿÿÜe•øÿÿÿÿÿ ÿ@ÿ++“ÿ54¯ÿ65±ÿ77µÿ88·ÿ88¹ÿ99¼ÿ:9¾ÿ<<Àÿ=<Âÿ>>Äÿ?>Æÿ@@ÉÿDD×ÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿFFÙÿEEÙÿFFÙÿFFÙÿFFÙÿFFÙÿFFÙÿFFÙÿFFÚÿFFÙÿFFÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿCCÙÿBBÙÿAAÙÿAAÙÿAAÙÿAAÙÿ@@Øÿ66ºÿQÿ ÿÿÿÿÿÿÿ÷•­ÿÿÿÿÿÿ +ÿ%%ÿ32«ÿ54®ÿ66±ÿ76³ÿ77µÿ88·ÿ:9ºÿ;:¼ÿ<<¾ÿ>>Àÿ>>Âÿ??Äÿ@?Æÿ@@ÉÿCCÒÿEEÙÿEEÙÿEEÙÿEEÙÿFFÚÿFFÚÿGGÚÿGGÚÿGGÚÿHHÚÿGGÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿGGÚÿGGÚÿGGÚÿGGÚÿFFÚÿEEÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿCCÙÿBBÙÿAAÙÿAAÙÿAAÙÿAAÙÿ??×ÿ//¢ÿ9ÿÿÿÿÿÿÿÿ­.Íÿÿÿÿÿÿ^ÿ00¤ÿ33ªÿ55­ÿ65¯ÿ66±ÿ77´ÿ77µÿ:9¸ÿ;;ºÿ=<¼ÿ==¾ÿ>>Àÿ?>Ãÿ??Åÿ@@Çÿ@@ÉÿBBÎÿDDÙÿGGÚÿGGÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿFFÚÿFFÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿBBÙÿAAÙÿAAÙÿAAÙÿAAÙÿ==Ñÿ&&�ÿÿÿÿÿÿÿÿÌ.Hãÿÿÿÿÿÿ('Šÿ22¦ÿ43¨ÿ54«ÿ55®ÿ65°ÿ66²ÿ98´ÿ::¶ÿ<;¸ÿ<<ºÿ==¼ÿ==¾ÿ>>Àÿ?>Ãÿ??ÅÿA@ÈÿAAÊÿCCÎÿGG×ÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿFFÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿBBÙÿAAÙÿAAÙÿAAÙÿ@@Øÿ22­ÿ #ÿÿÿÿÿÿÿáHGñÿÿÿÿÿ;ÿ-,–ÿ21£ÿ43§ÿ44©ÿ54«ÿ55®ÿ76°ÿ99²ÿ;:´ÿ;;¶ÿ<;¸ÿ<<ºÿ==¼ÿ>=¿ÿ>>Áÿ@?ÄÿAAÆÿBBÈÿBBÊÿDCÌÿFFÕÿKKÚÿMMÛÿHHÚÿHHÚÿHHÚÿIIÚÿIIÚÿJJÚÿJJÚÿJJÚÿJJÚÿKKÚÿJJÚÿJJÚÿJJÚÿJJÚÿJJÚÿJJÚÿJJÚÿJJÚÿJJÚÿJJÚÿIIÚÿHHÚÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿAAÙÿAAÙÿAAÙÿAAÙÿ::ÇÿNÿÿÿÿÿÿÿîG8ûÿÿÿÿÿ^ÿ..œÿ21£ÿ33¥ÿ43¨ÿ44ªÿ54¬ÿ77®ÿ:9°ÿ::²ÿ;:´ÿ;;¶ÿ<<¹ÿ<<ºÿ>=¾ÿ?>Àÿ@@ÂÿBAÄÿBBÆÿCBÈÿCCËÿccÕÿˆˆâÿ‘‘èÿ““éÿŽŽçÿqqâÿJJÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿJJÛÿKKÛÿJJÚÿIIÚÿHHÚÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿFFÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿAAÙÿAAÙÿAAÙÿ??Öÿ&&ƒÿÿÿÿÿÿÿú8Fûÿÿÿÿ ÿ$#{ÿ00žÿ21¡ÿ32£ÿ33¦ÿ43¨ÿ65ªÿ87¬ÿ99®ÿ:9°ÿ::²ÿ;:µÿ;;·ÿ<<¹ÿ>>¼ÿ??¾ÿ@@ÀÿAAÂÿBAÅÿBBÇÿDCÉÿ��àÿ¦¦äÿ––Îÿzz¨ÿŠŠ¾ÿ¦¦äÿªªíÿŸŸëÿeeàÿKKÛÿLLÛÿKKÛÿKKÛÿKKÛÿLLÛÿLLÛÿKKÛÿLLÛÿLLÛÿLLÛÿLLÛÿLLÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿJJÚÿJJÚÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿAAÙÿAAÙÿ@@Øÿ// ÿÿÿÿÿÿÿúFEñÿÿÿÿÿ%$ÿ0/œÿ10Ÿÿ22¢ÿ32¤ÿ44¦ÿ65¨ÿ87ªÿ98¬ÿ99¯ÿ:9±ÿ::³ÿ;;µÿ<<¸ÿ>=ºÿ??¼ÿ@?¾ÿA@ÁÿAAÃÿBAÅÿCCÇÿ‘�ßÿœœÓÿ@@Vÿÿÿÿ%ÿppšÿ¡¡Þÿ®®îÿŒŒçÿXXÝÿMMÚÿNNÚÿNNÚÿNNÚÿNNÛÿNNÛÿNNÚÿNNÚÿNNÛÿNNÚÿMMÛÿMMÛÿNNÛÿLLÛÿLLÛÿLLÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÚÿIIÚÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿAAÙÿAAÙÿAAÙÿ22«ÿÿÿÿÿÿÿíE,èÿÿÿÿÿ&%~ÿ//šÿ10�ÿ21 ÿ22¢ÿ43¤ÿ65¦ÿ87¨ÿ88ªÿ98­ÿ99¯ÿ:9±ÿ;;´ÿ=<¶ÿ>=¸ÿ?>ºÿ??½ÿ@?¿ÿA@ÁÿBBÃÿCBÅÿkkÓÿ¨§ãÿCCZÿÿÿÿÿÿÿ..?ÿxx£ÿ¯¯îÿ¨¨ìÿzzãÿXXÜÿOOÚÿOOÚÿOOÚÿOOÚÿOOÚÿOOÚÿPPÚÿOOÛÿOOÛÿOOÛÿOOÛÿOOÚÿOOÚÿNNÛÿMMÛÿMMÛÿLLÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿJJÚÿJJÚÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿFFÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿAAÙÿAAÙÿAAÙÿ66¹ÿ +ÿÿÿÿÿÿß,Õÿÿÿÿ #ÿ('†ÿ/.˜ÿ0/›ÿ11žÿ21 ÿ43¢ÿ55¤ÿ76¦ÿ87©ÿ88«ÿ98­ÿ99¯ÿ;:²ÿ=<´ÿ>=¶ÿ>=¸ÿ?>ºÿ??½ÿA@¿ÿBBÁÿDCÄÿNMÈÿ––àÿ„„²ÿÿÿÿÿÿÿÿÿÿ66IÿŸŸ×ÿ°°îÿ››éÿeeßÿQQÛÿQQÛÿQQÛÿQQÛÿQQÛÿQQÛÿQQÛÿPPÚÿPPÚÿPPÚÿPPÚÿPPÚÿOOÚÿOOÚÿOOÚÿNNÚÿMMÛÿMMÛÿMMÛÿLLÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿAAÙÿAAÙÿ88Àÿ7ÿÿÿÿÿÿŵÿÿÿÿ ÿ((‰ÿ/.˜ÿ00šÿ10œÿ11žÿ43 ÿ54£ÿ66¤ÿ76§ÿ87©ÿ88«ÿ98®ÿ;:°ÿ=<²ÿ=<µÿ>=·ÿ>>¹ÿ@?»ÿAA½ÿCBÀÿCCÂÿDDÅÿppÔÿ««æÿ%ÿÿÿÿÿÿÿÿÿÿÿÿ††¶ÿ°°îÿššêÿYYÜÿRRÛÿRRÛÿRRÛÿRRÛÿRRÛÿRRÛÿQQÛÿRRÛÿQQÛÿQQÛÿQQÛÿPPÚÿPPÚÿPPÚÿPPÚÿPPÚÿOOÚÿOOÚÿNNÛÿMMÛÿLLÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿAAÙÿAAÙÿ88Àÿ +ÿÿÿÿÿÿ¦•ÿÿÿÿÿ&%|ÿ.-•ÿ0.˜ÿ00šÿ10œÿ22žÿ55¡ÿ65£ÿ76¥ÿ77§ÿ87©ÿ:9¬ÿ::®ÿ<;±ÿ=<³ÿ=<µÿ?>·ÿ??¹ÿA@»ÿBB¿ÿCBÀÿCCÂÿMMÇÿ£¢ãÿ]]}ÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿŸŸ×ÿ±±îÿssáÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿRRÛÿSSÛÿSSÛÿRRÛÿRRÛÿQQÛÿQQÛÿPPÚÿPPÚÿPPÚÿPPÚÿOOÚÿNNÛÿNNÚÿMMÛÿLLÛÿKKÛÿKKÛÿKKÛÿKKÛÿJJÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿAAÙÿAAÙÿ66·ÿÿÿÿÿÿÿ�[üÿÿÿ ÿ mÿ.-“ÿ/.–ÿ0/˜ÿ00šÿ32œÿ54Ÿÿ65¡ÿ66£ÿ76¥ÿ77§ÿ98©ÿ;:¬ÿ;:¯ÿ<;±ÿ=<³ÿ>=µÿ@?·ÿA@ºÿBA¼ÿBB¿ÿCBÁÿEEÃÿ‚‚×ÿ““Äÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿbb„ÿ±±îÿ��çÿUUÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿRRÛÿRRÛÿRRÛÿQQÛÿQQÛÿPPÚÿPPÚÿPPÚÿOOÚÿOOÚÿNNÚÿMMÛÿLLÛÿKKÛÿKKÛÿKKÛÿKKÚÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿAAÙÿAAÙÿ22ªÿÿÿÿÿÿöZ#íÿÿÿÿeÿ-,‘ÿ/-”ÿ//—ÿ0/™ÿ31›ÿ44�ÿ54Ÿÿ65¡ÿ66£ÿ76¦ÿ87¨ÿ:9«ÿ;:­ÿ<;¯ÿ=<±ÿ>=³ÿ@?·ÿA@¹ÿA@ºÿBA½ÿCC¿ÿEDÁÿ^^Éÿ¬¬äÿBBXÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿSSoÿ²²ïÿššêÿYYÜÿUUÜÿUUÜÿUUÜÿUUÜÿUUÜÿUUÜÿUUÜÿTTÜÿTTÜÿTTÜÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿRRÛÿQQÛÿQQÛÿPPÚÿPPÚÿPPÚÿOOÚÿNNÚÿMMÚÿKKÛÿKKÛÿKKÛÿKKÛÿJJÚÿIIÚÿHHÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿAAÙÿAAÙÿ..œÿÿÿÿÿÿ×#¾ÿÿÿÿYÿ,+�ÿ.-’ÿ/.•ÿ//—ÿ10™ÿ43›ÿ54�ÿ54Ÿÿ65¡ÿ66£ÿ87§ÿ:9©ÿ:9«ÿ;:­ÿ<;¯ÿ=<±ÿ??´ÿ@?·ÿA@¹ÿBB»ÿDC½ÿEE¾ÿHGÀÿ››Ýÿ‚‚­ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿkk�ÿ²²ïÿššêÿYYÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿUUÜÿUUÜÿTTÜÿTTÜÿTTÜÿSSÛÿSSÛÿSSÛÿSSÛÿRRÛÿQQÛÿQQÛÿPPÚÿPPÚÿPPÚÿOOÚÿNNÛÿMMÛÿKKÛÿKKÛÿKKÛÿJJÚÿIIÚÿHHÚÿHHÚÿHHÚÿGGÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿAAÙÿ@@Øÿ%%~ÿÿÿÿÿÿšeÿÿÿÿ6ÿ+*‹ÿ.,�ÿ..“ÿ/.•ÿ10—ÿ33™ÿ43›ÿ54žÿ55 ÿ65¢ÿ87¥ÿ98§ÿ:9©ÿ;:«ÿ;:®ÿ==°ÿ?>³ÿ??µÿ@?·ÿAA¹ÿDCºÿED¾ÿGGÀÿssÏÿ¥¥Ûÿ::Mÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¨¨âÿ²²ïÿ��çÿXXÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿUUÜÿUUÜÿTTÜÿTTÜÿSSÛÿSSÛÿSSÛÿSSÛÿRRÛÿQQÛÿPPÚÿPPÚÿPPÚÿOOÚÿNNÚÿMMÛÿKKÛÿKKÛÿKKÛÿJJÚÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿAAÙÿ>>ÓÿIÿÿÿÿÿÿMúÿÿÿÿ&&|ÿ,+Žÿ.-‘ÿ/.“ÿ0/•ÿ21—ÿ43šÿ43›ÿ54žÿ55 ÿ77£ÿ98¥ÿ98§ÿ:9©ÿ;:¬ÿ=<¯ÿ>=±ÿ?>³ÿ@?µÿA@·ÿCC¸ÿED»ÿFF½ÿRRÃÿ››Ýÿzz¡ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿyy¢ÿ³³ïÿ³³ïÿwwâÿXXÜÿYYÛÿYYÜÿYYÜÿYYÛÿYYÛÿYYÜÿXXÜÿYYÜÿXXÜÿWWÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿTTÜÿTTÜÿTTÜÿSSÛÿSSÛÿRRÛÿQQÛÿQQÛÿPPÚÿPPÚÿOOÛÿNNÚÿNNÛÿLLÛÿKKÛÿKKÛÿJJÛÿIIÚÿHHÚÿHHÚÿHHÚÿGGÚÿFFÙÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿAAÙÿ99Áÿ ÿÿÿÿÿò¾ÿÿÿÿdÿ,+Œÿ.-�ÿ.-‘ÿ/.“ÿ11•ÿ32˜ÿ43šÿ44œÿ54žÿ65¡ÿ87£ÿ98¥ÿ98¨ÿ:9ªÿ<<­ÿ>=¯ÿ>=±ÿ?>³ÿAA¶ÿCB¸ÿDD¹ÿFE»ÿFF½ÿxxÐÿ­¬ãÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ66Hÿ´´ïÿ´´ïÿ��êÿ]]ÜÿYYÜÿZZÜÿYYÜÿYYÜÿYYÜÿYYÜÿYYÜÿYYÜÿXXÜÿXXÜÿXXÜÿYYÜÿWWÜÿWWÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿUUÜÿTTÜÿTTÜÿSSÛÿSSÛÿRRÛÿQQÛÿPPÚÿPPÚÿPPÚÿOOÚÿMMÛÿLLÛÿKKÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿFFÙÿEEÙÿEEÙÿEEÙÿEEÙÿBBÙÿAAÙÿAAÙÿ11¦ÿÿÿÿÿÿ¢Aÿÿÿÿ<ÿ*)‹ÿ-+�ÿ.-�ÿ.-’ÿ1/”ÿ32–ÿ32˜ÿ43šÿ44œÿ65Ÿÿ86¡ÿ87£ÿ98¦ÿ:8¨ÿ=<«ÿ=<­ÿ>=¯ÿ>>±ÿA@²ÿBAµÿED·ÿEE¹ÿFE»ÿRQÁÿªªáÿPPiÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿžžÑÿ´´ïÿ²²îÿuuáÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿYYÜÿYYÜÿYYÜÿXXÜÿXXÜÿYYÛÿXXÜÿWWÜÿVVÜÿVVÜÿVVÜÿVVÜÿUUÜÿTTÜÿSSÛÿSSÛÿSSÛÿRRÛÿQQÚÿPPÚÿPPÚÿOOÚÿNNÚÿLLÛÿKKÛÿKKÛÿJJÛÿIIÚÿHHÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿAAÙÿAAÙÿ""uÿÿÿÿÿú=ãÿÿÿÿ*)Šÿ,+‹ÿ-,�ÿ.-�ÿ/.’ÿ21”ÿ32–ÿ32˜ÿ43šÿ54œÿ76 ÿ87¢ÿ97¤ÿ98¦ÿ;:¨ÿ=<«ÿ=<­ÿ>=°ÿ@@²ÿBA³ÿDDµÿED·ÿEE¹ÿHG¼ÿ‹‹Öÿ��¼ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿWWtÿ±±ìÿ´´ïÿ››éÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿYYÜÿYYÜÿYYÜÿYYÜÿYYÛÿXXÜÿWWÜÿVVÜÿVVÜÿVVÜÿUUÜÿSSÛÿSSÛÿSSÛÿSSÛÿQQÛÿQQÛÿPPÚÿPPÚÿOOÛÿNNÚÿLLÛÿKKÛÿKKÛÿJJÚÿIIÚÿHHÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿAAÙÿ>>Òÿ -ÿÿÿÿÿ²qÿÿÿÿhÿ,*‰ÿ-,Œÿ-,Žÿ/.�ÿ00’ÿ21”ÿ32—ÿ32˜ÿ43›ÿ65žÿ76 ÿ87¢ÿ97¤ÿ;:¦ÿ<;ªÿ=<«ÿ=<®ÿ@?¯ÿBA±ÿCC³ÿDDµÿFE¸ÿGGºÿ^]Ãÿ©¨Ýÿ66Hÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ"ÿœœÏÿµµïÿ²²ïÿddßÿ[[Ýÿ[[Ýÿ[[Ýÿ[[Ýÿ[[Ýÿ[[Ýÿ[[Ýÿ[[Ýÿ[[Ýÿ[[ÝÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿYYÜÿYYÜÿYYÜÿYYÛÿXXÜÿWWÜÿVVÜÿVVÜÿVVÜÿTTÜÿTTÜÿSSÛÿSSÛÿRRÛÿQQÛÿPPÚÿPPÚÿOOÛÿNNÚÿLLÛÿKKÛÿKKÛÿJJÚÿHHÚÿHHÚÿHHÚÿGGÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿBBÙÿAAÙÿAAÙÿ--šÿÿÿÿÿúEçÿÿÿ5ÿ)(…ÿ,+Šÿ-,Œÿ-,Žÿ0.�ÿ21’ÿ21”ÿ32—ÿ43šÿ55œÿ75žÿAA¥ÿVU°ÿa_¶ÿecºÿdc»ÿ`_»ÿRQµÿAA¯ÿCB±ÿDC³ÿED·ÿFF¹ÿHG»ÿ|{Ïÿ­­âÿ~~¦ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ``€ÿµµðÿµµðÿ‚‚åÿ\\Þÿ]]Þÿ]]Þÿ]]Þÿ]]Þÿ]]Þÿ]]Þÿ]]Þÿ]]Þÿ]]Þÿ\\Þÿ\\Þÿ\\Þÿ\\Þÿ[[Ýÿ[[Ýÿ[[ÝÿZZÜÿZZÜÿZZÜÿZZÜÿYYÜÿYYÜÿYYÜÿWWÜÿVVÜÿVVÜÿVVÜÿTTÜÿTTÜÿSSÛÿSSÛÿSSÛÿQQÛÿPPÚÿPPÚÿOOÚÿNNÛÿLLÛÿKKÛÿKKÛÿJJÚÿHHÚÿHHÚÿHHÚÿGGÚÿFFÚÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿAAÙÿ??ÕÿHÿÿÿÿÿ°hÿÿÿ ÿ! iÿ,*ˆÿ,+Šÿ-,Œÿ..�ÿ10�ÿ21’ÿ21•ÿ32—ÿ43šÿcb²ÿ��Éÿ�œÏÿžžÏÿŸŸÐÿ ŸÑÿ¡ Óÿ£¢Ôÿ¢¢×ÿ——ÓÿVU¸ÿED´ÿFF·ÿHG¹ÿHH»ÿ]\Äÿ­­âÿ®®ãÿ¯®äÿWWrÿÿÿÿÿÿÿÿÿÿÿÿ ÿ´´ïÿµµðÿ¤¤ìÿccßÿ^^Þÿ^^Þÿ^^Þÿ^^Þÿ^^Þÿ^^Þÿ^^Þÿ^^Þÿ^^Þÿ^^Þÿ^^Þÿ^^Þÿ]]Þÿ]]Þÿ\\Þÿ\\Þÿ[[Ýÿ[[ÝÿZZÜÿZZÜÿZZÜÿZZÜÿYYÜÿXXÜÿYYÛÿXXÜÿWWÜÿVVÜÿVVÜÿVVÜÿTTÜÿSSÛÿSSÛÿSSÛÿQQÛÿPPÚÿPPÚÿOOÚÿNNÚÿLLÛÿKKÛÿKKÛÿJJÚÿHHÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿAAÙÿ33¬ÿ ÿÿÿÿÿ0éÿÿÿ3ÿ)(…ÿ,+ˆÿ,,‹ÿ-,�ÿ/.�ÿ10‘ÿ21“ÿ55–ÿ[Y«ÿœœÍÿ•”¿ÿ|| ÿXXqÿ88Hÿ&&1ÿ"!+ÿ))6ÿCCWÿ‡‡¯ÿ©©Ûÿ�€ÌÿGGµÿFF·ÿHG¹ÿII¼ÿKJ¾ÿttÎÿ¨¨áÿ®®äÿ°°åÿ••Âÿ88Jÿ ÿÿÿÿÿÿÿÿÿƒƒ­ÿ¶¶ðÿµµïÿzzãÿ__Þÿ__Þÿ__Þÿ``Þÿ``Þÿ``Þÿ``Þÿ``Þÿ``Þÿ__Þÿ__Þÿ__Þÿ^^Þÿ^^Þÿ^^Þÿ]]Þÿ]]Þÿ\\Þÿ\\Þÿ[[Ýÿ[[ÝÿZZÜÿZZÜÿZZÜÿYYÜÿYYÜÿXXÜÿXXÜÿWWÜÿVVÜÿVVÜÿUUÜÿTTÜÿSSÛÿSSÛÿSSÛÿQQÛÿPPÚÿPPÚÿNNÛÿMMÚÿLLÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿAAÙÿAAÙÿMÿÿÿÿÿºPÿÿÿÿ!!nÿ+)†ÿ,+‰ÿ,,‹ÿ/-�ÿ10�ÿ10‘ÿED�ÿ��¾ÿŸŸËÿZYrÿ%%/ÿÿÿÿÿÿÿ ÿ““¾ÿªªÛÿ‹‹ÏÿIH¶ÿGF¸ÿWW¿ÿddÅÿZYÄÿMLÁÿWWÆÿ‰ˆ×ÿ©©ãÿ°°çÿ±±çÿ||¢ÿ22Bÿÿÿÿÿÿÿ@@Uÿ¶¶ðÿ¶¶ðÿ��êÿccÞÿ``Þÿ``ÞÿaaÞÿaaÞÿaaÞÿaaÞÿaaÞÿaaÞÿaaÞÿaaÞÿ``ÞÿaaÞÿ``Þÿ``Þÿ__Þÿ__Þÿ^^Þÿ^^Þÿ]]Þÿ]]Þÿ\\Þÿ[[Ýÿ[[ÝÿZZÜÿZZÜÿZZÜÿYYÜÿXXÜÿYYÛÿXXÜÿVVÜÿVVÜÿUUÜÿTTÜÿSSÛÿSSÛÿSSÛÿQQÛÿPPÚÿPPÚÿOOÚÿMMÛÿKKÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿAAÙÿ::Æÿÿÿÿÿÿ'æÿÿÿ0ÿ*)„ÿ+*‡ÿ,+‰ÿ--‹ÿ/.�ÿ10�ÿQQ¢ÿ”“Æÿ‡†¬ÿ(ÿÿÿÿÿÿÿÿÿee‚ÿª©ÚÿªªÛÿpoÄÿFF¶ÿcbÂÿ¡¡Ûÿ«ªßÿŸŸÜÿÒÿQQÂÿQPÅÿggÍÿ��Üÿ±±èÿ²±èÿ§§Üÿvv›ÿÿÿÿÿ ÿ¡¡Ôÿ··ðÿµµïÿvvâÿaaÞÿbbÞÿbbÞÿbbÞÿbbÞÿbbÞÿbbÞÿbbÞÿbbÞÿbbÞÿbbÞÿbbÞÿbbÞÿbbÞÿaaÞÿaaÞÿ``Þÿ``Þÿ``Þÿ^^Þÿ^^Þÿ]]Þÿ]]Þÿ\\Þÿ[[ÝÿZZÜÿZZÜÿZZÜÿZZÜÿYYÜÿXXÜÿXXÜÿVVÜÿVVÜÿUUÜÿTTÜÿSSÛÿSSÛÿQQÛÿQQÛÿPPÚÿPPÚÿNNÛÿMMÚÿKKÛÿKKÛÿKKÛÿHHÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿAAÙÿgÿÿÿÿÿ¥9ÿÿÿÿ$#uÿ+)…ÿ+*‡ÿ,+‰ÿ/-‹ÿ/.�ÿJI�ÿ™™Éÿ¡ÿÿÿÿÿÿÿÿÿÿ'ÿ©©Ùÿª©Ûÿ––ÓÿNM·ÿGG¶ÿ££Üÿtt–ÿWWqÿªªÝÿ®®âÿ££ßÿnmÏÿSRÇÿUUÊÿllÓÿ§¦æÿ³³êÿ³³ìÿ­­âÿWWrÿ ÿÿcc‚ÿµµîÿ¸¸ðÿ˜˜éÿbbÞÿbbÞÿbbÞÿbbÞÿbbÞÿbbÞÿbbßÿbbßÿbbßÿaaßÿaaßÿbbßÿbbÞÿbbÞÿbbÞÿbbÞÿbbÞÿbbÞÿaaÞÿaaÞÿ``Þÿ__Þÿ^^Þÿ^^Þÿ]]Þÿ\\Þÿ\\Þÿ[[ÝÿZZÜÿZZÜÿZZÜÿYYÜÿYYÜÿXXÜÿVVÜÿVVÜÿUUÜÿSSÛÿSSÛÿSSÛÿRRÛÿPPÚÿPPÚÿOOÚÿNNÛÿLLÛÿKKÛÿKKÛÿJJÚÿHHÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿAAÙÿ::Âÿ ÿÿÿÿôÍÿÿÿ-ÿ)(‚ÿ+*…ÿ++‡ÿ-,Šÿ/.Œÿ88’ÿŠŠÀÿ‹‹°ÿÿÿÿÿÿÿÿÿÿÿ‰‰°ÿ©©Úÿ¨¨ÚÿmlÂÿFE´ÿ}|Ëÿ¢¢Òÿ((3ÿÿÿrq“ÿ¦¦×ÿ°°åÿ““Üÿ^]ÍÿUUÍÿWWÐÿ‰ˆßÿ±±ëÿ´´ìÿµµíÿ°°æÿuu™ÿ¤¤Öÿ¸¸ðÿµµïÿddÞÿbbÞÿbbßÿbbßÿbbßÿbbßÿbbßÿccßÿccßÿccßÿccßÿccßÿbbßÿbbßÿbbßÿbbßÿbbÞÿbbÞÿbbÞÿbbÞÿbbÞÿaaÞÿ``Þÿ``Þÿ__Þÿ^^Þÿ^^Þÿ\\Þÿ\\Þÿ[[ÝÿZZÜÿZZÜÿZZÜÿXXÜÿYYÜÿWWÜÿVVÜÿVVÜÿUUÜÿTTÜÿSSÛÿSSÛÿRRÛÿPPÚÿPPÚÿOOÛÿMMÛÿKKÛÿKKÛÿJJÛÿHHÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ??ÓÿYÿÿÿÿýqüÿÿÿ! iÿ*)‚ÿ+*…ÿ,+ˆÿ.,Šÿ0/Œÿcc©ÿ¡ Ëÿ$$.ÿÿÿÿÿÿÿÿÿÿGG[ÿ§§Öÿª©Úÿ”“ÑÿED²ÿWV»ÿ§§Üÿji‰ÿÿÿÿÿ..<ÿyyžÿ±°æÿ°°çÿ……ÜÿbbÒÿZYÓÿggØÿ””äÿ¬¬ëÿ··îÿ··ïÿ¸¸ðÿ¸¸ðÿ‚‚äÿbbßÿbbßÿccßÿccßÿccßÿccßÿccßÿddàÿccßÿddàÿddàÿccßÿccßÿccßÿddßÿbbßÿccßÿbbßÿbbßÿbbÞÿbbÞÿbbÞÿbbÞÿaaÞÿ``Þÿ__Þÿ__Þÿ^^Þÿ\\Þÿ\\Þÿ[[ÝÿZZÜÿZZÜÿZZÜÿYYÜÿXXÜÿXXÜÿVVÜÿVVÜÿTTÜÿTTÜÿSSÛÿSSÛÿQQÛÿPPÚÿPPÚÿNNÛÿKKÛÿKKÛÿKKÛÿJJÚÿHHÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿAAÙÿ..žÿÿÿÿÿ»‹ÿÿÿ ÿ&%{ÿ+*„ÿ+*…ÿ,+ˆÿ.-Šÿ87�ÿ˜—ÇÿSRhÿÿÿÿÿÿÿÿÿÿÿ‰‰±ÿ©©ÙÿªªÚÿTS¶ÿFF³ÿ‰ˆÏÿ££Òÿÿÿÿÿÿÿ ÿ21AÿššÉÿ³³éÿ¦¦æÿ„„Þÿ\\Õÿ^^×ÿooÝÿˆˆäÿ¥¥ëÿ¨¨íÿŽŽèÿddßÿccßÿddàÿddàÿddàÿddàÿddàÿddàÿddàÿddàÿddàÿddàÿddàÿddàÿddàÿddàÿddàÿddàÿccßÿccßÿccßÿbbßÿaaÞÿbbÞÿbbÞÿbbÞÿ``Þÿ``Þÿ^^Þÿ^^Þÿ]]Þÿ\\Þÿ[[ÝÿZZÜÿZZÜÿYYÜÿYYÜÿXXÜÿWWÜÿVVÜÿVVÜÿUUÜÿSSÛÿSSÛÿRRÛÿPPÛÿPPÚÿOOÛÿMMÛÿKKÛÿKKÛÿJJÚÿIIÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿEEÙÿBBÙÿAAÙÿ<<Éÿ0ÿÿÿÿî.ÐÿÿÿKÿ)(�ÿ+*„ÿ+*†ÿ-,ˆÿ/.Šÿhg«ÿ—–¿ÿ ÿÿÿÿÿÿÿÿÿÿLLaÿ©¨Øÿ©©ÙÿxxÄÿFE±ÿ``½ÿ©¨ÜÿSRkÿÿÿÿÿÿÿÿÿÿbbÿ¯¯âÿ´´êÿ©©èÿttÝÿ``ÚÿaaÝÿffßÿggßÿddßÿddàÿddàÿddàÿddàÿddàÿeeàÿeeàÿffàÿffßÿffßÿffàÿffàÿeeàÿffàÿeeàÿddàÿddàÿddàÿddàÿddàÿccßÿccßÿbbßÿbbßÿbbÞÿbbÞÿbbÞÿ``Þÿ``Þÿ__Þÿ^^Þÿ]]Þÿ\\Þÿ[[ÝÿZZÜÿZZÜÿYYÜÿXXÜÿYYÛÿWWÜÿVVÜÿUUÜÿTTÜÿSSÛÿSSÛÿRRÛÿQQÛÿPPÚÿNNÛÿMMÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿAAÙÿgÿÿÿÿÿ^3õÿÿ ÿ fÿ*)‚ÿ+*„ÿ+*†ÿ.,ˆÿ0/‹ÿ›šÈÿ\[tÿÿÿÿÿÿÿÿÿÿÿ��Éÿ©¨Øÿž�ÔÿIH±ÿJI³ÿ��Ðÿ““¾ÿÿÿÿÿÿÿÿÿÿÿÿ *ÿƒƒ©ÿ®®áÿ·¶íÿ••çÿiiÞÿddßÿccßÿddàÿddàÿddàÿddàÿffàÿffàÿggßÿggßÿggßÿggßÿggßÿhhàÿhhàÿggßÿggßÿggßÿggàÿffàÿeeàÿeeàÿddàÿddàÿddàÿccßÿbbßÿaaßÿbbßÿbbÞÿbbÞÿaaÞÿ``Þÿ__Þÿ^^Þÿ\\Þÿ\\Þÿ[[ÝÿZZÜÿZZÜÿYYÜÿXXÜÿXXÜÿVVÜÿVVÜÿUUÜÿSSÛÿSSÛÿRRÛÿQQÛÿPPÚÿOOÚÿNNÚÿLLÛÿKKÛÿJJÚÿHHÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿEEÙÿBBÙÿAAÙÿ44­ÿ ÿÿÿÿ¬eÿÿÿ "ÿ'&zÿ*(‚ÿ+*„ÿ,+†ÿ/.‰ÿIH™ÿ¡¡Ëÿ44BÿÿÿÿÿÿÿÿÿÿnnŒÿ©¨Øÿª©Ùÿkj¾ÿDC¯ÿih¿ÿ¬¬Ýÿ((4ÿÿÿÿÿÿÿÿÿÿÿÿÿÿ::Kÿ‰ˆ±ÿ··îÿ³³ïÿ””éÿyyäÿjjáÿeeàÿffàÿggàÿggßÿhhàÿhhàÿhhàÿhhàÿhhàÿhhàÿhhàÿhhàÿhhàÿhhàÿhhàÿhhßÿhhàÿggßÿggàÿeeàÿddàÿddàÿddàÿccßÿccßÿbbßÿbbÞÿbbÞÿaaÞÿaaÞÿ``Þÿ^^Þÿ^^Þÿ\\Þÿ\\Þÿ[[ÝÿZZÜÿZZÜÿXXÜÿXXÜÿVVÜÿVVÜÿUUÜÿTTÜÿSSÛÿSSÛÿRRÛÿPPÚÿPPÚÿOOÛÿMMÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿAAÙÿ %ÿÿÿÿêªÿÿÿ?ÿ)(�ÿ*)ƒÿ+*…ÿ-+†ÿ/.‰ÿgf©ÿ’’¸ÿ""+ÿÿÿÿÿÿÿÿÿ//<ÿ¨¨Öÿ©©Øÿ’’ÎÿIH¯ÿHG±ÿ¥¤Ùÿiiˆÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ22Aÿˆˆ±ÿ¹¹ñÿ¶¶ðÿ¨¨íÿ””èÿzzãÿhhàÿhhàÿhhàÿiiàÿiiàÿiiàÿiiàÿjjàÿiiàÿiiàÿjjàÿiiàÿiiàÿhhàÿhhàÿhhàÿhhàÿggßÿffßÿeeàÿddàÿddàÿddàÿccßÿbbßÿaaßÿbbÞÿbbÞÿaaÞÿ``Þÿ^^Þÿ^^Þÿ\\Þÿ[[ÝÿZZÜÿZZÜÿZZÜÿYYÜÿYYÛÿVVÜÿVVÜÿUUÜÿTTÜÿSSÛÿSSÛÿQQÛÿPPÚÿPPÚÿMMÚÿLLÛÿKKÛÿKKÛÿHHÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿAAÙÿ!!oÿÿÿÿÿ(íÿÿÿbÿ*)�ÿ*)ƒÿ+*…ÿ.-‡ÿ/.‰ÿzz´ÿ‡‡«ÿÿÿÿÿÿÿÿÿÿ’‘¹ÿ¨¨×ÿ¦¦ÖÿddºÿED®ÿ……ËÿœœÈÿ##-ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ$ÿss•ÿ±±åÿ¸¸îÿ¶¶ðÿ°°îÿœœêÿ��äÿjjàÿjjàÿjjàÿkkáÿjjáÿkkáÿkkáÿjjàÿjjàÿjjàÿjjàÿiiàÿiiàÿhhàÿhhàÿhhàÿggßÿffßÿeeàÿddàÿddàÿccßÿbbßÿccßÿ{{ãÿŠŠæÿ‹‹æÿ~~äÿ^^Þÿ]]Þÿ\\Þÿ[[ÝÿZZÜÿZZÜÿYYÜÿYYÜÿXXÜÿVVÜÿVVÜÿTTÜÿSSÛÿSSÛÿRRÛÿPPÚÿPPÚÿOOÚÿLLÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿHHÚÿEEÙÿEEÙÿEEÙÿEEÙÿBBÙÿAAÙÿ99Àÿÿÿÿÿw#ÿÿÿ ÿ)(ÿ*)�ÿ*)ƒÿ+*…ÿ/.‡ÿ/.Šÿ�€¸ÿƒƒ¦ÿÿÿÿÿÿÿÿÿNNdÿ¦¥Òÿ©¨×ÿ�ŽÌÿCB«ÿ[Z·ÿ§¦ØÿccÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿCCVÿ~~£ÿœœÊÿ­­ßÿ»»ñÿ¼¼ñÿ­­îÿ››êÿŠŠçÿ{{äÿssâÿqqâÿppáÿppáÿmmáÿmmáÿllàÿllàÿllàÿjjàÿllàÿllàÿllàÿmmáÿssãÿ„„æÿ––êÿ¨¨íÿ··ðÿ¬¬àÿ¡¡Òÿ¥¥Øÿ··ðÿ±±îÿ}}äÿ\\Þÿ\\ÞÿZZÜÿZZÜÿYYÜÿXXÜÿXXÛÿVVÜÿVVÜÿTTÜÿTTÜÿSSÛÿRRÛÿQQÛÿPPÚÿOOÚÿMMÛÿKKÛÿKKÛÿJJÛÿHHÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿAAÙÿÿÿÿÿÀnÿÿÿ +ÿ)(ÿ*)�ÿ**ƒÿ-+…ÿ/.ˆÿ/.Šÿ�€¹ÿ‡†ªÿÿÿÿÿÿÿÿÿ��¶ÿ¨¨Öÿ§¦ÖÿNL¯ÿED¬ÿŒŒÍÿ�œÈÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ11?ÿOOfÿllŠÿˆˆ­ÿ¡¡Îÿ¶¶èÿ½½ñÿ½½ñÿ¸¸ðÿ¯¯îÿªªíÿ§§íÿ¥¥ìÿ¢¢ìÿ¢¢ëÿ££ëÿ¥¥ìÿ§§íÿ®®îÿ´´ïÿººñÿ¸¸ïÿ¡¡Òÿ‰‰³ÿqq“ÿRRlÿ33Cÿ'ÿ''3ÿXXtÿ­­ãÿ¶¶ðÿ��åÿ\\Þÿ[[ÝÿZZÜÿZZÜÿYYÜÿYYÛÿWWÜÿVVÜÿVVÜÿTTÜÿSSÛÿSSÛÿQQÛÿPPÚÿOOÚÿNNÚÿLLÛÿKKÛÿJJÛÿHHÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿAAÙÿ`ÿÿÿÿû½ÿÿÿPÿ*(€ÿ*)‚ÿ+*„ÿ-+†ÿ/.ˆÿ/.Šÿ|{¶ÿ��¶ÿ(ÿÿÿÿÿÿÿVVmÿ§§Õÿ¨¨Öÿoo½ÿCBªÿcbºÿ©©ÙÿDCVÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿ"ÿ''2ÿ::JÿOOeÿ^^xÿkk‰ÿtt”ÿzz›ÿzz›ÿyyšÿtt•ÿmmŒÿ``|ÿPPgÿ@@Sÿ((4ÿ%ÿÿ ÿÿÿÿÿÿÿJJaÿ¶¶ïÿ®®îÿbbßÿ\\ÞÿZZÜÿZZÜÿZZÜÿYYÜÿXXÜÿVVÜÿVVÜÿUUÜÿTTÜÿSSÛÿRRÛÿQQÛÿPPÚÿNNÚÿLLÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿEEÙÿBBÙÿAAÙÿ//Ÿÿÿÿÿÿúÿÿÿ#"rÿ*(€ÿ*)‚ÿ+*„ÿ-,†ÿ/.ˆÿ//Šÿll­ÿ  Éÿ00=ÿÿÿÿÿÿÿ¥¥Òÿ§§Õÿ””ÍÿHG«ÿFE«ÿ•”ÐÿŠŠ°ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ***ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ••Äÿ¶¶ðÿ~~äÿ\\Þÿ\\ÞÿZZÜÿZZÜÿYYÜÿXXÜÿWWÜÿVVÜÿVVÜÿSSÜÿSSÛÿSSÛÿQQÛÿPPÚÿPPÚÿLLÛÿKKÛÿKKÛÿJJÚÿHHÚÿHHÚÿHHÚÿFFÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ::ÃÿÿÿÿÿQÿÿÿÿ)(ÿ*(€ÿ*)‚ÿ,+„ÿ-,†ÿ/.‰ÿ0/‹ÿRQŸÿ¢¢ÌÿSRhÿÿÿÿÿÿuu”ÿ§§Ôÿ§§Õÿbb¶ÿBA¨ÿji¼ÿ««Úÿ'ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ333ÿçççÿÂÂÂÿªªªÿ���ÿlllÿJJJÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ‰‰´ÿ¶¶ðÿ��çÿ]]Þÿ\\ÞÿZZÜÿZZÜÿZZÜÿXXÜÿXXÜÿVVÜÿVVÜÿUUÜÿSSÛÿSSÛÿQQÛÿPPÚÿOOÛÿNNÚÿKKÛÿKKÛÿKKÚÿHHÚÿHHÚÿHHÚÿFFÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ==Íÿ #ÿÿÿÿ‘Qÿÿÿ ÿ)(ÿ*)€ÿ*)‚ÿ-+„ÿ..‡ÿ/.‰ÿ0/‹ÿ31�ÿ¡ Ìÿ�Œ±ÿ ÿÿÿÿ66Eÿ§§Óÿ§§Ôÿ‹ŠÈÿEC§ÿIH¬ÿ§¦×ÿaa|ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿùùùÿóóóÿÚÚÚÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ››Êÿ··ðÿŒŒçÿ^^Þÿ\\Þÿ[[ÝÿZZÜÿZZÜÿYYÜÿXXÜÿVVÜÿVVÜÿUUÜÿSSÛÿSSÛÿRRÛÿPPÚÿPPÚÿNNÛÿLLÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿ>>ÒÿHÿÿÿÿÈ„ÿÿÿ9ÿ*(€ÿ*)�ÿ*)ƒÿ-+…ÿ..‡ÿ/.‰ÿ0/‹ÿ10Žÿvtµÿ£¢ÎÿEEWÿÿÿ ÿ’’ºÿ§§Óÿ¦¥ÔÿZY°ÿBA§ÿ‰‰Éÿ––¿ÿ&ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÚÚÚÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿLLcÿ¸¸ðÿ··ðÿ||äÿ^^Þÿ]]Þÿ[[ÝÿZZÜÿZZÜÿZZÜÿYYÜÿWWÜÿVVÜÿUUÜÿTTÜÿSSÛÿSSÛÿQQÛÿPPÚÿNNÛÿLLÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿ??Õÿiÿÿÿÿí¯ÿÿÿOÿ*(€ÿ*)�ÿ*)ƒÿ-,…ÿ/.‡ÿ/.‰ÿ0/‹ÿ11Žÿ@>˜ÿ£¢Îÿ££ÏÿÿÿWWoÿ¥¤Ñÿ§§Ôÿƒ‚Ãÿ@?¥ÿ`_µÿ££Ôÿ\\uÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¢¢¢ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ;;;ÿÿÿÿÿÿÿÿÿÿÿÿÿÿ***ÿGGGÿ444ÿ&&&ÿÿÿÿÿÿÿÿÿÿÿÿÿ¦¦Ùÿ¸¸ðÿ««íÿffßÿ^^Þÿ]]Þÿ[[ÝÿZZÜÿZZÜÿYYÜÿYYÜÿXXÜÿVVÜÿVVÜÿTTÜÿSSÛÿSSÛÿQQÛÿPPÚÿOOÚÿNNÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿHHÚÿEEÙÿEEÙÿEEÙÿEEÙÿAAÙÿAAÙÿ''„ÿÿÿÿùÜÿÿÿ dÿ*(€ÿ*)�ÿ**ƒÿ.-…ÿ/.ˆÿ/.Šÿ0/Œÿ21�ÿ54’ÿrp´ÿ££Ïÿˆˆ¬ÿ&&1ÿ��¶ÿ§¦Óÿ¥¥ÓÿBA¤ÿBB¦ÿ�ŽËÿ–•¾ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿjjjÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿqqqÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ«««ÿÿÿÿÿ÷÷÷ÿÝÝÝÿÁÁÁÿ§§§ÿ‹‹‹ÿ```ÿ ÿÿÿÿÿÿÿgg‡ÿ¸¸ðÿ¸¸ðÿ„„åÿ``Þÿ__Þÿ]]Þÿ\\Þÿ[[ÝÿZZÜÿZZÜÿYYÜÿXXÜÿVVÜÿVVÜÿTTÜÿSSÛÿSSÛÿQQÛÿPPÚÿOOÚÿNNÚÿLLÛÿKKÛÿJJÚÿHHÚÿHHÚÿGGÚÿFFÚÿEEÙÿEEÙÿDDÙÿBBÙÿAAÙÿ--–ÿÿÿÿüîÿÿÿ$#qÿ*(€ÿ*)�ÿ,+„ÿ.-…ÿ/.ˆÿ/.Šÿ1/�ÿ31�ÿ32‘ÿCB›ÿ””Çÿ¤¤Ñÿ˜—Áÿ¦¥Òÿ§¦Óÿff´ÿ?>£ÿdd¶ÿ¨§×ÿ:9Iÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ777ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ£££ÿÿÿÿÿÿÿÿÿÿÿÿÿ888ÿûûûÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ«««ÿÿÿÿÿÿÿ''3ÿ¸¸ðÿ¸¸ðÿ¥¥ìÿiißÿaaÞÿ__Þÿ^^Þÿ]]Þÿ[[ÝÿZZÜÿZZÜÿYYÜÿXXÛÿVVÜÿVVÜÿTTÜÿSSÛÿSSÛÿRRÛÿPPÚÿOOÚÿNNÚÿKKÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿDDÙÿBBÙÿAAÙÿ00£ÿ ÿÿÿþòÿÿÿ&%uÿ*)€ÿ*)‚ÿ,+„ÿ.-†ÿ/.ˆÿ//Šÿ1/�ÿ31�ÿ32’ÿ42”ÿTS¦ÿ£¢Ðÿ¥¤Ñÿ¦¥Òÿ�ŒÇÿA@¢ÿBA¥ÿ˜˜Ïÿyyšÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ!!!ÿòòòÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÞÞÞÿ ÿÿÿÿÿÿÿÿÿÿÿÿ¯¯¯ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿøøøÿ,,,ÿÿÿÿÿÿÿ••Âÿ¹¹ñÿ¶¶ðÿåÿbbÞÿaaÞÿ``Þÿ^^Þÿ]]Þÿ[[ÝÿZZÜÿZZÜÿYYÜÿYYÛÿVVÜÿVVÜÿUUÜÿSSÛÿSSÛÿRRÛÿPPÚÿPPÚÿOOÚÿLLÛÿKKÛÿKKÛÿHHÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ44®ÿÿÿÿþ9õÿÿ ÿ&%xÿ*)€ÿ*)‚ÿ,+„ÿ.-†ÿ/.‰ÿ//Šÿ1/�ÿ31�ÿ32’ÿ42”ÿ65—ÿFE¡ÿut¹ÿnm¶ÿFE£ÿ>=¡ÿoo»ÿ¨¨ÖÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÌÌÌÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿöööÿÇÇÇÿ›››ÿlllÿAAAÿ+++ÿ ÿÿÿÿ555ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ’’’ÿÿÿÿÿÿÿNNeÿ´´ëÿ¹¹ñÿ££ìÿbbßÿbbÞÿaaÞÿ``Þÿ__Þÿ]]Þÿ[[ÝÿZZÜÿZZÜÿYYÜÿYYÜÿXXÜÿVVÜÿUUÜÿSSÛÿSSÛÿRRÛÿPPÚÿPPÚÿNNÛÿLLÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ66¶ÿ ÿÿÿþQ+öÿÿÿ&%xÿ*)€ÿ*)‚ÿ,+„ÿ.-†ÿ/.‰ÿ0/‹ÿ1/Žÿ31�ÿ42’ÿ43”ÿ65—ÿ76™ÿ87œÿ:9�ÿ<;Ÿÿ>=¡ÿ¥¥Ôÿqq�ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ¦¦¦ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿøøøÿÚÚÚÿ¼¼¼ÿžžžÿyyyÿÐÐÐÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿåååÿ ÿÿÿÿÿÿÿ˜˜Äÿ¹¹ñÿ¹¹ñÿnnáÿbbßÿbbÞÿaaÞÿ``Þÿ__Þÿ]]Þÿ\\ÞÿZZÜÿZZÜÿYYÜÿYYÜÿXXÜÿVVÜÿUUÜÿTTÜÿSSÛÿRRÛÿPPÚÿPPÚÿOOÛÿLLÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿ88½ÿ &ÿÿÿþd9ùÿÿÿ&%yÿ*)€ÿ*)ƒÿ,+„ÿ..‡ÿ/.‰ÿ0/‹ÿ1/Žÿ31�ÿ42’ÿ43”ÿ65˜ÿ86šÿ87œÿ;:�ÿ==Ÿÿ>=¡ÿ¢¡Óÿ¨§Õÿ{zœÿ66Eÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ���ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿTTTÿÿÿÿÿÿÿVVoÿººðÿ¹¹ñÿŽŽèÿccßÿbbßÿbbÞÿbbÞÿ``Þÿ__Þÿ]]Þÿ\\Þÿ[[ÝÿZZÜÿYYÜÿYYÜÿXXÜÿVVÜÿUUÜÿTTÜÿSSÛÿRRÛÿQQÛÿPPÚÿNNÛÿMMÚÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿ::Âÿ +ÿÿÿÿrEùÿÿ ÿ'&yÿ*)�ÿ*)ƒÿ-+…ÿ..‡ÿ/.‰ÿ0/‹ÿ10Žÿ31‘ÿ42“ÿ43•ÿ65˜ÿ86šÿ98œÿ;:�ÿ== ÿ>=¢ÿlk¹ÿ©©×ÿª©Øÿ¢¢Îÿww˜ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ```ÿüüüÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¼¼¼ÿÿÿÿÿÿÿÿ¯¯âÿººðÿ««îÿiiáÿccßÿbbßÿbbÞÿbbÞÿ``Þÿ__Þÿ]]Þÿ\\Þÿ[[ÝÿZZÜÿYYÜÿXXÜÿXXÜÿVVÜÿUUÜÿTTÜÿSSÛÿSSÛÿQQÛÿPPÚÿNNÚÿMMÚÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿ;;Çÿ/ÿÿÿÿzJúÿÿ ÿ'&yÿ*)�ÿ*)ƒÿ,+…ÿ/.‡ÿ/.‰ÿ0/Œÿ10�ÿ31‘ÿ42“ÿ43•ÿ65˜ÿ86šÿ98�ÿ;:žÿ== ÿ?>£ÿA?¥ÿVU°ÿ••Ïÿ«ªÙÿ««Úÿ©©ØÿSSjÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ@@@ÿëëëÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýýýÿöööÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ÷÷÷ÿRRRÿÿÿÿÿÿÿwwšÿººñÿººðÿƒƒæÿddàÿccßÿbbßÿbbÞÿbbÞÿ``Þÿ__Þÿ]]Þÿ\\Þÿ[[Ýÿ[[Üÿ‚‚äÿ˜˜éÿ˜˜éÿ€€äÿWWÜÿTTÜÿSSÛÿSSÛÿQQÛÿPPÚÿOOÚÿMMÚÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ;;Çÿ0ÿÿÿÿvHùÿÿÿ'&yÿ*)�ÿ**ƒÿ-+…ÿ/.ˆÿ/.Šÿ0/Œÿ10�ÿ31‘ÿ42“ÿ43•ÿ65™ÿ86›ÿ97�ÿ<;žÿ== ÿ?>£ÿ@@¥ÿBA¨ÿDBªÿpp¿ÿ¤£×ÿ««Ûÿ­¬Üÿ—–Àÿ44Cÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÛÛÛÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿåååÿiiiÿ‚‚‚ÿŸŸŸÿ¹¹¹ÿÔÔÔÿíííÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¸¸¸ÿÿÿÿÿÿÿ--:ÿººñÿººñÿ¤¤ìÿiiàÿddàÿccßÿbbßÿbbÞÿbbÞÿaaÞÿ__Þÿ]]Þÿ\\Þÿggßÿªªíÿ££Ùÿ––Éÿ  Õÿ±±îÿ©©íÿeeßÿSSÛÿRRÛÿQQÛÿPPÚÿOOÚÿMMÚÿKKÛÿKKÛÿJJÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿ::Äÿ -ÿÿÿÿk>øÿÿÿ&%yÿ*)‚ÿ+*„ÿ-+†ÿ/.ˆÿ/.Šÿ0/Œÿ20�ÿ32’ÿ42”ÿ54–ÿ76™ÿ87›ÿ98�ÿ<:žÿ>=¡ÿ>>¢ÿ@@¦ÿBA¨ÿDBªÿED­ÿON²ÿ‚�Éÿ¥¤Ùÿ®­Ýÿ®®Þÿxw™ÿ..<ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÊÊÊÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿâââÿ ÿÿÿÿ!!!ÿ111ÿAAAÿ¸¸¸ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþþþÿ888ÿÿÿÿÿÿÿ››Èÿººñÿ¹¹ðÿ€€äÿeeàÿddàÿccßÿbbßÿbbÞÿbbÞÿ``Þÿ__Þÿ]]Þÿ\\Þÿ¤¤ìÿ��¼ÿ))7ÿ ÿ&ÿvvŸÿ²²ïÿ««íÿ__ÝÿRRÛÿPPÚÿPPÚÿOOÛÿMMÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿ99Àÿ (ÿÿÿÿY1õÿÿÿ&%xÿ*)‚ÿ+*„ÿ,+†ÿ/.ˆÿ//Šÿ0/Œÿ20�ÿ32’ÿ42”ÿ43–ÿ66™ÿ87›ÿ98�ÿ<: ÿ>=¡ÿ>>£ÿ@?¦ÿBA¨ÿDC«ÿED­ÿGF¯ÿGF±ÿ^]»ÿ‰‰Îÿ®®Þÿ¯®ßÿ¤¤Òÿtt•ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¨¨¨ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿîîîÿEEEÿÿÿÿÿÿÿøøøÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿŸŸŸÿÿÿÿÿÿÿXXqÿ¸¸îÿººñÿ  ëÿffßÿddàÿddàÿccßÿbbßÿbbÞÿbbÞÿ``Þÿ__Þÿ]]Þÿttâÿ²²ìÿ99Kÿÿÿÿ ÿ��Âÿ±±ïÿ��êÿUUÛÿQQÚÿPPÚÿNNÛÿMMÚÿKKÛÿKKÛÿJJÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿ77¸ÿ !ÿÿÿþF$òÿÿÿ&%vÿ*)‚ÿ+*„ÿ,+†ÿ/.ˆÿ0/‹ÿ0/�ÿ20�ÿ32’ÿ42”ÿ53–ÿ66™ÿ87›ÿ97�ÿ<;Ÿÿ>=¡ÿ?>¤ÿ@?¦ÿBA¨ÿDC«ÿED­ÿGF¯ÿIH²ÿKJ´ÿMMµÿbbÀÿŸžÙÿ°°áÿ°°âÿ§§ÖÿDDWÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿuuuÿþþþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿúúúÿeeeÿÿÿÿÿÿ™™™ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿôôôÿ ÿÿÿÿÿÿ"ÿ��Êÿ»»ñÿººñÿmmáÿffßÿeeàÿ��èÿ¬¬îÿ°°îÿ¥¥ìÿzzãÿ``Þÿ__Þÿ]]Þÿ‚‚åÿªªàÿ++:ÿÿÿÿÿÿ®®ëÿ°°îÿ……åÿPPÚÿPPÚÿNNÛÿLLÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ55±ÿÿÿÿþ$ñÿÿÿ$#rÿ*)‚ÿ+*„ÿ,+†ÿ/.‰ÿ0/‹ÿ0/�ÿ20�ÿ42’ÿ43”ÿ53–ÿ76šÿ87œÿ98žÿ;:Ÿÿ=<¡ÿ>>£ÿ@?¦ÿAA©ÿCB«ÿED­ÿGF°ÿIH²ÿKJ´ÿKK¶ÿMM¸ÿNMºÿ€€Ïÿ©©àÿ±±ãÿ²²äÿpp�ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿCCCÿýýýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿƒƒƒÿÿÿÿÿ///ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿiiiÿÿÿÿÿÿÿ^^xÿ¼¼ñÿ»»ñÿŠŠçÿhhàÿnnàÿ©©íÿ¬¬àÿ””Áÿ••Ãÿ¯¯äÿ··ðÿ™™êÿ__Þÿ]]Þÿ€€äÿµµïÿNNhÿÿÿÿÿÿ@@Vÿ±±îÿ§§ìÿccÝÿPPÚÿNNÛÿLLÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ22§ÿÿÿÿþìÿÿÿ"!jÿ*)ƒÿ+*…ÿ,+‡ÿ/.‰ÿ0/‹ÿ0/�ÿ10�ÿ42“ÿ43•ÿ53—ÿ76šÿ87œÿ98žÿ;: ÿ=<¡ÿ?>¤ÿ@?§ÿBA©ÿCB«ÿED®ÿGF°ÿHG²ÿJJ´ÿKJ¶ÿMM¹ÿNMºÿOO½ÿ__Æÿ��Øÿ¯¯ãÿ³³åÿ””½ÿ.-;ÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿýýýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¢¢¢ÿÿÿÿÿ¶¶¶ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÈÈÈÿÿÿÿÿÿÿ#ÿ³³åÿ»»ñÿ««íÿjjàÿhhßÿ‘‘èÿ®®âÿ11@ÿÿÿ==Pÿ°°çÿ··ðÿŠŠçÿ]]Þÿhhàÿ´´ïÿ¨¨àÿ%%1ÿÿÿÿÿÿ‚‚¯ÿ°°îÿ„„åÿQQÚÿNNÛÿLLÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ..œÿÿÿÿúæÿÿÿXÿ*(ƒÿ+*…ÿ,,‡ÿ/.‰ÿ0/‹ÿ00Žÿ10�ÿ42“ÿ43•ÿ53—ÿ76™ÿ87œÿ98žÿ;:¡ÿ=<¢ÿ?>¤ÿ@?§ÿAA©ÿCB«ÿED®ÿGF°ÿHG²ÿJJ´ÿLK¶ÿMM¹ÿNN»ÿPO¾ÿQQÁÿSSÃÿutÏÿªªâÿ´´çÿ¦¥Õÿ;;Lÿÿÿÿÿÿÿÿÿÿÿÿÿâââÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¿¿¿ÿÿÿÿJJJÿñññÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿùùùÿ___ÿÿÿÿÿÿÿˆˆ­ÿ¼¼ñÿ¹¹ðÿƒƒåÿhhàÿkkàÿ®®îÿhhˆÿÿÿÿÿ;;Nÿ¶¶ðÿ±±ïÿqqâÿ[[Ýÿ��æÿ´´ïÿžžÒÿÿÿÿÿÿ55Gÿ««çÿ­­íÿZZÜÿNNÛÿKKÛÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ**�ÿÿÿÿõ¾ÿÿÿBÿ*(ƒÿ+*…ÿ,+ˆÿ.-Šÿ0/Œÿ00Žÿ20�ÿ32“ÿ43•ÿ53˜ÿ76šÿ87œÿ98Ÿÿ;:¡ÿ=<£ÿ?>¤ÿ@?§ÿAA©ÿCB«ÿED®ÿFF°ÿHG³ÿJIµÿLK·ÿML¹ÿNN»ÿPO¾ÿQQÁÿSSÃÿUTÆÿbbËÿ¨¨ãÿµµèÿµµèÿ??Qÿÿÿÿÿÿÿÿÿÿÿÿ¨¨¨ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÞÞÞÿ ÿÿ ÿªªªÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÆÆÆÿÿÿÿÿÿÿ<¤ÿ@?¨ÿBAªÿCB¬ÿED®ÿFF±ÿHG³ÿJIµÿLK·ÿML¹ÿNN»ÿPO¿ÿRQÂÿRRÄÿUTÆÿWVÈÿYYÊÿŸŸáÿµµéÿ¶µêÿJJ`ÿÿÿÿÿÿÿÿÿÿÿlllÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüüüÿ---ÿÿ@@@ÿûûûÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýýýÿIIIÿÿÿÿÿÿ ÿ¥¥Ôÿ¼¼ñÿ··ðÿ~~äÿhhàÿggßÿkkáÿ³³ïÿššÉÿÿÿÿÿÿ&&2ÿ§§ÞÿµµðÿllàÿZZÜÿnnàÿ­­íÿ¬¬æÿ##/ÿÿÿÿÿVVtÿ­­ëÿ­­îÿNNÚÿKKÛÿKKÛÿJJÚÿHHÚÿHHÚÿHHÚÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿ??ÓÿUÿÿÿÿ¬fÿÿÿÿ+)„ÿ+*†ÿ,+ˆÿ..Šÿ0/Œÿ10�ÿ10‘ÿ31”ÿ43–ÿ54˜ÿ64šÿ97�ÿ98Ÿÿ::¢ÿ=<£ÿ?>¥ÿ@?§ÿBAªÿCB¬ÿED¯ÿFF±ÿHG³ÿIHµÿLK¸ÿMLºÿON¼ÿOO¾ÿQPÁÿRRÄÿTTÆÿWVÈÿXXÊÿ[[Íÿ””ßÿµµêÿ¶¶ëÿQQhÿÿÿÿÿÿÿÿÿÿ---ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿIIIÿÿ¿¿¿ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¬¬¬ÿÿÿÿÿÿÿ__yÿººïÿ»»ñÿžžêÿhhàÿhhàÿggßÿffàÿ››ëÿ¸¸ðÿnn�ÿÿÿÿÿÿ{{£ÿµµïÿššéÿZZÜÿZZÜÿŒŒæÿ±±îÿll’ÿÿÿÿÿ%ÿ§§áÿ¯¯îÿooáÿKKÛÿKKÛÿJJÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿ>>Ïÿ3ÿÿÿÿf2ÿÿÿÿ('ÿ+*†ÿ,+ˆÿ.-Šÿ0/Œÿ10�ÿ10‘ÿ31”ÿ53–ÿ54˜ÿ75šÿ87œÿ98 ÿ::¢ÿ=<¤ÿ?>¥ÿ@?§ÿBAªÿCB¬ÿDC¯ÿFE±ÿHG´ÿIHµÿKK·ÿMLºÿNM¼ÿOO¾ÿQPÁÿSRÄÿTTÆÿVUÈÿXXËÿZZÍÿ^^Ïÿ‘‘àÿ¶¶ìÿ±±åÿCCWÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿqqqÿOOOÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿûûûÿÿÿÿÿÿÿ&ÿ¥¥Óÿ»»ñÿ¶¶ïÿllàÿhhàÿggßÿeeàÿddàÿxxäÿ³³ïÿ±±çÿ55EÿÿÿÿÿII`ÿ°°éÿ´´ïÿ__ÝÿZZÜÿaaÞÿ®®îÿ––Êÿ"ÿÿÿÿÿ““Æÿ¯¯îÿ’’éÿKKÛÿKKÛÿIIÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿ<<Éÿ ÿÿÿÿ2ÿÿÿÿ_ÿ+*†ÿ,+‰ÿ-,Šÿ/.�ÿ10�ÿ10‘ÿ21“ÿ43–ÿ54™ÿ64›ÿ87�ÿ98 ÿ:9¢ÿ<;£ÿ>=¥ÿ@?§ÿA@«ÿCB¬ÿDC¯ÿFE²ÿGG´ÿIH¶ÿKJ¸ÿMLºÿNM¼ÿOO¾ÿPPÀÿRQÄÿSSÇÿVVÉÿXWËÿZYÍÿ\\Ðÿ]]Ñÿ——ãÿ··ìÿ¶¶ìÿ&&2ÿÿÿÿÿÿÿÿÿÎÎÎÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿžžžÿçççÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿuuuÿÿÿÿÿÿÿgg„ÿ¼¼ñÿ»»ñÿƒƒåÿhhàÿhhàÿffàÿddàÿddàÿccßÿ��èÿ¶¶ïÿyyŸÿÿÿÿÿ&ÿªªáÿ´´ïÿyyâÿXXÜÿYYÜÿ““èÿ²²ïÿ77Jÿÿÿÿÿ``ƒÿ¯¯îÿ¥¥íÿKKÛÿJJÚÿHHÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ66´ÿÿÿÿÿÑÿÿÿ;ÿ+)‡ÿ,+‰ÿ--‹ÿ/.�ÿ10�ÿ11‘ÿ21“ÿ43—ÿ54™ÿ64›ÿ87�ÿ98 ÿ:9¢ÿ;:¤ÿ>>¦ÿ@?¨ÿ@@ªÿCB­ÿDC¯ÿED±ÿGG´ÿIH¶ÿKJ¸ÿMLºÿNM¼ÿPO¿ÿPPÁÿRQÄÿTSÇÿVVÉÿXWËÿZYÍÿ[[Ðÿ]]Òÿ^^Õÿœœæÿ··îÿ²²çÿ ÿÿÿÿÿÿÿÿšššÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÖÖÖÿÿÿÿÿÿÿ(ÿ»»ðÿ»»ñÿ¦¦ìÿjjàÿhhàÿggßÿeeàÿddàÿddàÿccßÿggßÿ²²îÿ––Äÿÿÿÿÿÿ¥¥Ûÿ´´ïÿ��æÿXXÜÿWWÜÿmmàÿ²²ïÿ\\|ÿÿÿÿÿ55Hÿ¯¯îÿ¨¨íÿ]]ÞÿJJÚÿHHÚÿHHÚÿHHÚÿFFÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ$$zÿÿÿÿÜ‹ÿÿÿÿ+)‡ÿ,+‰ÿ-,‹ÿ/-�ÿ10�ÿ11’ÿ21”ÿ43—ÿ54™ÿ65›ÿ86�ÿ:8¡ÿ:9£ÿ;:¥ÿ>=¦ÿ@?©ÿA@ªÿBA­ÿED°ÿED²ÿGF´ÿIH¶ÿJI¹ÿLLºÿNM½ÿPO¿ÿPPÁÿQQÄÿSRÆÿUUÉÿWWÌÿYXÎÿZZÐÿ]]Òÿ^^Ôÿ``Øÿ  èÿ··ïÿ˜˜Çÿÿÿÿÿÿÿÿaaaÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿûûûÿkkkÿÿÿÿÿÿÿ��¶ÿ»»ñÿ¸¸ðÿäÿhhàÿggßÿeeàÿddàÿddàÿccßÿbbßÿbbÞÿ  ëÿ¥¥Ùÿ&&1ÿÿÿÿÿ™™Ìÿ´´ïÿ˜˜éÿYYÜÿVVÜÿVVÜÿ±±ïÿ}}©ÿ ÿÿÿÿÿ®®îÿªªîÿkkáÿJJÚÿHHÚÿHHÚÿGGÚÿFFÙÿEEÙÿEEÙÿEEÙÿBBÙÿAAÙÿ8ÿÿÿÿ”>ÿÿÿÿ%$uÿ,+‰ÿ-,‹ÿ/-�ÿ10�ÿ11’ÿ21”ÿ32–ÿ54™ÿ65›ÿ75�ÿ98 ÿ:9£ÿ;;¥ÿ>=§ÿ?>©ÿA@ªÿAA­ÿED°ÿED²ÿGFµÿHH·ÿJI¹ÿLK»ÿMM½ÿON¿ÿQPÁÿQQÄÿSRÇÿUTÊÿVVÌÿXXÎÿ[ZÐÿ\\Òÿ^^Õÿ__×ÿkkÜÿªªìÿ¸¸ðÿvvšÿÿÿÿÿÿÿ444ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÓÓÓÿ ÿÿÿÿÿÿJJ`ÿ»»ñÿ»»ñÿŸŸëÿiiàÿggßÿeeàÿddàÿddàÿccßÿbbßÿbbÞÿbbÞÿŒŒæÿªªàÿ**8ÿÿÿÿÿ””Äÿ´´ïÿ™™éÿXXÜÿVVÜÿVVÜÿœœêÿ••Éÿ ÿÿÿÿÿ®®îÿ««îÿvvãÿIIÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿ??ÓÿÿÿÿÿF÷ÿÿÿPÿ,+Šÿ-,Œÿ.-Žÿ10�ÿ21’ÿ21”ÿ32—ÿ64šÿ65œÿ75žÿ87 ÿ99¢ÿ;:¥ÿ=<§ÿ?>ªÿA@«ÿBA®ÿDC°ÿED²ÿFE´ÿHH·ÿJI¹ÿKJ»ÿMM¾ÿNNÀÿQPÂÿQQÄÿRRÆÿUTÊÿVVÌÿXXÎÿZZÑÿ\[Óÿ]]Õÿ__×ÿ``Ùÿuuàÿ¶¶ðÿ··ïÿBBVÿÿÿÿÿÿ!!!ÿêêêÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýýýÿWWWÿÿÿÿÿÿ ÿªªÛÿººñÿ··ðÿvvãÿggßÿddàÿddàÿddàÿccßÿbbßÿbbßÿbbÞÿbbÞÿ‹‹çÿ¥¥Ùÿ%%1ÿÿÿÿÿ››Îÿ³³ïÿššéÿWWÜÿVVÜÿUUÜÿŠŠçÿ££Üÿ ÿÿÿÿÿ§§æÿ¬¬îÿ{{äÿHHÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ++�ÿÿÿÿû ¹ÿÿÿ.ÿ,+Šÿ-,Œÿ--�ÿ0/�ÿ21’ÿ21”ÿ32—ÿ54šÿ65œÿ76žÿ87 ÿ:9£ÿ;:¥ÿ<;¨ÿ?>©ÿ@?¬ÿCB®ÿCB°ÿEE²ÿFE´ÿGF·ÿJIºÿKJ¼ÿML¾ÿNNÀÿPOÂÿQQÄÿRRÆÿSSÈÿUUÌÿWWÏÿYYÑÿ\\Óÿ]]Õÿ__×ÿ``ÚÿaaÝÿŠŠçÿ¸¸ðÿ¨¨ÛÿÿÿÿÿÿÿÈÈÈÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÂÂÂÿÿÿÿÿÿÿhh‡ÿ¸¸ïÿººñÿ——éÿggßÿeeàÿddàÿddàÿddàÿccßÿbbßÿbbÞÿbbÞÿaaÞÿžžêÿ••Äÿÿÿÿÿÿ¥¥Ûÿ´´îÿ––èÿVVÜÿVVÜÿTTÜÿ€€äÿªªåÿ ÿÿÿÿÿ¢¢ßÿ¬¬îÿ||äÿHHÚÿHHÚÿHHÚÿEEÚÿEEÙÿEEÙÿEEÙÿBBÙÿAAÙÿ@ÿÿÿÿÊ sÿÿÿÿ&%xÿ-,Œÿ--�ÿ0.‘ÿ21“ÿ21•ÿ32—ÿ33™ÿ65œÿ76Ÿÿ86¡ÿ99£ÿ;:¦ÿ<;¨ÿ>=ªÿ@?¬ÿBA­ÿCB±ÿED³ÿFEµÿGF·ÿIHºÿKJ¼ÿLK¾ÿNNÀÿOOÂÿRQÅÿRRÆÿSSÉÿVUÍÿVVÏÿYYÑÿZZÔÿ]\Öÿ]]Øÿ``ÚÿaaÜÿaaßÿœœêÿ¸¸ðÿ��¨ÿÿÿÿÿÿ•••ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüüüÿ&&&ÿÿÿÿÿÿ##-ÿ§§Ùÿººñÿ²²ïÿggßÿeeàÿddàÿddàÿddàÿccßÿbbßÿbbÞÿbbÞÿaaÞÿggßÿ±±ïÿww�ÿÿÿÿÿ +ÿ©©âÿ´´îÿ‡‡æÿVVÜÿUUÜÿTTÜÿäÿ¦¦àÿ ÿÿÿÿÿ¤¤áÿ¬¬îÿ{{äÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿ;;Çÿÿÿÿÿ>õÿÿÿ_ÿ-,Œÿ--�ÿ/.‘ÿ21“ÿ22•ÿ32—ÿ33™ÿ55œÿ76Ÿÿ86¡ÿ98£ÿ;:¦ÿ<;¨ÿ=<ªÿ@?¬ÿA@®ÿBB°ÿDC³ÿFEµÿGF·ÿIHºÿJJ¼ÿLK¾ÿMMÀÿPOÂÿPOÅÿRRÇÿSSÉÿTTËÿUUÎÿWWÑÿZZÔÿ[ZÖÿ]]Øÿ__ÚÿaaÜÿbbÞÿjjàÿ««íÿ¸¸ðÿLLcÿÿÿÿÿÿ***ÿHHHÿvvvÿ¥¥¥ÿÒÒÒÿÿÿÿÿÿÿÿÿÿÿÿÿ‚‚‚ÿÿÿÿÿÿÿqq‘ÿººðÿººðÿ}}äÿddàÿddàÿddàÿccßÿbbßÿbbßÿbbÞÿbbÞÿaaÞÿ``Þÿ‘‘èÿ±±êÿ//?ÿÿÿÿÿNNhÿ°°êÿ³³ïÿqqáÿVVÜÿUUÜÿSSÛÿˆˆæÿ˜˜Îÿ ÿÿÿÿÿ­­îÿ««îÿzzäÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ''ƒÿÿÿÿû>Íÿÿÿ6ÿ,+‰ÿ.-�ÿ/.‘ÿ10“ÿ22•ÿ32˜ÿ43šÿ54�ÿ76Ÿÿ86¡ÿ87£ÿ::¦ÿ<;©ÿ=;«ÿ@?­ÿA@¯ÿBB°ÿDC³ÿEE¶ÿGF¸ÿIHºÿII¼ÿLK¿ÿMLÁÿNNÃÿQPÅÿQPÇÿSSÉÿTTËÿUUÎÿVVÑÿYYÔÿ[[Öÿ]]Ùÿ]]Ûÿ``ÝÿbbÞÿbbÞÿ~~äÿ¶¶ðÿ©©Ýÿÿÿÿÿÿÿÿÿÿÿÿ!!!ÿSSSÿÿÿÿÿÿÿ%%0ÿººñÿ¹¹ñÿ��ëÿggàÿddàÿddàÿccßÿbbßÿbbßÿbbÞÿbbÞÿaaÞÿ``Þÿ‚‚åÿµµïÿnn’ÿÿÿÿÿÿ©ÿ³³ïÿ²²ïÿVVÜÿUUÜÿTTÜÿSSÛÿ™™éÿ‚‚°ÿ ÿÿÿÿ ÿ­­îÿ««îÿvvãÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿEEÙÿBBÙÿ@@ÖÿDÿÿÿÿØ‘ÿÿÿÿ('ÿ.-�ÿ.-‘ÿ1/”ÿ22•ÿ32˜ÿ43šÿ44�ÿ76Ÿÿ87¢ÿ87¤ÿ:9¦ÿ<;©ÿ=<«ÿ?>­ÿ@?¯ÿCB°ÿCC³ÿED¶ÿGF¸ÿHGºÿJI½ÿKK¿ÿMLÁÿMMÃÿPPÆÿRQÇÿSSÊÿTTÌÿUUÎÿVVÑÿXXÔÿZZÖÿ\\Ùÿ]]Ûÿ__ÝÿaaÞÿbbÞÿccÞÿ��ëÿ¶¶ïÿkkŒÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ––Ãÿ¹¹ñÿ´´ðÿxxäÿddàÿccßÿccßÿddßÿccÞÿccÞÿccÞÿccÞÿddßÿŽŽçÿ¶¶ðÿ©ÿÿÿÿÿÿ++9ÿ¦¦Ýÿ³³ïÿ��çÿVVÜÿUUÜÿSSÛÿSSÛÿ¬¬íÿbb…ÿÿÿÿÿ))8ÿ­­îÿ©©íÿlláÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿ44°ÿÿÿÿÿ‘:øÿÿÿOÿ.-�ÿ.-’ÿ0/”ÿ21–ÿ32˜ÿ43šÿ44œÿ65 ÿ87¢ÿ87¤ÿ98¦ÿ<;©ÿ=<«ÿ=<­ÿ??¯ÿA@±ÿCC³ÿDDµÿFF¸ÿHGºÿIH¼ÿJJ¿ÿMLÁÿNMÄÿOOÆÿQPÈÿRRÊÿTTÌÿUUÎÿVUÑÿWWÓÿYY×ÿ[[Ùÿ]]Ûÿ]]Ýÿ__ÞÿaaÞÿbbÞÿssáÿ³³ïÿ§§Úÿ$$/ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿRRkÿ¹¹ñÿ¹¹ñÿ˜˜êÿccßÿccßÿttâÿ‹‹çÿ––éÿ••èÿ��çÿ‹‹æÿ˜˜éÿ±±ïÿ¤¤ÙÿZZwÿÿÿÿÿÿÿvvžÿ³³ïÿ²²ïÿffßÿUUÜÿSSÛÿSSÛÿddÞÿ°°îÿ>>TÿÿÿÿÿPPoÿ¬¬îÿ§§íÿ^^ÞÿGGÚÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿ@@×ÿ$$yÿÿÿÿú:Îÿÿÿ ÿ-,�ÿ.-’ÿ/.”ÿ21–ÿ33™ÿ43šÿ44�ÿ65 ÿ87¢ÿ97¤ÿ98¦ÿ;:¨ÿ<;«ÿ=<®ÿ?>°ÿA@±ÿCC³ÿDDµÿFE¸ÿGGºÿIH¼ÿII¿ÿKKÂÿNMÄÿNNÆÿPOÈÿRQÊÿRRÌÿUUÎÿVVÑÿWVÓÿXXÖÿZZÙÿ\\Ûÿ]]Ýÿ^^Þÿ``Þÿ``ÞÿaaÞÿ’’èÿ¸¸ðÿdd‚ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ§§Úÿ¸¸ðÿ³³ïÿmmáÿbbßÿ‰‰æÿ°°îÿ¦¦Ùÿ‰‰²ÿ  Ñÿ¬¬âÿ¤¤×ÿ……°ÿOOiÿ%ÿÿÿÿÿÿÿHH_ÿ³³ïÿ²²ïÿ��èÿVVÜÿTTÜÿSSÛÿSSÛÿŒŒçÿ››Òÿ*ÿÿÿÿÿ„„¶ÿ¬¬îÿ¦¦íÿMMÛÿFFÚÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿ>>Ïÿ $ÿÿÿÿÎYÿÿÿÿSÿ..’ÿ/.”ÿ00—ÿ22™ÿ43›ÿ54�ÿ54Ÿÿ75¢ÿ97¤ÿ98§ÿ;:©ÿ<;«ÿ=<®ÿ>=°ÿ@@²ÿBA³ÿDD·ÿED¸ÿGG»ÿHH½ÿJI¿ÿKKÂÿMLÄÿNNÆÿOOÈÿQPÊÿSSÍÿTTÏÿUUÑÿWVÓÿWWÕÿYYÙÿ[[Üÿ\\Þÿ]]Þÿ^^Þÿ``Þÿ``ÞÿaaÞÿµµïÿ³³êÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿoo‘ÿ··ïÿ¸¸ðÿ‹‹çÿbbßÿrráÿ±±íÿ||¢ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿ//>ÿ³³ïÿ²²ïÿªªíÿjjàÿTTÜÿSSÛÿSSÛÿXXÜÿ¨¨ìÿvvŸÿÿÿÿÿÿŸŸÝÿ¬¬îÿ——êÿHHÚÿFFÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ,,”ÿÿÿÿÿYëÿÿÿÿ-,�ÿ/.”ÿ//—ÿ22™ÿ43›ÿ54�ÿ54Ÿÿ75£ÿ87¥ÿ98§ÿ:9©ÿ;:«ÿ>=®ÿ>=°ÿ??³ÿAAµÿCC¶ÿED¸ÿGF»ÿGG½ÿJI¿ÿJJÁÿLLÅÿNMÇÿOOÉÿQPËÿRRÍÿTTÎÿTTÑÿWWÓÿWWÖÿXXØÿYYÚÿ\\Þÿ\\Þÿ]]Þÿ^^Þÿ^^Þÿ``Þÿ††åÿ··ðÿaaÿÿÿÿÿÿÿÿÿÿÿÿÿÿ**7ÿ§§Üÿ¸¸ðÿ­­íÿbbÞÿbbÞÿ““èÿ­­âÿ *ÿÿÿÿÿÿÿÿÿÿÿÿÿ55Gÿ®®èÿ³³ïÿ°°îÿƒƒåÿTTÜÿTTÜÿSSÛÿSSÛÿƒƒåÿ§§åÿ55GÿÿÿÿÿCC]ÿ§§çÿ¬¬îÿssâÿFFÚÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿAAÙÿ ÿÿÿÿì^ÿÿÿÿSÿ/.•ÿ0/—ÿ10™ÿ32›ÿ54žÿ55 ÿ65¢ÿ76¥ÿ98§ÿ:9©ÿ;:«ÿ<;®ÿ>=±ÿ?>³ÿ@@µÿCB·ÿEE¸ÿFEºÿGG¾ÿIHÀÿJJÂÿLLÅÿMLÇÿNNÉÿPPËÿQPÍÿSSÐÿUUÑÿUUÔÿXWÖÿXXØÿYYÚÿZZÜÿ\\Þÿ\\Þÿ]]Þÿ]]Þÿ^^ÞÿccÞÿ®®îÿ´´ìÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿyyžÿ¸¸ðÿ¸¸ðÿwwâÿbbÞÿbbÞÿ››êÿªªàÿ ÿÿÿÿÿÿÿÿÿÿÿ ÿXXuÿ««åÿ²²ïÿ²²ïÿŽŽçÿTTÜÿSSÛÿSSÛÿSSÛÿbbÞÿ¥¥ìÿ||¨ÿÿÿÿÿÿxx¦ÿ¬¬îÿ¬¬îÿNNÛÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ((…ÿÿÿÿÿ_Ûÿÿÿÿ))†ÿ0/˜ÿ00šÿ31œÿ54žÿ55 ÿ65¢ÿ66¤ÿ98¨ÿ:9©ÿ;:¬ÿ;:®ÿ>=±ÿ?>³ÿ@?µÿBA·ÿCC¹ÿFEºÿFF½ÿHGÀÿJIÂÿKKÄÿLKÆÿNMÉÿOOËÿQPÍÿRQÐÿTTÒÿUUÔÿVVÖÿXXØÿYYÚÿZZÜÿZZÜÿ[[Ýÿ\\Þÿ\\Þÿ]]Þÿ]]Þÿˆˆæÿ¶¶ðÿ^^|ÿÿÿÿÿÿÿÿÿÿÿÿ11@ÿ··ðÿ··ðÿ——éÿaaÞÿ``Þÿ``Þÿ™™éÿ±±éÿ??SÿÿÿÿÿÿÿÿÿÿIIaÿ‘‘Áÿ²²îÿ²²ïÿ²²ïÿ„„åÿVVÜÿSSÛÿSSÛÿSSÛÿRRÛÿ––èÿ¦¦àÿÿÿÿÿÿ&&5ÿ¡¡ßÿ¬¬îÿˆˆçÿFFÚÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿ==ÌÿÿÿÿÿßjÿÿÿÿWÿ/.—ÿ00šÿ21œÿ54žÿ55 ÿ65¢ÿ66¤ÿ87¨ÿ:9ªÿ;:¬ÿ;:®ÿ==°ÿ?>³ÿ@?¶ÿ@@¸ÿCBºÿED¼ÿGF½ÿGG¿ÿIHÂÿKKÄÿLLÇÿMLÉÿNNÌÿQQÎÿRQÐÿRRÒÿTTÔÿWWÕÿWVÙÿXXÛÿZZÜÿZZÜÿZZÜÿZZÜÿ[[Ýÿ\\Þÿ\\Þÿooáÿ­­îÿ¥¥Úÿÿÿÿÿÿÿÿÿÿÿÿ¢¢Õÿ··ðÿ¯¯îÿqqáÿ__Þÿ__Þÿ^^Þÿ��åÿ´´ïÿ¤¤ÚÿKKcÿÿÿÿÿ ÿ00?ÿll�ÿ¥¥Ûÿ±±ëÿ³³ïÿ²²ïÿ®®îÿttâÿUUÜÿTTÜÿSSÛÿSSÛÿQQÛÿ��çÿ°°îÿ))8ÿÿÿÿÿÿhh�ÿ¬¬îÿ¬¬îÿVVÝÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ kÿÿÿÿÿj×ÿÿÿÿ+*‹ÿ00šÿ10œÿ32žÿ55 ÿ65£ÿ76¥ÿ77§ÿ99ªÿ;:¬ÿ;:®ÿ<;°ÿ>>³ÿ@?¶ÿ@@¸ÿBAºÿDD»ÿED¾ÿGG¿ÿIHÂÿJIÅÿKKÇÿMLÉÿMMËÿOOÎÿQQÑÿRRÒÿSSÔÿUU×ÿXXØÿXXÛÿYYÜÿZZÜÿZZÜÿZZÜÿ[[Ýÿ[[Ýÿ[[Ýÿ\\Þÿššêÿ°°éÿBBWÿÿÿÿÿÿÿÿÿÿ[[xÿ¶¶ðÿµµïÿ‘‘èÿ^^Þÿ^^Þÿ^^Þÿ]]Þÿ]]Þÿ¦¦íÿµµðÿµµðÿµµïÿ´´ïÿ°°êÿ´´ïÿ´´ïÿ´´ïÿ³³ïÿ´´ïÿ²²ïÿ®®îÿ””èÿ^^ÝÿTTÜÿSSÛÿSSÛÿSSÛÿQQÛÿ��èÿ®®ìÿKKeÿÿÿÿÿÿ$$2ÿ¬¬îÿ¬¬îÿˆˆçÿHHÚÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿ66¶ÿ ÿÿÿÿØuþÿÿÿ>ÿ00šÿ10œÿ32Ÿÿ43¡ÿ66£ÿ76¥ÿ77§ÿ87ªÿ:9¬ÿ;:¯ÿ<;±ÿ=<³ÿ?>¶ÿ@@¸ÿA@ºÿBB¼ÿED¾ÿFFÁÿHHÂÿJIÅÿKKÇÿLLÉÿMMËÿOOÎÿPPÑÿRQÓÿSSÕÿTT×ÿVVÙÿWWÚÿYYÜÿYYÜÿYYÜÿZZÜÿZZÜÿZZÜÿZZÜÿ[[Ýÿ}}äÿ²²ïÿyy ÿÿÿÿÿÿÿÿÿ&ÿ¬¬âÿµµðÿ­­îÿffßÿ]]Þÿ]]Þÿ\\Þÿ\\Þÿ\\Þÿddßÿ––éÿªªíÿ³³ïÿ´´ïÿ´´ïÿ´´ïÿ³³ïÿ³³ïÿ««íÿŸŸëÿ‹‹æÿffßÿVVÜÿTTÜÿSSÛÿSSÛÿSSÛÿ__Ýÿ““èÿ­­ëÿRRpÿÿÿÿÿÿ ÿ’’Êÿ¬¬îÿ¦¦íÿ]]ÞÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿ@@×ÿ^ÿÿÿÿþu×ÿÿÿÿ! kÿ10�ÿ21Ÿÿ33¡ÿ66£ÿ76¥ÿ77¨ÿ87©ÿ88¬ÿ<;¯ÿ<;±ÿ=<³ÿ=<µÿ@?¹ÿAA»ÿBA¼ÿCC¿ÿFEÀÿGGÃÿIIÄÿIIÆÿKKÊÿMMÌÿNNÎÿOOÐÿQPÓÿRRÕÿSS×ÿUUÙÿVVÜÿWWÜÿXXÜÿYYÛÿXXÜÿYYÜÿYYÜÿZZÜÿZZÜÿ]]Üÿ®®íÿ˜˜Êÿ"ÿÿÿÿÿÿÿÿss™ÿµµðÿµµðÿåÿ[[Ýÿ[[Ýÿ[[Ýÿ[[ÝÿZZÜÿZZÜÿZZÜÿZZÜÿjjßÿttáÿyyâÿzzãÿzzãÿxxâÿrráÿjjàÿ]]ÝÿVVÜÿUUÜÿTTÛÿSSÛÿSSÛÿSSÛÿzzãÿ££ëÿ§§âÿ77Kÿÿÿÿÿÿÿww¥ÿ¬¬îÿ¬¬îÿ……æÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ11¥ÿÿÿÿÿ×4ÿÿÿÿÿ,,‘ÿ21Ÿÿ22¡ÿ43¤ÿ76¦ÿ77¨ÿ87ªÿ88¬ÿ;:¯ÿ<;±ÿ=<³ÿ==¶ÿ?>¸ÿ@@»ÿBA½ÿBB¿ÿEEÁÿFFÃÿHHÆÿJIÇÿJJÉÿLLÌÿMMÎÿOOÐÿPPÒÿQQÕÿRRØÿTTÚÿVVÜÿVVÜÿVVÜÿWWÜÿXXÛÿYYÜÿYYÜÿYYÜÿZZÜÿZZÜÿ““èÿ³³îÿ77Iÿÿÿÿÿÿÿ..<ÿªªâÿ´´ïÿ¡¡ëÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿYYÜÿXXÜÿYYÜÿYYÛÿXXÛÿVVÜÿVVÜÿVVÜÿVVÜÿTTÜÿTTÜÿSSÛÿWWÛÿ||ãÿ¤¤ìÿ®®îÿ€€­ÿÿÿÿÿÿÿÿ[[~ÿ©©ëÿ¬¬îÿ¡¡ìÿMMÛÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿ@@Öÿÿÿÿÿÿ4‘ÿÿÿÿ8ÿ10žÿ22¡ÿ43¤ÿ65¦ÿ77¨ÿ87ªÿ98­ÿ99¯ÿ;:±ÿ=<³ÿ>=¶ÿ>=¸ÿ@?ºÿBA½ÿCBÀÿCCÁÿEDÂÿGGÆÿIHÇÿJJÉÿKKËÿMMÏÿNNÑÿPPÒÿQPÕÿQQ×ÿSSÚÿTTÜÿVVÜÿVVÜÿVVÜÿWWÜÿWWÜÿYYÛÿYYÜÿYYÜÿYYÜÿppàÿ´´ïÿ^^}ÿÿÿÿÿÿÿ||¥ÿ´´ïÿ´´ïÿiißÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿZZÜÿXXÜÿXXÜÿ^^Üÿ}}ãÿ��çÿ‰‰æÿyyãÿkkàÿbbÞÿeeßÿooàÿ‚‚äÿ››êÿ±±îÿ¦¦àÿ��Äÿ::OÿÿÿÿÿÿÿÿDD_ÿ¨¨éÿ¬¬îÿ¬¬îÿ[[ÞÿEEÙÿEEÙÿEEÙÿEEÙÿBBÙÿAAÙÿFÿÿÿÿÿ‘ Øÿÿÿÿeÿ22¢ÿ32¤ÿ54¦ÿ76¨ÿ88ªÿ98­ÿ99¯ÿ:9±ÿ<<´ÿ>=¶ÿ>=¸ÿ?>ºÿ@@½ÿCBÀÿCCÂÿDCÄÿEEÆÿHGÇÿIIÊÿKKÌÿLLÎÿMMÑÿOOÓÿQQÕÿQQ×ÿRRÙÿTTÜÿTTÜÿUUÜÿVVÜÿVVÜÿVVÜÿVVÜÿWWÜÿXXÛÿXXÜÿXXÜÿ²²îÿ  Õÿ ÿÿÿÿÿ@@Uÿ´´ïÿ´´ïÿŽŽçÿ[[ÜÿZZÜÿYYÜÿYYÜÿYYÜÿYYÜÿYYÜÿYYÜÿYYÜÿXXÛÿqqáÿªªíÿ££Ûÿ��ÀÿœœÑÿªªäÿ²²ïÿ±±ïÿ¯¯ìÿ¥¥ßÿ••Éÿ~~ªÿ^^ÿ11BÿÿÿÿÿÿÿÿÿLLjÿªªìÿ¬¬îÿªªíÿkkáÿEEÙÿEEÙÿEEÙÿEEÙÿBBÙÿAAÙÿ&&ÿÿÿÿÿØ K÷ÿÿÿ ÿ((…ÿ32¤ÿ43¦ÿ65©ÿ88«ÿ98­ÿ99¯ÿ:9±ÿ;;³ÿ>=¶ÿ>=¸ÿ?>»ÿ??½ÿA@¿ÿBBÂÿDCÄÿEDÆÿFFÈÿHGÊÿIIÍÿLLÎÿMLÐÿMMÒÿOOÕÿPP×ÿRRÙÿSSÛÿSSÛÿSSÛÿTTÜÿUUÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿWWÜÿ††åÿ³³ïÿ””Äÿ77Iÿÿ ÿVVrÿ©©àÿ´´ïÿªªíÿhhßÿYYÜÿYYÜÿYYÜÿYYÜÿYYÜÿXXÜÿXXÜÿWWÜÿWWÜÿ[[Ýÿ©©íÿ«ÿ(ÿÿ&ÿ%%2ÿ++9ÿ,,;ÿ))8ÿ"".ÿ ÿ ÿÿÿÿÿÿÿÿÿ ÿpp›ÿªªìÿ¬¬îÿªªíÿooâÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ22ªÿÿÿÿÿ÷K•ÿÿÿÿÿ10Ÿÿ43§ÿ44©ÿ76«ÿ98­ÿ99¯ÿ::±ÿ;:´ÿ;;¶ÿ>>¹ÿ?>»ÿ??½ÿ@@¿ÿA@ÁÿCCÅÿEDÇÿEEÉÿFFËÿHHÍÿJJÎÿLKÑÿMMÓÿNNÕÿOO×ÿPPÚÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿTTÜÿUUÜÿUUÜÿVVÜÿVVÜÿVVÜÿ\\Ýÿ¦¦ìÿ²²ïÿ¯¯êÿ««æÿ­­çÿ°°ìÿ³³ïÿ³³ïÿ‡‡åÿXXÜÿWWÜÿWWÜÿWWÜÿWWÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿ}}äÿ®®êÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ%%3ÿ’’Êÿ««íÿ¬¬îÿ««îÿmmáÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿAAÙÿ==Îÿ #ÿÿÿÿÿ•Õÿÿÿÿ "ÿ22¥ÿ44©ÿ65«ÿ76®ÿ99°ÿ::²ÿ;:´ÿ;;¶ÿ<;¸ÿ>>»ÿ@?¾ÿ@@ÀÿA@ÂÿAAÄÿDDÇÿEEÉÿFFËÿGFÍÿIIÐÿKKÑÿLLÔÿNNÕÿOO×ÿOOÙÿQQÛÿQQÛÿSSÛÿSSÛÿSSÛÿSSÛÿTTÜÿTTÜÿUUÜÿTTÜÿVVÜÿVVÜÿddßÿŸŸëÿ°°îÿ²²ïÿ²²ïÿ±±îÿ®®îÿ‡‡æÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿVVÜÿUUÜÿUUÜÿŒŒçÿ——Ìÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ``…ÿ¬¬îÿ¬¬îÿ¬¬îÿ¥¥ìÿ``ßÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿBBÙÿ@@Øÿ;ÿÿÿÿÿÕ"ìÿÿÿÿ=ÿ32¥ÿ54«ÿ66®ÿ87°ÿ::²ÿ;:´ÿ;;¶ÿ<;¸ÿ==ºÿ?>¾ÿ@@ÀÿA@ÂÿAAÄÿBBÆÿEDÈÿFFËÿGFÎÿGGÐÿIIÒÿJJÔÿLLÖÿNN×ÿOOÙÿPPÚÿPPÚÿPPÛÿRRÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿTTÜÿSSÜÿTTÜÿUUÜÿUUÜÿuuâÿ‰‰æÿ‰‰æÿ��åÿaaÞÿVVÜÿVVÜÿVVÜÿUUÜÿVVÜÿVVÜÿUUÜÿUUÜÿUUÜÿTTÜÿTTÜÿTTÜÿ‰‰æÿ°°íÿ!!,ÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ00Cÿbb‡ÿ¢¢àÿ¬¬îÿ¬¬îÿ©©íÿ‰‰çÿOOÛÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿ??ÔÿQÿÿÿÿÿì"?÷ÿÿÿÿNÿ54¬ÿ55®ÿ77°ÿ98²ÿ;:´ÿ;;·ÿ<<¹ÿ=<»ÿ==½ÿ@@ÀÿAAÂÿBAÄÿBBÆÿCBÉÿDDÊÿFFÍÿGGÐÿHHÒÿIIÔÿJJÖÿMMØÿMMÙÿOOÚÿPPÚÿPPÚÿPPÚÿPPÚÿQQÛÿRRÛÿRRÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿTTÛÿSSÛÿTTÜÿTTÜÿTTÜÿTTÜÿTTÜÿSSÛÿTTÛÿTTÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿmmàÿ°°îÿ••Éÿ??Uÿÿÿÿÿÿÿÿÿ)ÿAAYÿll•ÿ‹‹Àÿ££àÿ¬¬îÿ¬¬îÿ¬¬îÿ‘‘èÿccßÿGGÚÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿ@@×ÿaÿÿÿÿÿ÷?aþÿÿÿÿVÿ65®ÿ66°ÿ87²ÿ98µÿ;;·ÿ<<¹ÿ=<»ÿ==½ÿ>=¿ÿ@@ÂÿBAÅÿBBÇÿCBÉÿCCËÿEEÍÿFFÏÿHHÒÿIIÕÿII×ÿJJÙÿMMÛÿNNÛÿNNÛÿOOÛÿPPÚÿPPÚÿPPÚÿPPÚÿQQÛÿQQÛÿRRÛÿQQÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿSSÛÿRRÛÿRRÛÿUUÛÿššêÿ°°îÿ¬¬éÿªªæÿ¥¥àÿ™™Ðÿ��Àÿ‰‰»ÿ��ÃÿœœÖÿ¦¦äÿ¨¨çÿªªêÿ¬¬íÿ¬¬îÿ¬¬îÿ¬¬îÿ””éÿ^^ÞÿJJÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿBBÙÿAAÙÿ""rÿÿÿÿÿþa‹þÿÿÿÿTÿ66±ÿ76³ÿ88µÿ:9·ÿ;;¹ÿ=<¼ÿ==½ÿ>>Àÿ>>Âÿ@?ÅÿBBÇÿCCÉÿCCËÿDDÍÿFFÐÿFFÒÿHHÕÿII×ÿJJÙÿKKÛÿKKÛÿLLÛÿNNÚÿNNÛÿOOÛÿPPÚÿPPÚÿPPÚÿPPÚÿPPÚÿQQÛÿQQÛÿRRÛÿRRÛÿRRÛÿRRÛÿSSÛÿSSÛÿRRÛÿRRÛÿSSÛÿRRÛÿRRÛÿSSÛÿRRÛÿRRÛÿQQÛÿRRÛÿQQÛÿQQÛÿPPÚÿPPÚÿ]]Üÿššéÿ¬¬íÿ®®îÿ¯¯îÿ¯¯îÿ®®îÿ®®îÿ­­îÿ­­îÿ­­îÿ­­îÿ««îÿ§§íÿ££ìÿƒƒæÿYYÝÿHHÚÿFFÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿ@@×ÿ$$zÿÿÿÿÿþ‹ŸÿÿÿÿÿLÿ44¬ÿ77µÿ99·ÿ::¹ÿ<;¼ÿ==¾ÿ>>Àÿ>>Âÿ??Äÿ@?ÇÿBBÉÿDCÌÿDDÎÿEEÐÿEEÒÿGGÔÿHHÖÿJJÙÿKKÛÿKKÛÿKKÛÿKKÛÿLLÛÿMMÚÿNNÛÿNNÛÿOOÚÿPPÚÿPPÚÿPPÚÿPPÚÿPPÚÿPPÚÿQQÛÿQQÛÿQQÛÿQQÛÿQQÛÿQQÛÿPPÛÿQQÛÿQQÛÿQQÛÿQQÛÿQQÛÿQQÛÿQQÛÿPPÚÿPPÚÿPPÚÿOOÚÿOOÚÿOOÚÿhhàÿ{{ãÿˆˆçÿ��èÿ��èÿ��èÿ��èÿŽŽèÿ‰‰æÿ~~äÿooáÿ[[ÝÿHHÚÿHHÚÿGGÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿBBÙÿ>>ÏÿcÿÿÿÿÿÿŸ�ÿÿÿÿÿ;ÿ54®ÿ88¸ÿ98ºÿ::¼ÿ<<¾ÿ>>Àÿ?>Ãÿ??Äÿ@@Çÿ@@ÉÿCCÌÿCCÎÿEEÐÿEEÒÿFFÕÿGGÖÿHHÙÿIIÚÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿLLÛÿMMÛÿNNÛÿNNÛÿOOÛÿOOÛÿPPÛÿOOÚÿOOÛÿPPÚÿPPÚÿPPÚÿPPÚÿPPÚÿPPÚÿPPÚÿPPÚÿPPÚÿPPÚÿPPÚÿOOÚÿOOÛÿOOÚÿOOÚÿOOÛÿMMÚÿMMÛÿLLÛÿLLÛÿLLÛÿKKÛÿKKÛÿKKÛÿKKÛÿJJÚÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿBBÙÿ<<ÉÿIÿÿÿÿÿÿ��ÿÿÿÿÿÿ32©ÿ98ºÿ99¼ÿ<;¾ÿ==Àÿ?>Ãÿ??Åÿ@@Çÿ@@ÉÿAAËÿAAÍÿDDÐÿFEÓÿFFÕÿGG×ÿGGÙÿHHÚÿHHÚÿJJÚÿJJÚÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿLLÛÿLLÛÿMMÛÿMMÚÿNNÚÿNNÛÿNNÚÿNNÚÿNNÛÿOOÚÿNNÛÿNNÚÿOOÛÿNNÚÿNNÚÿNNÛÿNNÚÿNNÛÿMMÛÿNNÚÿLLÛÿLLÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿIIÚÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿAAÙÿ;;Çÿ +ÿÿÿÿÿÿ�˜ýÿÿÿÿ ÿ*)Œÿ99½ÿ::¿ÿ::Áÿ>=Ãÿ??Åÿ@@Çÿ@@ÉÿAAÌÿBAÎÿBBÐÿDDÓÿEEÕÿGG×ÿGGÙÿHHÚÿHHÚÿHHÚÿIIÚÿIIÚÿJJÚÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿLLÛÿLLÛÿLLÛÿMMÛÿMMÚÿMMÚÿMMÛÿMMÛÿMMÚÿLLÛÿMMÛÿLLÛÿMMÛÿLLÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿIIÛÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿFFÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿBBÙÿAAÙÿ33¬ÿ ÿÿÿÿÿý˜„ýÿÿÿÿÿfÿ77·ÿ::Áÿ;;Ãÿ>=Åÿ??ÇÿA@ÊÿAAÌÿBAÎÿBBÐÿCCÒÿCCÔÿEE×ÿFFÙÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿIIÚÿIIÚÿIIÚÿJJÛÿJJÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿJJÛÿJJÛÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿFFÚÿEEÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿCCÙÿAAÙÿ>>Ïÿ##uÿÿÿÿÿÿý„Uóÿÿÿÿÿ4ÿ0/žÿ;;Ãÿ<;Æÿ==Èÿ?>ÊÿAAÌÿBBÎÿBBÐÿCCÓÿCCÔÿDD×ÿEEÙÿFFÙÿGGÚÿGGÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿIIÚÿIIÚÿJJÚÿJJÚÿKKÚÿJJÚÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿKKÛÿJJÛÿKKÛÿKKÛÿJJÚÿJJÚÿJJÚÿJJÚÿIIÚÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿDDÙÿBBÙÿAAÙÿ66µÿ<ÿÿÿÿÿÿóU1ãÿÿÿÿÿ ÿgÿ<<Æÿ<<Èÿ==Êÿ>>ÌÿAAÎÿBBÐÿCCÓÿDDÕÿDD×ÿEEÙÿEEÙÿEEÙÿFFÚÿFFÚÿGGÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿIIÚÿHHÚÿIIÚÿIIÚÿIIÚÿIIÚÿIIÚÿIIÚÿIIÚÿIIÚÿIIÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿGGÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿCCÙÿBBÙÿAAÙÿ""rÿ ÿÿÿÿÿÿã1Åþÿÿÿÿÿ -ÿ22§ÿ;;Æÿ==Íÿ>>ÏÿA@ÑÿBBÓÿDDÕÿDD×ÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿFFÚÿFFÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿFFÚÿFFÚÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿCCÙÿBBÙÿ??Ôÿ55±ÿ.ÿÿÿÿÿÿþÅ|óÿÿÿÿÿÿWÿ11¦ÿ>>Ïÿ??Ñÿ@@ÓÿBBÕÿCC×ÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÚÿFFÚÿFFÚÿGGÚÿGGÚÿGGÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿHHÚÿGGÚÿGGÚÿFFÚÿFFÚÿFFÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿCCÙÿBBÙÿAAÙÿ44¯ÿ_ÿÿÿÿÿÿÿó|5ÈÿÿÿÿÿÿÿQÿ33«ÿ??Ôÿ@@Õÿ@@ØÿCCÙÿCCÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿFFÚÿFFÚÿFFÚÿFFÙÿGGÚÿGGÚÿGGÚÿGGÚÿGGÚÿGGÚÿGGÚÿGGÙÿGGÚÿFFÙÿFFÙÿFFÙÿFFÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿDDÙÿCCÙÿAAÙÿAAÙÿ55±ÿUÿÿÿÿÿÿÿÿÈ5cþÿÿÿÿÿÿÿ@ÿ44¯ÿ@@ØÿAAÙÿAAÙÿBBÙÿCCÙÿDDÙÿDDÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿCCÙÿBBÙÿAAÙÿAAÙÿ66´ÿEÿ ÿÿÿÿÿÿÿþc±ýÿÿÿÿÿÿÿ7ÿ00 ÿ==Íÿ??ÕÿAAÙÿBBÙÿBBÙÿCCÙÿDDÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿBBÙÿAAÙÿ??Õÿ==Ìÿ//Ÿÿ7ÿÿÿÿÿÿÿÿý±HÊüÿÿÿÿÿÿÿ ÿfÿ..œÿ99ÀÿAAÙÿAAÙÿAAÙÿAAÙÿBBÙÿCCÙÿCCÙÿDDÙÿDDÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿEEÙÿDDÙÿDDÙÿDDÙÿCCÙÿCCÙÿBBÙÿAAÙÿAAÙÿ99Áÿ//žÿfÿ ÿÿÿÿÿÿÿÿüÊHK¾ÿÿÿÿÿÿÿÿÿ (ÿMÿ&&€ÿ66¶ÿAAÙÿAAÙÿAAÙÿAAÙÿBBÙÿBBÙÿBBÙÿBBÙÿCCÙÿCCÙÿDDÙÿCCÙÿDDÙÿDDÙÿDDÙÿDDÙÿDDÙÿDDÙÿDDÙÿCCÙÿCCÙÿCCÙÿBBÙÿAAÙÿBBÙÿAAÙÿAAÙÿ66¶ÿ&&€ÿMÿ 'ÿÿÿÿÿÿÿÿÿÿ¾K-±ÿÿÿÿÿÿÿÿÿÿ ÿÿFÿ%%}ÿ44®ÿ??ÔÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿAAÙÿ??Ôÿ44®ÿ%%}ÿHÿÿ ÿÿÿÿÿÿÿÿÿÿÿ±-‘óþÿÿÿÿÿÿÿÿÿÿÿÿÿ7ÿUÿ""rÿ((ˆÿ..šÿ22©ÿ66´ÿ77¸ÿ66´ÿ22©ÿ..šÿ((‡ÿ""rÿVÿ7ÿÿÿÿÿÿÿÿÿÿÿÿÿÿþó‘V¾íýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýí¾VS”ÒùÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿùÒ”S 8…ËõÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿõË…8 ,n¡ÆÝêòùüýüùòêÝÆ¡n,ÿÿÿÿÿÿÿ€ÿÿÿÿÿÿÿÿÿÿÿÿðÿÿÿÿÿÿÿÿÿÿÿüÿÿÿÿÿÿÿÿÿÿðÿÿÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿÿÿ?ÿÿÿÿÿÿÿÿøÿÿÿÿÿÿÿÿàÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿ?ÿÿÿÿÿÿüÿÿÿÿÿÿøÿÿÿÿÿÿðÿÿÿÿÿÿÀÿÿÿÿÿÿ€ÿÿÿÿÿ?ÿÿÿÿþÿÿÿÿüÿÿÿÿøÿÿÿÿðÿÿÿÿàÿÿÿÿÀÿÿÿÿ€ÿÿÿ?ÿÿÿ?ÿÿþÿÿüÿÿøÿÿøÿÿðÿÿðÿÿàÿÿÀÿÿÀÿÿ€ÿ€ÿÿ?ÿ?þþüüøøøððððððààààààÀÀÀÀÀÀÀÀÀÀÀààààààðððððððøøüüþ?þ?ÿÿÿÿÿ€ÿÿÀÿÿÀÿÿàÿÿðÿÿðÿÿøÿÿüÿÿüÿÿþ?ÿÿÿÿÿÿ€ÿÿÿÿÀÿÿÿÿàÿÿÿÿàÿÿÿÿðÿÿÿÿøÿÿÿÿþ?ÿÿÿÿÿÿÿÿÿÿ€ÿÿÿÿÿÿÀÿÿÿÿÿÿðÿÿÿÿÿÿøÿÿÿÿÿÿüÿÿÿÿÿÿÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿàÿÿÿÿÿÿÿÿøÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿÿÿøÿÿÿÿÿÿÿÿÿÿþ?ÿÿÿÿÿÿÿÿÿÿÿþ?ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿcaronc-apprise-dfec414/apprise/assets/themes/default/apprise-failure-128x128.png000066400000000000000000000374071526720371000275410ustar00rootroot00000000000000‰PNG  IHDR€€Ã>aË pHYs  šœ IDATxÚí�w˜Õ•ö·ªsOÎA“”Ê„�%@Ø"ÙÆ`ÌŽØx°qZ¼³ Æ,»Kt/ØŒ1Á`ŒÖFDFÊy4£�¤É©g:V}Ô­žêêêž‘,av—û<ýÌtWuuw�sÏyÏ{Î=>ŒÆãƒñtˆÿå¿ïàCÀ|`Pä>À ¨òè@ˆa`8 ìÞ^¶} ïÏïï—Bm.Φ�“ðyCÀà¯ÀŸ€RY†¥} ïѨçgSÏßá{D�ÝÀ+ÀKÀ›@ÛŽåä ð iŽ»¤ÉÖßG�„ü^/ÊïéúÀœ˜‘üø,<Öï\æñÐPë÷Sã÷SæñPäñ�ïrt¹p A\× ÅãôÅãtE£tD£´†Ã4 Ñ84DG4z¬ßYBÀÿ ô} Ç6<Ò¬ÿ pÑh'»…À£(ä¹\ÌÌËãÔ‚ææç39Ä­(ó—‰i{B!6õõ±¾·—­ôÆbD5�˜>&×ÿ,pƒtÑ óðË�oIÿžùDE¡!`R0Èüü|0!´ý:áü\Ðõôã)sXOýß<_¾¾?b]o/ïôõ±gp�ÃÃD5m´ß÷p°ZF(€e¬�¦~ž ÏG�ßÏù¥¥,*(`b0H¥Ï—.d« �ᘠºžþ¿©À‘p˜ýCC¼ÕÓÃ_;:hÎvµ°Qº†ÿþ@ ø5°Èh¯OÍÏçË ÌÌÍŧ(¸%UÀVÁ¿—C×AÓ@׉kÚÆÖþ~~ÞÜÌÛ½½ÙÞ©ÏW½ÿÀ%üý™¾‡ON+,äëãÇ3-'‘Iàâ}bȤ"˜–ao(Ä���¼ÖÝÍpf÷ _�“ þEw§:,v»9«¸˜+««“‚W¡JÏpŒÀNXD·=w’†t;¸Óõ±[«ev òÛÖVÖvuÑž9¢xøG`ýÿv¸ø.�ãtðòª*../gZn.ŠÆCQ’Gý1NVáDX«`¥¢Œª¦5��üñÈnm%ƒ=n“ÑÏÿ:(þ,¼´Ï�™›Ë&M¢ÎïÇ›¸Š¢H” ‚LÎèò³R˜ ‘M,JK$Ø?4ÄwïfK&#ô&ð‘÷¼W 0WÆÂãìrT•OU�ãêÚš¤�!PTETó¹MÒðÀØ|Áèáß±š|›u«" ëü¢¹™Ÿ773”H8�Ý*9�MÿÓà2Ɉ¥™üùù|¥¾�I9AT„1Û…‚¢*I¡«B ä_E [Œü9…‚ºžÕÿ'…$ÏɈÆ äŒŠ`¾.ã¾Pˆ[÷íãµîîL.á³ÀN–pÔ“,üî”O s÷ùÚ:¾ÒPO¹×‹¡¨EAQcR#ää6Àx] ÌðÏîãMå°œ'¤²˜×c™ù6+“í‘ñýæç;Í0k#En7ç––’çrñFO�#ú1ù÷ÅÿI@î•áMŠcö+ 7O™ÊÜ‚üÓ®ºäì™õ E€j€Š]ˆ–Ù=ªpmïÓm7@„•í3ÿŽÅhZvP(£�ŒV!‘]gk?W¿û.¡t— ÉpùZI$½¯-€¸]~Ù©Ôø|Ü7sõÁ 1¡*(BI™íÉY¯ªò˜Ã,––Àœé™f"Š‚.ÿ !ŒÿM…‘³T—×±ÓÍs„@—ç‘eÖguKæu3Ý1 ^Ë=.«¬ä­Þ^{J 0c/ŸH¾àD+€K†1ß°XRXÄ�§N%×í2Ì»b>ä�~ÄŒš¾_1AU�Ͱª©V%IG« ía¾ÇTEIål®HX”&«¢8Yù¿ªr~i)‡†‡Ù74d?ë4‰¥ÖH«ð¾sŸ±¿¸²¬œÏÔÖàWÕdL¯ %Åì+B@PUŒcR!éÿ!’îÀ~ãt‹2èY�ÑMŒèN¹ÀSY‰Z\Œ+7ávƒª¦˜}-C …Hôõïì$ÑÕ5:P”àp(çžøUK‹ÓWzøï7¸#‹—rÍ�WVò55†O7Q¾b(€¦¿Çºý'Ž˜‘€)x‹iÖ˜Ñ,›ÏMXg¥õ[И’›Kî™gâ**¥Ù¢MC�ÇÑãq¢MM ïØA¢§';6Ð4´D‚‡[[¹eß>'®ààú÷‹ øÀOìÂÿDUŸW#-¥!D¡JŸn¢|9ëÓпôÙæìObU1íR ‘»I 9™|;NÈô¿ƒ;.ù眃»¬Œ¸¦K$ŠD8ÚÓCs{;­��ô ¢iŠ¢ I%R\.·Wi)þéÓqWU‘èëC ‡ ðéä.€9yyx…7S#! µƒÀ»o °#­™’—½¨¼œO×Ô¦\QäÌWP“¦]¢|éï“Ï%>Pe„²�CRg�u¶�•ÈÀèVW�±»«ªÈ[¶Œ½‡ño�?ÎëÛ¶Ñfšv 4µ¦†9&pÆŒ̨«£¾¢‚<¿?yNôÐ!†·l!ÖÚŠnOÉ|‚¦iütÿ~LwaŒ4úÚ¿—ŒÖÅÖÏ,*âšú<Š‚j%w¤�Mwà2q€ùº¤UEâAJ@˜ÖÀœ‘ÖîdþM�Êãžúz\%%ƽ ‡‰45¡…BÎX ƒß,\ˆwÊ–~ó›l;pàØ¸ðœæOœÈòyóøäÙg“0 —õD‚hSƒo¼�f~R b‰?ܽ›§�±_¶ X4¾× à6c”`�hD ÀÍS§â’³^ÂâÛG@µ‚=»5�窪!tUšt+(tâþ…½âGWóòÈY²µ°0ét]‡D‚ðîÝ mÛ*• xê© 9ûºëÌ^ø‘q¸].ŠssùÇ‹/æË+WޏýH„�^ zè�#W kŸÛ¼™×Ó £Àìã ÿ p7ðaë …n7·L›Ž[QGü¸2ã+Ò¿¦ÜÊôIa[ùÎ)B1ÂE9ódrv¿íñ�wÞy¸ ˆkÑXŒ„®Êärá.+U%ÞÞžîÿby5'‡¢‰ù¯U«Ž[4Mc0æÅwßeõ¦Mœ>}:…99(.¾I“ÐB!â��é˜@×Y^RÂêÎNzb1ëÑR xþ½R€�·b)}ª*ß�8‘"�7 Þ„ª¤€=¡&ÞŒí“àPRø’0_KÆû©T°°ƒ>kìoyþ™3ñÖÔ°ýàA®»ï>¾zç�Üÿç?33gÒ$|jn.±Ã‡Ñ£ÑT°_S}}&Mbêøñl=p¯ÛMeQuååÔWVRW^Nuq1Eyyø½^‰Ñxæ‰y¸»›Ç_y…šÒR¦Ž‡P<µµèá0ñŽŽ´ÈÅ#§ä沦³Ó^d2S½'Ûä0ªv K,Ÿ«©åÌ’béßù°ør9Ë¡XÂ<�ª¨)¸@H 0ÂX®g 3fM^@>-ºòJwwó‘ï}�&›ÿ¼ìì³ùå·¿m ©={½óΨô¯®ëx*+ÉY¼˜îH„C��ææRœ—GÐç]'¡i ÑÞÛKóÑ£lojâÕ­[yqWó••+ùñ§?�¢(èšÆàÚµ„÷ìq¤�=tˆïÙc_Ž´[2†ƒ'ÓÜ.-@rœZPÀ%•UÒDK³¯¦Ý:Ó“¡ Dò¦�å (¦�&_5g»d ­–‡š@ÝÊ �«¢ß„ ÜÿÜs<÷æ›iñvc[Óëê˜4n®âb¢¢G"YÙA¡ëhƒƒ„÷ïÇ—HPæõ…Ðd|?¼mÑýûííª«9}áBV,\ÈÇÏ<“®þ~v;“;lØ»—£==,›3UUñ64kiIªŠºÎÌÜ\v‡BìO�%r‚þ÷ÉR€À¯¬/ä»\|müx|ªËPÒL�ÄôŠ¢Z|¸ÕìKåPG ?L`7ù&84qEÚŒ7I!‹BfÍB-(àž§ŸfokkÚ�‰'´÷öráâÅxÝn\ÅÅDöï[R)‘ ÑÓC´­�ØáÃ$º»Ñ††Ð£QôH­¿ŸøÑ£„÷ícxûv<šFem-—.]Ê¢iÓxùÝw …Ãi—ßÜØHÀëeáäÉ(ÒDöìA·º rÏ/-åñ¶6{=Á©…7m'Z\À RË’ãóµuÔ‚#þ[<Å èd(§¦$yTi!�f©frFM‚Ga±*#�bMôØ5æ_U%¸hû�áú=Ž?ª¥½�©µµœ2~ aÓÕß�×íæŒY³ÀåB¸\ÄN‹òÏ;wy9ŠÇ“|=� ÔÄ.J0ˆ»¸ïøñ¨EEÄÚÛÁbÂõh”hc#îÂBÎ>óLºظ7¼GãqÞÙ»—O/_nXU—ËPL›¢ ÚëåÕînúF>CµKس®KK¥� oªõÅeåä¸\©iXÌ¥²:Â^£ØžËø_·øs]±¥a�hŠ‚ÐôŒt¯)|Ušç¾Pˆ§^yeLþ“GáPgg2 S‹‹SÀŸwâD”¼<Ú{zøÖ}÷1õª«(\¹’²K.¡påJj/»ŒKn¸�‡þò:ûúˆë:ÂíÆ[[Ká%—੯O a…�µk‰57sÛ¾ÀiÓ¦¥}§­MMüfõj¼'¢æå9r‚A.,/· sŠ”›ø[-@¾LóY ;VVTp¹F¾"lÀOÉáCŠi7£cæ39H¥PìQ„L )�‚ L¡'#SXãÇã7ŽŸ=ó /o{M厦&®X¶ ár!T•X[[òFû¦M# rݽ÷òÈ /¤¸hzëê@ƒp²d£Í͸JK¹äüóùíêÕ E")„QW?�v>�ÅëMÅ–1='‡çÚÛHåfHÙEþ¸øœÕd,/-eZnžE –pKᆅ½S,ç Å0ÿƒ 0(éóÕnG˜ £OØC5�‡œÅ‹I( WÞt‘TÆ,ëhiogRM Óêëqëè@6”ª¶–a—‹ïþüç„GY.®ë:ïîÛÇS¯¼Âp4ÊüÉ“q»\†ûˆY#!Htw“ÛÐÀÔñãyÂf±Ú{{Y8y2«ªp1¼}{zDx… èrñb*ƒXì’”ýq»€;¬Or]nÎ,.!¹IzEMÖ‘Ð4~ñÌ3tJ®=¸hÑÈl ‡QT•ª’’1_¯hˆÛ{ŒÏþô§Dâñ¤uòÏž�bÅ}}D9sÆ Î_¸0-T]µ~}R‘½“&9WB ÁÇ++©³d�äw¬ ðI{Ø·¼´Ä@íü°�lyu³ B4¬õs¤ø|C9Ì–I”/Œu‹ÉO¹¾… ÌžM4çgüãqqÜoïØÁŸßz ]×QƒAüÓ¦!€È�].¾wÕUä(Š‚KU“5Ë¢”¿¬_Ï'nºÉøÊŠ‚oÊ\ee)ç oߎW®8'}Uüóë×3$]ŽoâÄô ¨¬^?a‚ýíÅRŽÇå~T&9`Uå3µu#Hß á’é\%iº“ �ƒ0ò˜±½DüŠ�@J^‡‘°YQdã°(C`î\Ü¥¥ÜûôÓ¬’B<ž±³©‰��}6A¿WQÑ–4É#LŸ5‹«.¸€s.ä;W^É÷>õ)>á…,›7�ÂÜ\úB!ºVü4=Ê‘žV,Zd` E!ÚÚ:‚Ð4 W~>ùUU¬Ý²…޾ð>‰°báBÆ•” ÜnÂ;v”°CƲÒçcMGÝ©®o¼äŽIN¾mMø\P^NC0')˜‘Ê1ÂZbç$ðKoúï‘�™P,É $0³ˆ²H$™a4 CäC-*"8w.ýýÜôàƒÎP¤1–Ñ ¡é:Ë,0B0·›X[‰®.â4�šœ‚Ñ(JWÞ�j 8דּ¸ø¬³ú|¼¹}{šnmldò¸qL««ÃUXhPÏápÒB&B!ÊæÎå­�;ÙÑÜœòÞ’¼<Ξ5 4�hk«A[¯/-€ ÄuݾФ£�´åX\ÀåXtx…9ùI3�4×ÖXQÏ´ SQŒóÍê\Kˆh­äÑ `�^Ø)­IÊë¶ ``útPUž}í56§×Ðó¸÷©§Ø"iaϸq¸KKB�èé!¼kC72´q#Ã;vÞµ‹ÐúõôþéOäµ·óý«®âw?ü¡cøþ?ÿ™î��dda­ZNôô ÇãÌ�8—š:7ßÞ½;éîÔ¼<çµrÒ�ZP@…7u-Ž”ç˜1@)p†õ…Ùyù\®¤oÖ…sbQ×|¦¶$'_ �Ÿ\�m>— [ã=§žfŠ´ºwe%ÝÜþ»ß�YÈ |ìc£°°Ðñø?þÇ7ÚíÆ;iX“MºŽ��d•o"Áð–-„6là¼E‹¸ëk_KOøìÞ͆ݻ nñצ0ã]]̨¯ÇãJm2¶]†BQPd%Q¦b×É99œ’›kÿèó­DÞh 0ÍJü`ZnjRˆXf­1sSB¢xá“&L�TÔŸ®X¶Œ¥³g§\3�óÒ¦MÄ wuuŠ2'zz¨-+K³–âa!àœ¬€*K‹‹q§› å:ª( ’�6K=^*}þô2=Ci�H×J]â…ôÈÁQè#!#$�"ö0óÆë:�yóPssùï·ßæWÏ=—re¯×ËM7ÝÄöíÛYµj·ÝvW^y%3gÎL9ï?ø“'Ov xöYŽvu!€àÂ…è–j$aÏIX”`xÇÜÀ§W¬H»î«[¶ŽD Ų)•6sæ8–¢Û•@�F‰uu1¥¶–q¥¥iæ<"É%??í^¸].GÐÌ £ _ kYäñ0ϼvj%—o4ðËt¢qs„`| ˜b¾3Îbs]ºeÖK%H.²É ŒÄûéÀQJ* ÔS¹µ¤ÿ)§ ë:ÿôË_rÈZ>e†­99Œ?~Lx`É’%\~¹#Nâ§�›‡† ár¥_ ŽDCX¯Û� ÷klÏ/©¨°_b.F³Í¬ 0ê%.!˜”“cé‰jEw �BÞˆÖÏa6 ‡•ºV\`_FiDÉP'$gáBPþðÒK<�!áS\\Liié˜@Qn¸á‚ö^ƒ@ÏÀÿúÐCÉlŸoÚ4c� ÍØ¡ �×íœe ™•GÖRt!P‹Šèêï™íVÓìõ`3“X–ÎZº¤dU€ SÈd·›"·'Ëg¥gøLÍÐmæÝ‰-4]€™t%&%l†‡òór.DÍÍegS7Ü?± E—v_?Ú˜:u*ŸùÌg�=ôüó¼¾e‹SUVâ*+K[™dwî’"±XÒܧÓ:›-÷Ð]ZJÓ‘#ÄmDOUqqò|-ʼÂÉòÜ£(œ–n}.MRüÿDëŒ�ò°vHN‘ª°[‹H…@—á]RG,Ñ‚õC’˲I�ý�wÆxÊË Çb|òG?¢=½F>9/^|L àr¹¸öÚk3Z�ëîºËø™ªŠoÊ”‘%çNJàv£ÑÑ×G§C¿Àüœœ$ùc*�«¼„`ë�i•ijÆ�7‘°3�™–¤J·+FS€Y) &C<]· œÌMòu!’œ€°š÷ ¦KO"‡«ÊÙŸ·x1žêj4]ç#ßþvZ•¯},X°Àñõ|�#Þ;mÚ´ŒV`_k+÷<ñ„ÔJKqWVf4ÿþ©S.{[Zh1×È1¹¦�´ÉúEÁ'#‘�{÷’°¹€%3fÈÐ$A¡�L¦ûzzQ‘ý¥ Ù`žýìziF a�«–xßÛÛ>[Ÿ>+G`›ûÉÏÐu‹ÅQò,À_WG$åÚ;î`£½\ÚÁ§Ï�;×ÙoÝÊ5×\“ñ½·Ür‹£ˆ'üfÕ*= @`þ|�1¹ÕR¹KKñNœH8ãQYБ"”SNÁ'£ªX[›‘îöùðTW³·µ•]¦½g™Œfâ’âc K=r]i�ëçeR€”)“ïrãVTçø>Íè)3]Ïô¥,€^à”UÔSf=BP°h�‰Ñ�›zˆß¯Y3ª9Ÿa·±k×.žyæ^~ù匮àg?û™ã±=òè_ÿЦë(n7�yóFÂ\IO{'L@¸Ýü~Í^X¿>m¦.›7�ÛM´­Ít€oÆ PUÞÚ±ƒÆÃ‡SÞ3©ºšjiÊÃÖªå1´Åñ( “Ó�í‚1Y€j{nYO5׺�îM ±’¼¼Qj„ ¬ib‹b”,_N ¡\Ï=üüé§ÇäÏ—.]êœóO$h•7Ýtñ òÒK/å‚ .p<öŸ�=f„�Bà‘€0ù³Ün\ì>x�oß{oÚ{çNšÄ’SNAÃTºJJð64ŽÅø•CëÅK–ðx@ÓˆdªoÌ  .!dÄyvH¡Ã*¼Þà–!ⵚ}ݬÛÂĬ˜!I)ËP/'HåÅã-+C¾ôÓÛxèù±/};ãŒ3_?xð 2µ»nÝ:žÎ PŠ¢ðÃþojR%™žýž´B®çKé-¤ªünõê4$ïq»¹zÅ Šòòˆ:D¢¯áv˜;Åëå™7Þ`³m]B^0È9³g£ª*ÃÛ·gv†%ï.!œŠDNɤ)…Ÿe–opõÂÔ¬ù€TgÎÄÛA‡ß⯯§|Å \99tõöñù[náéµÇ¶þôÓOw|½¹¹™AY^=44ÄC=D¿sÇNæÏŸÏW\áxì¹×^cõ† z/+Ã]U5¦…ÃT¤ƒ/V,ZÄÕ+V Çã„wîMÃ?kîÒRZÚÛùêþgz$3}:ó&O6M»v·K¢q{OÝž–µi-õ¡&“¤üE6ÞÇ‘ˆÂÁÔë�’v!#ZÐqçåQzÆ™”,9ÕïgGS_üÉOxîõ×�Iø¹¹¹TWW;kiiIZ€çŸž×3\ßãñðå/™"a|çž{6ª|¦N5ò‰Ñ–®<ï<ζ€ÐKÎ<“_\=Ax÷nâ��¨¹¹ø§L¡op�«o½5 ù»].®¹ðB¼n7á�;�úÄ,æ^d`'ó].{bÈí¤UÖ>™˜Ñg´ÈxLO ít›»É´°ùZÑܹT}øÃÇ7 T•Ç׬áònàõ­[Ž9—¿ÈRËçd"‘Ô"ÙïÿûÏ?õÔS3RÄ�‡q÷þ`X�‚¼õõ Ô‚±ýÓ?±öž{xõÞ{¹çºëð{½DLšrWEÁO}”M{Óõ^zÆ,�=mx˜ð�ckNí0‚.ÁôÄP•]Ö©ê57eÈâRè> 0´äÿEÒEˆ”ôqòÝŠ‚¯´”ºK/¥hί—�¡!¾u×]\wç�ŽÊXƼyóýa,ã Cˆµyóf~ùË_f¼Þí·ßNNNŽÃýÖy|õjvËê�Àœ9·=f`õj|ÍÍ̬¨`Fu5¾¡!^}•Á7ßL™±}¡P²ðÄ:Ê ¹÷k_]'ÒØ8ÒalÔï4ªJ@M+þ Ø K‰˜OQY¢TÂÆA5[R(ílMÃ[\LågP»r%ž‚úùÓk¯ñ¡¯~•߯^}Üõ|¦ïvR€ÁÁAš2ÔÕ_ýõi–!9ƒ‚Aî¾ûng+ÐÖÆï^x�„|3û¨ë„wî¤oÕ*úž}–þ5kˆÙȧD?9>…¶~�‡?Üx#.U%ÞÕÅлï¦FY™*¯2$‰¼Š‚7Ý=Œ7MA°¥­?á¤y#/jòB)´¯|ŸêóQ>>&à’èô¯o¿Íëžçµ-[8#Sppp�½ÒÔ*ŠB~~>ÉÇË/¿ÌùçŸïøÞ«¯¾š_üâ¼õÖ[iÇî{òI._¶Œiuµ3’À­IDATu¸++qWU?thT“?rúûùæÇ?Ngo/Û º¤„ï_y%3Ç�7ZƼòJ²'@2ß’¡šPúoªȾN®L.Àe­÷w˜Ë::º.˸“MDJH¨ ¤¼¯è”TÌ_€ËçE…–£G¹ù¿~Åë[¶ŒºØb¬£¾¾>c‰—ªª\pÁTVVRSSCUUÕÕÕTVVfzVçG?ú+W®Lã"±×Ýu«î¸ÃX˜:q"ƒG�‚Ó¢›�^}•¹Ë–ñ›ü€Þ�r* ц‡é_½:¹ TXs)¶´óhŠæÂA®ôrÙháŠ-Ñ£gƒ6ä/HU]à+-¡îì¥J˨´¹ëñǸïÉ'9Ñ£¾¾ž‡ü;@EEwÜqþô¸xÌäÒE]ÄSO=•vì�­[ùýš5\¾l®²2 çœ�P½r–çTUQ4yZ"Áá 8sÖl.X|úIU€²²2Gá¼þúë ¡Ôh»{a”`ßvÛmÓÅ^¯—o¼1ãû¿y×]Æg( ÞÉ“fbm¬38Óì·÷1̆äy¡xÜiš6§dPJ­‘µÛ„®kint„¢P9k6¾ü|ö¶¶ò�ÿøwZÛ�Š™º¥KªJoc#ƒ‡s½\Z}²F¶57n<æëmÞ¼™?Êeæáp˜ÆÆF^yå{ì1þíßþ�?ÿùÏî�Ý»ù™L5»Š‹ñTW;n<=†”YF†O·^'‹b $öVršØŠ¥f¬#aœ?`Ayz2%l‚>O HÕ<#ëuÍO~Bgo/yë->{áE¨ªBÝ9çppÍÚ7o¦nù2®ùèG¹ý·¿=) pÚi§e<¶î8èðð07Þx#<ð]]]ô÷÷ÓÛÛK___FÚ8%‡ð»ßñÑ¥K)/*Â7eÊÈj`)<},ÛÐ9)‰ÓûdÁ¨áÔ�Úc¸„´øi`{JXcÿ‘zºý¯‘™·Ÿüæ7ÉäÍýÏ<ÃÑî.t p‚UUôú¨æä¤,s ÿFQaÙ�xLPâšÆ¡ô†”ƒVö/År¥8‰p8¥Ì;� å§Ì ¥½�ç^}5ùúP8<2ËAÅܹ ¨´¼ô2…|bÙ²Q÷>Öáv»ÓBÀh4šäÿ­àï½k6l`�l «š ¥“°lnAën¤£p ]ç@z«œ½)l�e¼m}r$¶%€tt�äêÝ‚ú:P«ÞxƒaÛ¬xaÝ:ÖnÜÈÙóæ‘[3Žüºú4qèÕWùô‡?ÌÖ¼ÈîƒÍ'ìFÏš5‹#Gްwï^Ž9B[[ûöí£±±‘;v8®´y/FW?íÝÝL6W;í5<`(Ì(!ÃæÕ™v9�é:{­½†mrv9 ò0$¢i D£äy¼)fÅôÿyÕU$4�í�û�½qlã_þë¿8kÎTE¡|Þ±l9�¯Y}Bntww7ÝÎûï¦ UQÈñû úýø¼^ rr¨(.¦º´”âü| ss úýxeݾ®ëÄb1†#zèèí¥³·—æ#Gè … ‡ ‡ …ÃD²Ë,`FCZ8œlçh�œv*˦v ’A±ö…BNy€·³)À3VØdvA�\¼™šPÝnú†‡3é:O¿ü2,^L}e%•§žFÿþô<È`K ×}ò“¼øÎ†ã®üë(ÊËcæ„ L©­ebM Æ�£¡²’ªÒRü2>×c1´áa£N_nÏB"1²àÕìMäõ œ=4M£åèQš�¡éÈv77³³¹™ÖŽrü~Ι;—¯_vÚºÕè$nœÕ¬§ù|óÿL˜!3‰‘<¾6½’(Ž¥“¸“ücÃgš­ÂÕ�Rg¤@<O+¶ŽÖövžzé%¾vù¸U•š�Ã�瞣}ã&V®äÚ�}Œ›~õ«.t�ÛÍ%g�Å…K–0¥¶–ÒÂBŠdo�D_ñînÛ¶1 ·nKaÑäïIcÕ,ÀU�âñPRXHEQ§×Õ¡žw½¡ý¡—‹Š¢"´h”Á7ß$*ëùu'.Àº£x/² =‹2¬¶o=)°N Ð) ¡"€h"AËÐu¶¤†.ñhwn.n—+«0xöY.[¶œÚò2åä�o`°©™��\°x1O½ü2[GëÓ?Æ1qÜ8¾yùå\rÖYø½^MC‹F‰wv2°mq¹¬Ëâ1mo*‰|$†‡�&�fÁ¦Ü¤¢$?„`p÷n£µ«ðË�ØÑ3»,€/Ó÷>Óš¦¬s&w~ýëÜú¥/1³¾5"ÖÚÊðöí„·n%ÖÖ–ÚnÝÉßÚw µßdkc(Ûë¦9׉wtoo7vµ˜ò4ÁÂÂñgœÕš–ÊÚö"H{Ÿ|íáÖVÞNw¯ÿ('yFH`ô˜Mö PLËÍÕlî([µj,àÍ­[9Ж}ƒŠƒG�ÒPUÅäšZ<9¹Dûû wt …8}ù2^Û¼™¶ts5êøÈé§ó¯_ü×_y%õ%%D oÛFdÏâmmèÒ…¥lú<þÝŒ|uÝÈ{ MK¼Å�§)�)'À'_K¾“ï—¦ßjþõl`ÐæZnÚ³ÇÞ0²¸KóèLlÌF`ÈÊvF"–E":º¦Óø0‰H”yS¦¦uµrw<ò}¡At]§lþ|·›ÁæfZZøéµ×“à§××óû›oæ¾o›3fÍ"ÒØHÏóÏ3ôî»ÄÚÛ�JS‰D’E³&ƒì³Òúšy¾�5œf}&4oóéc¡}3a+L„N$‘|¾m`€¶tóÿ 0<š 0qÀÇ€ €¨®SáóRíó§îã+·s�³p!O½ô‘Xöò§P8L<‘àŒY³Q½tt†"14DÝì9$4�õ;wf½†Ïã៮¾šÿÚטT]�3ðúk„÷íC$FÏU•«•Ó#ÌYå°„å¸SY–]ˆ)åÙ¶Ùlš]¸ºÕ÷gwöÙŸÕMØ,Ïoâíž{x›œÜ£*€&“BÉümTÓ˜‘›‹[Îts˜¡®.êçÎ%!FžÉ œ6c%äTUÑ·oÑ®N|…E,\´�?¾òŠcXôù8cæ,¹ñŸ9wáBD(ÄÐÎ�ôËíVÝreN`Ö,|ãÇ£ø|Ä::2.štjô˜i†êN³ÎIø&Ýiæ‹,´/²KجŒÓL×v)è�Åøys3‡SÙÙŒ ¾�ŽEÌÄÐ7L7Ñ�sJ^9.×Hhº–t–�wooÛ–Ö¨1-594„ªªœ1{6ŠPð—–Ò»k±Þ^ÊfL'?'—ÕR×ÔŸ1kÿôéOóÝO]EA0Èà¶mô½ó±¶Ã¸rsÈ�7�àŒxkjPý~¿Wi)‰ÁA´ÞÞÌ3L–Z §(@ˆT�âÐÞÎ)?ïdö³DiBÍ–øÉF[Χ·—‡Z[I¤žóº ïµ±*Àp1–nái[sèpoEµµ,^°€µ›618JŸþ­û÷sî¢E”àˆ 3ÔÚŠêñ2uÎlÖíÜÉáÎNÊ ùÉ—¿Â7.ÿ3ni¡sõ"­­ˆxœ¼9s)8}1î¢"·›H4ÊÓ¯¼BQ^¹�îŠ †wïNw£$P…žÉÏ�æóÍ×íìž=4´¿´sm�žüE5�_·¶ònêŠg ¸×ÎŒ¦`Ô|Î ä{bv‚SÐãq†»º™0{6s¦MãõÍ›“ýí3�M{öòÉóÎE îœ ƒMM„ZR1{6ùùùL®©á—ßùÓêQ#º^{�þ-[AKà+/§ôÜóð�«F‚¡h”Û}”ÏÜr O­]Kya! §NEQUc‡§Í˜Á# Dæ¥ív¡;(Ašð�¨]k‰—ý¹Ýï;$~²ÍþÎh”ví²WI9«´_BÖ É…8Óóò,Û¦4 1ÔÕɤ§°táB¶ìÛG�,ÁrÌ’õõQVXÈ)ã'àˆ éè ÒÝÃìE‹XŠ.¤pÞ\|%% /¬_ÏÍ¿þ5w=ñDÚN‘XŒŽÞ^.Z²ÄPR¯—hkköÙ„žeæë™Ò»&ÚÏ3qÿB×Gø�±Æýòœ®H„¯oßî´[äeÈ*àãQ€&à‹È¥D ]'®Ã´œ¹›w ˆÓ½o±ž>–.ûç,XÈ9óæÑPUEAN¹�Åyù4TVpêŒL7޲ÂB†Û3Ør�މ’@‹DÉŸ6•òeËð••¡¸Ýì;tˆïÜ{/÷>ýt²ÐÂiìhjbáÔ©Œ¯ªBÉÉ1¶v5ýâX 33E6�;Òµv_žIø–üƒ�Ê™B6ÙÖ^õkÃÿ¼{7;Ósÿ/ÿ™Ñð�]$Ûfåº\\5®†º@0¹Ý‹jÙõC…äÎß³gS>s&žÜ\0w ad�x(Ä¡_"ÞÓcì.·‘ñ—”R{é%É÷üûïãî'ŸÌØÔ>ê+*ØôÀ†böô0ðÒKÉ®\cc¼SŒï`ê3 ?ÉQX®;?»50I*`k?ÿ°i“}Ky$­ÿn¦Ÿçãmx ¸Ñ$†âq6ôö0ÎçC‘¼€®Én¿j*ˆjߺ…έÛðæ–”â q¹\ƹñ8Ñþ~¢½=É�CÑE‚uuF�»ïòíûî;fª¸¥½�;Ÿx‚o\v®ÂB$ÛYì qVQÑÈÞ¾&°neîì!FÌ¿�½ƒ“»Š"·47”ÆXƒ¬ ()fæìÙ<±ví˜M ý50@®ßÏ’3Œ°0??s¯½cµ Ntm6smQ†$Ú· ¬ŒŸ¹ì+‘à»»vÑ–^©ü<Æž�ñ¥q©W'Í‚¦1œˆ35˜3²6AZvMn++B—ÛÉšç˜J¡3²}œ‘£íL8ýt‡‡Ù°k×qÉí­�;¹òÜsÉQ‚Aâ==ÉmàŽIè–€£à³˜kÝ`¦¸ ' ’)ä³(×C--<}ô¨ý[ÇdøÞ8ÚÏ;ÖuÔMÀR !ID"Ôúý”x-®@†Õ)\�%�T]—ÛÇY,�"�™;…Å¢àüðØêÕËÏFsM‡óñ¥K“ÛÀGLeèF¾å<áNÚ³vY¾djÙ)Ø­ÐÃÆ ¼ÓÛË·œó/�b©ê:‘ `ÂïšïÕ%˜ž›‹_QÒ÷Lqé{ «{°(æ~�B� 0n“'Nä�–õÇ2ö:ÄŒº:¦ÔÔ ƒ$zzHôö¦´;ÇzOŽ·•Æ»û %]Á¡p˜·‡jŸ/9ó:Bw…úÈö³KÆ£róÙ$0ˆvtR0i¼°~ýq5•ŠD¨)-eÎĉèÑ(K½Þ11ƒYx'rHŒUø£(•ù¾ß·µñ+çIp?ðdëÔkÇ»>K¾€m5ñïÚ±£¿_†NVÐ’¾°LwBÊ’OHù_VåˆD‚ÎW_åÔéÓ9?K'Ь(6‘à€½=M¶PÐR ¤Ûª‰Ò@™Sþß|"1&áëv²Èv}€×º»¹ÕygÔ]²à㘖@ý- ôö_ÃÒVà·m‡Ø70 ÛÈ™‹Š)‚5ý­ŽŽfEÈò¡iZ²@T·Ü„èáà ìØÁW/½”¢ ˲³þX!(ÍÏG×u¡zÇuý(³Æ�'ÒØÈà‹/mmE·˜}'!ë¶<~ k—¥„[·²t¬g#“lǶôõñù-[2™ýnIÉ¿õ· MpâÇy LSRÍAUåõ LÈÍA•©_³Å\²�äŒ6tÂRk $+‘�zó˜BùÊ�à*/§½·—=­­”0yÜ8ü.Z(Dè�7Œ­Ù씯•åmÅP6W‘ È�fë:oõôpÝŽöU=Vž%ð×!¬“¡§aôpÙ?쪫9«¸¯êBQ,ÂD ¨ Š¢Ž<F£c�ѼZÆšD“mT0”(P_Oî¤Ixü~„¢ ÅbÄZZˆìÞ=¶ÜÿX“Bæ¾+€´™ü„¦ñäáÃüë¾}N�MáŸu"fþÉV0j·¦�ͱ¤ €O�«!×íÆ¥¤ SŠ¡!'-AR)¤µ�ƒ"üª‚Ûí66u¶Îá´7!cj³ž&̱¼'›©Ï x€îH„[÷íãYÛ.£–Ñ|øË‰’z`¿¤ŒÏ¶+AK8Ì;}}Ì æ¤€Ã$ØÓõdêØ1XicaÒÉV£ª(£uÑ¥$ë±Ñ„žA°ŽÉ?°½¿Ÿ/oÛæTÍkŽƒÀÿ;ÑÂ?Ù °XœŠe�‰™@z¡«“ ¢Òà÷¦^Xjä�‰,¡cò’ àŲúWNú°úöєĉýÂñ8O9—·m£'s­â;UÙ¯ŸŒŸ¡žü;ÅQà)`"0Í~pó@?»)õx(u{,‰$kfÐ5¤XÃj$7¸²ÌZáäl !Æ”r0ßcº­/pª>h¬ëéáG{öðp†E+r<%…àd ç½P$QñP‚±;y 8ìˆEYß×G[x˜q>¹.w’»O2… I$Ë_yN@²�£š~{fÏövNÿX]�ÓumQÃÁ¡!nÛ¿Ÿ»›šhÌ\Ü~‰±šç¤68¼÷ã À÷�»VÙGŽª²¬¸˜«ªÇás¹P(š€Ïpª =ªB®ÛýÞ˜ýãº)øáxœ;›šøÃáÃN�›íØé6ŒÔîIêßávmVc´§Ÿi?Õuv‡B¬êh' ªý>\ŒpVŠX TYš¤g^Þu2>Š™£\»'åÉÇùʶm¼ÝÛë”ɳŽG�¯œ °÷~²æpɰæl[ÖZG‘ÛÍ¥¥,Ì/`rNŸªÊ} Œ…'Ån·Ã¯rƒ™þ+p*ÞÈ𞈦±s`€Wº»yòÈŽŒÞ[8 |#±/…ð÷TsäaÔžK–…*yªÊäœNÍÏçìâbJ}>ò\..ר~„“Ð3�Âcìâm↣‘íè`mw7Û轌=ޱtë dûö÷z¼Àa,d8'«Ï¿¢0=äŠÊJÎ-+Knqk‚â$ºk-@\×YÓÑÁãGްm`€áDb43oŽ—0Öì=û÷¼éï'È–a´2;{¬o:£ €%%,*,¤ÀíÆ­(¸m{�Bè–mÚc‰1M#ªëôÆblèëcU{;¯�ÒÏa¼ Ü-ù‘¾¿÷ ¿)€9‚’<ú6ŽyTxãq úc1:¢Q³7bW(dï½3Ö±ø&FŸÞÐûåF¿_À:–ÿ Ì|c›Kr¥ðsT5¹�®WQpÉÐR“¦<©‰¡D‚©ñãk`©ÉxþàÇ2òyß�ÿ `ŽS0ª_–Óeù~=À êöa`Ûûù¦þORsKX€‘q\þwâ3¬#!gø_€ Rºø`œtå HKðaŒêäRw´=™�ù™–ß!ð?qB‰ÿ…Š‘‹Ñàò `F‡ ¿´ªÄfá±uÇt]úmó¯&gv£æqFzû5ŒbÌ�ÿ-³èÿÂp5@¹t!AI@å0R¿ÁH¼ôK”Þ…‘Élá=fçÞËñÿDf(Á u§ºIEND®B`‚caronc-apprise-dfec414/apprise/assets/themes/default/apprise-failure-256x256.png000066400000000000000000001217131526720371000275370ustar00rootroot00000000000000‰PNG  IHDR\r¨f pHYs  šœ IDATxÚì�w|åµ÷¿ÏÌV­z±eË’-¹ÛØWL±)Æ�›RL!ÀMîMr“po¸äÍ ! ¤‡š !à0½ƒ±MÇÆ½÷&Éj+iwçýcÊ>3;3;’ 6è|¬�äÝi;;ç<çüÎ9¿}Ò'}Ò'}Ò'}Ò'}Ò'}Ò'}Ò'}Ò'}Ò'}Ò'}Ò'}òÑÑw >rR úQ (Š�Ÿ"ãµ(2öI�@;Ðì7~ZŒ×:�]À`Oß-î3}òáJ9p"0 4* Š­߯0^S¤¿½¾w ÈH¿Í¿5 mŠŒñ÷:`)°X ¼ìëûjú @Ÿ¸„�ˆñSœL&Ê~$ËRàMàuàE`/Ðeüt÷}µ} OÜe0Pc¬êã eŸj¸ëG³´ÆàMà=Ã[Øjx}Òg>Ö2ËPò€:ÃT|Ä?ó^Ãl^1ŒÃs}�BŸ|\d.p°h5ÜbícúÓm܃Æ=™Û÷xôÉGI"@‰±Âßñ1VôžþÜaܳãöI_pTI p,pp&:Bؤ(¢$¢0"¡ª”†Ã…Bª*ªJLU‰�*ô¯?­itiÉtš6ã§%•¢¹»›ÖtšÖTŠ&ãïÃ,뀅ÀcÀÛFèÐ'}àˆ•Ó€Ï“�ñè)·C"qEapAƒb1 QXH],F"Ò•\Qˆ( ŠzÐPvBXùAä¥WÓȯeŒ¿3šF·¦Ñ‘NÓnƒÍÉ$«Z[Y×ÞÎæŽ6ttЙÉÊûšÞ–ó�§ûµ>p$É¿ßA/À)8X_LH"ŠBBUšH0"‘`Bq1ã‹‹©ˆD(PÕ#âÃw¤ÓìéêâÝýûywÿ~V¶µ±º­�Žtš®L†”¦¡¼Óµ£$] ü©ïÑë3†„€Rà«À–Ò¨*ÕÑ(c1†«úØ¢" ˆ(Á™4Íýu°¿gnëµOéÎdXÛÞβ–Vµ¶²¦½�mÉ$;:;i?x¡D;p5pЄ^¨Ô'}à�*þtàà›€ª8⸒¦”–2"‘ .g@4J´§«»¬°nÊmþ�Éän£i (î@ÞÆü-ïÐHte2lK&ÙÜÑÁʶ6^oläÝýûiJ�í~< ¼Ögú À¡�O®þ 襸´ÒÏ,/çSýû3¶¨ˆ²pØŠ×{¥ä²¢;WnY±Ýß¹½ßÊo¾æfœÇt{OÂ:3š»»y¯¥…íÜÉó{÷’p0d:p0¡·+~H¢ŠÂ¤’>Y]Í¬Š C¡ž­îNV}57Cçêí/ÃTÜ[6²òʆÇi0<¤5•â…½{ypûvÞoi¡-�&¥õ9èÞ.5<‚>é3=\ÌéíAª"Æ1½¬Œ3ªª‹Wvóoy%6•ÊÏø°ÅË{pŸ°asGÏìÙÃ+ûö±¢µ•]]]rEOÿ¬Bobê“>à+S€/ßèíŽ+.挪*¦”•1¬ €˜_<ï³»­úG³¸… n¯I’L§YÓÞÎ’¦&ݹ“÷ZZä þüXÜ÷ˆ÷7©AGôϪ{s€S+*¸¸¶–ÑEE¨ªwLï\ååÕþã$2°èæ)H˜A2“aYK ÷lÙÂÂÝ»{{Æè@áÕôõI¾\ ôïéÍ+ …˜]YÉ%uu4X7Täêœ1ô‘ìÖnÏÀËC0d}{;·mÜÈ3{ö°?•¢ÐáNà'†WÐg>¦¢ wàÝ LëéÎà ˜]YÉ9ÕÕÔÅ @dËþ„lü\zÑgózn^‚![“Iîß¶�g÷ìae[[oζøzgb¦Ï||¤øOઞî8º°�Ï À åå ˆÅ¬’Z¹¼Vqºõ‡!ž7 Žf(ŠMÓôßðEËûº[ÓŸA0kçÜ–Lòò¾}Ìß¶­·8ÁUÀ­FˆÐg>âr®áþ�íÉNUá0—64pJE UEŠ®Ûn@Q²é¹C¼Ê‹|8ƒW-€3­ç‚¸)¸‡Ò; ÄA7fºÑ'Ð4�Ötšg÷ìá†õëÙšLöô ËŒ0ð}à£)!à>àltBÌ@R 1¯¦†/ª%ª*Y…GØ €¢èF@ÝJew®‚²—á¦ØS} ‡–ÉØ¨Câ¸a.Ùƒ[7näÞmÛhìî Y'ð(0��I5¡ø˜|Æã€Ñ›uIy(Äìª*¾XSCm;`Ç—ØWyyÕ7Jws ôÊØbu¿zþ£Y%ÄÍ ˜Çñ0ï47ó×­[ydçΞ5 \ŽNNÒÜgŽ> \�Þ£Hª£Q¾;t(Ç—–Q”œØÞ2†»o3Îm{ ´â£ZüÄ�—™^Ù’�€t¼®L†·š›ùÑÊ•¬ïèèÉÑŸþÛÀú ÀQ"s�; ˜×� Á'««ù¯!õ„Å îYŠ-ªltƒ`FüNÒ ßÕ^ÓriïáR|7üàÚ~v¸ ìè-F�L§ùõÚµ<´cmÁÛ’w�ÔEýˆ}žðmàn 0ÈÇsù°a|ªºUÉ*uÖ::ã{)öwn›ÇBèJoxºµP~ 3õèLG:Ï)ÿ_~Íkÿƒi¬Ç=`Åí³Ëˆ¢prEŠ‹ÙžLÍç¡ÀR>B5%`z‰ç—ƒl‚¯ÌÜ~ý(…¥Xßpó­‡PAQ^€ðÈxÕ9sœ+þÁP·¿Ý¼ §«ÜÓU®30Žï»Z;;t5ïmˆà¬#�dw7lßÎï7lè ßá_ÐKÆ·ô€#GÆ_ÌqA6‘Hðÿ† £¡ Àˆã]Rz€PU„¦¡(>é?Ǫ/!§DéýÊŠ�8‡Ók>n½çk=‰ï%å= Cà¢è+Z[ùŸeËX×Þôho Í{}à×i謱eyã!˜UYÉ·ëë) …¬¯ÈJ-BQmÿW$šŠ‡Ò;·ÊÙçƒ ˆ#(ô­ô)ë l4 í@ˆC2×ëHkß_±‚;võïÑYŸõaž|�6:žoÃ~á_®­å+ƒ•¤œ¾TÌ#$·„ù/G!åxUAÚÏøéqìj† ޏÛYkpÄ®(Žëµ½î¬Fìéý1q–ƒ�8öU„àôª*úE£|ÐÚ$$ˆ_>à(ΨGñu_üò—ºO(*⇠cFE…´¡¤ÔBÑ•ßJý)®ù\]}ÅŠï��"Å—ÐvPÞq\͸FM>¦[¿‚è׋úáõ™zÛ'áf zb|>ߨ¢"¦––²º­�í��yýàs@úˆ3­Ï¹ø?üG]pZeß>Œêh̽¨G˜+¿‚@sÏé»Åý¦»/dÃуÒÄŒÕþ€^Bí5!ÀÀ/¬ÿ;•ÍìW0½Ã@XHÓÐTÕÚO€þÅJ`œäûÕã,ƒ‡é±×%}fÓC©ŠFùdÿþ4§Rùš‹Ì“�Š>Óññ>pèåqô”Œ’/Þ?oà@¾^_oõ8â|ËÕWŒÐ90C^õs €"§ò„ÈQ W »øòÐ:ާ©*Âè”Ó¼Òw.©1Ó@ù5Y1|<a6¹mÛK¬C83+é±·dœWÂjüz£¹9?¡‚>äuð×>ðЫç�™ù6,‡ù�Áƒ9©¢2§P'‹ò›+¿¢/)½ fW}(à·ªõx¥w)Ò|¾TMV.D¶@È:†˜J LPÏáó:÷³�Û£e·' ­9Y‡{ Jûô(c`‚‹�ñ_;wò‹5kØŒ›ðeàŽþÁ£Å(0\-_©/(àò¡Ã˜XZêR¨£H.¿é ä7Y€Oñ¬ûwý[ZsöõS~3ç.¯°| ÇÓp‚ˆRxÑ£/ïÁ§0?£i@Œ×57¯£¸�èíÊ.c½ÜOþ=¢°�É¥¥,km bê€ãÑ3SÉ#]±Ž , Àèèú‚~:r$¥‘ˆ« ×]ùsúf™¯ô�Ûê<1‚l&@9ß\Ý�E’’Y«Ý‘��~£-XóØNôpu×ü†ƒ\SO�<š‹öuuqÙòå¼ÚØ4Tý$Gx[ñÑ`^fäÛhLa!¿34ôÊ=S!«ð†ë/¿¯‡.n¿P²ûš@ªõ�bVÍæÔ(¶í­ö^@›1·vz䜽×ß½>¶‰KHÆAECæçgäÔô$4èmH _›±_sw7—-_ÎKûö9«èƒdú @/å¥|1¿fWTòÍúzTEXŠ™Uh)¯�{ŠÏ©ÈºâŠìê/ð-VÜNO!¯›/¯ŽFœ¯ÉJß …Ïçu˜ 4&w„iÕ·”_6nŠbM‰p£D"z&CÎn¤Ó�N“éê"“L¢uw[ AZ:�ß30ÞïÉuçô†¢­ZÅÃ;1ˆ½ œØgz&QàÏÀyù6<§z_¬HDUm �íÖ3Óbóž=þŠ©ôŠÔøc†°^@d …’¯-رÚË«ºõwúòz8;}Æ}ÉÆ$�r A¨ªŠHe%jEJ"�ZR‚�z+¡—N··“nm%ÝÔDzß>ÒÍͤ[[É´¶¢¹Åë= <Œ@*“á�›6qÃúõAîæß�‹Ð‡ú @ù%ðÝ|}vÀ¾X3(g•Vr˜z»âº(µb}¶UßÇH¹5(è¤ì² q$¥tÞKÙ]Wn?/Ëï/`>n@Ÿ€~aDxà@â£G®®F„ùsÝŽÙCWß2 ��tmÝJׯ�¹“‰¤­'FQ*%îÎdøË–-üzíÚ {ÿ ø}Y€üò-ô®¾¼Ê^Í ¬¢JJªst+–òå¬Ü¥V$…G® �¶Å±êç´­:Ë^±WÁi"o¹ùÈCsJj� Fùªø¼p?<Áëµ Yr«ÿLIL�JbòdB¥¥Ù> �ÊCÍëzüBóþ†Ã¨……„*+‰ L|ÌÔ²2ÒMMh��­1K‚‰%%$3ÞÝ¿?_Á èÃK�¨ÉDGš0Ïp—<Ë{ðikåwKÙ)öU\råÝ@;ÅÚ^ÚßmÕ—kýÁ?+ ×ȡ Ý÷æD¾Ê¸£�Ðø;>~<Ç›sÍétš¦¶6Z;:híè`_K �--ìki¡¹­�ή.ºÓi S�SVXHÿ²2Š HÄbPTP@�Bxx Ý;wÒ¾t)Ý»vé��&8ؓ̂Kkñ/Ö¬áÎÍ›}÷4�óÐÉiû €CNž$OmÿÙýúqAm-a7·^‘Ü}·_‘W}+Èö©Æ*®¸q8øå:uÅTú�•Ñè…Cü6š^VÆ7 •+ϪŸÏû C¢(Š-ýçiÌúzEA‘‰@zæõ�8Ä9¨ãPw ÊF!/ð'ñ±c)˜8Ѧ�ß¹ùfîá…)|‰E"”& (/çäñã¹àÔSi¨öõ˜Ú½›äš5$—/·âû¼FÀìÊd¸vݺ|žè¬Ãg¡w~ì @}†û¿�F% ¹jÔ(›²Ù €±ú›#º|Ñ~¡X¦kïc¼�³£PÊýë¡V3Ž[ÕŸ¯‹ß‹n»@à °çâ$ìì"ïš.ŒD(ýÄ'l«ÿ�?ÎÿÝ~;Ý©ÃS#sÜС\úéO3gÒ$¢á°«B§öí£õå—IíÚÕ3¾Ç ’ÖTŠÿY¶Œó× ¼L:>Lå;@À› kè«ü—¦7õHà\VkëoOº.¹²ÏêÒ3žm¶BÅP~��¹·À‘ ôê[ÏÇ\J}óy n¥²Â ¬S=¯®ªˆPHÞÌsÉCI½@C¯f&àÎ øS¢Q=ö7¤¹µ•ŸÞ}7›ví:lØŽÆF~õUîyæ*‹‹)-,´Ý?¥ €Ø¨Q(‰©Ý»!¨qr܃ˆ¢0½¬Œ7š›ÙéßN\�NZûÏ�³¸ø_¿¸¿6ãÛ ”™å½ÂQàƒÃ•—{7´? Ûovûùåÿ…¤¼Šáºk^½NO d ®$~Ê 8�‚ñã‰s ñ±c‰�Atøp¢C†*/G( ™Ž÷♞ôx ù–AŒÅˆ�m}¹k¶n厅 i N¿uФ-™ä¹wÞá±%KزgýËÊèWZjÛ&TYItÈ2íídš›ƒ¥ ©ËD(ÄI¼°w¯ßd" ˜„^*üÒÇÑ|¸Ñ¸W-ˆÁ·†28Q�«°Š‚ÐŒß.µý8ÝuÅ�ä Ý8à âRëoöÓ›…GšÔ,¯úŠ›ÒÀ<‰3Ü|ä{6d‰I“ˆ�K¨¬ %³U×)ñ8¡òr"uu„««!“!ÝÔt 袯QÑ(ñQ£¬û²sß>xñEZ:><Ïw{;KW¯fáÒ¥ìÝ¿ŸÉ#G…²¶9#2x0jQ©íÛõ*Ã|˜€ìY…¡3ÊËY°s']î!…ùÅÍVË?N`p;ÐÏœøz}=¬éfŒŽQ„sÕ·žU½G(ª®àdÛõX?[RŒ°wZµŽ�BQ\ò÷yV|ß8>�á(œ9“ø¸q¨EEùsåB &„kjP ºÝÊZ¤¿ß§5xG[÷ìa{c#M­­¤3 b±,…™ª¢VV‚tïÞ�%Á “½ŽÞE!6j”eK Y½u+KW­âH‘ÆÖVþúì³´'“3d‰X̆ DëëéÞ±ƒL[[`,Àü»¡ €nMc±¿—U”£w¦>êà?�ïùm0«¢’s @µúö³�{B¢ÐòŠõmm¼R±ÂÅí7VqÅ4ò±DÙGQ-%U$%u nc’Ú‚ýÜ|á… ä«÷‚È�!Ä'LÈ1óŸžßÞw×Οϭÿü'÷=ÿ<ϽõÛ÷î¥aà@ŠâqkûHÿþtoÝŠÖÞn)¾�q éoÙ‹Ð̬†ÇA¨¼µ¸Øúÿø†Þ\½šÍ»ws$Éâ•+Y²r% P[UeËdD† !ÓÔD:Ÿçâr&•”°©£ƒU~ÆÛ�%‡ó3î4àdtòDÏóÖÇã\Ú0”òhÄ]!…½^ß+…§È!‚œ0�V}E“†|ºÕþéE„¹­½ÈÇÉ4¤8@�®ÀšhÒ€IÞGñg’Ü×®®.¾yà <ºhHôèº:n½ì2Æ54X¯¥i~üqwàËÁò£9[–ñ`Ò4Â55�|²­Œyïþýüå‰'¸é¡‡hjm _i)CdèÀ�ÔTVRQ\LI"aÝ¯Ž®.ö67³wÿ~VmÙÂÊÍ›I6¡¢¸˜+¾ð.:ýt{¨ŸJÑúÊ+$W¬È� ¸Ô|jÉ’|³4tj±%EV£Oðq•„ªriÃPFæ*¤\²k+×’Û®$Õ–—wYõMàQ(wbÍ Ì†¶|¿äî+.=9åÁ½‰±e¥w¤ýÂ55Ÿ|²õº–ÉpÙÍ7óç… ó´ ¨¨àå›n¢\Z�ÛÞ|SÀMàJQr3n”_¦7àìó7®½xölÂýsG5¦Óiöµ´ ( eFjN¸hEŽ‚¡×´%“¼¹z5ÿZ´ˆgÞ|“õÁZtÉ·>ùI~|á…öSwwÓúúë$—`7‡ŒëÝÜÑÁg–.¥Ù?ŸÎab:œ!À Àl?£ó¹�™\Z&åóM·ß%ÝçPz¤UÛžŠòpûe~§¡ÈÊžMû!ŽÂ= ¹Ó‚§ò»ƒzÆ”¦Û-S„ è¤“P$WþÅ·ßæê»ï¦Ë;ýdK‹µwvrÚ¤IÖõ…«ªH®]›� ÓƒÙ€nÔ`fÛsº¥…HM BBÛ1B¦D,FA4jÏz0ˆB"¡ƒû÷çôI“øÏO|‚³§N¥¤°�æ¶6’��t@±Ñâ•+Ù¾o's 㺅ªêáÒž=döïÏÉP–„ÃTE£¼°w¯_ãP‘�<öQ2³�k /À}ƒÊ*Î0@â˜ËÙ–Þ|mºöØ[x�}B8F‚ ;G ¹¿¦¡ž‚³«P‘��¢ÚÜ|g_€p|~«¿ÉìëH÷å %%%a–Ø]ÝÝÜúϲhyðŒÒæ]»˜qÌ1 4Ba ÷Ý[·ë t‚„’1Hº£ƒî�;Q‹‹Qâq{WcPMø¹«Žûد¬Œ“'L`Þ)§0¶¾ž‚h” ;vôÚ¼³n»›š8iܸ¬…ˆÔÔнu«^K†°%™d…öxxå#л× @�*y¨×C ¸¨¶Žˆª¸äæ�Â^O—Þa÷U_Þ7\@dëÌ•^VBW€ÑQ,¯‚æêfsÙ½V}/ÒMC�„ÇC2„È lDÕÖÙÉUwÞI£?—½MÚ“I:»º8còdBÆ¥°�Ô®]d‚ë¸Ð”»=øÐ’I’7’Ú·¡(Ùt¥´mzÿ~ÒûöѽkÝÛ·Óµy3]›7“Ú±C'üØ¿­³S¿ï‘ˆ÷C3bÐ N›8‘3Ž?žt&ûëÖõê~wýzÖïØÁ§¦O·ƒ55tmØàN<âq�T!˜ZZÊã»v±ßÛ(ÅÐûcîâ³  à2à7žŸTQø¯úƹÄýöî>Ÿb7àO± çÈbvÅ—»%ì@dû,ÔÞ%–7 Cçµá3#Ðæ�x¬øn¬¹rŒíà 4¥øôÓ KhõªÍ›™úõ¯÷êKúçÏÎÌqã¬ÿwnØ@ë¢EÁ 8óbn`‘ „Bú�Ñ^«e2�Në¿�@šYl#RëÌBµµDjks FòÞºu|ýúëY¾iS¯>ʧgÌàOßùŽíµî;hzä‘ü÷@žI,jläâ·ßÎÇ!ð¿è)ó£Ö„>»ÏSN­¬âäÊJÐð0"‹H¡�¬-Š<Ò˹êcÐ�™+»›¡(YÊp+í§ä*´ F‚;‘ÓpxñJD!9ïç«ÓD4JbÂÛÒ¿<ñ/¾óN¯¾¨÷Ö­ã‹§ŸN(Ò)»JKéÞ¾]o•= %&÷3MË*}w·þ“Nëõ÷fs�Ó:”IK§Ñº»I77Óµiï¿Ojï^ýÚMPÓapû—•qÑœ9tvu±bóf:à$²¬0ºü¦�eywª‘¥èÞ¶-pjPÓ4jãq4ÈWpð'`ÿÑjµž(t4Ê%uƒ 9òýÙ•S±Qzã�ê+Nþ?yIQ\iÕ÷�÷%W^øbŠ}€SÁãºD�8Û!Ñúz"ƒÙŽùíod_ÜYv55‘ˆÅ˜1v¬u­á~ýè\³ÆæEÐ3g1‘âXx‹’“åp�§=<ªÌþýtmÚDrÍ´în½ü¹ ÀqÁ)&0®¾žuÛ¶±=»¯%o®^ͰšFÕÖZת¬$µgi?PÐ1Œô9„ïìßÏVÿ–èé†8ê À×�¯â3Âë[õõTEc¹Šë(¤ñ‹û­áœi7yŒ—›w�Tÿ�œæ“ã}à T„„Ä"lõø„2À'#ú�2’ÄÇŒ!TVf)Æûë×óÛûŒhfÙ† Ì™<™Ê’ݳŠFAR»vù€·Òfi‘MùåVe#âZ¥R¤ví¢kËÒ��„**ìx�4 Ài'²cß>>èAH�J§Y²r%§OœH…áiU%TVFçªUþÍCξ Ea@,Ƴ{öÐéj vK�&0ø5PãµÁéUUÌ(¯4:yíÜ{ú´^U÷ãòóxr@ºÜ9ÆI¬˜Þÿðàÿsæ÷eã#÷ ¸ 1<“4g\vŠ‚B%%z‡]4j—ëæÏgéÊ•ô¥µwvÒÞÙÉÜ©S­Ï¥––Ò½m›Î¥çúL‹œß®†@U‰ LtèPâ£G7NïT3Æþ3z4±#l53i��:¸WXá…¯¤R¤é\»¥ @7˜r¾­ €3&Mbgc#ïmØø>µtt°xåJ.:㌬-K$È$“¤‚&Ißý xœmÉ$ï{{n 0ÀH î? €..öÚ :åÂÚZ¢Šš[wo)µì2KJi.–BÉ©¶C^mUÅÛí7•Q*Â1@TqV’ï£I îA"„¢{V÷ c5pI¡ù)™‚ð€D‡ ³Ž•J§ùö�7Òz:ì¶îÙÃÄ‘#\]­ßÏPHO nÙâK\êu­J,FtØ0ŠgÏ&:x0áÊJÔÂB”h4 :~”HDoTªª"\W§…aÑZG}ðó­ IDATZ*åÚ|ãé�¤Rtm܈–L0ÀÖT …˜;e ;öíã�`ì¾ìlj¢-™dÖ„,�M¤¶–ä²eúõ ƒŒëYXÈÂÝ»iõî:lF¯¢=â @5ð7 ázB!øLõ…9q?BÊ©+ÇoN³�°åôsˆ9¥UHñ/PÔ,y‡šKî™%öP\™‡m× 8+ÿ²FÅb'Ö4{,Ôƒ¶` 衪Œ—º€G-bþsÏ‘>PÄèèì¤-™äÌ©S­Y5‘ µoŸÞ ã—ês(¤®Ž¢™3‰"‘° eOÎH»…ûõ#6r$á~ýt¥«+ëá‚ð!OMíÙC×Ö­„«ªôâ)IΜ<™ÎTŠEgéz{íZN3ÆÖ7*+£+ˆ!‘®³(¢,æé={üö˜Ü ´éàôÂW]XÄYÕÕ„]óúÎw 8Rk9@Ql~DNºOAA³yÙ‰¾Š­Þ_¢ÿ±U ·á#diÆrøpéöóQz/jm%‘ `òdÛk¿º÷^ÞëeŽÛMVnÞÌqÇ3º„ª"b1º6oöŽÉå�¦©¯§pêT��@ºÎÏÝ–LÒÒÖF[2I*•"ç ?u¦ja!‘Aƒ€�ë|Òê)| ˜´Ž:7n$TQ�ZTd{oê¨Qìkiá퀞@:“aÛÞ½Ì=þx¢Æ “Ú½;Ó�#Õ;ª°�·š›Ùì &Ðkjlwý`Ê0ôzW‰ÁWflqIv…Å1¶Û§àÇt U™…Çø-‡ŠW=¾…ø+¶ }Õ÷*9®õþŠ“�@UŒÏ¤ ŒÜ¦y}J€¾®,Ÿ0�ø˜1Ö1VmÞÌy?þ1ëüÒP½�Ú~ýX|ë­Ä M£å•WèÞ¼Ù½@Z (œ93'æÞÓÜÌ“K–ðÔÒ¥¬Ø´‰ûöÑÖÑAÆ âT…�ªR’H0¬¦†ãGŽää ˜6f ±h4;šÜ1ëOëì¤ý�wè\½:gf k?D&ŠBñœ9º7!)äþöv.øùÏyùý÷Ý'EQ¸å[ßâ³'f§umÚÄþ'žðç”Y…�¿W·µqîÒ¥^"¦ Ö©À| ÁëÍc‹K8½_ÿ �A²�*Üwäþ~%›Ï·)� îÉÍBN×_Ê`Ëõ{~Žt£âÄ„ëLlž‡ÈË àµÚã2Ïž�­!Ð4}í5î}úéƒîÆíok#­iœrÜqÖùÃÕÕ$å>~�Ï®­%6l˜í½G_{�Ï^y%÷?ÿ<+6mbOs3É®.Ò™Œe2™ ©tšÖŽ6íÚÅ¢å˹ï¹ç¸þ�xwíZT!(,(  C5g b”æD¸ºš®­[mÞ€Ÿt®^M¸›' ‡9¦¾ž'—. ÄZ¤i[öìáÓ3fd½€xœÔîÝdüR²Î©M@i8LK*ÅÛþ=cÑ+�8p*ð}¯cF„àkC†WCvä|”ÎÑÞk£ýƵÞ^ÉÁ²t2KÕ-#þ&_ ¸Ì°¥�Š/S„éqŠl�$ÏÄ 0óEÏä ±±c³uB�ììä‡ú[Q_ý60kâDªË˳¡@8¬³yq†B$&MÊÆØBðÔÒ¥|ég?ë5H©i«·lá‘W^áÑE‹Ø¾w/Eñ8«ª²iT«ˆ�E¦­ÍrÁE ®sãFݘ¬ÅBп´”Úª*~å•@×·£±‘‘µµ3dˆuŸÈdÜC&,@‚ªH„öî¥ÅÛˆ BgÑ^$€ú<¿q^|¢5cŠ‹íŠ“³Bâ÷ ·æGl©8bv9öG1â~CQs�z‰&ÜYtd[ù…ý¬HÊ)fÍ·Hx¥°¼@6E!1eŠ>5×xýýuë¸ú®»8TÒÙÝÍþövæN›f­¸jI‰¾ºy(³ˆÅH˜^ƒã_qûí¬Ùºõ \Ss[KV¬`á’%¼µj“FŒ D¢ŠB¸¶�Úªó<[±Óiºwî$2h�m8éÈÚZZÚÛY�¹héªUüçYgéá) ë!I�jCÉHTF"ìèìäo/@5ð€Œú*éY™âüU„ÃL+/G ЄÿMpÚK‹qÆ ÁÐcs�3} á™ÂÒ¿súð݃@Ûˆo‘Ñrã|-×å×üRW.Hx´¾%‘°½~ÍÝws¨åÉÅ‹yáí·³ˆ|8LlôhO:s«Ý×x}㎬ٲå _×î¦&~ùeN¼ôRîá;1‰ÄG�¢èä“mìQ^ÏM¦¥…¶×^Ë’×~ù¼yÔõë8-xϳÏf‰92XÆÃq}_4ˆr·ùY™mèG‚Ÿʼ68¹¢’U |4ÃØR(Ò—œ‹D E5÷CÃ_‰E.&ê\•5·~x¹WßA€aÿ_ia!%‰Ä!¿Æ;{ŒÏÿøÇ¼ç˜Í®ª"1}:HD$^™:Þz‹Tc£í½3&MbJ¾•Ü�Õ[·òÔ›oÚ‘ÈàÁ½ªøJ]•>íΆÎU à?�B/Ý9¦¤˜Šp4g öÿœ/\rqÑ…›…09såÖlçÙ5Ù•·)ÂZÉÍ¿æöi�]_#â:ØÃy î¿ìZ¿½f ϼñ‡S~xûí4Iqi¸ªŠhmnŸ—–JÙ>ÃàêjF|X®qÉŠ\üË_æÄí±aÈ î½KßCëË/ÛÞ*/)á ³gë\ y¤+•b¡Ã+‹�ëï}8ÎoJ"âþ÷­ÐнÕàJ¯7 T•eå¾+¸æg,åq�í]�‚t#…c[ {‹©j8=Éawý²lcŒd·ßå—VPÏ1\.BJQQóA0Þ»çÉ'JÙoOcîÝq‡í‹KLšd÷ �ڻ׺ÞP(ÄwÎ;Ïj0:Ô²vÛ6.üÙÏX㨋(˜8Q÷¢üÒ¬@º©‰G5à9'œÀÀŠŠ`FhÕ*¶H}¡ÊJD,–?pñÏêߟ�òÈó\¹’¬å9Ð,À•øðûO--cRY™{QŽªêh¼páýÇ�e×�üËû‰ÜJB$d_îÛ·³‰�ly¯{A&‹°Ùʪ¸ÕH‹ÎÏáÆ{çÅ·§($¦NÍæ©�5[¶ð«¿ý­G¬?KVnÚĉ&0¨ªÊ2Xja!Ý›7gn£ˆ%R“íPQÁ´±cygÍÚ;;Éd2¨ªJQö�8ÜÔÃ%©tš½ÍÍÌ�6ÍâÇS‹ŠHíÝK¦­ÍZŠ2É$áþýQbY Èšª*.˜3‡ ÆqÜðáÌ™<™/Ï�Ë%gŸÍ%g�Å…gžÉçgÏæÌ©S™qÌ1Œ¨­¥ cßþý=&î}ü׫VqÖ´iÄŒXZ( jq1]›6¹·ÿÏ$“„«ªP%¯å˜úzn^°€LãÑ•J1º®ŽÉ#FXßu¦£#ÛLÕƒvaEŠB!^ػׯQh:ánæp€ €y^ǘZf¬þ¶•ÙAøžÔÝø·’_3` ÿæ°N{�\h¤äòÊÍBÂ@Z#Ãr�²€ù=*VÙ¯k±×|?‡X8c†­ieOSßøíoiO&ù°dóÎ�Œ:”‘uuÖC«ÄbtmÛ–mÆI¥ÐÚÛ‰ÔÖÚùð…¡55?jGޤaÀú—•QZTDIa!ÅÅÔöëÇ1õõœ|챜9u*gM›FUi)KW®ìq³Óæ]»èèêâÔãŽË¶7Ó½{·5åÇÕç‰ÖÖZ=Ñ(‹W¬D; …8Ûl¦2š–:7l€t:˜¿.‰òp˜Z[Yé=T$ l@7~X1€sЛ\��ÊÊ]­šˆ _X©;3hK Ê]_šø“i©�ûZgëÂÓò§i¤UZ¯<“×%Çï 4¹¹ý.÷$Z_�*O®Õ4î~â vÈ Ïƒ �ÝÝüîÁÙ/††««õA£’toßNËK/ùf時T’H0~èP¾Á¼{ç�|֜йçÉ'yEJï EÑgúu' A׿Ívà«gŸèœï®_o£eW ²ÔíAS‚Šbysûõ#æÝ91tñ°‚€ÇáSˆ0ª°�ªXÌR�\%ÖÈS"©oÃèu5MBìóåeýwÇV>:!< KCâ•Û÷A€…ÑC/¤4fck+?¿çŽy}ùrzá[H=ÚŠ™ÍϘڵ‹¦G!¹|9™dÒ"ü´~Ìÿ§R:/`*eI¥{Ô¿¼œÛ¿û]®ÿÖ·,&ž ÒÞÙÉoçϧMªEWWë%Õ~õ™LÎÀÔq V8á';Ù(ƒ¨‰„�š,P]€ä̪¬¤ÁAmæ�)†N–@ŸÎózó´~ý¨‰Åsé¼�‹×ÖJ‹Y‡[ù¯ÙmçÞ‚+äþz0'Ï ´¹û8Ù‡…ë ¡ˆ,‹��¦,·W@Qœœ’a‘ÛŒ=V~€XCÑ!Clï_zà ¼×ŠC-‹–-ã³³fY9~%Gëì$½o_Ξڽ›®õëIïßOjÏR»v‘Ú¹“î­[é\·ŽäêÕ$ׯ§{Û6ÒûöYìCÂh%–åØaÃ×ÐÀ+ï¿oóBüdãÎ�L�¡(h™Lvö�“GÀÄ:;‰ j a^]¶,Ð8²é£G3ÖÈÞUÕéÉšš²ßwzŠÂ3Þ|ÅÀ»ô‚6¬7@Ü0®Ò/eH,ž››w*ƒc ÍÉËû¸Ñz¥½V@&@+­fx–7")¥³mTÓ44£x(»ò뻦s€=ÅÓÅÕäyòN‹œH5ÊöÞÓK—Ú qŽijmåÊÛo·?ãÆY+]Nú³»›®Í›é\½šäªU$W­¢sýzºwì ÝÜLfÿ~R»wÓ¹nmK–Ðòâ‹´¾ü²}6Ž9{âDî»ê* á"ÿï�$cÎ.¢ƒ#ÌýÝžÑ5 1qøðÀé@›¢9Hzš8«_?*ü˃?mèæ!7åÀi^oIP�ÊNjxCl?\röRÈâŽÓÝ4 |LC¤¹¹î.–Ys^ªëö¹ïyMÅšFÁqÇéycCZÚÛ¹ù¡‡i9moeá¢E<¹xqöúU•‚ã�÷ÇCŒ¿Â'×½m-¯¾Jû»ïf ´q¼±C†ð÷ýÈb-Êë–ïÛǽÏ=—}ZÂa¢ ._¯=äëÞ¹3›ÙRUFÖÖÃ-jQ‘ { 4I6®ªÊ§Íòkw9ÍÐÍCn¾æõFHŽ).¶Õ¾ç~ìQ´‰ÿynž…&ËМJîÓàœÒëÄ,þúüã*Ý?Ÿs˜y½¡S¤®Ž°£ü©%KzÍõ¨¥½³“ßÿã4··[Ÿ;TU•ó„ËlDaª>@@*ErÅ Úß|3 Êï�8~<ßíA­þíÿú—�•(6b„‡—˜'³O¿ÒR¢áü%ø›ví²·ú'~õ( ûtuu>…ýÚá0yºáà Þj"ütHøßEäQ@-ÚîÒ�'<º E¶Îßòœn¾f§.³’R/€k˜àäÑ3ŒŒ�ëÍ#’4¶´ð›{ï¥û\jyå½÷xüµ×¬²k¡ªÄFŽÌ‚^†À|À�Ïï;è\»–¶·ÞʲìǺpÎŽ6,е®Ø´‰7$×\-,Ôsý2A�ãûéÞ¾ÝþŒSZX˜÷\{[Zô�Cò8rc¡y]!E!Ðuï�®±ÇcÔÌÏì¸õŒðA>¨ ÀéxTþ_Z毩¾UPÂVÄccððØÇLýY»¹)½£“Ðòy¬ôyš­·qÉ^‡ÈiOö"È2Ðd¼—˜4 µ°ÐöÐýüî»:Ëï¡–T:Í÷n¾ÙöYã£G£[¬L¾†@®“ðÂÌs55‘’›k„ࢹs]çî¦&v75é×d’³ù/)f‹3z-€ª+ŸIËõûAªCèM�ª2©¤Õ{߈¡«‡ÄÄÜÞˆ) u^•J2& ¹¯ËÂAÖÑS7)ãŠ)è¿–�!rWë|­£²»¯!,üÀë<š‰|Ÿ56z4‘AƒlÛ¼ðöÛÜõøã�òÜoð�ì�i‰I“ì÷Â%ÎvåDtó$/ µ{·Í£œ0th t~_K »eã¡(ö½ ()ᦸ>&"г˜×;p¤¶O©¨ êí…Ä…ˆã½¶¯‹Pdäcsé¼òçôM† �£lXâ~wW`aqï»{�ŽX]¸sjÆ eãu§AÒÌ/ÔúÉ!�#W�=€áîKï‡ 6j”í³5·µñã;î Ù»ñ㈖ÎînnyøaöIÄ!¡òr¢õõÙﺮ°£ojÏ«i ‹1,ð¨i�­­Ù¯ÑÌäµ@ƒQþŒA»meÃAA¿žó˜¢"jb1?�ïç­÷ÆŒ4~\ex"áZS¬uu„å|»~xáB¶¡y Ù°Ñ|¹]Ko,°Ûj iŽ"ͽÆßí¦ÇãzÕœ9Ç�ŸÝuoà€Ï[/_΂—_¶‘žFl€ æ1VL¸Œ÷ Óû÷ç Þè Mó’¦ÖÖlŠÎ 2÷5E±‘žt§RY1�ÈÙ­W¡±÷6³ü³¾úÚPgüäZ9U¥:GqòûQ| ‚8Tš–›SN ÎeéÐò¹òš %{¸³»Ñ9A¿œ´¡ylU%>n!¹Ó¸ï™g¸åá‡9ÚEÓ4n¼ÿ~vIuï¡ÒR¢uu6×rhÚ\Üfy˜ªã�кºr\ãò€MBÉîn¯Ò¾b«ŽÐ¶5™¤-`;v‘¼o*°–@¡ëÙ&áHõµ7@Eï4rݶ<¦Òý—È8]*ƒsj¹Æ@Ëèìβ_¹GÓ¼�áü¿ÈsÃ(¥÷ä‡×ïKµ�Lj}ãcÆ3sφ,]±‚+þøG>*²vëV~ÿàƒ¶û9Rgîúå�‘�á€GG¥�O›=~›IÈZZj;wKGG &¤â‚&‘‘2 ¾xA@žH0È? 8ŽÍ~AîX8ÁëÍŠH„’pÄã³åºár@¶ lj” {!�¬Æfµ�È=zVéýÀ;{JN?ÆûËѼ� Ä òÿ¢ Œ“ƒJÿèOúÐûü¶Ü0>Ë%†^%%î$Èôðò/&¥œbÉöú�Ô²¤À4D.#Æ�ª¬´�{os3M­ùôʃAïmè P¾mT!˜á_ûp‚¡»ÅLuu „ .w-âéÍ—êXæÑÐüCAÎ@O„YàçuË4w0Gˆå„ TYIáĉ¶mºº»¹áþûyùÝwù(Êe7Ýdëï�b£ÙÊñ|VCgjP-/ÏVÖ ¼ÞQ¶ë%‰X,û¬hš>KÐkΡ,jšÆ¦]»ÜŽ:d”£i(ÓÚj-]±®â§ûcS–ˆÇШËW%D­>7Ä‹ X'ãÖõçgié»×ßÅ…´­˜ÛhÒßxÆü&ê¯S8y²´½.w?ñ7=ðUY´l÷>õ”í;-˜4)§ØÆü4�¹‰n `¨¢Âö}¥2™ÀµU¥¥ö‰Pé´~~g�B$‚ZTd=Y]©Ë–f�stfÚÚ¼gC¸„³Aôix"A¡wMB™¡»l>éé¨*ý= €æ ºZM ´œ0ÁË\Øæ€hYwÞ‰7hÖÈ1÷UE>˜–Ñlûç´+›…G¸\„®Ü"¡xÚ4BEE¶/oÁ+¯ð½[ná£,™L†Ûy„m‘…ZZJT"Ù|ˆ¾« áQ ©¦`Ѳe�˜„ ãqÊ…?™öv –�PDZÅ…á±½pV m˜H&CFÆ ‚ŒƒóºÒ3W 1ÌèÊ'†˜ìõÆ XŒ�¤è– ;q†æ«ÍZàA3Fq /Å-q(¦­±Ç�ŸßEà,mëÊfÏ&Tfw Þ^½šo_w��7î£&ƒ&�óæªU<òâ‹VÚMAdÈEVŽAöÇŒ*Aµ¤$çžÞ'Íåó“ �bg-“!mbŽ”°e¨Œ×ö47óÁ¦MyÏ¡( C$ʱL2I& Ÿƒïxx“HÆ4PŠÂ±þ™�ÉÃ|ÊÓÄãî�ÿ®=ôäYÓíÖQ¸áknlL1Bpbz­­hÆ$'­'x™›àÀIjãq« ×C¿ËÄxvX„„ "±ƒo> <²ûm� —xò5ÿhF½¾Ã­Ï«ê‚ÜÒ\i‡–ïHÂ+¬ÈMU M£dÚ4¢ƒjröýæu×–‰¾‡[&MšÄûï¿ÏÝwßMyy9ßüæ71b/KLn[°@Ïv˜^8LÜ«ƒÏ#V ILµ'¤ö45ñ×§ž D𠇙=q¢íµ®Í›í!€ª©©±õï/úàƒ@î?À¤áà «ª•ÍJIÞ�È7$�÷“hF"ùF‰Ï=0Úë�~Ñ(‘²©õÀ¢e¤}#²knç�p/äÕ‹ÄÌo{¿‚~å³f«Ë-ÂúìWðD@òhUU™4iwÝuK—.¥¼¼œ?üá >œßÿþ÷®ûüßÍ7Û€ºHM !‰çΖp„š¦‘˜:!Õ×g2î{öÙÀåÓU¥¥œ&qj.Ôßš„«ª9òø·?úh s 1‡¡šŸ%“¡[2¾€_ÃàØ·_4J•??Àè1'y½Ñ?³ša\WP-ŸûïSå�¿bPsÿœh½Ÿ¡ÊV ÷KT<0‹PˆòSO%â�ÑžLòõ_ÿšç¤±ÑG»ôë×�›o¾™§Ÿ~šOúÓüú׿fΜ9ü×ýû|fà-_¿žÛ,°½V0a‚û„fcõ´ Gùôûë×óÓŒI¿àt{“\—œÒ3éÁMJ3)½¶fëVžè¹�¨©¡Þ|„ “L’r›&ÔÛL€CŠB!úù³#Ÿt à¯7j½ÒVŽ™H¿~¶MºR)~rç�̈P-ÒÚÚÊI'�Dii)‹-âG?úï½÷^ÞýRé4yì1¯¾RP@LJ™Ùʹ¥BŸH}½M)›Z[¹ðê«IúM’¤¬¨ˆoæ3¶çÇ9!õïOØQÄsû£�ªÿ©*³Ž;ލ´"w z$Žç¹¾ ÀO‘�é­ðª½ë�­”™-§ž§H“¬pNþÅQ¦ IDAT-€­lÓ=ÎפوNðNÓ¤ëò±ö ˆl+«3T—P~ÒIDú÷Ë9Â_}Ίw4ºúUUU\rÉ%ÜtÓM 8�ööv®¼RŸ ?eÊN<ñDÏ}§OŸÎæÍ›mwËׯçÞ'ŸÌM :Yn¤†/¥°Ð6!t¢ +ÿ¾ÁÄ¥•²këVÒæ„e³ð,¡hÊ›‘Xµe —, TýW�sîÌ™VìÐ.�% êî÷´/ 6ó#ñÕe?p,=Åa!ˆ{6^©™÷xUà¹Í $y–s‘'dq!«0É?Âe¥ô›s‘òr›)éèìäó?øÁQ½ò' fϞ̓>ÈÎ�;ùÓŸþÄ…^È¹çž ÀüùóyöÙg)..æ«_ý*1iAˆF£Lš4‰ûî»�W_}•H$´iÓ¬÷ÿÀl”øöÕDB'çt´\[¬=á°­'¿«»»G’�6ŒÏÉs Z1gináŒ(V–'•NsÿóÏÛ®ÕON=î8ªJK­ç¤{çN}é�t�ºxœ°ÿìÀc{c*ðè&* …ˆªª{ùm CóxÁ«Gš‘çºh{5#8h¿s )„¿v*¿ôÙ ôŸ;%¶]ûÞæf¾q”ÇücÆŒáŽ;îà™gžaÊ”)üþ÷¿ç¢‹.âsŸûO>ù¤u®¼òJR©Ÿýìg™9s&Ó§OçÖ[oeéÒ¥Ì�;—«¯¾šN8�;ï¼Ó:~s[?ûË_lçŒ�á^äÄZ„`Ǿ}$Ö„C!.;ïnœEÑ%¤JºŒ£ÒïJ‡b;¥²¤„û~òK�¦i´¾öš d‹�™“^üÇ‹/òg£Š1ˆ|嬳h�‡ts3][¶d•Õ“kB;h†Á‡#ÐW—ý�<Ï>ßDHµFc»Çä~ë¹ðWvç¹ÐPq(ºËyÜŠƒ4¿� ��R"Ê�?ž~³f¡Æâ¶ÏמLò‡<È·¯½–ýG‘gUU×^{-·ÝvµFÚkˆÁT´+ÀøkkEM¥øÝï~GSS•••üßÿý_à}Ó™ ÷>ý4k¶lÉ~%ѨUêk~¿™tš®mÛl€“FŽäÏW\Aia!Š€…T•¡55/fÞ¼y´¶¶²råJÊËËùÞ÷¾Ço~ó›œøΜ9–DZmÛ6êëëéêê"‹ñô�72Æ GÓH®[GÇ;ï諾A®®®¦ÐÈ6hNBÇXoYº·m£å•W •Òq!Ó(™;Urÿ-_ÎÜï~7ðý^SÃÂ_þRç0½Â·Þ²Ðaz,™\Ê9M r*Ï´ÆãÃá8æk��\ôöÛ^{¼�Î °>hŃN(¤(~C òƒ›žÍ=n7ßõ¢ÿör£¼&ÿº†*úo%¡ÿÉ'S}ê©’òÛåwÝÅ×ùË£Jù‹‹‹)++Ë™hsË-·°nÝ:†Ê¿ÿû¿ÓÚÚʯ~õ+:::¨¯¯ç¡‡âñÇçòË/gèС´··³iÓ&>øàÖ®]K&“aÊ”)ÜvÛm466òÓŸþ€ÿþïÿ¦Æ ÒŠÅbœtÒI¼øâ‹,\¸�®®.¾÷½ïQSSC—�Ä·%“üÐAŠkhÈÿßC÷öí´¾öZ°l�¹ÏÎ�ú>Bn€ŽÚǶ›rã·¾eSþLk+2½› `g[yƒ¶¯8à©Ï^(J5p>.T`ªÊø’RŠB¡ìªkóäÞñ^ƒ°V}ónaÝæFC¨ÂR~˃Pç2=°_‡í·qfc[%¢dÄHÍ9ƒ˜Á4#c©t†·V®äŸü„Ç^}5pjèÖ9sæðõ¯�¯}íkÌ›7�3f°sçN¶ÔY­­­¨ªÊœ9s8ᄸ뮻xýõ×Y·neee(ŠÂ®]»xç�w¸ÿþû¹õÖ[¹öÚk¹é¦›øûßÿNcc#³fÍ¢¡¡�åË—óàƒ2gΆ®+_w7?ÿùÏùÕ¯~…‚_üâ\vÙe<öØc9׺nÛ6FÂ()  –—[õúfëvfÿ~º6l@I$Pâñœš ÍX3­­´-]ª+¤œ3kþ… 6z4BJ'Þÿüó¼¿~} {ûƒ .ÈçÞÿ䓤å‰ÀRSˆãå…IÉHû+BðGoz?p°+h0ÑrF­”‡Ã|aP-•Ñ(Š¡ ÙâÅRJÅÝ‘5Rh`*¯"rCS!Q»1]~Å~.E✓Ï�ªèn¿¦éçRU55TŽG¼uÖ(™çFðΚÕÜõøãGU¬?iÒ$~ó›ß0eÊ Å56làüóÏçÕW_ ®®Ž 0aÂæÏŸÏ<#�UQQA"‘ “ÉÐÖÖFSSSè‰D¸ãŽ;8ÿüóyá…8å”SøÊW¾Â­·ÞJWW©TŠÂÂB~ðƒpÏ=÷°1�×T?`Oßx#•‰JûÛo“\»6Û$]ƒZ^N¨ËhB uuѽg�ï›n´ÓÝ6þ.9çÛT ßÞwWß}wÞûû‰iÓ¸ýÿWïù—Ò~m‹Ù\w‘ÉXéháâÒk.ŸI¾FÍ/{`†æùÓi&¾ôi÷}¶!À›„…BÈPÒœ.@MótásPzG&Nóbä‘êïEF®#�渦 4^&° õïÏÀY³¨™=‹˜Ë�…ÝMM\yÛmüûÏ~vÔ(QQwÞy'K–,á”SNáá‡fذað©O}Šîîn† ÂÕW_M‘á^oÚ´‰{îînÎ>ûl«®ïÞ½lÚ´‰-[¶ÐØØèú0vuuñ�ü€qš¾`Á/^L,ã™gžaÀ€\sÍ5y•ôɽ¿wTÞÅF�F‰Çm­×Vaß>’Ë—Ó¾d m‹Ó¶x1ío¿­+žbMÓt¦^IþmÆŒ¼×8fð`®úò—mœÿéæfÝÓÐrçMô:×ßÃmT!(ö.ŽòÔg/Ãc¨ªB.TÛYžó…PrÒ{î)¼àÀ>×Ï“Æ_˹AY"=ë?cµgœAÑà:[‡™)]¸�³/»Œ??ö(;}Ú[�$™7oÍÍÍ\tÑE<÷ÜsL˜0�óÏ?Ÿµk×ÒÑÑÁ‚ øÌg>C&“aÖ¬YLŸ>ÝÚ÷Ö[oeÍš5$ .½ôR†lʨ>Àv£1g×®]üò—¿dêÔ©œsÎ9ìpk…õQÊž{Ž÷¤:�5J2âÙmeŽÆ®È~™ ío¼aÕ)äpf2½ P“°9!ü˜�<õ¹Ç $„;p ´\Z¼èÀÈ×ë#È£öãŠÇ YßÀˆ/~�ò1£Qyw*ÅŠ�ù•?äû·ÜÂŽ½{u€)ÒfÐNwuuqÝu×ñ®Ë¬�§žzЧŸ~Zwc?ñ ëõ–– À;ýôÓ9YnœñC–¢Q+d¸é¦›¬×~øa/^ܫϱiçNîY¸Ð"L@xàÀ,?Ÿô=ËŠ%|«>Ý�MrÅ }ô·ùl«*ÿï _à[çžKia!±H„H(D,aòÈ‘,¸újfžŽ�\«èÇ-;�Ïx:¿"¡N B�ð.Œò^Ð=v |Ñí�òp„ñ%%¨Ît�œŽsŽQYÜÞž²CJÊû‰ì=0RwØð#ΗA=©YD(và¯pÐ Î<�Ê Ç¢„BÆ}˦öÖlÙÂ-=Äå¿û›z[¿}˜¥¡¡�Ù³gSWWÇÖ­[Y¾|9C‡eâĉ 4ˆùóçç”ðjšÆ¨Q£8ñÄyóÍ7yTb¹Y¶l'�t£F�"‘H°`ÁRyh¶n»í6Î?ÿ|~øa®ºê*Ï’ážÊ;kÖpÚäÉÔ¬<"B¨*]Û¶Ù˜ƒ5?¢—@º­�¨Ôÿ¯ª*³Ž=–OžpÇ�Å)&ðísÏå»ç�ÇÇ@ή-[¬Þ?E~Šîã÷X°s'ÛÝ ˜à~`ePÀ›ePèñ†ç Íîv;ßÒ¶j@M¢ùãMš$+/gÐìÙÔ�v… ­¹K{g'×ÿýï\üÓŸrÇ?ÿ™�{„Š¢(Ìœ9“¥K—²jÕ*xà.\È®]»8óÌ3¹æškhiiᤓNâÌ3ÏÌýRC!Œú{ï½7ç¾}ÿûß'“Éð™Ï|†I“&嬶ªªRYYÉÅ_ÌîÝ»¹øâ‹ù׿þÅW¾òÚb%dw*ÅiÁhm­�d5ÇÝvÒò¬¬Bº7m¢ÓeØGÀœ3s&_:ã Ž9Ò>H�L´ÕlI–Vc7s­!@ sá8nžô|¨'!€�ÐW_o:ð}»wï¦ßfÚÆf�Šj.-Ħ‘Pª§N¡þŸ tØPgšn íUμôR~÷Àlur¶¹Hii)§Ÿ~:_ûÚ×w¢,‰ÅbLœ8‘‡zˆ—^z‰�òÜsÏñÄO°mÛ6ŠŠŠxüñÇœ—^z‰Õ«W³}ûvvïÞÍwÜAKK —_~9ÿöoÿÆÞ½{úg^´l|äÛsP ñùåx=t¡- AÓh}ýu:œ¼‚9U¦YI~ð-/¼`ë(ôUd¿Š?·Ï òÌÌöÈ„¢ðÈ'lÕcK—Ñ<9BÀ>NÚì©?À°¾…55ŒüÜç©úÿí½y|åµÿÿž‘dKòîØŽ³ØÙ�…�„$Ð%¡ìkÙ)…ö¸·—ÞKi)ô¾¥´¿.ÐÂm¡hËÒ…²µ—”})�!i �=qÇNlÇN¼¯Ú¥ùý1£ÑÌxf4Rœ@À'/Dz–Ñhôœóœås>gþÜÞ\ÝÅãqÛÚ¸îg?å¿î¾›–övG»þÅ_ÌO<Á믿΃>È]wÝEŽ=ãˆíø'œp=ô~ø!Ë–-ãÇ?þ1'�t§žz*gœq^x!Ø<Àk¯½Fcc#UUUÜ|óÍœqÆüùÏæwÊ®zÜqDZnÝ:V®\Éu×]§&ý"‘¿ûÝïèììäØc�åšk®a×®]üãÿ@E¶lÙÂo~ó¾ò•¯pÜqÇq÷ÝwÒÏ÷“O²_1.ò¨nïìÙÎ^’ÒÞ')³~HÏóÏii‘G…ÅbrËm<.ÿ�in¦gåJë×ë•ßÆØØ~²5`|n{£'d¢è—O™=0#/�+&V¥bu!eEDª+*'"š€�’8Qyw5 *@Ô‚�@p¥âz1yœä1d÷4l%ó�¦dÚôÔq5–ªiÿ~^X½šG^xžAã�A&·øÙÏ~ÆqǧÛMA.w]vÙe„ΊÏF<?ÿùϹñÆbΜ9477Ûù¾øÅ/òøã�ãñx¸ñÆ©ªªâ–[napp�Õ«Wsê©§âñxøáÈêÕ«;v,7ß|3óçÏ'�òú믫n½ÛíæÑG媫®¢©©‰yóæ‰D(//g``€���´¹�‘AøúðÓ¯]]cR4JÿoÈ\û†Ú¾d LjÿÚÛ’Y­]’d ²¢"9ÿ”¤öîé‘| ¯7{?Ýý(³%ج©-‘°ï"4yí×7of•µ7v92È‘·Û±’~j¯‘eˉó/™|(+¢ “±QR²ð 0á„¥L=ëLЧMv‘h”Gžޝÿü.î}ú)GÊ?nÜ8þþ÷¿³fÍ>ÿùÏS~€óÎ;�wÞy‡J ðHJ4eåÊ•ttt�——Çi§�fêö&Ÿãñx˜?>÷ß?���ræ™gòôÓOSTTÄwÜÁo¼Á_þò,XÀ­·ÞŠÇãáì³Ï¦¶¶–³Î:‹X,ƽ÷ÞK?ãÆ�ãºë®# ÒÔÔDOOÏaSþ¤‚<ûöÛ|´sg Qçñà�3Ç©±=¶•$ˆ¶´in&ÜÔD¤©)ÕÝ—&i§=®n¢q†IÀlœëˆ½7Ï$°ü–ã’DB²Âëg¨ùú‰ æ@¢d‹°:IŸ(Ì?ŽŠ£çáRpÝ)Æ!‰vìàâï}�»ÿòê 5_3)((àöÛo§¹¹™óÏO;U‰c�=–—_~™ ôb¯¨¨àÌ3ÏäÞ{ïåµ×^ãá‡æÜsÏeÆ *tö?øEEæMšM 4''‡}ûöñàƒÐÐÐÀ�~ô#ú“óï4ò³ŸýŒÓO?�ºº:JKKy饗¸ë®»¨¯¯çw¿û_þò—¹›ŸNtwóÈ‹/ ‡U…Î?~oÿðÑnÒAoJJOpjhÌ’€Ê9™„(NßÏäµa{`IdZeR\†a¢Ðíæ¨Â"Ü¢`€Í¢éH•á0„ ú4Ø~QP; U£ "SqiË~š×L^¾‚\…ô!ÉõÑÞÝÃ�}„Ÿþátöõ9®ç/^¼˜?ÿùϺþr'ÞŠ+¨««c÷îÝ—~¿Ÿû·ãž{îáæ›ofÑ¢E̘1ƒ… rÅWàõzyê©§8ÿüó)//'óöÛo뎑ìÃÏÏÏçþûïgÆ |ðÁ\zé¥LŸ>�X,Æo¼azvïÞÍË/¿LQQ ,Àãñð¯ý‹?þñ�l³b´=̲e÷nV,^L•B».¸\n7‘ÖVó!1É!šƒãH2;⸴66BÒ˜X)§]µ"Ý{ÿKK æ‰É8ð8°Ë©ÂV@\²CñHþíèoùå Q‘4'y•¶xœ•oÿ“ n¹…çÞy'óÝ&K @MM O=õ×]w]F¯»üòËY»v-¿ýío)--å²Ë.cÞ¼y,\¸�ÿS ±7Üp'Näé§ŸFE.»ì2¦OŸ®«üêW¿b„ ¼õÖ[üéOd`Pr8çõ×_¯–ÿ̤¡¡�믿ž¥K—rî¹ç²þ8¾ì|�°fq{&LÀSQa™EÇ�c§[…¶³#ÌgOH�Ž¢€ KÒ’$°nR +:í8°|AL’ˆ¥cü±‹½Èù¤¡Ó^H³ã‚@K{;=ýY-²žžžŒØp´RRRÂo~ó¾ûÝï¦}n~~>}ôO>ù$“'OæÆodÖ¬Y<óÌ3ìØ±ƒ 6pñÅóÖ[oár¹¸ñÆùÅ/~AOO³gÏæ¢‹.à‚ . ¡¡�+¯¼’W^y…sÎ9G£¿òÊ+¬ZµŠÜÜ\~üãÛžS0díÚµYþC-›êëù�Òw�ü®ý‹[³íXËÁ® ïÏϤ~on˜+�2dŽIƒÖÀrC·ó¬ @"�m¥Òöb8èÓ7Ûþ¨±dH¤¬K¹ðä“™®ýdštk³6OÓìñx¸óÎ;ùÁ~`š8LÊàà - ;ͦM›xôÑGMQt7Ýt‰D‚Å‹ãñxT%¾þúëÙ´i+W®$ rà 7pÖYg6 À¸ÿþû ƒ\zé¥,uÐìòI–_ÿßÿ±{ß>U�D¯ÿ1Ǥ_Ê'8‰ËG žaPJÇ$dø\qI¢Ï‰ÊÔˆ(?Õ$!•ÒŽÌ~[²¯HÊtÍzxgnA!ycÆà/+CÌÍUAI¯ iõj�70¡b,_<ùd\Y�–D£QÛ8þý÷ßgk’îÉFî¸ãî»ï>µãÎLn½õVÂá0Ë–-ãK_ú’ébܵk;€Êœ9sxì±ÇØ´iÕÕÕÌ™3‡ï~÷»,[¶L‡Ã7Ê›o¾É»ï¾ Àƒ>Hžý<ùO´t÷÷ó«gžÑa7r&OÖ±úØÆçɤœ•»îd×M§œÉC)¥?ÉBùm§;õ<4¯�Iëó²Ôg»*@Ì<� fÈÖKN\| ó Âƒµ~ |¥¥TŸpG]vs¯¸œ¹—_ÆÌsÎfÌŒ:·/ÔÝMïîÍõ�ø·³ÏaZ^@,³e´äŠ+® ¾¾>í±®½öZ^zé%ÆiØbµ²uëVî¹ç@fÐ1Ëî ‚@®RÝØ»w/===<ðÀ$ Ün7+W®TÉ=¬¤¯¯OEÖÖ֚δÃ(/®YÃZMrRp¹dpÐH|®d~ ™¼6‹Ã�¸ç ®™�›£>é·/ÏZêsÆ@L’›¸Â¦\ÿ’UPoâh\{_i)3Î8ƒq �Á­!lȯ¬dê¾ÀØyóÔ‹(%´oÞD,œòpDQä‡×ý{Æ1�Û†S¦LaëÖ­œþù4;(+.[¶ŒW^yEeÈ1ÊC=Dss3³fÍâ«_ýê°Ç/ºè"¦N�Ê;ï¼£zÏ=÷œÚig·ókåÿøÕÕÕ*wß‘,Ýýýüöï× õŒ�Ç„Ó!Û�U°Ë!¤SÞäS‰øÇ`H$ÍA‚zÐäõmö€´Œ=€^LDA„l í]}Á4I¨¡6r¹˜º|9¾²1ß¡äÊçE®fÇÚ€¾Æ=ºçSSÃ%+VdœIm10Çj¥´´”±cDz}ûv–.]ê(˜?>/¾ø"Ç*p´ÒÒÒ¢îÎ?þñ�)Sš]ÊË˹å–[øÃþ@cc#·Þz«ÚlsàÀ{ì1"‘'Ÿ|2§Ÿ~zÚsƒŽ Ö‘"Ï­^Í;IÌäHïY³ÀfN€n‡Oãz›Ž•·"ðtbt¬8+m�~™z-öÀRŸí @Ÿ•~‡âñhG2³Piì‚MqòI'Q8a¼îî=û÷ŽDT¦ŸÜâ"ÆÌœ©»°ûׯ'®\I!ùΗ¿LžM2ÎL:::,wÉœœ•;¿¥¥…SN9…×^{-í1kjjTè°ÑàüíocÛ¶mê8­ÿ÷gݺuÜu×]¬\¹’K/½”÷’-§ŠüéObÓ¦Mäæærà 7Ñq}¶rË…Ô5੨ güø´ñ¿­ ®¥ó2Ëh&§3,’ÍÔ*Á&4Hís6ÞŠ({ms0h Z—Í>þE,F Môy©öû ¯gZ0Pò¹2— 4Œ¼bö,&�°T0êèéå;÷ÝÇž¶6N\0_}¬ ²’žúzâá0‚ �P2 …UUO#ÇãÆ—›Ëê Æu•––rá…RXXhê!¬[·Nm¾âÕW_¥¦¦†YIÖ ÉÏÏçÔSO%‹©|| —‹‹‹9ùä“™;w.çŸ> \}õÕ<ðÀ4š�TÆb1öìÙÃUW]EuuµÚüY’ÞÁAAà¤ùóUep�KÆÃ§‚ IDAT¸¡!57ï`8÷1©UYy&UAcx„L&)ÈÁ´€ Àvëðn3&}v€eƒw_,FÂ*á—f8hÊÐ?)'?ŸI ç»Vîyâ ¶54ðÄk¯²Y“|D‘jsMûÆ�»ºÕc‹¢È9'œÈ¬I“÷���tvvš>æv»‡Åó���\rÉ%¬4Œ¸2“¢¢"î¼óNn¿ývE÷ƒ>HSSn·›W_}•eË–ñæ›ošÂwµqýsÏ=Gnn.ßøÆ7>“^ÀÃ/¼ÀFÍšsrð/X0|×wBÈ¡yŽ`Ö¼“F‰u�EÚÆ$“@²Ãdi´öÙ‡–ºl4Í>of$|‚ÈQ……)b£ ØÁð ¿ò 'RóJ@ øðn¦À,Jýñ¸eC�1â7ã‘ ãÃòÊË)�6U�Ì*!޼÷i½×ÒØÚÊ“¯¿®»¯úÄeròG©"„zzéÚ¾]÷¦—~á ŽÁAƒƒƒ¶'N´Üm¯¿þzn»í6Û�;)ßúÖ·xâ‰'Ô?ü0kÖ¬¡¬¬Œk®¹F-ÿÙÉ–-[8í´Óزe ŸUùÇûïë‚n7Þ™3-wUÉJÁ,Z{Õ¿Óôô[ƒ“­…4aFVb8ÿÁXŒkPLÑe25€©™ŒEåD ‰Úqÿv"Yÿ/�>�œü|ÝŽú=%;®û$ñ8ϼñí¦^—7—ñj†]. vnÛF,Ôevýõ¯­\ °²Â¥¥¥”””XzcÆŒq¤¼çŸ>Ï>û,Ó§O'‘Hðý®ºŠcÌn£2|3 xè¹çÔ$Á<å帕Æ![÷ݨàv“z2Äæ'›€2æÌâñ½Á Ý†œPt9c°‹Úa(‘0Ř)“Þ0‰«ss?_ßNûì?WÑhp©oiáÅÕ«uî|é̼¥%j­0ÔÕEO}=šÃÌ™2…kÏ;ÏÑ÷ÐÖÖf û;v¬)À•W^Iww77ß|³c2;Ï?ÿùO¦M›Æš5kxZñz’”\£âÀ øà^[·.¥|¹“&¥cN€ 9€Ì6dó)Õ‚©‡M8‘éã{;tnDÑåŒ @ 6Ä �‘ˆý Io Swk¹ü$ÊjfèÀ>ýCC¼²f q‹ûгÏÒ­q³=þ<ÊæÌQ9þ% íý÷IDônѵç�Çx±¤™466Z_”––RªPTçåå±lÙ2Ö¯_Ïã�?n餓 &°uëV/^̃>HWWK–,áŠ+®Õn‡òý‡Ö)³»¬L^Z‚Žtn·•Ú•Í`¾I°Y.È.!éÔ0<¯%²îЕu¸%`ÙÛ *‰7¦^ °Âêô&.\¤û{ãÎ:¶¤é«†ÃÜó—¿èÞ°|îÑä¥Jw‰h”ÖµktçSZTÄ]rIÚÅ´k×.KŠëÜÜ\ª««9óÌ3yì±Çxç�wt ºÙŠ×ëåõ×_§¦¦†{ï½—ÚÚZÊ�¤£b)­��¬Óä~\Ã:­(À´mè¦Ø};…µÂXpHN•=ƒ¼AC `gl{ÜÓK”ËþpÈ „$}š|x ¹éÇWZ‚·$ÕÈ�Åxoó&¢h§^\½štÉ>˜´\�üëÚQË�¡ÇÅâc9¦ffÚÀŽúê?øO=õ—80&O<ñ„£ 9>Ÿ�»îº‹÷Þ{�3Ï<“ûî»oT³3�í{öX&ó¤ƒ%Áy¯¿”­‚;ÉŽŒÇÙo_|í` @­¥Y ‡Óðh}7 �ò™3u�ÃaÞÙ°ÁñõøÿyD1òùËË)?z®Þ=úçÛ:r…ÒÂB¾zöY¶Ý‚mmmôõõY>>qâDS �Q¶lÙÂ1ÇÕW^É 'œ�¶‰è…^`þüù¼õÖ[ì1áª{1�Ú,¤(ä팀š°³ñœÄÿ‚¶ÆŸ!ÈÑ@PƒtD"V,@iu؉xÕê�P"Á@ºI0fAšR`©¡ÖÞÖÙ™Ñ,¾=mmüáÅuyˆ± âÖLÄ õöÒ¹e³n*éYKO`ѬÙö±O–ì@±XŒÚÚZ®¼òJæÍ›§¢8餓†ÁzÃá0k×®åÄOä¼óÎûTaö·Ôh&ühGpHË-Ø$3ý$qVÓ€m\yGTàFÓe¯‡¯Œh²{°ahH?ªÉ$Ž’ÁÒz‹ŠuþÁ›ë?Èè OHÏ¿û.{5|nŸ�±Ú˜ÊŽžKó[«T÷¢ý£�(©™Aކ¥çê3Ïä…Õ«©7�Ñ:i®‘$‰Ÿÿüçüô§?e``Àq¢©³³“·ÞzëcÝ-§M˜@ue%ãÊʨª¨`ì˜1ååQà÷S üöåæâ÷zñæä¤”+Ý®¤ü�H$E"²Â ŠD èîï§³·—Žžš;:hlmeO[û³Å~Æç>Ç5gŸ�:…D‚HS“º£JVЍ�ýÍv`+%¶¸É9€–Ã@íÊŒér&¼QIb“ݼYwÚ¬¾gö@ §;¡47ÇZ÷•߆?ñx½©QÞ‚D,ž Ë&ñf'¡H„=ò�(,¸ Q2c][·8p@ž%�ÓúÞ{LÖ Í,ÈËãú‹.âÛ÷Þ›±ظqã³Ïž<™…3gr¼y5u*eÅÅø•Þãv“c•îXlšeDQÄïõâ÷z)5P£%Ÿ™P<…p4J$¥«¯�ƶ6¶ìÞͺíÛyÇÒ r9ï„øùþ'ɼ�$ëì$š®ê¢iíMWú6…ØÂ+P= ³Ê@º¦¤t �i0c—=ÁËÊ‘0–~yL’h Ì €Ñ²é¦«JàAö9p�Óeÿµu+o­_ÏŠÅǪoU}Ê jŸxII ìÙË@SÕ©îÀ‹³dî\þe ÷¨¯¯'�ëºöŒ®ü'MùEQÄãrQ]YÉ’£ŽâüÏžcgϦ؆›0k¥Ï¶ª›ã*I¸\.|.>9YQR¬I“8sÉ’”¡Ýµ‹—×®åë×SßÒB$Åãv³xæL®=÷\Î9þxý{Äã„wï†XÌ÷¯Á¨˜*­ULnQ)4S¬3E:†*ž·7 Ó>þo ÞFî ÔI\’hYP\,ì”ÌZ�õ�?‚šqtË"?ø‘S?yì1–Ì=š<ŸŒ,Ì),¤báB:6lP�@ëê÷¨¹|"‚èB’ ßçãkçœÃGµµD4µÿ$=ØD‹&¢Ù³g"”ÞïõRUQÁ¬I“8nÎÎX²Dn|2�mæfjŸ�˼ ±‰x”™R,&'¸’.u,†¤$Øí Ê$_ž2#29À·AÁåBÌÉ‘o;É´K fÌ`ÁŒÜzõÕD¢Qú)ÊÏ×ÅûZ×?¼k‘t%Tå:8)ý9 ëtÇÊb——²0¨oÛ‡LocÁœ©kÌ @G$L0Ç—–Ž)‚8|+PþNŒpâ@O?ÿß¼ì2ÕÚTÌŸOCQ¥  cÃÆ.Z¬óN^´ˆ×5xr€;vX€êêjòòòTª®Ã-GM™Â™K–°pÖ,æMŸ®NÍ1ÛåÌQbp�øÐ‰äO(D"�HDVôX )•=‘P ¨-—¾ÑÀˆ"‚Ç##ò\.DåoÁãAôzý~\ÅňyyˆÆK“óÏñx(Ó2k?O8LpãF»w§Oüi0ÿò0›¨©X–ÿ¬â|ÉY㜩†Û'�×`1 S6Z=؉Ð3A‰ÁtnžzòÃ=ãÇökú²÷N%^x÷]Î>á¦O¬’GJçæ0vÑ"𕤛 ôxê4r5Øý;¿ñ�a`ëÖ­œzê©–îvUUµµµ‡Méó}>.8é$®>ã ¦M˜@IA�eˆ¢[¬‰ÑHôõÙ¿)�•<ùc±^²rÿ­îO$H$Qj u'NzŠQð”•áVºùcnÂ"¶Ž46ܶMàij¨Ìvl …u²ÎÌXÈ–é×Éc†çì …Øi¿ùlÄfÆg&d4QPm|`(§=f‚Ï—¢ö†Ç}Æ’_<Õ ËóúÈõx§¥‘ÖÎNž]õO¾ý¥+ð¸Ü @á¤Iä�O µI’ˆ ÐSWGåqǪ^I‘ßϯ»Žüþ÷ê±Òa-ZtÈ @ŽÇÃäÊJþã‚ ¸xÅ Š­˜’ 1‘@J$ˆ÷ôëê"ÒÜ,×ćyÁ”ÄI<pÆ‚“O=çdØ‹‰wuA]à*,”G€UVÊØ~íŠÅˆ47ª¯'Ñ×7,i—ÖSÉÆ%·«ß'+VG¦­Áv9à{ Zi€™€&+r;â¼¢"\‚qlxòoIŽ„TÌ ‡t]A;f M0óéä/½ÈOþ<5UUHˆ99”}´|lEQ:7o¦¤Fi#VäÒ+xú�7Ø®ððÙm´µµR¸î¸1c8¦¦†ëÎ=—‹¥âV£‡¥¸¿ñþ~â}}ÄÚÛ‰vvÊñ»â†§KÈ™&k­ò�F’×În‡•,Ž›¼ïï'ÞßOhûöô„šãIvŠª!ë4=7[Ækk£ dÓËÌBþaA[gÐ×ô‰c‡ï׫¸¦gÛ ¨LÁN%ÐÝ£o’ärÕH‰v'‰ÂÉ“)¨ªJ-ŽD‚}ï¾;l·ý¯‹/&GI05™à"‘wÜq555:rÏ‘’ ee|÷Ê+yüöÛyòŽ;då×.´¤rÅbD`ðƒ\³†Á5knÞ,—¿´�LIä™ñÇì~ã¢4{ž�2KZeˆÇm•ÂRùÓ�ù6þíÔ…7x$‚U†ßÊó±xöߪûÏŽþ;‹*Áž¡!v[—G%EW{GÒ¬¶J* Äbò`AÐÇö’öKÓ·GCAâwÉÜ£GL‘6ÖÕñÌ›oêLV•aN@ðÀ~zjwêNuùÂ…,™+7ƒAzzzyÂÎ /¼ÀôéÓùáÈàààÁu˜™Èõ_ü"ÿ¸÷^nþÒ—X4s¦éñãCC¶l¡ÿÕW\³†HS±îî”+�­{if$ÆÃ:Å7‹õÓ)²„–îÍŒ0Zm9/]!‹¦ÓÐBCýe[ûÏ ¶wrNïtw›2ri’ö«�.wËå9`0ÍÖmìïcºRo–L0†—ûZ[©˜Y£>c®Íëlä—O=ÅKާ(_Ž›]99Œ[º”ýkת ¤ýÿZKÑÔ©ˆ9Õ ø� .`}m-¡Pˆ-[¶ÐÑÑÁ}÷ÝÇ;YŒw"Ç̘Áßù3««Sñ¹Æ�–¢Qâ]]„êë‰%C¤d¼)I ŠJÏEä‘h5æ´3ðìšity»]߬ª`ö·!¹(9p³Mûô&èŒy!�Ñ:˜äÊI$x¿·×ŽhPÑUGâÊà»�!C‚Mç\õD£WR‚[Ô0k+q¾Ž-X¨¨©Q.¦ŒE_·mÊ®{°ŽF G",›¿@)DøÊÊhj" Ê —3áùUÕ˜ºª¢‚]ÍÍlkhà½÷Þã±Ç³š­Ì¬®æ–+¯ä—ßü&eEEŒ„¼@â½½D›› |ôáúz6¨/§3—�ƒ1í EºÖTµ{΂üÂ6™è€hÓ2�èÔCÑ>/ÉökÕŒ“óoùÌ(Äí*&Z¯' öß=��57ÛÍ|øã¡0ÝÀ•¦ÞPìv3ÁëUƒ0ÜHIå— @¨¯ŸêãŽK=GéàƒÛGLÉZ::XzôÑ”Ëç$Џ}^ú÷¨ç `âDÜ~ŸªL'ÌŸÏ㯾Jkr�l¤¢¤„.¹”\ó5NVÈ?“J/R @`Ó&BÛ·miARÆ~'w »öÖÔµ–Ff`¦!ÞM*½ Iªe”ÈJ‚‰„ìÆËc§l6»­¤¹>Ž]Í5 À*ÁR8UþLC4£vwuñ¬}¢ü›@ý¡2õÀ÷­r¡xœ…Š¢¡pÿ' úSÆ@PI"¿¬Œ¼1cQÉÍñðî† ÂáY´Áp˜X<ÎI àrɧ�[\LðÀ~¢ò¬ä"ò'NT½”\�‡ê±•¼¼víˆ*Ñ¥+Vpß·¾ÅYÇ/¡(?_· A XWÇà{ïÉå°h4…¬3ÙÅmNÒ¨J†1,&ÐlS#¢ÙÙí‚w¸¸m©·Ò ¾´‹“ ÇMØðë›ö›æ¼%‡\‚fð‡� ]˜S£ea”Âñ8�57Sg]ÿ�Wg²]Y¬á±À±¦™ýxœéþ<òGr§I}:�@ˆ 0nî\õ%……ìØ»‡†}ûFLévìÙéÇG…üD·?�þÆFPZZÃ]�N™ŠÛïS•ebÅX6Ôí¤Ei+>™4v,úþ÷ù�óϧ¤°P?2 ˆõôзj‘¦&Yé4×Èj!92# N­M*»òù‡eÇ�»¥ƒÝÌ ›�¥ò'½ »0 “]ßà˜ºþév‹Ç»J‚#éôÜ Ï;‰ð“úz»øÿ!àåCmö_·ª?x].¦ùóTÂPAã€6�¢� EãÇá+*DQ¤¤ €Uë×;ât*›w×sùNUs9E…„»» ÷ô¨áIðÀJæÌQÏÕãvS^\ÌËk×fÝ¿^=v,×_t�|ï{L¬¨HyC :2ÖÓC`Û6ׯG G† ŽÑãAp»e\½Iö7›„Ÿ.g Ýõ3H é¸ò4¥1ÉŽwßÉ®o6€Ó¬cæi@M–eI«9€C9ì8vm¿Nc“õö­­¬²‡ÿ^GБ0CÀy@…éyK5ùùäº\²�ô‰@ÙL¤…2,NÙŒꂚP^Nó�ìt0Ë©tööâËõ²P™#ùUUtoÛ†�#ñP—Ûƒ¿r¬ª&Ë+عw¯)g€�ø½^®=÷\~tÍ5œ¹äx Z6ЉH„¡M›Ú¼™h{;‚(*±uÊHæVUá›3ïŒäTW“S]�èõïëK•ÄL”êPUïZÚóR’oí”i€GNg$³A�ï!Y a®2J·“Lìo‘¤Œ'ÜZ[K¯õ¦¸ø³Ìhó%ÙìþéÜ@Ñuu´XÓwwT¸²Ô¥dL€)r§'åè‚"ü.Q�¹Õ² Y2tuQBAˆâW‰'?OÍ^,_x �¿ú*A“êDyq1_>ýtþðÿþ³&MJ¥<•ÏëïgpÛ6zÞ}—ÄP UQâ{ï¤É-]ŠwêT9Ö×ÄéÉÒ`{Oy>ŸúžîÒRÂ--HÉJ&äføþL<�4M,Z–'Ç»šÓ,&®Ri•ë*YÔæm1]âNIÌ’n÷·59õ^LÎ{c_¿on¶Kþ­î¢‡ËD�rÅ ;F\’ð¹\LV:×DÁ`„¤Ë+¤ÀA@_K3ãçÏ—Çk+†|Δ©´vv°{„Æ_‡"‚¡_8öXDAT½€Pw‘^>�#Å¢N™¢0�i&ð¼Æ#�+O;�^s ¯X�[iÍU €” Ó&ú?úˆpkë0`”wÒ$Š/Æ_SƒèÍÕUJ’·7îÚÅoŸ{Ž{žzŠùÓ§S9fŒü˜Ë…˜—'sßã3ÄœM†<v)ÓP"“bC²/é�8žé‡ Ã�Sè­‰¡Q~|RºŠi`Áiäá¦&>²¦Ë‹*Ùÿ·³9¶ë ti p`: ó@$̱EE¸AU4]%="PâÑÁžÊjjTCšãñpÜœ9Ô·´Ð”%W¿Qvµ´pìœ9TUŒU �Hnq1}uuê—ííÅ?n<ž‚5\˜P^N]s3»÷íãssŽâ¡ïÞÂÅË—3.©”Å 67ÓñÆ„÷íC Gt—ßGɲeäÏž�« @QZ}vþ­ ¸õ·¿å—ý+«6l ­«‹m��\­á4tëéQûàMcY-d8’KíSÒ”#eõӕײU�dÂÏàú;Nú¥{oÍΟE˜®êâØÐÈÞ@€÷ì¡Ç:ù7|)ÓäßH€r{°)& &Ix].&ùü*ï_jM&wAi˜1öôàÉÍ¥pÜ8õÂyss9óøã©oi±œœ©¬Ù²…¯�s®ª+n¿ŸX @¨£S-ÓZ[)=úhÕ÷¸\Lª¬äø¹Góÿ®¾šÊ²1¸•jGòKLƒt®^Mÿ¦Mj’.ùÙE�‡¼šŒ9åÜEE*5VòºÄ >¬«ã«?ý)÷þõ¯ìÞ·OrtöõQYZÊüiÓÔyÊÊ9™a -/šPxI†ûu?#Upê 8I>jw~ ¹GÆÊo¸>¦ Ò‡¤‹û�òý›”_éè`¥ýÆ÷(È?…Ø|ÛêÁîH”……x\.}.À4)˜ú=ÐÚŠ¿t þÒRUN_ò9"Ñ(;öì1•� …BÄq–Ì�«®ñü‰éÙ±CUÜD4Š �üxu¡�--eÖ¤Iˆº�@lpˆ�Út¼µŠX_Ÿõ‚Ë�¯ºšÒNÀ?mš.�Ü�k÷îåž§žâ¦ûï§Í¢ÖO$híèàìã�'/É äñ H±öö‡þfœ­6CòÙ‘kÚ(¨cŽ<-"/“l¿¶ìfñ¸ áê·íø³Kd:õj,XZ_O»ýè¯ �þ�Ëô#7Í3u$ ¿ËEµÏ§WÁ ÒÝ/Sx÷55‘_Qޝ¸Xï$ð¹¹s™QUÅž¶6:³¤OÊŽ={8eñbÆÉç" ä––Ð_¿;Õ'ÐÕEÁÔ©¸´YyÕ­“oõmÝJ÷úõöîMáå“Þ˸q”w…s�Âåó¡�…twqÿ³Ïrû£�òΦMiÏù@O•¥¥;k–z]ùùDZ[åž;ÒσQt3”^š¤™mB̈“ŽÉרO ˜õë§#öLó~B’ô4iä Ç×U ¦ìgÁ5 Iÿìêâ�ö¹¯¿8˜¯Ö5ûÀFäÓ,q8‘`v~>9j‚LPûD5•¬¨‹3�ˆÅèÜUGn^>cÇ© >eÜ8¾pìq”°i×®¬½�H,F ÀiɆ$Üyy�Ë‚‚lŒBà7·×«ô[÷Ñöúëöì% j0®eË–QtÌrŠŠt8€ä1ž|ã n¼ï>^Y·.-ÿ½V6ïÞÍ_ø‚Z<¤D‚˜•«hUpÂäd$V&%6哜–Ö4à"Á.§àЃ1û¥mÉ6SЃ*:|î7·m£Ûž"ï‹JEîc5A``Êæ1‹QáÍ¥2׫ßíµýÚP@‹”$zÝn ÆU"h’‰¾Ü\ÔÔpáçO&‹³³©)+fá}íí,˜1ƒªŠ±j·  îÝ›JNômßN¬¿�pg'Áýûé^¿ž¾Í[�"aí(J²2æMIÕGU IDAT™Låé§“3¦t˜»/I²çñµ;ïäÑ—^¢×~°ƒ©ÂaƒAÎ8î8õ>Ϙ1„÷îE2sÍxéŒJŸm+SÞÀl”ß`œ$c²/“öÞ4Þ‡‘5Ø��ÎÅwRÁ°H,>ÛÖÆ_í»þžÇñç¡61%y©Ùñ$dÆ y…xA“ 0Ϩ�'ïG ¯©‰`w7¾ÒRrü~µ¿dÈí‰óçsÅ©§RYZªÎ«‹'Ä’sÚl$�ãËÍåø£çârɳÃ�] ìÝ; ¯íé%¸ÿáˆzP“ â?޲%K(œ;WæÄ×e€ÛZyè¹ç¸áÞ_tƒÑÆ]»8uñbÆ—•¥¾Ì¢""IT£v÷϶58›l¾q§×–õBñÕ¿fÓ |¿ã 9G5ù—IBÓpml•ß&1Ø�òÝÚZú¬3ÿäi]ÍH;RY#rÒÉVO¸¸rÇ!‰""¢h0I£ ¦Ðƒ¢¶•ðQ1{6ã.ÄíÍEßd”ú@m]]Ô·´°¿«‹¶®.ú ‰'ˆ¢€7'‡¿ŸÊ1c˜PVÎäñ㨩ªÂ%º‰Þ;8°ú=ɨ)W"è:QÁì—Ž¡dÁòª&"(LÄÚ]¿§¿Ÿ'ßx“'ßø»G¨Š0Ú4ÞøßÿÅ£¡Ïzÿ}ÂvCVµ8…MH· “4Ý�¦óÌ�†R¦^EÒ%·hÂ158F$£&)i–í×Ò“Ù&þ¬Ê�‹ºçXÿ·{öpoc#6�í?�³ïû Ä=Bk1<�.àÕÎŽ.,Ä%IjÀü L%ÉŒ8ÔßGËûïÓ¹};ã/¦rÁ½«¡wkc#¿ñE®¿àõœ|óæii1¬2õf$›„L�†Fíú&ç£í"´T~§°a›�9顨á…Åwå´:‘ ä %äÅöv;å+º6"M2®\�›�/e¦>‹bu§åå§È*½«ŸÊž æ�%“À‘ý{÷Ò¾y3¯_i‰Uˆ¶ Y—(ÊC0Ýn*Š‹ùÜìÙêuý~¢û÷#…BÎ!ÁÆjAYmÇ»¼M‚O›å×6óN?3�ùMŽa…ö“Ò]‡L”ß í…¸mçNöU­K€=#µ~rd%r§ ©,(,äâñ”þAŽ¥•dšˆ†íg·ˆý%IâÖÚÚt|G.ý�˜¸Ázüp¾•qÙ18HÃÐÓóó-­¬líÓg®S“‡R ˆ‚…Ž”ári”ؤ#QU#$aA¨!)ÿéòI^xù®}í‡]ùz¹ýÑGùÓm·©÷åL›F°®Ž¸�a9^3Å–,2î’SOÃFùÕŒ¼Ct]6¥¾d× mÿ€öý²h"ÊDù?êëK§ü’¢[#*®C°‡�y–š=—$úcQæàN–³² ¨Ô×ÍBt9aXËñðîC’݇ oGS"J&œÆþ͹æUOÒµ O›8�×Ö­£ïc˜\×ÜÌçfÏfJeeʺ——6é0Û¥�üN]ý¬óɸZÛ´d$ Í&Ñç04Ðòú Njýv¬FNŒª�! Äãü×Ö­tÙƒ~îQ<€Ä'Ý$�‰ Ί̞Ð�RävS•(jPJt .è ¸Ã ‚â,h»ß]€#h’sfó ’ÉBI;¾\@“¬Ô)eH¢}}άQ��çõâq»Yµa‡¬ß¹“«N; �2ÚLôzID£Ä sä$Lûˆ%+­2õZ¶ äî iû÷3‰õÓuþ陬‘Ë’ß_û>77ój{»f¥¹ç¦}¤×Œë­ÅN` p¢U(а¨ Ÿ²X‡¡µ`AP›o°É :c‘òtÿ«}o@ÒAå¿×&S ćx É-MµO(/çÃ�;iµÞxÈB_NK•Ñfî’"MMH9u9#Å7ƒ+n¾v–^²Ô&dÂ:äPùMŸ§ìö:$aºŸtI?§×Ä¢ ±sp�»vïfÀ:ñ—~¯½ÿþaOJ’ľŽ¾°h‘ZD‰÷÷ïí=Tošv.�ã^Cx`å!hÉ;U»o³Ã:Úõµïí´iÃw(Iÿ½mû-ù;“:ñ`ÍáZ#.¯ì@ž+x‚Õ:¢òD‘j¿_†æê”ÑD¹ÍØ…L<c uÌOªÖoˆƒ —¡ &ÀPšp#IrBPyÏi&PÛÔÄÎæf·ôL;–Å55ª!”"¢#4hÅRùM²òR¦rÒ½N›pÔÄÞ‚Ó^ü,§ôf"üzÏžtXy¸ç}‡s}ˆ~¹™NÜR^ho§v`@u9ÍðV’1+›¿®þ39ΰ/=Yæ‘, ¼„”�@RâM-·›$1ÔÐ@Xí”$‰Û®¾š|Íh¯Ã𠍫#¢¡—sr²Sn3å1Ž´R®ƒ¤©•KNÊf6�2v»­º(€›t<~N¹üÔÏ‘Éu±8ÿç÷ïç¡ô“®7)ºÁ§Ý„�ç˜wÛùBO¶î£)Ðßi1’IûŦû¤„fQjmJ ÅhH ›…¢t–¡@[%H èß±Cw¬ cÆpÓe—}, ¾¥…¨â™¨®Œ•›ýh•Sû·æ~I!|Ñ*»#Va©dc4ÃkT×?]^"ù]§£òÒÒ‚eZë·Pþ-ýýÜÝÐ�.›×­èDè³`@.oÜ� ¼1�óXs Ýá0’$¡Fy’Õn )¿,öxÉ"L÷å&,æD!‘ê+—€¡�; %Yy¹tùrÏœyø-n4:ÜÀÛᢶ›’a‘Ñ‹0KÿبüÚ�ßI÷bq|ÆÍ=yŽÞH„›wìH7ØCRtáƒ�C?.ðkà»'ôÆ¢<ÓÖJ(WCÓ=>‹ywIW˜›ov[J…"fÞ@ò(B<ñ8 ‘�$:ßyWwÀâ¼|®;ç|Ù¸à!•¥¥ˆ¢him�n±dòÛjgÎHY’øz“÷²Kð�›“u:P|Ɇ|ÓjçwlÈ,pþÉktÓŽéÀ>(:ðë�K ?Nr“ƒ-TnçÐ/ìß/j*.9f¤sò�šØÛäiÊ=ª1‘)1lν.¬yþÔ©ä(]—�…LÝu³]=ëö`+O„ÅG²×­!(•TO+áÈ•wšÊjç·yI’ÇãüOm-ïõ¤�Ù±MÑ>«à4äŽ'KYÓÓÃsûÛ†)1V‹FÒ»“æ6C$ÍŸ6� ‰( F~‚$1°e 1¥ä&y>×�}öaKúss9eáB•#€D‚Xו˜Í‚Æ|�æ1)MRPÐ$óT†alw|§ñ~¦;¿UÎÄ ÑD‚»x.=î£EYû«¸>`™Lôläù¦²7"&%˜¥° kÝÆa€ ɤ›ôÝ�˜”“X†ãо^Ó ‚‡0+A‚�!ÅcøªªÔî¼ê±ciéè`sCÃ!¿¸ç.]Ê¿Ÿsne4›‰0¸n$«vÓ€ÌFŠˆ=†ÅõV!†�Ú콆B§ôiŽ/˜$;eÖ$B³Îa˜|Æ'öíãþôÿnà*ÒTÃ>+d€PP±ˆ–^Éž@€|—‹I~ÿpp�³° 3Úæ! Ða=%¹0Œs 5Ñ•OPÏ/0ÜÄúñVŽÃS�A.˜>�gßy‡¡Ð¡KüN®¬äwßþ6¥ s1@hÇ¢Z…PÔaôuîzò¶”ÂòëÙ}†ó&ÍÍ0À�KpLØ R,#ÖÛKnEžâbõ}¦ŒÇGuu4µ�ÕÛÄòr¾}É%Üýõ¯ë« ’ÄàêÕĺ»uŠ £÷ÖÜ7ÒÜ€‚¡V¯ëÓO&N¾§”)šO‹9`q.+÷ï玺:¢é�ýGd¨/£ÀZ^UŒÀiÊöI4( Á—‘Ç Ù�탃ä "““F@ÃÝgôíuaº`Å1È0²�Ô1 Ü‚µ÷ S~IRO ÖÛ‹¯ªJ%ñ¸\Ì�<…gß}—�=bl˜TWTpß7¾Á·/ºˆ‰å帴»~"Atÿ~úß|“¨“‰Äf$!Ú–`mfÞH虜*¬ðèr#N¹Ó¹úÙˆ¡Kбâ§qù’Ä3­­ÜäÌ£jR’ÛíŸDEû¤€AàoÀ@¹åw l"�1· @)+³ CFÁÈôcS&45¤²ÿÃ…°-!!A"ŽãŸ�’Ä‚ë×3ôÑGÄÛÛS.¹ '9չȆZÒ…¬BÍϰjB–»µ”Ž„4ÃX?«ÉEiÐ}òÇMðú:þÒêHŸ?.T<€O¬23='ÝçðÕ‰U(Ó�ejoAÔ+´¨Qh19ŒT”³(ˆÆ sŠqQ¡(s‰¢zL'á�§°ˆq—\¬›b 2‹Ïî}ûX½u+õûöÑ;8ÈØ’æM�Ê⚦VVâ5é%mÛF`ófy"°v7L*¨(ñ:ñLKifœÿ�'”Ìcö g ;²õ¤ÁH’ÄP,Æ÷jkyÝÙÀ—íÀ¹B:ïÏš@I¾g—LÊt¿Ÿ/�Ÿ@µß�$ʬ=¢{PÒEW²ó"&¸«ÛÊëEez‘Zi°kžzmîØ *Î<—׫w­Q3À2…c†Å¸±ž†þõ/bK9¦5‡ˆ¿PJgH² “‰lOÆÖåמóÎ�þgçNv :9r32áMó‘ T®#ÈôO'ãížØ�²u`€b�›ñ¹^O=X “ -™Ñ�§7É�`Xœm“H†66âÎ/À�Ÿ—":\ŠA“†É ü±ÎN‚›61ôÞ{$>†a¤YíòÙ(»Åó%'Ó}²Lô%Ï_’$Vuuñ?;wÒàl¨Ê‡À)À¾#E©Ž$råïÈ8Ûp �H°¥¿ ‰šü|}]ó ‘ |8®ÀŒPTÿ·6? ©¡†õc)&ØØHdÿ<¥¥ªâ›I´½�ÀGذ�¨Â7 ód3þ;[4¡Dr¬ºnGÎ}—n×ÏæxZ²N'mÄ’ÄcÍÍÜ^WGŸ3ŒÆß€+�ýG’B ™’üø’“'Ÿ\ZÊ¥ãÆ“çv«TÞ""2ŒßÚMQNŒ‰²:‹fCFlÆ’‹š©×š�.Sþv‰¸s½ä�«$·¸!7Wý ñ¾>bÄäF¥7’L•nÕ_?RÆÁt„¸UÿÀÁ´X‹œŽ.O«ü6ç•<ö`4ÊwíâçÍ'�A>CGš"©@{á/ÃA[ó¬¼<.7žiùyª[ŸÌ ˆve¼äcÉü€nH©]Å@ÿ˜K7÷Ðl>a2a)àw‰™+kºçgr<»æ « B#™7ÈdR�“c9ù%Iûú¸s÷n;ö^�iQÂÒ/© ä: Àÿ)¿^»'vF£lèëÃ-L÷ù ãÆåBºÑã’!„Ð5 Ù€dx )ÖjØÄ#Ÿ�84 fŠjFÇeö˜Ùn™)/SeOVœŽßJ—Üsë™�þØÒÂOêë�Æû}À�À G²édØðVàtÀo÷İ$±u`€ÖPˆyä(»ºé‘aJ,é}äà^Hö±Úx o%6& 5 Ç^Q”�“UYîH‹Ï•u6ßÊ É ÿЉðÍíÛyªµ•Ag³º�¡½¿?Ò¿ŽOÓ ›¼¦$ÓJ‘ËÅ7§N¥ÆŸ—Â$Ãl€•‹/*»¿èJybrHˆµ`O&jB¢2w°@ãŧɘ…ÆŸƒö(´¯wJŠŒêû°·—ÛêêØçœ—¡QÙlv}”æS¸Åð.rÖÑg;»¼œÓË*(÷æêƹ’Þ�¢¨¦.¾:²\LÅó’•ñH3 ¯K$WqÿÃÞ¬ãù�[ñ-rÒHV2@&ß·%ä™ÖV~ßÜì´_@BÆ¡,û4)‹ëShS¾¬ã€´ô»»¶ à`ªß¯cüQ”ji¿Læ Ê­Æ’~ Ù°ü€}Å iD+z.ÃØm5«oÞ9\†=Hʦ 7Ë$¡0�öö_ÛÚøÙîÝNQ} g÷|õÓ¦,ŸF )§�ÕXÌ"ÔŠ(ÌÎËãÚª*&z} ™>œDŠ‚Ã¬¿Ë… @ŠÑÑÆ#p yÊPÔôù=ax¹Ï,dÐŒû¶tË͸ÿ¬Û %¨5F#µ³[@íy:È™�÷…Büp×.þÕÓ㤅7¹fš€¯o~•Äõ)6�À#À$lÈE´ßvG$ª®.<Ty½r’P4óM¦ ÛÄühéÈ&�&)èu¹ä�¨#´+ %”V]K÷ÜÎ]7Ü–4åC²»;‘ •0ãÏûöñßÛ¶Ñ :�¹- —™Ïj?­Jòi6àYä¬í ¤)‚L9¶yp€ÍøD‘2—›—˜* i:‡)¸¾eV÷z�àu¹,ÑY¹vZÐŒD£¥þÒ$á´�k½éãPpc\Ÿ!¸(yÎÑ(¯wvòý�;y¾½� ê }ÀÍÀÿ(kˆQpdËûJrp «=Ñ(ôõQ74„O©òù ¤ ‚NÙ,ó¤Z�U‚ RôdHnQ$׬Ùç³"víÀ™ðj~¿ÚÑÁ/x|ß¾t£¹Œ² ¸–ÆäS-ŸµWŒŒ¼S¹íHÜ‚@�ßÏW&L`vA�×kr¢¨6¥*iú”–cðºÝø\®ÃŸÄû$)¼ÙoJ/ MH°¡¯�_íÙæþ~™a z•ÿiå6£àÓ+5ÀCÈl-ÉÒ¢".?ž*Ÿ�—%5ÌÄ.«2 I¸ .—:¸ãS‡0*¹‘Ž\»ã;>T*d‘$‰h"AC ÀCMM¼ÚÑ‘ÍÙ­¾Ô}Öá³j’r=ðsd¡ãkáN*)ayéfåç“ïqk<‚Tµ@ì;“ÐߢäØ.³Ú¿IÇ‘ ðfcÈ-* N%‘H¨¡Ò@4ÊG}}<à¯vtÏð �p ðÀgU>ë`,ð3à‹™„¹‚À¬ü|N()ayi)ùžÕ­Ñ`Ì BžÛ…×.ûo2ÞŸdý9Jmö·$Ù»÷*º1¶×Þî�Fy©½�tv²¥¿ß %·™»¿y@Ç�Ïòâ5)9¸¸4Óº”]|Ei)VVRž“+ã4ðâd�¿¤c'*u»3KþYqúÙyvl?f3,€“V߃!ôt øÂažØ·�ç 3%–ÝñŸ~…LÕý™—Q`ØÔ�c€?3³9€,/-åÜŠ ¦åçãE\I^¥Ñ'yÑ}¢‹�{¾EJof(Ìâò¤a×ão:(CŒX"A0c×Ð�·¶òZg'ñìßg'2’o]ê£ �ü7pPF–åÒ¿Ÿ“JK9º €éyy2! )¢�Òœ܆ILJç[Ì]z£âgé¶g­ô ³Ð@,Æ®ÁA>ìïç�Ž6;ãâ3“8Љ ãýõè’5™J!ðmàbਬâr1ÙïgAAKŠ‹™SP€×å¢ÐÄý×N8²b÷9E²©�”Í}}¬êîæ£þ~ꆆè�Fæ-·!Ótý/2Ÿä¨Œ€¬e"p&rÕ`A¶� 0Áëå¤ÒR.;–i~¿2bÜÜU·RþOºQ0ÆðÃz”ßu�/8À›]]´†B„ ’`#rVÿ etéŽ)�ޱAIDAT€‘¼V~àDä$Ò¬‘8è$¯—cÆpê˜1Tù|¸Ýx\.™ãÌ`Ì”>é)nƒ ­Å›qõïOH‘xœÁxœ¦`�×;;y»«‹=ÎûðÓI-rw5ryO]²£àPÊÙÀO�é¤a"r*99ÌÍÏgAASü~Æû|TæäPìñÈ㿌ñy†�“瘅R†pÜäíîH„ý‘­¡{‚A6ö÷³e`€ŽÈˆÁë@=p+ðÒè’5‡|ø2òø²9#uP� Pâñ06'‡ÊÜ\¦øýLñz™–—ÇTŸ�|�GíW•ôZ®¿m,ÍY)Ý,@«ì¿r{(cw Àî@€=Á ��"ö‡ÃôD£NÚo3‘íÀàqdJ¸Q5»L>‡œ0<d$™æ\‚€Gp‹"99Lñù¨öz™ê÷3#/�j¯ŸË…G;W {knkçú™uÄw’ E’ˆK �Ó RPÐ ±{hˆ®h”h"ATynbä¯ox9±·Ø3ºäF À'Q¼ÈˆÂK�a¦ç›{�±¹¹”{<”x<»Ý”åäPâñPäñPèráw¹ðº\x”×D%‰P"ÁP,Æ@"Ao,Fo$Bg4JO4Jo4JG4J{8Läð·· õÿŠŒà �.±Q9’d&ð0ò¸¨!äÚ´4úcúW®Ñ>åšÍ]>£À§ING¦*[„<ñxòè%Å�o@ž­÷&2»ó¨Œ€O­ø�«3‘‡šœ‚LbúY’÷eÿ¦[G—ƨø¬‰[Éx‘û.E¦/›ý)ûœ;�iµŸAÆã‡”ŸØè5£bþÝœ®xË€ù€¹ºàR~R¾? 9KW~G�MÈ4lï+.ý(0gÔŒÊAÊ8%d¨P Â8 _ù»Pñ" �«Â!Pò^äí!d|};0´) ß®¸ôm£_Õ¨•Ã#. (2€ÅKð+·‹”ßÊ}¹Jø�ↇ‘‘uÊOŸò; ìêÐt(»þ¨Œ€Qù‹¨ù1'wwíOBó3*£2*£2*£2*£2*£2*£2*£2*£2*G¸üÿ?ú¯9+ÔIEND®B`‚caronc-apprise-dfec414/apprise/assets/themes/default/apprise-failure-32x32.png000066400000000000000000000046051526720371000273570ustar00rootroot00000000000000‰PNG  IHDR szzô pHYs  šœ 7IDATXÃ�—{pTÕÇ?çÜsï>’ IØ<„�&€ qÀBÉ&>`ŠÏV«ÖqjGìÔVÇ:ÓvÚŽNgèè?N�¡£ ŽÿP‡±_° ±h �¬1aW IØM6Éf_÷žþ±7! í™9s~sçžóýžßïw~Á¥‡ØáPH „QÿÁ~ –ûOúÃÍÍI´¶ë[[õØÞ‹.¸ü±X¬¨öz¯,1ÍŠ¥@Ò¶u<“9óU*uØüÃ]/9¾“@¸¹YÔð�nÖOõxVßTQa-.)¡Êëe²eQ #¶M<“!’LòùÀ{z{3§R©w€-Àžp($\�\’€´»Á <]nY?ÿYM�XU^N…ÏB „án´ŸZkbé4ï÷õñ—®.Ý—Élž‡B)—„ôwj ÜÔ$ë÷í«þ¶*\øèìÙ”y½(…!%Z¤”y`)Ï�Úqò$Ü5žNóLG;zzk­ï 77·»Zýv ¸7Ÿì¼oÚ´Ú»«§S`Yø•�O)L¥À%!ÆVU¥¥˜Á v2IöÌœÁA^ˆDxº³ó¤ÖzM¸¥åDýÞ½z¢‡O/vÝ=ujíӦa)Ó�˜¦…2†i‚RH¥PÁ žéÓ±GFÀq�Jñù”)Üþò˼xä{NŸFÔÖ²pÖ,®L¥(�²äãx|ÙÆ®®íî«9ŸÀ¦H`ss0xÓÕÓñš&†X¦…²L”i¢”B(…ðû9ðÔ¾},il¤`x8oŽ¢" KKY0miÛæ¹wߥµ§‡ZZ¸vt”¯“ÉÊö‘‘²ðÊ•;7uu�0\§¸mŠeýùÁÚZ M %%–i¢<†«~eš¥ðTWó§ýûy÷ÓOQ¥¥4MŸŽ“N£KJ˜ Ò\^ÎJ¿Ÿ,[ƶƒù𫯸§©‰ÆTŠÖ¾¾…O†ÃmáPè‹M‘ˆ”n PÀë+*ñ(2ï`Ú�Hmh)‘Jq&àÀ±clݵ‹®ÂB¼sæð›½{Y¾a«_y…C�SûûyõÞ{9ÒÕÅýý—–òÓš€‡o=|X¶|wÉ ¬ªöz›gB¢…D.¸+#%Â0PS¦ð¡C %“äl›göîÅðzùCS;{Œ©Á ë·mãde%3 V66²ãÐ!TI ×——sU Ð|lpðúp($d�Ï'�ЂI“ò�Eæ†"OÂÈr„Ãà¨ÏÇ‹»wŸ÷tw8ÀžS§¨d~<ÎKëÖQèóñÒáÃ>u¥¥ôÄbh¿ŸÓdÙäÉ¡úÖV-ë[[KM!šË¼>¤ØÚ�ÈKJÁÕWó»íÛM§¿ÑžyóM˜5 !v9~œÀ4€ÐÆ2¥ÖØZÏf`@±žTª=–NÏ­ ˆœé%�òvg'ëæÍ㺫®â@[¦Rds9fWWsíܹ̯«czQÓDkM"�¦ëìY¢ýýÔùý8§OçAóöűm„ÖK$ÊåÂ@Lµ‡B½ ­­ûzÓ©¹£££”–—°e÷nVÍšÅC«Ws ­�l.GMe%¿\»–›jkñÇã8##08˜·«ã€ã°Ä0UU8'OæmlÛq0´æŸ±g³ÙVàk‰ã8@kxh˜�xœEåÔVU‘aÃ;ïPïõrgK w·´ðÎã�óÃ+¯„L;ÃI§±³Yt.‡cÛØ¶�“N“M$pr9„ãä‹—€áVOÇb­Æ¦hT§bÙlË ¯wjÉð0·¬YCJHúƒ,ª«cU°Œ•óçó¯înn߸‘·;:¸céRD"qÎ¾Žƒ˜°žnÛãrk_�F?þdÆÒq ÙÌÚy¾èîæ{S¦pÃÌ™ˆÿt“;{–ã^÷?û,ã£ôÄbL�9“y†™ Ú½¡ž îq9—CÛ6Ú¶If³ü¾½�žtúÉp(thS$"Œ }x0—kôIÑPãõAr„l,Ž‘Í ‡ªšöuvàß‘·,_Žw``œÀØÔ.1î~ßÜÕÅÎÞÞ¿OlŠDœ±x Ñ à‘ݽ½Ýmƒds6Z;Ø9;oß¶6~½nÝ8Û‘ÑQâ€Îfó3——ÉfÙlžˆ«úÝ==l‰F»�G&Vàç•díMM‰M‘ÈçG‰Û*=–Uåõa 9›†Ê)$‹‹1„`óÐÐׇÎûÁ…ê×z\3{NŸæ·íí#9­ï>¿hcÒ¾b…hØ¿¿xù–ŠÊª[«ª˜äñà7 LePÑЀßã!�¢3´Ö!ÐcAÆqò…¦Ö¤2ž‹FÙ�~ Ü콬ÆÄ•g¯Ö¬­¬dùäÉ(“ËÄ2Œs%ù˜ ]cÉf_[¢Q>K$v¿"ß‚ñÝ­Ù‰+Äœýû5°xpaQQã’ân,/ãŠ@à\g4�DûÐc1>ŠÅø8ÿ xÞmÏþ¯!\"P Ü<_nY]u>Ÿ^Z\¬o.+Ó7—•é¥ÅÅz¦ß¯ƒ¦Ùå‚Þ·75©K]T\ yA‹] LÊ�R÷[ èΧ.hí�‰*¿pü1¬b96²�IEND®B`‚caronc-apprise-dfec414/apprise/assets/themes/default/apprise-failure-72x72.png000066400000000000000000000166601526720371000273730ustar00rootroot00000000000000‰PNG  IHDRHHUí³G pHYs  šœbIDATxÚíœy˜\e�ï?ï{Ní½oIgé%IÇNè$d%K'121œ‘ÍëÁ{u®ƒŠÊ\ǯˆ+ hŒQ!Q@ ‹’ �@BÈF:IwÒéNoéê®®êÚÎyç�sNÕ©êjBGç>÷MëëH!r€B¼u)rI�R¹�)Ã`Ô0x®¿Ÿ¯;ÆéxÜÙ•¾|ù¿ Rà^à}Ά+'Nä��u]ÓÑ5‰& ¤Ì¦€ÔˆqÀÊ¢Àoeš¹0M† ¾zì[Ïœ!•ýÏÀG�¡ó¨ö‰øgÀå%ºÎf¾ƒ«'MÆ£ëHMC �¤hR"¤Ì•0¸ x>ÂýqΣTv¿”¹OY|ºÎ¥UUÔ쇵@l–ÏÙõg‘ fÛ”.h.*âÓ3fÐ ¡i]Jt!Ð¥D“�´¤Hº›Tžª©B7äR«| R…¶¹%Ê4912Â?8Àá‘gë+À?ÙÎæÛ Ðt`3°LKË˹¹¡‘¿]Ó2àhRâÑ4 ({=‡¤ó¥PoÄEJ!lUJ¡WV"‹ŠºŽ�Ó4QÉ$*ÇŒF1Âa0ͬj*ÅH2É-ð»�ç¬{�«€co@•ÀƒÀ;°º²’ÖÕQáË‚£ a#Ñ¥ÈH�n«œ£ZÊþÃ=ŽúŒ”¯¹™À¬Yh%%¹¥*™$“îë#uú4ÉŽ”‘õÁÉ$_8r„_ž9ãlÚü-0ðvô p5À²òr>ÒÐ@¹×‹GÓÑl•òH™£Zº”è¶Ú M˨—º¨ë[!‚AÌHåXŸ<Õ^/òÒKùúc�ñôž=ŒÄã ]§®¦†‹š›Y·d s1c1R==Ä ÙÙ™9ÍH"Á¿¿þº¤­Àßý©$ýUà#çÜÔØH™×‹Ô,.¥DÓ,²•R¢I�”¡I4])‘šfl9#%þ–B âŸ6 ß´i é³gÇp”xE)nùþ÷éf8c8#<2BGO»âç¿ûÛöìaÚÔ©L›9ϤI¯—t·¦y5�¹ÅÅœåx,æpjxú­´ÎöHõr�‡™>ƒ*¯©IË¿±U)()‘RZ!šý-Ý–Èþx¦NÅ7oïÞÍ«'O2sÆ ü'’êîF%“ÖqPé4ÔÖòà®]¤M]Ó2×v€›6 ºøÅöíô ±tÎЦLAúý–$ AH×™ ²x؉é.^¶�ÊóR±�Mf3>×4“ÙÅÅèºucºx¤æR%ç·†¦Û$í"oáµë\°€Ï<õ›·màïW­â‡Ÿú©Þ^†ŸyfŒ%^/©¦&šÆÔ`‘H0˜Hpphˆß<ÈcÏ?Ï@$B*me@Þ½`w~èCL­ª"öÒKÄöîÍX·m==Üñúëô[ uÙAuô|$èÀ»qÅ„‰´VVZÒaK�&µŒ´hRCJ�Ô5�MØÇÙœäV1G*dq1²±‘}ç;˜6½ƒƒ4LšÄ¬¦&ÌX sh(Kâ6�˾>‚}}¤O�"ÕÕ…ÞÓCm8LkQ7^v”•qôôiF“IŽuwÓÞÓÊ . lÊŒþ~ŒH„`z0H_"ÁþHe太€ÇÞ,@K�Šk}>644"m0,‘–™uK­DMJ‹ƒ„eÞ�ãò%ÈSSÃoûûytçÎÌEG Fb1Ö,YBQe%É“'J!„À;y2E­­-^Lp΂sçâojB+.ΘxzzXVVÆÜeËx¹½�ðÈm]](¥X=>š×Kª« •Nƒ”Ì-*bûÀ€#E³�g€Î7ÐÿZ644RíóeH8#Â"c‰ކÌNJ ió’ã !�^/þ3¸ý‘GhÏZ:ûû™RS–PŠt?2B_²„-»wóÛ_dÇ«¯r ½�‘TŠ©3gRÔÜŒðz1‡‡1#&G£Ìºè"v;F$c÷믳₠˜ÞÜLª»sxØÊûJ‰_Jž$¥”ÇNðýü\]l¡E KK¹¤ªÚ¬íÏhÒ¤æ¦i�Q;i�¤iÖGfAu‡ Zy9'ÊʸkË©TÎÅ Ó¤çìYV/X@å„ ¤zzÐJKyª«‹ wÝÅŽW_eçþý<½gØ¿Ÿ¤²´”¦–ô²2R½½¨xœ)é4Ô×óâÑ£¤ ƒ£§Osý»ÞJ‘:}Úò‘„ 9â¹�º­¼ÒT`'ÐîÎxæ“ö{€ ¸¸ª)ÛÁC È:}J€)í`S ëßRX§q{Ê�U3]Ç[WǽOct¯”cŽ;ÝßÏ/¼ÀG¿þu~øÄxjk .\RbŽŽòÕÕ´44ðr[{ÚÚð54 |¾â64 [ë`ƒÛxåKÐvâ«zAi)K+*ñhº% ‚LàéH†°UH“ZAΑ¶ Û…”ÈÒR:ª«ùæ–- F"ãZˆW�ãò+˜0e éîn&Á»W¯fýêÕÜpñÅÌ�>�ã== F"Dãqv¾ö³iž=#Æ ‡ñ¤R TT°§­ Ã4)¸tÑ"íí¨h4ôNõûyn`€KŠj�'€ÞB4˜0¯´ _ÎS³¥Ëò KZ”­:Êþ­¤°å-ë=+[êBsçòÃ'ŸÃ=Îâõz­”àÈßÚ²…„øgÌÀ;4DK8Ì¢¡!fŸ=Ë5EElÛ°�Ë—/GA4çÓ÷܃ašæÎµ¬¥a°lâDJC!¶ïßo•X**Ƥ\>\Wç«ÞSHÅJm³ÎÔ@€Z¿/;8•q;ƒE€ÖV%µ’Ö…Yóggo/ÏìÞ @CC7Þx#wÞy'¿þõ¯9tèwß}7Á`€ŸmÛÆþãÇñÖ×£WW# …X 52‚ìèàžµkiš2€žÁA~²mzy9²¨L“¦`� ßÀÑÓ§­A¯Þè’ÊJªì‡cÇh¥ùM° š¡eº'#1`PöIJ`ï·¥DaoS ¬ TIi'%Z @jÖ,6>þ8'{z¬täg?Ë·¿ým>ñ‰O°nÝ:š››yÿûßϺuë2×ýÔw¿K"™ÄßÒ‚°*\ƒK÷õñ±Ë.³jA†ÁS»wƒèåå”yz”Ÿ=õzy9Þº:”=Xa»*‘`icc&ûxÂŽ»„Ï—ó�Ñ&)Á4Ǥ\‚Ë'Lp+v¨ÆÈkÇ$Tù|Tyù�ï°÷hn°•¡ ¥‹1:q"üò—ù믎9K>@;wîdÓ¦M ºžîm·ÝF�m~cñ8_Þ´ Ó4 Ì›gqP À½¿ÿ=»[éå’Pˆ×®%þÚkhœ./ç�`Úyê«W¯&qâf:]°'„ ¹¨ÈY-B@AÀë×4Šìr°ÊQRåâ•%d×1™ûkk©Z¹’^¿ŸõŸû»(\¼Ÿ=ݹ–Rìß¿Ÿƒ²qãÆÌ1555ÜrË-™õ}G�òý­[ÑËÊðÍœ‰™HÐvêT&½qçM7Q”H�ìì¤xéR~üÔSh·¢†¿_¹’ÚÒRR§N!òKE®ÒÓÔ,Ÿ  Ø6GÔx¥$¤i¹R£\Õ†É1sÒ5Z @uë ª[[ùýñ\yë­îè(œà®¬¤ªª*³�Féêê"™LòÐCqüøñ̾ 6°xñâŒýǶmœìéÁ7}:B×¹eÑ">wíµüôöÛyïœ9D_z  ±uÿ~~üä“™�ßöþ÷“hk³²•ùÀ¸ÖýRÈ:ÈSœ_å^!ðK-—€ó}!‡”2¼sçPÅå55ܱy3ýêWèqßHÞ2kÖ,в¢Ì—”íÚµ‹M›6e¬‘¦i|ãßÈÆiG�²ñ±Ç�Á þ™3 õô°¡¼œ•gÏ2ò‡?XyíTŠ×O�bÔnløÚ† Ôz<ÄÛÚÀIä�SÄÔ„ 2ë0NÕmK;ŸK^ ưS)MžÄ”ÖVR/�ïÙÃW7o¦»¿ÿÜŵææ€Âá0ëׯgÁ‚Ìš5‹E‹åT;–/_Îu×]ÇæÍ›øÕެ]º”E��è&�îîF¹ÊÑf4Ê¥S¦ðâüù,hjâš+H½ò Æà`ná P‰Iˆ Í5º«,“ÆP6Ê�ñTYKæ ™ÚºÿÔ:^íhç¾Gá¹W^yÓ¶Y³fQ\\œY_»v-kÖ¬A×ut]·œÔ¼&‡/~ñ‹<ðÀ¤R)Ú:;yð™g˜óáã›6�ô™3Ùú™SÄëêbãªUH¿ŸÔÓOçø>ç*ó¸bР´ÏÉ©Vš*ÇÊ|+¡ âï KÓøæ/~Áu_øÂy�ÐØØ˜1ßN€ ñz½cÀɸùµµÜqÇ™õ_<ó ;÷ïÇSSƒ×¶tù7Ý×G²£Ó�V± �ãÖß#»/!]ý4Vפ©\•Þ,Pz0HÕ¬Ù„jkID"<¾c›žx#ß"œ«'¯¶–šl̃RŠ��vîÜɽ÷ÞËÍ7ßLkk+Bn½õV6�x<n¸ášššŽFùÁ¯~E8Ç[_�pyÓ(e…î¸ë Éé}PŠ‘¬pÖ‘ ~€´©H(3çI(h’úU+i¼ôR¦­YCjt”÷4ͤȎºÏgihhȱ`íííTUUÑÚÚÊM7ÝÄ=÷ÜÃN;^»ë®»8uꔈ¦R„B!nºé¦ÌŸÜµ‹§^|­ºï¤I¹@¼` ì3�¡,@§¥«u�¤i2j³|FvTNŸÎ¡XŒŸýöIRºFYc#¾þ>>~å•o  êêê¬o³o߸ǦR)¾ô¥/ñ›ßü†{ï½—Ûn»�G}4G=ÿ}Ó&†¢QüMMVŠ#¿áªPËÌ–2M³ià“I§�®„iLЦÓ`œs·´pÿ£�ð»={˜7£‰9Íï`äd+'Ldvc#O¼ùvÀ¦¦&*ì„U:�æùçŸÃã7nܘã<æ/ígÎðÝ_þ’½þzôòr’ápÁ®·J Gý Ê}¹ ¢ƒŽ%€¶¸i2”Jå8„ÁŠrŽ�=Ë÷ïG)Ŷ>JÜ0¨jiaôøqþeýúó’ S§N±qãFn¿ývn¾ùf|ðÁ?¹pó“O¢LáñlßË€à¥x¦iòz4S;Œ1ÝÐ^`U8•ÂDáøÓ¾Š žî$n“åï_~™äâù :zO$Â{W­bëöíç6¡B°eË~úÓŸ’rU3ŸG AIQ+*(+.Æo;lÉTŠp$Bïà ÃÃÙ”†ý=ÛŽÒ�³g³­/ù4N?QnsšÉkvIÈ®¶;�;€�&“„S)ªìܲ!%}îôð¥�Y2{6“—-§íá‡ùÇeËyv÷n"çð5”RD"„„ü~B�K[ZXuá…,œ>�¦²2¼‰F4ŠJ$P©”5`»t-½^ Ÿ�c‘{»»Ùqà~¯—Ûo¼‘Ñ}ûH lÙso.S¯Üûlk¼+Ûú20¤»îÿíO&Bƒ©U^/JA*>Š–ç–wõ÷ó“'žàc믢ª¥…ð‰\·v-ß{øá7î÷“’IUU,ž5‹õ«WsÙìÙ¤:;­bÞ믓Âêª"û´ÝOÝæŽ:¥¨3MÞÛÒ‚ 1¶o'‹¡ #§q*cîó×Ïë1êM&9‘}ÈÛ�Q7@½À+Ãhí�Ç™ �‚dÿ§5Žì}¿ü%ëV¬ qÑ"†ŽaUÝTž©«ãÈÉ“Á©›0�÷,]ÊÖ¬¡H´µ1òì³Ùì`>7ŒñÞŒìÀLËQ±æÈˆ5h·?æV¹¾E!Ð]Ç?Ý×—1 ÀþüºX krÉ%^!¨ñk•NSZßÀËÇ�suµ›JÑÙÓÃå+Zñ••‘zuÁ¦&^8t(“\uÉ%|ꪫ¸fÎümm¤»»-QO§sžpN�bÞÏgŒD¸9Æ,Oš„½Oå[/{ÿíÙn�ƒvóÆH~áÐþ!’JygSáõ"…$¤ ÔĉcÌygo/Ó'O¦¥¥…ÑÞ^&h:m££œ¶ŸÄ»/æ3×¼�ëæ_HõÙ³}ý¦O'4>ÞÚZÒƒƒ˜££9¼À8OYåõæ€ã&æ|Ëeïy`çœß0822·ÛÛÝ-ê¬+Ò0½L×™ ¢K‰¥¹©‰“±hNC)ʼn®.Þ»j%¥µ“ˆîÛGYc#1Ãà“ï{×.YBc4Šè'0iÅóç³ãd{Oœ qÚ4¼ºNª§'W=ÎÕiï¤=øà\ å¨–k½�ôüßcÇœNØpÖ™1%íôëºp*)gS¤ëH ±rá"F4-SZÆ4MV-Z„™JQÞ×Çe+–3S)´3gðW×PÒÒB—ÜrÏ=|kË~³k³d “ëëIb¸’õ¢ G­àÜ ¸> j>9›&=‰·9â©»€;m, vw´7Íš2]§.²ú}”B °¬®ž‹W´()Á0 |^/%Á KfÏFõ÷ïè@ôõá-.¦ráBS§r÷#�ò‰o}‹'N�L¥H¥Óô‡Ã¬]ºo @êÌ‹�ÜÈ#Û 0…¤&_BÜÄìâ"§‡ZåYů;Æ>ËÿIcM€yá\-x¾Ò4íæÆiV/´”hšŽ.~ŸŸ`i ¡êjü� “ôp„äÙtSáñû©]ÙÊKCC|î¾ûè(Üi»åŽ;¸táB¢»v‘ÇúšŸ¡Þ@¢Ü Ûò¹U.Ç‘4MG"ܸw¯½f—{"çj Ú\�Rª&a\P\š™Z ¤ÓÀŒÇIž=Ëh_/‰þ~ÌèBYm0Ò0ñ””rÃ}÷Ò“çdº—æúË.#P]MâÄ «ó«QçKL?Gå%Ü•¢ëÿw9ÂAKÅSXsÝ^z³=Š{€t'r’ÏÇŸßê*s:VV³” ³® а«ªÑ(-‹óøž=ã^ <2BÐçcÅüù¨tšToï¸òq ù:ÎqIL޹øjKw7??}š¤uþ=Àÿ:Ÿ&ÎÓÀ4àÂöXŒ¥¥¤–íQÖŸ¥=™$Hf:ÍŒÚZN¤Óï:é ‡qõÅS=mñ#G,.o®†Û·É3íŽdT+78öúá‘î:~œ.Ë·‹רcÎ-&ž#¾üÐ>”NóóÎNÒÊt‰¬•LSöoë&Tf›0 †â¯Y“i?)´$R)¾ø“Ÿ€RVG†aä~ÜÒàpˆRäs�ãm�Ž l$•âþŽYª¥€»� Ýß¹X Œ¶Å¢líî"mª,NZÖÎB*{;Ê´nÊ0¨êîæc—_þ†xÙ.K«dr¬ Ù)ÃÈuMëÊÞ/ #k±Þœ”a°¹³“_gëùþu¼{{3uÿd‚±kp�ý}6˜–gæ™YÓÌ>ÕDçiÖÕ×Óœíà³ü�µkIZѸkàÊmÒÝ`9Reš·t�Ža<ÞÓÃ7³ÁI\“ßê\ °æV�𮓱˜(ñèLñÑ„²Ú€m€f“¶Ä!rð†ÃLY´ˆßØ�eÎ2¹ªŠ»o¾™+››IìÞ�iÏ¥@<樌0ÍŒg, ™x7?¹]Ã4yº¯�[vjÂg�õÀ�· Gý)¥V‹FEP“LöÐ�^éŒuÃ^·^ “I¡jâD^ikCÁ'ׯç[ü ï‡I¿ö†;Ušç;áBŽ ¹}£|_)Ï»NÛ’ó™Ã‡�)šQà:»qüœu²ó]¾|: „ø› xwu5ź]·çk83…À£eg êR#PS �Ó(*.‰�8vÌ*è9_çpÝÛÔx© wÌfšŒ¤Óü¬³“»²jnp‚Ñs-oeÎêÓ@" —‘áTŠ)?%š�²&ñºÕÌ‘"@E£ø{{��Vx‘HŒ ÞÈÿ)T¡ãlIk‹F¹ëøq~œ�36€5MüÑ7;Ø·À€CÀå'ãq½md¯”4ƒ6eù'3ÁЄ$`Çuœ ˜B@å'à 9�¶úmíîæëÇ�³#›}8aòSç3Ð?uÞ|½ Ö”Mã‚âbþqòêƒAkÖ¡Ý=ªIKÍ‚šFPÓÆ$òs¦b(ø©ñ }@>�ò½övž bÕø”�o¿è8ß¾]¯¦xX/@/óxXY^Á5“&Qåó¡K‘á¡r�gÜ·0œ×’ŸoÂÉ$÷ttðëžΦR¨CÀýÀ-oõRoç»;V?°û‹e±¦qqe%W×Ö25$ iîÒ,H®ŽÕs’tµêŠÇùÑ©SÊ=Š5 �$•bÄ0L¥èI$²Í_Òv€ù¤m(öÙÞñÛºüW½Ã̃5d"ÖûËZm°š°šØßÌ·Cž½¶UÚk×òzìdןeùK¼OÚ¸æ³?¥X3ýj°šI‹Év½…mNa½è-aFíÕŸ}ùO&G䆑sDíIEND®B`‚caronc-apprise-dfec414/apprise/assets/themes/default/apprise-info-128x128.ico000066400000000000000000002040761526720371000270310ustar00rootroot00000000000000€€ ((€ !8KVdpwwpdVK8! Gƒ³äíðóõ÷øúûûúø÷õóðí䳃G  J™ãÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿã™J =q¸üÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿü¸q=,‰Äîÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿîĉ,DÂõýÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿ# ÿ*#ÿ0(ÿ6-ÿ90ÿ;1ÿ90ÿ6-ÿ1)ÿ*#ÿ# ÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýõÂD BÑÿÿÿÿÿÿÿÿÿÿÿÿÿ7.ÿWIÿq_%ÿƒn+ÿ�x/ÿ™�2ÿ£‰6ÿ«�8ÿ°”:ÿ¶™<ÿ¹œ=ÿ¹œ=ÿ¸›=ÿ¶™<ÿ±•:ÿ«�8ÿ£‰6ÿš‚3ÿ�x/ÿƒn+ÿq_%ÿWIÿ7.ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÑB R¿ÿÿÿÿÿÿÿÿÿÿÿ3+ÿq_%ÿŸ†4ÿ­‘9ÿ±•:ÿ¶™<ÿ¹œ>ÿ¹œ>ÿ¹œ>ÿ¹œ>ÿº�>ÿº�>ÿº�>ÿ¹œ>ÿº�>ÿº�>ÿº�>ÿº�>ÿº�>ÿ¹œ>ÿº�>ÿ¹œ>ÿ¹œ>ÿ¹œ>ÿ¹œ>ÿ¶™<ÿ±•:ÿ¬‘9ÿž…4ÿr`%ÿ3+ÿÿÿÿÿÿÿÿÿÿÿÿÿ¿RN¾ÿÿÿÿÿÿÿÿÿ ÿ.'ÿxe'ÿµ˜<ÿ¸›=ÿ¸›=ÿ¹œ=ÿ¹œ>ÿ¹œ>ÿº�>ÿº�>ÿ»ž>ÿº�>ÿº�>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿº�>ÿº�>ÿº�>ÿº�>ÿ¹œ>ÿ¹œ>ÿ¹œ=ÿ¹œ=ÿ¸›=ÿµ˜<ÿ{g(ÿ1)ÿ ÿÿÿÿÿÿÿÿÿÿÿ¾N¶ûÿÿÿÿÿÿÿ ÿ-%ÿ]Nÿ”|0ÿ¨�6ÿ²•:ÿ¹œ=ÿ¹œ>ÿº�>ÿº�>ÿº�>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿº�>ÿº�>ÿ¹œ>ÿ¹œ>ÿ¸›=ÿ¥‹6ÿiX"ÿ3+ÿ ÿÿÿÿÿÿÿÿÿû¶]ùÿÿÿÿÿÿÿ ÿOBÿ~i(ÿ †3ÿ¦‹6ÿ§Œ7ÿªŽ7ÿ­’8ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»Ÿ?ÿ¼Ÿ?ÿ¼Ÿ?ÿ½Ÿ?ÿ½Ÿ?ÿ½Ÿ?ÿ½Ÿ?ÿ½Ÿ?ÿ½Ÿ?ÿ½Ÿ?ÿ½ ?ÿ½Ÿ?ÿ½Ÿ?ÿ½Ÿ?ÿ½Ÿ?ÿ¼Ÿ?ÿ½Ÿ?ÿ»Ÿ?ÿ¼Ÿ?ÿ»ž>ÿ¼ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ¹œ>ÿ¹œ>ÿ²–;ÿ‘z0ÿ[Mÿÿÿÿÿÿÿÿÿÿù]*¥ÿÿÿÿÿÿÿ ÿ[Lÿ–|0ÿŸ…3ÿ£ˆ5ÿ¥‹6ÿ§Œ6ÿ©Ž7ÿ«�8ÿ¬‘8ÿ·š<ÿ»ž>ÿ¼Ÿ?ÿ¼Ÿ?ÿ½Ÿ?ÿ½Ÿ?ÿ½Ÿ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ½Ÿ?ÿ½Ÿ?ÿ»Ÿ?ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿº�>ÿº�>ÿº�>ÿµ™<ÿª�8ÿjY#ÿ ÿÿÿÿÿÿÿÿÿ¥*eÝÿÿÿÿÿÿ ÿE:ÿ–}/ÿŸ„3ÿ †4ÿ£ˆ5ÿ¤‰5ÿ¦Œ6ÿ¨�6ÿªŽ7ÿ«�8ÿ®’9ÿ²–;ÿ½Ÿ?ÿ½Ÿ?ÿ½Ÿ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¼Ÿ?ÿ¼Ÿ?ÿ¼Ÿ?ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿº�>ÿº�>ÿº�>ÿ¹œ=ÿ±•:ÿREÿ ÿÿÿÿÿÿÿÿÜe•øÿÿÿÿÿ ÿ9/ÿ‚l)ÿ›�1ÿž„3ÿ …4ÿ¢ˆ4ÿ¤‰5ÿ¥‹5ÿ§Œ6ÿ¨�7ÿ«�8ÿ­‘8ÿ¯’9ÿ±”:ÿ¼Ÿ>ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ½Ÿ?ÿ½Ÿ?ÿ¼Ÿ?ÿ¼ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿº�>ÿº�>ÿ¹œ=ÿŸ†4ÿE:ÿ ÿÿÿÿÿÿÿ÷•­ÿÿÿÿÿÿ& ÿp]#ÿ˜0ÿ›�2ÿœƒ2ÿŸ…3ÿ¡‡4ÿ¢ˆ4ÿ¤‰5ÿ¦‹6ÿ©�7ÿªŽ7ÿ­�8ÿ®’9ÿ°“9ÿ²•:ÿ¹›=ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢BÿÀ¢BÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¼Ÿ?ÿ¼Ÿ?ÿ»ž>ÿ»ž>ÿ»ž>ÿº�>ÿº�>ÿ¸›<ÿ‹u.ÿ1)ÿÿÿÿÿÿÿÿ­.ÍÿÿÿÿÿÿUFÿ’y.ÿ—~1ÿš€1ÿœ‚2ÿž„3ÿŸ…3ÿ¡‡4ÿ¤‰5ÿ¦Š6ÿ¨�6ÿªŽ7ÿ¬�7ÿ­‘8ÿ¯’9ÿ±•:ÿ³–;ÿ·š=ÿ¾ ?ÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢BÿÀ¢BÿÀ¢BÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£BÿÀ£BÿÀ¢BÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ½Ÿ?ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ»ž>ÿ¹œ>ÿ²–;ÿo]$ÿÿÿÿÿÿÿÿÌ.Hãÿÿÿÿÿÿ{f&ÿ”{/ÿ—~0ÿ™€1ÿ›‚1ÿ�ƒ2ÿž„3ÿ …3ÿ£‡4ÿ¤‰5ÿ§‹6ÿ©�6ÿªŽ7ÿ¬�7ÿ®’9ÿ°“:ÿ±•:ÿ³–;ÿ·š=ÿ¾ @ÿÀ¢BÿÀ¢BÿÀ£BÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£DÿÀ£CÿÁ¤DÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£DÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£BÿÀ£BÿÀ¢BÿÀ£BÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¾ ?ÿ½Ÿ?ÿ»ž>ÿ»ž>ÿ»ž>ÿº�>ÿº�>ÿ¹œ>ÿ”}1ÿ ÿÿÿÿÿÿÿáHGñÿÿÿÿÿ5,ÿˆq*ÿ“z.ÿ—}0ÿ˜0ÿ™€1ÿ›‚1ÿ�ƒ2ÿ …3ÿ¢†4ÿ¤‰5ÿ¦Š5ÿ§‹6ÿ©�6ÿ«�8ÿ­‘9ÿ¯“:ÿ±•;ÿ³–;ÿ´—=ÿ¶š=ÿ¼ @ÿÁ¤FÿÁ¥HÿÀ£CÿÀ£DÿÀ£DÿÁ¤EÿÁ¤EÿÁ¤EÿÁ¤EÿÁ¤EÿÁ¤EÿÁ¤EÿÁ¤FÿÁ¤EÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤EÿÁ¤EÿÁ¤EÿÁ¤EÿÁ¤EÿÀ£DÿÀ£DÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ¢BÿÀ¢BÿÀ¢AÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¼Ÿ?ÿ»ž>ÿ»ž>ÿ»ž>ÿº�>ÿº�>ÿ©Ž8ÿC8ÿÿÿÿÿÿÿîG8ûÿÿÿÿÿUFÿ�u-ÿ“z/ÿ•|/ÿ—~0ÿ˜0ÿš�1ÿ�‚2ÿŸ…3ÿ¡†3ÿ£‡4ÿ¤‰5ÿ§‹6ÿ¨�7ÿªŽ7ÿ¬�9ÿ®’9ÿ¯”;ÿ±–<ÿ³—=ÿ´™>ÿ«_ÿÓÀ„ÿÙÈŽÿÚÉ�ÿØÆ‹ÿηmÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤GÿÂ¥GÿÂ¥GÿÂ¥GÿÂ¥GÿÂ¥GÿÂ¥GÿÂ¥GÿÂ¥GÿÂ¥GÿÂ¥GÿÂ¥GÿÂ¥GÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤EÿÁ¤EÿÁ¤DÿÁ¤DÿÀ£CÿÀ£CÿÀ£CÿÀ£BÿÀ¢BÿÀ¢BÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¼Ÿ?ÿ¼ž>ÿ¼ž>ÿ»ž>ÿ»ž>ÿº�>ÿ·š=ÿo^%ÿÿÿÿÿÿÿú8Fûÿÿÿÿ ÿn["ÿ�w-ÿ‘y.ÿ”{/ÿ•|/ÿ˜~0ÿ™1ÿœ�2ÿžƒ2ÿ …3ÿ¡†3ÿ£‰5ÿ¥Š6ÿ¨Œ7ÿ©Ž8ÿ«�8ÿ¬‘:ÿ®“<ÿ°”<ÿ±–=ÿ´˜?ÿÒÂ�ÿÚÍ¥ÿĹ•ÿ —yÿµªˆÿÚÌ¥ÿáÔ¨ÿÞÏžÿʲbÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤EÿÁ¤EÿÁ¤DÿÀ£CÿÀ£CÿÀ£CÿÀ£BÿÀ¢AÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ½Ÿ?ÿ¼ž>ÿ»ž>ÿ»ž>ÿº�>ÿ¹œ=ÿ‰t-ÿ ÿÿÿÿÿÿúFEñÿÿÿÿÿr^$ÿŽv-ÿ�w.ÿ’z.ÿ”{/ÿ–}/ÿ™~0ÿš€1ÿ�‚2ÿž„2ÿ …3ÿ¢ˆ4ÿ¥Š6ÿ¦‹6ÿ¨Œ7ÿ©Ž8ÿ«�:ÿ­’;ÿ®“<ÿ°•<ÿ³—?ÿÒÂŽÿʾšÿRM>ÿÿÿÿ#!ÿ“‹oÿÓÇ ÿãÖ¬ÿ×ʼnÿŪSÿÂ¥Hÿ¦IÿÂ¥HÿÂ¥HÿÂ¥Hÿ¦IÿÂ¥Hÿ¦IÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤GÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤DÿÀ£CÿÀ£CÿÀ£CÿÀ¢BÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¼Ÿ?ÿ»ž>ÿ»ž>ÿ»ž>ÿº�>ÿ“|1ÿÿÿÿÿÿÿíE,èÿÿÿÿÿs_#ÿ�u,ÿŽv-ÿ‘y.ÿ’z.ÿ”|/ÿ—}0ÿ™0ÿ›�1ÿ�‚2ÿž„2ÿ¡†4ÿ£ˆ5ÿ¥Š6ÿ§‹7ÿ¨Ž8ÿª�:ÿ«�:ÿ®“<ÿ¯”=ÿ±–?ÿ¬fÿÙÍ¥ÿWQBÿÿÿÿÿÿÿ<9-ÿ›’vÿãÖ¬ÿàÓ¥ÿÑ»vÿŪSÿ¦Jÿ¦Jÿ¦Kÿ¦JÿæJÿæJÿ¦Jÿ¦Jÿ¦Jÿ¦Jÿ¦Iÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥GÿÂ¥GÿÁ¤GÿÁ¤FÿÁ¤FÿÁ¤EÿÁ¤DÿÀ£DÿÀ£CÿÀ£CÿÀ¢AÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ½Ÿ?ÿ»ž>ÿ»ž>ÿ»ž>ÿº�>ÿŸ†4ÿ% ÿÿÿÿÿÿß,Õÿÿÿÿ  ÿze%ÿŒt,ÿŽv,ÿ�w-ÿ’y.ÿ”{/ÿ–|/ÿ˜~0ÿš€1ÿœ‚2ÿž„3ÿ †4ÿ¢‡5ÿ£ˆ6ÿ¥‹8ÿ§Œ8ÿ¨Ž:ÿ«�;ÿ­’<ÿ®“>ÿ°”?ÿ´›IÿÔÅ”ÿª¡‚ÿÿÿÿÿÿÿÿÿÿFB5ÿÍÁœÿãÖ­ÿÜ̘ÿʰ`ÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæJÿæJÿ¦Jÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥GÿÂ¥GÿÁ¤FÿÁ¤FÿÁ¤DÿÁ¤CÿÀ£CÿÀ£CÿÀ¢AÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¼Ÿ?ÿ»ž>ÿ»ž>ÿº�>ÿ¤Š7ÿ/(ÿÿÿÿÿÿŵÿÿÿÿ ÿ|g&ÿŠr+ÿ�u,ÿŽv-ÿ�x-ÿ“y.ÿ–|/ÿ—}/ÿ˜~0ÿ›�2ÿœ‚2ÿŸ„3ÿ †4ÿ¢ˆ6ÿ¤Š7ÿ¥‹8ÿ¨�9ÿ©�;ÿ«�<ÿ­’>ÿ¯”?ÿ±•@ÿįmÿÝЩÿ#!ÿÿÿÿÿÿÿÿÿÿÿÿ­¤…ÿã×®ÿÜË—ÿÅ«TÿçLÿçLÿçLÿçLÿçLÿçLÿçLÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿ¦Jÿ¦Iÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤DÿÀ£CÿÀ£CÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ½Ÿ?ÿ»ž>ÿ»ž>ÿº�>ÿ¤Š7ÿ$ ÿÿÿÿÿÿ¦•ÿÿÿÿÿr^#ÿ‰q+ÿŒt+ÿ�u,ÿ�v-ÿ‘x-ÿ“y.ÿ–|/ÿ—}/ÿ™€1ÿ›�2ÿžƒ3ÿŸ„3ÿ¡‡6ÿ¢ˆ7ÿ¤Š7ÿ¦Œ:ÿ¨�;ÿª�<ÿ«‘=ÿ®“?ÿ¯”@ÿ´›IÿÙË ÿxq\ÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÍÂ�ÿã×®ÿθnÿèMÿèMÿèMÿèMÿèMÿçMÿèMÿçMÿçLÿçLÿçLÿæKÿæKÿæKÿæKÿæKÿæKÿæJÿæJÿ¦IÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÁ¤GÿÁ¤FÿÁ¤FÿÁ¤EÿÁ¤DÿÀ£CÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ½ž>ÿ»ž>ÿ»ž>ÿº�>ÿ�„4ÿÿÿÿÿÿÿ�[üÿÿÿ ÿdSÿˆp*ÿŠr+ÿŒt+ÿ�u,ÿ�w-ÿ“y.ÿ”{.ÿ–|/ÿ˜~1ÿš€2ÿœ‚2ÿž„4ÿŸ†5ÿ¡‡6ÿ¤Š8ÿ¥‹:ÿ§Œ:ÿ¨Ž<ÿª�>ÿ¬‘>ÿ®“?ÿ¯”@ÿʸ~ÿ¼²’ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿ~waÿãׯÿØÆŒÿéPÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèMÿèNÿçMÿçMÿçLÿçLÿæKÿæKÿæKÿæKÿæKÿ¦Jÿ¦IÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤FÿÁ¤EÿÁ¤EÿÀ£CÿÀ£CÿÀ¢BÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¾Ÿ?ÿ»ž>ÿ»ž>ÿ»ž>ÿº�>ÿ’{0ÿ ÿÿÿÿÿöZ#íÿÿÿÿ]Lÿ†o*ÿ‰q*ÿŠs+ÿŒt,ÿ�v-ÿ‘x-ÿ“y.ÿ•|/ÿ–}0ÿ™1ÿš€2ÿœƒ3ÿž„5ÿ ‡6ÿ¢ˆ8ÿ¤Š9ÿ¥‹:ÿ¨�;ÿ©Ž=ÿ«�>ÿ¬’?ÿ®”@ÿº¢ZÿÜЪÿTPAÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿidQÿãׯÿÛË–ÿĪRÿÄ©NÿèNÿèNÿÄ©OÿÄ©NÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèMÿèMÿçKÿæKÿæKÿæKÿæKÿæJÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤FÿÁ¤EÿÁ¤DÿÀ£CÿÀ£CÿÀ¢BÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½ ?ÿ¼ž?ÿ»ž>ÿ»ž>ÿº�>ÿ†q,ÿÿÿÿÿÿ×#¾ÿÿÿÿRBÿ…n)ÿˆp*ÿ‰q*ÿ‹s+ÿ�t,ÿ�w-ÿ’x-ÿ“{/ÿ•|/ÿ˜~0ÿ™1ÿ›‚4ÿ�ƒ4ÿž…6ÿ¡‡8ÿ¢ˆ8ÿ¤Š:ÿ¦Œ<ÿ¨�<ÿ©�=ÿ«‘?ÿ­“?ÿ¯”BÿÔÆ˜ÿ§ž�ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ‰‚jÿ䨰ÿÜÌ—ÿƬVÿŪQÿŪQÿŪQÿŪQÿŪQÿÄ©PÿÄ©PÿÄ©PÿÄ©OÿÄ©NÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèLÿçLÿçLÿæKÿæKÿæKÿæJÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤GÿÁ¤EÿÁ¤DÿÀ£CÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ»ž>ÿ»ž>ÿ¹œ=ÿl[$ÿÿÿÿÿÿšeÿÿÿÿ2)ÿ�j(ÿ†o)ÿˆp*ÿ‰r+ÿŒs+ÿ�v,ÿ�w-ÿ’y.ÿ”{/ÿ—}0ÿ˜~1ÿš�3ÿ›‚4ÿž„5ÿŸ†7ÿ¡‡8ÿ£‰:ÿ¥‹;ÿ¦Œ<ÿ¨�=ÿª�>ÿ«’@ÿ®“Aÿ®oÿÔÈ¢ÿJF9ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿØÌ§ÿ䨰ÿØÇ�ÿÅ«TÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪQÿÄ©QÿÄ©OÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿçLÿæKÿçLÿæKÿæKÿæJÿ¦JÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤FÿÁ¤FÿÁ¤DÿÀ£CÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¼ž>ÿ»ž>ÿ»ž>ÿ¶™<ÿ?5ÿÿÿÿÿÿMúÿÿÿÿs_$ÿ…n)ÿ†o)ÿˆp*ÿ‹r+ÿŒs+ÿ�v,ÿ�w-ÿ’z.ÿ•{/ÿ—}0ÿ˜3ÿš�3ÿœƒ5ÿž…7ÿŸ†8ÿ¢ˆ9ÿ£‰;ÿ¥‹;ÿ§�=ÿ¨�>ÿ«�?ÿ¬’@ÿ³™LÿÔÅ™ÿ›“yÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿš’wÿ䨰ÿ䨰ÿϹqÿŪRÿŪRÿŪSÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪQÿÄ©QÿÄ©OÿèOÿèNÿèNÿèNÿèNÿèMÿçMÿçLÿæKÿæKÿæKÿ¦Jÿ¦IÿÂ¥HÿÂ¥HÿÂ¥HÿÁ¤GÿÁ¤FÿÁ¤EÿÀ£CÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾Ÿ?ÿ¼ž>ÿ»ž>ÿ»ž>ÿ§Œ7ÿÿÿÿÿÿò¾ÿÿÿÿ]Lÿ‚k(ÿ…n)ÿ‡o*ÿ‰q*ÿŒs+ÿ�t,ÿ�w-ÿ‘x.ÿ“z/ÿ•|0ÿ—~2ÿ˜3ÿ›‚5ÿ�ƒ6ÿŸ…8ÿ ‡9ÿ¢ˆ:ÿ£Š;ÿ¥Œ<ÿ¨Ž>ÿ©�?ÿ«�@ÿ­’AÿİuÿÛϪÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿEA5ÿåÙ±ÿåÙ±ÿÝÍšÿÇ­WÿƬTÿÆ«TÿÆ«TÿÆ«TÿÆ«TÿÆ«TÿÆ«TÿÆ«SÿÆ«SÿŪRÿÆ«SÿŪRÿŪRÿŪRÿŪRÿŪRÿŪQÿŪQÿÄ©OÿèNÿèNÿèNÿèNÿèMÿèMÿçKÿæKÿæKÿæJÿ¦Jÿ¦IÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤FÿÁ¤EÿÁ¤DÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ»Ÿ?ÿ»ž>ÿ»ž>ÿŽx/ÿÿÿÿÿÿ¢Aÿÿÿÿ7-ÿ�j(ÿƒl(ÿ…n)ÿˆp*ÿŠq*ÿŒs+ÿŽv-ÿ�w-ÿ’y.ÿ”{/ÿ•}1ÿ—~2ÿš�4ÿ›‚5ÿ�„7ÿŸ…9ÿ ‡9ÿ¢‰:ÿ¥‹<ÿ¦Œ=ÿ¨Ž>ÿª�?ÿ«‘Aÿ±™MÿÙͧÿfaOÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿɾœÿåÙ²ÿ䨝ÿйrÿƬVÿƬVÿƬUÿƬVÿƬUÿƬUÿƬUÿƬUÿƬUÿƬUÿƬUÿÆ«SÿÆ«SÿÆ«SÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿÄ©PÿÄ©OÿèNÿèNÿèNÿèNÿèNÿçKÿæKÿæKÿæKÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤FÿÁ¤EÿÁ¤DÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¼ž>ÿ»ž>ÿº�>ÿeU!ÿÿÿÿÿú=ãÿÿÿ ÿ€i'ÿ‚k(ÿ„m(ÿ†n)ÿˆp*ÿ‹r+ÿŒt,ÿŽv-ÿ‘x.ÿ’y.ÿ”{1ÿ•}2ÿ˜€4ÿš�5ÿœƒ6ÿž„8ÿŸ†9ÿ¡‡:ÿ£Š<ÿ¥‹=ÿ§�>ÿ¨Ž>ÿª�@ÿ¬’BÿÊ»‡ÿµ¬�ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿoiVÿâÖ¯ÿåÙ²ÿÝ̘ÿǬVÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬVÿƬVÿƬUÿƬUÿƬUÿƬUÿƬTÿÆ«TÿÆ«TÿŪRÿŪRÿŪRÿŪRÿŪRÿŪQÿÄ©PÿèNÿèNÿèNÿèNÿèMÿçMÿæKÿæKÿæKÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÁ¤HÿÁ¤FÿÁ¤EÿÀ£CÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¼Ÿ?ÿ»ž>ÿ»ž>ÿ´˜<ÿ'! ÿÿÿÿÿ²qÿÿÿÿ`Nÿ€i'ÿ‚k(ÿ„m)ÿ‡n)ÿ‰q*ÿ‹r+ÿ�u,ÿ�v-ÿ‘x.ÿ“z/ÿ”|1ÿ—~3ÿ˜€4ÿš�6ÿœƒ8ÿž„8ÿŸ†9ÿ¢ˆ;ÿ¤Š<ÿ¥‹=ÿ§�>ÿ¨�Aÿª‘AÿµŸYÿÕɦÿEA6ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ!ÿƼ›ÿåÙ³ÿ䨰ÿʰ`ÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬVÿƬVÿƬUÿƬUÿƬUÿÆ«TÿŪSÿŪRÿŪRÿŪRÿŪRÿÄ©QÿÄ©OÿèNÿèNÿèNÿèNÿèMÿæKÿæKÿæKÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÁ¤HÿÁ¤FÿÁ¤EÿÀ£CÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¼Ÿ?ÿ»ž>ÿ»ž>ÿ„p,ÿÿÿÿÿúEçÿÿÿ2)ÿ}g&ÿ�j'ÿƒl(ÿ…m)ÿˆp)ÿ‰q*ÿ‹s,ÿŽu,ÿ�v-ÿ‘y/ÿ“z0ÿš„=ÿ¦‘Sÿ­™]ÿ°œaÿ°œaÿ¯›]ÿ©“Nÿ¢ˆ<ÿ¤Š=ÿ¥Œ=ÿ§�@ÿ©�Aÿª‘BÿñxÿÚΪÿ ˜}ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿzt_ÿåÚ³ÿåÚ³ÿÓ¿~ÿÇ­XÿÇ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿǬWÿÇ­XÿÇ­XÿÇ­XÿǬWÿǬWÿǬWÿǬWÿǬVÿǬVÿƬUÿƬUÿƪSÿÆ«SÿŪRÿŪRÿŪRÿŪRÿÄ©PÿèNÿèNÿèNÿèNÿèLÿæKÿæKÿæKÿæKÿ¦HÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤FÿÁ¤EÿÀ£CÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¼ž>ÿ»ž>ÿ·š=ÿ>4ÿÿÿÿÿ°hÿÿÿ ÿcRÿ€i'ÿ�j'ÿƒl(ÿ‡n)ÿˆp)ÿ‰q*ÿŒt,ÿŽu-ÿ�x.ÿª—_ÿö�ÿʾšÿʾœÿÊ¿�ÿËÀ�ÿÌÁŸÿÎáÿÑÄ ÿ̾”ÿ­–Qÿ¥Œ?ÿ§�Aÿ©�Aÿ«’BÿµŸWÿÚÏ«ÿÛЬÿÜѬÿnhVÿÿÿÿÿÿÿÿÿÿÿÿÿåÙ³ÿæÚ´ÿßÑ¡ÿɰ^ÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÇ­XÿÇ­XÿǬWÿǬWÿǬWÿǬWÿǬWÿƬUÿƬUÿÆ«TÿÆ«TÿŪSÿŪRÿŪRÿŪRÿŪQÿèNÿèNÿèNÿèNÿèMÿçLÿæKÿæKÿæJÿ¦IÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤FÿÁ¤DÿÀ£CÿÀ£CÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ»ž>ÿ»ž>ÿ”}1ÿÿÿÿÿÿ0éÿÿÿ1(ÿ~h&ÿ€i'ÿ‚k(ÿ…l(ÿ‡n)ÿˆp*ÿŠr+ÿ�v/ÿ£‘Vÿɽ™ÿ»±“ÿœ”{ÿoiWÿFC7ÿ0-%ÿ*(!ÿ41)ÿUPCÿª …ÿÕɧÿÁ±}ÿ§ŽBÿ¨�Aÿ©�Bÿ¬“Cÿ®•FÿÁ­qÿÙ̦ÿÜЬÿÝÑ­ÿ¼²“ÿGC7ÿ ÿÿÿÿÿÿÿÿÿ¥��ÿæÚ´ÿåÚ³ÿѼvÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®ZÿÈ®YÿÈ®YÿÈ®ZÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÇ­XÿÇ­XÿǬWÿǬWÿǬWÿǬVÿƬUÿÆ«TÿÆ«TÿÆ«SÿŪRÿŪRÿŪRÿŪQÿèNÿèNÿèNÿèNÿçMÿçLÿæKÿæKÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤FÿÁ¤DÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¼Ÿ?ÿ»ž>ÿº�>ÿB8ÿÿÿÿÿºPÿÿÿÿgUÿ~h&ÿ€i'ÿƒk(ÿ…m(ÿ‡o)ÿˆp*ÿ•€>ÿ¹©}ÿǼœÿpjXÿ.,$ÿÿÿÿÿÿÿ ÿ¹¯‘ÿÕʧÿÆ·ˆÿ§�Cÿ¨�Aÿ±šRÿ¹£`ÿ´�Uÿ°—Hÿ¶�RÿÌ»„ÿÛΧÿÞÓ¯ÿßÔ¯ÿ�•{ÿ@<2ÿÿÿÿÿÿÿRM@ÿæÚ´ÿæÚ´ÿÝÍšÿɯ]ÿȯ[ÿȯ[ÿȯ\ÿȯ\ÿȯ\ÿȯ\ÿȯ\ÿȯ[ÿȯ[ÿȯ[ÿȯ[ÿÈ®[ÿÈ®ZÿÈ®ZÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÇ­XÿǬWÿǬWÿǬWÿǬWÿƬWÿƬUÿÆ«TÿÆ«SÿŪRÿŪRÿŪRÿŪQÿèOÿèNÿèNÿèNÿçMÿçLÿæKÿæKÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤EÿÁ¤DÿÀ£CÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¼ž>ÿ»ž>ÿª�8ÿÿÿÿÿÿ'æÿÿÿ-%ÿ}g&ÿh'ÿ�j'ÿƒk(ÿ†n(ÿ‡o)ÿ›‡Lÿµ�ÿ¨ …ÿ(%ÿÿÿÿÿÿÿÿÿxdÿÔɧÿÕʨÿ¹¦jÿ¦Ž@ÿµ ^ÿÓÇžÿØÍ¨ÿÓÆœÿÆ´|ÿ´šLÿ´œLÿ¿¨bÿÑÀ�ÿàÔ°ÿàÕ°ÿÓȦÿ•�uÿÿÿÿÿ ÿËÁŸÿæÚµÿåÙ³ÿйqÿɰ\ÿɰ]ÿɰ]ÿɰ^ÿɰ]ÿɰ^ÿɰ^ÿɰ^ÿɰ]ÿɰ]ÿɰ]ÿɰ]ÿɰ]ÿɯ\ÿɯ[ÿȯZÿȯZÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÇ­XÿÇ­XÿǬWÿǬWÿǬWÿƬUÿƬUÿÆ«TÿŪSÿŪRÿŪRÿŪRÿÄ©PÿèNÿèNÿèNÿèLÿæKÿæKÿæKÿ¦IÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤FÿÁ¤EÿÀ£CÿÀ£CÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¼Ÿ?ÿ»ž>ÿ»ž>ÿXKÿÿÿÿÿ¥9ÿÿÿÿo[!ÿ}g&ÿh'ÿ�j'ÿ„l(ÿ†n)ÿ–�CÿŹ–ÿŸ•|ÿÿÿÿÿÿÿÿÿÿ&$ÿÔȧÿÕʧÿ˽“ÿ©’Hÿ§�BÿÔÈ¡ÿ‘ŠrÿnhVÿÕʨÿÛЬÿ×ÉŸÿ¬jÿ·�Oÿº RÿÄ®hÿÜϤÿáÕ±ÿãײÿÚÏ«ÿmhVÿ ÿÿ}vbÿäÙ³ÿæÛµÿÛË–ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ]ÿɯ\ÿȯ\ÿȯ[ÿȯYÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÇ­XÿǬWÿǬWÿǬWÿƬVÿƬUÿÆ«TÿÆ«SÿŪRÿŪRÿŪRÿèOÿèNÿèNÿèNÿçMÿæKÿæKÿæKÿ¦IÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤FÿÀ£DÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¼ž>ÿ»ž>ÿ§�7ÿÿÿÿÿôÍÿÿÿ+# ÿ|f%ÿ~g&ÿ€i'ÿƒk'ÿ…m(ÿ‹t2ÿ»®†ÿ­¤ˆÿÿÿÿÿÿÿÿÿÿÿ¬£‡ÿÔɧÿÓÈ¥ÿ·¤hÿ¥�AÿÁ°zÿÌÁ¡ÿ2/'ÿÿÿ�ˆpÿÏÅ£ÿÞÒ¯ÿÒ‘ÿ½¤Zÿ»¢Qÿ½¤SÿÑ¿…ÿáÕ¯ÿãײÿäÙ´ÿÝÓ¯ÿ“ŒtÿÍâÿæÛ¶ÿåÚ³ÿɱ`ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿʱ_ÿʱ_ÿɰ_ÿʱ_ÿɰ_ÿʱ_ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ]ÿɯ\ÿȯ[ÿÈ®[ÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÇ­XÿÇ­XÿǬWÿǬWÿƬVÿƬUÿÆ«TÿŪSÿŪRÿŪRÿŪRÿèPÿèNÿèNÿèMÿçLÿæKÿæKÿæJÿÂ¥HÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤FÿÀ£CÿÀ£CÿÀ¢Bÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ»ž>ÿ¶š<ÿMAÿÿÿÿýqüÿÿÿcQÿ|f&ÿ~g&ÿ€i'ÿƒk'ÿ…m(ÿ£’^ÿȽžÿ-+$ÿÿÿÿÿÿÿÿÿÿXTFÿÐÅ¥ÿÔɧÿɼ�ÿ¤‹?ÿ®˜SÿÕɦÿ„~iÿÿÿÿÿ:7.ÿ™�xÿÞÓ°ÿÞÓ®ÿμ‚ÿÁ©]ÿ¿¦TÿÅ­cÿ×Ç�ÿáÔªÿåÚµÿæÚµÿæÛ¶ÿæÛ¶ÿÔÀÿɰ_ÿʱ_ÿʱ`ÿʱ`ÿʱ`ÿʱ`ÿʱ`ÿʱ`ÿʲaÿʱ`ÿʱ`ÿʱ`ÿʱaÿʱ`ÿʱ_ÿɰ_ÿɰ_ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ]ÿɯ]ÿȯ\ÿÈ®[ÿÈ®YÿÈ®YÿÈ®YÿÇ­XÿÇ­XÿǬWÿǬWÿƬVÿƬUÿÆ«TÿŪRÿŪRÿŪRÿŪQÿèOÿèNÿèNÿèMÿæKÿæKÿæKÿ¦JÿÂ¥HÿÂ¥HÿÁ¤GÿÁ¤FÿÀ£EÿÀ£CÿÀ£BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ»ž>ÿ»ž>ÿ‰s-ÿÿÿÿÿ»‹ÿÿÿ ÿv`#ÿ}f&ÿ~g&ÿ‚j'ÿƒk(ÿ‰s1ÿ÷”ÿgaQÿÿÿÿÿÿÿÿÿÿÿ¬£ˆÿÓȧÿÔɧÿ©“Mÿ¥�Aÿƶ†ÿÌÁ¡ÿÿÿÿÿÿÿ ÿ>;1ÿÁ·˜ÿàÕ±ÿÜΣÿϽ€ÿ©XÿÄ«ZÿʵkÿÕÂ…ÿßÑ¢ÿáÓ§ÿØÇŒÿʲaÿʲaÿʲaÿʲaÿʲaÿʲaÿʲaÿʲaÿʲaÿʲaÿʲaÿʲaÿʲaÿʲaÿʲaÿʲaÿʲaÿʲaÿʱ`ÿʱ_ÿʱ_ÿɰ^ÿɰ^ÿɰ^ÿɰ]ÿɰ]ÿȯ[ÿȯZÿÈ®YÿÈ®YÿÇ­XÿÇ­XÿǬWÿǬWÿǬVÿƬUÿÆ«TÿÆ«RÿŪRÿŪRÿŪQÿÄ©NÿèNÿèNÿèMÿçKÿæKÿæJÿ¦IÿÂ¥HÿÂ¥HÿÁ¤GÿÁ¤FÿÁ¤CÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ»ž>ÿ­’9ÿ)# ÿÿÿÿî.ÐÿÿÿH;ÿzd$ÿ}f&ÿh&ÿ‚j'ÿƒk(ÿ¦•bÿ¼²”ÿ ÿÿÿÿÿÿÿÿÿÿ_ZKÿÓȧÿÓȧÿ»ªtÿ¤‹@ÿ²�]ÿÕʧÿhbRÿÿÿÿÿÿÿÿÿÿztaÿÚϬÿâײÿÞÒ§ÿ̶qÿÆ­\ÿȯ^ÿ˳dÿ˳dÿʲbÿʲaÿʲaÿʲaÿʲaÿʲaÿ˲bÿ˲bÿ˲bÿ˲bÿ˲bÿ˲bÿ˲bÿ˲bÿʲaÿʲaÿʲaÿʲaÿʲaÿʲaÿʲaÿʱ`ÿʱ`ÿʱ^ÿɰ^ÿɰ^ÿɰ^ÿɰ\ÿȯ[ÿȯZÿÈ®YÿÈ®YÿÈ®YÿÇ­XÿǬWÿǬWÿƬUÿƬUÿÆ«TÿÆ«RÿŪRÿŪRÿÄ©QÿÄ©NÿèNÿèNÿçMÿçKÿæKÿæJÿ¦IÿÂ¥HÿÁ¤GÿÁ¤FÿÁ¤EÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ½Ÿ?ÿ¼Ÿ?ÿ»ž>ÿYKÿÿÿÿÿ^3õÿÿ ÿaPÿ{e%ÿ}g&ÿh&ÿ‚j'ÿ„l(ÿĹ—ÿrlYÿÿÿÿÿÿÿÿÿÿÿĺ›ÿÓȧÿÍÁ›ÿ£ŒDÿ¦ŽFÿȹŒÿ¸¯’ÿÿÿÿÿÿÿÿÿÿÿÿ(& ÿ£›�ÿÙάÿäÙµÿÙÉ“ÿË´gÿʲbÿʲaÿʲaÿʲaÿʲaÿʲbÿ˲bÿ˲bÿ˲cÿ˳cÿ˳cÿ˳cÿ˳cÿ˳cÿ˳cÿ˳cÿ˲bÿ˲bÿ˲bÿ˲bÿ˲bÿʲaÿʲaÿʲaÿʲaÿʱ`ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɯ]ÿȯ[ÿȯ[ÿÈ®YÿÈ®YÿÈ®YÿÇ­XÿǬWÿǬWÿƬUÿƬUÿÆ«SÿŪRÿŪRÿŪRÿÄ©PÿèNÿèNÿèNÿçLÿæKÿæKÿ¦IÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤FÿÁ¤DÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¼Ÿ?ÿ•~1ÿ ÿÿÿÿ¬eÿÿÿ! ÿu_#ÿ|e%ÿ~g&ÿ�i'ÿ‚k'ÿ’~CÿǽžÿA=3ÿÿÿÿÿÿÿÿÿÿ‰‚lÿÒǧÿÓȧÿ´¡fÿ£‹@ÿ´ dÿÖ̪ÿ20(ÿÿÿÿÿÿÿÿÿÿÿÿÿÿHE9ÿª£‡ÿåÚ¶ÿåÙ²ÿÚÊ’ÿѼwÿ̵gÿ˲bÿ˲cÿ˳cÿ˳dÿ˳dÿ˳dÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿ˳dÿ˳dÿ˳eÿ˳dÿ˲cÿ˲cÿ˲bÿ˲bÿʲaÿʲaÿʲaÿʲaÿʱ`ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɯ\ÿÈ®YÿÈ®YÿÈ®YÿÇ­XÿÇ­XÿǬWÿǬWÿƬUÿƬUÿÆ«SÿŪRÿŪRÿŪQÿèNÿèNÿèNÿèMÿçLÿæKÿæJÿ¦IÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤EÿÀ£CÿÀ¢CÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¼ž>ÿ»ž>ÿ  ÿÿÿÿêªÿÿÿ<1ÿzd%ÿ|f%ÿh&ÿ�i'ÿƒl(ÿ¥“bÿµ¬�ÿ*(!ÿÿÿÿÿÿÿÿÿ;7.ÿÑǦÿÒǧÿǹŽÿ£ŒDÿ¤�DÿÒÆ¢ÿƒ}hÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ>;1ÿª¢‡ÿçÜ·ÿæÚµÿáÓ¦ÿÚÉ‘ÿÒ½xÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿ˳dÿ˳cÿ˲bÿ˲bÿʲaÿʲaÿʲaÿʲ`ÿɰ_ÿɰ^ÿɰ^ÿɰ^ÿȯ\ÿȯZÿÈ®YÿÈ®YÿÇ­XÿÇ­XÿǬWÿǬWÿƬUÿÆ«TÿŪSÿŪRÿŪRÿÄ©PÿèNÿèNÿèNÿçLÿæKÿæKÿ¦IÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤FÿÀ£CÿÀ£CÿÀ£BÿÀ¢Aÿ¾ ?ÿ¾ ?ÿ¾ ?ÿ¼Ÿ?ÿ»ž>ÿ`Q ÿÿÿÿÿ(íÿÿÿ]Lÿzd%ÿ}f%ÿ€h&ÿ�j'ÿƒl(ÿ±¢vÿ¨ …ÿÿÿÿÿÿÿÿÿÿµ¬�ÿÑǦÿÑÆ¤ÿ°œ`ÿ¡‰@ÿ³‚ÿ¸™ÿ,*#ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ#!ÿ�‰rÿÝÒ°ÿåÚ¶ÿæÛµÿ䨝ÿÝ͘ÿÔ¿~ÿÌ´gÿÌ´gÿÌ´gÿÌ´gÿÌ´gÿÌ´gÿÌ´gÿÌ´gÿÌ´gÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿÌ´eÿ˳dÿ˳cÿ˲cÿʲaÿʲaÿʲaÿʲaÿʱ_ÿɰ_ÿѼwÿÖćÿÖĈÿÒ½yÿÈ®YÿÈ®YÿÇ­XÿÇ­XÿǬWÿǬVÿƬUÿÆ«TÿŪRÿŪRÿŪQÿÄ©OÿèNÿèNÿçMÿçLÿæKÿæKÿÂ¥IÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤EÿÀ£CÿÀ£BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ»ž>ÿ¥Œ6ÿÿÿÿÿw#ÿÿÿ ÿzd%ÿ{d%ÿ}f&ÿ€h&ÿ�j'ÿƒl(ÿ´¦|ÿ£š�ÿÿÿÿÿÿÿÿÿa\MÿÎäÿÑǦÿĶ‹ÿ ˆ>ÿ­˜XÿÒÇ¥ÿ|ubÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿSOBÿ�•}ÿ¹œÿÖ̬ÿèݺÿèݺÿãÖ«ÿÝ̘ÿ×ćÿÒ¼wÿϸnÿηmÿηlÿηlÿͶjÿ͵iÿ͵iÿ͵iÿ͵hÿ͵hÿͶiÿ̵hÿ̵iÿ͵jÿϹpÿÕ‚ÿÛË”ÿáÓ§ÿæÛ¶ÿ×ͪÿÊÀŸÿÏÄ£ÿæÚµÿä×®ÿÒ½yÿÈ®YÿÇ­XÿǬWÿǬWÿƬVÿƬUÿÆ«SÿŪRÿŪRÿÄ©PÿèNÿèNÿèNÿçLÿæKÿæKÿ¦IÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤EÿÀ£CÿÀ£CÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¼ž>ÿ»ž>ÿÿÿÿÿÀnÿÿÿ)! ÿzd%ÿ{e%ÿ~g&ÿ€h&ÿ�j'ÿƒl)ÿµ¦|ÿ§ž„ÿÿÿÿÿÿÿÿÿ²ª�ÿÑǦÿÑÆ¤ÿ£ŒHÿ ˆ@ÿŶŠÿ¸šÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ<:0ÿb]Nÿ…kÿ§Ÿ†ÿƼŸÿßÕ³ÿèݺÿèݺÿæÛµÿãÖ­ÿâÔ§ÿáÒ¤ÿàÑ¢ÿßÏŸÿßРÿßРÿàÒ¢ÿáÓ¥ÿã׬ÿåÙ²ÿçܹÿåÚ¶ÿÊÀ ÿ¬£ˆÿ�†oÿhbRÿA=3ÿ%$ÿ1/'ÿojXÿÚΫÿæÚ´ÿÔÀ~ÿÇ­XÿÇ­XÿǬWÿǬWÿƬUÿÆ«TÿŪSÿŪRÿŪRÿÄ©OÿèNÿèNÿçLÿçLÿæKÿ¦JÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤FÿÀ£DÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¼Ÿ?ÿ»ž>ÿSFÿÿÿÿû½ÿÿÿL?ÿzd%ÿ|e%ÿg&ÿ€i&ÿ‚k(ÿ„m)ÿ²£xÿ³©Žÿ'%ÿÿÿÿÿÿÿkeUÿÑÅ¥ÿÑǦÿ´¡jÿž‡=ÿ°œ`ÿÓȧÿTPBÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿ! ÿ0.&ÿGD9ÿa]Nÿto]ÿƒ~jÿŽˆrÿ–�xÿ–�xÿ•Žwÿ�ˆsÿ‡�lÿxr`ÿd_OÿPL?ÿ20(ÿ$"ÿÿ ÿÿÿÿÿÿÿ]YIÿåÙ³ÿãÖ¬ÿʱ_ÿÈ®YÿǬWÿǬWÿǬVÿƬUÿÆ«SÿŪRÿŪRÿŪPÿèNÿèNÿèMÿçLÿæKÿæJÿ¦IÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤DÿÀ£CÿÀ£BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ»ž>ÿ‰t-ÿÿÿÿÿúÿÿÿlX!ÿzd%ÿ|e%ÿh&ÿ€i&ÿ‚k(ÿ…m)ÿ©™iÿÆ»�ÿ<8/ÿÿÿÿÿÿÿÎãÿÑÆ¦ÿƹ‘ÿ ‰Bÿ ‰CÿÉ»’ÿ¬£ˆÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ***ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¼²”ÿæÚ´ÿÓ¾zÿÈ®YÿÇ­XÿǬWÿǬWÿƬUÿÆ«TÿŪRÿŪRÿŪRÿÄ©OÿèNÿèNÿèMÿçLÿæKÿ¦Iÿ¦IÿÂ¥HÿÁ¤FÿÁ¤EÿÁ¤DÿÀ£CÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ½Ÿ?ÿ»ž>ÿ¨Ž7ÿÿÿÿÿQÿÿÿÿzd%ÿzd%ÿ~f%ÿh&ÿ�i'ÿ‚l(ÿ…m*ÿ™†NÿÉ¿ŸÿfaQÿÿÿÿÿÿ’ŠsÿÐÅ¥ÿÑÆ¦ÿ­™^ÿ�…=ÿ² gÿÔÉ©ÿ&$ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ333ÿçççÿÂÂÂÿªªªÿ���ÿlllÿJJJÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ­¤ˆÿæÚ´ÿØÅ‰ÿÈ®YÿÇ­XÿǬWÿǬWÿƬUÿƬUÿŪSÿŪRÿŪRÿÄ©PÿèNÿèNÿèMÿçLÿæKÿæJÿ¦IÿÂ¥HÿÁ¤GÿÁ¤FÿÁ¤DÿÀ£CÿÀ¢Bÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¼Ÿ?ÿ°•:ÿ ÿÿÿÿ‘Qÿÿÿ ÿzd%ÿzd%ÿ~f%ÿh&ÿ�j(ÿ‚l(ÿ…n*ÿˆq.ÿȾžÿ¯¦‹ÿ ÿÿÿÿC@5ÿÏĤÿÐÅ¥ÿÁ³ˆÿœ…>ÿ¡ŠEÿÑÇ¥ÿys`ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿùùùÿóóóÿÚÚÚÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÁ¸™ÿæÚµÿ×ʼnÿÈ®YÿÈ®YÿÇ­XÿǬWÿǬWÿƬUÿÆ«TÿŪRÿŪRÿŪQÿèNÿèNÿèNÿçLÿæKÿæKÿ¦IÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤EÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿµ™<ÿ>4ÿÿÿÿÈ„ÿÿÿ7-ÿzd%ÿzd%ÿ~g%ÿh&ÿ�j(ÿ„l(ÿ…o*ÿ‡p,ÿ° rÿÊÀ ÿUQDÿÿÿ ÿµ¬�ÿÏĤÿÏÄ£ÿ§“Uÿ›„<ÿ³‡ÿ»±”ÿ%#ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÚÚÚÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ_[KÿæÛ¶ÿæÚµÿÒ½yÿÈ®YÿÈ®YÿÇ­XÿÇ­XÿǬWÿƬVÿÆ«TÿŪSÿŪRÿŪRÿÄ©OÿèNÿèNÿçLÿæKÿæKÿ¦JÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤EÿÀ£CÿÀ£BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¸›=ÿZLÿÿÿÿí¯ÿÿÿL>ÿzd%ÿ|d%ÿ~g&ÿ€h&ÿ�k(ÿ„l(ÿ…o*ÿ‡p,ÿ‘{;ÿÊÀ¡ÿËÁ¢ÿÿÿmgVÿÍ¢ÿÏÄ¥ÿ½®�ÿ™‚:ÿ«˜[ÿÎáÿrlZÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¢¢¢ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ;;;ÿÿÿÿÿÿÿÿÿÿÿÿÿÿ***ÿGGGÿ444ÿ&&&ÿÿÿÿÿÿÿÿÿÿÿÿÿÑÆ¤ÿæÛ¶ÿâÕ©ÿʲbÿȯ[ÿÈ®YÿÈ®YÿÇ­XÿǬWÿǬWÿÆ«UÿÆ«SÿŪRÿŪRÿÄ©PÿèNÿèNÿèMÿæKÿæKÿæJÿ¦IÿÂ¥HÿÁ¤FÿÁ¤FÿÁ¤DÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ½Ÿ?ÿ»ž>ÿr`%ÿÿÿÿùÜÿÿÿ`Nÿzd%ÿ|d%ÿ~g&ÿ€h&ÿ‚k(ÿ„l)ÿ†o+ÿˆq,ÿ‹t0ÿ¯žoÿËÁ¢ÿ© †ÿ0-&ÿ²©�ÿÏĤÿÎâÿšƒ=ÿ›ƒ=ÿĶŒÿº°”ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿjjjÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿqqqÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ«««ÿÿÿÿÿ÷÷÷ÿÝÝÝÿÁÁÁÿ§§§ÿ‹‹‹ÿ```ÿ ÿÿÿÿÿÿÿ‚|gÿçÛ·ÿæÛ¶ÿÔÁ�ÿɯ\ÿȯZÿÈ®YÿÈ®YÿÇ­XÿǬWÿǬWÿƬUÿÆ«TÿŪRÿŪRÿÄ©QÿèNÿèNÿèNÿçLÿæKÿæJÿ¦IÿÂ¥HÿÂ¥GÿÁ¤FÿÁ¤DÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ½Ÿ?ÿ»ž>ÿ�m+ÿÿÿÿüîÿÿÿkX ÿzd%ÿ|e%ÿg&ÿ€h&ÿ‚k(ÿ„m)ÿ†o+ÿ‰q,ÿŠs.ÿ•~@ÿö‘ÿÌ¢ÿ½´–ÿÎãÿÏĤÿ­›cÿ˜�:ÿ­š_ÿÒǦÿGD9ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ777ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ£££ÿÿÿÿÿÿÿÿÿÿÿÿÿ888ÿûûûÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ«««ÿÿÿÿÿÿÿ0.&ÿçÜ·ÿçÛ·ÿàÑ£ÿ˳eÿɰ]ÿȯ[ÿÈ®YÿÈ®YÿÇ­XÿǬWÿǬWÿƬVÿÆ«TÿŪRÿŪRÿŪRÿèNÿèNÿèNÿçLÿæKÿæKÿ¦IÿÂ¥HÿÂ¥GÿÁ¤FÿÁ¤EÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¼Ÿ?ÿŒv.ÿ ÿÿÿþòÿÿÿp\"ÿzd%ÿ|e%ÿg&ÿ€j'ÿ‚k(ÿ…m*ÿ†o+ÿ‰q,ÿŠs.ÿŒu0ÿŸŠPÿËÁ ÿÍãÿÎãÿÁ³Šÿ™‚<ÿ›ƒ=ÿɼ–ÿ–�xÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ!!!ÿòòòÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÞÞÞÿ ÿÿÿÿÿÿÿÿÿÿÿÿ¯¯¯ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿøøøÿ,,,ÿÿÿÿÿÿÿº±“ÿçÜ·ÿæÛ¶ÿÔ¿}ÿɰ^ÿɰ^ÿɰ[ÿȯZÿÈ®YÿÈ®YÿÇ­XÿǬWÿƬVÿƬUÿÆ«SÿŪRÿŪRÿÄ©OÿèNÿèNÿçMÿæKÿæKÿ¦JÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤EÿÀ£CÿÀ£BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¼Ÿ?ÿ–1ÿÿÿÿþ9õÿÿ ÿr]"ÿ{d%ÿ|e%ÿh&ÿ€j'ÿƒl(ÿ…m*ÿ†o+ÿ‰r-ÿ‹s.ÿŒu1ÿŽv2ÿ˜ƒCÿ³£rÿ°žjÿš…Aÿ—€9ÿ³¡lÿÒǧÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÌÌÌÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿöööÿÇÇÇÿ›››ÿlllÿAAAÿ+++ÿ ÿÿÿÿ555ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ’’’ÿÿÿÿÿÿÿa]NÿáÖ³ÿçÜ·ÿàÒ¢ÿʱ`ÿɰ^ÿɰ^ÿɯ\ÿȯ[ÿÈ®YÿÈ®YÿÇ­XÿǬWÿƬVÿƬUÿÆ«SÿŪRÿŪRÿÄ©OÿèNÿèNÿçMÿæKÿæKÿæJÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤EÿÀ£CÿÀ£BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿœ„4ÿÿÿÿþQ+öÿÿÿs^"ÿzd%ÿ}e%ÿh&ÿ�j(ÿƒl(ÿ…m*ÿ†o,ÿ‰r-ÿ‹t.ÿ�u0ÿŽx2ÿ�y4ÿ“z5ÿ”|5ÿ–8ÿ—€9ÿÏÄ£ÿ�†pÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ¦¦¦ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿøøøÿÚÚÚÿ¼¼¼ÿžžžÿyyyÿÐÐÐÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿåååÿ ÿÿÿÿÿÿÿ¾µ—ÿçܸÿçÜ·ÿÍ·lÿʱ`ÿɰ^ÿɰ^ÿɰ]ÿȯ[ÿÈ®YÿÈ®YÿÇ­XÿǬWÿƬVÿƬUÿÆ«TÿŪRÿŪRÿÄ©PÿèNÿèNÿçNÿæKÿæKÿæJÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤EÿÀ£CÿÀ£BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ£Š6ÿ! ÿÿÿþd9ùÿÿÿs^"ÿ{d%ÿ~g%ÿh&ÿ�j(ÿ„l(ÿ…n*ÿˆq,ÿ‰r.ÿ‹t/ÿ�u1ÿ�x2ÿ‘y4ÿ“{5ÿ”|5ÿ–8ÿ˜€9ÿÍŸÿÑÆ¦ÿ™‘yÿD@6ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ���ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿTTTÿÿÿÿÿÿÿkfUÿèݹÿçܸÿØÇŒÿʲaÿʲaÿɰ_ÿɰ^ÿɰ]ÿȯ[ÿÈ®YÿÈ®YÿÈ®YÿǬWÿǬWÿƬUÿÆ«TÿŪRÿŪRÿŪQÿèNÿèNÿèMÿçKÿæKÿæJÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤FÿÀ£DÿÀ£CÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¨�7ÿ% ÿÿÿÿrEùÿÿÿt_#ÿ{d%ÿ~g&ÿ€h&ÿ�j(ÿ„l(ÿ…o*ÿˆq,ÿŠr.ÿ‹t.ÿ�u1ÿ�x2ÿ‘y4ÿ“{5ÿ•}7ÿ–8ÿ˜�9ÿ°žgÿÒǧÿÓȨÿÊ¿ ÿ”�uÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ```ÿüüüÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¼¼¼ÿÿÿÿÿÿÿÿÚЮÿèݹÿâÕªÿÌ´gÿʲaÿʲaÿɰ_ÿɰ^ÿɰ^ÿɯ\ÿÈ®ZÿÈ®YÿÈ®YÿǬWÿǬWÿƬUÿÆ«TÿŪRÿŪRÿŪQÿèNÿèNÿèMÿçLÿæKÿæJÿ¦IÿÂ¥HÿÂ¥GÿÁ¤FÿÀ£DÿÀ£CÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¬�8ÿ)" ÿÿÿÿzJúÿÿ ÿt_#ÿ|d%ÿ~g&ÿ€h&ÿ�k(ÿ„l(ÿ†n*ÿˆq,ÿŠs.ÿŒt0ÿ�v1ÿ�x2ÿ‘y4ÿ“{5ÿ•}7ÿ–9ÿ˜�:ÿ™‚;ÿ¦‘PÿÈ»“ÿÔÉ©ÿÕÊ©ÿÒȨÿhcSÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ@@@ÿëëëÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýýýÿöööÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ÷÷÷ÿRRRÿÿÿÿÿÿÿ•ŽwÿèݹÿèݹÿÕÁ�ÿ˲bÿʲaÿʲaÿʱ_ÿɰ^ÿɰ^ÿɯ\ÿÈ®ZÿÈ®YÿÈ®YÿÇ­YÿÔÀ€ÿÛË–ÿÛË–ÿÔÀ~ÿÅ«TÿŪQÿèOÿèNÿèMÿçLÿæKÿæKÿÂ¥IÿÂ¥HÿÂ¥GÿÁ¤FÿÀ£DÿÀ£CÿÀ¢BÿÀ¢Aÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ­’:ÿ)# ÿÿÿÿvHùÿÿÿt_#ÿ|e%ÿ~g&ÿ€i'ÿ‚k(ÿ„m)ÿ†o*ÿˆq,ÿŠs.ÿŒt/ÿŽv1ÿ�x2ÿ’z4ÿ”{5ÿ•}7ÿ—9ÿ˜�:ÿšƒ:ÿ�…>ÿŸ‡?ÿ¶¤lÿÑÆ¢ÿÕË©ÿ×̬ÿ¼²•ÿA>3ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÛÛÛÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿåååÿiiiÿ‚‚‚ÿŸŸŸÿ¹¹¹ÿÔÔÔÿíííÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¸¸¸ÿÿÿÿÿÿÿ85-ÿèݺÿèݹÿàÒ£ÿ̵hÿ˲bÿʲaÿʲaÿʱ_ÿɰ^ÿɰ^ÿɰ\ÿÈ®ZÿÈ®YÿÌ´fÿâÕ©ÿÐÅ¢ÿÁ¶–ÿÌŸÿã×°ÿáÔ§ÿʲcÿÄ©OÿèNÿèMÿçLÿæKÿæJÿ¦IÿÂ¥HÿÂ¥HÿÁ¤FÿÀ£DÿÀ£CÿÀ¢AÿÀ¢Aÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿª�8ÿ& ÿÿÿÿk>øÿÿÿs^"ÿ|e%ÿg&ÿ€i&ÿ‚k(ÿ„m)ÿ†o*ÿ‰r,ÿŠs.ÿŒu0ÿŽv1ÿ�y2ÿ’z4ÿ”{5ÿ•}7ÿ—€9ÿ˜�:ÿšƒ;ÿ�…>ÿŸ‡?ÿ¡‰@ÿ§�KÿÁ±€ÿÓÇ£ÿØÍ¬ÿÙάÿ•Žvÿ:7.ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÊÊÊÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿâââÿ ÿÿÿÿ!!!ÿ111ÿAAAÿ¸¸¸ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþþþÿ888ÿÿÿÿÿÿÿÀ·šÿèݺÿçÜ·ÿÔÀ~ÿ˳dÿ˲bÿʲaÿʲaÿʱ`ÿɰ^ÿɰ^ÿɰ\ÿÈ®ZÿÈ®YÿàÒ£ÿ´«�ÿ42)ÿ ÿ$"ÿ˜�vÿ䨰ÿâÕªÿȯ[ÿèNÿèMÿçLÿæKÿæJÿ¦HÿÂ¥HÿÂ¥HÿÁ¤FÿÀ£DÿÀ£CÿÀ¢BÿÀ¢Aÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ¦Œ7ÿ# ÿÿÿÿY1õÿÿ ÿs]"ÿ{e%ÿg&ÿ€i&ÿ‚k(ÿ…m)ÿ†o*ÿ‰r,ÿŠs.ÿŒt/ÿŽv1ÿ�y3ÿ’z4ÿ”|5ÿ•}7ÿ—€9ÿ™�:ÿšƒ;ÿ�…>ÿŸ‡@ÿ¡‰@ÿ£‹Cÿ¥ŽEÿ±›[ÿƶ‡ÿÙάÿÚÏ­ÿË¢ÿ‘Šsÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¨¨¨ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿîîîÿEEEÿÿÿÿÿÿÿøøøÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿŸŸŸÿÿÿÿÿÿÿmhWÿåÚ·ÿèݺÿßÏŸÿÌ´eÿ˳dÿ˲bÿʲaÿʲaÿʱ`ÿɰ^ÿɰ^ÿɰ\ÿÈ®Zÿкqÿã×±ÿHE8ÿÿÿÿÿ¹¯Žÿ䨰ÿÝÍ›ÿéPÿèNÿçLÿæKÿæKÿ¦IÿÂ¥HÿÂ¥GÿÁ¤FÿÀ£DÿÀ£CÿÀ¢AÿÀ¢Aÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿŸ‡5ÿ ÿÿÿþF$òÿÿÿq]"ÿ}e%ÿh&ÿ€i&ÿƒk(ÿ…m)ÿ†o+ÿ‰r,ÿ‹s.ÿŒu0ÿŽw2ÿ�y3ÿ’z4ÿ”|5ÿ–}7ÿ—€9ÿ™‚:ÿ›ƒ;ÿž†>ÿŸ‡@ÿ¡‰@ÿ£‹Cÿ¥ŽDÿ¨�Gÿª’Jÿ¶¡`ÿÒÆ�ÿÚЮÿÜѯÿÐÆ¦ÿTPDÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿuuuÿþþþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿúúúÿeeeÿÿÿÿÿÿ™™™ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿôôôÿ ÿÿÿÿÿÿ ÿ¹œÿèݺÿèݺÿͶkÿÌ´eÿ˳dÿØÆ‹ÿâÖ«ÿ䨝ÿàÒ£ÿѼvÿɰ^ÿɰ\ÿÈ®ZÿÕÁ€ÿØÍ©ÿ75+ÿÿÿÿÿÿàÔ­ÿä×®ÿÔÁ‚ÿèNÿçLÿæKÿæKÿ¦IÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤DÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿš‚3ÿÿÿÿþ$ñÿÿÿmY!ÿ}e%ÿh&ÿ�j(ÿƒl(ÿ…m)ÿ‡p+ÿ‰r,ÿ‹t.ÿŒu0ÿŽw2ÿ�y3ÿ’{4ÿ”|5ÿ–~7ÿ˜€9ÿ™‚:ÿ›ƒ;ÿž†?ÿŸˆ@ÿ¡‰Aÿ¤ŒCÿ¦ŽDÿ¨�Gÿª’Hÿ«“Iÿ®–KÿÄ´}ÿÙͨÿÝÒ°ÿÝÓ±ÿ‹…oÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿCCCÿýýýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿƒƒƒÿÿÿÿÿ///ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿiiiÿÿÿÿÿÿÿto^ÿèÞ»ÿèݺÿ×ÅŠÿÌ´eÿϸnÿâÔ¨ÿ×Í«ÿº±“ÿ¼³•ÿÛЭÿæÛ¶ÿÜ̘ÿɰ\ÿÈ®ZÿÔÀ~ÿæÚ´ÿc^MÿÿÿÿÿÿSN@ÿ䨝ÿàÒ¤ÿɱ_ÿçLÿæKÿæKÿ¦IÿÂ¥HÿÂ¥HÿÁ¤FÿÀ£DÿÀ£CÿÀ£BÿÀ¢Aÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ‘z0ÿ ÿÿÿþìÿÿÿfSÿ}f%ÿh&ÿ�i'ÿƒl(ÿ…m)ÿ‡p+ÿˆq,ÿ‹t.ÿ�u/ÿ�w2ÿ�y3ÿ“{4ÿ”|5ÿ–~7ÿ˜€9ÿ™‚:ÿ›„;ÿž†>ÿ ˆ@ÿ¡‰Aÿ¤ŒCÿ¦ŽDÿ¨�Gÿª‘Hÿ¬”Jÿ®–Kÿ¯—Lÿ¸¢]ÿξ�ÿÜÑ®ÿÞÔ²ÿ·¯“ÿ96-ÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿýýýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¢¢¢ÿÿÿÿÿ¶¶¶ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÈÈÈÿÿÿÿÿÿÿ"!ÿÝÓ³ÿèݺÿãÕªÿ̵iÿÌ´fÿÚÉ�ÿÙÏ­ÿ>;1ÿÿÿLI<ÿÝÓ¯ÿæÛ¶ÿØÅ‰ÿÈ®ZÿÌ´eÿæÚ´ÿÖ̧ÿ/-%ÿÿÿÿÿÿ¨Ÿ�ÿãׯÿÕƒÿçMÿæKÿæJÿ¦IÿÂ¥HÿÂ¥GÿÁ¤FÿÁ¤EÿÀ£CÿÀ¢AÿÀ¢Aÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ‡r-ÿÿÿÿúæÿÿÿTEÿ}f&ÿ€h&ÿ�j'ÿƒl(ÿ…n)ÿ‡p+ÿŠr-ÿ‹t.ÿ�v0ÿ�w2ÿ�y3ÿ“{5ÿ•|6ÿ–~6ÿ˜�9ÿ™‚:ÿ›„;ÿž†=ÿ ˆ@ÿ¢ŠAÿ¤ŒCÿ¦�Dÿ¨�Gÿª’Hÿ¬”Jÿ®–Lÿ°˜Mÿ²šOÿ´�RÿðsÿÛÏ©ÿßÕ³ÿÎÅ¥ÿJF;ÿÿÿÿÿÿÿÿÿÿÿÿÿâââÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¿¿¿ÿÿÿÿJJJÿñññÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿùùùÿ___ÿÿÿÿÿÿÿ§ ˆÿèÞ»ÿçܸÿÖƒÿÌ´fÿͶiÿä×®ÿ‚|hÿÿÿÿÿJG;ÿæÛ¶ÿ䨝ÿϸnÿÈ®YÿØÆŒÿåÚ³ÿÊ¿�ÿÿÿÿÿÿD@4ÿÜѪÿãÖ«ÿƬVÿæKÿæJÿ¦IÿÂ¥HÿÁ¤GÿÁ¤FÿÀ£DÿÀ£CÿÀ¢AÿÀ¢Aÿ¾ ?ÿ¾ ?ÿ½ ?ÿzg)ÿÿÿÿõ¾ÿÿÿ?4ÿ|f&ÿ€h&ÿ�j'ÿƒl(ÿ†n)ÿ‡p+ÿŠs-ÿ‹t/ÿ�u0ÿ�w2ÿ‘z3ÿ’{5ÿ•}6ÿ—~6ÿ˜�9ÿš‚:ÿ›„;ÿž†=ÿ ˆ@ÿ¢ŠAÿ¤ŒBÿ¦�Eÿ©�Gÿª’Hÿ¬”Jÿ®–Lÿ°˜Lÿ³šOÿ´�Qÿ¶žTÿ½¦aÿÛΧÿàÖ´ÿáÖ´ÿNK?ÿÿÿÿÿÿÿÿÿÿÿÿ¨¨¨ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÞÞÞÿ ÿÿ ÿªªªÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÆÆÆÿÿÿÿÿÿÿIF<ÿèÞ¼ÿèÝ»ÿàÒ£ÿÌ´hÿÌ´fÿζkÿçÛ¶ÿlgVÿÿÿÿÿÿ‹…nÿæÚµÿÜ̘ÿȯ[ÿɰ^ÿÞÏžÿåÙ²ÿ‘‰qÿÿÿÿÿ ÿ¬£…ÿã×®ÿÔÀ€ÿæKÿæJÿÂ¥HÿÂ¥HÿÁ¤GÿÁ¤FÿÀ£DÿÀ£BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ»�>ÿdT!ÿÿÿÿؘÿÿÿ(! ÿ}f&ÿi&ÿ�j'ÿƒl(ÿ†n)ÿˆo+ÿ‰r-ÿŒt.ÿŽv/ÿ�w2ÿ‘z3ÿ“{5ÿ•}6ÿ—~7ÿ˜�9ÿšƒ:ÿœ„;ÿž†=ÿ ˆ@ÿ¢ŠAÿ¥�Cÿ¦�Eÿ©‘Gÿ«’Hÿ¬”Jÿ¯—Lÿ°˜Mÿ²›NÿµœQÿ¶ŸTÿ¸ Uÿº£WÿØÊžÿá×´ÿâØµÿ]YJÿÿÿÿÿÿÿÿÿÿÿlllÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüüüÿ---ÿÿ@@@ÿûûûÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýýýÿIIIÿÿÿÿÿÿ ÿÌæÿèÞ»ÿçÛ¶ÿÔ¿}ÿÌ´gÿÌ´eÿͶjÿåÙ³ÿÁ¸šÿÿÿÿÿÿ0.&ÿÔɧÿæÚ´ÿͶjÿÇ­XÿͶlÿãÖ¬ÿÜÑ«ÿ-+#ÿÿÿÿÿoiUÿàÔ«ÿãÕ«ÿæKÿæJÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤FÿÀ£DÿÀ£CÿÀ¢AÿÀ¢Aÿ¾ ?ÿ¾ ?ÿ¸›=ÿJ>ÿÿÿÿ¬fÿÿÿ ÿ}f&ÿ€i&ÿ‚j'ÿ„m)ÿ†n)ÿˆp+ÿ‰s-ÿŒt/ÿŽv/ÿ�x2ÿ‘y3ÿ“{4ÿ•}6ÿ—7ÿ™�9ÿšƒ;ÿœ„;ÿž‡=ÿ¡‰@ÿ¢ŠAÿ¥�Cÿ¦ŽDÿ©‘Gÿ«’Iÿ­”Jÿ®–Kÿ°˜Mÿ²›NÿµœQÿ¶ŸTÿ¸ Uÿº£Vÿ¼¥YÿÔÅ“ÿâ×µÿãÙ¶ÿe`Qÿÿÿÿÿÿÿÿÿÿ---ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿIIIÿÿ¿¿¿ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¬¬¬ÿÿÿÿÿÿÿup_ÿæÜºÿèÞ»ÿÞΜÿ͵hÿÌ´fÿÌ´eÿ̵gÿÝΛÿçܸÿŠ„mÿÿÿÿÿÿœ”zÿæÚ´ÿÝÍ™ÿÇ­XÿǬWÿ×ÅŠÿ䨰ÿŒ„lÿÿÿÿÿ#!ÿ×Ì¥ÿã×®ÿηmÿæJÿÂ¥HÿÂ¥HÿÁ¤GÿÁ¤EÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ´˜<ÿ,%ÿÿÿÿf2ÿÿÿÿxb%ÿh&ÿ‚j'ÿ„m)ÿ†n)ÿˆp+ÿŠr-ÿŒu/ÿŽv0ÿ�x1ÿ‘y3ÿ“|4ÿ•}6ÿ—7ÿ™€8ÿ›ƒ;ÿœ„;ÿž†=ÿ¡‰@ÿ¢ŠAÿ¤ŒCÿ§�Dÿ©‘Gÿ«’Iÿ­”Iÿ®–Kÿ±™Mÿ²šNÿ´œQÿ·ŸTÿ¸ Uÿ»£Vÿ½¥Xÿ¿¨\ÿÔÄ�ÿãØ¶ÿÝÔ±ÿTPCÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿqqqÿOOOÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿûûûÿÿÿÿÿÿÿ%#ÿËæÿèÞ¼ÿæÛµÿηlÿ͵hÿÌ´fÿÌ´eÿÌ´eÿѽwÿåÙ²ÿÞÔ°ÿC?5ÿÿÿÿÿ\XHÿàÔ¯ÿåÚ³ÿȯ]ÿǬWÿɰ_ÿã×­ÿÁ·•ÿ!ÿÿÿÿÿ½³‘ÿãÖ­ÿÚÉ’ÿ¦IÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤EÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ¯“:ÿ ÿÿÿÿ2ÿÿÿÿ[Kÿ€h'ÿ‚k'ÿ„m)ÿ†o)ÿˆp*ÿŠr-ÿŒu.ÿŽv0ÿ�x1ÿ’y3ÿ“|4ÿ•}6ÿ—7ÿ™�9ÿ›ƒ;ÿœ„;ÿž†=ÿ¡‰?ÿ£‹Aÿ¤ŒBÿ§�Dÿª‘Gÿ«“Iÿ­”Jÿ¯–Kÿ±™Mÿ²šNÿ´�Pÿ·ŸTÿ¹¡Uÿº¢Wÿ½¥Yÿ¿§ZÿÀ©[ÿØÈ–ÿäÚ·ÿäÚ·ÿ0.&ÿÿÿÿÿÿÿÿÿÎÎÎÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿžžžÿçççÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿuuuÿÿÿÿÿÿÿygÿèÞ¼ÿèÝ»ÿÖƒÿ͵hÿÌ´gÿÌ´eÿÌ´eÿ˳dÿ˲cÿÙÈŽÿæÛ¶ÿ™‘yÿÿÿÿÿ$#ÿÙΪÿåÚ³ÿÑ»vÿǬWÿƬVÿÚÉ’ÿåÙ±ÿFB6ÿÿÿÿÿ}v`ÿãÖ­ÿáÒ¥ÿ¦IÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤EÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ½Ÿ?ÿ�„4ÿÿÿÿÿÑÿÿÿ8.ÿ€h'ÿ‚k'ÿ„l(ÿ†o)ÿˆp*ÿŠs-ÿ‹t.ÿŽw0ÿ�x1ÿ’y3ÿ“|4ÿ–~6ÿ˜7ÿ™�9ÿ›ƒ:ÿ�…<ÿž†<ÿ¡‰?ÿ£‹Aÿ¥ŒBÿ¦ŽDÿ©‘Fÿ«“Iÿ­”Jÿ¯–Kÿ±™Mÿ³›Nÿµ�Oÿ·žSÿ¹¡Uÿº¢Vÿ½¥Xÿ¿§ZÿÀ©[ÿë]ÿÛËœÿåÚ·ÿÞÔ²ÿÿÿÿÿÿÿÿÿšššÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÖÖÖÿÿÿÿÿÿÿ'%ÿçݼÿèÞ»ÿáÓ¥ÿͶjÿÌ´gÿÌ´fÿÌ´eÿÌ´eÿ˳dÿ˲bÿË´fÿåÙ±ÿ½³•ÿÿÿÿÿÿÓÈ¥ÿåÚ³ÿØÅ‹ÿǬWÿƬUÿζlÿ䨰ÿvp[ÿÿÿÿÿEA5ÿãÖ­ÿâÔ¨ÿȯ[ÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤DÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿjY#ÿÿÿÿÜ‹ÿÿÿÿ€h'ÿƒk'ÿ„m)ÿ†o*ÿ‰p*ÿŠs-ÿŒt.ÿŽw0ÿ�x1ÿ’z3ÿ“|4ÿ•~6ÿ˜7ÿ™�9ÿ›„;ÿ�…<ÿž†<ÿ¡‰?ÿ£‹Bÿ¥�Bÿ¦ŽDÿ©‘Fÿ¬“Hÿ­•Jÿ¯–Kÿ±™Mÿ³›NÿµœOÿ¶žQÿ¹¡Uÿ»£Vÿ¼¤Xÿ¿§ZÿÁ©[ÿ«\ÿÅ­`ÿÝÏ ÿæÛ¸ÿÀ¶™ÿÿÿÿÿÿÿÿaaaÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿûûûÿkkkÿÿÿÿÿÿÿ¯¨�ÿèÞ»ÿçܸÿÔÀÿ͵hÿÌ´gÿÌ´eÿÌ´eÿ˳dÿ˳cÿʲaÿʲaÿßРÿÑÆ¤ÿ/-%ÿÿÿÿÿú™ÿåÚ³ÿÜË—ÿǬVÿƬUÿÆ«Tÿ䨰ÿ¡™|ÿÿÿÿÿÿãÖ­ÿâÕªÿͶiÿÂ¥HÿÂ¥GÿÁ¤FÿÁ¤DÿÀ£BÿÀ¢AÿÀ¢Aÿ¾ ?ÿ¾ ?ÿ½Ÿ?ÿ0)ÿÿÿÿ”>ÿÿÿÿp\"ÿƒk'ÿ„l(ÿ†o*ÿ‰p*ÿŠs,ÿŒt.ÿ�w0ÿ�x1ÿ’z3ÿ”{4ÿ•~6ÿ˜7ÿš�8ÿ›ƒ:ÿ�…<ÿŸ‡=ÿ¡‰>ÿ£‹Bÿ¥�Bÿ§ŽCÿ©’Fÿ«“Hÿ®•Jÿ¯–Kÿ±˜Lÿ³›NÿµœOÿ·ŸRÿ¹¡Tÿ»£Vÿ½¥Wÿ¾¦ZÿÁ©\ÿë]ÿÄ­^ÿÊ´lÿâÕªÿçܹÿ”�vÿÿÿÿÿÿÿ444ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÓÓÓÿ ÿÿÿÿÿÿ\XKÿèÞ»ÿèÝ»ÿßÏŸÿ͵iÿÌ´gÿÌ´fÿÌ´eÿÌ´eÿ˲cÿ˲bÿʲaÿʲaÿØÆ‹ÿÖÌ©ÿ63*ÿÿÿÿÿ¼³“ÿåÙ³ÿÜ̘ÿǬVÿÆ«TÿŪRÿÝΛÿÁ¶•ÿ ÿÿÿÿÿãÖ­ÿâÕ«ÿлtÿÂ¥HÿÁ¤GÿÁ¤EÿÁ¤DÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ·š=ÿÿÿÿÿF÷ÿÿÿM@ÿƒk'ÿ…m(ÿ‡o*ÿ‰q+ÿ‹s,ÿŒu.ÿ�w/ÿ�x1ÿ’z3ÿ”|4ÿ–~5ÿ—7ÿš�8ÿ›ƒ:ÿ�…;ÿŸ‡=ÿ¡‰>ÿ£‹Aÿ¥�Bÿ§ŽCÿ©‘Fÿ«“Gÿ®•Iÿ°—Kÿ±™Lÿ³›Nÿµ�Oÿ·žPÿ¹ Sÿ»£Vÿ½¥Wÿ¿¦XÿÁª[ÿë]ÿÄ­^ÿÆ®_ÿÏ»wÿæÛ¶ÿæÛ·ÿSOBÿÿÿÿÿÿ!!!ÿêêêÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýýýÿWWWÿÿÿÿÿÿ ÿÓɪÿèݺÿçÜ·ÿѼvÿÌ´hÿÌ´fÿÌ´eÿÌ´eÿ˳cÿ˲bÿʲaÿʲaÿʱ`ÿ×ÅŠÿÐÆ¤ÿ/-%ÿÿÿÿÿżšÿåÙ³ÿÜ̘ÿƬUÿÆ«UÿŪRÿ×ňÿÓÇ¢ÿ ÿÿÿÿÿÛϧÿãÖ«ÿÒ½zÿÂ¥GÿÁ¤FÿÁ¤EÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ|i)ÿÿÿÿû ¹ÿÿÿ,# ÿ‚k(ÿ…m(ÿ‡n)ÿˆq*ÿ‹r,ÿŒu.ÿŽv/ÿ‘y1ÿ“z2ÿ”|4ÿ–}5ÿ—€7ÿš�8ÿœƒ:ÿ�…<ÿŸ‡=ÿ¡‰>ÿ£‹@ÿ¥�Cÿ§�Cÿª‘Dÿ¬”Gÿ­•Iÿ°—Kÿ±™Lÿ³šMÿµ�Oÿ·žPÿº¡Sÿ»¢Uÿ½¥Wÿ¿§XÿÁ©[ÿë]ÿÅ­^ÿƯ_ÿɱbÿØÆ‹ÿçܹÿÓɨÿÿÿÿÿÿÿÈÈÈÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÂÂÂÿÿÿÿÿÿÿ‚{hÿæÛ¸ÿèݺÿÜÌ—ÿ͵hÿÌ´fÿÌ´eÿÌ´eÿÌ´eÿ˲cÿʲaÿʲaÿʲaÿʱ_ÿÞÎ�ÿ¼³•ÿÿÿÿÿÿÒǤÿåÙ³ÿÛÊ”ÿƬUÿÆ«SÿŪRÿÔÀÿÛЩÿ ÿÿÿÿÿÔÈ¢ÿãÖ«ÿÓ¾{ÿÁ¤GÿÁ¤FÿÁ¤DÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ½Ÿ?ÿ8/ÿÿÿÿÊ sÿÿÿÿq]"ÿ…m(ÿ‡n)ÿˆq*ÿ‹r+ÿ�u-ÿŽv/ÿ‘y1ÿ’z2ÿ”|4ÿ–~5ÿ˜€6ÿš�8ÿœƒ:ÿ�…;ÿŸ‡=ÿ¡‰>ÿ¤‹>ÿ¦�Cÿ§�Dÿ©�Dÿ¬“Gÿ­•Hÿ°—Kÿ²˜Lÿ³›Mÿ¶�Oÿ·žPÿ¹¡Rÿ¼£Tÿ½¥Wÿ¿§XÿÁ¨Yÿë\ÿÅ­^ÿƯ_ÿȰ`ÿ˲bÿÞÏ�ÿèݹÿ¡š�ÿÿÿÿÿÿ•••ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüüüÿ&&&ÿÿÿÿÿÿ+)#ÿÑǧÿèݺÿåÙ²ÿÌ´hÿÌ´fÿÌ´eÿÌ´eÿÌ´eÿ˲cÿʲaÿʲaÿʲaÿʱ`ÿ˳eÿ䨱ÿ–�wÿÿÿÿÿ)' ÿÙÍ©ÿåÙ³ÿÖćÿÆ«TÿŪRÿŪRÿÔÀ~ÿÖˤÿ ÿÿÿÿÿ×ˤÿãÕ«ÿÓ¾{ÿÁ¤GÿÁ¤FÿÁ¤DÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ®’:ÿ ÿÿÿÿ>õÿÿÿYJÿ…m(ÿ‡o)ÿ‰q+ÿ‹s+ÿ�u-ÿŽw/ÿ‘y0ÿ“z2ÿ•|4ÿ–~5ÿ˜6ÿ›‚8ÿœƒ9ÿž…;ÿ ‡=ÿ¡‰>ÿ£Š>ÿ¥�Bÿ§�Dÿ©‘Dÿ¬“Fÿ­•Hÿ¯—Jÿ²˜Lÿ³šMÿµœNÿ·ŸPÿ¹ Qÿ»£Sÿ¾¤Wÿ¿§XÿÁ¨Yÿª[ÿŬ]ÿǯ_ÿɱ`ÿ˲bÿ͵jÿãÖ¬ÿèݹÿ`[Lÿÿÿÿÿÿ***ÿHHHÿvvvÿ¥¥¥ÿÒÒÒÿÿÿÿÿÿÿÿÿÿÿÿÿ‚‚‚ÿÿÿÿÿÿÿ�†qÿèݺÿèݺÿÔÀ~ÿÌ´fÿÌ´eÿÌ´eÿ˳dÿ˲cÿ˲bÿʲaÿʲaÿʱ`ÿɰ_ÿÙÈ�ÿáÖ±ÿ<9/ÿÿÿÿÿc^MÿàÔ¯ÿåÙ²ÿϹqÿÆ«SÿŪRÿŪRÿÖĆÿĺ—ÿ ÿÿÿÿÿãÖ¬ÿãÕ«ÿÒ¾zÿÁ¤FÿÁ¤EÿÀ£CÿÀ£BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿr`&ÿÿÿÿû>Íÿÿÿ3)ÿ€i'ÿ‡o)ÿ‰p*ÿ‹s,ÿ�t,ÿ�w/ÿ�x0ÿ“{2ÿ”|3ÿ–~5ÿ˜6ÿ›‚8ÿœƒ9ÿž…:ÿ ‡<ÿ¡‰>ÿ£‹?ÿ¥Œ?ÿ§�Dÿ©‘Eÿ«’Eÿ®•Hÿ¯–Iÿ²™Kÿ´šMÿµœNÿ·žOÿ¹ Qÿ»¢Rÿ½¤Uÿ¿¦XÿÁ©YÿêZÿŬ[ÿÇ®_ÿɱ`ÿʲaÿʲaÿÔÀÿçÜ·ÿÕ˪ÿÿÿÿÿÿÿÿÿÿÿÿ!!!ÿSSSÿÿÿÿÿÿÿ.,%ÿèݺÿèݺÿÞÏŸÿ͵hÿÌ´eÿÌ´eÿ˳eÿ˲cÿ˲bÿʲaÿʲaÿʱ`ÿʱ_ÿÕÁ�ÿæÚµÿŒ…nÿÿÿÿÿÿ¢šÿåÙ³ÿåÙ²ÿÆ«TÿÆ«SÿŪRÿŪRÿÜÌ—ÿ¨Ÿ�ÿ ÿÿÿÿ ÿãÖ¬ÿãÕ«ÿÑ»uÿÁ¤FÿÁ¤DÿÀ£CÿÀ£Bÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ»ž>ÿ;1ÿÿÿÿØ‘ÿÿÿÿxc%ÿ‡o)ÿ‰p*ÿ‹s+ÿ�t,ÿ�w/ÿ�x0ÿ“{2ÿ•|3ÿ—~5ÿ˜€6ÿ›‚7ÿ�„9ÿž…:ÿ ‡<ÿ¡‰>ÿ£‹?ÿ¥�@ÿ§�Bÿ©‘Eÿ«’Eÿ­”Gÿ°–Iÿ²™Kÿ´šMÿ¶œNÿ·žOÿº¡Qÿ»¢Rÿ½¥Tÿ¿¦VÿÁ©Yÿê[ÿŬ[ÿÇ®]ÿɱ_ÿʲaÿʲaÿʲbÿÞÏ�ÿæÛ¸ÿ‡�lÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¼³–ÿèݺÿæÚ´ÿÒ½xÿÌ´eÿÌ´eÿ˳cÿ˳dÿ˲cÿʲbÿʲbÿʲbÿ˳dÿØÆŒÿæÛ¶ÿ¢šÿÿÿÿÿÿ64*ÿÓÈ¥ÿåÙ²ÿÙÈ�ÿÆ«TÿŪRÿŪRÿŪQÿâÕªÿxbÿÿÿÿÿ63)ÿãÖ¬ÿâÕ©ÿηkÿÁ¤EÿÀ£CÿÀ£CÿÀ¢Bÿ¿¡@ÿ¾ ?ÿ¾ ?ÿš�3ÿÿÿÿÿ‘:øÿÿÿK>ÿ‡o)ÿ‰q*ÿ‹s,ÿŽu,ÿ�v.ÿ‘y0ÿ“{1ÿ•|3ÿ—~4ÿ˜€6ÿš�7ÿ�„9ÿŸ…:ÿ †;ÿ¡ˆ<ÿ£‹?ÿ¥�@ÿ¨�Aÿª‘Eÿ«“Eÿ­”Fÿ°—Iÿ±™Jÿ´›Mÿ¶œNÿ·žOÿ¹ Pÿ»¢Rÿ½¤Sÿ¿§UÿÁ¨Xÿë[ÿŬ\ÿÇ®\ÿȯ^ÿʲ`ÿʲaÿʲaÿкrÿæÚ´ÿÒȧÿ-+$ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿgbSÿèݺÿèݹÿÝÍ™ÿÌ´eÿÌ´eÿлtÿØÆ‹ÿÜË–ÿÛË”ÿØÇŒÿØÅŠÿÜ̘ÿ䨱ÿÐÆ¤ÿrmZÿÿÿÿÿÿÿ—�vÿåÙ²ÿåÙ²ÿ˳dÿŪRÿŪRÿŪRÿʲbÿãׯÿPK=ÿÿÿÿÿicPÿãÖ¬ÿáÓ§ÿȯ]ÿÁ¤EÿÀ£CÿÀ£BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¼ž>ÿiY#ÿÿÿÿú:Îÿÿÿ ÿ‡n)ÿ‰q*ÿ‹s,ÿ�u,ÿ�v-ÿ‘y/ÿ”{1ÿ•}3ÿ—~3ÿ™€6ÿš�7ÿœ„8ÿŸ…:ÿ ‡;ÿ¢‰=ÿ¤‹?ÿ¥�@ÿ§Ž@ÿ©‘Cÿ¬“Fÿ­”Fÿ¯–Gÿ²˜Jÿ³›Kÿ¶œNÿ·žOÿ¹ Qÿ»¢Qÿ½¤Sÿ¿¦TÿÁ¨WÿêYÿŬ\ÿÇ®]ÿȯ]ÿʱ_ÿʱ`ÿʲaÿʲaÿÚÉ‘ÿçÜ·ÿ}wdÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÓɨÿèݹÿåÚ´ÿθoÿ˳dÿØÅŠÿ䨰ÿÑÇ¥ÿ¬£ˆÿÊÀ ÿÚάÿÎÄ£ÿ¨ …ÿe`Oÿ$"ÿÿÿÿÿÿÿ[VGÿåÙ³ÿåÙ²ÿÙÈ�ÿÅ«UÿŪRÿŪRÿÄ©Qÿ×ÅŠÿȽšÿ(&ÿÿÿÿÿ®¤„ÿãÖ¬ÿàÒ¥ÿ¦JÿÀ£DÿÀ£CÿÀ¢AÿÀ¢Aÿ¾ ?ÿ¾ ?ÿ´˜<ÿ ÿÿÿÿÎYÿÿÿÿO@ÿ‰q*ÿ‹r+ÿ�u,ÿ�v-ÿ‘x/ÿ“{1ÿ•}2ÿ—~4ÿ™€5ÿ›‚7ÿœ„8ÿŸ…:ÿ ‡;ÿ¢‰=ÿ¤Š>ÿ¥�@ÿ§ŽAÿª‘Bÿ¬“Fÿ®•Gÿ¯–Gÿ²˜Iÿ³›KÿµœLÿ¸žOÿº Pÿ»¢Rÿ½£Rÿ¿¦TÿÁ§UÿéWÿĬYÿÇ®]ÿɰ^ÿɰ^ÿɰ^ÿʱ`ÿʲaÿʲbÿæÛµÿáÖ²ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿŒ…pÿæÛ¸ÿçܹÿØÆ‹ÿ˲bÿϺqÿ䨱ÿœ•|ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿ<9/ÿåÙ³ÿåÙ²ÿâÕªÿ̵hÿŪRÿŪRÿŪRÿƬUÿáÔ§ÿ˜�uÿÿÿÿÿÿÒÆŸÿãÖ¬ÿÛË–ÿÁ¤EÿÀ£CÿÀ£BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ‚m+ÿÿÿÿÿYëÿÿÿ ÿ‡o*ÿ‹r+ÿ�u,ÿ�w-ÿ’x.ÿ“{1ÿ•}1ÿ—4ÿ™€4ÿ›‚7ÿœƒ8ÿž†9ÿ¡‡;ÿ¢‰<ÿ¤‹>ÿ¦�?ÿ§ŽAÿ©�Aÿ«’Dÿ®•Gÿ¯–Gÿ±˜Hÿ´šKÿ¶�Lÿ·žNÿº Pÿ»¡Qÿ½¤Sÿ¿¥SÿÁ¨UÿéVÿĬYÿÇ­[ÿɰ]ÿɰ^ÿɰ^ÿɰ^ÿʱ_ÿʲaÿÖĆÿçÜ·ÿzt`ÿÿÿÿÿÿÿÿÿÿÿÿÿÿ52*ÿÓÉ©ÿçܸÿãÖ­ÿ˲bÿ˲bÿÚÊ’ÿÚÏ­ÿ(& ÿÿÿÿÿÿÿÿÿÿÿÿÿD@5ÿÞÓ®ÿåÙ²ÿ䨰ÿÕÂ�ÿŪRÿŪRÿŪRÿÄ©PÿÔÁ�ÿÚϦÿD@4ÿÿÿÿÿYSCÿÜЧÿãÖ¬ÿϹrÿÁ¤DÿÀ£BÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¾ ?ÿ ÿÿÿÿì^ÿÿÿÿN@ÿŒs+ÿ�t+ÿ�w-ÿ’x.ÿ“{0ÿ•|1ÿ—2ÿ™€4ÿ›‚6ÿ�ƒ8ÿž†9ÿ ‡;ÿ£‰<ÿ¤‹>ÿ¦Œ>ÿ§�Aÿ©�Bÿ«’Bÿ­”Fÿ°–Hÿ±˜Hÿ´šIÿ¶œLÿ·žMÿº Oÿ»¡Qÿ½£Rÿ¿¥SÿÁ¨UÿéVÿÅ«WÿÆ­Yÿɯ[ÿɰ]ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿ˳dÿ䨝ÿãØ´ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿ˜�yÿçܸÿçܸÿѼvÿʲaÿʲaÿÝΛÿ×ͪÿ ÿÿÿÿÿÿÿÿÿÿÿ ÿpjWÿÜЫÿåÙ²ÿåÙ²ÿÙÇŽÿŪSÿŪRÿŪRÿŪQÿɰ`ÿàÒ¤ÿ —{ÿÿÿÿÿÿž•xÿãÖ¬ÿãÕ«ÿçKÿÁ¤CÿÀ£BÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿta&ÿÿÿÿÿ_Ûÿÿÿÿ~g'ÿ�t,ÿ�v,ÿ’y.ÿ“z/ÿ•|1ÿ—~2ÿ™€4ÿ›‚5ÿ�ƒ8ÿŸ…9ÿ ˆ:ÿ£‰<ÿ¤Š=ÿ¦Œ>ÿ¨�@ÿ©�Bÿ«’Bÿ­”Cÿ°–Gÿ²˜Hÿ³šIÿµ›Jÿ·žMÿ¹ Nÿ¼¢Pÿ¾£Rÿ¿¥SÿÁ§UÿêVÿÅ«WÿƬXÿÈ®Yÿȯ[ÿɯ]ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿ×ʼnÿçÛ·ÿwq^ÿÿÿÿÿÿÿÿÿÿÿÿ>;1ÿçÜ·ÿçÜ·ÿÜÌ—ÿʲbÿʱ`ÿʱ`ÿÝÍ™ÿßÔ±ÿPL?ÿÿÿÿÿÿÿÿÿÿ]XIÿ¸¯�ÿ䨲ÿåÙ³ÿåÙ²ÿÕƒÿÅ«UÿŪRÿŪRÿŪRÿèOÿÛË•ÿÖË¥ÿÿÿÿÿÿ2/&ÿÔÈ¡ÿãÖ¬ÿÖćÿÁ¤DÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ²–;ÿÿÿÿÿßjÿÿÿÿQCÿŒs+ÿ�v,ÿ‘x.ÿ”z/ÿ•}1ÿ—~2ÿ˜4ÿ›‚5ÿ�„7ÿŸ…9ÿ ‡9ÿ£‰<ÿ¤Š=ÿ¦Œ>ÿ¨Ž?ÿ©‘Aÿ«’Bÿ­”Cÿ¯•Eÿ²˜Hÿ³šIÿµ›Jÿ·�Lÿ¹ŸNÿ»¢Oÿ½¤Qÿ¿¥SÿÁ¦Tÿ©UÿĪVÿÇ­XÿÈ®YÿÈ®YÿȯZÿȯ\ÿɰ]ÿɰ^ÿɰ^ÿϸpÿã×®ÿÑÇ¥ÿÿÿÿÿÿÿÿÿÿÿÿÍâÿçÜ·ÿäׯÿкrÿʱ`ÿʱ_ÿʱ_ÿÕÁ�ÿæÚ´ÿÑÇ¥ÿ_[Kÿÿÿÿÿ ÿ=:0ÿŠ‚lÿÒȤÿâÖ°ÿåÙ³ÿåÙ²ÿã×­ÿкsÿŪSÿŪRÿŪRÿŪRÿèOÿØÇ�ÿãׯÿ52)ÿÿÿÿÿÿ‰‚hÿãÖ¬ÿãÕ«ÿƬUÿÀ£CÿÀ£BÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ]Oÿÿÿÿÿj×ÿÿÿ ÿ�k(ÿ�v,ÿ‘x.ÿ”z/ÿ•|0ÿ—~2ÿ™€3ÿ›‚5ÿ�„7ÿŸ†8ÿ ‡9ÿ¢‰:ÿ¥‹=ÿ§Œ>ÿ¨Ž>ÿª‘Aÿ«’Cÿ­”Cÿ¯•Dÿ°—Fÿ´šIÿµœJÿ·�Kÿ¹ŸMÿ»¡Nÿ½¤Pÿ¿¥RÿÁ§TÿèUÿÅ«WÿƬWÿÈ®YÿÈ®YÿÈ®YÿÈ®Zÿȯ[ÿɯ\ÿɰ]ÿɰ^ÿÝΛÿßÔ±ÿSOBÿÿÿÿÿÿÿÿÿÿsm[ÿçÛ¶ÿæÛ¶ÿÛÊ’ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿáÔ§ÿæÛ¶ÿæÚµÿæÚµÿæÚ´ÿáÕ°ÿæÚ´ÿæÚ´ÿåÚ³ÿåÚ³ÿåÙ³ÿåÙ²ÿä×®ÿÛÊ“ÿȰ]ÿŪSÿŪRÿŪRÿŪRÿÄ©PÿØÇŽÿáÕ­ÿa[Jÿÿÿÿÿÿ0-$ÿãÖ¬ÿãÖ¬ÿ×ćÿÀ¤EÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿŸ†4ÿÿÿÿÿØuþÿÿÿ9.ÿ�v,ÿ‘x-ÿ“z/ÿ–|0ÿ—1ÿ™€3ÿ›‚4ÿ�„6ÿŸ…8ÿ¡‡:ÿ¢‰:ÿ¤‹;ÿ§�>ÿ¨Ž>ÿª�@ÿ¬“Aÿ­”Cÿ¯–Dÿ°—Eÿ²˜GÿµœJÿ·�Kÿ¹ŸLÿ»¡Nÿ½£Oÿ¿¥QÿÀ§SÿèUÿŪVÿƬWÿÇ­XÿÈ®YÿÈ®YÿÈ®YÿÈ®Yÿȯ[ÿȯ[ÿɰ\ÿÔÀ~ÿåÚ³ÿš’zÿÿÿÿÿÿÿÿÿ$#ÿÙάÿæÛ¶ÿã×­ÿÌ´gÿɰ^ÿɰ^ÿɰ^ÿɰ^ÿɰ]ÿÌ´eÿÜË–ÿâÖªÿåÚ³ÿæÚ´ÿæÚ´ÿæÚ´ÿåÚ³ÿåÙ³ÿâÕªÿßÏŸÿØÆ‹ÿË´eÿÆ«TÿŪSÿŪRÿŪRÿŪRÿȯ]ÿÚÉ’ÿàÔ¬ÿjeRÿÿÿÿÿÿ ÿÁ¶’ÿãÖ¬ÿáÓ¥ÿɯ\ÿÀ£CÿÀ£BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ¼ž>ÿREÿÿÿÿþu×ÿÿÿÿdRÿ‘x-ÿ“z/ÿ–|0ÿ—~0ÿ™€3ÿ›‚4ÿ�„6ÿŸ…7ÿ¡‡9ÿ£‰:ÿ¤Š;ÿ¦�=ÿ©Ž?ÿª�?ÿ¬’Aÿ­“Bÿ¯–Dÿ±—Eÿ²™Fÿµ›Hÿ¸žKÿ¹ŸLÿº Mÿ¼¢Nÿ¿¤PÿÁ§Rÿ©SÿĪVÿǬWÿǬWÿÇ­XÿÇ­XÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®Zÿɰ]ÿä×®ÿ¸™ÿ!ÿÿÿÿÿÿÿÿ“‹tÿæÛ¶ÿæÛ¶ÿÔÁ�ÿɰ]ÿɰ]ÿɯ]ÿȯ\ÿȯ\ÿȯ[ÿÈ®ZÿÈ®YÿͶiÿлsÿÒ½yÿÒ½zÿÒ½zÿÑ»wÿйrÿ͵jÿɯ]ÿƬUÿÆ«TÿŪSÿŪRÿŪRÿŪRÿѽyÿßÑ¡ÿØÌ¦ÿHD7ÿÿÿÿÿÿÿ�”wÿãÖ¬ÿãÕ«ÿÕƒÿÀ£CÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ�y/ÿÿÿÿÿ×4ÿÿÿÿ ÿ†o*ÿ“z.ÿ•|/ÿ˜~1ÿ™1ÿ›‚4ÿœƒ4ÿŸ…6ÿ¡‡8ÿ£‰:ÿ¤Š;ÿ¦Œ<ÿ¨Ž=ÿª�?ÿ¬‘@ÿ­“Bÿ¯•Dÿ±—Eÿ³™Fÿ´šGÿ¶œIÿ¹ŸLÿ»¡Mÿ½¢Nÿ¾¤OÿÁ¦PÿéSÿĪTÿǬWÿǬWÿǬWÿǬWÿÇ­XÿÇ­XÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÛÊ’ÿåÙ³ÿFB7ÿÿÿÿÿÿÿ:7.ÿÚΫÿæÚµÿàÑ¡ÿɯ\ÿɯ\ÿȯ[ÿȯ[ÿȯZÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÇ­XÿǬWÿǬWÿǬWÿǬVÿƬUÿƬUÿÆ«TÿŪSÿŪRÿƬVÿÓ¾|ÿàÒ¤ÿãÖ­ÿ¥œÿÿÿÿÿÿÿÿxq[ÿàÓ©ÿãÕ«ÿßРÿçKÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ»ž>ÿÿÿÿÿÿ4‘ÿÿÿÿ3+ÿ‘x-ÿ•{.ÿ—~0ÿ™1ÿ›‚3ÿ�ƒ4ÿŸ†6ÿ¡‡8ÿ¢ˆ8ÿ¤Š;ÿ¦Œ<ÿ¨Ž=ÿ«�@ÿ¬‘@ÿ®“Aÿ¯•Cÿ±—Dÿ³™Fÿ´›Gÿ¶œHÿ¸žIÿ»¡Mÿ½£Nÿ¾¤OÿÁ¦PÿèRÿĪSÿƬUÿǬUÿǬWÿǬWÿǬWÿǬWÿÇ­XÿÇ­XÿÈ®YÿÈ®YÿϹpÿæÚ´ÿxr^ÿÿÿÿÿÿÿŸ—|ÿæÚµÿæÚ´ÿ͵iÿȯZÿÈ®ZÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÇ­XÿÇ­XÿÈ®\ÿÓ¾}ÿÙÆ�ÿØÅŠÿÒ½yÿͶkÿʲbÿ˲cÿθnÿÔÁ�ÿÝΛÿ䨰ÿ×̦ÿº±�ÿKG:ÿÿÿÿÿÿÿÿZUDÿÞѨÿãÕ¬ÿãÕ«ÿÈ®ZÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿ=4ÿÿÿÿÿ‘ Øÿÿÿÿ\Lÿ•{/ÿ—~0ÿ™1ÿ›�2ÿ�ƒ5ÿž…5ÿ¡‡6ÿ£‰9ÿ¥‹9ÿ§Œ<ÿ¨Ž=ÿª�>ÿ¬’@ÿ®“Aÿ°•Bÿ±—Dÿ³™Dÿ´›Gÿ¶œHÿ¸žHÿ¹ŸJÿ½£Mÿ¿¤OÿÀ¦Oÿ§PÿĪRÿÆ«TÿƬTÿƬVÿǬVÿǬWÿǬWÿǬWÿÇ­XÿÇ­XÿÇ­XÿÈ®YÿåÙ²ÿÍ ÿÿÿÿÿÿRM@ÿæÚ´ÿæÚ´ÿÙÇ�ÿÈ®ZÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÈ®YÿÇ­XÿÇ­XÿÇ­XÿÇ­XÿϹqÿâÕªÿÒǤÿ¸®�ÿɾœÿÛϪÿåÙ±ÿåÙ°ÿâÖ®ÿÕʤÿÁ¶•ÿ£›~ÿzt^ÿ?;0ÿÿÿÿÿÿÿÿÿe_LÿáÔªÿãÖ¬ÿâÔ©ÿͶjÿÀ£CÿÀ£CÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿo^%ÿÿÿÿÿØ K÷ÿÿÿ ÿ{f'ÿ—}/ÿ™1ÿ›�2ÿ�ƒ3ÿŸ…5ÿ ‡6ÿ£‰8ÿ¤Š9ÿ¦Œ:ÿ¨Ž=ÿª�=ÿ«‘?ÿ­“Aÿ°•Bÿ±–Cÿ³˜DÿµšFÿ¶œHÿ¸žIÿºŸIÿ»¡Kÿ¾£MÿÀ¦Pÿ§PÿÄ©QÿŪRÿŪSÿÆ«TÿÆ«TÿƬUÿƬVÿǬVÿǬWÿǬWÿǬWÿǬWÿÖÃ…ÿåÚ³ÿ¼³“ÿFC7ÿÿ ÿmhUÿ×ͨÿåÚ³ÿâÕªÿÌ´gÿÇ­XÿÇ­XÿÇ­XÿÇ­XÿÇ­XÿǬWÿǬWÿǬWÿǬWÿÈ®\ÿâÕªÿ¤›ÿ&$ÿÿ$"ÿ0-%ÿ74*ÿ96,ÿ52)ÿ,*"ÿÿ ÿÿÿÿÿÿÿÿÿ ÿ”ŒpÿáÔªÿãÖ¬ÿâÔ©ÿθnÿÀ£CÿÀ£CÿÀ¢BÿÀ¢Aÿ¾ ?ÿ¾ ?ÿ•}1ÿ ÿÿÿÿ÷K•ÿÿÿÿ ÿ’y.ÿ™0ÿ›�2ÿ�ƒ3ÿŸ„3ÿ ‡5ÿ¢ˆ7ÿ¥‹9ÿ¦Œ:ÿ¨Ž<ÿª�=ÿ¬‘>ÿ®”@ÿ¯•@ÿ²–Cÿ³˜DÿµšEÿ·œFÿ¸žIÿºŸIÿ»¡Jÿ½£KÿÀ¥Mÿ¨PÿÄ©QÿŪRÿŪRÿŪRÿÆ«SÿÆ«TÿÆ«TÿƬUÿƬUÿǬVÿǬWÿǬWÿɯ]ÿáÓ§ÿåÙ³ÿàÔ¯ÿÜѬÿÝÒ­ÿâ×°ÿåÙ³ÿåÙ³ÿÖćÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬVÿÓ¿}ÿàÔ®ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ0.%ÿÁ¶’ÿâÕ«ÿãÖ¬ÿâÕªÿͶkÿÀ£CÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿµ˜<ÿ ÿÿÿÿÿ•Õÿÿÿÿ ÿ—}0ÿš€1ÿœ‚2ÿŸ…3ÿ¡†5ÿ¢ˆ7ÿ¤Š7ÿ¦Œ:ÿ¨Ž;ÿª�>ÿ¬‘>ÿ®“?ÿ¯•Aÿ²—Bÿ´˜DÿµšDÿ·›Eÿ¸�Gÿº Jÿ¼¡Jÿ½£Kÿ¿¤LÿÁ¦NÿèPÿŪRÿŪRÿŪRÿŪRÿŪRÿÆ«TÿÆ«TÿƬUÿƬUÿƬUÿǬVÿǬVÿ˳eÿßРÿ䨰ÿåÙ²ÿåÙ²ÿ娱ÿäׯÿ×ĈÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬWÿǬVÿƬVÿƬUÿƬUÿØÇŒÿù—ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿx`ÿãÖ¬ÿãÖ¬ÿãÖ¬ÿàÒ¤ÿɰ`ÿÁ¤CÿÀ£CÿÀ£BÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ½Ÿ>ÿ4+ÿÿÿÿÿÕ"ìÿÿÿÿ8.ÿ–}/ÿ�ƒ2ÿž„3ÿ¡†4ÿ¢‡6ÿ¤Š7ÿ§Œ9ÿ¨Ž;ÿª�<ÿ¬‘>ÿ®“?ÿ¯”@ÿ±—Aÿ´˜BÿµšDÿ·›Eÿ¹�Fÿº Iÿ¼¢Jÿ½£Kÿ¿¤LÿÁ¦Mÿ§MÿÄ©OÿÄ©QÿŪRÿŪRÿŪRÿŪRÿŪRÿŪSÿÆ«TÿÆ«TÿƬTÿƬUÿƬUÿƬUÿÑ»uÿ×ňÿØÅŠÿÕÁ�ÿʱaÿǬVÿǬVÿǬVÿƬVÿǬVÿƬUÿƬUÿƬUÿƬUÿƬUÿÆ«TÿÆ«Sÿ×ňÿãׯÿ*(!ÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ@<0ÿ�ybÿÖÊ¢ÿãÖ¬ÿãÖ¬ÿâÕ©ÿ×ĈÿĨOÿÁ¤CÿÀ£CÿÀ£BÿÀ¢AÿÀ¢Aÿ¿¡@ÿºœ=ÿG<ÿÿÿÿÿì"?÷ÿÿÿÿG;ÿœ‚1ÿžƒ2ÿ¡†4ÿ£ˆ5ÿ¤‰6ÿ¦Œ8ÿ©Ž:ÿª�;ÿ¬‘=ÿ­’>ÿ°•@ÿ±–Aÿ³˜BÿµšCÿ·›Dÿ¹�Fÿ»žGÿ¼ Hÿ¾¢Jÿ¿¥KÿÁ¦Mÿ§MÿèNÿèNÿèOÿÄ©PÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪSÿŪSÿÆ«TÿÆ«TÿÆ«TÿÆ«TÿÆ«TÿÆ«TÿÆ«TÿÆ«TÿÆ«TÿÆ«TÿÆ«TÿÆ«TÿÆ«TÿÆ«SÿŪSÿŪSÿŪRÿŪRÿηmÿ䨰ÿÁ¶•ÿRM?ÿÿÿÿÿÿÿÿÿ'%ÿUPAÿކlÿ·¬‹ÿÖÊ£ÿãÖ¬ÿãÖ¬ÿãÖ¬ÿÚÉ�ÿʱbÿÁ¤FÿÀ£DÿÀ£CÿÀ£CÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¼ž>ÿTGÿÿÿÿÿ÷?aþÿÿÿÿNAÿž„2ÿ …3ÿ¢‡4ÿ¤‰6ÿ¦‹6ÿ¨�9ÿª�:ÿ¬‘<ÿ®“=ÿ¯”?ÿ²–Aÿ³˜Aÿµ™Bÿ¶›Eÿ¹�Eÿ»žGÿ¼ Hÿ¾¡HÿÀ¤JÿÁ¥KÿèMÿèNÿèNÿèNÿèNÿèOÿÄ©PÿŪQÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪSÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿƬVÿÝΚÿ䨰ÿßÓ«ÿÛЩÿÕʤÿƼ™ÿ·­�ÿ²©‰ÿº°ŽÿÌÁœÿÚͦÿÜШÿßÒªÿâÕ¬ÿãÖ¬ÿãÖ¬ÿãÖ¬ÿÛÊ”ÿȯ]ÿÂ¥IÿÁ¤FÿÀ£DÿÀ£CÿÀ£CÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¾ ?ÿcT!ÿÿÿÿÿþa‹þÿÿÿÿL?ÿ …3ÿ¢ˆ4ÿ¤‰5ÿ§‹6ÿ¨�8ÿª�:ÿ«�:ÿ®“=ÿ¯”>ÿ±–?ÿ³˜@ÿµ™Bÿ·›Cÿ¸�Dÿ»ŸFÿ½ Hÿ¾¢IÿÀ£Iÿ¦KÿçLÿèMÿèNÿèNÿèNÿèNÿèNÿèNÿÄ©OÿÄ©PÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪRÿŪQÿÄ©PÿÄ©PÿÇ®[ÿÜ̘ÿâÖ«ÿã×­ÿãׯÿãׯÿã×®ÿã×®ÿã×®ÿãÖ­ÿãÖ­ÿãÖ­ÿãÕªÿáÔ§ÿàÑ£ÿÕƒÿÇ­YÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤CÿÀ£CÿÀ£BÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¼ž>ÿkZ#ÿÿÿÿÿþ‹ŸÿÿÿÿÿD9ÿ›�1ÿ¤‰5ÿ§‹6ÿ¨�7ÿªŽ9ÿ«�:ÿ­’;ÿ°”<ÿ±–?ÿ´˜Aÿµ™Aÿ·›Cÿ¸œDÿºŸEÿ¼ Fÿ¾¢HÿÀ£IÿÂ¥JÿæKÿçLÿçLÿèMÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿÄ©NÿÄ©OÿÄ©PÿŪQÿŪQÿŪQÿŪQÿŪQÿŪQÿŪQÿŪRÿŪRÿŪQÿŪQÿŪQÿÄ©PÿÄ©PÿÄ©PÿÄ©OÿèNÿèNÿèNÿèNÿèNÿË´hÿÒ¾{ÿ×ňÿÙÇ�ÿÙÈ�ÿÚÈ�ÿÚÈ�ÿÙÇŽÿ×ʼnÿÓÀ~ÿϸoÿÈ®[ÿÂ¥HÿÂ¥HÿÁ¤FÿÁ¤FÿÁ¤EÿÁ¤CÿÀ£CÿÀ¢BÿÀ¢AÿÀ¢Aÿ¾ ?ÿµ™<ÿWIÿÿÿÿÿÿŸ�ÿÿÿÿÿ5,ÿœƒ2ÿ¥Š5ÿ§Œ7ÿªŽ8ÿ¬�8ÿ­’;ÿ¯”<ÿ±–=ÿ³˜?ÿµ™@ÿ¶šBÿ¹�DÿºžDÿ¼ŸEÿ¾¢Gÿ¿£GÿÁ¥IÿæJÿæKÿæKÿæKÿçLÿçLÿçMÿèMÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèOÿèOÿèOÿèOÿèOÿèOÿèOÿÄ©OÿèOÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèMÿçMÿçLÿçLÿæKÿæKÿæKÿæKÿ¦JÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÁ¤GÿÁ¤FÿÁ¤FÿÁ¤DÿÀ£CÿÀ£CÿÀ¢BÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ°”:ÿ@5ÿÿÿÿÿÿ��ÿÿÿÿÿÿ˜1ÿ§‹6ÿ©Ž7ÿ¬�9ÿ­‘9ÿ¯“;ÿ±•<ÿ²—=ÿµ™@ÿ·›Aÿ¸œCÿ»žDÿ¼ Eÿ¾¡Fÿ¿£GÿÁ¥Hÿ¦Iÿ¦JÿæKÿæKÿæKÿæKÿçLÿçLÿçLÿçMÿèMÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèMÿèMÿçLÿçLÿçLÿæKÿæKÿæKÿæKÿæJÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤FÿÁ¤FÿÁ¤EÿÁ¤DÿÀ£CÿÀ£BÿÀ¢AÿÀ¢AÿÀ¢Aÿ¾ ?ÿ¯“:ÿ& ÿÿÿÿÿÿ�˜ýÿÿÿÿÿ~i(ÿ©Ž7ÿ«�8ÿ®’9ÿ¯“:ÿ±•<ÿ³—=ÿ´˜>ÿ·›?ÿ¸œAÿºžCÿ¼ŸCÿ¾¡EÿÀ£GÿÁ¤GÿÂ¥Hÿ¦Iÿ¦JÿæJÿæKÿæKÿæKÿæKÿæKÿçLÿçLÿçLÿçLÿèMÿèNÿèMÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèNÿèMÿèMÿçLÿçMÿçLÿçLÿæKÿæKÿæKÿæKÿæKÿæKÿ¦Iÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÂ¥HÿÁ¤GÿÁ¤FÿÁ¤FÿÁ¤EÿÀ£CÿÀ£CÿÀ£BÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿ—2ÿ ÿÿÿÿÿý˜„ýÿÿÿÿÿ[Lÿ£ˆ5ÿ­‘9ÿ¯“:ÿ±•;ÿ³–;ÿ´™=ÿ¶š?ÿ·›?ÿºžBÿ¼ŸCÿ½ Dÿ¿¢FÿÁ¤GÿÂ¥HÿÂ¥HÿÂ¥Hÿ¦Iÿ¦Jÿ¦JÿæJÿæKÿæKÿæKÿæKÿçLÿçLÿçLÿçLÿçLÿçLÿçKÿçLÿçLÿèMÿèMÿçLÿçLÿçLÿçKÿçLÿçLÿçLÿæKÿæKÿæKÿæKÿæKÿæKÿæJÿ¦Hÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤GÿÁ¤FÿÁ¤FÿÁ¤EÿÁ¤DÿÀ£CÿÀ£CÿÀ¢AÿÀ¢AÿÀ¢Aÿ¿¡@ÿµ™<ÿfV"ÿÿÿÿÿÿý„Uóÿÿÿÿÿ.&ÿ‹u-ÿ®’9ÿ°”:ÿ³–;ÿ´˜<ÿ¶š>ÿ¸œ?ÿ¹�@ÿ»ŸBÿ½¡Cÿ¿¢DÿÁ¤FÿÁ¤FÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥Hÿ¦Iÿ¦Iÿ¦Iÿ¦Jÿ¦JÿæJÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæKÿæJÿæJÿ¦Jÿ¦Jÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥GÿÁ¤GÿÁ¤FÿÁ¤FÿÁ¤EÿÀ£DÿÀ£CÿÀ£CÿÀ¢BÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿž…4ÿ4,ÿÿÿÿÿÿóU1ãÿÿÿÿÿ ÿ\Mÿ±”:ÿ²•;ÿ´—;ÿ¶™=ÿ¸›>ÿ¹�?ÿ»ŸAÿ½ Aÿ¿£CÿÁ¤DÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤GÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥Hÿ¦Iÿ¦Iÿ¦Iÿ¦Jÿ¦JÿæJÿ¦JÿæJÿæJÿæKÿæKÿæJÿæKÿæJÿæJÿ¦Jÿ¦Iÿ¦Jÿ¦Iÿ¦Iÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÁ¤GÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤EÿÀ£DÿÀ£CÿÀ£CÿÀ£CÿÀ¢AÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¾ ?ÿdT!ÿÿÿÿÿÿÿã1Åþÿÿÿÿÿ'" ÿ“{0ÿ°“:ÿ¶™<ÿ¸›>ÿºœ>ÿ»ž?ÿ½ @ÿ¾¢BÿÀ£CÿÁ¤DÿÁ¤EÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤FÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥Hÿ¦Iÿ¦Iÿ¦Iÿ¦Iÿ¦Iÿ¦Iÿ¦Iÿ¦Iÿ¦Iÿ¦Iÿ¦Iÿ¦Iÿ¦Iÿ¦IÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÁ¤GÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤EÿÁ¤DÿÀ£CÿÀ£CÿÀ£CÿÀ£BÿÀ¢AÿÀ¢Aÿ¿¡@ÿ»�>ÿš‚3ÿ(" ÿÿÿÿÿÿþÅ|óÿÿÿÿÿÿLAÿ’z0ÿ·š=ÿº�>ÿ¼ž?ÿ½Ÿ@ÿ¿¡@ÿÀ£BÿÀ£CÿÀ£CÿÀ£DÿÁ¤DÿÁ¤EÿÁ¤EÿÁ¤FÿÂ¥GÿÁ¤GÿÂ¥GÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥HÿÂ¥GÿÂ¥GÿÂ¥GÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤EÿÁ¤DÿÀ£DÿÀ£CÿÀ£CÿÀ£CÿÀ£BÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¿¡@ÿš‚3ÿSFÿÿÿÿÿÿÿó|5ÈÿÿÿÿÿÿÿH<ÿ—1ÿºœ=ÿ¼ž?ÿ¾ ?ÿÀ¢AÿÀ¢AÿÀ¢BÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÁ¤DÿÁ¤EÿÁ¤EÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤GÿÁ¤GÿÁ¤GÿÁ¤GÿÂ¥GÿÂ¥GÿÂ¥GÿÁ¤GÿÁ¤GÿÂ¥GÿÁ¤GÿÁ¤GÿÁ¤GÿÁ¤GÿÁ¤GÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤EÿÁ¤EÿÁ¤EÿÁ¤DÿÀ£DÿÀ£CÿÀ£CÿÀ£CÿÀ¢BÿÀ¢BÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ›ƒ3ÿK?ÿÿÿÿÿÿÿÿÈ5cþÿÿÿÿÿÿÿ8/ÿš�2ÿ¾ ?ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿÀ¢AÿÀ¢AÿÀ¢AÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£DÿÁ¤DÿÁ¤DÿÁ¤EÿÁ¤EÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤FÿÁ¤EÿÁ¤EÿÁ¤EÿÁ¤DÿÁ¤DÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£BÿÀ¢BÿÀ¢AÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ�…4ÿ=3ÿÿÿÿÿÿÿÿþc±ýÿÿÿÿÿÿÿ0(ÿŒv.ÿµ˜<ÿ¼ž>ÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ£BÿÀ£BÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÁ¤CÿÀ£CÿÁ¤DÿÁ¤DÿÁ¤DÿÁ¤DÿÁ¤DÿÁ¤DÿÁ¤DÿÀ£DÿÁ¤DÿÀ£CÿÁ¤DÿÀ£DÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£BÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢Aÿ¼ž>ÿ³—<ÿŒv/ÿ0(ÿÿÿÿÿÿÿÿý±HÊüÿÿÿÿÿÿÿÿZKÿ‰t.ÿ©Ž8ÿ¿¡@ÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ£BÿÀ£BÿÀ£BÿÀ£BÿÀ£BÿÀ£BÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£CÿÀ£BÿÀ£BÿÀ£BÿÀ£BÿÀ£BÿÀ£BÿÀ£BÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢Aÿ¿¡@ÿª�8ÿ‹u.ÿZKÿ ÿÿÿÿÿÿÿÿüÊHK¾ÿÿÿÿÿÿÿÿÿ# ÿD9ÿq_%ÿ¡‡5ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢BÿÀ¢AÿÀ¢BÿÀ¢BÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¡‡5ÿp^%ÿD9ÿ# ÿÿÿÿÿÿÿÿÿÿ¾K-±ÿÿÿÿÿÿÿÿÿÿ ÿÿ=4ÿm\$ÿ™�3ÿ»�>ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ¿¡@ÿÀ¢Aÿ¿¡@ÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¿¡@ÿÀ¢AÿÀ¢Aÿ¿¡@ÿ¿¡@ÿ¿¡@ÿ»�>ÿ™�3ÿn\$ÿ?5ÿÿ ÿÿÿÿÿÿÿÿÿÿÿ±-‘óþÿÿÿÿÿÿÿÿÿÿÿÿÿ0(ÿK?ÿdT!ÿwd'ÿ†q,ÿ”|1ÿ�…4ÿ¢‰6ÿ�…4ÿ”|1ÿ†q,ÿwd'ÿdT!ÿL@ÿ0(ÿÿÿÿÿÿÿÿÿÿÿÿÿÿþó‘V¾íýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýí¾VS”ÒùÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿùÒ”S 8…ËõÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿõË…8 ,n¡ÆÝêòùüýüùòêÝÆ¡n,ÿÿÿÿÿÿÿ€ÿÿÿÿÿÿÿÿÿÿÿÿðÿÿÿÿÿÿÿÿÿÿÿüÿÿÿÿÿÿÿÿÿÿðÿÿÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿÿÿ?ÿÿÿÿÿÿÿÿøÿÿÿÿÿÿÿÿàÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿ?ÿÿÿÿÿÿüÿÿÿÿÿÿøÿÿÿÿÿÿðÿÿÿÿÿÿÀÿÿÿÿÿÿ€ÿÿÿÿÿ?ÿÿÿÿþÿÿÿÿüÿÿÿÿøÿÿÿÿðÿÿÿÿàÿÿÿÿÀÿÿÿÿ€ÿÿÿ?ÿÿÿ?ÿÿþÿÿüÿÿøÿÿøÿÿðÿÿðÿÿàÿÿÀÿÿÀÿÿ€ÿ€ÿÿ?ÿ?þþüüøøøððððððààààààÀÀÀÀÀÀÀÀÀÀÀààààààðððððððøøüüþ?þ?ÿÿÿÿÿ€ÿÿÀÿÿÀÿÿàÿÿðÿÿðÿÿøÿÿüÿÿüÿÿþ?ÿÿÿÿÿÿ€ÿÿÿÿÀÿÿÿÿàÿÿÿÿàÿÿÿÿðÿÿÿÿøÿÿÿÿþ?ÿÿÿÿÿÿÿÿÿÿ€ÿÿÿÿÿÿÀÿÿÿÿÿÿðÿÿÿÿÿÿøÿÿÿÿÿÿüÿÿÿÿÿÿÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿàÿÿÿÿÿÿÿÿøÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿÿÿøÿÿÿÿÿÿÿÿÿÿþ?ÿÿÿÿÿÿÿÿÿÿÿþ?ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿcaronc-apprise-dfec414/apprise/assets/themes/default/apprise-info-128x128.png000066400000000000000000000404371526720371000270420ustar00rootroot00000000000000‰PNG  IHDR€€Ã>aË pHYs  šœ IDATxÚí�w|õ™ÿßß™í»êÍ’,Û’-WÜmŒ1565„BzòË…—vé¸p@î—¤�@H$¡'@6˜:îE¶dɲ¬ÞVZm�ïï�™Ý�™�•dcîŽ/¯Eëi;3Ïó}Êçy¾Ïï�÷Çûãýñþø?:Äÿòç;ø°hª€ÀxÕxH1 „�.  xxØú>¼7ïßoµøp:0…ß‹Û�¿Z f3˜è}x—F °88 ˜xþ÷vÏÏ/ï+–£3\ÀG qÜgˆlùú¤ŒûzÚ¸O×ûàÈŒ"àûÀç€à¡Þ³§¸ŒÀ”:|Uµø+ªñ—á.(Â,DõûQ\n´d‚ÔØ‰Ñaá!âƒ}D{;ël'r°�øPÿ¡Þ³F�_ÿ ½Ï‡6<†XÿàÜ Bu¡¸Ý¸4Ì¡xîŠ�!8µÅå<…H)3ÍÛË6-™d´½™¡¦m íÚD¸e'‰‘a´D™JNæy¾g¨‹øû �x�uÀ7 ýžw(n�ê:µõÍ^HñÜ%k¦å9ßCKÓÃOÆz33ÀhGƒ;Þb¨i+£í-D¶!“‰‰.ó p°Þð8ÞgÓ8ÓõË ÷Ìqøªj©Xq2%ó¨™�¯¬Ò‘Ðyh?©–ã2B.cDû{ˆt´2°ý-z_ޱ®öñ.ŸÞ4TÃ_Þg(~ œ(y �¹K˜~î')l˜‡âñ¢ÚD{šàÂYÞƒ!îÇ¥¬I- e^F�Œ�J&Ñbc„[vÑúèoÜùöxÏ­> þ_d—ñð¿ÈK7·—’ùK™qáç(˜>ËB@ówE8<”±_¼ƒ•ßíª@ÿ“êb¤½™}ÞEÿ–×ÐâÑñ~æRc$ÿ¯0ÀJà`•ÓNwa %‹VQ»öÛ…‚P"M|ƒ„Ã1þ£ˆÃ|R)Ç÷ rˆîÄ &FÐ’IF´°û®ÿ&ܼ3¯½ |ðݰ Þ-XjøÂSí;T_€šÓ.¤îŒ‹M3]A(:ñÓŒ ¨ B(+QŒçê §‡´:€f7PXf¹tØ–Ÿ!Á¶M“¶ã¥¤íÏ¿£õO¿E‹9ÚíòÖÿt¸Ø@ÄrD~ÑìEL;ïS„êfW3DŠ>۳߳6€2�Ûg&ø‘x@iû&ó¹„6U`g)­ö�”’ÑûØ{ïm l}=ŸJøðÇ£Eõ(ÿ?€› €'K—‹º³?Æôó>£ûñ‘E«áú6!„…øBè€:©õ#³ÿeöÙ>˜¾?›¹ž}¿Ù½öÿ„@Ú�3þ!ÌÆ©é! ‰cl÷S¾ü\�ÛÞpBD/2þ>ý?I¨ÀO ÷ÆâÛ+³ÿá;5.Љ«ª¶™.ªŠ"Lâ?í/M'xþžë!䢓�õRZUA>›@3Ë é¬$ IéhH) ·ìbÓõß$�8a¿¾dIïi àn0nÖò¾}•5óõëTMÍêwC¤§ «*ŠN|! ±o"zú{f6Úˆ,²Ä¶J‡Cçvar7ÍRÁ"),ÇgMS‹:2Ï~Û¿V)á).£úä2°ã-»»(€è�±�G/8Ò à2ܘ¯Ùw”³’ÙŸû6.0cÑ#ª" Kß�B €1ã ÑnP2ÍfÒODô¼„uúŒã>:26Æ0« ‘•vcÕTÙ~Põú(_qÑžN"ûì·qœaKm0¤Â{NÜ |Ö¾±rõ:jϼ—ÏŸ5ôL.žPTTE (ªa¤UAÖø¤"„U´�çû‰ ¼»7�××w�ý5!e/^7nEAÙ«iR’Ð4FI†¢Iz#1úÆâ™}•�6M¢¦i¤bcì{ønÚŸø½Ó-ßü¿÷Ü€ų\sÊÉçP{ú‡3ÄŠªz†NW  m*ÂìªFɺ‚bün]G–·ÍLt«œ8­‚²€GWWŽ dŠHHI�dJ²oh”í=Ãf ­Ô [Á‚¤RXÿ{÷§Û¹ øö{Eü?à9Ä?õ\jÖ]�ÑqŠ0‰ò´0þ�þ.„@>z¶‹ hFÍ\œ6ÓöÄ‘æþ´q« ¨¯¢*èCK&I%âDÇ" övÓݾŸÞƒ"5 EQ‘RC.EÁ­*T¼Ì¯(¤¶ÀÏ`4N4©å¬Õ¡(Μ‡âr1¸ý-ûí¬Ú€·ÿÞàdô°¦Ï"öלAíë„TÕ¬_¯*(f5 „¾ß$¬bßP ª~<&§‰¢LB¼‹Ã�Ò4•ÓD©-ð³¶¾’Í{ùÃí7³õÕ—éë:˜WÕÍšÍÌ‹Xpìj¦ÏžÇ”iÓñ‡ ²hOxŒÍ�ƒ´‡ÇÐLƒ&³GK¥hþýOiò~ûODÑÃèÏþ½ x(3o,]tuç}Õã5«»u ~Ú´º~ Bd÷ EAU…Iï›±tÌ0o·©ŠEA]l#K¦Ø78Êh"e#¹À)œÖÑ+kJ˜SâœAËÎí‡ô²BEÅÌZ¸„e'žÊ)\LÀ`„””´ Œòr{#É”£:H%ì¾ë&º^xÒ~Ù>àX ùÝf° =;35Ó˜ý—ëþ½¢>½îΡ(:¸cüÅ ª¢¤™D%ëþ©ª)`ÂÒî£S,ÀŠêÿ(òºXSWN±Ï�9NJÝÛÕæ­ÎÁ ð€,K¬ª-%8ÜÃ7.<“±ÑÑÃ{qn7Å¥œ÷—qΧ?Ÿ�ÎÉë›»Ø?µ0A'�R²åÆa`[j¸X|8îá;±nζg.u ³( xiŠÓ´\7ÓtO#ñ$M}#¤\¼ÅåDT/-áÛ{†ÙÖ3DSÿ(=q L�ÑÀŠU«9víéœøÁóègÿ^çIÚ´ù-zºXrÂ)¸T•ÅAö�1šHæÜOAýFÛ›‰t´™w•ô/G‹V¿´èý`3>|)ª×—qíÈ0��A÷lŸ´ÈŠv�Ôôñ±Ÿ� ¨Bɰb‹ªŠ)öºyè—?¥½yOÎäRI{{8þô³ñx<”¼´ Œš˜.WF¦C}cq„Ç88e š`4‘"–Òˆ¥4±$]#QšúGØÖ3Œ¦¸™V[ÃIgŸË¼¥+ØôÒóD#9Aš·oÁë÷3{Ér„P˜V`wÿ)Ã(4?_ÅŠ“8¸ñ1R±1ó%V¡'ÞtipO\–S?ô)üS¦fpz†·}f›ÀÌ$i£/ýÒucOÍ} @?6-E”q ¯/‚U5¥tíkæÑ»IxÈÙÒïîhgÚ¬Ù4Ì›OЭM¦ˆ&²ÌG6¾�‘x>7¥~å~/¥~¥~E^7!� ¯ª IݵËÂÂЉ±£7L<•¢±qç~üÓ´7ïá€cnùÛ Ì\°�Ú†™¸ŸKaßPÄJN¿€‚†¹t½�3áO~6™xÁ¡0À7��šçDÉ¢ÕT{ªiF+&b “QgHƒ ñ1¡�&⣻~a ëLƒá)(¢Û³ƒu&4©+ò³õ•—yü¾ßŒû`û››X{Þ‡q{u‚¶ EHj2çwn«jJYPYHciˆiEAêŠÔ˜V ¾8ÈÔB?S ”ø=D’ɤ¯o,Nûp„’P€3Ï=�d"ή·ßÈQOolÜÀq§ŸMaI …^7�#1Fâɉä-­ !ܼü¹Ì°^:R 0ÝÀž+²¢¿€iç}ÅãÍFéLâ\1A¾/RMGòÒ’ °1Ä~Fç›@+Tl¶-¬P0™ë(ÀÂÊ"h̵8žÇ­Cõ1„y­�LÿP:íZ¯NZÂ¥ŠPH'Ø[B¯i‘#õ‹IÛ�´ ˜q�RŸ‡2¿‡‘ð0Ï=þè¤8üž[n¢·³C‡o ü”¼Õ5³$D�ÇÍ@o·]s9Ÿ\³„sf×pþ‚éœ3»†‹—ÎæÊÏ~”'ﻇ¡�>´T ·"˜^àü9µÌ(Z&¯"϶ö°oxŒK¿÷}æ-[™sOûvncýý¿`VIˆbŸÇÑÓ ÖL§jõZrÎ1è&Þ©(2¼¥æÄŽª5g¡z}ú Vlb9×7ëxÅj’@˜ >3v`¨ ]ì§·¥SÄÈ�j:]ÌŒO—™Zàá»~Á[/>7i·o÷NÖ^p1.UA‚ƒ#c�W^HPhÜzÕwyê�ß�ŒÚflœÎým¼úÌS<ü«Ÿ1ØÛCiU%啸…éÅA„€žÑh†ñ!Ø78JeÈÇé:— ÜGllÌ ÷÷sÜégãñùðª -CGŸ74½‘î¿m 5f¹¯íbï„.þÁü£å+O%4cvÖ�CÉ1è2³GÁ`Å$¾³@�Kdí!” 3("‹÷g®kB³‘?ëÍzT…ÕSËRãšË>K">ùŸÝíÔÕÏdÆì¹”øùlüUµãÒïPàcv·¯lÅ)z*­)L&ÒÄ5h.LO'¤Èê}IöX{†¯~°q=S­Ì2�0[û6}šÞ¶¸ª˜D<Î#wýâ°0îío¾ÆËëŸ) y\Ì+×#v-ƒ£¸ü>ùÏß" éîªË•ù(j~aúÚÆõ\ó…OgàëyeT½–ûßÚ3Œp{9õüçžÿô“Ì`–Á"ŽÛð‘´o*3èxX `É8Q}J—;3E’Íx¿)TésÒAW3Ìš!.i†IY¢§u~ú¼¬ªÑ�Y\UŒ"�þú†ú9Üqïí?f°¿„`^y•p,É®¾V¬=“_<õ"×þúürýKüþÕíܵñUþý—÷pÞg/ejÃ,Çknzùn½ò;ÀçR™SV`¤�6DJcÿP„Æ…K˜>{nŽ—’Æ Š¼nÜŠ°IÉì…J,#P3}\:NÖ8ø–9àSyüéø«§™rÝ� ^EØP;kô/í"eí=&�q¡ »‰¦ý¯°$Õ SˆWH«í 0RÄM¿¹ ¢—€Ÿ|Œ=Û¶¼ã4§~õ3ölߊ0²€Êüº 6K²³/Ì›�¼up�í=ÃììæÕŽ~þÔt�N||ò«ßâÊŸÞåþó=w6¤Ó‚ŠB‹ˆ&Hh’Æ…‹sêìz[_4¢(‚BŸÇ®63€BñÜ¥xK+̧» zNZT'˜7Ì:Åã› Œh'¬sf�‚�Ћ°ÓªÁt¼4Ý´ù÷ë Ôøàw·þpÒD®¯¯ç¢‹.¢¤¤Äqÿ/ÿFƳ˜SBM[Ü·3ÃÎ$5É–ža^ëàØSÖñÕÿ¼1çš»7½ÅîÍo!€™%!+³Háô9óp¹Ý6L`{FE\jNæ“ù:ÁºzBÓgÛú 3�7̳?BªŸ�p¹³ÆYf’Ȩ̈¬z3‡JIFïIñÓ³Üâ)¤A²Ëz2ã)¤­¿ª²°²EîþÑ ô÷t[~¿¼¼œùóçsæ™gò�ï|‡{͛7#¥¤¹¹™û￟¯ýëŽ/aïŽm<ñû{¨- PôZ$\¨2I+ y`„¦þ0k/¸˜Å«OÈAøÞ|áY’‰„Á¼~Ëþ�hœÊÚ©¨ªU˜“Oܪș„pLQ)[¼ a½ÆLƒ®2€‚^À!SiÓST†·lŠIü›W9š]>iòú¤ÃvÓzi^±cÊáK3“0y–H\ú&ôíËkJ(ðºùÛ†'ùó=V±ëõz¹æškضmO<ñ×_=ÿøÇY¸p¡å¸+®¸‚Ù³g;º…�þæWôuwé¡Ðê‹ôɆ¦í‘IÁ¶žaPTÎüÈÇsƒ=¯¼D<C€•©€hR£ ¤ÔȦ²Ž¨ÁḘÅ)_v‚ÔYƒy§ÚiîÄ>ôr«Yp¥l žÂ‡™-ssK¤u»Å]3˜Aš¦‹0cÆf)€°@ÊN®ß¼ò¦øèíáÖ«.Ïy�ÂÂB–/_>qHTU¹ãŽ;òJ�gÿüš¦ás©,žRœƒw"°èÿ@zöc^À(s‰/Xñ‚†#X%CFŸšD�Ž N˜VIUÐG<åªü4½=y ºzõêCb—ËÅ—¾ô¥¼RãGW~!%ª"˜]V�Á6,F˜1£ÝŠ Äïa¨¯7[°¨§B}R�Ä“™ó§}ÀÈÎÔ3ç/Ìd ÇãJWs®@Ù’ãí?}æD °ÈÂu3óèh¬ Ù,T—&g@Úô”´z¶sÍ’Ãr¬®« ¶ÈH¾õñ éÜß6.AW¬Xá¸ýÎ;盧Ó)œ7o^^)ÐÞ²—?ÞñSÝzx©ù—” `ny!nEÐÞ¼‡îŽ–ýSgÎÂíÑÁœžH,sÎì²Bš6¿�£¬Ô!ø”&é‹;€Âá}–,È1‚gŽÇËìGªêò¸}äÖÎö›´0ƒ4XÌM AJ¤!ú+kË™Q$�qãw¾ÆîÍoO¨Ó—.]ê¸oË–-\vÙeyϽöÚk¥@*™ä‰?üŽîû–Uã2­HϾò ‡Y%!â±(ëüCÎuŽYy\Æ.Hçø]*µ…~Ú›÷8f /=ñ0 ËI!ð•¢úsìšeùÀ2e\¡BüqZX)¤ 𕹿€ÀŠ `õrDWº‹µ ɪº Ë AÀ]7þ€ ß?á³/X° ï¾�;wòÈ#�°qãÆ¼ªàöÛowÜ×¶·‰'øš¦áQ–TÛÂÕzöŽGUØððý¼öì† }Ù §àöxèGI ÿ * Q…`ǯq°µÅrNmýLÊ«kšúGr¼�q'‚ÛMpj=ùè<®ðUÔX ^i.…)-ÞžÕ§—6ÐV\×Ô‘ÀüU½OkœJ}i¸õê+xh’aÞ“O>Ùq{*•¢½]/æ|Í5×�L:/§»à‚ 8묳÷Ý÷Ó[é5ÜΚ?�,˜£ Á” �¶=»¹íê+rÎm\¸˜…†8ßÔ5h‹JB$bQž¸÷×¹Q¹3ÏÁãó“Ò y`4g–›SèrPAÕE zZ^;ÏÎ8Ì[VePZäÎ~¡;/Bæâø2ŸÕgrQ,ûÒ9ïRd$KÈãæüõT†| ῾ùe¿÷7“–~'œp‚ó nk#Ö}ðW_}•‡z(¯ ¹òÊ+ñz\¸è·ýû•:h£]w“……UEaýC$ec.—ÇÙ—|‚Â’RÚ‡# ÅxT…eÕ%xT…—þò{m�¬@A!KŽ?UUÙÖ3äh7Ù•°¥ÿ�¢â¯¬ÉÑBùÀˆö”T8i�š�þLDO:ÚV/ ÝF˜^ZÀ™ó¦òºè纯ýÏ>ö(‡2Ž?þxÇí­­­ŒŒè�j$á®»îbxxØñØåË—óÑ�~Ôqß }‚ן{„îîÕêȹ†$šLQZQ™sΪS×qæG>A\ÓØÞF“°¨²ˆŠ —žŽÜ|Å7rΙ¿üX-!%a{ï°³AN®7’• ¾²œ{©ËÇ–ÆKîÂÒ,±¤=X§å{i_]ä¸{6n•9¼„”’BŸ‡ëkXS_�ß­²o÷N~ðõ/ñâ_mÑkAAµµµŽûöïߟ‘�?þ8/¾ø¢ã±�‡ú§¢´´Ôqÿ­×ü+ÑÑQ!2 )MÒ6á´‹.aéš“²é¬søÖ�·èÁ¾z#1BsË â_½4Çòw¹Ý|è3ŸÇíñ²³w˜±DÊÑÐsƒÌDq ì�!·Ô˜w¨¯9ŽãüÃæ`�ÓÒJiÏ´²lZ\-­«àƒ ê©//BUy€+?ÿ)¶¼ú2‡:Ž=öؼûZ[[‰Ù’:/¿üò¼Ç¯Zµ*/D|`_ ¼ãv„ÐW Õ‘èz:áösÅ�Æ-�ü•Ÿüi=_¿î‡x}~Ú†"l1DyuH÷û÷ã›hÚ’›·yÂYç²hõ Œ&Rì±dXÚ'W&³Ê�T_Õç·#‚5v˜¯­¸½SR‚f­sêLoé¬û-5õM8�T¹pÉ,–N­ÄëR‰Œ„¹åÊáÇßûƒÊdƲeËgD"‘ ­-;Ø´i?ÿùÏó^ï†n  9¨_Éú‡ï§µi7K¦”àQôÒõ-]ì‹ ¦4Î¥¦q.ÅÍÆÖ^jï³´FÃÃ4ïÈíIYRQÉW®½ )õðòÀX|B—ÏL3¤úüvHÓÛ˜RÄ�ר­Ù­:kQeé”÷a3ôd®¾/ú9qÖTÎY8“b¿�‘á!^xò1¾rÁ™¬ø~Ç`ÈdÇòåË`dd„}ûö9žóío;G2¤G0ä–[nqÜ×Ñ„�ø­ IDATº�§ú#)C|¯¨Îª‹½aßÓÉ£»;ØÐÒM爵(ôP,�/¤ Øiõø|\ùóß º\ôEb¼Ù9�÷Yeà%klK·Å�ÓV±abpL=•NŒ—uú¤ó±éo>·‹53§ræ1 4Vé/ë•gžâÆoÿ37}çŸßQ._æÉòDGFFhjjÊXù%%%Ô×׳téR–/_žøÌg>ÃqÇç¸ïÁ_ý<ÞL)ðeDûD£s$Êp"ÅÅ_ø2ó–­À0µa_ûÁ�h˜·€xJcc[�^0 »­åŒG,À‹âöÚ+Këgà2Ǥe.t›é±“Í×#*¶Þø1µÛP‹×íB ]ÚùÕ�ÿÉ–W_&�r$ÆŒ3ò¦x©ªÊYg�Euu5uuuÔÔÔP[[Kuuu^CÏ à\}õÕœsÎ99ØA"ã–û.7Ý÷0Š4–†è�OÉ<Ó&;žoëeíüE\~ËŒ á)©¨ÒÕHs—¾ 4F"Ç“æcU"7m½ÔΖwæiêt‘/d®û– m s¥}=©£4ägíü* C:Ó¤RÜû‹[xà—?åH�3fP\\ì¸oÊ”)ÜtÓMøýþúöÉ'ŸÌ¹çžËƒ>˜ /¿ö76<ò8ï"*ƒ>ªC~Z‡"ÈmÇ“<¼ósÊ ¨¬¨a héb{Ï©<Ý©Ä8f˜c(ªêÄA;$œUŠÌ™Õ�  ‰*EÞg\8µŠSæ5 „`,aÛ›¯ò›þ�ím�1žPå°‰àóùøâ¿ÈÓO?Íà`n½�_\w «>pÁ‚Bæ•Ð#®IçàˆihR·¶õgª—�7£å8"À©¬­¦I§c]vÀRWF¦’™ I'F07¹”e�ãfÕ!¥dÓ+/qÛ5WpÃ7¿|Ôˆz¦¯ª½6k×®eݺuŽûz{¸÷¶›(ôºi, 9yX­Æå8Á¶qÏK%�:œFí 0Œ©½–ˆeV³™ Ð4KçäÜ�¾Í¥(¼ºq=·^õ]^ݸž£9����‡|^,£©©‰ 6pç�wrÅW°iSÞõ”Ü|óÍŽ™CRJž~äš�îEݪ%áÉé[ä$×’O ÝqÚÕÈqì ™J YíiÐÛ¢FÌ×ÐâQSÎ^ö&sÒô$!AøX<£–h"ÅŽƒÝD)^ÙÛΚSOãž[n"<8pT  :f÷šÇØØ›6mbëÖ­lÚ´‰mÛ¶ÑÝÝÍèè(áp˜‘‘b±ëׯçÕW_u¼FMM ×]w_ýêWsöõuuòÄ}¿áÒ˯ÆåñplmO·tOzæÊI|?Ô¡Åc¦ �½U»×P´î BU©ÿØWP}£Z§ŠPÓ˼U£Ô[¶ò÷ŒŠ2Î_uŒ^ÁÁÁ0�¾µ!>¶zm[Þà_ÿâQe€úúz6mÚDAAAξ––/^œ‰ãb Š¢ðÀpþùçç•+V¬`ëÖ­Žûoyä¯ÌZ°)%/·÷ÓjT÷H¥g­¥ì[V-˜k§d6+M‚X"3ßÓõ…5‡Ê¢úÁÃûv³íæ+ÍM($°xÙ¬0••©š­rºÕ�]\ûýœzÌ,!2ŘjJ ™S]NJKñJs;‹Ž;�ÕëÎ8ª PYYéH|€_|‘p8l)»:î¬Ñ4®¿þú¼áb¯×ËUW]•÷ü[®üŽa• f—†ðªJ¶©”�øfé`éS�Ç̼ç{¤ÔX„Tt,Ãr Y–Ô&ÓU0d~#P‚Å3j( úhoÞË�.ÿ&Ýz¼ýä¹õ¨ŠBsW?Ã|âËßÌH>c¼5o¾ùæ!_oÓ¦M<ü°^°!�ÒÜÜÌsÏ=Ç}÷ÝÇ�7ÞÈc�=–—ávo~›‡�Ü…²€‡©…þñm�qôÿD~~^V6=56j/%§9Ù[0åŒ%{bÉ2õ“’ ×ò=òvý7¾ÄP/¯<ýç|⳨ŠÂº3yrëÞj;Èé frþg/åw·þ÷Qa€|H�WŸOd/\uÕUÜqÇôõõ1<<Ìàà CCCyacó¸÷'?âijϣ´¢‚¹å…´Eˆ¥òxr|ýŸ¯_ádF<ù$¯¿þ:»w僚»{RÄ}]ÿ½?Ñ™=ävéM&¤“x—–Þã»|27oéMhUo2•"Ö›¯�˜Ñ?ó°4®‹õÌ#g4c‡à˜iSèé8À OfcöѱHf–+B°|F Š<½£…’ÊJÖžÿaÇ’ïïd¸Ýî0�gð³ñ÷nŽ7ž{&'(ö¹u¶tÐ퇢ÆÛ!µ‘Îvûæ& d¯X §ÓI­dÜ¿%!xé©'ˆÙŒŒ×6nà­—žgÙš“˜VVÌŒòZzynW+g}ä<ýÈ´íÙ}Ä^ô¢E‹èì줩©‰ÎÎN:::سgÍÍÍlß¾MÓþ. 04ÐÏ@o/µ �YàÜdüi¶J)“1þ¬ø� �fäQ¢%NÝÇ^ÉÇ$ìÐ’q’#øB…†  º_ï˜Y[VŒ–LѼc;ÒáßuÓµ,Y}ªª²|F5íƒaötõ³ ¶Š/^u-ßýÔ‡�Ø‹niiaíÚµôööÒ××— Ïþ½GAa¡Â"�b‰|¶Ú¤|~‰C×rÛ9Â(+ŸfˆT,Jä`Na��ùàyàéDq‘ ýÑÿQð3ÔßÇ`Ÿóêœþžn½û—œÿÙ/PQdî”r¶ttófëAÎ8f8ÿÃ<=‰ïÉŒþþ~úû'® ¤¨*þ@0ˆ×ç#XXDYeÕ5•–QPT„/Ô×á/1�‹Eb ¯—Á¾^ºÚÛfl,B4!6q,I·üä0}Î<¢ÉŒê£ùDÚ<—¶î¢N¹8NúßÉ0Œt´:Y�¯ŒÇ�˜`´m¯Ái¨Wç0!%.U%2:L$Oç ©il|ìaŽ;í ªë¦s|cÍ=ìëdÿ0½ì«¼ùüÆÃÎü™ì(,)¥aÞ|¦ÍœM]ÃLjg4P]7�òêj¼F¦LBÓˆ&RÄ5IJ“hRÿ›)R¥T!ðº<Š‚WUHiÝèÜ߯Áý­ìßÓDkÓ.º¶†Xºæ$.úü—B°¹{ˆX2•3£¥ÍÄ·ªgãOæ3"Œ‚þÍ9ÞOS%q'x ½á³nÐuî·ú"¶œþT2™“þl=ØøèC\ò�_Aq©¬�_ÏŸ6ïáͶN>´¸‘ ?w¿ºá?Ž8ÑÝ'žõ!Öœ~6Óg5R\VNaIiF÷Eâlˆ2 3–LYf’&s-m]¼f—º —B©?HéÜÅ·d%gøÜŒ 1ãr¹)­¬"–Òxq/ûŒÐ°”Vqnåš@5^ö]N—r“þOí{3'áÕ²Á‰z @¨ •ˆ1Ö¹ÿ”ºœœ�x2…ßãÆ�›ndúí�¬=ÿÃTÖN¥º¸€†òböõ ±·w�UkOçÙÇfïö­G„ðSëgò‘˾ÂIg}�χ‚xJ£k4Ææ¶^ºF'N<Ég.JMf01 �±TŠ¡X‚–ÁQݽ“’)!Å>}�Àv ásõ·ù“Lí“Lh$ÚOˆöwÍuŸ°¨D'ü½¤˜áG&‰h±Þºñ«½C#B &F÷~uõ€¾rfQ].Uá¹Ýmø ‹9í¢KrªbÊ„ Xºæ$®»ûüâ©X{ÁGˆM£<ßÖËã{:yãàG£h�Çû8.š”NoëŽoOK�ƒ#Q¶õ ³¹g8‡øæÙïûæ¶«Íz v{AË—h:ªûŧȣâÇe€8zcˆ +Žu@K$,w%¥¤µ§�¢’RJ+ª&$ÒæW^䥧ôÆÕE!*JHj’çv·ñ�ó.¢ñ˜E‡EüÕ§�É•·ÿ’ÿ¼ó^¬ZCS˜çÚzx¶­‡M]CôŒÆ2¢u¢@f¿ñWÓŒOƳFò¥4ˆo–Ro+m€OŽÛgáÚ$\?ìFŸÌ�˜Å÷«s42pp"xÈä3%ú{ˆõ’]#¨Aú‡‰%SÌ^¼tRI÷þ䇌„õÕ-+gTãÐÖ?L[ß0_¼êÚC"üŒÙs¹æ¿á›×ßÌ¢c�gïÀ�5uðvç Ý‘ñ”>o4cM£&ÉÎàÌŒ¶~¤‰€é$šl‡•ðiCѲMʼM'ÍÄׯ ú`²ü-†"Ö¦Óã©*! ¼o7±¾.û®ç Ÿù¨Ö \LIãÞ²)xË«3mßÓeÛUUeÕ²%lüóC$&€H£‘QR©‹V�Ç¥"�ý#Œ%’,™5™ÒØñÖëã^Ããõòé¯ÿ _þþõLm˜EÁó­=F_p©Ê�£Ç_O;^®N–�¹6CšôRÚ%ŠðÉo´<˜@>àÇN¶Þ ÑUlÃ# îÜd—!ד{BÐŒ Pf��LÄ(hX€b,1J÷è �²¬±EJv¾ýÆ„3w §›Ë�¥¸¬œšâvwõÑ;¥4ègåÊ•<ÿÄ£Ž }� �]Í•·ÝÉÊSÖ2š”lïäŶ^"ÉÅ~7õÅ!U1«$„Ï­fŠ/¼ÓaõÃ�cÚ[½:úû2Û.>½=eû·f ¹Hîof¾¼­î¥$12LÛc¿#ÞoÁgÐ|wM†Ò�¡¯¥ÕDrd˜‚ú¹¸‚¡L‘D„BR79cÝZ¶½ñJN¡FûˆŒŒ ¨ ‹W­A( •…Avìc(㘺)òú³O[aÞUÇó©¯ý Ÿüê7 ±¥s€×Ú{é�Qàq³¬¶Œ…•%L+ p»ð¹U*^FâIÆây2Í®�U>HÛLs4Èe–@šÓL•Ö@�S²§ž6²ìœýþ ƒI«í`7�„�á¦møëH-ewÿn¶ °ñ œd äk‰8¡†y–bÑBFǘVYÊòãVóöKÏ™ ð²wûVVžºŽâ²r7‘D’ö�0^·‹%óç³ã­×éë:Hqy—ýÛòá/|‰†¹óiá©ÝØ?!)%ËjÊX3½’2¿�K!�ñÜã�RP\B T@u��F¦­9o.S§SÉ5ã¯&å¸1{i#¼œ$ñsô¼¦Ùüxëïh8„f1þ¤£ø×’ <õ á½;ì‚ã'v `"=? Ó-$>ÐCÑÜ¥zWp²Í ’š¤7aéœYÌY¸„ͯ¼Ht,2î…›¶¼Íi^‚PB7Í}C´ö³dF-…E…Ô͜ͷo¼•sæ• Ï·t²¹£ ¨*pÆì©ÔQ…XtŒ{oýo®ûêxî±G(©¨bÎÒå(ŠBÈí¢=<öÎT@†@2#¦­sINˆ×Ì|é‘Ò´œTösÉãö9&•˜˜!>4Àî;oBZc!ƒŽ‘Ce€và1ò´h„PýÜL1B¡×�g$£/<Ê1sYy‰ìݶ•ðÐ@^·k¨¿�’ò æÎ'èõ0�Ó=¡4ʱ˖°èØãI¤ØÙ5À3{:ŠÅ¨X>µ‚cë*ñ¸"á0¯<ýW®þ§yã¹g2•µvo~›Sν�`¨�B¯›®Ñ£&6ßB&'ݯ™üp§ôzû¬Ï1ø¤ÝM”Ø¿õ\)s±þñCÅÙ#ö?~/C;s2šï«kL&�~/z¿@Ý%"8½54ú(™æOƒ£c´ö2µº–s/¾0Ht,Bw—ã…·¿ùÇŸ~6ÁÂB�’–¾!†¢qöŒ°p”]ýìãs»X9½Šu•T„| àõgŸæîÿþÜq[NÎD<ÆP_ÇŸq6B|.…¶¡ˆP�\µ í(ݸÌ!ÅjæYï óÇCû4‡j*V¨K¤Ïî÷LJØqÛ¿g“7³ãbŒ,àÃa€}À0–ÉT ©i„fÌ1'Î6z‹'iêêe(ã§œÂÊOaÙ 'S3½žPQ�PE¥¥T×MgÁòc©­ŸIIy%C#´õ‡AbÉÃññ”ÆüªRN›3�ª‚.EáÀ¾n»ú úÕÏòöàØ·{s–,Ó×ë¢?g(–ÌÉ¡×쳋 rìf|Úbפó,•†Øwò,œÜD»ÏŸ›<êÀÆRÒt÷�Ý¿×~ËO?:<§8;.2e³\þ5g]‚¿ª.Û6Nq¾…¾M(UaéôM¯¦Àç5šDaêø¡ …ÊH<Á†�­ôG¢(ªb¤¡+Tøð’ÆŒºùÃí?æÁ;n'™LLꦫê¦qÇú—õÎ\c16´tgªrš(�ñyÂå„g�ˆŸöX]>iÂÒ¿�öë×·ìbÓõß²·”½4lÞšz“]Cµø,zgj´d§ÍÊàB�™Z<€®ð(›÷w±³³—ƒƒ#¥}p„Öþaöôòv{¯íëd,‘B¦ÛÎ%Ø�©.§º0ÄæW^âêÏ‚×6n8¤Ìž±ÑÜó—¯ÄïV‰Ä“ô�Å-ÝÎAmüš‡Òf¬i–j'f}oÇì5r%´fНH›Û‡Ý#°ß`ˆd’½¿ÿ™Óìø)¦tÿÃe€¨qï™ÄþXïAµ ¸ ŠMªÀhK¶`º€n"¥1�Ó;¡7¡otŒþÑ‘D2kG¤ÛÊR"¥IË ¹çæhÚºé°fn÷�vVž²–Pa1SB>öô�’He [8}òG×eŽ—n£}öZf¾f3ølH‘´Å4éìó›ÕÀÖ×hûÓ=v¿?aà8ãæÝJfæí†W��Ï<œó‚°å¤e¸Vš Kážd.Lé �Ñ:<Ê%_ú>à°\¸ƒ­-<õÀïI¦R(BpÜÔ² —Yetºé#Íž)`'‰&¥eE�YäkZ~ÕAši&C|©e¥CãÓë·YýHIûSÒõâ_í?™0Ü÷æ#Éi�àd Þ¬ |•SñémND¦'°ALSËWL-M„±†0ÓÖÆi=�ÐôJ¢gžq:ú£cœ`2ªà`[+'�s>Bü.•–¡ˆw'�g­ý �;n&�‰øÖê9Ä7¯ð5ÁÏNFŸYv5meçϯszäßaÊê:’ pð/™s¥$ÖÛEAý½•Ñ9#[¥ÊÐ2Šî.¦=ij�>N˜J½á@Àp4Á´’fÍnä…'þtXR c_3ÓgÏeêÌF‚ýcq£‰Cr³q€¬Ž×œ/�àÝ<ÑB›ØÏO|«4ˆõ÷²íÖ«Í-ãÍþšq¢Åï˜R@'¦Æ©±QRñ¡é³ªšmîd¸sæÎài!á3"ßÄŠÈ�šDQ‹æÎ¦³míãøÿãÂÏ›ßfÝEÅíñ�’’á±I-»6}¼X�fê‚#m³^sÈæÍ"�¹ÄÏMòȲ¦–H°ç773Üä˜Fw1°c²ïäpKi¼�ÞWÈ¢ Ü¥ø*«³DN2N‹ûLgiý‹¢du½J¦•\š!Ò±)=#QfO)£zÊ^߸�xìЋJÅ¢cTÔÔ2sÁ"b)�Ö¡© Š.h6"å‹�K™ß˜tb&‰}¦�¿"(ÃJšFç³�Ñþ—?:ô à‡B9�Ã]Ÿ¥—b[M|ðé 7ï4ù:ÒäHÓf-ãog#kÒbI[J JÝnOó–­då©ëë¦SÉ$ÛöefXRÓHIIJÓHiZÆ‚·d åñ ÓLIýqìÀNÚsÈ|LÖ¾”ù“D2ghZÆ{BJ¶½ÁÞßÿÌéñv ‡´ê�,ÐÛ |SY€Žõ÷3ÒÞœy*™ŽS›tš´äš^Œ}‘#2cõJ ãlíìç‚Ï]–S\q2C( Åez#ˆH"eT?µ¥få1æ¤ÌiÐ —N³…3®£Ì%¼5íLfÞƒf~~M³ê|ͽ�’áæ�ìüùuÈ\$t øwƒ&‡4Þi5¥-@z£É *9ØJhZ#.0‹¦EºáJ“‡€P�C%€Ì´–Ùþ‚†­Ð=eyc=—Êæ¿½pH7ëpÙÕ×á ÙÔ=D4á,i9õ±¥m¾SöŽeÕî"ß9±3KüÈÁýlùÑ¿’ 9=Ú-ÀM‡CÀ#QNë/4fCÆcD;÷š9Õí1 A“‹§(ÙbÓL ÛMÜâ¤[ÓndRÓ“/O;åd^~ò±I×RT•«î¸‡ºY³é‰±¹{ÐÈfÊç:NdÊñ#{¦ vBËI“ÊŠýX_7;nÿ¢Ýœnå^àŸ8Ì2BG‚¤ÁÂÔœ89:Lä`Áé�¸¼þ Ô›mÿjÅ,ÆŸ!Ò"¨X?/�S[bÁ¢Å<óðǽ9�×Dz“?À5wþžºY³é‰Äxå@?C±ä!?$688oŒÀdá›s5‡ÜÂ|I�fâïúÕ ïu4ì_>…C¢Ç»Éiôz&q¦8^2Ž^Ø‹¿7t›Ðó½YIfdß.BSgâDÙÀ�@ɸ�¢*Áìfòø„ ,ÎÔâËVËP­»³3¤¦~&Ÿû×ïsá¥_¦~þ1ìa}s7íá1’f¤ “Ï.ÈÉœ�Žî u­À¸„wšõƶpË.¶þð ÆºÚ�n¡ß€äÿöN‰&8òãtô¦–µ^ŠÏÏŒ‹.%8µ!SbN�À‘¢ê£a(ªbØ zI:„’)I§LEQ9wþ4¦} övÓÞ¼‡¢² êQ}^"‰/ìï¥#="z.Üë”â”Àá<ë¥ÅØ“R2¸ómvþìZÎ_8øë‘ ÖÑ`€ãŒH”uÁŸÔœöaʧׯ7\7UD:!Ä ²•PtFÐ�×�©/-¤±¢„€Ïƒb„žÛ†"ìê [TÚãŽGpˆŽtÞŸ;ëµÜZÌR"S):_ø {ïû)Z<–�ø'‰™´ôÜ�?…öÅǬ¤æ´‹pùƒúB“tî€bšõFFQ*Vt¦ÂX¤*¨—Å€Žó5V¶?üä’C¥ã>§¥Ûã-ùÊ'îAÏæmþÃÏéþÛ†|·;| xòHI=Š °×€ŒO±3A´»ƒ¡Ý›͘ƒ+4e [+Va³È‘²� �Š¥ª‰Ð¡]‘Ç�³Ïb#Êç´ÏñxN�/\ëHtcÝ�ÝÈËèûÖ&¶ßz5ƒ;ófnµÿïHÿh3è©d€U˜˜è¤}o<�ËÄ_5Õ¨fr3΀’5-í¹³YGBQô¦Bä�ɇ(ý­+p'£œ ù™¬û\ÆHÅct¾ðW¶ßr‰‘¡|·òzVö‹Gƒ@G›@_‹ö 0 ˜gßÞ»�Ñý{q•â))3_±ö¿3‰ss¾B ¸Ü(Nõl ‘/ä;Þ>&+ÞD}>ÃOÓ4†voaÏoo¡cÃÃã½» â·-â¼ €T<”£w'·P+>ÔÇЮMD{»ð•OÁeácÅ”`f°¤ú¼ŽæÌD5&r÷&s lyzöô6»Å?ÖÝAË~Në£w3–[½+£%�Ÿ£¯æ9ªïþ¸ø.FתŽô([¶†êµàòú­.£ƒ‡ ¸\zÌápeýaû…VB;|Ù}©X”Ö‡Mçó!�Ld;]�Ú=êãïÁó�5�,ò1Bͺ ([²ÕïGQ]Y0¹�.ÅeÖÿâè0ƒ´.èʶ7aþÉÉÈ(=¯n¤õ‘»IF&œÌ¿þØþnâïÅjàcÀØZÖš‡;TDù±§P4g1Áº=¸$(*Â¥â æ8Kþ¿`B0GAßKçŠñ•†‘ö›ŠÇmo¡Ë«t¾ð$ñ� ËàÅ�Ï£v’ï&þž �…èy†§á\µLç–@�ÚÍ[BÙ¢UxJÊqù¨^o.Èón¨)sô{l°�Þ7^``Ë«„[›HŽ Ot•$úÒ­�b”o·Ç{�Òã\ô… §Ž{Ê‚âñœ>‹ê“Φbù‰Ù–hf ÁÔ:ÕÔOõˆ},•<Ìè]ï[/ÑùÜ㌴6‘ŠE�49�gÐ×ì=ú÷|éï%(Ö_1¤I�’Ë)_qEsá"\.Ý5´®23@¦ã%y�‹t7:¨Èd‚ÄÈ0CM[é}íY¶òZ…�è €¡¿÷ ¯1@z ð臆Û8éá))'T7“@í ü•5xŠJqB¨þ._ÅëÓ™Ãí15Ç”HMCK&Ðq´X”TlŒdd”dd„øP?cÝDìc´½™XÏá<ÓfàëèuzGß+/ú½Êæ±ø7`9àãó…ªâòë  ú¨¯N|·G—B©¡¥RÈd-#�‘ŠFH�EHŽ�Ø«mLvh†?ÿð}`ý{ñ!¼áÚ¹IDATåþO`€ô8=ûe�áF–¼GïsÀpã^D¯Ê±õ½üRÿ'1@z” °=⸎wÑÌ7RÆ xÝ`€>ÞG�y†$8ø°Ÿ *¿ÁÏ~ã7Ï6î!ð?qB‰ÿ…ŒQ€^àò` z… ¿!%TÆHwJ7wL7Kçs¦ŒÏðzxûôdÌðÿ–Yôa¸€:ô5 e†—QˆžÀšÎ_Œ¡^† +½=’¹Ÿw�{7ÇÿEí·HˆMˆ¯IEND®B`‚caronc-apprise-dfec414/apprise/assets/themes/default/apprise-info-256x256.png000066400000000000000000001245031526720371000270430ustar00rootroot00000000000000‰PNG  IHDR\r¨f pHYs  šœ IDATxÚì�w|åµ÷¿Ïl/ZõnYr�1Õ�ä&@Šs/¹¹„$oH¹i�›BÚMà†��„^ ¡H†„nÓÁƸâ.ÉêZíJÚÝçýcgg§ïÊ6`=|„¬ÙÙ™gÊ9Ï9¿sÎïÀè£ctŒŽÑ1:FÇè£ctŒŽÑ1:FÇè£ctŒŽÑ1:>˜CŒÞ‚ܨZ€ „� ¦þ”¨Û€WýN €> WýéS· mÀV`ïè-U£ãýÀÑÀ$ 8xEl�ú|…ºMÑýÛé¹K £û�û·Òª¢È¨ÿÞ ¬Þ6ÿ:Gͨû?|€_ý©–GóTa?˜Çà%àyàI R†Gí¨ö£hTWõYª°©šë‡òP•ÁKÀ몵°SµFǨø—ǪB¾«*€Êø5w¨ `ð´ª}FÇ¿Ê8ø°èWÍbù/ú3¬Þƒ=ê=9yôõ¤áJÕþÆaAéÏ�ê=+Uïáèu©ÑÌN>D¡φ'Á)ÁŽà †ñFJð†£xB<�¿áõ#<d:�L ‘$•L�N�è'ï'�Œ“ˆ3ï#�ˆ¿×÷q3°xxEuFǨ8hÇ À§€#€ÙdCnïÊPüABu�„ªê Õ�%2¦…PM#ž`o(Œâ  øüj PAQÙ¨ PR‚PÔéÉìÂ+3¹ßu{™‘dRC¤“¤“ Ò‰í»ˆïØJb÷6í»IìÙNfxèݼ¯à5`5p'°jôUUÓø<ðm² 8µáñ¢x½x‚a ÍDÆŒ#6~%¦á�•á „Ô]ß»Ç(¥´œ/•L0Ü×Mßæuôl\G|Çfvn!=˜$“J!Ó)UÉ�1@6!éRà†ÑWoT¼à ”_~x „Þ⯨&XYK¸¡…hÓx¢-“×5¡øü#t!„AXÍ‚k÷™4 ©Ýþ²HAΤRÄwm¥ëú·of`×V’­ v¶“LHep>pÐM6QitŒ*€wUð�N¾Æ¨¼á(±I3(›2‡HÓB5 *jðøýŽBm'àï÷(¤2ÃC$;ÚH´í"¾}Ýë^¡wó[¤â½âôCÀï€û€gGÁ¨x7ÆÇTS1ÙTÜ}_éƒ!ÊgAí¢)i™Œ7Ããóç}rÁ?PZf½ yÈ]Èìo§ïI£gâhÍ›­ ³ÒÒöËdH ’è§oó[´=÷(¯>Gfhp/³“lnÁ Àý£¯ì¨8ã(àr`ξ®øÂãAñ(�4“šE'P9÷(|áhÁ~_¨Ì=Xaú{_Vvõ»iU$ÒBè§óÕçÙóÏ•ôm]O:9€L§÷Ç"xø†jŒŽQ0²E˜ \,ß׃øK+ˆ¶L¦|ú<ª?š`e­­p�È··,çïßÔä~()%‰öÝt¼ô4]o¾Hÿ¶M uïWâÃÀ·€·É1�ŽQà:ÿ|e_›8ƒªÃ—R6uá†f<þ€&äÅúî¹�s2þ®<(�=?€o¤ BJw×ÁI1¤‡‰ïÜJÏúWi{îqú¶¼µ?Ó¹ø=ðÂè+>ªìF#YDÿT n_PyØb—‚’æIxƒ¡ûôBgÃ�ÈÈ)�Ð):å!Ýð³ÀêýxÓß]$@ì‹ W.ß·Ç ’ôm}›]«î£}õûúŒ÷� Ïg4©hTèÆW€µ#]A½‘•óÑô¡O®kÒ„~$f~”+|º¼ð¡Ûݱ÷×ç7ûþÒt\W __ÿ]·]í”LîØ{¶³ýo·³÷¥§I ôƒÌŒôòZ�Ÿ©VÁ¨ø Ù ¼? Gúåð˜ñTÎ]Híâ“Õ4jBiVVE`\ ­ôÂðaxhïçÃ3(unRÚ˜.R:¯ô6ж ¤$¹w»Ÿx�Ž—Ÿ!¾có¾\ÆsÀ¿“­LÌŒ*€�Q| øéH¿;‘úc>BùŒù+k²/@ Ô¥\dÓnmn´Q¼°‹>(kÈÎUÐ, ýÛ¬pô‚W$N MÿÂxC1–�Et´ÒõÆvÿãoûŠü¸FuFÀ|œ®š3Fò%_Y%ãNû•s¡ƒ(Š'+T"›[O.·^˪_PèmVΑ(:Ÿ]èMh}&ß¶ÝþÒ¤ ÜܧcetìjèL 'WC¯H¤”¤úéxåY¶Ü{#ƒ{[Gú^¼©º�÷Ž*€æðw&KˆYÜ—JÊhXöOú¸–Ž›5óUAW‘;¡(ÚßB(†8¼Ûj¯÷á‹]åÍ+¨°Qï¹KàbêÛY9¥‘)Æ¢(^”RBzh�m½�Ý�?Àp_ÏH.cø°‚‘lBñ/r�‡÷�-Ö)RðK©:l1 Ç,ëã ‘5áµU^õõ5H§®üBuì'¤ßFØ…‹9/lÌðƒù¡I³ÔXéõ‰¹ïH—}3EºRJ’í{ؾòNÚW?Ápo×H.c+ðqàeFìUÑ(Î.&›ÜSÔ¨^p,uKO¡dÜ”üÊž¾œð t澺]y0P/è6Bëf¨‡bvñÍ®½�n±Ýÿ@)¡R7¥ M.†³’)‰�RÒ»i»½ŸÖgÉ”ÓÀwÈ’“ôŒ*€CoÌ~M¶F¿¨¨¨aü§Ï¦tò¯×ðåüû¬0çL}‘_ís«¿Î@Í—€;a÷Qà÷õ%Ø9I‚��… ]Î-õ Áåmb‡dRÃônx“·‰=ÛGr™«€oªÁ¨8DÆÉÀM×>?µ‹N¢ùÔÿÒ?�ê«>½*ÐÙÕ^TE öË»Š¢8fðvT×B`a·KöÉþ;ÿ—@ É ¢ŠZ�AZâÿùä¾Y feàºOe�‘ÎV€þïôÐ ›ï¼–Ö§&�(vª­À瀇FÀÁô}�lQ#6qc>´‚ؤ™Y9V5\§Vrí·ê÷ ƒ5�Uúïˆfÿ¾¬ôv_± º>+Pr  §õº�¼•þ[„UPÈ"�êíÎg§ ºÖ½Ì¶ûo¥û­WFr¾E¶ì85ª¾1†lŠçuá^c?rU –á‹”hB›§ÍR,&½¢è’{t ·¯0€�Æì¼bÁ;'½`J':hœÕÏ­åBSAVeàZR\HxÍ¿í>/€ ä~§âýì~òAÞyàÖ‘ðþžlÊøŽQpðŒYêƒ9¬˜�#Mãÿ鯮oʇôô«º¶’£}¦hÊAïû+H!ðØøþz@¯˜ÕÞ~u‡äÃ2ºÖh@±n‚Ôg:(ŠŒÃ E ô8Aÿ¶�¬½êç$v� ¼¬.4¯�*€÷,$Ë[^ðb=^*[Dóégá †µ$CŸP,>½¢˜[‹ dƒÞRPr¹Bq 2éõ¡1å>";”ßÍl?‘«Ioo¸ ·\é²�“±s d&Íú.¦õ™UÅÖt‘e}~îPÏ!.üŸ"K*´£¯¬Š1Ë?IÓ‡ÿ�ϯ ²å:Ÿ>ô)zs_÷£h‘#ø§?ŽcÕŸ0  êth¿s¹úB¸ ºÝOtÌÿÛ £°�Ÿé8ÅBÂò[Uœˆ<;‘°ž^"’¥ùä(ó„ý²-Ô‚ªùK”WÒ¿m#éDA€0|XÇ!!ðÂó>¸–"àJ&Ì`üŠ/Q>ó�§XÀ;£�óûEÕ×>cÒ�¢F „VácÂ̤ ƒ¢õbÞ,„æEXÙð™YàtÛÎÇ4ÏÁéüÅ–ùЧPÛ;ß';Eà”L%@µÈ„›Ð=[¥¢W %-“)�:—øÎ- v¶cà|ˆ“mq&GÀ{3.¾�{«k*çÍøOŸM°¢ÆŠÖ«á>=¢o'ÄB•ÅÎÇW](К à¼Ú šKجpŠ�Pcü)ä ‡ÃyõJÁÆ’ÈÍ¡�i/t÷"* ÿ ›U€ ¾âd¸Y9¥à/­ ö¨HÅ{éÛ²¾çx²=Uïþx8ƒõ2Âã¥á¸�Ñü±Ï¢xý*‚Ÿû”<*o2íÍB¬¨ á׎ƒù7ƒ„yKÂhþŠ"…Þlªë�eXÍßC`ǬL�ÂA8[(ÅàvÀ§«"Q¦˜–^ƒ‚´9¡ÖªIëûã¡rÎB<Á0=o¿^ˆŸP!Ûäu!ðÇQðîÍõIขþ~I9-ÿ<µ‹N4ضv1ý˜óésàŸ@àÉaúÍÜ7ùþº%2êsMü ?væ«Iè&Y˜ç+�Ö�Þr(?0ØG.؃0 ¹pRT6šv–€�UQ:q¡êzz7¼Iz0Qè–L$›yzË¡â*   ¸[5µÜ‘™ú&Æ}úË”Nšexa\ý…APUPO ztÖ€æ(JÖì0Õä,…œRÈ�Gà E�uª¾q\M™!ŒÀ!&—�bÁ?ƒ³”7ul•�Y±;(‹(BDÆŒ§lêúÞÙÀPwG¡« N62•Uû?¼ªðŸRŒðOþü÷UÕrõÍ%¼B•bÅ¢„Á·ËðÓ½½yw€|΀ÅB(b¥·ø”,æ¢úLFsDÃ\s¨ýµôJÁ©w�n¨–”tÙߟ®×WsyÏ�m$3ªöqüXRÈi®œ·„æÓ>‡P²�¢(ÖBÝêoHÙÕ•ûòr Ÿ0²üX€t…AÅèr˜|Oဘ�´È,ÌNeÇÒ†Vl¿…Õ¡”·Pù®+z&Ð@V�x�×£ð(x”¼û$€´”¤3’¡t†Át†áL†´”dÔívÙvssM’öÊÃLW¾á÷—ÑútQåÅOG�*€‘�p3ðéB;Öý!N8Åë×Ú^+е�ǶdWQÔ—S©¯´’}hX!¤îßF+Cª€  Œ�\P¬1ëƒéáI)‘ê2,u Èn9WT‡ƒT‡T†ýD|^Jƒ>‚Å!‡ßù¼Ãiú†Sô$‡èL Ó;4Lß`Šþ¡CéŒA VwÝ1ÌŠ cC[nV2�fÛßncë½7s{nÎ$Ë84ªŠ¿þ§ÐNuÇ|„†Oǯ7š÷³¯¯šæJÞ´7’xäëŒ)Â&Ó‡Ä�л #]ì þ84M@Gd-¨®‚ž.¬±$Ä´ªuÑ >EtŠUúï §èJÑ•fg_‚wzâd¤smAû C½Eag dR)v>r7›ï¼¶˜[rðÝQ°ðøo²U}îÂìGi8þT‹oÆ•:öòûU?]Ñ›þꪮŽ£7÷Ñ¡ûhQL9Y�AŸ,FDã­e³�œ;”†0Í)°…c*9¼¡‚² /e¡»‘‡(Ü¿ëó(Dý^ªÂšËÂL¯ŽQôÑ3˜b0�qt«lóÌ Yo‰) ¥“f’JÒ·ù­Bj1Ùæ¥Ug¢ƒíÍZ¡šK΋¥Ô.ý0 ÇŸjY±µ•¬È¾¦cŽ?hȽTýQL˜AN˜=jõŸpp4s_oE*²yÁÄ{ñ Í-ÊlÊj÷µ¨Ý˜USÊܺ2ã1¥$�Nïí!ï'�Ó×ÝEO7}Ý]Ä{{$�J�¢àóùE£DKË(¯ª%\RB0!-!-! œok<Éš]]ìéO’ÑY&n|z\ÀL7–›n¿Š+ï*dœÕ­½cTXljÀ#…,嚣N¤ñÄO€×cìt´]9¡Ö’؃Y¯±÷æÂvZ”]�¿1 �îÛsäòÌ¥Å8™ïBSn÷‡ LƒHkÁ®;�Âî4b/'Œ«%ê÷¶?ûȃ¼üÔìܲ‰Î¶=tµ·‘(š¥Åã!V^AuC#Õ c¨ÛBÓÄÉ´L™ÎØIS\Ážþ$ëöö²³7Á@*mpl#Ò!ª û÷;÷ßÂÖ?ß\Ì-= øû¨È�id'ZÜv*›y8ã>ñ%Cvž>µ×.ÁG¯3ƒÆBL¹ó(˜"º´bCZ°"œSƒm½ýYí…M$ÀÁ`2™ÏV¿Ú¡5AQ¤9¢O!„-ª.€5¥Ì«+3l¿ê'ß㉿Ü;"�/føA"±R*j똽p1ÇüÓÔ5�sÜ¿}`� }¼ÙÞk Ú¹!,�Ìð[ß]hš[Éæµ¬UÙ²ÊgÉ&N8ŽHó$&}î;º0XÎä6*%' �Utqü||߸Š[Ý©&ëЙû¹Õß&Šà�  Œ€$Ä dÇ,ô^À…:÷è?x><©Þ°ú¯üÓ­\ÿ?&5<üž¼`fÎá´/|…ùËŽÇçØ€}’®ÄOnÛKûÀ`>2` �B„ú§qÖ]u>�¯=_hZ¯G‰÷Søð* dùEš'2áßÿáñi¼}y3\Ñ š`êÿ^X³Ål3MY�ÂÌ Y ÖH€‘P$«€<ûê)9…ãÄ3è’R«²qu¡àQ™‹4+ˆ|9²•…@˜8 Š?§9É*àñ0W·ú÷÷öp륿 mçö÷ìëjk噕åÑ»oϺ$•DKË ¸KØëeZUŒ�ÏCk<™�˜A?›’bó¿ŸŸòióèÙð:C]{]ql²¤µùWVççºùýÁšZ>ñE|ÑR ‰7º¼}EKÙ5†ÿ„¹kj/:³Þ˜Í‡…ó_{Ás ­½PtÑ¥@�SŠnE·«Ó2�À§(4FCÌ©)cfM)3kbL©,aRE -e*CÙGÉá´óÖ »Ý1sÿRtÊCS$¼züF�ƒ­d¯ŸqŸø"¡Ú1ÚÝ7†—t¦ÏÈ¥öøùóÉ=v+º07ùzW#o[+Š0… ±1ýUP�|²Q1&¾bíÙyÐ "¦Õº¹4Ìáõą̊.¥<ä#äõàS|¿G!ìóPò3¶4B]4HFBw²°)n.Ù5þmTz¥ð(L­ŠiÂÑÙÞÊ“½ŸD¼ÿ}{ñúúØðÚˬy|½]�L™;¯Ï§]HÐãali˜¨ßËîþ„%™ß-¬›»No(BùŒy´>ûw¤½«“ûúà-`í¿’˜ \Ô¸9³Í§žIÉÄÆÔZEÒ{³ÂfŒ½[’ô¼~ ;hé¼X¢ÖÎ@z·Á¨<Šj=¨¦QÄ&@POªaáÝ*l| 7U2»¦Œ’€·ˆ¶ãñyiŒ…û²/ú¾„û„…ÞĨÄü9 ~šæ¹GWÒÓ±÷}÷=ñ~Þzi5Ï>üWÆOŸIU}£v“=BPöÓ\fg_‚d*㊞Ù=__I)á†f:^~™N¹Éà\5Öñ¯ BÀ¯€cÝvª?îcTÎ;:ÿ¢šo¹û �·0ðÄ™Ý!„¥ZPÑÒy±û(ÂjžÛ)-µXöƒé…Ñ‚vüu9E",Þ¹u,SÉøòˆQð ô÷Ѿg�­­ô÷v“‘‚á°”¬ ûÚâƒFž‘"Ê ²×0µ2¦]k4cçæ�¼ýêË,£¿§›Çd"AË”éÚýB^/ãÊ"´Å‰§Ò¦ŠI+N`áú±ÈtŠž·^u›B%PA–ì&õAW_¾ç¶CÅaK¨;æßòí¶ÍÂfX�1ñó;—½bÊØË ¼b ±àf|ÁPà“+dñxt…AÆH€ÖHÔ °Õ «|q$"Í¥aæÔ•鬗ìÏã÷ßÃ�W]Î�Wÿ–¿Ür#ÿxà^^þçt´í¡¡eáhTˇ¨�ÙÙ— ‘JäE“èg_ò ø´ÏÆOŸÉ†×^¦}×N¦±þå5¬åEêǶPÝ8FK ñyšË"t'‡évŒº8Y{¥“f‘hÛE|Ç·ÓÏv«ßËk~¯Ã€G�%Ot�ÈS||=ɇÙ6‡ ­§€¢ˆ` ̵ Ïùùv««%V¡PŸ"à¤ñuT†ýÚ¶á¡A.ÿÞ9<·j%ƒIû(Só¤)œsño?}f-OñàÆ=®!>·Ð �xcIˆ¥ÍUxtd�½��<|×ùó WÓßÓ @YU5 ÍãhhOU}±ò "%1íÀCƒIz;;èíêdǦ�lß´á]‰&ÄÊ+ø÷oü'~ê Ãõ¤2žÞ¾—·:û‹ ë†Yó£/ê= ÉR‹­þ *€ °�lû�W0ĸO�MdÌÝJmöÛÍ>>Z.€9·_1uñÕﯨÅ<úÔ^C1‘",ÊÄL*tà¢0…ÍyfÓÑêGz»d�1%!–µÔ^¾+ò=VÞñ‡‚Yz•µuüö�UÄÊ+´m/íébÝÞ¾¢¨]ʬ�ðÁqãj¨�´·<7Òé4}Ý](B!RV–ÏÁ�²`ÓR)%É�Þ~íež_µ’—þùölÛzÀ^Ø�žu6Ÿ=÷?Çw½þØS)›v˜i¶£õÒ!õètäÙ{±(}ý¾¢c6º Æ#gÒOÅÐHG Âzè™­4S_¸½#žzÌ¥c« zó�ñÕgŸâÖË.dxh¨¨°Ø`"Áü¥Çi÷®*`SW?©LaHÐJ­mµR„È GOr˜1±>SÓTEQ†ÃB!Ã3´p+8œßç÷S×4–ùK�åÃÿyGžð!¢¥¥Ä{{L&÷+ÙhýËkèlÝÃÌ…‹ñù}Ú»U Б2º¢p‡g_¤i�¯½G¢ xðƒ¤Ž.P­{¿ÞÑÔ.=ÅJ´i^3¼$ŠÁ0 ¶ C¯‰ D³tŠÂ|AÍnnf%�H¥ií$ðòy !Og0Ñtü&’‚²ªjfµ„eý8-S¦ …Ù³íRÃCûôân^ûÝ{™µpI6Tx…Æ’;ú’$Ô:=(è7´�lßM|û&7«|2ÙªÁ-à'K•<ÁÑïol¦é”Gñùm€¥§ŽÀÓ"ðØtóÑ|yŤÃT:lørÇò(Š©D×5Ð7 Õ5 1¸.Àž=�¶pÿšbyD01ÀM_@_wWÑ'™`x0É‚eÇ£x¼(¢~/íñ$ÉtÚb±ŒT!Ân©4[{ât £AIÀkÉ}èLÑ™¤->È®þÛ{lï`Oÿ É!ú†²%½!ðyÇ›èóù3a‡½ŒÃ�9žL&ÃæµoìÓ ¼eÝìÙ¶•Eúˆöl|…¦Ò›»ã ëKŒ…Õ:2——M�Kûê�èws—§‘eNêÀ9À%Ž~¿?Hóégm™b­ÄC‡¢k,=YðN+ÔÑ8ûr_ÑÒgm9ÿm�ôî†0¥ñjßU„–Ù—�æ¿BSð(6Ø:—Âeµw[=Ìã¤ñµT‡ó¹íÛ7màË'³OéÂ?ÜìGioí‰óÜΣoK®øÇVÜÅâŠ�PðêHArÔ^iµž £± aXes�‡}…êp€±ª"ÔËÿزîM~ýÝo²mÃ[ût��üo|ûWW®{O<Éýëwš†òsÐó'êçÔµîe^»è;…úžK6d~ÈZcÈöîs¢æ/¥rî"ã oîÒ«ãä×½ ]}ôuæ–^Âh%`t'ôæ£�äÃÔïOÑçLtd"¹esV¡âZädî»"ª…9ueÙúU8¾ó�¼úìSûô 6­}ƒNÿo¶h§,ègO’�áŒ�—OwÙàИ¤«@éŒdX÷“RyýòmÆ­“œbÈHÎHz‡ÙÖ3Àëm=t$²f~,à³�Syu ËW|†á¡A¶oÜÀðàȺ¶o|€ióh¡Ýˆßƒ‚ÝýI[+ÀI†ªëY(?à$à ÷PU+�&§µ4}ø ¯ÏT¬cº)6 œN(º¢ ¡/ÕÅÔ¹äöÛ±ü(JžœÒZ<” =�ˆ¡Í˜s6 à+‚�Bm$ÈÔÊ5‘ �m2œ‘1”œÒnO ±£w€êH��×øê~ì‰ ±îÅâ³p7½ù:Ó�XHMC>¹µ<ägcWÿˆ¢:Þpo4FÇKO»�n>p+а+€ péàmžLÍ¢“²]|LH=&AVlÈ=̹ý–zênû]Śݗﬠ`Â!Ì-ÅLÀ¡¥Ç 90•úêÒ�íÞPżâ«ÍD̾tÄïe~C¹aÛm¿»ŒÍëÞ<`pû¦ Lž5—¦ “´.ɯ‡}‰¢)‚[Ê"ÙXAÀ$hæE190@¼¯—äÀ©Ôp–²K¸ª£~/M±0uÑ !Ÿ‡žäp>sÑM ‰T†wºãT¨–ˆþó©ó§·«‹Mo¾VÔ5f2i:öìæðcOÄhˆöÄ –%XÔ®iìDz7¼áÖw0B6§fåÁœ0‘l¾¿ýɼ>Æ~ô¿ˆŽŸjªÓÂxŠ9"€�•×\l)ôö$ŸúÌ<‹K (¦,@kc­O Î1WôH¿%2‘_ùyç…µÈ @›]SÊt•aÛ7½Íÿ~éLv½s`“ÆjÆpíÃOf©¶UÙzjû^C† tˆ·‡½Žn®¦<è3ìÛÓÑÁê<Êš'cÛ†õt¶·‘ˆÇ‘2ƒT{3z<^"±�ã&0eÎ<浄i‡�ßoM"Íå$Ói^kíf}G¿fsÒS�™ B>4±Žš°‘#0ÞßÇ…_ý»þ‡ÖÜ€øŽ-¼øÓ³‘)×ôåIÀƃÕ¸ïôalÒ,ª�<ÞˆŠk+·b 7i¦½¢XÂpŠƒé/ìrDž8›²ÞÜ¿=6áB„¾ÊP˜ZŠÛÑ‹©%Ø÷´~=t¦¸¿Ž5þ<·êaVÝ{ç7ãâ}½d2i[¼T[>ë¢AÞîè3Є è¾)fbEÔ°í¹U+ùñçÏàù3Û6¬§§³ƒ¡d’L&�Ìd@&“!�J‘ˆÇiÛ¹�µ/¾Àc÷ßÍÝ×^Áæuo …P4J0VC¯ÙIø…1±0õÑ ;ú¤¥³¡’›iFJÞîì§.4X~€–©Óyñ«Šb-’R²w÷.}è#šöyh2„‹1œ|Ñé�~z7¹’Í ›!xÐ)€ã�8Sx}4Ÿz&ž@ÈèûÛdà =-—F©eª®Ó» Š9�ÈÔ´Óß#¬áÂ|0©± ià$VŒÂH1¦Ø´‘÷éò‚J¡úz‘ ýÕDBšÊLrý…?{×êê·¾ýó–CEM­ÜOZ ”r×ïUëB¾¼P­~â1.øÚö™LJÉŽÍyzå_ynÕJ:Z÷ŽF©ªk°¸GS*cćRY¼ “$›ñX X‹Ëªk¨ihâé•ÅñuvµµÒ4a2-S§kaÁŒ”ìèK`ðL ²4)øË*é|í9Ò‰¸[„íYP�À�R^²ýüf9íP»h9ÑñÓ�Í9M+½ùWl¨¶Á�˜* G õyб°G˜" ŠÆ7`­A0æX1a! ÅZihJh:?uÕ/ø „àˆÆJžÚâƒqˆÙíz<Ì­ËcÉ�®ÿÅOÙ¹eó�±Lz{yë•YýøßÙðú«Lž}Ñ’XÞ$‚¦X¿WaW_ÒU¡æ‹­ñcbažƒ“-„`ZUŒcš« ç[�Í |vû^ÒÒøÙ§¾ü j›Š >vo¾ã—OQ˜ZUbi¢R êÖxÂéøJÊÜv9N•9à> ”;íPqØ<ÁàH ?ki¥Ôª,lPL™ý†]#x,w,ÅÅL̾w±VÁª73è"Ý�>³>� )Öe0Âέ›YóäcïºHÄãÜÿûHª9¨�©� Ç�¡D‚ÁÄ»×û"ÞÛÃ%ç|•«vq•Y(7šKÃ|hB�ãsÑÈìêOòf{�áÕ‰ÄJYñµo=—‡o7ºæóêÊó…i6n�Ó¼‚ÕuÔóa·S•«2ç;@5ð§ýå5Ä&LËVÎ!�+½4-¸ºmzÒÌý‰aÛå]šêË¥Ei˜I:5 Î¡Î1Â'ólw.=aCx"T†üT…�Yl7_ò Þ«ñâ“�óô#j÷Ä«¦VÅ vøE@´¬”H¬ô]Ÿãƒ·ýžÿýŸe˺7 ¯EM$À¢1•xÅQ äÀÌwwÑ•6è°Ã�9ž)sç5‡�›7ñÒ“�ë\;As,ì`-¹ß»±§|_¬Üm—/¨²÷¾+€/Q'©ˆMœŽ/Vi.QHžÕÕ_ê­qUcJi~á ŒR[qÍk¶âØ:§™¥MÊ©´™¬tÎ_ÒPìFj¡wGÌçm)‹àѽÀßx�ÿù8ïå¸þŸÑ×Ó­Í¿: ¹4b!661ÝÔ�Kó¤)ïÉßzåE~ùÍ/[R¢'VD™\-ÊB{r[»1rU^Îq§å«$ÝFjxˆ5�?bØ6³&fMk�öJßàè‡Â4ì³n§‹ª²÷¾*üØ­„(Ÿy„Åw¯#—ýï„m(E °�úJ{ÁDš�íd¤±û‹ƒ‚qÄüã\#M[Ågv+ÔƒGý^šK�I•�Üý§’ö;RŸûÆ‹Î7\â¼ú2Í È)‚TFÒ1�/³õx½¬øòSª½Ûc×ÖÍüß×¾À.î0¯®œª°ß!#_SÒ�²p".úЇ©¬­/ Ü»{—öwU8@ÐkJ³mÒjI æÈe*kÜN÷cö3—gÀ�Ü>,™<oI…ƒŒ�`3&Lþ»-ƒŽvºÃÝÊps+¤É4“zᗎdz…,Zš�>»yçúêåV©�[6ñâ?Ÿàý�Þwk_\­ÍÅ# �fj:[º�Êiò¬¹üäê›™0}&áh�׋×ç#R£¬²ŠÊÚ:*ëê)¯®¡¤¬ ¸_óìlkåGŸû4½��Ú½ó(‚“ÆÕ2.íqÁÆÎ>ºF ‘X)‹Où·¢Îݺ}»ßÙ¢=G¯¢0¦$dàƒ°ï·h²„ÀŽÂ Ê`1+ø¾/°�l¡‚õÀþó|¥å†$ÝvŽVÛ@ŠV_�¡ÖŸl¦ zs´–ÜÂc ñ©ûë“~0¶ëÒwì1†ðÔ®>úοZ¢¹r~X:éù ‡ÄŠ1„ª�ŸãÆÕæ“W2î¸ê7üþ] ý �?‰ïÿú*‚áHIFJ­«nÎm z==¶Š² Ï 6SÃüüÌ“ìÚº�×K}S3åU5#<^CÉ$Ý{iÛ¹“í›6°ñÍ×xë•—ˆ÷í[ÜÌ#òƒ+n ZšGÔ·tÅyr[»­:—RfãjRrlK Í¥yºµž®N>¿t>éTá¾ÿyîyüÛ™ÿ!$’µí½<µ£Ã²Ò뙃l-)éß¶‰7.ÿ!ƒ�mN§Û ŒeŠìOÀg€NÇ(›~¥Sç„ÌŽá7_·�¥ñ‡…ÿ?§E=!VÖ_Û¦ úJ?,ÉBŠ�ÍÇ�Œ¤ßW1§(ëæ«˜ ƒ t v~�`qSa_þöövvpÉÿ|ƒ¤sÕØ»>Zwî`ü´YB õ:‚^»úUX*#I¤ÒŒ‰+ï�‡Æ–ñL�;�ɳçÒÐ<ŽòªJJˈ–”+¯ ¦q ã¦Ngî¢%y܉,U·!÷“ÎHR2Ãp&C:“Ýfwß*ªjøŸK¯à¿~‘¡‘I¡1˜Hp×Õ¿%9�·šê"Aj£AûÕXÅm2R²§?aØ>nÚô¢ð‰®öÖ|Ç"‘-§Žø¼®}ÝšÈVÎ=ŠpýX·S.Peò=qpði§Õ¿úˆe«ljñ�¬;è}á\[o°°ÛßœŠ‹‰íÇdú+Zº¯ 㯢Ë}…•lÔL"j×¥H1Õ%èiÍŒU�ùãWýéÇ„ò-eù~}Þ¹lÚGZëwc¼¹æyŽýèiÙÖ] 1÷tªÄœzEÐ60ÈÖîzSì¢m Kû½³7Áæî8owô³¹«Ÿ]ñ$]Éa­o0·jê")gÌfü´é¼ñÂs ô×Á¨uÇ6&̘E“ÊãçQ ;{Ý;"'†ÓLÒ5yo®y޶�; žsê¼´L™¦Z Û{òmØÅÈ¡7Åã¥ãågœ>ޝ±´aûb„T`o�”W2k+=Ø‘gvRWJi‹Îë‘R)ÝMðlz€t-¹2Äñ Ù‚fUÚ¢þÂ!ØŸ»†œ{g›\d"Á”–LB�Ùë÷h©¶¹­«ŸxŒ§yˆƒiô÷öpý…?7<�Y5¥ÌBÿ\‡3¶÷°¡³Ÿ·;²?›»ãìéOÒ38LïPжø ;ûY½«“'ßiã©m{�l»êÉæ-YÆO¯½%[ª\ä¸öü‘Ñå(´”† x×(Mû@Ò�lïõù˜8knQçÛ ËE@Hw_¤ƒàÈ)Õ –ዹ¦Ÿ¦Êæ»®*€œ> ×�Å«°ñ…­qyéꃻ ›T`sžµÕ’”öþž0¥öJÓ¾6H­y*†fÒtN©#õTs‹…ƒé pXmy¶(E]òúû¹ïæë|�?§ñücgõã«ò¡6!8¼¾Ü\351+>‹éž–ìêOòôö½¼ÚÚm*A†–)ÓøÉ5·hÝz �ζV¿ï.íoŸGa|yÔ‘·/WÚªS@¯—¦ “Š:ßæµFz±˜ß›'v5G€tŠÁé=ó‚Ô-^îvÊTÙ|×ÀÙŽ«¬ÇCÉ„é¦.‘Eúñ†•_:gÞ9 ¶Ã•\'`§Ý¥3ɤ�áÖÄMoÎ×wB‡¹²¿"l“]òŠÙz/ÌŠ@/ˆ»úyiw—¡¾^(‚åŸú&Θ]Ô\·mx› ¯½¬ý]â÷R¦r+:eš{$–”•--\ÛÐ×Ýip9|�ý»á°xعÁª:ÊgÌß'Ù

 x ÏÎâ:dòÚ\Q<(Jq �N¹bJ p/5<�¥“f‚â(º~UVßtòMBõÍä[Ã8õOüí~öù ¶!@;KÀ “ºŒ Œ„öÄ �bÂôYE¡ó}Ý]tµ·Îòyt��#y×­¸DaBìmC€zLÄÆšµ+æ,ÌvÕ²ÁwCø�ÙNû‡êÆâ EL±2ýê) DëlJ~µÀ¯ÕÉ×Ç„¥�p —óhE<.Ê$÷�͸�PiÇ„Ã9�¢e¢ˆõÑ S«b†½ûûz¹é’_ìsõÛû=†‡¹ï÷×ÓÛ•ê*‚>ZJÆRX7Mïø­Ø$­»¯�PˆÆqã ÎQJIO—áqÝz²”l¹  1<4TœÐ#ºÒF1Ú-Dѱ VÕ¹Éôl7k}_ÀõÇvDÆŒGx¼–$û¶ÏfsYaIÃÿõá5áŽ6À¨çDHâFT¢�M»¦8H{Ó? eOòz˜U[¦ÑTå®û–__Ä[ûÙàóýk_ZÃS?¨½¸B%4 èºó*WOÚ2"Yœ>w\ïà°¡n@UuÅ‘vô÷öh!:!܉Y=Bä©Ï¤$5<\´EK,swmz$ ¦¹m•sþؘÿAUu˜ròÊdþ»¹ó"Oªa!þ°¹SúÎn×ÜŠnn¼©í#œ¡@s'Ð@Ñý­å,Hañcg×–iñçÜXußÝÜwóõêCJÉ]×_IwG»v§Ê‚~šb!c›lì)² á¹ïf¤åýŠ••Î VVE8çèSx‚ä@¼è¬ÌH4ªÃ2–‚ÖB€³Û¨^pì>Éë¾(ÙJ#Û}}±2|¥•:·úKÝ l¼¹Vf]©WÒ]K:%!³y«�Ÿcù•HÎeõ×™ÿRº*jKÒ�Å´U¿9³¦”沈á{ë^y‘kÿï§|PÆÎ-›¹ç†k &ý”ʃàx»LxÁHJ²‹Eç3™´½Kisl}{3 ô÷3Ð_ØE —Ä ˜ÄP:cûŽ9á[Ò�7�hÓ8‚Õõnr}Eû£ÀbG?§¬ o¤ÄÖœ6_ƒ0�9æ´{avðu+¨Yôòñki¯tQ¯y-›±5=¬Q ™…µo�Ñê�¶ê!w“Ç—G™¦óûе·�.ºà}¯ó?ÐãÎk¯`Ëú|s‹€×Ì꘻ðÔùáU¬e×zìÁõ…†Œœ yhÆD0“Mj2Ÿ£¿§§ð ÝÐh¸„d*ƒ,¢ÐÅÜiÉq?ÅCÙt× àŪìp¤Ó$‚5cìÈóóþ¿´ŠìŠ,‹€CL@¡0ùáÒŠÖ GüÀin/¡t¾Y®I ]JªÃ~æÕW˜@³!îºîJ^{þ>ˆã·?þ¾!ÜR¡4àst,‚ ž•€ò _c…Ê�ÝE°õ#‘|{0 Y8@-õÅÒGi¤”´ïÜ^puhš0Ùˆ; §´4fK p·Dí" ©š·Äm G(ЀSÓE!T74fÞ¼°ÙFÑ̰}‘­¦rxl`‡ÐK“u!tX„tõÁòæˆúyØ!‰ö%¾Bd[F1¦Êâk>|ן¸ûú«ø Ž7_|�GÔÆ¥9Q�__^D¸»p—䪰�®Þ �J�;QVQ¥ò:ªß•öij>Ez¦†‡Øº~]Qç7m†á­ˆë(È„ èvÍæ7=ÜØŒ'vÚ¡\•ÝýVuüµŽZÚbþ›@3‹àš¬icVkiþÚ#íÔ=H:¯÷¹U:#­ ¥°ómLGqè=’í꣎SE‰Ï˜ìóôÃãªóÄyd2¸õ&:öìÖnfiÐÇ„òˆee<aÊ£¨  ”ðk_\]ag(¡¤¼\;�†SÖª! ¤ ©¡aÞÖ¹�)‡®1#³‰Å(½‘ $F¸¡eŸdw$ àGß ºAeö)3Ö…PÚA~Fá·Kå×L1›°bþ³ÛÓpL,! «¿ùØòJ£’° ÜÜ‹ãÆ×Q2ZbÞx•K¿NÑá¤Cq477 …XÿÚ+<ùÐ_óa7Õȇ’l¬ó}- ú¨P;%å¶=zÿÝEÍ«²¶žJµÛq–ô¦l•΄ #¶Õݹ—mÖ<‡¢(Ô5åAød*M²€pËÿwRÂëÏvÝÚÙ‰ø˜£6­mr»"Ãc5›æBû*™ý#{Ƨ:é¼Û%W©o"­Ë¿=õ¿´(½[¡Éñë( ó1v½³•‹¿ó ú{{>�‚‰DøÖ·¾ÅÃ?Ìyç�À]×_ÅÞÝ;µÛ øSr‚Z\]4ņÊÉd"Áó�>R¤¨£¢¦N;Ø`:£K(ÊŸ-äõPizvϯz¸¨sLž3��N30œÆÕDÞ‡!<"�-û$»Å*€ ·}ÂÚÉ�«ºî`š“§ox Ò!µÿºŽÚË\X$AJQàØÖÕ_˜ƒ˜sþ À@~•ò*‚ÅÍ5¹§³ƒ+vﱊŠã3Ÿù ¯¼ò —^z)Š¢°råJÚwïâŽk¯Ì¬S«Jˆ˜éÃ\Ôîw]4˜�¤¨ýó]EeO !h™2�P8†ÕSŽéq›qe­|77ûó�E݇I³30õ¥l]#;ô;å(Ù«ëñ„"nò]±? àd7í“�ÿ PÏü™CU� ŸŸ›_í -÷< IDATÿ‰" Da0æM‘‡`¿°õIóQ ó\6U3¦ÔúP.ýþ·Yóäã8ÁŸ?>o¼ñ·Þz+|ík_còäÉ<õTžÉè�[oâÕçŸÑ5U˜USj4sqàеI[ØXi1ËWÝ{GQ¤)>€yK–¶mï°<[¯4ÆB†þ‘ë^Z]”ùoVØ;0d¿J× ¹¢0iE¡Vâ'ï�pt0üå5jA‚4IAqy¶ÒNª]î‚mú­mxÀ½{¯pŸ–�R±&5ž“é/áØ u4•Y…ÿ¼³þƒçûûFè=óçÏç–[naÍš5TTTpå•W2iÒ$®¸â Ûï\õóg�:õ!4”„¨‹¦ÙÎ=³…�øuÉD™L†Çõ¯Ì•UU1i>ƒ.#³ý Ìg«Š¨Q­·Ü~ð�7uŽÚ¦fš'ç³pÓI[o–œÓÄl¾ÝãÊ#†Oo|í &‹šsIY9§áËÛnm»7¨‹†hÒ±3 !xð¶›(œÿïñz™³ø¼ºàm=Ú á.(NaQ÷¬@‘ÍÅq& �¹¯ ÀÕ®ðWT[„G"‹Zãvaá •6Ù|²€£$Ý…Úä»Íl¿ú#éw/ ú9z|µQc]?~}ÞwxàÖ›yS¿ººš³Î:‹ßþö·444000À�œí ¿`ÁŽ>úhÇïuÔQlß¾�iÓ²–è–õoñȽw‘Q‹cÐ\&ê÷:úÄQ¿—sÏ-—ýÒÀ@Thœñõs óÏhGï}CFÜÀïQ8²±Âð¼wlÚÈšÇW•ýŠDYrÊG ïÇí½° ø=n8€š ª®G¸×AT싘‹CM±ðúP!ƒOì*ï&€]¸„ŠÂEìZ‡ÛYÒD2ĤƒË¡¿¿.z¥<`ùä1T†Œ~ì`2Á?ÿ™Czå�D"wÜqÜsÏ=´¶¶rà 7ðÙÏ~–ÓO?€;3Ç{ŒX,Æ¿øE‚Áü=ÌŸ?Ÿ;gžy¿ßÏÂ…ùòÕ»¯¿šÖÛµ×&âóÒ\vlÒáó(ø<ùÆnCCC<õÐ_‹¾–‰3f³ìßNÓeFJ6uÅ-ËÌâ¦Ê|~‚j±<ñ—{iݱ­¨ó¶äÊ*«´Ìø¶ø ýC)ÇÞfWÇÞ/¡jYkÜ€_•å+€Jª‰¼á<þ I �%WhûÏ‹ÌÕwùLÚN�D¬gjÓÓ¹Ô§9¾2Æ)SÇâók{º;¹ø;_ç¥CØçŸ>}:7Þx#�>ú( ,àŠ+®àÌ3Ï䓟ü$�<òˆ¶ýøÇ?&•Jñ‰O|‚%K²yéGu×\s kÖ¬áä“OæüóÏgñâÅÜtSÞŠ÷õf[œëДʃð™W>ý¶®öV“Å™þ^Ÿ�_þ:%ºá{i4ì7¹2J}4d8Ïžm[¹çº+Š·2¾õ]má�^ÙÓ­]�,ÆW.´�:ìªcËÆ�3ÄTY¶¿?.§hrT‘h^ãHwAÒ4›t/aÉ6L‚²¨Ÿ�&S,_Ò ;tUý["mV$‰ßãaj]%s*-SÞ±eWþ쇼úÜÓ‡¬ðO›6�?ýéOÌž=›óÏ?ŸË/¿œžž†mÂlkÖ¬á–[nᬳÎâ /díÚµœ~úéD".½ôR.»ì2vïÞMÚ¦­÷£÷ßËñ§~‚#–§½ësëÊxz{™\é¶ÃÃÑ’¢;ê'9ò¸“´¿3R²A%Í­¬µ‘³kÊ Õ…étš_÷›E¥,9å£Ô66isîNkàŸYn‹ð&l÷³Ë@J|%¥xƒaÒ‰¸“hÚ `:`{—}Ñ25¸¸uÙ‰Ô.óß®B¸­íÒ‰0ÌjeÈ‚`h ‡¤$àcÙ¤1Ìi¨4�†¬}i5|ýK‡´ðÎ;ïÈÖžͲð{Žh¨ è5ŠÂMý¼è¼�×˧¾òMöõ}Y2Q³ã ï¶™3±øÎÔÙ†¶ÁjGŠ0Ÿ*Ë#V1G³!Zª¢Ž¦Jì3¦¸tn=ï¿MNŽYè¤Ão;ŠñbHWõæš4˜’¦²(šÚL­)…Uÿøë}üï—ÏbÛÆ·9¡¯®®Ö^¬ššÎ8ã ^xán¼ñÆ¢¾ÿì³ÏòÐCÙÆ¥­­­,_¾œÍ›7õÝí›7ZX�fÕÄð*Æôñþ¡qS*í™çþÀµHiE%ÿ{Ý-†Vâ)yfG‡a¿)•%–ðâ?ÿv?+M}ÜÆÉÿñ_Ô�mÑæÜ3˜b{Ï€å/få—.ŒIÒ%Ç%PåÚ!6R pé4ê …)È÷m–L'€·í yJÄŠÚY+ �s2s÷KÈd‘áÛj8vÒ‚¦Š¾äÀ÷Þt-—ýàÜCŽÈ³ººšK/½”ë®»Ž¦¦¬…ØÒ’}‰ÛÚÚŠ>N*•âw¿ûÝÝÝTUUñýï¿èïfÒiþþç;Ù±eSÞ ñz´TßÜÎHv÷;õN™s?øÍµDc¥Ù¶ëºÕ¸±e<çßx5µ†wã™{ E9>Ed³u;½³a=·\z¡�ÓßmTÖÕsÜiŸ2dþíì o8e+¨–Œ_!ÜðrWÁ×ï(¯vÛ!ä$FN*´Tý±Wþ`>^XW˜=naããK¢¦òþj˜–ÉXéÐB8ÊÓ¼èææËjпÏ㥩¬„Ù�•”‡­ºpÏömÜ~õoxô¾CéW…³Ï>›P(ĪU«øÝï~§%ñ,Y²„±cDzaà 8¸dÉæÎ�K:�æé§Ÿæž{îaÍš5ÜvÛm|å+_aÅŠüñ�´|WÞ .¸¿ßÏ9çœÃÎ-›yøîÛ9ó[ßÕVô¦X˜wzèÖµåÚÔÙÇø²0aU `ÑI'3nêtž|ðvmÝŒÇëcâŒYÚ'ð‚“|[o°>L­Šäo0™à–K/,õXöÑÓ§sG©4¯µõ8Z–ö‹“½ŸŸÃœØ�õw..€^ž»‹ÐÇ`“D ¼>ê�?�HÓrYBQ4!JÎdQ´‚¡¨ |ª TTü G¿�EysÇÒDf¦�qB(ºã í3¡¨}ÞÚqróRÈ6‘ÌŸGQ¿jš�ª©!S^Â̆*J£ê "4m-¼ðø*nýͯù¢žŸþô§üä'?aÓ¦MuÔQ$ þþ÷¿³páB¶lÙ·¿ým’É$Çw§Ÿ~:&L°ã…^`ÅŠô÷÷³~ýz***øÞ÷¾Ç%—\bñÿ—/_®Y»víbܸq  ‡¹üî¿1nJ>kuSW?/ïé6M]$À’¦*góNgAhÕ÷—¶®‚C°!¨ €•ì‚^ÇLÃñ“ÇÒP±ÕŽ·üú.:÷¿)á�Åb”——ã1ÅŠ¯¾új6oÞÌ„ øüç?O?]t‰D‚qãÆñç?ÿ™‡zˆï|ç;L˜0���¶mÛÆºuëØ´i™L† pÝu×ÑÕÕÅÏþs¾ùÍoÒØ˜åÄ ƒ,]º”'Ÿ|’•+W244Ä÷¾÷=Rù’\wáÏŒ^y”Rsòîþ$Ïììpw MŸ´Æyzû^rݽôï§FRªn{þчGt_¿vÁ¯ Âß7”âÕÖn—V³Nh³}�L³ð;�RB°Òp”g§àap6T`ž@ˆØÄYxÃQmF·‚[VaJnuדäVa�e m×V[aµ çC«ØÊ﫟“®M˜PtmÁ„áx>�ÂäÚ –Oo¡22Y�N¥YÿÚËüü«ŸçÙU+]‘íƒi,_¾œ/ùËœ}öÙ¬X±‚E‹ÑÚÚÊ®]»²àZ?�‡åË—³xñbn¹åžþy6oÞLyy9Š¢ÐÖÖÆ«¯¾Ê]wÝÅ5×\Ã¥—^Êoû[n¿ývººº8öØc?~ø u•~g+Í“§Ð<)_HSô³YÍ×Ï Aï`Š­Ýq"~o¶£ÖRćҬÞÕÉkm=*ý«Õ6˜^­Žê×ÿñÀ½lykmQ÷öŒoþKÿí4öG6í!žJ»·+ ¶ìL}#g C&«PØþàíN‡ïîÚŠuæ©.€¥¸/VNÉŸÄ_^¥AEÑ„E3Ù³ ܭ‹@QòBª˜€’SнPt}ÿt&¼ÇNˆü¹„((ÖW�¢0¦¢„9cj¨�Eó@ y°qíë¿ßÏ�7ÞÈgœÁO<Á²eËøÂ¾À5×\ÃÐЩTŠh4ÊøCþð‡?ðÎ;î©»õc›¹üî¿QVY¥3©»ØÔ7˜Ë¹iT„|ÔEƒ„}^�Õw0�aï@’í½ úŸ7À3ºK8}j#!¯Gí= w^ýn½ì—ïïÂOæœ_]aÈEx«£�çvthóËØXÂS¯I3È—1 xFZ±)­•¯RJÒCIžþÊGs §æ±Su^ÚÀãE˜Ã/¶`ºß›…xAõ’SàAHs$Rª]|õU„ÆcÖ”D8nj3ÇOm¦¶$b™DwÇ^®ûåϸàë_:d„¿¤¤„›nº‰Õ«W³lÙ2î»ï>&NœH8æcûÃÃô´´pþùçSR’e¬Ù¶møÃæÃþ°–×ßÑÑÁ¶mÛØ±c]]]¶+ÏÐÐ÷Þ{o6|7k<ð/¼ðÁ`�G}”úúz.¸à‚‚ŸVï½ñZöiU±¬�Úwv$†x³½—Õ»:y~g'/ìêä•ÖnƒðVWÓ÷³iºùë:êÄS αyòT>{Î÷ñùýÚ Ù=8Ìk­ÝgÔl©g Øbß;Ð5  'OU<ølèù ɳ“âÐTx¼Ù6`Ž1?Óß–ÒÞ …´‡±¨È÷·;�MPZ´eö¼Š,šÐȇfŒ£¹"†b cðð]âÜOŸÆƒº•®ö¶CBøW¬XAOOgžy&�?þ8sæÌáŒ3Î`Ó¦M$ xà>þñ�“Éd8öØc9ê¨|;ík®¹†�7‰DøÆ7¾�¯È,»ª°{÷n-„øË_þ’#�<’SO=•={ö},)%�ÿåÏlZ÷¦ÁOŸVUb('î¡àú¢3HCs ÿŸ½óŽ“£<òþ·{òæ]­´»ÒJ(KP&Á ‘ÁdÆ6‡Óqw>ö½ø ؇m°Í0ÌÙÈä`l�‰"ˆ �¡œVÒîJ›s˜ÜÏûÇÌôt÷tšÕJóðY´ÛÓÝÓá©zª~Uõ«‹¿ûo–Ç ùÖ�ÆèñÕïR|ÐÔ•“§�;E%Ë{¶ú[–X6xåñØ1YÊó€ÇZîí”B:Ì&YêgŸI˜Ó� áœk-K2*K¹â˜Ã8¬¦¿×£ó¯ñ8{¶oå¦ï|�û~~­Í®*À>-c``I’ˆÅbüö·¿åã�?ÎÙçÕW_åµ×RuígŸ}v¼êëS¼… râ‰'ºúÎ@  º ¿ûÝïÔíÏ<ó «W¯Ò}´ìm`éÓ��ÅÒ+©Äèâ~>A.ÚoG—@ã.›ÚzÕl=Hå\ö¯ÿÁùWÿ3E¥eøA¼>?þ@�i³çrËŸŸâˆ£�Õùæ[:zÓ%¿ÖzHÇA%¬ý{ã5ç—¹*á †òVVy~«Ï$YÖXWúÛÖý&B�уá—eáJë›<=mJqmY1³ÇVQ[Q‚>!õkcÝN^]ò/>þ�Ê^ûi'NdΜ9ô÷÷óÖ[oñüóÏóðÃså•Wrýõ׳lÙ2 5ìñxœ>ø€E‹á5¸qO<ñßþö·9ùä“ùÞ÷¾ÇŠ+ˆ8$ÃÜ{ï½\tÑE<óÌ3<òÈ#ÃvoÏ?ú 'Ÿ}>‡Î™‡ �™T^Hç`TÛ©Á4äg]|n®½··ƒãÆf1ŸßÏU?üoN»ô ê6m`°¿ŸqS¦2eæœa­ïä½½�n§d6&Ü ¼Ðöµ²@ Ð[ /•½²ÍæšFö¨TàXÔÖ§¶bšûoäö†p`*OX¼4a,èÅ*Y…!N9t< gL ¶<×?Š†Ã·=+�”� _ ˜fÔNbô¢‹5n)‹ ·—Ò©·éTàTœ>›$i’r2É@²!qH’ÓØ¼”Ê/Ð¥ËÆ¤Ÿ” Œ.+fÖ¸j&�¡>œL–Ÿ$I47Ö³ré‹<÷Èbûû]½²/}éKüò—¿ä¨£ŽÒ­¦� w]z饎~òþ ŸÏǯ~õ+®»î:˜1c ¹Í)Ï?ÿ|yä|>×]wcÇŽå‡?ü!ýýý¬X±‚… âóùøéOÊŠ+¨ªªâúë¯gÖ¬YÄãq^yåÕ¬÷z½<ðÀ\yå•Ô××3sæLb±#Gޤ¯¯�¾¾>.kä÷wH’ÄW¾ù-¾û㟥kOqEðJ]K�Wæ‘(&ìÓBdš¿˜ùÙ¹+s¡ßCiÀ‡œY !è ÇèÓô÷BäÄí³JÈ|]†6óÚëÍ|�±7†À9 8ÃP½áΟйî]«ÇùURÉ@®,€¤í- ºÊŒZתƒ� ŒS½±f^´—:‡,I7ug̚ʤ‘zØGñ8Ï=²˜_ýà_yò¾ß¹þššžyæÞyçN<ñÄá8÷Üsyûí·©®®>`�ÇY²d mmm²hÑ"Ó —ÙÇçó1kÖ,î¾ûnÚÛÛ)))áŒ3ÎàÉ'Ÿ¤´´”›o¾™×^{�G}”Ù³gsà 7àóù8묳زe gžy&‰D‚;3ÞÞ^jjjøö·¿M8¦¾¾ž®®®ƒ&ü™yðÖóϲõãµê$ðÊ3*Kl•†ÐÎ ï‹%hì ³§g�úžAöô ê„ßltB ;àÀ5ò­‘“„mo„d>.€å[Š‚°4›�Ú4ÛaÇòi;õÀžC°¦¬˜Yc«sÌ}€­}È ßü*�Þý[ëv:>Êââbn¼ñF8ï<Ç®Jy䑼øâ‹Ìž={¿'û¨Q£8ãŒ3¸óÎ;Yºt)üã9çœsX»v­š:{ÓM7QZj^¤Y_Ÿª`óûýìÝ»—{ïMu®««ãg?û½½¹åÊ¿üå/9í´Óضm¼ð ÜvÛmìØ±ƒû￟¯}íkÜÌw�m­<ÿèƒÄ¢Y ¯ÑÅAFÌgŸ ß„daïŠ<8s«!nK‡@úý\{éL¸m¯IS™¶r&¦M†á-(¦xâ¡ÈªÀe*óÈÍéWÁBI‡hsú3x¨)ºèêÐü­ºšï;õ°É”jž¿ºÚÛxà×?çÁßÞFOg‡ëxþüùóyøá‡uõån¬…/ùËlÛ¶��;wæ=Á ¸êª«¸ýöÛ¹þúë™7oS¦Laîܹ\vÙeƒAžxâ Î;ïÞ}÷]|ðA6nÜø©ˆ~ÔmÞȼ'2jLm‘ðËûú"9d3ÂÄ7¥À¾#¯ÖH\¦g¡UNšÈ€öü¦×ibï{ýYb=�V«ÿ#Àv·@ˆš£,I„’tiø˜gý Ì*úìCÂè`¥·U—eU\"Á[/<Ë�®¼ˆå/=—÷DkiiÒ�:uªGÏg|õ«_eÕªUüá ¢¢‚K/½”™3g2wî\þú×ÏÀµ×^Kmm-O>ù$²,s饗2yòd]„àŽ;î`̘1,[¶Œ‡zH%ešs^sÍ5jøÏTÀêê¸æšk8öØc9çœsX³f Ÿ¶qß-7꬀1%T\aÐù¬ÒnpL‹rlΙ/°-ò Íœ+ [íMË´kÀò‘L ’‰¼®Ö²:JäzG–¦“Ý¥¬¦lÛ×H_w×�&YWWW^l8ÚQ^^Îïÿ{~ô£9î[TTć~Èã�?Îøñã¹îºë˜>}:O=õ›7ofíÚµ\tÑE,[¶ �ÇÃu×]ǯýkººº8ôÐC¹ð øÊW¾B]]W\q/½ôgŸ}¶ÎG饗xã�7ÜrË-¶×‡YµjÕ�ïÿ@�׳d±¾N`þè òŒ”„Oà^\ “Ô™7çý“¾I!§÷¬îZÍ@Í¢šL’ [6/±\Ðí,€ˆ¹�@¨“ÌÄàFÃÆ<öj­Ì÷±Òò-=ýê²ÇÉg…Ú “† ºerÚMí(‡2`ŸÏÇ­·ÞÊM7Ýd fF?���¬[·ŽxÀ”xó?øŠ¢0þ||>Ÿ*Ä×\s ëÖ­cÉ’%„Ãa®½öZÎ<óL¢Qý;îëëãî»ï&sÉ%—pì±ÇòYýÓ}ìÝ�å z=Ì©.·ñÕ�|’yZ6v­é÷Y,` • b›WkÑH• %I| �|åÙJÄÒ?¹_˜H ’qÓKÍñs´!cU�°Ð·@’ $ ²¸�‘Å…i~=nøöÖ]ºcGÕŒáij¿‚lµTv~üêÕ«Ù°aƒãyn¾ùfîºë.µâÎlÜpà D£Q,XÀå—_núr·oßÎÖ­)â‘3f°xñbÖ­[Ǹqã˜1c?úÑ�X°`�.ß8^ýu–/_¤Rw ?³  ·«“¿Üÿ{]îÆ„²ÂRO3³Q¸èYkžŽëÞ±�òØn¶ªk+2¡o.¨Sl"™°‹Xʳ] aå(ÉÄô¦ý¾F�DQÇO›ÀeÇÎæòãfsÙq³8oÞ ¦ÕTêøú;ûÃìhÕg¢�uÙ×3~bÞ,‘HØ2Úö÷÷sÙe—±cÇÇs}ë[ßâ…^ ¦¦Æôó 6pûí·)3t_’$�<”R IDAT”Ÿ»gϺºº¸çž{P¯×Ë’%KTr«ÑÓÓ£fnÙ²Åøú,Œw^}™�kÞˢؒČ‘%6®€pí×Kû)èùäåû„C× Ä m*¶¥<ço$(±˜³ìâõº*`CÑhòTpƬéÌ�0† ?Þ«)+fÑS˜}H�z~E>ÚÓDXÃÂ*Ë2ßþ¯óž\ÉdÒÖ˜0a6là¼óΣ¡¡Áñ| ,ॗ^Rrr€­û¡�éÓ§óÍo~3çó /¼�‰'òöÛo«–À³Ï>«VÚÙ­üÚñꫯ2nÜ8•»ï³o[[›å*é÷ûUîüÆÆFN9å–.u&’œ:uªš:l|©O?ý47nTÛi}ç;ßá½÷Þã¶ÛncÉ’%\rÉ%¬\©ï6ôÐC±nÝ:�×^{ígÚ¯ê¸÷§?&¢¢Þ‚Q…Ƈ,1¥|‡e× !Cp!rø +º’OcPÔ"ÒÖdë Z)�Í™ÏÇ¢¥P°jLª!¡¡Á<(õ�¤#ýÔ“‰f¶Ï¨­æ¸i4ä¢ÐÝÞÎ]7þˆ¦ú=Ì:æ85᧦¬˜-éFÑDI’[Qª^—×ç' æÕ®«¢¢‚ .¸€’’Ó—ñÞ{ï©Å7¼üòËL�:•éÓ§Ûž·¨¨ˆ… ’H$T>>H…ËÊÊ8餓8üðÃ9ï¼ó¨««ãë_ÿ:÷Üs»ví2Å*vïÞÍ•W^ɸqãÔòà¤ÑßÛƒ$ÉÌþÒñ궪¢ ;»HŠ,¨�ù­_©�éçÂAä,>f„IBˆ!#gfHëª×鯷Ħ>ƤÀΰDâý=j¯ìcQ‹,Š2L}EÁÇ:!ç‰w»ž`d¨`i.€¹[à´®çD:l“Ø,eÙ.V68ÑLIÈ�ÅfhÊ5AÊn•ˆIc¨´Ü iÇ:‘q•e:zñçý3Kÿò˜ú½;7màÔ¯\¬¦ —„‚tDè«—ÐÒÓÏáµUê£âñxøhÕJ›->©pÁ¨¦¾î¾e™]»vñÌ3Ïäóä“ORSSÜ9slS‰eYæä“O¦¸¸˜åË—�ljD"„ÃaÎ9çÊÊÊX¹r¥+Í�72yòdþó?ÿÓ»ø¼ŽX4Â`?Çœ²H ûú½´Fé�'òTm‹�p&Ý/ÊÖ+»ǘ+#ý^{þþ ó<€ð4°<_ `7¡ƒÄ@_n:°]v”Á2nURĤê:¬aÇžúÃݺ=›öìæ•¿=©»ÿ¦�Ç#g©Ã:"lÜÛ¦;ÿ)ç]DíwaÁþþ~[‚‹ÚÚZËÕöšk®áÇ?þ±íÊ�ßÿþ÷yì±ÇÔüãyç�w¨¬¬äꫯVÃvcýúõ,Z´ˆõë×ó�:ÞóuÖ½»"k¥IÓ+‹M}øTìÝYH% ‰¶E …öÙ”áâ}=V;&Ò²L¾   ‹ˆGr°%Í}ªút�} �PRÉ>“«+) t+ê½·üwî÷&,{æiºÚ[Õ³|^ŽšT«S66¶ÖÔ‹ àšîÒ½JE¬Ì®ŠŠ ÊËË-­‡#F¸^€óÎ;�¿ýíoLž<EQøÉO~À•W^Éœ9søb8�Áþ>ž}ðO„5”d# Œ,XdÞ SPÎiÎå“¿oV´aÖ++»ªÃì¶pë>¶íì´LËrÞ `3±C%E‰F1Ðlq„¢^³±Ê*àõ2{ÂÝóù%ìÛ³Ëô¢wídåÒUs^¦×TRQRV[˜í-�º—8~êtÎùÚU®&USS“eÚoUU•)ÀW\Agg'×_½k)vž7ß|“I“&ñÎ;ïðä“) 'CÉõÅpkÞZÆê7^UÿöÉ2Ê sòé±X¥svXú¸uÚóÁóŒ²åÄd"oáæFkž†XZ–óV�6.±žvÇ[jGÙB] ¦ŒEPÃC?Ð×˪W_F±É»_²øôvw©/¬0àçðÚ*•ã_ X]×H<™Ô¥)œ{å?QY]ãøRvíÚeI|QQQAEEŠx¤°°� °fÍyäKËÀiŒ3† 60þ|î½÷^:::8æ˜c¸ì²Ë¾�n—ã�·ý�N˜Fty–v]ʳ°sôɳ ЕXÛ€Ú³DÚ›M*tu°EãP€%´nÙ›{AšâìóW,ïzþ¤±º¿·­_ÇÎÍö 6ÑH„Çîþ_ݶ™c«)Õ$Å’ +·ëÛ<—”WpÑ·®qœÛ·o7-Î�þ¸qã8ãŒ3X¼x1o¿ý¶ŽAw¨# òÊ+¯0uêTî¼óN¶lÙÂÈ‘#¿�l—££¹‰M¼¯ŠF±ß§Kw~»’b#]—ñ8³N@VY…n\ æ`S¤ÊbSƒUK0[v£,³\bÍfN°Žö;ƒ jI¾µ¿�(* ¬0[5—ˆÇøø½wHÄãŽ/|å+/²yíûº‡vêauOiKS;-½úȼNfêÌÙŽ.€õÕM7ÝÄO<ÁÅ_ìjr>öØc®:ä„B!n»í6V®\ÉgœÁ]wÝõ…dç1voÝâãÑ ©0Ô£äSücÓÅÎôcú¯bØK8õLï$IY| �íj’ »Q–O4ÚÙšË �÷´�X(Á47:mŒž–: ³ö�å®_ÀâßüB§,ªJŠ˜9¶Z×DòÍ-»5Tå‚’²rÎüê•¶Õ‚MMMôôX¢ªÔÖÖš& Çúõë™3gW\qÇwœcÑsÏ=ǬY³X¶l»wïþB¢óJ2á˜wo'¤Vuû©`Öq>!¬‹n7ZÈØSŽ™â†m±žNb=C’a7 àeˇ‹ïïÍãõh3é{žP5Bgó´77ÑÙæž•§©a/<þ�.ò0oÂh üYL¡{ ̺†ÝÃûÒÂÓ™>k®½ï3Dv D"Á–-[¸âŠ+˜9s¦š5XWWÇ 'œ�“Ö�FYµjÇ<çž{îç*gÿ`�±“¦¸öÑ…‰™>Šárõ75ñ BîÊö0„2c=�Äz»‡$Ãn@½Ý‡ƒûv›ßžMU v”„t;}°üͼ^¸P–¿ôÍ Ù¤™�ÏÇ‘ƨñKØÚÜN_8ª›×þϯq€|Ç|À~ðæÍ›§6Ø0Z_|1Ï>û,¯½öW_}5Ç{lŽbøbä7B…E̘´ú÷@<¡[µµ‹¯á®%ÉGÞW#lÍz® œ+õ'Ó_Q¤­™„ýBl+ÃnZܼœlöA¤¹�kóL¸€„„�RÂ/Ë™ªÀTf`ÝæüI(›êw³ò•¹àŸ¾‹,{�$˜4ª‚-Mí´õ]a¶µt0oühõŠÊGŽâÒïýOÞg^RûÑGñµ¯}ÍÝ545ñãÿXíÔã´ï·¾õ-îºë.Ö­[w@ºê eH’D " ††Bøü~|þ^¿ŸÏ�/Àçóáó�eE()t](É$Š¢�ˆÇI$â$âq¢‘púßñX”h$B,ap ß6Ê3”qé?_‹WMjˆ¦j,’ÜÅâ­¤Ó.óOXXzrP'PÒE%c2É`sƒ“첿 à]Kк×Ú)2!-L±©¦Ä½$ÐS�Iаsû�^þó�>Èñ§ŸEuí! ä÷2sl¯oªK™y’ć{š˜V]Iq( ¾…3.ý+—¾Hã®\ 7Å5B~õ«_ñ‹_ü‚¾¾>×R{{;Ë–-ûÄ„½ ¨ˆ1ã'RU;–ʪF�ÈQU**¦°¨˜PQQZàtÂåf$…X4+ð±H„ð@?ƒýýôvuÑÝÙAw{­ûöÒT¿›¦†=t¶Íí:êä…œyù7³Â-»{H*Î!4á`úçï3w?gõ׆%ÍÒ�Ý„-•Dœ¾ºÍN²»ß ` ðÿL_r$L¬§i…AÕå®þF<#àóéüŸd2AOçÐVÃX4¿ú97Ü•%�œZ=‚ {[ié@‰¤"X±½ž3fNQÝ´‚¢b.¸ú»Üõß?Ì[|ôÑGŸŸ}üÔéL�9›#Ž<†‰ÓgPZ1‚`A>¿¯×‡?àÏFlö³Œ6s¸,ËCC””W˜‚ÄÉd’xXþ&óœ¤n;uÆDY•Í“ßÝÑC}GãF”ªoeÞ‚“9|þÑlÐÐLìØ±ƒd2©«Ú3šòŸ6á—e�×GuíX›w Î8›CçÌ£¨¤ÔÙµ$?á7"ÕŽ�lDny®ÇãAö¤\€òÊ‘2uÇœršºßŽ�ëXõÚRÖ¼µŒÆ];IÄâx}^¦ÍšËÙW|“cž¡aÁήâ�;3sQö;Ö•xVÊžÞ@`ß¿ÐRHE·=ܲ�xo×�d7Þ"U˜ãƒ„[÷R2mVŽ·“MbÐL ͯ’¡ßOÒEìßi,¾ý6ÿ(‚@ªZpþøÑ|X߬êÎåÛ빬ü°tÅž TXÈYW|ƒ-ë>Ô…3ô`µµµ¦ßu衇~*„>*`Ô˜Z™<•CçÎç˜//2+²"ñ¤BB¤šcÆÓM2•ôß™àV¦q¦Ö\ÔÌ;YJ±ôxd oú_�”jðÊx$ÉEZmê—ɇÍdòa3¹ò߯'‹1Ð×Gaq ^ŸÏ$�V°½³�º®þ\å†EÌ}uÿN8@>þ¼ûæ?¹v„M@Ò2Þ1S±îv’‘0ž`È”4¦“iáÂþÚž@W[+ø.ùÛf�«fGk'½‘’$ÑŽòÁžfŽœ0Z�Î[ps�?I—O°yófK0nÜ8 Uª®ƒ=&L;”cNYÄÔ™s˜|ØáT�®Íëø�X‚þx‚þX‚þh‚HR!®() ($A"-è_:Èrød9¥ $)ÕÑÇ#á÷Ƚ |^Ê‚> }^Šü^MÉüÌ>¿ŸÒ#L¿8–Tø°©‹�ݦSJBú³@5�D¸ZaYëjý̬: Yˆ~ÏÎõ«íŽz«æ>y*€ð‘Õ‡ñÞNb½�„‚crÐþZfÉZ+Bû-BV¼üÇ.:C]^ó'ŒfÙæ¬¯´½¥ƒÉU”f‰$¯¹ñ–°aÃ.\hin�;–-[¶4¡rÂçrÚ%—3füŠËÊUÅØZÚ¸26÷GèŽÄiî�0O¤Ys@Qœ™ó„f²*.ßCæœJRQA® ,4<‘ršÛÁ+É„¼F¨ ùUPiàÕû3�2 êºØØÖK_,aÊÓõŸö¹ýv¼ÿ˜¸KN¹ýŠÅ~®è¿­Ìò¶fwÙÂTØôøÌG@*›¨—†‰uµ9Úº‚I'ü©iK$³yÔéÉíóˆÇ¢û%$íÍûxëùgùê?_‹Ç›B®ÇW–QSVLSO?²$щ±µ¹ƒ£'ŽA’Rʪ°¸„«ôþtÛÿd…C.À¼yó¸ðùýT�=„ó¾þO|ùÜ (,.±¬'B ˆ”ÀwEbt ÆØÓ3À@<é°R“÷êåÖÌU,(²2çKŠctL$L$igßiÀǘ’5EAŠü>dÍ5$…†Þ0Û:ûè�& J³žÂJø]³[·@XdþY¡þvÅ?ÚÏÚÖ¼mwõ8dæ«ê­@xßJ&¯Á P@’M5A$“¨‘ŽH’DŨQ´4î?°öÂcrâYçR;q x<Ì;JW°®¡…iÕ#(/ ª+Ä)_¹ˆ×ŸyšÝ[S¡;¢�¦¦¦š®;¢ªš)GÌ✯]żãOÔ?A-©ª$I$é�Æé‰ÄiŒÒ6%¡ä™Ó>D5ÍFÐ˹Ç*6Þ�ÓÝgcko¶d× €³(÷ÅÂì6d&„Š0§÷²zNÂá_£âæ7eyOí®p#¯ÎÀ±Ë9Ò�6)LŸ[¸iI O»™­dL»ììÈACÇO>`íÿný™îr'T–3¶¢DýN!omÕÓnù|~.ºúŸÕ˜w¦å¶Î׌Ÿùæ›™:uªŽÜs¸Feõh®ø·ÿà'¿ÿ7Ý÷gæ¢édK(‚–�«÷v°¢¾�õí¬kí¡©?’—ðÅÍÒþ˜Mx'áÂÈÛ/lªðôI;vÂo–3‹öY[+æ9ÿÂÖJæ•&#œ¬#ÛÒ_ úßÜÈà¾z;�ý,ÀCU+¬@…Ä`?Ñ—IéûˆÄãÄ ÅD‡yô°MÔíë×±ìÙ¿ê¶�:c‚N÷ô°¥©#k°sœÈáé”Òp8LWW*ÌÒÓÓÃsÏ=ÇäÉ“ùéOJ^•cnÆùW}‡;ž~ž¯þË÷™6kŽ¡µZêg žäã–^ÚÑÌʆvwÒ‰k²Þ„ƒ)¹‚o* 1–.‡¤uYÜ_§"†Ž°;u¶+ÀqÔ¹ÀP„p`[ücr��ëßG±v•£iYu5¼yÜdz@?4û°wÛ: Çæ~Ú×Ñô1AUÃN˜>cX'í“÷ÞÅÑ_^DQq ð{<7e,ïìÜ«¾ØUu{™4ªLóúüœ÷�o±å£‰D"¬_¿ž¶¶6îºë.Þ~ûí²ªN9b?¸íNÆMž¢—�ôÌ�+‚öÁ(;ºúiê�¤¯]ÒONJF§‰e6ÑÔ ´¬”æÎYa½*º6ù…pt=2„2ŠÈw0þGd_±01„®`z˜M&Ÿk�—Žýë”`"N÷Öu數©ÑŸ–UW#Ÿš R)Á¦%Wñ¾nJgÌENoZnÿÌß*ãoºðR¦�¥~†ØøÁjºÛÛ†E°â±(±XŒÙǧNèÊâBv·wŽ'‘%)æ0¶¼De2®3–ƺÔmÙÄÊ•+Y¼x±mÓСŽq“§rù¿~Ÿkoù¥#4 #R×Ú‹ÓÐfMSÛ»úé�%,&§db~«·cÕ“Õ‘±&¹±Bàóz½6+±¡Rϵ…b\q³f¹dãfØY"B£ìŒÙí:Ž ‘ëJ8)+�úoѾ<ÜÜÈÞ¥%1h™!ùðà�P�ÀæOQÁ[XBpd�J®zÝï)^ÿÞÁ0GO›�^¡R´á½Ýl^»fØ„¬u_#Gy e••@*%è󱫽[¥*ï Ç[Q’.#N½å™GËÒ¿-+m9>þØq Àà'VØ�‹P:m–A`Pó(åÓ�,-¢²¤(5}e0ÈGï,'‹°E#a’ɳ¿t¼JRV¤¹w€ÞH) ¨AÊ È˜Ë>€êÚ±¬zíåaþ“Ͻ�ïÿüvŽ>õ4ŠÒ¤"Z°µ½—• í´‡c$4”PCa’–Ø?úimÜ^24Îp+øn¢ ÂlåÎþº#Ý–pöáí€?-–gEùåF™XZ`yâ4J�°G¯îâKÄ�‘i%àÉ5’xü! ÆŒ×øµ g…t!ÃÆŽnf�“‚´‚˜0}íMûØ»kxqbÑ‘ð Gžp rºÜ´$ } Lwº-S?¡²4›£€Ä˜ñu‰$I,¼è«\ý£Ÿpò9àñxU…‘Y¡ÖííàƒÆvö¦ ‘$UñIŒ+/â豕L­,%èõ¤+¨%P¸cÃzž}ð�ÙãÑ)Õô1$ �D<‘¤³�icª²ñxŸ�CçÎgï®�ÃR)ÐX·“Cçɨ1µêõ”ÙÚÒ¡&¬tFSVLqЯ®‰•5£iع�}»ë˜1ïHþó7wsÒ9ç3¢ª­V“�¨ïêçÕí�ìí$šî‚œ¹§Ÿ‡ÆW3£ªŒâ€OffÙÚoqÿÏoâéûÇÚ•oÓÑÒÌî­›YtÉåênÅ]‘8}±áMT’ÐV|л$C~+³ßLø%ì‹|Üvõ5GýÍ?3ûNÅ)<�}j¶ñ|áÖ}Ô?ÿ( ëößÀåùdr8 IDAT‚áb¤Êƒ�4½‘D�?@¨ºVç÷£YátÛÓ» ú}Œ.ÏòØ‚AŽ9å4öîÚiÙ58ß±aõ*κ⛪Àú} Äâ´õ ªS·±§Ÿ™µ£Ô¿=^/Õµã8ìÈ£ùú÷ĈªêÔªŸ|A*‹ïíº}¬Û×I\©U=£Ì<2S*K9uòJƒ~• ##&I%ɶ�?âÖk¿ËÓÿ÷{öí®#ÉVYvwt0bT“f¡nY`k{HŸ�Ð óê?•@ÄeŽ“Éo¹ò á Ôyºfì&ì'¹ûY»)úfaBÚV¿Eë;¯Ú]ภÿ< `ð–¢·‹’)3‘}>Cø/×Ðþ¾·«‡ÊâB*Š ufóQ_^D<c϶-$“û·êEQ”$‡Í?Zõ�Ç–—°©©]Í Œ'S½G—©B^1ªŠC¦LËjñ´%Ó‹³¹µ‹e;éÇU«G’R¼x‡”qü„j&W¦² ³rŸ:Oýö­Zzè‹ÆA’P„ ©o�ÑeE„|^u–K4ö ðòæ=ìêè%œH¦ÛQ§~^ &Žfî˜JÊBdÍjŸYù_ÿÛSÜ}ãY½ìGþ{íØ¹i=_>ÿbB…H€ß#£ hé�|Tí -i„I21_µ+º”~Y™ÿC �r²‡^øÝ”+.²ü2$µN¹Ž€!¹eÄ"ågªFÒûmº÷ˆ[ûþ瓊È}¢ LŽ0²Á~å# VT™+€œ¼€¬5 €�-ø¼jÊ5¦3…˜2s6'�}>‰xœúÛŠ’÷Å·5íeÊá³T@Г’TvwôªîÊ`,ÁÆæNzÂ1ÚûÃ4õ°º¾…�÷µM$u†×ãabe gL?„…Át^CÖUB°gÛn½î{¼øØƒô÷tç}ÍÑp˜ÈÀG�œÍŪ,P×3@,¡ �ÛÎjÿ"n’…Í7¸‰¹çcökÏ)À1�ÜÅü%“ó»2ý…5BѼb)ÍË_²»ÕÇ�GpAüy @"�D^bu¾ä@Å“g˜'éLjIƒ¶dB}{ýa*ŠBüZ×™@(ĬcŽãÔ .eĨ*%‰$É(Éd 'p˜0Éd’`(Èáó¿„Ç›ê-ØÑfwGO¯È˜ëƒšûié$Oê\Y–©--âØñU^S‘æÈ—²ÁIbßž]üýÁ?rç�ÿ“¶}�ûõÐwlø˜y'|™Êêu[YÐG�–[;½¤\Ó«D iÿ…Ý ÙwR7V¡8‘�àcßÌCäiq˜}G.v`_¾l'üf%ØòÇÛH ôÙðÿ—ÄŸvc¸°ãð"p’ÕU'�KéÔ™ú$ )S $§[ʇµûI2%!«­bþÄZ‚~­9®-6‚öæföîÚAGk3-Íô÷öèCQ$I&**fDU •Õ5ÔŒ;„±“¦ªy›š:X¾£$9çz E .ËÙÏ*‹BÌ©Ÿ²bd�Ï�€¾ž.^ÿÛS¼¶ä/ìÛ]Çp�I3Žàö¿<¯ëÛ÷îÞvvü^ÚÜv3pÏ)ëЊ÷Ï�ð#IR€µí¼°oî$ü:åšÓñ7¿Õß ü«ñ v/ù3X[´og¦­ïýÞazÿaàOÀ—°È èX½Œ’‰3�¼¾lÇ t§` �3fÌ´�²9ô½ƒÞÝQÏÆÆVŽšTËœ £sLU ¨¬®¦²¦Fµ.‰x:j�ÚC–=ø¼>d¯7 F¦÷Ž'¶·ti¼ÞT›k�׫ËÓÏì§ACWkZc#9B^VdÑôqº”æÌÇíMMÜ}ãyêÞ;éîhv’ÎÌDëhifÞ 'SXR‚„„W–ðÊ2ûú#TèóAÔ­„É.„èF�’.À>£Ÿ>ÐOwM˜3Y „ë$…æå/Ñúîë¶0ð�áz§žaž#€oZ:.]mŽ�Œ· (Ô†I \j¬@B·"©°«­‹uõMôFbÄ’‰T+ |éb }‡¢¬b‘T_w0– c ÌG ­¼[·/½‚I©DÖw%{ͧNGqÀ¯3ù;Z›yù‰‡¹ýú¥~Ç6ôèéh§lD%‡Î=2u "•iØÜ&œ0·8´fºñw·`�{ßÞ(@Ö+¾SR�Qøm‹~rVekÁwb6^—p ýp~sÓ?ÖÕζŷ“ŒÚ.kþˆü±%¤*MGñä#¨þò¹È²Gƒh­9½]¯ d9#|²nÿjŸ]¡ JB ƒ ü>ü>…�@ªAÊæèÇ“ Ñd‚�h‚Hîn·|9±(½C23EXtÔÑ9šVØ‚/¹f™^{+f&›ÊN#,3º„™ 9‚‹.ºŒªÚq|ã�W^díJ}ß‚ãÆVšTÆ®<†{¢É Ï]q#ü.ÍýLbOÒ¥•`–» Ÿ_.\ “¦,v%Åνý2¦¿I#fûCw:ÝÖíiÙÖá9sQ!ELpPj¶C¢· oa ¡‘£U @_€9›�&?@Ò'ÕaÌ,Ôîkµüû’®\Íßút_}?ƒÔþ=á(Ó«*²9 x}>Ö®xëQ[×­eÑÅ—áM÷fx=Ä…öÁذ~�¶Y¸¿ƒ�".áÚ=È�£Ö Díyý�ñ~ÛT_;áO�Æ—ÿBÛû¶d> ¤jnZ? R %#€ã­\�Á}»)�6 9CbR„¶hF“Ï¡œê+hÇufnƇׂwÀH^ªÍ*3yâñ%!?# Bª‚Y3†mëÖÒÞÜô‰¸þ`(Uà”¡{zT2QóÀ&†„á\ž~§ª:+�g‚O†³Ï¥ðM~«U_8¹1£°Öç׳9%üäº�»¨{ò$Ãv êï�§ÄœñÀùø&pµ•€¢iÛGéÔÙ Cb�œ“%] ½*̲9߀d’`”Ýnf]X쯱.2Q‚Î�SG•áM'C xÿÍ׆”š¼ß𷹉ÙÇ� 8yÓlÂ�}ƒ¦F²¶yµVÐÝšçf‚/™øÅ®ÍG}ÖQ«ú½ò1Û''´—¹­€¾O{ovÝ}­„_‰ÇØõ×èݾÁîÓÖ´ø¬)A*7À²QAb O°€�Z-ˆ¹2Àè&Hj­€6F/D&õV›Jœ|,„_U6˜ü-‘£t¡HIJ×@mYqæ*;y [?ú�æ†úƒ®úº»((.áð#�AJg+ú½´ Dˆ=É, LLgÉ%W ÙdQ\‘eš m¾ÔaVh¿âoåØk&a*üm,gÏ3Ž]¼.êÔ|ñàù¸ ˜Ì¶Ú!ÚÑJaíD¼…¦ kÒë­¡Y‰u¹9ñ{IÇ´£Óèªu¯¯=PIIÒYY7AƒHÐMP[ZD�ß«n›0}oþýoC®TÜŸ±cý:¾tÚY”UŒ@�*pòÈ =ƒC«Ü3kÿ|¨Ýs…ÆÜwÛ%X»›b“bìv´rM¬¬�¼È=TE¦äzÀéÏ›ÙôûŸ¢Ämñ™�ßȹâ9óq%©¼€¦:%>8@ñÄC‘d9+¨FÉ�q'éøú2d£e!Ðûñ’¡à(£Te�^ad±��¡$‰ $³oBI‡Œ-/VÊÒŠ$ 6­yï +€d2AKC='œs¾z¿eA?mQz£q]Â�ÑÄÕÚÑ’…/?É#ù˜úy÷0;΂ÔC29Æ.¿ß�…’‰˜<Á´ÆÚp×�DZmAýíÀW�ÁϺ$Õ­äB+@0ÞÝŽ'XH¨ºÖ¶§ j‚ËF…�ùLè�;Pñ½U‘ëN`LûÕ"ÿé_…63Q{>$Úú#LQ¢³j'Naݪôt´t%ÐT¿›šC&0~êtu VÙÔÞ—ÓÀBÛÍGbxˆA­VK·¹ûfí$ÐÄ œÃÄvmÀÝD&œV;óe×’Å´½o)R€Þ?ÐóÄs�æãV`0Ýj‡�Æ�„F‚¿¤Ü ÄÚÕV˜ˆ` Óº);_2ºèÙy³&?9™€Hz¼AÒ¹zl¢s0¡UåªðCB!>\þÆ'n[·–E_ŽÏŸJWöÉ2ñ¤B›!AÀkÐ }>‚o¥Œlšù¸2û­:ö¸1û…ß…ïÒµñvýõ”XÔnïg�Ÿa•5ôT °¸(²Ú)¼oEã§á „ô!?«¡�?WvµÖa`Ò¾\_ld<Î>ª5€8ì�% ù¼Œ,.HOà“ðÎ Óº·á +€hx!³¾t¼º­"`kGŸ»>uy ºñ×{ùâ Ú,É^ðÍ]sá&¿“ð;¯úÖ™€ñ¾6ßÿK¢¶áü–´éßq0æˆç ÎÇ®4šy)¹áèÔC�EˆõvR4a:²Ç«6gÏEæ54"Fa6& i}}m/kÿªmÁ²ùÿèX~r„ŠEJY1}Lç@„‰•¥ø½$$üÁ Å¥¥¬~ãÕƒ !hoÚ˼_ÖðBO4Awdÿ¯j³6ѸŠæï¼HC*²­5møY'áÚäÏßçGúYŸlãÝ7ÓW·ÙI÷}xç`Íwl&ÕWð8«âÝx„ªÆhrùe}¶Ÿ.$¡ 7ù¬8¹Mo…Éêow-ÂP¯9T“_±JúѹR^f¿)à—ÞëïeëÿÝJ¬»ÃéÝÌAù}šÀï€çìvHô÷мìY’ºŽ¼fÜäw­Á(Hr^´€VÆêªnô?súñŠ\ËCÞ¬Û§[…ŠJJ9ëkWáꃮU•"`9Xà£ÆÕÒ­òÚmÂÝ™œÐ}£ › ?VÛ…M´ÀÖµi«ÒžôcË~A¸Å±‚÷¹´,ð�¦ Uä°ÑÄjØAë;¯ $ãæK¶ÖH5è<ìæ|6i¸è„ÍöFb|ÐØ®;ÜãO䈣�;¨yâŒ#tÌÁÑ„2l‚nª‹­LsáNäEšO@5ù1½ÀfÞp’ÜŠ>AnOÜãíL~»›È0NÅ¢l{à×toúÐév7¦e€T°ˆTÅ“åèÞ°š–å/eÁ@Û‘áŠæ¸�Ðjñ – eýyãj vC}º©Ðà BÀº}t‡£êdræßµ¬~3¢ÂÄl×®âŠÐûЊ¢ÿ\¸\ñõBo}œQà…q 情©Ñ(\®úvàpöb•xœº§ÿHëª×�NÓ˜žûŸèð| @)2ѳHõ0G[É$…c'Yfâ¥b}¾Z3ç{¨aB,âüútcc’–¨$[œ”½žÌþIE!©I× $ªjÇÒ¶o/u›6ð‡û¥EgrÖ•WáIS Ç’ ïîíT f„A’r}t­àØRlí‹�;�í"«±Àrp#!5q³‚oËvì@æ©=ß¾7þNýßqºõNàJ¢aÿ( R Bá´F´´JÂMõxC…©òáÌl�Ìòüs…^_[€!ï_ß® AÎ5õréÂ%LÚœIÐ�S]RHqÀG&™iÒá3Yþ³D\­GÕØCøþo¬-»pw$Ψ¢å!¿z Õcdz}ýZZ÷6Û9z ~çßøÎ�¿È‰6¼µ§]e Þ¡í8ì‚3ß ¨É<4sEœ2ù„C�>á’úXXÅï5Âß¼ò¶?|'"é˜êý ©T_¾PÖãå´˜i§úë6ã+*%8jŒÆ„× /íÇEN9°ös#g ¾‘ˆ”n®Ç ²úAÃC�S|Í}a«®Hõ8¼>?%eå¬yëuñýÌ¢Ò2^r9Wý×ÍuÊi©ÎGš±¶¹›Íí}Ã$êYÏ]ÒüŸaÀ Ìxøµþ¿¢±D«>y¹É=Få´½ÿ6Û¾%ê˜Ãó(©Ž>‰/€ýHo €±vZºo×f|ÅeGŽ6w˜² cꌈ²…GR.˜hâ èpƒôŒHU çVJ’D,©K*Œ+ËvFª¬MÃŽ­4îÜ>t ï´³øÎO~Ήç]DyåH½Ã)à�Æv6´õê�4ƒ@[ zîš.éø€íâônÝá`æƒ3)ˆYhÏΰ_õöIG :×¿Ïæ{~êfå_EНïÓ&lŸÞQ¼ ØíK/U'œEåœãô�ƒÔÞZP0Ã8$§P–Aˆ‘¤ï0”a'Ê®æ¹Ý‹eÙX}¨‰¤ùøD¦)Iú3¿Wæ¼ÃÆSYRDsýn~üµ èïéÎë�3–¯ÿð'yòBå× Ds˜• ôFãªÈJ†W/ÔìzI·ªg÷®i¿†‚8µª¹Ÿ×Êo×µÇd%‘ yÅËìxØUÝN=p Ðôi2ϧXôO§#màWê·“Œ†):dZ ºÓ¢öf~Z`tܺ*C.] ²Ð¸&¦¿Žj;½=)±„„ŠbÕ0)*-gÂôl\½Šð€skï²ÊQ,úê•|ÿö{˜0}F޹ß6eõÞNVïë"šTô5õZ°Ï!ò¥Ã1ö4®òN㊃r0k$jÕ­Øò5&JÖ×¶+2‘`÷ßpCæ ° 8Ø÷i²O³è^ŽÆØÍÖpS=‘¶&J§Ï6”ó �¯3ý ¾¼±‰Î´7„IG-š�¨8�P£ ½Ñ8# C”…üêµU�=„ÃŽúý½=´îm0å(«ÉñgžÇ7~t#'åb•éGÕ˜±köuñAS­ƒQw&à0¢ý¦îƒ òP·=ìÌ|W9üŽÎ…¹à M~ÿÎGî¢é�çÜœø}à‚´ð©Ÿ�1‘TÎô §‹'Í`Ìi—à+,64•ÍsÈôH»²�#P#ÈFb•§À� ÝŒtâ2å!?ÏœˆÏ#ë”Qdp�}»ëظzûvÕÑßÛCùÈQŒ?ô0¦ÍžKõ¸ñøÁœ)¼±µ‡�[zL$?ñ—¥å6�7%Íjï$¸vŸ7È'²¹‚¶(¿æ|ÉH˜m‹Cû+Ü®üçpé¼ÿÑi@p¥-0˜¡1ã}ê„FÕ¦HA%ª¯á” ½dÓ–à¤Fnò‘¬‰>ÈÙnÄ™È�6*‘Þ^]âìCÇHûïÙC ŸÈ”Kê9´.KªCng8ƪÆZ¢¶ Þp ¶ãjéòXáRX…È£ýv@Ÿe·#Ø 4Ô±õ�ß0аÓÍcj ExÓðY*ÏgHôO'£ívLôuÓ¿{+¾¢R#ª4ä½¹á9óœcHÑÔø÷ZR°àÔ¸Wc ž ®³�¢€�"¿O "ÉH²Œ,ÉȲlš�Ø6a]sËëÛé�%,Yu2ß/´>¯MÚ«vß ¸¶KÎPÈ?´¥¸ÂAIH.ý¼_—Ð[˜ýëÞcëâ_nv%ϧpšx~¡R£�T�ô Nî€ §ù×…µ“ôBªUºß3;s_Þj´4âšÔbwC OŠ&vuõÓÔFPQ@–älİ¢· Dø ©“›»iê�¸@{@rL³uûF&_Åe{/I£Y�i»Úë3}–J)· o6ÄÉäO“Ë4¾òWv<|‰WÑ»§�+€æÏ’@I|6G!p?p¹›�ËgK͉gã RÊG7qô!ô@š“P–m2õ¦½l,Êù ©Æ�ðJ~Ÿ—š²bÊ ‚½²:7»#1Ú£ôÇ(i¾ w1ýéë[ ¿“?/9˜ùV¨¾{_kê»Û?cñÄÃì|änZß[æöQ¿Î}°c«1õå%sIÍiºá 2B”Œ”ë·çÓÜš¯qh=ö†dîk„?ÒÑBÓ[/ÒøòSîJSy( >OÂâù*€Åé—uàwÚypï.úvmAòú(¨›­k#pÈ;¹IDATÿ9 DY×Ý`Îg©«ô �¢Ð–ƒ”~Ire`Ua’\™ün|ú¼|~Ç„ž¡ò†™1æ f°ŽLýþò—¨{ât|¸ÂíU ?¾ùy–Ï£�§�ª&t´Ž%Y¦pÜdjÏø*Á‘5Ù�žÖ%ÈD !@�9�œ>TÎÍ�e]Xg�<<� ­ð»Q f1óa1ýMâðv~¼U/ÿU^›Ä#ò;V›Û ‘öfv¿ÊA0dm.|}.ö§”PØ×º2ó åŒÚcƒý´¸’íÝAë»ËôíãìGp=ð_é9ô¹Ÿw @;Žþ‡T}¶»‡#ËŽ›ÂÈ£N¦ì°¹ºâ Ù#ç*ÕÌ7(M¤ s|*˜ §Ì3ôÿ ô~™êùºVPMj�SUy.aE3?sî¶5oÓüÖ‹ôlßàÆÜ׎7€Ÿ¤¿ÏýøGRe¤2oMÿîRx(¨�ȘER4n²Î%™h€6P–M9…‚TÖ ìàñûÓóW23 ßOópàBA’dër\§óÌüÌèݹ‰ÝKþL_Ý”x^‹wwzÅ2ý;_(€ÏÆNÎ[ƒÌ˜KõIçY­ºpд6 Ä0LãÞP�ž‚ÌêÕìOzàÁxíõY¤äæmqX% (º›ëixá Ú×¼=”«#íëoûG„T�׿"•AèúYH²LùÌ£)Ÿy Åã&ãùÿí�ËoEÇ?»ö:öú‘4N“:MHZ@i© - RZ*„„„=ñ/ !à7„„ĉNå€!N¥Ph+èC¨-Q›>œ4Ϋ¶ãWüâ0ëÄ]¯¿Ò¦Íï#­Ö±ÇÙ™ùÎof~óŸ¿*ˆ*³[°’T%.†éwhë:ξ¸8 ƒ½ÁtZ$ö]Á!hgÍŸšå¼B&Ebâ±SÇ™ýë×fÆø•QCøør³6€Í.À§À[Í 4ÃC`øQzöÔÛ?HxïAÂOÆ·5‚ËçGw5iÁ+yö6“» Ázä ÔÉê«ôìu7•J” yŠÙ4™™(ógO²ðÏ233Ðý5‰û;jy¯,UV`=yøxŒ5œˆÅÓÝK`Ç¡�»ð áíÀÓÓ‡5¶O ªWלD@Ó”ã­ÍÞÜ>X9/±\*ÕD'u7ý8ääç“q–gÉÎÇÈÌDI\¹ÌÒÕq–ã �*ƒ40|ü$URà~ðð.êø²':V0n#ÂÓ¦kËVÌÈ0¾�협GðE†W&7 …lšÌô ÒS×HÏDÉܺÉòí9r‹sä“ñfóñ×âðð ÊN¸ïŒÏ¡& ߤ3’æJJmª˜†zzÂø†ðõâ‹ ãÁÛ?ˆËÓ…ær·½³°^_ÊeÉÎÝ"=u�Ôôu²±iÒÓ×É') ”‹Eµ>ßÞ8Þ‰eàÔÄÞi`RªœÀFÄ‹Ê(|•f:tO ÔmÐÕÆèÞ‚èÆð1º{1! —ÀÕåÅåñª,EMS {9G1›¡˜I‘O%É/%ÈǬ×qòñEr·ç›ÝOß n¢Òµ¿Eeðe¥Š cÀW¨ã¢R¨µé²\ŽWÑzFQë™�Iõ‘àaâʪìԉǣòHÀ 篠ÎÖ;ŽrwDZ|¨Õƒ1Ô¡&¯ LL7g¬Æ~•¦;d¤jˆl6ÜÖ¼�µá(ʽh÷Cö^F¹ìCåãg­« U@@p.›#Vdð"°0P« .ë¾QʯŒš¥/Z÷o }w]¦Ûƒéö`:šã²&4„‰σ)nP‚¨eqª'Fg,Aë†79ò�EG”Rsʪj]HB¿ÜlÈ›>ol^ùl\n7†n`š†®c:º¦ah:™^7ƒRèIX @×4rÏ4±üÑ{ùtí*Nî¬aDª‹+Š®"š; ÍãMøhÊÑ5ËV:·æ‹Z×,x9X<åæ¼és“àš†iº0 Ã40tCÓð™:þÖO©{õ9J&_C·îF�fjÓü _„�³iåRNÕï rÆ,z‡M Úq:'ÚÖœYVU³ÞÁCw–nu³ž)¸e!._ ¦n`&¦©c&.ÓÄ0 ]£`��m‹žgOÝ‚¯#nK† cè02KnÀ{ÕõL™9›W¯ ­þCÊçÍ':d ï(iªþËþ²ªšÆÖ5Ë4Í x(óÚJ ·’I&ÝÉv]€¡ R£aöì`ÃUø;O26#�š—žæ�ß�ÅŠn'­íí)|ÿÙE´l ´ý‚éòožðPý“h€­MþÓ* ˜áÉÊ«H:M×Nv&3þ<¸.¹)v¯^A´§۲زøE¼ºNùý¿à±E+ÈÈÉcÉo%7rŠ®@.ÅSËÙýÞÛ}n2'–á>¦¢ûhã�eK·Í›‘­ÓÒFaøRJ].КCÀw²™wVUáêîØüÇvÖñgÊÍܧ_›’¾uoâ3uÒó él?�O˜^/Á “¦m½§Ri[V…nT¸Ò³“Þ+…ÀYJ%ÁI®¢t7+Ÿ}ŠþXß)ÚšWÿÌÈT:‚ξÙC è:Û�Ç0ˆ÷öàr»‘€‚`Ñd Út K2܃³¯4S%Á•‡( çù×ïEãþO¾Tº�|v˜†5+Iqé¸BÇ9T¿—+J&áw|þÑ.FŒ/¢;a!ø GaxSÆ0¼)nÝ›’ÔGGh”LEªiP’îâŸOþœ·lüÚ²aey2ŠÛt1}Þ|ãÖ4­[Éñ#ÍL¼í.Â1 !Àp{0ýin  ár!t%%J)”RB0f°Oãnž»w>»j7-¸¦ëtœ>ÅæÅ/¢gæRp×OiêìÁçñpߠßu¾ 0S#™cII•R‚RøLƒ‰™>V?ókš6\V­–v²ìo^ýwJfÍ%–5ЏmãºöfbÒ¦'G)yNºM×4 , �MÀ˜Œ4&d£émÍMI¾âi`Ý+/0<Õ zѸå¯@9a°º#a [±^iÇz14óT «~÷KlO*w?þÄyÆ€¦iÿEÆåöàóûñùý¸ÜÕï%r`¦6P¶“)-•“ÖÒ"Ñ‘@ØBñ³í‡ã]¡ñià ionåXS#-Û73®rE“®eÿž�†‰e%È1Šq%>~iÙC0}~ú{»9Ûv”Žãmxrò‰YÛÉ)å”h)‘–F¬hÏg@È([ú~ûÖ{¦×ÅCgÆGûb䂼»üoŒ¼¡’éwÝÇþ=;±¬9ù̹÷! ¯¯ $\ô&,:Õ@? �AùÈ‚–¸…eÛHÇs©¶m‚®ÃûHt‡k�šJfFmϱFºÂa�/!wX!Ñîn6¿úî¡#©¸å6*æÍç±¥oqå̹ô+E¨/N,a“°l,Û&aÛ$lI,a‰õ“�2 .¶CMG)EgÃGµzëÚj´%"�•îÜü!÷ fÏýz"F$t–EÅ¿9•¢©Ó8qàþúðš·×pÍì['TòlQާ éè¨�ÂVçÁm¶”tìÛAÛ†U»�ßñ�r„ÕÝù-Oá8Úúlr¯šÌð*i‹Útôõã;ÕÌ‹�ÞO_o¡3§U��ÈIÜ–N †¨°œÉ–�ØRaõõÒXõñÎŽ§J—lÚÓº®Zè$t½ÕÛ]¬¹Üc=9ùD,Eg4NB”Ð6$�#{> §+œ”Þƒû™rÓ· IK%“K:’g–RX¸”6ÇÖVsfWíZàñÖuÕ§Æ(@”¾¾Y�tì|ÿx¤é*ÇR’„%±¤ÍþP”¹÷ÿìÛX´ÑÛEBJ,[bI…%¶³,¥°å¸äÌ®ZŽ­ã8ðÈ…øù–lm5¥K6EZ×UÜÓrèVw0Óåœ�0 ”а…FΈѤÓ�¦é,xêyN§Л°Q$Î9+…  JrfWŸ½þ|¯²­;€�¿v0)]²Ilûጠ`yö”YyÙ¥³1SRÑ\ntÓd|~¯—Ö®(q™¼ãâÜU“HÛFh:RJìXmï¬âØÛ+Ow[.k0qž �—RGŒ�“yÝ�‹&£{¼¸RÓÐ ã‚©$ùÅ€Z*%A*Î~º‹¶õoi>¸ø pôK0¾z4+]¼Ql»o¦~”:zBq`\1™§âR˜l/²žcM„î#Ô°—ðÁú}ÀkÎxö™pˆè@¸xÍ5(ØâÍÉW�±W«ÌÉå*sr¹ Œ+V¾Üe¦¥·8 w�©‹7—rT\ í¢; dAç¿ÐœÚ.íå…!¿ØþbSc&õ{öIEND®B`‚caronc-apprise-dfec414/apprise/assets/themes/default/apprise-info-72x72.png000066400000000000000000000173031526720371000266720ustar00rootroot00000000000000‰PNG  IHDRHHUí³G pHYs  šœuIDATxÚíœwxå�ï?ïÌœstšz·-Ë’‹dl\°Û²éÕ”dY—@Hv“s“�» yH …\Â&ln —›Ä1&\À@h‰)Áƒ�±qÃÙ’%Y½œ^fÞýcÊ™#É´�Mö>wôœçÌÌ™ö~õû}õøÿËû.âot_‚€(<€ê:F2@H1 ¤þ_Hj�i@°˜L°€òçY$�F€N`;°Ø ì·öeÿ+T,ΖS,0\O  øQ½>„æA( ÒБÙ,z:…žJ€aŒ¾nh^^°¾»þ+4ø°˜îVoq9þªZ Ê*ñ—ã-*Å*D Q=>„¦åÀIÄÈDGH�ê#ÙßC¢»ƒôP?£Tñð ð °ûï )ÀÍÀ§� ‡pJ+(nžGaãL5“((¯ÂW\Žê+pžàxúe¯èéÉÁ^R}ÝÄ»Ú9¸›¡=ï�èuŸ2¬~´þ½t+ð  Ô¾n¸q&ÕKϧ°¡‰‚òj¼¡B„ëŽBˆõ@J0)!!Ù×ÍHë^ºßx™á};ܧ ÿ ¸ýï S€‡€©€b3iù•5ž€¯¨¡¨a‚u�pß\ˆHJ9¤G‰9@Ûó�0¼o»û´ýÀç€M+€¾|Ï2Ûhá"¯XAù¼E¨Þ�¡TEqä€Ê{�÷y �¶’T_·}zø>ðoÿ™+�+íU-çQÙñøýEEÕTEEQUTS|œ›‰<Þïû Òµ&G�eK’‘”©~û%Ý^FêŽð(ð`ø£ Tý˜Dü[àb-fêµÿƒ g\‚êñ EUPUQLéE€b©™‚pþÌ}¹�¢ä@tpŽ`ï'¼”Lª×Gù¼%ø+kÞ·#�˜,^³8ê¯P°X œ4•éÿtE M¦ÔX€¨ªŠ"Šª (йî@b!@(®ugÐã€#ò¥NØÊ*\dïÊÞœØ@ټŠ¿·“Ìð @½å—½ô}Ò*Öh�³!(žyu—~Ž‚Ò UCÓTUCUëÛT/EÕ¬X7.‚ÂÔ™÷±fnB(øj*šª  0�¤uƒdÖ –É2”Ì K‰DbH“›2ñ({uýÛÞ´/» øGàà'Pð8p&BP:g“–_…¯¨EQQ5UÕL@4ë[±A2UNŒ2ín/ò¸&ÿᤋœg”…i®(¤Ð§9\d“xÚ� %ÓôÆRtDŽ¡K0¤D’l*Å{ÝM÷Ÿ_°OÛ\ô=\P|Âê.¾oa‰ €¦:ਚŠbîWl)²xH€É!Ž*HòzxT"é,ɬ‘GÊHsÍ«¨œUâùûîaË믒ŒÇ�R¢y¼TN˜HÓ¼…œ|ÖyLi>�xVçX4É»½Ã�$‘R¢[’tà·?wƒô ð©¿”ƒ~ | 8y“/½o¸Øä‹ŒE1UKQI+›“,€r„�û̪,⤚ŠC4À@"�£që8¿GAÞ˽·}›‘�~â‘ñh„èðÝGÛØ³u3¯þn [^{…IõS˜ÞØ@mØ�GtÅR @Ѽ„šHötïj³95üñã´ÜòH5O¸˜†+Và-.3 Ù&` EQQ„”j¥ª&a !,ðL R-iª+ôsbyˆ-/>GÛÞ]L�>�š¢]Ñ$IÃ@6H�Ñ%µoþþ) ]GµTYX KÃ@ÏféïîâõgŸd¸¿ŸÙ 2±´¯ªÐIš® @`b=‘Ö}vLw °Õr*?’Š-2› ÐxÍ7O™‘#`[µMÓªEœšjùAšjîW5<· ̯.æå»~ÀKk`ÙòK¹é®_ÐKñJkO/I)ñ© Óü j"B ²†$�f¤õ=vÿyo¾´–ÈÐÙL€ùËÎä‹ß¹�òÚ‰lì`{÷�ãTöl~�ý«f[·N+¨Ž} º 8•-çSzâ)fÈ E˜ªe›raî³%G³yH1S…yŒéç˜jòjL {ù?7ýw¤•Îìï¥vR=Ó›šIduFÒYË×1Q5$ôf$=R£}$IW$IwZ2(%xÂλâ³{U: �LÒu¤•î£mœ°ðT&U”ÑO1’Î"jêHõ3Òº¤ åÀó S�a_i%u—\kŠ³È©“ª„ ‚ÅAB‹¤0MA³8É&j0ýžªP}›ÿƵ¹gJ%$bQN>í ʊ´ Ç­ãÃ~ZêÊYX[ÊìÊbfW1­4D¡×CJ׉¥uºS:E3ObÑü9ywÑá!:B’y‹Zðj�Ñ$º”€ <¥‰Á�o‘°S4¯G? @ÿh¨»ô øJËÂâÕ”acó.p„¢˜ÍÇâ!!@¯ª0­4ÄSwÝÁ±ö¶¼÷vuRY;�Yóæ#%ô&Ò= +ƒ¼½öÞzõevlzƒ#ûö�IĘV?‰¦Ê¼šÂp2K$�!^XÉÉ'Îäà;›ID£¼·}+3žJóô©tE §2¦EõzQ¼>woAf³+Á÷Ètº„† ›æR6o‰C†Š"Lpl‚VŠ*\’ã²hB1SsLµBŽbŸ‡¢áNžXy™t~ŠÙÐuz{˜·¨…ÊŠ2ºcI }:7¿ÎOoú;6½ÁηÞdË믲sÓ›ìÙú6…%%Ìž1�¿—îXŠdV'[\I}ÈËþm[Ðõ,­9ûò«0€ŽH‘¢à¤v¼Eª¿`°8l?�2i_T!åóZŠ vÌä8ù¤ÅðÖ·bù.B±ÒöñÒ”U˜ûU!˜TàýšD,:®øصƒõx–�%!Åãs\ß±N6½ò"?ýæ üá‘UÔ„ XP[‚"‰¬AÙÒåÔ75›×ܹ�ý;ÞaJqŸeQm‡uò¥×"T«€p�Ûx�– ¬ÄWEáŒ9Í\€êõZÒ“ãáR1aÇ^N fgºãM[ñR‘O£2ÚÍ÷þœÈðñcÆïî¤åüåLª®¢+š@)©à´³Îá´‹.åô˯¢±ùºÛŽ"�±kó›ÔÏhffSƒÉ CÉ U£4áàŽmºN ⤥§sx8N4•u¼ô‚Šú·o"=ØgçÑÿôŒ'As€f3é5 Õ[`JҔܣ¹Oq%,é²¥I`žkÅBÀ‰UżðÈCc¸ÇÉY{½DG†Ysß/Ù4SKÃŒè ÃU� OœIy=Á¥ËùÒ¯Ÿdñ¹"„ �³ò·`è:sªŠB ’êÙ °sÓJý^ǪچcÒò+ÝaÕ…ã©X‘eÖ)¨šHAy5‰n`œÈ(—à²"OEÈü|�ÅŠ¦ìh»¾8HïÎ-¼ýú«Ô××óùÏž;3çž{Ž={öp÷Ýwxé‰G9¸û]êŠT¼¤ ƒDV'™•D3mñ,Ür'M7¡·——Ö÷e³¼‘ÍðökëL))ð€h¡BTM3kEéRš’-¤ÌœU•ÊSδo;×v�•Q5¬ÉBóPP^�PKmr*‚� ­m)•2¥GZ¿çò7NŠC –Õ•óÊS�³áÅß;7\¼x1¡P¯×ëˆzaa!W_}5555ìßµƒ—ŸzŒ¿‡º¢€óÀ¶ÑHfuêç.t¼ícm‡A€WQQøTeTÎt8G¥5BPµè,û°°M5öÙ^+&ÁWRŽ·¸ÜÊmJWZAæÅ&öõå8Ò¥Ú¬d�¼¤®œ#ï¼Åšû~îäwB¡UUUcò>—\r gžéü7ùõ¿ßÁ`o'Vá÷¨Î]àU>ϘëèR¢ÐbCdÒi3v*,Bñ´ž³b®ó‚ÐB…ö¥Z¯ P�å=£–à �M¤»$/�#í¤…Ìd¸ª(̯-Eôwqÿ�o§¿ûXÎÌ™ƒÇ2݆a°gϢќÙÿú׿ÎäÉ“ˆ qÿ�oGS³*Šò¹Ãï£gß»Îy¥•U#é ªF¢ýɸbÕÏ0Mþ`2m z~žJU)ižç8P¨¸š fxBÅÍëp –é.Â8™*!FY8kÕ«¨Ì­.¥B¦¸ç»7³×ö|s9gŽc±t]çÉ'Ÿäþûïw~?ù䓹ä’KPMÿ„××>϶7ÖS_ "àC�Êô²0Ï­~À”\Mcîâ¥Ä­ÌbCI�í~•¨åNœ´ì ¢é,±L–Q6ÆQ‹pC“½UçÈLª†'Tˆ¤‘7ð¼ð_ºR¦î|¨ÅK�Ê‚‰åTi:ÿvÃWرé�1× І Xµjƒƒƒ¹Jä­·RWW@2gÕÏ~‚aÌ©*F`òËŸy€}Û·š*.ä‚+¯fWÏ¥~/¥±>v¿½ à †—]üi ÅÈêÒqKrDi®‡êíÛA àU½¨�pž7#\$-rv²}.pl%«) ²¬¡–‚h?·\{ïnyküâýÌ™h–u‘R²sçNvïÞ̓>èSYYÉ�7Þèlïßµ“gú5Å~Óˤ²í­‡œôÆW¾s;)O€¶‘8‹&–ñÇÇæð¾=&¡\x áŠjÚ‡ãèV$ Gㄦ{“RØöó*„׋ZÌ“ 9~5OæJx~�ÆÒ†Z–6Örhó¾uí9ðÞø î²2ÊËË�íX,Fgg'étš5kÖpèÐ!ç·ë®»Ž… :RôÒSkèéh§±$ˆ¦|îz®¾á&¾ó‹˜uö…lê èUÙùÊZ^xtµ3ðÏ~ã›ìï�ПH;ÿy1 ÕçGñúìÝm *PTª×k1ïX)Éa–Øì \:g*UÉÃ?ù!wþëWèí9nÑÜÜL(r¶ß}7G²7ndÕªUŽ5RU•»îºË%E;xþ‘Õ<3ÊÂ3<Ÿÿߘ2‡õm}DÒY2ºä衤’IäïþOi%¢V�ú>™B¡¸ÆIšul!`FÞ–Ø�-»ŒU¯ Å!–ΨÇ'³lyåVÿìßó,Õq‹kMMy qÙe—1þ|š››Y°`A^yzñâÅ\sÍ5¬^mJÄú~Ï)gžË”Ùó¨ ú8MbH³ÌÍd™°p)ó–lfêì¹,¾è2¶ôÅHeÆ©Ø2ÆOÒüA»�­Rsu€�PÌèÝV)]ê”»HÀãaYóꊂÙ·‹§sï¼±þCØš›› ‡ÃÎö\À9眃¦ihš†¢(c¸áöÛoçÑG%“Ép´õ ëžy’/Í<�Æ’Ý‘dþ€% U³ä–»ñ{^>:H,£#Fi…nGUº“äT\ý4HCÇÈf\åþ�払Ô‡‰$R¬y-k]�¡ë­'¯¦†ÊÊʼëïïgÆ ¬\¹’믿ž––„Ü|óͤR¦°{<®½öZ¦M›@<áÙÕ’Œ 3¹8H@S­1nóØÿ·]Õƒ”�žˆÛ[¶õH]ÇȤr–Ë:_‚Óš9göTÎ;q:±T†©KÏÅ }ä·úúú< vøðaÊËËiiiaÅŠüò—¿dÃ3-ñÓŸþ”öv3°Íd2ƒAV¬X᜻iÝKl~må~µaÿ6�¸}Z9n߈”2¿Jkdb{³Cqµ®adÒ©„£ †¥R�Õå$ÚÞãÅ5ÿÕÈÐXUJ¯®ò©/]ÿ±ª¨pºóؾ}ûq�Íd2ÜqǬ]»–•+Wrë­·òôÓOç©çª»~L,2´Ò!¯–⃘â8÷Ô³i²Ñ{³Í&é,ÐidRµÙDÌâg3„�R2»ªˆgï]Å–õ¢qælš›gq¸o˜ê¹‹¨ŸÑì8cf™6m¥¥¥æM³YÞ|óÍ÷=þÁÌsG/ÇÚÛøÝ÷rÍ7n¢Ôïe(™qz…¤ËÐ8û\ˆãeÕÉœžG[‚RÀ#�2Ñ“†s‘ÒP€�¶Vv¼õ&RJž]ýÙTŠëª88㲯Üð‘$¨½½�|�ï~÷»\ýõ<þøãqà‹k1ëôŠmOÌBáé‘c¹IJ¤‘%vÔéýLqÍÐ6`YÆÈ2õ¥¡­ÛI[N×;^cÏ;›™ßrïu÷3løXzÁŬ_ûÜ÷ÚÁO<ÁÃ?L&“ãÅ E!.¤´²ŠPQ^ŸÏJteˆ1Ø×ËÈà@Î7³¾ë§7#% $2Ò&‚‹�îNsš#ì Iä°ãýw#6@ `=ðõÌÈ ™hoQ)Ò�=ËP_~¯Ñª»~ÄÌù'³dêdÖlÙé—\Ζõ¯�Fß¿SJ"‘ˆóøAN8édæ.n¡aÖ\Šê¦�B#–1»<²†�n˜@MQði ^ bGèÚ·‹Ýo½‰ÇçãÚý6Ûº‡éO¤r­zË<‰r6¬z¾t…RJ†öl³÷l†Ýnó> –fFð•"€TFã›ôëbíc«ù‡¾ŽÙ*9ÔÛÏùŸ¹š§Xù¾)ªJyU5Íó°ì¢Kin9‹ö‘]Ñ{3:tG�Û£è°,(Gžx:MóÏ$èÑxµ/E<w,’¤åVŽ´¹]—ÙA8’ÔP?‰cíö­_n€z€w²ñHKz°—ÀÄF$’ÞXœiÕÕcüw¿¹—Eç^ÀÂ)“Ù×ÝOݼEÔM]GÛqÔª “8õ¬s9ûÊk�åµìˆòJkO^eáxR—ãkІ¹˘õ{)ÉÇ�†õí–¦QßvDï ú·np¸Ø9º.Çœ\r†ðxñWMDõùIë’)%´îØJ*‘Èóz;;Xrî… ØÑgZY˜½[7;:PrúEŸæW|ƒ™|šý Á±Xd ÃêPÍ7¿æàF ÆÞïH®”pŽ�¥Pëï#=†áRAK‚ö?ô32#ƒ`Ng¸ ˆŽ.êÀgôèˆ78©O¸¡(d½~j ÄsÞÓy” õ Ìš5‹îH ¥¤‚TG+½]�,8íL®üê¿pâÅ—Óç+¢'–bjY˜ù5¥Ô† LdHêúpÆséœY¶�p;zÒ‘.[µl óŽ^JbG[9òÌCî–á'Ç«¬K¤¡7j¡büÕª‡XFgzS3ñcí ºÒRJºÚ³ô‚‹¨)-fÛ±A¦T”¡'ã\±â^ò"%µô%3L( 2Bí[7rèÝLih@õxèŠ$de¤S•žpþª EA—‚¾¤ÎÄy§ÐrÚ麎×W@ ¦yþBúRFéM„ý,¨«¢®8ij÷ÿœÿ}Ë�Þ·›L:M6“ax ŸSÏ<‡Ÿ�ÎH‚¬!�·t€ÊIBŽKdž4È<@Æ‚c«–‘GøùÜÓºæ^"‡öÚÅíî¹Ç3_~¡ÔÉ—}_q9Bõ˜m/š‡Ÿ‡âP€ÊÂ0�R†“úbIP|^§M¯#²w÷þð{ôw�?×íû÷ÿ–“–žÎ†£ýŽ�yiU?dz½7_m\üâæËYÔmitGƒè‘ìøÉ7íøk—Uî‰|PÕàr™ÍTéá†&Ó+fM[—‚TVg ž¢g$F_4I4•1n( }^î»ñ+yœ5zÙûÎνü**ÃZ‡bd 9~Ú{Tˆ�GÌn`l2·�•9©rT˹ -xø¢mlÓ~3°ùÃö(n¾�ê?¦xË«ñ•T „vW©E sam …hFçä¹³ÙºîÅãÞ :2ŒÏïgÞ)‹ÈݱäK&“ž´-]†;%leÆ€c©«!óUKJɱõké\÷2›±Çûµ�ÒÄÙ4s�G(jš‹âõš «ßP(NS¦½_Z�jêÑ»Ûè:rüÉ{¶¾Í²‹?ECM5{û"dtÃR ‘çìá"qÃòŸò‚Òã�cx^Ø!%ÑöC´>ù€ÝY®°Æœïý@|ùàp6¡ãÅÇA×]5{óÛn÷7\ânÊ`÷±~Îþç¯:í'ãæ|Ò)~{÷�€¤,à—| «Öž5 +~r$Ëp›‹�-'q\p ƒl<Êѵ�k;hc|70nïƒÂšÙ“ˆw´rìõçA×Ͷ]kÒ0Â�–)¶>†9€%ÌEŸÿòûÞ`ÿÎmH)‰g²d +H•Ý0ÐmPܳ ó€Éwt猎‘ÍÐùÊÓôl\gßþ à–ãÆ� £ÀUH©íÞJÿ¶ H]GJÃUm5rþ‡”yV¥c8ʤ%gQ7uÆqopÞ•Ÿc(™aÐ.ê�šåcƒ¢£$Æ%59HæD7çÒÈÒ»éUÿî7NÆפÀ�;W̉ü ãìD÷Qá †ñWÔZ¾g:BA EX%$‹›† �…ÓëÙ²î¥ü ku +n»“g\È[dž­&ïqâ±q-Y>Y£QqÊ™”/8O0„°'Ïå}Ûë ª¦R ÒÒ( …ÁÃ�Á(±TvìSˆ±I÷üL Ù�‘ïæc«6£sÝ3~ÊÉkÇ€kí`ôƒ–�3gõ�@ ]?#Ö~PÉF†ñ•×à MkŽ%ŠÓ‡'„‚D!–5èÎ*IdèŠ$Hé9‹dwZ8±—` Ÿå"rË/2ŽŒùc¬ã0‡Ÿz€Ž—Ÿ²Ÿ½sšøÓv° €?{€‹“=Z¼ãŠæ#P5ÁŠÕ�ãR=„‚âó!¥ÈWáÊZ"�—ì/—,Ýu<Áî7þHë“¿fp×Ûö3·Z„üòGè_:o~²ÖDÕ$\?ƒš³>E jBÓÌÐDÕÌY@šÕ[€êóaO\¯ýäxåà1Ûy=”9 â]myöavnFOÄì×[’sä£ð“z5Å£Àe¡iÁBJf/¤æô‹ñ•"¬ùcBUñ†ÂÎÔ†zÛÂûä`]Žjn_::Bûó�гq™è°-žÃÀýÀ�w`Ÿä»;–÷YýÅŠêR:çTª—-'PYƒâóá †Ç”�>ø1äø¥c‹Œ’ý=}éIºßxÉ]SOZy�ïYÉwþ²—VXqœó.�ê–ó)›¿Íç7{°-ÿéà ŽCC’MÆéß¾Ñ|©ÉÞí£~›1§¯ÿê“Ì_ëýAeÀUV¸ëÝEÓgS4}6á†&üÕ“Ð V@XÙ†é¿ %R×Ñ“qâÝG‰ÚËоŒìß5ú~`ð,æLæþOj í7P•'aÎ9˜=:¼Qý|%xBE¨þЦad³è‰8™è0©Á>›lG/Y+À|Á2Û-ïø]þ³ÞaæÁœ R�ùþ² ¬i˜MìfIZ!Ï6Ë*m³jyÝV²ë¯²ü-Þ‚§bN\óYŸ"Ì™~•˜Í¤ar]oCí˜/zKYŸ„U¢ú«/ÿê<(/(ÁsIEND®B`‚caronc-apprise-dfec414/apprise/assets/themes/default/apprise-logo.png000066400000000000000000004722131526720371000261150ustar00rootroot00000000000000‰PNG  IHDR’àþ?Gr„iCCPICC profile(‘}‘=HÃ@Å_ÓJE*Rì â�¡:Y•â¨U(B…P+´ê`ré4iHR\ׂƒ‹Ug]\AðÄÉÑIÑEJü_ZhãÁq?ÞÝ{ܽ„F…iV`ÐtÛL'b6·*_€€0âËÌ2æ$)Ïñu_ïb<ËûÜŸ£_Í[ ð‰Ä³Ì0mâ âø¦mpÞ'ް’¬Ÿ�›tAâG®+-~ã\tYà™3“ž'Ž‹Å.Vº˜•L�xš8ªj:å Ù«œ·8k•kß“¿0”×W–¹NsI,b D(¨¡Œ lÄhÕI±�¦ý„‡ØõKäRÈU#ǪР»~ð?øÝ­U˜šl%…@Ï‹ã|ŒÁ] YwœïcÇižþgàJïø« `æ“ôzG‹ÛÀÅuGSö€Ë`èÉ�MÙ•ü4…Bx?£oʃ·@ßZ«·ö>N€ u•º�±"e¯{¼»·»·Ï´ûûTár›‘¼”ÓbKGDPPV”ÜÉ pHYs  šœtIMEä9zïé IDATxÚìÝy˜¤gyßûïïy«»gzI£Ñ‚ö]BlB lŒ±˜Å0«X ,ÀÛ9v'>ÎI®,N|�8�x»ìØØcbâ;Æ`vd a‹ÅbB  A„–Y»«žûüñ<ïRÕÝ£‘˜‘fù}|µº«ê­·ªÞª~Ûóã¾ïÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌì$³CÐ{ÿh•ßvQ¿ÀXÖ Àzˆy`ii˜#b˜5#@H#"„ÊÎДЍ˜!@š“zy± #-Ë»Ëϱ“È;!í@Ú ì–hŸ¶ên£=s©<Ô¯ÿ.¼ïc~ÏÍÌÌÌÌÌ’ÌEïý£l6Ôïë‘ÖC,B,@ZÌ•� ¡”—‡ç†48]¨ß¾»9AÜÞ&?”ð*r*!ÖÔmšÚ÷à±»Ëc¤ÛQì¶Ü�¸tO»üN>ðÎ’õ»%êå|~í·ýy033333ÛG$™Šþ¿ß„…͉¼i3‘7›‘6‚6Qª‡¢$.Š>·© L fR_þÓ–3E ’4’T+’²jÙ�¦�hjÆ}ÂCB©T Õïe_¬<¡úT¢< e"¶!ÝÜ ñ}¤ïé»�w ž#]¸ðÔËý133333»$™ŠÞûŽéj È ¨•AJAG›€ÍÀH› Ú€©žº„IÝuDÔ )ALJ(TB)J`”Ûª¢6pR}Jƒª§@µœ(ULÐJ}d¤Ô�»"ÊKˆ JQŸ[®Ïu'âàû ïBÜú^(Õ×—&å©_æpÉÌÌÌÌÌìÞ8H2;=õøGWÔ€Eå;Ô�FÀ0—jS¿4�bpÄfàHБ”ùIt3�€R ”€H}à4Ó'¥îñË}-kµ¥­?êžWyžƒV¸îLU‚%DZõ•KÃ*¦ú8)•�E åþI³Ü|ôз!îì ©Ú­žú2ŽÌÌÌÌÌÌf8H2;œ¼úEðö?ƒ¿þCºÑGÝÙ «`Ju¨væK+œŽ†88 qs5 ‰éÓI×â]km€¥Áæ ¥’>Eݾ´¬ #4UÕ†Im+\{úêg;uµOõnipAÝ ™ˆ2\L(C½¿º ø6;—neýBžzi—9X233333s�dfð×ï,æ3í_ Õpi„4OÄV¤­GQ*—Ö îT*€VT$1˜¯ÔÍG¢Û r?'©Ý>r ¤©p¨äB3ç®¶2Š~ÆSDm¹Ë³ç»ÔoW[æBÐbi7ð]ˆÛ€[à¨oÁ÷û‡jæ!�á)/ñçÆÌÌÌÌÌ;’Ìluïû#útiv%6Fˆù.­m¶ÖÊ¥-Àb¸L}5¬Ý·Î1ÕÞÖ§¬þÖE¥™S£¦gEE_©TöÕÔvÛÊ—¾…t ‘oí¨Ï ‡Jfffffv¨p�dfûÏûk+\—ùĈˆ��Ž!t Ðî!M$IýÙJ© db0;©­Jê šr©ÛG Ÿ@u•MÝipVœi‡ž9Uw’©¥P©Þw7¡»�{€[€oB¾¥]+ŽÍ¥ÖmfffffIföÀù›?\¸çØtÄf¤#ˆ|4plY Ž#AóºUàè‡pG´«³µË²¥ö†Aø”J�”jLµ}.1˜Ó4¬nJƒ!Ý 誑úê&u¯a¸ÊœPi›K#`p'p'âfàfØ|Ü=}L(™™™™™ÙAÄA’™=8ºP©æ5ãÝÐ,l.•IÚŠ8Ø ŒµR ¦Â¢¾œ¨_­­æÝ¶¯å¿MW´eÒôi0M¯4§¾½mduwR?¿©<—a˜U‡u3BºâˆÛ€v¦ÒíÝslÈžô".ÌÌÌÌÌì€æ ÉÌ|xgùÞ®œ#Hã#AÇÇÇ#¶l*�P7O‰Rƒsš@‘ºl©¬È&¤$Å`R ‹¦šßº‰Ütv­pA?£©},%bR§}k8 »î»öFu˜7wߥ´¾Ý‚t#°£<¯2.Ê�’™™™™™¨$™Ù�å<}z*ß¶Ç<±tTYýM™ˆ˜š»]›2˜› -õ3•º6µ™ ©—Vœ#»9Lƒ™K’êýS�•4x|jœÔ%TÃÊ©R¥c¤mH·AÜDÄ×É/¹‰Ñ{úGâ ýy03333³Šƒ$3;pµ•JíÙJ$2'' �IÄf¤†’æôÕ@m+Z„úQF æµÁPZµASÛ™6X¹m8Ô;B¤TG- [Þº™L}eÒl»]W̤9`3°±Ü éZÄ—GpÝÖ”¶ýÄQÇpÇòvDß\ð4ÌÌÌÌÌìAç ÉÌ||g‰a&ó—6§€N‚|,h3°]…R&H5X*D]uÐÔiP3gÅ銤®r‰T¨èW�úà© �ÒTŒ´òñº]�€ ˆ� #Êcè6àNjš�¿xËñ_¼m÷öeg%ÐzçO÷çÁÌÌÌÌÌ4’ÌìàòÁ?™¹¢�X>¥“§l6Õ@¨¶¾¥Á*k+‚$V¯š9GJ©_®½m¸jÛTu“Ö8»ö#¤ À"hê¾o­;­inxÚæ£~gûdùFàö@wÈ�’™™™™™=È$™ÙÁé-¯�·þA©VЍ³®ÓfÈgœ…8’ÐFˆy¤<È=sÔÏ1ê†m“V´µuÕH­œ?D3sZ-÷-L%Ð*÷Kƒ ¨¦V$ ƒ¤õˆõÀƳ›Ñ¶'lÚü‘¥œ—�ëD|-Ð͉|g0ÊQwé–733333{ 5>fvPúôÕåû;þ¼d3< `7ps\Cæn”2¢©AÏ\ qÊ,¥.ЉZ_”@©Ôž™�Ô…JÃj¤®z©†QƒPŠö2ÔÁKið˜í°îDh1_æ%ib®ÎNšÛ"í¼;�/&•Ö¶ækAþ²ˆëƒÑÝsÊãT»éî¸áfþÇó^ãÏ�™™™™™í’ÌìÐôg¿GY/ -ç�Î…84ßM2ŠZ¨´b€vhÅÜ$i0oItáS·£©Î9¹Ÿ¯ÔU;A­@ZDÚ€R ‘ mmÛpVÓÜuñ¦#>²”óÂ*¯®$âZ‘¾ÔˆëÚ6'�Sµ{Ûvþë�<ÇŸ33333Û§$™Ù¡ïCïª?0Ù͹ÀÃ�-ˆ9B}›¯bpjŒÁ$Úê#õj™é0©­8®ö ª’úJjÊl¤và6‹ Å$ÅâÙÍèî‹7múèRÎs價ß5Œ Ô$4jÄWúû‘týmK" ‘¸õ«×òî¿ÞŸ33333Û'$™ÙáãCïžúÖç�mEŒ@µåm8'©SnmG‚”!ú�‰Ò`^Òàþ ®“F  mkÛzJ«Û:ºŠ¤ÍkU$!‚@ˆD#š4’¾:BŸMâFI»šRE&ø«ŸûnøÐßú3`fffff?IfvøÎQJižˆó€GÇ×¥ÖúùH"­mi8,‰&u+´©T!Õm4l‹X¢V$±¡~µó‘AëPªAÒ¦.H)9×IÀ„@$D#)‘P3‚q_Mð©$ÝzWºk×Ñq£ú þÃÞè÷ßÌÌÌÌÌî7Ifvøúð»gÃ÷LþÏ×®÷{offfff{ÅA’™Yë1‡Ï¹¶¼•"$`#p!e•·c€IA ‰ºJ¥ay�ºj¥rm;{©–!1êZÛJ ´©¶}v3ºëâM›>²;Çz¹ÎCJ�r; H åvFRS¶)áQiyS"$‰R_úø†ˆv)e)ñ/Î{¼ß{33333Û+nm33k}þËåû¯b� m®þø4J;ËÕ”ÊBoP*’r7 ( C$ºÞÝÏÑŸŠÛp*†· ·«³Êí|¤*ÏžËSûÌ4|œúŒ¥,ÒIé%»Só\©Ù*àW¯ý ÿîÚOûý73333³{åŠ$3³µüåïÆõåçQãÉéàñÀÃ(…?1}>ÕÌyµ]½mØÞHó”ÕÚ6¶¶)+·­6œÕŒîºxÓæõ«¶•ùG¥"‰a˜Äœú*¤¥¤2Hi¤D3hskDW�”H$¸=)}n”š«#&;–Xf=süâ9?ì÷ÞÌÌÌÌÌVå ÉÌìÞt«» 4šuÀÀE 3ê�¹¶Ãµs‘ÚókBʵNÝtmi�6H’Öן댤ų›Ñ]—l:âC»òdaú¤]fiGWê$F*�osR7#I$’`$Ѩ!5H*×' !‘RZNÄ͉ü1ÔÜ0ªûýGg?Îï»™™™™™­àÖ63³{sÙËào>F)jv×ÿâo nïƒ" Œ$JˆDª§Ûºª[¨®àV¶.ö}+Zii›ÎûËÊm³×)uñ*·K¤ú"rÝõÌÿvP2­9‚3`ô‚„ž.8R¿õõOóÖ¯Öï½™™™™™Mq�df¶7þÃïÀS.‡oÜÜ^“)ó“>¼ ôÐД,G3ó·£^ˆrAš>w�Ú ˜ž©4Ü4Õï¥]NDP¥N)úà”!Ü+šîÚüª T: xBÀK!�¿ƒ€Ã$33333›ù÷ˆ™™Ýwoû—ð°s‡§ÓEà4à1À9BšÔ,(ÕPh�*ÅJ‹ �ë‘V�‘´œóB™�³Ï ¹mmkTò«9Ô®ÐV¾+Ñ´s‘”hPßÚ¦Dj·+×§„r’î|ø ð½ößrÖE~ßÍÌÌÌÌs’ÌÌî¯cŽ‚Kð?ÿk{:MÀfàÑÀ£�¶Nõª! VQ›/á‘6¢X_ƒ¨EˆuHÎJÍ]o>âCK9/´s‘ !º…ÚJ>%b&HJ$ !‰9%HIä¦Ý.AR;|»ì§‘j¨Tt –�ïPV¬ûŠTª ®<ó1~ßÍÌÌÌÌcnm33»¿¾û}ؾ½ÌP*2p'ð)à/€/ÔE¥O¬T%ýp$˜­4R»ª]{Z”EØVT%Emmkg&ið]µÊ¨>JDDЯµµMuE¸A7^ùÛ°œ<xš”¶DoýúgIs#¿÷fffff‡)W$™™íKy÷ðìz$�Dzð�â¤:e;µ"i¸j›ÖÎjÒ�—l:âCKy²Ð†=Ã!ÛíåÒÚ–Õ*¤1R$µmk1h_K$‘šÒÂV+’ I¢)uLmER+;�[€OHéºvx÷Ï=ôGÈËË~ÏÍÌÌÌÌ#®H23Û—žò’ò½d-wñ¿€¿þ7h'ÄBe‹5³ü ÚÒ%u§k­œ“Ômœgoªuƒ¥Áˆ¦áæ%t"D^­Bª¼’Eàlà™ Šr™ÿôÕOò²ÿ+~ÏÍÌÌÌÌ#®H23Û_>ò§ƒ3­6ƒ.€xp|°½®T$ÑÏHë�ų𿮋7ù¡åœ¢kqk¿OÅC4*ƒ³G”%ãA4¤AERª¶Ë0î2#©¶]ª™RÔÝÎP¢ô¦ÿè._ËðiÁ·RÝßçßÿaþë›þ±ßs3333³Cœ+’ÌÌö—§¼”€oÜ “O |´ 4¡©yÜ]õP?)êòoÔö³r]»µnD{cšÞë`ѸÁ^cPë4˜‘Ô†HÃg•!6#=²‘žœÿÕ;î`’3<ýRÞöõÏú=73333;Ä9H23ÛŸ.}1\úµ§Ü1Ä À�®¾]†o×%ц5¢]¶Ôf9ÃÛŠ¤>牘jV[qª×*÷¯Ã·Q{M í†Håçr{óq:ðŒs�:êâˆØŒ�xÛõŸ÷{nffffvs�dfö@¸ô%”ç¡w3„²²ÛÕH»sýƱæn¢›cÔV(õ•HêÛÑÚÅß²fî½ògM]S¯Í³�+)·�DÙ÷q‚'§”ž qbª¡ÔÛ®ÿ ÿì¯ßí÷ÜÌÌÌÌìä ÉÌì�ré‹áÒG•Ÿ›fL䫉ünˆ�|·ö˜i¶úHµfˆúß fFoÒ°µm¶[®.§S+¿•­Û}¬l±›Ñ.×VtK)ƒ6Ï tÎKˆ†“Î=‡ß¸þû=73333;Ä8H23{ ]ú’Ò&¦(ÝŠš?�xÄ׀岲ÛÊf¶a hf7µçó\n™^�-è*‰úíÕ7¼õqÕªkÃ¥ˆHªûIµ. åòB8xÖ󩤹_ûägý†›™™™™ $™™ý€ŽyÞ³G;®¹.ïïǹã _á+¿ÿ§œüÔ‹Ù¸eÁø›ÐÜ“¤-IlT�b$5eE¶6R"QBŸr{Ñ õIšnm«wIAýžÚ¶¶ ·�úE;+©&Iu^R7£›¶œ éž@·‰àÜ‹.ä[_ÿ·^÷ °ÌÌÌÌÌ@ž‘dfv�øªWÎO––_@Î?Œt,p$�‘Æ‚HKjš¿ùöÿñ»ö×sX<óT^üçÀ82#¥ÓGŠK:½A*ó�‚•ÙHä:|;uC·%JµRjg)Õ¡ÜMÉyýªm«ýœ¤¤vðvMˆIA®3’ºÉJê#*¡&Êõ�à{Àßÿ ʼ¤4‘ÇcØÌÌÌÌÌ0’ÌÌöÒ /{é3óxò¢˜LƒtÒÆ$5@ ©t~•¾²9ßCÄwRJK £Ñç¾ýö·oßWÏ%m\OÞ¶“W\ý!vLj#šÉ± ]6R:¯�¦]�­)}fy8l;‰”PnM•z¥Ô(ò HZý�F]¹�È]xHƒËµ™.§R‘õJ)ÊñI¥ *êÐn4‚;€�¬Ÿ›ûÛÝãqxýé�ö‡ÎÌÌÌÌìã ÉÌl/÷‚|ñ蔚£bõ ¥­¿é:¸"Æä|;Mó¿5ýÞèÄÿ꛿ök“}ñœÒâ:òŽ]¼æ !4aŽfó¼t©ˆ “ÒÜHMNĪ«¶‰áŠm")Ñ(ØC�4U•Tç(ií¿+‘Ú©M"¢¬è&¥v½7õG¬¡ŒpºøxJ£�Gä%p˜dffffv ñªmff{8GûüçÿùÖç<ç5ÍÓ¤´5G4D°âÿ"ê*iu&uä #R:AÒ³c<þ­É7¿ye»óã_ú’ùäÉ廸Üoþ>#wåÉÝ“ˆ÷Šôw‚%bR†•ÉIÝýÊÄìúœÕÎDŠîÖX¥I«ÿ@ÄŠ<©¬ÖV+’Êã(%‚,bue·rÄ”k9בRzJD~jë@üÞ _ð§ÐÌÌÌÌìâŠ$3³G<ëÙiý†ÅWå¥Ý—§ù…§Eí÷]­N�OÚeÌbꬪé“kmCBwGÄoÏoÜü¶›ßþû·ì«ç|é¯ýßœõŒKY—“q¤Ñ(]ÒÀ%�´˜”r-ùaT«¥u†’ºê¤¶.IkýqHÓ/³ Ü®GCš9 ©ìD(P¢Þ”vw"B*ýq÷ˆô à£"v¼öô üÁ43333;xÕ63³Gœþ)¥?H£Ñ#È5ig Ý›nèôê× �?/Ÿ±å¢Ç~宫¯¾}_<ç?xinŽM7ÞÂâÃΉJß\€åFÚ*b1‰(M Š¥TS›¨ÁÎZ«¶ _O0XrMm#Ÿ¤%•¹Û¥)µ{¬Õ•áúëê¾” µM�eÓ´^èx`”ˆoKZúñŸ{ñŸ~ÛN3333³™+’Ì̪“^õªùñöíωœÿo5Ím R*eHݶ{Œ”JåÑô¶Òôí)AÎï_Ì1Ï¿á7s×¾|oùòÇ0Α=&I�O¤cUfåffÕ6)ÕÙI±§Š¤ä$‘#R’Ê�m"kfÕ¶QVvk+’Ô UöÙ©­Ž$Õ}Šh(õN÷—’>Q*“~ê4W&™™™™™=˜<#É̬ºçߨ*ém�.ˆÕG±÷;ª3�bx¯ˆ©¯˜Lž²ëŽ;~i_¿Ž·>üI4Àº¤¼;/^è*Á­¹9unQtƒÁE¤{ù[‘r”à)G‘Û’¤v»ö‡,R*c½ ŽD®ÇFíx¥¶S°\#"K�"È›�‹sΗIZðû7^íª™™™™ÙƒÈA’™pÌ�=wÓ–-¿Ò ‘ëâdQ—Ù`(~Ы!Uó‘óÏŸøŠWüؾ~=ÿñáO" Ö¥ù<&1ào�[DL†FѵîåµþF”aÝÝ味úëµ^«ì-&DE´¥Xí¼Ó 5ž IDATí$åº|[ÝNŠîo‘ÚIK)ˆ(-rl–Ò#â²Ìd”o¿ñ Ìo\ô‡ÖÌÌÌÌìAà ÉÌ{'ÿä+æÓ¨ù•fýúWFÄ÷À¥(gSL&ÿòä×¼æè}½û_{Ø“Hs¤<É|¸ éf‘&’R?v»}2ÝD£vj˜V®ÍRÊ’R¿ò[fÚ¤Kk[»ŸÒ ¸òïN7©­ˆêVŽäF•#±ô䆹§Ž˜OKÛvðˆËžè¯™™™™ÙÌA’™ö–îÚöJÍÏ_™K»Ù£+o Èùü¼¼ü ûãa~õüKb.‘—c| Áß n *“bP%U‡Í<ÙÁÊt)‚ÜÍDÒÔjmíV¹¯áê'r‘¢ßcM’ÚíÛ§ zûTGqÙ2;/ 2xÓ—øÒ‡¯âÌ‹í°™™™™ÙÈöÍì°ö�¿øâÈù�Ίé±=ûõDÛf&BeNÑÜÜMsósçÝüö?ܵ?^ç/õØ1§Ísóç”Üœ–`.I¹ ÙN¤Ó°Å J+•ö3²(UID¨ÌY¤@D*c¼£ms««¹•%ÙÊ«ŽîºúHu¹�J…RêfqK¤r[ºCð—ŸhîÕ§=Êd3333³ˆ+’Ìì°uÜóžw.9ÿªR:«�ö}k[¬òÕÞDÎÄÒÒÑ9Çcö×ký×½!6ŽæóŽ<ºøp#°Üþ-˜ik+O1¦þ^dˆA"2"JaR_JåÕ)÷3�4ÝF7ø!­<öƒˆ©Ü/—*&mž º¨m‡ûCà63333{À8H2³ÃÒ©¯}ízàç4=¡®½§iÿÎN XÈãÉ�ïÏÇøW}IbCšä,J˜$ÝL€&È«¼Î®Á,÷7Ú-Kµ’Ä lIÝl¤aª+ŵûÑŠZvÑÕBÍž:Ù›ÈA¼â‘J ’ø£›¾èµ™™™™ÙÀA’™vNù™Ÿ™Û}Ï=¿—CÐNb*Mí1\eú85?AÄy¢AˆwÜô%¸ÍÌÌÌÌö3IfvØYþÞ÷^—ææÞ’'“á4窙F;Çhoö¬™¯ûD±õ�xý¿|îã0ߤ¬¼óZ‰O n�)º£¡á±Im‹YùãQç"©Û¦&I‰2Ä;Ö<:´‡•Á#i_}T!Ñ^©Wc‚Ó�çCœ,Á;nú‚?àfffffû‘ƒ$3;¬œøò—?/�F¿Âd²±´´µG«m]£�bY‚D,žòS¯=ñ�8ÿìÜR™”Òb–æ® âS·PœÒF¦îïDê£%Q.çz¹¦må ¶›åÚŠ&Í”{ #uG}Åì¤ò¾ ¶T}¬<ê­òxçñl1wb[™ôÇns33333Ûo$™Ùaã!/|áy1ÿâøœÛ¶)Ö^©m¯¤˜ž­3—ÛÛƒÁcÄÔÍ¥'ÈKãõ“¥¥'6´¶I¹ì¡>|!��‚xF0ÞšHâOnv››™™™™Ùþà ÉÌÒ¿Ö“òò¸Ï6öx‡èƒ¡nVRÌOmÃV[\S/·Ñ°ªi¿LX!�‚¸ð�<$¿xöãÁœærjÖ}EÒ§@·×¿meRî�UŸ E„…ºälvPv½ºKëbµW?ud†muƒ?PQf+µ#˜ú°>öðCBOŽÐÆœËjn¿þ‰¿ögÞÌÌÌÌls�df‡¼_ñŠ£O~Õ«Þ9Ú¸ñ'òx ôœKÌ0øèC£¶�hÕz¥lO¬¼<µí Jivh÷ÔX%5À)ôñù…³¨Ô奱}IèÓÀ´Ã³ûc•"J…R½®ýÏŠ¥Ö´â(¨Ý0O¿ò®9Nj�}7c) w—Ë£†jÓ[’D (+Ái“àÉH�O©eÇ�r²?üfffffû˜ƒ$3;ä©iþY�^ËË÷¶åÔÔž>ßÎöQ%ƒ²¦aÛÖðr;ÒG3£¦WÊ-§<ÇèçÏzl}6ã±ÈŸþpÐL!)IeU5u�iÃyFíË^ÑÆ§ÙˆMDˆŒ •iJ©gužÚ•Ü$¥Ò´FˆTG0!µã½a‚tŒÐ³D~Ô»Üâfffff¶�9H2³CÚI¯yÍ/©i^?�SÎMrÔ¶ªÍÒìpía`To›ª*Z+ ZåfX¥ÅmæÑ#¶ìÖàoEDFD»‚[´•IZñzJÐ4¨ûê¦u#»Ëcµ—Û/úª¥è€r]Ô{ÖUÜÚ²¨&E,qðÂ1óç7Ìð®›¿ì_3333³}ÄA’™²Ný©Ÿz³àŸDΛb2�É„ˆÉôˆ£ñ«.š¤M”j›®ýmO_íïZáb0ˆ;€ÉDyyùˆó˜½å¬‹Ú—€¿>° $)åþð”ã’$IÓéX[#Tê”ú$-úñÙu©vÀå\‚!€¬ 3¦j¥4xgÈD&Jª×>�ËÀy dƧ·¡Õ»¿ùþ…03333Û$™Ù!é”×¾ö5~9r>"//×é:ýXèX½"¨½±Ïw†AP½mõGS×ïiÿ³D¿Üýú“_õªÇ>˜Çn&Lúð÷À$"—ä'‚!””#bØÖÖ�ÈV©‹ì$EêÚÉe¶Q„�R "r»r[Yš�®­­ÛM�Pª­ptsšÊ s©„Z»ž)8n�Ã$3333³}ÀA’™rN~Ýë~’ÉäWÉù¸<™”ꡜÛeäûiE%Ò 0ZC¬ñ5» ¬&ixà ‰’TÆ(E,äÐùö1ü¹ó~¸ý± “¾š¤¬þøED.M~‘%I™ '‘RÿÊPD(÷+Á¥Õ�R™‘´«ÄRŠˆµµ­èÆ@ ª�êKšZ] ‘&õÐF·¦�¦ÓR F˜ç•çz`jˆÔ-áVšåB9"ÕAMeVw�­TžW £¤ÍÀe9âÂ…ùM à¿}Óó’ÌÌÌÌÌ~’Ìì�qÊë^wEìÞýQ5ÍÙ“É„œ3ä~°v .'ö]�L*íMH´VåRWu¤ÙQÜí¸ú5ÌfHRqÖ�p<Û·¦™òw"òÇ#òm9"·ÃŠ4=h;(%J‰éø,¦æ©°]Š‹¬—§ÁŸ¦A[[®3•D’Úû }'ÕJ$ˆXxJÏ]ZÚöðöùü™[ÜÌÌÌÌÌî7Ifv(Ðozã9yiù'Ë˧N––J+[ý"g”óLfSjXº ¥™Xh¶ñê¾T*­6G»‹®Q%�‰©¯‘òîÝÇ(¶ “„r‚ëW î JV&µƒµƒœËÛÕÉÚ‰AéÒpâx))¢[E/ÊuõBd¤T}[å„j[Û ¥.  o#AQB®eç/ùôvûŸý�_óo�™™™™Ùýà ÉÌz§¾þõ¯\Ú¾ãFžœ1�K€4™”ïP*’€˜�‰$н¤C1øû쯱J\™‘DžL¶HÇ÷-g]ÔÎŽÊÀ�¿&¥*)¢=®e\x”g^Y»UƒZI”ÕŒ©‘º–·¾")•ÿ”Ý%uwhW‡ÓÌ;[J€Æ ó!=3Á€‹ŸûLÿ☙™™™Ý’Ìì 7Þ±ãb<>g2‹¶}mØÒ&­¬Ý­ÄÆ3Öô�Zqã¦_þòÍÒ1~ËÙ�ãÊR�”�O >)©¬Ù6ÈpÊl¤ Jf4\µMD¤º6�D¨´*—"¤¬AýW¬þ%®Ê�¨Ñ 9çh«ŽÚ¬*§r9õ�¹Ö~åy‘è °ä¿}fffff÷“ÿŸi3;h�þÓ?ýÐ.¿üc‘óÃ&“2X»]�­´ŽµB^1ŧ�îKËÚF:Ц9ë@{Z'<â¼öøeà9âKÒlM—Mjž=¼µ¾¨NX4*Í ¬*þ£¶JJ"uó“b°Û\w”ÔNbÊ!RÍ©’ꄦh ðt˜ ˆ÷ÜòUÿ™™™™™ÝG’Ìì túW\°{ûöwOŠˆÔVA[m”ûª£zŸ©p©mwc_¶¬õfú¬ö:°*“…´(xèɯyÍ•q}ûK×ð»WþS$‘šfœ¤爛›Dôˆ”»ñHmÖ¤áQ©[².rtsª¥¢hB ¹m‡“ˆ”TÛÜêsDV×^W{Ý¢ŒM*×3N=_jÎxÏ-_ñ/“™™™™Ù}à ÉÌ:§½î§Oݽ}û;‘uu¶È«¬Ì6øRL­¶ß­XÇŒ5«©iÜõ5L&sã�;:Þµë€;ö_|߇¹òÌÇò3§_ˆ¤Û“ô1àû%À &“6Dj{ΠL¿ng$©Ì]‹‰€:öH�IôÑ"rÝ&‘ûÞƒÁÚmº”R9Š™ ¶´iE„7�8ò�A ðßoñ*nfffff{ËA’™TNyíkŸ1ÞµãO#âü¼¼LE]h´ÊýîåÑþ Ÿ¦ã¦ö™GÎ 89r> ߃~nyž$éÚˆø„¤q�%D3œ‘„úê£R*„‚í­qTR ƒBRÕ–5PÓnÞNö‘s”¹I¨„TjWäĈ!iôhàjøefffff{ÇA’™4θâ�›&K»ÿcŽüÃ1“Uª�誺/Õ…é÷N»2}m• íý}îS\U·ëž¦ºâ�Èù$rÖ�ø>|ûË_cëY§ñGÿô_‘#rDþL ÏA›å¤ÁŸ—@Ñ·®•X§]GOD)A’¦VÕkç!Im⤮­­­á¶ª�Je•¶Zà´Æ’|epw-xz�NÌñçž—dffff¶WäC`fºS^÷ú4Ùµëiy¼ü�iÒSby\‚žaÅÎìªlý,ž#ÅÞE<³Ë¸ioO“qO«etûmöÆ”Ò7n{Ï{Î:Ðß›·}ý³”š£´.‘_œ§R.”)ÿcE×[VßTÕÑØ(©,ëÖÀTn¯ÛHõ@‘jRTÞ¥&•w6•ø¨”)©l#ꎣ>�ÔPJRJ‚ù‹‰æ­AÜ*‚çŸt¾áÌÌÌÌÌöÀIfvÀ»ù÷~7çÈÿIJO‰qmC ¦ëŒV„1³0{k0ßY÷%k¿¯ë¿i•çÃrªãN¸üò£ô÷æÊ³.ª�bpð�(«R0¨¨’�RR—ñD=À¥˜¨ EW}ñ‘DÛ—Vg$‘�Õ;¶ïhnÿ�)µµ­{»stt�ô�ë~ÒYAó±€È,�!> q”åТ-’Ò°Œ,“̃¨‡Våºb[©9ÒŠUc¤©¶µˆˆÔßš©!‘©Õ=³R^ýOÎxþ»GÂ-nfffffkq�df”s韧_ñŠ’Ò 8//�É9“'“$å>«¹×`ç¾ÒTAÒjµDÓfB ÅôM1˜¤¬¾§©ŒIƒµæPÀ…q-Šð3g^Xœ´ ô à» Ó±µ«¼J¹Y¥áÆ1uXËA�¾"i3©ÎDŠÁ­á�/™Q;ì{ð†Œë�‹v³pI�øK·¸™™™™™­ÊA’™PîºöšK"ò[c299/-ãñôêlƒHa:õ²V™Ÿ´ÊÏ|×jÃÀ1øê^ŽÐ0ˆHz|^Zj¦÷ïÍg^D"qç]|Ø 4õ�d¦â�îO Ꭹ¥ìb•w¦¹v^R[Ù” «^R4 c¦©7!4ûñ ~¼¶ºñ—nq33333[ÁA’™νâ ÷üç?+çü&Ëã‡ä¥åîŸ÷«—äÜ×UÒª©Ê Xc¿«]ÓÁÏšÏiÅM_Õf$Z­nzÛ€…ÉîÝÍÁö^Áu| àÓÀWKšnkÓjÇWƒ÷uz¤zÛ¨¦s9Giƒkn+ k¦´iåàô\g$õ3–jø”�tðßùÎïÑ®gfffff=Ifö ;åÕ?¹îÎï|çÕš›ûÝ4?ÿ8"Ê¢Z{²W9RL}ëî×þZ,i¦ úꢮH¦ž��T¿úcú«ý¿ˆ©ÛºÛºñÏý}�6lïçÏœù˜öÇ]Eº9"&k¼‹©�ÊÀq¡á nÃÞµ.`Ò °I3owùq­Š5©¬äVB¨~Û€&­“ôÃÇÿú‹›4â½ß¾Ö¿ fffff’ÌìAuêë_·y¼k÷¯4ë×ÿºšæ„™…¹¦‡ru›¶ä=ÌGŠé{­8uaT¿ŸÙàB«B+žÞŠG^ñkl»²@*úÑÒöüè�tçê6LÜŸ ¸;j‹Û웲rÔx ©xh¸mwÄR]ñMý¤¤vØöì„«A1XéT™å”û7}z˜Õ1ñÂÍõíëü‹jffffÖþ?â>fö`8ëç¶9ù§^}äòöÿ(­[÷K¤tm 0•,¨VèäÁŠhÃJ£™×Sóuö`*ÚÃÜîé±ESÁP¬ò¥µï¾gÝÎ5hõJÍK4ÍÌïuÀ(-n»i!õr­Àš®.üyŠU•w[䈮I�<}@WÞþ~}3Ýð]-Ï$¡óÆL~"p{›™™™™Ù�ƒ$3{PL––OFé]Íúõÿ"rÌ!*%A1ÉD.+µM70 ×5› ´2@XkÒÌžîó´¥Xãþ«Ž†žy¼5w8{O©i^”w/­;ßã¶*)IK�tp=«æmšlÔ Ðž>DiÅšwÃJµá{­G{0t[BÝŸ¿.†ŠÁ=i.àIМ/Â-nfffff•ƒ$3{@�zå•Úúìg¯_Þ¶ýM©ýhLÊðã<™0^Z&/-“ñ Ç,fÓ„{Qçu«Õ¯aÊÐ^œ“´†“¡µr¬öšc»WÙ~Xÿ²úƒ´W R:2�Ç£ƒõýþ™3Ó– Ý|¸ƒ¾Å-V{ïJ­PÿæWbëà”%”X¨ŽBO3±žD$­rôËg#×kÚ¤²d\mûp<Ä+E¬óo®™™™™Yá ÉÌXKKç,žp‡F6üâdi‰ñömŒwì`²´„bPç£ÁW2D¬2Óhf~QÐ-ûÞ.ަ„ÑÇ>ým{®Zš.¡Ñ0�˜úq¶&Œtbå×ju3ÌÜŸ“ƒþ\À¾BùÚ5û²§3Ã:Æh•½ ¥rû±Ú’²¦'«G D.¡Q‰ #r¬XÝm*TûA� ô4 Þw««’ÌÌÌÌÌ$™Ùæ¤W½ò\rþ/jšYÞv“¥Ý�"QªKX¹W *ФÁL£˜Z™k0b¨.ì«Ü Ú0ÕV¥è^«‘†µ*%êg*­Ï´†û4p{Å}“¼p0¿ïo<ãÂöÎR†¿®c&Ÿ“fs�Á1ê†$åvÔùàSÒU))†Ó’¦æ R©n;˜´> öÊÃå2“ BäðÜGB “6n9Ê¿ÌffffvØr�dfˆÓÞø†Ÿ^8òÈO¦u O�,/ͬ§SéÀð’ÔWôÄÊN¨¶ö¨ ¢ö«E;ñ¦í_îU�4#íqFÒ·g×k£­û7p;VÖèÔðLó /=êé?zPÜ~S&ÅwD|6w¨kqÓÌÛ�ˆ’*µ ²¥rDÔþX�¿Bý(î@)©k}«{h£§Rá¥u ú¦Æ¶1.†o/}ÈIÄÙ�^9©OwÛß÷/´™™™™¶$™Ù~uâ«^¹pú®xK3?ÿÿhnn 9“)!%¦ ‚”a»ÚžL­¿¥èŠZfWVëM7¢ijòÎÞ‹Uö¶Êä£Ù—µêåáµÒà`¨„Iͨy},íÞt°ÊqJÀü5×±\þuïu=t¹}W£Þ–Ë,EWq¤„ˆZY”�Ò �ªÃ´#‹‡wk¸ôŸ¤”jE\”Çlr—ç[bGžÒ09Gˆ÷ßzÏ~ÍËýËmffff‡%If¶_ÍoÜøKÍ↓֭{H®Á�RCR_qÔµŽM2‘c¸ÞûŠ ¢ÿÞçÑѱÉeN? g¸Û¶[nP¬to+·M5LiåJm÷Diµ×°Ò08ë"®QsJÎã…ƒý³Ð¶¸Áò.à“ÀõѽsŠ>¼0‰2J;uáZô �më*µëÜ£6VÌ´«±õ­nšêe«b”*¶º³™Š$1Hõ"o^-4/Ä{ÿà�þå6333³Ã’ƒ$3ÛoNãþm³~ý/¤ù¹�Ý`¡: G)AÎLvî$//“I½W¬ÊhåEuÿ©ÿÖŽUj£€A‚Ô§QjY¢ŒcžmQ[í¡cêihE5“BL‰Ásê+öxŸvÛ„5 1‰ùCá3ñ†3 4$ø–Ðç„îªåEÊ1 jxÔeÊ»ªi°Â[žÙv‘™Íˆj,)ˆ mK]”}iøÆ×XR’ÚÑç�žÿþ[¯Koþ÷ÿÊ¿ôffffvXq�dfûÄ1Ï{ÞºÓÞô¦3·añ­iÝÂi4Íà_úåŸú“ñ2ã¥Ý]uÒô”¡úOy­5i8!§ÞK5j“ u�sý(åöa4¬ ê¿·+¾MU' uö Wi[»•mO—÷jضÔ £æÅ4s›•ÏËu^ÒrÎ7%éê$íb00;wïl7p=·=iY�’”B¥¶ )�JkÜjïLù”�0Þšˆ¼b¨{_Ñ”êϹíx+ÉÔs�y€ç¼êeœùȇù`ffff‡ If¶Ol>ùäwÍ-®ÿy-,�jÂS¿Ú¢OvîbyûH‰@+#õ•D+z’ PuÆRßÐÔÍÕ‰™Ü jÀ´VëX Íf6Ã-¤AÕRÝç0Žê}÷÷Öl|Áô<¥=U-Ű©ë« šQs�"o<Ô>7s)�'_øšˆQ]ƒ-—æÅL]@­þÁ’r9’Ý|ìòî&ˆÜ®ä6ýf+µÇ½¯YÓjc¯úOÝg­O—úêµ$à4àµÔ0éú/þƒOffffvØp�df?�S^ûºμòÊ�¤u ÏÑÜÜú.@jE ”˜,/3Þµ«os›ÚKôC�B+æÁðŸü1©è‚ž¶3l¶Å¬´ÔÅTÑSƒ®K—ÓLü0õÔÊ7us‘b°Òܰ-® ›�Ý5I ¨îi F7­|�í6uÓÜüÑ�ÖJŸ�Ÿ}쥤”HJß]莀&I$D õ•d)Ô !ÕÖ¶ZkVÖç›�°Õ¯‡ºáæJƒºµ4ÜNF‚’DR7à»RíܦHÒ3qÌFí䃷]ç�™™™™6$™Ùývêßxùܦ Ÿl×?Y©‘c•>®òOùñöí,mÛVVkÌ·éZΆ¥>�™``­�Û±rå´©]­èj ºÊ nǯÚ§6|º]šª?ªyJ(4¨Pêº>ù~úŽúË{ªÔ>JšŸoR;sê±ëŽ»øòUŸ„˜1¾ò—�‘úÕÒ‘§ª‚Ê î¸—?bÝ*n*Ç[Q·d{¡nºÒÔ¦áÔ.!RyG#«´Aæ¶5îØ@¯ØÁâº@|ð¶oø„`ffff‡Ifv¿Î§¿ùÊ?-®ÿ�faÝ©ý`ŸUHä¥ÝŒwîêÿÁý€ê™üÏL¾ž—Ö¸­¾©+ˆÄ{ÝÞ}�ætór†ÕHŠ>pêVŒ§´"c IDAT>57)úÛºW²V©Óš—é‚&iÔ@Îpgy þó«ÞT>PÝ-ôià é�€ê;Пv~{ �òª¯|ˆú�Üѽ%\ @yæO`´ïdý8LP(¢oÈlßò§‰tÚˆõ‡ÚÛbffff¶ö?}Ìì¾8íŠ+~äô7_ù¾fÝüO¤ÑÜ‘«ý3~úŸôbéžm,oÛi�ÑÔmO WncED°ç�ÛÑ5,IpoÏ«MDihêâ…©¡F};[iAS] ~j½·U#¡`Õ—0¸×ÊgLerÒÌXpéÇñˆ#ÍOVäo ] MÔÀ¨¶�êêiõ`¬(Ì’€<øü™\ª™R{TKœj-YW‘¤~}=[D¦� #¢ýè¶¿ê´®Ø “—Lؾc>tÛõ>A˜™™™Ù!ÏA’™íµÓÞð†W¦…ù·�Ö/W?}Æcꤹ]�¾ñ5˜Ì•�„o]“­„<ƒãTÛÿÔ{�HHIQzãúQU‘g'p­ÈùÊ6}i\ r´ó˜ÔnC é2ÑocÏÇØfÌxð36Æ Ï/˜7c@ ±ƒÙÍ&B´t·ÔÝU]U÷Þü½?2#ã‘y«KЭ®êŠÃרêÖ½™y####Nžs~?Ê0÷IÀœ #âÉ$]é�4ð–7ÖUýTw}fXQKe¨vPSOs–"eŒºTÛÖÁÛ®‚S+¢D ȸŸ à›ýë½wàg6�œ�„„„„„„„„„é jBB¼Øuà ›Lžÿs>1þ‹‡D‰áìúÓÓ�áÀEίUq‡gMäÈ›¬¹­ÚQÖ3DÖ68N!>f¯\‘Ju„FH6àìr Ün>q›pÔÕku©°ˆ¶�\±e.ã©â£êü%—A^³L·ÛEžwOÄ~öª=ç2̾ à2ÔyFޏ# Yg$•·±¤Ñ6@‰Û»êb"A€–D=¥Ò<•œ�“È!T»‰øÀoSÛ¥$ÎÊs¦{jBBBBBBBB �¤HJHHhÅÚg?§3±aýb§sU6>~6¦s‡fÀ`f¦¬Ô¦•HŽDaôKÿ@ÅÄZ‹øœ"ý²þ4~¬ÏUMÑIU>S-+ò×Ááá2Œ”M­7÷ýjQðoúLžƒÀ¶eÐýîð'“fŸ&[¦‰W$•0u›Ñ7* }:R�¿%A•—¦ Áê3��{‘uJõ‰‘kúýâëLª¤„„„„„„„„„ééiBBB€1€;n¼ñìÉM?Ÿœøå|b"$‘"áP»ŽˆÎÎb0=åÕHäˆO©0#÷ŠŠˆD[E½Œ)§lá �2k)`€*ù†�mx™�¦,\ÌS¸ ms“€¶þ¹Ì§"¬V;‰ /ÏÃÝÓN[y"ö½›ö“U¶b±’DÝED¤¨h$Ó*e ó™ �¼˜4g˜•”��������p‚#Íx¬{å+Ÿe:ù_䯱yÞùIªš“ÀÜC0œ™­”#ˆH¶H†Ú²Œ‘I-ÌPEˆ—„ÜÒÊ'Ië†ÚDLâ…S†_ëÏꨚc¥qõÕ<�æöNE2EÍ%€Éó§Úññ�'jÿ»qÏـݻ÷ y€½"åÙ5ëSê 5»Jt^”ü¨6XVe¥fbé,,÷Å*YIAU[éª]U¿W*9‘ W‰ÈZøð½w¦Á$!!!!!!!!á„D²¶%$$¶¾ìe›mžÿ²ívž—��ïæO÷ÂR�ÔŸšB1€Æ*Žh+%�…Kœ[-zùºDÕß꥿Ï톯ðæ¥ rŸk¤±ÞOý›JɸPí*ÛÇ8«ý‡•éêï%ŒX.Ï.IùìŽ1ª]W½ßäù.Z³úDï�ßûÞ� q7€/‘¼œà\Ù&H‘†VY �­°<'•%Í�;!¤Š»b³[ÕÆBGJIY0ØŽëIJ.\�O’COø�üDŽUiPIHHHHHHHH8!‘I Ë;n¼qåöWÞpMÖí¾­³jÕ/•$RKDt#³z”© ˜}ð! çf++–´ç]‹4UJ�eh¤}¯T†#‰md*G¤moþ5A¬jbŹÊjŒC 7PïD\t³'¢tú:¡[ª,%øºa€ª$‡ÊNW¶£ív'˜å“'rŸ,UIî¸óãÓ"ø$Àû¤ÖeMy΄l¹ŠmŠ”šd •iRŸ¡ªKfX“Oá6¨YIqËò6Z³–« ¹¼ƒÉµÀ¹÷»i€IHHHHHHHH8á�ˆ¤„„eŒ7ܰEñùøØÛó•+®.‰ŸØÕòÁQ,‰áÜ,úÓÓ(*±Fûg*r§i(í¥‹«¶92À[Ïq´2*E‰T ;¤ÔÛÑÿD M5¸$²-•m-Ü«œ¦¸jœ°¢¹è)*WóÍÉž¨ŠÛ‹(bŒ¬+Ç(·�Yrâ¤ÝOÉ{ù w^JB®Iç‰è~QŸ;ã{\Ù¶J—Vƒc«›ÒWö Ò¸kr°Ö$isâ@€‹xT±€*„ K‰HJHX†Øqã�cÛ_ùÊg3Ëþ$_±âu¶×[XÙZ²fFEé·—j¤QÌÍ•j$•JÃH¸Ã–ÏÖov?FaAa öj1ïÄ"*:YÚX"6÷A(a’’èjpÐÛœiJêìðòðT´¨o§ÆÁä¿¿ÿ¬ˆ)â­Ýo¬öé¾½ÖÀY8Á­É7î9@�™™©ù€|KK�>ßÉ"êr±„LB�Y�°Â[e`«s”ÐQí¶ŠÄ*nxI†Ò~øÑ{ïHNBBBBBBBB …D$%$,3ì¼ùæMù�ÙøøŸæÏ0yž·¿S•§ ]j­ÊF:4�¢Ä�°ÉM5~פSµÆ�ÓYç…ŸíGS¶¶ÀFVíUªk¡J[¡tŶPíT7GÀøP9ÐBzLsY”ð¸jË•Îw¢„QIÕÁSYYΔ-îM5¹$A+%Juw­™ ’³öh�‹Ø ‡1–�„„„„„„„„„ ‰HJHX&Øuã�«wÜpÃó üu>9ù«Y¯·‘A©òÑ0â-ÒüÓ̾}(}�l5¨ÕD çÛ¶¶Â¹*f¬—òu¸u¥øa ×ѤŽÛˆT„�c™*›¼ÝÌ…dSí#ŒÅq¯+µQDIHň¡pö6Ÿ�ä ­‡¡5±ŽIr!ânÿ&Ëm;��'zŸ-UI@!2àS÷9¢gT·ñ(FuåÊÝVIÚ‚7×4"kc¡*Ú¸V”=„µ®°À“D¤×ÃL|N(¤ªm '8v¿þõ¶˜ž~œ�?—��¿Àt:+Žô™:¿gA(}kÅl•�TuèqÛB¾^€+ µ- jÝ.®Rš?ªZT³ŽžV\‹*¢�J«è‘\Ž+¡k:�#ÛI€¨Š”z(ülËVè+�­®UVµ}¯ú–�ÎZZ»v¹ôa °?ŠÏÅå‡T EDç9L62e|¶øóš€lœy¸ ¦ŠýCø:.Ic€ËŒ1à·Þw'.Þ¸' F '’")!áÆî[nYWÌ̼Ät»ïÈW®¼Át»G$‘t¶�_?+ŒP%ÍìÛ‡b8¬­gÄG+Þô©4Õ6t0vm §ª¶$�h¨Ôšòe‰·‚0c‰:Œ›A¶NT¾ÞÔûÛ›$¦$"µÚƒ½5 "â•IáwÔ?ŽÛ0yn‘e“ˡ߰ûl 0[ŠÛ (ÃÓTêóMhª2oÒèÁ>é*¤Ÿ$ìù”FW—èœD58E€óV÷zi JHHHHHHHH8¡�I ' vß|óŠþôô*�¿‘��¿ØdÙ˜gGæQ�ü[¬ÉQ¿ PÌÍa0= EE$Q-ÍÝ�Ѷ)�ÎCªŠkB:‘N­+‘J‘C)-b¥[­R! 뜡@TÑ úÈ©X¢€lˆZq¬¦Êc(�КޚMLüó|m¦½HËO£^)wÙH( ¿’®ÍC¬•EÞ¾¥B®Å…âúì�§‰¬ªÄœ¯¶S»Ð\í/·ŸŠX¢âž�ÃäŽK½æ>Ve-ÙNwÂØlíòëù,|Y¤ø.i­z˜)•m5£Y©»LÔ•)ž.”Ö:zQ½½êÿ$ˆwgÝ.¤Ûo]…Ol༠�� Ѥ‘+!!!!!!!!a©#)’–àJzÃÕWwç|𼕧œòïùää›lž¯ÅOK"Õ�…ý‰Äô÷CŠB vÊe¶ •–ªl½ XEëíTä�J»a â“ê*íþ5§YÇ( TòíJ´9¡bgêîž *ó]\8NWŒƒŽ¯j‚iŸœxÃsXÊÂ&‚ dZHµª¥Df–&³+—SÇùî³A÷ïã„ êÖ­Dx‚Rf)Ulž;ëŽ6…§ì¡éUGBš@ÂÄ >¼9¢ R†&yý]ùkeæäyCôÏ€�ßwgÁ–4‘”�°„°ã¯˜ÜyÓMÏ\qòÉ_Û¸ñ“6Ï·kYÃ}ʈW‹ÙY gf›V­ï #‹ê•x¥ñù5�GZۈЂæ+VKôà«%<¡ÄÀiÆ:èÆ©‘j5Q½Ã(�ˆ^Du`RïH‚ƒ¬£ut6U2ŽÞö„f ¸ˆJS::û_E~³bì–åv ¼l×YÈŒÅÁ©ûnp¯×–I!õwmj¼­•^Q=•Ö�¶¨� —º$âc¹hªþk*>‘¨¢—å$ÀÀIÎ8j\oBBBBBBBBBÂqD"’–¸óÆŸ�ML¼/Ÿœ|—étök,�AQeIŽðR•ù3uÿ¥I´•L¼²F¼M‹•}LPVf“h»¢Öݱ샵-Îw³~gqÔ–&T55=iŽÊ0ÞVNQå/óªÖ¶ºz;q¸cÑ´R… ¾q½?�I¯<ùÄú­$žÒ=ý±[—Ó…pêOÀ bÅÄøiãó‘Œë)PÂ4’†…èÄÊ3WIØð0.ž^;W½‚B€¡ˆHý`€J5UQQ .4(vÀm÷?�h K‰HJHXäØþ’—¬Ûóš×¼=ŸœügÛë]nó|ÜC€GŸD‚Z^ÏóJ1;‡áÌášä§’aU½ªþÅqJŠü¨¶T^‹^s¿‰{µ ö* Ñ\Qýƒ'™j>¡¶¶U7.l(,“Vþ(å6Ë ôçt‹¸¬%êÊnAðQIKÔÑ9Q 4Qwùþº¸½H£ÁŒµ�¶Ýήåt=|듟ÅËv�åáã™ û©x-˜¬Ï{è1ÔÒ$²b}jª¯´©¹ mH¤m /«)J"RÄ.ÅÁS@söÑÎÁOHHHHHHHHHx¤‘f´ ‹»n¸á¬=¯}íßvÖ®ý²©ív×k閻ǎDÂȘ¤Ê „ƒ÷ß_‘lÉ&Šò‚‚hã–È ÆÅí}\q 4bT­VëP×f¯9„:øÚå ¹×žsbL AqAâ@±bP‰*Ù ?£@gÈÍ*öÙ%hWº™F99AàÙSÙß̲�ÌóÍËíÚØtÊ|ø¯ß ûü€¡HQ ®œæ¸E# Ÿ�%%K$RuIqŠ$ÝU¾<½ÁPê'NWÇ +®èοÀB Û ‘óÞù�ï¤Á-!!!!!!!!aI#I ‹ ;o¼açî[nùM39ù÷vlìg³nw«±6¸V�)‰¿ŒnC1ׇÌÍBW­¯K‰ •ÃË«�ÂUºÏO*åÚê%qá8ÄÕ×HG¹÷W$uu6]¡M3XV�kTvCЇ4ÅqOÄ%5JßÕ/Mq Û›¾¶2° ¢®G˜N·ÇeX¹íÞï܉·¿ñ7]æÖmL•S$u}@ñt&GdbeYÐ{Ú”ãrAA©bϹ1žÁt|¥qº¹R•TG«W9J§¿ä”SžÏ¸îùi°KHHHHHHHHX’HDRBÂ"ÁÎnX³û5¯ù¿ìøÄÿ²ããoÈÊ$^¦� ‰ä—Êák‚é½÷‘ÆQ!·¯âµ Œ^Ño’úï�C úƒ\×ò"Eú(ÂFÆ@m`£óäQ´?Ž�gqÄÏñ›ÈèïÕØá[ŽéTJ&³0™]±\¯™ëw=¿àCP×Ùs*4!!FA YCRS‹ δ„7�'¢{ŒHÂ[’¾â-@`H`7€ÇÀ¯ÿÑMƒ^BBBBBBBBÂ’D–š !áøb÷M7?‘�ì…0æi&ËNµYÖ�É#‡GŒDÒëiz�P17‡bzº`µ­)' <Â0ê¸ÁÅPjžIt5]þÍýî¬n¢Ë»iu•GÓ]‘ŒH.\‚�Qâhc b2MäÈM~®¥ÊšméɤŠ(É2€Ü½\¯�K^úg¡üL!x”!;U'2¨É#\&7E$Š2œ»ìe%+D[v&SæsChùÄJ]Är»UÏ*”òŽmW*휑r«<ÁÀü½@H£_BBBBBBBBÂRDR$%$'ì¾ù–SvÝrËïr¬ûnÛí¾.ïõδ�Îâ!‘‚å°Tj¤½5DeÓ 3-LµÐÞ‰ S¨A˜°rÊéTm´¨ƒ¥–/0‚Ù‚´:Фå#ú0G‰�F‰ŽF‘´lœr¤ó ºÎŽz|÷Œ3v.Çkè£õ· ˆ~QÜjÈUõµº|`EîÔÒ°ª¿Jv%Å“@ï\d¶´0ƒ%ujêN\òS¥|jë^�=‚áy¥H)!!!!!!!!!aé!I �0¶^yeg×-·ü»�e½Þë³nw—Éò|�Åp¸0™Ë±‚ÃÙY .�r¥‚…¨¢ˆZÍt¥4ÅþH`Ec�—Ä–åûh™^¯ám?á;"S^],¾l“&{Ô^�.äÆô… 8]m€±ödÛ霼\¯§—î: ]›ü€>ým­qBÊìt–�EµµM "&ê?�.ÌPrSHƒ²úÚPŠ*´Ê‘Q‚ ڂdö*ð{(àVÀœÜ~ÿ÷Ó€˜��������°ä�¬m �v¾êU+-y•ó3°ö|›ç§š<ï‘\øFÎ{�6Š‡ï¿¡€(Î�.—ÙÕÿUÖ3 Òˆü›Ê-°±…êw¶[åŽÐHë�‚F”·&Z_Ó¯”LD-€ñŸ‰â™�K\ëûËWƒÚpT–<&ËÖ™,Ûº\¯­Wþöÿ�Rˆ„Û ùdAÑuaW¥��úLQDXVÊ3#®­’ÿ)`hQê‘*oLighœÅM%�Šª%pMÒ¸ŽÒä,Àìðƒ4:&$$$$$$$$,5$")!ábçÍ7�à*Zû"dÙ“³,[Ï,³l,^Gã¸YÚ†³³ÌÌÀ6I¡€ÝðKjBFÄ�ò� ¤¢#(tZÐFÆÄBž¶]{ÒJ*‹þ�œ½Iç\�øÞáA·ÑB¾U$zÅ“Zºå¤±%]TN˜N§Ë,[·\¯±?ãoâg^üéÆÿ5JÔ®¬J!o$DÛ7õ´� n’ŠlÓí(-GÖÖR¢›YZ»ry_mDQ @à „9�qÝuˆ¢ŠÅ®Œo¤ˆNnwG0b¨‰"Ëò¬Q×t¿{7§Sß0Ú›‰Â]Õq�)lp.À÷Å – K ‰HJH8Jظu«¿úê«aÌÅ0f·É²Klž¯…µ|8 $‡€D:޶¶bnƒÃ‡AkƒÐ‘¨žYýV…ÔØd?DÚ¿ ç÷°é&Ñ* q‹zQ%áähK�«{Óœ¥Ž"a‘P‰“ºXœ.'�#Ö?E–9 ÛÑSÞ�WúoQ«ÐXÀ˜ËùÚ{鮳ð×w}¹��«Œ‡ï0u*“škVÃ’­s�DÙ2+È Ìð®È'1´DXV‡+%N ê—ŠïIuGÏœj Næw¤Ñ3!!!!!!!!a)!I ?%V>úÑfõSžòd“e/a–=ÝdÙcmFc~bH� (ÕH3û÷ÃXN/0jHuÊßéEm[ÅBƒ®cK—+ºÞØ¡‚ƒT&Sõþ†¢É­ëÝ+"�bcM™ N{#þ- a…Z´ârxHWá+`E.ÆÕר•¤=—IªoD�Uõ; AšÇvý˜�³ßø÷»–óµHðA�|ƒàù:AŸt?‰ˆ©„IH!( ›$Ê:mN¡D…†NIHİֱQo׿¯¬Ø&Úœq‚�-^ÈŸ|à.\°~gL–‘”�ðbçõ×÷Øë½Žä9Èósm§srm_û)DR­åÆ�†³³èš‚ÉlƒaˆÍš°iudE–0D‰@ÁòZ½MZ>/@àosF£ºLVeXòf$—oÄ2ÿ[‹Cjµ—  dphêîîêÕ;$$‡;Ò�HEQŒ8_CÖ¤—¸6“º5¡‰-V$“(ïœDÑä5fÍ;>~*€»Ò•)_Ì㑢λ O‹4A·,ûL@2ˆ�º4+©Nz‹,•MôV9²!LªØP BÖ2<�¿"m:m K‰HJHxØuÓM§@ä±0æ,�çÚ±±§Û,ƒ1øIìk�%°H“D:N¶6 0ûàƒ0™ñLH…¤1A€öˆceó—‘‘Ô-ޏ°Lš&^Ú'ÿfQ¬é/ê Ñ?pà›ÅÌÌ[°fõ…º%Oü¸}’avùg§„’Z¬¤R¢0J–RÿXsqlo“ÊTUn[<'Q}æÙZfvYÛÛ^¼óÌÊÞÆOų ®lkpOŠ˜*íʟ˺ä`ÕéË:……ˆ çVï ÎbÍUImƒ h$Vª§:³IÀiv`�w¦Ñ5!!!!!!!!a© IU¹f/ IDAT Àž›o^S ‡×ÁÚç˜ø»u?ÈpPôxïÌ×¾ö©ü Ÿ.ƒL§|5V_\  TÛ«$wœÔ;Uß»ÎM‚¯Û¥¦RÑÕ“&‡&Ÿ§ÚØX “å«–ûu[ª’¾ _p>È ÃºÁêÿˆ• ©î±F�‘˜( û±?Ÿ¦6ÖYYþ].š]¥.Õ½ ¬)Èsü!iàMHHHHHHHHXHDRB‚Âöë_6FÃó@¹Ütº—Ùn÷lfÙ„ Î>0ÂÒvŒöµ gg1wð�R#µå)2§ò]IXÇÍ;ÃFì§Œ*jDjû#-ÌqíT¯ý¥ÎIÏä„YCh±ÐÕ+{Á`zê{o¿ý&�¢8\ôû…ét t¨7YS BÚÌÆa#°Ã¹ÏÄßÞtÉ\“Tº¸¼ V%Qe$Õ½ê h-h̆t—˜)f¾4n'‘Õ �ñ6Z1ÝÏaY«‘UO ¢ñHo/ >¢Ü� ÊB'ÊÚ%p2 ë<�Î\BBBBBBBBÂR@"’–=¶½ä%[HžBò$Zs¡évŸj{½“L–u�ºum‹¥�„ôû˜;xÆÚJuã³xB C¶(Z}»•rðÕ«ó.¢ò—êÏÔÊXùŒ¼?ÈýEÔÒœšåaKBŽÚïððÌá™öþ€>Èp0U SWhZAÜù¡7­IðU$¬Ê7)""M¼RÊQ"®v|I‰¸¬%E̹fo“X•ýôälÛ¶5ƒ{îٿܯéq;1 ø-@¶˜ˆº&Cé@ˆ­{OtÑ3LëRc‚Ó–Ee]&Ü»I߇œeŽ%ËYOÈ%¼7�Æ K‰HJXvØ|á“ wîXÈvcì9&ÏŸa»Ý l·»ÕdYÖ"\,[,&KD0œ™©lmyÕQ#-UÙŽ�­-±ýM´ÈÊG×v±Ú®%*Û-½Ÿ#ÊÖæ—÷-€0ƒ¨Zóý� ú×Ãÿöo¶öŠ+¸ïC ‹)é÷ïÊ J\l®î-ÑΨr•ÚÎfMj¡GM4­¡âvéUJÕ9(ë‹Qå2°ö¤Î†õgî¹çcËùú~ñγ𮻾ŠòeO4Àdع<]W}ÄTÿZPPýIiã {nuý MP-°Ü�ªŠæ"CV<À{?õÀÝxÒúíi�NHHHHHHHHXÔHDR²À¦ç>§kŒÝ&Ƭ£µçš$Ãá·)ô69pï�õësû÷îÏ&&?àA³¨ï‡0J<Ø_lvÒôZÞïq9ORÛõnÁJЉÓiKfÙ˜Éò­éÊ÷§Àwìd¬jµJk&&!IÀ*1È3b}UÊ� ’™ï}µÔ¬þÝ…m M½Í’Œb•›$`ã§qÏŀܚN[BBBBBBBBÂbG"’N¬»òªÌÐLÐrÖ®¸Êv;WcvÛñ±‹l§·Þv»f.8²ˆ«$5Òìl©F²­ú¥ÐhÊ~¤Ê*‚²œIµ¬¦__+ê£^º;u‘HPõÍ­àYÿ¿S†¡x‡-Ûm\!Ƈß6õ™Ï¼)n‚á]wÏr×îÛE kÒÁßN û ?y¯žWŸxgŸ"¾rtê{ ‡ªJ§I¤šÒªª½k ­YŸFàE;ÏÀßüàkðuç£ÌI*ÊS¥UF�ëÌuJQ”eÕþÚ²F˜’(2š` ´FUL;a‚ﯠ:s©È Op맸 O\¿3�Ä„„„„„„„„„E‹D$%,i¬¿òÊU&ïì¢5§™,;Ïäù£M¯ûx“w×fcÝq‹ò_äb•ýaÑ©‘Š¢¬ÔV«‘‚1ÚŸF›ºÅ¿NÈC¶ Vj¹]ºËHÚòÕ¢RÔ±Ê7*+½1Hµrg@8âuG ùp0†ÆN©H«R»çímr.€¿úÌÞ»qþºíéä%$$$$$$$$$")ay`åO2ÌrCk �1,�!­ÙA›�n¬=›6;Õt;�²Y¾ÁäùJ“eæYk�1&Ëccá«ùåE¥7;³�ò›¨µ6æ¯Wœìmmö5‡êq•©?5f¶Ñ4eÈKEа²¯©€ µõyÒ¦,yâL 6$ÈL ¬tZE&*2™žð©«œ¡ LM½gú‹ÿöþ#6GQLƒá�Ù�¸z[ލ¿Ï,‰Oâgm«ìp ɸš~pj˜XNuÉ:Í'PÐD4i–M˜<ß•F!ƒâ^?p ÂÞáˆ@DX© DŠŠ¨&Ù2ˆ°tÅ5âºkW¦*œÞ�/A)xª¦ú´W]¦àÔEgµMHHHHHHHHHHDRÂÑ¢BV_tÓÓ,ú}Ún×d““+ií*cíi°öT›ç2϶ÚNw§Éó6ÏÇ`�e–c-�1„5U5ãÅ*’Ö… [눪™Ö˜ÌÃ#©rôõù£ÉÙ85Òñ ŒZ¿?1œ�Á`z¨+µùRô.,[âªT.)&p†¤^¸Èn‘9Á›ŒØ¨†Æ ’Y¹°wͦ¨‘€Ô©3‰tV1DêÐmn»íWtކÃ)Å·žlˆÞƦØÌò¸?äx‰ÀÖFí£’F{éü¥úo %Ç×Ý™Í×m–e̲ÍixÒCà»Î4äšÐó(:•Ý‘Fõ4¡¬±V9¢É4ôf:^…k‡$·ô cH³ÀÙ¾˜Î\BBBBBBBBB"’–,a�|–��›¹‡âØÆ�ÛiÌvÓíœßYµj»éõη™]oº½õÌlÇd�œ–´67°†4¶Lª5F)Eš«¨šÓ�³ò0ä‚"BÉ 8Ä×Fg¤Z ¡£=Fû{$ˆ›ÅµÂ —j$cm­:òÍ$�Eq¸v¯2¢Fš*£ŸçŠ«‹ø0îÚòW�ÿªú™>­Ú΄§“Í*m$úÓSûü?¦Ò$Åû§d|â3\낳¥ÙÕ.*⨠¯[ItÁ�ùQd×…­…±vM¶J¼pÇcñž|¾EÈYJa`P¶vA �cþªÔ/C_œ­:G¡}Íõ‰B¤�JÞT½ß¸:mtVÐJ‹$®"œN sÝO¤#Í…¾ø3Ϲÿú÷ÿ˜NbBBBBBBBBB"’%Q$k.¹¤·ÿ£�YùÄ'M˜<';ÙVcífÙÙ¦Óym¶Ãv»§¬8é¤U¦Ó™0ÖZf™% MfË kcªE—*O�Lo ¨×ÎÕ"¼ZT×k8¢½„„ꌀìsä‡#£¤V¢û¬Ž“2‚¢Š+ñQ%R³´´ §¦ß1õ™ÏþùB›eöž»æº{v~¢ÎbRuÓ‚¾(±õIÐWQl>h›a'®I$Æ\•ÿžÍ’taSII$ÁÚµiXÓ(0Äà[òÉ`00€(¤0%¿*…*bÉÅÊK¶ÍÒÚV‘H•VMÛ8E 8Î(Òã¹huzÁ¥O#MÀãà·ÿì­8ÿïSNRBBBBBBBBB"’ŽÖ<å»ÿ¶�×\zñšý¹uÿšK/Ù=·÷�û»›¶\J0·½î回÷ÜGÙ¼»Ãv:k™çã&Ë:´¦Ê:²(­hÖ¥‹Ä�EŽ,b.Ï%(�=‹—¨Ìº Î5Én'd9Øôþˆ„2™QîµG€àYT$RՖÙÒÖfŒQY=Žú +PäC}‘/^ïˆCc¬”3ê'מ‹a•¿äÏ£#ŸX³‡J…TõÇÈ{)Ð?tðss{÷ýþÃ>W�éb8€Éòà¸ãu•5Ä/ÇTiÍ1Tg©p$Ñ›S9ßZŽçI;ý»&¯Òìì<ê´Ýsßþæ÷ÒÈçntù€;<@O1@ˆ)iÓÒWNWVý·¦Kk›ªÁæÇ% –tg£*¨ç{«ÊL8–¢ àQéŒ%$$$$,¶úgZþËpB>ŽCXWÿ+Zþ Õ¿(˜��ˆ¤„£J]|qwÿ­·Î®½ìÒÍû>ü‘¯{ÚÓž2sï½_·+Ç^³þÊ«$ë]ºõÚk7±ÓÙ4¹ç¤1c³ŽÉ3Kc ÊlìªêµñKžZ™‘7Š�ñkg]ò<ô¢¥�)È–¦%*ÜŒ@/Áˆ�…ÒAÐ*¿6$²â[U �µœH$�>\lÖ¡Úaü�?Ô•ð‚sÎ0«Š¢*¨©R÷e_Ó¼�Öì¨Jkµ2‰‘b,ê‹ÚþÅèûšššúó™¯}õ·Ý>E1�ÁP�圇m ýãËlŸ"U_G4IV3^k£f•´Ú¯¶ÒévaŒÙ”ONœ=$" Àµ;ÎÀßþàkð}‘â!�cFy]Œt#jò[]ÂRYת³R*’¤€èt77ª‘¢"§Ä»6=™dÂ!OXšäƒ*+I{ ÿ–Î^BBBBÂb™=VëÆš4ZyÑE§¯Ø±ýEÙØø¹Ì²­´v%­3d Å?öOWZ¦OuéZ��"¥@^ †"2�b8]ôû{‹¹þ�ÃÇ?¼ïK_ü»™o|sou3(’)!!á–Æžpnžu;vxpÚt×®];u×]Vœ|òÃ~ÿÇ�ÉɗØ ÛížÁ,_•w;«�e9�ÉLžÐZ[(2ê©zh© ì9>¶mÅŒöRäž5ЄÃ|�Г a±_h#È:’–}�&‡t@sH:kERQ‹®Íîß�™}ûÊÜ*u¾}Þ�7àðöÀ Ã(:%afQxn%Ú%>álïzmÇ3`vß¾?}èÖ[_ý“´ÍŠ'=ñÔÎê5_ÎV¬è.ìrt†Ò iâKÆÀ¸¤Jk)’¦!Iš²q ”T»�´¦|ª8íòCkJ•’©LºBÒ˜ÊäæJÃU;,i{_ÃO,ià�¼õ¼uÉÚöÎi8¢¯`ÿ}¸s#iùY0ú)»{Òž°8úÇ‚Æ�¯=œþ±Ð¾!HJ‹„Ås½”¤ÑÄøêÍW_óäããWšng›ÉóqC“ƒÌXÞøÊ[$ÑêNPÏÚóÉÖ©žô+)\ާ@Š  Îss÷fßvøÞûþxï‡>ôUE0%õRBÂ1BR$-b¬¼àÂIô ÛÛ,Ö‚ÍùØØ9 VÛ^ï‰Ül7dÝîÖÉ;ÆM§3a²ÌÂË,3$A¢&ŒT¨/š./¿æ/Õ �ðdP=üIZi¡zñÛô錘…»àeE-Õy<â�As¢(%á<‹üˆ0ÒáÛÇP˜´ÕHÃÇ18|¸.aaí¥—mb'{ºíõ®bÞÙeó|ƒé䫬Íz̲.²¬ô KcHgC«‡uÃZ6®U%(Î.- tQˆš)E*‘x •,�Lõ6BR©±(×vøL$;Sª/Ü»…ó5�B«�Ô™oõ°XÕH³û÷7ˆe žµQ€a%3M"!´Öç|D£ËèuÚB¯úÔb8=-³{÷þÂÁO|ò�~Òö?÷ÜÍùŠÿ»·aãc[ûU4¤Òt_åBȤøuiù²=!“¹>f÷ï{ç�[?öÒE~ß‘•—\r…µöJZ{¬ÙLph: ä($™‰ #aÄ”4ŒSS–&1ª­öeÈsÒçêkÇdðy6NKã¼1¶Ï X”�úEQE�1ke¼ÛÁD¯++ÇÇŠõ«W wmß:{Î9gØuÒîicl9j³,/ÀRëä$‚wxœ¿>ö|Zûc³“`Ìz�ãt@ddÂÒjaH#•¦Œ¥>LuV$yD;ès.Q D¶ÓçAÖ?”É—>ÃLG›Õj]UpA†CH!b /E1ƒÁð@1Ü3˜™ùðÁ;ïüÓþ·¾u7ŽécŽbîÊ�Ï~Î l'¬}ŒÉí&ÒN‚ìÌÉ ’�°¨„…n!\�#�Ùptßsàêé„›—ˆ2y�êÁ�ÎÒ Gh³;¤( ¥M§�a1[ÃCÃÁà^™�ýÒì¾}o}ðÖ[?¿œûo¹…ß{ë[�’ø¨CNþÅ_äo~ó’»n×_yõYã›7ý™évN¡µ“´&3Y^ßc%òèaÒ¥ ÖÅQ±äÝ>ãqäuÝqõ³µ ¸¯gjÕÁE]¨",R@C à ‹Ù™Ÿ¾û‡¯»ÿ_>øÅÅ2¶žôú×ñ»ôÇ‹µ?',§üÒ/á;ðËò^�:í"Äš‹/~ÒøÖmÑ]·öXSN‹�“Œšh!£‰„åÑ)Aî´&Š#V{M69ùã[¶<³•À‘ˆP�#©Úš䑸¨y;–_¥J�Uý}X`îÁÿaÿ‡ÿõYǻ˭¾äâ=̲›`ì´fÁ•Æè@¤Sâ¦ߌ¯ŒçÔlR*´4Cm©œŸˆdð¤TORcÂ/d­G‘ITC�TcWÛØX‘Wn`+†�Ác4²frBÖ¬˜�í7 ÿ¸3§ŸxÁùfy”Šªü,€ïŸèDÒúk®¹ÖXûLZ{:­Y'‚Iˆt!Ò™3ËHW ”&$jÙ®¬ïêÁH¼x‘&wÐì`m¯vÔÏÝÃÀ¶k’ €”›—r‘å©HQ‘á@dND¦I>àKÃÃSÿã¾üã¿,·ùкk®Ù‘�õn1Y~�ÙcVƒ  c¬1eÅØ¶B êÊeÔSÚ,6£ÆdmñŽj{Ä·¹æÜ‘}»½ÃÑKÜ4?]u3 A!0@Š¢/äa³³ßëOM½ý�ú§?Ç2ʆÙñŠW¼‹Yv E&Ý ®Õïê�¥¾G´Ýƹ�[6õMgt?¡µœþá?zß>péRiǵW\±ylóæwåccgr »kŒm-�Óz߬§s>ã€Áä¬ÙR‡dä –}¶šOŒí§9Àó]2ÄpfF<$sswÏîßÿ¦½÷½ï:Þça׫núÓé\Á„¯cìî*úÛ‡`—T&[MJ5ô¸b6nª®;�*rƒª˜ ‚Æ�ùOôp´mE¥¥ a•eŸ¼]®b5‚�5ZGJÛºUFL¦ã¹\ó8üAÇãõÖ‡ŠÁw½ímf¹rÉÚ¶Ù=›m0™]i2k˜ç­ý8¾Àƒ� ‘Ÿ‚¶RádG) E)já/¡f¥eJ.Ñ$\Ô�é›RsB ³’üà"jNÀzqL5‰õгF·Æcº½è*µÌÌ`8;Ò4¡÷1?wRª15òû‰„¢ŸVÆ–ÌŸ#‘HÁã'd |bù·þÁƒ §¿õ§!‘@ƒYÅ×<Q÷’¸R[ 9IÆç^"¾×o£Õm¹@ Z¦ÛǰÜX�ïƒc¶À»újîý§XuÙe�¡17ÓÚ'’f³PV (&HÛ¥µ.N¨Ñ¿‚W…Œëàðö¾äU_ÊÖ˜�²öI­Ør ÷8á­µô^K4—Ãü8âí¡íŒ—̪£ÊsÌø£Ù9þxv_`¿ý__þZ·û—ïZ³aÅŠbë†uÅcN9iÕÎ=ç™»OÞóÇ'Ê}m㳞u½û\> ÀZ(dÜd6sd‘�0£†w=~H°w×_˜Ë&©�i-vÑv7[µoˆï–¯âŽ‹º[„CÉ)5ñîlÆȬ…trBÐ…Hàˆì�±ÏÞríµs0ö!Pî*fúï»÷ïÞ÷;K¹OlÅ+x÷Ûß.°éy?»Ó˜ìç˜çO±…ÀJˆL€¦Ë<§±e-DG8»ÛDÀ IDATœ’ µtƒlfL ¶û503iŽþï£{‰_2Š·ðó$w¡*² 40´`I6çÉ!X)ããÛ²ÉÉ w¼üåÿ½€’þà¾ÁÔÔÿwßûßÿë:QçÉ&ËN²cc“%ÁLó�ˆTˆg5m ÝP�8ê,ÍÏ5 ÛíN.…öÛrÝu¿ÜY¹âh즬ÛËQ=Ì jðFSäÆ ˜×H8÷Wr%‘È–_ÿ8ê[Í´„-Zn†b?7—B£æ ‚Â$®Ð-ìø¬ÀØØcØë¾sÏk_û§2Ü=ûàƒoúÑ{Þó×Ç¥?Ûì¤l|b¢Ê(e3‰|ÄÌ©®b¬ú¶[;©¹ ãy5λ$^1P3oúÄôúÏóUn®zÝÓRø9²Ðßg¡p€ÍµßžÎJ쥨oõŠh«ûÑÒ­<– —w¶{R$-B¬¹è¢Çô6oúËñ�›Ï5½½‚™í÷¸†D?�ý´& wÕâ¸f�[ˆ]2ÌCª€Ö‘[šŠ ý94Yá&£ÍF¨6•„Õ=��Ö‡jD,¢ASsÙþS‘H‹W�4LPãA5¼—è'é-} ó>çÇžûE7}ÇE¶Ý™ ŠÙYÌoæ«_ýÜ1«žþô[ü<Œ9ÍdY-ö‚o¡,!îi™ÍÚ`ÒÒ’�ÕxüÕ®‰û¤ñH8ânÈ#]JáÚ¸Ð8¿á˜'G¸²A¿˜ḣýþßÈÝ?øí}_þÊK›¯{áx17÷(ä´vÜ乩þ'Ë´p¦#ž8’lLæÓËùlÒ1AÕ "æw°:·ž£h_drI�Hh,“$Z¢ÖOkEŒ@úÎÎÎÒÚ½ù÷ááÃÿù¾|àÖ¥6ßÙú¢]+À¯‘|¬ítÈ,#çJ-'©‡]6®Òv9KëqÄÁµ‘L¶Ž]þÒ­ŽSØ|ˆ° )·[$—}Ãÿ¬R‘Z8ì­•½¶ß—bn®/…(úý;üÉýüàŸŸHóä]7Þx›¿eQƒÀ<”dø°B+Þ1ÏÕÞ°Â.`'€©{îùì�þöoÏ_,í´ó¦Wñ®·ýO÷Mòm/}é[²±ñgÙN¶Át»¦ª31ª\N«­›�‡ÆMš.ž·sA÷¿PÅÔú´YTiŠˆ¨eãñ:ZçS­GQ3ÓE1,ýþ·gxàu÷½ÿýŸy¤ÎÓîWßr{¾bâÒRæY»ì![îÿæ-€äÍúµQóÛû±Syë¡Z®HT©XÔÔœ�¹qìb�– 0Že §-ÖÅØ:1rf�˜-Qnž¯BIË0÷�á`€ïþ·ÿ¶lù”¤HZ„:ôãb°þ@!Œº �f:‘ó$û¢x!Ê O(Þf¡t¢…¶‹\ßœôQ…w×*•¶¼¤†Ò@OŽ›s<ýdIO†ýÍ"°SÕì± I7·eA[’gÑ©‘D0˜ž®ÔH¬Ï¥Ö~´å_ÕÓVšKg‚0Îô9Bþ‘´üÒˆ´ ý�ß/J¦ÀÜC}l8;ûö£Ä ­ýޱõN]UŠ`«SUÕ=MZØž`òªoŠõÊß´â{s[8þ|�ã0vµíuà˜I4æñ¦Ó} MØ'(ú UäÕ¨íkÑXA?ebN|t <çY›µ/UJc!&ó¨ÔQÄb3A+ߥŽÀ�¶´èÕ:&Ë/6y6ÙæNðŠX4ícÒvÂȇ�îC¢:†®fÈ×%&ªÕ1óZYrAÙi$¾S»ñAšZ¸f¿•f?"õ!KeMž÷l‘m/Ùü‚ì§áƒ™Ùÿrÿûßÿÿ.‰ �µgd�î™´£k®ú娛§øbôÊ$„cpƒ¿]H…Íx× WQW´6úüF£Ï®ûV®Tœ%Ì{M o¯TOéIÂv{´Ý^"ëeX¬ÏÆÆ¿í¥/y³ ‹»æzÇÿð¿çö¿çç~Žw¾å-K6Ûƒ­Eám?Ð ÌÖŠ㇠d;ÁÍ–8ÈèL.¶†¼ëmÿSV?ýª•›×¿%ïõ®4�ÎZÓéP“ ñÙï�‹ÄwΈTÕùaÁ³€uv<¬Õ.ÞöNƇÁp-Òrß½EÚ"›˜0VËPž`óü¶Ý7ß|_ÿðáÜ÷•¯ýJÿ‹_˜€“þçyÇþ¡ƒŽ¬þ+Íùs°œi¯z(Ä@¤Þ�¢O<‰£Ö ï‹Úš¨ÇÖZíÕЫEKA‘Ö¸Ïø$ID&ùJM¢…Bl§™ï!‚jgQùÂÔëöÌM¶¸á$E&"iâྰ¿·uÛ7d8¸@Ö\D†Ã‚`žkG$¨z¦ã¸ü‹¶Þã\’ ª{£¼{L*Œ¼=Oö%š$HÃñÛ´?·?ÕÐ7?DÄÀ2HôÂ`ú0¤*cyAv‡¾è+¤á Ôª¦ÖŽÄ#Ý9%ÐáPt”ƒ~¬ë[Õw0C šžþ­éÏ}îÞ£HÎÃ!Le- ( ­œi|¡ya™FhÉÑ‘Ö,øfQA6)£š•™g–ŸüˆPD�Êp"J L=æ¨ë˜�e€+>‰ÆŽ@·"Ñܱ�„hL%Z–—Š�f^¶Ä^´Z=õøÆQªÊxøS3½yìŒU:(&Ï ó|äÉÅ ÿsœÁ 믹æ{2¾cï?ø»îó[^øBþèÝï^LRH”i§þ<{Gê§þ­!ÕÍÆ©;€H›ýLÂŒ@uaË#˜ú¯ÁÉ“‚hæTD÷ªZ‘©Œ|¿U¯Dý@ޏ®ŠÌè�/b�MŒe7ˆÈ‚ïÞôüçÝOš¯Í8ðÚ}úз1 „ Yfƒ…-Š2"ZõŒ˜ËÄ9�GÈæÓêgÑs¬XÝÂÐ�[œQ¥Z´V”m,¨ÔâNF}›pØQëQ‡Êq"Ak‘MŒ[`|¥Å¶×û­m/yÉA~³˜›{Ë�oyË;–ê¼ çç-Ѽb´èé�/“¯;£:}QÕa.:Q·½ô¥¯ÏÆÆÞ`{c›m^ªýê"”pÖGmÖFø q>¥ùv QevD.YtåÔya”ˆÔoðóm+¼mÄjUWñÙ= ´TÖ [±"°ÍŽ�ݲõüó^\œsÖ—úþòø‡Ÿ=6§Jâ– –œo¼R¤6Û”enžÎ èˆ¢Å«�EÍÐôØ*al�Ÿë{Z5Oo#ÿÝ:T¤ñ‰c<ùb ÷ÆàmÒdZ�<°[›™,ßvì;—7X2¬”­oÞ±³TˆÚ~D•¿à¦;¬3ؼáKµÝ¿ÚÌAõlˆmb¸¦¼2jªçÇ�àz‰ùC§üÓ %Úž#¢<±zbF?±�±™±““ÙÄäcm·÷Ÿ×]yս뮼êý믺ê,G"myá ߊFuáš ©U²­ÃfPÔ=*¹6�öŸjL¢qïd|iXWˆQ9n ëb4c别/sÍx…Öþ�JÜŠ�jJT·Íò}v|<ë¬\µ%_±âi�•+¾¸éùÏÿÆÆg?û ‹r²%Ñý¿mu!-O°�dM—±²?ðÈ÷ãQ­"m%Áòh>Ë+¢”àH>¨±(¦R,˜ÿ¤>r8éu¯ç]ö6ÙôÜçoÚþÒëßjÇÆÿ._µê ;6–•�ät…®8—’Œ—Ú~,t$Kpk¤þÒîá¯(v�çÎÍóUÐà�ø-²Ü/ÃÁaÅ$¬Á|²»Qoªúh¸5â…¾4)‘ÙÉ ±¸'ýH-cTûR¾Uáh—-”“_ªÅù*óÕÐà¨éé1¿òc6Rêй~e>: $-$å(3jP¯Ù‘Qî�jnͶ0jÂ@ð¦�³³˜;pðW¦>ý™{�Á‰HpX€ƒ>è{¨í„Cè&áÍ�JZÎ0TœZQÇ๙^Dµk‚«-Y“ÙU�QTþÃWšD`hPaþ#‰àf%® :eks„æXAX5²µüˆ,•Øùa{¤㱑GTðù5tæ¶¥ÓÂPÿ4•¤�˜X‘OÌÌ<~Íå—¿ÞdùÛ~ôî¿yûâYö…}%Ž¿Íù·=am±j;›ÎÎÁmðÜP ’ çÙ…« 3‘×àHãRåÕ½Sç¨iUŸ¨LŒFP5 ™N7ët{;e8¼ÁÐ\Ñ}Ö³þu8œû?ï~û_Ü·HxÆüQZ*3-�te-¥,øcŒ³¶t„öd $Ú)GnQ×Døp‚�‡òŒèxE¸-xŸ³ÌtÖ®YÁ5s$µê‰Q`¤Äó2rVnۀƬ°ò‘^ J­›˜oÊß²À#ZÛvtÜ#‡æj|þU§ò‘ó|7DÖir‰1ß¹i…ÒºÝp0åè!måº@`{½±îÚuO¶ÝÎ_ó´Ÿùкg<ãiDZ'¡ËF$XÈÈÇùêF&QÊ0ZÔ”=-Z«®ÓÉ6ÉB[ÿ«UsÑc.€¤'Ûí©-1#"‘z³e-J`û ]⇭egõší�µk_–uÇ>»áª«~oËsvüø­}Øèl]ÿªþR“¸Ò¨^Ò*Àˆó,†�Ñç¯íÂæ¨£Œÿ. 5)Íŵ_ÿŒÎ•l½j.¡Íó:«V­î­Ûp•ûà–k¯}ÏÆç>sãâe�¤ÙÞÁ÷acöÙ~Saëµü$ͳÍx.�ìbaç 7¸ÝeÛ^üâ[²±Þº«WŸióœÚš)Á²Íê¦ÒFú»¡Zç%µx›åK±PM0TÊwÑ=ŸkÌö½Œ¬p+ºˆ�SUÅäI˜õ#óô•Zym l·3žMNv�ý¼ªýÜÅdzÛ|” `î%É“âzÿAÜ£Z'Hp·÷Ž4-Çñ,–G8¾ög‡Òœ;q”;… ú5Ÿ?àˆ‰Äà ë‘t‚¢{æ£'Ö?ýŠWöÖoø“Þ† ¿ÒÛ¸áÜΪUc¦×��1Æ�ܱú)®y¤�m87÷ öaX,p¸T=Ÿ�†A]œ/þ£…õ g¥ i£êEI#ŒyôŒ¼9!r'GçeíeoGÏ2ˆù“ŸOlA"E0wè†ý~ki 6k™(/D‡›˜Úïb*™º•Ñ’QŒ$Â'±~‘ÝŸš>Tž¹eêsŸŸ9mWH1(†ƒ-ĤM£·ùÌ•°7’ÁÔžr%m]¶a^YÓÂ(<¥:VcÖ��}öSŽíG×oLáäêY{Ø{p{`†,(�‚ˆ³M´v®…ü›oöi›Y‡V¢V›5Ý3Êr$ñĬå;rþQiTB^è0ð#£éöÆz6<��Î{×\~ù;×\téñÉûk™„µ»Õ8ÏäŒ#‡r‰{£ª®E:uÒh߉ˆ´3À£îsñ÷Q _¶pGî¼4 )}7rvMAT¹´åˆê쉶煮†%:h ;kÖìì¬_ÿÿ³÷¦Q–�W•àÙ÷¾÷âEdfDΩTJò [�G,x�åIxÛòˆ-(0Û‹¢ËU4]Í*ŠUUî.¨ÆÀª6¶UC6¦�)§CDWf¬W2:¡â­‘~ôGqË»ÞÅ/~ñžC¯}í¯vï~koûö=RF�&¯Ä­î�OÙfyH€“ÌqÀ]ç6ÕaÇ%�ìh`w€PÑh1 øGÅ2JÂpyJ;æ7ne²;´ÿ~¼¸xó‰¯ßò«[#¹~3¬ÛÀ°óŒš!€¨[ãâgÒ–"ºœ´g‘åà²;¾R’LÐz„ ÚC?×ײ÷£c}è‡kÂà:‡œ–Ÿ=ÈgÛ´@Òžë®{ÅÂîúOƒ½{~avÿþk sÅ``eòËèõ.)†sû¶úóÕ£•{9ßͺ®»Åÿ¦ü…¦Ð#¦]Ü t$åZëT¨ó„jʨ¼!mÁG÷oÈîb>‰O!_�öH”LC¬­§c›êÏ66ÒÒ’Ô««.¬w$µšâ`{p�œÀé2ä@#3S3 Øß@Æ�³oS�F²røÞŸ_=väîÍCr둌«¯‡@ggÚß}þ“Þ}Ãk^¸{0q´b›T1n²{O e¡(¶ƒÁwnnzƒí¡Q�¹m´‰u"�ã,ì„O´×m"úœB3_èœò<›ýNq{ ôè€ ÛV�: é[’'€~”jÊä )Ò››[îÙóZ™é}r÷u×ý«3"ÁüM­R| t?"m ¯÷=1d ‰„f:”·¨ÜŽ�寎k™DjéY°Ÿ $ ¬Û¥(­¤È{Ê{Q¤è÷ËÁ®]W wïúÕ}/zÑï{Ñ‹®¾õÝïÞòà E¢?¿Ø°¥ÑÄ�ãSè­DA³3+h jl ¯I´jð¸}\ÿ”`جPÚy´ìmF…ab±CÉ’ëK€jÁH ÷íÈpÏîvàúë?vàú—¿ìlÊãM¾:Ñi‰LSÃßAž›$†dÎþ º4¼s—ÆÍ¾ò†7à–oäÁ×¾öâÞüü;göî}c9˜äwE”p;ÌX¦Yðy»”eÚz‚Zð>صΔÙzÚ3ÈtË/G¯íç6‡Êr`÷Ф‹9tqœñG] ‰/Þûç=öÐ7¾§e1sÊ�/à�u3d»õ!O Õ}a¿ÜgK˜nsoïõIŒürêI£�•AüÊUÙ HBÜ0 *–’wŒ{p#I: i÷µÏ~äÞ¼à�ƒ]»Þ2{Ñþ×÷ì9TÎ�²ÌCFQÊò"å–IÇ?õé{ëÑèf©ªÚ€3޵‘I~¹Ž‰�ªí58L¢Ü$kLpÙ:ì"GË¢¨é@ÇÙ01¨§þ@t¶»]Œ¨Mh�M@(²rì˜Ôã±�A„äÎ#ˆ¦äu¥1íÀ¦íRÏ™7]¬äBFUqÓÃ'ŒûÒ>AX9räË'nºé—Wÿþƛ䮮®²®¿‰Ñ#¿þ¦Š¬ÂNX莗X…ôMDØ �ÕJ¦Ž*Š™²ß{ئgòÊU‹­ú«�LŠ›è½´%bp*Þu=‹Î„¬ýMR@çÓÚ%ëVù€F“œÓ¾“Ž}ᜭÝx8ÙÄ|ÛŠx}å Å$)3{ö\5XXø·;Ÿõ¬÷Ï?í©oY¥ç„õ’Ž â1´ðøà”§(‚Vcmò^ЀòÏÕMÖ´¢‹ËЉ3–?sÀ\ð½Iš;¥Z¦ŠyO¾S`‚hXÐújÇÐ�ÇSñ¸µÏ^ÎÍÎ xQ97÷á]Ï~ö›Ÿ^òC['ÆíYH0N–µØŸú¶ºtSòˆ° b< ÚŠv7õU|ö™ºJ;å,AcL GLÿž]HÜ”àÙzBš¦a¢©kcí«Ïɵ5“œùhälÎ3-ãòòvT5i s¨'ò&i†lÏõ䀤óðšÊS‡»Ÿûܧôw-üÊì�¿4Ü»÷òÞöíeÑtlJJQRôz{Pöö�‰ÏLòžº®ëT€LÑé°Ë6%>ÛèT„g& ÿ®´¡‰qÊmN�0 J¹J�±Wæ:æ[sŠ5•ÃÉóx¦�ÑÒ¢p4šXÛ„Ê”TÀ:ø?²æv›35Ô'••˜¦:ƒG¥aNÄÝ>]žÕË+ãÑ‘Ã/ßì[¸ü·;¢È§Úü)ÀºæÑ1í` ÷aº§�H0’Ñ:“DHÓ›|Û°Âʲ@Ù»h³óx›´G|2¶žÖC‹¢4%hÖ˜—=l׎™N¡DîxèÊ&³§¤îX“�pz‡Ì‚Ö�“¤M… ®cÆÙ¦i4å15hñ¯LvEoö¢‹^SÎÎ}d׳Ÿýª­� ºùhÇ„Ì'æ4@E'§ìt­ñd5RÂ1ïÏ yà,Ìó`쓼š/zÝ9•5`FÛ@[Ю€ÃCc¿ ÷IB \Ó(ÊߟŸ¿dö⃿²û{®{Ïþ—^ñ·Þû^nê¨$4÷†E6²‚ë6´áSº àžª4“sf«Ñ˜ù™=[½E¨eKÌ­)¦¸®Gg}»ÇL0Ñvæ~€$¾= áµë&lض˜V«o°cûüìÁ‹~bÿK^òÇ{^ø‚ÇŸi ] §¯«¦5~Àà)i‚¶Øü›¿ñvzí÷_Q¿7³k÷Ó‹^.ذê;T:�mCØç^t£êFçrÃÓå9`­¦Ђ~´M”Éz‡“G@— ™cvƒ¨ IDAT A�›7`'†˜0~éö/š³ºÁ‡©GÓxiñ+·¼ãíïÛŒüÝ<œ’ˆtdž.´@"—‡Žç…“Û„öw�ÌE”@’6ÏÉ…Ù‘ïÉ)¹•ȶÖ¥‘‹Ð{™BCJ²ØÕ¦3bݶÃÆE\û]m;ÿ¯�ϼfï`ÏΟìÞ}ãpß¾çvïÞ^Î Í®kr … WÎe¹ãL|îj4ºµ�—È:f¸Õ2 É‚Ñ$`°ð�žVM15~ê”ëh¢PrÁíÈÂ9lkJ­>sÚž5S\ʅõ}Mm:tIÎã�OÊÊÑ£‰�dÐA—4PHæâ0é.дª�M0¦" ¹Ý6ºkƈ“@33Yˆu-Ë÷Üõ[‹ŸýüßmÅ],ûý`­Ò)&S3ADË:LBŒœ@wÿ˜ëD’ÖH3Üöñ%}¥ ßßT}7�LRÐaÆ‹¬9Þºð6eÊàkŠs~4šfV¨bq¨ÄÄ<:P ¦Ð4µž/1‹ÓvN¯%=,˜ðšA*Ý| ”¥Ï1³wïcûóó7î|ö³v+ò€ôöT÷ŒÝ¸¾>„TBM$|háîÆ¥�·A¸fs´qÑ/PÏh n- š¦‚�hýhM R[ÜMX~È4 £ÇœDša:AB�Ч… éÍÌ ·]|è‡òG»Ÿ÷¼'ÝúîwóÒ×ÿÈæ”ÊŒb%rð͇[LqÐ2/ ë<´NÙÛîu“÷$ÆQ66•ã—‰�)zIàTyùv„¨ã»fƒ¦¡tèÎÙÈ>›fÇ’ÁHwÙvé¥/œYØùÁ/~ñœI )¹¿²ÍýWΉïÁ�lu´¬,B4^Ü®�Ÿ­.Ûf_¿êÕ»Ëáìgv.<¦ÑCаIúó%;™£´§[V†c7,“ìî,¨lœk„µµYl¹Òm<¡ë!ßPBf3Ôâåmß(³ ñÌ�h¸Žu%(ð…ÓCÀSËM ¤¯³·í¡‡(‰¢«MöégžØÓMÏ4”œÝÓ4@šý„ 1è¥%à»ÚŽMn ¦|�Krt�>4t«€™7.Içáµë™Ï<ЛÛöãÃ={ÿÍìþýßÙŸÛV@żÿ¨ïeoFJì>3x@½ÈñxEêÚ$®¥:½óâF j¡…XmjDmbCkШek©“Õ.Í&Líé8æS”üÐcÈËj!¬˜¥ëóž' ÕÒÒ„�d²Rq-¼NýŒ(´LlªN¿é|Y»úé=¤ -]6V5I­ŸÑñûÿ«O¾nËö_] 뱢ı݃Pc|dް)4ÅÚµ¸šJ[àITg L#<ì>mt"P@P–󛣨tnT8a„m@*ý mÕl… }Ae¾„ ëÖ¶‚™ŠŸÂƒ{.D.Fª»šº„„­Â(gZÓ…2aj˜$0Ÿ ƒCgÐ)!ÊØWá„ '`èäxwÌîÛÿ ;¯½ö?‰ÈpS× |(²ÆÒè*ôðƒ·êSR“µ%ƒÓè¡Û{1G*õÚ ŽëšQwÁµí¼½v?÷9W ~qfÏžÿyfç®må ¯ŠN�|ç´à»_EQÅÁ3r¯Æãop<:ºviækvL(0i"iº–k…)¢JÈXÏÚô[ã° …×|–OÕ¾Ÿhj$G\§‡®¤ÔI2�™SXíØ2À¨®ë³cÑ“²täˆÔUµ¶bõ`|†â¯“Ñ·À…=-"ïx�ÂèqÀ¹wA11tÅ9©¦êÕ—î¹ëu[}3YUÔ÷ PLzaòc…ªñÉ.ÀÎý¯?ØvñtÒLA.»&ò&(Ê]23Øt·.ÏùXÆu§Û°½•:Ûu@„òAÒÙ¢wá ¦ Ë¼y¬öCÐ1 ± "`�¸ûÐÖ�°âãSÄØk0®:n�ÉáEÆxsz»¸#èµ¥1{ðàO-\sÍ[Edûé_´"’옽p<;z áØJàè®-ªÓÞAPš;:¬/0Ý ´�oè¸Ïæó»±n�*Óö‡‚ÜU¹•A%éIÓ„ Ènj ¬SƒŠŠ%Hq¯¤ÑÛ¶ýà¶Ë.ûÐŽ'?ùº-8¾‚DŒa8DÒ^Âd¯h;Ã5…$'À•¤ñäBÛ¶àÕqOϧ:V­/€éž69ræY`XiÎ*ÿ$¬±©$ BÇ# Ò¨vdHDzÛ¶m›»èà¿;pýõo‘þÖϓӂŽÁÖ•Ì«¿†ß£ �KÿíFÅÄý>`¾“¹¶]rÙ[gvízŠYØWy™P±B#áßHc_&YcZêñ$çîÆ¿gÚ�0Kð¬àD¾CLu’YJ›r1­»Ã4¦éÃét®sF’™/.Þtó�ïøèéGF›Ïª¾GçN˜~DÇä†)C¸¯lï8³�¥·TŠfï Q‘›ì-B¯†”ßT©ýH ¬3ˆfj"÷Fv€ö¹S#ÅSÏ{d®}m#IMe<Ø•’ÎK iáÚk{žwÝÕýí;þÃìÁƒ¯îÙ»½bÿ']®±Š.ʲD¯|è®k®Ùzç¶Š‡ëqu” ¤hYªË9Hd�'÷åZÁåÆ} ÌAn—Œj!açó§Žíú¿=¨¡ƒÅŠòŠ®,�E:Äüô?$»%úÏWð�jqQd<ΈHqwh½ã¦YŸjܤ?²Þ(WÛyð`”§lG¬cJ³vWþôÒg>û‡[ºÿj²WU³øµ“�>LìÌ}Pȸ–hšÅïh5š�-H¡DR­|º…,Ì~Ç£ž±eKP’Ha¤gš>æ`‘1’EúSÛ9ôÊãÈ(ÛmÊ25€³ , HqZD�5ˆêÕFpYǾ„xcÆ´|^Ü‚þƒ€yg0ß`e“!¹!KÓ¤eîС7ì|æ3í´ƒIš8%ZË®óK˰ñ1¦$[ƒ±°Í[˜3ëÂg"6 &ÀTð»‘:2±-Ô™3êø"Á‹l30ƒaæ†Âñ\O—�9«‡êcÑ6z3Ãù…+®øðö'>ñ¹›@ÒŽU �V€ÍÖ¡É锸§2ýk5fLM$¤¸>‡±ü~]¿‰eM¢Xº C¶ù©â™÷7w„Ó•.]�IÈîåï°ùµ?÷z˜;xð§¼ô¥¿,"3[›óhÑ[Z™Lv8*¢‹¡¡ÀîLOO²µ¦‘@l�PÊÁW¿êŸÎîÛÿz”½"ͤư�· L£ tãÐ> é† [»z`�q'´l�µxiµ0A+{ñ{,oP‚Ê™K5€ :GÓ#çhEûÛg Ö$0ë|¯öîÔµ ßûâf£Í`—ö‘bž·M']˜ÁeCŠ]mõJTR_'IÒ:H¹‹—Ó9mN�€ÕºÉƒ§³]ç�û‚\­m¦ Ä6€™Ož¦éPºfU^‘5;.ˆmŸ‡@ÒÜžÐïÍ̼r°{׆{÷½¸77'RÀœLÕݺ�˜•Q@YìÇtõýuôãûZ=Ýʺª½Jt±èw;èÑÈÝ¢h1üpœÔ�Z¨‹ÆDl­©izÔš©³«ßÓ×Äktb ´Z6H»ßzpQï¹Gêj<íБ6wk~ièú OD|N5Žh $šiˆþç# žX\-..ýØÇž¶å7³®kŽÇ+mÇ‘©ë««¶€GNKfÆé3³Q@ÕiœMb5c’h7³!(ʹrÐ{ÌæV~ì°˜U£ H]ìD˜@¶ÕCï—F‡ZhÛsPôÒc®4#”9ả›x‘)NJ“ØHÅPèêæÒ[®t±€c-éãœ.&M‡Z#ŠÈßcrÏæ.¾ø‡w^sÍ/‹Èìi†U}l›8Iç+8ã ­G<¾�ԙń,CthI;’ƒv ìj]M# /�q¢K‰��]kJ~K�`VDßÉØ( ÃN&ZhH1\š¸ïG;ŠÁ`nçUWýáì£}õén€N‡)¦^d÷'–ÒÑ 5ÊÌeVÄ1udJqë—i……ZF/:룗§ö�Ö·z_ÌÈnkûKÙ>D7I5ÜÔüc¼üì{4÷²(eîСŸ<ð²—ýGémÍ=Pn& <³¾‹9C¿c=ŠÈ�d�‡W•ÃR5w7áÚû½ß»vÿ�_FYôb.~ba¨ÁñvÌ«)„½� ÃfZwʤYˆÐðëuÚx�PY8³‹Æ´":zV2ú ·ne׬Þ-É*è÷¾y0’+:2ÜŒ¥BhÙŒfïs±©\F¿‘tÚšû¥”iñ4í€q‰©ù$Î; qÇÒè¬ÀóÉ£­p)'EI± ˜½7®ã6�Óè¥*va-"s—zÃÎg>ó?ˆHyz•¦Û¢ëý¤Ÿ5¹�±) ˪3ïJ%h9=jÙ:ú¨·Ež¨ttÐŒKeò.yFûý�ÝS´ra%�{‘“œÏ…[=J—Z†!´¶Ú“ñí±ìã3³{ÿø�õ/¿üa§¯~².t óØN 0xV�|¨IY‘ùB›Ddæ‘bTË‹¤c;$!�±KyÄœÕaN†YÀn O £úåùcÜ¥òç¶Ó¢D–›©æk›?2w𢟼èe/û÷[s@#kR±MºìDØeuîF�€ð”–ñªÆn æœ;Ý×ÜÁƒŸ/z½a ƒ(�ÐÉùÕî wo¼Ütº¥Ç�4«©Yƒ«ÇÇu˜Í³qšp¤ f°‡½š‡Ý0´‚Ë¢˜OG¦éuQs±oéø›jeåc7ßxã6a5+½&¨�ý­pÝú…˜ÄÁw»nV¦[5Å’0©Ó]£� Hµš ]ùYCÇ`÷S&öcÉ­®�}ŒYæðtÞüûF£6ÔR1ò`Ÿl;€¤…g\3SlßqÃÌîÝï˜Ù»÷E¿¿†î"-(#jK¯ºÈGQHQ–sRÛÎÄ÷âh|k]�F"µœµ•2…©7ÉÞ52ßxÉ͹¦é.G+ÂØèÁ09hÈ {¡�tÕgÏiÜ& ¹¤àúÖ”š…@vO*œ�›ž”¥»ï‘ºª��´I‚dÒÖFCb’hæ3ÏAa!pˆ¿dGƒv ô3­Íu{ØÐh‰ŒŽ¹íÄ_ÿõ¿9#·¨êj|$�K¥Ð'>nÝ%V�T*”º‘×|?º£��°Àsö`íž+z½¢èõnÚ= ÜùšÂ ¢Á‘ôMÛ�“Æm é;JZOȾ’5¦L6ÞfüMgaÔvpˆ“<Æ¢Š„“bÁvŸŸQ@Rôé`ßÍ•ÔÓˆHIîâ×$plµ š°YSf^üS;¯}Ö¿:M›È‹PâëzlIt¼ÒŒZãK=M‹¦�$Â:YÕÜþž+ç7Ä '¬-@ObéMccÏç¨ÔÜ‚VcPÂŒ-‚y1£ ~©µ“XÌ𜌬å>gÑïÏí{ÊSn‘…ÓKÄ ‰·ç¾ÕšgîGJH§òÏ2e wq¦/fŸ†«`×+ÂÜþˆ¤¹ÀŠ!·ÏŸz MÏáuØ€·y0ðe�PƒX¼Ôà‘v-lF Kîßÿæ×_ÿ3[$¡ÕßT8cÇž4¼RPºgƒÑÑm$ªµ«Ÿh®âôs’¾æ5¿Üß¶í€>㽨8F.oV³•Ý2Eá™Ï ÈP F�7üز6¨˜ƒPãéNH:fÿ¨Ÿ)@žÚUqâœÙŽ2"¯fláye áf-šÜo4fQö¿°«™|ôZ|ÔØpãö€q>c’B+~8b7Œf óúÍÓ¾èǦ}õèjz€ÓÊ$P¾Y+Êј¶ZsÓ=ظ›T£ž ÷LgÜÖkš‰xA#éÜ‘®½¦ß›�}iaçÏÍìÙsY9ê5! Ù OÞÅ·ÅÅ�„~eyñNHU��$-� —‚I˜4ér3+4‡§®dʶJxL:1̶`‡w£¦Wšâ A×H”3 [Ü7ø w®uv|ž_Õòò¤KƒT¨)­„N·k]¢êÃS±õüº¢Nì…FÓ( Ð:"FŽ„ Õg!õêêhñöÛ¯=s7µªXÕw™*®�­Šx±^*‚K›(�­Ö LÕ¤„S[ow†z¾Y�È4‡.ýޞͺEƒms‚²§@j±ïµ£Å-óÆ} Vð0ÎB°íUg©�al_»Àî�išP�×èQ¸8õH¬"‡B1éîš9îjA; ¸´è€¤uÎ_Áû vîü™�ÏzÖ«O½ÖKÀ0Lò‰�”vzCb¦Ajš4+eÊw$òé2æ5$ÔØ*�XkjP5€²™h2Õ‡J~Ww×N_ÁI[Ðíw&ÓŠÈ”~=#bòµzL&�aDýË™™}_ùŠ�ÈiX¦x�žÃR§ª "·X7ƒì�Úìâò)�¦ÑÆë,è´áˆv¡d'þ‘ur¬Æ¦ärú¼fD¦bÛ³{/B¹ÐÁÌA#ʼn`"Î43¨Ógë¸* ý~ÙŸŸÿ™ý/yÉË6GªEQÈÌãfgJ»‘ÍzZ+_¾:·V”nOÐX9Åkÿ÷}ßžÙýûÿŠ¢ÈÀRÝ áÑE0ÚsÂÀ‹Ç8ÚG53†¸ýaîÕÚ�vLye0IVÞ‹¢k/q›­7zìwˆÜÒ˜¾+G�|q¼|â77Õß7QêØuöEq È„¥5+,�Ž¥F•5po ×e;ìYžyÛ j—aüT ?É0èÍt%1*ýÇŒqAªR_‰è{�Ö©�€8¡AØ ×9$õ}ÊþÌ÷v.üÂpïÞG”33­†….¬¨4†WŽ´)wç#% sg¤–�¿Q¯®cU·¨®ˆñ4 ÆŠú&`‘¸Û’}í8iÅ욃”©X{ršÜÙkD( ~Ç&NcZ´�>åì�¥…Z?…|pÌ®’²t×ÝŠ¸é®YR×ßD¨Þ»bk¶>•è-´à,t·<¨záRÌ’YH=séî»?²ò•¯|íŒÝ×ZjVÕ­ÒLOèåSÅï4/$1·É¡ eUÚîd¨xk“8)Vtû{( )z½í›v‹@¬§IÛ"ƒšÒÎV_Š*ãO�-dÉÞ�g 6ßa ƵE!ò$ß:¿PB‹Ý² `Ùužƒ""d ä–Tú¡ˆ'÷&I}®IV/pÔN»ÿ›çRη½ÞÏ.<ýé—ž–ôØ1�Žog: šm+¤Sìqå8Jü–Q?£ad�!8«+S’´”^8”ïS18¤ÌdsΚ#æ÷¦¸b­¾C…�øÉ4F ÜSiB�8I›"E!½í;žºû¹Ïý19 $Œì{5ÀÄ1¯:}ZÅkAêŠ� Õ8[Æ¢=§Ñ ‘t†˜±!nŹÄ7ÕÔª„v4ì0Ò¥XWŽ˜"êßšB0ØÛñìö\¢_8䕎0˜ŸßUÎÌüüîçNgÆ9wÑE_,Š¢àˆöñÒ3y_ëz\±®¿*!KV E²+1 2#Â$ï: §ØD­xÖ`Û.h˜\õAQÌËÜÜ®M PÉ&;Jè‘,†Ûá ¨Á X»Ý¬  n­¾ëK»%^œÖ1‚�2ÒmÈ,°ÅAöÒ°ÒÆJºd†pJìJ éB•³GÀÒ�‹ðxŒ7Ý«Ä`�ú§öþë鱿; ÷î{4ÊÞ¿?ÕõÑtiÈht²\½›Îd¥u¤ &Â>i3¶B'Žh0ÀÛ•±´ŒJ.Ó9Ë ížitÓï ˆqk 5‰óË{º ´–ý@†ŽQÆ®»C�‰jïÁ Þ#«­Û"De!Û.½ô­"rè4ãö5<:Ž‹(�€-¶ˆ˜ÅLªøK¨¢JW“q)‡ä,Å:fð§�òÒ;Ò‘¦3¯8'¦YäU™ ‘ÿ5¦<� `Èll-À«5vô5»ÿÀ£»výêæ�Ðz|šâÕ�4yÅÄ{ÖÚ]2œ˜¤^H¬ZR:óÙS¹.zù˯(·Í]Ä ŠmFóÂÑØ K’¡Tšþ4³o¶ùGǶê/Žòº4zˆ`?F]m,¢½zZ¦±’Pð)>ôù)Ó]Ù323™w(´�%(ÕÊêHŠÞg·*ÿôó!ÝnçYO‡~Ýë$ÃÈ3 xOm£­"Õºµvð „)qrb¥Õwo'í$šerR5ª4ð ob@¿‡�nz„¾ÓîZ¯3ÆZ˜€¤sꪹÞüöŸÙ·÷;z33‚škQs­�!Ní^Œ ©«Æ )ŠÝ³ûæ·¼˜]Zþz=Ý!5•5©®Ñ¡´/¸NB¯†nm@Ÿ¶¶ ¨D!Q°Ú1mÂÌØBQ%`´d  ÅÜÚ–Uµ1O ý&ƒ gXh›”Å»îV.AA¡�fd(±/ ´7²ƒU2³e³tº©äÅ�‹bz`Î>ñx<^¾ëÎ7�éøR/-UR×'1�l¤Åv™˜{(Ó±ÂàUlgªuäÑŽ"TíLéŠbÇðÊ+Ÿ¹iKPÃ&/¡*|¬&=Ø5:tËT >¸3ñxsËó±"(G,#à€rX+ „Ò!ÌM-l g¥+Ý$Óñ¦‰q2MúMèÌnÓ†Î(fÚ¸AÇü¡ xÍîç]÷¿œ"ŽdÆ*ôzÏØJšI„@2D;+ ¼(5“ÎM’ÞO19¬5›®(-è©AòÈqP³=¬å²Ö¬²ë†Öæ@VHL³6“f‹Cá21ÞNàÈÈq i=‘¥”zвwè†>)"ÛN5†hö+•@~óž ‘#¶Òé› -Õ�ÃÐAÛÑ,&8»Ç Q±ëè®fŽYí½4v0Ér]÷aÔûy³ô”’uµçÕ¿§*´ôh&X�X�…š-è{�â0D†ûö^ðU¯ü…M;xL|í2�wâ ô¹‹G0ÅÝa¥»Çü|´žáémîÛ÷)eÁl‘[CªÑ/ç«×6¼ëVëÂÛ‚=�#/L§Y3ÑálaõúàŒ±Ôg£ Ìؤ´1‡v`¸mBh ÁI(d#ö ·Ó¡Z™cÙägµÈÊáÃ~ËGþdSÆÚ¢]Ëàÿûýe4ýƒ#âE‘œ¸Ï ÏmýÚÚÎN± U°{ü< ãTÚI4Z›¤e‚ªž„r”îøiϵg´ÎëÔÊÒž`;ö|Áµí½v=ç9O�¿êª?îÞ{}ofhݮŠ':1a:áÉË^¯({½Ë‹áÌe[ý=�}õoîdUßú&A“äiw uo]¬6 -‘°;?íÔÌýyYU²|ø¾/�øÌg>p¦ïíÊßÿ}…¢¸iÝC]E�sÈ1÷Óvh¨Ó»A!”“—*Æ� Ô§ÃÃþ`ð¸ÍAÛ´î†á)·1&¼kj$V`UÞt|`– Â&¬]±NŤf·Õ¸éJfšߙ܅ ¾Ž½$Ýš�NQ7ã»4ÃÇ>½¦YKþ t¤jíí†s¿u€ƒÖ�öÊ¢$ëv=û™l´z-ú¤�ñ=6BÈXˆ¶=ÚÂODI4½¦ü( Ó•Ð«ä ³‡áé/žî AWÚ÷¡;òÀ1kE�¢ðç®~¿v6ŒêbÌ�ÊXS ˜gHP03sÉÂ5×<ÿTZ7Vû­%x“/Ð5˜2u“¦èaªÆà@3)iT5Ýl1î§tlФ¨Ø‹tjeqeœ�´"zFÌ8šN‡ÒÑ”�õO^­}£;äL +¯q�ŠÇÄIcú»¯?EÑë@ñý{^ð‚}§H‚š"`Gˆ…9@ �5zêíÁ ÇIï)§�©Mœz|Õ«®íÍÎî”)‰•~ÈõÁlIls;Íšm~†@4˜ 6W�Óœ/vï!²»UcÔ@p@ëíÚ¥ó§5@%Ò?BÈÆñùYبRšWºÙGRPŸ“;¾=Ú4 Äì"E8›(¥�®y„xµû —d˜¡»„nMf"ÿZ;)8©­�…†Ö¡[çBlk\äû8�u`3†¹¤~àF`Ò6¥.È$�»@R¯?7÷Ã3{ö>µ·}{�p@Qg±Kwmí^éœ •{ÔäÀDY΢Fo«¿(ïZYáht3«ªNöî õ�€)Î6tÝn3¦tµÍØ@6‚Oó!¼H"9UÅ* ÞÉY¤4­ uÂs)oµã<§’²t÷]"¬ÍxœõXFãÄŽ™e1LÇQ°·nÖ„4ÒÅŠÖ#˜ŒlŽ�¿ãèÿ÷ß¿ë¬ �½ÞqV•Ä#‚ëœ65?rõédí4ÌþÑÖ¾°ã:&èç8aC (úè÷±)K°í$iÇT`@±< \3‰Md&óe#éß·]Oíþ‚¼Û “ÅBi� f\±Ð~䪳r=4®Oy¸×7 IIÀÔåŽ6µ&IW ިΜ�ú¿4qF»àiíœáþý�‘rðÖS-ú¨Š‡PëQ·³‰û �D»F½7ïU°ÊÁ]Ï2·³`C®RÈPºjÙ{´Æ�±%#àÜ´L«õ(�XF™&v6ÒbÇÚÍ=�4‚IeMÝèÍ?ìa¿%"ûN=žÀfªég40.ä´X%²AO±Z‹Õ"ÓaeðÐ’›­¸%w32k!P�Æ Õ(ªä²Â&MƒŒ§ÐéðÓä¶T‚Ê�¤'go«*úµ¥¼3z‰8ÑÃ}û13?ÿ¶Ó_yS5‘éíÐ�º6W†Éy)£­’œ�3Y‡)ý‹S¹®ø—?�áž=ÐÎÍ2o(ÓH¹‹ IÞ¥£—â hêcEȲ tÞ’¹KD�¥KÙ-¸}�:ž*ªQ¼–$Ãv„/g ÖÄ›X-o’n^µ¼|EñÉMOé5‚ÈuÎ;YæÃËú»ûã/æ)vå©’ƒJâfÈ uª˜£tŒ7U˜GSŸçöÿ¼œõ+@ÊÉ‚ NB뜙¦e ¦ÏV‰êë 0Ò9 $í¾îy?ПŸ¿¾77[J‘^¶c!Ý"�’Ù¢‹éÒRí6A¬,¤è÷/*fúWž¼ >ƪªP7�_¬R1i¢iR�×ÒžM‡5ŸÇ54~J+ΩÏ"pÊÛjJa . ˜õƒ5Qñ®çñU--):² óŽ :ǽlÆ:‰6ÒÌJ³5¢®�c›¼|±©Ld²Pî­.Ÿ¸õ[O?«°:aͺr“I^+ øþEî*†0¹qš=¢Æ»Ú±Õ4*E�qf"ôà $*Kز,ÐëÜŒûÃ.VVï�ŒåámÍa4¸ô8”(wž„�Š¿AÄ"Ú°øtà ˜ç¸x0ÿ÷­–Šÿt FcKÞºLe¬!;&ƒF_@M³#ÑJëìîEúä=þ¼íHY€¿7Le¿ÿ¢ÝÏþU§’ ‹f@j�¬°û ©J8”Ü~�L£ÔâÚ€†Š±�—eÅ‘žzT “ä9%úN PÖ]â1¬0©ÁÓ¼î�MСœÖ¨õjh½€Ù±® èT�!; Ë ±Z¥×î}Ñ‹þÙö'>±<¥5âÆaÑ|­qcpBÓÜlŸÛmÓó2FL[ë¶az±Õºê¤E�”¹û�iìÌ Í�g{ŒòZ+ë~ÂâÙ*Üx¦œ¤ÏÉ\WT2T 4•Á‚IéÌçMd9>çà«^ñ=›sF[³‰Ìap£û"86ZD y† :¼!OíZ¼óÛ»ËÙáü´�Ò²€[Æ¢bqhI8"mäåÖ­8çÓNìÈÞ4ƒPLXšâœ®Qño ³W‰×'‰†”/ mŠZ�O„qXñ9é”à2©1¬Î•Ã÷}ø–w½ë�6E‚žTš^œ¤Ú5®!"ëaé3Ës«éöÃZƒ´#èæ‚Ýáûæý;Ò±4ÀHMÉv´›&vvaémC±^­™EWÍL‰ó…õ´¸.Igéµë9ÏyÁ`~á̓ùù=èõÌŒ+Üaº›ÐY+£�HIZÓ‰)EÙ;S÷«WwÔ£ÑëZihˆƒei:ˆì#üÆwsº€ƒñt°�JÿÉ ü´9ÝS'p,%1¶ŠŒ[€Í ,ù`Å„IYº÷^SíP bl5é-ý�ɬ¼fY¸ Y2†éè‚ÖúÐì 'J�I÷–««\¹÷Þ?_þòß~í¬ºÍU-7l@WYu1Ö9Xl³Æ¦Qι×Ív;ŠBt½(ª>x�BÐëíÞ”ûSë |f²ÆëÛ0¡dlP¡y£m«‹KªÿX ç9¨L'to¹W¨è&½î€ÍGÛýþYRoZèb.¼cC¼6Ìò‘ £ý’}$3XèÆ5Öþ«¿sçAÖõÏ=0x€VǯKÌÀ‹@À«˜ûñ fjF×4½ˆì=•º®w tI¼V i´e¢#p 7%ãgF1ç6‘:¼P£*È@K«‹¤]ÃQoèýèÄ‘²Q¿¾í\”I?îßÿÓ÷ÿõ_8y4Z2w%ã æHÿQôâm�ëXÍ}‚ÚW#‚´Ú÷q 7‚N�¶PTÅ;ýÉ6Þµ’,È F-·³Y-$Ô²�•£†††‰Û&ÓËd œÕŽ4ù\¬…÷'ћ߱ EùÓ""yÃN[Êæ­KVú˜ë �@åXùÚ•Ñzžúõ°7½©_Î ÿ2:ˆ´ˆ0¡åø¨§ò“.v”ÁŽERÓÐ ×MóZ2ûöÊ­U�M;DÎl͹ÓF  Ædz(ëß®oT‡†~ ~Õ•½ò‹›žkm!ÛdkÖZx~IÒÊÕL&�cQŒÉ¨ÓÚ›ž§"º_>kÏIZ†g–÷°34f[AÆã ÅzOº) -�3» ä$I¼D‰"t)Å€óvbRï\ú°Û¯~Ò�ÁÎ�ÿb¸g™²…åÒGO:�œ¾ZqAŠ-P\"ˆ^9»Yã!ëkõ]õx|‚¬ )_qÄÄ”(áiŠÈQa"{ Ý3 –ah­uïÝ@�¢áã*jä%�“µ$~ú¹Ñá¡{ž\Õò²°ªLDnZ„#žÇ#‡–É” H˜ƒ•Òòjc¥ìßОq �°¦ŒG££G?þ/=Ûî3 d]�E8´È²îØ[}ÜÔ@DÍÀ¹ ¹pZ64I±™ïô­Cñ&‡d){ým›£ ™R“ξ©F$�OÐYÞ»@a’mÒ=>1Rx�[X“T´6Íú�½A6ËÙÆsC§;v0TûS�1ºFãzf@}î°„�³B×EÃzÏP1%’P9¥ìõž½ç…/|ؽùÈ7N% óÈö,?£§1 øs='y±ž…]?²g°&ì(L0Ôo:=%UèÊjÜÆ éPTb²¤IÅ>ôùƒ–­M²èüÅŽºÂ¢âév˜°«L±§@Èv|AÝ«ô8Ó÷)Šr°ç/x# üÛ{þä#ÕIjÞ¤Õ“¸‡Hc-*× tN ãÄ…%®}>€-V#;«¥¢/ÅÎ4‰$¹Kµ�Kõ™üHFŠeŠ¥d´¶˜?ø/œ� Ññ?´Ë™vÉMJI¤¾T΄lFß¼ÖyûO éÍn{ÊÁW¿ê¥7¿ýí¿ºg0wÉmžÚÆ™N†>;` ªþ¨¢ÃYÛN=žrí~ìw��üÝ—¯L±Ï LC˜iÒ�°Ì`µ…•-i3æ^ÈYšâ—}ÛRc©Ú�³®Éñøx]×wJU}“ä­ ü�”ø&P.ƒ{¤(FÕÊÊ嬪ƒ$ÊÚ˜ìN xú½‡E±KŠbVŠÉû èF>Â?"?ç}l7í1vG÷ßKÑëý·MÏ5uËžÌ2˜fÂ3Æ•‰“Ùß寥éΠ.°–Vº€:§0…§%­¨>5sÖÛFïRÞÌSR“Ÿu¸�êc�z"É7V|�bBíº9]’Nÿ5³{ç÷vìxr9®¥ëTh+ØŽ*èQÛEšNJ“&é.-uR cW °{ðÈGôWÿñ«£­üÞ�¿Åñø^ÖõÅí¡?ùŒm‚«í†�cúºˆJcQlm�Ú‹ Gî,Q–™ ²£±.ïkÊ>§Øùæ(q¦ƒ9µ щnœÖëŒ9¶‘²tϽ-E™Js„°ät莎 È‹,å‚n¡¾öyÑ]À£ôªcA®Ô«««+÷Üûó"2>ë‚N-5ÇÕªˆ ´kM³¾Ù&´!"êÿ%pºÎè$ÑÝfkœ¤ „S¨ºÒ™þ‡@Xˆ°Àv™‘¥Ó¼ € "Ú1GW㥤!æ�`_7V°FÇ…™‡ Å…`?·©ÂV® c–Y°®óº‘½Ü¬€òa´º ¶ß­eÕÚdSÄ6NÖÎ*áçøèÏÏï_=zô_‹È�œL¬J@«º?HÍ ˆ´šW“q tˆ"»øÅÈ’|”t"ƒ±È½’ü²û-Ü�AsÃÌYvý– ã�5f\lÅFŠ)û½ÑWêû"G«@f€†ÃÒš�³ûö½ù®O~òF¹õ¤°F- O ¼·Dª¼Êž«¦éÄUÖí=U1¥YI€™%:Eëw8°Ðå?œ‚ÚRkª;Š “±�"qÖÝ‘¨ôQ’æ”wtÌ€/šŒÙŽ«°Aþ#_‰Jû¶mõÊòOˆÈé’H£}G‰˜Q‘§©dãY Âhj¨YR¼nGiòv*¥åÅ7ÜPÞñ‰�¿rvÿ~›¢©óF¯HõÒß,r(�3ÒHl'QÓØalœb…dÛ³º­G•p4:^Wã•33ÿ¹èõOkH]W"2&9°LÖ«õ¨Z^9zttçïüßUó‚_øÂ¿ïíßß+ʲ'eÙ“BJ œaÍ¡€}©ëR�¢è÷–W�É×õƒ«Ñ+çP”ÓÁ/ZíÏ dÕ¢9“)²züø‡o{ßû>·eé½ú<í�­kY Îè¯B5Ú�F�é­[…®çÇðܰ·¶ÚÜË«]êâ3Ö'€i ÁHP¤Óñ�ªé­‹™$á¡Ø½ °ïJ«ÙáoJ#ãáöá…ëÜ’v^sÍÕƒ]»þioûöíR¨Ñ(¤- ’ŠjÝÝQ­˜3Twpíµ‹²×+úýËç]vhõ¿úÍ­üîÕêê·ëñøa­òAŤ²–;m2öZZ=ÝêÏÛ ìØÆP‚°ª57y©f–Y9î d­T•ÿÙ„ºøS:N” ‡9¯7|µ´$õx,( ô:š¢­2à…½'‰JwØL·S%Z3ÁÔ¯æ�`:§^ë"RW•ŒO,Þ|ÿ'ÿê­gã½®ëZªªZÛ³2‘Ø�5¦Ý›'Ù”ŽŸ£.4¡@‚TøJAû²°mö±�}ÆÒ¾ðÑÓÜFËNÐù*V)wÍ Rôú.t˜HÎfQja~¹¹/º¼¯ê±ˆ,×ä ÔÕ}B¹_(Ç)z¼w÷ÒRIÁš–Oã;Èf=¨1#*‡¨¼ˆd–ÀÙY t€è éb0GÓ@Æp ´5Àñøi'·>’;XRÖ�x¸*ë ÐÝȨÑàØÚÝ,Œbô`:[­¡ÔõjÍz 5�Rä¨Ôõý9&äòN�܉²¼½(Ë#uUír^È�\ë ?"‡¤À)нEQEbë"últÀ‡U½`æÞ‡v¤ÄâTlY ZûÎ4]]ÅR Y ÊÓæ½tb²·1lïmÛvÅêIIlõ-õ9ŸetŒ0qÝrf•�Vq²Ç]ܪñOD' R×IÈ·ªÇ¬«û¥®×u}…w�¼UP|"'¤(Ž£(ŽJY.s<^�ºÞEr—ˆì‘‡ä!@±E±E¹€^9C4LŽÂ‰ú0ßÉ:®éêçuÃGùƒƒ4†Äé¶Ævͤm% ˆ£Zº¡Ûûæ !({�?øò—]òíýÞ·NÛä@dìØ<—ùÇË‚sb+Ó(GO}ϼÍüÍ<;v̰ªÞâ ìdha4q¼ø¼7l\tlÃf~3µiBÂ*’‹Á4죪’щߨ«ê-½¹¹›êñ¸Èí_{ë[ï=Ùïÿí�|¤‘JDV6òï÷¿âߜݻ÷Ïëj¼�W3õhõEèõ~¢ìæQ¹SœuqÄyváä¾r<ôÊÏoY©FÆ„qCØ´UTÞoFúÛ0¯" õˆ;ú�©¡Õ­)qM—í äçz$#ÆYŒ?'í´MÀüU D²NßêlËä…D7ƒॳHšÊwÇ>õÉzáÚg]5³sþóó—½^: Vb;\M=ói/6¤Y�¾ôxY@ÊrUµå|¶cŸøÄ­Ã—¼ä6VÍ‘PÛEî(i4‰¿È$¦;Æž¸)¥ëSül¶éy›15ÅX)þ3‡ñ=qªMäBÀnhÿüºêZ–È xAìN•%º›ÔQÜhª}“Li‰�dn·kIfÞ.Nè£^]=¾røðžÕ7¼áþpãéO|÷³¯yõp8ó½eY.Oا((¨k¬Çe5®Êñx\VãªWUUQ�ÇåÚϤVÅZžFÜõí;ç>ñ‰›vö·WEb >èg—ˆé’¨>�‘î1뙋s¡MS8hç=ÉÆfûóóÝóÂþË{?ò‘_:iœÑ0Î`Fh|§ÒâÓÉ�‰ÒÁ0×YndÄXÅæ[¾ëŽw ÷í{Çã©Èz$5G‘\a]�IŽÉzU*Ž„\;²zä/þ²e$Ï^vi9|Ä#{åÜÜ@Šb[Q–sRc %‡¨9+@_Š¢ ¬9¾RêYŽ«‚²|vÑëïFY¬­‘†}ÅÌ ·¦e\xz¨ž•@/Jòú^¼PöLÀx4 RÜo^{áÊ+¥7;ûÜcŸüä� Þrž­šŽg #Ï1’ü¬ m>}³þ•nÖÞ§Z\ªÐï]ͪZY e\³^•ªZa]/\®WÆK«Ç��X>v¬Z¼é&èí=üáåÂWô{³³}ôzsEYl#Š!z½>êzu5�¢(DPB„¬«ƒu]½²({Ï(úýÝ(ËÉ8KõËæiÙ]~§?Ïz—7¸­dîÌ'~“Ï$:µi¬\ÄÚ¿éíØ±»®ÆÿADNýÌoFÛŠü;[ÆB¼è©¢Àj¸Z :ÖÔñã3´@(gg!uý»–5sŠ ÍÌÔ 0l _Lj9T18´ @Ô0›¼jḒÑñû?�BþwVõßÞrã�_“-f”ßõÁŽd °nAë‡üèë¿Tˆü^-˜e]_."?SÎ ¯B¸²êñSÍ®Rlü•£Gþº7;û_·âû4ÌÈÂLÕ8²´+l4û¨aÇÁ×[z]·àã—Žd ƒÉ©]ç;](—“aGw-=8Û€�†`|””,�v–v¯åùå:,0K«#±YÉ™ÊA£¸,Ky0_g5�4wè�‰HoÐq~á»ËÙa }øiûð@�3­; h†÷“­K5ï]Rôú‡Ê¹á"ò�­¾¬ë;몢�'kº‰�Ë)$ŸÓ·å„v ÒpA$i©L¸Â¨[BëæÖ¡‹›Ýç Ä�XsÆÎíÛÜ‘<¿u‘„”ñò²T£‘ (l>˜9§¸ Î+˜3ÐÖÕ§KøñOÎ×E‘‚Û„=’ÐC˜Bžª£À©ÕJdLdj$/XÍvíf²±Áä\13`qñE"òK]ºé‡ì LK-f†z¶û©ÇžœÖS¶Ÿ&7yéÛw|åðG?ú©SYþK·ÜZ-ÝrkÓE?¾‘ßÙ÷’—|®è÷/#e¡¨ ÔãñÐ+_WôûCQ¶:[´°u~¿`:=*'�HÍ6é/Ñ3IìH©÷Ìiÿ–½ä ýíÛµzìØ~¹ó¤nf&DÔ40 FÈr 5cá±cŒ0Š-€v¶Ð¬+~ë�ï}Àl„ñ׿^Ýûõ¯W"²¼ÑµqÑ«_ýqéóR’;Q}!±®ºŠ¢)ŠŽ3Ì5ç¬FÇÖ`+Íø±ƒtæÜãÌ»¸Å‰`Ô»èð$‘5Ñí›ßþvžâáÓŽD›%Ÿ‰üj›råÖéº]P¼i$›ñÀÓÏjqq¾·m›iÓ3™‘ïÌ”¥!«m,Ϊ˜Ð#÷ÑFΘɽŸX\©–—ÿO ~ýÖwÜxÛÙ”®Ý|ã;�‰ÈßLþø—}Ó›> à’CðsÅpæR¨=dôÀD¬¶+)ãÅ¥?ºíýïߺï¨Q Íöô:ZŸT;›yíæ;"( ÐºRíWZëE¡¬>ü•ÁÂÂ�W++û7$é8ú$ Ç1Fhè—ž¢e2ëŒJ“Nû..i›YÑèÌÇ9’ñuVIw|èƒÜùÌg>ª??ÿÚþ¶mÛQ”Îé(ZՉРŸ?8!Á¬ƒ`F­¿, ÊbHÖý3RÏVõ½�Gd=‡¬ÛÙôÔY¦`ŠøÓ|«ÖIhcAžÄê¾eË"h’Óf~Ì:F€2Û�rÖ­‘¢œ0çlŸˆIY¹ï>)ŠÒ>¿)v¿6DælßàFo‡yCÿLrÐBƒœ]n�ëZêå•;Žå¼ñ¬¾ç"µTÕ�íºGêÓÚ&â*_?2Ê4†ÓZukKç¥×¬èR"‡èJêEP”%úýK6'ù¡éfiMqÅg Pš?lÈ÷¶êÈC²‚QcHÊÃä$r7Ö"rLDŠ””Zo®¢]ñ…P¨xF‘BHJAÙ+¹gßÞeË“¡®â’Ë.9ñÄ'?áèÒâbïóŸÿÛùúsÛXWiT‚î±™„0�ò2ë°3¸az^ƒ†ÀÀ‰×X6ë²È[÷ê³Aäª]×=mpßG?±ºQ$Iûñ´ãØ@ -j|Ó«‚Æ`REI%T:ÏiXzID¤ôÏÈY~÷üÁ·D¤íÙþ„Çý·m½üÏÖ@>½òuå ŠÂéúú‘®Äž±££^w'é½èÛæ”`0“"kXƒ^³g¸ßÓw\ñÒ¿½ûÿ_o0ζºK¾¾uul|®dÂùJÛɱµsý)*Í91nc[žó~à†a!"rð5¯ùŒ—X`]ýd9œ{,zEw`Р 7©Ó;,Ÿs¸¹­jåJ*Ih>[Réþ33—^ôŠW>ÿæ·¿ýOOWõ=ÝÁwšjzä:gÙÅPúižwŒ¸wÒ×¥?ò#³ãåå·$) @Õ5íXw»®ªG ÜÁ©ál z`›¡®ie?~÷hqñ_ßóWñÎå¯~ãìÓ´tþ›o{ÛEä‹""{Ó›þ…�$ùè²ßsÑï—R è×±iF z½/l冻ߞak6%rH]ƒŸÚ�;M@™AèmlÍZôýŸX¼óæw¼ãcráºp�‹@’ˆH93|yÇŽ+0ø PÓ¯ƒ‚@©59”›@—ÃØa§'”ÎJ)ú½íepé¹Õøžº/KÍ)Ýø�¦€O}£•$!"£‡JEUu&n%�Tª´�sÏk¦ˆ åÈίgc �þLÖoÌÞÏ3Z)ÕÒ’T««R”E*ü¨m`%ŸaÐkbš¤¦R§Eeέ?Ú‚· º‹ä+D ÕÊòòê±c¿RÝyÏ}góm¯G#–½ÞÍé~Ã-q¶öòð}- â$âuÇD±) €n, ˆ_£ IDATÍ€n”1šnÖ‡@E!E¿¿góÒv ’VðÓ¸ÆP•þA�ÃVØ4úÒqÊÌf�³("«h¸-Eûj‰ý …Ô¬¥”M¶Â9`­d·ïØ1Ú±cÇ.}ÈC¿ëñ�ÙöÍoÞ2÷®?ûïólûs�ƒvw)v 5)Ѝ¦Ž· 5Fmi¯…Éõ˜;k‰6i˜Û¶§ZÄ›Eä-^ƒÖL€ +*"iLÍE÷-àt™p ÷æóË÷æóŸ™{ÜcþtÇ#¯ü3Vã§eïuE¿¥´ìÓ ÑB£5g2¢zN.ÖÕâë4ohÿ-èÅ~SÀŸÝÑO.ÝqçŠÈúÝ|®f] Esø›¡µ�r&R‡Ä…pÕÖÛgþúöoÿöM"rÓ¨ôýŸ“¢xœ¬ð1Å`æ§ÐïG!±Í¹¼ŽR v0ýxã?'RQÒây&ØP5N ý¹ÙYŽG?!"§$™Q«�¨ûið9ƒ/|¯»ÐFèÁÎ)ô.{3ƒ!)/3ÀW«‰åäëÆùÌ×OzRÐj�6NeèлŠnôxqqqõøñŸ½í}ï{ǹ˜Bãmoû”ˆ|jÿË_>»íÀEŸfUòÍåììÃu}ÔäN+‡ÿq9þÅVçù^Ü?Õ[ÌÌ¢lpVÒFÿ_iZʵ�ŽÕîÃzxpÏm]¸Îm iç3žöðþÂŽ—õfgg Ö•×4m7íDíµ}s'{‚AlàbÀ¢8xF ÚñøŽFÇÈz(U�á!+šñóØ‘!šs×�ÎèÛ>E¿A þêA teq¾ÕTMå6ÔR‘c2åyoÄȺ–•#Gò‘6ˆ›.VE•®:Ñ]ã �ap¶3"¹Æ$1uI‹¸5Ú¬†z<–ñ‰_:ö—ñÏöû‘º®ë¯dÚö¨WE;\×_ët “ÿÞjQùxF;ÞÓiîæ‹ký0'šVè÷vlÒêT™t¾!á§W[Q×ö«0ÂÝt ¹èt_5XèIZÊÊš›Ý’ˆ h-�/P8Qg*º8Ú±��šè¾­ýÙÍF �ž?zù#/?qÙe—,ꦿ™ÿ¾ðå!9îù��@ú#VMÒ®š™vNGnн¨œ×¿P úýj¹øž IJPúÙ‡Ÿ<°)^WxÄÑÑ‘3$3¸g/[uñó_\Züü?&";ðêWÿ¿õxü#E¿ÿšb0Ø•¹Ï¡aõÑ8»E£èzýšAÆ@“0‚ƒ½B"ÍÿÚFžÒß¾íª£•‹7$¹gé�£²)MÍÒîKZI�óx÷´È¡KÄÆYL8‰gÛõíßþ�ÏŠÈgû�»zöÀc¿ó ²ºúär8|#"�ŽTö�æ%i»G¨<�’ã¢b¾F¿f+Û<±ýïµßï8Ý­ 19¼c~ç&åæ Àkà7ã?´ /ñÀ��¢?( Èμ˜F��X6â°Ø­šb{ëÜ–™WD¢u“¸:Ëê‘£o»í¿~î]çz.}ׇ>´$"yèßø%�—ª¾®œ�}!Ê¢}èÕÒÒïßúž÷lq£³ëÀ£¹@ë4ª§ö›=¬ 4nei;«ZŒ’¹‡^¸.\]Wq6¶ÞöùÌÏ_Y4]�ñ€âu6L¬…¥Bj³!Fe³l£@ÑCY^²óéOÛ±Õ7£�o¯ÇãÃM;“TiÏ æ?5öºëж�=v²~õ šûÝ’˜rA�<�Ïï}6hHûìP:›ªK|Þ¡H‰�´64Õ˜ÏA7Ö›­x¼tQ•M¦9p#9-™m%v%¡é|ÐÝ»6êå•#££Çÿí¹pë!¨¥ª?£Åɉ`^ÜÒZ–ûRÆìŠqŸ€ΆsO9é/�œQ–Û6 Gr6ìTÝhmîš_FL9ý~ú!cßNf �îîF®—ºªyB+"X^£\:ºøH¤Ìu�G_ “Áô˜ßÇM±:3œ©ýØG}ùË_r×ÿzÃ+£,ZàÊÄY�×ö#Mqmí¤Áåcfüï�¢BJP§<ëjô�“†[‡ÔŽáCÿ™1 TSR'°›L[s‡2p<7Zw~àŸ9ü•¯ü‹z4úázuõ/¥f†4âÓÚ¡iúá>Ù›P7 dÑ ¿Ù’HŒ@-²¢UŒŽÛ¹ÑÚÉdnJ½�•ÌûbÖ~šÙB‰‚ìEÚ˜œºØÍ\£ÏÿÍÒ·ÞûÞwþøÇß\¯¬üÄxiéa�Ý·1Xé8WÚK)ðÒŒüèÜ[I!µ…�·dvZk©(SºJ§Â$ñMú_+mpÅkd åìðúŽÇmBöÓùWQ\h»Ü�Ìž 8$&ÑE'ˆÔ¼Þ #YZ»ßEeÓÞR¹|Øs ·ù‹"¦Ž¸¾gïžÕ§<íI÷ýüë~àž�Ãfvµ‘ رÉá~ªø·‚ÞÆ€�¹êàzP\Qö÷íyÁóyÒKÅ9¹Ö‘7¦¾–Yh Ô¦–çF bõK_Z¾ãw÷¥ª_[¯®üY]Õæ;‹^¦Ó®h°ç«L;‘»ïæ”4wÉ%ßwñk_Û?¹õ3‚ú „™±çQù]ö£?Ÿ%Sü>G2‹Å¯}ñÖw¿û?W++?2Z\ü‹é+\ï/f#M– ƒ–MK“)Û%×ÍÅ,[µy•Þp8×Û¾ãͧ~ Ýiûå"s†šËqˆPR»[%àä×M½º:›Ï'¦õiM¤¹¡èf\9pŒH�Ý›‹U%Ë÷Ý÷k·¼ó]‡Ï×û–÷¼÷Ö¯ýÚ¯ýÊÒÝ÷þh½´ô³£Å?%¾¼•ŸÁ†J“2zïÁ÷\Ža­â6’±ÛHÚŸHûœ \@‘.\ç4�Ô›¾¤¿}ûCÐëI8u9¨š ™§�‰2¦…gŠ”Š~97{`«ïÇñOú.VÕíÒIã-–îRn #sŽ Ù&æ°ÒÃUDÔ7Ÿ°k0ÎÌBu$ÈʸóE¢TËË2^YQtþøQ 7�ÙKb,é$Á¨§­(×Äÿ“ä‘´cô{PÆ÷ŸøúÒwüâ¹ô†û÷köÕJ'o¢\Ǭ¡r~I )%ÂM  Dd,¨ Wïk¿1SÎmÆ&eAùòsîuÔ.$ð�™F2­%6Ð#Ÿ1pÕ²p3–6p­Èš»’ï7·Ó­¦ RÑvòekóm&:~¿�J ÷„Çûéþ�{wÎÌP:™Cë={ –Žq¶öÑLXÎØ`)Qím››CÙÛ¸P¾›oĺ'ÖE6´Ï\&8ê:÷²ãÛç·oU«7T«+,uÕv•“ó×…> ;kÂZ³'ì=íOéyKâNÿ´>gvï¾~åž{º‘û 0[‚‘­„µGï:ÈôÛ¹ÌÅL:väÇÆ‹‹B­Ê¯GÛ=Óü~Fð é°¨“Âxò^ úeoÐÞ©�9ÜXÜèx �M':œ/RN%÷?>Òůyí`õø±é��äÒÿÚsSr>vµn'HÅ4z¼´´T_–ÁuÇïþö=_ûõ_ÿ÷·¼ãÆw~ë7sKÇÚ"í¶hwbzšgj^FÛ�!§)ÉXD¹ê…ëÂu®I;ŸñôK{Û·¿¸œ™äE,òü`ÒI Ó(ÈcZ¤Æ�²B°©ÝËBÐëí’ZÎÄ}aUÝU×U1vÒ(<3z¶¥+n 0�YvAÖ-0A¬™3¦Tës—Wëó7Ðkl¤cÇ”6B(.á=È�ŸZ{L[B‘w·üÝn´`¨©²¦”£ùp\Y�—ßµüå/ÿãžç?ÿœ©æV.8“”lìBNv-zS5ßhÊ0òÉ0l�µ¡jÇpÜ!R½bÐ{ÌéÏ~hVJÜFF³Ï†’!nРYªz“H×OniM^mU„Kù/Û!žõ1ÝúÆ¿ÍQ–EX.ˆPýØGÿçÿäûï›ëõó£ŠÈ÷6»1› ФC¡vu¢'?ÆRö ¸z£@&¦Dù Ÿ)S2h£n`?ž[`ÒÁ^‹»÷C÷JUÿp=ýW¨¼~ëƒÖt†2%�3cpqJó­7ªWWl(†À­3jÆ×lì`éQÖ-¼šùT¶Ì�Äß>c×·Þû^ŠˆÜù{þû¥Ûn{ÓhiéÓ"5­æ "yÊóOqÈ… Īr`¢8 sûÚ§Co4ˆ¤]™KŒBk‚޹Ú“ ë­ý“[9½ÙálQ¯ÈŸfäÝÆ÷)g ™Ð´Ù·>Xà$`»¾Ðèè‘�”ƒÞW/”Ë›^á™e…“8ªàÏÚ€­„©ç_Ä´ƒ\@‘.\ç,�„áðºþܶ+Ðëy&�Ö„Þ­Æ„6àÕ*&USÁ—:ÉB|ºPHÑëͽގ3fªú^ŽšŠV>º#ž¦@h-~áÝU$´Ù�=(�jÒ…�Šî–DJÛØÈŒ)Ð׃`v·® !e í6éRWç ­óG®‹æmZQd#l�u¬¶£Ö´i ǵŒŽßÿñ£ûØ/‰ˆÜû§zÎQ¬k©Çã*m…¤[@³•ÖŸÇ& ­ý´qïbÎ'1ËèÁpCüƒ69FQE¿ÿÐÓ°Õ·‡-EÑFx¦ Ä¶]„]´ˆ3¢t<Ù¾p-"õHÖI)5&{cÚ€.Ì7êü¬íÊY‹˜�{üc�ýØ‹_p|8±~§½›‚Fò^ºe'·gůC"ù°CDj^´q¤Ž-2Na!.Àt#†mîЕ4k6ÜdôûË�¿ý¾÷óânÀ¿û»÷¶íøAVÕ— ëâö_×ÑšÈè@7‘È%æO‰ùóšÈ7«HQ¢ZíÚH¡¸¹axL±ÜAà~L«…/0ßçæuÏG?ú­Ññã?U­®ÞŠMmìHcU"J`;3›ë)æÙ]`>×€„u}j Ygè›Ö@à>Üè°“Ú¬nê©Ù2Õ7?Ù‰‰žÜ**Ê^¿èõ®4aÖüSŸÝ"JHO5Ó¶–‹'÷êì�8;ˆl\¹ïÈïë·ÞìB¹¼Ù…/¦Äá ÖiátDnH®ŠHüNÌDºúÌ® ×Y$Í=áê¹þìÜ÷–³³ÛµC•ßT~ï­u“`«áLBÀìE–‡ä›FÔE!EYî@Y^u† ÚÃ�—ò1KB3õ$H‰^#ÈÆ„úL?öh³V$¸ÃE­5 ›1�ó{¯‹Q&pjÚ!~ÞçXs7[=v|¢�DÅ2P²¼fÄMLr ¦&‹ ¸Œô ZWŒ{^pU'àYWkÒád¼´x|uqé]"²z®= V•p<kñ)¥ZÔÜó‡rn “ç‹Iq§aaû¢z� ˜’¸:=!Ðëï?ý7H:ŠQªqXÉØ-È-¹ ¿aEL)ü5  Úié$B±ö «˜Œ¶‘µõï ]~¨ø«Z J9y”Qù¶êb–Í…ÆYç9Ï{Öáï~äÃG†ä×8QMUβÝc4†Ý88m20yK]Uë‹)#}~L„ž¹ÑEâ…0tû–‘Ó~_dãZé9�ÛYñíï{ED¾ùŽß¸“�×7.uúÜd8£í=2y9°ÈŽ< Á:¢ß@E=ZÝPÓ XÏK<ëªÓø\KCß\o¯ˆË—¢ þ\ê8ôƒ?ˆ;?øÁ›¸ºúŸX�—áæÖ1qÄ‹†@ ]øuÌ~Z^#Êxû·¾Šbvß‹_|ñiJ˜¬ˆÎ]•˜(1½ §ûn:F"Ë�6x ®÷É˲”²·Ë¥¾íIÑfpí4FdßíI‘F÷2„°•£ˆö0…"E±x¡T>Ã倬ïÐÇ@Îÿ®—f°‹† %rA&éÂuIƒùOíÍÍ=¥œ™)½`);;sà7Ú­í\,о¼V´A¤»kC§Å€¢�ÛÏĽãxt{5am]$èBšŽ+5s©Ph»ÂØ˜È LÀ”¤M@"O–1Å1Š ùVŸ= ”�Àj¾;°�wÁ®®¥^Y‘ÑÊr[„¥)%N ˜$?/·tkÓ¥c sèbL¹8µ ‡/Ö;RîfÙ�+ŸX|Ïýõ‰÷Ÿ‹�¢ɺ^i»VÔíí¥ 1¦X’û?±µuM ^CG¢³­ôùyWšaÝÔŠ~þ´£H:¶èbŒÊ‰1Ú´&p›‚Eä6èºÕ‰n«/ÓA�©iš¬ŠÈÊäÃ*?ˆ†á]d̲oä^W´¥�ù¦D²ï}ÁóŽھ͠ìz¤aºæº¶c·û“V('„b—x•„`Êbû®kŸ³}Ãë„üñ §@}yyñÑzÆe©£¤ƒ×Õ9G ÿÀqõž5’ZÖq‰a-F"ý•îs”¹Y‰juV6“ýÜß±ãQ¾ïúb�/„(`òs¬ÑAÏ­Wpõ†YÚ²=�Ljé\Ì*nû/ÿ…""·¾÷7ßR�Æ_òìuÖߘ1x¨kK´ Rœh-åCHÑãã¢ÙfM„)ƒAÇŽ?m•vd8âò”€êfâK[OÀêù1‹…ŸÝh‚Q (‹Y-˜��+úóz�8³ÿÕŒg1Böp:™¹ºÀyåÔvö^ëðް~¬F°»j³vKS72�¢‰\˜n»p�ƒ@R9<¥œ�ݹæœ3ÅÁiÁœ²·0uœ®£€ŽBÅú44Ü”åLÙŸyÌü“Ÿ8·åØÂxô-ŽÇw‘µ‘ ±GÛÂlVZ±QB64Ó—T9Ý�DþB¬ÃäÞ…I¯>Õ›ìuâæ…œ€¦Koãü¸©G#Y=vLŠ¢°·$ª°³Ù ä˜fP9Lɯôb§´QzWÈ ÙitîÕ£GþqõÈ}ï9g�÷º–ºªNX�MN´Ù4Iª‹h=­Ùq(gàDkÛj;ìÆÑE^`¾.”&†uû*¤èõNoÜ ;Ð ùH«“¸ eEBî‹UzmÑ®áèL8i!rG­�¶¡aΨ]GÅÆ`Þ…dÃ�bl¬ EsÈšpz²…h•³+¯¼bñQ»lD ?ÆÔ·‰["Ñ!œì˜[œœ‹�™4¢jsò�Ê™áL¹mxÃI ¡.ÛæG†ŽŠ8Æ+­'¹4ÏË€&DœlƒçMV|ë{ß}ƒÞÏ¥‰°IQß)„ ›XZi€6–Óò¼,[Ò:ý­�Vèë·t†»÷<�2>°n Ñ(¼f´) É"QjLVÄÆ:¯-à)æm;©Áès|­T£•߫ǣ¥HOë¶y.‚4 D;êÕ0MI-<$e x“n"E¤ìõŠ¢×{Âé9‡7:gƒè>*TCÖ�_jѵDZw$œžÀI¤{ EÑKÇûä´˜œyŒ>SëÆê)rJ†­N‡I� á®MÀ‘:¸³Ö¥�ÀMÍÆLS˜]ç¢éÕ*҅뜒úýA9;{]onv(E‘çº f>Û)ÙÒPº-“�¶§öuÛ¿�$aE�E±£˜�nõí_úv=ßκ–Hr6�”ÑéQ'w�Yyæ/h‚”HPÌù™ÍÌA@ó)õ,w¯:}v>xu-ÕÊŠŒ–WT*?±(6"6裥Ǩ<Ó§3n’æì×À>“Vz}z8ýή³P%$ÕhÄzuõÿZüìg?{.I�ïÕzEI:AÝ?u·Ý¾l|"bE%³Èêh~ðª¼c·2Ž´˜²‹-ËÙ¶9¨³ð6’´LH‚­‡ÙÐë“íw t2Ûøp{Àôˆy’\Š1EVÈ:ƒ^Š„º Ñš€Ô pòj.…¤a=ŸùµÅÉÄÙ\Óö3ÞHrÍSŸ|ââ¹Yj<=‹•Œ�±µÚºaiYNM »ŽΕ nÛQ(ôz%Êò‰¬ó&cg †n, F‡gZVK66ÅTÞ€0q~$É5W8ÿa\½û„F©ÙLâ~ë°‰Èù À‘¤£Á6ðu12쟊áÌÃëñxßôÅáÎù 0Ôèa¢Y¯Mаd§µÏF†´ÞLŒÍåôÏqÒšÜþ›¿õ©Æ_ñ÷¢K„WÉk›CŽ ÔÚØÁÞ8¯¡nh¹E!(ŠÓ6j ÍFóç€×Lêx¸©é™X@l�d±àÁ·–û€ZšºÚ‡9+[`Aé¾z·Xªæ*ºVm…›Ÿ†î¼“7í](•·àR¹x`�¬Ì”˜9újv¼@Ô>ìhŒ¹ÍÖ‚ãöäaÑ × é _óOzÒ%½¹¹+Åh#a FVW ¥ÕÏw3ÖÛÑ®¦ Z(ƒKåÁ­¾?÷ö¯óÿgï]£,˪2Ñùí}ΉG¾³ª²ª¨wQ¼DTDß­b_„ *%O Šg‹Þî¾=ú:º½zínu ×ÛW<,ZJP*¶ˆ/@„². o ¨‚¢¨WVU¾""#✽÷wœ³×šs®µÏ‰ÌŒ¬ÌŒŠ=TfFĉsö^k®9¿ùÍï«ë;¥iDÑ%ø�NQ;æÆ\˜I¦™%tggJb÷º˜rJ9ŠDa²ASÇä!-O£±ïÉ?—“f4’ÑÒ’e¡>>]þ%Ñ&|TRä" i–ÆKH4O߃-à3›:ôž¥ºùMçò#©ŽgSU_3u>t1C¥75lÃd‚ƒb×>°­‹¸@Å$ñ˵§,ûp+óßñøuZ7mŽ© xÝXWê–RÍë[_€èvŽ” Ü9A ©Wa<Þ†±ø¶)± âã¤nhßm1)`B»Âåã­/{‘E¹Z­3+]<þï·Ç·-=ê’‹ªCÛqAסž®Ã€v¨…i�¿4Å>í#Ê\ºáYòÏ19šU9@ü6LÆž ŠÞÉ™ÍM3V:+¯;Þüæƒèõ^eÎïŽÙ Ü&:,ž™º|µú:Тö9ÿ/„±1ôû»šºÞ=sûê,Ò/å욤¿Î´Ý¡`mßé'#+�ö‡$õ»:‡±Æº¹g,ù†ÀàóÏ–™’U�BŲ4詤Øf–�D&»7õ Zr ,G¢&š„¡° Z-!LÇaé#•ðÊýIëô�€ û%4ç›É�§˜èié¨9Fk´’íÔHš *ðJšfa»T~0.mRbm �Çyâw: ‰ë¦9 1ÕiØÇómRÚöuNI½�;¦·0¿·(Êd [ç»™`’Fu´ýŠ^ÂFUDH¦ño¶òäÏcÁíž°9#t¬ë;›ºnZ*»–�JÇ�$I,ãð„·œVz1ElÚ„]ÒÔâÈ@ ùÑÈ¢±ÖÅ]æò™!³óí�-äáÖ4R¯¯I½¾nÝ«åæe3!R'C¶ö3mC¦à,¡œÖ`JŽ�¯‰Ü) ©Ž¯®×ÃÑÕ«kKçò#™;o?YW_¯ñ L¤4¿à45 ¸ä­ö†…V€œ§,§�ÂH³G.‰„{îð¢bÔÔ‘²è÷æO< ‘)28­6uûš @¯ñ*Î�jX)¦ÛÈçß}%"#“OOžNC+Ä‚‰ƒ'[ÏD6îó4“ˆTŒÿ7 ÔÞáÉ´^ŠÈïÙµ«±£;Ó*íb ã• ¢~R�Ëí¹©Æ’iOX2Mr5Ú7Y±û7��÷ b¡b ´ºZz¤$€“™ÑéÀŸÔŽRêL÷Nä4…ÚI��¹( ‚N©4Wâä #HÃT°7’VÃ`¿Õö‚ÓÇÒ¤YUE_šfºŽ"0oL²+ïàsúùF/D›;ªaÏTmVýik¬�jmø¬F«ÐL”LΗ«±Ce¬eóc (òfº†‡‘oo­6¤° ¢¤vâ„ ^“£NK*:Aç"°»‡­Ý0f°¯TÃÞm±ãüµ_óF9q£Ãç„H÷Öø‚Áà[ç¾íqåv¹|º‹;'ò$M}Òõ:v®Ø ¥t�õ¾¦O1n3’¶¯s HZXø_Šþ\ßôäµ$BGoc:êj`fÛa¤�Ó0Á4—eNk¯w œ[øÞ3kšf…Õ¨"›¼ýi2ÒFÚèô;=h;Á-¨D ZNtŒ]"ÛÄёӊ$׉ÄzÊ£:G‹©××exì˜Vž>ñs̰ψv¨ÈÙ3¥¶aÆz{ZžÖRgõè‰y.¿¯k©–ŽýƱ�~ä�Ïùó½n„uóÅÀ@Ÿˆ�Ûä‹a,‚‘Ç‘IâÔÓÊéêø0$¶Š¾C?ªi:ð�zŠ@Š¢ßäfÂG”Ô%0趨.g«±5[Ú17ÉŠêKpŠc—æÊJÕJ8Á PKYÉ„‘„À*|XÂ,5¶Œó$vž°Gÿ®Db<ÈÛ^sõUýý¾ªõÝÎ 7‹ù¾0Þ­�JsNЬÅÀ¿å,/†¶È*gÖx🠱AÞ墠M棧 IDATö �¶âë‚F�6T“º j-°·ÜU×k¬ªÏµŸ±ûx!bg‹—%¸9KrŠeºÒ4Ål„M3]€…û šyɧ5„†Å”36€Ï„‘L³¶ÂuècÿôºfTݦút P$àƒVsRm2ºé2»y¡Rýzȱ*ú§"±‡g Á‚YÉ�&¨ šÉß™‡-Û6²ÜN˜‘Äq‡*Ä98io¤ëÒŸ2)~œàî ‹dØILRÿ¹ýûŸ¿ûÊ«³].Ÿîü?WoRýOR+±õ1LNhsS›å‡éŒ§íkû:W€¤½ßÿý�/ûý‹Q´–QiqJÛ7°K>é6L„� eoÝÎl×EL!¨»bI/¬(½^æ9tFŠÚª¾·Ž–ÙX1[f<% í?Ã'Útm^Š9@I:Â’;rÍlŽˆw�…¥;óƒ¸f}9²AσH(™d ªsôji†C©G#'<*I‘ïKì‡R ºƒ_÷ P·i”]‹À.uSpö±ø>ü�jåøßn… yôC"ëú‹> iy/ÐRØÝH7KË€µb¬îòpAd è¥çTÚ�¯,Š½Þ¥›yâr`tu ÕC\“mL†&ž™ÎUì ÒÎáTé´éYÙôëB¹«‘ÀHª's€c�¡¢Eó3°=bx  M3y�PÐò_Ǫ¦Mý̯�o}Ü·¿lïî&ÀOÚÜ€y g$ç ÔAa³n_«¹Ãf°QÈ‘‚l#À°�rJ³ÈÔ>!,NNç˜É¸9Eåܹë£$ß”ŽºúüHñ·I9Rh&.-Å>›°+1R–Tdyü³áhµNˆ8îIÉ€Ÿñ/ÔàÓ!;A·vnÖJ�Øo0iýÖÛ–€âpÈ�átQ2QËPƒcðEPÂNV«Æoô®Ë£‹í"ã6Ç4:4 éZ¢È©zü‘6M�ãh·hË`ÍFP"MÛ:4 8-gVÉ5…2سç2ˆlI§?š»‘Kš©~'H¹66Ò ÕøïšoŒvÕZÉ(mn�¶m_ç�„¢¸¤\X¸½Ò�æöËÚ×ÅW UØP -Rј” mƒä6E±»·¸ð )äA¡kêêVÕQaÏÀõaC•LB�˜‚Xll­q3öº™DÐGošÐåˆDaLGu½®7’$5&(DnUœÃ—b#¡(†QâÇb2çu‘ƶ“æÕ0AFŸuƒ…*ݾŒ ¸7XÕRG¿¼ô±þç­(›ºº•iújœ"ánÐüZ²YG–0�¦¬†;k3ÉhàëüBsO&îL-“ç]öûçofîãGGÎé"ˆ‚ó\ˆ‰" fÖyÇï?ÙXpTöK $‰õ&$„ë85¤�Æ Ü�”ÔSRŠªrèž»Qãfñ ê[ràÀ�á�}{šv†¹ub zÔ™“ŠÔÂüùbÑL6‡øÂ©E$U+"¹1 Éy h½ Ñ2KUã pØ*p#Áa˜ù guF¬¶�fÔx)ýüqÇK;@éQµq-›°·9‡�âS&Ú^i6LÉ 3jöT î�ã iÇ‚xÊð‘G’8JòjQâꬡ«R›Ÿ…³”¢b¢(¹g›à ±F`RôûRôʧï|⓲}�Î" 4˃Iû_è£îĉf+6'|sˆêë �ËSNÕò¶Å¶·¯s HB1˜{²”e,Jšìqå 36ÁúGh:àaŸj‘ji­QWJõ’Ò­zÅyþ�ª«áÝÍht'ëZ¤°B½Z_ÐT6�¦wÒeí.’÷e>YPâp ŠLédàC�à0ýµ@G�Í|@r˜ùÙ«OœÚꪒDã&¾ Î Ñ5$Ž=™´I±\Rê¬ÞCމDš·^#:9L4ÌÆZ»ÿ¾¿\{à�÷n¥@¹ú©y@‹»Ú©6=Š›êy˜ô�0ã¢À�E4Åwîp�Ž\éñ� ´#d½ÞÎMÄ?Õ{AŠ 0vµõJ£2“ äHÂEzºýŽª‘ŽröIJŸF(¥ˆH-f´-÷q‘FU²­�¥¸d¬îÂx%¹]‰Üð\ÙëOÀGªiïñØ 9ô™æžJ2¢aG …<�wŽv­Ò1„ÙŒ ó’ˆÔ“­U´šêº/háUFÞ«Ž’]p7ÔB* PkޙѸÀ­Û5Șèk´#–°ZæY½:…>l¸ïŬêyX&Ÿ¤”J*]-ä5=½¢Ø—Ŭ¸0˜ºJm‰ i/Žq¦…\ÂÎÌ€Œ°€]`i àõô4 ¢˜™†j±ìvõF΀>tÀU2îšÍ,èÔŠr›8½õJ(ÈdV7s€˜‰Q¥ßGÍÔ¹�zƉV¹ÑçÓfæp 9ÎæÔ ðìkظ¥u‚:\ ¡^# ,(÷3mF�h‰µÁ˜'>:˺iØ4˦±$‘¡—ÓLÔ¨Œ•d@É*Ÿe¨d0  ¬b0�þž=¿|Á3žñ”í²ùtÖ*4É“c@x^]2Aàý¬ãÏL œçö@ÛöuŽIÕý÷We,ÁÜô2옜Jý4©�Â&h6 � ß#^[Zů(€²7:røÁ¾W£Ûn9Ϊ¾�u£ÊyVñÖÓã!DE‰®*±À„áÔ)BÉDð’.›¥£MjW7 c H|¦6޳Z³èþÞ³ôbÓ××…Uåò ç�—¼+PHwC¹™-#t“-“wÂö™á°Â\ö )k‡ýÊÑøðû·b°lªª‰›¬Ûã"Ú­ºV¬/£©ÂÄÿ<$ì9§ “.dð+(Eª×PŠAqþlr±"A@_�ºšñ¥%A+‰îý+V’OÈ��Zª Ç@é?‘j°±U¯Zâ…IìÇ�¬™ÄÊry4j'ê7ߤˆ� Õù„"lHÈØ›Û)6æ¸)-[]uSâ&Õ¹ Ùˆé ëP¼œW&HÁ�!³}>SsÛÐNN~5SÙ½ÛŠ7±èVvÜTÖÖ"aRñ1Ù[æ L-vÏ"¨Bö\˜4Ï@–Ôk²½s¢>·c àx¡OÏ3јœzí0ƒƒùùÖ ÏÎqà$�CLÌS1€-3ÚFÖ9bhfP, Mƒÿµ-ãÚb‡½}2¹kÝñØá9¿9¸]bÉœš3¸µž•‚œ€FP��6´·qHÓLYÁf;²®†RUŸÓï9@“Xv�·6È1ÔÛæ{§Ã®y!7vFÒ) .¸¤¿s×oíû¡¾r»t>�Ö eëØ:a"+b…8Ð=K±T³ð^* ¾®h¯ŠmRÒöunI{žúÔË‹¹þù‚"ꌘxÆÑ)XÊuB•ÏnÔ`T°›@èú‡T( È[Û�á˜5Òï_t®˜ 6¶`: ø(†}˜,€ÌHgÉ!M®¡a`fÒm¿Å¬Aö9¿@ N&]8h�7ƒa€‘M¤eóçðØÌÓ¶HÒNýV'V§+cZª¾Ç›è IZá~øZÀ/AÏ9 FÒðØ‘ _îÐdìÏèXuhm(b]ökqO Á'Ò¿8$ñ÷¶kcÇe—MMCCs:$“`&º¬U©iÛÑÄ«çŠ:LŠE`m_Û×Ù$•‹;ž] ú;‹®Cï�µ½–éxFÚ÷ î†¶AÕUaº=C Øš}HQÍhyå¶3q¿šº>\�FëÚ%-g†ã‰�Ii)»ˆ…/ÌCΦ§ tJ´üÈÈ|i_‡é8Z:¤¦üù fÎ>ÆU¦FØ‘šlD4ü$6OqêÛ‡M#…ÕH¤7÷luø­e˜Z͉ÖxøÎ–ÈVkÄvÖŒs¡‘«�£[OÞWÝH³ºúŠ¥›oþÔV –ÍúúR’¬Jªë" Pp,¤‹èTô‘¬ðUÜS„‰]]ND9ÝÌ fÞjQ–ýþ6§HQ‚áá}"M²»gmý�Rü7�Ôt‰ÖÑ6 ÂXç Œ¶=ë’Ç ‰l•€šÉ6 =ê‰&vZPH� ã—ky8ºï=æZ�iÔ¿büw  Ûí®ZJ³…"Ë05A EÅ}3ZÄ\­1MMwè=•È|Â4“$±m¿?;Îi°$Ú8æÊQ¬�Jä~ F_ˆý´l�|ƒ‰nºLŸÃQß-ìkƒø¹�UBÓ¼CNAdB¯Oú8ƒ” %£o¸¸b+AI”^ÛT§tP~®9©u¥†DÆqF‡ý¤–*íýW^¤Ô@Ž„f°ÒCkNué4UK+ ƒøøÞŽŠ¹öã3ÇCšGC�õ¶�zbkçž?yWÕß½çf½¡-52˜“¹´ÁŽvÄ …Y“N±ÒV1ÏcJU›ìÌ—^ú´=W_óáóžþô§m—ЛŽúº)³Î‘rŽ·yÚœë@üwd„Ì(<)†Ýöµ $�™­S`€²uK°‰�ê$*=—ÌL“K’ÜïТÌ£z8Âôg…Õ$±€HÙÛ7Þþçž‘¼ ªqT­š ¨­JÞ�18“C4ÐÙ}»6€ TBÜÌ竾à0­>Øb²#94Dè®Æî22ˆ¡ˆèƒ°Žœ¦¢þà¯û–�´²"R®^‹ÌF�(é*gÜ72ФQ0ÓI#% ¡r5јÖR¢XñɲvðàGG+Ç?º•ƒeSWw'óž^"�dfOÌÓ4KG@!ý)]h9 ; Ã•Œ“´¡À ÿ¨Í*b¡öxÌy; à!d3Ìg“#vÜ@ ÙEöDp`’‹÷íéøJÙ*j }”òS<¡ GáŠV7iò©Æ`ØH ,y2wW‹Iêß�Ò4�(i+[Ð;¨}ƒ¹‘6ÃRÒÊ̦èpú{Ɔ|›ž+ÐÔ/‡dnÉ… f2ŽaNßCƒ˜â˜(íØ:8m‹ž›8Œ›$.}}É^ƒÈ4L_Òa�n�q­<Ñ•if.ë”™�dµCÿ—ÈÀ%m¶s¶éƒ‰è|´yßB cQôºüù’ì=‡Htîf’ ™ë Jý¢¡Qâ'䔀$«GIØqÌ#!;P à—ïÑø•JT”’qÂÝðçhÆSš�ËgŠC%¶i �ÑJwf‚„ÜÝh÷æ “ká‚ ®Ü}õÕ<ðìgÿ»í2z³»NÒY0Ùü�QSÓ7ÖáØÇ`Â*õÝG[Ìu`ûÚ²@ÒÎï|Â@ ‹(JvP€.„Õ…AåÈàò%d åF¡§}œ�&™¥T·ô^¦9#ÙëêÞf¸~ëZ±«D1¶<<@ã@é87“F*+IMÿÓB0Ë‚;Dd˪�q## liê+Ê�Š^4&WÀÂ:ºœñ#cÂF’jñQ�ôÂ;*qTäÜS*?Q?CŠ<Îÿ«„Z'F�6O«Wפ®Fÿqåã»g+Ëf8úz¹a×œÏÆ»¡R•ŽC�n“„›Î„ѾÕÉ_Ù×F`Á0ׯ€²ɦ€¡>µCQf`}Ø!dëÒM#$�‹ó’J Vá…”ºÂôëºÿüÜg(&#iÊ!¤h#{Ú€Ò´ ¦xç'®oX²‰ž‰=@*!Ø Û)´é�B½>x6{Ž70q˜Ž´E082A˜kY›ÅÅdÇá§ ‚„tOV(}5FZ¹ŠLM�æ¤oÅ®,ÍÔL}{�/õÁ,Ø~2 ‡M£„½-Ðé›·IºO¨“îè„q–…ÈÔåÆl ëû,‘ÞŽý=W]ýÛ—¾èEÿ´óÉO~øv9½¹»Ø—S´*bË<:Œa�ºÜÉÄ_D]Ul�¶m_g9�TÌ E¯wúýRO®Aé°$ÙP&Ðkqmf-“èS90´ú ˆ¢‚ÞššŠö܆Ӣכ+柸ÿû~`ÿƒ}Ïêµµo°ªîž tÉS™±Ræ\éÈ8ÚsPGˆÇóíªÙ¨ŽàI¶OÂŒþæ—(I“§A–²ÓkÆ~(‰•Žé&g4UDQÈèøq®¬Q_ý€¼ª@îAéq–T—À~½Nc‘¯¿ІŽ¤¢ªS`_Y»ÿþ߬–W>¹Õƒ%ëú¢æ5Ñ 8'UoÂ!AgÖ?‡s: Š_H¸BÐ -™gN·ÝPRôzû6åžø$Ĩ$¥V¶Ö¡ˆCæ“ä)HL0%ͨÀõ ìí}ÞO�Á#!ki§Ü$ލ!šñ�˜GŠÓŒÿ'‘…EMÊÑ*aEW �¼ÒÙ ®›<(ÐŒ :¬§ ÑÊÅÌÛ{»�ÎÔékz!ØsP±2ž0p ‚ö½¹ó ‰íÉÈž8æ“GY7Ïtü¬‰K­ãfv J#c²zÃèZ.1èN0�#¨d5„˜©j¨jyÔ³�Ž.V¬SÑ B T¯aÒ®d6FŠb`©©/Ùµ%Ö‡pND¹p¥¤¿lîM#t{"�ÿ erÒš¾äÐ8%=ÅKh»¢Ž•�›ahóJÏ(fH”˜­¡5èfšÐì��\ÍpØ4kk‡ÑžóZÑ¥¶Å,ŸìúvdQßw$�1×…eªo…Œ  ᆭ޾ZYÈü…>éÀw}×—ö¼çýþvI}Ê ¯tö˜’=ÒA¤Y¯¾Ü¸§Ô�B\Pé­•#Ù¾¶¯³H:vóÍËeðˆ¢,�ÒúÙu°Õ–¢tãMv,Miï¨ßGÒå¨ÑÝ Êö6tb‹¢(ÊrÿÑ/|åÁ¾gëKK‡šº¾£™0’Æ4bº²2&í þz® Ð…‚dXÁÖ�ƒíè �y*`1aE­ªˆùä.M½€VHö g°¤©¢ªÌùg”�f8vj“TÂO'’'mg�N·LN¥÷Žbº´Edp`2®6¦•ÔSgœV�YkFë¿wü“ŸXßêÁ²©ªÏ7æ!xÅ8�Ql:J¾…fa×µf'azئ|øw&{´. Å ¿ssSZ6öDð6J<0à ¡ U¥Ë�L"“Pl¡‚|È™f¿ÐyNÀŸbrLÊ�FGáá@ˆdàÈq [—¶ÄŠ×Si‚†Éš ÜZY�FöSvJ&sÃÞõL"Í8¦N†ËøO�q±é… 2†vÓøT°k_c!P ™’Ó[²q°S8qË5X1ê?lSI”^—æ R·,`ÿL¨ñfbê-DrNpö …r”úÏÌ�™ `RŨ4C³¤èö¤3•ló­3ÑqásžóC.Ï Ò&æàv{Y÷y7ó\4�> ì^Ó¬¦¥d¸)'P2¾9K “Ö¡.È9ès3ÇžíªàyJ ¦Z]]©G£_ Ϊ @½î“Ê×ÓYm.ÉG¦}fL ÷¯€” ó½Å‹.zÉU¯yÍÊe×]÷ÚíÒú$2¦ÿSÈÛ;x¡ÝµX¨�Z¶R+­±ÍHÚ¾Îv IDe¹Ëx”_ƒR«ÎHªÙCé¢)\Ê`ÅÑ.Ø.Îdƒƒ¹ ÷<æ»ñ IŸûÜq6Í×¢S�ë Çž˜ ÑÑWu-ƒl¸qˆRp‚RL¦…\2ñš”&¡•Äa™¼¿Â`4YŒå•ý ÐëÈ<åƒÿ”Ö{QÈðøq_�AZ%c("[#f ˆN^zÞwäsÌT±ÚôÂÐ ÉA”“ eC;ú²fåÐm…`ɦù¢�ÜÈ–étõ›.š³BêÝ2ÆTçíºe"IÇÑ|?ÌшƒýÞÜ&ÝKutÅ…Ñq‚¿Q.nf~ÖK¬)F‹×>QÙUD9¹ZPÆ#PL|&L#6æ6Ì©OÛÚ}ºBDЉ»®­ÊâSœŒÂ¡h¸Ã‹--ïZZ)â=ÈŒ»ÌÞŠ=‡Õ´àq¬â » IÒÉüy §çÆmã >ãì$ˆÁdé#Mò›¡á=-è›e� ¶”æÃ¥ÏA¿^]}:gV'´Zˆd`k ÅäHhT]itÁ9¤ŠA)Ögn=%=�d:•Ÿ´¬åD ÈR¡'„¦R$dWMwN^½Å/.úý ´f]ª°™5ƽM‹ÔgÜ0騃Ö_fŒÕ5 CÞF9µ”3 g0g³RuŽ’×] lä uȆ܂7pÝqã�Ãrnî�Äajü4� GnG–'£ØyЙDÛ|Zƒ†Tf>Œ V!è÷¥·°¸8¿ÿ¿¹úç~nùò—¾ôÛ%ö‰¥ VâÍ5`7:ï®ìšAS·€cu&âw°šgÛ×öu¶I$+«9¢oÛ’4y]2¡FNÐѹ…:x¨‹ñIG"Z›‹E�4e§(¤,ËÞÚ¡C÷�‰{ÖTÕÁf4jÆ#™ºÎ=¤?Ñ#ã*{H¹×Õqä‡Ç‘(¦ÒH29W¼hä[2…¤y¢PŒ‹(ä93AÍW?ê¿§VXœŒsëZ¸¾.¬«Ìü²(ÐÈv’M‘®ZôT~*¯(X«̡›.›ñ â­?pÿWêQýî•Oßò�8j8ZÿFÜJ9ñQ?öÃÔ VH±‹4§�*m‡—F=tÞÛñÜ<hÜÞÆ‰fÑï=â‘Wl 0:-U ”x¨Îo`ÛÄq)Ш<„¤• <5±Ë¶Š3‡“Q8�ˆ §•ƒh…ÉØ2”ƺE�Fa&o° ´úJMÜžÆÁ™­vÌä5ãßÇÖ^;~ Ïî ‹w=Z]®l·éÅÔ�ÔÂMÊf]9eå³[�_šÙ‘õé!x"ªh’Ú0&�Z# Éžò¬Šeâ瀇x´«ño¯²|Ž_ôö ,®ïbM@Ù’s22žsǘ{ÔS„™ŠãÔ¯ÜE¹¾ e1œµ÷ Ñ­zE‰- ä¾YÇÕPPÑ ûFýo$i@+Ûzz ô.eYž¥ø¦žJµ ÜÖms²ïiÎ-fò—h]�YxË<\ݼëYÁÓ©eœ¬×(¦ÝÆ©v�D�ÂDàÚÜO1Ú®öÛ¸ÝkíX�&j¶ &ó IDATE5kÉM%(`}R¸—s{vÿèfg@`dqR }8®Ü °‰؈°³ªaP‘há�;*Nж-^åøVµ¯Ô´›¶MífD1n p=(§5A!×7Ä16Õý@Ñò§:'ô€B¾xË—çî][W³>2}ÆÂâ1ùoîpäaT§×ë¤: ÇŸ}mC+HzAȵ¶ÃÖ³êLìV[+QLPË aÎPdËÌ·厢×rtUãœTƒ{Ý;%¸j§=±z4ÉÏA•=lA(¶gÞÚˆ)Ι�™r¿Ï2õò ½6‰€G5 ¶…VD¸Îû‘¾´\>fEÂå¼ö.Cw0ôýnÏÆ<9{¯ âG½I¸¡¬c^yø”Nf(×pìÍ”ÕBömƒ#jÁéb¼K Ak²CØ{£WµºÚT««ÿ_|oˆ[¢ë”«%fà{º©É°öý™ š�³ É:(W°–¹V”¥ôç»v?~áüóßÕ«_}Ïå×_¿ÍRšY$HZ9Mûlÿ{* Ö0G2Ò%ÕZ¨Û×öuVIE¯÷ƒ<Ú“È,棹ÖRB¶nGÃ0IHfŸ`jµäØKèxä¹3KÂÑê'P@²ãENóź–z¸.­ã^ëÎ`ë: ã�€`Ñ åxD5™¸Ül$[†KÒÍßuæ]ÈÚý÷ÿéÒÍ7ßô‹—�—ˆÑ¤3>`œmEkÄA1uB*a”x j&Š™úíjDŠr0xÂ&áGf²È|�âRÒ˜hB3Êq-ЉÚ5íÇj�¡O¶zK'º¥'€¨"2m ^y�v#O! S­�I”ÆØ3�™H…ŠÔãÑ7˜œ §‚�ÜèHi¨±].*Ê^»[Ò@I£À•}caYW€)&QTŽc7,"GfåÄÈ‚9„Êê¯}õ­©µ6€ž�§™÷ÝB‰1P 0<,¤FϧŒ(øÂ¾žðZUœÑœ Bؓѹ¦i¤,§ëH¶ŒÆUj@™¤Ä‚ö¦ 1L ½ÆÍ¶¦]¢l¹Žüü�‹½ . ýZŽe—÷c›‹„yÃ× ’3NKaàø#UŦ®¿xʇ�·;ÏŽõæÅÌj±-k›TgUšYFŸJrtìØš×‹yF¹P 3žÓ4Š„í¯çXRÆÉ * k¶usв Èøÿ1ŽDŠˆ”�rq¡ìíÜuá`Ïžç^õªW­\õªWÝrÙu×ý_Û%¸?Âa~š‘nÌL¹›>G»Ä‘ÕIL’LtT`Û×öuÖI"Ò lÝ‘ÜÜDg¯m“98˜-ò&ÛÔ�'ìmÆ%¨rè¼ (æú ‹ß5÷øïì?Ø7�Ãê®z4¼G࡚´jˆ‚¼L›µ“IŽ©ßÚ…M1)&-+"Ô™àÆáèLÌT#»¡æ9ÈÉæ‚~ œŽƒ¢(d´¼$õêÚÄ \º™Vß×éÜXB12`Æ 6Møü k <Èï|µ²T­9ò Å€ÉѨñË—â4” �\9Fcn©1Mr§çÐsSã´¬�a‹Òôz›£ïÖj5X}vãø¨üæR©(«zjö¤MrIMë²c¤wã R%Uá�$ØçÈæN|2P`›1ïÆa…JâÜ.6ÙöÇ‘#Gû÷>p¸ÔɧYª‰&�ƵGïÚ¦uLÚñh¦ò1ùŸNŽÁ{6€1N~�1– "hÜݦéþ@Í%±rÏa࢛ªÝ¢ÁçâuÉ ^8W¯®=‹¹Ïëb¼TB´˜˜¼4ºƒ ½‰Âº:^ôÊ¥ļ ¨X•âÌ8˜èmØÖ|:&ÏT&zÏyi�EÙUÎ ¾OÏÞS��Ö`†a‹Àå‹SG�32sˆéÕŒF›æí§tô„çÎîܦ#'$h㓺zOwQ³T½,#3ÿ$ÃË]ïx÷<ö±Ÿ­×ÖÖüº·g&Uãqrþ‹½ç@F¤9I”² B/ÚÞ„GïѰÛf…goÁ`L´{=ôwï^ìïÚõÈÁž=ÿ畯xÅ}W¾ò•¿ô…/|ÞC½·FÖY¹uÚ4à’Ö¿kŸÔx±7Çpy:·³íà°®×dû ×5ÿþßm#kîê�é70ZYùÒBQô¤(ÔNiÊè6…<½Õ•Þúß}#EŒ&€§‘š YöÐë]³þ©OŽìûÖÔÕý¬Fw²®‡¢Ìê6©2§=8#”¥cY§s0J{uÀN¦�ŸŸ³6¡�Uh(ù¾Ô c 53ë3º\f!'S‹Ø�E&ëFªÕ5iêZPÖn™‘Î`ǪôS´–ARÜÅÏåk�ä‘û�ê뢶Êúü—ãŸüäÝÅ€Ù¬­­ƒÁN‰,›Lžý¡›ÅÑëÝÇ9@rˆ�oøÚ?[Úà¦óÁ( ½ÞÃ6 " ÚìÜö°a™b Q‘‡Î�›¦+ŽÄåÑO5ŸÄ»—žôz I«QÁövÍ8îDÅ¢ ,ÿl’Ö@ò3�‚4žŸC 'OJ¬øÞÿ¾ì½õБBÿxÐÇ”bN`{!J¬_9Ð8áòç-ã9ȪjH~yÃi²ÒðBr¶*}8ÒH¶YâîF?Å1¹u4’ÊùÁ…MUÿjæ†:§Š¸Ïàö¡ÿ¢uÕARR¶ dÂf8º·({GfàÐèŽcÁAc+Yä°¶nn?§@�Õœ}5•æçbµpéu/Á7o|3/yö _�Áà²6¯�?ŠQu³ÙU< ‘¸ŒžFøöÌ78E¸jªjõη¾õïO½Ï Ýth„w¤%�–§‹@™3ìa%;·ô#Ÿý,DäçËùùÈTXŠÛÞP.èËÔ œn¢£]­?4SÆ÷%¸"2—ƒ¨Ÿ ˆVžA[ÎÏåüÜùÒð|ôzpÅË_þ›"rëheå×îzÛÛÞÿÐD’蘌¹œrrö¬ô›°Ýì!L*µi t¤êš?ÿü¼âú—Ïó3=X™9p|™—K+°ß®=èÌd“¢ÓBARç´±O7B8Ã�õèo¾úÛÿÏ‹·¡£³ HZ¿÷ÞC¸æŒ“Gª�#’_‰5˜-Ðr‰qF¿'aëê•&g•Ñ7)P ß¿hÏSŸº÷èM7y0ïÛÑ›nºgî™?q‹4|z"…IgúL;FÍqtrÅŒgwœN>)™{«‚!EŽ7âpáx�¦S’ÑT×ïZ‰Ñ"ÃO˜=ø–¼è¦I( ="Íp((`ÆrLCÞ8¤Ä@ ×j'T2œEó:ŠtînY?$dœò2sŽÃ£Gý‡�üׇjÀ¬G£C}‘�YÃ$\ZøUÒBK ÀZ@†3ÏÍXb!@ñ!ªòÀ dúÒ¯è÷÷nÚ�A&^ˆul3†˜ƒ0½oZ @ÇkÚ–Fqè1ƒwÒ� '"ƒ°£·$|ÅÓºB\ßPì»,„ÒÄî­R2¼Š(VÀ§oùÊ`h· ƒ–‹Q+qÖæ-ãÈ=Í/ÏßY½˜â²^_¡×{çF×G®þ‹ã¼­Ã—L{ÐÉ @³ý2I#Ì{ÖÅâ¹ßTdU]„²wþÔãµe$“Î�K  Ù•èî3fœ¥“¼ hM5(£ååèïÚ=“µ&~ÿ‹Ø±*ñÍ#J€Fäã¥}HÆõ—9‡ÏE¨ñ’½ß¼ñͼ觯}q¹¸ðœ¢,‹l�ˆŒ\¨sÀkóC(ÑŒÒç6·i™�ÿlÎ�Ûsöø¦³ËI»Šej0 YMš,yÍ�0¾Yy*¡åozSsàÇüͽÅÅÊ^ª–Yâ6ØM1éôfР,]­DÐÈ4E˜q1‚.¼Ñ­Õ–Äÿñ ëïØÑ—�;/e]_Zôûß}ÅË^~›ú“ëG�üo÷¾ëÏMK(°H�¥ëÛQ »ß�“‰X§qݨ†qäôÉ~–’*½;z½;öeW!Óš�ÒM´M ËŒ…ÒÓLݧ‡qšÔu£}é×%­!G@Èü@Ju|å»¶a£ô:ã£m£Ûo_kêz¤;¤gÍx”Òt½Ü…nµ¤å2|eºÎŒbŒô’s„€ˆ”…ýþþz´>&jZVõ7šºN>TÂÚi»“ÿµ³ïæ3ê(Ài"œ©l¤8 ¶ÁTÍ' #3—lNaT‘êw¥£j³)ëë>‰¤¿®¥:¾6ÖFò ½ú3u•ÙQ× ”~º™¤Aòt0®w;ù®DS2É}3qý�^ýP˜õpø�V-'uÂr± pš">Â$`òÌÑ|ħܕ3·ôtÀõ°Æ‹§è÷7Aß­CW9‹e T'þ™`–!Ä+œ]À�óòdÚÂHÒÇ" «u2 L[uI�4IG‡È�KÁÚ»QH|ÝqøÀû?¸ï«(ãÆÌé »½¦F(�xèyâL²‡ $l’ôbXUÇþٟݲ‘Üx|îä€/榱U}8MC̉(Q]B2±VÎyBÒe׿ô@SÕ¿/œ¶$¬C™¸1%nØáÙŦæ¹PŽÝŽõÇoºëm´6pR7iæèù·®pi‚cdIp” Ñ•Ûúy–^—¾øÅ¸ó­oå…?ù“Oèí\ü­²ß_°ÂÚ’›¦H6Vkp:Ö ,sÔDxbú]¤Ís<önÀš9¡ôd²a®ç§ ¢‡Âº× ˜Óí;ùëàßüM]¯­ýj2‚”‹«& vŒ%ÏZÙ:øæ¹…^Ó”Å�fæ˜EéCÔÒµ(KéïÚµ0·ÿÃçöîûéù½ç}öòë_öñKögaô| #Ij 9hDæDñ™Íz†6HR®ãÔ²NòÒ9õ›Ï§ücn?vœùÇC?Ñv5�’Z… Ã9ãÀ‰LÌBâ£ïfκ†ÃmØè,’Ê‹ö”Ò4U+Ý™é唯5€4m8Z »Á4"‰¦4'xÂA¿·Ø[ÜyÞ™¸wMÓi&š-Ó2Aà s› ‚»ŠË�x6C�HŠ&�ö0�ó™!‚Û•Ñ*vC+ÊJ¥”•š9‹DP2\^–¦‰Å„ñ�SRtŽm 2ÈYb§ë9oEîN¢~ö@ÞÙƒ”õÇ?¶ôOÿôgå€Éªúrc »¼.W'•\�Ø0ªr Kͯëšg‡{ÆèòàUA¿?جäÇþš.+$EÂnm¥?‘3%à  ÊÒÚ'QöDd.wöÐßi¤RöæÛ™Ûq&-bk…aE�”£GŽößõþíX®+˜$½ÓîL?‹ü�I†Œ–LtÙÛ’¤‹ņY¸–kÕ‰XRòw7ƒ! =¬3€’Õ ;÷®‡=ï…ýzmø³Å`î±.Êœ÷LöQ'xëê:cp§ÁR="–ÔÏ7ÍÁ Dópu~a¿‚ÙÙÓ¸«›3Æ0e®x®¬’Ë^ò|ó-oáÅÏ}îÓz;vüqaáPØÜ�Ú`¦+ŸÓÀ[¤A@ò¬µl‘ÉÜZLY¶¬kJÓ|jsen(¡Ï�;F�vNäÁæ§š·ÿÞïýjµ¶¶ÂY›&§¹Èé R¼e¶CƤYÃHŽ<†q6v—î]‘*Y§ ú=™;ÿyóçíÿ®¹½{é²ë¯ÿòå/yÉ{.}þ ~DÿÈÕ¯ùù-¥YCú3?ŠÀ›`‹ÛX?îI>äÌPî‚Þ|>·ÈÈÈÆi Û0Ólü¬¨¿Mã1§¬atÈí:‰Ǧîl@ºñiæ›Û×”äùŒ^;þ­³iÖ"íxÚL£¢XŠžÏf4sÈ¡²š¾†ü à•"ô¢65 )ÊrN(œ‘€SׇšªZrÁêCµ�ÉA�¤àcÂ) 0e*-ïa×Içïw:J×U%B'°~l¯ -OJÍ“ '0â6m¼mÌFZ‘¦®d¬g•WB„— §Ý�M¼½Er4[]1Œ³ÈöDìÎÒ¹’LÆ:õøb½¶6Z¹óÎç?ÔfSUŸ›T'ØXqÔ•TÙµ qÅ|2_º�…èt,R½&eå×¾ªÖÖVEðßO}3tÑ{7r xs“ô 3´v o®ˆ?ëáð/ççß�+{§š b†ÖgÌMåäf= •|N3ôK»Ez$âþîÍÏ÷Êùù+Ù4WŽ––žvÙu×}‰MóÖòßo{íïÞ¿5"; f&}2‚è¢.rJ™€E¥ôϸ‡jeJ¦¬û®|ÀÉfªÈÅùœs"õ½:‘fNw‚þ­ãbfqY…šô…ru-©kM 0s+LÄŸ–ëŒ3’P”`] 3PÓÖ­woCŠˆO«²ØqHȬ©Óœ =°µ.Ê^¿\˜»ò I4ÃáÛÇ›ž*"5R»<ÃÜsºÀмtFטgç@®iÄz:ôZÃä“¢ ¼É*aM£bN ±«À £å%aUÛo?f`ñ#ÿÎS+[¨C]é<"ó#õþ‚�Ãäõ©XNfÚ±idí�þpýóŸ¿í¡09ª>­»A]’…œ�,A9¹C�^‘�ñ)P¢$.‹’®7EoÇΧoVò“¯iŽÞ–¤ž”§ûÙR˜3~=££N°ŸL€$±0z˜Ð^r™Ô'·‘í Btßö¬BÈßþÕûÎû›Ï|~®V_«ù†y”NíÙàÚ5¡h ɳ¿`cÆ8§Ÿ<Á†È—7=‘ˆ&»wE•ÙM+TœNaè‚çf¬LÍ4 ÎÁë²½è V£‹~gW€±u�¶ ‡Ø£ŸÙuC§$¿/MÃ×±½(ŽFËå`0{| Vª C.Ë€ȃ�,ïMcHþ…ÏåÚ`ïÿ«}û™kÿïþŽ7´ R6.Æ%ΊàFãÏ”µ3â¿ÛÏ’j%‰ˆ°iîºãÆ¿pšêð�ÿ˜ec’]�”—¼9ä7n¸á�ÕñãŸOV3"U´ì_nt|~$qÒ¨DŒÖˆþq18Ï:~³÷ÊÈgU( ìݳwáÂOž?ï¼_ìÍõn¾ìE/zë%/xþ�ŸûÙ%&™Çü,ƒSpDQl"­Ÿ[æÒ o)Ÿ/¸Z Ì×óT£hôfV¢:sm=sÚ¦Ý�1””gLiyÈiØÖ[ò:ãŒ$²YmªêY‡·ƒŒŒ˜IVrSg�‚²”Áž½ûû»w?³^_ÿ1�Dzi… }¡‡|Xq­&÷ÓóË»BÒÔÍ­'Fœ6ŠgÎÀ35 jâNzž™ÁSÕЊÀÎÊá‚g>swÑïÿ˜��–º¾¦·¸øÌÞââ^”EZÏÑB5—iŸ&•Î�ÙBQ*…~N_EÏ–n†Ã¯}ó-oyϦßýÑ﨣ñǬÝ9\~o,."hÕ—má+bÆ9qF`¾þÆ7þ‡«^ýê§öŸLßk¤’Wv-‹é‰°¥ ÐÀ©’9{RIþKd'�2KìÖ¡RêŽS¯Ç¶&íU�Fë=Y˜³±Å}¤ÓäR‡R'É0ޱÖì;?‚a™h�£ë*Ùn;§ã�®þü©7ÒQ ÷Л'©?˜NhN—%�vdl¡UœØæžÜ;ˆ,è�в‹ÔS.Úg;îÒ¹hâø ñ9¼÷àü½ß¼ká+_þꎷýãÇw²i⾩ƒ™½݃¸Úµ$ú§ò Q×Ì%:g}|õhÙë½iñ’©TÖ*ã;œ™̬k«mFQ˜iRœ&=“S½.xÖ³.`U}w3=IP¼pá ¯BQ¶¢_ ó7>[¦£àíH´º)v] )ŒµÓ�)Riõ-�m¬X@y¸´ô'ó.¼ûDOU_Lw>�Œ›¶£b7°#œä¦cóÜÝ“Z$Øõ”'÷{÷•ý¹…¾”ÅbÓÔ»@Ù)äNŠì¹‹ä. ¥ábS× .‘oïíÜñ¨r~~ÑëEÁÙB�D‹õdS袰͓,‚‚Vh73b0zJÞùS,K©µv³n[�V–ïápý×7­ÜN„^�w‚LÀ„‚Y³ö€ð(•íQ9édcbZâyŠ×Ýò'_¼ìºëþ#€×—ós{"îÏlÏ"Áz�ï8QÕEdÌÍ™ {Òˆ¤ä�-ÐùÿùÊ ª)™Ì~0޾a¥FÀ Ïí”…ÌíÛÑ`ïÞ_ªVVŽ]ôìg¿ss¾ûïøÛ³BšýSSY¡;3]@úÓõˆ¨�oE¦2áÍsPû]�Ó"wæšè]Ý�…®¦ fGv“d*ÑM+ûàÒ¡DFnIg3�$Uu‡ŒF«BηFróøÎñY˜îƒödf†„?h;@�©¹±(?˜¢èƒÁÞ3q뚺:Z×W„;wI‘ Ûiû"-@f€�ÔÛÂÚòzÒ·ŽÙ.‡¤ ’¹'϶%ú‚ nÒa¡Jž$ÚÌ”ñ5’†ËËã¿—…²‚&&ˆîØu ¡ûó;Ê!Pg j~¤Ît!Ưެ®=pøxövˆÌÜÊj´$ÂÝÆºÖ!/»B'v1N1 Ygј«²-G{RcJ ÔŸCv\ôûýMI}2£Âí ¾f)ÂqàÒ£Þ�´0“íÀ Èå‰AÄ7üóÚ?.ˆÈüxû6-é¨Á… x @«rÖˆU^‰êgH5\/î?xߎcGŽõï¿÷àÜ_}è¦}·9V’�w…ÇAT KCAÇTÜ<>]Jì4Âë Ò�ƒµ‘À.¡ +(_׿pß»ßý™‹¯½w¿ó�Ó˦I‡>}í\÷Â�ÛÑ€”LÄacl4Ľ®(ˆNÕï3vxæ³^�ÖÛ¬­�¦yþÜ޽߇~¿´Z/bº¢š¥/fTuúYÞ ¤•Ì0=6u²RýâˆU31€°ïsœsúô§ÿßÑ×n[ßè9jÇ3ëÊ¿Ç �ŠFÿ¦Fétz�P˜%XF�rÏSŸú‹ÅüüÎ)TžöèõP‹(Š9)Š…†õŽBŠýe°½r'ŠrGQ– E¯7�¢(P´9A¡awè‰Ú—™x(’6°›ÚÐA·žÒø ‰#“°ý:k.6Ð1Ø’BòCw¾ýw]ñ²—áö7½éÔ!·–»˜‰L¢˜>ƒà XÖ~Ft›šé�4QxZ@ê+^þrÜ~à o¿üúë-Eñ”ýÁ\¦‰äÙc‰@�X3¸æ¢;cݱ¡(ÌÈ3(f�ÆÅ�ƒ±FóÚ@À,×åŽØHoçÎÝ{õ¨_­¬üü�g=ë¿•ss¹û�ÿø¦³ Bb>mÏ€1î¨4 0Óžñt´D¦™@烦1¡ÙoÑF:€³»gc'…4ÓLŸm Ó1÷JT寥Ž"‰†nˆ$>êzm{¦í¬’š¦ùj½¾~˜ä>q� éB#3›O«ÑCï0·é[Tº«‚T(·ó ãxŠe9WöO‘|ЃOÓ,Éh´"À.Ÿè$"¦¾øì¢ÊIWGAFZðH&tTeÆ`ïu«�Bt»¶‘ÑÉ*µkÔcÕTI LŒÊ=¤ë‹¬l¦te?;¿axjrè… �›ü¹”zÞÛñ~Sá'ÓÞÑn†ùAæuúyc ¡PÓ.ÍpX­ÝwßE¤Þ‘9 ©:DÊ%pâÐ0H)"}jMKŽi›òÑÌÈiÂØ€‘»iȘºf!(Šbî[ûèõÏ}þK§�Ë›XÁ‰X ÀØ/Á{Ô©Dªp‘$c×x…)”¸a×¶—>¬ý㜈,� àúiqœ„"" ‰"Œ�€`4+KÇçŽ// ÖŽ¯õŽ//÷oýê×v¾÷ŸÙut8Ä<òoIOç¤mëÑ’l©Ô…Fp;CÀE ÒèФi¸97)Ò�å`ðq‘™ R²Ú2Yëº�vôAR†<|ý‘™r0ÀCDçƒOQßý=OìÍ�wá�f4º„u} ©ª½(ŠGJY>~Ïù× ìE±ó){—PZ‚À†vz»hb�™áÀ2#v-°rÈË9=�ÒT£#;®¸üÈ‘¯Ýv‚H´JMœ‚¬=&™‰T±i,¬_gíK±bí:.õ‹}�{Üo´t2£ •Í1” €XM8täXº×âÍ“£´íºö�&Ip%vË#Q?€Ö¡£) íüé>„rhÒM“h$?¾ªååƒ(ËßÙ 4玗ìXÅ#1ç“‹!ø@ó}[¨Þ°ò��Ái¨9o¿á^ùªWáëoxï\ñÊ—_Z x1z½žÀw`§ä¬Ìض-mO¥Ë9ê`¡fœBÄ>yßâèê>9‡/ ¥(ž×Õ8CªåeÌËÚ¢wçŽ{ó˜ß¨Ž;rà™Ïú¥rÐÿÀÝú§_’3|YŸX5fšI“ǧþtÍ÷äùi@]�¼×(ÐÍnä:ÍÁüø#•†�6ukMº�nFéH“’o퀚Q»jAŠiÖ‰n ²‹–¸}�]@ÒpýÖz4º‹usUÑË·M¦¦L{d9®ýÁÐeC ˆÌÀ͘ʄuPEQöΈs[3Ý_ ‡_c]]„^/!ðl›$Ü@:åLŠˆ°± ‹ú“]š8© ÙêÎÕHQ U'Ò#Üp.i&XÀÎîv½ÿÄÚÅøaæ@¥®™Ëñï)})û=a3‘K¦V:U¤�´êŽ1mBõ^`ŠFUˆ)>˜VÄКK9ay6”jeù¶c7ÝôKÛá±kÏUwˆÈãb“›öÀ³Z LÎ`_sÚÚ4k˜lb·¡[šò»#r2™»°øÃë"'•H‘c)a5»üÔC—F+iu’èÆõMÒöâÌÞP­Á€ˆeY„`"«l¤ š¦.šjTÖ£QÙÔu¯©›¢ªêr4öšª*šª*ëª.–�|ý¶Ûw}ö‹_Ýñ•#Çúì•"M­4¬2ÞˆZO¦Mø`»¨‘ÙéA‘”yÚ‚l¤œ˜h@!ûT‘佫•åƒÅ`ð»'\ó)§Â.fq4q°@›äú ’¢ µ™&S`=MrýdÖüîï|bÑ?Q Êe¿ßTÕBSÕó`³@6ó¤,JÓ,�œg]/°®Ëù¹Ç¢,d°s磋~ÊÒ¹N+׬ŒÑåb�çÊÉÜÃfŽÎœ9´óäd/€âØ–Û¬¤³H9zh°gï­¬ë§��O–g9�!YÓÚˆÙ׌Q�jÆ\ò¼7ñܧI`*Ê^1?x’ȃÏ�_>8Wíû Šò{„–®n¬r‰¼ç«¶6@˜#÷é"Cf̦;SfÓ%¥¯»pâ“lª$+Ð`C1í™Hyi**F�ý Ù‘@;÷†Ò¬­K9˜“¦®§#¢ªCà©óº�Žk­ˆ@…ŠÈæèU*#\¨éʪ;àòN©×ÖV–ï¾çßn‡Æ)Ë¥}IÈ7 gÍ.É�ð@‹ßÒæÀÌÕvìª+î5M™8¦"Üȸڴ/QôßqJ‰�îê@„‘¡˜Ìªn�}LèÏu…Z©Š{c¦hQö.»ìG{{öœç•âÈS®}úse÷Â<^úªë¾o÷þ} ×Öe8öF£Qy|yepìèÒÜÑcKýcË+ý¥ãkåѵõâXUzT5‚<“7QW’d±þ-8�,ÅšŽÚ¦cFq€f‚ÙƒZ0RÅN YÒ‡oCDX×½÷]ïºõdV 1#«`D¤¶yÝM()t÷9L`¸ ž´ã Ox¦;:J-ókQ–ý¢×ß]ô{»êj´³‘fWQò¢¢ßXo08E±½rgQöQ¢ã{›s3u¬á¶`Ö}.§(Ïé� ý• —”ŽÒèÀOZˆB×D*ä›*‚™ßغù_W¼¯:¡®cœ"ŒÀÅKëœÕÞ =œå¡˜æ4²º )è�àrY¿ÈˆKJJâ\qƬm5�)Ú n ‘åm¦Ÿ£]c6ßQŽŽǰ I5)ýÉh4yvÁú±c_iXýšˆÈ¦�H”ˆðମI^åŠ!?š…åèž�Ó‹$‰È}ïyOsà'~¢øúoxù/ùZoaá•èúÐ"Wj¢h]$=C¯ÆŒ¨™¡íè$ÌÑcT‘í × È4†rcèðjhÝCÜ"d›…½E¯ž2WÛ/"óçŸÿð¹}ûÞ¹üõ¯¿þÀ³žuÃÁw¿ûSr®ð©isŠÈ�ÐÍc*1@µžD‰’$ú™9ÀQô‰fÑ{Åõ´*͘Îä­éÌ,º<•{¯Ñº"L;€É€*m"�P–=r• ˜JB k·µ*±=Ýv¶I«_øÂúÂÅû\]UuI)ôœ¶dF ¼&' ’«Y+Îâ’´”pÚ¤ŒèZȨ3“€²úý}r–ZQUSÕ÷ÕkëR..𢆨Ò|O9'xî§t i£²¢õ)½ ªçƒÀ^‚ïÄù Ò*ÙÆ%JÖÞ\ã÷væ²�†Þì&£8]»±$oÝŒ…ªëZŠÁ@¤ªDšFÙF¦£MZÀh!=‚[7¦TMÙ¼&ab&)‡ÕšjÁ¬¦ªd´¼üÅÕO|òo¶Cc÷ÕTÕÇ' ¾+¢í“ƒ­}׌6SVžMsÓŒ•×j8ï⣀ɶqDúýGœ|öÃ0¶a�D|}z’X˜Y‘N'² ¿Qºgä}‚Òßµk°ï‰O|/2€ƒ’Ú÷üûõÁ̽IŸÃ8ù,,@Òv·í¼or@Q�â¦ëˆœ­¯‹Ä¬Êj¢µzBT‚%6e´†@A Èu±˜©×‡«ÅÜàžTÍ—‘¡³€D"Ê’½‰ä¢ ¼߃dfW\¤ßûÈG<�‚ç%Å¡Vœ gÒ�×z4zMgm¨Ç©‚h²Jd=Ù™eÛžÛp3Ùã›ÌÐ�#½®4hkaJ®9…Î9xHµ²ò±=ßöí‡ýÝß�@ ±LDk&£sÖÈbNjÕŸ‡ÈÛÑ™Ñd‹Ð%“§f‰·€+ˆ’ÌHÅ´�î†4àôn›Fêw%÷B±ÆµpT…Ëð™½,UáïöbS7 o¾ëÿè§¥úÎhæF�‚L@û,BãGY£·ãY¹¢Ñ!ÓÕÑIiœÞ2àà{ÞÓ\ðŒg·ßpÃÏ_þ²—-• ü·½Á`!‚…Ý�÷!«àú©Û–æÜÍGë £i£&Ðe^¯î£JœZk†„ѬcaŽ­…שÆyÍÐ Å�êRP²ëáW¿ºZ^ùY<ûÙ¯>~×]ï\úç^—3pÁˆ>¼ϖz5f÷ØæIš0ÒI–[&W¨™ü´ŽK™¤Ãô,™lï%s?v´ É åiÝ9 "_±)°H‹å'E—«ƒ}6–°z'öXCów•¹¡¦“*vnà8(g£í�Ê„§jå‘õß…yßT‡.NfÅ1ï¨�À™Á»Ûr“�Ÿsüµ¦iDêZØPØÔB6ã€W"E)Òº¶µ‡@+@‹ÐÂQãj­_TÚÙ Ým-,ßRà½áx{H(),ºøżÇw¨^=¾:¼ï¾—l‡ÅéWuäÈßç•GÃpñY‹$£´Fy�lŠêNFù&(s »´¼Ñë•;õL^Ížz+‘`𩾍çÑ#µG€Ä�ùÍå-I(3¾IßAcÚPpÉ)¶èð¹VN£PŸµ �¶š"Nö̉„2E²HšJš�Ò0“#-ÄýVN\W¹Êcòƒ£¥cðþù�ì2ÉRí 7ŽÓÇ!¨? øaÖ“g¸:-ÓeÌ�Tì¿  ‚ÖiV]¤¤L?fO•è¶I·%㘉kÄš\Ü8Ñé³”¹®LÇèNd�˜wCœ1)#"†w½ýí?yèïþîø‰£Œâ´Ñ$ó ™ÑHrûZò¯¿:@•w´þLìÑðý˜’ÐÚsæ,N9V¯‘N»Ä!—Ò©ºëõ>ƒV’>Yh5HTóC÷@ÛÅönc†_j$&ìUЬ9ô¶;þàÆß8-‡1}±eâ»´>Í3£Î£3“gpÄ£L¯¦¸Kø [ƒß÷—Ù\ùêWãozÓª––~¶Z[½ z`=Cõø�Bå'k$Y>“g—ã–Ø„+Þ™Y¬pû¶mh�ö¸÷9kÔ�÷�êŒDf)pœpM )wîXÜóÈGþÁ~íy?ò£—>˜y¥f#’—±€ò[=´°×›I •3ÛxÓ€T þÐÎ�˺œÒ0�$¼Ë�†•—¼ÒŒzBƒ¾bØe¬eW3šYÅéj�Ù–=,Ù jò6�tƯ£þð_×k«÷Ž¢\DÐoN´/É:Ùéÿ8¢«Ç€É¶]×/3"×.¾¢èÏ£,ÏĽcÓ Z]‘�¸�67çJ«hQëTw€I¢—ƒ‹²©›s½ÐÉÍags©t]Ø® ÍÌë+y %8°3€K–|æ Õm¡°®[7!5û(ˆ‡q'""iÊÉ­@8)b;ál‚ê\æ„Õ;k.Éñ$;¨CRHSU2\Y¹yùŸøÜvXœ~­}ùË÷²iŸ$A­aÚ,U´$(ý &•š ²%v,t&'/5Å  •ŒªJ^'£(Dz½Srœ ;î(†OiÎèE_è5è[¡C&Þ6É夨�EAÞ¶�‚ÏègA¡vÕú=tÅY` :*3—âÛ䌌À†°Û‰×Oð³=3‰¤õé=sͳž ¬ªaoaáÏO¶Òë!ìºFHЧàpؤ…Oj‹Œ À—"S ê½ < Kš8+œïìêc4Ž‹¨uªH·mcF”ŽHŠ<¦g6ò0—^·š‰³ú¬v:2ÊâÙWõúú÷¸Ã§ÀîQï*0ëÓ‹îÃj«´M?X%6²èrÊpŸ€èZßiú&6ÒJ»ÐœÓê÷™‘ q®G>Ï5 qŽr£ÆzZu±„¤ü�nX‘uÍÄËŽ%w5Ù©“Ÿ=vKo0ÿ_Oßiìf—t.›±‹¢q?Œ¬(S :?U“Ê£ªÊñòA¾¾þú×SDäŽoü“ã÷Üû´jeõ¦6Ÿî>(�Ö=t“H{OF›QGfÌd£¹17AÚÑI ‘®[鎟vB ê܇ÚæT©ª? ÆíÉ{* ÙyåU/Ûyå>ð¿>ë‡äA‚ad�¹`F}iíÙpàF]ÙÛ²¡Û}àFÌ̸¾ª~�ıq‰^`ÛSè¦zèêGf`l@š“‹™u‘£¥ëE¬ÀEº<ÈÄ^“Žp@:×€$iêõõ[ØŠ$)ð¢ý;’.$³2ÌB”Nån¤J‰ÖaIêŽ;Ló#èêö‹¹ÞüÜ#Ï�T×w³ª†ã‘ jýðŒuW°‰£Í¼^Ù±’YûKÛ'øà ôa‰¤QüˆlaD�í«NfM÷µn($$ׯÍQ¾)Ý _’Ò4M|?¤éèŠÐ’*PºÚ1õeüCªC—‚¡¦·:=5zd ŠÂ¸ìÕË+÷~ï{x;$n0XUU¥c’ÖcIFv @êÉTºm,Tî[|P…7Œ54ÒùÝîÑë-£qVÌ N÷‚Ò0 ¼ó)Ê5†Œs¢aâ#òÒ�@�-ë†ÆþÚ)«fA*‡Ñq€ë^MžÓ„=HÆ´ˆI±¡÷$ÀíÒ]¦ ]ËL¬iÈ¥jG¦¢EÍŒ¬€^'î0ÃÇÿòà»ß}É/«a™„¡u¶Ê$|„$ì-J‚$¤¿¢Ÿƒ]:3^Û‘êï¦P 6«ØbºH© �Ö‹ÆÆØPû�±ÛŸ)xõ¹v}C2_éær²´–©Cñ㮬ÒÎÇDNhöL2›rüêÍúÚqþò7Þüæ/Ÿ^Iå‚Èœ™™ç®Å¢Å`O¶\6C-aüAƒ²C5õAÏGî}×»¾öµ×¿îi£åå×IÓLy ª8FŒûÌøÁAææ(dÔÞ¢kµ"ÛÄÉE'šG'»5Y³í"[9�ðà|™6ˆM…¤Aåc¼GçÏ?ÿê=W?üý»öÚó Ôqú„Ñ µÖŸUŸ9ŒÂ†]ífG¶ô¤}ÛŒÂøž�¡K]ÃÔLg;è;‡PS pµ³jlIbô:HqŒ/Û5 .ñ¸q›?iž™‹¸ÔàÚ$?Ó°ÁÂwH:ãWµºúÁzm­bÓ8«@š„þ Ÿtöt‡�]ˆ‡Qä¦; ™¢(æD‡ë¤†j¬(Ë^ïê3RÔ6ÍÑj}ýž¦®;ø¶¸Ó·Tì �`´Épè´‡.Æ¿EÉw¦5!“ŒJƒ1AXŽÆY#°–,,mž[ Éê‰P�Nì~?Ó�» ‡$š1§^ `•a\­PÛIÑVˆOC`†Üa2qrAFõ€é(hW1Ý­kåjõx3:røÆípx{n8ZËñ,èF_Â1ý!ïƒÌ`Â"ÓEƒÝ¯Ì€‰ª"ð‹Ó�EÙœJ-•ñ†MÔt¶¨õZ”ZxF±!Ä%YŒ>ÉÕ½8JtgEÑ•À7æœõØßt°5aßWŠ�EÐ!m˜ÒÕ‚…°D³*B^®»Ñ™é“À0¡FMèöžÕëëK½Åo>ùô˜’ƒïsn?]v0€Tt�Dz-òá:'ÑŒF.h÷ãešÖ&Lò‰ ïѵä4«ú¿Z›Á÷”3Ÿ>gÑ¥™£:Õh†úw¤{ÀDÍöñÊE^Ä¢‘êøñ‰ÈáÿŸ½7�·íªÊDÇ·öÞçÜþ¦!@BzòÀ¥”Rô¡è{ZBaóh  >,Q±Ô²|VáÏzõê‰ H€  �$ƒ4OéD%h"$—ÜäÞäÞ4·;ÍnÖúÞg¯9Çs¬}ÎMr»°?ãÍÍiöZkÎ1ÇøÆ7¾ï�®í: X)&Ϭ€³sµà5žó¥ôF£«õªÍZ@ ¶¿Ù¿ IDAT—ë2�jlÃéX±#nš2¼=3ˆ`S¢@¼)âDj[§Ü¶‰fGž<ËZ�»šw­GeK°I\bO72^Y~Õî·¾õºc^}ûNe]q\zÆ¥¨!DFXU¤×·>×®Ë/éèàÁ™,/ïíúPú~ìä…d6“‘ºÒlÜ,RΠñêÔ’fäÔÁ?Ô®5–tŠoÙÊéd¤Í¨€xÂÀþÙyäýU-.VÛ.¸àõ�yîs÷X¿;óY¼6�Í ÙÌPÍdà4âÏ@_8i§YÚôä4`Å\Ëh0<<×; ÓßK±ìOv¦a%«/ƒ´¤v�D�j�_m¶Eñu¡(C ËI!¦º€Ó?·ÿ—¿g^÷œ@R=\ýôdeõp{¸'m¸ëEcFAJ=P‹—ÅSŸmÃB¸mOqDä¼8ÛCz͹mÓÓNȃ�ïãdrz=‹—)@¢ŒÆÔ²=½ÆÌ´þ6€•�Ñ ÚÈŽ‹íT7t(EB׎ÃÑP*UùIßËpXf "Qwðh uk©¶ök*Q"Ž0£;PŒ;µ 3¾äþçGv˜æš#g8gz~¢®Oj©WVþåþ¿þëߘ‡Ã£X “É‘ˆ ut`;èÔqK±|?oVÚ•4>”è+Å­£´®"%Ù|‚Wý^%ÐxÁ3ER0"R Tƒ§­;¶N 1DUèØ}"FÙXíÿÂ׉ÎÑ*ïJTR£ÈˆÄ „oS�j¡EÂoGBM`¯^¡ LÅPІ{Iv�w!“¥¥·í{ï 7>(¨ÑJ Iê4uj­ÀáŸ…Š¹ÂBPtv@% è`£2ƒõTùD¢¸Ó¹~­SÝt#*5Á쩱º†PY^ûˆ2yï‰lºJžƒDU˜4³åÙ­¬ÝÇ{®¹æÇDdò€—ˆáI©HH38¥m¦˜4X’ÊíhŠ`ûm µéÀ®8žªïœ dä—° «æŒYÌŒÄÄÕYì›YÙ“¢Ô#�ÓbJ�t ~ÐX�Ž#ï½÷¿Ýù¶·ÿ¿Çø¶È,áÎðt!�û¦¯Ek‘­ ²b€¬yÝ~Å7®ìÛ÷øñ¡C¯c]wÇõs¯2Ò²×j×TÕ–¶HW:GZù´”Ý£GɃ½ÌÙ�F\“L5ÓÈX:ÔÎõ“,GìÐëÉÖóÏûÏç¾àùrìW³Ë.´®\­**÷ŸÖC&c§³+ ï<ˆ}fX&£ë}›ü»›[áÏiDêI1ZÞ‚J>Ù£ÅqRwÄt L÷ÊBw@ ã«_!¤MóóÙUÌé¤aš<�—\Ù³ïßÏCáQ‚ÞãÑÝâT5º%Q=‡ h¡²;”p:tÙŒà-ZËÝ\paF¾ù�­ýË–'?ù¨diJㆣrµbZÁp„œŸ(vÁ…8Ú5 é49»´_ ¬“0tíHJ;–騤n�™?*WŠ–Œf²I«õ»kA~“ß:𹬄C4!Ý �±ý»ÑÁƒ7 ¶mû�_ìI·ŸÃcnøâÇ œ¡•Ì£Úe‘.˜Qˆ‹ßDn¤ID %�*¸N)g÷á5Ñ™DPàxxR»zú0à-Hµ[!z “8·£¡4¨ ] Ö²•Žx™ˆ~ÀK"Eú^è�häº „{‰¸Ó³Õ­1jVÛŒ¦f1v©=®Á|ÚQ©û€i*˜LçWTÈî�VS¯ƒ€ eAÿå=ûó¤LˆdtÏ=¿·çšwüÖq;Œ)ª\�EÀDÖLBd‡¯6ïM¥ ò–CÐØ+TO`@à`*Ùú˜s_xÞ¥—¾_DzÇd‹•#A¡d;ÎëbÔŠó­ïu”Ü@ÛFU^ªÌz Ú8Ñé9ò‰×BÞ$tçyÒt5M¡iîÇ(ä,Œ¸ìäš™a~î Uð˜�aÑÏA¥èêŸl¨^:òãÕ•o ú �‚W½ToYÍ‘k!<§Ì1c\XÏ­g±ÌìæËLyv�}:‹zè-.ž+Ý8Å1»þíßîÄ3Ÿù TÕóËî£-eÂD6Á�Ññ´¢¬ðJÿÙ«³ÔftÔoõC,hÛ?t¿½õÚÁ3Þ·vÐ ¤Ë›€¤HÓ–S·Ê¶SÚ­Ç)œÞ�) ̘ž>Ð:]x±Ï\ì:ë[ÞxýækaûÎ�ƒoÜþÅ�ßð8Çj°á6æÎRJÓŸuw;'k’º±Ó¢¸C\б§®­Cq†$â¤O´HPâ:Ǥ\¸¢’^OÅâ—p*›ð[ûù4Bê0ŽŠ4.}ö'*|7%ºTVÖÆÐ;w_£Ï  ·°ð]"ò�Tfy1âÅcxñMEf©%ƒÀ;EJ�ŽJIÝ/":&¾'w,X@Jè<ô �uA™á”n]Ô =%eýM×X‹"•±÷UŒS<ó¤zN Y•~'“±¿w×»Þu÷ƒ+ò¦�p ªD_fˆƒ·  ÙÃE�š‚²Ì¢Ãé­‘…s¨ËIæ 8ÍDp,A×'Ê¾ØøKŒ€4¾ÀÄ$„³Ü6²g:Wñ?º„n{Ý5P<7†&îfmŠ´g‹û^{0Ípô/w¿ûÝW<ØNXèé—,é¥oÑ•† ªÆ÷œPy{æRüx rÁˆ]&Y@ºe‰ÑƒNáä›-œJ²lB$u.(ÁþÒ¢ûy%ÉX§w±‘äˆÐâÑÀiÄFoÏøÕ{îy-'£ß<® iÅ©Ú3•Žë&ÀÌ�²~°Ü ö#‰sèÅÆsÑãt-ß³ÿS;Î>ç[ÇËË?Tõz×÷6oÀë:¡Ü ¿hÏ¥éÞÒæ&wìÔggÙÔ€s‡V¦J(ß^7DÏÆµ¦4y,Ñí#³õdŒƒ [Î>û‡Ï»ôÒ?Ý}ÕU?öP¿«¶Á•Act”@%§ÐèþRiË:v*]Jäøˆ£Ê;Î÷ïâ§Uô9«Î-É„èwmò±òbDœÕœj—A�bÑ‘ØJxÉL/²XÔºÍ4:%€¤ñ‘¥ë†ÃK[·žÅ~_Œæ�žÑŸGäEvÌÂèÌôß6 ÍA›¤·\é ;DdQDV�ç3[¼ðÂ)ýz¸Êþ–­…#CàR„œpCwî}¡±æ½Ðd{ðÐ2_¡�àâºFüâ‹ÃèÅ=ج�›ÍÍÖ\Ù $P¢x-YˆZjrSì僾€1!V‡àk cõ¾ÓèB šRA�,ª3G›ZÀ„D¥¦+ó»sWß³!ô³®¤\{f=èßko±|‹ê&=)ÄD•tƒ‡þìInlV‰ž‚^÷æ�ß×*T…l0(ð�С]9jÝ+{?«€JÐ|ÓƒÝQp㙸M�ªÞßmq<ÍFsCÒ:Úe6’“'QÈô›Â,3ºà<˜~� ú=PȺ>.}6q6ZXÌysnÄ2HÄiòZ¿5ýàT<¶Ž[%óªmÊ€(õÙR<:xðu÷}èCW?øxK)ënj‹²[YkzýÉãØöó—q¸}š•RƒGqp ØoŠq× �*¦‹ó«À)³­;ENPB±( Žt�¬ß"GUÏš�rZ*"Fù¥R8Ù÷“¢[S—÷ìù.y #mŦr®»z¤>R7me2–íó¡ö¡FO‘F¿àö Ê› ;ªæ¥~ÎNÏ…*7ÌZ$>õ@gGNU`͈+%žTÔvf?vmbÆç oaš÷ ï½÷wQõ_µçúw<¸õ°qÉ®8Ð.ê-´ïD姆-A7 {SÙm®� ®?Y®{nx_s�È蜟þé¶n=�‡= W÷6mª ë*�¨¥’ Ôö"ý%ÒYbbm”_!¨'lq`4ŽXDdU�ÖÙXƒ±È.n^¿0BññÅwnüdóÙgÿ»s/½ôš;®ºê¹éj&»7;b»8×mŠ’ P&U­¸¶•P"©ú­ŠTÁ¸Çk{èèxÉ*+m÷˜—íLãˆö]Q4Ä"†õ½²Ê �O“_AØÊY[ç÷sBRxU'Û:ô‰O|frèðMÍxL¸"Œõþ²%BK_ º2z,Â0²Râš7‹lµˆJ0lÞñ´§=úx?³á®]u3©OTPÉ`9þ¢Áž¨� XXÜM‹AO7¸‚rAïÞݺÀCUt£rŽ—ÓG›¸z½¤Ò!Bš&‘¤Ô06³Üv�‚víŠTî�í±�1ÜJÝ ±TÞ–Y`ÜÞõÿW3Ïô”ÐbäIÄÎø·óÙ^(4ôPŽ˜ÎOÓ« ›©|ÞÑ_Àïµuò4ˆqå#uìG˜€£#ù+§€BþIPÓˆ¤wÖLjõ{—=²¢É¾Ò±_=ȧæ­RáúÄ#¿Q ú>ˆt^� Ø=�@ÿÈÚ]2 )êÏX˜¸è}i&ATáŸõ£Ê%Dƒ%tp‹ú*¹E¡“¤ÝµÖqªi;€´‰ÊKö&iq§c‘Á˜‹1;E¢˜Ó­=DD%Tªáôá}÷]?ؾã!g°ð Ó–;â„ü>‘RuÃàOjl©« .ª°Ÿ%«Dg³ñ"ðÅ•å¤tþg� _#"®ÏY'ØeÜÛÊ©PפMS¡kçqý|Pà43(¬ëÉèС§Þÿ‘�xèV†ÊZf]á$'(¥¤¹Yæw¹�ñïê™”n…yC:Ƙ+FuˆÞö…—DH˜‰´LÌ®¤WbÜpû,É4ÄVàâ@·$4Û±‡Â•ÃF†÷Ý÷+� ûΫßvœ@${†9Ôµ£Ù"�¬”¡ƒšÓ ä·r^EOÌÎ4þ“@#)ºö¼õ­õ®7¾ñîááÃÚŒF—Œ�þ÷“•å‰÷®…aÞ QÜioTäÙ%=ØP°:Ûª3Á†œ,-ýæÝú§Ÿ{È@$ºÎ‡úý†Øåg³<ŽÐ$M‹ëP 3,ë�&�1ÚÛZ:²ÉÞxe<˜œ 1B3]˜:´�µFð½áxÒŒzÁ�W_ý‡{¯ùÓã\q¹fº_£û6îžëmå«Ïçvب: ¯=×\3Þuùå·¼ý¶÷6ãÑ7×++O:ôUÖt£dŒTMˆ¬f#Zà`=Y‘vüH»rë_N–¢ÓŽRiP¸}Ä¢í`˜w¢š±ÌÓ3väMùk¯�Å3NÁy/¼ô¿|ùþ�½ô¥ÁÛG)ê„èÀ0+KAUãÞø4¦ÑRd2,‚'8¡6 ½SpW;‡¹bç?£1žò€Ë Âè ‚F‚ZÄAïq~�"@Òpõ/ƇÝÆñ8ç~𶃴cZ…å \+Bß·eJ¾jeÁ5 Zõª~5<ñD<³¦iÕ£Ñ}¦#À|Xjn †”˜}Gñ,]Rå´Œ"Sf]EšŒ›f£ ÐÏßM�=X0)`%1b!§–Ƭ/­'~-w ‰ëvÙ<¥ÛO؇b!à[Øözì¦QT¦æ3"b0…VO¹`±ÃP�tÊÚt&ç OŽ4 ŠšLZ[O·Ï5“:>°ÓüÅ÷ú“,�Út¤ñru"ƒMWÈ–-Xˆ]øéq„E«Õ0wF ƒíEZo›gG½¨…oÛÞ½Õ°ŠÃ E�2ªÍ �ìÊ7�Š/ÊàæFMSÜdŒ,”l‚ªoJx•8w™oÛ¯ Tȃvkñ€4Õ‹ÒÐX½²º[D^²ï†v?TÀ|iðà AÀØ8‹ÌºÍœY’JäZucaÎu&¥‘¦®Ï£ÂŒs×qÁX¯k´‚è \jæ•öc™³zä–¦šÝµ¦D´ô܈cÁŒÉÍ‹¯j�.ÿ놓¥¥+î»õÖ?”‡�{aåÅYØ-£psÜDcŒÉ%5»©˜�Ð3ìöÚ7vGökÖeÙQ×A&“‚]w�X¼Êyƒ.Â,¸ƒãÒߢÂÕùˆÿ5õÒòm“ååÿ}÷[ßúöV,n“¡HAXôu*8ÉŒ0þþš°@œ4bÛë]÷¿ÿCõW/óo½ì²�×£ñÖ««ÿvxàÀ•Íx”š^Œ?§íÈòBNÀ�sÉ.¶Zrmnã��-{¶ŒQ¡c,KJâÚ4À0tÂÀPHQ…¤ûîmÚÔïoÞòòsžûÜï¼í²Ë‚XH ƒt°´sã®Û@á ÄÃÔ†)^*³²I)Éù:Å6Úã\â)o+iä·Ó¹›ûÙ,²CSo‰ù‘uœÅ´‹Ò™ýËœœtêI?þñÏŽ~7Çã:Å£¢îÜ&€€gAtLyd}A]Ù"ÈU`ñãìÆ`Ô5Â(hGG£Ô/0‚bF¶:†…éEpÚåæ M³¥x œóõ@·œýÎì{Í)cÒȘM¿ª�—ƇÿŒÔ“Wî¾êªÏŸøR�vZ€å k#¼ ìצ\. §÷ïidtgÝ)|í¹úšý»ÞtùG¿úÇoþãz¸úÓåå8ð‰f<ž>fÌý¸¾{îðŒœÊ±Òƒl|Ò�Q@Šbb4®[~I  @¼v"" § šöm:ãŒ'�÷¢])"ò`Fܲ< Ÿ��»hÿÃYÍ…Iåù xÂ#-ðÞÖIìÖê4 ÕFå\\>­ ˜† uÐò¹tÚ2Š£þÏ`h¾#n%µ@am1¿ÜÕ?Y?Øêçnºmtö9ïoßöœª·¥'¨§%Ô*vþ1w ´•TN>"ÆA,áJàܱ 7<ò„ ‚ƒþ�ä=Íx,ÕâBv‘Ò¾\é¶f&u×Êér8°GÛ(S%r~d76Xa8Jon® Äë¢:žGbð–ÛÈÙä“pú,s—,PÔ¼·Ø.žNkS�H[,Ø%¤ˆðP#Iše@-�KkÝáT˜¿¼¯XǺ­¢½Â|f¯]¯`Ü6Ây¾BP ¨‹jÀ´º£‚,v™&fËÜ"�†† l^š\¾tS^´z´Ê£5(¦:‹Mw)ܰ´–�“EJfh< ˆÝ´õž©lþ–']°ò�ŸûêÑ&òá™m£’¢cŽþ˸€@)3DãnEê­û‡8[ÜVÆŽ&0Ç3±SR¦hôú>ðÊ](�5e0XÝ/ë–«öf è§pà´ñ`̘í6UC½ºz[3¿ìžoüó‡¾ÎÛ ú¬Û¤Ê¨/üB$ÿÜ¡.u…J‡!§ø*Î >oà2¶:ŠÞMgÖþy?¸ '�“¢´ãs¹i‚@5kþIå¿¨Ý IDAT¡åd2š,-ý¤úå½×_{÷±\#Éy¯Œ&ÖMVas”çj´çA #üIçNf³aÆ)ãæZ žEî¤,ƒŒYµðª”bGf·ç4K‘€a¥‹ˆL––nž,-ý‡Éêê+v_yåI"©BÑ.�uÏ«rk}n’�(ߨµö6Þ=¯;Þö¶/~õòËßõÕË/íduåù“奟}–& ,Y³Ý3áÌ_l¾\âº6Øê@2ضuǶm¯yì/þâB!]¥Oq"Hɱ†OùC2p¡S¤´,sCv¬=_=ÄR ¦ëÚ͇̬P:N©v”ön ?8i¯jñ]ÍTì¦�؆Eטj{ÈT[æ°QyõOæwð¯?ó¾Á3ŸöÃý-[Ÿ;ØÖ•¢'ÛC–‘µ·Y‡j|@wZ bøFF�®Dýþ¦Ó¿ïû.¸ÿ#¹å<²ûšápÌ-[Ð]NNçåéݽèÄp™)úÔ I¨©Ÿ­ŽŠó��TœÛtG:Ã�'úàÌ4c=52ºEíÌñ ±3CÈÅ; òœèÓÞ<�ƒ¬c…½) ØBÒòÔénh”]7ÖG*™äQܲ°#j[h uqÓ‹n½Æ�yA3EâiÝCÈvdϰÊheSAÿŠ,¾�Y9%Íû‹öÔ¡Qš+æ3NW "CPY"lDÈÕ ÿùã£MJ v'\šl_}P¦°ënd¿,õƒÕlžœ“’¦’§˜©H£há^„˜…ý|L<9fü¨uüRØ�jÐ,l-"[® 2>räJišÿrÏûßû±+ôÄ«Î×è‹ft—€e%ž;J�‚žEr+Å*´äæxt¨…Šd4úŸf›$ BZÓvÅ�èïý%Û5FÀ€øïÞG\d‹p4ºo²ºúòû?÷×oùr}ìÒ†ôðh2Ü0î\ãÁ€ÏŽ7NÍéyNHÈæŒ‰p9ܺ²O‘´±¡ƒØÓ;&­aÖC²teÿС§õ6mº¸ZXè¶í#� XêÜ@Úyu7‹¬{”eP µ#"¨ª>z½Ç‰Èq’šÉdT‡‡DäÌ¢CÐstÝL:0Iš� d¨pvîv¼�È:#Úù¦݆DnBSBžëÑdÁ™ì¶F¡„H€/´h2õ´vÊù@tµ윊9´œW ”¦#GúÍÀËs3 p£Ë±W#P¢Ì*\–ẞ º}‡«M§•¶�õDw‡ +‰ô.f¡ÑD•–r|¶,:4ÉmB”øžÛè¨i�.¡tÈ þtF=™ªðQûÚ}{—ã õÏlbø ýþãH ؉^�{UЉWkY·÷4hÇì�X·+í8"F¥ˆ’¤GðÁÊôyˆ],ÏbDÒLiÇUõ*7cž†±W}Á0|NFU¢/Õ£Éïù³?ûuiŽm¡g‹„UºC ±~‡Á ¦¶ºSW5ÂÒuÆyïËÒm™t† Mqt2_âs8¿oÀ½×D\„ÐWíZB§·™)“áêMœL~í®ë®ûðñX¬óÂèÚÚÞŠßç’ðox2#˜JpÞŽ7ª5J­»UÎmÀ&¦yÖmí­³ÕLw –Ne¼vŒÎ�!Ïd%¥³‰Ò*‰ô¡Z4–*™‚Nh OC‚‘‡L§`φØAc%þæ³,l´RᆒUO ñ º…ÑÖ oB�1¶à#L�’â5zèÒ WÃé’òõœ}í\Áâ¼sÐUG•Ï<GÇ‚rÓƒƒtØý´cìèx,©©žåäuJÂrÒ�€ƒ ޽ÅYPNîÁwn�DSý7–öê´#wN»‹~OÆ’öúià ñÀŽ7O;Ò—¿UOª~ÿ1 +€NÒ|üte?Ebí`!Ãx>ñ‚I»p(Î$�“„ÙsÁÃéï´¤K(&'“-«/ÚÇëJq°\ím§²^Zþ¸T¸jÿ{oxËñ®õÖý�Xoýú“'~0ƒš„ÔNNãt³±$µ[ÜàY%Æ!5³%»`Ñ.,m¦Ò¨Ce#²ùÛFãÃõhøŽªÂì¹öÚ/ï¦n ÄBQ«#! G%hwZ†šE^hV|¥ƒ§Ï-¸a̦Öy}aîéÉîóP�Ó™X°°®/2NVVn‘º¾‘l>~ÇUWÝ "ráÏ¿»ÞtùÉ[4yú²EÜ ßæF0µÃì˜_“ÈY¸¯ð©x�ÿ³?‹Ûßò–;dÊb¾ðç^rŽ�Ï¡àq¾­·yñ;PõJƲù3;›.�OÒ³?£e0s‡Q\‚µ�sÍåÓU%ýÅM—œó“?ù�·¾þõŸz@ç¤/Æ: ls>É(Ù®QK¸p�uì&óµUo0‡Jò5‘NA ixóÍ+ý~ÿ�vìxVqñ›«ÅÅ Z”4ñ“ÿâþM+ëvd™áöíJDD¤BUõßz"ž.7ãñ.�|ýŒú¢°šöA[»É„î‡zTM%}ì(€ÓìjˆBÜEn;Uçz6[!²TˆŠ =ŽTR( œIº´•‚¯��³H8ÅXd�–™ = `>�f!UeAÃŽÌK=ÙŽF´9�“�”“˜ÒJåºQ2mb*ûm XòS<¡aµ×ïÈ€HF=Š©BdF’“…›%Hw³Ú€Tfñ$ dk˜Á³5îìØ�A ¨z=iƒÓ,^õ ÐY];T0‰3Á’'g HÌ‘´[šFNãÅ¡EøÓˆ°¡�™CWÌ*‘®sO7ØP^(j+·¥Éäþf2¹^êÉoíßû÷ŸÈ3ÝL|±cÖsFاz&ÚÜ·äŇa›uˆ3ŸCá*ºèAˆ²ÍxFâÁdÑuÍŽb´{hŸ—1üˆg( 6�%7[¬nX�F75£Ñ«÷^ýÕ""ç½ð…Ø}å•<®kbV!®Ï˜bK(`Àƒq¶ˆzTYæ`=ñè\ äÛ@Ê ÜϬµÐ«»$.±-¦­²Áä@®É¸®‡£k¤n^½û­W~~ @úyìzÓ›xRƒHâ“ 80IÂC#íRqH¼ûmÇC^ï~ \·¿å-éÉ_ò‹¿„/¿î5{Däu""þÂÏ_$+|‘�àɽM›.ftvÈEîhÜÓ7æ�;"dPåWZ¢óµÅÔ_Äø<³Ú¼¸¸°sçï‹Èw=`L4�.˜IÖ¼^]_ò4ƒÁlÇwÛÜ¥k9+9аN˜Ã&óëT’DD–nºéŽÁé§¿±¿iÓï.œqÆè÷,»D�Iº"ê`/ìØéè�bEUÑQxaúöð‚ˆTèƒÁù'â95M³ÜLê;Ø4‚ª× Šˆyt²¡Nèp›µÎžûhÿŽE¥d pÿ„59�Ä`ùnŽ&ùl­Î­õ»sdkŸB¯OeÐØ0Ç`ã±  œ~(ÝÁƒ©9š°ÖÙ©ËÀcöpwš5ׂó°3Õ^Q«¸+ì4Ø"¡'[@¤tîIì|2ß-×ÝÉ–f$kcµ]‚ Ýx�M05X„¢hÅÌô‚‡b÷¢wѵO G òÀ 'UÝßü€Óy—t!ÀÚ“6]Ò/ŠAA�ÆÙ‚ÓÙéÏ÷Þe�5W2Ðüöòà Uf‚³>3�ëOø9Íšo5¡:Þ]ÁR@ÈfT¯? àºý7Üð¶UëÅ!©tï°ŽI;!LSÀÜþÜ 7ýÌ>…Û[žcM@¥ìüùtc0Pz^x4>”×5§ê –�ö`½:ÜÅñø½•ô^½÷úëïhÿþx�Hæ6Ô3Ÿ9TB¿î%ÎÒ¹T®˜¬P^”B¹Œ}”ÐŒÁ}!bÐrÆ`¼ý »×Y8ZéùÎÙç` 1ŽÚ|×»ÞõùÇýêÄ-¯þ=îzÓ›N�ò‘2ÕH Ä“£{ÕÓkl¥2@±¶÷J“Ž2dÉ¢5ùµq}ùu¯1ëe×Þt›ˆü¶ˆÈE/}é·OVV^ò¢j0øÎjaa‹•YèZõF:&_F–¿GwóƒÝ�YÍ-ÕrçwWû†!˜IA¯'Õ¦ÍO\ܲmópùÈÊÑ.e¯­+3)åÐ~Ñ1dhVáŸT€©†§ËË0G’æ×:Wuª|ÐýèG‡}¬^]i¤i:c«h �uë·•Ð+Å”Ìl ˆÍ�¿éïëU¨ g�ˆgT/-a]ßÒŒ'3°t¨R§N0˜Š%gmewžL»hŠ\PÆ[yíp�Ç£Ø1Ú²Æh` "ù¯ézJÜ��EÔµC’wµ² ³ÀPKJ©ºdÒDFdàq-ìì34«ó¢…áKáVd‘•éç62çCâµZ"o©àѰÞ�ˆa! DfÃq;¶€›ó®4xîÑ@‘ÝÚÖ9 ²ÑQI³Ä‘GÎ4¨kðÌø�ð"Ïß@˜;´:U°„Ñ£<œùY®`û'ôz‚þƒ 0cfJd;ùtñ, Vf®ä ØiëZÄ”\2´�½®Kès-€1ë—M]�º =¬Sd4çË$ŽéFÐÇ$“9kŠQ}c2:˜Ô++ŸjÆãÿ±ÿ½ïý‘}' Dr@{Ù„¡ôŠÂ,|6'±[Õϸå%asW�‡NËj ëÑëöi†f$d6Ãm=b,ÙQ3Ä ÃrÌã³ó%â'H§†Où.fƒTž–_0ñ ¹®Hž�Ó²iFÍpt3€�íï{é¤8¸•[e‘¼—jÈaÄ0ûÁÉ�Á­�Ða”ÖÐCÒÞò\­† o¡�Œ\¹«ú¨dÞ:¸2 E£Œû€L]L \#^9¾ÔÃá^Š|©�~û®ë¯ÿý•»¯ºòÄT °iã£ÖB†¢”¤3Ö= ÊF>bºû�{õIõ=ú|šÅ!¥;P¸¨uɳyütuVj=» X�3~_5è÷Ï:ë×ùìgv×Þð¾SDbÀNÛ€qƒ:_€.57* )› ¢Gk7æ÷µ|ízã_D~ÿ—¼ä "ü¯\T-nz"z”­³Ä¬2¸B~ïÐnœ‘G)Q¬°H*Ü`6‚©ØõÓô1ضí9"òŠ ¥Ç5WÙ$�SúngóìËà°áê’'8_Îóëá$ú›�¨zúÓ/îmÞò÷ƒÓÐëÙ‚_mºbC±ì¢ad†©´:ÒØ�X‡”ulŒ!"è÷N{Úwœÿ_þÅ¿ÏçS „M³4YYYíoÙ²Y¡3õ&º€“®ä,B:¬\s¤¼Mñe®¬ìw|N®øÑ.®#2Šrml¼"R¥ܬtÀÊ`—V¹î9¥¥¼u‡1ŸëáP¶kÛ²,B²Ò Æ�A1ô˜h—$S$4Œ.?­|¸·ú-ƒTü6B6~²Ã«á–•YC©~vký�\°"±˜œ÷`F×+õ•'»PÎLEahp>‡†Ì,§è’d?yCŠ4ÄâEç÷dÿ]=*œ@1ç²H»-ö‚ *dFœKcÖ"Š}1á­\v0îV,¶|7—Œíû7ûÜvöLO¢(,gðÔ¦kÚpV_ßÀ¥Ñý{¼™LŽp2ùWˆ|xÿ 7üÆÉY÷¢a.Ž!Å€p8¸Ëq=QUïr#(¤Q‚(Åqªâ‡6«Èöh¶ Z‘Ê •—(LJ’ˆ¨QÇiüAç8Mn^LXYG·øêd4ü¥»¯×?žT‹ƒ,�#ñ.ª¢s?Ž2›EŽbò+ð¹dÖJ2ÖâÐù¡G�,sÀ4¥úD»dY®#:àX8Õb³œ�%Hê!jYñXi)ç2,l:ýô«õ£?ú„»ß÷¾Ý§DQ Ç ŠNdwÒ‹ÀÉݸ–Å6šU³Ž³ƒòüZPºüòϋȳE¿ô¥W ßÿfôzO¨ýA0-š²£.Éxy³X —ö•K%fyFïØæº,nU zý£Ö›´iaΉõyDΞfîÌ@`ÓM:s¶†">6pÎLš_ÁN±«^^þÀêý÷ß8:|xH=âæÆÂÙ öŠÖZË�CR�JZ”L}];ªƒ`·¢ƒ~O©Põ«AïIÇûÙ¬ÜrKúþr31”챈�×î eK2:käâg°,á=o…ë&œŽ£*yŒŽ´šž5D™EYf pÔÙ¡½ª…GË[ Kˆ6Xer xÙ:X—ÅI›<£ z´Óg´¨‰tœM›>àÅù°Œ¼ð¤oãܽÒa•·GC?Ž…‰\‡Œ#¯k@ó4­²kCìÎfÆ h9~ ¾­Þ®¯YïFÊ÷€0¬)SŒ¬§†~›Fª~ÿéGH©ØçHÈhÇáa]«q­)°C„ ¡/ì½üÂÂÆ÷”²p¼¸Y&„Ì:¡”` .`ÔHiݧÅùÓEgMú�ö5£Ñ§YׯØà OÞw²‚HˆÄ­Y:wRf·@ 'íº· $š­Ç4¦˜" -ÖN—7ë�©G´�.�ݶXWº€ÁaKØ5Hu.tk{1»k–3{Åž€ª¢šÉh©Ž¾Ü G7`2ùº;ßþöï9é@$C8Ë- §,×Eë”I“*\Ãø^šJç.1`SFZ¶ãÊpÔw3tö˜ò¸•ghÝçëtnÔÔF{¦ØB¸·¸xZoûöëO Ég‘Ô *)ªNM~?’r£–¡nfÈh߻вÆDc^|Í[¼õ²Ë^ô•×¾öÛêÕÕ××ÃáMõd<ÆÌ$²l’)ñHû3I2ø­¦A‡"'ÝÑ’�¯éùO‘ªª6�û¼çÿÒQ†@•'PîP¤Øñ*ƒ�Š Óm NAý¾’¿8£œ_óëT’úÓ»ßzë¯ ï»ï¯ëá°ÜÔbÝ·�\ŠôÔúô0bµ^ºÙíØBÀ¥«U+ÐÖëõª^ï©' k«G£½qØcwŽŽbp&¸û°×þØÌF Ø XMA �­¶Ó7I‹ÖÓ!éVH{�q€ÈjÅT,N©Ÿ~fÙ,3ð S.xË&]l™ÉT•ȬåƒB )'ÅÐŘĈÑaê:Çíó#d�î…g "tK× ¢ ß‹úOÕââ·?�\ÞMñ¨ÿeM]�LÉKµs©‘ï`þT—G¤ÝÃÞp„Z\W‹MMÁ»‚5n ÎÒ$\˸ù˜–’²ôJtñéÏSWˆÇrZ6CFZØŒÇ9…“ñÛú=åQÏzÖ«N�ж)„îU²BæƒØw~ZíIÀ’I„æ…÷ºn{Ã~å+¯yí“ê•Õ?š¬®~IšÌðC6_fn,èxHåxŠÝ«1t)–EIM£a×Bµ0ÀÂÎ/{pÀzn:±?(·ã‘>ê2Ö\¦e=¢l‹¤ø�9ín~;ú§â‡ßrËÞÑ£}Yoqññ›ÎzÄÕÂB˜t¸Á Ñy�h%Ý!7U-‹Q¨Ö)ŒnMÞp¨ªªê{Bàý¦>ÈñèNy‚ŸÎ.ï¥eMœ¬Áº&9Šh�GÄj¹å;Q‘ J¯�FŒOìa�oxÖ¯Î?Y�0H‡®¥´õLsÃúqèÏ@O‘§‘Ø)55`’\¹ 4¥h UŸöfÀ4ò±IÅ3”åWg±ošdÓØ+›eáX1ìX–Jô2ä,¾: Ž3߇ÀXÛ‘öûCmí˜æ¦vTÖïQÅÅ`Ôͤe:ÖÃæg%Æ•ß0á¦ü9Þ”®è3B¢V%åNkiÍ Tƒ� ¶lù¦ÞcÏ­ê¯ÜÑl&(¶']¬„ƒRô}¶,*b$åˆV•k¡}×FóÖ¿ê{é�`trX¬ÊÙÊ©0Ý�½�o)CB»&›¦™LŽ8$"_ò7î~Ï{«äìŸø ì½î:Þuõ5'a CO°ˆ! »II8Tó€ÈÀB«%Jì7)EÂÆúP‚EëÓ�Æi§8³™TŸÙ¦“\gpW»€½76͈u}@*ÜÏQ}ãžw^ó«§LB§[ûN+¬Ð³öcinÕõ(Ìiž´ýt)Éc‘¦i¤�ö¨PL:bƒ7FJÓ[œÁjªÅ0K æQ¨|E‡ŠØ€À¥ Io æŽa|ÌZš‚•¨zØòèGÿç3~ð™¼ïCüäɽxØêv·ƒrïM�³i€_\\ñ¾i.þˆltm̯€Ò+Eä•¿ìåW£ß{Foqá,é]3KŸ…û3ºwd_ŒÜÐŒ&Êilé�s7&�•T‹Ggºäë/lnóy‰ð#¸Ph›ÒΩ²Í}¨$BÀã†ù5¿N@’ˆÈÁ�}ì}ü®ïn¤Â›7ŸuÖÙUàêsõOU—é…Ú8Ð�€iìz$Hë”PÜxO¶­/9!ÇjÝjÆ“i&“ªz}ñjNôŠ¥©˜SA8¶° ˆ#íƒ5T ýZñ1Y`[��ð18Ø5Êl쵕s*ö=ÒaÄÙáô˜ç‰ÕøcùsÁ1å1Á²«hA6U$ȨÓíEY[ÚµNžÛC#:q"  q"âT‚ÀÔkZ±¢XO�$iS¿ù^µŸuÑßví‹KCWò/!µf¬þu£ÆêÄr©ÄÎ×;} øÌä"é6Œ)0Í ,@¨F} ŒÔ!âMщÃô-Ò ½íÜ‹Ï?ø•;vm¼ôº!9£þ¼0ðR›èÖ’l¼�A%"ZI!so1jM)ˆ¯¬(vxæÔLXŰ”M‚Bß±æ2ÓºËi�"ÒƒÈiý>ýÞ𮥕}Òð½^ÿ•{¯¿nOûãýS?…»ÞùNŠˆì½îºS"ÕC¬‚­€Y*ÆN¬å]ßL§z�ùð3Né+Î7%äóvßZÃ;ë[i§KׄB$¸­é†¹ ãsHqN„M]sR¯HU- ¹—ãñÛ÷¼ó�¯>s¹\¼ÃêOÂ*/€l3!5S\£$[ ú[ZÒ/é½QDꥥñ�W_ýȇâÏ}ñ‹ßÒ_¨~ìïv“stu¬g©'¡ªN IDAT&-9Ä¥2}Ü4À,lHåNÙÆÌöœï÷zÛÎ;÷“§~ÇŇ>ùéûNþ„­Ã{™šAFÝ€j÷´Fr�N@Âô™ÕyEÆ8¿Ö½n}ý=ï1Ïÿ?Îß|æ#¯ÆÂÂSQU½´£5“ZÿS õÃÔv¥?‡5{1bXD²-+v¬A TOϯMG�§ûœ“öÏ6/¦‘K�ôøË4ßëýÒ�HÖû’s$i~=œ�$‘ñ�ƒoÛòžÞâ¦/ìØ± ý¾�/º6dQ¤qÌW}šœˆæDRå�¶àíU¨úýÓNÈ™:™ŒI�¦Y{»DP+�j0ØÖRÅiº¦ê°.öÖ- ¡×n�ÅK4#�}hxœVˆÒ•¤9oW®LÈvÛZ\Ë+—Mñx¤É‹"—­ÕÞ Ç§`LÓ5̺\´6m¦ƒ†ÿ(ŽŒ¶+^hoH1R³öûÐ�Èéî0Œl§}ãgtYJÌÍ0Jf¶ã™A=ˆ§I_§x¹îÇ¢WT*¨pï_sê(HÅ$3Ó PTáS‚DœS|ÝtÐb)ÍA%ü Ù™>8pC B¶ÅÕtûW=a½þwŸöô§õÀ_ýÕüJhXD–MÓŠJC¹$J3“à œˆ½OŠ ÷�¾o÷Ž Þ�v—¾N �[˜ ›^È{”Áš™„ºv«Š,T�íý·,.rç–-|ì¹gO~è¿ÿ®óÎ?ï×EäÚo;óÜâ¹· Ò)…&v®”m²Œ€ .hC¯–ÇzõÚQ~çtl6& ˆT�Ìúƒþ:úÙ‹ àIŽWÆå°=P “¤4¬krT‰ à>ŽGÙÔõ¯ÝuÝuOù,´¸eÍxuƒ¹:u! äv·ŒX2¤h{µû{êðô�Aw\qÅÏž÷¢žÝÛ´ù‡µó«íÅeñlw©uä(ÊÙ®ÝW:íòÚ% «ÇâDz›wn»èâ·úä§D¬hÁÉO—îmîlˆóª¼!;}¦³Õ±"#~}â{±tœ_ÍuçÛßq»ˆ<íâ—þÂïö_. ; )À4$m®„¢ AAð�5r°7P¸œEi¸oU58ûÇü{ö^ý_ �6M¤{€!6˜pRaÕ¼ÒÏ!0+òµÊüš_g iåæ:°ró?ýÇÓ¿ÿûö±©_¹xúÛ['·²Ø¥ÁBLš¡ºŠIôÔØ¦h>Ìù®› I˜ Û§Ïwr\JUMX×_�¬®Ö ƒžãò¹CÉéFê‚å6¹ÿ>;†`§¤Ó"¢v0WRtN"OÈI:¦IñJV†ÂnÀ'íLUŽS‰£ñÕ�ä-Jo2RA´võÝ×À&U�ªð�à•6Ñ,‡4‰ÈÚ¾DsJ—¶q"õMBÕvyÀåÕ«†ÖP/¬Tàdç©é;L©À€½u²À鑾‡‡¸oaiÐT7#¡þ=8ûqíc®Ø)1K¬|.4˜û´¤)Œ´û¨ Z…²·ï®ªž("Wo8‘WIZñÑua ŸÊyç,ËnJÀž)¨è£Ö«a�Ì Ô�³u±ôxã,�¥(—Õô‘žˆô+‘ͽÙº¸ÈGîÜYó×]2|Æþ¯÷nÞ¼¹ý =ô+ùTópÉå Æjº0ëæÈÈòFjœ¤ËÝŽ8c”£tFFMë(Á«¦”`ªvZCÑ\ún¯f1iÖ´A8TYfÝì’ÉäŠ=ï|ÇåÇ$4�©øÆ`ÆÛÌÙjòcŸX S€b¨³ìÝwuñêk|ààoð49¿¿yó7ÁqŒÀÕ2”“0­¥À@!9ô)0NuÙ|#Êø•ªs»emA ‹§ŸñC�xæ3ŸuÏ?ø§''I+µ_é’}¿>°0åmdL.VvDbµC�ÝÚŲç×Cu]òK¯À—_óÚߺàç^òƒ-Í[ªM›[4×`Ó9zSñç(kô(l£œÓµ8â˜ò"ý>wîüO"ò×[Ë%vLkÒ[|ŠFëþæÅ(®}CÿXÂù5’NÂkyrøÈU£…ů«zýgvì\¨ªÊ�YÑh(›z;m|mÛ+¢uÊEr’ ÆT½~ÿÌø�‹ïýð‡o9žcrð`Óß¼ùŽÇ!z©Ëé_é�›g”b9›L86ŒX. ¶‰ãifÖ“As²Ê,ŸCk›ääÒw†ØÉˆÑ$´.ë4¨N‡è¯ EÎ6A…¨¢ÀÍÃÅèTþ¢C¢F7à^Ÿ~OLÊŒ9ÿAÔáãǼO‹"¬‹ìØ úÙŒû™1VPnxbt}ÊÑ”ü>ò( »Á˜hæÁÚ6]Z]¬)ªÑ¿ÈÜA“^ÇX û åØlí½Es¤±ÓvÖÕ>‡“õZ+×®Ðë=~£©¤É6çt ™ÒÅIcŽXPl-–ª–0²®­~ŒÖ�X·ÍÄô+>/€ìQeýnÜèCúÌc ’�)`ͽ¢'Â~¯'ý^% ý· xÆŽ­Íù�~Ôø OøÆ#OþÖ'€µ;åtà·Bþäh ¼oÒÈW«‡Ma‚ÎΩYnÅlUn¡9;èŽ�I½  Hš C.%¼mµ¹¦›b�!�É´ì"u_�’„'i(h„Í„“É 'õ>©äïz‹›^sçÛÞvÓ×T& _ÿ£Ø£�Á¦¢Vª±m©}GCÅêÈÈSäØL'Ýõž÷üÓ9Ï{îkê^ÿ÷{ ;Qd4mø§aK…T_‰ÀI:�¯ Rµ ÍÙÚA*¦µû ¶]tѵ+OzÒEKŸûÜdùžÃ¹4?²cÒ¢¢)¢Ùæodæ‘׿SmÇâúòk^K‘¯þñå=÷ù—>cñŒïêmÞòd¯u*:@�Eà�b³Ó˜èt䃅þ4›™m TU%Õ¦Mß°qTt:!QÙ±ïî�ïk“1�I�dÔ€¬ VhøA 2Ì§Ùæ×× �$‡?ó™]ÍŸøJÖõaŠ\ºxúé )ñ3è¢%K-ÁCÅŽ§T·‚µF�¤ÂÀw‹Èq’6=êQBru2Þ· <JTÐ & Xʸ­”ÛÓùx‹¦f¹äÞ€E(±õ�"à /ŠÄ’nl Êþô£Ú¹IºžÕ=yÀB‰~¼ÑŠ1Gɬa:¹Q�PæÚ‡©g`?ìÁ¤$¤'¹Ó£)šM”Q/nš•K`µ¢  º¿Êw¶xl R’0ÑÚLI2Ú�ÃÔx©Ð,î˜�‹J í/ú5eךvæi5X¨8ɲ>††¤ÈE¤\¸PbÁj0é.U÷´}>´°ªQ€«ž)‚~ï¢ œ&ƒö⤼5Ы3qÙ‰j¬¸`­ý\–BKÙž&…„ˆ4B²ö’© ½ªZûѪ£Ü~I]ÐCú|¨ UUI�~¯Ç~UɦAŸÛ6/6§oß6yÔ#Î_pÁùË_÷ �?pú§M ½5(bíÂ*ÁÐ4Ÿš§’�•P8‚È—DDž|ÆcFXA «=–nŽR@Ûj}•H“üv7ÂF"l’(“�Z=£F̸dþÈMR)KÛ²ÉEÔÙ°á„M3dSbû›zr 'õ‡îù³?»^Dêy ªOVe$áãÅ ü #ó‚ÛêEÑ7Ò€æ¤+H`ÏÕ×üÏó.}á%ìõ~C³çg‰ä&¶5:`.ó˜óQ56)jŒ9¼·6‡QòíÓ¯zUïŒ'>ñƒ+{ö|{³oßòIOÙS‚ǘ´Í*¶˜ÿ>|¨f›ÎkðcwÝñö«n;ïÒ>g@ywëÖ'¥MMNÔP›ÜãîÔAl‰Eë´Ê×£¯z½í_Ì�5Û²)Ï#äz  Ry ó¶!­5DM£ZO~D£}ók~} I""K7Ý´G¾é‰¿µvNàÅ ;¶÷Yõ:Ø6,!Š1ˆöÇ»š›Q\Õ¥F%èõ*é÷¼ŸÃýý(O{ÚÓîiF£}9[;Q¥‚?œÿ¡sL#4‹Òª ²ÿÊ‚„$Å‘ëýôB8§ÀRH~R²ÏàGŒ5ý¤›µìì¢ äAK/‰¼j‰n¦Z³m‘?Ú(bÒ¥Ÿ N_Tƒ!á‚aÓènM‡ÜE§�bMWËtíbæ�4J(ËY[[ ç°(–i%À1¿L4S…�™Ê ߢ{íÿ5ÇÝ�õx€Ò¢`·«š]FΉ®è|iæX Àª˜Y-xÛj.•âô~DT£È�ª×?wã§ÑÙšzJRÎÿR Ãìd³®áFWrÌ¢Ñ�2l¤”ὡá¡CËÿò#[ÛOô®;¾Ðþñù"|V%²µ’*-lˆT¬…¦ !«µT©Z‘j;»ªªôõRMß8 @µö3*Jm¢ZùûJ—RtPQ‰ŒDäŸ~éÊ‚.ð¸‰�¶� ÉíףؒËÜ‘¾ö_|áæ—-ÿÃ?¼ažžHR²³£˜7h£7@ð¬4 NYâ«uäTÍ*ʱ«¡v_uå:ïE/Ú‰M›þCä fò¦š-ë ‹²ÕDæD*8¸ªNNv*F ¶oÿ†3Ÿò”ŸÝã�$'_� æià… «‘à2S–Ú|IL®1™©¾klÕ9”t,¯ÝW]yÛ…?ÿó¿Y//¿¥¿eó9í†Ñ¶-¦Kìe5ˆØ³‡y´QÄ5ÝT¬aIJ÷BÕ€HU Žz«ÚríV˜c’j¢@JY×6÷4 xGy¤r´¦s+.#Ñüš_ë_ÕÃéf–þù¦}«÷Þûªå»îºjxàà�M#ÞÃC:NÁ6èX�ãi`J�Ë,-Ký“U3$±ª ½……§ž�sµ®ïçhô%2w Ùq¾jÓ@ .´ÁYËLºÖÈ‘b®À¤-Ô€CYFû¬IàKj[›˜.ŽôÁÿÎ$椫ÖÀ�ÍgºìX€0@¤ña�)¤%è:äë »*}!çx•‹}¥‘ÍžNgo[®ó¶@úŒjóy­†&¨iº}>Q¤T¢µâ´xJuÉ(©úTq+Lô¸=*j�I{A‰GÇ?;ý`¨$8�p*dWZ6˜Ü<¬& L·4AàOœ  ß_Dƒ=ç0”ãd¶×mø[ÆÕ²3ã’Ðë\-ÞòN$¤¤ÊÌû¯Åí®ðq¡HµƒÒ«9e„@Ãs¬§·Ò¸Ô®™N¡Vk�©Hi„l�y­f8†�Ê "¢.a#Â:¡`œê)«ÂoUD>)«‹™¹gö Áú%Ó�Ó¦bAà —Ô/ÛE)Ð*@Ø6OOˆ,Æ=XÜ™ èƒT¤àF¶�0­I¨M(´3‘ѹ:†xÀê}÷ý÷zeå#…;_GØÒG¤[/‰îà§BÝuë›g­AGÂã0ÑÙv»2ŸJœ×<+NMúš€Å?ÅÌãæ§GŠñÛfr?Ò?ÐòGáÞ§UÏß O�¡ÍÔH(mž¬ƒÌÀ.^ÃfdC�æ˜;¡XJP ÌPTþÿZ O•¤Ó¯mÁ .- t•¦¸P >çöÔƒ£=H�–GD�Ê'® T6kÞZ›n¨uŽ"Û —&Àeþiu¨Xj¨ÛjïgL3ë§h©µ’†„À&*ƒGÇ;ìö…1o‚ª{†@&è™j-ëj0XìmÚ˜s-ÕˆZ;^ØŽæ"¿i€ >1'hkÊATû†-¦;n •)B—2D‰Èi¦ë«Ç !"büF͵¤4XIÉ‘Mñ¬Æ¡�ˆ4Ó"N¯µµ]â½” <$"ÿø0ƒ \$ úŸ 쇎ûí3GU†:HëÀ6OŠbžü�¤ËÄšm3J»üÁËÐêœ�ªIÑr­K$’ë>V×þ÷¾ww=]QW fè(¶qÍ[ˆ•v³‡î\žî-Ò¥@šu£œ5�VßòúèïùÞ�mûöoÄÉO{yXèžyl‡QD¢Ã³e†®£ù­°d§È¸f~Óë+¯¿ì“ååXàÊ9.$†áF»‚ Ò¡Ëï9G‘NºË3ˆMïØÉÉ�Kuœ®_Ä"Õߪ'‰ý˜òL6ôCxí¹æš«9i~¿™4æ^íiàìb߈³µS‰ Qžy.¥(vƒf=OÒߺõœ-gŸý3g?ç9ý“)„D'§yW§Ðrjøe!r8½?ÚÝFB�zts¡âã7ø6µ¤c¸m„#+1…N33>?£’Hµ�b³]Y¥þyÕßû½^¹ÖâZËÝ`€u‚ʼ¦Aͤnä9m¸˜ü³’Ô0EvÛ9¦0¿æ×ÃH9ò™Ïì[Þ³çUGn¿ý§–÷îýüde…méî:Á�u™Mfê$+ðæGHéõ·~Û· ŽûÍSRÕ“ í½rÝD«#¿ ¥¦„”aÉ,&û}j„’çŠÛ`8-8³ÃH‰£ñfîxy--´¥ljÚfÐÄÓ|Y“ޝè¡v‚áÉI]1¡¨º-�ˆÃ3Máƒr:¥tYª �PFûˆìdZCí¶V® Ï:’ÂÌÝ·´X*[+ÿVßc‘˜4UÊ™~d~OmaÚ¡»!ÅŠTˆ0$åkpÊiöá‚‹Ó®QDv=å¶PDU� ìÚÖILÏôC@1Üòs6r“•|ë`Çv}èÉ#LÚE©ý.Àd ¶hºë{Ô '増öªg§ë ]¿ûæv•_$"§Q®1„¤Êã¿ytm »‚ÒЫ¥dž]3ÅÙ­_0ý~‰P‹´:¦,@{b`MqID†"ò9‘_ÿ™Ÿåûe¡ìµêŒnë@cÂ- ðXt-‚�ÙrNv�ƒm~� ‰ÁAC?éG’i�dz€½  h%³2_áâêC}í¾êÊß­‡«¯�ëJNM6èítŰ²éÚ Ž!S‡%X£07�RÚéU}ñ¶óÎûï÷ß|óãN.0ÚµI5³=*a£s«±–ƱiÓQº–HZ\(Ÿ÷¼î>®×pyé�êåÕ¿iAS1n™Ó5ÝŽ¶êw®ò‘( ×N}íÙ£èjiÅ/<Å• ²éœ³Ÿ¾±µL!šìˬØ2ë@Úf¥05§¡@`èÜVá]€%åtâîÌž_óëkHYýÂõ6-~pxï½/_Ú³÷c£ƒ' gÄg ³hô7[ç­×"ßft "½Á`qó™g~ýñOÚ›Fšæ®zu¸b�=½Á"ò�DÄt¨ü÷h¶8¡Av±'üÀo ÔÏ`Öy)Ó!v§{Úz|môdšˆ5¹`5¹iLgŠPéb™Yâö³éyijÚ|„؇FŒ ÖÛÌø«Ç4Þ¼o]¬û#nL0'‰"° =?mܼ02(¿¥ö^ÝD6}JÌ =_Jà9†¢ÀŠ¢cÝŽQ7tšŠYš–¶çàÁ­rªñ4=�®Åë#ÛgD»%%<±£P§eµÖÔ€ŠOÂùô:"ùwWÒû¦^o°þùˆ‘£(Qò^±‰ Ó×ј¤ø‡–×­øñ(¸øf¯ÑhÒ‘ Ø AËînr$© ÒŽ)š&¨¦;¶šF.ˆ°NNkZœ…BrÍÑ«ÅóÓ}ŠáºQi•PÖ´–ÒŽY…ðÊ_ÞpãÃ$0î�â…BQ„Ç@…jþkÇ-Z†qªµ�_'  ÙÏžYD v’h,3€ÅÑÅ”Ø*)±7 �êã†5²^]}g½¼üEzíÍô TC@‹¡h$&I°y]qH« HÎæÂR‹;§Ú 8ë©Oýó3žñŒsNøÂ¡Z þyÀÈæÓÓ5WŒ¡GàÒe¢jýX½íyÕ}"®;¯¼ê.~A£œN!°�IïÏ:AJIö· «Í=Á¤—+™çäô ÊÔ1Yõú³^u>9L©|*¯EZ×úµÓ4ÉP¸ýoDÇ£3“å|mϯ¯m IDäþ¿ú(Ç|ußÝ/^Þ³çÍ+{öîo&㜄hqX™QÓª" 4—ñ�Ô)S£BO*|ëñ¾çM§ŸIÖõÝÍxx efJêä€F Xœ£-î˜s·ð™AÑ~iŠJ&f‘bz �³Ñ°t`îTè70RšõmÁƒI-õp$“#KÞwïhuß]‡–vï¾}õî}÷5M“²ˆË?‘ïÍÁTˆ¹Y–¹¬§yÁÿ,±Ý¯€T †®i%+¦à²WF¹[dj8.é@Ì®šŠ¥q(Õ&HH_£sšÒm>�‡išVd´fx. €se¢&ï¨ÅÔQË,D˜�ÑÞbÀ{Fì,$¥p¨)>JÒ€A™Gƒ¢Tb÷@Í­º5Júe…@È ;ôz�—^ï1’t«Ï q[ M@”�Ek#�TÆ.M‹~�‘3óïRÖ¹ê “IíjŠ;ÓGNŶ)�î°N�TüÓÃ’@{ô*§Ã5y‘ý�T}$s†BB§Lñ#ä‘d6m(B†õ¼1SÐðãx8g œŒ e*í<õôˆb”K„ÊÉQ½5£ÔjÒ�¡èvŒ?¦×Þw¾óSMÃ×s<¶�eË ™¦Rxj§äFZ ` QB�2ŠÒ3Ìç:,f›öfëÖ z‹›^xÎO>÷Ä�¸5âb…Ž%ìY8ºÙveò–ðêt9‡qZ�_Çû:À¦Q)söb4BÔ)ß.sžbt6Ï”¹±h±”~-Öî*B®a®RUÕ#7ž@!IWh6´Õ¤cõCÿY¥í™î¬i\C'Î')2m›_¼ú_ 7¹üÏŸoDäÖ­O~òo7Ãá§šÉä¿.ìØy~ÛÖ~Õºѱ)D[Ʋ:�…[[ïUýA…ÁÂ÷ŠÈŸÏû½÷ÿû0w>íi÷Ö£ñ^<&kPXAÂ68Y—:q ;Àdv�ˆµJUºH‚™p¨Ü” ”Lª`F†²uyûsaC‘†ÒÔiÆcr<®ëÑp¹×GšÉäëÉýœLö5us7›æNNÆ»Æî>ã[žøçUU©[`¶¨·é�’'š>'†f±bqëF…dGª‹((–•8}Ì–_ãÛ©5¤ £øÔd©‡«Ò¬®Ž1ôQõ€ªéõ¤ªZ×.ˆH‚JºúœšuÙÂm¢,s�®€º·ì 3}ÎT‡5PÚ�ÂNã„Zi†­�Ò7H +…l%–S`D'êà6Õ§oMBYͲÔìêôUË@mþü0©G— t*Ì »ë´£wP,AGÏ—¬Yõ_´ÙbÕ«.`…õ3ˆ&R^sâ­¢;zІ­¸æ¤t|Ì EÚ#„bí߯ß}ótPM'";[…„)tSù—£ŽUž i£B?ïl tÑ“ÆìhŠH5}‘k6TÑíoBxËÃÑ>Ú¾‡€)‹ÐRNºØÄ6ÔËÇlÛÖùuR­«š�; HÊjŒÖXÐ�tF6e˜p<¯;ßzÕÎÑ‹Ï�~ÿwLŽ!¥øxLCU@YbËdmI-'e´ü�ØëS]4“U½“­çŸ÷;û?ûÙ‹ÈgOÔb!tÓK/"„}—Ž”=ç[S@‘Á‰–ò —ûÜu`ŽïŦÙǺ™ ×ëÛÆlü23¯3Š…mó‘Zd¾e‡NAÏ ªÎÚð‚F4QÀPÓÌš 弸-ªtÚ¬óÜv.´’Ѷ•ä±·¯œ_s éT»–þáîÝú„'\[O&·ÕËK/Y<ãÌìØ¹­·¸`¬c}Z£Èö�É� Rªê1""§=ýé½õWõñº×f<9$“É>NÅK[16 t'5Ä"ŽùamÙÓßjvOJüØ–JkÿÎüótáo)OÖŽž …M-li&©G£I3 ›Éøp3žà¤>Àz|ëæžf2¹MÈýÍd²‹u}7ëúpÓ4KŒ*‘Ñ¡¿ûÌòYÿîGÿ¢·yófIE5¦Bß L¢µyÍÅ7�® ¸&Q³Ëhˆåˆà¤†Å’àžr­rL& '9²kׯ ä¬jŠÃÒ IDATª?x¼ˆÔ‚jGó¦'TƒÁöªªú úXX¨€jÍ•«×KÖÀˆ\ •p£Û+-�V P‚ÄÆý0釹 -ï]d[æP€ïÀ¢B*±Vº@df�…÷Eû¡ÀòĆá,³[ªé@X'?õùK Ó&* q–ÉŒ/¢ƒè”›l°zDî>Ò÷VÕ£7TN¡É±£¨ö2`dÇZwýá0ióü‹Êß¡hÖZðƒ/!›�д/…DHOR´R¶ab‹'«¹Ð2’ªÌdR;Bë&AD*‡¢Ó¸ ièKü½ˆÈSÎ<÷á–î—ïÝ$ê¾Ùáß1Kò…^gÈïžœ‘,’:¦Ï¯“h…aЮ ðõ.­ݙ̙l‡j¬Öå#ÇóöÇ«Ë×7l~¤¿uë·§†ÝM΄àÄe·ÍP›lP Ó™q~*-»J±¿¿êõzg~ý׿cë#ùŒ}7Þ¸ëD IX£}ˆ³|]/V- ÓOƒ˲8³3 莆É5.Ç÷jšƒd3¥¯;&¨?¦¹Ôy˜s·…{Žø³¤p-‚°c£0¹•ú¦ˆäÒd$mF漂´¬;–Ài”gÌᔿÎ×óüšILúüçG"òñmßöä¯L–W>´xÚÒËNßù-ý­ÛÕ‚«ôiÍwh�š£§f:¢×Coqñb‘ã "‰ˆ4ÃÕ¥f4ú"'“FP´z¡(“Zo¦ÀJTgàÃ$ 2±VÖ̦à†²Yb8�–ûý.ö3“èlûÎïxâ–³ùo«þ¹ØU‘¤a"‘‡€˜è�5'´­)㤠’îlt€†XñU‘.­Zúîê=÷þë‘üâkûžµ©¿°éôF8¹yÜï?UïôªßƒÇUƒÁ¨ª�Õ`pVµ°x:ýA5èWU¯¿æ2ªéxArNÒî(Ô÷<ˆ¦µ4=¿=¼ÓA¬q("©î7DŽ”´ù^¡KJøÑ3&ú0BðX}@ÒuiògFGšÍ¶Œë€lŽñG³q�ÙZფQ ½¿N´[ ¨ÎëôOßX¡RH "hÃrKÚÇÇÉÒ£HNÉ�(´SÄ&?dó9gí¼C-ªo·†'M9KB01Òˆ›•7_Ó&¤‚¤ñ6¸[‰v)$…•™±¬¬Æ©¼wU‰È²ˆ|ìáL]:î™{¥.YaLŒÔ~pç•Éëé˜r|üÝç×Ñ­2”¦L7e\\Ð+ ¹øc [—[Ð ‘šG·ÓO¯dï;ÞùÅǼà§_Çñøm Rc]”¡e¡Žót±šÊ�´P³S?&Ùexbô·ï¸Dî»ÿÅgþÐÿöª{?ðþúø¯•¸éâ³à6N¿é¸)F‘š>¶ÐŽQzpzn—~Üq$òH¯®Gn5.µ„ø�3(Æ¢8`01ô´Ÿf„#â�;Øzìvš«V½G·¦!QW–ÚåV#>z2„4øØÝ.-þ®èò̯ù5’f^G>û{EäíÛŸò”Ï�—�üÄ`ûŽŸlÛ~á`û¶Åj0©`6!(%5°Ë&]éÛÕ¶rƒ@-À!N&d¿¯&„r‘Ú:tÙ7:= –s €Ó;É…u-Ò4º–z2nšÑx\�F‡8™,7“É^©ë#�omšæ~6Íîf2ÙæÙóÿ³÷æqšUÕ¹ðZûœóNUÕÕ34Ѐ ‚ˆ‚И¨‘Tǽz�7ƒI¾\“ïû4÷æ‹ææw“_¢Ñ 4Æ ¨$‰hœ™I˜Á0õTWwÕ]Ã;ž³¾?Þ÷ì½ÖÚû¼ouÓCu÷Ù¿ŸUõçœ=¬õ¬g=OÚéNesY«ÕŒëõtîæ›ÛEq]gÈ-ßxâW£j-±®_D¢�ƒ;+1Z4ëUæ`… µdå�TX§Ež“o@†6pX1ßBȺèîÞýöt~G/½{Ç|`ž½h@åÔS«ÑÄĤ1¸ë&Iž†Qt2FÑ ˜Ä§GÕÚS1NVš8nDÕJÍÄ1š8îÓEöfm¹µ—J�'Dé,!&Ü9bÐò(Z$ƒè‡¼YÈwbnz÷neyÀ'@Biæ[¸Ž¤½/JÆR!ÿu²¢ÀUÎÀYC×?±É:0 ’{ ¶,º5 Úýº5Q”40ŽFGÄ4䉡n–LǼí�ÈUƒ‹Ñ = n�Ú&¾Ø³q26ë„.†¾™|%[Ίz#yl¥|-X¡n6}8‰ë§1ì£ [`󑇰¹ šý†ç6”,@%êªÌƒ½ÆÆýd�J�U‚$µ’Ê8yÙ qŽ 9÷¼Ò�ÿüUW¯>Œ²=g@ñú.Š¢…j�婺ÝüC—@ˆñ5œ�Š Q8Kw�°uZ;vÜ:ûãÝ4ì>tz¨ ÓƒÿÜP9ic-ž\½’øD'M�nâx#³!ªÕžajõc¢J\Á¤b±ßEœ ýÖ/Ò•�`#q !ª{€*™"™\žy1'¾„Ê܆„ÞAHŸÇs•âÔuRþ~e“Èk�î82¸ñœ…W1éî+u[ †o%8qPÕ)ŽLwn¾1«õ¯˜ÐCÞ}™‹6TÖµä„îùîÊ´.I#²ŒÃ~õÖ{ó;ø "š@Ä,o.CDãs·˜¥3ÈÍP1Úá›-Q»”ƒHˆø‚sIq; \Uœ˜%ÑÜN¨s˜€iÖx-Da-,àî›Ã˜ƒ¼åiˆŸ¢×YŽe&1ÐCî dv_ᬖ̩fuÁF•L‘jÿõ™�of’…À+ùí3’p92:½ {D-+žL¢’ o­Ôû�òÇEmŽÞ* I[-êìÞýî}½?�Í[[غ¶TO9©j굄L<Ujg˜$yª‰ã§FõúËÐDãÇ+ãzm�©VcŒ¢>°`ŒB1÷ÔÂc IÒaDPÅky¢~/.`®ä’噊Ònß™ �é£]gQ«eÂqtŠ]S¨v䉓̸ lD£šH‘_SáŸ#¤�ö³ÆÎ9熅[oÍFó¬ R8 äÉIh­A@…«ãbÁ¥ Ø.ˆÉ׃¸Cïd¨Ã€ÃÒG¬2 @ãk/‘DÛìÎГ�7 ÷úŸ˜qÙrÔm¯Î…·ø‘AÀyºàk6AQ> š½`&‘Ú,$ä~ýX˜(˜P:ý F‘e½‰„³Ën®„ö´ÃŸkAa›61I1¯uF&Àj$à-ØÜ•ëP�G¿øÅMÇ¿ýíK»Ý«M’¸û!�$ÀÄ!�é‡X¸s“:€1Ø*çñ|TW®|Þž™™7ÀŸİ=| >ö^͵(EÜ@ÀÁGôyqä´,ÑG¦Êqð6ŽU&Šª C±= ¬ì‘3úÉÍQņܕeÿc‰%!@–eS{"áXhXV2%QH ê9 Æ#ØuåYYŽHZòhÞq×\àÚúsžóÃ^«õý¸V{EÜh¼,?ÕÔj�¨REG"y:Mñ=%‰b4æeÇ^xÑmS_»æ �0HÔKg²NwN€N¬©ï€fm$‰úmhY”ö(ëtÓ¬×keÝî|–¦3Y·»²t6ëô¶Ñî^§}?Ìôï΀ڋ·Ý6�SI¶{ †Å;oëÌvík_{E21±ÒŠ+#kc!òb,`‰ž ^)D ñÄ‚��eÞfE¢U?×ÖÜð×{`  HB‰Z;v\·ç§?½kݳöÛÛІ~{Ü\0vöÙg#â8s&És£$9=ªÕž×ÇaŦR‰MÔ_}™—ÜÅ< UOÑ�9EFôˆàA1 t‚Ðt‹ÓÀz×ÂÙ”®3è ¥{\ÁUq-+Ñ™@Ê�ƒœH9ê[ÀZò¬ówiCÍ 'åNG#aÒ æÁ¬ÎŽÇÆnf´ ô!kŸÍY\ÈgÄ…°s—F„Ú„‘´J·¥&dxPEÅ�žkrm#&»Ž€�A*ËìÜRQ–m bÛs™"þ¶A€I‚I@Û�Ùøƒ,Ûvž¬$qV ^ùZ¯nhÐÌݨ,sÙ ­Ûâ‘Æø:BfHîêHTT/(0näT É+H„`Ø^·œLýˆ¿™µ;ÅÉïñ½Ñ2Y ç"Û[URM!½1}HÕ ,ôú:06ü�u¯{Ý-;¾õ­Ÿìý…mÍ0ÄêÏOÆ %âÀ¹“D¢Ø£ñÍP/,(•ãÀæ:ƬFÅÒæºœÄœk­Q ró ƒßê.ÀíUˆë‚jWk"‚´»ôvuQäÃàZ®È¨DÅÁ|uk<gÎú/]ËQIû(ݱ» ðõèØc¿S?ùä³’……_ŽªÕDµÚ³âFc}T­TLRAŒ#@ì3•ˆ%¥œ÷c¢Èâé}g‰ƒ7æn¹%›|éK·gÝîN¢ôx €,Ëúi~¯¯M�µÛ½,íu³Nw¥½ù¬Û{œÒt>K{�e½ÞvH³­Y¯·ÒôáÝ×_¿ú:Ý¢Ï �H{¬xÑ‹66Ž=ömÅÀÛ¢ˆdå kq¦ÂöjtÕpÎêâí;kpt‹p(§ÛwüCJË¿ô{­];Þw0îçÂm·ÝÆþóÆž÷Ü—GµÚ‹1NN1•ÊQ’oªÕ5¦Z­F•ŠAcMÄ\ýHwÊ8ÏÞ7™:È ¼,dÌNdJ¢�Åhý+,€¾ø3ÆP$ZÖpØÁQ¬9sŠ”6‘l2ºâ¼½Ö�!kØ{'¯@)—¢y¦‰b³$� ˆù±–p- 1qgÑë±0tIþ* VÖ-+�Î@À ¢,ˆ�;õ¢Q‹¿‘� ‰³Aš;²YæJ‚‹á!JG7÷Êq$€MDøÏ#DòŒ+ЇÒ)�â-¥ ýµ‚‘Ä<ñ€Ë´—cÙ%ƒäfR…�@¦mù„³¦ÚI²Rs�?^ônËC‹ý±«®jžøŽwýyÚlþZÔ¨ŸjMR€'ÁX�#’€ÇÜVbäl׿Ï©5¨“îþÄ�Æêx|üœð¶·]¼í‹_Üsà' ÇlK‘TÑ…4.É÷v±>À©á¨>ãÔßø0>ô—A@TwÅý"Pϵn‰µ!tbAvç‡fŽ£¶¦-ŠQns ‚¬ÓûùÞž—Ê –@‘�‚Pˆ'ßÝ�±s53 ¸è|y^–£’žÔH§¦ZóSS7ÀÍãgŸ½!j4^l*•s¢jõyñX㌨R]U*“$âL+Ž$‚1ÆõÚsM¥rÐã“´ÝÞ•6›7´vÍ<)ëöÚ�i$ê¤íö&èdÝ¦;¨×»7ít¶/<ðð}Ù®éÅÃù™ÕŽ;îËQ£‘�çÕJ¡.5ðFjµ»#Ó{��àt0Tñ€‘Pžë7øòCNüÒ2–² Z»v]3ó­›Õ=^ø÷ÿø!ü*õ3Ï<ÕT«§™8~¶©$gEÕÚ3ãz}£I’ZT«ÅE¹-—´3æàïHß-LËEÙ �¼;† Ô.*òu7tA†„$2ë}÷&Í>¸Ì*1wió»ý�)hñøˆ|QHôc…Q`R0¼f_�9âá@…X{âÚ|¡X)šÎ ¾¬çÝh±ð9s› HŸ uŸ¿�îG/0œºH­ZTÿÊÛr¬}�$ÑÄ(Ø[ý·œÀ[^°æ„#&pkI%´ÞýÊëjÔ@1�êw ¦wK §”ãa�¹Kª}räXŠRk¥Î�MäœÁoK' Îà»mä3J0 ñØuÏ»VžþÌ�˜^ò5Œ#•À§ÝHm=Õ“s= ®�)î“ë–.ÕU«^µ°eË›à³~â ;äT¯tèˆñŠ}|S—LrÄp�cQè• öÏXyþùµÙ�r·pã¡¿ü :ñ�ï9 мÈ�dHI+湤dŽÐ õã°Žg Ï= ÇNbÌfï¼c/dMtÑ3¤`9ìû©Ub.j0–|Ö–äºr”@Ò~Ùüm·= _€¯Ž�söÉq½þBS©>7ªTžmâx½©VOˆëõÕ¦R©"˜8îçýÿ{(4æo¹eμä%�vº¥;{ÍÖMÝæüîö]÷Nihò¼sÑ$µ_©¯]{nž°kñ½�¤ !›FP“mÈ„N#Éž)…º3ZÐÇÿ"&D,“ÂŒ ³°Ðš»ÿÁ÷/“ÛÞiÞyç}p|j�3Î8Ý4/7Iü´¸V?×T«Ç›Z}e\«&f*¡1LUÞÎ*âîoÁûÈPWuŽÊ³¤%ëÒ° b ‡½°µ¶Ú Œoä±2p ÁùØ‡Ö ‡(*—r1ŽN€ ‡Iqµ ¢˜‹f3@ÏoÇP‰ù] \» yvS"ùKk Ò3`�»!Ü7-”GQ0##aŒ€D„h�#KÝ#"è(áPû9N>7°“tDFnj:SPò6U¥Ž…jßAô“]=+xK•¬�å6ODª>¾ݽ¨XËÇãŸhŒ¤ð¥åÔö¸pë¿§«ÏzÞwz­ÖUÉøøe{s,xtN’=,¤™| À:æ€(Ý] �'¦’DÕµkÿØ7¾ñ橯ýöƒBbÕTq€áÜ Î…–n¬4ÄhÅ)i¢�EØßcüøãÿdâ�ïxeÖM7·gžø¯;¿ó­G�öíâ)ü>òן"S‹ß×kgEf:dOKœÈÄ4K¥®òœ¦k­uNú/í-ÞwÿÒ‹óHÂæ•^[«2ærú –E½°‰æ¿r”£’öëÛ&èÛ«ÿãøóÏ9žêQ%yzT©½ØT*§™¤rbT«‡€ˆI2Þk5ï�²ìP|UÚó³ŸÝ wéÏd÷�¯§ —\ò×Q­0¸×8"ð �\xUžüS.ž>P…ѠѨ¨Øò,Úkª"!¨Y–Q{çÎÏv7=8»LCkñ®»n€Û*O{úÆx|ìŒ¨Ñø¥¸^?7®×O6I2a*•Ä$ ¢1}'8¹‡--©!±ê“×ÊœÝÂU�=�²™=¤€8z°oH$ Zîuò>¨Ã`H³b0¸„¶vU9%=‡Ás­3¼'7ª$�õ�I<Ú¿f…ð܆4¢£ƒ£3GÍþbx�`Æ_ˆî�2è÷²Ù¦…>dÀ¦Qêe€`Àhu­„H·h0ø M@�dÓx$lr& †&Ù%önÎ= x)ìÿ8 .ǡƹ3)·¢àO] £ªâVÀv µˆ£bm#Ó["ô>Š…ë±Wø£R'©%�tˆÀ¥[òv€ïáØúˆ¦³òΘQ�XuBmíÚ�b;VJÚ»§½ÃÐT`ƒŽRå:Oà�{š5wmJ¼]JG)J[‰„$ ³²N'kíØù¿×g”ÍÍ휿å–+ªÏxƉQ£þ Çg'�±×DõúÓ£zmUT­WqàGh ÈèUè!ù…À€I�ƒœB0j[5”�V!FrÁ×Vzß¶¢ÎŸÔôUù6 Bhˆ *Wlô+úýÈÊêÞFQ¼äV¥1 ¾›§ ²~Hö~º{€ ΃ÁBâŠä°ëû` eŽ¥äDˆÀ 7ÁCƒe† PD0H�1b•¾Q�'°%Ða€i¸žà…k7™`²Ä”;‡”Ž)Øwy�Xª{RLËNá„D¡ÇPŽe1GH&{(h‰Ši0؉Ô\b›qž>~ÍW;§ü�?=ÿЃoKÆ/*ž¿aí?~d…þ p(®3‰B†ü5$7|ˆëõF2>þ‘ãßò–ë6}æ3;Ьaç¾ÞQÆ[#ºÎsÁ¹: ý±„ôšõë|\¹ÿïÊDãÏÐ`Ä cLCmíÚ)MOìÎÏ¿êøK/}eé�»íÖv|í� ûÅÆ÷¼çUq£ña“$‰¾ã:+ˆ”›^×»©SÐÂ9d:ê–È,Mç÷îÊ0i´d¬‡úÀ“‘òk…¼}W²ß5f ¿ÊQŽH:„AÒÂtZÞ…3V½âI<>ö?“ÉɺW añôy¸æª$-ƒ²–‘ðÓbÙý`„i% †&�¥ÐÝ=ûÈìðçGÂ3kßwߨ×EÇ®þ«ÊªõO‰'V^��ýR\¯�Õj“¦V‹MÓ³.;^ŒO¬ª’k `Å)àìFÒX!ù&D#%,ˆ'*è´(P¥"¥ÛÅE!}3³")ä_ǤŸ™Ø1{"‚ÞüüZ˜Î˼‚<ìE¤-!h”e; ÔõÐÅÞ bç�¹Ø@ Ù§Wo¿µ�rßM’Î.v}sý—­ÃßAZk\ø–  J„°¼- ô²GR ‡ DGäþìåù(§«ÿýÅíJÈc¯].$Ø CÀérú¹BD6x‹:ÔÉíñÚþÓÑ ÛDÈ8A§Œhµ\ÆýøÑlÃÅ¿9ªT6IbÂh)[)Ê‘L&¢R'ÉÏYÝÞK\[&°ëyÀ•ÍÈ*““g5·oÿ]øÝº¹ð¯ÃÆXp| ¤�Deî²Ú/4ñRG¨e6è »´ññ Á2`Ýg 1P™œœ¨¬Xq~Öëžožxââ _üS¸5kµ¿:ýO×Ήûĉïz×ùñØØßGÕê:·äÄ /® æ„F®¤„¾ ®¸×Ào†DXö #¢¨ÛݺwÀh0qQ1NÈ��ÿŽ�£laÛbI¸Éµö¡]e�¥%�TŽ£f˜8þ…Úºu—êM�„•/“LÔL0·`Ì!±éJªN¿ê IDAT¶‹LbHlÄ~îHlò¯ƒÜŽ^� zívwqjûo��/K§fö4§fî€;àŽ=ç¬W˜Fíü¸1öšxl씨Z­GÕªc�  Üa†œ> i°Ç»·Ô@�Ô8–ÏJ€¤VŒlwZ1äôš€3*ˆäçJhÕämšE 니ÄóV?íqÔK!ëöÎ^ñÒ—nÛóÓŸö†a�ÂE6л/! ÑÒ‚ëòUq½oÂã«(Šª9tkÐ�{êÆ¥¨ è´­å¡&bÿÿõ'þ#—W"öO—ŽÈÿWŒÄ~h`;�¹ùˆß¤í%@j|’ZŸ[R¸©"«$�%ëôUŽe6æË` ’ÓÀóò+žøqQü�\+F°Ew»c�°î¡Žu&Ûz‹ “¬˜üb˜“ߪ I…�¬…½žOušNŠ�öë(Æ¨Ñ¸äØ /üÂÔ×¾v×þßW ˜¹D¾üî¨\]‹9‹è�X ¼.ÿ…+¸ßÒðÞö¶“L’TCàˆ#‹õj’ Ô×ó,JÓg¥íö»:»gß±áâ‹@?‡^ö�©¯}uæpß'Nz÷»×gÝî%•+>nªÕIÆ%†"ÅDæÓ˺úú@ƒß ZÇH]CD(fšk°ÊÎ DÈÒ”z­Ö•{¹ú®¯àëzvVˆËÙÞÄt94{�;è–­lå(�¤r-cõ«^µ2™˜øDÜh ÎSf/ï+•ø•‹Â=“@×ÂóCí»}6 9[véØÉØ%²�#ÑÞ„  4ƒÎÌ·î¹á†^Á8}íµGò.ß^¸ãö€MN>ù“EkÇóß«““ç›$™ŒÇÆÀ(* bõ†¶›Ù¿óª9±A-—åt�Îx€_¿u ±ì-ˆ%™OáÆ…°’‰ œÂw å{ :0†RÊR@„çD•ê·`ˆõ0Ài6 Ü7b»èëv€øÁ<”xÙ@´À "2¤Ç‰…äÐO¿í-ß,‚J9=V¹Ÿ/+¯Šƒ�È› MPbž˜ SòÊ!'ì‹+™)ÑM¼pí G6X€!:@ ÑÜúp³ €R:IqAaЀÈ–•r,/°1@ºÍ—ï¾h.²>aÒ,¶Ü$d{‹¬êK�Ûr‹—YYþÑÏ}>€n|Ï{~5®ÕŽ ³n Æ„°#úÒd�säP˜õ>ís¨N®<±Õíþ)¼ö@LÝÒn¹¡ÞÉÄÛZõïä@J÷�kȰ8Qú@°­™ûeÏL’Ï ¢AæP™;  f1i…(‚¸Ñ¨Ä�ÆK³4}iÚjµ:³»o>ö¢‹®Cc¶d½ÞO¶ýë‡UûÛ oyËTª¿@ˆ©­_ÿ Œc S‡#-‹ÚÿÎH'Yh¶ÏQU6‚:�jð8Ò¬½íÊ+?»ôéLØÅ‘�†þ`Â!¯>£b´{EEÅlÅ:’’Ÿò#»PŽr”@R9ŽÀaL’¼±ºvíÙ¶‚¤Ø@.·÷­¤d‚Á”Åíâ\ÈђÕ¾’Kjë‚ÉLè“<ë賑šÍù=?ðV€#Dw£»iÓ4LwzèísÕ±¼à7ãzý—’±±gÇcã«L51h¢AS:÷ ”­†aâЀ«^ª6ÂBZEªZ¤‰IÄÃôáôw§óAV䑇РáÖ.�ÄD#§@•ˆ2›bdÝn?85ætDzNôµÃˆU´8°' ’í=wíX²þ+ )ž*:ùÈj@!`� ”ϳþ]Í ¢1¢ÔL(¿BÎmËͰ±C|¡þ_š2"‚l É=žûß 2H@èÑø[c‚|ä±C EÎ/ãh²2yÌ/?£jƒ*A¤å7OüL‰;!†ìêþc®HÊÉ�1› ÷%°lµ\VN“õo¼Àôæö¼5J’bd¼ùîL?Šb¶o2¼Ö‡y¥3"¡¾wo­i¢zý¥Ç\táÅÛ¯ùÚÕûõf J†óïë�Ñ\°¸´ãZeÉ2(i ³"?¡Šæp?‹m'ãçCÞfgõšPë¤ÛùX"k˜’ôZQoq i›ªpo„Á(PF�Íí݈j¾j£èRØ0…Ká“Ê}HItØWrtL$ùD•ÊÓŽ¹à ¿ Q¼Jç/82s½†‚WÏæºe˜EÛTÅ]ëHi‹:Ö‰â©é�``y ´¨ŒêõÒéþç�–@R9Êq�Õ¯~õé•ÉÉOšju“2¹�-W*J¬™›$˜9#)ߨ‘ˆèÅZIž[V¾¡Û£öÌÌwÛ÷þ|ÓQþ¸Û 7ßüÿÀ'jÏ~ö‹¢zýµÕ5«ß™4kM¥Z1IŒ€‘8\�fy@º–Â<`Ìn;xeˆ” � È,TxÉ H\žUyI–,J€¥"¹|};|ÁSðWQ)±lçI®Q3ØëÐú7V=€¨¨£N�$¯À&‹º½Müc/CÀqúë×fð£�ïxÇMñØø ­A‚– t¿ž8ÆH°\�”øôN/jL À¼v‘'ãY§ó À5]€ý§J�ý#t j „°Œ2�Ò»”º—aÉ%’ =BXPg/Ç ï|ç©&Ž#T­¾ˆáðÆÄtrÐ0µº©o¨=·NÇ<—z)ôZ­fwnîöõoxÃ×MÏ¢1³°‹²l[w~~óÌw¿Û>˜sûÄw¾³@§¥�îÉi«µ�ùïõc�}&Þ©AØ"ð (Ô2�ì¸ Ùpï:[œ%›;lùYY§³io'´e1“f©H¥^œ¯ J q�7‚ÏñÌýV´‘M}ýº�õõëþÔ®~až"ïátAwé@’bÔkݨ!@’n¡·{#’x 2TpFRäW@’õÎ2˜(�¤r”ãpãçœS‹kµËª«W¯áA! ÝÈ9ü¨j#†�å.xôBÅ¢°9’¥%^È3˜ãè;A¯Ùܱë_þåMå·#kÝ}÷õpãÀ×ÏxÎÕµ«;®×O��ɨV‹Ð­ù !G<‘\€ªX b•ª¢hݧ]£×ŽŽ>,bÙÌĉµsQÁA,+mž�G!Í‘\ÜÜÄ1@?•²¬:2ùc(©6M'Y*ï˜~de©=Zá‚+˜…ŠÙBž ¹Õ'"0‰2 2�¢�ø= À��Ô€aÐP†”“‰‘(ËÕ“ò+6}ð)SÉ ‚ɯK †„@ÙV4ÑÍD/Z{ÒQ²|%Ã(ò½ûzlÖÊÌOXЇ¨2Ér,+ ” .(}’`S$„#Oó¶%¯ÅVPÜ÷—1Ú¸õÊ+_tâ¯ÿz3Š“ZÈáîÊÖö/îØBµعÊ}œ”†•6¦™ä >'™˜8cÝ›/ùäc_þÇßÚ¯³Æ#Ü’¿ð¶XÀ?hïî6–zÊ\”˜ž´ ÿIï?fÝîÕ€€ÄXŽ9PåY¨d{0¡fj¨}*?¤IévS(3Q%. šÓX‘|§-€´Ýîµvîüô©åPç4´šwÝqu૵g=ë…ÉÄŠË’ñ±ó“+NŠjµÄĉÐñÖQç"êð媠ö„ªÜx¢ ƒ;ovÊcK þ8áG,kßA<|ñmuaµšÁœ�c0I€8Öݳg#ì„EKç6àv"–®ÍíEå¢èhÝB¬ð8¿Kè„vQ÷ô¡jmS�B–ã}“ª`Nâ¢,g¿Y¬ŠÛÑíÑæ£†c“y Êó5ÙÎh]ÝëÂLNRš$ût àÓ„˜Ê±\&‰8lÏ�Ï+ÉäÑsÃdVñnogÅÆPZîݹùËͪ•´×ýÏ D>@§PzDÍ@BCÆ õ‚L´¬²­ È&‘¦R1&©üÚÚ_üÅ?ÞùÝïî'áç0mƒ*@ šrpÛ7Ù‘Îÿ½y Bà>^UX®Ýû�óÂÐÇau¶ZÀ e«�þn\‹ Mh"0•J5?­¶nÝiùß@–õR@]Ȩ™¥é<õz3Y/Ý•u:3ÔK§�hDZ^¸›²¬•u{ ”b’¬4I2™µ;« ËÖ›Jõä¨ZÝ`¢h¥©$“Å"‹ $ôx]ÌF Aѹ;.�ƒ�Ø\ècåNº¼« ݾÃo-^�I(Mç¶]yå§ör:‹ÇD¤”óˆ†Áâ&Ù¦±9º…‹Ç›}�Äw­ãÑc^üöb3%‰-‡ëÈê°Üœ¦àâ"¥¤Ð@ö¬Ôrë@<=R¬C Díªò‰N½t öÑNq.�¤rVcåyç×ëN&'#âàd#Æ�à:$¢G�Ì i ¦º:�.³Š¢BkmA.¤'<€,Ë ·¸¸iæ{ßûÍò‰�ÐÚlg¶Å&þ­‚®(žÖ�¯ì*ó ¸¼æíNL¬œÍÀÁI¶y ?ØÖ) "e¤ÝîƒûTkaûb6qHÆ|ºØÉMcÈS¯då;âΨN;ÎM)Dk÷Bk “ƒžD…‚ä!Æ�g¥H¿û{ÅD‚pK�–K#¶�HH ý`HÛ2Ghd ³˜œGwaÊ”aL9‡Q?㌤qæ™Ï��?®®YóZî?Â68¿(IkW~¿ÜS=gKy–ý¼AFC,@õ R*ª1Z©»C�5›ÍÖÔÔÿ.ŸúÞJ�‡~¸uÏ=×îùÉO^·ëÆŸ�3¿eË•Í�;¦{ si–öÔâÌù E„­è’7ãl ›ÿ"ø�éàîBT! }ÛrËèAòÚÅ‘¸6 à5îHçRÉônFD�·Æ™¦R©„£ŸŒµO€o¦’ƒ½$�S ^†C2Îâs…öƒ´ÑPŸ%”A_¦ƒ(wm£â%ËZ€OöOÃh02Õ»á?–¢ #Ú„Ô»èè¡C#2qxÞ…lÑl0ß�Ùk¶Sˆõ„û’a–ãÀÎ píg‚¹i™i��!Å<�‚ÃÜ̓ŠBùþ9|ZV£5;{¥iW«< ²1Æ@4šD2Ä´ó¸„\K8 Î4Bm�ŽC–0…î4€1‘yþ1^xÎþ!Eâ€(=j„>÷Xwºn§µ€Ÿ/JÔœ¼³|_F21ž˜$ùvÞšÙgi’ãâ±Ø=8‰í�ª½à�{䃯Þ�&Öð9`*Ùèh°ÿpÍ-¿ã(gW¹V@’ÚŠCpà�)!RT°šE!1[�³¤ÁZlCkHƒÙDêVÚjw» óÝ÷ÝP½E¿c1U(F~ñ£_©?íb\ IL£58ŒÜûJbØ�Ý”ƒHœ!&9~!>kI{Î�ÍÏвäÙ´pÒ CB5ôhíèX€:e,ÉÍ%�TŽÃcDõú)�ã�ÿÄØñ'¼+ªÕ�M¦ÇË5m,xãû�x–ïLTR3aàÃ<ªR]ižó Ë¡‰ØÖD¨tˤۃÎüüݳ?ùÉß”O}ŸcËfº}ç=ó7Üð¾¹ûïùüæÍmnÝv[wvw‡z=+„ìb&TÕ×feht Œfï�'É@%YÑ¢pLıG®Ð• Wk�%}€ò×È–Ñ*Þò‚aþ�¹8²1Ï5I’>¢E„�À+ª’žouË€,•ܹÐb %8„!ý2b¿þi@”á K”ú-myS[®5?ˆâÚQ@•eMuø¹†1 "î6ˆ7F;�ÎÕIÁ©£‘‚œ @aû„~B‚ö ²z+ê00ða%†´úøo”O}¿ŒNwóæûn½í“Í;/›ß²ùýó�<ò�ö®]ói§ ”edxŠÿ¶w�…jWÚH�ï¦Ó"ø‘¹‰ACCÍntAzq£îÚ|´Í s Æq¸ :cß%™Q§ Ñ³íÆ¼í �¢<ÒEp"ÒAËßU‡ûºF†ÁMhȸ"R?QÊ�Öo„3H�yHˆÞ�µ2ÞÜ7Š¥†6à�ÿÐoUH�Â<&\@(^‰) f)§¬»æbdÑ,úr´ìý©”HZfÃv’ˆ¹Ðo…%ÕÛâ'FL57½@8 �’¬w縯�$aÛUW�™¥é?­Ö¬!Æ«$R€ÅŒkdé¬e{¹=Ø“àC†D�¢ÝL’goxÓ›.Ù?³FCTâ‰ò/P}_/!%6SˆÝÛ3®{�„î;”tÜ%›ÎîÙçcùÀ§> „Ž9ÀÁ5<½íMÄ6ìþPÀ€C" àÑyL¬ÚŒ83ýBƒn¶mUµiY³u')#…¬–‘WÆÐ÷!@à¹qx”8ÅÌ!È‹ÝÉæÏ²Œz­îÍûxBB‘t‹% €1 x �ÂèãV~¾*<¢ëðã¬#Î "Ûöåã*@ðPw„ Zm¦‚à3ªµaÛ•Ií=ĘNʼÅ7´Dgv£ 2V)‰H*Ça1jO}öÓ+cã—VV®¬[”šÔ¦�,Rö‘à%¸\$˜òjC×½½Ëî±è™r„œBÂÈ¿«C˴ݦÎìîŸÎßrÓ�åS߯#mß{Ï}Ý]O|¡9½ýýó�lzãü#›®lNOoïÍÍ¥ÔKmO:ONû炦€.K~¼¶6Çu!œ@˜öë%Õƒþn+ˆaÊp($É©½$èÞB{ÚÓ�ñ‘d�ãUQG…y9þ1Þ»’'3Þ&½EDý�Šà3%zéÀä~#‡e#±" Ê)¢Œ‰Gê›Ù×;ÂVføY8÷¿”±ÜÂüÚ² •×4m˜zÏ‹kÿvP ßùò5GŽ„L¯‚ÖA 2BÕ¶?F_+B<%R!ÄTŽC %‘+Ì83 t‚ûŠBbÛ€ÔÞ§ eU®ý�xMO–Zr�Gw÷î¯R–¥úåa” éŠj<nºs/o+QÚfzËÄ|�£bŲÄ�±j\oü¿ûœÖˆl¡ÞÌ-0‚*¤¸$9˜ Ò|ÖŽE!àmß’ËʪUõ¨^ûŠÂ€œ©À ³y3‚Œ[ef-ÛÏ…'æNtØC¼ûëþ‡Ìژơ®_QHÓÉj&êÈ™×¼Q¼' |g^…“ƒ‡É�»HË|ýäû’�Û¶N´ž!dÍÅéÍû™ Ÿ ¾^<‡>a‚HAÎÊ”$ bíeù#&{¶Û—]ét¥Dá�ܽ Æf$u™´#›·çÚN] U€°\ptÀ!�'%ÑžBÞ‡š•™Ã^„¬ ‹R„³ƒ¢’Êq´F˜ÕU+/HV®8“ˆ¥šêÀ´¢E\õŽÀw)lü<Ð[6§e’{K²!�6'¼ xmLzóC.“eÐ[ln�½ñ†ËÊG~`RÙî–M½îƒmÇ8þ·îîÝ¿×|ì±KüòæôöG;»g»Y/hB ÷sÌNüÚjÀ‚Å0ÈÁ©…tî|bÖªdzÛg¦¶÷³pðá ƒø± 1Šã—ت<ä? ÇFõŽ®?L]‚…Ê.Qª(šË ˆxM¯¯EeÐ €AÇ0Ûô�X,Ëlú  – •ïFfÐ"‡} +€Çèæë›/ƒ£�Cv[ û1!A%W¿Vɉm‡bh?±¬õg²›åX.3Ào»!©m(÷U_i÷�‰ùã�¤M´ùŠ¢Áá5ûò—ßD»ŠvN»³YÖ')&­tóô<1YFŒ™Ö«ct¥Õ‡ñjS«=õ¸·¼ù�öüXÐK¥@Eð7}â C-,Ž�aH’}^¤í½¯s'ªÕ1nŒ­÷¤€™++_+š½ÞSɺ*ëxeÖ>üì•ë’”Õ Z€� T›HLHml ‘5Lp㞌 ò÷A‰PrPéD†Y?>ˆоʥ)¬~UÿY¯K½NçûOj.‹2[:�É¥/•»B£ÐÎ æü˜£Äö�ÎÖäðP;©ùhÁ*|ºöe%\D�$Á,Y³øÏ‘,“¨Ïº$[t°±_‘°‘•W ¯µÐ†Dº2–(]Ûʱ¬G㬳Έõ‹“ñ‰Ø÷’t‡%',`Á>a[ÓlÅcÐ;N¾T$zy+�v˜ê¨É¸’ŽÜ*}ð½æbÚ™}â[½™™Ùò©ØÑ¼ãŽ ¶Àöê3žqOÚn3š›û¥dqñ‚¤Ñ8ÆŒ�'&Š’ äý›n¡:ê83-§Ó¢OëXÅ“Ÿa2ª`Á¦ †ÉÒÇÁUž™æ‘>~ù™‹ÞL_i §%�HÐÂ�SLþ[éÂDàk‰{Œ!0)üÙ.À\]jP=³ ÜØ™�⃡ƒy+–‚ FBÕ'OúdD`�ߎu‚Ã0€ÿq4†4Ì* ¥Õ¸ÛÈÕB Õ! H]k9nÝjA¼ YV—Ë�Eh¿µ‚;¦" ïŠÊ‚z@•,¸½[ûgaCÍ*í°ìMÛ¼‘u»¿cþ£¨*/Ý~lEcùý’ð×L!&zËWÞhäă|Åó�jÕ(ëTÿ |l¿Î�Û{y˜„÷(5OÂø”•¬@÷~°ÿ>î-—Äí]».¨­[gÏI¢€S©vùeg¬ÛK¥ÃoÝ´ì¦üg¼%T¯•¢Þ{âOÌ­Õó +¼�¼Ã‰Ç âÉ^FfŠ‹¤½<šH}_¦µ˜3ÓD±®h~qZÉæ&sôZ­­[>û·—>@�€À€ñcJrmÿ$6æHF,ÆW·Ï‡x@ÉŠx¹ç,q–"wXÕÛ-*•bø‘ÄYL’­üg$ˆ¤Ú®�B¶boòáTë8G<Âô§´f×I´Ž'(¾+x»ÛÑ;JFR9–ó¨×�9æ×+«VŸa¢ØÑ4¹R"ùme…Ôâ9ÚLÈ„�‹“Wò9%~I˧R%”i²X+cŽcgiz‹‹wïú×ï~ |äw´ï»oª·{÷wZÛ·tñ±Ç.Y˜šú›v#ü IDATÎÎ�Ó½¹¹Òtp ±` C–¶Y�!(Ÿyð†äœ.ÔÔt„®ÊgÁËA%Y¯:ŠàžÕ²0„n;è( ¡u«Ù%·5@Ö¢Ú—‘¬\=��ýUøÂÈ�zJKžB÷ƒ³$ˆƒ%äAú›«Ö"Á°gbH® ™kÞ¡Ú£D!”¯áEY÷X|±%nö‰là�XœÃ~x܆|z:×µa0¨ˆsBK�u:YÖnc¢ÚU‘ 9P Õ9É[½´T7¡jùf.·ž«2©Ò¡�ðBl¸�CóŠXpˆàɯ«–I¿eѪۺ5ÉõÉ�?4>JA J¹ÄbXµ žôÊ/�¤r”『/zÑ/$㿚Œ�™~Â6è×%~L2ÚP€Êú$ÒG •qd+“·¹ƒ.n«pØÆÙÞLleï{¯Ùjvž˜½ª|â‡ftz(í>òÈlëž{®ooßþGó›7_°ðØcŸnnŸÞÞÛ³§×åÖm¿–�| ‰9D9ø�ÇuC‡‚†¤�Å€$Î9PÛ@rx@ÃÁÓ¬×}îØ _P ­-§M£ëˆƒª*QÀ!ĵTéàñ¿d¯§áQ*6 ûÍàn"$µQhPføÙ…:¶uMÔÂŒ¥b¥ƒkr%1=L@wÑQtØ64äB¶!Ûpß?%o7{¹Zt–‰¤µ1I†µ¤7òr, ‰‹lÛ� “5 •4cp6q ïØ¶&¹C'JI¶ÃldÝîë²^oÖêäñÊ>¡N�‚^|ƒ®õ„Ð=1á�3�·ƒØÂþw0I‚&Š_ÿäê!þð(5_Ã�B" p“dÀ°–„'ïÖ–Œ�gq½¾2¿&ßñ]&âÚ}”vN®µhç8Q„!ß!,dÈ5­G3Y ‹¸6ø‰HŽ‰Æ¤4¸Ð‚7Ù�I.°v0{ÙKùÕ£[„h»':¤%NFº à»a²è�ô¤€% W#@©9 ô%ðªž3·@mÆa—;ï·/jì+�¤r”c¹�ñêê5ï­¬œ<¾o�„¬×•�ˆCи-Éé/8½%¹É+ä4Â$f ª¼Ÿž¤áT/4@½.¤óó7Ì|ÿû^>òC?º›6Ͷï¿ÿÆöθ°uëÝö‰…mÛîíîÞ�Rš:[Q` :´äÜPÂÑ>&%]Åb²l@#’˜—’øŒ2p&AÃ¥þõá3“j½Zx;iVfñwÊ-v¹¨5±ö¥àפ@`{úÉcdIKoF¯—T%u¡‘Ô×@b¸XФ�<…Rö¹}aï[bcî xɺ“Žšõ$â^D눸õ¸Éã±yA¡«h£éE!x9J¤ËbÎðv¸£Üc1œ2ûÂ9ÇM*ø5ÒáÝü¸õŠ+îÊ~.ÈePƒÈ´ÉY—‡m@ÉjGk™"B6èE²Fã1[ÿõÑøØê.»ì_ŸÜl)F'ÝWÑ�� ERœ·Ê Aÿý±�lxË¥µÖÔöY�>ïÖåî¦$ Qê¬%íeÜ‹}뮽ÖÙ�徨‚Œ¹¡PnˆN¸˜ÇFÄ�õx»Û(")¿!tˆë11@¹Ö�§©ÙgyÒ››ß‘¶Ú¯Ò‹—ØÆ�`rR–OÍ͆žk»<Öû ÇÉ]æd—Ÿ›Cj½{çl`qœ˜xû’Þ‰baÁ:¾š�cãDžv™Cæ4òš{‹Cì|ï @l_†n”IåX–cÅK_úÊh|ìü¨Vs‚vÀÚ\Ù²@‰>èôaPÙD’×Ëïúa¹,O2©Ð¢Å¦-X+ÅÑõ» ‹;ÚO<ñ¿Ë'¾Ì¥‡ž€àôÓîLÛíN÷ìyM<>vAuÕê3M£™(vÕ>M8xBä«¢¶>->J�s9�.øP ²"ñ¹ƒùŽd’B™âŸèÏwvz? ’¤R˜ø1½/ä²€y ˆRR’¬SNA�•1¬SI––p¨ Þ u�ú#€ÀXȉ±˜�7¸Ï~'áºçáH,ŒE °Eô(ÜÚM{�è(c \1)¬'‚Ã’Q¥ÿEáÞíïùÖŠ–K&‰–c9Hö¹¢¶mã�G¸s¤X‘ŸÇLÄ—ÍTm4‡3s0ëu_ €÷›$9NÝ$¦=�‹ðJçÛ�k”K_ ‚¸“·:ül‘ïʃûn ˜8~É�›Mƒ+�8Eά>ò"ìà™]81õ=}¹ûP¯�Úª“@ð{Þ>‡,–$Ç„²]ЍSó0_•t42}c ª=â°… ÒQ �óS¶ÀB)h1o DjÝCÑeE§pF©XDìùL'ÈÅù|.c@‹Ò½–ËŒgívš¶Z—o¹âŠMûeÆ�Q2=ÄBÅ<¦#v)’­›; ‹°N?;@Æ“9��c©�ŸÖ$Ÿ * M÷èù“Žéú¼H…c*�MÛΩBnP¡xp:sòCÙæF:¥²•­h”Œ¤r,ÇQ­¬ZõþêÊ•«ye (4,�@U™CYMàRq‚Î|n‘ÀÏ%IþDȪ;Ç€ÈÚmèÎÍýÛì�~ôíò‘/ÏѾÿç Í;ï¼qqjêOÚOÌ~`qjêsÍ©©û»ós)õº°�M?,ê‘Ðâ‡ÐÑmP¡g"òêˆc:û\.R‰V†‚Šô~ÕZ~ŒÌ‰QÅCOx¢¡ø6 µün}Ƽ è8’çêTd»X@€Lù€ñ|Q²ó�ÝUhS r7FÜqÇtB ›mCL€wÀ]¢—ÿhB¬¸«SV bÏB¢ƒÁ·eIsY"F+ ¹*•Áಜ"l�sbTûiÈDoÿË�i.˜�3/ еzß÷Øf(u˜|ˆ½%�TŽe;V¿ò•ïªLŒ¿ÜTNªžZrjÓ`p3ËwBÖ ØdÎ`é yšÒ÷0šÂÛ Kv2èÎÏ?Ø~bæcå_þ#{ôÑNóÎ;olïÚõí]3hmŸþrkÇÎGÓVKWÄUPô4! bå ñ*=,j$a–�~RÉ(d])D)S ôR¶�1ë! I6U‚Œ.® [Ù•b×È„3ѱ¢YQ�¸NDÈ{Y;pÊ9Lý–4†”±db ¨ä,aÈsàŒqlƒwδ@¢W?€ø1>Nð’£LdÛð(\2ƒŒ!Õº$�[BjJNÁµÁ‰†â ­}@UŽe0e˜¶ É]Uêb ŸˆŽÖ~“ͧd÷Ê[KàðÏ:{ö|0KÓMje댬v‰4QÝQ’šÌ.É«>�8¿‚:DL’€Æ0•Ê a]½±÷Û ²zݥ矉 ìÖÐ%½ö/n¢;½ìCâ7{‡?wé¥+šÓÛÿ/¼x‡¬¬Q‹ÿ¼DRïí}è$«ô M®åIĹa4 „}±�ÊX±o1õìå"ke³$ç 1aT§>YàÓ•Œ°€…JÊԆȶ PFЛŸ¿LôºýzTºhDaxd ‚¨XVÂÙÎÆ-$Ó¦\:Àë&C¯ 6”½„Äø=Ø– R#1=,¹fdu‡$~ôq'vc­‘u}%Ö²ˆD(øÏÈó…­Ý§æI5”@R9ƒ­Zµ1Y±âý•“u„!‡@¸RB¼Ú·ôÝÂm=§×|.yn \¬N›¬Ûj"d­uçæ®Ýó³ëï-Ÿúa ?ðÀ®æwü µs×o·vîüÈ£�~»µsÇlÖíús�ÿ¡œ)�|ÍárAÀzÀ•`6²ªr‘Q!gEñ^r¡S¬·£€ßG«L%1E ªˆƒ¯.‹#u”A'ˆ6V¹±±ÎýˆŒÅ„jl!6øÇ©° „3rÚ7 þÜé§H^"y@$pÝq´#‰94Ϥl% j €ðpË[u  ÜÎ)Ü ]ŽCˆ5z§4©Ê8É4Ù\Boñ+w*/)ó?½LüðN¦¾ò•¥½OQ¯·(®�ˆ{L •“c(kƒ‘/!€Ö0“Ì(FõòÖg0Õjuãk/þî>Í /g©›‡�ȵUHø¥xÔU¿N©Ü°óq_¼Û*ã«£±±w©ŒY�IòÌ›@W„—lL “вF5?šŸ2Ž¡0(è<¬eY;aÑræ"Û$XÕyasMH*ˆÝ…>²µîbbï~g‚çÆEÈÜÎ87Y�%DÐ�›{8M»¯ßôéOíÞ_kVšq—3QYc‚زRçLˆ{3/Üh$…Àµñs—¶�s†â<ÑRG"Æôœò,�‰jQéŠg ­˜3šsè‡ëÎ3¤ÏéÑJ /®áÄï5ŸÊ¨¿3I‡<µ U€/�¤r”ãP�ɼàÝÉŠÏÂ8’UM—Õ…t�g¬½@é¼a8ð¦=KÊ#‰¤¼H>ε#PšBgÏž›¦§?Q>ñÃPúÏŸo_ü�ÿø‡öc�¿sqjê·¶mûYû‰'{í6eòŒ$´°yˆä�œ\› …p)1FäÃÚ’È6´óYŽ.£ú<ÅÅ,NU¥Æ†ÖP@ÖbJ‚U€ Pµ$åFá„4¼MäÊ Q6 0 ˆ ×K¥‡d«zvÿ0 $e  ß;~�à¼õ'¥«…¬œ…訤PE‹°CbÉ›ÕHROU¼?• Ò²œ!|§àY Iš%If±Ü/� é¢@Š@Èð>qdÔ�·| ÊœQ›˜…�» 0z¿C›x †ï#Q�"‹:ûGƒL­ö‚Éó_¹aŸfNQk�оaóÂRNr …ÈRŽ­EŽý˜$,Úɲ)öföDõjW’ª½ôï¿$&»ö&d;7áǹf$hÐK# FèT°v$…FÝv ´>ÀUÔ,.à{6PÆ‚¸eQçÉ|ƒý ÅO±;?ÿxÚl¾mËg/äÀ ë¬å”a$ t¨ï´{¶ä\úì\ ,lbÒ DR1Sß®�f¥WIðˆa,3/©ƒÂ�YÌèÜ­;r‰5m¢d¬r£H †‹9‘a–¬HX8ùi ]Iå(Dzë*cc—Æccq¡'g0udtOÞ¢‚¬�†e˜!yAO>[�v…6Æÿ&NåU¸°` ]Xèt¾Ðü÷Ÿ.ùa(=òðŽæÜþw­í�½µùøÔ7§§ïîì™ëfÝŽ¢MëȽ醠4,íX*A #¯£®bh t ªʘP¹mä?Ž"  zhˆÐ“H€²JR4q®²‰DÒ™I:Éf‚‘ç·_f-Ûˆ9 D6¼ÑŽavÈRê¿4�g±š~Àm[áÔr¡`š’ñ¤0ø.(NûHè Úk„…±mOâ.œTœ —c âf®44�z�#C:”VV‘F–÷DGÞ˜B–ý?Y·;c›‹‰éË „þ‰#r£”®ôŠP”H*ÉOÈòŠ.œ‹&©$ÇŸðµ½›2äé$…þƘ(ÛaçÿKÛ…(¯”Ó9örœðö·¯éÌîþ¤hË-éA&ü.”@6E™Ð5ôhF$ºÄbe~šÙ5ìë‘4kʇ_/ß Äp!A‰)±¶A”Ä óC†þSÍï#y}™Ú…7ÿ·ÞÂâLoaá¿mùüço8ûOhhΣÖJv ¦S(ŸQXþƒ<:pm®¤ZW1€>æ�-)/>±îäš_�L.t�î •[ðan³ †l-¥nR $•c�5¿üËKVNžŠQäo ÃÔ/…‚& Ñ@Kadt]�¾©KPÂaHþÊîÚ6Cå$ãW:‰1˜²^:óóÿ:óÓ›®(Ÿø(ýçC[n»õOÚ;w~¨µcÇ?´¦wlî-̧”¦êT“daÔ4r”Œo׆ mJ=!(’�ÑUNÒA52±B‰*‰¬=m·OÃ5k’Âà'L1ЊÄûÜ­Ýô€Q"œS8dˆ-Bž”@v¼3_âj}´@y™fJ œ xI”À÷pÀ/¬ÊQ p±cæp ¡I¬P€j.“‚øÛ“4:ðR‚Ò¹mYÎ×Ü" .BÉ*} ±þu?í¨î�xDL‘Í—î”e·‹ûìU( ´¬÷uÞþãþéÄlµè/øàÍ›34q£ñü5¿øš§ííurï°¥�Jêz |1¤ö‘l›D¦{ˆˆòì^ˆ�uÉØØ›œØ3ú†|~øz3 ‚b#šÃÕß#ˆg;fù›‹¶7Á5 ,âZ�{®çBG퓺¥Šº‰†/úAk£dÐk“ó— ;?¿µ;·û][¯¼òÚ¹"(Ô²äß?f…ObgYv_庳ÞU©x)Þ‡¥n[ào=í>‰C :«Åŵ`"a`c± –Fz(�¤r”ã€�‰ç?ÿ•+.ŽjuÜ…‚WÌ74ßø°85õ�öÎ�3i«E”e�¹ÖE˜“=øX¯7£åz‘t00!%AƒÒɇØÏ¹“ªãSšž5~ÚiuýÁÂU= lÈ£#´m¨d[í�àì–yè‰HRDÁSÍÇ@Òb•ˆQVr8‰”–,dÌÃyðk!¦�”i~“2€>iý,ƒt s¯Ôl åÂYô Y[ËF Å&A*ˆ©8¢-Ç2ƒ•¤ÌF®Y‚à²é=•÷rP8Œ 5åHìƒGR”oþ0ët·y€ ª"�àî �È™Dn[]“˜²Œ£òSÀ$IÔ8îØ¿‡¥–øh\ü²� lý«OÆ!InùÄFm­¨W1…Ö¦ŠÏE{)`YÀNjaË€¿¨¨lm+G9}¶Q;æ˜?ªL¬XeŒäk$‰§‹VgÅÚÍÒˆóFù@™ÖŒî³¦�(·:W³Nºsó_Ÿ¹îºo–�üÈ�ØÙ¼ëÎo/ÜqÏ{·oÿHkzû�ݹ=�¬ÓqЏ‚ÄÍ ªDÊA§� ºrØÀ“¦ úêÙýà´¨R©…¾/ŠÒ—R ÂÖå´L¼öˆý@hm¨@Kß#æâ¥áôŽ àeÖ÷æš:xr�"Õà•Qª ü€6õZ-²°Î’‚Ð{8¼åqI•q¾P½ù†K� Ëqè§ÊüS¸ažf{Íû:Jéo€|À#�©¶å³Ÿ½žÒÞ-@"ÚP”„_PˈA+T�¾"xl�á‡z€îÒhœ½öµ¯=k‰³f(!€tÔg…‹YôçµbiC—šŠs”$3coRÉ o}˺¬Ûù K㉀&væJ÷ª¥¢ÅsAu”øŠ:�id’Í’z�2÷þYYÐ!¼3’ŸêÂÖ×ÃÁ!ßUÇ$4­ãt–BwnÏMÝ…ù ·^yå tÿƒPùninBñ–„Ö0žà^Ìâ@Ë¢{¨ò¶ ¥*šŠ–Êa1p`æb¸ }}¡5%\•I~6j—å%ç uøJ ©å8„cåË^ö‹•+^n* �"]Rð�poÅ¢ÀÐá)³i/¾ý­©íמÙ5Ýk6‰Ò ‚v¿: N$è'ÇÎØcDâŒÁ̵¿.;Ú•° š§`œHT$§¤c®å@�ž˜“J âDÄÕ·0E#Îñâ€K6•ò“ÁôA&áw‡ý–ñ£k´~ÿ �<ò{­éé�4·oÿqw÷l+kµ²Löè[aAôòi{\ 83£`¾÷¹Î€pC ~ |_cŽ7IœCIb8vˆ 73ÁzíÄîé‘aÑ’'Xz½.¨–�6¨°í…°ð~òšªÐܪ-ø1<€`€”(÷Q �hòõƒY�(ª_޶D_:¹‡Ë<=/ª�‘`hšâežÅ8�ÚW¬’713çä÷½7_~ù]”¥WIÕëºF ÚEF‡_$«ñ#œ˜\²‰ÊŽ�Ÿ}® )ÞêW¿ê™K ©@47„ðIF£³ô". $“ /qÎl»ê »·~þóŸì5[—õ¯î-.î‚«w‰øƒR1p=…Âõl‘�j õ’}Ö€AÆãÅ‘'zðNs­õ6Èèþ¼“€ à®BðFeÄDݹ¹»{æ>¾õg7\´í W=þ´ßù�ƒ‚P�À0 Éļ7`|:†è½¸ÐÓÑÕgÏ€kÙ ÓFyè]éõ¥tåš,ذ‰\ ‹»G‚IZªô(qº”ãPŽÕ¯<ÿ½•ÉɳL�>ýé_à ÒÁ?+ö¤PÏ›˜îUñJ“ö§êy#Tƒ’.ðƒŸ1Ñi¶°Ø¶9xÍAQÖ>ê|HùÏÛ–û˜Ó®¯�F{󀜻ŸbË­£d$•ã��Õ¯yÍG“‰‰ã0ŠG l³SóªF+ÜZ_…WD„ÓϾUK�m$ÒÌ~…�í"Öž5·Æ¥>ÿ¡=;û@ºØú§ò‰—c0zÍ;ïün´zõMÕ“NzsÚîü×ÊäŠgÄõF“Р׊)DY�g«oDþṙœ[-h‚ܤ7$XhÌfDì¿-$Jú6ÉœšnW.[ÃĊ  f9¤ZçÒÔvÃÈL.8ŠŠ§B@�‚Aôôv2ÀA ¦Oj"¢¾Dù<}ge­63ÛjÁËÖ?¥œé|æA. ô½š2CÐ'¢ˆgÈœYíž·¼aYF\f(R¨ªì‰¼ÕFËÉçIaRN�B�€;i”"ïa96öo  3¿ð¥ÊøØ/GQôºÐ.‹(צ'Ü«\®ˆ%ã9Ãuèþ“ˆïÒÂr q|ãÆÿ³àèƒSd�>uRÄùÀÙ+AÕ95kng�Û®ºê[ð­ —\rfuÅŠ?"cžU«ÏÂ(ŠÐ24È•,à %¶i¥KŸ».´æq"—·æ¦C_*ضIÉAèB)à–$ÛÄ2›û;rP0ÿïÁ= €Ÿ~šö,ÈRè6›› ×»}ç]w¾yþúÚ‡~/,Ìú MKÓ÷@Pv�k®c ÙöH™º ˜Ånù„úD)°O‡�=Ri2Ÿ”œ|7Í ”Å5ŽH„Ê�Ç {÷h( ÃM!º’ÊQŽ?"€‰ÚêÕ¿U«fäVÉmB»ñÉ„‘‚²n(À#EÅÖÂF;¡¬  Þ`PˆÀd�ô?þÄp_ùÔËÁG:3³{qfæ3ðå±sÎùýêä䛓ÉÉ£FÝ 5Ÿ¹ë•km4eu6µD°ÈN`îãLh«ÿ"€oÀîà2a­ydÛMݺíƒIèQ¨‘°/®È²E ñ‚y€£õ}³ïBÙ�0ʦƒXúhrÜÙLʸ÷¢¨³aÎï2ø¸c¦Ùƒ%ã%4 G+�-]‰ííòaú|;—€jz½Lx8Tˆ:Ë*Ç!HÚcÁæž2 C�¨<ÎUŸ°„®ÅW@¯ÝëÈš#'¾÷½¸åòËŸ8é½ïýõzça’Lä‰Un1À[§½FnVËÅŽ}ȇµ¯¢{¯<†CM©í?¥êªU¿i@¾/=©9®»Íe'"Îr1ªu:m–=.œ€:¶^M�áa û­÷3àß 3äFÊ�éùØ¥°øúãÈì]Íÿ²=¹‹ýv›L·ïkÚC·«-øƒ6YO·”ÄniBg�i™sa$:vLEM·®‘#G¬1KßgUÆö›?üa€û?üáóüpø1¶:n¢nâ€NDr~>ÅfÉÙÎ Éš´d·åª¡âwN§c\3–éÇŽ R­>æyÿ7€ÑZa/jŒä%�W“Ð X·1JP7=%xÎÐù³ÇxàÜsßsß>ðÊÅmÛ~r8;ûÙraá–jqq»••ÁgÕ…#ï'3�‹­æ¦0§ãa眹%Úu´‹åºf9:–~£ÉÊŸ,+'êŠ ÝÏ\ýó—9IœÎ¦c³±É ¢ü`0(ççî.gç¾¼ðøÖßû�¾aß‘òs-Ö¿Q1¹Ál̇ۺyE�©•Kiã„Ýn5F'eÖÆÊ¨­q_._ßÐî€Öºž¾ã-+7¶Ùq|EHç!ÍÜ´ìóË&‹‘íóÙÐ�ÓêsiŽYb޵JÙˆÔ—UMs׋É9’Ä^gí«Îü‘Éõë–ýæ°`ÛÀr÷Qíb³µ¦Ô-}Yå˜p `Ës�ÿî4ªî�Ë5¬û�¶ºÃ6>ÿ5.TüÒÕÒÒm¿úª{5êâÉ(ï¹÷¦¥Éé߬æçß6yÈúߘX»ö79~Qc×™ËæÕúãß^x�/ý@ÝÞºq~´Kåj yóËÞ£ ?„¢¸ö±/q8rBWŸÀ¤¶¬PZøYÖ]%¯Ý·(Ý0Ï5âèŸY× vÄ.ɵhÇÆs~6ËкÀ<è\ÝsŽŽù)Š�õjé·LW;¦\ø ª )^Aà– •Ú´�9/cö¹­ÃÛ[}Ý™i�¹Øo�ãt7(·m¡·ì—svZ·Od•’´ï¨�6ò‹®uò±<¦ùPuÃ�Ùt÷É+5¬ÚoiÝZkµ„ñg±ÔèÜ—ª²üE×ë­›Æ!µÀÛiX0zÞd%u~µ³ôÉ ÎB˜™¾7¢�RçDýp]Ö~Xùä'¯Gt)ùÖ·žÝ›Zñ{œìŸHW¬wE±6•¿%)0}¿[¦ÔæÆ"Ë�~­âøX¶j�Ð¥£±³ÌytÇ-•{£s®Ò �yË­‘ ˜Õ@µJémÔ‡ ˜¯¼/Ëøê¡r~á‚Íçžûß÷—/ΖۖcDÔ<Û¬U>Úþg¤Ã:�·n§Š1…uÙ‰°å§b>�òÚäNéc{ÜFC¼-Ÿ¨6êukŸ¦óŠNYd-j¢-mccsLr}¹}Z‹¡V‡Õ�_4JHâieê°Ã?ÝŸžž �)š/ë\,ªÕvf9'–ŸP²-�ý"gö£Å˜N–Õ�Û˜Ãf·°üð¿˜<ä�+WŽ®qnÔ¿¸5ßb–P³ãè«_´Ñ�óˆ“Ï�°ívçÒðñ‡?õ©¹Ñz@ÒXÚÁ¥é„–l—•Ö_ÂȺ©e"-;�ãXwqÊœ ™¶Ûý—€aYåž«Z±rI†Ë{À†k>ÏDbSÌä$:½ÁàI�1û‰æ=ì36k ^øñú$·Âì5'eIølwÝc¶¦k— wZ)#%nDSº�}ê–i!•}o˜ž}á‚Îãò@˜w˜ªóP²ã ›Åe~žÀ\Y$GÜËó]ßíþDæg'›?üá/ýŽwü9ŠÞû�‹ OÚ1Ê:1F–�¯�‹9nAšý"Ò-Šc«|;nîVsÌ+úýßÙñõ¯/dûé5¡YÍ—E'u ËößÌ¿ ó’Z븒‹�–9DšRògÎÓº®:ú×�›Ï=÷JWÀÁ?õ“Ï]yè¡ÿ¥˜˜z# ·†Î­aÑ[¾Ì8RæÔ]××GÄñ‰Íû¸¢Þk‡Žfa�¸ØŽ4è80-˲–ÊÓ¸nΗYSÚâ F‹$Gôr\+Ü\EJbó9hôŬl¯Û¨eÌ1�m{Üt‹Å2�7FÍ䦆¬8²Õ¤€ 2’„Ø{¬{ÝkrrÝÁ'³×k0˜µ6ÏÂôØ �cV Ûý1jä$4û¢Ït©Ã×`»Î”Ù•š>VojN_|Ya87÷gÛ.½TB’øA)wÞyµ›˜ø_Uï�X»öµk×®b¯—i5–ýJ—¾ÓÚ¿ÔÔ'ç툯±.ºnœt´ÛÌø¸ûÈ'Ïß¾Ìgÿ«Ê‡Ìûu0xcÃp—;½´‘åIøòžìõŠ©‰¢C­[ñß#ù m ¥�Ï=úˆù0#]-J³I_ËK3õ §l„§"vmàâ?+÷I™‡Ñ…Ò öUßó —Ëeô³ ïï¶á°of[­ý²%_|g=ZC<¾~y™"3Kÿ¸Ï^ëd×¹>�Û¬áÙ”F<âËò‡ ù´KWº¿êpϾ‚î‘ÓÎù²Üò,Wxo¨K7‘|¾™UM qÌ AÛÍÕú -•)wEþZ¿å“k ÝðÞnk„ììprÝÁošàÁÕj!ÉÌîñƒÁñ®Ÿ뜇%Q‘̓uy-[ÁÿË‹aË5gV:3°(ú ïxZEÀsÏm½Œm_¼ð�mÀ»Òõ#ÞöÖ—“XLNžÆ^oÁi8N³( ‚Áf;26¦´©#àcŒ"ðäÜDÏò˜ºíãƒÇríäG¤¯ð‚à+ï«j`ÞWÕÖjaás›?ò‘ÿ²ŸMÉ{ªÁàx:Nµìn#жšUØH®\{n�…8æËÑv±Aç0löä [­-rGº5ƒmYS"¶Ríç´ñ/£�)• Tí¯ËÌ[Üu6.Æ�_dŸ¹Vs›”“úªàßáBì=ÿ¹ŸÛ4}èaDZ皆±'‡V—½ õ7&ر{èË}kÊàÌØñ1qLÐZç„5äZuÃÖ9±²ö —ü�áÜÜ9Û¾ò•Û5êb7ŽÏÓÓ§Ÿö[Së×ÿ~íAzSS™›§[’aÝ↓ܳ}ÂØçÉd„=`fçÒð÷ÎcËCŸ>ä«÷°Ÿÿy>ö™Ïì1SïGî½1ýÓ6øe§ð®v82üÔʦ_†‹‚ GG:3ïÐÑÅÊ6¦ÆÍ.ˆF€…kN«É`‘$8„Ë.(YñwÝð{Ž)��n°wþÀá r#í�sLqIÏÏýµ_ãýè>ûþóÛ¿Ãû?ø�gÝçãy¿õÛ¼ïŸ>¸ßþ»ž÷;¿Ãû>°ïŽËáoy‹{ôsŸÛ'Êœ×�õê5+�9ö½nrògЉ‰ÃQÓ&œs 8W+9ÜÅ*1¹–Ò/.ùÀO¥kUóCV·ÙM&ü‘ËY掙YU•„ ½Ù¢•Õ~0ø:†Ã?»ÿ£½cü<÷îwóž¿û;}íÇl|ï{y÷_ýÕ9†’Ä^cêÔSYyÄQŸY¹aÃkŠÓÎRÎKÝ|WA‡£5¹õwQí6j”ãÆéÌN+HûÖ¹Årœe:Û.Ù€½a°m룃;Þ:{ÿýW,Ýy§2SÄîÒŸØxüK§Ÿ³á/'>ø%ý•+ûìY·ÂŽ ›2²`Dæ]Ù0ú#löë±°À_™Ùÿ÷È'>±¸7þ�ÿñÏþ^û+?€€=€·Da„¹Ú™Dç’€‹ÓŠ(!– 9®» þx’Ž0cPŠœkb8 GñÐà±©Ž’�À¹(I;’Qhr$? à¯,&!I!Ä>Ïo{Û/º^ïíÅääÉE¿¿E1 ° Y�(±1çÚÙ4Ö5ÉXv޾œ‚4ær§¹>Ûöñ Þ{3Zc³æ—|Um÷Ãá]~0øë>úÑK4ŠBHHÌRÅA¯xÍo¯<∿è¯[·’EÑary¨Ü×ö±Uº“gk4ÁmÖþå£# å�lw ¶ñ k6®Š4 ÍøÅ[xüñ4³÷nýÒ—5ðbá&O<ñÔbzú¦;ì]ë:¨(úÑ#“‡ ·NÉš¬±¼£0¶]²C›|�À?<|Þyƒ½õ�ûȽ7¦‹Çø’Ǩs.s¡v$YÌ)r„y—ÜD g¿µ�#[[·1V¤(2£¿‹¶¥Æ}TïÓÒcÑìÇÑ[Ìì7wì¸ï¡µk�ÁŸ³QŸR!„û5Çüæoýß çž_n=œ[ºI’=ÆÔã¯vûvΕ۶#æÑ÷yE»y<Ì—f¶ˆÊÏyï· ªôeùåÍçž{.òrW!Ä>‡„$±WXõÒ—?}Øs>=uøá§÷¦&™×±3 Tmµ·Ñ2vé%ªëpY·³mîë,°Ç-£["’µZÆ4]ª­Õì OúðU…¥mÛ)çæ~lëEÝ•Lˆ=¼ž˜zÁ ÞÜ_½ú�Së=»X1=á&úÈÛd4Î;´.çn¤F��‘?à,‰«Ìðþì’Î;o¯�¼ÑÞôXà§¼�ä0¼8sŒ%ká_g¹�䀨,E7RØ]0(9 GO†àÜ(‘.�j!É`±ä�ÌŸ/ìØ1l»Æm$œð10xãsäDB!„ Û{e­Ø›^ùK­=¹˜šÈú“lµ�af»j?‰L¼Éºþã ›¿�o–ëîÑü¨’g¼²kœŠB“•¥U‹ Ÿü&HD{˜u¯=Ÿ¸ì²¥Åï}ï‚ê¸ã®ñÃáïöV¬xcoÕÊç““Ónrªpý~KµÜ4Ǻ•³Á¼�¬`þ �7v! ÿþÈyçíõâÚôítñ§ §Ö ‡ ƒá«ÝÀvãaªx»KSÓy¤>té×м%d–ÒÏ¥lµ¹Xúf080?R™t p€ÀÆž‚»oºER!„Bq@ !I<í¬<ãŒãúk×üjoÅÊIÔ A«‹­óò²¦¸å½Z]žê!Ýî•i¡œ·dÝeg‚´SæšÎpÍ |ì›Ðâ°ÀìœyÀå¿ZéUÒ&ö8O\vYr UÃ{î¹üÉÄÆã?Ú[¹ú,79ùr79q²›èo(ú¡èõUé*��30ÜË^ïW¸ëéÜ&’÷ÜcŸ÷±roÿ›>tïwÂ?v˜Ï&p(€ª940T!» ô¤àÚý]š‘ÞÌ\üǧžujþÈ”Æff �€k§è{‚E<ð¸xhòµÈejàß,À…;_"’B!„8 ��$žnŠþêÕ¿Þ_»öXöŠÔªNëK¢M½’³vþQ\%fî4}BS Ú–¨du!vî(JÁÃc%¡´ŸºûDìÝÎGJÿÏ•&QÎÎËÙ¹r¢ÿ�­—\¨šn±7XܽéÖp—[¿þüþ¡ë7˜a…-,¸bÍš“Љ‰£x�s eQ<Ð[1ýØÄšµOô¦&æa>|ÞÇŸñ¶¥%z®�áiN28ÏPîŒSðtp€Þ,––Ñ,”±™kòŒ(:©Å):’Â%i¨=Y°"z‘¢˜Tg=¤Gæc©›‹ûu€U ÜM€]pÙ/ï_þ&üÏßÿC}…B!Ä…„$ñ´²ú¯8ubÍš·ö¦¦'ȬÇTV¾–õ’z ¡]Ö“š’¸Xð–w¢J Ü–?CÚ�µ)f·#¯B˜1³Néé“3Ê—†ss÷øØ`vv Q{™¡ß²eûÒ–-Ûë�÷ÑG§"Ãñ½(Œ½~¹zãF¿õKî3%—É�ÔCyŒ�/8ÍXÖ–º+†)êI¸¼´,öb¤ Æ"ZÊGÊ÷ϬŸbãbÔR=Ie&ˤh&{S#,ÕÞF’ÎöI‹¯?.þÔgõéB!„’ÄÓÆô)§¬è­Xñ›ôÜ¢>jyéZ½Â‹BMË™„vÌQÓjªI=±ÔvÔZ[† "iݧ‹Ût:Å�M@1Ÿ*&¨dî§vàw¸T.. «……v½Þ#3W^©l$ñŒSnÞ\h•ªm½óŽ}æõýÓ½7¦Y´ À+Ë èĤPB¤8é]sgªˆe“�V…¥zF3Íg懑p�‡pòv8F™Ž´”ž;ÄŇƒÀ•ðl !„Bˆ§·@í…ÓýÕ«ß=±zõ¡pEXžeE´|ÁW/òšÎhlbŽÚÝÙb&RTwb”n½qêËëu7U*Öy¢ÆÖ`uÙZÒ�Ø-‰CSÒ3 gg—ʅź^ñЖ ¿,7’OÂïù6ªj f¶Á‘/#pˆ>H¹.¹€Ð”­¥ÐpçiÞYÝU ŒùFÈåc3ÒeP,•cÊÛn„i´"û“8ýˆ†dD"Aó—õ&Wß¾´ð~üÈ“4�B!„â€EŽ$ñtÀÞôŠS'Ö¬9§˜šrÌ JÒb-`Ùâm´´-]ˆ9ÙȬHÑ…ŸÌ·S(“kL»TvRQL§èeô¶ÝRéµTƒ� ff¾A³ ¶\øel ñ$|ðžo0x_NѹWx~3Ó|wöÇÊÒzºÎáÂbÈ­”k£™Í¬å!i;kâô[‡ˆ¶ll€Tîª.ÍÀ¹«^§ÁB!„,r$‰=ΊÓ_tPÕª÷öW¯9Ä9‡\ÖÉÃM²¦jÈ‚MZ«º¦Ã[vw íFŠÈµ,cɘ•Â¥EaV�VG#±[ÕRo²ÜËIÿ’0ó(ç«¥¥¿ÜvñÅ[4êB<5H‡¢(Npa“ªà,b�¢šÓB1a; ¬IIKw…ÙYKE¸: ?zY{³Â×4­[26-IQ Bá€}pÛàÇ6œ AB!„4r$‰=Êô Oë÷W¯:grݺ7ÓÓ®^²±#ép¼g(Ï( ¹Ì-IYÝY\(FEªé÷6’ª�ÜÉÔ\Ž[yHˆË^Kž®Ô8¬,­œ›½Þ_®QâÉ n$À›­‡ù—v˜·ú`ÿ!HW ?l{ÑôbËïNS×,¿±%…ô}#ëû;]Ùj­)o)Ë¿ ØEJDB!„’Ħ˜š\×[µêwúkÖ¬v®íöi:³Y“qT»„Ú)DÙ 0>˜�“)éA­Îk»(b³,;4E7“0dùtLI›YÝŽf(wÎm÷KK³íÒK4êBìšÜsCüÂq=¯6àE}sí¼"±Ö-ÎKóf>䬙ùva‘¹”ê6lu)mŒ:ÊÓÂñÄš´6r$†ßÌ›áû€}à¢FQ!„BÄóz!ö«Î8c¢˜š:gbõê—¸~¿½$Ì.0U«Ñ4Tb+ ;¬æ‚¨q%¡•˜ÒlÏñ/¨QˆÚëÅVM]*y˶ÑxšeMÆK~0»ó2óÕeu!žƒ¡Bu2€SLÖêrXÛ‹,mÃ̵£ù- jGRî2ævEäÆÃºþm™ƒò¹O«Ug–€]î­¼èãMž¯�B!„’Äļ­î­XñýU«V¹ÖR­^¦eN"Ës­ëõœ�÷1w u—‚�˜î–�(O;²Ñ×£É-e�'m|Kñ5xC97·Åÿ¸íÒ¯ÎkÔ…Ø5¸ç; <àˬàÛ–BúøuÔ”›ÕNÅÚOä‚—ÈØ‘‡[óÛB@v^×ÚÈÚ×3—d]G•·Î]àØC¨pB!„B’Äbòäû½Õ«~ibíÚ—»ÉI¶òˆêBk¥å½”Z±×™K‰±” ö$åkùÚpŒ8Œ^® KY'¸Ñîlq‘ÉðªÁ°ÎÎ}i83s¥F]ˆ]ó�1É¡×sÀY]Ú >sÒ`.Eñ“)¾(�èo[Š· ¼™µêbm\#6¤ÂÖ¤b[6ÅëL¶üÈñ8aŸ®¼m1Þ´a£S!„Bˆˆ„$±G˜8èàô׬ùí‰Õk¦šR4¶ªÆÌØ´ÝnEœ4‘&i™Ä£–|´K-)ŠMÖÙ v·º¶µ} Ëì-»³”ÌPÎÎ>R-.þÓÎ뮓EAˆ§€ÁàQžJàTS*’.ÞW»‡š#CwVúÖœ´f[÷yÒlÍ&o6•Ù™óÝ\6€#làÚXs¥ñã ØB!„¢…„$±Û¬:㌽ÉÉŸ™X³ú$×ïC“n³¼×Y€¬ Ô IDAT\²ÅÜêºÔ-_×�E|J¯�]'RÞ"ά½n$: ÎeöɦO¸™¡ZZgg?;¸óÖë4êBìšØô­4éørÖàÓ”4óhBÒˆvyZš²�òˤ(µ§¨#]–¤�fÒ†£Nf€ ’–…8%‡¼¥d˜ã$PîNç¯ÁÎÖqK!„B��$vÿC49qPoÅŠŸè­X9�VCí¸�5ë5faÖ#î¡úÞ\ñÙÕB.Wž¢:•Å®XÝñ-¼K Ö.ödËÜfU…rvöA¿¸ôѹ¿_iÔ…Xž¸çÛ_•fö:ÀŽ7 |‰õì²îl³¨±SÈÖLMkÍ`’1L» Ži.‡ m¶*cƒpćáˆ2|áa@|Žð ‰Eט¹(%" !„B±œ ·@ìÓ§œ2éúý7ôW¯^ÇV°vã@²N›5^„V#µvö.©ów“ŠQý1äQÝOmgí¥,ê×_U¡œ›¿c°¸ø76Z^#„ÈøûM7À{�bbêX/%9°ÊëÈÂÿRIšÏfl#,±nÖï7s¤3ÀƒÑZhf¾Ý™1=¬žúln0‹±ùé]ãxdìw7`Ÿ-±ªÔW£B!„Ë£³e±[ôW¯>´˜œ<Çõzqá–ˆ¥°lËú³e=”Æé;ãb’8²Eç2;TÖŽ­ÁÂ'×fçµxÀ/.Ε³³çÍ\qÅcq!–çï7Ý�.N8 fº´Áš™ê,f1$óùܶºŸš¥#C[ÞÛÑ:G&ÊÚKa:ç¥nÙ!ˆ°ìhEà!À>ð~ø©#NÔ` !„B± ’Äî}€úýÃ99¹14a²ä%â™i<©¹wGüá“ÇiÛHw¥ôx¶o‹Ž†:b·µd÷SžÉÝ8™ Yiž¾Úpvö{ÕâÂùm!–'‘€%yézXΘJÒBðµ³,-¿‰Án‹KAþ¡Å¬#v¶!s´ºúÆTCçÑHÚuV›#8ò33�~Í{�Ÿ>âG4˜B!„BìJÐ[ vÅZ×ë­€sYØu”d²8"6ucù£SbÉ“= –]ˆZžsdínmí6àÁ¡„¬Œ­õ c|OæQ-.Îæç?±ýk_Û¬Ñb<¯üå·€ * àE^jf¹Ã0O1J~ 3cèäÖ‹S$wž·æÒ~ؤ$ù:7) �ªÙènò­fŽÙ™Áße濸zõaž¤S!„Bˆ'AB’Ø]¦HöȤßX]Ê–/×,Ë4j:ºeeg•‹F¤�ä*²Ô}­Ù_§Â¬²…í~O]ŪµM³ð_¼ÝK+çæn(ggÿUÃ,Äò¼ýOÿ‡þ‘^EØ ’>dYW¨õ³ø¢0 ýè�#ÌĸQåÍ<£+ Hž&ßq.Âjáˆy˜w|€£™#°ÉƒŸL]Ú~æH•´ !„BñdHH»GQ,¦:cæÈ›¦uÊÛÚ´KÎ:÷Œidžڰ´© þäBSý”¶\x·µz9åi,Iï2ïQ.Ì?^ÎÎ~`ç7¾±U-ÄxÞ¿éú8{Š`¯'yèB’6éBš‘·öŒ@Zꥤž\ö s:bh:«¹Õ_ïh; »‚t“‘”Ô'3œ‘[ v±ƒ¿Þ I!„Bˆ§JOo�ØlX>bÃrÁÌV€ÕÖ¤¶:ÄÑU$ò•´4l£YÓã©V•ò•èFj"ŒŒâR½%ò²7ë·<ÁÒäÃj87÷�ù»ïþ¼FYˆQ¦×¯ÃáÇ€¨°bÅ�Š̬4íÓRf‘5óÝ5ÓÜ‚ñ :ë²D&³”}ç³köåãþH¸4©k j±©{ð±E3»�®w…™1 !„Bñƒ !Iì…z«¶Âì°`>°L"²Ìíƒfí˜ý5ä¹ÙÖÎDbîR¶Ð´(Ymf¨ËÙRycNJrC1=G¨ª{8YëÕÀ*Cµ°¸ÅÏÏŸ·xÿýKd!Ú¬8ôÌ?¾¿í% ˜z9€WæHÌÃH2 =q6Ò/ÌÑ—.;>xÀ¢Ë¨c2Š‚”æšL$ï@¥ãŠ„ƒ™÷Ž®@->…î�¤‘pØø=Ð}Á›ßFo–I!„Bˆ§ŒJÛÄnaƒÁc¶¸t¥•eŒ-",l8raÌ>ºõpùú19’2GóøÞhu"Ùª…ëÉdà±¹µ÷ÉfÙY•KU¹0wõÂ÷¾P#,Ä(ó�oÅû7}+^«žðlÄ&ÌÌ@GGæ]šu ¹HiNt\ËH8�µ™ ¬3’¼Ì< „�sÙ1ôˆ´�¯®Ó9Ë3Ç‘�pf@aÀ=ŽîÀî$" !„Bñƒ"!IìåÂp®*«¯—ƒAÕtQkâp­i�ôö–geÎ¥ZÊ·Ó]±ë›e¥tczõ/yÃðv‰[U¡š]¸g8³ó/¾wÛ¼FXˆQ ë¼ÀÁß8�ÌG Ëf]dD#h0OÆÒ²Æ�Ø´wôQ 3µ©AcØÆ ôÍ8øpðñx‘ÒµSà¶#° ð9›¸ÞÌð‰HB!„BüÀHH»ÅÜ ×VðåµåÎ�÷›OÝ•Z°¬ɺWØØ“FÝGäH_§ts³FÍvŸ�€LpbsÝâ"Ö ¨–ù�_Ú~Å×ht…å¾ð±ðåQðÇQ43‹0Rë눴zöy˜yo¾.Ycž�dÆö‘!¸ŽÀÂ¥pílú»ü`’rÓÒ—[*€‹÷ î‚W—XT0’B!„?$’Änc•¤Z\¼ÈÊù¢ã®Yg¸Ü}––žõ•VéšuÐyl»Ô1%•‰F˜Åš¶äo(K” ÷V‹‹ŸÖ¨ 1Êû7} GŸ\<¾ªÎpZ˜Ztù$´¦UšCÈ5j¼Iá6GÀç_J2BmIL@úAZåÃ<&˜çhR "SF›CèçÄ'7iÀ­¡¶À5]ªB!„â‡@B’Ømž¸âŠÙjXþãÒŽ›¯âÂnØ-<«“‹²mШ@ño ËNKÍÆ�Ô–”,{šzÏu�wû9X×Þ †r°4ÎÏ_<»éžë5ªB´ÉÊÙ€3¼€ =ÔüȬ·XRr�Pçuf¹YœùÌϬKõö¤s;ãÑ¡d¨b ¿1va³æ¨�>`wö)›Ò>þâ7Þ­AB!„â‡@B’Ø#vW93ó§åÜÜ -¹Ì–­¼$‹kGk–‘–7ú6ÀoAª››…dùR–]·Rp1vp Å.Q‘¢ƒUª…ù[‡s;?4¼ÿþ�FTˆ†Žˆt2‚ˆÔË&Z–Á�i»#:0|ÕÔu©$šDí4§»²Ñu²ù‹tÁ›ù("¥Š¶TOKZöülÄ)gf�˜Ù+oJ;ú¹£NÒ  !„BñC"!Iì¶]zé’UÕùK[·}°Ü9[s�u›y¶˜œRhV¯Ô=Ø,>†moQÚ3;•Åãa_ák¯r›u¯y”ss3ƒ�³ÿ2så×nÖh Ñðww_ÀP8À1Î6ð f®ç‘uXC„Ràë¶8ͳYœâP¹f#Áü1ŒÛ¼#�…¦nÆ|®£–† Òùp›#€Y_솥¥Yÿ G�Œ_8êd ªB!„»�„$±ç>L½â «ªÿwqûŸñ‹KŸ³°ý×z*Û¢NSñÒ„h[{Úõ1Œtb³Zª ¾Æ¥jýˆ<ÜÛ(çço¨?¯Q¢!9‘½ýJ”•nàYŽ„ù::›q¶*0ˆ7p$Ì<=ˆ´�LVÏÁT|š$ä¤@Ųoµ‹ÉåwÄéMÇè{B~Gc}¼Àgâ‚oÕ  !„B±»k½bO±å+ÛÖ‹/~Øõ{¿ºðè÷ÿ×ÁŽs1í°lËâ�².j#aÛhÊÙb7¦®ˆÔY<6—-¸‘já):“hÙKˆÉ-Õââ¬_ZúâŽ+¯¼W£(Dürè‡Ê5’8üÄ×®"q–™ßh I¦Ò´8Ý¢‹0ù�Ìb'DŸuG«u$Ÿ‚ô“�(Ôª¥¯#ÒÌ¢jdqßWg*‘¤ µnõñ ÿ:³ 7¸À#éÈ 7’B!„»O¡·@ìiæn¿½š>öØïù¥áv?ç\±ŽE/6h )×�›!.c’‘ÈÍCH·Õ%jÙíõã£H”œLyáËØ ¦´&l8Äpçίvìøó¥Í›·kô„üí]×Çye_àE$ûÌ'ŒAÀ1.Ù‚ïgkɘõ].™þ‹÷€u¨’c,Q£ttµÇПѥ×âÂ-“ïpÞ"üýør~ñ¹/Р !„B±��$ž6Ý3\ñün¶²¼¤\˜Ÿ÷eµÑõz«èê¥%Z+R$)ˆ-QX‚ŽÓƒˆP—–¦/'ïR\Ä6aÛõ2¶ÉkÎî|t03ûçÛ/¿ì*�š�¿½ûú˜CßïÁìÕÎ4³IF=ÈÌ¢Häh0Ǧ´,‰A¬ç_œ•¹X„Z/JB™?ÖÌ@:t€Gˆ,î5K¡ªÍk‰š0AòAŸAq㪸S‰HB!„Bì9$$‰§�ù»ï.Ì-<>}ø¡WÛ°üzµ°0aey”+ŠiE§k²uhÛ’dl£0+¦I›É@c};Ìê•+‘ÖÄ¥«_\°ÁÌÎÏ fv¾ðÀæ�˜Àßmº!Ì5«z0ÿ ƒ� `EhŒŠÊ€h7-–´Y.9Æö‹0—¥l*:DI7ÉMm] Y„™Z›œ¢ˆÔr/e·ÑÐsäV‚_ pÍ$¬€_’ˆ$„B!Ä¥§·@<�Ø#ÙÖGZpõ!眳iP–Ÿ/ÞÖ[¹ò ½+Vý>:f4yJ�pÔÊGjéOñž:P;‰I)‰q¬wFVUÎ/|¿ZZú÷�_ÿÚ6�”Á‰df(Š~¯ªp`g’\ifžIÅBQªK] ZraT(N"ê‚¶úqVç,…}Ö™GQG6K%tÙvqw–ö½Àe¾á�¯GSR²1ªµ�ƒÈàX»Œ2gQ”�bvR린w6F# í> ÊQžÅ7t¿i°/õ ·Íû�•öÎSÏÔÀ !„B±‡‘�$ö*K÷oöówßµ0Ç›§Ž9æZ_–÷TƒÁ:3;Œt=®ãT¨Wš¨»|3ÙfµC¼™Â¹³*¹¼™¸ù Ã�3·”só²ãŠË7iTÄ�N‘Ì*犉x5 �ì>ͼkE›±1¹,õÌåQIùnç$YTšRjZ-åûOñü‚6Õ*o ×�pßøy�¤ ÿ_>ú ¬B!„O’Ä3ÆÂ¦MOôׯ¿äÕ~8|Ї‡ÂÛ!ι‚ΡÛ*udšÿ² ¤´±±YµZ{ kU…rvçL97û?¶^tÑ…@+Ù[ˆŽ$"p¤;ä«iˆ�Ú¤74‚²²5Â!ÎÄn1[��”]¯íJ™¾KÒ!ËGJ�(ÀÁ5%¶D¥Ô ® q+aŸ1àÞôd¿"I!„Bˆ§ IâeéÁË…M›wëùNÑë]áƒÇý`ø�Ó9G—š}‡eª²îkJQÞ\<­Z£S©vIÐÞ£œ›[ÎÌ|¤š›ÿðÂ}÷íÔˆ™\Dp€W8&^OªNôfÆìöä LdyW6tF©Ûšcž||Žºú-Û>3Fñ(%r×ep À>�{IžO¸;$" !„B±w��$ö >XÎß}÷cníºØsßöÃá¬/Ëì¡s¤sh¨¹)z‘¢)·?4«J”ssóÙ™UƒÁ_o»ì²‡ô®‹™¿‰"R868À±ñ{Á#+& d@ÈAj²’ÂõTÚVÏQ6³ª¸ºyb’~Ù¨BdÛ¥4R ×4yèÀ€Gyž7'cá[�>U+„B!ÄÓŒ„$±O1xø¡Á¦M÷÷;üZ3“–ó(«õt\KçÈÂ5ý¡€�‰„<š›ÍJ6f%ùÁÃ�3w”s³ÿ³\\úû'.½T"’8 ùë»®,9çzÀñ^à8})Í$Ë£‘²�ëx•@[Dª'bL>2€u~RÛQ®8:Œ‘2!©N;K÷9�øÉo¥'”ˆ$„B!ÄÞAB’Ø'YÚ¼yqaÓ¦»zëþ¦Án³²tæýzš­°´¢$³8^4KNæ+Øpˆrn~G5»ó²áÎ�¾ó�û?>{Íu;ô¿¼ëZÀâ°rS…;–À+Iª¸™Ct[R‚߈H@œuŒfã“,�å!µ³ï�Y÷Å8s“ƒ© ív±Â ]¸ÃÜJàó¾ž ZßvÌ 5°B!„Bì%¨·@ì¬}ÍYGÓÓg“¯u“§º~£ëõV¹Âõᜠåm4«ÊÒWÕ‚•Ãûý°¼Õ/.^>œ_üêìý[ï-ﻩÒ;)dþâÎkA n€•6y¼#ÏtÀF¶E¤T0êxƼ¤äCJ½ã–,Û®Q™H2vQth‡m@ÁèY ”…Tm¸Ø Ž`lžØ‘ðÀ'|‰ÀÅðöc^ˆ�/}î¾þ;\!„B!ö’Ä~Ū—¼tmoÕêcz“§¸^oú½CHo%€%óÕCUY>â—n©æfîš¹î[Ûô® ü?QD �é ïÀ3¹ÑÌD$D ÈB��“c“H83øäL%nuéa.×�¢˜ÝNYÒv(Wk☂ñ©‘ùI°j;é.ð”o?æ…xáÎÂM_½Jƒ+„B!Ä^DB’x6PÄÏr©·BˆQþôŽo€ w“xŽ Ÿïˆ3xœ#&X-óEP IŽ¡c›æ˜‹b7·àSrhÊÚ`fd(…s {ˆß9qV[’\ Îg½Ã°‡�¿DGΘùË ç¾lfø�S^‰ÁÎ9 ®B!„{™žÞñ,@%kB,Ãÿuû7à ˜/·ã°‰u'|€çèèˆHLQHáÍÌ€¡öªÔ¬Ù¸¡“¶mŒqÛòCDRÉ}MÜHt©ˆŽ°fö5_ñføOÊDB!„âÃé-Bˆg'ÿçíß„�¸g®ÂÊþš;uw6ó–¥ –b³£ˆD:¸jožfÍ#ˆÔEu§5ƒ™Ì×ß1QR²ôlL:R¸ìƒÔdõk@#<Íø5Â.°hf‘„B!„x†Q×6!„xò'·] Øî×âˆÉÁ)$Ï"ø\’…c6bVGÖ!å$1–©ÕmÙò¶j¬Å£Ô]-ôW3€ŽõmY‡·�¦�Ò’b.’#òª¶PÖFr†àUþ+ÎÀ¯=ï4 ¬B!„Ï0r$ !ijŒ?¾íë0¯8ñLTì8�äëwÉ"¨:ËÈG )lÛ[pØ©`‹1Üï >Üf„™™Gxlma2¼™y²¡ó)0 IX¢ÛàJ�êbHDB!„bBIBñ,áøsÎÆsV¯‡0É ß¼ýëg8òµf¶.8�\T{’Šc.^òcvWwnK ®¥BGR¸7D¨¨ðÈ”ˆä`1¸;ø›œkúÂ�¶v…Cÿ«†rÑ`x‡D$!„B!ö$$ !ij€�?þz¼ù/ÿ;&]�ICë�Iòl«I¶„"‹ÂŽýr¾¤$EK’ B�…‡×ûñÌ*ײ--ÑÕ5pMRŠÐ¶Z5ŠŽ¤"Ú�v�¸ Àeå€ ~]"’B!„û*mBˆýœUljŸ}ߟàè©)ìλEà žók 6Îmó‘–-os¸T×ä#oFGÊÚ ±éZˆ=Êž–ѳ”Ä©:~»·„m¾â.0 ïxž‚µ…B!„Ø×�#I!ös?ù�Ä}‹‹+Võ¦^kf/5ZÏ€ÊÕrQ25òQsËIªlhˆH €fónpá°ªî,PxƒáÖ«¾)I!„Bˆý9’„b HàáPž àeŽ8AXÕäµ*ËÂcÌàX‡gÃGÂǶkàÒe>ùËq¼Á\�€”í;=·5¯!†m»€­»Î‘7«ò±~¾f$ !„Bñì@B’B<Ã¼íÆ‹‘’† œ*P½À‹ <8§+ÀAÏ¥�g!¹VÙZò+9¢±kÉ)s7:n’>e#!gÇíÓ³×!LÍcÀ>€Í®2ð6ó=‰HB!„B<ëPi›B<ƒ¼õÆK`Ç Ø‘„ý¨�¯xxPqP¥ì"f ¶¯ˆ cnR’£€Ö@SþÆöåî÷€«£�²þlmMª¾ €J€wü²Gy�ù*–ÄIDB!„âÙ…IBñLpÕÀÒ"½Ç„ÃÄ¡¸åd x1`G0i°²)#cíêÈGq «·L÷X ÛF'd;w!-Wâ–n·ì/c—·èJJ»(μ àµpèÃàðÈ·ãOâ—5ÎB!„B<Ë��$„{›«.üôúCPrpgìt‡Dɨ ‚O«t miõå¬ÝG¹÷(ïÔ–„$æ‚ÒhÐ6bÀvæJÊÍMÀï¼àMfÒÃßuüéc!„B!ž¥Po�Bì%®ºØ±X³@1”¯}ÝôÊ�GNNìÍÖ$ß‘�¬‰CRŸ@Çðk€cCAÂ1öR‹— û‹xŸ‹JáÆ 8Æl$|èü²‘HàÀC®^ƒ#nÙ‰ï×üÝãUÊ&„B!ije$ !ÄÞàÊO‡ÐêÕ«³ƒÁêí ß½è«×õBPõ ÓOÍ™¯¨E“–dLelÝù¹ÚJ”=.t_‹9IËÿ”àø¨¹¸!Ifœð]¢øw·Ìà!kzq¤IDATJ|û+—JDB!„â@¥mBñtrùùÀ¡?l¹ { �øfÀ~ f¼Ù yŠÐ·- BÖ*Gs `Žy.R(;#Û‚QÜÄç©Ja‹´}KDj•¾5·Ñ7{543{,®üM€Í×|î‹ø×ßÿc�³B!„’„âéâòO†¿[îÌÖ€|5€s@œ`€!Ðd¥¤Ü(”üHŒ®#2ˆH®¾=EqûúšY(g#‘º²ù¨-…ýYP’¢PäÇ“Ì;’0’f~žt�îJÀî ¯Ëã='½~0Ô8 !„Bq!!I!ö4—�$d=Àº7ö€‡%>ú‚¬ñ¥Hm_wiK¤pìÊ€›ŽmÉYdA[ò€�tÈËßrˆÔ}­‰ä—ç (`f ·¸ÙÁ¾å�mðž_?,5ÎB!„B€HHBˆ=É埊 ¶°Ó¾ÀI0›1\@³`j{‘Xw^딬™風ä8x !Ú$[¥pÙÿ}{O¬Ëç8ÚÕ-ZZ2àû0•nMàï:þ%c!„B!`$$ !ÄîB_þ`r"Ý0øC¼ÀKnvU¸?ˆê|£ ÷d¹D­§0¤Rµ�£ÄÚ³�G–n›Á’•ƽVKOÛd+ur’¸•äm´ê:nñqŸïÙøR�µB!„8’„â‡eí* _üWDáǘü‹¾°Ã@* …%'Tµ™µå#Ö HIX20¸Šj±(Ãe}ÞX¸Ð:³9´|HlGo[óx�Fqx„°«*�[‹Ðµ ï–€$„B!„ˆHHBˆ†ÿí�ÀÏœ¯L8ÀË;1*>>µŠË,¸‡âåˆ üEÎrŽ2qÉ'Q‰�vk­|#«|íHJÅoF¸´yl×–:À‘¹[É*Û Üì�ëlŸè9xïUÊ&„B!„h!!I!~ÞýkÀ[~¼{�ðj/¹H´(Î0«Æ¢rÃÌF”Â�,fÁ7Á×i³¨EÕPŠÝv±ÄÍàࢠ):š,”Á96{KEpÉóÄètBp!mp€{“äõŸ�}±ÆZ!„B1‚„$!„x*¼é,ࢫ€·¼)è/V8À&œà ŽhTž¬Ü@Îù<”¨¾Ý̬–xRòQØM¸­@¢ ,vmsðShÕf!·›.$(‘ ¼3£7ÖjUÝ¥-JTÀc¾EØ�Χ§,$!„B!ÄrHHBˆ'ã÷~ø©7ðŸWVô;ÀËœ³ ÌE¢\/¢�‚Q´ HYI¤‘�ɺ�27R…<<;/L ¥jM63ƒEGRîE2>¸•æ�Ù­$¯ö°-.ú–$ !„B!ž IB±gœÜpK‘ࣃwëA¼ð§‚®Jؘ2ˆRiZnHñGïaÞ‚àTW´Ñ,+tCSxÿa¨½‰ ;ópQNò`–<£¨D8o�Ž ÚÒ=f¸rªç7-TðŽ”€$„B!„xÊHHBˆåxßeWÜ ¯ì×6 ØRôQ9 ™HMšv ¹˜Ú©±Õ¸�í§ ÅjÉGäRv\³¡GzªôÊš¼£Óm€§¡JåR›·‡Íp=énò°ùá`€ß;ñ58ìÄ�g!„B!ÄSFB’Bt¹ìüüÚ€8À¡€‹MÔ2±¢\ÎEô(ýÁ ÊCB!„BüðHHB¸|õ¼ð× èõz(Ë`|1ˆç6åhæT!LF1C›É�„xÙ#”«ûÌÒÆhññY \ŠLj Ü"­@$f·…²7¸^Ø!Üîa×À}0[4Â/Í÷àáññwý!î¸äJ�¹B!„b·��$„8ð¸ô¼ìŠõnDY¾à {yFPEQÈÕ¢ÀÐÁÍ¥@¢¨];–ø,lÛ7÷5n¤dgò�W6®K[xA4Be@¯¾»*Wxàv~À&›-@o ôðÀ­·áC?÷�¹B!„b� !IqàðÕO4—Ëj=wÀØ€�‰+Ŭ1|¼ôã‹w�€yבrQ)n˜º»ï4¬ªou6_—¶…yIõmE|mw¸Ùw0  L1Û‹³³ø/{“Æ\!„B±G‘�$„xösiÌ@2h+wzÅ)Ö˜ŽÇ °`ý©õ£ZPÊT VFR°)¹‹\—ØlÀ<31‰q÷)©©;ᢣ¡Eœ�aÜŽ€+BíÛ�n°ÉÃÍ8ø2<ñئûðÑŸþ�s!„B!ÄÓ‚„$!ij“/|X9.ÓV¬ìGžóë@®Dpöx„Ú1 ´E«e«QÜA-&± ÐN�ÙšìÆ‰Tw^ÛU×¶ºþÍšÇùP3XpB9¢ên à­î° À €2…ríƒÿ‚ëþöÃw!„B!ÄÓŠ„$!ij‹KSùš!TŒU‡ƒ<ðǃ\`eÐi’‹¨î®Ö¸Ž,f5Ø®Î8²Ž#‰-…(s.5]ÙÚUr$Âãsñ)=«1¥{Žà�î ÜæÙ²Ú¾¦²•*#ÞêÙs!„B!Ä^CB’bÿ翼í-ñŠì6<Àqpî(˜­0³=X‡1+c ©Ø)<»v&Õ΢,öˆÍ«£ÜµÔ�´(‹Bó}Xìæ–ž΂úe·üÝï4àûžØf0LÄÐ ÿtÚë5îB!„Bˆ½Ž„$!ÄþÉ»~ xÿG�KZØV<V=t‡¶ÆI0&UhœFÖÝ£CÊÀfî:J‰ÚÌJÛê¿A"˜kD*CÛ�T‹FÍí4Ä m ”ØMXð �»n2ðq†68Jx|ìô7jì…B!„Ï’„û—|¼¹üæs‚.ã±ÀQ€{€ƒ@Œàìñ ˸uVB6Nè�‡™k®¶JÑ’8ţ؅-¸Œói7YP7Óv™bU?o¸�ì�ì˜%p¯÷¸‡(5ôù+øW HB!„Bˆ} IBˆ}Ÿ–ë(áWÜÃÀްÀÐùP.fBøµkÜE ®É-òÖIÁŽåiµ ¨C´é2‘B†R”‚Ð ÉN¥m®Ù�Ûål(@L†ûyÏÀìª W\4ôöØvÌ=¶+`1ü¼ÓÏÑØ !„B!ö)¨·@±Ïrqæ>"ôPØsw4hÃp0`=U(3KñG>éA 3mÈuŽ~MWµPzæÀ±ÉØyà6ÛÇP«u¦¸]¼`ä*k@NX°Ä&‚7›/¿sΊÕÛ›˜Dw|Þé?ª±B!„Bì“HHBì[Ôî£èôéõ�áð`�ëÛðë®­‚¡j³S·5"–›¹ 9€ÆX�æFS³  sõÓŽ’Òs¤.kuã6Æ»\Ø:ÁqO+Væ@< ¸û»½©M¨q$úÖExûëþƒò�„B!„û<’„Ï<¬}H²pý5€­¸ÀQpPܰŠÛ:0‰7)èU’–ßÄúö:pM 6Ù<&ÜÖ=>faÛy÷5C(e3(¢jå,ØrÀC ïÃäê[±4ãîÎþ%�¿B!„b¿AB’â™!/[›–­ N# à¹!Ï­jr�’óQ¿1’íl#¢åTj"¯˜²�ê4åm-Á({Lë¸ÙzªäGBìC·e¸Ü%â6äÞ±�opã=¿’$I’^I’^œ�óðÕgÇ®2  [‡¼±\!ó[ÔðhцT×ÁA5ä¡V1­ê+Œ†õjC�4\Í" …E}0T«†8QÅÔ?=gT+‘Zë[D’@û;À½Zy”?Õ÷bÌ¥V+xç¿ ’$I’^JI’žß­á­7ŸöÈä:Ä:Q^�¼yb‹ÚÆ}3¿"•ñ²”ý\£Bä´ûl>»hk´bz]¢é&·VÝDÍ«2ʱÍlcèT‡Àä>”mÈ{ÀˆßO|î›VI’$I: ’$ýsß~ñôËG‰Bæ:u(v­:"¶ ¯åR«êÈlUGQN¶ŽåÉKR­&*“¶8æÛÖŽW#µ–¶ùÐ혜]ú±ÚãûF¬êª8VÔmk‰Ø†ø™ [÷ÙûµÏ[�á‘$I’¤SÉ IÒÓÍB£é墫±F›ÔYGýí:QºÚ±Vrþ 3�¦AO×L��eòp “Æ–³œ}˜~|9 Œrl…ÎèÏ/™+"� :‚}àÉ/�wÙØ¸ÏrÙ ­j±¨çÝx×ï„$I’¤SÏ I|þ1\Þ|ÆÕ!Ö¨AO¡Vm—©ÁÑ�çkûX@�Oĉ™C™­5-çíkýÈ£ ÌÞ˜k4 Åžog«AQ�©4ÜŸql[[Ú�D«:*���÷�m¢ìÔ§ÀÐpÓ k’$I’tÜšÿÒ)ó͗퇡ä§ýÜÑŠ‚j`”‚5à"ä&Ä&° qr�1-ZqÔΜL´Îy0Óv³IQQíh] sÊЪ6†LL†e÷/ìÚë»öxxמ²GæðxüÂbõÝücõÇð¶²%I’$éï$I§W™ßF �Ø^£nT{8ÇPÙÓ‡A¹šÌ(*cËZNÛÎj2U«ƒÚ°ëŽqÖQ/jÅÐÐòƘ7 B‘Ô9FË꼥èˆÅ’ìv v!C<¢ÄC²ûóÄo¼jù—[Õ$I’$é¹$I§ÓcPô*5@ºœahM6¨´íl“¨ƒªÛ†´èK†¦ƒ­Û˲«ÕEuÈöäüÖSm-[v]{^ßÚvì‘«?ˆ²$»%Q–�»DìòãÙ®žüíú<ë»à£OükK’$IÒ b�$�J¹ v!Ïç ÎÖ[ÎB9Cpr�.µ�l¹ sA”,jûZÆdžRNzØVd·¢niëZÓa{Î!ÄÈCjPµ_ÿÍ>ɰO,–Ð<ó׸þn} _ïŸT’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’ôŸñ¸v|ãËØIEND®B`‚caronc-apprise-dfec414/apprise/assets/themes/default/apprise-success-128x128.ico000066400000000000000000002040761526720371000275460ustar00rootroot00000000000000€€ ((€ !8KVdpwwpdVK8! Gƒ³äíðóõ÷øúûûúø÷õóðí䳃G  J™ãÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿã™J =q¸üÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿü¸q=,‰Äîÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿîĉ,DÂõýÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ ÿÿ# ÿ( ÿ- ÿ0 ÿ1 ÿ0 ÿ- ÿ) ÿ# ÿÿ ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýõÂD BÑÿÿÿÿÿÿÿÿÿÿÿÿÿ. ÿ%Iÿ0`ÿ7oÿ=yÿA‚"ÿFŠ$ÿI�&ÿK•'ÿNš(ÿOœ)ÿOœ)ÿOœ)ÿNš(ÿL–'ÿI�&ÿFŠ$ÿB‚"ÿ=yÿ8oÿ0`ÿ%Iÿ. ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÑB R¿ÿÿÿÿÿÿÿÿÿÿÿ+ ÿ0`ÿD‡#ÿJ’&ÿL–'ÿMš(ÿOœ)ÿO�*ÿP�*ÿP�*ÿP�*ÿP�*ÿP�*ÿP�*ÿP�*ÿP�*ÿP�*ÿP�*ÿP�*ÿP�*ÿP�*ÿP�)ÿO�*ÿP�)ÿO�)ÿM™(ÿL–'ÿJ’&ÿC†"ÿ0`ÿ+ ÿÿÿÿÿÿÿÿÿÿÿÿÿ¿RN¾ÿÿÿÿÿÿÿÿÿÿ' ÿ3eÿM˜(ÿOœ)ÿO�)ÿO�)ÿO�*ÿP�*ÿP�*ÿP�*ÿPž*ÿPž+ÿQž*ÿPž+ÿPž+ÿQž*ÿQž+ÿQž+ÿQž+ÿQž+ÿQž+ÿQž+ÿPž*ÿQž*ÿPž+ÿP�*ÿPž*ÿP�*ÿP�*ÿP�*ÿO�*ÿOœ*ÿOœ)ÿOœ)ÿL˜(ÿ4gÿ) ÿ ÿÿÿÿÿÿÿÿÿÿÿ¾N¶ûÿÿÿÿÿÿÿ ÿ& ÿ'Nÿ?} ÿG�$ÿL•'ÿO�*ÿP�*ÿPž*ÿPž*ÿPž+ÿQž+ÿQž*ÿQŸ+ÿQŸ+ÿQŸ+ÿQŸ+ÿRŸ+ÿQŸ+ÿRŸ+ÿQŸ,ÿRŸ+ÿRŸ,ÿRŸ+ÿRŸ+ÿQŸ+ÿQŸ+ÿRŸ+ÿQŸ+ÿQž+ÿQž+ÿQž+ÿQž+ÿQž+ÿPž*ÿPž*ÿP�*ÿP�*ÿO�)ÿOœ)ÿOœ)ÿF‹#ÿ-Yÿ, ÿ ÿÿÿÿÿÿÿÿÿû¶]ùÿÿÿÿÿÿÿ ÿ!Bÿ5iÿE‡#ÿGŒ%ÿH�%ÿIŽ%ÿK’'ÿQŸ+ÿQŸ+ÿQŸ,ÿRŸ,ÿR ,ÿR ,ÿR ,ÿR ,ÿS -ÿR -ÿR -ÿS¡-ÿS¡-ÿS¡-ÿS -ÿS¡-ÿS¡-ÿS¡-ÿS¡-ÿS¡-ÿS¡-ÿS¡-ÿS -ÿS -ÿS ,ÿR ,ÿR ,ÿRŸ,ÿRŸ,ÿRŸ+ÿQŸ+ÿQŸ+ÿQž+ÿPž*ÿP�*ÿP�*ÿO�*ÿL—'ÿ=z ÿ&Mÿÿÿÿÿÿÿÿÿÿù]*¥ÿÿÿÿÿÿÿ ÿ'Mÿ@} ÿD†"ÿF‰$ÿGŒ$ÿH�%ÿIŽ&ÿK�'ÿK’(ÿP›*ÿRŸ,ÿS ,ÿR ,ÿS -ÿS¡-ÿS¡-ÿS¡-ÿS¡-ÿT¡-ÿT¡.ÿS¢-ÿT¢-ÿT¢.ÿT¢.ÿT¢.ÿT¢-ÿT¢.ÿT¡.ÿT¢-ÿT¡.ÿT¢-ÿS¢-ÿT¡-ÿS¡-ÿS¡-ÿS¡-ÿS ,ÿR¡-ÿR -ÿR ,ÿRŸ,ÿQŸ,ÿQŸ+ÿQž+ÿQž+ÿQž*ÿO�*ÿO�)ÿNš(ÿI�%ÿ-Zÿ ÿÿÿÿÿÿÿÿÿ¥*eÝÿÿÿÿÿÿ ÿ:ÿ@ ÿC…"ÿE‡#ÿF‰$ÿGŠ%ÿHŒ%ÿIŽ%ÿJ�'ÿK’'ÿK“(ÿO—)ÿS -ÿS¡-ÿS¡-ÿT¡-ÿS¡.ÿT¢.ÿT¢.ÿT¢.ÿT¢.ÿT£.ÿT¢.ÿU£/ÿU£.ÿT£.ÿU£.ÿU£/ÿU£.ÿU£.ÿU£.ÿU¢.ÿT¢.ÿU¢.ÿT¢.ÿT¢.ÿT¢.ÿT¡-ÿT¡.ÿS¡-ÿS¡-ÿS -ÿR ,ÿS ,ÿR ,ÿRŸ,ÿQŸ,ÿQž+ÿQž+ÿP�*ÿP�*ÿP�)ÿO�)ÿL•'ÿ"EÿÿÿÿÿÿÿÿÿÜe•øÿÿÿÿÿÿ0 ÿ8lÿCƒ"ÿD„"ÿD…#ÿFˆ#ÿGŠ$ÿG‹%ÿI�&ÿI�&ÿK�(ÿL“(ÿL”)ÿM–)ÿS¡-ÿT¢.ÿT¢.ÿT¢.ÿU£.ÿU£.ÿU£/ÿU£/ÿU£/ÿV¤/ÿU£/ÿU¤/ÿU¤/ÿU¤/ÿU¤/ÿV¤/ÿV¤/ÿU¤/ÿV¤/ÿV£/ÿV£/ÿV£/ÿU£/ÿU£/ÿU£.ÿT¢.ÿT¢.ÿT¢.ÿT¢.ÿT¢.ÿS¡-ÿS¡-ÿS¡-ÿS -ÿRŸ,ÿRŸ,ÿRŸ+ÿQž+ÿQž+ÿPž*ÿP�*ÿO�*ÿOœ)ÿC‡#ÿ:ÿ ÿÿÿÿÿÿÿ÷•­ÿÿÿÿÿÿÿ0^ÿB€!ÿC‚"ÿD„"ÿE†#ÿF‡$ÿGŠ%ÿH‹%ÿIŒ'ÿJ�'ÿK�'ÿL’(ÿM”)ÿN•*ÿO˜+ÿRž-ÿU¢/ÿV¤/ÿV¤/ÿV¤0ÿV¤0ÿV¥0ÿV¥0ÿV¥0ÿW¥0ÿW¥1ÿW¥0ÿW¥1ÿW¥1ÿW¥1ÿW¥1ÿW¥1ÿW¥1ÿW¥1ÿW¥1ÿW¥1ÿW¥0ÿV¥0ÿV¥0ÿV¤0ÿV¤0ÿV¤0ÿV¤/ÿU£/ÿU£/ÿU£/ÿU¢.ÿT¢.ÿT¢.ÿS¡-ÿS¡-ÿS -ÿS ,ÿR ,ÿRŸ,ÿQž+ÿQž+ÿPž*ÿP�*ÿN›(ÿ;vÿ) ÿÿÿÿÿÿÿÿ­.Íÿÿÿÿÿÿ$Fÿ>{ÿA!ÿC�"ÿCƒ"ÿE…#ÿE‡$ÿGˆ$ÿH‹%ÿHŒ&ÿJŽ'ÿK�(ÿL‘(ÿM”)ÿN•*ÿO˜+ÿP™+ÿR�-ÿU£/ÿV¥0ÿW¥0ÿW¥1ÿW¥1ÿW¦1ÿX¦1ÿX¦1ÿX¦1ÿX¦1ÿX¦1ÿX¦1ÿX¦1ÿX¦2ÿX§2ÿX§2ÿX§1ÿX¦1ÿX¦1ÿX¦1ÿX¦1ÿW¦1ÿW¦1ÿW¥1ÿW¥0ÿW¥0ÿW¥0ÿW¤0ÿV¤/ÿV¤/ÿV¤/ÿU£/ÿT£.ÿT¢.ÿT¢.ÿT¢-ÿS¡-ÿS¡-ÿS -ÿR ,ÿQŸ+ÿQŸ+ÿQž+ÿPž*ÿOœ)ÿL—'ÿ/^ÿÿÿÿÿÿÿÿÌ.Hãÿÿÿÿÿ ÿ4gÿ@| ÿA ÿB€!ÿC‚"ÿDƒ#ÿE…$ÿFˆ%ÿG‰%ÿIŒ&ÿJ�'ÿKŽ'ÿL‘)ÿL’)ÿM”*ÿO–+ÿP˜,ÿPš,ÿS�.ÿV¤/ÿW¥1ÿW¦1ÿX¦1ÿX¦2ÿX§1ÿX§2ÿX§2ÿY§2ÿY§2ÿY§2ÿY§2ÿY§2ÿY§2ÿY§2ÿY§2ÿY§2ÿY§2ÿX§2ÿY§2ÿX¦2ÿX§2ÿX¦2ÿX¦2ÿX¦1ÿW¦1ÿW¦1ÿW¥1ÿW¥0ÿV¤0ÿV¤0ÿV¤0ÿU£/ÿU£/ÿU£/ÿU¢.ÿT¢.ÿT¡-ÿS¡-ÿS¡-ÿR ,ÿRŸ,ÿRŸ+ÿQž+ÿP�*ÿP�*ÿO�)ÿ?~!ÿ ÿÿÿÿÿÿÿáHGñÿÿÿÿÿ, ÿ:qÿ?{ ÿ@} ÿB!ÿC�"ÿDƒ#ÿD„#ÿF‡%ÿHˆ&ÿHŠ&ÿJŒ'ÿJŽ(ÿK�(ÿL’)ÿN”)ÿN•,ÿP—+ÿQ™-ÿQš-ÿSœ.ÿV£1ÿ[§5ÿ\¨7ÿY§2ÿY§2ÿY¨2ÿY¨2ÿY¨3ÿY¨3ÿZ¨3ÿY¨3ÿZ¨3ÿZ¨3ÿZ¨3ÿZ¨3ÿZ¨3ÿ[¨3ÿY¨3ÿZ¨3ÿZ¨3ÿY©2ÿY§2ÿY§2ÿY§2ÿY§2ÿX§2ÿX§2ÿX¦1ÿX¦1ÿW¥1ÿW¥1ÿW¥0ÿV¥0ÿV¤/ÿV¤/ÿV¤/ÿU£/ÿT¢.ÿT¢.ÿS¡-ÿS¢-ÿS ,ÿR ,ÿRŸ,ÿQŸ+ÿPž+ÿP�*ÿP�*ÿH�%ÿ8ÿÿÿÿÿÿÿîG8ûÿÿÿÿÿ$Gÿ=vÿ?{ÿA|!ÿB~"ÿB€"ÿD‚"ÿF„$ÿF†%ÿGˆ%ÿHŠ&ÿJ‹'ÿKŽ(ÿK�)ÿM’)ÿN“+ÿO•+ÿP—,ÿQ˜,ÿS›.ÿSœ.ÿp®Rÿ’Ä{ÿœÊ„ÿ�ˆÿ˜É€ÿ}»_ÿ[©4ÿ[©4ÿ[©4ÿ[©4ÿ[ª4ÿ[ª4ÿ[ª5ÿ[ª4ÿ[ª4ÿ[ª5ÿ\ª4ÿ[ª4ÿ[ª5ÿ[ª4ÿ[ª4ÿ[ª4ÿ[©4ÿ[©4ÿZ©3ÿZ©3ÿZ©3ÿZ¨3ÿY¨3ÿY§2ÿX§2ÿY§2ÿX¦1ÿX¦1ÿW¦1ÿW¥1ÿV¤0ÿV¤0ÿV¤0ÿU¤/ÿU¢.ÿT¢.ÿT¢.ÿS¡-ÿS -ÿR ,ÿRŸ,ÿQŸ+ÿQž*ÿP�*ÿO›)ÿ0^ÿÿÿÿÿÿÿú8Fûÿÿÿÿÿ0\ÿ=xÿ?zÿ@{ ÿA~!ÿB#ÿC�#ÿE„$ÿF…$ÿF‡%ÿH‰&ÿI‹'ÿJ�(ÿL�(ÿM�*ÿN“*ÿO”,ÿO–,ÿQ˜-ÿRš-ÿT›0ÿ™Äƒÿ­Ï�ÿ�»Žÿ˜sÿ�¬‚ÿ­Ïœÿ²Ö ÿ¨Ò“ÿs¶Rÿ\«5ÿ\«5ÿ\«5ÿ\«6ÿ\«5ÿ\«5ÿ\«5ÿ\«6ÿ\«5ÿ\«5ÿ\«5ÿ\«5ÿ\«5ÿ\«5ÿ\ª5ÿ[ª5ÿ[ª5ÿ[ª4ÿ[©4ÿZ©4ÿZ©4ÿZ¨3ÿY¨3ÿY¨2ÿY§3ÿY§2ÿX§1ÿX¦1ÿX¦1ÿW¥0ÿV¥0ÿV¤0ÿV¤0ÿU£/ÿU¢.ÿT¢.ÿT¢.ÿS¡-ÿR -ÿR ,ÿRŸ,ÿQž+ÿPž*ÿOœ)ÿ:tÿ ÿÿÿÿÿÿúFEñÿÿÿÿÿ1_ÿ=wÿ?xÿ@zÿ@}!ÿB!ÿC€#ÿD‚$ÿF„$ÿF†&ÿHˆ&ÿIŠ'ÿKŒ(ÿKŽ)ÿL�)ÿM‘*ÿN”+ÿP•,ÿQ—-ÿQ™-ÿS›.ÿšÅ…ÿ¢À“ÿBN<ÿÿÿÿ"ÿu�jÿ¨Ê˜ÿµÙ£ÿ–É}ÿg°Bÿ]¬6ÿ]¬6ÿ]¬6ÿ]¬6ÿ]¬6ÿ]¬6ÿ]¬6ÿ]¬6ÿ]¬6ÿ]¬6ÿ]¬6ÿ\«5ÿ\«6ÿ\«5ÿ\«6ÿ[ª5ÿ[ª5ÿ[ª5ÿ[ª5ÿ[©4ÿZ©4ÿZ©4ÿZ¨3ÿY¨3ÿY¨2ÿX§2ÿX§2ÿW¦1ÿW¥1ÿW¥0ÿW¥0ÿV¤0ÿV¤/ÿU£/ÿU£.ÿT¢.ÿS¡-ÿS -ÿR ,ÿQŸ+ÿQž+ÿPž*ÿP�*ÿ>| ÿÿÿÿÿÿÿíE,èÿÿÿÿ ÿ1`ÿwÿ?y ÿA{ ÿA}"ÿC"ÿD�#ÿEƒ%ÿF…%ÿG‡&ÿH‰'ÿJ‹(ÿK�)ÿL�)ÿM�*ÿN’+ÿO•,ÿP–-ÿR˜-ÿSš/ÿw°Zÿ®ÏžÿES?ÿÿÿÿÿÿÿ09+ÿ|”pÿµÚ¤ÿ¯Öœÿ…Áhÿf°Aÿ]­7ÿ^­7ÿ^­7ÿ^­7ÿ^­7ÿ^­7ÿ^­7ÿ^­7ÿ^­7ÿ^¬7ÿ]¬6ÿ]¬6ÿ]¬6ÿ]¬6ÿ\«6ÿ\«5ÿ\«5ÿ\«6ÿ[ª4ÿ[©4ÿZ©4ÿZ©4ÿZ¨3ÿY¨3ÿY¨3ÿY§2ÿX§1ÿX¦1ÿW¦1ÿW¥0ÿV¤0ÿV¤/ÿU£/ÿU£.ÿT¢.ÿT¡-ÿS¡-ÿR ,ÿR +ÿQž+ÿPž+ÿP�*ÿD†#ÿÿÿÿÿÿÿß,Õÿÿÿÿ ÿ4eÿ=uÿ=vÿ>y ÿ@{!ÿB}"ÿB~"ÿD�#ÿD‚$ÿF…%ÿG†&ÿIˆ'ÿIŠ(ÿJŒ(ÿKŽ*ÿM�+ÿO’+ÿP”,ÿP–-ÿR˜.ÿRš/ÿ\¡9ÿŸÈŠÿŠ£}ÿÿÿÿÿÿÿÿÿÿ8C3ÿ¤Å•ÿ¶Ú¥ÿ¤ÐŽÿq¸Oÿ_¯8ÿ_¯9ÿ`¯9ÿ_®8ÿ_¯8ÿ_®8ÿ_®8ÿ_®8ÿ_®8ÿ_®8ÿ_®8ÿ_®8ÿ^­7ÿ^­7ÿ]­7ÿ]¬6ÿ]¬6ÿ]¬6ÿ\«6ÿ\«5ÿ[ª5ÿ[ª4ÿ[ª4ÿZ©4ÿZ¨3ÿY¨3ÿY¨3ÿX§2ÿX¦1ÿX¦1ÿW¥1ÿV¤0ÿV¤/ÿU£/ÿU£/ÿT¢.ÿS¡-ÿS¡-ÿRŸ,ÿQŸ+ÿQž+ÿP�*ÿG‹%ÿ( ÿÿÿÿÿÿŵÿÿÿÿ ÿ6hÿwÿ@z!ÿA{!ÿB}"ÿC~#ÿD‚$ÿF„%ÿF…%ÿHˆ'ÿI‰'ÿJ‹)ÿL�)ÿL�*ÿN’+ÿO“+ÿP•-ÿQ—.ÿS˜/ÿT›/ÿ|³aÿ²Ó¢ÿ"ÿÿÿÿÿÿÿÿÿÿÿÿ‹¦~ÿ·Ú¥ÿ¢ÐŒÿg²Bÿ`°9ÿ`°9ÿ`¯9ÿ`¯9ÿ`°9ÿ`¯9ÿ`¯9ÿ`¯9ÿ`¯9ÿ_¯8ÿ_¯8ÿ_®8ÿ_®8ÿ_®7ÿ^­7ÿ^­7ÿ^¬7ÿ]¬6ÿ]¬6ÿ\«6ÿ\«5ÿ\«5ÿ\©4ÿ[©4ÿZ¨4ÿZ¨3ÿZ¨3ÿY§2ÿX§2ÿX¦1ÿW¦1ÿW¥0ÿV¤0ÿU¤/ÿU¢/ÿT¢.ÿT¡-ÿS¡-ÿRŸ,ÿQŸ+ÿQž+ÿPž*ÿG‹%ÿÿÿÿÿÿÿ¦•ÿÿÿÿÿ1_ÿw ÿ@y ÿA{!ÿB}"ÿD€$ÿD�$ÿFƒ%ÿG…&ÿH‡'ÿJ‰(ÿJ‹)ÿK�)ÿM�+ÿN‘+ÿO“-ÿQ•-ÿQ—/ÿS™/ÿT›1ÿŒ½tÿ˜µ‹ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿfy\ÿ¸Ü¦ÿ™Í€ÿd²>ÿb²;ÿb²;ÿb±;ÿb±;ÿb²;ÿa±;ÿa±:ÿa±:ÿa±:ÿa±:ÿa°:ÿ`°9ÿ`°9ÿ`¯9ÿ`¯9ÿ_®8ÿ_®8ÿ_­8ÿ^­7ÿ]¬6ÿ]¬6ÿ]¬6ÿ\«5ÿ[ª5ÿ[ª5ÿ[ª4ÿZ©4ÿZ¨3ÿY¨2ÿY§2ÿX§2ÿW¦1ÿW¥0ÿV¤0ÿV¤/ÿU¤.ÿT¢.ÿT¡-ÿS¡-ÿR ,ÿQŸ+ÿQž+ÿPž*ÿ?{ ÿ ÿÿÿÿÿöZ#íÿÿÿÿ'Mÿ:pÿ;qÿ=tÿ>v ÿ?x ÿ@y!ÿA|"ÿC~#ÿD€$ÿE‚%ÿF„&ÿH†'ÿIˆ'ÿKŠ(ÿKŒ*ÿMŽ*ÿN�,ÿO“,ÿQ•.ÿR—.ÿS˜0ÿTš0ÿk©Kÿ³Ó¢ÿDQ>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿVfMÿ¸Ü§ÿ¢Ñ‹ÿg´@ÿc³<ÿc³<ÿc³<ÿc³<ÿc³<ÿc³<ÿc³<ÿc³<ÿc³;ÿb²;ÿb±;ÿb±;ÿb±;ÿ`±:ÿa±:ÿa°:ÿ`°9ÿ`¯9ÿ_¯9ÿ_®8ÿ_®8ÿ^­7ÿ]¬7ÿ]¬6ÿ]¬6ÿ\«5ÿ[ª5ÿ[ª5ÿ[©4ÿZ¨3ÿY¨3ÿX§2ÿX¥2ÿW¥0ÿW¥0ÿV¤0ÿU¤/ÿT¢.ÿT¢.ÿS¡-ÿS¡-ÿQŸ+ÿQž+ÿP�+ÿ9qÿÿÿÿÿÿ×#¾ÿÿÿÿ#Dÿ:oÿ;pÿvÿ?w ÿ@z!ÿB|"ÿC~#ÿD$ÿE‚%ÿGƒ'ÿH†'ÿIˆ(ÿKŠ)ÿKŒ*ÿMŽ+ÿO�,ÿP’-ÿQ”.ÿR–/ÿS™0ÿUš1ÿ~³cÿ¬Ì›ÿÿeµ>ÿeµ>ÿeµ>ÿeµ>ÿeµ>ÿeµ=ÿe´=ÿd´=ÿd´=ÿd´=ÿc³<ÿc³<ÿc³<ÿb²;ÿb²;ÿb±:ÿb±:ÿa°:ÿ`°9ÿ`°9ÿ`¯9ÿ_®8ÿ^®8ÿ^­7ÿ^­7ÿ]¬6ÿ\«6ÿ\«5ÿ[ª5ÿZª4ÿZ¨3ÿZ¨3ÿY§2ÿX§1ÿX¥1ÿW¥0ÿV¤0ÿU¤.ÿT¢.ÿT¢-ÿS¡-ÿRŸ,ÿRŸ+ÿQž+ÿN™)ÿ5ÿÿÿÿÿÿMúÿÿÿ ÿ2_ÿ9nÿ;oÿtÿ>v ÿ?y!ÿAz"ÿC|"ÿC~$ÿE�$ÿF‚&ÿG„'ÿI‡'ÿJ‰)ÿL‹*ÿL�*ÿN�,ÿO‘,ÿP“.ÿQ–.ÿR—/ÿT™0ÿ_¡>ÿ£Ê�ÿ•sÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ~–qÿºÞ¨ÿ¹Ý§ÿ‚Ãbÿf¶>ÿf¶>ÿf¶?ÿf¶?ÿf¶>ÿf¶>ÿf¶>ÿeµ>ÿeµ>ÿeµ>ÿeµ>ÿeµ=ÿd´=ÿd´=ÿd´=ÿc³<ÿc³<ÿc²;ÿb²;ÿb±;ÿa±:ÿa°:ÿ`°9ÿ`¯9ÿ_®8ÿ_®8ÿ^­7ÿ^­7ÿ]¬6ÿ]«6ÿ\«5ÿ\ª5ÿZ©4ÿZ©3ÿY¨3ÿY§2ÿX§2ÿW¥1ÿW¥0ÿV¤0ÿV£/ÿT¢.ÿT¢.ÿS¡-ÿRŸ,ÿQŸ+ÿQž+ÿGŒ%ÿ ÿÿÿÿÿò¾ÿÿÿÿ(Mÿ9lÿ:nÿ;qÿ=sÿ>tÿ?w!ÿ@y!ÿB{#ÿC}#ÿD%ÿF‚%ÿFƒ&ÿH†(ÿI‡(ÿKŠ)ÿMŒ+ÿNŽ+ÿO�-ÿP“-ÿR”.ÿR–/ÿT˜1ÿV›1ÿƒ¶hÿ³Ó£ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ8C2ÿºÞ¨ÿºÞ¨ÿ¥ÕŽÿk¹Eÿg¸@ÿg¸@ÿg¸@ÿg¸@ÿg¸@ÿg¸@ÿg·@ÿg·@ÿg·?ÿg·?ÿf¶?ÿf¶?ÿf¶>ÿf¶>ÿeµ>ÿeµ>ÿe´=ÿd´=ÿc³;ÿc³<ÿc²<ÿb²;ÿa±:ÿa±:ÿ`°:ÿ`¯9ÿ_¯9ÿ_®8ÿ^­7ÿ^­7ÿ]¬6ÿ\«6ÿ\«5ÿ[ª5ÿZ©4ÿZ¨3ÿY§3ÿY§2ÿW¥1ÿW¥0ÿV¤0ÿU¤/ÿT¢.ÿT¡-ÿS¡-ÿS ,ÿQŸ+ÿPž+ÿ=x ÿÿÿÿÿÿ¢Aÿÿÿÿ. ÿ8kÿ:nÿ;pÿÿ±ÑŸÿTbLÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ£Ã”ÿºß©ÿ¸Þ¦ÿ‚Äaÿh¹Aÿh¹Aÿh¹Aÿh¹Aÿh¹Aÿh¸Aÿh¹Aÿh¸@ÿh¸@ÿh¸@ÿg¸@ÿg¸@ÿg·?ÿg·?ÿf·?ÿf¶>ÿf¶>ÿeµ>ÿeµ>ÿdµ=ÿc´<ÿc³<ÿc³<ÿc²;ÿb±:ÿb±:ÿa°:ÿa°9ÿ_®8ÿ_®8ÿ^­8ÿ^­7ÿ]¬6ÿ\«6ÿ\«5ÿ[ª5ÿZ©4ÿZ¨3ÿY§2ÿX§2ÿW¦1ÿW¥0ÿV¤0ÿU¤/ÿT¢.ÿT¡.ÿS¡-ÿS ,ÿQž+ÿPž*ÿ+Uÿÿÿÿÿú=ãÿÿÿ ÿ8jÿ8lÿ:nÿÿeµ>ÿe´=ÿd´=ÿd³=ÿc³<ÿb²;ÿb²:ÿa±:ÿa°:ÿ`¯9ÿ`¯9ÿ_®8ÿ_®8ÿ^­7ÿ]¬6ÿ]¬6ÿ\«5ÿ[ª4ÿZ©4ÿZ¨3ÿY¨3ÿX¦1ÿW¦1ÿW¥0ÿV¤0ÿU£/ÿT¢.ÿT¡-ÿS -ÿQŸ+ÿQž+ÿM™(ÿ!ÿÿÿÿÿ²qÿÿÿÿ)Pÿ8jÿ8mÿ:oÿu ÿ@w!ÿAy"ÿB{$ÿD}$ÿE%ÿF‚&ÿG„'ÿI†(ÿK‰)ÿLŠ*ÿMŒ+ÿO�,ÿO�-ÿQ’/ÿR•/ÿS˜0ÿU™2ÿi¦Jÿ¯ÍŸÿ9C3ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ¢Á’ÿ»ß©ÿ¹Þ¦ÿr¿Mÿj»Cÿj»Cÿj»Bÿj»Bÿj»Bÿj»BÿjºBÿj»BÿjºBÿjºBÿiºBÿiºBÿi¹Aÿi¹Aÿh¹Aÿh¸@ÿh¸@ÿg¸@ÿg·?ÿf·?ÿf¶>ÿf¶>ÿeµ=ÿdµ=ÿd´=ÿc³<ÿc³<ÿb²;ÿa±;ÿb±:ÿ`°9ÿ`¯9ÿ_®8ÿ_®8ÿ^­7ÿ]¬6ÿ]«6ÿ\«5ÿ[©4ÿZ©4ÿZ¨3ÿY§2ÿX¦2ÿW¥1ÿW¥0ÿV¤0ÿU£.ÿT¢.ÿT¡-ÿS ,ÿQŸ+ÿQž+ÿ9pÿÿÿÿÿúEçÿÿÿ) ÿ7hÿ8kÿ:nÿ;oÿs ÿ?v ÿ@w"ÿAz#ÿC|$ÿE~$ÿP‡1ÿc•Hÿl�RÿpŸVÿp¡Uÿl Rÿ`˜AÿO�,ÿP‘.ÿR”/ÿS–0ÿU˜1ÿUš2ÿ†¹mÿ´Ó£ÿ„›wÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿdwZÿ¼àªÿ¼àªÿ�Ëoÿl¼Dÿk¼Dÿl½Dÿl½Dÿl½Dÿk½Dÿl¼Dÿk¼Dÿk¼Dÿk¼Dÿk¼Cÿk¼Cÿk»Cÿi»Cÿj»BÿjºBÿiºBÿi¹Aÿi¹Aÿh¸Aÿh¸@ÿg·?ÿf·?ÿf¶?ÿf¶?ÿeµ>ÿd´=ÿd´=ÿc³<ÿb²;ÿb²;ÿa±:ÿa°:ÿ`¯9ÿ_¯8ÿ_®8ÿ^­7ÿ]¬6ÿ]¬6ÿ\«5ÿ[©5ÿZ©4ÿZ¨3ÿY§2ÿX¦2ÿW¥1ÿV¤0ÿV£/ÿU£/ÿT¢.ÿS¡-ÿR ,ÿRŸ+ÿN›*ÿ4ÿÿÿÿÿ°hÿÿÿ ÿ+Sÿ7jÿ9lÿ;nÿt ÿ@v!ÿAy#ÿBz#ÿmšUÿ˜¸‡ÿ£À”ÿ¥À–ÿ¦Â–ÿ¦Ã—ÿ¨Ä™ÿ¨Æšÿ©Ç™ÿŸÂ�ÿbžCÿS•0ÿT—1ÿU™1ÿWœ3ÿi¨Iÿ´Ô£ÿµÕ¤ÿµÕ¥ÿZjRÿÿÿÿÿÿÿÿÿÿÿÿÿ»à©ÿ¼áªÿ«Ù•ÿp¿Iÿl¾Dÿl½Eÿm½Dÿl¾Eÿl¾Eÿm½Eÿl½Dÿl½Eÿl½Eÿl½Dÿl½Dÿk½Dÿk¼Dÿk¼Cÿk¼Cÿj»Cÿj»CÿjºBÿiºBÿiºBÿi¹Aÿh¸@ÿh¸@ÿg·@ÿg·?ÿf¶?ÿeµ>ÿdµ=ÿd´=ÿd³<ÿc³<ÿb²;ÿb±:ÿa±:ÿ`¯9ÿ`¯8ÿ_®8ÿ^­7ÿ]¬6ÿ\¬6ÿ\«5ÿ[©5ÿZ©3ÿY¨3ÿY§2ÿX§2ÿW¥0ÿV¤0ÿV£/ÿU£.ÿS¡-ÿS -ÿR ,ÿQŸ+ÿ@~!ÿÿÿÿÿÿ0éÿÿÿ( ÿ7iÿ8jÿ9mÿ;nÿÿeµ>ÿeµ>ÿe´=ÿc³<ÿc³;ÿb²;ÿb±;ÿa°:ÿ`¯9ÿ_¯8ÿ_®8ÿ^­6ÿ]¬6ÿ\«6ÿ\«5ÿ[©5ÿZ©3ÿY¨3ÿY§2ÿW¦1ÿW¥0ÿV¤0ÿV£/ÿT¢.ÿS¡-ÿR ,ÿRŸ,ÿPž+ÿ8ÿÿÿÿÿºPÿÿÿÿ-Uÿ7iÿ8lÿ:mÿ;oÿ=rÿ>sÿQ�5ÿЬwÿ¥¾—ÿ]kUÿ&,#ÿÿÿÿÿÿÿ ÿ™³‹ÿ°Í ÿ“¼~ÿW˜5ÿUš2ÿc£Cÿp«Sÿf§Dÿ[£7ÿe¨Aÿ‘Âyÿ°Ôžÿ·Ù¦ÿ¸Ú§ÿ�™uÿ4>/ÿÿÿÿÿÿÿCP=ÿ½á«ÿ½á«ÿ¦×�ÿqÀIÿn¿Fÿn¿Fÿn¿Fÿn¿FÿnÀFÿnÀGÿnÀFÿn¿Fÿn¿Fÿn¿Fÿn¿Fÿn¿Fÿm¾Eÿm¾Eÿm¾Eÿm½Eÿl½Dÿl½Dÿk¼Dÿl¼Cÿj»Cÿj»CÿjºBÿiºBÿi¹Aÿh¸@ÿg¸@ÿg·?ÿf¶?ÿf¶>ÿeµ>ÿdµ=ÿc³<ÿc³<ÿb²;ÿa±:ÿ`±:ÿ`¯9ÿ_®8ÿ_®8ÿ]¬6ÿ]¬6ÿ\«6ÿ[ª5ÿ[©4ÿZ¨3ÿY¨2ÿX§2ÿW¦1ÿW¤0ÿV¤0ÿU£/ÿT¢.ÿS¡-ÿR ,ÿQŸ+ÿI�&ÿÿÿÿÿÿ'æÿÿÿ% ÿ6gÿ8iÿ9lÿ:mÿ;pÿ=rÿ]‰Cÿš·‹ÿŒ¡€ÿ!&ÿÿÿÿÿÿÿÿÿiz`ÿ°Í¡ÿ°Î¡ÿz­_ÿT˜2ÿp©Qÿ©Ì–ÿ±Ñ ÿ§Ì”ÿмpÿ`¦;ÿ_¨:ÿs´Rÿ™É€ÿ¸Ú§ÿ¹Û§ÿ¯Ïžÿ{’pÿÿÿÿÿ  ÿ§È—ÿ¾â¬ÿ¼á©ÿ‚È_ÿoÀGÿoÁGÿoÁHÿoÁHÿpÁHÿpÁHÿpÁHÿoÁGÿoÁGÿoÁHÿoÁGÿoÀGÿoÀGÿoÀGÿoÀGÿn¿Fÿn¿Fÿm¾Fÿm¾Eÿl½Eÿl½Dÿl½Dÿk¼Cÿk»Cÿj»CÿiºBÿiºBÿh¹Aÿh¸@ÿg¸@ÿg¶?ÿf¶?ÿeµ>ÿdµ=ÿd³=ÿc³;ÿb²;ÿa±:ÿa±:ÿ`¯9ÿ_®8ÿ_®8ÿ^­7ÿ]¬6ÿ\«5ÿ[ª5ÿ[©4ÿZ¨3ÿX§2ÿX¦1ÿX¦1ÿV¤0ÿU£/ÿT¢.ÿT¢-ÿS -ÿRŸ,ÿQž+ÿ&Kÿÿÿÿÿ¥9ÿÿÿÿ0\ÿ6hÿ9jÿ:lÿ;oÿÿeµ=ÿd³=ÿc³;ÿb²;ÿb±;ÿa°:ÿ`¯9ÿ_®8ÿ^­8ÿ^­7ÿ\«6ÿ\ª5ÿ[ª4ÿ[©4ÿY¨2ÿY§2ÿX¦1ÿW¥1ÿV¤0ÿU£/ÿT¢.ÿS¢-ÿR ,ÿRŸ+ÿH�%ÿÿÿÿÿôÍÿÿÿ# ÿ6gÿ7hÿ9kÿ:mÿÿeµ>ÿeµ=ÿc³<ÿc²<ÿb²;ÿa±:ÿa°9ÿ_¯8ÿ_®8ÿ^­8ÿ]¬6ÿ\«6ÿ\«5ÿ[ª4ÿY¨3ÿY¨3ÿX§2ÿX¦1ÿV¤0ÿU¤/ÿU£/ÿT¢.ÿS ,ÿRŸ,ÿOš)ÿ!Aÿÿÿÿýqüÿÿÿ+Qÿ6gÿ7iÿ9kÿ:mÿ;pÿl”Wÿ¦¿™ÿ&+#ÿÿÿÿÿÿÿÿÿÿJUCÿ­Éžÿ°Ì ÿ›ÁˆÿU•1ÿe¡Eÿ®Îžÿn�dÿÿÿÿÿ09,ÿ~•sÿ¸Ú§ÿ·Ù¥ÿ�Çtÿp·Kÿh¶Bÿu½QÿžÑƒÿ´Ü ÿ½â«ÿ¾â¬ÿ¾ã¬ÿ¾ã¬ÿŽÎnÿqÃIÿrÃIÿrÄJÿrÄJÿrÄJÿrÄJÿrÄJÿrÄJÿrÄJÿrÄJÿrÄJÿrÄJÿrÄJÿrÄIÿrÃIÿqÃIÿqÃIÿqÂIÿqÂHÿpÁHÿpÁGÿoÀGÿoÀGÿn¿Fÿm¿Eÿm¾Eÿl¾Eÿl½Dÿk¼Cÿk¼Cÿj»Cÿi¹Bÿi¹Aÿh¸Aÿg¸@ÿf·?ÿf¶>ÿeµ>ÿd´=ÿc³<ÿc²;ÿb²;ÿa±:ÿa¯:ÿ_¯8ÿ_®8ÿ^­7ÿ]¬6ÿ\«5ÿ\ª5ÿZ©4ÿY¨3ÿY§2ÿX¦1ÿW¥1ÿV¤/ÿU£/ÿT¢.ÿS¡.ÿR¡,ÿRŸ+ÿ;sÿÿÿÿÿ»‹ÿÿÿ ÿ3bÿ7hÿ8jÿ9lÿ;oÿCu&ÿž¹�ÿUbOÿÿÿÿÿÿÿÿÿÿÿ�¦ƒÿ°Ì ÿ°Î¡ÿbœAÿU–2ÿ’½{ÿ©Æšÿÿÿÿÿÿÿ ÿ3=/ÿ ½‘ÿºÜ¨ÿ®×™ÿ�ÉrÿkºDÿm»Eÿ|ÄXÿ“Ðuÿ­Ü–ÿ±Þ›ÿ™Ô}ÿsÄLÿsÄKÿsÅKÿsÅKÿsÅKÿtÅKÿtÆKÿtÆLÿtÆKÿtÆKÿtÆLÿtÆKÿtÅKÿsÅKÿsÅKÿsÅKÿsÅJÿrÄJÿrÄJÿqÃIÿqÃIÿqÃIÿpÂHÿpÁHÿoÁGÿoÀGÿn¿Fÿn¿Fÿm¾Eÿl½Eÿl¼Dÿk»Cÿj»Cÿj»Bÿi¹Aÿh¹Aÿh¸@ÿf·?ÿf¶>ÿeµ>ÿd´=ÿd´=ÿc²<ÿb±;ÿa°:ÿ`°9ÿ_®8ÿ^®8ÿ^­7ÿ]¬6ÿ\«5ÿ[©4ÿZ©4ÿY§3ÿY§2ÿW¦1ÿV¥0ÿV£0ÿU£.ÿT¡-ÿS¡-ÿR ,ÿK“'ÿ# ÿÿÿÿî.Ðÿÿÿ;ÿ5eÿ7iÿ9jÿ9mÿ;oÿq—[ÿœ³�ÿ  ÿÿÿÿÿÿÿÿÿÿO\Hÿ¯Ì ÿ°Ì¡ÿ‚±jÿT•1ÿm¦Nÿ¯ÏŸÿVeNÿÿÿÿÿÿÿÿÿÿfx\ÿµ×¤ÿºÞ¨ÿ±Ûœÿ�Æ_ÿoÀGÿpÁHÿuÅNÿuÅNÿtÅLÿsÅKÿsÅKÿtÆLÿtÆLÿtÆLÿuÆLÿtÆLÿuÇLÿuÇLÿtÇLÿuÇLÿtÇLÿtÇLÿtÆLÿtÆLÿtÆKÿtÅKÿsÅKÿsÅKÿrÄJÿrÄJÿrÃIÿqÃIÿpÂHÿpÂHÿoÁGÿoÀGÿn¿Fÿn¿Fÿm¾Eÿl¾Eÿl¼Dÿk¼Cÿk»BÿjºBÿi¹Aÿh¸@ÿh¸@ÿf·?ÿf¶>ÿeµ>ÿd´=ÿd³<ÿb²;ÿb±:ÿa°:ÿ`°9ÿ_®8ÿ^­7ÿ]¬7ÿ]¬6ÿ[ª4ÿZ©4ÿZ©3ÿY¨3ÿX¦1ÿW¥1ÿV¥0ÿV¤/ÿT¢.ÿT¡-ÿR¡-ÿRŸ,ÿ&Lÿÿÿÿÿ^3õÿÿ ÿ*Qÿ6gÿ8iÿ9jÿ:mÿÿeµ=ÿd´=ÿc²;ÿb²;ÿa±:ÿ`°:ÿ_®8ÿ^®8ÿ^­7ÿ]¬6ÿ\ª5ÿ[ª4ÿZ©3ÿZ¨3ÿX¦2ÿW¥1ÿV¥0ÿV¤/ÿT¢.ÿT¢-ÿS¡-ÿR ,ÿ@~"ÿÿÿÿÿ¬eÿÿÿÿ3aÿ7gÿ8iÿ9kÿ;nÿS€:ÿ§¿šÿ6>2ÿÿÿÿÿÿÿÿÿÿs…hÿ¯Ë ÿ°Ì¡ÿv©[ÿS”1ÿs¨Vÿ³Ñ£ÿ*1&ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿdµ=ÿd³=ÿb²;ÿb±;ÿa±:ÿ`°9ÿ_®8ÿ^­7ÿ]­7ÿ\«6ÿ[ª5ÿ[©4ÿZ¨3ÿY§2ÿX¦1ÿW¥1ÿV¤0ÿU£/ÿT¢.ÿS¡-ÿS -ÿQŸ+ÿ ÿÿÿÿêªÿÿÿ2 ÿ6fÿ8hÿ8jÿ:kÿ/ÿ�¨€ÿÀå®ÿ¾ä«ÿ±ß™ÿžÙ‚ÿ‡ÏdÿwÉNÿwÉNÿwÊOÿxÊOÿxÊOÿxÊOÿxÊOÿxÊOÿxÊOÿxÊOÿwÊOÿxÊOÿwÊNÿwÉNÿvÉNÿvÉNÿvÈMÿuÇMÿuÇLÿuÆLÿtÅKÿsÅKÿsÄKÿrÄIÿqÃIÿqÂHÿpÁHÿpÁHÿnÀGÿn¿Fÿm¾Eÿm½Eÿl½Dÿk¼Cÿj»CÿiºBÿh¹Aÿh¸@ÿg·?ÿf¶?ÿeµ>ÿd´=ÿc³<ÿc²<ÿa±;ÿa°:ÿ`¯9ÿ_®8ÿ^­7ÿ]¬6ÿ\«6ÿ[ª5ÿ[©4ÿY¨3ÿX§2ÿX¦1ÿW¥0ÿV£/ÿU£.ÿT¢.ÿS¡-ÿRŸ+ÿ)Qÿÿÿÿÿ(íÿÿÿ)Mÿ7fÿ8hÿ8jÿ:mÿÿd´=ÿd´<ÿc³<ÿb²;ÿa°:ÿ`°9ÿ`¯9ÿ_®8ÿ]¬6ÿ\«6ÿ\«5ÿ[ª4ÿZ¨3ÿY§2ÿX§1ÿW¦1ÿV¤0ÿU£/ÿT¢.ÿT¡-ÿR ,ÿHŒ&ÿÿÿÿÿw#ÿÿÿ ÿ5eÿ7fÿ8hÿ9lÿ:mÿÿd´=ÿc³<ÿc³<ÿa±:ÿ`°9ÿ`¯9ÿ_®8ÿ^¬7ÿ]¬6ÿ\«5ÿ\ª5ÿZ¨3ÿY¨3ÿX§2ÿW¦1ÿV¤0ÿU£/ÿU£.ÿT¢.ÿR ,ÿRŸ,ÿ ÿÿÿÿÀnÿÿÿ!ÿ6fÿ7gÿ8iÿ:lÿ;mÿÿdµ=ÿd´=ÿc³<ÿb²;ÿa°:ÿ`°9ÿ_¯9ÿ^­7ÿ]¬6ÿ]¬6ÿ\ª5ÿZª4ÿY¨3ÿY§2ÿX¦2ÿW¥0ÿV¤/ÿU£/ÿT¢.ÿS¡-ÿRŸ,ÿ$Gÿÿÿÿû½ÿÿÿ!?ÿ6fÿ7gÿ8iÿ:lÿ;mÿ

ÿe´=ÿc³=ÿb²;ÿa±;ÿa°:ÿ`¯9ÿ_®7ÿ^­7ÿ]¬6ÿ\ª4ÿ[©4ÿZ¨3ÿY¨3ÿX§2ÿW¥1ÿV¤0ÿU£/ÿT¢.ÿS¡-ÿR ,ÿ;u ÿÿÿÿÿúÿÿÿ/Zÿ7fÿ7hÿ9kÿ;lÿ;mÿ=p ÿv›bÿ¦½™ÿ29.ÿÿÿÿÿÿÿ¬Æ�ÿ®É ÿœ¾ŠÿW‘5ÿU“4ÿ�‰ÿ�¨ƒÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ***ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿš¹Œÿ½â«ÿŠËjÿl½Eÿk¼Dÿj»BÿiºBÿi¹Aÿh¸@ÿf·?ÿf¶>ÿeµ>ÿd´=ÿc²<ÿb±;ÿa±:ÿa°9ÿ_®8ÿ^­7ÿ^¬6ÿ\«6ÿ[ª4ÿ[©4ÿZ¨3ÿY§2ÿW¦1ÿV¤0ÿV¤/ÿU£/ÿS¡-ÿS ,ÿI�'ÿÿÿÿÿQÿÿÿÿ6eÿ7fÿ7hÿ9kÿ;lÿÿe´=ÿc³<ÿb²;ÿb±;ÿ`°:ÿ`®8ÿ^­8ÿ^­7ÿ]¬6ÿ[ª5ÿ[©4ÿZ¨3ÿY§2ÿX¦1ÿW¥0ÿV¤/ÿU£/ÿS¢-ÿS -ÿM—)ÿ ÿÿÿÿ‘Qÿÿÿ ÿ6fÿ7fÿ7hÿ9kÿ;mÿÿeµ>ÿd´<ÿc²;ÿb±;ÿa°:ÿ`¯9ÿ_®8ÿ^­7ÿ]¬6ÿ\«5ÿ[©4ÿZ©4ÿY¨3ÿX¦2ÿW¥1ÿV¤0ÿU¤/ÿT¢.ÿS¡-ÿOš*ÿ5ÿÿÿÿÈ„ÿÿÿ- ÿ6fÿ7fÿ9jÿ:kÿ;nÿ=o ÿ>r ÿ?t"ÿ~£jÿ©Â›ÿHRBÿÿÿ ÿ—¯‹ÿ­ÈŸÿ¬È�ÿf›JÿPŽ/ÿ’¹}ÿœµŽÿ$ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÚÚÚÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿN^Gÿ¾ã¬ÿ¾ã¬ÿˆËhÿn¿Fÿm¾Eÿk¼Dÿk¼Cÿj»BÿiºAÿh¸@ÿg·?ÿf·?ÿeµ>ÿd´=ÿc³<ÿb²;ÿa±:ÿ`¯9ÿ_®8ÿ^®8ÿ]­6ÿ\«6ÿ[ª5ÿZ©3ÿY¨3ÿX§2ÿW¦1ÿW¥0ÿV¤/ÿT¢.ÿS¡-ÿQ�,ÿ'Mÿÿÿÿí¯ÿÿÿ!?ÿ7fÿ7fÿ9jÿ:kÿ;nÿ=o ÿ?r ÿ@t"ÿL0ÿ©ÂœÿªÃ�ÿÿÿ[hSÿ«Åœÿ­ÈŸÿ�³xÿNŒ-ÿl¡PÿªÈšÿ_oWÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¢¢¢ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ;;;ÿÿÿÿÿÿÿÿÿÿÿÿÿÿ***ÿGGGÿ444ÿ&&&ÿÿÿÿÿÿÿÿÿÿÿÿ ÿ¬Í›ÿ¾ã¬ÿ³ÞžÿtÂNÿn¿Fÿm¾Fÿl½Dÿk¼Cÿj»CÿjºBÿh¹@ÿg¸?ÿf·?ÿf¶?ÿd´=ÿc³<ÿc²<ÿb±;ÿ`°9ÿ_¯9ÿ_®8ÿ^­7ÿ\«5ÿ[ª5ÿZ©4ÿZ¨3ÿX§2ÿW¦1ÿW¥0ÿV¤0ÿT¢.ÿT¡.ÿS -ÿ2aÿÿÿÿùÜÿÿÿ*Pÿ7fÿ7gÿ9jÿ:kÿ;nÿ=p ÿ?s!ÿ@t"ÿDw%ÿ{¡fÿªÃ�ÿŽ¢‚ÿ(.%ÿ•«ˆÿ­Çžÿ¬Ç�ÿQŒ1ÿR�1ÿ—¼ƒÿœ´Žÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿjjjÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿqqqÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ«««ÿÿÿÿÿ÷÷÷ÿÝÝÝÿÁÁÁÿ§§§ÿ‹‹‹ÿ```ÿ ÿÿÿÿÿÿÿk€aÿ¿ä­ÿ¾ã¬ÿ�ÎpÿoÀGÿnÀGÿn¿Fÿl½Dÿk½Dÿk»Cÿj»Bÿh¹Aÿh¸@ÿg·?ÿf¶?ÿd´=ÿd³<ÿc²<ÿb²;ÿa°9ÿ_¯9ÿ_®8ÿ^­7ÿ^«6ÿ[ª5ÿ[©4ÿZ¨3ÿY§2ÿX¦1ÿW¥1ÿV¤0ÿU£.ÿT¢-ÿS¡-ÿ8oÿÿÿÿüîÿÿÿ0Zÿ7fÿ7hÿ:jÿ;lÿ;nÿ=p ÿ?s!ÿ@u"ÿBw#ÿQ„5ÿœ¹Œÿ«Ä�ÿŸ¶’ÿ¬Çžÿ­ÈŸÿr¡YÿNŠ-ÿq¢Vÿ¯Ë ÿÿd´=ÿc³<ÿb²;ÿa°:ÿ`¯9ÿ_¯8ÿ^­7ÿ]¬6ÿ\«5ÿ[ª4ÿZ©4ÿY¨2ÿX¦2ÿW¥1ÿW¤0ÿU£/ÿT¢.ÿS¡-ÿ=x!ÿÿÿÿþòÿÿÿ2]ÿ7fÿ7hÿ:jÿ;mÿ;nÿ=p ÿ?s"ÿ@u"ÿBx$ÿCz$ÿa�GÿªÄ›ÿ¬Æžÿ¬Çžÿ•¸ƒÿPŠ0ÿQ�1ÿ Á�ÿ~’sÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ!!!ÿòòòÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÞÞÞÿ ÿÿÿÿÿÿÿÿÿÿÿÿ¯¯¯ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿøøøÿ,,,ÿÿÿÿÿÿÿ›¸ŒÿÀå­ÿ¾ä¬ÿ�ÏlÿqÃIÿpÁHÿoÁGÿnÀFÿm¾Eÿl½Dÿl¼Dÿj»CÿiºAÿi¹Aÿg¸@ÿf¶?ÿeµ>ÿd´=ÿc³<ÿb²;ÿa±:ÿ`°9ÿ_¯8ÿ^®8ÿ]¬6ÿ\«6ÿ\ª5ÿZ©4ÿY¨2ÿX§2ÿX¦1ÿV¥0ÿU£/ÿT¢.ÿS¡-ÿB€#ÿÿÿÿþ9õÿÿ ÿ2_ÿ7fÿ7hÿ:kÿ;mÿÿdµ=ÿc³<ÿc³<ÿa±:ÿ`°9ÿ`¯9ÿ_®8ÿ]¬6ÿ\«6ÿ[ª5ÿ[©4ÿY¨3ÿY§2ÿW¦1ÿW¥0ÿU£/ÿT¢.ÿT¡-ÿE†%ÿ ÿÿÿþQ+öÿÿÿ3aÿ7fÿ7iÿ:kÿ;mÿr ÿ?t"ÿAv"ÿCx$ÿD{$ÿF}&ÿG(ÿI‚(ÿI„)ÿK‡,ÿM‰-ÿ¬É�ÿvˆlÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ¦¦¦ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿøøøÿÚÚÚÿ¼¼¼ÿžžžÿyyyÿÐÐÐÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿåååÿ ÿÿÿÿÿÿÿž¼�ÿÀå®ÿÀå®ÿ}ÉWÿsÅJÿqÃIÿqÂIÿpÁHÿoÀGÿm¿Fÿm¾Eÿl¼Dÿk¼CÿjºBÿi¹Aÿh¸@ÿg·@ÿfµ>ÿeµ=ÿd´=ÿc³<ÿa±:ÿ`°:ÿ`¯9ÿ_®8ÿ]­6ÿ\«6ÿ\«5ÿ[©4ÿY§3ÿX§2ÿX¦1ÿW¥0ÿU£/ÿU¢.ÿT¡.ÿGŒ'ÿÿÿÿþd9ùÿÿ ÿ3`ÿ7fÿ8iÿ:kÿ;nÿ=o ÿ?r ÿ?t"ÿAw"ÿCy$ÿD{%ÿF~&ÿG(ÿH‚)ÿJ„)ÿL‡,ÿMŠ-ÿªÇšÿ¯Ë ÿ”uÿ8B4ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ���ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿTTTÿÿÿÿÿÿÿYjPÿÁæ¯ÿÁæ®ÿšÖ}ÿtÆLÿsÅKÿqÃJÿqÂHÿpÁHÿoÀGÿn¿Fÿm¾Eÿl½Dÿk¼CÿjºBÿi¹Aÿh¸Aÿg·@ÿe¶>ÿeµ=ÿd´=ÿc³<ÿb²;ÿ`°:ÿ`¯9ÿ_®8ÿ^­7ÿ]¬6ÿ\«5ÿ[ª4ÿY¨3ÿY§2ÿX¦1ÿW¥1ÿU£/ÿU¢.ÿT¡.ÿJ�(ÿ ÿÿÿÿrEùÿÿ ÿ4aÿ7gÿ9jÿ:kÿ;nÿ=o ÿ?r ÿ@t"ÿAw"ÿCy$ÿD{&ÿF~&ÿG(ÿIƒ)ÿK…*ÿMˆ,ÿNŠ-ÿw¦^ÿ°Ì¡ÿ°Í¢ÿ¨Äšÿ|�qÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ```ÿüüüÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¼¼¼ÿÿÿÿÿÿÿÿ¶Ù¥ÿÁç¯ÿ´ážÿyÈSÿtÆLÿsÅKÿrÄJÿqÂIÿqÂHÿoÀGÿn¿Fÿm¾Eÿl½Eÿk¼CÿjºBÿi¹Bÿh¹@ÿg·@ÿf¶?ÿeµ>ÿd´=ÿc³<ÿb²;ÿa±:ÿ`¯9ÿ_¯8ÿ^­7ÿ]¬6ÿ\«5ÿ[ª4ÿZ¨3ÿY§2ÿX¦2ÿW¥1ÿV¤/ÿT£.ÿT¢.ÿL“)ÿ# ÿÿÿÿzJúÿÿ ÿ4bÿ7gÿ9jÿ:kÿ;nÿ=o ÿ?s ÿ@u"ÿAw$ÿCz$ÿD{&ÿF~'ÿG�(ÿI‚)ÿK…*ÿLˆ,ÿOŠ-ÿPŒ/ÿcšEÿŸÁ‹ÿ±Ï¢ÿ²Ð£ÿ°Î¡ÿWfOÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ@@@ÿëëëÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýýýÿöööÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ÷÷÷ÿRRRÿÿÿÿÿÿÿ|•qÿÂç°ÿÂç¯ÿ�ÒoÿuÇMÿtÇLÿtÆKÿrÄJÿqÃIÿpÂIÿpÁHÿn¿Fÿm¿Eÿl½Dÿl¼FÿŽËpÿ¢ÔŠÿ¢ÓŠÿ�Éoÿg¶Aÿeµ>ÿd´=ÿc³<ÿb²;ÿa±:ÿ`°9ÿ_¯8ÿ^­7ÿ]¬6ÿ\«5ÿ[ª5ÿZ¨3ÿY¨3ÿX¦1ÿW¥1ÿU¤/ÿU£/ÿT¢.ÿL”)ÿ# ÿÿÿÿvHùÿÿ ÿ4aÿ7hÿ9jÿ;mÿ;nÿ=p ÿ?s ÿ@u"ÿAw#ÿCz$ÿD|&ÿF~'ÿG�(ÿI„)ÿK…*ÿLˆ,ÿN‹-ÿP�/ÿQ�0ÿS’1ÿ}­bÿ¬Ì›ÿ³Ð£ÿ´Ò¤ÿ�¸�ÿ6@2ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÛÛÛÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿåååÿiiiÿ‚‚‚ÿŸŸŸÿ¹¹¹ÿÔÔÔÿíííÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¸¸¸ÿÿÿÿÿÿÿ/8*ÿÃè°ÿÂç°ÿ®à–ÿzÊSÿvÈMÿuÇLÿtÆLÿrÄJÿqÃIÿqÂIÿpÁHÿnÀFÿm¾FÿxÃSÿ´ÝŸÿªËšÿ�¼Žÿ¦Ç—ÿ¹Ý§ÿ±Ú�ÿt½Qÿd´=ÿd³<ÿb²;ÿa±:ÿ`°9ÿ_¯8ÿ^­7ÿ]¬6ÿ\«5ÿ[ª5ÿZ¨3ÿY§2ÿX¦1ÿW¥1ÿV¤/ÿU£/ÿT¢.ÿK‘(ÿ! ÿÿÿÿk>øÿÿ ÿ3aÿ7hÿ9jÿ;lÿ;nÿ=p ÿ?s ÿ@u"ÿBx$ÿCz$ÿD|&ÿF'ÿG€(ÿI„)ÿK†+ÿMˆ,ÿN‹-ÿP�/ÿQ�0ÿT’1ÿU•2ÿ]›=ÿ�ºuÿ­Î›ÿµÔ¥ÿµÕ¦ÿ|’rÿ19,ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÊÊÊÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿâââÿ ÿÿÿÿ!!!ÿ111ÿAAAÿ¸¸¸ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþþþÿ888ÿÿÿÿÿÿÿ¢À’ÿÃè°ÿÀæ®ÿŽÒkÿvÉNÿuÈMÿtÇLÿtÆLÿrÄJÿrÃIÿqÂHÿpÁHÿnÀFÿm¾Eÿ®Ú˜ÿ”±†ÿ+3'ÿ ÿ#ÿ|”pÿºÞ¨ÿ´Û¡ÿnºJÿd³<ÿb²;ÿa±:ÿa°:ÿ`¯8ÿ^­7ÿ]¬6ÿ\«5ÿ\ª5ÿZ©3ÿY§3ÿX¦2ÿW¦1ÿV¤0ÿU£/ÿT¢.ÿIŽ'ÿÿÿÿÿY1õÿÿ ÿ3`ÿ7hÿ9jÿ;mÿr ÿ?t!ÿ@v"ÿBx$ÿD{$ÿE|&ÿF(ÿH‚)ÿI„)ÿK†+ÿMŠ,ÿNŒ-ÿP�/ÿQ�0ÿT“2ÿU–3ÿX˜4ÿX›5ÿ[ž7ÿ] 9ÿ_£:ÿ_¥<ÿ�¿rÿ³Õ ÿºÚ©ÿºÛ©ÿuŠjÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿCCCÿýýýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿƒƒƒÿÿÿÿÿ///ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿiiiÿÿÿÿÿÿÿbtXÿÃé±ÿÃè°ÿ˜ØyÿyÌPÿÎZÿ³á�ÿ´Ö¢ÿ›¸Œÿœº�ÿ¶Ù¤ÿ¾ã­ÿ¤Ø‹ÿpÁHÿoÀGÿŽÍpÿ½á«ÿQaJÿÿÿÿÿÿCP<ÿ¹Ý§ÿ¯Ø›ÿrºOÿa±:ÿ`°9ÿ`¯8ÿ^­7ÿ]¬6ÿ\«5ÿ[ª5ÿZ¨3ÿY¨3ÿX¦2ÿW¦1ÿV¤0ÿU£/ÿT¢.ÿ@|"ÿ ÿÿÿþìÿÿÿ.Uÿ8iÿ9kÿ;nÿr ÿ?t!ÿAv"ÿBx$ÿD{%ÿE}&ÿG(ÿH‚)ÿI„)ÿK†+ÿM‰,ÿNŒ-ÿQ�/ÿQ�0ÿT“2ÿV–3ÿX˜4ÿY›6ÿ[ž7ÿ] 9ÿ_£:ÿ`¥<ÿb©=ÿo±NÿœÉƒÿ·Ù¦ÿ»Üªÿš¶Œÿ08+ÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿýýýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¢¢¢ÿÿÿÿÿ¶¶¶ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÈÈÈÿÿÿÿÿÿÿ"ÿºÞ©ÿÃé±ÿµãžÿ|ÌSÿzËQÿžØ�ÿµØ¤ÿ4>/ÿÿÿ?L9ÿ¸Ú¦ÿ¾ã¬ÿ—ÒzÿnÀGÿxÃSÿ½á«ÿ°ÒŸÿ&.#ÿÿÿÿÿÿˆ¢{ÿ¹Ü§ÿ‘Évÿb±;ÿ`°9ÿ`¯9ÿ^­7ÿ]¬6ÿ\«6ÿ[ª5ÿZ¨3ÿY§3ÿX§2ÿW¦1ÿV¤0ÿU£/ÿT¢.ÿ;t ÿÿÿÿúæÿÿÿ%Fÿ8iÿ9kÿ;nÿ=o ÿ>q ÿ?t!ÿ@v"ÿBx#ÿD{%ÿE}&ÿG€(ÿI‚(ÿJ…)ÿK‡+ÿN‰,ÿOŒ-ÿP�/ÿR�0ÿT”2ÿV–3ÿW˜4ÿY›6ÿ[ž7ÿ]¡9ÿ^£:ÿ`¦<ÿb©>ÿc«?ÿf®@ÿƒ¾eÿ´Ù¡ÿ¼Þ«ÿ­Í�ÿ>I8ÿÿÿÿÿÿÿÿÿÿÿÿÿâââÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¿¿¿ÿÿÿÿJJJÿñññÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿùùùÿ___ÿÿÿÿÿÿÿ�¨€ÿÄé±ÿÁè®ÿ’ÖqÿyÍQÿ}ÍUÿ¸ã£ÿl�bÿÿÿÿÿ=I7ÿ¾ã¬ÿºá¦ÿ€È^ÿn¿FÿšÒÿ¼áªÿ¥Å–ÿÿÿÿÿÿ7B2ÿ³Õ¢ÿµÛ£ÿjµEÿ`°9ÿ_¯8ÿ^­7ÿ]¬6ÿ\«5ÿ[ª5ÿZ©3ÿY¨3ÿX¦2ÿW¦1ÿV¤0ÿU£/ÿT¢.ÿ6hÿÿÿÿõ¾ÿÿÿ5ÿ8iÿ:lÿ;nÿ

q ÿ?s!ÿAv#ÿBy#ÿD{%ÿE}&ÿG€'ÿH‚(ÿJ„)ÿL‡+ÿMŠ-ÿOŒ.ÿP�/ÿR�0ÿT”2ÿV–3ÿW˜4ÿY›5ÿ[ž7ÿ]¡9ÿ_¤:ÿ`¦;ÿb©>ÿd«?ÿe®Aÿg±Bÿt¹Qÿ²Ø�ÿ½ß¬ÿ¾à¬ÿBN<ÿÿÿÿÿÿÿÿÿÿÿÿ¨¨¨ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÞÞÞÿ ÿÿ ÿªªªÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÆÆÆÿÿÿÿÿÿÿ>J8ÿÄê²ÿÄé±ÿ¯á–ÿ{ÍSÿyÌQÿ~ÍVÿ¿æ¬ÿZlRÿÿÿÿÿÿs‰hÿ¾ã¬ÿ¥×ŒÿoÀHÿrÀKÿ©Ø’ÿ»à©ÿv�kÿÿÿÿÿ ÿŒ§ÿ¸Ü¦ÿ�Çsÿ`¯9ÿ_¯9ÿ^­7ÿ]¬6ÿ\«5ÿ[ª5ÿZ¨3ÿY§2ÿX§2ÿW¦1ÿV¤/ÿU£/ÿS -ÿ,Vÿÿÿÿؘÿÿÿ" ÿ8iÿ:lÿ;nÿ

q ÿ@t!ÿAv#ÿBy#ÿD|%ÿE}&ÿG€(ÿHƒ)ÿJ„*ÿL‡+ÿMŠ-ÿO�.ÿPŽ/ÿR‘0ÿU”2ÿU–3ÿW™4ÿY›6ÿ[Ÿ8ÿ]¡9ÿ^£:ÿ`¦;ÿc©>ÿe«?ÿe®@ÿg±Bÿj´Cÿk·EÿªÖ”ÿ¾à¬ÿ¿â­ÿO]Gÿÿÿÿÿÿÿÿÿÿÿlllÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüüüÿ---ÿÿ@@@ÿûûûÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýýýÿIIIÿÿÿÿÿÿ ÿ¬Î�ÿÄê±ÿÀè¬ÿŽÕkÿzÍRÿzÌQÿ}ÌVÿ¼å¨ÿ¡Á’ÿÿÿÿÿÿ(0$ÿ¯ÒŸÿ½â«ÿ|ÅYÿm½Eÿ}ÅZÿµÝ¢ÿ´×£ÿ%,!ÿÿÿÿÿZkQÿµÙ¤ÿµÚ£ÿ`¯9ÿ_¯9ÿ^­7ÿ]¬6ÿ\«5ÿ[ª5ÿZ¨3ÿY§2ÿX¦2ÿW¥1ÿV£0ÿU£/ÿRž,ÿ ?ÿÿÿÿ¬fÿÿÿ ÿ8iÿ:lÿ;mÿ

r ÿ@t!ÿAw#ÿBy#ÿD|%ÿE}&ÿG€'ÿH‚)ÿJ…*ÿL‡+ÿMŠ-ÿOŒ.ÿQŽ/ÿR‘0ÿT”2ÿV—3ÿW™5ÿY›5ÿ[ž8ÿ\¢8ÿ^¤:ÿ`¦;ÿc©>ÿd«?ÿe®@ÿg±Bÿi´Cÿk¶Eÿm¹Gÿ¡Ó‡ÿ¾â­ÿ¿ã­ÿUeMÿÿÿÿÿÿÿÿÿÿ---ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿIIIÿÿ¿¿¿ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¬¬¬ÿÿÿÿÿÿÿcvZÿÂè°ÿÄé±ÿ©à�ÿ{ÎSÿzÍQÿyÌQÿ{ËRÿ§ÝŽÿÁæ¯ÿs‰hÿÿÿÿÿÿ�šuÿ½â«ÿ¥×�ÿl¾Eÿl¼Dÿ˜Ð|ÿ¹Þ¨ÿqˆgÿÿÿÿÿ"ÿ®Ï�ÿ·Û¦ÿ~¿_ÿ_®8ÿ^­7ÿ]¬6ÿ\«5ÿ[ª4ÿZ¨3ÿY§2ÿX¦2ÿW¥1ÿV¤/ÿU£.ÿP›,ÿ% ÿÿÿÿf2ÿÿÿÿ6dÿ9lÿ;mÿ

q ÿ@t!ÿAw#ÿCy#ÿD|%ÿE~&ÿG€'ÿHƒ)ÿJ„*ÿL‡+ÿMŠ-ÿO�.ÿQ�/ÿR‘0ÿT”2ÿV–3ÿW™4ÿYœ6ÿ[ž8ÿ\¡8ÿ^£:ÿ`¦;ÿb©=ÿd«?ÿf­@ÿg±Bÿi´Cÿk¶Eÿm¹Fÿq½JÿžÓ„ÿ¿ã®ÿºÝ©ÿGT@ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿqqqÿOOOÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿûûûÿÿÿÿÿÿÿ%ÿ­ÍœÿÄê²ÿ¿è«ÿ€ÐXÿ{ÎRÿzÍQÿxËPÿxÊOÿ‰Ðeÿ¼ä¨ÿ¹Ü§ÿ7B2ÿÿÿÿÿL[Eÿ¸Ü¦ÿ½á«ÿp¿Iÿk¼Dÿr¾Mÿ·Ý¤ÿ�¼�ÿ ÿÿÿÿÿ™¶Šÿ·Û¦ÿžÎ‡ÿ_®8ÿ^­7ÿ\¬6ÿ\«5ÿ[ª4ÿZ¨3ÿY§2ÿX¦2ÿW¥1ÿV¤/ÿU£.ÿN–)ÿ ÿÿÿÿ2ÿÿÿÿ)Mÿ:lÿr!ÿ?t!ÿAw#ÿCy#ÿC{%ÿE~&ÿG€'ÿH‚)ÿJ…)ÿK‡*ÿMŠ-ÿO�.ÿP�.ÿR’0ÿT”2ÿV–3ÿW™4ÿY›6ÿ[Ÿ7ÿ\¡8ÿ^¤:ÿ`¦;ÿb¨=ÿd«?ÿf®@ÿg°Bÿi´Cÿk·Dÿm¹Fÿn»Hÿq¿Jÿ¤×‹ÿÀä®ÿÀä®ÿ)0%ÿÿÿÿÿÿÿÿÿÎÎÎÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿžžžÿçççÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿuuuÿÿÿÿÿÿÿl€aÿÄê²ÿÄé±ÿ“×rÿ|ÎSÿ{ÍRÿyÌQÿxËPÿxÊOÿwÉNÿ�×€ÿ¿ä¬ÿ—rÿÿÿÿÿ$ÿ²Õ¡ÿ½á«ÿ‡Égÿk¼CÿjºBÿŸÒ†ÿºß¨ÿ9D4ÿÿÿÿÿey[ÿ·Û¦ÿ¯×œÿ_®8ÿ]­6ÿ\«6ÿ\«5ÿ[ª4ÿY¨3ÿX§2ÿX¦1ÿW¥1ÿV£/ÿU¢.ÿE†%ÿÿÿÿÿÑÿÿÿ/ ÿ9kÿr!ÿ?t!ÿAw#ÿCy#ÿD{%ÿE~&ÿG€'ÿH‚)ÿJ…*ÿK‡*ÿMŠ-ÿO�-ÿP�/ÿR’0ÿT”1ÿV–3ÿW™4ÿX›5ÿ[Ÿ7ÿ] 9ÿ]¤:ÿ_¥;ÿb©=ÿd¬?ÿe®@ÿg±Aÿj´Dÿk¶Dÿl¹Fÿn»Hÿq¿IÿrÂKÿ©Û‘ÿÁå¯ÿ»ßªÿÿÿÿÿÿÿÿÿšššÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÖÖÖÿÿÿÿÿÿÿ!'ÿÄé±ÿÄê²ÿ²ã™ÿ~ÏVÿ{ÎRÿzÍRÿyÌPÿxËPÿwÊOÿvÉNÿyÈSÿºã§ÿ�»�ÿÿÿÿÿÿ­Ïœÿ½á«ÿ™Ñ}ÿk¼CÿiºBÿ}Â[ÿºÞ¨ÿasWÿÿÿÿÿ7B2ÿ·Û¥ÿ²Ø ÿo¶Lÿ]¬6ÿ\«6ÿ\ª5ÿ[©4ÿY¨3ÿX§2ÿX¦1ÿW¥0ÿU£/ÿU¢.ÿ/ZÿÿÿÿÜ‹ÿÿÿ ÿ9kÿÿÿÿÿ2^ÿ;nÿ=qÿ=r!ÿ?t!ÿAw#ÿBy#ÿD|$ÿE~&ÿG�'ÿH‚(ÿI„*ÿK‡+ÿMŠ,ÿNŒ.ÿP�/ÿR‘0ÿS•1ÿU–3ÿW™4ÿY›5ÿZž7ÿ\¡9ÿ^£9ÿ`¦;ÿb©<ÿc«>ÿe®@ÿg°Aÿi´CÿkµDÿl¸Eÿo¼Gÿq¿IÿrÁJÿsÃLÿÊZÿµá ÿÂè°ÿ|”pÿÿÿÿÿÿÿ444ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÓÓÓÿ ÿÿÿÿÿÿN]GÿÄê²ÿÄé±ÿ«á’ÿ}ÏTÿ{ÍRÿzÍQÿyÌQÿxËPÿwÉOÿvÉNÿuÈMÿtÇLÿ˜Ô{ÿ²Ô¡ÿ,5(ÿÿÿÿÿ›¹Œÿ¼áªÿ¥ÖŒÿj»CÿiºBÿh¹@ÿ§Õ�ÿ�¼Žÿ ÿÿÿÿÿ·Ú¥ÿ´Ù¢ÿ„Ágÿ]¬6ÿ\«5ÿ[ª4ÿZ©4ÿY§3ÿX¦2ÿW¥1ÿV¥0ÿU£/ÿQ�,ÿÿÿÿÿF÷ÿÿÿ"Aÿ;nÿ=pÿ>s ÿ?t!ÿAx"ÿBy#ÿD{$ÿF}&ÿG€'ÿHƒ(ÿJ„*ÿK‡*ÿMŠ,ÿNŒ.ÿP�.ÿQ‘/ÿS”1ÿU–3ÿW™4ÿX›5ÿZ�6ÿ\¡8ÿ^£:ÿ_¥;ÿb©=ÿc«>ÿd®@ÿf°Aÿh³Bÿj¶Dÿl¸Eÿn»Fÿp¾IÿrÁJÿsÃKÿuÆMÿ‰ÏeÿÀç­ÿÁæ®ÿFS?ÿÿÿÿÿÿ!!!ÿêêêÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýýýÿWWWÿÿÿÿÿÿ  ÿ²Ô¡ÿÄé±ÿÁè®ÿˆÓcÿ{ÎRÿzÍQÿyÌQÿyËPÿxÊOÿwÉNÿvÈNÿuÇMÿtÆKÿ™Ô|ÿ­Îœÿ'/#ÿÿÿÿÿ¢Â“ÿ¼àªÿ¥ÕŒÿj»Bÿh¹Aÿh¸@ÿ—Í{ÿ¬Í›ÿ  ÿÿÿÿÿ±ÓŸÿµÙ£ÿŠÃmÿ]«6ÿ\«5ÿ[ª4ÿZ©3ÿY§2ÿX¦2ÿW¥1ÿV¤0ÿU£.ÿ8lÿÿÿÿû ¹ÿÿÿ$ ÿ;oÿ=pÿ>s ÿ>t!ÿAx#ÿBy#ÿD{$ÿE~&ÿG€'ÿHƒ(ÿI…*ÿJ‡*ÿMŠ,ÿNŒ.ÿO�.ÿQ‘/ÿT”2ÿU–2ÿW™4ÿY›5ÿ[ž7ÿ\¡8ÿ]£:ÿ_¥:ÿa¨=ÿc«>ÿd­?ÿf°Aÿh³Bÿj¶Dÿl¸EÿmºFÿp¾HÿqÀJÿsÃKÿuÅLÿwÉOÿ›Ù|ÿÂè°ÿ±Ó ÿÿÿÿÿÿÿÈÈÈÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÂÂÂÿÿÿÿÿÿÿmƒcÿÂç¯ÿÃé±ÿ¥Þˆÿ{ÏSÿzÍQÿzÌQÿyÌPÿxËPÿwÉNÿvÈNÿvÈMÿuÇMÿsÅKÿ©Û�ÿœº�ÿÿÿÿÿÿ¬Îœÿ¼àªÿ¡ÔˆÿjºBÿh¹Aÿg¸@ÿŽÉqÿ³Ö¡ÿ  ÿÿÿÿÿªÌšÿµÙ£ÿ‹Äoÿ\«6ÿ\ª5ÿ[©4ÿZ©3ÿX§2ÿW¦1ÿW¥1ÿV¤0ÿU£.ÿ/ ÿÿÿÿÊ sÿÿÿ ÿ3`ÿ=pÿ>s ÿ>t!ÿAw"ÿBy$ÿC{$ÿE~%ÿF€&ÿIƒ(ÿI…*ÿK‡*ÿLŠ+ÿOŒ-ÿO�.ÿQ‘0ÿS“0ÿU—2ÿV™4ÿX›4ÿZž6ÿ\¡8ÿ]£9ÿ_¥:ÿa¨<ÿc«=ÿe­?ÿf°@ÿh³Bÿi¶Cÿk¸EÿmºFÿo½HÿqÀJÿsÃKÿtÅLÿvÈNÿyÊPÿªÞ�ÿÂè°ÿˆ¡zÿÿÿÿÿÿ•••ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüüüÿ&&&ÿÿÿÿÿÿ$,!ÿ°ÒŸÿÃé±ÿ¼æ§ÿ|ÎTÿ{ÍRÿzÌQÿyÌQÿxËPÿwÊOÿwÉNÿvÈMÿuÇLÿtÆLÿyÇSÿºâ§ÿ|”pÿÿÿÿÿ!(ÿ²Õ¡ÿ¼àªÿ•ÏyÿiºBÿh¸Aÿg·@ÿ�Épÿ®Ð�ÿ  ÿÿÿÿÿ¬ÎœÿµÙ£ÿŠÄoÿ\«6ÿ[ª5ÿZ©4ÿZ¨3ÿX§2ÿX¦1ÿW¥0ÿU¤/ÿM•*ÿ ÿÿÿÿ>õÿÿÿ(Kÿr ÿ?t!ÿ@w"ÿBz$ÿC{$ÿE~%ÿG€&ÿH‚(ÿJ…)ÿK‡*ÿM‰+ÿNŒ-ÿPŽ.ÿR’0ÿS“1ÿU–2ÿV™3ÿX›4ÿZž6ÿ\ 7ÿ]¢9ÿ^¥:ÿ`¨<ÿbª=ÿd­>ÿe¯@ÿh³BÿhµCÿj·DÿmºFÿn¼Gÿp¿IÿrÂKÿtÅLÿvÈNÿwÊNÿ~ÍXÿ·ã¢ÿÂè°ÿQ`Iÿÿÿÿÿÿ***ÿHHHÿvvvÿ¥¥¥ÿÒÒÒÿÿÿÿÿÿÿÿÿÿÿÿÿ‚‚‚ÿÿÿÿÿÿÿv�kÿÃé±ÿÃé±ÿ�ÕmÿzÍRÿzÌQÿyËQÿxÊOÿxÉOÿwÉNÿvÈNÿuÇLÿtÆLÿtÅKÿž×‚ÿºÞ¨ÿ1;-ÿÿÿÿÿRaJÿ¸Û¦ÿ»àªÿ‚Åaÿi¹Aÿh¸@ÿf·?ÿ•Ìyÿ ¿�ÿ ÿÿÿÿÿ¶Ú¥ÿµÙ£ÿŠÃmÿ]«5ÿ[ª4ÿZ©4ÿZ¨3ÿX¦2ÿW¥1ÿV¤0ÿU¤/ÿ3bÿÿÿÿû>Íÿÿÿ* ÿ:lÿ>rÿ?u!ÿ@w"ÿBy#ÿC{$ÿE}%ÿG�&ÿH‚(ÿI…)ÿJ‡*ÿMŠ+ÿNŒ-ÿOŽ.ÿQ‘/ÿS“0ÿU•2ÿV˜3ÿX›5ÿZž6ÿ[ 8ÿ]£8ÿ_¥:ÿ`¨;ÿbª=ÿc­>ÿe¯?ÿg²BÿhµBÿj·Dÿl¹Fÿn½Gÿp¿HÿqÁJÿsÅLÿuÈMÿwÉNÿwÊOÿ�ÔpÿÁç®ÿ³Õ¢ÿÿÿÿÿÿÿÿÿÿÿÿ!!!ÿSSSÿÿÿÿÿÿÿ'.#ÿÃé±ÿÃè±ÿ«ß‘ÿ|ÍUÿzÍQÿyÌPÿxËOÿxÊOÿwÉNÿvÉNÿvÇMÿtÆLÿtÆKÿ’Ñsÿ¾ã«ÿtŠiÿÿÿÿÿÿ…Ÿyÿ¼àªÿ»à©ÿiºBÿi¹Aÿg·?ÿf¶?ÿ¤Ô�ÿ‰£{ÿ ÿÿÿÿ ÿ¶Ú¥ÿµÙ£ÿ†Áiÿ[ª5ÿ[©4ÿZ¨3ÿY§3ÿX¦1ÿW¥1ÿV¤/ÿT¡.ÿ2ÿÿÿÿØ‘ÿÿÿÿ7fÿ=sÿ?u!ÿAw"ÿBy#ÿC|$ÿD}%ÿG€'ÿH‚(ÿI…)ÿK‡*ÿLŠ,ÿNŒ,ÿOŽ.ÿQ‘/ÿR“0ÿT•2ÿV˜3ÿX›4ÿYž6ÿ[ 7ÿ]¢8ÿ^¥:ÿ`¨;ÿaª<ÿc¬>ÿe¯?ÿg±AÿiµBÿk·Cÿl¹Eÿm¼Fÿo¿HÿqÁIÿsÃKÿuÇLÿvÉNÿwÉNÿxÊQÿ«Þ’ÿÁç¯ÿq‡fÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿž¼�ÿÃè±ÿ¿ç«ÿ‹ÓgÿyÌQÿyÌPÿyÊPÿyÊQÿxÉPÿwÈOÿwÈNÿvÇNÿxÇQÿ›Õÿ¿ä­ÿ† yÿÿÿÿÿÿ,5(ÿ­Ð�ÿ»àªÿœÑ‚ÿi¹Aÿh¹Aÿg·?ÿf¶>ÿµÛ¢ÿg{]ÿÿÿÿÿ+4'ÿ¶Ú¤ÿ³Ø¡ÿ}¼^ÿ[ª5ÿZ©4ÿY¨3ÿY§2ÿW¦1ÿW¥0ÿV¤/ÿE„&ÿ ÿÿÿÿ‘:øÿÿÿ!@ÿ=rÿ?u!ÿ@w"ÿAy#ÿC|$ÿD~%ÿF€&ÿH‚'ÿI„)ÿK‡*ÿL‰*ÿNŒ,ÿP�-ÿP�.ÿR“0ÿT–1ÿU—3ÿWš4ÿZ�6ÿZŸ7ÿ\¢7ÿ^¥9ÿ`§;ÿaª<ÿc¬>ÿe¯?ÿf²@ÿh´Bÿj·Cÿl¹Dÿm»Eÿp¾HÿqÁIÿrÃKÿtÆKÿvÇMÿvÉNÿwÉNÿ†Ðcÿ¾æ«ÿ°Ò ÿ&-"ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿWgNÿÃè°ÿÃè°ÿ§ÝŒÿyÌQÿyÌPÿ‡Ðcÿš×}ÿ¤Û‰ÿ¢Ú‡ÿ›×}ÿ™Õ{ÿ¥Ú‹ÿ»ã¨ÿ­Îœÿ_qUÿÿÿÿÿÿÿ|•pÿ¼àªÿ»à©ÿwÀTÿh¹Aÿh¸@ÿg·?ÿu½Rÿ¹Ý§ÿAM:ÿÿÿÿÿTeMÿ¶Ú¤ÿ±×žÿoµNÿ[ª4ÿZ©4ÿY¨3ÿX§2ÿW¥1ÿV¥0ÿU¢.ÿ/[ÿÿÿÿú:Îÿÿÿÿ=sÿ?t ÿ@w"ÿAx#ÿC{#ÿD}%ÿF€&ÿHƒ'ÿI„)ÿJ‡*ÿL‰+ÿMŒ,ÿO�-ÿP�.ÿR“0ÿT•1ÿU˜3ÿWš3ÿY�6ÿZŸ6ÿ[¢7ÿ]¤9ÿ_§;ÿa©;ÿb¬>ÿd®>ÿf±@ÿg´BÿjµBÿk¹Dÿm¼Fÿn½GÿpÀHÿrÃJÿsÅKÿuÇLÿuÇMÿvÈNÿwÉNÿ¡Ù„ÿÁç¯ÿi}_ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ±Ó ÿÂè°ÿ¾æªÿ‚Î]ÿxËPÿ™×{ÿºã¦ÿ®Ðžÿ�«‚ÿ§È˜ÿµØ¤ÿ¬ÍœÿŒ§~ÿSdKÿ#ÿÿÿÿÿÿÿKZDÿ¼àªÿ¼àªÿ�Òƒÿi¹Cÿh¸@ÿg·?ÿf¶?ÿ™Îÿ£Ã“ÿ!'ÿÿÿÿÿ‹§~ÿµÙ¤ÿ°Ö�ÿ`­:ÿ[©4ÿY¨3ÿY§2ÿX¦2ÿW¥1ÿV¤/ÿQ›,ÿÿÿÿÿÎYÿÿÿÿ$Bÿ>t ÿ@w!ÿBy#ÿB{#ÿD~%ÿE�&ÿG‚'ÿI„(ÿJ†)ÿL‰*ÿMŒ,ÿO�-ÿP�-ÿR“0ÿT”1ÿU—2ÿV™3ÿYœ5ÿZŸ6ÿ\¡7ÿ\£8ÿ_¦:ÿa¨;ÿb«<ÿc¯>ÿf°@ÿg³AÿhµBÿk¸Cÿl»Eÿn¼Fÿp¿HÿqÃIÿsÅKÿtÆLÿuÆLÿuÈLÿvÈMÿwÉOÿÀæ­ÿ¼àªÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿu�jÿÁç¯ÿÂç°ÿ›Ø}ÿxÊPÿ…Ïaÿ»ã§ÿ‚œvÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿ1;,ÿ¼àªÿ¼àªÿ´Ý ÿ{ÁXÿh¸@ÿg¸@ÿg·?ÿk·Eÿ²ÙŸÿ{”oÿÿÿÿÿ ÿ©Ê˜ÿµÙ¤ÿ¢ÏŒÿ[ª5ÿZ©4ÿY¨3ÿY§2ÿX¦1ÿW¥0ÿU£/ÿ9oÿÿÿÿÿYëÿÿÿ ÿ=r ÿ@w!ÿAy#ÿBz$ÿD}$ÿE€&ÿG‚&ÿI…(ÿJ‡)ÿL‰*ÿM‹,ÿOŽ-ÿP�.ÿR’/ÿS•1ÿU˜2ÿV™3ÿXœ5ÿZŸ5ÿ\¡7ÿ\¤8ÿ_¦:ÿ`©;ÿb«<ÿc­>ÿe°>ÿg³AÿhµBÿj·CÿlºEÿm½Fÿo¿GÿqÁIÿsÄJÿsÅKÿtÆKÿtÆLÿuÇMÿvÈMÿ–ÕxÿÁæ¯ÿfz\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿ,5(ÿ²Ó¡ÿÂç°ÿ¸ã£ÿwÊOÿwÉNÿ Ù…ÿ¶Ù¥ÿ"(ÿÿÿÿÿÿÿÿÿÿÿÿÿ8C2ÿ¶Ù¥ÿ¼àªÿºß¨ÿ‘Ëtÿh¹@ÿg¸@ÿg·?ÿf¶?ÿ‘Êuÿ°ÔŸÿ7B2ÿÿÿÿÿGU@ÿ°ÓŸÿµÙ¤ÿ‚¾eÿ[ª4ÿZ¨3ÿY§3ÿX¦2ÿX¦1ÿV¤0ÿU£/ÿ ÿÿÿÿì^ÿÿÿÿ"Bÿ@v ÿAy"ÿBz#ÿD}$ÿE&ÿG�&ÿI„(ÿJ†(ÿK‰*ÿM‹,ÿNŽ-ÿP�.ÿR’/ÿS•0ÿU˜2ÿV™3ÿWœ4ÿZž5ÿ[¡7ÿ]£8ÿ^¦9ÿ`¨;ÿb«<ÿc­=ÿd°>ÿf±@ÿgµBÿi·Bÿk¹Dÿm¼Fÿn¿GÿpÁHÿrÃJÿsÄJÿsÅKÿsÅLÿtÆLÿuÇMÿyÉQÿºã¥ÿ½â¬ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿ˜sÿÂç¯ÿÁç¯ÿ‰ÐgÿwÉNÿvÉNÿ¨ÜŽÿ³Õ¢ÿ  ÿÿÿÿÿÿÿÿÿÿÿ ÿ\nSÿ´Ø£ÿ¼àªÿ»àªÿœÑ�ÿi¹Aÿh¸@ÿg¸@ÿf¶?ÿt¼Qÿ¯Øœÿ�›uÿÿÿÿÿÿ˜rÿµÙ¤ÿµÙ¤ÿa­<ÿ[©4ÿY¨3ÿY§2ÿX¦1ÿW¥0ÿV£/ÿ4dÿÿÿÿÿ_Ûÿÿÿ ÿ8kÿAx"ÿBz#ÿC}$ÿE€&ÿF�&ÿH„'ÿJ†)ÿK‰*ÿM‹+ÿN�,ÿO�.ÿR’.ÿR”0ÿT—1ÿV™2ÿW›4ÿYž5ÿZ 6ÿ\£8ÿ^¥9ÿ_§:ÿ`ª;ÿc¬=ÿd¯>ÿf²?ÿg´Aÿh·Bÿj¹Cÿl»Eÿm¾EÿoÀGÿqÃIÿqÃIÿrÄJÿsÅKÿsÅKÿtÅKÿtÆLÿ™Õ|ÿÀå®ÿcwZÿÿÿÿÿÿÿÿÿÿÿÿ4>/ÿÁæ¯ÿÁæ¯ÿ¥Ú‹ÿwÈNÿuÇMÿuÇLÿ¦ÛŒÿºÞ©ÿCO<ÿÿÿÿÿÿÿÿÿÿM\Eÿ—µŠÿ»à©ÿ¼àªÿ»àªÿ’Íuÿj¹Cÿh¹Aÿg·@ÿf¶?ÿgµ?ÿ£Ò‹ÿ®Ð�ÿÿÿÿÿÿ(0$ÿªÌ™ÿµÙ¤ÿ•È|ÿ[ª4ÿZ©3ÿY¨2ÿX¦2ÿW¦1ÿV¥0ÿP™,ÿ ÿÿÿÿßjÿÿÿÿ%Eÿ@x!ÿBz"ÿB|$ÿE%ÿF�&ÿGƒ'ÿI†(ÿKˆ)ÿLŠ+ÿN�,ÿO�-ÿQ‘.ÿQ”0ÿS–1ÿU™2ÿX›3ÿX�5ÿZ 6ÿ[£7ÿ]¥8ÿ^§:ÿ`ª:ÿa¬<ÿd®>ÿe±?ÿg´@ÿg¶Bÿj¹CÿkºDÿm½FÿnÀFÿpÂHÿqÂIÿqÂIÿrÄJÿrÄJÿsÅKÿsÅKÿ‚Ë_ÿ¹â¥ÿ®Ðžÿÿÿÿÿÿÿÿÿÿÿÿ«ÌšÿÁæ®ÿºã¥ÿ…ÍaÿuÇLÿtÇLÿtÆLÿ“Òtÿ¾ä«ÿ­Ï�ÿO^Gÿÿÿÿÿ ÿ2<-ÿq‡fÿ­ÏœÿºÞ¨ÿ¼áªÿ¼àªÿ¸Þ¥ÿ„Ædÿj¹Bÿh¹Aÿh¸@ÿf¶?ÿf¶>ÿœÏƒÿ¹Ý§ÿ+3'ÿÿÿÿÿÿn„cÿ¶Ú¤ÿµÙ£ÿi±Gÿ[©4ÿZ©3ÿX§2ÿX¦1ÿW¥1ÿV¤0ÿ*Pÿÿÿÿÿj×ÿÿÿ ÿ:nÿB{#ÿB|#ÿD%ÿF�&ÿGƒ'ÿH…(ÿKˆ)ÿLŠ+ÿMŒ+ÿN�,ÿQ‘.ÿR”/ÿT–1ÿU˜1ÿV›3ÿYž4ÿYŸ6ÿ[¢7ÿ]¤7ÿ_§9ÿ`ª;ÿa«;ÿc®=ÿe±>ÿf³?ÿhµAÿi·Bÿl»Dÿl½Eÿn¿FÿoÁHÿpÁHÿqÂIÿqÃIÿrÃJÿrÄJÿsÄJÿsÅKÿ¨Û�ÿºÞ¨ÿFS?ÿÿÿÿÿÿÿÿÿÿ`rWÿÀå®ÿÀå­ÿ¡Ø…ÿtÇLÿtÆLÿtÆKÿsÆKÿsÄKÿ²ß�ÿ¿ä­ÿ¿ã­ÿ¾ã¬ÿ¾ã¬ÿºÞ¨ÿ¾â¬ÿ¾â¬ÿ½â«ÿ½â«ÿ½á«ÿ¼à©ÿ¹ß¥ÿ¡Ôˆÿq¾LÿiºAÿh¹Aÿh¸@ÿg·?ÿf¶>ÿ�Ï„ÿ·Û¥ÿN^Gÿÿÿÿÿÿ&."ÿ¶Ú¤ÿµÙ¤ÿ–È}ÿ]«7ÿZ©4ÿY¨3ÿX¦1ÿW¥1ÿV¥0ÿH‰'ÿ ÿÿÿÿØuþÿÿÿ0 ÿBz"ÿB|#ÿD~$ÿF�%ÿGƒ'ÿH…(ÿJ‡(ÿLŠ+ÿMŒ+ÿO�,ÿP‘.ÿR“/ÿS–0ÿT˜1ÿVš3ÿX�4ÿZŸ6ÿZ¡6ÿ\¤8ÿ^§9ÿ`©:ÿa«;ÿb®=ÿd¯?ÿf³?ÿgµAÿi·AÿkºCÿl½Eÿm¾EÿnÀGÿoÁGÿpÁHÿpÂHÿqÂIÿrÃIÿrÃJÿrÄJÿ�Ðoÿ½ã«ÿ€˜tÿÿÿÿÿÿÿÿÿ$ÿµØ¤ÿÀå®ÿ¸â¤ÿ|ÈUÿtÅKÿsÅKÿsÅKÿrÄJÿrÄJÿzÆTÿ¤ØŠÿ¶à¡ÿ½ã«ÿ¾ã¬ÿ¾ã¬ÿ¾â¬ÿ½â«ÿ¼áªÿµÞ¡ÿ«Ù”ÿ™Ñ~ÿyÁUÿj»BÿiºAÿh¹Aÿh¸@ÿg·@ÿr»Nÿ Òˆÿ¶Ú¥ÿVhNÿÿÿÿÿÿ ÿš¹‹ÿµÙ¤ÿ°Ö�ÿpµNÿZ©4ÿZ¨3ÿY§2ÿW¦1ÿW¥1ÿU¢/ÿ%Fÿÿÿÿþu×ÿÿÿÿ-UÿB}#ÿC~%ÿE€%ÿG‚&ÿH…'ÿJˆ(ÿLŠ*ÿLŒ*ÿN�,ÿO‘-ÿQ’/ÿS–/ÿT˜0ÿVš2ÿWœ3ÿXž4ÿZ¡5ÿ\¤7ÿ]¥8ÿ^¨9ÿa«;ÿb­<ÿc¯>ÿd±>ÿg´@ÿh·Aÿj¸Bÿk»Cÿm¾Eÿn¿Eÿn¿FÿnÀGÿoÁGÿoÁHÿpÂHÿqÂHÿqÂIÿsÄLÿ¹á¥ÿ¡À’ÿ!ÿÿÿÿÿÿÿÿz‘nÿ¿å­ÿ¿ä­ÿ‘ÐqÿrÄJÿrÄKÿrÃIÿrÃIÿqÃIÿqÃIÿpÂHÿpÁHÿ~ÇZÿ†Êdÿ‹Ìkÿ‹ÌkÿŠÌkÿ‰ÊhÿƒÈbÿ}ÃZÿq¾Kÿj»CÿjºBÿi¹Aÿh¹Aÿh¸@ÿg¸?ÿŠÇlÿ®Ø™ÿ°ÒŸÿ:F5ÿÿÿÿÿÿÿ~—qÿµÙ¤ÿµÙ¤ÿ’Æyÿ[©4ÿZ¨3ÿY§2ÿX§2ÿW¥1ÿV¤0ÿA|#ÿÿÿÿÿ×4ÿÿÿÿ ÿÿe²>ÿg¶@ÿh¸AÿjºBÿk¼Dÿl½Dÿm¾Eÿm¾Eÿm¿Fÿn¿FÿoÀGÿoÀGÿoÀGÿpÁGÿ„Éaÿ¾ã¬ÿcwZÿÿÿÿÿÿÿƒ�wÿ¾ã¬ÿ¾ã¬ÿ~ÈZÿqÂIÿpÂHÿpÂHÿpÂHÿpÂHÿpÁHÿoÀGÿoÀGÿoÀFÿn¿FÿqÀJÿŽÌoÿ›Ò€ÿ˜Ñ}ÿŠÊkÿ~Ä[ÿuÀQÿxÁSÿ�Ä`ÿ‘Ìuÿ§Ö�ÿºÞ¨ÿ¯Ñžÿ™¶‰ÿ=I7ÿÿÿÿÿÿÿÿHWBÿ²Õ ÿµÙ¤ÿµÙ¤ÿo´Lÿ[ª4ÿY¨3ÿY§2ÿX¦2ÿW¦1ÿV¤0ÿ5ÿÿÿÿÿ‘ Øÿÿÿÿ)NÿE€$ÿE‚&ÿGƒ&ÿI‡'ÿJ‰)ÿLŠ*ÿL�*ÿN�,ÿP’-ÿQ”.ÿR–0ÿT™0ÿV›2ÿWž3ÿYŸ5ÿZ¢5ÿ[¤7ÿ]§8ÿ_©9ÿ`«:ÿb®<ÿc°=ÿd²>ÿf´?ÿh·@ÿi¹Aÿk»Cÿk¼Cÿl½Dÿl½Dÿm¾Dÿm¾Eÿn¿Fÿn¿FÿnÀFÿoÀFÿoÀGÿ¼á©ÿ©Ê™ÿÿÿÿÿÿCQ=ÿ¾ã¬ÿ¾ã¬ÿ�Õ‚ÿqÁIÿpÁHÿpÁHÿpÁHÿoÁGÿo¿GÿnÀGÿn¿Fÿn¿Fÿn¿FÿƒÈbÿµÞ ÿ¬Îœÿ—´ˆÿ¥Ä•ÿ²Ö¢ÿ»àªÿ»ß©ÿ¹Ý§ÿ®Ñžÿ�¼Žÿ…ŸxÿcwZÿ3=.ÿÿÿÿÿÿÿÿÿQaIÿ´Ø£ÿµÙ¤ÿ³Ø¡ÿ|»]ÿ[ª4ÿZ¨3ÿY§3ÿX§2ÿX¦1ÿV¥0ÿ2_ÿÿÿÿÿØ K÷ÿÿÿÿ7iÿE‚%ÿFƒ&ÿI†'ÿJ‰(ÿKŠ)ÿM�*ÿN�,ÿO‘,ÿP“.ÿR–/ÿT˜1ÿTš1ÿV�2ÿXŸ4ÿZ¡4ÿ[£6ÿ\¦7ÿ]¨8ÿ_ª:ÿa­:ÿb®<ÿd²=ÿe´>ÿf¶@ÿh¸@ÿiºBÿj»Bÿj»Cÿk»Cÿl¼Cÿl½Dÿl½Dÿl¾Eÿm¾Eÿm¾Eÿn¿Fÿ–Ðyÿ½â«ÿ›ºŒÿ:F4ÿÿ  ÿZlRÿ²Ô ÿ¾â¬ÿµÞ¡ÿ|ÅXÿnÀFÿnÀGÿn¿Fÿn¿Fÿn¿Fÿm¿Fÿm¾Eÿn¾Eÿl½Eÿq¿Jÿ´Ý ÿ† zÿ%ÿÿ$ÿ'/#ÿ-6)ÿ.7*ÿ+4'ÿ$+ ÿÿ ÿÿÿÿÿÿÿÿÿ  ÿwŽlÿ´Ø¢ÿµÙ¤ÿ´Ø¡ÿ€½bÿ[©4ÿZ¨3ÿY¨3ÿY§2ÿW¥1ÿW¥0ÿC€%ÿÿÿÿÿ÷K•ÿÿÿÿ ÿC}#ÿF„%ÿH…'ÿIˆ(ÿJŠ(ÿLŒ*ÿM�+ÿO‘,ÿP“.ÿQ•.ÿS—0ÿUš1ÿVœ2ÿWž3ÿY¡5ÿ[£5ÿ\¥7ÿ]¨7ÿ_ª9ÿ`¬:ÿb®;ÿc±<ÿe´>ÿeµ?ÿg·@ÿhºAÿiºBÿjºBÿj»Cÿj»Cÿk¼Cÿk¼Dÿl½Dÿl½Dÿl½Eÿm¾EÿrÀLÿ²Ý�ÿ½â«ÿ¹Ý§ÿ¶Ú¤ÿ·Ú¥ÿºß¨ÿ½â«ÿ½â«ÿ–Ñzÿn¿Fÿn¿Fÿn¿Fÿm¿Fÿm¾Eÿm¾Eÿm¾Eÿl½Eÿl½Dÿl¼Dÿ�Ëoÿ¸Û¦ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ'/#ÿš¹ŒÿµÙ£ÿµÙ¤ÿµÙ£ÿ}¼^ÿ\ª4ÿZ©4ÿZ¨3ÿY§2ÿW¦1ÿW¥0ÿQœ-ÿÿÿÿÿÿ•Õÿÿÿÿ ÿE�$ÿG…'ÿIˆ'ÿKŠ(ÿKŒ*ÿMŽ+ÿN‘,ÿP“-ÿQ•.ÿS˜0ÿT™0ÿVœ2ÿVž2ÿX¡5ÿZ£5ÿ\¥7ÿ\§7ÿ^ª9ÿ`¬9ÿa®;ÿc±<ÿd³=ÿfµ>ÿg·?ÿh¸@ÿi¹Aÿi¹AÿiºBÿjºBÿj»Cÿk»Cÿk»Cÿk¼Cÿl½Dÿl½Dÿl½DÿxÂTÿ¬Ú•ÿºà¨ÿ¼áªÿ¼áªÿ¼á©ÿ¹à¦ÿ—Ñ{ÿm¾Eÿm¾Eÿm¾Eÿm¾Eÿm¾Eÿl½Dÿl½Eÿl½Dÿl½Dÿk¼Cÿk¼Cÿ›Ñ€ÿŸ¿‘ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿfz\ÿ¶Ú¥ÿ¶Ú¥ÿµÚ¤ÿ¯Öœÿs¶Rÿ\«5ÿZ©4ÿZ¨3ÿY§2ÿX§2ÿW¥1ÿU£/ÿ- ÿÿÿÿÿÕ"ìÿÿÿÿ0ÿD�%ÿI‡'ÿJŠ(ÿK‹)ÿMŽ*ÿN‘,ÿO’-ÿQ•.ÿR–/ÿS™0ÿU›1ÿV�2ÿW 3ÿY¡4ÿ[¥6ÿ\¦6ÿ]©8ÿ_«9ÿ`­:ÿb°<ÿd±;ÿe´>ÿf¶>ÿg·@ÿg¸@ÿh¸Aÿi¹Aÿi¹AÿiºBÿjºBÿj»Cÿj»Cÿk¼Cÿk¼Dÿk¼Cÿk¼Dÿl¼Dÿ‡Égÿ˜Ð|ÿ™Ñ}ÿ’ÎtÿvÁRÿl½Eÿl½Dÿl½Dÿl½Dÿl½Dÿl½Dÿl¼Dÿk¼Cÿk¼Dÿk»Cÿj»Cÿj»Bÿ˜Ð}ÿºÞ¨ÿ#)ÿÿÿÿÿÿÿÿÿÿÿÿÿ  ÿ3=.ÿh|]ÿ¬Î›ÿ¶Ú¥ÿ¶Ú¥ÿ³Ø¡ÿ–É}ÿd¯@ÿ\«4ÿZ©4ÿZ¨3ÿY¨3ÿX§2ÿW¦1ÿU¡/ÿ =ÿÿÿÿÿì"?÷ÿÿÿÿ >ÿH‡&ÿIˆ(ÿKŒ)ÿLŽ*ÿM�+ÿN’,ÿP”.ÿR—.ÿS™0ÿT›0ÿVž2ÿWŸ2ÿX¡4ÿZ£4ÿ\¦6ÿ]¨7ÿ^ª9ÿ_¬9ÿa¯;ÿb°;ÿd³=ÿd´=ÿf¶?ÿf·?ÿg·?ÿg·?ÿh¸@ÿh¸Aÿh¹Aÿi¹Aÿi¹BÿiºBÿiºBÿj»Bÿj»Bÿj»Bÿj»Cÿk»Cÿk»Cÿk»Cÿk¼Cÿk»Cÿk»Cÿk»Cÿk»Cÿj»Cÿj»Bÿj»BÿiºBÿiºBÿiºBÿi¹Aÿh¹Aÿ€Ã^ÿºß©ÿ�¼ŽÿCP<ÿÿÿÿÿÿÿÿÿ%ÿER>ÿs‰hÿ“°†ÿ¬Î›ÿ·Ú¥ÿ¶Ú¥ÿ¶Ú¤ÿ�͇ÿu¸Tÿ]«7ÿ[«5ÿ[©4ÿZ¨3ÿY¨3ÿY¦2ÿW¦1ÿV£/ÿ&Iÿÿÿÿÿ÷?aþÿÿÿÿ#CÿI‰'ÿJŠ(ÿK�*ÿMŽ*ÿN‘,ÿO“,ÿQ–-ÿS˜/ÿTš0ÿU�1ÿWž2ÿX¡3ÿY¢4ÿ[¦6ÿ]§7ÿ^ª8ÿ^¬8ÿ_­:ÿb±;ÿc²<ÿe´=ÿeµ=ÿf¶>ÿf¶>ÿf·?ÿg·?ÿg¸@ÿg¸@ÿh¸@ÿh¹Aÿh¹Aÿi¹Aÿi¹AÿiºAÿiºBÿiºBÿiºBÿjºBÿjºBÿjºBÿjºBÿjºBÿjºBÿjºBÿiºBÿiºBÿiºAÿiºAÿi¹Aÿh¹Aÿh¹@ÿh¸@ÿl¹Eÿ§Ö�ÿºÞ¨ÿ¶Ù¤ÿ´×¢ÿ¯Ð�ÿ¡Á’ÿ•²†ÿ‘®ƒÿ—µˆÿ¥Æ•ÿ°ÓŸÿ²Õ¡ÿ´×£ÿ¶Ú¤ÿ·Ú¥ÿ¶Ú¥ÿ¶Ú¥ÿ¡ÏŠÿp¶Oÿ`­9ÿ\«6ÿ[ª5ÿ[©4ÿZ©3ÿY¨3ÿY¦2ÿW¦1ÿW¥1ÿ-Vÿÿÿÿÿþa‹þÿÿÿÿ"BÿJŠ(ÿJŒ)ÿM�*ÿN�+ÿO“,ÿQ–-ÿR—.ÿTš/ÿT›1ÿWž1ÿX 3ÿY£3ÿZ¤5ÿ\§5ÿ]©7ÿ_«8ÿ_­9ÿa°;ÿb²;ÿd³=ÿd´=ÿeµ>ÿeµ>ÿe¶>ÿf¶>ÿf¶?ÿg·?ÿg·@ÿg·@ÿh¸@ÿh¸Aÿh¹Aÿh¹Aÿh¹Aÿi¹Aÿi¹AÿiºAÿi¹AÿiºAÿi¹Aÿi¹AÿiºAÿi¹Aÿi¹Aÿi¹Aÿh¹@ÿh¹Aÿh¸@ÿh¸@ÿg·@ÿg·@ÿg·?ÿq»Mÿ¦Ô�ÿ·Ü¤ÿ¸Ý¦ÿ¹Ý§ÿ¹Ý§ÿ¹Ý§ÿ¸Ü§ÿ¸Ü§ÿ¸Ü¦ÿ¸Û¦ÿ·Û¥ÿµÚ£ÿ²Ø ÿ®Ö›ÿ“ÈxÿmµKÿ]¬7ÿ\¬6ÿ\«5ÿ\ª5ÿ[©4ÿZ©3ÿZ¨3ÿY§2ÿW¦1ÿV£0ÿ0]ÿÿÿÿÿþ‹Ÿÿÿÿÿÿ;ÿF†'ÿKŽ*ÿM�+ÿO“+ÿO”-ÿQ–.ÿS™.ÿTœ0ÿU�0ÿWŸ2ÿY¢3ÿY£4ÿ[¦4ÿ]¨7ÿ^ª8ÿ^¬8ÿ`¯:ÿb±:ÿc³<ÿc³<ÿd´<ÿd´=ÿeµ=ÿeµ>ÿfµ>ÿf¶?ÿf¶?ÿg¶?ÿg·?ÿg·@ÿg¸@ÿg¸@ÿh¸@ÿh¸@ÿh¸Aÿh¸@ÿh¸Aÿh¹Aÿh¸Aÿh¸@ÿh¸@ÿh¸@ÿh¸@ÿh¸@ÿh·@ÿg¸@ÿg·@ÿg·?ÿf¶?ÿf·?ÿf¶?ÿeµ>ÿeµ>ÿ{ÀYÿŒÇoÿ—Í}ÿœÏƒÿžÏ†ÿžÏ†ÿžÏ†ÿ�΄ÿ˜Ë~ÿ�Çsÿ‚Àcÿo¶Mÿ_®8ÿ^­7ÿ]¬6ÿ]¬6ÿ\«5ÿ\ª5ÿ[©4ÿZ©3ÿY¨3ÿY§2ÿX¦1ÿS�.ÿ'KÿÿÿÿÿÿŸ�ÿÿÿÿÿ. ÿH‡'ÿL�*ÿN’+ÿP”,ÿP–-ÿS˜.ÿT›/ÿUœ0ÿVŸ1ÿX 2ÿY£4ÿZ¥5ÿ[¨5ÿ]©7ÿ^¬7ÿ_­9ÿ`°9ÿb±:ÿb²;ÿc²;ÿc³<ÿc³<ÿd´<ÿd´=ÿe´=ÿeµ=ÿeµ>ÿe¶>ÿe¶>ÿf¶>ÿf¶?ÿf¶?ÿf·?ÿf¶?ÿf¶?ÿf·?ÿf·?ÿf·?ÿf·?ÿf·?ÿf¶?ÿf¶?ÿf¶?ÿf¶?ÿf¶>ÿe¶>ÿe¶>ÿeµ>ÿeµ=ÿd´=ÿd´=ÿd´=ÿc³<ÿc³<ÿc²;ÿb²;ÿb±:ÿa±:ÿa°:ÿ`°9ÿ`¯8ÿ_¯8ÿ_®8ÿ^­7ÿ^¬7ÿ]¬6ÿ\«6ÿ\«5ÿ[©4ÿ[©4ÿZ¨3ÿY¨3ÿX§2ÿX¦1ÿP™-ÿ7ÿÿÿÿÿÿ��ÿÿÿÿÿ ÿFƒ&ÿM‘+ÿO”,ÿO•,ÿQ˜.ÿS™.ÿTœ0ÿUž0ÿW 2ÿX¢3ÿY¤4ÿZ§5ÿ\¨6ÿ]«6ÿ^­8ÿ`¯8ÿa°:ÿa±:ÿb²;ÿb²;ÿc²<ÿc³<ÿc³<ÿd´<ÿd´=ÿd´=ÿe´=ÿeµ>ÿeµ>ÿeµ>ÿeµ>ÿeµ>ÿeµ>ÿf¶>ÿf¶>ÿf¶>ÿf¶>ÿe¶>ÿf¶>ÿeµ>ÿeµ>ÿeµ>ÿeµ>ÿeµ>ÿeµ>ÿdµ=ÿd´=ÿd´=ÿd´<ÿc³<ÿc³;ÿc²<ÿb²;ÿb±;ÿa±:ÿa°:ÿ`°9ÿ`¯9ÿ_¯8ÿ_®8ÿ_®7ÿ^­7ÿ]¬7ÿ]¬6ÿ\«6ÿ\«5ÿ[©4ÿZ©4ÿY¨3ÿY¨3ÿX¦2ÿX¦1ÿP˜-ÿ! ÿÿÿÿÿÿ�˜ýÿÿÿÿÿ9mÿO“+ÿP•,ÿP—-ÿR™-ÿSœ/ÿU�0ÿV 1ÿW¡2ÿY¥4ÿZ¦4ÿ[¨5ÿ]ª6ÿ^­8ÿ_®8ÿ`°9ÿa°9ÿa°:ÿa±;ÿb±;ÿb²;ÿb²;ÿc²<ÿc³<ÿc³<ÿd´=ÿd´<ÿd´=ÿd´=ÿdµ=ÿd´=ÿe´=ÿeµ>ÿeµ>ÿeµ>ÿeµ>ÿeµ=ÿdµ>ÿeµ=ÿd´=ÿd´=ÿd´=ÿd´=ÿd´=ÿd´<ÿc³<ÿc³<ÿc³<ÿc²<ÿb²;ÿb±;ÿa±:ÿa±:ÿ`°9ÿ`¯9ÿ`¯9ÿ_¯8ÿ_®8ÿ^­8ÿ^­7ÿ]­7ÿ]«6ÿ\«5ÿ\ª5ÿ[ª5ÿZ©3ÿZ¨3ÿY¨2ÿY¦2ÿW¦1ÿEƒ'ÿ ÿÿÿÿÿý˜„ýÿÿÿÿÿ)OÿL�*ÿP–,ÿQ™-ÿSš.ÿT�/ÿUŸ1ÿW¡1ÿX£2ÿY¥3ÿ[¨5ÿ\©5ÿ]«6ÿ^­7ÿ_¯8ÿ`¯9ÿ`°9ÿa°:ÿa±:ÿa±:ÿb±:ÿb²;ÿb²;ÿb²;ÿb²<ÿc³<ÿc³<ÿc³<ÿc³<ÿd´=ÿd´<ÿd´<ÿd´=ÿd´=ÿd´=ÿd´=ÿd´=ÿd´<ÿd´<ÿd³<ÿc³<ÿc³<ÿc³<ÿc²;ÿb²;ÿb²;ÿb²;ÿb±:ÿa±:ÿa°:ÿa°:ÿ_¯9ÿ`¯8ÿ_®8ÿ_®8ÿ^­7ÿ^­7ÿ]­7ÿ]«6ÿ\«5ÿ\«5ÿ[ª5ÿ[©4ÿZ©3ÿZ¨3ÿY¨2ÿX¦2ÿSž.ÿ/Yÿÿÿÿÿÿý„Uóÿÿÿÿÿ( ÿ?y#ÿP—-ÿR™-ÿTœ/ÿT�/ÿV 1ÿW¢2ÿX¤2ÿY¦3ÿ[©4ÿ\ª6ÿ^¬7ÿ^­7ÿ^®8ÿ_®8ÿ_®8ÿ_¯9ÿ`°9ÿ`°9ÿa°:ÿa°:ÿa±:ÿa±:ÿa±;ÿb±;ÿb²;ÿb²;ÿb²;ÿb²;ÿb²;ÿb²<ÿc²;ÿb²;ÿb²;ÿb²;ÿb²;ÿb²;ÿb²;ÿb²;ÿb±;ÿa±:ÿa±:ÿa±:ÿa°:ÿ`°9ÿ`°9ÿ`¯9ÿ_¯8ÿ_®8ÿ_®8ÿ_®8ÿ^­7ÿ]­7ÿ]¬6ÿ]¬6ÿ\«5ÿ\«5ÿ[ª5ÿ[©4ÿZ¨3ÿY¨3ÿY§3ÿY§2ÿX¦1ÿH‰(ÿ- ÿÿÿÿÿÿóU1ãÿÿÿÿÿ ÿ*PÿQš-ÿS›.ÿTž.ÿUŸ0ÿW¡1ÿX¤2ÿX¦2ÿZ¨5ÿ[«5ÿ]¬6ÿ]¬6ÿ]­7ÿ^­7ÿ^®7ÿ_®8ÿ_®8ÿ_¯8ÿ`¯9ÿ`¯9ÿ`¯9ÿa°9ÿa°:ÿa°:ÿa°:ÿa±:ÿa±:ÿa±:ÿa±;ÿb±;ÿb±;ÿb±:ÿb±:ÿa±:ÿa±:ÿa±:ÿa±:ÿa°:ÿa°:ÿa°:ÿ`°9ÿ`°9ÿ`¯9ÿ`¯9ÿ_¯9ÿ_®8ÿ_®8ÿ^­7ÿ^­7ÿ^­7ÿ]¬6ÿ]¬6ÿ\«5ÿ[ª5ÿ[ª5ÿ[ª4ÿ[©4ÿZ¨3ÿY¨3ÿY§2ÿX§2ÿX¥1ÿ.Wÿÿÿÿÿÿÿã1Åþÿÿÿÿÿ# ÿD€%ÿR™-ÿTŸ0ÿV¡0ÿW£2ÿX¥2ÿY§3ÿ[ª4ÿ\«5ÿ]«6ÿ]¬6ÿ]¬6ÿ^­7ÿ^­7ÿ^­7ÿ_®8ÿ_®8ÿ_®8ÿ_®8ÿ`¯9ÿ`¯9ÿ`¯9ÿ`¯9ÿ`°9ÿ`°9ÿa°9ÿ`°:ÿa°:ÿa°:ÿa°:ÿa°:ÿa°9ÿ`°9ÿ`°9ÿ`°9ÿ`¯9ÿ`¯9ÿ`¯9ÿ`®9ÿ_¯8ÿ_®8ÿ_®8ÿ_®8ÿ^­7ÿ^­7ÿ^­7ÿ^¬7ÿ]¬6ÿ]«6ÿ\«5ÿ\«5ÿ[ª4ÿ[©4ÿZ©4ÿZ©3ÿY¨2ÿX§2ÿV¢1ÿG‡'ÿ# ÿÿÿÿÿÿþÅ|óÿÿÿÿÿÿ#CÿC€%ÿU /ÿV¢1ÿX¤1ÿX¦3ÿZ¨3ÿ[ª5ÿ\ª5ÿ\«5ÿ\«5ÿ]¬6ÿ]¬6ÿ]¬7ÿ]­7ÿ^­7ÿ^­7ÿ_®8ÿ_®8ÿ_®8ÿ_®8ÿ_¯8ÿ`¯8ÿ`¯9ÿ`¯9ÿ`¯9ÿ`¯9ÿ`¯9ÿ`¯9ÿ`¯9ÿ`¯9ÿ`¯9ÿ`¯9ÿ_¯8ÿ_¯8ÿ_®8ÿ_®8ÿ_®8ÿ^®8ÿ^­7ÿ^­7ÿ^­7ÿ^¬7ÿ]¬6ÿ]¬6ÿ\«5ÿ\«5ÿ\ª5ÿ[ª5ÿ[ª4ÿZ©4ÿZ¨3ÿY¨3ÿY§2ÿX§2ÿG†(ÿ&Hÿÿÿÿÿÿÿó|5Èÿÿÿÿÿÿÿ!>ÿE„&ÿW£0ÿW¥1ÿY§2ÿZ¨4ÿZ©4ÿ[©4ÿ[ª4ÿ[ª5ÿ\«5ÿ\«5ÿ\«6ÿ]«6ÿ]¬6ÿ]¬6ÿ]¬7ÿ^­6ÿ^­7ÿ^­7ÿ^­7ÿ^­7ÿ^­7ÿ^­7ÿ^­7ÿ_­7ÿ^­7ÿ^®7ÿ^®7ÿ^­7ÿ^­7ÿ^­7ÿ^­7ÿ^­7ÿ]­7ÿ^­6ÿ]¬6ÿ]¬6ÿ]¬6ÿ]«6ÿ\«5ÿ\«5ÿ\ª5ÿ[ª5ÿ[©4ÿZ©3ÿZ¨3ÿY¨3ÿY¨3ÿX§2ÿX¦1ÿHˆ(ÿ"AÿÿÿÿÿÿÿÿÈ5cþÿÿÿÿÿÿÿ1ÿG‡(ÿX¦1ÿY§3ÿZ¨3ÿZ¨3ÿ[©3ÿ[©4ÿ[ª5ÿ[ª4ÿ\ª5ÿ\«5ÿ\«6ÿ\«5ÿ\¬6ÿ]¬6ÿ]¬6ÿ]¬6ÿ]¬6ÿ]¬6ÿ]¬6ÿ]¬6ÿ^¬7ÿ]­7ÿ]¬7ÿ^­6ÿ]¬6ÿ]¬6ÿ]¬7ÿ]¬6ÿ]¬6ÿ\¬6ÿ]¬6ÿ]«6ÿ\«5ÿ\«5ÿ\ª5ÿ\ª5ÿ[ª5ÿ[ª4ÿ[©4ÿZ©4ÿZ¨3ÿY¨3ÿY§2ÿY§2ÿX¦2ÿIŠ)ÿ5ÿÿÿÿÿÿÿÿþc±ýÿÿÿÿÿÿÿ* ÿA{%ÿSž/ÿW¤1ÿY¨3ÿZ¨3ÿZ©3ÿZ©4ÿ[ª4ÿ[ª5ÿ[ª5ÿ\ª5ÿ\«5ÿ\«5ÿ\«5ÿ\«5ÿ\«6ÿ\«6ÿ]«6ÿ\«6ÿ]«6ÿ]¬6ÿ]¬6ÿ]¬6ÿ]«6ÿ]¬6ÿ\¬5ÿ\«5ÿ\«5ÿ\«5ÿ\«5ÿ\«5ÿ\ª5ÿ[ª5ÿ[ª5ÿ[©4ÿZ©4ÿZ©3ÿZ©3ÿY¨3ÿY§2ÿW¤1ÿR�/ÿ@z$ÿ* ÿÿÿÿÿÿÿÿý±HÊüÿÿÿÿÿÿÿ ÿ)Oÿ@x$ÿN”-ÿY¨3ÿZ¨3ÿZ¨3ÿZ©4ÿZ©4ÿ[©4ÿ[ª4ÿ[ª5ÿ[ª5ÿ[ª5ÿ\ª5ÿ[ª5ÿ\ª5ÿ\ª5ÿ\«5ÿ\«5ÿ\«5ÿ\ª5ÿ\«5ÿ\ª5ÿ\ª5ÿ[ª5ÿ[ª5ÿ[ª5ÿ[ª4ÿ[ª4ÿ[©4ÿZ©4ÿZ©4ÿZ¨3ÿZ¨3ÿZ¨3ÿY¨2ÿO”,ÿ@y$ÿ)Nÿ ÿÿÿÿÿÿÿÿüÊHK¾ÿÿÿÿÿÿÿÿÿ ÿ;ÿ4bÿKŒ*ÿY§2ÿY§2ÿY¨3ÿY¨3ÿZ¨3ÿZ¨3ÿZ¨3ÿZ¨3ÿZ©3ÿZ©3ÿZ©4ÿZ©4ÿZ©3ÿZ©4ÿZ©4ÿZ©4ÿZ©4ÿZ©3ÿZ©3ÿZ¨3ÿZ¨3ÿZ¨3ÿY¨3ÿY¨3ÿY§2ÿY§2ÿX§2ÿJŒ*ÿ4bÿ;ÿ ÿÿÿÿÿÿÿÿÿÿ¾K-±ÿÿÿÿÿÿÿÿÿÿÿ ÿ6ÿ2`ÿG†(ÿW£1ÿY§3ÿY§2ÿY§2ÿY¨3ÿY¨3ÿY¨3ÿY¨3ÿY¨2ÿY¨3ÿY¨3ÿY¨3ÿY¨2ÿY¨3ÿY¨3ÿY¨2ÿY§2ÿY§2ÿW£1ÿF†(ÿ2`ÿ7ÿ ÿÿÿÿÿÿÿÿÿÿÿÿ±-‘óþÿÿÿÿÿÿÿÿÿÿÿÿÿ* ÿ"Bÿ.Xÿ7hÿ>v#ÿE‚'ÿIŠ)ÿJŽ*ÿIŠ)ÿE‚'ÿ>v#ÿ6hÿ.Xÿ#Bÿ* ÿÿÿÿÿÿÿÿÿÿÿÿÿÿþó‘V¾íýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýí¾VS”ÒùÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿùÒ”S 8…ËõÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿõË…8 ,n¡ÆÝêòùüýüùòêÝÆ¡n,ÿÿÿÿÿÿÿ€ÿÿÿÿÿÿÿÿÿÿÿÿðÿÿÿÿÿÿÿÿÿÿÿüÿÿÿÿÿÿÿÿÿÿðÿÿÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿÿÿ?ÿÿÿÿÿÿÿÿøÿÿÿÿÿÿÿÿàÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿ?ÿÿÿÿÿÿüÿÿÿÿÿÿøÿÿÿÿÿÿðÿÿÿÿÿÿÀÿÿÿÿÿÿ€ÿÿÿÿÿ?ÿÿÿÿþÿÿÿÿüÿÿÿÿøÿÿÿÿðÿÿÿÿàÿÿÿÿÀÿÿÿÿ€ÿÿÿ?ÿÿÿ?ÿÿþÿÿüÿÿøÿÿøÿÿðÿÿðÿÿàÿÿÀÿÿÀÿÿ€ÿ€ÿÿ?ÿ?þþüüøøøððððððààààààÀÀÀÀÀÀÀÀÀÀÀààààààðððððððøøüüþ?þ?ÿÿÿÿÿ€ÿÿÀÿÿÀÿÿàÿÿðÿÿðÿÿøÿÿüÿÿüÿÿþ?ÿÿÿÿÿÿ€ÿÿÿÿÀÿÿÿÿàÿÿÿÿàÿÿÿÿðÿÿÿÿøÿÿÿÿþ?ÿÿÿÿÿÿÿÿÿÿ€ÿÿÿÿÿÿÀÿÿÿÿÿÿðÿÿÿÿÿÿøÿÿÿÿÿÿüÿÿÿÿÿÿÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿàÿÿÿÿÿÿÿÿøÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿÿÿøÿÿÿÿÿÿÿÿÿÿþ?ÿÿÿÿÿÿÿÿÿÿÿþ?ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿcaronc-apprise-dfec414/apprise/assets/themes/default/apprise-success-128x128.png000066400000000000000000000420461526720371000275550ustar00rootroot00000000000000‰PNG  IHDR€€Ã>aË pHYs  šœ IDATxÚí�wœ\gu÷¿Ï½Svfvgûjwµ+­z—¬fInrÃB¸Ä&¶É›` Í�@z1!’¶‰ ¦lƒ+îr—mÙ«fõ•¶h{Ÿ�²3sŸ÷�{gæ¹eTŒdœÄ�>«Ý¹wî�¹Ï9Ï)¿sžsàíñöx{¼=ÞÿG‡ø_þ|‹�ó�•À` P”A@·æ@Y $�q Ø¼< ìx›Þšß?dup)p°Ÿ‚Ï‹»€G�û�6‹Y½ÍoÒhÖççó€Àá{L{€ÍÀSÀóÀ‘·Ë©>àÏ,qDNô;û+K6D Ö—¨+%P â+ ¢•ø>�ÌHŒd†L,Ef,Iz$ÉdßÉžq’GÆÈŒ$Oô;K`øoà‹ÀèÛ pb#`‰õ¯—ó!t á×Ð#"³ª(]XGéÜZBÍh>-¯œ…pSIy-)hu™1Ht Û;@ìõ~â†ÈÄRÈ´�ÌÇó<÷Ÿ·ÔÅäÛ P|� ��[ú½ø÷ë”4–QÒTNé¼ZÊN¡djÔAháù´¢ ZrÒ}T*'“]cŒïê#¶§ŸdÇ(Éî1dú˜Ìðp#ð¸åq¼ÍÊx§%êWXî™7‡Ô—R±¦™²S5E Ô´‚�ðÂùpÂNr¯¥ïA}û!'#€D29'Ù5ÊøÎ^F¶t�ê‰í9³ÀVK5<ü6@ðcà@+ö¦ÈÂZ¯XLxVZÀ‡æÓì„ByQlñÛÁƒ $ÒÛ¡s1€T˜C"%Ȭ�‘J3q`ˆžßî"¶«ïhÏmïFþ/2€Ïzø[Š}Ð([4…Æ?[Jhz%š…en\ˆí Ì óÒ@åÁ“ØRˆü²®õo~@žÜR]ûÆ�¶ß&S$:G9òëíŒoëÆHe�f0þµµ2ÿW`5ð=`�×I½7<«Š©±‚’†(z@+\M˜®žyQï$´“èÞL�c·ý/�w[Ømé%œL¡0„ùZ"Óñ®Q:n}‰ø�¡b*áyàâ7Ã6x³`¹å 79Oh!µæR÷®y¡�.Є@hB �®™Ç…Ðk?§„b÷ •ì'œŠbö>n^ðÐÿÒ͹¿sÄVmGóœ" è¹o½¿Ý…‘òTý�òêÿtx�…ˆ¹D~d~-õW,$<­Ò\ñº4]C8VŽ)vOÕþ¾ð|JSû ŽÑ6[ߥû�ƒ�Ȫj°lë|î2)%‰Žº~úãÛ{Š©„¿>UÄÑO1ñ¿ Üd<…É÷ ê.]@ã•KTEÖêšÈë}a_ˉ~Mùɽ×ú-Dá˜ù·"E„rLÓLÉPä'�ùÒ:^ì5¹×RñJ vˆyÊ’T¹×ªäà�–P¾z*Z8@lG¯"únë÷“ÿ“$€ü§åÞØ|{Ô™ö7§S:·Î"¨eàå˜@·ˆå³ˆ¥™„Ôt‘�[ëê„¢¸‚9H»ê·V«( {Xû6àxu¾"dþ,Ò°{ †T®7@b?0ľ¯=…‘Èxa·¶€¤·´(¾i}YÛT¦”2ëë 5DMB Ð4 ! ŸmU[«ë5–+(ÖõެH°¤€mU‹‚ÊP~ÌcZ~»%ƒ²Úm8„}5;+úÊVYà…r+�௠SsÞLÆwö:ÝE¬Â Œm:™xÁÉfŸåÆü£óDÙÒz¦ýõéøÂ~K4ç­çW}Žàš%êU†Ð”ó1lM´BäüßPhcÍbØEºªòâ;woTU€M%¨ªçkXU`;Œ­}TœÞÄdÿÉ®1ç4®µl©',©ð–S·×:Vž9�º‹ç¡—˜ÄG×Ð…¿-ñ®išå®åŒAÓ0,`öÕ”Ÿ^¯X€pÛþR… dQü§€æT€P-z˜©£.XI4! ùÑmgHÉd6ÍD&ÁHjœ¾ø ýÉ ²« )¥¥ Æb6‘¦çžô=¸ÇëÛݼÿ­ÆßÄŒâÙîYuþLêÞ9ßDótÝ\=ºâ×[Ì€®›ÄµÜ@ôœ$Ðy0%BnUº@ÕõË3ˆÇã ��p»~BUýŠ[õ…9¿a5µ%&ä¼í p:pðÍfЊ™‚]põ˘ö·kÑ}æ*V0'íªDͯj ô¼‘§¹Œ>¡ky<@Ø\='áXXYùþRΫ_Eu jÚcde–�£Ù2¸ËMiÇ s†ÛÙµ§Q9äãS—|ˆäDâ�é]¿�²Ê(—üÕ•l¼öò¼ÜIf&yèÐ3tLô"-&(H‰aøÆfbÛ]€Ñ.`Ùqÿà{À»l7+ 2ýCëÐü7Ï$†VpñTÝ­"u&—(þ¼fCøL{»Í ì¾}Š»V¢¸´élª‚QŒŒA&�ÁÈMàÓ}Ô‡ªÑ„ ;9h¿Ÿj;(~™?Ìܺ™<öÓûI¼A�†Ar"Á¶§_¡uóËÌ_½˜HE¿¦3¿jc“ô'GA+8(_ÙÈè+GÈŽÛ2Êj�à¡7‹.¾Ž’ú¬•ø˜ú¾øËKLÃI]­š›ð9=ž#.BS|}KÌç€KeämÀ1o¢…Ø ÄÓ*çÒRÚÀáݹåsßá¿>þ ùñ½$'Ì\2�`0HÔ¡+ÑOÂH)ÅÎ Bh$#é æW´0cî,ÚvîÇ PU_C]s=õÓ©kª§ª¡†²Êr‚¡ FÖ 3™.:‘ýƒ<{ßSÔN�BãœihBÐ�J2“¤7>d~I‰ðë„[*ÝziO2Yb„ûNµ (^ÆÌÚµ¨/¨»d>ÑMfì^hŸ%¾…z.À£wºfùý–k§ EÜÔD! åUAžy\ÙAn×ð3.e¤w�/¾çcô¶ïÙ8ûŠ øèw?gÊб6žØa þä`Z„= )TË9u+ÈŒ$8ÒOYE”²ª2JÂ!¤Ã0HÄ&馯£‡öÝÙñÜkl{ú•£NìÆ÷_Á{?õA„d¥Áã/òúpÒ°0‚\â‰4èt?]?Ùê4b÷XˆaìTJ€oZ À‹¦PuîL[ &�áç\>¡ÙÅ~Î P _Sÿk¶@N.€£)v€¦ÙU€&®µ‚CµÌ‹Nãáÿ–-¿{ÆÃzu1mþ ¦ÎšFm°’Cñn’Ƥ… bsM󨟌¥'x}ì?ø«BŒ’ìOtÑ:¼ŸW‡÷ðúøazÒC¥�3šX¹v5+/\Ç—�ÏøÐ(]ûÛ='ö@ëFú‡Xrö*|ºÎìòf�w3‘I $¾�€ðÌ*#¤ŽŒ«·¨±èçŠV?²Ý 4@Ã{–¢ýù S‰Š¢L ǃˆšúäÔ‡°"KbÄ!`d‡bi`‰îUUó©ô—qßE×÷¤g3YF†Yû®sðÔ+Øk·T ¶”`þm`0�¡=ÞKg¢Ÿ�Ô(™)#M2›b4ãH|€=c‡xmh/ŸÁô†fξô<æ­\Äög¶’Š»ÓÃÚvî' 2gù|„Ðh‰6°kè #ëÈs””¯nbðɃÎ|‚5VâÍ‘“Í>à1‹Ëò£îORÒ5‰¥†e=Ÿ3à4rž�jä)" ²ˆnJÝZ‰ª P€Ía �OÓ9³f}mGxè¶{ˆ�Œ{>Tg/Ms¦Ó²h=D<›b0=Š&„éªv…µ«ƒåÔ+¨+©¤:XNu0Je J©?D‰�•Y™U’N zÃlÙÏd6ͼÙs¹øÿ]Á‘í9ÐáúN;Ÿ�–ÅshœÑD@÷ÒƒëRs]òq„Ь*†7»\Ãs€O¼àDàãÀÕªÝPvZ#k§¢iÊÊÎ;ª¨†šb šöž¢ÿ­kÍÕ^Pš-n Œ¤å½ ¡ f—63#ÒÀ®ç[yô§÷õÁ:÷æÜ+7¨-©ààD7i™µ^"zˆ³j—±¬r ¢Ó™QÚhþDÌß³K›˜^ÚÀ´H=ÕÁr&2IFÊBõ%‡h�õPUZΆË6’I§Ù»õuWúµM[X½áLÊ*¢”Ë8ï'–ž°I�À_";‘v¦—U[vÀs'‹¦[Øs­*ú§\¾=è³V¹Ã5ÓË\ÖġÆùE.(¤09·PW‰›K¡Àhs+*ç‘%Üûƒ;� ØŒ�âX|Æiø4>M§3Õ§DAHÁe�gѪ! üùk3éŒÉĹÍG©/Dm°’¹åÓ© VГ0Êb‚¤1ɾ±ªƒåœuî9ÄFÆ8ÐjúdÒlÛÃyWmÄ'4|ÂGÛx—’�3ÀÁ_a|{Ù‰IÕ¸Ÿ†¹…ý¨ûµãô.æ«+ÖMG òA2ùx*Zˆ×™ †yZCH©„\sI ^Ÿ½JÓ¶�²ju†d•°nM œº`%ñ±ÏÞw|‰4wþûí t÷#„ %Ò@m ²@| ¢-Dý¥Œôqóç¾Ã_­¼’+›Ïçê™qå´óù‹…—ð•÷~’Ç~ö£C#HÃÀ¯éÌ,�Ê5-˜U:ÕTa¢]?Úý"c]\û¥3oå"×w:üúAžúÕà s+§S(Uð‰6š¥rÝ4çæÇyÝÄ*Ê­0oU®”ÊsZl†Ÿºr5ÅPý÷‚d(„ƒs–~^”kMè./¢à%˜RDÅì©Z‚yeÓ˜žÂ?º‹Öͯ·ŽkßÝÆyWnÀ¯ùÐ…FG²/ÏЋËg•%üð3ßá©_ýÎ…f&ÓôµwóÊ/ðÀ-w120Lõ”jÊëªÐ…ά²©H=ñA3Ìl�ƒãG¨UsáeØô«‡I%R6Àh|h”ÕÎ$ Ôì+Ø BÁ‰BÓ+~¾#nÃY´Ký! pðAuu—¯i"ÔRmzÌU¨ 45‰%ÇOÓÜÉ*Ú§æ",�¯å‰®i “å˜Æ’š&ðë~Ö×,G7àëïÿ飀0^áÔÙÍL›?“ª`”ÞÔ 1#‰0#ÜH0©qën"�:ú_)%·íåÙû71™L1gù||>?SÃ5DüaźA)HŽ0§j-sgòÜý›l÷föi h˜1•šP¯ ì!k8·¶ ŸŽò1¶ÕfüW»-Èþ «€mñS~ÚTòÉ8ª¢¸²v�»u oWÓ¦¤=Ÿ/÷Ž|þŸ’ýƒ°éfÔ!˜WÚLXòÛÜI||â„|\#›åÁÝÍØÀÁYÕËòÏ’È&Ñ}:Õ uÇ}¿øøw÷§|ûÃ_µ˜F0/:�•UómÑÊ¡É1öŒµ³pÝ2Vœ¿Æåªn}â…<#/¨œaº„’€Z½~�)¥G¥ß‰2À5N·¯âôi ˆ+…vÏEYIù½zR(È•’F‘�¸ˆü·Òœ6…u½ÀŒ“ÛütKýhB°ºr!éÉ4Ür׸w¿´ƒ}‰¤Ìfit6šì�w¡…ý\ý‰k •†Ñ4 ݧç4½øTn}â¾þ�ÏY ©ÆâªYÔ—T‚XBðÚà^ðkœý§º®åñòªa^e jªƒ=±®^â¼¼Ú¢ãR? x¿ŸÚËD7t{ΊWt¶í·&liß›�¼hGè �d¾GGÓíªC³Ùä“?O¯XÈ”’Jî¿ù×¼ôès.äïxÇáÝmœsÅ…”DBÔ*8ïf4kJ“¥ —°ášKXqþþìcÉUÿt-ßw9ËÖ¯¦´²Œ‰Ñãî\>ú:zîdåëðk>4M£=Ö�çý¬ÌR,£©ª��ϽÆè`aSÐd2ÅŠó×PÝP‹_óÓ:¸OQö­�þª0£[]Á¢™.pB pð 5àS~ÖtJ¢Š¯æê GdOÁïsÆZÎuS\@rz_‰ð-Ÿ'€EZ>;¸6XÎÚªEŒŒrÇ×n.š¤qúÒ#ô¤†È!ÜXA"”¥ÇaX�SÞTÍÙ®çÌ‹Ï#±{ËvÚy€ÆÙÍ4Ïm¡:XÎÁX‰l*OÂñô«›—°û¥t쵃;ѪrŸ¹C´�w3žŽÛÍûœI i�5·‡Œk¬DÒŽQW¡lè~�ÈÜ:¥o¡RŽLœ|wMªé¸²�rå–Šn)ˆ¼‚nÉgþ"Ð…`Yt>¡ó܃¿çàö½pšÓ}?¼“¶ûÀôp=uÁ*0˜cûx/ ïäÅá]lÝÏöñƒ<=ØÊ��OÐâê�_˧þÛ½¿Ó0 ¾ý·Œ�Œ"€¥•slI,©QÒF†YKç¢ûìks﫯›+VhT£îâ¢`7•.¨Ã_Rßà·èyÜ6@-p–z <§- ô´m¿œ(VR*Ý:ì[¹]»zm;rh—T"}3höD�éázšBuŒ�ñëoÿ三–¶àºßCHÓ¤±Si™áåÑ=<7¸�¬åºo|ÂuÏý¯ífÿ«»Mƒ°|º}›ПfÚü™øüö"cí¯4¸ÐˆøC¶9ulŒ£¤¹œP‹ë™6¨@Þ±`� øšQeæö)´Â+·Î"´jì©9{Ê)‰°µ´UððÊðÍmã/0‚É,!=ÈŠòyhBð‹oþ7Ã}ƒ¶©©©aáÂ…¼ó�ïäŸÿùŸùÙÏ~ƶmÛLWíàAîºë.>ö±�yNBÛÎý&Œ, %\O}IM~Ê5Û´«À 쎵ózì민ˆ%g.w!|­›_!“ΘÒ%Ò`³’£Ô6MAsH5ùħù¬U€ísóS¬ ¢§5˜›k c–E×c2€†YÀ!_iÓW"Pq˜úÂ]HIä¶^+(…�…-RŽ}šyKVñÕg0ï<'ͽ ³ÜjA�Ô„ñ•—¸ˆ'íkÇÚÎ3ƒÄ´Ôó{óPáa§‚�Ê1ÅÎÁBçZÂõŒôsógþÝõ Ñh”•+W;$ªëÜzë­ÞR`Ç~ž¹÷ CÖƒ¬ªXPØu¤¤�aóÉM¼¿75LóÜjë\ˆcL*÷—V†;û~wM;ˆÀkÖ:G _´„ðœ¯L®’c1@È 'š¡ JšË«]8¿pü-\IGÒÆ±R±�…½Ô‹r-Üï© T±<:Ã�Üöåÿdàˆ» Sii)3gÎ<.{àÌ3Ï䪫®òŽÜx;cƒ#€`n¸‰ _™²g°À�Nµ5�¡¼¦’òÚÊ¢«Ù'|6Ã:ì 1™HynRõM›Ü�ÒÆ4…y´O^åÙÓ�·XŽYló¨ °ÈÆ%ºFIsEÁØÌSRÁ!;@ºÈ/\{ôEÛ±&K:¯"ÏXªè@™/Ì™U‹Ñ…Ææ{+ð©®®¦¶¶ö¸@Ó4>ÿùωD\çb#ãüü†[t¡³,:ËT¶|—6C"ñüø~×=S‰”m窶¤‚ñáQeµF 0ËÒå²–Üþ˜Í)ˆ.oðÊéX~,¸ÔvEY_Y�æHwìPzlʰ_']0±%toÝÊï¿·Ò´5¡qFÕÊýe´ïnãöëÿ‹LÚ;Ú©ë�5æÏŸÏµ×^ëyî‘;îcÇó­MÁ:‚ÕÊÆO‡ c­Æú’jÒ“iÏxD °ö£¨UÓ{¸›lƾ ¼ª¾&_Æ.–NàÂSm6€yLóëD¸ÀeÇb›þ6—ÛÁ…è¶p„–Ž�,p†´Yö²æµI}%ñ¡#æ‚)«˜ª%�Jñ¯ùFú‡Štݺu'Ä>Ÿ�øÃE¥Æþù[H >MgqÙLt¡+–¸ýw@ø¨ –3:8ÂØ€»ÔO¸ÜÔýc™x4i ™Ÿ{h×2i;Ó´,œ•4�NƱ•beÎÑå�Î�~ç±`©Í"œV¡è¶¢=_9÷^:¹Væ=¡Ä„µ)W:08·vÍ¡)H)ùÜ¡·ýèio«V­ò<~Ûm·ÑÓÓãíÿ.XPT thç·ßÿ%õ�*šJjm&°*�—”ÏÆ/ütío§¿Ë¾‘cêìiyµÐ—Ê_·¨b&8кÃQƒxÁš¥ #³ $G¼¬g;˜ÿ—.v¯f�V8ßœR–7Ú Û¤¼“?r^ŸpzõjBHÇxðͽGÓtΨY¬ҩ¤S“|÷¿Î¾×^?¦N_¾|¹ç¹íÛ·ów÷wE¯ýÚ×¾æ)²™,�ýì~ú:zÖU.ƯùìA`J°ŠùeÓI''Ùôkw‚îÂ5KñÍöíñ^ñ•0-RO÷�:÷v]³ìl“™ûÃ.ÐÌ ÃWB »l�ÅÀ¶dôÒ ø5OýïFñ.¿-ÝÆ‰”nîNª+o9»î4”· �ŸþÛ-lºë‘cŠóE‹�øíÞͽ÷Þ˦M›Šª‚ïÿûžç:÷æÉ;Â0 ÂÏšŠJdW  �eÓj~~÷£l}òE—j;mýjü?íñ22–UÍÅ'4v¿¼ƒžCvÉÖ8³‰ê“!w�¢1¼]\¿–/¢íEç£J€@]QLÍH±#›9eÍ€àæÏ~‡ûo9¾‚Yëׯ÷<žÍféììàúë¯'“ñ6 ¯¸â 6nÜèyîîïý”Á#}  ¹d õÁê¼Øõ �Æ’Z:öâ–Ï}Çuí¬eóX¸ÖÔ²/½RPªdnY3éTšGú€ëš5Ï!’•{GÚ)ZÑK±ŽëÁƲ¢vž“lp˜¿:\,åÅSHéQe]q uwU) lâ"÷*êsõŒ Ô‡Ì�Çßþûá‘;î=nƒî¬³Îò<ÞÞÞÎø¸™"¾eË~ó›ßU!_øÂƒž.Ü-Ÿÿ.ð ‹Jg€0!ð �M¿~ÄeÉû~.¼æbÊ*Ë9<ÑÃpzœ€îgmõb‚z€ÚLÛûήpY„¥g­@Óu3gàXCØ=1¡ ‚î$‘ÅÅÀ–øé« Ù . .‡³Fž½&ŸDJQ´Ÿp0“p‘f•5qy˹DaF‡FøÖu_æ™ûN¬JÚgœáë?|˜XÌ´¢ãñ8·ß~;cccžï]¹r%W_}µç¹ÚœïõÁ*¦…¦äç%‘MQQ[å¾ßk¹ð½3idØ6ºCJVUͧ>TÍ`Wßÿço¹]ÓÕ‹™µtY™eÛÐ>¼L*P¼éÀ 5�¿Êµ�›‹1€­ñ’¿<ä¶ø…ËUNIE H»�äWÜJ ”J¹`êéœ7u!=ÈáÝù÷ÿ /ünó ¿¬¬Œ©S§zžëèèÈK€‡zˆgŸ}Öó½�@€ë®»Žªª*Ïó7ö;$'âèBcIÙ,4©“1²œ8ÂùWmdÙ9“jÝ%ëùèMfVÐŽÑô&‡(ó…Y\>‹‰Ñ7^w½Ëò×ý>6^{þ`€íCû‰g’®™N{ËCè¥~°†ü*2”·5Ô`ÖôC*�� ™·Û¤·±hHåÃ¥,lY]·ˆ¥us ùƒ `Ó=�ò³oÜÂèÀ‰—Ë=ýôÓ‹ž;|ø0©”=Iö3ŸùLQ}¿fÍ®ºê*O£°»­“ßü×/¹æ“ï§Êeni¯Ç³'~˜¦êZ>þƒ/Ó×Þ�ÐSê «è IDAT¦7 88q„­Ã{AsØtÑ~ýís`›[¼¯»x=‹Ï\N,gÏèកbN¸Às/�VâG/ñ«û|½�¨ lC–ý:‘疯–%½�AÕ4Ò©2 †‚°®×L‰TqÍ‚�¬®_D‰/@<ãûŸ¾‘ïê[oˆø+V¬p‹Òé4ííî=‚­­­Ü|óÍEï÷Ío~“ÒÒRSH²é®GòÙ;k*ÀÇD6Å}=ϲ—#Ô,h¤~~3ã¾$¿ë}‘'û_ÉWÑ„ÆÄXŒ¶�û]÷®¨­âï¾ñ ¤”ìí0}Ä?#)ÄÚ\b@ úÐJ\MÌÂNP†’"¦ôBÂ�ôví¤r@Š„Û[¨‹Tqþô5\9ïBª‚Qb£ã<ÿÐïùøÆ¿aÓÝ�¼á|¾œîöb€X,Æ¡C‡<¯ùä'?é’ ¹‰DøÞ÷¾çy®çPOýêa²YÓà;³z‰dlÛÏÝG6ñ‹ÎǸ¿ûºý6é8<9NI$DY¥ÝM ”ùôm_E×5úSÃléßa#€ËQvΕ´¿G ˜Mµc¦SØ@uŸÞ´r¥ð \ÒÀ´ „�iB¾k§ŸÆÜšÂ3ÄüÒÏñè/dÇó'§0v±`,cß¾}y+¿¼¼œŠŠŠüϦM›Ø°aƒçµï{ßûøáÈ /¼à:wßÍ¿fý»70mþ šƒuL-©£#уµ£¿¨Õïc$ãò]ÃèÀ‡^?@MC-ïùØûhY8›Tv’Gºž'#�"øjŽD2—j†"͵€î\–ÕN°«]? T�BÉ ÌÉ“<ñtZýÖM_F‰/ˆÐ}�=Üño?dÇ ­L&ON­–––¢)^º®³qãFhnn¦±±‘©S§ÒÐÐPÔÐSœ/ùË\rÉ%.ì �šäŸ¾‘¯ýö?ЄƢh =É&eÆž8áñÇz·pÉâ³øø¾DltœP$DE]5™t<ÍødܱƤ‡¤U*�ƒ«ø¥Ð5—:ÇÚéåsÀÂð ›[‘7æ$ÞhnбVm¤Š‹æ�Å”hB #Ë=ÿõ î½ùNNöhii¡¢¢Âó\}}=7Þx#¡Pè Ý{ýúõ\vÙeÜsÏ=®s»^hå÷w?Âú?½ˆ†` M%uœèòÙJ¹y£“1~Ñö0 ËgÒX_ø̰{`Û÷’ͯüãS‡BØ[Û@({9 šÍÉiÛU†ÌÛ{ž¢Þªž¢©N‚¡ÞÑäže�ó¹`î:„€äD‚×_ÞÁÏoü½ÝœŠq4  iÚ&>@II úЇxòÉ'q¨·_ÿ}V]xáh„eå³éŒ÷’2Ò Žb¥¹ækyÕY$Û†÷³mhÒœûbD—ÅI[V PzßÊç4muedÖP,~/ÁS¤×F®®UÞì¬+‘R²ýùW¹ù‹ßáÛÿøÕSF|03}uýÔµA¸à‚ ¸ð =Ï�ôñë›î þ2Fg¸ 3Y2vJ ‰xf÷ª#‘HÐÚÚÊŽ;hmmeçÎ�ôõõ111Áøø8±XŒT*Åã�?Ζ-[<ïÑØØÈ׿þu>ò‘�¸Î õ ðØÏîç}_üþ€ŸsjNãÁîg]œ”Î-̵Ÿ)&iR!—tcC‚ÌO4& 䤫ÏÄ Ó¶¯[î BLyïrô�ßêê¡[¥^­ ¹êÝÖïYuÓ¸rÕF³Á“€®Ñ^îÞöBüåª?¡³õ 7~ä+§”f̘Akk+ee®èmmm,[¶,ðZUN{áî»ïæòË//*5V­ZÅŽ;<Ïëá[˜±dRlꕱN¤‘+õfVþ,”‹7 D¤‘?Wì)1 µ¼|îúÂkõñ¶aç9µ …ΞWUÀ0J©Q™•n½!Ý ”PŽòŽg¢!òŘš*êY0e&™l–ç·²dÝrÖ¼ã¬SÊuuužÄxöÙg·OäQ†aÜpà EÃÅÁ`�/}éKE¯ÿÁ§o̧º-*›APøQ}5‘ŸOiï,Vìk©š×&ù¥«q¨#™ÆHºžãˆW0È–`gÄ'•ô˜4inÎXÞ¼�Êp”®ƒ|ïÓ7Ð×Ù‹”páÜ3ði:ûúÓ1ÚÃU½Öª”}jÆÑölݺõ„ï×ÚÚÊoû[ÓbJ&9xð ›7oæ—¿ü%ßúÖ·xðÁ‹2Üþ×vóÀ­æõÚ`-á†ü<šN–´£5ý�ë:êB]Õ­ôy©áôýíF{6‘v–’3¼l€í(9c™á�º2oÜÁ*ï))áôf‚Ã�û £ƒ#¼ôÄslü‹ËÑu� óÎâÁ=¿ç•®�¼kÞ9\úÁ+¹ó¦ŸX»vmÑsÅôù±ì…/}éKÜzë­ 266ÆÈÈ£££EacuÜuÓœqéyTÖU±´b6c]$³“…¦Ò âHïP�ÄÃ3\ “³Ô}ÌŒM:/ÏZß%vª/&‡K&�1{øÙ·ÿ;¿§ýÁŸÜÍpŸ¹={NítšËëiì¤c´‡5�MÓìéo:lß¾ý Ýs×®]<òÈ#¼üòËìÝ»—¾¾¾ã">ÀøðwÝdnV�ú#4†jìA5•l6o_Ë…N"…#ÂÛùs°„©ÆÓ®J)1ýS‡­¢’íB« A�k%�eÍ èïê幇 = ’ñ$w~×\åšÐ8½y >¡óè¾g©˜Rɹz‘YCà$¿ßïr'''óø¿jü½™ãÕM/™¥a%T*l]EÉýíjL¡dMIwŒEͪråHûFÖ Õãzö}64H/Ú î™è™;4³® Á‹�=CÊ�å¿üÔ ´>û ËÎZEKÕTZª¦²¨ƒ'ö¿À†«/á÷¿yŒŽ}‡NÚD/]º”žžöíÛGOOGŽaÿþýŸŸŠ)ÕLf'y²çeö�w8Vº´‰t”ž�y»Ÿ¯z…‘BÓIURäÞç( ð¬+$è T•³&{b¬´%%/ ™I„ñùýG%ÆC?ù ç^quMõ4Fë˜]=�Ãíì:ÌéžÁÓ÷=IÛÎ}'…ð�³šy÷ßÿ9ë.YO T‚! RFšîä /öï¥+ÙOñ5î6qj.£ÄÞI4‘M™E­Õ)¤`j¨†ªp�`´÷ ûÇ; ]A__5ê¤eWÙ BŠÔ‚Ñ^ܘŒ“îwIçß©/ô"³°³NH‰ PÒ-v¶Š3Ö”U2½ª�WŸy¹¨�݇�°nãz4MP ³·ÿ0m#�œ>ó4þ­Ï¼l¶Fy#QÀ² ×.ãº>Îû¿üa¦-œÉ( %�°ud//�ì¦=ÑËxf‚£o§*ô·E×T=¬ê^i™fJË×±ô=‰ºƒ ¥ÆXŽ]”«ÇlU󜰵3í^º=Šƒ =¾Ÿ‰Ý®ryÿ`-ò¢ �Ŭ1›¯ 4AÉŒÊB¿?«îŸa¬Y°‚�/µÒ}¸ë¨Dêëì¡¡¥‰¦YÓˆKI�Ód$㢳/`ûó¯2ØÓÂÄ?ý�gqí—>Ì{þñ/¨j®cwì0[G÷°cü íñ^&² ;òfs®U#J:ðxi3ÊÔU¨2BšjÄ£pî¼áûΖ±**X ~�q¤Dù­2�Â\–íÐ}ÇkdÇmu�û�¯¡�.†Ælò²#=” 3’(0ëKt÷’LO2géüãJÂøÕwÌÄø Y×¼ Ÿðqh¤‹CÃ]üõW>zB„Ÿ6Ÿ½ýëüÿšÅk—±g¬�_u>Á–¡]I ä!W5*–ŸDCÚ&QªâÙ#ò–'¾ÚÕSæ:{*ר«ÖʹS¯/\c8™Ì°‡Ž¥t€½Š;))v-¡h„­ƒ»y¶§•Îx/Q)kk—°²j3J)ÕC„ô õ%ÕŒ¥ã NŽ:V‹´ç-:rí8¼´çAHéZ•ªÁ¨®ò¼Ÿ¯V¹^*:5àcûÒf“ØÔˆÄ—)` ñ½ƒ >º¿�ÑUpÿn±yìh 0ü Jµp™ÎšYE®&m®IÄP|„µÍ¬\³ŠÖç^É—@+ ÛîÚÇÊó×R^]I$b"�àðh7_€å‹–±û•� õ PQSɯÿ—_w ÓçÏäÐèî?¼™Ã±n&ɰ¶n1ç7®¢¶¤‚€î'�LóìOQV%\¡)\GëÈ~W´ÍS$¸ò¥M2¨ºWZypÒ�¾å™·J(è{éXÕŠ=’3<앱œR%ç�#ÁHg|l?‰ÃN‰þŸN àX �Ëø š^P‹Ðó¼…i#Ë@lˆås—2oéB¶¿ð*©Dò¨7Þ¿mç_ùN4!ˆBîààH+›�Fiš3��Þô9¦ÏŸI‚IžèØÂK}»�4Fj¹¬å\¦—5  �T"É]ß½ƒ¯»žçî{ŠÊºjæ®Xˆ®i”úBžèö¬£ã,¶nÛs#UÿÛ½^J{W%znå^+Œâã¶(žÍÝ”¶Ï�^‰—·‘Mrä¶W!kãö¸EÇø‰2@'ð·Xy‚F"ChFe¾h²idÁxr‚þña–Ì[ÈʳÖpp×>ÆGÆŠ¦^� �PQSÉŒ…³) †MÅè‹0�eÍŠÕ,>}±LœûyøÐs OŽQ©fMýRÎlXFP��óÊãÏñµ÷–×6½”/·¿u7g_q!‘²R*üet'ˆ¥ã6rã¨J`×ûNûJÚ (½@çŠÍ©ìîž¡bŠ*‘v�¯2ˆj[¨ïÏ¿Ï(0ÑÀC{‰»}ÿ»€;¼èp< ô0ûš A,Epz%zØŸ÷ráP|”C]4OmâÒ÷\N(&•Hº 8çÆîWv°æ�g.+EJƒC� OŽÑ6ÒIûèZöq`¬ƒ_ g5.c]ÃR¦DªB°õ©ùù7nåÞÜéêÀ™N¥aÍ;ÏF (у´MtcHÃáúIÚ']±wÕPs¨D�•.@:À#Õ¸S @Ë•´>#²v“6Ïð@þ¬é±$ÿµÅKÕ½+ ø�0À!àoȕ͚;WB3*-[À\M¹yñt’=½IŒsÁ¹ç³òœ5œvÖjZ¦‰–.‹­,gÊ´F¬\ÂÔ™ÍTÔVÑ5ÖËÁ‘.��2RŒ¦&HeÓ,©�ÃųϦ¡´Ÿæ£û`'·~á&î¿õ®¢=xÁ¬Ç;wÅê[‰ú# ¤F™Ï‹,!¥sázƒ6é`sÃìÆŸ*¶épkPƒ÷R¹¿á’28BÆÒÅh6#QJºü©Wìÿ1à;G³~Žgü�¯� …ýTmœKpJi¡á³nÁÄV�\‡Ï-‹X1me%³u¬ÕæÕìöRbé8íšÁĨÙÄJ7¯+­â½‹/Η›¿û?ƽ·üŠl:s\_º®¹�ï=ý„ô'‡yàȳ¤eÆÊÅ4ûš“.<Ò¬ìùzÎ$ ÖºKEH�T.éŒÚ=‚<3Ò1ƒ# iæ_È‚�oæÐ¿=í•ÿ¿³­¼wÐì8à—À—rÀ�OßÕG ¶),İ6!æ¿ ^mßÅ«�») E˜­&\§ù�de†ádŒþÄp¾u,ÒŒ4JÃ`ve3HØþü«ÜúÅ›ì>1¨xàH/÷~ÿN.¿îj³“gi3;GÛòE( +ßÈ3�+,d)Y©T¹°YÿØc.�.½A#d¬2–!]ŸÝpÛ2“eð¡½^Ä¿³m\Ñq¼›è’–©±AM+iŠ¢—•äÛ¿äkØÚ¹™§³iF’côO Ñ71D|˜Áø(ñtÂj§?´ºŒ§³TÎà—ß¾�Ûöœpœ@JIW/ËÏ[C¤¼”ÆP-»Ç‘62._Ü—wä×I%×JÝ}+Ç\¸¾ !Ú=Óñ�ï¤ëæ—½žàçjV×Éd€œAø©üµÒÜ?n©D”èùHKžè9‚::^ˆO¾�œM](�ê±É-�Ìš3›çúý’Ým�4Ï›AÓìi”ú $GL�ÙÒlaWGξ3yÃ)²ñDí¼¯ž7\*ÄîxĬk'‡tþÇ‹d'Ò^þ™xŒ;9 �zÔ„#‘!›Îš^Qç ao©¢)Ÿªl­W açÉl]hœ6)½‡�Ðu ý 1Á�ÖÝœ{åü?i#CG¬§(Ré”î¿í�¢uˆ.`H:`e%À(’L’Ãû»ïh%¾ÏÛyðúñÎÉ-¥ñf_¡¼*ÈôÇÑËKÔ•æ ™W`uúV$€VhzŒ¶¿QŒÃ\É«žØ §Ì¢¡¾�­O½Èä1r¼F*‘¢¦±Ž™‹ç�ÊN²o´CGÓv;g„Ðñ·GTЕ'`“4Ò¶KD:âÎÄ3qÄ`xÓ!Þïõ•o¾Íñâøº‡Ëøk»‰G?@âà`^\aÓk†² rzÏÈO–a JG–Žùû±¶˜»r‘«ÃöqéL–ÞÃÝéUEöácßÏ�¡Šn‡ï¸'.ÿ_:C¬†iCØu¾acCñO'vôÑ{§gM‚ÝVÂÇ m�úC6è>‚RV`ø±$;Gó_\}®¬‰+.o2…%òŒ\¡󨑱^¶õíáÒ¿º’ÒŠè a¡ Ê«+�ÀD&�ad 7”T1ÃÀ°~rŒk2©aÆ ÓP3rßÏ0‘ºü5Ò"ªózCZñˆ“©�!C1ø ׿‰ƒÃtÞü²³Ü Vš×¿X4áÍb€ŸáèJm$3Œ<¾ŸôP¼Àñ†´m„4/0�aJÔIRˆô\G+e U\òÁwŸð—- ‡8ãÒõ %mc]dT°UîÀ#qC9&=,;ÓãÌ1”ÞÉ"9 SH&)H¿d÷8�ÿñ†ÛèÂéþFx2Êi=ŒÙitN� ²¤zb„fW!üºʹ‰šb#hö¬ä]FÅV@�– ™å¢s/äÅGžölÕîÉéºÆ§ÿû_™:{:Ýñ~^é�t®�£'äKŽa(ˆ�3þïˆ:}y Ç9Þ!”LÐõý-LöNx=Ú/€ëNDïŸl�\ŠÒœØ˜˜$Õ=FhZZ‰?ïÞÙÀaÇ Ç)´dS›ò 3€3šž 9ZÏ¢¥KØü›ÇŽ™DºüÜÕ|î'_§iÎtzãClîÙÊÈäxÞÈ”Rzß3'À™�é‘#`sQ#yîì!gÆ��øR’JÐõ£Wœ>¹ñð^‰o&ätÐfÌLâ|Yíìø$ÉÎQ‚SËÐ#~Ôæ°Bm½šÃ~lÀ‘£#¹(äídŒ †4X9w)#C~ÝðZ´î4®þÄûy÷Gÿ‚:/ôn繞m [6ì‘<�P°³ž�BH‘·qðØw€-¾à�àðÿmLaOõÆèüþK$ åÞ‹YØ‹?6ô­Va° Ò$Ú†)i®@/ €" ñâ_µ¢-~ …¤?1ÌôŠF–¯ZÉøÐ(í{Úò_¤afùù¿å²¿ý3¦/œÅÞ‘ÃçÎ!¤;‹ØJ–!cz¨¸†ñ¶!:¾ý<“½ž…-†,Hþ.¼(8ùã"Ì ¦¶P³V¢Swå‚Me Y;Œta6•Î…‚sû 4ÍŒhÂÌ50{¶Zï5Ï£ t¡ñîï 1RÃèÀ]Ú)¯©¤qV3¾ ŸñLœ§:_¢c¢×öÀÅz òØ'P º�¸¶}{‡ˆ°°ÇfiHâ»ûéüáËÎ]=*Î pRöÕŸ X‹YgÀçü´ª‹f›D¿f%…X¹…š†¦iVMÂ\Ö±†°j¢kfѬöíº‰Í®jfAÝLB�0šLiÚÆºØ9tÐóiÝ{Ôÿ½bÒÅRé’"‘W�ÃIx;¾‘W†døéÃôþr»Wl?GüsNÆÊ?Õ €•;ð+5|œ‘ÅS¨zÇ,ô°ß"¨²â…i± ¡åW{^]è¦DÐHMCÓ YHzPÇçó£ AÚ ©·“^™ £`§é©¸á_UF¸ŒCUäSU(\“KÒ{çÆ^è,6ŸÃÀ5À#'“Hú)d€d|®“ Ò}Ä÷P2£=( Biˆ”;¦¬Ã|#iáð4 |IÖ¢š›ÔÇâuG LCzìsoñŽx{ö & yx„ŽïmñÊæÍ�vàý'›ø§šöOkP6˜äHã¯A”ø6”*M°÷b·â¬j"I¾ö}.!U³¤CÎPdI„E˜ÕÉ.�LöĈmï!›¤¤¥Âò„‚ :°ÌÞFšO³‰!‹´¹·:›V·(bù»{âÀB' ¬6ÆtÄÿIb$³ôÞ½“žŸl#Ù6ìÐQm§Ï[Á�S^ÜPðÇ �ÏYH–÷ ù¨ºpe§5 …|¦Ë—g‘÷´�ßd€¬ìj«^P!RH—��W V‘m‘/¶�ûÃÁB:šdHGà 3 1+®gãiƶtÒÿÛÝñô±æåçÀ¿»Þ,Bü±K \ÜŠ£e­MD•ˆž>•м3®à×òñͧá+ ("<6�ª u…å�îxÙn�À^÷/;™%Õ1Jl{/#Ï´“Nk.&�¿²;™7“LÈ�(fžá;8ÊF-ì'Ø%2¿†Òeõø*JÐKüèJ¢Ê/›P(¢_æä…Çváaú;@!Çîa€Éá±­ÝĶ÷’<4r4Ë>72˜[·®Æ*ßþf�·äÆe˜Î;ê»4�脦—SqN Ñ•�Ÿæbõ ÊA‚QE¾Â ž!b;Æ/3YÆ^íadóa’‡G̺üÙãŠÌ>…¹gï¾?椿• ¸³”Ù¹Ç{QdQÑÕ�„çÕ G¦[è³ìñƳP0Î2ý2†ÙE%c�‰¥ˆïdü¥#Lìì;Ñ[o¾gá#£ì «1@ž¦xômËm<~Ⲅ’ær‚S£ø§Dð—ÑÂô�­Äo·ðkfsl¥™¢4,"Of1&³ÉŒÙi#ž&3š$Õ;Ád×ÉÎ1gí�ãÛ€�aÖé�x«Lô[•Ôq!ðE`%P‰¦±é=ä7=‰ZÀg1€–�@"ÍÆ &“Œd#‘&›H¯H÷r{“’÷àñ·âäþO`€ÜXŒ™ýr¦åFV¾E¿ç°åÆ=‹Y•cÇ[yRÿ'1@nT[ ° 3âxá› hy�¬µÂ^¶`�·Ç)gÞ°% Þ…™�l•ä~S~:¬Ï|—õÂÿ”ø_Èe˜.ÏNì�²¤„nÙ¹NéjÇtI¡Ü’aýd­Ÿð*fxûÌdÌñÿ-«èÿÂðÍÀK…D,ª”Bþb 3ð2fY郘q‹Ž7�{3ÇÿÌTµ‹y#`®IEND®B`‚caronc-apprise-dfec414/apprise/assets/themes/default/apprise-success-256x256.png000066400000000000000000001371311526720371000275610ustar00rootroot00000000000000‰PNG  IHDR\r¨f pHYs  šœ IDATxÚì½wœ$Gyÿÿ®ž¼3›s¸œO—twºÓ)¢€„““ ؘ¯16Ææ‡Ác‚mÂd ƒ�È’‘‘B("¡¬S–N§Ëyo÷6܆Ù�Ù õû£{º«ª{fçNñ¤-½V·;ÓÓÝÓUÏSÏóy>ÏóÀ̘3cfÌŒ™13fÆÌ˜3cfÌŒ™13fÆÌ˜3cfÌŒ™13^žCÌ<‚—Ýhæm@ ¨j�:ç§Öy-„�Ïä�,0Œ£ÎϘóZèö3�xFÌŒw4g‹€9Àz`>,G°CÎü ç5Kù½Ü¼K ¨ü[ú]GQ�ßw›�½ÀvàÀÐÌÔÌ(€™ñìGˆ:?ÍÀYÀF`­#ì/å±x¸¸¦œŸÜÌÔÎ(€™<æÝή¾Òö�޹~"� G< <áX ËafÌ(€Wô8ÇòÓ�ÙŽh~™çAGìîv”Ãí3Kaf¼RÆEÀ��ÃÀ¸cËWèOÎy‡�grÑÌò˜/§ê�þÊW° ëÏ•Î3«wžá̘qN¨Ñ ¬^ ¼¡ÁF¨&B(%TÁŠG§¢„’B‰V,Œˆ†°"!DH€Y”ód¶@1“§0™£0‘#Ÿž¢èü^Ÿ¢0ù‚cx»€�€G×afÌ(€—ì8xp ° ;äö¼ +"ÖQK´5I¼»žÄ¬:¢mµ„a¬x„P4„ˆ„@„%AX @BÚS/œ% Š R�}X"�  Ì)ds3ŠSdúÇÉ!sp”©þ4™Þ1d®ð|>×"ð8ð p pËÌR›Q/¥ñÿ€¿Ç&àÔ»�šyMÄ»j±"!mÊ„1ƒ2`Bmš°¨2„öŠD*W‘�¢_^ í7d®Èä¡Q&v1¹ï¨m- ¤É NRÌæŸKep1p9p›¨43fÀó*ø›€7æ9¨BÉÉE-¤–·‘˜Õ@¬½–hs ¡hÈTQåÔ ¿1 þ-÷oç=©x¢/d9™W>!¥k]èKйÙ� rýãLì;ÊøSý¤wRŸz.æe ø&ð àÞE0£ž�ñFÇÔ?›Š{ü;}ÓÃ;0ŸÎ1úè!ïØÅÄ®a ™Ž8˜>âX3cF›È‹�¯·¿Ð§f^u+ÚiØÐC´%éÛ‰}»¼PLn øÁþ€o"eÙÉ�/]{Âüœ§IeWó¼#¤bhHßIÛðè(¥sX¦œ‘‡2öÄa&÷%7<ùlæòwÀG�mØIL3cFT€?>t¼'¨YÔBãÆj—¶ŸU‡�lÖÂUÞ¦­üU‚àE�!¯ji~²¢ Q-ýS¢* @ÃHU}ºEÊʆAàûöoÅlžÌÁQÆžîgøÞ}Lì|VɆ—?˜Yâ3 htc#úo:Žçuëºh{í’óš°b;ô. d­$ÂBFCJE¹»¬É.Ü}WášåÞɤ‡&JÓ¼P°Eéh{¾”z±’6�øTŽ® ÞÿK†ƒô©©œ§X�§ LîäÈM;8zÿþã�ãÃØ@áÅÌ�Šf€2>|h?¶§'¥"Ô¯í¦ýõK‰wÕ¹Bn ¡£y&WØsÍê²f¯‹¶‰ifÔHÁ±†å3_5Û­àþiò ¤_w�‘&6àiI)e@ÐÒûg²w”¾ëŸf䡃Ò9¿1ýèþݱ fÀ+tXØxW§ë‡ã³ê©_ÛMÓs‰w¤laž°[Í_7MÕ�—În ¾ ä´Ó拊’PçQbŒR:»²slÉM � |ª~ð��²œU€†1ho( ÷ÛIÈI3pûNF:HfÿÈñ¬�û€?ÆÎL,Î(€WÎè>|öX?˜˜Ó@Ó¹ ¨_ÕN´Ù|QÚá�ß-aùŸn€Ooz>�—2àG€Dé÷ªt‚ �Êy„²{ �úq•­<Ê�4£ º[ £êaÍ �ÒïI¨¶€¡9¤„©�qF?Ìàm»˜Øu\8Ág�o;.ÂŒx™�·8æßIÇò¡pcœÎ·­¤~m¡DápëËRütKØ~¿"¬Â‡Ü{Z@¸Ûµ(o²‹òÓ¥* )¤§d¤zró³:ׯüÞîaRè»/>èNhB,ìúxÊAúH‰n)¨ošÊFJ¤”&sŒ…•v÷*w~i–Òþ\Ò¹Ÿ*�ö€”t L÷ @³H Ål޾_>ÍÀ­;É�e�åéd�ßïäÂ&¯�ïx2p-v²N•‚£~]­,"Þ^«˜øŽP[Š�o àX–šÀò« ¶ÄÁà W¯4›ÂJ! U!]¼A Gâùý�0á4€ë>”Ó%Aá¾*ˆ�ãîþ?u@zÀb€bȧÿ×[9zÿ~ò£Ç¤öoá¹LcšQ/ø¨Þ\‚Mî©j4œ:‹æs�œßd 7%á¶Ü]ܲ„'Ãîߎ¸Y lÐÏ�›@ò]¡Qý{,P VØýIÂÿ� LĽœ…à £,“nT’Û òSÆ0ƒÒd,ù„ß:TßKïdঠݵçXÖNøìâ$#3 àÄ'_ÅÎѯjDškèþ“Õ¤–µcE,oGµ,„prꑈ’Ÿ¯X–ã í…ƒ ô¬þàd¡šü=Xƒ dù™*8W¥«PÎ"(»Êa®$:–E@*¡«d°�¹ >+Aù[¨ŠÇÑwÅ|žô3ƒì¿r3ÙÞ±cYG·ÿŸƒÌ(€d\|�*ãú"bÑxÆ\:ߺ¶ì�^Xn9á˜þÂò�;!<Ÿ_X–ã8‚êë˜Õ%_Xx4ŸK Z¦ÀWœ5¿¸Úøæ)¤wJþz9\Ï·ëéÂA›¼ô%™¾ÉC8�)ÔŠ}o2 uBúÜ éœ«˜ÍsðêÇúÃnŠ“U»ù}ÀŸ¿�Q/} ïÃØþªFÍâfÚ^»”Ô’VG¨-O¸ÀÏõñÁv„·‹—°ï}¡cA8€(³mƒbÐ……_ö'U¥å M EÀÎëñòP®äú ×g×1ÀZ�~…`†ôÝD¢@ï@?—/È(e°µúÇ蓇éûÅÆ·ôËúú(vÚq~F¼ôF6ÅóϪúâa‹¶7.§ñÔÙ„“Qw÷vw÷�e¯õ’9¯ìúBvHÐ’^t@µðbîÚ#B;à“‹`Þ7qâ˜fÒŸPÆÉ¯ä+P!®ï³¤ÏÇ� ³Ð<‡‰ø0i:'öîî�Î/¥ß|Պȧ³ Þ¾‹Ã?ßB±úz‡?À¦Œ˜Q/�±Ò™˜“«98>»�î÷¬&ÞUï‚{.h_B÷…°ý’oO é·Ë/æï€v¢º2î`† Š x¾Éô¹î%S¿œÀ@q%LAݼ…Ïò6”ŒDJ‹C㘘¢p>WÆ p-)ØÏ}»yÝÀå9øÈBÁä!ítF˜AýsbÏ{¾qï±`�8Í3 àŧbW�mœöˆuëºé|û*‡Ìƒ·ƒ»Â�Žü çu%a9Ç é’�J®.XÇ* yÐŽ¯MŽ/eÐo8á¾é ¾ÊÁ Ÿ¯,€Fè'Øb�²bDÁoú+öD9_>H1È ÏI $4­ d¤”Èb‘}ßy�á»öV›[0Œ]õù¾ðâ�w`“{¦ã´œ·�¦³æ{d�@gy±}!¼j¹B(ï—ü|MØ…ò¯p³ÿJa¿’ Êúú~^˜€ïgÁ Q9ß_h¥º§Ð*K/téÛU¤B ‹õÉ n€ÿ3+@úceéÀ>àQÅ"æßîï.YÉ»ÿ�Ûwrøº§È U]ƒà�Ø•ŠOÈ:�ïûcÀw¨"ÆU³°™®w¯¡ne§+ä^lßó?N¨Px¦¿£ Ô�½dhX�úºÐ-¡(¡$©÷¯žÏSH¸ÊK;V(ñE (q˾¦…äð¢ ÞÉ)}_¤Ä}] Ö<ðž…Е§ù<\%(\2•ú}\5©~a|¿Ò}¨‘¥ zée/‰Ã;®fnÉåmdö��š–N,�·iìgrF¼0ãkÀ'¨Üê€úSºé~÷ÉÄš“ º¯µKãµô0Ÿ¥,P7: àA€"|Ú9ÔÅ�®Í ¹ª%¢ja0½Ó*×Þ®`*²ë *šàyç•®¢3š„"@Âå-xJÁ8ýZ~%¨„HƒP” ú=Eô>ÊwÁSRÊ9U%¨§(‰hc‚ÆÓç�Ÿb²rrQédça÷t<á„'¢ ð[ìn;ÓúûM‡ß�Ú—|}«dèI<–êÏ[n$@*À p>ï2ÿ!Âò¢�¿ ä L–¯'ˆRÿL@ÀîªÁ¨Êÿ¯ŒøË‚•' É’û à~w (5Øôåƒ@=|¥Šµ:R/D¢Óˆ¥ârà ªÑ…þ߸V½»“Zȇð› Eúª*)ƒ£¦5`¸ùÑ {.½�ñ'ûªuUßÀKœ5x"(€»�Ó¦;(1¯‘Ù:Õ¸Jν%ô0�‚P•,¨2­'îoáÿ(¹%kBZJ¸Ð­`Ù+Çrª™1௠Dñ°tÔ>Ð  N½VTR}M…�é(¡4Ýp�fTÏÇç×MoÕô—Ž pc0p÷6ŽUˆnaç7,õËåÒSìý潌=^Uñ {°ÉÌ(€ã˜ÖçP¿®›Ž·­D„„ÂÝÚ®ŽsýoKhœí˜Ê/–4Âr”¬5Ð ðó}‚.*šþXåt€[AL–S:yH'óL×Y X1¨ [PY·ÖO¶^HXn µ°&BˆH(LTD [–¨d�¼,�-L‘ÍçÈsŠE$’‚,Rt~¯¤\’v�^úb …`Њ Syö_ùÃØS-&pæŒ8¶¾¼kºÏšGë…‹‘°zVCzÚ.¯„¨(Åí-Ã|/…„Jæ»À°,�€�¶…`)®Šk ,Ÿéï—Q1íoË¿‘?`X ¢Bö_080�¨(ÿA� F „ $,ÚbM´'šh‰7P®¡!ZK<#@K”u%Òù cSã eÇÌŒ025ÊèÔcSi²Å\yE XåHCÚg¥Ô€Áb¡HßõOsøgOV³žÿxvÅ¡PÅøð�ÓÔtÎ|Z_³Ø%Žxq~¯‡e™ïyŠ¡$¤–VÑ×Rªûx¤” –ŸVtÇ(®„¥ô0}|# ¸,(è;®ÒìUn*L;HÈ*B0@¯ùΚJpDO²�•� èªi%"ÂÓ#‘²\™0?R)�ë¥s“ŒM¥ÌŒ°oü0»GP0^Ť*š˜‡”E?FóEúo|†Þ«¯fMø§—𠽉@‹�ÕWYøÏ]@ë‹�€ì¼UÖ2^Wbø:�_x *›ÎÒ@Ć ,Oy€£@,/ägù”ƒ!j…••¦* +PÙj*iFc±¡²ôЮ#TRŽºŠÍYÔj!S�è£V<Ö7ÊóÓùH,¼Òg´¯á´¶U4Æê ‹�~£>ÃAîø*ÜBEµ"ÔF’´%š˜_×ÍÊæÅ4ÅëÊŒ’)NiY¼ï-5/L-Ó.LRQéK�ZÔB1›'½khºØêéØÍK_R�‰^jÀ;s©BÞŠ éUóh¹`‘æk£ê Âü;ºÂ€³p©½.Ї9p•„¥ƒ@©ÿg¹œD¡ÖP+›»»Äóý¡¾ NAF1}üxÊ4 ŨÛüy_µ@YÆBðì¬mZÂ)-Ë}ß·X(0>2Îäø“é Æ‡Gc|xŒôè8¹ì…|!áH„Dª†TC- mM$j“ÄkâÔ¤jH¤jˆ%âà¢>¥¸·ïqzÓdA�$ø’†<÷@*ä_ª±”üÉ£ùí¶iÌ„ãÖþtFøÇ«�›¦óbNŸCë…‹‘�Cg7L|-�·swÀ9…ŸîšýJ¾¿ßwêùYÞçÜòß" UX š¦¿’b\¶EX9?à5_³P­1§QvL³ýñûûr/+t,C p#2àO_¨�¤x]÷ÔFj´¹¼ÿÆ»xüqhç>†ûî";‘©zÁX!‹ÚÆzZºÛhéj§cN=‹f3gÙ|zÍñQ~;”îçÉ¡ìëc"—!¨K‘ ú"¯IÉáŸ?ÅákŸªF \Ü<£¼± ›<1·ÒA©ít¾k�gŠ»^°³„f¶º€œ¥òãý.DÉPÎgé¹;¾o FhÐ>ÎrÉ@ŽK D"PÐNï”]h5õ�ý ‘–�§ 6@ó‘+àq>á(Ý㚦Ell>I…¸ü“_ã®_ÞzL_͈Ģ$ëS4µ·°â´5œýö é˜ÝÁèŸbëðnÜŽ¤¨��‘~‚ƒ'”ÈE…©<½×<1�%vÕá×OÏ(H`÷p_]é øœFfÿÅ)š¯íîÒÂÈã×È9 o_˜ žUr€�ÂWôà zn‚jú+ÖA‰\2Üð„T€d àëѾ`�J#®4¹^¦@dPÛ…Ö¸Sa‚2ès~pN"‰ZÞ6û\ê"I×Ú¸åÇ¿æ{ÿ~…Ü Ã‘™¿r1ô—oçäs6‰F}÷],J³G¹íÀƒôM * @˜¤:t  (C!�cÏ¥÷0öØ´<�Ç€MÀä‹)|/ð[LCñ�Ïi¤û½k°Â!`²tpÏ ŠãëÔ\×eP²Ðôl¹ T]K#•\ Ð- Kµ ”´àRÅ`Uùxi°þ ÁîqaIÁžŒ„ª8»IºÎ½ZVˆ°&l…Y!,Ë"ä€oRí¬(/»ú1$zž‰ù9t�Sy-ŠrJór÷>'FÓ\õÅ+8r ï[`ÃýƒÜÿÛ?ðûÿûuÍõ$ëSÊE(ô¼~õú¹÷BS¤ñP”• ÝÙ=´s?7ÿä×LŽO¼à‹-3‘ቻæá[ïcàP? ­MÔ·6¢j¼¶D#óë{Hç&ÊŽjZP«) ünYéÐP<Ê¢“—Ž„ÝûN„bÌ«ë¦.’âPºŸ¼,h˜†VçI5“”×B‰©“Ú9z÷^d®XÉC;Ø ly%)€ÅÀw�¶JlÇ[VP³¨E Ôô[…Ö«U˜BQ ŠIªUÿQ麖R5¦¤ÝKÖkPU€T áw=„0êê±sÔ 9˜UÐHE” 7]‚sÛ׳¶q õ‘”ך,@®KíËS‘fÕ´“ ×pp²ßÃC* DBÁLD@ˆVSâ–c8oç§rRéš�&ì ÂüË]|øçJÔ­ë¦ùœùˆ�åݱì4 ÏSV1H+�¥šúZŒ_eJ%ÙG÷ÿµÆ –"Ü– „ý=ðÌTºõ!Ê$#)~§…¦àÔv³®i©&ˆ¸óº[¸ö›?áç߸Šß^yw^w3�ݹ™¡¾ºæöP“Jº÷Ñ•haÿd…¬VWзã ÃåreÞÄD„ËKh‰7ÒI¹B2÷¤Eìxl+ûy)�í?ÍŽGž¦}v'-=mîw�†"̯ïa8;ÂÑì¸ð³!uw(¹¸…lß8™[ ®z�_ÎaÀS°‹'–½n¬³ŽŽw­&\s¢m¥¦œx%¹Õ¸»â£»1x(ùìºï¯ *F-?�X$üéÀÎç-!Á éõKŠÅ`–ÏúzT/ 2 ]º®Öa ‹7tŸi{Îë¹ì—~üøÝ]Le‚óOf-žËß}ý_˜·|¡‹? N�pÝ�ßWœ@Y¶û�Ó}Ø÷:+ÑΫ;7r¬$Œ�pëÕ¿áWßù?ÆGìzü ­�tÌí¦s^Í�­Ô5ÕSS—rïo*“eth„±áîØÇÁû9ràðs¾HkëxûGÿŒsßu‘ÆÌóÜ~p3Oíòs‚Rˆ¥G&*æ <󉛦ë= ��/¤x!@ØŽÝÁ'pXñ0�ïZMbv£»ËYfJ®™ˆ£Ò~�bŸ:ËO©`«(ÝŠ( ±ã×Ö‡[%(dë…Ó!ØÒ·‘Œ×<uçPbñZ±€J?åZŒsj:¹ c£û†”’Ëÿå«Üü“_WîÌ 4u´ðß7]Nmc½ûÚ}ƒOñÄȲ`ÐZ-ÓþϼnÁE]§Ñ™hñµ+ Œ �€¢¶!å)tŸ\ø‰GRJ²¶?¶•oº›ÇîØLßÞCÏÙ‚}Ý_¼•?þÇÿçSwõ>ÂãÛ�ú‚êïÒ(*bÿ�ígû§n¡�žªtÙÀ" óBå é| 8·’Òi:o©åíº°»Hœa¶cøñ–«7…_øýV%·_XAæ·™ú[ <«Ã2Ã_nó5a�øcº¦ð«ï£ß»št~û)$Â1÷�'î~„«¾xù©é[]eÒ“d&3œ|ÎWèÚãMlÛG^u®‚ðW¶É>FÖ)–dvxjœ9É"VÄ�YÄkÄq%�‚€´G?ƒRA8¡}V''¿j¯yß›8åÕ›HÖ¥H�Ž3•™"ÿ,ÈFÛ~š¡¾A–oZãF B¢3ÙÊ‘ÉaF¦Æºñ¯’îJF ×Çl’P±¬r®uð€^N à\à?+ Øï_ßMÓ«(ü~Ñ÷í¢–¥øú–Þ�Óˆ©£ÄðK‚f)É?înmùC‹êù<ZQQ7‘ÈX.ÀÆ#<œÁÒŽÔzþøküƒNêQ[é;ÅCNmYáíNS9n¸ò:¶>ødÕsä@Ë6®¢¹³Õ]àÑP„}“}šò¥|;µÊP}aûµ‰|†Ã“÷�ÇF IDATÔER$Ãq›E°°» ƒÀ÷Ê&"€úÖFVœ¾–³Þt¾ è%âôíë­J!�=Oí`d`˜“N;ÙUa+ĬTûÆ3QÈà‹júRh¸@¢»žlšÌ¾£•¬òÅØYƒ»_ Š]ÐsAY¿¿»Ž¶?Zn3ý„¹{¢ æfE_Æ›â³qz­Y†eøøA žšÑg™Œ@¯Ü˜Z?Ðr‰GxƒÌ° e6±�z‘R ½è 0,Hu3'Ùé>ÇìD†îrÆ�ŽV=9Ù‰ ¹ÌkÏÝH(l/‡ÚH’¾ìùI�ü¢F$-•I©1+•ùR%XÀD!ËÎôú3G ‹ºHÒ�”É�3�=Jïäö§ûÙ3~ˆÝé^Ma {”‘Ü8™Â!ËVTAò#€p4L÷‚Ù¬9{=kÎÙ@±PdÏS;Žkïݲ“¾½‡Øø¯8U$aNm';Gö3UÈìþ¾NîzK-kåèýû)Lä*¹ËË€ò>rÎûŽk’þýš¯pÒ¦5”rw¦pç‘G]¦ YßϤT[�›=þŒÚ>Ò~aaS•K>sQÉ˲X¤€D%’¢ëû»ŒK6‰ZÚbMÌKu17ÕIÄ ëwe`{¶ìä²�}‘ýÛö×3ÚøÚ³øÛ¯þ“V©7=À5;nò•7qµ¶ ”0¾¥��ÿuçt}?î„ÌOX  »YBÙQ¿¾‡ºuÝš­ÇÖ�®5Z~¿bNŸµÔ4ÝRåÏiwÌuŒªÀ–g¶iIDYÚÑ­€ÌB<åc¹Ê Ý"A 㡚ŒjÑ a´¸RbïVŒõMK=d¸åª_óÄÝ�×Dízjç¼ã5„"aЭåàäéBFq[Ð ŽÓ\Gu]tÜ Ä›w[J(ÈS2O®˜'çü[”E[äKåwÔgŽB�¤$WÌs47Ê®ñC<2ô G2vÉîúhÊëÙ lq ­Mœ÷'¯#—Íqpû^rÙ©czF·ï)X²~¹Û>-I`!8�îóùüÁ(†­E£­I»ÜøÖŠü€ €+€ÑUÜÌ*÷f¤¹†¶7,G„CFµ¥h‡FöñL­Ùg©d£—žz:[PÁC3�_?FxÐr` /‹Pm+.Ô^�–Ç´ ¥&>àÑìë§úѪ2XXÛÜdž‚úÖ?|‰±áã[+#G†‰%â,߸ÊãŽx3[Çö æki¦)7À5û‚ÚgÝ}P5�¯Í—†éøÛGsãì?ÈÖ£{˜’9bV„d8¡ ¢¬€kûGçÛWnJ¸i¸¾.>A~QÓvõà*x¦+½ž½{ë8�(ÓÜC­hydCá”H x÷'+C¡kÁ‚:kœùV„ouÃBš¢uî{{¶ìäÚ¯ÿøYMØÞ­»Xþ&êšll&E‹ÃÙA# 7EM 2,½b‘A,zI3aveV¿³ª[$¸Õ TËÄùÈ”ÌÓ;1ÈžñC gGi‰7 EµïÚ1§‹5g¯g¸o�Û÷VýŒ ù;yšÕgo ¶±ÎñPC4ÇëÙ:¼Ë÷Íú‹j5jåÿV$D´9ÉèÃËå tG€Í'’è.ºË¢þz¨]Ý©#ÇZ2ŽÎ„óÕ¦3(¿ÚyL^€’Òkùúê»}0è/ª}”PcI¹Xfz±å�Rø…j-Ê„¶û©f¶ÐM±¢n1eQ_÷Í«Øþȳ«/‘�Ì��œdý«Osï±)ZËþÉ~§–ž@‹NºÞ¿.¬žpFDˆy©n–ÔÎaeýÖ6-amÃbÖ4,bUÃ"V;?«²¼nó’]4Ũ EÉräŠy f×)¹†åá¼’—²#lÙG2œ 9Þ }ßD*ÉÉçlàè‘!ö>½«êç4™ždû#[l¢�sùd8A¦�åðä �ˆ2­ÞK ,Úšdjp‚ÉÝe;Y@§=€Þüy¹ÂÍ5´¼n V$d€{–^\ÓÜ„ðøún{.O°,­¨§å&½ *D0ž`*-ÅØr몇–™ (\kE‰ÿtõPh¦¯÷AO¢�%µ³µ]é[ÿø%2ég_[bðЋN^FûìN;¡Å  EØ3Ñ«SŒv¾¥û–†ÂЇb,«›Çk:OeAª›¶x#u‘$ñP”ˆ&,Âö¿Vˆ°"b…‰…¢Ô†kh�71/Õɪƅ,­ŸKÌŠ0™Ï�“'0¿¦‹%/óì;@&—¥'Ù®%J…BaÖŸ*Cýƒì~²ú(ÁÑ#Ãd&2¬#YŲÊD(ÔR`nöŸR�¸"B@5¯F K –Ò×6Lɺ†e¬®÷ lر—ÿz߿лûàs:‘­Ýí|ýÎKÄ\PëÖþÍì›<ì¶ õ•Ñvn6iÅxuÛ¤T¸G‡Žòð­÷ñÈí÷³Û†û†ÈLL"‹E¤”X–… ‘¬OÑ5‹N^ÊÊÓײô”•®€™M;ŠH2ù,›‡žfËÑÝø x*ŸR’vBX¼qöY´'šQ¿ÄÄXšÿþëcË}�W'@–Å__ò1N{ý«ÜÛÚ3z�_ïùƒÛ¶Lï7è%•î¬ôwæÀÛ>}K%@ì<�/U à`~¹7k·Òpêl_Ç_Üß ñ Ôøz™]ÃoGø²ÿ¤ºû«u(¥£›µ–ÐØmj©p½ �å …•p —†¬r T0Ks 0¨ÁªðÛwùšöS•õ*yàwwqû5¿{Î͸‰±4Åb‘Õg®wQ¾žD+O�íÖBj©°˜[Ó¥aÜt¿çŸ¸ë·²ÛF�2•ÉR,mÂ�”‹E ù™ô$GfëƒOrçu7ó‹Ë®fÏS;±,‹Dª†x"޲\Û b…˜“ì »¦�}é>r2�¯ÔªD3Å%’§�¦Å-X ‚H,œ¥óyäöª«Z$%ƒ½GØxÑ™D%• ×pxbÀ êü 4ÑÄ¡TŒb:ÇÄŽŠ¡É“°‚/9pð/åÎ)Âío9 + �–øþ–zn¿ŽØ«l>…ˆ£$ßø‹Z %™uìÜì?É7�@³s°Û·ÔNBV@j±¥�Ѷ\U„ªp¡ÔD°¦a�ñfWÒ¦2Süà?þ‡�ƒÏO�Í}[w³æ¬Shj·-6¤‹p03 …UÕjHabSÓ ’á„û}¾í~.ùËÏw0)%wîãÞßÜÁƒ7ÝÍÐáA⩚»Z5œ 6’dEã|Æs“^™3­U›®|‚�£èJ¶Ú= œs5´6ÒÒÝÆ}7ÜYÕý ÷ѽ`³—ÎwjÅXd�}ㇻ¬å•6¬pcœÑG{)Næ*EØîå9Êx®@»ŸßÊr4ž1—šùMZÝ|wî,Ë×ËCþÕÚzøRƒ-%´&ArÓ*ýj„nvŸTø–'´%½`)#OÀR: ©ÔaKÙÅ-�ñ'K:£d¡Áé-«ˆYQW�îÙ²ƒ«¾ð¼qDÈe§˜Ksʧ ÙK¥1ZKof�ÉBF³NJë<б±q¹Züþ¿‹C»ö?'÷”gÛÃ[xèÖûØùØ3,Z³”šÚR±‰%,æ¦:‰‡¢ìŸèséÖ:ç¹.EY¤wr€9É;¬êÛ½p6“ãU‡Vw<¶• Þû,ÇýkŒÕ±ehS2§Û"AÀ ôŽ×ÇÈ N2±s°’Ì&�ë€â³}žÖs´V6TþÂu1’«:]±Ó#ã|ý#ŸãŠOí,Tº›yµÝ¼iÖÙŠZ×3+Õ^'úy|h›¶ÆRu)Þúwï­ú^n½úm-mh[A€µo¼¦®c{犵$i:g~¥K5:2y)(€Và/Êj‡¦5 ›½²^( mRøÂ".‚«ñÅ•'&ý JI³JE£ gÂ¥‡Yë /ÔX±À0 Uê1n÷SîEPo÷Úæ%„/�W+=F‰M¨×Úâ�´Å4}øãÏ_Î 5¹ýî¿á.÷ †-‹•õ œì;•· æØ?ÉúZ’õ©çýoúÑõ|þÏ?é„C=án�7󪎵„J¡X#ü¦Z÷yŠ¡ìˆ¶¾Öž³�Ek—Uu‡vîç±;<Æ®%,æÕuk5ÚÁê…_œ7[_¿”p}¼Òåþ‘½]|ža!H,l!Ò�PvNioŒRšRG¹vWº`j)|pé×²Ž•à«¹‡gj› av½¶âfsŠÒŽnh •ÏïZ#B¯þcnÒ»kK—6BÁ¢ä,Â"ì~fçÛxäö¶Ëô÷ÿý2ÒGÇÜgÕkb^M—Q°H�—yͦkŸÓɬÅó^�{Üöð¾òዿݨ¥usY^?O±ÆŒÊÆÊ9n9ô 6Qµ�uœý– ÜZ •F>—ç¡Ûî×6œÕ-Kü=WÍêP¦5„ãaÚßXQñ¤Ù{Q€>]öä±0µ+Û�q§ù„¨(ÿÊÎ'}~½É³ÒâÒ4)ÍRwRIÞºr0kr¡¹üŠ[Pî‹(®ƒs¾p%zRKiSHE’,¨éÒÏ­WßðœÐ~�ÕçþÁÅßÒ^;µqaöˆ[òèŸ:ê�Zá0oý»wSßÜð‚Ügïî|郟uAÇҜں‚öDÇÊ;Ápv„'†vhØÎ©¯=“¦Ž–ªÁÀÁÞ#îµÛMÔ„c]³2”SýÆ"Í5•.÷iž%—çÙF>M…úþÉí¤–·{ÂSJ®±T™ºKši¢º‰,„ÞÚ;�×o £ í“õgöк£uÖK“c0 -/\‰WÂÌ#%¡×%ƤQ¡½ï|vó;Níœ÷ÐÎ\ó•SÕŸçjؾ‡gœLkW»Ô…kØ›ésWu)—vÂÏš:ZX¶a%»žØNv"ƒ, …BÄS5ÔÔ&IÖ¥HÔÖ«‰ŽDI!ü´æLz’Í7ßË™o>ŸX"î>ó…µ=<:´M1ó|QA¤�L²ÌKu¹õ £±(G� óÌCÓÓ„§&3¬8}-m³:Ü j s”Áì~Ëßìû ‹³ QÌæIWî)Pîx1@»ÔWm i´¼n)¡XX/ó…ÑdSH¥jŽžˆc²õ×J1wC`2ùôÔ]·ž€[ÀCéûg ßñ#uXaª|7ãOaºÇªÔ`£V�Y;OõÛc�¬kXâÚÅb‘›~t=÷üú÷¼£�/0:8† O'µCfõá$}SäKy2Å,�ñf»X©³¦[ºÚ8ï]¯eþªÅ,Xµ„uç�Ê«ßýz.üÓ7rá{ßÀ«ßýzÎzóù¬õi,߸šîE³‰ÕÄ!—=öz~civ<º•õœF,nM ‹úhŠÝãuþ‚á‚Mæ³t$šiŒÕ¹oÌY6Ÿß~ïçÈ¢œÖ ˜µx ×,u¹$ù {ÇzËìÕÒ *ƒ+fì±ÞJ‰BK± î_hðà�åΑZÑáîþnëíR–ŸÛܯGéK-¸!˜¬7íTÚrÐÎ[©ÞƒÙÅÅJÀ¬"$*�"Ôª[jûo¯~�–ôcU†Õ’eŠ£�ØyNëZ’¡„»nF�òõ�üÙ‰ /Ö8r �y'-bÖâ9î³N„âì›ì£ètqÊó¤ æÕtj®™²èš×ÃâµËY¸z)�s»ilk"ÕPK².Emc=­=íÌ]¾€Ug¬eýù›ØpÁÔµ4°ý‘§)Žm�9ØÇTfŠÕg­wù"�±Zz'†˧�(�7‡%àxnm§Kߎ%âl{x }{{§ßÃaÖ_°‰p8Œ@0UȱcdÅ’ÿ)àZѨ€‚ẓ{GÈì/ÛT$ìÁn7þ‚boÂNNDì\ÿ ·d@L^úÀC¼h˜c²Iãm©ûT"àœ’àä‹€ÜL­[�2* érTQ|éM¸9ô¥«Â¿(5‹¦H�vª[®ú5#ü˜#—�â—ÿóSÒ£i÷)uÅZèI´jQ–Ù#Ü<°Ù¦åŠ2˜Ž0V€–&ëRÌ[±�w}ìϹ잫9ãMçóýÞöÓß²å¾Çðö‹Õ� m7L–Ÿw�÷2UÔwÝ ÿô�U]sÏÓ;É+VK2’ð*Iá®_Qfù«ÏHX‚† =ˆhÙ½:êÈâ êœ üPôf|^µ«;�*¼êÎPw¿d)”kú©ìº Ò~1j牀ì>ã\–Jaµ”^€FÒF"—yh\#()H_z0ja#4ä)7A"ccã2’¡„+(cGGùÏ÷þ3Åb‘{ ê§cN V-qï¹.\Ãî‰^»ˆ¨3×c… žµ«õJàRŒŠRR @AÜÀ�/P&ñd‚S_{�ÍlxKÕÜ‚B>ÏÐáA6^t¦�ÞìïèË2–›@×;ÞÚ“²Hk¼‰&Å ˆ%âÜü“_O›v�IO²ñµgÑØÚä†w�d<7¡pE‚¸.Á»Y¼³–‘�)kùÕ·‡_ €7ï*ú«ßÐC´­V+Õei½¸õRÙ^âN€0+©¼þj>æqŠ?¯â–Z¤|n€¿=˜AõUëºôe /�ÀÒÊ„ëØ�YKÃ%ñÞ_’œÍÂd�† ]öñKØ]ešê 1¶Üÿg½ù|’uv8Š“-Nq$7¢qEÒ;5ÄŽôŽæÆéÏq(3Hov€}‡y&½Ÿ-£»Ù–ÞϾÉ~§F˜,ÚÅ:kB1M óW.fÎòl¹ïq&ÆÒUÝkÿþ^æ­XDÏ¢¹ö¢ɾtŸnÕ©1™B–¥õsÝ9°B‚­>Á‘Óç^,Y³—ÚáÏ�bÏX/C™Q½°³€Êá!‹Ñ‡UR�seÃŽÇH8 pDšâĺêT»N ˪ÓCnÒW8¡d H=>o2ú¤4ø¿‡ñŸC¨.†ð\u|ä 5xàË•®SÅ([ ZH…¬¬[ -„‡o»�û~{'/¥‘ãÿq™ö\ÖÖ/¶éʪm î”̱kâ[Æ÷òÔØnžÛöô~e�0œãhnœÞìO�ïå®ÁǸ¹ÿn9ò '�øærÍÙ§ð‰ïÎFø«ßûÌ¥‹EwuÍOu³¢Ñïï¾ÉAÏa’p$‚•Kª >ºUskBq¿§!ƒYªÂç4 6ö®«H~³#›Ï»hÎ/ë�tÔ®O¨3¿€Š @ø„£�Òéÿ¬Ù­V•Â@¡z[R/n'„¤,)IHE�ýØÒ„IÝŸõ\~»µwYº¯��+ˆ‡¼…99>ɯ.ÿÙ‹ ü•Þ|›o¹×ýö–qZã #¬Š®¨ j{&|u'‹YöMôs둇xpx«-ˆÊùæ,›Ï'¾ÿŸnÕ¢éÆpÿ w^{3%ŠxÔŠ°¤~¶.v)­ %‡&ޏ‡ÂaºÍ®0 ´4ÄRXåX¢LPùÓŠ…i…ñ Tv ‚Æ3çNGûù«B¼¯, P'>«>H­á/�lëzÁ€‰e]"K®C©|ÚŽ@¶Ó4¾TX�n¨ün„Tü5ÁGmT*”zÑÒ(]N°±a¹öÕÆ†GùÙWø¬\>ßã©{å�ïƒq,Nª�OÌŠ½ô*oÃ$g¹V‚b;mÛË}COQ¤¨” œ÷ǯcþÊÅUÝë�m{ÓܾLm8iƒ|>÷Í»ï}iWK5Ö‘¬¯�öZcã6`ëœªÔÆÌ ‰Àþ§ºij0£-5¤V´—l>W `J}×,jF„JyFfÜtÈ¢ûMÈ RÍQ²¤Ë´7ΪøðÂßÑ5( QñHô$&eƒ÷�d©Y/B (*¼MQÙ E×Õ-&Šk¼æËßgïÖ]¼”G>—çºË®v’mÑF— (‚*â*jÓ[RK‚Ö¼(glOïçî¡Çµ5ÕØÖÄ9o¿°z®þ­÷iðÔ‚Úc»ÑïðhN¯ÁYS›$‘ª©"úP`r,�WÔ4DHÛ@‚­`µ¼) V4LÝÉ�•.ÛíÈèó¦*j˜äIíþ/¦ v`àSÊ4FZ½jªKóüeì¡â®“ºHÒeäyÊÊ»v¾XÔ”@(r«#M7¦2YE ûBêJGêh±æ¨tåxg-ñžúã’Ñg«^M9æ�ZÖæó e%àÏ4ñ…ÞVÙ›-mË3Ô$¾‚!åà53Àg€˜�·5ЖÆÉ-cƒ÷O²0À%á3ó65® ®ÑžÒÕ_º’Ã{žP  �/ðÝO}]›çUu ¨§|�0¤jõÏ ¤ÈzÏ~pj”þìQ}q¾ûõUÝçèàQFŽz®˜°¨�¤|‰XBY,yé¬�…¨RJ„!_’h@Qá3ŒƒÝ€X˜ä’‡x8¢Ž¬>/ ÐöÑÑÎ:ŸdùSýAU¥¥{}§”¦“í+C`¹›à®‚èì»rØ¢_kcrŸ^zÅG\eå›HÏ\ÊsXU»@ËšxüqóO~͉8½ãAîúå­Ú³:­q…ÙVàZCïÁ;§Ð¢:Šôg‡œî@öµæ­XD$6=:?6<ÊÑ#Ê’ḹ"•ÍÇ7‰*p-§k”^"NâK¢ÌòÃWEZP»º+RVtãχˆ«ÊAì¼– IDATë¬%TÑv8Ip½½ ßEÑTÂ�Ì O\{¸®-¯ hÉdAO<P›Oêe$¥17ÒI!õWšQ‹¢¦5¤ë÷¯¨�¯}·ôè8?ú¯ï�?!@.;ů¾û3F‡FÜïÕ©gQr–¿"¯©gµÝQT�p83lÓŽ�Ä1ºæÍšö¥”Z*µ�Ìó[«ÊýD-�k�›Ê‘Ÿª.;±T&<0G¤¬è›˜˜ô\Wç¯ÄÜF"-ÉJ2½ª’µ~< `‰óìÿÏj€�¥Ý¤þ6�@¸Ë–‹HÃ7óüpÛ3 :‘PVø€HuA Í “¢ìÜP©Ê Ã*ò™ùj‹r‰ б¶~©›o^ÿ{Él{x 'òxæÁ'¹ï†;Ü"�–,NÎ"fÅq!‚æ-HDt�0’£ ‹Zä ©³º YéÑq7§B¨;u—#$,Bné3;’�«R¨ÑI…4b£&V䟡nMçqËëñ(€ÙÎ�ÿÑ‘–d Y¤ï¬jqMƒ´Sƪ¤fÔ•)ç¥!üš)é³§|¬b�Z�X˜ñ„WCèjÆ×ÕW-!XW¿”¦h­öÞ?û¿ºügœèCJÉÏ/½š‘#C®p7GꘟèT£ µj il›3’)Ny•¦JmÓ몶TT:¶¯kŸ²dkœ,¾Ò­gÒ“U³2U�/|´¶Öƒ¼M)Ê(C¨?uÖqÉëñ(€vö_ౡº8‘†˜±+ÕyUió{úx�Y¶Kzæ6*9ÏðµƒÂƒ2H½èüwáú'¦hX«°äZ”Êyû)žBp>³ºa ’:•♇ŸâÊ»”—Ë8´k?¿üöOµ¾²v>q+ê#z꘿±g¥ÊÍfëíR· éF±P,“…£÷8hŽÕiësr|¢ª$¤šÚ¤Æœ*LùÊÁ«M–µ-Jꀩ·¤½›NôÔm­èœLÉ~Õ<±pz¹7Ã� ÂÉ~¢¾Y^["Àº’‡,W ø ‰êíé|†¤Yª€¿TpC4Äáý éãI¥¤³4 P®¦‘,NÍbuÝ"íÃ#G†ùáÅÿÃÈ‘a^Nãºo^ÅÞ§w¹Ï*nE9¹n�ý‚ž¿Ä,œ =·ˆëm·€±¡‘ê­DŒ`:º4yA›SG°dÕ���™£iénÓþž(LùÜciB/•’ëRW†!AꤶJ·pº#»Ï‰Ø(– Úž$0Ö!¥‚ãqeP�ß_jqsó`©‡]üPúû�ž¦0³E `(›A3ƒB7ÎëªRëˆ5³IEÄ‘ä¦rüü²«xòžGy9Žÿù§ÿ¶Ù˜Îü-ªé¡)R«-jSYká\©«ª:hŽ5h-Ç¥Þ{¨:Ó<™pÓÇ%P�E­„º,f';Ü;•²È‘}ÁÖ¢1ºÎÖVÚØTÚ®DņáÎ’®Û+µ‚µ:NQ·®«Ò-l|®@åš~X–þ“Ò'Øe1�º,Æ/ “HÂXH¨<‚焚°'úK÷G\ꪬx9÷»5Dj9½y•RÁ~ïÖ«Ã/¾õ¿¼\ÇÓ<Ám?ý­67§5¬Ôê=˜XНgC Þk $< ·�+TÍ�¨onÔ8Yô»»O©q¨òSyöUIÍž»|¡vÃv1ãKˆ @§ÄëfDðš�u×c%ÊfC6:²û¬ÀÊÊgÌ"Ú˜0‹Úû¥G½ j2à- ,º$›R©~@¯A ÕF¾„Jó%3Î'”Ä~¨ºµàœ#ŠpVëêÂIí”÷þæ‡8óòÅb‘ß\y�[& 1RËÒälÓõv¬.Ïô*ø*Ì|y‹öX“Æ.|fóUUŽ'¤°°(%é¤OÓ`^J—ŸB.ώǶVõÝ�ìÕô/Èî5¤f²ã¥t–´ž$Œî4áT”xWÝqÉî±(€SÊúR­)­ã� ¦ Ó— üŠdŠ ~yP8Ï—-T¦¸‡4¢î»B T¨}^äÂóó¥‘ èWNn±G)ymÇ&Z¢ú$íxô¾ù÷_¨:ž|"Ž9sæ�H$ØñèVîþÕív1Ç”]XÓC*T£«d­n¢ ÜýÝ hŠ–X½¶#ÞqíÍUÝWSG‹ÝíX‰G�L�®¯�=F�r`ÛÞi¯!,‹¶Ù�îB›ÌO1™›Ò°Ýã°×Œ€Ê "RZÞa‹Ä¦ã’ÝcQe+!F;Rß_”ÍÕ×cåª�ƒÊ|c•/]®~\°¿a°~„ B-ݬ+¥ÍñW,[ÉIå~…ã» ‹×u�Nc´Nû^½{òõ�|ŽôÈØËRð“É$ýèGùÝï~Ç'?ùI~qÙÿ2p°ß}^�‘Zæ$:”¾˜Â“¦‰ ½ù™—ì¶+ì8ïe'3¤�‡9Š"ښΉT’ï¦g£.*û0C‚PC¢Œ\æU‰‚j*­T"Ö‡öz`‡p¤4H�”LL_0›„ÉÀó–*ISù¸ÐƒÐªûœÕv2s’�>œ÷ÿy¾íþ—�à¯[·Ž'Ÿ|’ýèG455ñá˜Å‹s×]w¹ÇÜpåuW!8“.êÚä€Gúø·?ùxÕfê‰0B¡ëÖ­ã‡?ü!›7o¦©©‰Ë.»ŒE‹qé¥Á¤¦ïþë×)äóîSœ�h§;ÖD¨ÈœÝ²†¸åQÜ Å"w^{³Ó ´ ô¿µ‘“_µÁ½TAJNÑÛµKh‹7ÑžhÒîI­wPi´Íî¤gÑ <<1èË6Ee1:.­”¤HfÓñ­HCbºúËž�8«¬6mª) N纄ê“\rðŠ•ÁBágê´Iá¢ÉZÕp‹~‚ñ€ÍZ�R§‰HXŒˆ¯í>�®ší:ÙÉ _ý»ÿäÑ;|Ù[[ßúÖ·¸å–[xó›ßÌ%—\Â…^ÈßüÍß044Tös{·îâ†ïýB{Þë—ù•±Ð•°�0;ÑF£Ñ(eß–�\õ…ïV}ßç¾ã"mŠwŽï÷�Ë–e±²aaÅü?´ë@Õ–[×üY´Ïöxú“ù,‡&ŽØ…gƒ"€2`#îrZª�ór(!RYœõlÀŠroÄ:RÆ}Øð²œàü^ �Æ Q)ƒ“xK&Rå¶m¯3ÔÌC—�ÂGÑs©4!©H‚ ºN¥3¡w�ÍårüèsßáŽ*}ÇeŒ��sÖYgÑÐÐÀ}÷ÝÇg>óžxâ‰i?WȸéÇ×Óà°û“¡¸“©ûáêô[Âbqj6!·™£]–ü‹ù¦2SUÝsª¡Ž?ú«wj à±áè èN´2W ÿ à¦]_ÿ?±ê̵^ °{ì ·‹È·X5*…ßÖA›Ÿ’œ먭U[q¼  "xnª ¬Øâ“O£œ¿V‡¯\–°”•ø¥fFIsw/J­|@éÂ>ú±sni ¾^XF.}™ †yÖKr~çOø•C/ûø%Üð½ëNxS¿µµ•÷¿ÿý|ãß ««‹‰‰ >ýi»+ü† 8óÌ3Ë~vÓ¦Mìß¿ŸeËlKtïÓ»¸ýšßzÙx ևk½DÔ†k¨§´s_õÅ+é«’ùðÎ�½�xÜó•÷¤{ɧµµaÎj?YÛ¨ìÚÇ÷ÝWû/žL°éõ¯ÒÖÊ#Û\•& ÄÙL5——AkÊ„ÐXÚ5¢mIDå<ˆ¦ãQk(“S,ÂV,llô…„[fç¦RRdE.ž/X±¥’H5Qn¯¤-´æ nõ^Œ¾ ²‰hŽÖóÆÙgÓÓÃEÙÉ,ÿñžäŽëNÜ�?™Lrî¹çríµ×Ò××ÇW\ÁŸþéŸò–·¼€k®¹†Ûn»�ºº:>ð��{fh,cݺuüô§?åž{î!�rê©§ºïÿò[?¥Ÿ×d³6”`AM�úÒYóQ+âæäK 7•çÞßTß{þÊÅœ©ô”R²mlEYtacœÛ±�T8¡Y,wýâ6ú÷W×qkõ™ë©oöÖBïäc¹t™mæÄVf°–úQHÃw¢¤*VZs<  ™2ÙD¡dÔV¾Š/ Vˆ*¤]5ø¨à´ëÆ¥ä–{Êž‰àøD káG5-àÛù—4Ìæ-sϱsú•sŽ�ðµÜç_¾|9W^y%·Þz+6làÒK/å}ï{oû۹馛\í§?ýiòù=5mÚòéÛÛËõÿóÓê­Œ�¿O»ÛÍýOÖŒ´–¥Š9•aô§ðú\8.@y äÈrðó©ð}f•U5§üwÉÔ>ßZ”t‹”e”€¿n�F¹5ÓéÜð¡ ö$&£Ç�ðK*7$�Æ-H?<³"¬j^ÀºV?¸zpç>¾û©¯ñÄÝ�œ°Â¿lÙ2®¾újV­ZÅÅ_Ì×¾ö5FFFÈåü¬ÅÍ›7óÃþ�÷¿ÿý|þóŸgË–-¼å-o!™Lòå/™¯|å+ôöözµñ”qǵ7óª·]ÈÚsOuݵ ˸u`³ÛH¿^¯I% …«ëtöÛ.`ý«OS¢J’­£{ȽïÓ™ha]óR,áes .ýø«¢lzÝÙ´õt¸›ÉPv”C®’™š’ž•˜¯VÒS‚¥k…j£„âaйr `ÖñXË�@†�UóµrV…Vß@¤z I×vkéfô@l)Ëà R/1†Q^MÃ)¤’~ê¿á´ûª‹&¹pÖ©¬k]¦”õ¶�ÙºùI¾ôÁÏžÐÂ‹Åøä'?ɪU«øÐ‡>ħ>õ)… ›ÍòýïŸþþ~Ö­[Ç{ßû^6oÞÌ‚ øØÇ>Æ�…¿4¾÷ÙK™Ÿp}ÜîX+ókº ãÍÊ¢2ó©†Zæž´`Úï3oÅ"Þó‰Ø=œ5Ñ›`ÇØ~o÷´"œÞºŠ„Óˆ´´~ô¹ï¸MDªÿÞú·ïV HÉ“Ã;É :�ïkþ!-e_½[ÅT†±¢R‰°ˆ#ËǬÊf„kcˆ�Lá¦h|Áb�‰@–þ¾<®–‰ ø*úu%i/–3óUaªï.æÖvò¦¹¯rÃ|ªÉð‡_ÜÂçÞÿ öoßs }kk«kÒ·µµñîw¿›x€+¯¼²ªÏß{ï½üö·vÆ___^x!»vU—1w`Ç>~]ê}à<êõõKˆˆ°µÍ¥ÏOhÓöžþËŠÍ@êšø—ïŽÚÆzwª Ròûþ‡4zEý§;�7îùÕï¹åªßTý /xÏhŸÓåÞÞðÔ{Çzý@žÉEMU*ž&ƒ[Û¨ÇG[*6+©;V ¨ÐiÔŠGÝmÝ„‘šéLðí+Âmðž¥�T¬Ûâªï/}›†f'™1ØbQÏ9ÓO£Ç]£¡(›:VóšÙ›œÎ=Þ2“\ù5|óã_`b4} þ—¿üe.¿ürfͲ-ĹsçÐßß_õyòù<ßüæ79zô(---|⟨ú³ÅB�Û¯¹‘ƒ;÷yHºeUí wÉÉ<û&ûÜ|z€E'/åãßþ,Éú–’� ‡èœ×ÿþø‹çß›²Ûû7“.dÝ}!b…YÛ´D[?û·íáêK®¨chêlåì·¾šp$ìlV’}ã‡É�—í÷) c׳d•’÷JÜ@U¬·ùæŠ��eœß²@½ó¬b!û‹º ‘R*]r(ïsÛítTk]'âKìºçÊëzHÎgKùNá¾ ÿö¾;>ŽêÚÿ{g»V½XÍ’mI®àÞp7c›fÀ€CL}@!ïGHˆSÈK€<%ðè5y8!ô6Ü1`c÷"Ùj¶ªÕÛJÛçþþ˜Ý™{gî¬$c;á½Ìç’µ»³³³÷œ{Î÷|Ï÷õ'Õµ•Ru9å1 Ja·Ø�Ÿ”� ™#�îL1| †ª:|ðü›}æ‡ÿ³’$á®»î‚ËåÂÆ�ñ /¨$ž™3g"??¥¥¥ppæÌ™7nÂá0¶nÝŠ?ü»wïÆÛo¿�»ï¾K—.Å[o½ex-û¾�<òìv;î»ï>ÔUTãówVcÙ/î€dUvþ‚¸TôÔ¡-Ø©nÇ=Uá„x«K•ˆœºp�,ÄÖO¿@}E ,6+ Îй×.€ÝaãŒåP{9*»ë 8‚1ÉEJÞybÀçÇ;O¼ÞgÔf-ž‡A# Õ]Üöcos‰¶³³%(jìTåAnÊÿJ� EºðVýwŒ€µçvó­X—>ø±JH�_g^ŠÆU–$MÍEbôÞuG€HʃˆsPžy ‘Ç$fˆ$�2-GR~ò¯—áH"EÞ_y_ "E®Ñ‘]D}\YM"ÿ–¢:%Mï]’�—�…ñ™Ã‘? ¢ËjÔìÞô Þ}êõï|Sσ>ˆxååå˜6m¼^/6lØ€ .¸•••øéO ŸÏ‡yóæášk®Aa¡1ïÞ¹s'–.] �ǃcÇŽ!55¿üå/ñä“Oòÿ ¨G]]† ‚@ gœ O¬~ù# ÔÅ]â©ÂŽö#ñE±g k.NŸÄôsP¾¤Æ}\ªÑÓˆ�§v"éúSÎGpMÞ\¤ÙÕ¹d÷!üöÚ{û|ÿr òðÐßþñ‘a¡ÀÎÆCØÙtX»ª]›ú“£©ÓÈó˜ø“Ræ10ÜýëµózŽ4¢òñ-f—zŠ6@e_SL䄈U‚d· <—÷X?%¥bP°ÃÊ"º�Šè<§°wˆã©%qX혟.2y YŠƒÑ±·Þyêu<óÿþë;eü‰‰‰HII1Œ´zå•WPQQ�ÂÂBÜ~ûíðx�ˆ„d•ü£8àjo#¾hÞ'^–�ò×y›±©q‚ªö¤¶s:%;‡;õµÝ7züûc?A|²ÖîÝèÆ®æ#à<ˆ˜öÅX)¬^½Úp­§NÔ"øä ¢®öt{ŽwWq†Ðô ÌS�xkâ¬Îˆ"3á"�¡nlm>ˆÝmG¦aAʼn`tJì’f[V}Ž“GËûto—Þw+f,žÇYæ''7£;ä塸þAk¼™˜´™s£b+€4vÌìr;¼ ±¯)À„H `nMt mÁpXS\šˆ€0ª‘IÌ@ŒˆaÖYHãdC~)b´ÑòšÄ¤ñB#—§q‰ªªH ¼=ÑR¢ ñƒ$XˆùÉY˜˜u²ã3˜Ç5ï]~è86¼õ)¾ü±ú&Nœˆ'Ÿ|S¦LA\¿3œ8qË–-ömÊn—ŸŸ�O>ùcÇŽÅûï¿�¥K¾|ZZÜn7dYFww7ÚÛÛ ¡µÝnÇ믿Že˖᫯¾ÂܹsqÇwàÕW_E @(B||<þó?ÿo¾ù&NžŒ­¦“5(�ö ’ÒSÔ%¾½õŽyªŒa4(ØS�ãHWÙ{Šüö)o+*{êÔ4� Âp€bÙ …ˆ‹N¢ÀG/¼…wŸ\Ñëý�¼`îyæW°Fe¿)p¨­ ›ë÷B†ö~D שZifÃûèõPíA>ô×§4¢¢MÀZVÎö‡qä®�Íbj#)ÀÞo�À*)ÿéê“FFû¼`Ù”@f‹ ÌO*ë_A¹÷§\ÝÆò —†d¹3° `:ÌDv|ºájÚ[Úðç‡_“?|à;cü X±bvíÚ…¹sçbÕªU(**B\\/^Œ`0ˆÁƒãá‡FB‚’·VUUáÍ7ßD0Äe—]¦òú[ZZPUU…šš´µµ ¹ð�@}¤ô:Œ=ZÙ?ù;wî„ÓéÄ矎ììl<òÈ#½¿¬Öã“×ÞçrÅq‰C#ê?ÆEÓèoþÎRliÙ�ÍÍû±¥y?¾i=‚=uJ€MÍKª–ÙñqSÎêõó†Æ Ëo׌@[ {š�BfÊR,¡™š²Sù)Rª¢ jÐÎK8< jt’D`qÛú�˜9'Ì�Z$SÞ15b3ºB~lãú JÅqT„¢ Q ®¤d!暈+‡ÍAAÊ@X$c´ñÝÏpÿ5ÿ�uo~‚¶ÆÖï„ñ/]º¸õÖ[ñÅ_`ìØ±X¶lÊËËáõzñÉ'Ÿ`É’%�e^x!¦M›¦¾öÕW_EYYÜn7~üãÃf³õù}£%¸úúzµ„øøã�cêÔ©¸êª«pêTßuJ)¶¬ÜˆÊÃåêè´Ø1&±P·f(çÐ)%<¨ý°Yj@¥(*»ë¹çe ÎÅU?ºÁôúœnn}àGÈ2P+eRÛÂò2ô\b�€±–Oa$žéçΈ VÕJíJ·(­Áýµçþ;‰¨�GDdÛ¢`áÐÝÌjê µ_ �_PSáa eÔÔД|üÛøk0&c˜a._(BÕñJ<|ë/ñ?>�Ö†æ>u€ý³ÝÝÝ „ àé§ŸÆÁƒ ÏÙ°a6nܸürmœvWW— àÍŸ?sæÌéÓ{:5exþùçÕ¿¯Zµ ;wî<­ÏÑX} Ÿ¿ó™2/2y9ß•… [²®Vm“O¢ú}Óø÷â¶2å�úz‹Õ‚ë|®øÁup'%Àæ°Ãj·Áæ°cèø‘øÍ[ÀyŒåZòµ•£²«ÞX�#B"Ÿ9ªÇpûÅ|T]”m˜ZL 9­ývf ày¾/Ì’œp Mל“Wv>^´Œ0å6pó•µñÐÌ<=® ¨�†a$¢•äŸÃTü�°è«òspr.<“r΃Õbaté•·¬-¯Æ§ÿó^ùõÓL¯ú?÷QPP€yóæ!??µµµ8rä 1a 8ï¿ÿ¾�ÂK)ň#0kÖ,ìÝ»Ÿ}¦±Ý>ŒÙ³gcĈp»Ýøä“O Å–ÙúãÿˆeË–aÕªUxðÁM)Ãý=*Š�c¼©HÏÉ:-° ÕÞF�š<˜¾Ïž­5QƒÎ`W°CÜ9êI%‹cfNÄÔ…31tüHŒž>WüûõXrÏ�HÍJç øDW=6Öïdì—ß±Ì+W4fT¬÷U$ÆÉž½}[‚­^³�þoŽõ50ï´�ˆ–�ؘ¢�HaàöŠÙR”«çêˈ\©O­…C$�îNÁ¢¡3qéÐYÈKÎ6\—ßëÃ/¾…Çïú-Ö¼±T–ÿ©�^’$Ìœ9»wïÆñãÇñÁ`íÚµhllÄÂ… ñÈ#� «« ³gÏÆÂ… �_ªÕŠ‚¥ÖþÎ;ïœÃý÷ßY–±dÉLœ8‘Û]!°X,HOOÇm·Ý†¦¦&ÜvÛmøûßÿŽ;î¸ÝÝgŽ  †ðg®,ºs‘åHåJ¼´—¤R3D��£Ÿ)Yá©Õz˜‡²çbÚe³1ï{‹0tÜHfš‘ò¬šîFl¬ß¡ ‰‰‘ã³ô^6¹7«îQ}š M@û·G  ö˜£­ýILaqÍZo‰©ã²åŒ(èü FQ®�GCS%"afþ\{Þ| O»Ånx“ë¿Æ/®º+_yÍu½S_“““1þ|Üu×]°ö±íLN§&LÀÊ•+±eËäääà‹/¾ÀºuëPWW‡„„¬Y³ƒ Rõøžyæ¸\=Ôívãæ›oÆÒ¥K±sçNø|>dffrï³wï^¼ýöÛ€§Ÿ~~#º› IDAT0tèPlÙ²¥¥¥¨¯¯GSS^ýutuuaùòå¸âŠ+ÐÒÒrÆ?óÑ�Å1•é©£�[ ›‡’¾í©”©ÇÉ ØÜ¸Åíe‚SˆOx°¥ëk·#DH¤÷×aCuQJìŸ (ò½ˆ‚ô˘ßJ¡,It!bïLÍ<ÒûJÐÏ‘c5ÔÀGÉ‚¼¤lÜ<þJLÊ=+?´T‡qêd-žú�ñÜ}�¢©¶¡O»þµ×^‹·ß~ëׯÇË/¿ŒÇv»ýœìø3fÌÀ+¯¼‚={ö`Ö¬Yxøá‡1{ölÌŸ? .Ä5×\ƒýû•Ù‚/½ôÖ­[‡ÊÊJäååaùò倅 â¯ý+^{í5ŠŠÏŽ;°råJÜyç�*èðÚk¯¡¹¹“'OÆí·ßŽÒÒRlذ’$¡¸¸/¾ø"n¹åL™2O>ùäYýü{æ¯hihVâkÆ$ñì² 4ÆîÝ)ÍäB4ŒmMÅxçÄzœðœB˜†¢a„©Œ•ÕWzêðVùlm:Ÿˆ]é"T¬oÁ]/b0iÃ_Ù¢  Ö˜öDúcèK‡Ö9󒑺`¨R‹ç0p”_Uâ9R÷×p0\€(Õj½ ZAä1IËí£u}�? Q�s’`Bîyž1HKl¡¡¦ÛW…Õo¬„7Ò‚ÚÛ1mÚ4<ú裘2e ·›FË]K—.…Ïç;k`³ÙðÄOàÞ{ïEww7F�…êêj8yõÕWãÍ7ß„Ífý÷Þ‹¼¼<üüç?‡ÇãÁ×_�ùóçÃf³á¡‡Â×_�ÌÌL,_¾cÇŽE0ÄúõëÕ°Þjµâõ×_ÇM7Ý„ªª*Œ3�@èêêBWWW¯ØÀ™:!¸ìŽkñoýH5�€ÂÇõ›ÑêK�å” e­|�]«»GÓ¾îžh‹CŠ-"©uúf_;:‚æý´£JÍszÊ®ÙOÊ\—²yìt*ã9€“ÏlC×þz³Ûù=(d >EáX¥] ¶<Î[é¢Kêh)Ñ!TÇf³aìØ±xá…ÐÜÜŒÄÄD,Z´ï½÷’’’ðàƒbãÆ�xë­·0nÜ8Üÿý°Ùl¸ì²ËPRR‚K/½¡PÏ>û,:;;‘���;ï¼^¯UUUhkk;gÆ]k[?Þ„Ò}Z_¾]²b|Ò0ÆøuA%«óªä¨87d™Ánœè®G¹§å]µ¨èª�?1ÌÀ¢B-[óš” ZF¹3òRvê< f˜Y$õÕi0&C5ÜŸ ã[Q=¬–ƒ‘èdäq™æ�Ü 0l)Âü�w@4²ó�‚Óæ0¼ÿ±}GðÀ�÷á½gÿ̵œš øío‹êêj,^ÜëT%Lž<«W¯Æ¸qã¾õb0`-Z„gŸ}ë֭ßþô'\qÅØ·oŸJ�}à��”$nÒ¬ªR>ŸÝnGmm-^~ùe䪨Àï~÷;tvv^óè£�bÁ‚8~ü8RSSñÙgŸáñÇGYY^{í5Üxã�g=ÌïíhklÁÚ¿¬BÐP¿ßìÚµ ×_=ŠŠŠ …°qãFá}(//ÇêÕ«‘””„qãÆÁf³á›o¾Á_þò>|øŸ¢úqâpÆÍ™ŒŒ�Y(,D‚ÝbCUÏ)ÈŠ(%úÒ›@ëÁ´×Œ &X‹[ɨg`Cu�'àÿ®¯ˆQA4l(%ög7ß–�uøÌvÿ7”ö5ð*"Ð0P“(�˜…;Ž!iØ’ íÕ%#;1Cû”¡0¾þtþó{÷bëg_ô{¡544œÖ6lÞ}÷]Üyç�ýzÝ÷¾÷=lß¾¯¾ú*RSS±téRŒ3&LÀ‡~¸çž{0pà@¼÷Þ{�$ K—.EQQW!xæ™g�››‹M›6á�7Þ ƒ¢Ã9ï¾ûnµü':***p÷Ýwcúôé¸âŠ+°{÷nü³ÿóÀóøêzäÊBŽ3Ý`Ãú¸Ÿ*Sú”ÁT(’‡ °Ù ÍèPS“@<æüø;*¶j†Ö1‘¥²ß4p÷GlºÏ)€é ¦ aʇ>‚/äè9út?€•A6¼˜šD|‘kiªm@W{çé…›mmýRÃa�””¼øâ‹øÅ/~Ñësããã±wï^¼óÎ;Ž=Š}ûöáÚk¯Å¦M›`±Xpï½÷âøÚÚÚ0räH,Y²pÕUW¡¢¢Ë–-Ú5kpùå—s9úš5kðÅ_ÀápàᇎyM^¯Û·o?í϶�ŠâR|úÚ߸¿ÍH Äþëìˆg£ScD@)o´”ÏÅÙã5œŒÄ¬niÀ¤>  Ü&hx{z*H[B²×Ô˜nè±"Ÿ8���Ìääº4†ê&Sµÿ^vvb²ûSÎk§ºšÕ'J f]yr òNt‹rÚ…¾¯—6`›Í†Ç{ <ð€8Œ�555€àõ×_²èî»ï>ȲŒI“&Áf³©F|÷ÝwãÀ�X¹r%¼^/î¹ç\zé¥ðûù︫« /¼ð¼^/®¿þzLŸ>ßåããWÞC]E�úo—Å�©©çChS�~ °QAþÌÓ‹ôxÂ-\¾bOÅ‹–Û ´¡6„š&(LDÏP�M.G—G€RŠpwýµg3ˆüg4À� 9$÷Žk°·_1*Ifì~‚$§ñ©ÈHHU‡Ch§¤ØTö ÷MfädbÖó Y¤Ór±òø�;wâСC½žçÁÄsÏ=§v܉Žûï¿~¿³fÍÂ÷¿ÿ}ÓL9JKKqì˜ÂÞ5jV¬X� ??£F�Â/~ñ Ìš5‹ãáë�Ï?ÿ[¶(J1/¿ü2Ün÷wÖtµu`ÕKï¨S…ŠañyH³'š ™â+îªø³qf½N–˜`øº­—èòyJ¸¼ŸÍ(l€”{Z´ÈEÃÁ¨ ¥¦º�™šÛ¥©=Ǫ„Ì0„eÄ: íºÐç8 ² i—iñ)˜3t n¾àjÜzÁÕ¸eÊU¸nüBŒÌ,Tø‘5w·£´©’;÷Â#gHÿ£€P(SÑÖãñà†n@YYY¯çºãŽ;ðÙgŸ!;;[øø¡C‡ðÔSOPtDè>!‡RÝ8yò$ÚÚÚðÒK/A–eX­V¬\¹R÷0;:::Tv`II‰ÐÑ|—Žk¿Æ‘�Å*g!Æ% ãŒZ‹àùŽ9¶tGu ¦šÔ€þƒ á ãåÅÑ¿1ÓWµ)1ÙA ¡B(¡ž`o€Ð™‰Â2ä@¢ª8—Š)P]ðJ�æNÆå£çaÊà1pÙœê�ÍN€KGÍÁ„¼Qj(&S{jŽÀÒÂ_I’pÛ¯ïî÷â ‡Ã1S€!C†àСCX¼x1ª««{=߬Y³°fÍU!G¼òÊ+¨®®Æˆ#pë­·_²d °yóf5øøã�ÕN»X;?{lذùùùªvßwùèjëÀê×?ↂŒ€g:#ºÁ‚Kìrc�zŽÀq€Âœª®�¢O̲0U/#Æ×=¸'Ø;�HE¨Åë¶õ;h‡@ATM!£«“u!˜H—�˜A¡Ú¯V"aÁy31 >ÕÔ�ŽÍŽWbd\P×Ùˆ²¦*î™CÇŒÀœ«æ÷kqQJÕÜ\t¤¦¦"33GŽÁôéÓû”Œ;ÿûß1yòdÃc555êîüðÃ#=]Aµ322ðóŸÿþóŸQYY‰ûï¿_m¶ihhÀŠ+0wî\,X° ×kðz½}rXß•ã›Ï¾BñVMÜÆ c’Š`#ÀÀ¨7߇ ·,uF)ËQ@ Øß(Ó…hèyƒøÅ¢‘tTgâæ ~€n°)¦c7µçX ÃÄJ вjL¤Sgã2ŒrE‚ˆkÞˆi\ç4T×s:íÉq‰•Uĉ"l?¹Þ�s,ýñ-pº]ýZ\MMM¦»¤ÝnWµókjjpÑEaݺu½žsذa*uXïp>øà>|X§õƒü;vìÀã�?Ž•+Wâúë¯ÇÖ­[¹×½ñÆ8pàî¹çžït^ºÇÿüæ9ø½>ÕX²�iÈ�ËdÌQRÝ®L´ò%âØ�Pàkƒ"±QüF¼ÁQJL;€£õªù84!¢Œ¥/DÞÓß³³ÃÌXbØðÕ0)ä{VOøQ�Ñ$ÂY’�*’ =}qM8?wæ ŸÂMnéhnÇKÿù$NUÕaô´ñˆÊ&e'eâxc|¡@‚á ¥ä¨uU›Í»Ó�âí}וššŠk®¹‰‰‰ÂaÇŽjóMww7Ö®]‹aÆaĈ1Ï�ùóç# ©z|€RzLNNÆÜ¹sqþùçcñâŨ¨¨ÀÍ7ߌ—^z •••B¬âĉ¸é¦›�ŸŸ¯¶ÿ_:º;< „`ôôñêN˜ëÊ@IçI„©l Êþ`šr‘Ñ Š¡†<ÜÀ2"ºI>"g o 6mù3©˜àìíÛªà;Ùav»BÐ+S—òø…÷AkŽyL“P*ò{‚Ó�¹Ã§2„÷^ø N-ÇÆ÷W£ü°¶È-’„‹‡OgÞ‹bWõ!4yÚ´ÉA’„i g#oèà>/¬ææf47‹‡:Z­VC>ßÜ܌뮻+W®ìõÜIIIxì±ÇðÛßþ–“è~ùå—QUU«Õеk×bÖ¬YøüóÏ…ô]6¯ÿøã�áp8ð£ýèÿd°ö��QQ\ª."»dô´ÑÌ.Iu?ÊŒ„� µŽ˜äçÉ;"”±ÐE”?�¨$ £·s‘¡â¶]Dl‰Ùãbj˱F˜#r’à WAjD„§÷F‡wHQÝ•¬Ñ��T_‚¹#¦"?-‡‘ã&XýÖ*lxOS­©8\Šy×,T#ˆ$W:¼hîiS#Žº®FŒÍ¡FÎ8',V+nÛÛ§Ö_Y–qÍ5ר¡>÷¹% •••Xµj•á5ï½÷²³³1~üø˜TbI’pá…"!![¶lA0„Ïçƒ×ëÅW\�äädlݺµO"š‡FQQ~ö³ŸÅÄ.þ·_^O&ÍŸ®–}lqhð¶)³©8\g XÏ ,øÝÐX�R5ù¿V” |�� ùz�¦šÁOÑ&âÐôq ÂÝA³ À¶ô78aV:w€0¤>ú"+a,E2Ó1tÀ ¦Ã©¦ü$>|ù-î}O�¨Åç¬æ^?·h*¬’þ´öt ¸þ8÷s¯¾¤Ïä �ÇSàbàÀ�¦»íÝwß�_ÿú×1wîèñ“Ÿüo¿ý¶:@ãOú¶mÛ†ôôtÜ~ûíjù/ÖQ\\ŒK.¹ÅÅÅø¿zìÝ´‡˜‰Ì6ÉŠ1É…PÕàÇ䣈½Ã ‰y„!¹Q6Ðñ(�î›Â�|šÉNÙ¦DËY¨0K {ƒu™’€B[F@t³~UÐ4’�D’ÀTàÜtŽ€‚a#ÁÏí¨¯=ôœñ}Ãa|¹r½¦ÔK—Í�iƒÇ«EeYÆþúôèÁüîÇýª˜5¥¦¦"%%Å4zHKKë“ñÀâÅ‹ñÑG¡¨¨²,ã7¿ù ছnÂøñãñ¯£÷£§«Ÿ½þ¼Ý^uMd9Ó‘íJÓ!í:*Hä:â�þè¾N ¿”Æý9ÜžÄð7ìÒ‡>¶@ƒ'–Æ ±å~;€£0ãÂLã5q”":%5 uXl˜8è<ïÜüéç¨?)ik+ª±míW ,’ ŒP€4W²zÞ¦î6kªàÎ9hx.½ùê>-ªúúzSÚoff¦P`Ù²ehmmÅòåËûìE�çË/¿Daa!¶mÛ†÷ÞS°š¨$׿ŽÞ�}_ìÀž�Û™(À‚¡ñy�ˆ¤¥Ð޲y€`úŽÄ3…G�‘½.å ”ç&²ìD3€� rªK\ žÞX€GOÇÔ †0H¨Ýg0w½D5qì=™]§Ý©¾¶»Ëƒ¾†6Ï×?yýoèjëTïT¼=csF@‚¤Þ̯OîC0äîÝe·\ƒ´ìŒ^Tee¥©ðEjj*RSŽ‚ÛíÆ¬Y³°{÷n¼ù曦‘AoGnn.:„I“&áå—_FKK .¸àÜpà ÿ²î>o<ü g>™ŽXᆆrå5Žöo ¯)³p¸ _g„ÚÎ'¬‰õo@m×7Ú’î=˜×›zx�]à±å~;h0;ºbÏgd%“ôYƒ L-Ë}e�¡âHi/à�ï=ÿîoãsG"Ù¥qïCᾬØÅ]Ybj®þ÷ïõº˜JKKM%®òóó±hÑ"¬X±›7oætO÷p:�X¿~=† †gŸ}%%%ÈÈÈø—e÷ñh=ÕŒ’]1+ɯ w5æºtž2ˆ>U‡‡†.L(5eF…EŒŒ=ccRÔ¨i¤€è€H±¶5…xРö'ÿ©.³‘`1m¸/À”ålî6v=Ë:ì:â£ÞJd…ïŸâÖ8ð¡`Å;ö!ì]vjûÚÍ(Ù{ˆs& GÌÒ€ n(C}WwK'Ì™Š¡cGôšÄ’¾zà�ðî»ïâºë®ëÓâ|ûí·û4!ÇåráñÇÇÖ­[±hÑ"<÷Üsÿ²ì~'K*t%i®MÕ€ëP�Ž?+-F(oÔ†Q_TŸáë±£zEl.¼7:¨hã§w¨ÛmeÈl@¯6ÜPbêZ½JÞ!r<�@ãͦ•SĽÌïóãÀÖ=}ûžx!u§&ÈŠOÃøÜQ\ž·¡tdæÛHHIÄ‚ï_³[°¾¾¦¤ 8PHÒÅÅÅ?~<–-[†3fôÚDôé§ŸbìØ±Ø´iNœ8ñ/‹îç!Gqõëæ�µª”~s5Œ­àE>5Z/6 qü}=˜°òc̈’¨ƒ‰b"A"Ö)ÞSEŸìð™©õjÃ}qkÍ �°°î(ð±® ÈgîÐrª m�}ט?UU�µoÂùË©ùcgצ µöt`oÝaÎ3O]0ÃÆ�Š�ûœ¦:P(BII –-[†1cƨ¬ÁŠŠ Ìž=Û@ëõûýؾ};fΜ‰+¯¼ògÿ\¹EƒŒ«‘�<©˜V§ï ¢Ð=òzV�ŠâTi/͈Ù׳ÓÁD#¿¸w&LšB5qPÊTÓB~„:ý§eÃ}q1•4ýu‘JȺÝàþuôïpÅÇaä”ÑêÚò„z¦pOȯeÅmøì›èZp©ˆïo‚öÑ> ‚¼oÒE*‡€•ÊãUƒ�„=�Ӷᾌ¸ùÀ…B0î”î‘ø®>•·@  ÙH&Å�¤ÍŒ'Žô_TóÔÉZ|³n3®¼ýzX,!(J„à e8åiÐÒÓŽ’¦ LÉñ¦©™©Xò£eøðÅ·„çÝ¿?n¼ñÆ>]C}}=~ýë_«“zz{îwÜ�çž{8+SuNç „ÀîrÀárÂùi³Û`uØa³YaµÛasØ`µYasØ!I¨¬(ÝÈ”B‡AeŠP ˆP(„P „€Ï�P0¿Ï��_ùðùáõxµpý ×üÇ÷aµYUê÷¶ $‡¹›£úSQ>O ›–žFL…êÃP÷¾li‘;‡nî��•, `"¿„ÃðŸòôf»ø¶à3lðˆSðƒ¹¦F ÈHpº!Âø ŠêòÓË{×üu¦-œƒÌül ÄÙœ—3k�o�t{ì¬)ÆÈ…Htj,¾Kn¸Û×lF]…1ìîKs ¥O<ñ~ÿûߣ«««ÏêÃÍÍÍØ´iÓ?t·Ì)ÈÀ¼,¤fg`@n&’3ÓàNpÕàF\‚qñq°»pƹ”)¹6³¥Âp;˜�–e}ø}>ø<=ðûüðu{áíêAg[:šÛÑÑ܆¦Úœ:Q‡†ª:´5œž3œxñ4,¸I“qSžZÈ+;%•ù!\ꪟ=¡ÕÝ Dƒ<ô«œˆFúâ7ä(˜Ç7ÄP�ßH¢ÂBÉðV´öf»ßÚ¬ð+Ña_¡v?l)N°]T$òÁ5ÁSb ¹lNN�& £³µã´@ÀÀ�¿ŠŸ¿ø ú·‘ pðÔ1Ôw6)¹’¦ò¸ê¼yêsâÜX|çõxùWOõÛìß¿ÿ;“³ç�‚¢q#qÞc1xT!Ó’ö+;ºÕnëOâÅ[»É(wI"‘HÂ�Ä”$�¡ÑÈF(B(@ÀDgk;NÖáÄ‘r”ì>„c»÷:Èeê¢Y¸ýwÿ®ø8õ|­¨íiÒôø`Üå9~Õrl"Äôa¾Û>ãJqW¡Y<¡ì4!]F!ìXäotØ‚¿¦³7ÛýÖÀ\#K–hì‚5ÙÁ3e”’ù¨€˜ðŸèñ|»é²GvÄÞÍ;1aöõ�Ÿ…×w}¤–s*ÛkPÙV‡!)¹*r;~ÎŒš2GväÎWVV†p8ÌuíéCù6ã—$ ›ò²0rÊhL¿|.†M<ñI ßòÌÆ¤W?�“w ÔàŒ"±‹—v—qHÎHAþð!˜|É uç­(>Ž�ë·bß;QW^�P0‹ÕŠ¡ãGbÑ-Waò‚Ü%†äJ:O ‡xàMGé%ÜØ-¢Ìò£FŒ@PÍ$ú| �¤»"û¿®`�ΤÑèƒ*ú ‚š �Oo`Å™p>_Aé ä—E˜"ÐàAܰtñzÑè�Jp$ÓÈü¿ètí±ð9õæĨÉcàŒSD@’œ ˜š7;k©e—Måßà¶ WÃ")H\î8,¼q1Žï;Âñ¢ò`¾×È‘#ÿ)ŒÞár"#/yÃcÄÄó1é’éÈ-ÌÓ¬ÙÎO§ S9„ #DCʡȀÌ0dÈ�ee¡Ggjï �€ÂB,dY‚D,D‚�Xa%Xˆ+±À!Ùa‰tLÉ5Ú"*= £‡á{÷݆` ˆžÎn¸ãa±Y ëM¦2ŽtV¢´³º¾aW¦º ¤öª`ˆê<4…`caKÇ:Ô z¬AßcÀ;LbZa`qÏ�˜•ª¯`&íßOà°Mä Ôæ…ì Áâ´ñÆcý’GH¬ÂÁimM­øì/aÉ]ËÔÓOxJ[N¢Í×  ÓçÁŽšbLÏ‹¨,ñø9S0nödìþ|;w¾£G�š:€üü|¸ÝnUªë\ƒF`ò%3P4n φô�™|¨K£›„¢3Ôƒ®PwägzÂ>ä æä04„0 #LÃüi Ñ1exîT}[ l’H°I¥=[²Ã.Ùà²8ou!Õ–ˆKlq1 så°ÙlHJKâå¸#ï�ƒø¦¹G;O 5}�d‘ñeÁŒ1,å Ý@ &’O ÎÆ×Gï'”…`(‰öú®ƒ1ÉeÛ`2  ¿ `¿éƒ�JRrڌӀa~‚“×÷æÛ]Îom”Rl[ó.¸dvd¤pX¸ o,V—~­:¤’¦ ÏŒ´¸$uYßñà=pèÐ!ÌŸ?ß4ÜÎËËCIIÉ93z§Û…W\ˆ‹¿²‡ D|J,+D¢Õ*¸JP¬Æ×„¶P'j¼Mð„½Ë2dÈ �©J…%൩¨ÀøEàÕ͈T"ŠP8²CÔ€H D¡JoœÅ‰lg*2ì©Èu¥Ã&Y›1ì„Ç:«°¯í:ƒÝZÈÌ™1¯!àÚ ³5AÄð„2ç0îüÔàŪ0àÁ°Y“Àhîé-ÿß�X3>ûá…MT ßxƒ¶öÀžá�Š€ëLì‹€@ˆ'¹Ü ( ¿•‘´Ô7a˧Ÿãºÿ¸ « À�´<ä5f¢¦³A‡¯ GË0}ÐX"Ø…;1·ÜÿCüå÷/«çê� 0qâijî¬v2óspÙí×`öÕÃ�oŒ—‰–—Ë�¦2Z‚hôµ¡¼§]á“/D[Ö†\ºêsx¢wl^k{óÆ"ƒ‚Frõ@8„î�Mþ6€–�H±&`�;ã2�d�gÒF $‡QÙ]‡Ã'ÐèauÂÁ4† TTç Usq6Ÿ'Åa?Ô1üŒ)*a@wÕ÷ãSDJ'l IDATŒÎ]µ±–Mzaö×T™9Ôu"nh±pq¿zou ,€/èC˜Ê°Â¢„ e@:k꿵Ѭ}ëc̼|å+Q€Å† 9£PÛÙ) Rì©=‚QЏ(`î5óñåGëp²DÁNb mÔ×ןUºnjV: Ç Ç¢Û®Æ¸Ù“Ô)Ì"™9oȇö�­�NÔû[ÐàoE�†t7ݘó—T”5øh J O¨ ¢¦†¼žʨ¾F®�®-؉ÖöNìk;¦Ö¨Qí'âÄD8‚ö~z>½Žžn¶5Sý{êvyÊu½ 0.y08\aè¡/²°A¥èÜS×{í8îãzl�„ÂäÌ_ßêG‰D"¿7{Ú iÀ áCΘýù÷/qàlaj†¤äª‹M¦ʶqÈj³añ–ª5ïèÈmÎÙxðÁ1lØ0NÜóLiÙ¸þ'·àçü~µâÅøõh3(Brµ¾&liÙ��M»°±iv¶Aµ·9Äí”C¶¨nÑQ]á‹r Zn¶^´¡F?ÞŠiÀÒ¶EÝHw½1RžN �ç2ªCÕ™KV;ñeª6ýdºØôF稨¾A€khãs~*¨p©Î™P!˜ÈjÄnŽV ôŸY‹Rü§<ð×uÅ*Ù쇉 ðé:øÚ T�{‚´ôë§ú0,ú¥ù~Â!Î Ÿ7iÌ3¤²âcøråzîo ‡Íäv‰ú®&n(ãvªñs&cÔå:¼^/ÚÚÚ(v>ýôSᡇ‚Çãé3ñ§¯Çåw^‹Ç>} ×þøF ?Rx~OÈ‹Ým%ø°î+llÚ…ÒîZ4ÚÖxÊ¥žaJ£¥” {© •RÝTF9�ò’p”¿t�7TW"ÄQsàÛf©É”XÂfì:‘ZasZô³v«eÞ‡Š’'V±‡šD1âùƒTçìô©‚ 1è'ó<Å  �˜“€¿îsŠÙ�õù1!Z×s¬Î�IL}�˜äªÚTÓZÏuYtF ꃗÞÄäù3àNtƒ‚ÀaµaNÁ$|ub�òå‚Í'ö`Xú`Ø­Ê­°Zm¸ì¶kqlïø|>£©© Ï=÷6oÞ|VÂýÂ1ÃqÏ3¿BnQ¾¦4«†ÝB£¿G»N¢ÆÛ¨J©«| J@ ‘•ú*Ÿ·ò ‘]ì¢Î6Ù c)Î(›ù2»“†R]t+¨“›‘f¸¼W®+m„)ÕAÔ�kä(PãØ:ñÂ|_¨O!TÈÿ7\‹A‰HÏ$ÂT‚hrPFwI37¡[¿GDlµO‡¥ë4…,œsîòÁ}Þ«Ä+G=šZ ˆÎ Áˆœ"Uå×îràèîb´7·�à ‚=}‚ªPœáNEEk5ºƒ>„†Leež@dég ÌBmyN””cëÖ­X±bEÌ¡¡§{ :×Ý{ îzü>$¦&q÷ Ú‚]¨ì©ÃÖ–bé:�ÎP·Q†ZgÐD�`"@”cE�„«]�p*€Óº!™†ñÜTPÃþÌ’Ȩ�Áò»°PrK0Œ¸ûºÏN Ì]&Åc”òý… 48*½âåRŠÀ©.´¬/ƒl> ð+9Z,3[;·ön­Yˆ„u„ £§ 3†NPY„’$ÁÓÙ…’½‡Ï˜‘5Õ5`Ô”±HJO�D$¸ìN”µV«öÑáó`prN¤�X¹–ó§�ÃÆ÷WãT]lq�Ó9’ÓSpÕoÀ�¿¼cgMR¯#zƒzÂ>ìl=‚½íÇPÙs þp€ˆøí_gÒDUÍêéªÒ5í‹+`öza`'ºˆÜ&Rˆ•:™Îé‘uØÒ#5`îLÞ­ ¥ Ñ#|€.àkÔøÙ¨I¡‹ê»Ù?�þs~bæˆùûà9Ø€ö¯câ{?Pv¶@€ß˜a4Fܰ fP”õG´ß£ÿ†RŸ�”†ŒEc�Hlv;nÛËŒ}úv‡ßë‡ aôŒ °HÊÇMq%¢®« þ.€„"D—AÉ9�"–hsØ1``v®ÿúŒÿì«/ÆÝXŽÉó§+]u ‚bЇÚ+°±aýí"£ ”XEë‚ ¯,¡<*iÕÇ3¸ j>G/)]T]3"Û⃺Πµ\Ûhüºé=ÔXj£†h‚p˜”¹Ê5D'1Ñ Lîz¨±€ˆËýZP  ó]Ê�0š×•Á_kZÿ¸¹?ëÑrk8Àd!è ‘—KœM =£á¾ºX  x¼=7H£Õ&¤$áäñJÔ�8sƒ.N«ÄÄ9 9#U5¶x» eÍ'•œ— Ý­–6X‹AFΔ,ASm÷¾†yYXþÚC¸ô¶%HHIäî‹L(š|mø¬n+Ê=µ�‰¬a&ã¼�c¾  ‰‚}"ÈzYà†Â 5Ûí ôp0fÆ XÞë÷bŽ%ÇŠkRîšxPTà!z‘䥜¶¥±ÚA À+ Ñ;O„°e« Ke[}hx·8–ð+VŸmP à.Ó²‚ÝGn’:™'šó«é€nÇëx‘—š…d·""Y$$$%`ï–]}ÒìëQq¤.Y bIÎ4÷´£ÅÛ¡Î&¬ó4alÖ0õ›³Ú¬HNOÁÎõ[cª÷føWÜy~úÂo‘‘›É/š}ØÛv ["Dv&b"€C²Ã&Ù`%V5ZÑlšpSl{³fMZ‚gîéûÒM�v®‹óz®éF¨ ­«&@°C Ø=F§@cD)Œ 5dä"ž‚¡!À€ PQ!ªoÈåM ]ÄC¸KiÛrž1é¿w¢Ð3áº\ `€p‘È®AÉ VKd§'œ3P¢†B!9„áÙêßÒ² ©¶U¥'Θèhi‡ÃåDÑØ‘jô1(%N•@¦2@z‚~Ø,Vä$d¨[iFv&ªKO ¶¢�Ž8'Ýrnºÿß1yþ % b Õ/±£é0v·E½·šÍkSa âs0>yF&F¡;Eñ¹pYœh t"$žÙÂ9­û4nÕWÄ+—Æ2<Á@HÞpõc°¨iŽaé =ë§’“ÛË” NõŽÆ ÀdC¢ÿˆÔÞÃÔYRóÈ$" 2EÝŠ½±ÔøLfyœIà $ÉSöñ°¦¸”»-Âë¨�@ä�¯ƒÒs‘äJPWláùðkÓ6mâË8ªŽUbòÅÓáNˆ`!� (m©R °ÉÓŠáéCà°Ú•¿Ir† ÄæUûL¾x:þã©_`Ú¥s‘”š í( ¤´£ êv ¦§‘ÙÑ5ç8,a.Êœ„á‰ùHs$ÁmuÁmuF¸òiÈv¦£ÖÛˆ ‚zC ¿’«bÑò!C�ªÎò�µÕ¶.‘‘}LÙ �ì38JME*¸0m'fZõj¥’2Q‰yuÑä…èÔ踨¼¤½~–¸¥ÃA<‡Ѻ1f%êI›û€Z|k@¡€_ NfÚ$8&ƒHü€P0A‰65a9Œ°FQÖàÈ`M‡Ó�¼¢ÁؾîÌÕÞUšñs¦ª©H²+µ� èòwƒD®%HC(HÉS�)9=íÍm¨8»7 )-w?þ3\{ÏMHJKÖ”‡#sÝ;ƒÝX_³ÅmeÒ0—6[$ †'æã²Ü™(LÈ…Ëj‡¤Ý,îp[�ðËœòµ€Íø¹âÀ<(Ø~.#Ù�Æ@©IŸ< ÈqÏ"ýË¡&zÑ”]Ê4ØRñü j2¾Ú0 D-i�&…èÓ¢sm„/EPÍñÔÿu?‚M¦â(ÍÐo� Ëi®‰6(œ€aˆÐ釫( ’Óª–¬Ø2—Z d�즮Vä§å Ù�„èÌá´ì È2Eé�£gl1·64cèØ‘HÍ̈ 3–à¶Ç¡´EAºüÝÈI€»&6vÖ$|þÞjø½F2dRz .ºþR,åAä Â…òÐð`ó1l¨ÝO¨‡{ÜNlš8ó²'cDâ X$ÉpoÚ›Zát»Ô?gØ“Qá©C@Æ!u“e9$š‚Pb:­FP…”õ�QMÇ^—?óÑ 5†ÕÂ_OÂ!FŽˆž'b ߇ƈôýTŒ<@ÌeÐÏŒzê)¥F ¢»¼-«Kc�Û< x®@@FÄ Ï!S‡öìÄHéŠ- êKƒT}¼ª¥ŸIRfºB0xd!šëQWyfÔw¾ü^?&Ì�ªîÐIÎx4õ´¡Õ× B€•’C(LÍSq B²‡ Ä7k6sHü¼ëáæ_ý;f]uQD=Hã=ÈTÆ®Æ#ØÞpUÝ ŽÕ¯„�^|£‡"%3Mù∄D›ežýôU³LÀ<4húÑ>„¿<¨FjaÔ m'ŽÒu³ïy½!`œ¥bäÜ § Ô¼®o4tí7BMJzæ +)Nc§S†ÀÃHk@óšRxËZbÙâ+P@8WŠÜ@8 3Ôæ…{TˆEb*Z9�­D€?D«§#s‹ÔÁj³bÄÄóQ[Y�ÆšSgÄ ÔVTaÄÄó1€AåS\I8Ú\9ð´ø:�Ÿ”…DG¼ú¥¤å @mÙIÔWÖ`Ĥóqï3÷cÖâ‹‘š•fø<�µøû‰-¨êªG€†T£!ˆ·¹pqîTŒMŠD»›wŠ‘×ܲ+z «^z·ìAë©fœ(9OGPVxÑü:/1K5Ò”Ì4䬦0Qûé ö ¸¹kNlAk KËóX% ‰¹¸hà O¬•DµU‚êã•øè…·ñÇ_?‹ÖSÍÂk–Ã2Zê›0ù’épEðE5‡ ÎÛl4F& PëòZ@Ë„öD0ÄRk@1©úóa=Ø%@öÍ E¼Cë¢bdè±y6õú‹A*O ¥|ciÐ ”BŒd"-Ýa_O¸ÏNtX�õ z1a_ ï&qëŽktþ£@'”æ 1¦Q€Ó [f‚VúË$€¤EÑÜ9Leœl®Efr:’#ÃC£Ï9y4 B}U-:[Û¿ÕÅW?�ñs¦¨²Õ„¤»SPÒ\¡F(MÝmš>N«ƒædY±¨ý�%øºv/Ê;j˜¦GåzÆÀ¬œñŸ1qV'ÂTomlÁ§üÞ|ô5Ú¶¯×knolEjf:†N©ž#Á‡ªžSðÉÁí× ±Á>tE¿¨S1c³¨2ÙZmÎH6ΉÔGÔ”? ¢ù²!;L؃" @GÞ!¿égG‹ë0>E ,@�ÿ©Î×dxI×Á´nˆYú{ ÀŸ¿� |[(â?6[Cr@†sp2$›E3|– &5�ˆº³†Â!”ÔW Þ‡¬¤ 9'ÈÎÏÁÄ9S‘�’ˆòCÇO[Q8¢§«æ] F&n» õ]Mèôwƒ€@–eÔz001.›“IY”áã²/QÖ^ o8ÀE+«çOÅY£‘âLàÒ�èºùêÃõxõWOa÷†í½êß³Gåá2ÌY2_­ Ø%ÂTF}OW²Óg¨Ä�èÿªkù%±Ræ¹L ²>!¦†pÛ }§ ¸ª§3fb쾡T7–R¼SCñ½®‘(²ãsø5:Nk�²¯¡13&6âÒ~QœLÍK;cåþpu¤"÷u^…F ÃÖž l©.ØÒâVáØ€,Àþ¤ (m<»Å†œäLŽRìp:P4f8f]1áPUe'@åþ t4×5 hô¤çf‚DÈA@E[�òVAwЇƒ�ÇÐæ÷ ©§5]�øºf?ö6�?ì×JQ °[¬(J„« ç"ÕÂU¢»KÕ±J<ý£ßaÝ_?�§½«ß×ì÷úàëöaâE¨Ët¥â¸§þp*ADÐ>‡š¡é+Y"É`¡aSA¦"˜¾Cu‘…¨ÞÏ–÷"žOÜ>lV²·sš}¬o3œÎØ ¥+‘› lÔ��ü»ýë“hßr2Ö·ð€7ÑáϳíB$òz³ó…»p¥�X$`ãA í\Ó4TÙR‹æîv¤»“ávÄq¯s¸œ8ÿ‚ñ¸hÉB¤d¦C–à „@Ë NЋjO8,Ãát`Ô”±�,J”ÒÔÓ†ò–j-2‰8¤–žvÔu7¢ÎÓ„î —isV4ðó³0gà$ŒÏ ‘ ÔÞú“uølÅGxåO¢¹¶ñ[ÝôŠâãá¤ee¨&”jOÄñ.¾U4z ”SÌ�Ný,q¿—n½Ñ›LÂ4ˆbµ$¦�uD�(�i7…(g×çüJ ,6 $‹•é¶U~ Ê!”4WjÈ3?Ú”ù·b“³Îǹc IZí—½ô]·áÃÞBMÙÉÓî(4;N­Àº7>Æe·/QÓ¤”Q¨èªƒ_1p3цhø? å4ÊêŒ@¯¬õɦ]¼||¬ãD,^ZSªC&bqg¨™HÓqOc-ò?å”…‰`„¸X ”ë)à0ʃ�z‘RŸ":ह;jb¿?bkg¤IÆr×ãA7Hn"AŹIÜŽM˜t@úkeC¶¥˜" ¢¢¹ûjÃis(åB0ó%øR Þj³Âj³Âb³Âbµ‚Dz@øÅw²½»êй÷ŽþÎòR�I¸¬h6÷XÔé4×5âÕ_?��^|-íg\@4ºpÛNµ`Üœ)p'ŰJVX% jzbûû,` ˆ"¶á‡š4¹ ˜€Ÿø£Bƒ5ÔÝ…M>Tj ôtó¸ÕhAࡨHÔƒ<ŽÁ™P¾±˜UY¦TFûæ“èü&¦F9€œ©5d9Ãkò€[MÁ‚6_D0ÄÎQU Ó-G f Fó~< †Ã(k:‰½ÕGÐáõ –à °Yl|•�¿˜¼L)z‚^4u·aOíal9¹;"Œm«å{æ ±_Z8 IÎxî¼­ ÍØðößñÜOEMéIœí££¥IéÉ>ñ¼ˆ#ܶ8Ôv7ÂöAi‘þÛ;»y <‡HUG÷˜ˆíBEýø¢Ç!æöë»ë¨¹øq„¹xT9I�GC 2ÕGz&lgE Ú5†Z}¨ÿó>Ⱦ˜¸ÞuNœ©õCΚ\ ¥SP  MCʼBµM„(2\„ؚͻ!)é83`TÝ¥#%Äx‡ ñÎxÄ;݈³;á°Ûo�cÚ�•yñ9_(€î@zB~´û<ÊÁHÈ ¼! ^A˜œ?ê‘ðÉ×ÁnÕ°ˆ¯V®Çú·þŽª’ œË#>)�~ú"2ósÔ¿í8�-Mû•EJÄ–Íuëˆ?š¡S36�PØà  ½§ ‚!Æó™¼@W€Uö5TY°N_‡§Fœ€²U}¯Cÿ¹×Q¡ü˜áõT›ÛP·booz«"¥¿3vXÏÂzü €ÅfÎÅw²¾Ú8ó’¡ [¥€.׎&£Ñ¼L«Áè{OØ/Ùã÷¢;èCƒ§Y‹$‹jÌ ’¤Èh«á¼D"Ž(’³É$FwŽhDblˆU�–ÚÆsnüàé蛿 ?}åõ~ŒH„ÃhñwD,t[7aµçˆn¶j€šöËryN À€ P“q½Z)œ@¬ýÍ1´ £ñCГGÅÀ¥^ÕWà=„�I|‚òX1 Jš=e-½?�ØÖ=¤³°kFŇRcß¿îœó¡Æ�‡¥Ý:¥H˜3˜„ì¡þ�ý½}ÅOEl댖³°e(‹$‰žîôCŠ·Ã> >B+%†üÜØ(c[1ÛX¤.%¾ •ä _zäIíï`ƒI‡#DÎÛîëÄyEêsq.XlVܲçâÊöż¥‹`‰ 9qY„ƒhôµšýÌÂ{Î`Lk„4Ư| "ñ˜×ô©`61›mP†©ÇöÞÎÂEŽœçŒ5K¶ÔG X; ‰r㻩(J f¸ß м¶´·aŸÕPzn¿ PJÒÌ4[_�ºNÄ Ï€ä°0„>ÏÖW¢á!+.Âv®í˜Õ`¡@VDSוh`#F_ÇäþÌgè t#Ù•€ WŠê\Ò² l ZN5�sÐÝá�ÝéÀÈ)£ULw&£¼«Vaês|*˜ˆ…>»°hlÖçü¼l˜–êE��èJÄL¶[äjÌ0ØÇÒzÙ�ÝÐ~L%R})D�"@Üy£ òo_MÞ;Ùе¡¾àý³±f,gq=~ àv³(2E°¹qÃÓµ²Ÿ¤U8#ç U·³S¢vh”LD¡Yc'NE} ³óGÙˆ<“�ã,ª�7v·bTz¬e×uº\pÆ9±gÓ7§EMþ¶GK}ÆÌœ€„Tå¶Û$ ì’ 'ºë…F*ÎÓønV3 –c0F Ôdž5eÁ:ô*Ým(4R“÷!Ú. ëÔƒùµÐ ¥þ2)?óžr ŒÆ�À[Úë+­‰DÓô»æ(n€é ‚°'Éaƒ=3ºaBºYÌï:c58CˆE¼¹F$UxŒ¯í³Ýzœ.?á»ü¢o¯ì¬ƒ³ÕÏ0pè ”î?Іªúsî<í]ˆKpcÄä1 ×�(Ý‚§¼-è zÍË?:Ì.¶‹ÐKö@¼;“j{þj=† êøTO;þ®#Ñ""&mÃÐãQÂÔBùˆf˜FžÐ¹§M«zeó.pÖ�eËY^�•†gö„`Ky °ÄÙùÞ&oW�“B\ÛgCx¶Q à™~„p,8V2›Ýßõš<ATíþhúÑèF^RÜv—ú¼Á£ ±åã�‡ÂçÜ ”<Ž©‹f!1-Y)õ ¬’•ž:˜d¿<[¯O;¿Ùn�x¦iÞ`:üÓ8aȤ (*ÒXÊÀâ©@ÔÌøuŸÇ W ”Ÿ/@™pˆ äÆý ]¨yq§B�3?þàé³¹V,ç`=n… H.Ÿ`²7ˆ¸!©J @Xq‰Ï½M; ùü�{œò8€êXA®-™ê€HÝH3=ö¨"ƒ“sÔ×&¦&# £dWñ9wr8ŒÆêS˜yÕEêßRIhð¶ 3Ð-€õÖBb­>[0sÚÆ/R6ªâjl.¢"‡¡'àPq‰”oÀ!úª†‰£¢ú¶`J ^�ðœÔ˜ÿW?÷  ݱ¾ÆRßÐó]w=P¦•,1Cm>HqVØ3xÀ�0FÉR†9}[Â÷ÚëCz¶: Pʉ“0éƒ :9sÂȘ@CO ŠRògs©ÏÍ-ÌÇ¡mûÐÑÒ~Î�À©“uÈœ‹üCÔ¿åÆeà`[)oè”a =ÜP»íahÏIDATûT˜èŒ�}5ÓÏV×…RãDç}Œ©€9†!N¨IAÍ«µ1³‚ âx«á££èÜYÛ[%íGv�íub9Gëñ€±F˜=Á_Ý{N¬‰ÍðxO ƒì./�Ç Xœ€Ùíy¥næyúj� "î}£|Êø°£Ï4y[1:£Hubv§v§û7ïú‡‚¥û�â¢ï] «ÝE8Ä¢ÁÛbØýM4E J¼ˆ!$NØ‘V‚€‚Ęöc«‰9àÎ?nDç‰)† ÐûåJ‚7¤¦X¾yú#¼?Ês=GÑôÁaÐ@ÌÐÿc¿‹8‚ÿ@°ÀRñfO ÔvÁ9$’Ã&Ð `ó~G@_9ˆÎØãa-à#è~-ãàÀޝ ÐÙðÂes!Ó�� Ò³ âP)šÎ�¢q¿×*Ë8úxõoÎdj+‹ŒC@Ñg1€GŒ  (Ã2a F Õ½�­óSÁB‚ô\ý¤"á¨1c5ƒBŸÛ‹?mà%Ô"ïòøQûên[b6ò5DBÿ–s±F,çp=¶EÐÌ¥&‰&h ¬ ’áED‰Ä"Õ‰‰~^¬ú¾NÐÙ0½˜‹> Î(`I@ju�™yBÐÔÓ†¡iƒà°* Ov‡î$7ölÚqîAJÑRׄqs'#!¢{(‚ö@ZýÌ2%š–ÈÇš &—'D À´" EðM$¿M§ǨPnF¿‰}|OŒ`5s>ÚƒÄ� P†‡@ Üiö³T?¿ÞжÞ|ë-¶�«%b9Ç›ÒQ(sg˜=!Üîƒä´Á–¯pôa $‘€\`¼†’X Qþé§�ðø%\ -rÓˆÂAÈ�1$i z=ÙCrQSz5eUç< èùÿí�i�\Wy†Ÿso/³õ,²F»eÉ–WÀ2vã°Á—I � ¤HŠ ÙŠªФþ�Pü ©P¤R›!l�€ PÁ66Y¶dI–-/’µÎLÏŒF³õ,Ý=Ý}òãnçœ{nw�Ð6ÒýªZ=3½êÞ{¾ó-ï÷¾¥yÖ\¾Žm7]‡G8TêK~G%¬oNªõÊ…Ù6� )½Q#f(­«`è,ÃêŒ‚š£ ½�(â�EipÆË šF’�û1GcŠž›ï)¤´Â…Õûñï?ËÌ#MÏ¿À÷ü×sy}8œ{û{<:ñD›ùÕ1ÊG§ì^Þ„~Úª¹2Ê<¤MzIÆ�ÒÖ®ÒÐ]R©K+@„†÷i'�Qœ?©]ê¿÷á÷kÒ^çÒí}V“ZÏ;Y]ó^ê’”Êáó1ø2Bå i]üÑx�÷¸ˆ‹üùïe`øµÑi¨íJÍ9È0›Œô”BÆçl-ÿàÿlaçˆ) K©OöiQ‚©ˆ1î0½ã÷=×ê4íóׯ95÷<\�5`·ßèlVÌoèÅ-äÂ�ZJ¡�˜È>“ŠK$q�R…>[(y¾Fö¬ªë¨À#ŒÖ¢ò’Z£FC6¸r`SØJì*t“ÍçØÿÈçü€;®Ã­÷¼‘l.ë]�Õ9Ξ`¹à25ÊMäº�UÏã]© -E¶$RÙ„†Lj1D¢H(FØ. ‰q®‚øT Ê—$­¢¡’…#S¿´·ÕŒÿ¤ŸŸóÑáüØãÀÇ›]��r�S?yž¥™Š¶k}Y›ª´ê…-E%u;Œ¤˜¯Uv„ñjµzÞEôÝœ<Ìðì˜öÝ^÷í\}Óuçü`/U–t¼»>ã¦ibHebP�T��´1ßH”iJ£u&õYÃð3Ìß•¨DjS|ÒPçQ§£Å'”;|�ñúÄIG”kAqæN/•nAIHí»ŸY›«2ò»[ {H-<~>âùrŸ~Øì õR•é^ð½§Ð÷ŠFp¡4°cÉ͸OÆÂ;%¸×<¼Zù�;ãÄ£; õºýÙ‘Úk»ûz¸ã½w“ëÈŸÓ=°v—03Ö”jk#¾Z@O„j3&Ù$Æø®á G«*‘1Ø ~¦È�1Û~d#Œ8‚EšÄËocï2¿{°Ãk…T�‡’Y.·†” nW+°þøÌùZ„çÓ€7äðtÓVÖ‰f9ŽL Ô”¿Ç.½œß¸6¤µj+›ˆÇ¤ �@‹2‚K_ŠË¥�’éJ‰�#ûµ‹vû-/çE¯Þ~Nò–¶‘ÉDÜ/åZÕ[.Òt|Š?³HeG9¼² Ÿ!¥Æ`¥Æ’RÆÆs‘ÂàÇ“[ŽTÞ[?þŠr�Jí¥ìÆêùÔÙ¤Æ,Q�ˆþc “»_êÒd²¡ÔPüÇêK5Š_ØÃüÓ-'BŸö×—ªx3ÞÄS¢Í?5ÆÌ/Ž‚lÄCø†9Òi°SHc2ÂÌ€2K;ºÔY*¢“­\`ÞÓ 4 ÒŒà¡ÝÅL•gÃï˜ïêäÍïyû9+æ;ólýo�ɺ %õF�±…SJ1S* K à¥T«r�+ÇS*nÚ1oDÇ2|f,šµý?„ôáZ„­£šlP©WÙÖ¿ÙW‚|w'½}ì~àѳr0oxÕ�üÝ×>Eïª>"Ñ“%~xìaIh½Ä"µMH$,\ƒ=QÚÚv’�uÏa™Š·=gÖÐã™På·}1i‰&4ÂZ[©Hw'³»†þl[…ü{}@êâö=ZðÐ�”�Íàöv�[Ýí�TÄ`Dõ¡Dä†|˜Ô¸Ðøb©êku €ŠÔ2cN@ ˜,ϲ®°š�޾ðµk7¯çоç˜;cpõ†5ÜýÁwñþ�ˆ\GN»xÿoh'•im‘ Ù>@S+åFê´A°B6Óé»LÍ?¬;:–ß-rà²Iø/uü¾Žh&L"™~ä8ůîƒzË#ùe<¨/©H¶ŸøNàÆ¦Nàð$N!ç± ©»ºª1¨Œîª÷¡0^E!€ ¢c HÔV�â*Bè|†FæÆÙ¾æš0 Èä2ú ìùùãRït¬§¿Àmï| ïùØŸðò7½&Ò]ômçøSìŸ<…[È‘[Ó­ìºRËÃãd£z Žc‰ ¤91h–b°…_Aç~®R[bkÿÆðsV­dèÐq†_8}Ø+ï¼™÷ýǸùžÛéÐÎF�‡w±wâ9=4 •RO›£i6!†Òv”ŸU°�d]« ´Æ1âPipþÙ|›4JDi�N¬qñ¹ý£ }öñvvþxÔx¥ m±]ˆ¿ ø¿x2H}MkG§òë Ú0PlÈ'(òIb€Q- Ú•´aDX�"ֹƤú!Àdy–-ý|ú0AÆu¹âú­ì¸ï!ª•ê²Ò঵üñ'þ’»?ø..Û0ˆã:¨šsÃóãüðÈܘWF‘¥Q  Ír€…×/Ñ9´xÜ*$*B}ë®'àÔÒ a¡"·x ]¹Xj>Ëìh耺dêácŒ|®-ú÷ã~q{üB\hª˜¾Ü 6�ŽM#+5:· D‹Ò T"tCo�8¿€±³«tæø°JM(CŸ¡èj JP—u*õ Û6#„'GÞÝßË–ë¯äÀÎ')Ï·}íà7ÿ.þìŸÿ†+®¿2L)]8ůF÷ñËâ^ÊõŠõ=l¼ g+áku¶Em[wRO6¬©BXÅ×?A&° Ûxíí½8¶?þ½ÌvŸˆ>KJd­ÁøwpòûÏ´s nF.ÔEv!;€YàÇÀk��Íœ@µX¢zrŽ®ëÁ8:Ûg@)#‰9 bcá`ªÙT”B­p0Sžcma5…ðýÖn^Ïõ¯| ó³sœ·rô­à5wÝÊ»?ún¹ç�düážÐcVØ1º�GÇö3ºp*¾™ K¶/ÕHIª^Ì“—qÞkv Í�C)B%éØ…Í™ýèwaó.±Qe,‚ÒZ Ð(JlêBÂ�~íI¦<Òεû8ðÎÀÏrL°2ìJ<Ìô ­žØqÕ*.»ójÜã«ù{(ø,JÁNê � ä|‚Q‹Á EÕßc‚§za±¿³—÷¾ämäœlø¸ª eFŽñÌÎýŒb~fŽþÁUl¹á*®Ú~-k¯Ø@.Ÿ3¶mÉÞSϳ{üóµrìäÊÄo,¼ÄhÀ’ØH=¬55iOéePj´åÛÒÂAb—ýŽåè"‚†ÛpH1è3ñ±`[Õ¿Q©1òÅ'(ín‹êýðvÎ"�÷¥æð ‚�4- ú–ÛX`Õ›¶EÒcŽEPÄÿÝqÙ‡õÞ¿RHt”ʾc„ký…\…jWÀ±úÄúÂjÞqÍéÈæP(ŒÃǽë²á¿·£ kÈåiÙMqþd|éªdŸI§ÚþѪëj1SÚŸžXɳ‡÷-…´w ‰aXZü’ÔÄ&-·V“hÂ>$¥¤<4ËÈvS91ÛÎuz�ðæÄJXTî r³À7�7š=±^ª²xdŠL!Ofu—Æ(¬WìEÌj¸A¡T»¥Ê €Q¨ 8 ¤¶ô…#ü�ýàÔqzs=ôä»p…9Ç“M¾ZtGx,ĻƟæþ¡Ç(U°nûMå}[ì †X°Z U:²ÉúW¤µ„´LUŠ¥�ç÷äîjoj KWAǨ"%�Óq²7›1b~}ñÅ2{­à',Ûµ°; eW¶K{ˆ&Õy{b`-ª }FÇVÆo&*ML€HÉ$‘RË®¿tjžÉ‡Ž1ùãƒí¶G%å–‹i±¸¡øOÿd½ȵzrex–ò‘)pùu áåÈ 0õ1j zø.„P†�ÀP ïܬ«´- 4ÍŠñú7H¢ïHÚÃ­Ž ¹² ­ãÁ±…¬À|õÐ ¬¿X&I�Ù{äÃíŽ@Ÿ#˜zø8cÿõsOÛ½¦æ�OøaÿEecØíÀ�Í­²ã`Îoîã²·^Cn°+Êí ÂÒè ï걂¨ŠÅkÆë \ǯIˆø‰1vv¡‹TÙÃ{“»ÏŒ¤Ñ>´N×$ƒ‰“;ƒºsˆ=Òp~è­ 2JšJq¨n䄯tl¶%+'ç»÷IæŸ9ÙÎop4ŽÜ1.’‹Ù€gm‘qè¿m+…—mÀíÈ„¹yb8U¨±aœï§Â¼¿Žœœ‹“u£Ý_ 3@ˆí¨&ÂPžÆI•-¢+=` Ä+â|zÞžP�­”…¤Žä—fÑ»’°´Gõ…*ScâžEÖ–¥¸ýuàƒ\€süi ОU�ÿö«¶¯£E«ðÙ†¦X<<‰ÈgÈôå'Z¬êÚ†d¹nð •*W£H!p³Ê¼€ÅG ¡k!Æw}¡«ãªu°ú•:€•Àìê| 2æ$²�\ÚÊ ¨¤ R-˜ai²ò cžWhÄúb•Òî"ůìõ@=�¶±Ð3À_õ¯!R°²í1¿8¸•6»õ¹* ÏMP91ƒÈ¹ iŠã¿–Õ)ü™©…ìFÅÞˆ¬ƒ>) “д,¡ ðˆ„°]´x+Ùô ABEP¶òÒ µtâõ?ÓsØÕ„fw 3þíLÞ˜úte9×ʃÀˆ0&µ .-ëÇC~Òÿ¹=sùM½¬ºc—÷ùiA°É*¡>‘ZoßÛœoG:exÐpr.NÎ�ZpB¯û2èRúu=Ch}†Õœ^(]€° ìîMÑ}FJ.âS‚H¬=ÿËo¬H�Üÿ]š”M¾D²xh’“ß{†ÅÃSÈ¥e…ûÓþŽÿMÿgRpñÚ5~mà¶å¾°ë†5 ÜvÙÁî0gjaÀ B{ÂQ%=0¨ÊÝÎŒ¯Ý§Vñ£ÝÖcú Êðµ4ãŒD§ åÍÅ­AoqÞ¬8”0zªI°!-5=çŒ ò©…=sT²Ö RœeâG)í:-ò�ý\ÿùKm!\ª °?>t-ëX8‚î×R¸qWôátfÃÊ V�Ð*ÿþd‘ ®ÀíÊ!ŒEgr– žhú“XÓ‰PIWÈ8e¬aVûšTmD ­šˆAë�ØqÔ´Hž!�F ¾XeñÐ$3;†˜Ý5²œ?ø„à#À¿]ª àRwk�îYVZˆ¬Cþò>º_¼†Âöµ¸]yVlÞ*áH0 ä9ÑézýcÑDë3ä/D„Cš4`Z« >¯Ö:…EôC™O2S,)‚5‡�"аĨ„�°>Wefç0¥=E�LµCÉm ÷¿‡'Ð1v)_ü©ˆì5À_ï\ö+�Û�¥ç¥ëé{íf²ýÑLA€$4JüVa¦'Õ¤°E÷ØõÎÔ)Oâ´‚ÿÙ¦dÄOÉó…•ÿOI ¤mÙK–¦™zà(³�ž 6[i§—o³oÿ‚GÕ}É[êtË/Å,ºöôލ ç¦µô¾zú9Çq�Ž/;¦òæ\ûsšw‰ÓÀáN¯C�Õ2ürÏrTCÐþ*T5 Xã/!*Á.ðÓP‡t,)EÃãâ+•˜~àfw—æ«ö’oPI/õÔ´²?þXÍi¶Kó t߸ŽÎ­ýä6p;²�˜Û�C¸£oh–÷â{µh'/·ý¬ÖçE¬æfÒ{¨ I²òg3�<4ÕâO©/,Qžañà$¥=EÊGN»(_&ð`¼ŸI/éÔ,×z�¿¼ètßÄéÊ’]×Cוt]¿šÎ-8§3£�…¢áú…Iìi>GËï›�ó¶wŠe‹äÀ\ÁRq8ZÍÐJ.ÚD;P™Ñ_<2ÅܾQMR.QŸûµ08OãÑt}�O2µÔœ¶mÞ‚×5¸éô�¶@dÙË:é~ÉZ^·™ì†^�ý'i)†!~“Ü?�šçùæÚµ!�íJß:) xõ{T†g™Ù9ÌÜÞ"K§‘Kõe‘—Zl/^UÿÇÀPzé¦àL«.àf¿ˆtÝ™xÓìšn Û×QxÙ:²ƒ=¸]DÆM€+»?Ħ[íèm�|ÚŠ¤!Þ|d$�†„Zƒúb�êØ¥'ŠÌíciìŒÑè=‹WÄý%^{O¦—lêΦÝü#°Íw ¿¶eúòtl óÊrëzÈ­î"Ó׉[È%;%Lê¤�0TwC P¡Œ¶y©LÚéÁ[� K >[aiz‘¥‰Eªcs,ž¢|dŠÚÌ«Á-‡€�?J/ÉÔœ»xž|Ù gìÄdÜž™�2ý�ä×÷�[ÛM~}�ì†n>…Eÿ$Ð`‹iè¸V®EHhØÄØðO½\£Z,Q)Q›§::Gmz‘ÚT™Zé´[vIvøð5<%éÔRpÞm ð*¼‚áoÑÉrÏ”ppÂuÈôw�[ÛCnM7¹õ=ä7È­éÆÉ¹à:Ñ2 ý·hQ{³ê€€(„¯ÔXšXðz±Du|�j±Dm¶‚¬7 .½û3€W�àövGÓK.u¢uà! fºéœžÐŒç2}yÜB·+‹Û×A¦'‡Ó�ÅíÊâä3ˆœëµ …@ÖêÈjƒF¹F}q‰Æüµ¹*õ™2õù*õ¹*µ™ µéòrçéÏ„ áÁµ¿�‡à+§—Xj+É®>�'5�×›–éÍz«ûÇhØ?fצ—O\LvUÙËñ�·¤‡üpþ0ž¶ÞýxìΩ¥ࢵN¼îÁµx¢&·ã‘˜^Jö˜¿ØŸÀƒéÓK#u—šeüºAÞÂ;ñèË®¿ÈþŸÏàÑj �_öoµôH@jöss‡Ül²xÝ׿¿PΟīÒ×ýû%` Ûc~HŸsR�Ú¯iëý”a�ïÖ=þï½~QÀëBˆ³°È§ñrËxøúq`(ú ~Üé‹é©J@jçÆ\`è3@Á�ºüŸûüû‚ÿ·¼Ÿ~à‡á MÐ@ ¡ �>|ç…†¦ëd_ÜZóµ«{ä“Ï ºLGA„àä(K7>‡/à§qw~ÓCfI=¡.Ò /ºÏ"}æh€‡N?ÿ¡ÒN>»+Gèb‰‘ëG34P ¡Rf£‰Ä«¸×7•�ÞÜL<û†£ÕnÜI™§M×étú((.$ÜÙ‹×ð`Æ0=6.B2f¢¬‡� È3sÓ*ŒtO|„5(¥PɯórË©ýé»\:uá[­û«sÍ\Þv”tÃGZ»â—g™pï42¬4š��g|Å$Âö B|¥Yè~s�§YšÏðè> ¥RÈ(™P ÝLc�µ?y—£ÿuÛÄrpË^Æåá1=,^ó(“×Ï¥õƒÓ´5™±v!ݱ0Ð=zÀòY:0Ã*<뛘‹fêèÉiY&3ò&â=9ÈÖ×7Ñ|¶é¶àš®3Ô?ˆ6 )X1•Øì4:¢=ø¿r©Z:�®)Špl%6Ýw¼•ø��=  «®dÂÏÓ /󳦳ï—Ûhn¸xW¹Zº‰´h×f¬šG¤4†mÛÈ%â¶MÿP7JÉDêR „H8:Ð+mdbqzîæ��…¦é\»Ü’Hâ)ààï>dª¯€p|ˆA;a× �É×ìÄz"¶t#6–ÐñµºìùÅfl¿ úõï§c‚®ö 2–×Âðã ø±¼‰éÂñbÇBXI“UÃz8cºg .�^è¶»‡Î;}ÑòŒÒt:.^'xñ*ÁÏ™ôÈl*æÍ¢áÈ)tÓÀµŠ&•0õž JfL"0&=`¡$ØýQú®vÐ}­³(@ÄîK¤g)‘RáÚ.JI†š»qí @·1ó½§C§_øè³x×Py$:DzvÿØRCñe,þî ŽœÂµF•²ì¥Õ>4…ëV˜^'B76¨R&j1%�Ê¢!Þ†c;Èdm †‰(ÅÀ¹nìÐfH)%p(ÚÒ³¡gj/Ù•³),-¢ý«V>ÿÓ>|m5‹ž\†ÒaÑ�Wc ºÂ½tFzÿ{mQ K–éJ"Žƒ›¬Ž†×¤tQ”+é?sà€Ú{NA7[jŠz½V¬^…nC¸»�q3'‘6 Ó¬"t¦…w^ü-ÁÏ©Zµ�§¥d*úa@W*¤ëâº.25%®ãÒWßFgmÓQà >œŽ£€�áèãÚøL®Fn�7»˜âÊhŽ^§#ÚMfPã/¯¼Et0BO¨›Éc'â”zˆ;vJfwØHnÜŠÓún=NOôÍŠwž8ÖQs^è#t½;hW S+³FÛƒtEz‰ã …bÜØb‚uM ôõl¸Ì}�-&¤ú@)©rKW"7IÌu]nìm$|¤µx­£æ¼Вň˜¾åI¼® ¶^ìDÚIÙlÇqù²«‰Ç6¬I±�FÐÃ.ŽãàŽœ¶›xº.Žë¦Ö{ê‚tîoj^Y�§Õœ£bË᎚ó'£ÍÝOÙ>ËÈó£é¥ \\ò'’'ÓšFõ¯^¢%7Ì€IE¯RÑ&Tp�D½G‚´½S?ˆ«žNÞ¶1©Ø²Z4|ïïK€÷2–ŒÉ~p¬,0 ž@x^ÍÐ@ 2+Qé¹d¤{Ý Ð¼!Ì[ Á;(e®Pd£)R½£Ä[|µ�Ñ#}î‹E€ïwþ9t!ð ` ÙÀÔl˜E°±o‰MÓB 4aØèØ`�»( s[9@)J)P ¥$Ik¢çÉCŒîs£{ø àµ?@_¾i™mô"“®_@é’Z4��î5š@Ó-`P ×8+1Vn,ɱ@17Æ€¤P ¤’äYFö�á̃-¤ûcö3¦�¿¾ó P °¸Î>Pº¦žIŸœ‡ð¢š¡!t M×Ñ4ÂR)! ­ç— ÈK‘’y�”²”ÍÞV&`R*2£)º~¹�Áß�Få¤}µ‡�/Ãïf úHÄ¿>  ‡¼Lþ«%T^Úˆî1Lpt M7Ðt $MCh¡iam áR=s­ ó¸6 &¨ŽÄi�f¢ê:¯¦æÿè>ƒâŵx«CÄŽô!S9€9À `›ÅQï @MÀýÀEþº Ô}aáåM×Ðu=Œ®¡iMhh8æoš–Ä>.‘»ÁÂæÿ òDZϱþ,õ L)¡xQ-±£}d‡“õ–_¶ è¯UlºÎ „gWSsÍ\|åASb<ºµ6L  Í0� GR,žy¢Îk×øŽ´²9ÇÚ¨ô—6üx5�”’L.M"›"šI0”AÊœ¥z)ÙXжíddŸãKî>œ|¯*¶ëP¼pÕfá) X ˜\#<¦ôh†^ÈC†æ¼iGmìW>†‚c¬½e­0§dsKgPì ›Ç]Ö-•K3”¡'1HG´›ÖH'9%QR!•é‘wlzƒÁWÚì[í®Þ €¶ŸÏ­¡æ#³ñ”øMöX x44Ã@·9ÈÉ”"„[mÜ~� Ø"døÎŒÏ¥œ3]¯0¸ªlÏýø1öo{�d,�BaxòÇrÐ?_ÔO æcsLp4�0LõÑ,k¥ë&ñš èÎqM&9¼c­5�¥3Y^9‡ÅS˜Q4Mô§‡ 8HA=€q"Φ¿ûF‡‰Gc$¢qbÃQz;º9ºç Û{�ý¿}�Ú†)\0½‘É¡j<ºAWÌô�„G'8­ŒtÏ(©³Q›SƒÀoþP€Ö[©aú8 ñ–-òÕ�뺒¦»¬˜MØ4 ]˃bƒUšÄâ M´<µ“ÎCmL¿ ‘Ú¢*:“}$eÚ![!i²LõTñú¯ sÒ‘L›è•Tä²9»ûyõñ—ˆ0gé<¦–N§{éˆv£0cßäbâ­ƒd‡’¶£»×r*ߕЅ,2›0é†Å„§•™ÀXê¤éºÇ&hÝ\–ÔæqÝæ!MwÌ»m¶/,ŸÃö»~Å‹=Àª kù›ýgý<Ó½sŒÿ~ÍC³6_Là¯."�ÈÆN pl[ »Ÿ{•‘¡r3²à’¥Üð�›)«­d{×~öôFI‰T’Èktü|¯mݺ¬ :ön$èà€(½¨ž’…µ.ŸÆ\ë–ŠÙD¬YÇ5ÛŠéB7\f_8 •xÃ4jù·¿þ?(+�1Ü7DuÝ$gÍ$–KÉŽš\¥™>�DÒ##œ1i‹wÑëãLn€Þâ8Þå\vÝU”aº[;I'St·uÑÛÑͬeó˜ZYKobˆ‘ŒyMߤb2C â­ƒ (*€§Þ)@Ë�oEFy�š�Íq|a«“e¾•*Fwˆ:¯~šå¿˜ÿ71PNôw�ìzj›sÓT"E"géÚT—q2Ö… „`j°ŠuÕKYU1�E¥M,*k¢©¤žoˆd.M4çlv€àÜ*V-¸�ޭĆ£œ=u¥`ÞÊÅx í£=äd¦•}³Û–¢YÀ‹@ç;èÛÀj€ªkæà�0}Í Ž°Âˆü¾)MÂQ1Ý&hÃíM ¼ºAsq=O÷AzÚ ó\ý]½TN®fÖ¢y($=é!ºŸÕEóhyroüf'wì§ýH+™XŠ ¦62»|:^ÃK$e8c¸,Ãò9K9µç‰Ñ8'ö¡yÙ\šg6Ñ9ÚÃp:jºáÕ=ЃÊJ�•à{ð÷t‰„†CÍULX\ëH�M̶ä8Ç…KÍtݲlyðtMÏ;‹B£ÔWLu��_ýð!2©tÁÍeNé`þE‹©ª¨¢+ÙG‰7ÌÀïNñ/·|‡ƒ;öspW û^ÞÍÁ�-Ùs€â²æÎœM™¯„îD?ñ\Šd™ 1Pˉ½G�¹]­¬ûä‡�(ÚG»‘J‚€À”FöŸ%3˜lgI‡´¯ªP²h’©V¦ ìÄÚšP儜N¼dZ͉©Ðp¶5+ö24�†ÐDž¿ï ’£ñqÅ÷ä›ÇؾõeÂø¸ ¨Ž¸L¡yÏ}—gûxãùüðo¾Ëó÷oer¨Š•sÑ„F,— tùTêš§Ðúæ1N¶eFÉ|º§ÀY­þØ,ÐVá‹nã5ö®³­ÄWe°©’ðœ4¯á2Ï®àÓMЖú +r× km›~Ëä MPâ-bjo˜Çô£‘èy-Dë[ÇY~åÅL�XKG²YááâËײòêu¬þÔåLŸÝHß鳌F¢$c íj¡®i³šf1˜f(%¥ç¨Ž‡9Õr ™“øÃA­YJëH'ÑLþåø*C¦ %ì<ú3@ïx4hÍ(G÷{ìx99++8T…é 'PtåzD>rtË¥¥M¼ð‹§Îá{ñz½Ä†£<þã‡ÐRЮcP�Ñ5=MO³¤³>�º²šOoùŸ\xÅE!HÆ“Üû�Fʋ˛Ar*Gù‚)‹ÃÚÕ@™B>ªµž¿êª&wXuåx*Vb™u¼5a<•!3Þq'µ„¡ò¸Ü(;ÏV毚țöÆð†öœaßoÍ_}}=7Üpwß}7[·nåðáÃüà?  ðâÃÏpêà ¦…&Qí/#I†Q™ ®ÒŒÈdz]\ôÝO0iú3ÏÚ7Ä‹?K¹¿„o˜œ’xëŠðýt�ì!�ßyå65/šˆQⳇs•…G@ÕAãŸ2£È諸r0R…¹daîk6P*S—H!Aæõ<·ù z;ºÍtä×¾ÆøC¾ò•¯°~ýzššš¸þúëY¿~½óPÿöõ{Ȥ3,,ž‰OóZ`›¹!% +5ÀºÏØ,od²øe¾b”=ìÃ0Ìâ@&�1�Ï1©hºÎ„åSìÛ.°dmL «NÞŠBÓò©P%œTg�Ô(Wàé¨Uþ¸�×єƥKضåv=�÷{V®\I8Æëõ:ªY\\Ìg>ó&NœhöQ^ú¿ÏRî-fZpbaŒ&)™¦vÉ +°WtŸîB ðë^˜k×¢ áÀö˶Ç4ae�}Z‘M56@^+&Á( `LXátžklÚ)È[`Øê§lqšk«qæõüêG:‡©®®Î§9Tþê6l`ݺuÎþ/îÜÈPï‹'4Ôý©¯î%äóŸ“/É!Ñ…Ž/jJ@Èâ¢ÑlÜ”~wÁ@“KÐ૯ �ßòž1ŠÍBJå9̹±pí˜`(ÍIËäï'B�.4–WÌÁs6Ãæ;72Ø�OâÍŸ?�Ç,²J)9|ø0£££Îï·Ür uuæ�DÙüí�x„΢’™ iŽWûK:˜'üÒª2³@–‰4¼¤ÛFHÆÍÚÙÔæi€` 9ì#\å&¡ ³ªŠ5W3Á, ŸéaæÓ¼NrJ(›£]É.IaÒËÂÎÐ –VÌbb²˜�·}Ÿ“-G ÍåüùŽÅÊår<öØcÜ{ï½ÎïË–-cÆ èºé‰lßú[Þ|u/3‚µT{KAÂz€YE <»éqÓg1tæ]´˜x6ÉP:ÊŒ¢©|eŸãN,¸x)ÑLŒX&áló¨m�ÓÊìG˜êÈ4] ‡}.qµRy®rŒS›R./ÑÍOÂz€UUó˜’+ç{7ÝÁ�ûÎ1çcÚ¾};›7ofhh(_‰¼ýv¦N�jÆiñ$Þ}R*–•4[<ãcǦ_s|¯Ym…¸ìÓW±wè(•¾ LŒ„8¼ûM”4ßâê«×ql¸�´Ê¢œ·ïv“�º{¯Ù¯ðèAϘꦹㆨ° *Í|ŒõûäP—N^JÑ Îß_÷Uï~küâý¬YŽuQJñÖ[oqèÐ!6mÚäœSUUÅ­·Þêò°�òô}�Qæ)fvQ= ™¢³µ�¬•Þø«oßB"�ãT¬‹5U yù¡çh?bfW\u áÊÚF»È©Ü¹yË2z+CH³6ª4¯Žæ7\âá¢U¨BÂ>GšÉÑ ágí”%\:y�;ŽñÍë¾JDZ¶ñÜååTTT8û±XŒ®®.Òé4�<ò­­­Îo_üâYºt©#E/my–ÞÎnšBux„Aó—.æÚ[oäoïû63?´�mýûAŽüz//üò)Çg»öÜÀ‘á6ú’‘·Mƒi^‘k&ÛT Ík8�S”#RÊ•0XXÝ̵Md"e<ü�ŸqÏ-w2Ô{þ\xss3ápØÙ?xð ³½k×.6oÞìÜC×uî¹çž¼µå¹ÍOÒýÌ)j Ý3€¸n ms2¼Ð»›‘LŒ´Êpæd;餙߾ñŽ/#Ê}Žœ"§rcF#Ü„&L#e.S ëŒbËI@ZÁà•ëOVƒ™\RúâÏ´<¿‹¿ÿ³KuÞâZSS@‘H„k®¹†E‹ÑÜÜÌ’%KòeiË_úìg?Ëý÷ßo‚øôË,ýÀJ¦.œÁ$_9g}H%‘×D3q¦¯h`Þ‹™>ï–}d »"H 3œÓ@¡ËÆ�¹ýöÛyüñÇ Ôó¡»ï#6<ÊìâŠ��ÓX¥Æ4~*«=&O…em§ç(+É�:µÛ$�ºd&7I&2Ö¥ËJXPyOoܾ߽δ9�Ìž5ƒÖH'eK'S×4�ÓGZß1@���”•™î|6›eçÎ�o{þ¦M› œÇ±KOûY¶þô®»õF*¼% ¦†)²AÉ»%…®‹²9ÛÕ½6¦AtÈ– pB¥säFS(™×ã²ð"í}ØÝ‚RЧþïä’YLlæèHWùºw%AlÚ´‰o|ãÜ|óÍlÙ²å�î|ñágPJáÑ „�gŒm¾rûrÊN)TN‘ìpú«’@Üp´X“M£”BHPº¢<4�®ÖÇéÚÿêÙ{€-áÐ@+}2ɪõ—°ýé—¯�<úè£<ðÀd2™‚㦛¡*QZ]N¸¤�ßk¾ÕL–ÑÈ‘¾!¢ƒÃy °ÖS›@Á@2‚T¹î1‰Xž+QζuijM¢Í‰»€ ð;à–ÜHŠ\4…V@)ÐrŠHÿPÁ@øÇ{iZ<‡K–ð@ËS,ÿèÅìݶ›Äyªî·�FBàð‡4/›ËÜÕ‹¨[ÐHhZq=Íh.A"—$-sH•C( Cèø5é!Õ>Bß�NŽî| ¯Ï˧þö/Ù=x�Þä�3`¥RåËÖc%F©ÂÊÐÕºv7*b™H"”‹¦0&J‘Ȥ1´Bçj »�çÚÊÕÿíZNjæp_;øÔU<ñÓ·WM×(¯©ä‚%³YyõZ¦¯�K[²›ŽD¯Ëé°$J¹$ß- ÖÀ'(ÔJAÍEËëžÝM,›4Á�.©@¡¬!·…˯±@Ícé¡$én''õ �pÔ ì“ñÌêÌ`ßäPÐdNuÍ9ƒ}òg�rá×°¢n>{OP»¤ŽÉ/×Ñyüô¸àTM®aéV±æú’žäáðhGº·çs×®RºÒ- ¹¬”"šŽ3¢b.Õ‘®uþ¼Bõ¢à|%Ñ=]ŽÞ[‹cN.Y+<¾ê"4¿AFf™^9…Ó-'H%ò ¯¤¤¯«›åZCY°„Ý‘CÌ*màè�ü €?`õÕ—²á–O1}ÃB¨v:’½f®Ê¾N«¯¥ÊººXÝÒ¤”r€�Ò¥Zã‚RÖ³¹Ž£ïÚ¼�ìH k:Ã=ÀèØÂaød.–öš• Bd=‚Z9§�šó¾3=Lj˜ÂœÙ³é@–{ȵ�ÒÖ`ÑÚ ¹æ–ÏÐü± 9¡;9ÀÌâ:VVÌer ŠþT„x6™‡E¹ÿò 9’à´=X»1 µm­q8žôØ•h�Ð÷ÄwËðcãUV‡€UH5]+òâ©£éÑl‚™M3Iv�0Ô7X@ºÝ§»Xyå%Ô–V³§ÿ0Ó+¦"Y>þåÏ0ïšUôW¥éN25\Ãòª¹t¿ÖÊé·NÐÐP�á18“è#ç¢\"¯¬Ø0/ŒÊ€T@Ù’%m Pä¹’%=�°M|¸Ãš s@i+ýº>MiÁú2´€¡ 2£,Z¹oRÐÝîè*ÑÈRJ¯\J2—¡Óaùe«IÖ´gú©U°¨²ß äg·ÿ3OþëÞx~;‹/]IÝä:ú“†3Ñs%×`)$Y3Þ,T¯p¤<TiæVEó÷ô`œ®ÍûìiW»€»-,Æíî8|D¥sUz؇·&ŒÐÌæ¥¾Ôµ‹f°rÍjJBÅÈœÄë÷ ¹`ÑzSƒœéälv�bˆåµ ˜^2‰g6>ÊÆÛ¾ÇéídÒ²™,#–]¶’€×Gg¼—L.;®�kÅpÞºr ôpdᾫžÖµ¥Rôly‹Dë�QÜéžÛq¾äÈ€k~C¯üøl<¥AtCwZ\|>?¥áb*‹Ë ‚(¡J�ПŒ 5ðû|¬›~!ÉClúÖ�ΛDûúÏïbþ%KùmÏNŽvRÐÞêê“vÂJéâ%ËÝ7[êT!ÉsËIYš´T2Ñ¡í_µã¯V¹'úû¨öŸPYY%Ó9‚ eV;‹i’%’D&E"BOt€ÞØ £™8ÒJ|K$|aîÿÊ?qqÖØåØÞC¬»î ªCé #scøÁå ɹê<–L�#MRÊ<—ÉBžêz …d{Ä6í·¯¿ÓÅ=À�Ùþ¸æ©b”ÌNy!¬Â�Uw×ÈORqurD³q–Ï[FËovŸ÷±áQ|?ó—/"#³tÅûÏqâPç‚Â8aKРd�й¹I2øJC/�²s`{€¿~7Mœg€iÀ‚ÔÙ‚T yó�ªhfï3Za›‹=u"#³Ô4ÔÂÙ$=§»Î{“£odÕÕk™QSÏ�¡“ddÖcÈv Yþ ç“(—ºÉ±R&‰öaz;Df a[®k­1zÿ¿'¾¼h“± ¿>ŽÌå½T›àkgãTÞä¾ÙsŒ‹¿°Þi?7ç“Îðð÷~ *}LP¤[=ÜÄ+óü‘³ ZŽoÞsÖß8–-KÓ÷ì1’íÃv@ö`\Q×ÞA6á" ‘>%òÊ)dÖ[é<¬JN"%ùAäÌßNyøÐ�}Û´¶¥HfRε�{XÕ ûzRŽ %¤ ¢ý<ÊÙvI’ ™Î1ðâIFv9ͬ;€ÿ}ÞøñÔ |¥rñƒ=Œî;›wʤ-òÒÑë±¾FG¤‹šÕÓ™ÜXwÞ\öéõ $GèO 9¶jV*×àsyP`”"'•5ÓGž�)sªyäµú~uØÉº'þ1óÅŽ $—¥z¢ByñV† ´‚¦©‚‰*Ö±5ÊŠ Ù÷RáÔѲš >çWh¸lÛ{[ˆ¤G Hx|ÏÙJšŽ CäÛ�¸TÈœddo]?Ûk;„ƒÀ5ÀÁ÷jBÝÀOV­JvŽÍg୙ݡIk”×l@A–,þŠ"&û+i=p!WéZ®ÿÖ—è*eßàQS#ok¾q‚ÔóxÌîˆ]æÓRJT6GäµNºîÛk[¬ðY«qü=Ÿ³ú]à ¯&Š—O¡hI-FÐkÍɰ�I{&�nõS t]§º¸‚¦ÐÂá"""ÆÑHÑtÂì¦?–ÍUtpKŽ¡JYõ…s�ÍÆÓ ý¶•ÞÇµŠŸ³ƒÑ÷ ,‡êNzhN5V×™]†æL=º†0Ì`lÐt�ß@3t«þ¯ :hÎWìtêV*Ÿq‚Y%óu°1.AòÌ0ýÏgxg‡“ëþxòýš³j/¯‡�gzcFêÌ0šGÇ[2Gk5Z*‘ŸKªìþE¯nZ= UÃq›•»Ü펟p…ª �/Ú�ìl§÷ÑCÄôÚÏ|Ê"äÞÍ@ÿØyóuX“µ€A ¡”ÒuÓñÖ„ÕÖ| ¡kh>Ýg8Èw¦åEH5¦óÂ�M¤ ¹†:wªxòìý[�2úf�]!UV¾ý/€Óïv€ïÕ§)®A`è!/á¹Õ”^2 £Ä�0Lp„&0Š|ÖGÆ™//D¾ş)ò‰EUP²qÏ�ÏÆRôo=Æðk�ä¢)ûÔaà^àÖ?t`ïå·;Ö?µú‹5-`^PCéš¼U!„WÇö:­“_[…ªc9H¹6îr²"=�`à×ÇÞÑᮩ'­¼Î7­ä;ÙËW�›¬8NðO/¥ô¢:ŠNDó{×À™à[̘nPå*[¦>—È}³‡áííÄ�ô�Mø½Ž9}ý_ß‹Á¼_ß*>m€K¬.Z³áff9Á *4”â­ £ §­×i3V¶ ‘3§t§»cÄO ?ÒOâø9)”(ð’e�žà÷LõþsÈq–�Řs@.æŽ o´€�1ÁoòSÀ@šÙ@�È’‹¦ÈF’nÕq/Y+À|Î2-–wüž.ÿQß0ó`ΩÁü~Ùj ¬FÌ&öw²$­�g¿e•ö[µ¼+Ùõ¾,Нàé˜×|Ö_ æL¿*«™´ÈÕõ±èÀüÐ[ÊúKX%ª÷}ùb邤J…eIEND®B`‚caronc-apprise-dfec414/apprise/assets/themes/default/apprise-warning-128x128.ico000066400000000000000000002040761526720371000275430ustar00rootroot00000000000000€€ ((€ !8KVdpwwpdVK8! Gƒ³äíðóõ÷øúûûúø÷õóðí䳃G  J™ãÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿã™J =q¸üÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿü¸q=,‰Äîÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿîĉ,DÂõýÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿ )ÿ&1ÿ,8ÿ1?ÿ4Cÿ6Eÿ4Cÿ1?ÿ,9ÿ&1ÿ )ÿ ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýõÂD BÑÿÿÿÿÿÿÿÿÿÿÿÿÿ2AÿOfÿ(g„ÿ/w™ÿ3ƒ§ÿ7Œ³ÿ:•¿ÿ>�Èÿ?¡ÎÿA¦ÕÿC©ØÿC©ØÿC©ØÿB§Öÿ@£Ðÿ>�Èÿ;•Àÿ7Œ´ÿ4ƒ§ÿ/w™ÿ(g„ÿOfÿ2AÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÑB R¿ÿÿÿÿÿÿÿÿÿÿÿ/<ÿ'g„ÿ9‘ºÿ>žÊÿ@¢ÏÿA¦ÔÿD©ØÿD©ØÿD©ØÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿD©ØÿD©ØÿEªÙÿB¦Ôÿ@¢Ïÿ>�Éÿ9�¹ÿ)h…ÿ/<ÿÿÿÿÿÿÿÿÿÿÿÿÿ¿RN¾ÿÿÿÿÿÿÿÿÿ ÿ)6ÿ*nŽÿ@¥ÓÿC¨×ÿC¨×ÿD©ØÿD©ØÿD©ØÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿD©ØÿC¨×ÿC¨×ÿA¥Óÿ,p�ÿ-:ÿ ÿÿÿÿÿÿÿÿÿÿÿ¾N¶ûÿÿÿÿÿÿÿ ÿ)4ÿ Tkÿ5‡«ÿ=˜ÀÿA¢ÎÿE©ØÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿFªÙÿEªÙÿFªÙÿFªÙÿF«ÚÿFªÙÿF«ÚÿF«ÚÿFªÙÿFªÙÿFªÙÿFªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿD©ØÿC©Øÿ<–Áÿ%`{ÿ/<ÿ ÿÿÿÿÿÿÿÿÿû¶]ùÿÿÿÿÿÿÿÿG[ÿ,r�ÿ9‘¶ÿ<—¾ÿ>˜Àÿ?šÃÿ@�ÆÿEªÙÿEªÙÿEªÙÿEªÙÿF«ÚÿE«ÚÿF«ÚÿF«ÚÿG«ÚÿG«ÚÿH«ÚÿH«ÙÿH«ÙÿH«ÙÿH«ÙÿH«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿH«ÙÿH«ÙÿH«ÙÿG«ÙÿH«ÚÿG«ÚÿF«ÚÿE«ÚÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿDªÙÿB¤Òÿ4„©ÿ!Skÿÿÿÿÿÿÿÿÿÿù]*¥ÿÿÿÿÿÿÿ ÿSiÿ5‡ªÿ9�µÿ<”ºÿ=–½ÿ>—¿ÿ>™Áÿ?šÃÿ?œÅÿC¦ÓÿEªÙÿF«ÚÿG«ÚÿH«ÚÿH«ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿG«ÙÿG«ÚÿG«ÚÿG«ÚÿE«ÚÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿC§Õÿ>›Æÿ&a|ÿ ÿÿÿÿÿÿÿÿÿ¥*eÝÿÿÿÿÿÿ ÿ?Oÿ5ˆ«ÿ9�³ÿ:�µÿ<“¹ÿ=•»ÿ=–½ÿ>˜¿ÿ>™Áÿ?›Äÿ@�ÇÿC¢ÍÿH«ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿH«ÚÿG«ÚÿG«ÚÿF«ÚÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿD©Øÿ@¢ÏÿK`ÿ ÿÿÿÿÿÿÿÿÜe•øÿÿÿÿÿ ÿ3@ÿ.u“ÿ8Œ¯ÿ9�±ÿ;�µÿ<’·ÿ<“¹ÿ=•¼ÿ=–¾ÿ?˜Àÿ@šÃÿBœÅÿB�ÇÿDŸÉÿHª×ÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿJ¬ÙÿI«ÙÿJ¬ÙÿJ¬ÙÿJ¬ÙÿJ¬ÙÿJ¬ÙÿJ¬ÙÿJ¬ÚÿJ¬ÙÿJ«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÚÿH«ÚÿF«ÚÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿD©Øÿ:‘ºÿ?Qÿ ÿÿÿÿÿÿÿ÷•­ÿÿÿÿÿÿ #+ÿ(eÿ6‰«ÿ8Œ®ÿ:�±ÿ:�³ÿ;‘µÿ<’·ÿ<”ºÿ>–½ÿ@—¾ÿA™ÀÿBšÂÿCœÄÿCžÆÿDŸÉÿG¦ÒÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿJ¬ÚÿJ¬ÚÿK¬ÚÿK­ÚÿK­ÚÿL­ÚÿK­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK­ÚÿK­ÚÿK­ÚÿK¬ÚÿK¬ÚÿJ¬ÚÿI¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿG«ÚÿE«ÚÿEªÙÿEªÙÿEªÙÿEªÙÿC¨×ÿ2¢ÿ,9ÿÿÿÿÿÿÿÿ­.ÍÿÿÿÿÿÿL^ÿ4„¤ÿ7ˆªÿ9‹­ÿ9Œ¯ÿ:ޱÿ;�´ÿ;‘µÿ=“¹ÿ?•ºÿ@–¼ÿA˜¾ÿB™ÀÿB›ÃÿCœÅÿDžÇÿDŸÉÿF£ÎÿH«ÙÿK¬ÚÿK¬ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK¬ÚÿJ¬ÚÿJ«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿG«ÚÿEªÙÿEªÙÿEªÙÿEªÙÿEªÙÿA£Ñÿ(e�ÿÿÿÿÿÿÿÿÌ.Hãÿÿÿÿÿÿ+oŠÿ6…¦ÿ7†¨ÿ8‰«ÿ9‹®ÿ9�°ÿ:޲ÿ<�µÿ>’·ÿ?”¸ÿ@•ºÿA–¼ÿA˜¾ÿB™ÀÿB›ÃÿCœÅÿDŸÈÿE¡ÊÿG£ÎÿK«×ÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿM­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK¬ÚÿJ¬ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿF«ÚÿEªÙÿEªÙÿEªÙÿEªÙÿD©Øÿ6‡­ÿ #ÿÿÿÿÿÿÿáHGñÿÿÿÿÿ0;ÿ0y–ÿ5„£ÿ7‡§ÿ8ˆ©ÿ8‰«ÿ9‹®ÿ:�°ÿ=�³ÿ>‘´ÿ?’¶ÿ?”¸ÿ@•ºÿA–¼ÿA˜¿ÿBšÁÿCœÄÿEžÆÿF ÈÿF¢ÊÿG£ÌÿJ©ÕÿO®ÚÿP¯ÛÿL­ÚÿL­ÚÿL­ÚÿM­ÚÿM®ÚÿN®ÚÿN®ÚÿN®ÚÿN®ÚÿO®ÚÿN®ÚÿN®ÚÿN®ÚÿN®ÚÿN®ÚÿN®ÚÿN®ÚÿN®ÚÿN®ÚÿN®ÚÿM­ÚÿL­ÚÿM­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÚÿH«ÚÿF«ÚÿEªÙÿEªÙÿEªÙÿEªÙÿ>›Çÿ=NÿÿÿÿÿÿÿîG8ûÿÿÿÿÿL^ÿ2~œÿ5„£ÿ7…¥ÿ7‡¨ÿ8ˆªÿ8Ьÿ;Œ®ÿ<ްÿ>�²ÿ>‘´ÿ?’¶ÿ@”¹ÿ@•ºÿA˜¾ÿB™ÀÿDœÂÿE�ÄÿFŸÆÿF ÈÿG¢Ëÿf²ÕÿŠÇâÿ“Íèÿ•Ïéÿ�ÌçÿtÀâÿN¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿN¯ÛÿO¯ÛÿN®ÚÿM®ÚÿL®ÚÿM®ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK¬ÚÿJ¬ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿG«ÚÿF«ÚÿEªÙÿEªÙÿEªÙÿC§Öÿ)fƒÿÿÿÿÿÿÿú8Fûÿÿÿÿ ÿ&c{ÿ4€žÿ5‚¡ÿ6„£ÿ7…¦ÿ7‡¨ÿ9‰«ÿ:‹­ÿ=Œ®ÿ=ްÿ>�²ÿ>‘µÿ?“·ÿ@”¹ÿB—¼ÿC™¾ÿDšÀÿEœÂÿE�ÅÿFŸÇÿG Éÿ‘Çàÿ¨Ñäÿ˜½Îÿ|š¨ÿŒ®¾ÿ¨Ñäÿ¬Øíÿ¡ÔëÿhºàÿO¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿP¯ÛÿP¯ÛÿO¯ÛÿP¯ÛÿP¯ÛÿP¯ÛÿP¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿN®ÚÿN®ÚÿM­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK¬ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿG«ÚÿEªÙÿEªÙÿEªÙÿD©Øÿ2} ÿÿÿÿÿÿÿúFEñÿÿÿÿÿ(eÿ3~œÿ4�Ÿÿ6ƒ¢ÿ6„¤ÿ7†¦ÿ8ˆ©ÿ:Š«ÿ<‹¬ÿ=�¯ÿ=ޱÿ>�³ÿ?‘µÿ@”¸ÿA–ºÿC—¼ÿC™¾ÿD›ÁÿEœÃÿEžÅÿGŸÇÿ’Çßÿ�ÂÓÿ@OVÿÿÿÿ"%ÿq�šÿ£ËÞÿ°Úîÿ�Ëçÿ[´ÝÿQ¯ÚÿR¯ÚÿR¯ÚÿR¯ÚÿR¯ÛÿR¯ÛÿR¯ÚÿR¯ÚÿR¯ÛÿR¯ÚÿQ¯ÛÿQ¯ÛÿR¯ÛÿP¯ÛÿP¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÚÿM­ÚÿM­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK­ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿEªÙÿEªÙÿEªÙÿEªÙÿ6†«ÿÿÿÿÿÿÿíE,èÿÿÿÿ ÿ(g~ÿ3}šÿ4�ÿ5� ÿ6ƒ¢ÿ7…¥ÿ8‡§ÿ;ˆ©ÿ<Šªÿ<‹­ÿ=�¯ÿ=�±ÿ?‘´ÿ@“¶ÿA•¸ÿB–ºÿC˜½ÿC™¿ÿD›ÁÿFœÃÿFŸÅÿn´Óÿ¨ÑãÿCSZÿÿÿÿÿÿÿ.:?ÿx•£ÿ±Ûîÿª×ìÿ}Âãÿ\´ÜÿS°ÚÿS°ÚÿS°ÚÿS°ÚÿS°ÚÿS°ÚÿT°ÚÿS°ÛÿS°ÛÿS¯ÛÿS¯ÛÿS¯ÚÿS¯ÚÿR¯ÛÿQ¯ÛÿQ¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿN®ÚÿN®ÚÿM­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿJ¬ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿFªÙÿEªÙÿEªÙÿEªÙÿ9�¹ÿ "+ÿÿÿÿÿÿß,Õÿÿÿÿ #ÿ+k†ÿ2}˜ÿ3~›ÿ5€žÿ5� ÿ6ƒ¢ÿ8…¥ÿ:‡¦ÿ;‰©ÿ<Š«ÿ<Œ­ÿ=�¯ÿ>�²ÿ@’´ÿA“¶ÿA•¸ÿB–ºÿC˜½ÿDš¿ÿFœÁÿGžÄÿQ¤Èÿ˜Êàÿ„¤²ÿÿÿÿÿÿÿÿÿÿ6CIÿ Æ×ÿ±Ûîÿ�ÒéÿiºßÿU±ÛÿU±ÛÿU±ÛÿU±ÛÿU±ÛÿU±ÛÿU±ÛÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿS°ÚÿS°ÚÿS°ÚÿR°ÚÿQ¯ÛÿQ¯ÛÿQ¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿM®ÚÿL®ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK¬ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿF«ÚÿEªÙÿEªÙÿEªÙÿ<–Àÿ+7ÿÿÿÿÿÿŵÿÿÿÿ  ÿ,o‰ÿ2|˜ÿ4}šÿ4œÿ5€žÿ6ƒ¡ÿ8„¤ÿ:†¤ÿ:‡§ÿ;‰©ÿ<Š«ÿ<Œ®ÿ>�°ÿ@�²ÿ@’µÿA“·ÿB•¹ÿC–»ÿE™½ÿF›ÀÿG�ÂÿHžÅÿs¶Ôÿ¬Õæÿ"%ÿÿÿÿÿÿÿÿÿÿÿÿˆ§¶ÿ²ÛîÿœÑêÿ]´ÜÿV±ÛÿV±ÛÿV±ÛÿV±ÛÿV±ÛÿV±ÛÿU±ÛÿV±ÛÿU±ÛÿU±ÛÿU±ÛÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿS°ÚÿS¯ÚÿR¯ÛÿQ¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿM­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿF«ÚÿEªÙÿEªÙÿEªÙÿ<–Àÿ !+ÿÿÿÿÿÿ¦•ÿÿÿÿÿ(e|ÿ1y•ÿ2|˜ÿ4~šÿ4œÿ6�Ÿÿ8ƒ¢ÿ9„£ÿ:†¥ÿ;ˆ§ÿ;‰©ÿ=Œ¬ÿ>�®ÿ?�±ÿ@‘³ÿ@’µÿB”·ÿC–¹ÿD˜»ÿFš¿ÿF›ÀÿG�ÂÿQ£Çÿ¤Ïãÿ^t}ÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ¡Æ×ÿ³Ûîÿv¿áÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿV±ÛÿW±ÛÿW±ÛÿV±ÛÿV±ÛÿU±ÛÿU±ÛÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿS°ÚÿR¯ÛÿR¯ÚÿQ¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿN¯ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK¬ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿEªÙÿEªÙÿ9�·ÿÿÿÿÿÿÿ�[üÿÿÿ ÿ"Ymÿ1x“ÿ2z–ÿ3|˜ÿ4~šÿ5€�ÿ7‚Ÿÿ9ƒ¡ÿ:…£ÿ:†¥ÿ;ˆ§ÿ<Š©ÿ>Œ¬ÿ>ޝÿ?�±ÿ@‘³ÿA“µÿC•·ÿD—ºÿE™¼ÿFš¿ÿFœÁÿI�Ãÿ…¾×ÿ•¶Äÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿcy„ÿ³Ûîÿ’ÌèÿY²ÜÿW±ÜÿW±ÜÿW±ÜÿW±ÜÿW±ÜÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿV±ÛÿV±ÛÿV±ÛÿU±ÛÿU±ÛÿT°ÚÿT°ÚÿT°ÚÿS°ÚÿS¯ÚÿR¯ÚÿQ¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÚÿM®ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK­ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿF«ÚÿEªÙÿEªÙÿEªÙÿ5…ªÿ ÿÿÿÿÿöZ#íÿÿÿÿReÿ0w‘ÿ1y”ÿ3{—ÿ3|™ÿ5œÿ7€žÿ8‚Ÿÿ9ƒ¡ÿ:…£ÿ:†¦ÿ;‰¨ÿ=‹«ÿ>Œ­ÿ?ޝÿ@�±ÿA’³ÿC”·ÿD–¹ÿD—ºÿE™½ÿGš¿ÿHœÁÿbªÉÿ®ÔäÿBQXÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿSfoÿ³ÜïÿœÑêÿ[´ÜÿX³ÝÿX³ÝÿX³ÝÿX³ÝÿX³ÝÿW²ÝÿX²ÝÿX²ÝÿX²ÝÿW²ÜÿW±ÜÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿV±ÛÿU±ÛÿU±ÛÿT°ÚÿT°ÚÿT°ÚÿS°ÚÿR¯ÚÿQ¯ÚÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿN®ÚÿM®ÚÿL­ÚÿL­ÚÿL­ÚÿK¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿF«ÚÿEªÙÿEªÙÿEªÙÿ1zœÿÿÿÿÿÿ×#¾ÿÿÿÿHYÿ/u�ÿ1x’ÿ2z•ÿ3{—ÿ3}™ÿ6œÿ8��ÿ8‚Ÿÿ9ƒ¡ÿ:…£ÿ;ˆ§ÿ=Š©ÿ=‹«ÿ>�­ÿ?ޝÿ@‘±ÿC“´ÿC”·ÿD–¹ÿF—»ÿG™½ÿI›¾ÿK�ÀÿžËÝÿ„¡­ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿl„�ÿ´Üïÿ�Ñêÿ]´ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿY³ÜÿY³ÜÿX³ÝÿX³ÝÿW²ÝÿW²ÝÿW²ÜÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿV±ÛÿU±ÛÿU±ÛÿT°ÚÿT°ÚÿT°ÚÿS°ÚÿR¯ÛÿQ¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿN®ÚÿM­ÚÿL­ÚÿL­ÚÿL­ÚÿK­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿEªÙÿD©Øÿ'b~ÿÿÿÿÿÿšeÿÿÿÿ-6ÿ-r‹ÿ0v�ÿ2x“ÿ2z•ÿ4|˜ÿ6~šÿ7›ÿ8�žÿ9‚ ÿ9„¢ÿ;‡¥ÿ<ˆ§ÿ=Š©ÿ>‹«ÿ>�®ÿA�°ÿB‘³ÿC“µÿC•·ÿE–¹ÿG˜ºÿH™¾ÿKœÀÿv´Ïÿ§ÌÛÿ;GMÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿªÐâÿ´Üïÿ’Ìçÿ\´ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿY³ÝÿX²ÝÿW²ÝÿW²ÜÿW±ÜÿW±ÛÿW±ÛÿW±ÛÿV±ÛÿU±ÛÿT°ÚÿT°ÚÿT°ÚÿS°ÚÿR¯ÚÿQ¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿN®ÚÿM®ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿFªÙÿEªÙÿEªÙÿB¥Óÿ9IÿÿÿÿÿÿMúÿÿÿÿ)f|ÿ/tŽÿ1w‘ÿ2x“ÿ2z•ÿ5|˜ÿ7~šÿ7›ÿ8�žÿ9‚ ÿ;…£ÿ<‡¥ÿ<ˆ§ÿ=Š©ÿ>Œ¬ÿ@ޝÿA�±ÿB’³ÿC“µÿD•·ÿG–¸ÿH˜»ÿJš½ÿV¢ÃÿžÊÝÿ{–¡ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿz•¢ÿµÜïÿ´ÜïÿyÁâÿ[³Üÿ[²Ûÿ[³Üÿ[³Üÿ[²Ûÿ[²Ûÿ[³Üÿ[³Üÿ[³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿY³ÝÿX²ÜÿW²ÝÿW²ÜÿW±ÛÿW±ÛÿV±ÛÿU±ÛÿU±ÛÿT°ÚÿT°ÚÿS°ÛÿR¯ÚÿR¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿN¯ÛÿM®ÚÿL­ÚÿL­ÚÿL­ÚÿK­ÚÿJ¬ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿFªÙÿEªÙÿEªÙÿ=—Áÿ ÿÿÿÿÿò¾ÿÿÿÿ!Qdÿ/sŒÿ1v�ÿ1w‘ÿ2y“ÿ4{–ÿ6}˜ÿ7~šÿ8€œÿ8�žÿ9ƒ¡ÿ;†£ÿ<‡¥ÿ<‰¨ÿ=Šªÿ@�­ÿA�¯ÿA�±ÿB’³ÿE“¶ÿF•¸ÿH˜¹ÿI™»ÿJ›½ÿ{·Ðÿ®Óãÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ6BHÿµÜïÿµÜïÿžÒêÿ^µÜÿ[³Üÿ\³Üÿ[³Üÿ[³Üÿ[³Üÿ[³Üÿ[³Üÿ[³ÜÿZ³ÜÿZ³Üÿ[³Üÿ[³Üÿ[³Üÿ[³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿY³ÜÿX³ÝÿW²ÜÿW²ÛÿW±ÛÿW±ÛÿV±ÛÿU±ÛÿT°ÚÿT°ÚÿT°ÚÿS°ÚÿQ¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿM®ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿJ«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿF«ÚÿEªÙÿEªÙÿ4�¦ÿÿÿÿÿÿ¢Aÿÿÿÿ0<ÿ-r‹ÿ/t�ÿ1v�ÿ1w’ÿ3z•ÿ6{–ÿ6}˜ÿ7~šÿ8€œÿ9‚Ÿÿ:„¡ÿ;†£ÿ<‡¦ÿ<Ѝÿ@Œ«ÿ@�­ÿA�¯ÿB�±ÿD’²ÿE“µÿH–·ÿI˜¹ÿI™»ÿU¡Áÿ«ÑáÿQbiÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿžÁÑÿµÜïÿ²Ûîÿw¿áÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ[³Üÿ[³Üÿ[³ÜÿZ³ÜÿZ³Üÿ[²Ûÿ[³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿY³ÜÿY³ÝÿX²ÝÿW²ÝÿW±ÛÿW±ÛÿW±ÛÿV±ÛÿU°ÚÿT°ÚÿT°ÚÿS°ÚÿR¯ÚÿP¯ÛÿO¯ÛÿO¯ÛÿN¯ÛÿM®ÚÿL­ÚÿL­ÚÿL­ÚÿK­ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿEªÙÿEªÙÿ$[uÿÿÿÿÿú=ãÿÿÿ ÿ-qŠÿ/s‹ÿ0t�ÿ1v�ÿ1x’ÿ5z”ÿ6{–ÿ6}˜ÿ7šÿ8€œÿ:ƒ ÿ;„¢ÿ;†¤ÿ<‡¦ÿ>Ѝÿ@Œ«ÿ@Ž­ÿA�°ÿD‘²ÿE“³ÿH•µÿH–·ÿI˜¹ÿK›¼ÿ�ÀÖÿ‘¯¼ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿXjtÿ²ÙìÿµÜïÿœÑéÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ[³Üÿ[³Üÿ[³Üÿ[³Üÿ[²Ûÿ[³Üÿ[³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿX³ÝÿW±ÝÿW±ÛÿW±ÛÿW±ÛÿU±ÛÿU±ÛÿT°ÚÿT°ÚÿS¯ÛÿR¯ÚÿP¯ÛÿO¯ÛÿO¯ÛÿN®ÚÿM®ÚÿL­ÚÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿEªÙÿB¤Òÿ#-ÿÿÿÿÿ²qÿÿÿÿ!Uhÿ.q‰ÿ0sŒÿ0uŽÿ1v�ÿ4y“ÿ5z”ÿ6|—ÿ6}˜ÿ7›ÿ9‚žÿ:ƒ ÿ;…¢ÿ;†¤ÿ>‰¦ÿ?‹ªÿ@Œ«ÿ@Ž®ÿC�¯ÿE‘±ÿG“³ÿH•µÿI˜¸ÿK™ºÿa¦ÃÿªÍÝÿ7CHÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ"ÿœ¾ÏÿµÝïÿ³Üïÿf¸ßÿ]´Ýÿ]´Ýÿ]´Ýÿ]´Ýÿ]´Ýÿ]´Ýÿ]´Ýÿ]´Ýÿ]´Ýÿ]´Ýÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ[³Üÿ[³Üÿ[³Üÿ[²Ûÿ[³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿY³ÝÿW²ÝÿW²ÜÿW±ÛÿW±ÛÿV±ÛÿU±ÛÿT°ÚÿT°ÚÿS¯ÛÿR¯ÚÿP¯ÛÿO¯ÛÿO¯ÛÿN®ÚÿL­ÚÿL­ÚÿL­ÚÿK­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿF«ÚÿEªÙÿEªÙÿ0xšÿÿÿÿÿúEçÿÿÿ+5ÿ,n…ÿ/rŠÿ0sŒÿ0uŽÿ2w�ÿ5y’ÿ5z”ÿ6|—ÿ7~šÿ9€œÿ9‚žÿD‰¥ÿX—°ÿcŸ¶ÿf¡ºÿf£»ÿc¡»ÿU™µÿE�¯ÿF’±ÿG”³ÿH–·ÿJ˜¹ÿL™»ÿ·Ïÿ®Òâÿ€š¦ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿau€ÿ¶Ýðÿ¶Ýðÿ„Æåÿ^µÞÿ_¶Þÿ_¶Þÿ_¶Þÿ_¶Þÿ_¶Þÿ_¶Þÿ_¶Þÿ_¶Þÿ_µÞÿ^µÞÿ^µÞÿ^µÞÿ^µÞÿ]´Ýÿ]´Ýÿ]´Ýÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ[³ÜÿZ³Üÿ[³Üÿ[³ÜÿZ³ÜÿZ³ÜÿY³ÜÿX³ÝÿW²ÝÿW±ÛÿW±ÛÿW±ÛÿU±ÛÿT°ÚÿT°ÚÿS°ÚÿR¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿN®ÚÿL­ÚÿL­ÚÿL­ÚÿK­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿF«ÚÿEªÙÿC§Õÿ8Hÿÿÿÿÿ°hÿÿÿ ÿ#Wiÿ.pˆÿ/rŠÿ0tŒÿ2v�ÿ4w‘ÿ5y’ÿ5{•ÿ6|—ÿ7šÿf�²ÿ’¹Éÿ�ÁÏÿ ÂÏÿ ÃÐÿ¡ÄÑÿ¢ÄÓÿ¤ÆÔÿ¤Ç×ÿ™ÃÓÿX�¸ÿH•´ÿJ—·ÿK˜¹ÿLš»ÿ_¦Äÿ¯Óãÿ°Ôãÿ°ÔäÿWjrÿÿÿÿÿÿÿÿÿÿÿÿ ÿµÝïÿ¶Ýðÿ¥Õìÿd¸ßÿ`¶Þÿ`¶Þÿ`¶Þÿ`¶Þÿ`¶Þÿ`¶Þÿ`¶Þÿ`¶Þÿ`¶Þÿ`¶Þÿ`¶Þÿ`¶Þÿ_¶Þÿ_¶Þÿ^µÞÿ^µÞÿ]´Ýÿ]´Ýÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ[³ÜÿZ³Üÿ[²Ûÿ[³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿX³ÜÿW²ÜÿW±ÛÿW±ÛÿW±ÛÿU±ÛÿT°ÚÿT°ÚÿS°ÚÿR¯ÚÿP¯ÛÿO¯ÛÿO¯ÛÿN®ÚÿL­ÚÿL­ÚÿL­ÚÿK­ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿF«ÚÿEªÙÿEªÙÿ6‡¬ÿ ÿÿÿÿÿ0éÿÿÿ+3ÿ,n…ÿ/pˆÿ0r‹ÿ0t�ÿ2v�ÿ4x‘ÿ5y“ÿ9}–ÿ\•«ÿžÀÍÿ–´¿ÿ~– ÿYjqÿ8DHÿ&.1ÿ")+ÿ*26ÿDQWÿ‰¤¯ÿ«ÍÛÿƒ¶ÌÿK–µÿJ—·ÿK˜¹ÿLš¼ÿM�¿ÿw´ÏÿªÐáÿ°Ôäÿ°Õåÿ•µÂÿ8DJÿ ÿÿÿÿÿÿÿÿÿ„ ­ÿ·Þðÿ¶Ýïÿ|Âãÿa¶Þÿa¶Þÿa·Þÿb·Þÿb·Þÿb·Þÿb·Þÿb·Þÿb·Þÿa·Þÿa·Þÿa¶Þÿ`¶Þÿ`¶Þÿ`¶Þÿ_¶Þÿ_¶Þÿ^µÞÿ^µÞÿ]´Ýÿ]´Ýÿ\³Üÿ\³Üÿ\³Üÿ[³Üÿ[³ÜÿZ³Üÿ[³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿY²ÝÿW²ÜÿW±ÛÿW±ÛÿW±ÛÿU±ÛÿT°ÚÿT°ÚÿR¯ÛÿQ¯ÚÿP¯ÛÿO¯ÛÿO¯ÛÿM®ÚÿL­ÚÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿF«ÚÿEªÙÿEªÙÿ›Æÿÿÿÿÿÿ'æÿÿÿ'0ÿ-m„ÿ.p‡ÿ/q‰ÿ1tŒÿ1uŽÿ4w�ÿT�¢ÿ•¹Æÿˆ¢¬ÿ &(ÿÿÿÿÿÿÿÿÿfz‚ÿ«ÍÚÿ¬ÍÛÿr¬ÄÿJ•¶ÿe§Âÿ£ÊÜÿ¬Ðßÿ¡ÊÜÿ�ºÒÿT¡ÂÿS¢Åÿh¯Íÿ‘ÄÜÿ²×èÿ³×èÿ¨ÌÜÿw�›ÿÿÿÿÿ ÿ¢ÄÔÿ¸Þðÿ¶ÝïÿwÀâÿc·Þÿd·Þÿd·Þÿd·Þÿd·Þÿd·Þÿd·Þÿd·Þÿd·Þÿd·Þÿd·Þÿd·Þÿd·Þÿd·Þÿc·Þÿc·Þÿb·Þÿb·Þÿb·Þÿ`¶Þÿ`¶Þÿ_¶Þÿ_¶Þÿ^µÞÿ]´Ýÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ[³ÜÿZ³Üÿ[³ÜÿZ³ÜÿZ³ÜÿX³ÝÿW²ÜÿW±ÛÿW±ÛÿU±ÛÿU±ÛÿT°ÚÿT°ÚÿR¯ÛÿQ¯ÚÿO¯ÛÿO¯ÛÿO¯ÛÿL­ÚÿL­ÚÿL­ÚÿK­ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿF«ÚÿEªÙÿEªÙÿ Pgÿÿÿÿÿ¥9ÿÿÿÿ&auÿ-n…ÿ.p‡ÿ/q‰ÿ1tŒÿ2u�ÿL‡�ÿ›¼Éÿ˜¡ÿÿÿÿÿÿÿÿÿÿ%'ÿ«ÌÙÿ«ÍÛÿ˜ÂÓÿQ™·ÿJ–¶ÿ¥ËÜÿtŒ–ÿXjqÿ¬ÎÝÿ¯Òâÿ¤Íßÿp±ÏÿT¤ÇÿW§Êÿm³Óÿ¨Óæÿ´Ùêÿ´Úìÿ­ÒâÿWjrÿ ÿÿdy‚ÿ¶Üîÿ¹ÞðÿšÐéÿd·Þÿd·Þÿd·Þÿd·Þÿd·Þÿd·Þÿd¸ßÿd¸ßÿd¸ßÿc¸ßÿc¸ßÿd¸ßÿd·Þÿd·Þÿd·Þÿd·Þÿd·Þÿd·Þÿc·Þÿc·Þÿb·Þÿa¶Þÿ`¶Þÿ`¶Þÿ_¶Þÿ^µÞÿ^µÞÿ]´Ýÿ\³Üÿ\³Üÿ\³Üÿ[³Üÿ[³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿX³ÝÿW±ÜÿW±ÛÿW±ÛÿV±ÛÿT°ÚÿT°ÚÿS°ÚÿR¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿN®ÚÿL­ÚÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿEªÙÿ=˜Âÿ ÿÿÿÿôÍÿÿÿ%-ÿ,l‚ÿ.n…ÿ/p‡ÿ/rŠÿ2t�ÿ<{’ÿŒ²Àÿ‹¦°ÿÿÿÿÿÿÿÿÿÿÿŠ¥°ÿ«ÌÚÿ©ÌÚÿpªÂÿI”´ÿ~´Ëÿ¤ÅÒÿ(03ÿÿÿrŠ“ÿ¦È×ÿ±Õåÿ”ÆÜÿ_«ÍÿW¨ÍÿY«Ðÿ‰Äßÿ²ÙëÿµÛìÿ¶Üíÿ±ÖæÿuŽ™ÿ¥ÆÖÿ¹Þðÿ¶Ýïÿf¸Þÿd·Þÿd¸ßÿd¸ßÿd¸ßÿd¸ßÿd¸ßÿe¸ßÿe¸ßÿe¸ßÿe¸ßÿe¸ßÿd¸ßÿd¸ßÿd¸ßÿd¸ßÿd·Þÿd·Þÿd·Þÿd·Þÿd·Þÿc·Þÿb·Þÿb·Þÿa¶Þÿ`¶Þÿ`µÞÿ^µÞÿ^µÞÿ]´Ýÿ\³Üÿ\³Üÿ\³ÜÿZ³Üÿ[³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿX²ÝÿW²ÜÿW±ÛÿW±ÛÿV±ÛÿT°ÚÿT°ÚÿS°ÛÿQ¯ÛÿO¯ÛÿO¯ÛÿN®ÛÿL­ÚÿL­ÚÿL­ÚÿK­ÚÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿFªÙÿEªÙÿC¦ÓÿFYÿÿÿÿýqüÿÿÿ#Wiÿ-l‚ÿ.o…ÿ/pˆÿ0s‹ÿ3t�ÿf—©ÿ¢ÀËÿ%,.ÿÿÿÿÿÿÿÿÿÿHU[ÿ¨ÉÖÿ«ÌÚÿ–ÀÑÿI“²ÿZž¼ÿ©ÍÜÿk€‰ÿÿÿÿÿ/8<ÿz“žÿ²Õæÿ±Öçÿ†ÀÜÿd±Òÿ\®ÓÿiµØÿ•Ìäÿ­Øëÿ¸Ýîÿ¸Þïÿ¹Þðÿ¹Þðÿ„Åäÿd¸ßÿd¸ßÿe¸ßÿe¸ßÿe¸ßÿe¸ßÿe¸ßÿf¹àÿe¸ßÿf¹àÿf¹àÿe¸ßÿe¸ßÿe¸ßÿf¸ßÿd¸ßÿe¸ßÿd¸ßÿd¸ßÿd·Þÿd·Þÿd·Þÿd·Þÿc·Þÿb·Þÿa¶Þÿa¶Þÿ`¶Þÿ^µÞÿ^µÞÿ]´Ýÿ\³Üÿ\³Üÿ\³Üÿ[³Üÿ[³ÜÿZ³ÜÿZ³ÜÿY³ÜÿX²ÝÿW²ÜÿW±ÛÿW±ÛÿU±ÛÿT°ÚÿT°ÚÿR¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿN®ÚÿL­ÚÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿEªÙÿ1{žÿÿÿÿÿ»‹ÿÿÿ ÿ)g{ÿ.m„ÿ.o…ÿ/pˆÿ0sŠÿ;y�ÿ™ºÇÿSchÿÿÿÿÿÿÿÿÿÿÿ‹¦±ÿ«ÌÙÿ¬ÌÚÿWš¶ÿI”³ÿŠ»Ðÿ¥ÅÒÿÿÿÿÿÿÿ ÿ2Bÿÿÿÿÿÿÿÿÿÿp…ŒÿªËØÿ«ÌÙÿm¦¾ÿH‘¯ÿj¦Àÿ®ÏÝÿ(04ÿÿÿÿÿÿÿÿÿÿÿÿÿÿ:FKÿ‰¥±ÿ¸Ýîÿ´Üïÿ–Îéÿ{Âäÿl»áÿg¹àÿh¹àÿi¹àÿi¹ßÿjºàÿjºàÿjºàÿjºàÿjºàÿjºàÿjºàÿjºàÿjºàÿjºàÿjºàÿjºßÿjºàÿi¹ßÿi¹àÿg¹àÿf¹àÿf¹àÿf¹àÿe¸ßÿe¸ßÿd¸ßÿd·Þÿd·Þÿc·Þÿc·Þÿb·Þÿ`¶Þÿ`¶Þÿ^µÞÿ^µÞÿ]´Ýÿ\³Üÿ\³ÜÿZ³Üÿ[³ÜÿZ³ÜÿZ³ÜÿY³ÝÿW²ÝÿW±ÛÿW±ÛÿV±ÛÿT°ÚÿT°ÚÿR¯ÛÿQ¯ÛÿO¯ÛÿO¯ÛÿM®ÚÿL­ÚÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿH«ÚÿG«ÚÿEªÙÿEªÙÿ %ÿÿÿÿêªÿÿÿ4?ÿ,k�ÿ-mƒÿ.n…ÿ/p‡ÿ2r‰ÿi™©ÿ”®¸ÿ"(+ÿÿÿÿÿÿÿÿÿ/8<ÿªÊÖÿ«ËØÿ”½ÎÿL’¯ÿK“²ÿ¦ÊÙÿkˆÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ2Cÿ$'ÿ'/3ÿYktÿ®Òãÿ·Þðÿ‚Æåÿ^µÞÿ]´Ýÿ\³Üÿ\³Üÿ[³Üÿ[²Ûÿ[³ÜÿZ³ÜÿY³ÜÿW²ÝÿW±ÛÿW±ÛÿU±ÛÿT°ÚÿS°ÚÿR¯ÚÿP¯ÛÿO¯ÛÿN¯ÛÿL­ÚÿL­ÚÿL­ÚÿK¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿEªÙÿK`ÿÿÿÿû½ÿÿÿBPÿ,k€ÿ-l‚ÿ.m„ÿ/p‡ÿ2qˆÿ2sŠÿ~§¶ÿ‘¬¶ÿ &(ÿÿÿÿÿÿÿWgmÿ©ÈÕÿªÊÖÿr§½ÿF�ªÿf¡ºÿªÌÙÿDQVÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿ "ÿ'.2ÿ:EJÿO^eÿ_pxÿl‰ÿtŠ”ÿz‘›ÿz‘›ÿz�šÿuŠ•ÿn‚Œÿas|ÿQ`gÿ@LSÿ)04ÿ"%ÿÿ ÿÿÿÿÿÿÿKZaÿ·Ýïÿ¯Úîÿd·ßÿ^µÞÿ\³Üÿ\³Üÿ\³Üÿ[³Üÿ[³ÜÿZ³ÜÿZ³ÜÿY³ÝÿW²ÜÿW±ÛÿV±ÛÿU±ÛÿT°ÚÿR¯ÚÿP¯ÛÿO¯ÛÿO¯ÛÿM®ÚÿL­ÚÿL­ÚÿK­ÚÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿEªÙÿEªÙÿ2}Ÿÿÿÿÿÿúÿÿÿ%^rÿ,k€ÿ-l‚ÿ.n„ÿ/p‡ÿ2rˆÿ3sŠÿo�­ÿ¡¾Éÿ09=ÿÿÿÿÿÿÿ§ÆÒÿ©ÈÕÿ–½ÍÿK�«ÿI�¬ÿ–¿Ðÿ‹¥°ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ***ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–µÄÿ·Þðÿ€Ääÿ^µÞÿ^µÞÿ\³Üÿ\³Üÿ[³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿY³ÝÿW²ÜÿW±ÛÿW±ÛÿU±ÛÿT°ÚÿT°ÚÿP¯ÛÿO¯ÛÿO¯ÛÿN®ÚÿL­ÚÿL­ÚÿL­ÚÿJ¬ÙÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ>™ÃÿÿÿÿÿQÿÿÿÿ,jÿ,k€ÿ-l‚ÿ/o…ÿ0p‡ÿ2r‰ÿ3s‹ÿT‹Ÿÿ£ÁÌÿSbhÿÿÿÿÿÿvŒ”ÿ©ÈÔÿ©ÉÕÿeŸ¶ÿFŒ¨ÿl¥¼ÿ¬ÌÚÿ$'ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ333ÿçççÿÂÂÂÿªªªÿ���ÿlllÿJJJÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿЧ´ÿ·ÞðÿŽËçÿ_¶Þÿ^µÞÿ\³Üÿ\³Üÿ\³ÜÿZ³Üÿ[³ÜÿZ³ÜÿZ³ÜÿX³ÝÿW±ÛÿW±ÛÿU±ÛÿT°ÚÿS¯ÛÿR¯ÚÿO¯ÛÿO¯ÛÿO®ÚÿL­ÚÿL­ÚÿL­ÚÿJ¬ÙÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿA Íÿ #ÿÿÿÿ‘Qÿÿÿ ÿ,jÿ-k€ÿ-l‚ÿ/o…ÿ2q‡ÿ2r‰ÿ3s‹ÿ5w�ÿ¢ÁÌÿލ±ÿ ÿÿÿÿ7AEÿ¨ÇÓÿ©ÈÔÿŒ·ÈÿH�§ÿM�¬ÿ§É×ÿbu|ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿùùùÿóóóÿÚÚÚÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ›»Êÿ¸ÞðÿŽËçÿ`¶Þÿ^µÞÿ]´Ýÿ\³Üÿ\³Üÿ[³Üÿ[³ÜÿZ³ÜÿZ³ÜÿX²ÝÿW±ÛÿW±ÛÿV±ÛÿT°ÚÿT°ÚÿR¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿM­ÚÿL­ÚÿL­ÚÿK¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿB¤Òÿ8HÿÿÿÿÈ„ÿÿÿ/9ÿ,k€ÿ-k�ÿ-mƒÿ/o†ÿ2q‡ÿ2r‰ÿ3t‹ÿ4vŽÿw¤µÿ¤ÃÎÿERWÿÿÿ ÿ“®ºÿ©ÇÓÿ§ÇÔÿ\™°ÿE‹§ÿ‹·Éÿ—´Àÿ$&ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÚÚÚÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿL\cÿ¹Þðÿ¸Þðÿ~Ääÿ`¶Þÿ_µÞÿ]´Ýÿ\³Üÿ\³Üÿ\³Üÿ[³Üÿ[³ÜÿZ³ÜÿY³ÝÿW²ÜÿW±ÛÿW±ÛÿU±ÛÿT°ÚÿR¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿM®ÚÿL­ÚÿL­ÚÿK¬ÚÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿEªÙÿC§Õÿ!Riÿÿÿÿí¯ÿÿÿBOÿ,k€ÿ-k�ÿ-mƒÿ/o†ÿ2q‡ÿ2r‰ÿ3t‹ÿ5vŽÿB�˜ÿ¤ÃÎÿ¥ÃÏÿÿÿXhoÿ¦ÅÑÿ©ÇÔÿ…²ÃÿC‰¥ÿb�µÿ¥ÆÔÿ]nuÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¢¢¢ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ;;;ÿÿÿÿÿÿÿÿÿÿÿÿÿÿ***ÿGGGÿ444ÿ&&&ÿÿÿÿÿÿÿÿÿÿÿÿÿ§ÉÙÿ¹Þðÿ­Ùíÿh¹ßÿ`¶Þÿ_¶Þÿ]´Ýÿ\³Üÿ\³Üÿ[³Üÿ[³Üÿ[³ÜÿZ³ÜÿY³ÜÿW²ÜÿW±ÛÿW±ÛÿU±ÛÿT°ÚÿS°ÚÿR¯ÛÿO¯ÛÿO¯ÛÿM®ÚÿL­ÚÿL­ÚÿL¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÚÿEªÙÿEªÙÿ*h„ÿÿÿÿùÜÿÿÿ"Sdÿ,k€ÿ-k�ÿ/n„ÿ0o†ÿ2qˆÿ2sŠÿ3tŒÿ5w�ÿ8z’ÿs¢´ÿ¥ÃÏÿŠ¢¬ÿ'.1ÿ�«¶ÿ¨ÇÓÿ§ÆÓÿEФÿF‹¦ÿ�ºËÿ–³¿ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿjjjÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿqqqÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ«««ÿÿÿÿÿ÷÷÷ÿÝÝÝÿÁÁÁÿ§§§ÿ‹‹‹ÿ```ÿ ÿÿÿÿÿÿÿh}‡ÿ¹Þðÿ¹Þðÿ†Æåÿb·Þÿa¶Þÿ_¶Þÿ^µÞÿ]´Ýÿ\³Üÿ\³Üÿ[³Üÿ[³ÜÿZ³ÜÿY³ÜÿW²ÝÿW±ÛÿW±ÛÿU±ÛÿT°ÚÿS°ÚÿR¯ÚÿP¯ÛÿO¯ÛÿN®ÚÿL­ÚÿL­ÚÿK­ÚÿJ¬ÚÿI«ÙÿI«ÙÿH«ÚÿF«ÚÿEªÙÿ/v–ÿÿÿÿüîÿÿÿ&]qÿ,k€ÿ-l�ÿ.n…ÿ0p†ÿ2qˆÿ2sŠÿ3u�ÿ5w�ÿ6y‘ÿE„›ÿ–ºÇÿ¦ÄÑÿ™¶Áÿ§ÆÒÿ¨ÇÓÿi ´ÿBˆ£ÿg ¶ÿ©É×ÿ:EIÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ777ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ£££ÿÿÿÿÿÿÿÿÿÿÿÿÿ888ÿûûûÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ«««ÿÿÿÿÿÿÿ'/3ÿ¹Þðÿ¹Þðÿ¦Õìÿkºßÿc·Þÿa·Þÿ`¶Þÿ_µÞÿ]´Ýÿ\³Üÿ\³Üÿ[³Üÿ[²ÛÿZ³ÜÿZ³ÜÿX²ÝÿW±ÜÿW±ÛÿV±ÛÿT°ÚÿS°ÚÿR¯ÚÿO¯ÛÿO¯ÛÿO¯ÛÿM­ÚÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿH«ÚÿF«ÚÿEªÙÿ3€£ÿ ÿÿÿþòÿÿÿ(auÿ-k€ÿ-l‚ÿ.n…ÿ1p†ÿ2qˆÿ3sŠÿ3v�ÿ5w�ÿ6y’ÿ6z”ÿW�¦ÿ¤ÄÐÿ¦ÅÑÿ§ÆÒÿŽ·ÇÿDˆ¢ÿEŠ¥ÿšÀÏÿz‘šÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ!!!ÿòòòÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÞÞÞÿ ÿÿÿÿÿÿÿÿÿÿÿÿ¯¯¯ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿøøøÿ,,,ÿÿÿÿÿÿÿ–´Âÿºßñÿ·Þðÿ€Ååÿd·Þÿc·Þÿb·Þÿ`¶Þÿ_µÞÿ]´Ýÿ\³Üÿ\³Üÿ[³Üÿ[²Ûÿ[³ÜÿZ³ÜÿY²ÝÿW±ÜÿW±ÛÿV±ÛÿT°ÚÿT°ÚÿS°ÚÿP¯ÛÿO¯ÛÿO¯ÛÿL­ÚÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿH«ÙÿF«ÚÿEªÙÿ7ˆ®ÿÿÿÿþ9õÿÿ ÿ(dxÿ-k€ÿ-l‚ÿ.o…ÿ1p†ÿ2r‰ÿ3sŠÿ3v�ÿ5w�ÿ6y’ÿ6{”ÿ9}—ÿI‰¡ÿw§¹ÿp¢¶ÿHŠ£ÿA†¡ÿr§»ÿªÊÖÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÌÌÌÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿöööÿÇÇÇÿ›››ÿlllÿAAAÿ+++ÿ ÿÿÿÿ555ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ’’’ÿÿÿÿÿÿÿN^eÿµÙëÿºßñÿ¥Õìÿd¸ßÿd·Þÿc·Þÿb·Þÿa¶Þÿ_µÞÿ]´Ýÿ\³Üÿ\³Üÿ[³Üÿ[³Üÿ[³ÜÿZ³ÜÿY²ÝÿW±ÜÿW±ÛÿV±ÛÿT°ÚÿT°ÚÿR¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿM®ÚÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿF«ÚÿEªÙÿ9޶ÿ ÿÿÿþQ+öÿÿÿ)dxÿ-k€ÿ-l‚ÿ/o…ÿ0p†ÿ2r‰ÿ3t‹ÿ3vŽÿ5x�ÿ6y’ÿ7{”ÿ9~—ÿ:™ÿ;�œÿ=‚�ÿ?…ŸÿA†¡ÿ§ÇÔÿsˆ�ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ¦¦¦ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿøøøÿÚÚÚÿ¼¼¼ÿžžžÿyyyÿÐÐÐÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿåååÿ ÿÿÿÿÿÿÿ˜¶Äÿºßñÿºßñÿo½áÿd¸ßÿd·Þÿc·Þÿb·Þÿa·Þÿ_µÞÿ^µÞÿ\³Üÿ\³Üÿ[³Üÿ[³Üÿ[³ÜÿZ³ÜÿY²ÝÿW²ÜÿW±ÛÿV±ÛÿT°ÚÿT°ÚÿS°ÛÿP¯ÛÿO¯ÛÿO¯ÛÿM®ÚÿL­ÚÿL­ÚÿK¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ<”½ÿ &ÿÿÿþd9ùÿÿÿ)dyÿ-k€ÿ-mƒÿ/o…ÿ2q‡ÿ2r‰ÿ3t‹ÿ3vŽÿ5x�ÿ6y’ÿ7{”ÿ9~˜ÿ:šÿ;�œÿ>‚�ÿA…ŸÿA†¡ÿ£ÆÓÿªÉÕÿ|“œÿ7AFÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ���ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿTTTÿÿÿÿÿÿÿVgoÿºßðÿºßñÿ�Ìèÿe¸ßÿd¸ßÿd·Þÿd·Þÿb·Þÿa¶Þÿ_µÞÿ^µÞÿ]´Ýÿ\³Üÿ[³Üÿ[³Üÿ[³ÜÿZ³ÜÿY³ÝÿW²ÜÿW±ÛÿV±ÛÿU±ÛÿT°ÚÿR°ÛÿQ¯ÚÿO¯ÛÿO¯ÛÿM®ÚÿL­ÚÿL­ÚÿK­ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ=˜Âÿ "+ÿÿÿÿrEùÿÿ ÿ)eyÿ-k�ÿ-mƒÿ/o†ÿ2q‡ÿ2r‰ÿ3t‹ÿ4vŽÿ5x‘ÿ6z“ÿ7{•ÿ9~˜ÿ:šÿ<�œÿ>ƒ�ÿA… ÿA‡¢ÿn¤¹ÿ«Ê×ÿ«ËØÿ£ÂÏÿx�˜ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ```ÿüüüÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¼¼¼ÿÿÿÿÿÿÿÿ°Ñâÿºßðÿ¬Ùîÿk»áÿe¸ßÿd¸ßÿd·Þÿd·Þÿb·Þÿa·Þÿ_¶Þÿ^µÞÿ]´Ýÿ\³Üÿ[³ÜÿZ³Üÿ[³ÜÿZ³ÜÿY³ÝÿW²ÝÿW±ÛÿW±ÛÿU±ÛÿT°ÚÿR°ÚÿQ¯ÚÿO¯ÛÿO¯ÛÿM®ÚÿL­ÚÿL­ÚÿK¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ?œÇÿ%/ÿÿÿÿzJúÿÿ ÿ*eyÿ-k�ÿ-mƒÿ/o†ÿ2q‡ÿ2r‰ÿ3tŒÿ4w�ÿ5x‘ÿ6z“ÿ7{•ÿ9~˜ÿ:€šÿ<��ÿ>ƒžÿA… ÿBˆ£ÿC‰¥ÿX—°ÿ—¿Ïÿ¬ÌÚÿ­ÍÚÿªËØÿTdjÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ@@@ÿëëëÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýýýÿöööÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ÷÷÷ÿRRRÿÿÿÿÿÿÿx�šÿ»ßñÿ»ßðÿ„Ææÿf¹àÿe¸ßÿd¸ßÿd·Þÿd·Þÿb·Þÿa·Þÿ_¶Þÿ^µÞÿ]´Ýÿ]³ÜÿƒÅäÿšÐéÿšÐéÿƒÆäÿZ³ÝÿW²ÝÿW±ÛÿW±ÛÿU±ÛÿT°ÚÿS°ÚÿQ¯ÚÿO¯ÛÿO¯ÛÿM®ÚÿL­ÚÿL­ÚÿK¬ÚÿI«ÙÿI«ÙÿI«ÙÿF«ÚÿEªÙÿ?œÇÿ&0ÿÿÿÿvHùÿÿÿ)eyÿ-l�ÿ.mƒÿ/p†ÿ2qˆÿ2sŠÿ3tŒÿ4w�ÿ5x‘ÿ6z“ÿ7|•ÿ9~™ÿ:€›ÿ;‚�ÿ?ƒžÿA… ÿBˆ£ÿDŠ¥ÿE‹¨ÿF�ªÿr©Àÿ¦É×ÿ­ÍÛÿ®ÎÜÿ˜´Àÿ4>Cÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÛÛÛÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿåååÿiiiÿ‚‚‚ÿŸŸŸÿ¹¹¹ÿÔÔÔÿíííÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¸¸¸ÿÿÿÿÿÿÿ-6:ÿ»ßñÿ»ßñÿ¦Õìÿk»àÿf¹àÿe¸ßÿd¸ßÿd·Þÿd·Þÿc·Þÿa·Þÿ_¶Þÿ^µÞÿi¹ßÿ«Øíÿ¤ÈÙÿ—¹Éÿ¡ÄÕÿ³Ûîÿ«Øíÿh¹àÿW±ÛÿV±ÛÿU±ÛÿT°ÚÿS°ÚÿQ¯ÚÿO¯ÛÿO¯ÛÿN®ÚÿL­ÚÿL­ÚÿK­ÚÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿEªÙÿ>šÄÿ#-ÿÿÿÿk>øÿÿÿ)dyÿ-l‚ÿ.m„ÿ/p‡ÿ2qˆÿ2sŠÿ3tŒÿ4w�ÿ6y’ÿ6z”ÿ8|–ÿ:™ÿ;€›ÿ<‚�ÿ>ƒžÿA†¡ÿB‡¢ÿDЦÿE‹¨ÿF�ªÿG�®ÿR—²ÿ„µÉÿ¥ÊÙÿ¯ÏÝÿ¯ÐÞÿx�™ÿ.8<ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÊÊÊÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿâââÿ ÿÿÿÿ!!!ÿ111ÿAAAÿ¸¸¸ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþþþÿ888ÿÿÿÿÿÿÿœºÈÿ»ßñÿºßðÿ�Ääÿg¹àÿf¹àÿe¸ßÿd¸ßÿd·Þÿd·Þÿb·Þÿa·Þÿ_¶Þÿ^µÞÿ¦ÕìÿŽ­¼ÿ)27ÿ ÿ#&ÿx’Ÿÿ´Üïÿ­Ùîÿb¶ÝÿV±ÛÿT°ÚÿT°ÚÿS°ÛÿQ¯ÛÿO¯ÛÿO¯ÛÿM®ÚÿL­ÚÿL­ÚÿK­ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ=–Àÿ (ÿÿÿÿY1õÿÿ ÿ)dxÿ-l‚ÿ.m„ÿ/p‡ÿ2rˆÿ3sŠÿ3uŒÿ4w�ÿ6y’ÿ6z”ÿ7|–ÿ:™ÿ;€›ÿ<‚�ÿ>ƒ ÿA†¡ÿB‡£ÿCЦÿFŒ¨ÿFŽ«ÿG�®ÿJ’¯ÿK“±ÿ`¡»ÿŠºÎÿ¯ÐÞÿ°Ñßÿ¤ÄÒÿu‹•ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¨¨¨ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿîîîÿEEEÿÿÿÿÿÿÿøøøÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿŸŸŸÿÿÿÿÿÿÿXiqÿ¹Ýîÿ»ßñÿ¡Óëÿh¹ßÿf¹àÿf¹àÿe¸ßÿd¸ßÿd·Þÿd·Þÿb·Þÿa·Þÿ_¶ÞÿuÀâÿ²Úìÿ9EKÿÿÿÿ ÿ’²Âÿ³Üïÿ ÒêÿY²ÛÿU°ÚÿT°ÚÿR¯ÛÿQ¯ÚÿO¯ÛÿO¯ÛÿN®ÚÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ:�¸ÿ !ÿÿÿþF$òÿÿÿ(bvÿ-l‚ÿ.n„ÿ/p‡ÿ2rˆÿ3s‹ÿ3u�ÿ4w�ÿ6y’ÿ6{”ÿ7|–ÿ:™ÿ;€›ÿ;‚�ÿ?„ŸÿA†¡ÿB‰¤ÿCЦÿFŒ¨ÿFŽ«ÿG�®ÿJ’¯ÿK”²ÿL•´ÿO˜µÿd¥Àÿ ÈÙÿ°Òáÿ±Óâÿ¨ÉÖÿDQWÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿuuuÿþþþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿúúúÿeeeÿÿÿÿÿÿ™™™ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿôôôÿ ÿÿÿÿÿÿ"ÿž»Êÿ¼ßñÿ»ßñÿo¼áÿh¹ßÿg¹àÿŽËèÿ­Ùîÿ±Ûîÿ¦Õìÿ{Âãÿb·Þÿa·Þÿ_¶Þÿ„ÆåÿªÐàÿ,5:ÿÿÿÿÿÿ°Øëÿ²ÛîÿˆÈåÿT°ÚÿT°ÚÿR¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿM®ÚÿL­ÚÿL­ÚÿK¬ÚÿI«ÙÿI«ÙÿI«ÙÿF«ÚÿEªÙÿ8‹±ÿÿÿÿþ$ñÿÿÿ'_rÿ-l‚ÿ.n„ÿ/p‡ÿ2r‰ÿ3t‹ÿ3u�ÿ4x�ÿ6y’ÿ7{”ÿ7|–ÿ:šÿ;�œÿ<‚žÿ>„Ÿÿ@†¡ÿBˆ£ÿC‹¦ÿEŒ©ÿFŽ«ÿG�®ÿJ’°ÿK”²ÿL–´ÿM—¶ÿO™¸ÿPšºÿ�·ÏÿªÏàÿ²Õãÿ³Õäÿp†�ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿCCCÿýýýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿƒƒƒÿÿÿÿÿ///ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿiiiÿÿÿÿÿÿÿ^pxÿ½àñÿ¼ßñÿŒÉçÿjºàÿo½àÿªØíÿ­Ðàÿ•³Áÿ–µÃÿ°Óäÿ¸Þðÿ›Ñêÿa¶Þÿ_µÞÿ�ÅäÿµÝïÿN_hÿÿÿÿÿÿAOWÿ³Ûîÿ©×ìÿg¸ÝÿT°ÚÿR¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿM­ÚÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ5ƒ§ÿ ÿÿÿþìÿÿÿ$Xjÿ-mƒÿ.n…ÿ.qˆÿ2r‰ÿ3t‹ÿ3u�ÿ4x�ÿ6z“ÿ7{•ÿ7}—ÿ:šÿ;�œÿ<‚žÿ>„ ÿ@†¡ÿBˆ¤ÿC‹§ÿFŒ©ÿGެÿG�¯ÿJ’°ÿK”²ÿM•´ÿM—¶ÿO™¹ÿPšºÿQ�½ÿa¦Æÿ‘ÂØÿ¯Óãÿ´Öåÿ•±½ÿ.7;ÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿýýýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¢¢¢ÿÿÿÿÿ¶¶¶ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÈÈÈÿÿÿÿÿÿÿ!#ÿ´Õåÿ¼àñÿ¬Øíÿl»àÿj¹ßÿ’Ìèÿ¯Ñâÿ2<@ÿÿÿ=JPÿ±Öçÿ¸ÞðÿŒÊçÿ_µÞÿiºàÿµÜïÿ©Îàÿ%-1ÿÿÿÿÿÿƒ¡¯ÿ²Ûîÿ‡ÇåÿU°ÚÿR¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿM­ÚÿL­ÚÿL­ÚÿK¬ÚÿI«ÙÿI«ÙÿI«ÙÿF«ÚÿEªÙÿ1zœÿÿÿÿúæÿÿÿIXÿ,mƒÿ.n…ÿ/qˆÿ2r‰ÿ3t‹ÿ4vŽÿ4w�ÿ6z“ÿ7{•ÿ7}—ÿ:™ÿ;�œÿ<ƒžÿ>„¡ÿ@‡¢ÿBˆ¤ÿC‹§ÿE�©ÿGެÿG�¯ÿJ“°ÿK”²ÿM•´ÿM—¶ÿO™¹ÿP›»ÿR�¾ÿSŸÁÿU¢ÃÿuµÏÿ«Òâÿµ×çÿ¦ÇÕÿ;GLÿÿÿÿÿÿÿÿÿÿÿÿÿâââÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¿¿¿ÿÿÿÿJJJÿñññÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿùùùÿ___ÿÿÿÿÿÿÿ‰¢­ÿ½àñÿºßðÿ„Æåÿjºàÿm»àÿ¯Úîÿi}ˆÿÿÿÿÿ;HNÿ·Þðÿ²Ûïÿs¾âÿ]´Ýÿ�ÊæÿµÜïÿŸÂÒÿÿÿÿÿÿ6BGÿ­Ôçÿ¯Úíÿ^´ÜÿR¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿM­ÚÿL­ÚÿL­ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿF«ÚÿEªÙÿ,n�ÿÿÿÿõ¾ÿÿÿ7Bÿ,mƒÿ.o…ÿ0qˆÿ1s‹ÿ3tŒÿ4vŽÿ4w�ÿ6z“ÿ7{•ÿ7}˜ÿ:€šÿ;�œÿ<ƒŸÿ>„¡ÿ@‡£ÿB‰¤ÿC‹§ÿE�©ÿGެÿG�¯ÿI“±ÿK”³ÿM–µÿM˜·ÿN™¹ÿP›»ÿR�¾ÿSŸÁÿU¢ÃÿW¤Æÿd¬Ëÿ©ÑãÿµØèÿ¶Ùèÿ?LQÿÿÿÿÿÿÿÿÿÿÿÿ¨¨¨ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÞÞÞÿ ÿÿ ÿªªªÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÆÆÆÿÿÿÿÿÿÿ…¡ÿA‡£ÿB‰¤ÿC‹¨ÿE�ªÿGެÿG�¯ÿI’²ÿL•³ÿM–µÿN˜·ÿN™¹ÿP›»ÿRž¿ÿS ÂÿT¢ÄÿW¤ÆÿY§Èÿ[¨Êÿ Íáÿ¶Ùéÿ·ÚêÿKZ`ÿÿÿÿÿÿÿÿÿÿÿlllÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüüüÿ---ÿÿ@@@ÿûûûÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýýýÿIIIÿÿÿÿÿÿ ÿ¦ÆÔÿ½àñÿ¸ÞðÿÄäÿjºàÿi¹ßÿm»áÿ´Üïÿ›ºÉÿÿÿÿÿÿ&/2ÿ¨ÍÞÿ¶Ýðÿn»àÿ\³Üÿo¼àÿ®Ùíÿ®Ôæÿ$,/ÿÿÿÿÿWktÿ®Øëÿ¯ÚîÿR¯ÚÿO¯ÛÿO¯ÛÿN®ÚÿL­ÚÿL­ÚÿL­ÚÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿF«ÚÿC¦ÓÿCUÿÿÿÿ¬fÿÿÿ ÿ-m„ÿ.o†ÿ/qˆÿ1s‹ÿ3uŒÿ4v�ÿ4x‘ÿ5z”ÿ7|–ÿ8}˜ÿ8šÿ;‚�ÿ<ƒŸÿ>…¢ÿ@†£ÿB‰¥ÿC‹§ÿE�ªÿG�¬ÿH‘®ÿI’²ÿL•³ÿL–µÿN˜¸ÿNšºÿP›¼ÿQ�¾ÿSŸÁÿT¢ÄÿV¤ÆÿY¦ÈÿZ¨Êÿ]ªÍÿ–Èßÿ¶Úêÿ·ÛëÿQahÿÿÿÿÿÿÿÿÿÿ---ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿIIIÿÿ¿¿¿ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¬¬¬ÿÿÿÿÿÿÿ`qyÿ»Þïÿ¼àñÿŸÒêÿjºàÿjºàÿi¹ßÿhºàÿ�Òëÿ¹Þðÿo…�ÿÿÿÿÿÿ|—£ÿµÝïÿ›Ðéÿ\³Üÿ\³Üÿ�Êæÿ³Ûîÿn†’ÿÿÿÿÿ"%ÿ§Ïáÿ±Úîÿr¾áÿO¯ÛÿO¯ÛÿN®ÚÿL­ÚÿL­ÚÿK¬ÚÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿF«ÚÿB¢Ïÿ(3ÿÿÿÿf2ÿÿÿÿ+iÿ.o†ÿ/qˆÿ0s‹ÿ2u�ÿ4v�ÿ4x‘ÿ5z”ÿ7|–ÿ8~˜ÿ9šÿ;‚œÿ<ƒ ÿ>…¢ÿ@†¤ÿB‰¥ÿC‹§ÿE�ªÿG�¬ÿH�¯ÿH’±ÿK•´ÿL—µÿN—·ÿNšºÿO›¼ÿQ�¾ÿS ÁÿT¢ÄÿV¤ÆÿX¦ÈÿZ©Ëÿ\ªÍÿ`­Ïÿ’Èàÿ·Ûìÿ±ÕåÿCQWÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿqqqÿOOOÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿûûûÿÿÿÿÿÿÿ$&ÿ¦ÄÓÿ¼àñÿ·Ýïÿn¼àÿjºàÿi¹ßÿg¹àÿf¹àÿzÂäÿ´Üïÿ²Öçÿ5@EÿÿÿÿÿIY`ÿ°×éÿµÜïÿaµÝÿ[³Üÿd·Þÿ°Úîÿ˜ºÊÿ"ÿÿÿÿÿ“¶Æÿ°Úîÿ•ÎéÿO¯ÛÿO¯ÛÿM®ÚÿL­ÚÿL­ÚÿK¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ@žÉÿ ÿÿÿÿ2ÿÿÿÿO_ÿ.o†ÿ/q‰ÿ0s‹ÿ2uŽÿ4w�ÿ4x‘ÿ5y“ÿ7|–ÿ8~™ÿ8›ÿ;‚�ÿ<„ ÿ=…¢ÿ?‡£ÿA‰¥ÿC‹§ÿDŽ«ÿG�¬ÿH‘¯ÿI“²ÿK”µÿL—¶ÿN˜¸ÿO™ºÿOœ¼ÿQ�¾ÿRŸÀÿT¡ÄÿU¤ÇÿX¦ÉÿZ©Ëÿ\«Íÿ^¬Ðÿ_®Ñÿ˜Ìãÿ¸Üìÿ·Üìÿ'/2ÿÿÿÿÿÿÿÿÿÎÎÎÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿžžžÿçççÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿuuuÿÿÿÿÿÿÿgz„ÿ½àñÿ¼àñÿ„Æåÿjºàÿjºàÿh¹àÿf¹àÿf¹àÿe¸ßÿ�Ìèÿ·Ýïÿz“Ÿÿÿÿÿÿ#&ÿ«ÏáÿµÜïÿzÁâÿZ³Üÿ[³Üÿ–Îèÿ´Üïÿ7DJÿÿÿÿÿayƒÿ°Úîÿ§×íÿO¯ÛÿN®ÚÿL­ÚÿL­ÚÿL­ÚÿK¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ9�´ÿÿÿÿÿÑÿÿÿ1;ÿ-p‡ÿ/q‰ÿ1tŒÿ2uŽÿ4w�ÿ5x‘ÿ5z“ÿ7|—ÿ8~™ÿ8›ÿ;‚�ÿ<„ ÿ=…¢ÿ>‡¤ÿBЦÿC‹¨ÿD�ªÿF�­ÿH‘¯ÿI’±ÿJ•µÿM—¶ÿM™¸ÿO™ºÿOœ¼ÿQ�¿ÿRŸÁÿT¢ÄÿU¤ÇÿX§ÉÿZ©Ëÿ\«Íÿ]¬Ðÿ_®Òÿ`±Õÿ�Ïæÿ¸Ýîÿ³×çÿ ÿÿÿÿÿÿÿÿšššÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÖÖÖÿÿÿÿÿÿÿ%(ÿ¼ßðÿ¼àñÿ§Öìÿl»àÿjºàÿi¹ßÿg¹àÿf¹àÿf¹àÿe¸ßÿi¹ßÿ³Ûîÿ—µÄÿÿÿÿÿÿ¦ÉÛÿµÜïÿŽÊæÿZ³ÜÿZ³Üÿq½àÿ´Üïÿ]r|ÿÿÿÿÿ6BHÿ°ÚîÿªØíÿ`·ÞÿN®ÚÿL­ÚÿL­ÚÿL­ÚÿJ¬ÙÿI«ÙÿI«ÙÿH«ÙÿF«ÚÿEªÙÿ&`zÿÿÿÿÜ‹ÿÿÿÿ-p‡ÿ/q‰ÿ0s‹ÿ1uŽÿ4w�ÿ5x’ÿ5z”ÿ7|—ÿ8~™ÿ9€›ÿ:��ÿ<„¡ÿ=†£ÿ>‡¥ÿAЦÿC‹©ÿD�ªÿE�­ÿH‘°ÿI“²ÿI•µÿM—¶ÿM™¹ÿO™ºÿPœ½ÿQž¿ÿRŸÁÿS¡ÄÿU£ÆÿW¦ÉÿY¨Ìÿ[«Îÿ\­Ðÿ_®Òÿ`°Ôÿb²Øÿ¡Òèÿ¸Ýïÿ™¸ÇÿÿÿÿÿÿÿÿaaaÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿûûûÿkkkÿÿÿÿÿÿÿŽ©¶ÿ¼àñÿ¹Þðÿ€Ääÿjºàÿi¹ßÿg¹àÿf¹àÿf¹àÿe¸ßÿd¸ßÿd·Þÿ¢Óëÿ¦ÉÙÿ&.1ÿÿÿÿÿš¼ÌÿµÜïÿšÐéÿ[³ÜÿZ³ÜÿZ³Üÿ³Üïÿœ©ÿ ÿÿÿÿÿ°Úîÿ¬Ùîÿn½áÿN®ÚÿL­ÚÿL­ÚÿK­ÚÿJ«ÙÿI«ÙÿI«ÙÿH«ÙÿEªÙÿEªÙÿ,8ÿÿÿÿ”>ÿÿÿÿ'`uÿ/q‰ÿ0s‹ÿ1uŽÿ3w‘ÿ5y’ÿ5z”ÿ6}–ÿ8~™ÿ9€›ÿ9��ÿ<„ ÿ=†£ÿ?ˆ¥ÿA‰§ÿB‹©ÿD�ªÿE�­ÿH‘°ÿI“²ÿJ•µÿL˜¸ÿN™¹ÿN›»ÿPœ½ÿPž¿ÿRŸÁÿS¡ÄÿU¤ÇÿV¦ÊÿX¨ÌÿZªÎÿ]­Ðÿ^®Òÿ`°Õÿa²×ÿm¹Üÿ«×ìÿ¹ÞðÿvŽšÿÿÿÿÿÿÿ444ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÓÓÓÿ ÿÿÿÿÿÿKY`ÿ¼àñÿ¼ßñÿ Óëÿkºàÿi¹ßÿg¹àÿf¹àÿf¹àÿe¸ßÿd¸ßÿd·Þÿd·Þÿ�Êæÿ«Ïàÿ+48ÿÿÿÿÿ•µÄÿµÜïÿšÐéÿ[³ÜÿZ³ÜÿZ³ÜÿžÒëÿ—¹Éÿ ÿÿÿÿÿ°Úîÿ­ÙîÿyÂãÿM®ÚÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿC¥ÓÿÿÿÿÿF÷ÿÿÿBPÿ/rŠÿ0sŒÿ0vŽÿ4w‘ÿ5y’ÿ5z”ÿ6|—ÿ8~šÿ9€œÿ9‚žÿ;ƒ ÿ=†¢ÿ>ˆ¥ÿ@‰§ÿB‹ªÿD�«ÿE�®ÿG‘°ÿI“²ÿJ”µÿK˜¸ÿN™¹ÿN›»ÿP�¾ÿPžÀÿR ÂÿS¡ÄÿT£ÆÿV¦ÊÿX¨ÌÿZªÎÿ\¬Ñÿ^¯Óÿ_°Õÿa²×ÿb³Ùÿw¿àÿ·Ýðÿ¸ÝïÿCPVÿÿÿÿÿÿ!!!ÿêêêÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýýýÿWWWÿÿÿÿÿÿ ÿ«ÊÛÿ»ßñÿ¸ÞðÿxÀãÿi¹ßÿf¹àÿf¹àÿf¹àÿe¸ßÿd¸ßÿd¸ßÿd·Þÿd·Þÿ�Êçÿ¦ÉÙÿ&.1ÿÿÿÿÿœ½Îÿ´Üïÿ›Ðéÿ[³ÜÿZ³ÜÿX²ÝÿŒÊçÿ¥ÊÜÿ ÿÿÿÿÿ©Óæÿ®Úîÿ~ÄäÿL­ÚÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿF«ÚÿEªÙÿ.q�ÿÿÿÿû ¹ÿÿÿ&.ÿ/rŠÿ0sŒÿ1u�ÿ2w‘ÿ5y’ÿ5z”ÿ6|—ÿ8šÿ9€œÿ:‚žÿ;ƒ ÿ=†£ÿ>ˆ¥ÿ?‰¨ÿB‹©ÿCެÿF�®ÿF‘°ÿI“²ÿJ•´ÿK—¸ÿMšºÿO›¼ÿO�¾ÿPžÀÿQ ÂÿS¡ÄÿT£ÆÿU¥ÈÿW¨ÌÿYªÏÿ[¬Ñÿ^¯Óÿ_°Õÿa²×ÿb´Úÿc¶Ýÿ‹Êçÿ¹Þðÿ©ÊÛÿÿÿÿÿÿÿÈÈÈÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÂÂÂÿÿÿÿÿÿÿi}‡ÿ¹Ýïÿ»ßñÿ˜Ïéÿi¹ßÿg¹àÿf¹àÿf¹àÿf¹àÿe¸ßÿd¸ßÿd·Þÿd·Þÿc·ÞÿŸÓêÿ•µÄÿÿÿÿÿÿ¦ÉÛÿµÜîÿ—ÏèÿZ³ÜÿY³ÜÿW²ÝÿƒÅåÿ¬Óåÿ ÿÿÿÿÿ¤Ìßÿ®ÚîÿÄäÿL­ÚÿL­ÚÿL­ÚÿI¬ÚÿI«ÙÿI«ÙÿI«ÙÿF«ÚÿEªÙÿ2@ÿÿÿÿÊ sÿÿÿÿ(cxÿ0tŒÿ1u�ÿ2x’ÿ5y“ÿ5{•ÿ6|—ÿ7™ÿ9€œÿ:‚Ÿÿ:„¡ÿ=†£ÿ>ˆ¦ÿ?ЍÿA‹ªÿC�¬ÿE�­ÿF’±ÿH“³ÿJ•µÿK—¸ÿKš»ÿO›¼ÿO�¾ÿQžÀÿQ ÂÿS¢ÅÿT£ÆÿU¥ÉÿW¨ÍÿXªÏÿ[¬Ñÿ\®Ôÿ_±Öÿ_²Øÿb´ÚÿcµÜÿc¸ßÿ�Òêÿ¹Þðÿ�›¨ÿÿÿÿÿÿ•••ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüüüÿ&&&ÿÿÿÿÿÿ#)-ÿ¨ÉÙÿ»ßñÿ³Üïÿi¹ßÿg¹àÿf¹àÿf¹àÿf¹àÿe¸ßÿd¸ßÿd·Þÿd·Þÿc·Þÿiºßÿ²Üïÿw‘�ÿÿÿÿÿ '+ÿªÐâÿµÜîÿŠÉæÿZ³ÜÿX³ÝÿW²Üÿ‚Åäÿ¨Îàÿ ÿÿÿÿÿ¦Îáÿ®Úîÿ~ÄäÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿF«Úÿ?œÇÿ ÿÿÿÿ>õÿÿÿ N_ÿ0tŒÿ1u�ÿ2x’ÿ4y“ÿ6{•ÿ6|—ÿ7~™ÿ9€œÿ:‚Ÿÿ:„¡ÿ<†£ÿ>ˆ¦ÿ?Ѝÿ@‹ªÿC�¬ÿD�®ÿF‘°ÿG“³ÿJ•µÿK—·ÿK™ºÿN›½ÿO�¾ÿPŸÀÿRŸÂÿR¢ÅÿT£ÇÿU¥ÉÿV§ËÿW©ÎÿY¬Ñÿ\®Ôÿ]°Öÿ_²Øÿa´Úÿc¶Üÿd·Þÿk»àÿ¬Ùíÿ¹ÞðÿL\cÿÿÿÿÿÿ***ÿHHHÿvvvÿ¥¥¥ÿÒÒÒÿÿÿÿÿÿÿÿÿÿÿÿÿ‚‚‚ÿÿÿÿÿÿÿq‡‘ÿ»ßðÿ»ßðÿÄäÿf¹àÿf¹àÿf¹àÿe¸ßÿd¸ßÿd¸ßÿd·Þÿd·Þÿc·Þÿb·Þÿ’Íèÿ²Øêÿ/:?ÿÿÿÿÿN_hÿ±×êÿµÜïÿu¿áÿZ³ÜÿX²ÝÿW±Üÿ‹Éæÿš½Îÿ ÿÿÿÿÿ¯Úîÿ­Ùîÿ}ÃäÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ)fƒÿÿÿÿû>Íÿÿÿ,6ÿ/q‰ÿ1v�ÿ1w‘ÿ3z“ÿ6{•ÿ6}˜ÿ7~šÿ8��ÿ:‚Ÿÿ:„¡ÿ;†£ÿ>ˆ¦ÿ?Š©ÿ?Œ«ÿC�­ÿD�¯ÿF‘°ÿG”³ÿI•¶ÿK—¸ÿK™ºÿL›½ÿP�¿ÿPŸÁÿR ÃÿR¢ÅÿS¤ÇÿU¥ÉÿV§ËÿW©ÎÿX«Ñÿ[®Ôÿ]°Öÿ_²Ùÿ_´Ûÿb¶Ýÿd·Þÿd·Þÿ€Ääÿ·ÞðÿªÌÝÿÿÿÿÿÿÿÿÿÿÿÿ!!!ÿSSSÿÿÿÿÿÿÿ%,0ÿºßñÿºßñÿŸÒëÿiºàÿf¹àÿf¹àÿe¸ßÿd¸ßÿd¸ßÿd·Þÿd·Þÿc·Þÿb·Þÿ„Çåÿ¶Ýïÿo‡’ÿÿÿÿÿÿœ©ÿµÜïÿ´ÜïÿZ³ÜÿX³ÜÿW²ÝÿW±ÛÿœÑéÿƒ¢°ÿ ÿÿÿÿ ÿ¯Úîÿ­ÙîÿyÁãÿL­ÚÿK­ÚÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿF«ÙÿD¨Öÿ5DÿÿÿÿØ‘ÿÿÿÿ*hÿ1v�ÿ1w‘ÿ3z•ÿ6{•ÿ6}˜ÿ7~šÿ8€�ÿ:ƒŸÿ;„¢ÿ;†¤ÿ=‡¦ÿ?Š©ÿ@Œ«ÿB�­ÿC�¯ÿF‘°ÿG“³ÿH•¶ÿK—¸ÿK™ºÿL›½ÿN�ÀÿPŸÁÿQ ÃÿS¢ÆÿS¤ÇÿU¥ÊÿV§ÌÿW©ÎÿX«ÑÿZ®Ôÿ\°Öÿ^²Ùÿ_³Ûÿa¶Ýÿc·Þÿd·Þÿe·ÞÿžÓëÿ·Ýïÿl�Œÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–µÃÿºßñÿµÝðÿyÂäÿf¹àÿe¸ßÿe¸ßÿf¹ßÿe·Þÿe·Þÿe·Þÿe¸Þÿf¹ßÿ�Ëçÿ·Þðÿ€œ©ÿÿÿÿÿÿ+49ÿ§ËÝÿ´Üïÿ’ÌçÿZ³ÜÿW³ÝÿW±ÛÿW±Ûÿ®Ùíÿcz…ÿÿÿÿÿ)48ÿ¯Úîÿ«Øíÿo½áÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿF«Úÿ8аÿÿÿÿÿ‘:øÿÿÿAOÿ1v�ÿ1w’ÿ2y”ÿ4|–ÿ6}˜ÿ7šÿ8€œÿ9‚ ÿ;„¢ÿ;†¤ÿ<ˆ¦ÿ?Š©ÿ@Œ«ÿ@�­ÿC�¯ÿD’±ÿG“³ÿH•µÿJ˜¸ÿL™ºÿLš¼ÿM�¿ÿPŸÂÿQ¡ÄÿR¢ÆÿS¤ÈÿT¦ÊÿV§ÌÿW©ÎÿXªÑÿY­Óÿ[°×ÿ]²Ùÿ_´Ûÿ_µÝÿa·Þÿc·Þÿd·Þÿu¿áÿ´Üïÿ¨ÉÚÿ$+/ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿSckÿºßñÿºßñÿšÐêÿe¸ßÿe¸ßÿvÀâÿŒÊçÿ—Ïéÿ–ÎèÿŽÊçÿŒÊæÿšÐéÿ²Üïÿ¥ÉÙÿ[nwÿÿÿÿÿÿÿw‘žÿ´Üïÿ´ÜïÿjºßÿX³ÜÿW±ÜÿW±Ûÿg¹Þÿ²Ûîÿ>MTÿÿÿÿÿQeoÿ®Úîÿ©×íÿb·ÞÿK­ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿF«ÚÿD¨×ÿ&_yÿÿÿÿú:Îÿÿÿ ÿ0u�ÿ1x’ÿ2y”ÿ4|—ÿ7}™ÿ7šÿ8€�ÿ9ƒ ÿ;…¢ÿ;†¤ÿ<ˆ¦ÿ>Ѝÿ?Œ«ÿ@Ž®ÿB�°ÿD‘±ÿG“³ÿH•µÿI˜¸ÿK™ºÿL›¼ÿMœ¿ÿOŸÃÿP¡ÄÿR£ÆÿS¤ÈÿT¦ÊÿT§ÌÿW©ÎÿX«ÑÿX¬ÓÿZ¯Öÿ\±Ùÿ^´Ûÿ_µÝÿ`¶Þÿb¶Þÿb·Þÿc·Þÿ”Íèÿ¹Þðÿdy‚ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¨ÉÚÿ¹Þðÿ´Üïÿo½áÿd¸ßÿŠÉæÿ±Úîÿ§ÉÙÿ‰¥²ÿ¡ÂÑÿ­Ñâÿ¥Ç×ÿ†¢°ÿPaiÿ"%ÿÿÿÿÿÿÿHX_ÿ´Üïÿ´Üïÿ“ÍèÿY²ÝÿW²ÜÿW±ÛÿW±Ûÿ�ËçÿœÁÒÿ'*ÿÿÿÿÿ†§¶ÿ®Úîÿ§×íÿP¯ÛÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿEªÙÿB¢Ïÿ $ÿÿÿÿÎYÿÿÿÿDSÿ2x’ÿ2y”ÿ3{—ÿ6}šÿ7›ÿ8��ÿ8‚Ÿÿ9„¢ÿ;†¤ÿ<ˆ§ÿ>‰©ÿ?Œ«ÿ@Ž®ÿA�°ÿD‘²ÿE“³ÿH•·ÿH—¸ÿK™»ÿL›½ÿMœ¿ÿNŸÂÿO ÅÿR£ÆÿS¥ÈÿT¥ÊÿU¨ÍÿV©ÏÿW«ÑÿX¬ÓÿY®Õÿ[±Ùÿ]³Üÿ^µÞÿ_¶Þÿ`¶Þÿb¶Þÿb·Þÿc·Þÿ¶Ýïÿ´Øêÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿp†‘ÿ¸Ýïÿ¹ÞðÿŒÊçÿd¸ßÿt¾áÿ²Úíÿ}–¢ÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿ/9>ÿµÜïÿ´Üïÿ¬Ùíÿm»áÿW²ÜÿW±ÜÿW±Ûÿ\´Üÿª×ìÿv’Ÿÿÿÿÿÿÿ¡ÊÝÿ®Úîÿ™ÐêÿL­ÚÿJ«ÙÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ/t”ÿÿÿÿÿYëÿÿÿ ÿ0v�ÿ2y”ÿ3{—ÿ5~šÿ7›ÿ8��ÿ8‚Ÿÿ9…£ÿ;‡¥ÿ<ˆ§ÿ=Š©ÿ>Œ«ÿAŽ®ÿA�°ÿC‘³ÿE“µÿG•¶ÿH—¸ÿJ™»ÿK›½ÿM�¿ÿNžÂÿO¡ÆÿQ£ÈÿS¥ÉÿT¦ËÿU¨ÍÿV¨ÎÿV«ÑÿY­ÓÿY®ÖÿZ°Øÿ[±Úÿ^µÞÿ^µÞÿ_¶Þÿ`¶Þÿ`·Þÿb·Þÿ‡Èåÿ¸Þðÿauÿÿÿÿÿÿÿÿÿÿÿÿÿÿ*37ÿ¨ËÜÿ¹Þðÿ®Ùíÿd·Þÿd·Þÿ”Íèÿ®Òâÿ '*ÿÿÿÿÿÿÿÿÿÿÿÿÿ5AGÿ°Õèÿ´Üïÿ²Ûîÿ…ÇåÿW²ÝÿW²ÜÿW±ÛÿW±Ûÿ†Çåÿ©Òåÿ5BGÿÿÿÿÿDU]ÿ©Ôçÿ®ÚîÿvÀâÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÙÿEªÙÿEªÙÿ ÿÿÿÿì^ÿÿÿÿDSÿ2z•ÿ3{—ÿ3}™ÿ6œÿ8�žÿ9‚ ÿ9„¢ÿ:†¥ÿ<ˆ§ÿ=Š©ÿ>‹«ÿ?�®ÿA�±ÿB‘³ÿD“µÿF•·ÿI—¸ÿI™ºÿK›¾ÿL�ÀÿNžÂÿO¡ÄÿP¢ÈÿR¥ÊÿT¦ËÿU¨ÍÿVªÐÿW«ÑÿW­ÔÿY®ÖÿZ°Øÿ[±Úÿ\³Üÿ^µÞÿ^µÞÿ_¶Þÿ_¶Þÿ`¶Þÿe¸Þÿ¯ÚîÿµÚìÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿy’žÿ¹Þðÿ¹ÞðÿyÀâÿd·Þÿd·ÞÿœÑêÿ«Ïàÿ ÿÿÿÿÿÿÿÿÿÿÿ ÿXkuÿ­Óåÿ´Üïÿ´Üïÿ‘ÌçÿX²ÝÿW±ÜÿW±ÛÿW±Ûÿf¸Þÿ§Öìÿ|š¨ÿÿÿÿÿÿy˜¦ÿ®Úîÿ®ÚîÿR¯ÛÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ*h…ÿÿÿÿÿ_Ûÿÿÿ ÿ-n†ÿ3|˜ÿ4}šÿ5€�ÿ8�žÿ9‚ ÿ9„¢ÿ:…¤ÿ<‰¨ÿ=Š©ÿ>Œ¬ÿ>�®ÿA�±ÿB’³ÿC“µÿE•·ÿG–¹ÿI™ºÿJ›½ÿK�ÀÿMŸÂÿO ÄÿP¢ÆÿP¤ÊÿS§ÌÿU¨ÍÿUªÐÿW«ÒÿX­ÔÿX¯ÖÿZ°Øÿ[±Úÿ\³Üÿ\³Üÿ]´Ýÿ^µÞÿ^µÞÿ_µÞÿ_¶ÞÿŠÉæÿ·Þðÿ^r|ÿÿÿÿÿÿÿÿÿÿÿÿ2<@ÿ¸Þðÿ¸Þðÿ˜Ïéÿc·Þÿb·Þÿb·ÞÿšÐéÿ±×éÿ?MSÿÿÿÿÿÿÿÿÿÿIYaÿ’±Áÿ´Ûîÿ´Üïÿ´Üïÿ‡ÇæÿX²ÝÿW±ÜÿW±ÛÿW±ÛÿV±Ûÿ™Ïèÿ§Îàÿÿÿÿÿÿ&05ÿ£Ìßÿ®Úîÿ‹ÉçÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿE«Úÿ@ ÌÿÿÿÿÿßjÿÿÿÿGWÿ2{—ÿ4}šÿ5œÿ8�žÿ9ƒ ÿ9„¢ÿ:†¤ÿ;ˆ¨ÿ=Šªÿ>Œ¬ÿ>�®ÿA�°ÿB’³ÿC“¶ÿD•¸ÿF—ºÿH™¼ÿJ›½ÿKœ¿ÿLŸÂÿO ÄÿP¢ÇÿQ¤ÉÿQ¦ÍÿT¨ÎÿVªÐÿV¬ÒÿX­ÔÿY®ÕÿY°ÙÿZ²Ûÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ]´Ýÿ^µÞÿ^µÞÿq½áÿ®Úîÿ¦ÉÚÿÿÿÿÿÿÿÿÿÿÿÿ£ÅÕÿ¸Þðÿ°Úîÿs¿áÿa·Þÿa·Þÿ`¶ÞÿƒÆåÿ´Ýïÿ¥ÉÚÿK[cÿÿÿÿÿ ÿ0:?ÿl„�ÿ¦ÉÛÿ²ÙëÿµÜïÿ´Üïÿ°ÚîÿwÀâÿX²ÝÿW²ÜÿW±ÛÿW±ÛÿU±Ûÿ’Íçÿ±Ûîÿ)38ÿÿÿÿÿÿi„�ÿ®Úîÿ®ÚîÿZ³ÝÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ"Tkÿÿÿÿÿj×ÿÿÿ ÿ.q‹ÿ4~šÿ4œÿ6�Ÿÿ9ƒ ÿ9„£ÿ:†¥ÿ;‡§ÿ=Šªÿ>Œ¬ÿ>�®ÿ?�°ÿB’³ÿC”¶ÿD•¸ÿE—ºÿH˜»ÿH›¾ÿKœ¿ÿLŸÂÿM¡ÅÿO¢ÇÿQ¤ÉÿQ¥ËÿR§ÏÿT©ÒÿV¬ÒÿW­ÔÿX¯×ÿZ°ØÿZ²Ûÿ[³Üÿ\³Üÿ\³Üÿ\³Üÿ]´Ýÿ]´Ýÿ]´Ýÿ^µÞÿ›Ñêÿ±×éÿBPWÿÿÿÿÿÿÿÿÿÿ[oxÿ·Þðÿ¶Ýïÿ“Íèÿ`¶Þÿ`¶Þÿ`¶Þÿ_¶Þÿ_µÞÿ§Öíÿ¶Ýðÿ¶ÝðÿµÝïÿµÜïÿ±×êÿµÜïÿµÜïÿµÜïÿ´ÜïÿµÜïÿ´Üïÿ°Úîÿ–Ïèÿb¶ÝÿX²ÝÿW±ÜÿW±ÛÿW±ÛÿU±Ûÿ“Íèÿ¯ÙìÿK]eÿÿÿÿÿÿ$.2ÿ®Úîÿ®Úîÿ‹ÊçÿL­ÚÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ9޶ÿ ÿÿÿÿØuþÿÿÿ1>ÿ4~šÿ4œÿ5€Ÿÿ7ƒ¢ÿ:…£ÿ:†¥ÿ;‡§ÿ;‰ªÿ=Œ¬ÿ>ޝÿ?�±ÿ@’³ÿB”¶ÿD•¸ÿD—ºÿF™¼ÿHš¾ÿJœÁÿLžÂÿM¡ÅÿO¢ÇÿP¤ÉÿQ¥ËÿR¨ÎÿS©ÒÿT«ÔÿW®ÕÿX¯×ÿY±ÙÿZ±Úÿ[³Üÿ[³Üÿ[³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ]´Ýÿ~Ãäÿ³Üïÿz” ÿÿÿÿÿÿÿÿÿ#&ÿ¬Ñâÿ¶Þðÿ®Úîÿhºßÿ_¶Þÿ_µÞÿ^µÞÿ^µÞÿ^µÞÿf¸ßÿ—Ïéÿ«Øíÿ´ÜïÿµÜïÿµÜïÿµÜïÿ´Üïÿ³Üïÿ¬Øíÿ¡Óëÿ�ÊæÿjºßÿY³ÜÿW²ÝÿW±ÜÿW±ÛÿW±Ûÿc·Ýÿ–Îèÿ®ØëÿSgpÿÿÿÿÿÿ ÿ”¹Êÿ®Úîÿ¨×íÿa¶ÞÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿF«ÚÿD¨×ÿJ^ÿÿÿÿþu×ÿÿÿÿ#Wkÿ4�ÿ5�Ÿÿ7ƒ¢ÿ9…¤ÿ:†¥ÿ;ˆ¨ÿ;‰©ÿ<‹¬ÿ?ޝÿ?�±ÿ@‘³ÿ@’µÿC–¹ÿE—»ÿE™¼ÿGš¿ÿIœÀÿKžÃÿM ÄÿM¡ÆÿO¤ÊÿQ¦ÌÿR§ÎÿS©ÐÿT«ÔÿT­ÖÿW¯×ÿY±ÙÿZ³ÜÿZ³Üÿ[³Üÿ[²ÛÿZ³Üÿ[³Üÿ[³Üÿ\³Üÿ\³Üÿ_´Üÿ¯Ùíÿ™ºÊÿ "ÿÿÿÿÿÿÿÿt�™ÿ¶Ýðÿ¶Ýðÿ�Ååÿ]´Ýÿ]´Ýÿ]´Ýÿ]´Ýÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿlºßÿv¿áÿ{Áâÿ|Âãÿ|ÂãÿzÁâÿt¿áÿm»àÿa¶ÝÿY³ÜÿX²ÝÿW²ÜÿW±ÜÿW±ÛÿW±Ûÿ}Ããÿ¥Õëÿ¨Ðâÿ8EKÿÿÿÿÿÿÿx—¥ÿ®Úîÿ®Úîÿ‡ÈæÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ4�¥ÿÿÿÿÿ×4ÿÿÿÿ ÿ/u‘ÿ5�Ÿÿ6‚¡ÿ7…¥ÿ:†§ÿ;ˆ¨ÿ;‰ªÿ<‹¬ÿ>ޝÿ?�±ÿ@‘³ÿA“¶ÿB”¸ÿD—»ÿE™½ÿFš¿ÿIœÁÿJ�ÃÿL ÆÿM¢ÇÿN£ÉÿP¦ÌÿQ§ÎÿS©ÐÿT«ÒÿT­ÖÿU¯ÙÿW°ÛÿZ³ÜÿZ³ÜÿZ³ÜÿZ³Üÿ[²Ûÿ[³Üÿ[³Üÿ[³Üÿ\³Üÿ\³Üÿ”Íèÿ´Ûîÿ7CIÿÿÿÿÿÿÿ.8<ÿ«ÐâÿµÜïÿ¢Óëÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ[³ÜÿZ³Üÿ[³Üÿ[²Ûÿ[²ÛÿZ³ÜÿZ³ÜÿZ³ÜÿY³ÝÿW²ÝÿW²ÜÿW±ÛÿZ²ÛÿÃãÿ¦Öìÿ°Úîÿ�Ÿ­ÿÿÿÿÿÿÿÿ\s~ÿ«×ëÿ®Úîÿ£ÕìÿQ¯ÛÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿD¨Öÿÿÿÿÿÿ4‘ÿÿÿÿ-8ÿ4€žÿ6‚¡ÿ6„¤ÿ8‡§ÿ;ˆ¨ÿ;Šªÿ<‹­ÿ=�¯ÿ>�±ÿ@‘³ÿA“¶ÿA”¸ÿC–ºÿE™½ÿF›ÀÿGœÁÿHžÂÿKŸÆÿL¢ÇÿN¤ÉÿO¥ËÿQ¨ÏÿR©ÑÿT«ÒÿU¬ÕÿU®×ÿV°ÚÿX²ÝÿX³ÜÿZ³ÜÿZ³ÜÿZ³Üÿ[³Üÿ[²Ûÿ[³Üÿ[³Üÿ[³Üÿr½àÿµÜïÿ_s}ÿÿÿÿÿÿÿ}˜¥ÿµÜïÿµÜïÿkºßÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³Üÿ\³ÜÿZ³ÜÿZ³Üÿ`´Üÿ€Äãÿ�ËçÿŒÊæÿ|Ããÿo¼àÿf¸ßÿfºàÿr½áÿ…ÆäÿžÒêÿ³Ûîÿ¨Îàÿ‘´Äÿ;IOÿÿÿÿÿÿÿÿEW_ÿªÕéÿ®Úîÿ®Úîÿ_µÞÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿF«ÚÿEªÙÿ7Fÿÿÿÿÿ‘ Øÿÿÿÿ!Qeÿ6ƒ¢ÿ6„¤ÿ7†¦ÿ:ˆ©ÿ<Šªÿ<‹­ÿ=�¯ÿ=�±ÿ@’´ÿA“¶ÿA•¸ÿB–ºÿD˜½ÿF›ÀÿGœÂÿGžÄÿIŸÆÿK¡ÇÿM¤ÊÿO¥ÌÿP§ÎÿQ©ÑÿS«ÓÿU¬ÕÿU®×ÿV¯ÙÿW²ÝÿW²ÝÿX³ÝÿZ³ÜÿZ³ÜÿZ³Üÿ[³Üÿ[³Üÿ[²Ûÿ[³ÜÿZ³Üÿ³Ûîÿ¡ÄÕÿ ÿÿÿÿÿ@NUÿµÜïÿµÜïÿ�Ëçÿ]³Üÿ\³Üÿ[³Üÿ[³Üÿ[³Üÿ[³Üÿ[³Üÿ[³Üÿ[³Üÿ[²Ûÿt¾áÿ«Øíÿ¥ÉÛÿ�°ÀÿžÁÑÿ¬Òäÿ³Üïÿ³Üïÿ±Ùìÿ§Íßÿ—¹Éÿ�ªÿ_uÿ1�±ÿ>�´ÿ?’¶ÿB•¹ÿB–»ÿC˜½ÿDš¿ÿD›ÁÿGžÅÿH ÇÿI¢ÉÿJ£ËÿL¥ÍÿN¦ÎÿO©ÑÿQªÓÿR¬ÕÿS­×ÿT°ÚÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW²ÝÿX³ÜÿX²ÝÿZ³ÝÿZ³ÜÿZ³Üÿ`µÝÿ¨×ìÿ´Üïÿ°×êÿ­Ôæÿ®Õçÿ±ÙìÿµÜïÿ´Üïÿ‰Èåÿ[³ÜÿZ³Üÿ[³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³Üÿ€Ääÿ°×êÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ%/3ÿ”¹Êÿ­Ùíÿ®Úîÿ­Ùîÿp½áÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿEªÙÿA¢Îÿ #ÿÿÿÿÿ•Õÿÿÿÿ "ÿ6„¥ÿ8ˆ©ÿ8‰«ÿ:Œ¯ÿ<ްÿ>�²ÿ>‘´ÿ?’¶ÿ?”¸ÿB—»ÿC˜¾ÿDšÀÿD›ÂÿE�ÄÿH ÇÿI¢ÉÿJ£ËÿJ¥ÍÿM§ÐÿO¨ÑÿPªÔÿR¬ÕÿS®×ÿS¯ÙÿU±ÛÿU±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW²ÜÿW²ÝÿX²ÝÿX³ÝÿZ³ÝÿY³Ýÿh¹ßÿ¡Ôëÿ²Ûîÿ³Üïÿ³Üïÿ³Üîÿ°ÚîÿŠÉæÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿZ³ÜÿX²ÝÿX²ÝÿŽËèÿ™¼Ìÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿaz…ÿ®Úîÿ®Úîÿ®Úîÿ§Öìÿd¸ßÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿEªÙÿD©Øÿ.;ÿÿÿÿÿÕ"ìÿÿÿÿ1=ÿ6„¥ÿ8Š«ÿ9‹®ÿ;ޱÿ>�²ÿ>‘´ÿ?’¶ÿ?”¸ÿA•ºÿB˜¾ÿDšÀÿD›ÂÿE�ÄÿF ÆÿH¡ÈÿJ¤ËÿJ¥ÎÿK§ÐÿM¨ÒÿNªÔÿP«ÖÿR®×ÿS¯ÙÿT°ÚÿT°ÚÿT±ÛÿV±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÜÿW²ÜÿW²ÜÿX²ÝÿW²ÝÿX²ÝÿwÁâÿ‹ÉæÿŒÊæÿƒÆåÿd¸ÞÿY³ÜÿY³ÝÿY³ÜÿY³ÝÿY³ÜÿX³ÝÿX³ÝÿX²ÝÿW³ÝÿW²ÝÿW²ÜÿW²Üÿ‹Êæÿ²Úíÿ!),ÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ1=Cÿc{‡ÿ¤Îàÿ®Úîÿ®Úîÿ«Øíÿ‹ÊçÿS°ÛÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿFªÙÿC¦Ôÿ@Qÿÿÿÿÿì"?÷ÿÿÿÿ>Nÿ8Ьÿ9‹®ÿ:�°ÿ<�³ÿ=‘´ÿ?“·ÿ@”¹ÿ@–»ÿA—½ÿDšÀÿEœÂÿE�ÄÿFŸÆÿF ÉÿH£ÊÿJ¤ÍÿK§ÐÿL¨ÒÿMªÔÿN«ÖÿQ­ØÿQ®ÙÿS°ÚÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿU±ÛÿV±ÛÿV±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÜÿW±ÛÿW±ÜÿW±ÜÿW±ÜÿW²ÜÿW²ÜÿW²ÝÿW²ÜÿW²ÜÿW±ÜÿW±ÜÿW±ÜÿW±ÜÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±Ûÿq½àÿ²Ûîÿ–¹Éÿ@OUÿÿÿÿÿÿÿÿÿ%)ÿARYÿmˆ•ÿ�°Àÿ¥Îàÿ®Úîÿ®Úîÿ®Úîÿ“Íèÿg¹ßÿK¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿEªÙÿD¨×ÿLaÿÿÿÿÿ÷?aþÿÿÿÿDVÿ9Œ®ÿ:�°ÿ:�²ÿ<‘¶ÿ>“¸ÿ@”¹ÿ@–»ÿA—½ÿA™¿ÿDœÂÿE�ÅÿFŸÇÿF ÉÿG¢ËÿI¤ÍÿJ¦ÏÿL©ÒÿMªÕÿM¬×ÿN­ÙÿQ¯ÛÿR¯ÛÿR¯ÛÿS°ÛÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿU±ÛÿU±ÛÿV±ÛÿU±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿW±ÛÿV±ÛÿV±ÛÿY³Ûÿ�Òêÿ±Ûîÿ­Öéÿ«Ôæÿ¦Îàÿš¿ÐÿްÀÿŠ«»ÿ�²ÃÿžÄÖÿ¨ÑäÿªÔçÿ¬Öêÿ®Ùíÿ®Úîÿ®Úîÿ®Úîÿ—Ïéÿa¶ÞÿN­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿEªÙÿEªÙÿ$Yrÿÿÿÿÿþa‹þÿÿÿÿCTÿ:�±ÿ:�³ÿ;‘µÿ=“¸ÿ?•ºÿ@–¼ÿA—½ÿB™ÀÿBšÂÿC�ÅÿFŸÇÿG¡ÉÿG¢ËÿH¤ÍÿJ¥ÐÿJ¨ÒÿLªÕÿM¬×ÿN®ÙÿO¯ÛÿO¯ÛÿP¯ÛÿR¯ÚÿR¯ÛÿS°ÛÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿU±ÛÿU±ÛÿV±ÛÿV±ÛÿV±ÛÿV±ÛÿW±ÛÿW±ÛÿV±ÛÿV±ÛÿW±ÛÿV±ÛÿV±ÛÿW±ÛÿV±ÛÿV±ÛÿU±ÛÿV±ÛÿU±ÛÿU±ÛÿT°ÚÿT°Úÿ`µÜÿœÑéÿ®Ùíÿ°Úîÿ±Ûîÿ°Úîÿ°Úîÿ°Úîÿ¯Úîÿ¯Úîÿ¯Úîÿ®Úîÿ¬Ùîÿ©×íÿ¥Õìÿ†Çæÿ]µÝÿL­ÚÿJ¬ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿD¨×ÿ&`zÿÿÿÿÿþ‹Ÿÿÿÿÿÿ–½ÿ@˜¿ÿB™ÀÿB›ÃÿCœÄÿDžÇÿDŸÉÿG¢ÌÿG¤ÎÿI¦ÐÿI§ÒÿJ©ÕÿKªÖÿL¬ÙÿM®ÚÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿP¯ÛÿQ¯ÛÿR¯ÛÿR¯ÛÿS¯ÛÿS¯ÛÿT°ÛÿS°ÚÿS°ÛÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿT°ÚÿS°ÚÿS°ÛÿS¯ÚÿS¯ÚÿS¯ÛÿQ¯ÚÿQ¯ÛÿP¯ÛÿP¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿN®ÚÿM­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK¬ÚÿI¬ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿEªÙÿ@žÉÿ9Iÿÿÿÿÿÿ��ÿÿÿÿÿ ÿ6†©ÿ<”ºÿ=•¼ÿ>˜¿ÿ@™ÁÿA›ÃÿCœÅÿDžÇÿD ÉÿE¡ËÿE¤ÍÿH¦ÐÿI§ÓÿJ©ÕÿKª×ÿK¬ÙÿL­ÚÿL­ÚÿN®ÚÿN®ÚÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿP¯ÛÿP¯ÛÿQ¯ÛÿQ¯ÚÿR¯ÚÿR¯ÛÿR¯ÚÿR¯ÚÿR¯ÛÿS°ÚÿR¯ÛÿR°ÚÿS°ÛÿR¯ÚÿR¯ÚÿR¯ÛÿR¯ÚÿR¯ÛÿQ¯ÛÿR¯ÚÿP¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿM®ÚÿM®ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿFªÙÿEªÙÿ?œÇÿ "+ÿÿÿÿÿÿ�˜ýÿÿÿÿ ÿ,oŒÿ=–½ÿ>—¿ÿ>™ÁÿA›ÄÿC�ÅÿDžÇÿD ÉÿE¡ÌÿE£ÎÿF¤ÐÿH§ÓÿI©ÕÿK«×ÿK¬ÙÿL­ÚÿL­ÚÿL­ÚÿM­ÚÿM®ÚÿN®ÚÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿP¯ÛÿP¯ÛÿP¯ÛÿQ¯ÛÿQ¯ÚÿQ¯ÚÿQ¯ÛÿQ¯ÛÿQ¯ÚÿP¯ÛÿQ¯ÛÿP¯ÛÿQ¯ÛÿP¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿM®ÛÿM®ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿJ¬ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿF«ÚÿEªÙÿ6‡¬ÿ ÿÿÿÿÿý˜„ýÿÿÿÿÿ Qfÿ;‘·ÿ>™Áÿ?šÃÿ@�ÆÿCžÇÿD ÊÿE¡ÌÿE£ÎÿF¤ÐÿG¦ÒÿG§ÔÿIª×ÿJ¬ÙÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿM­ÚÿM®ÚÿM®ÚÿN¯ÛÿN¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿN¯ÛÿN®ÛÿM®ÚÿL®ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿJ¬ÚÿI¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿG«ÚÿE«ÚÿB¢Ïÿ%\uÿÿÿÿÿÿý„Uóÿÿÿÿÿ)4ÿ2|žÿ?šÃÿ?œÆÿAžÈÿB ËÿE¢ÍÿF£ÎÿF¥ÐÿG¦ÓÿG¨ÔÿH©×ÿI«ÙÿJ¬ÙÿK­ÚÿK­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿM­ÚÿM­ÚÿN®ÚÿN®ÚÿO®ÚÿN®ÚÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿO¯ÛÿN¯ÛÿO¯ÛÿO¯ÛÿN®ÚÿN®ÚÿN®ÚÿN®ÚÿM­ÚÿM­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK¬ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿG«ÚÿF«ÚÿEªÙÿ9޵ÿ/<ÿÿÿÿÿÿóU1ãÿÿÿÿÿ ÿ Rgÿ@œÆÿ@žÈÿAŸÊÿB¡ÌÿD£ÏÿF¥ÐÿG§ÓÿH¨ÕÿHª×ÿI«ÙÿI«ÙÿI«ÙÿJ¬ÚÿJ¬ÚÿK­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿM­ÚÿL­ÚÿM­ÚÿM­ÚÿM®ÚÿM®ÚÿM®ÚÿM­ÚÿM®ÚÿM­ÚÿM®ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK­ÚÿK¬ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÚÿG«ÚÿG«ÚÿEªÙÿEªÙÿ$Zrÿ ÿÿÿÿÿÿã1Åþÿÿÿÿÿ $-ÿ5„§ÿ?œÆÿA¡ÍÿB£ÏÿD¥ÒÿE§ÔÿH¨ÕÿHª×ÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿJ¬ÚÿJ¬ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK¬ÚÿJ¬ÚÿJ¬ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿG«ÚÿEªÙÿC¦Ôÿ8бÿ$.ÿÿÿÿÿÿþÅ|óÿÿÿÿÿÿDWÿ5ƒ¦ÿB£ÏÿC¤ÑÿC¦ÓÿE¨ÖÿFªØÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI¬ÚÿJ¬ÚÿJ¬ÚÿK¬ÚÿK­ÚÿK­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿL­ÚÿK¬ÚÿK­ÚÿJ¬ÚÿJ¬ÚÿJ¬ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿG«ÚÿG«ÙÿEªÙÿEªÙÿ7‰¯ÿJ_ÿÿÿÿÿÿÿó|5Èÿÿÿÿÿÿÿ@Qÿ6‡«ÿC¦ÔÿD§ÕÿD©ØÿFªÙÿG«ÚÿH«ÚÿH«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿJ¬ÚÿJ¬ÚÿJ¬ÚÿJ¬ÙÿK¬ÚÿK¬ÚÿK¬ÚÿK¬ÚÿK¬ÚÿK¬ÚÿK¬ÚÿK¬ÙÿK¬ÚÿJ¬ÙÿJ¬ÙÿJ¬ÙÿJ«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿH«ÚÿG«ÚÿF«ÚÿEªÙÿEªÙÿ8‹±ÿCUÿÿÿÿÿÿÿÿÈ5cþÿÿÿÿÿÿÿ3@ÿ7НÿD©ØÿEªÙÿEªÙÿFªÙÿF«ÚÿG«ÚÿH«ÚÿH«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿG«ÚÿFªÙÿEªÙÿEªÙÿ9�´ÿ6Eÿ ÿÿÿÿÿÿÿþc±ýÿÿÿÿÿÿÿ+7ÿ3~ ÿA¡ÍÿC§ÕÿEªÙÿE«ÚÿF«ÚÿF«ÚÿG«ÚÿH«ÚÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÚÿG«ÚÿF«ÚÿEªÙÿC§Õÿ@ Ìÿ2}Ÿÿ+7ÿÿÿÿÿÿÿÿý±HÊüÿÿÿÿÿÿÿ ÿ Pfÿ1zœÿ=–ÀÿEªÙÿEªÙÿEªÙÿE«ÚÿE«ÚÿG«ÚÿG«ÙÿH«ÙÿH«ÙÿH«ÙÿH«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿI«ÙÿH«ÙÿI«ÙÿH«ÙÿH«ÚÿG«ÙÿG«ÚÿF«ÚÿF«ÚÿF«ÚÿEªÙÿ=—Áÿ2|žÿ Pfÿ  ÿÿÿÿÿÿÿÿüÊHK¾ÿÿÿÿÿÿÿÿÿ (ÿaË pHYs  šœ IDATxÚí�yœUµÇ¿·ª÷îÙg23™d²ï; I€@€„} ÈSq}‚ø@TÄ'(â‚n¢²‚¢¢PvûNX&û:Yf²MfŸžÞ»îû£ª««ª«g&1A|�ûù4dºº««êì¿sî9ðáúp}¸>\®ÿ§Kü¿¿éÀñÀ\`P ”À¨Æ3�@H  Ø lÞžVÈÌëDœœLB‡à÷bÀZà)àÀVƒYâ}ÈïÓL|ÿ‚ëH€�ç€×€]–C³<ÀÇ uÜa¨lùze�ëzÖ¸Nχàà¬2à{Àg�ðþ^ó° ?c†‡h¬ 1²6Ȱr?•¥^Ê"^ÂAU�ÉJúãz¢i:zÓìëNÒÚgûîÍ;ûÙ×�Úßk–@?ð;à;@χ °Ëg¨õgöa¯Gàó(”F¼ÌWÊ‚iÌ™XÎÄÆ^�òO_L:£±qG”w7vóÖÚnVmé¡»/M*£‘Î Éôÿ¸Ú0© øòK�¯ö½ø} c†‡˜0"ÂÜÉåÌŸVɸ†°ËíI@ Ä`·+\ü8‰” Hi?¶eg?o®éäíõÝll‰²uWŒTZìþž~ üõPG=ÄÄÿp“ðØ�»Ïd4_:gµUƒØ„ª;z†—¯ÿ_wÙEA(!Óû·[Ì¡èŽbÎaÌ¿ñíLeÿ¾ã•ó1„‹#’û}6�-\Ÿ·ª2/KFiØÃ«+;Ýѳ�ÿ?ûï¤TàWFxc3ÌA¿Â÷/šÊœI'OE(Jþo‹ÊWUÝP¥€ˆ9GO �¸ÂÕ_6¹/°Còþ‘ÚÀN¡Ôð¤î,Jɪ-=\ø½·é�gÝ0ƒÛ�K é­À�ÆÅÚ¨2²6È­WÎaôð0a8xŠ)QVI (ŠŠb‘8› %/i®ÄÏIšb€Cºf‘9éC€!¥Ò8�õ˜Ì}�D1nD ?ì>I­P„1sGj+ýœ»¤�×Ww²¯+å¼™y艱ç&^p°Àc„1_q8jVß»h*%!/B*]( tÉGèΞ®îs„×Ñ>ÛqE ¡�ðÂP8Šå߆ÙÈ1�Á8¦ó(ò*Ù|ß|aâ:)A¸šŒB^·q¯A¿ÂI kÙÙgsk¿ó _j¹éQ~ÀLÀ]À§�ož¾¨ŽOŸ1š`@5Ôº@:èê=§îuâç¾¼÷¯ ¨†9p!º)­SŠ„Š9~î~�t|Ö0С:Ô`_ Bõ€ §�hÙZ:F6ÙC&ÞN6Ñi‰ÜEÝhÄYnùÛîüûv·KºøÌ�nDÏâÙÎyÎ’>yê(��fl¯˜ª]ó@Uuï_1þV�I5å˜"OxC5 ÷@ñhA8ˆ)Šü[ÚþÎLñF(±O ÒÐ.Âaÿÿ–RË"µ ©ÞíÄ;בMtõ rL i’{ŸØÁuwotãØŸW|PLÀg€;ŸôÇ–Žàã§4š¶='õ äm½EUQL_€¼o:Ö¨@µ¨v�4£§çžó=›ç]=¸ªsáú!BõPÖxÞP ™ŒF:“%O°w_Û[÷Òºk]ÝQ4M¢( šÔ™HQ=(ªO¨š`Õ¼‘ád“=h™¤Å%µøB „dö„r|…×Vw:9ø`ðÞ¿Z,FOk¬ožyL=Ÿ:}TØQCâ°£ªN©¡*¨Bѵ‚ͨ:#(JÁ¥Ûm¯þ±ÃVy•…bæ\ÞÈpJ�cSs+?¹õ~^yc»öt59“Ç�döŒq,š?�i“G3º±ŽÒHÐüLªo'ñöÕ¤ûZM€Èjܤ̢i’þ°‘»Ùáü‰zý…ŒÞª¬o=§š‹Î‹Ï§ ŠñU Ò§Ûu�šïE ¨E(¨ª‚š{/ç'¡æMGÞáV\ ž'pžüÞ²QxÕºÉÈÄIönGK÷~¦DJt‡Mæ™!T7ù$ä2V¯ÛºXxY„¹3'°ô˜ÃøøÙÇQ Qb–Tï6¢»^GKÇ\PF]Ó|û¶µ<øünçi;€ù@óûÍ  ½;Ï a¾ñ4<�°U1b|ÅÆªjuö é7µ�::(r~€AäÕ»#Ú²á Q|eDŽ@õW˜Ì#´,‰®�ÄÚ×¢Â�Îpý|Z{Âû‘¯í�Ѓóz=TU”ðߟ?‹‹?}F>ØÏ$éÛ±œTßNWM ¥äs?|‡W £µÀ¬ ÿàfàTë%^®ûòt¼Å‚îéá“B—Õœ*·Ùë±…Æ+XA>+hç]EXÃ,Åt …â§tôR<þr2™,©t†lVÓ™Lõâ Õ€PÈÄÚ–‰\(ê�PY;žßÝ÷Ø3€¦iDû<ûÒ{<óâ;yø4*Ê#(ª‡@ùx´t?™xG�O’¥ó‡ñÌ[ûèêM[OYT�½_ pð#,¥Ïá€Ê•Ÿ™De¹ß´õv8#ìËÇö&QUËçrÌ"[¼¯8 ^3êÏ™„œ3èÈ«§á/Éš ÛùêwnåËWþ‚Ûï}„h‚Ù3&ðûP}Òý»‘Ùä€N @6ÕK¨r<“'ŽcÕºfü~/õµ•ŒYËèÆzF�¨¥¡®šÊŠ‚A?Ùl–Tª¸`îÞÛÉý�‘ 5L?¡(øJG"3 2ñvè0úô±¥,_Ñæ,2™a8„›µ ˆ+ЫvuéSŸûÈhŽ>¬&¯Î x×´å†Ú·9€ŠbÆö9¿@˜ïåRÂ9?!oB“'L†0UºÛ+(¶÷%—TÌ’0Õ >ŠÝ�RØŸ#V¨fª¿œ[îXƦæÖ‚›Éd³´µwsƉGâ÷yñ*Ivo¢ÒÌ’Mt’êÛE:º‡l² -Cj)d6‰–ê%ÛK¢k ñö5øT�ú†F>zÚ±Ì?l Ï¿òý±DÁÙ›Ö4 ú9|ÎDÃ$»6!µŒSÏqÒÂZî¦ÕYO°½ðf×Áfð´ÁeæúüGÇ0ª>l!ªbâöˆœ×¯ä�>0ÂBÅ8n|>§5 ÀP éG¨ (v4PêRY€•pý|¶lÛÃoïù;]Ý}®7Õ²³�É™>e,ª7„Ì&È&ºlZ«à¡ù+ð«ðªð+ð*Pýe(ÞŠâCJ )³f8*¤$o'ѹ©¥7~"ŸýÄ™lÜÒêʘ/¼¶’ÙÓÇ1alBõ¡x¤z·øBÀÌñe,{¾€ÖÇ¿J¾`àkÀùVÑ;fN5'Y¯«f,^¿�Ú)h£˜¾3(¦S(„b0�%Þ·xüjŽÖ�,†ä»@Ùhü¥#yù�ÕÜußÀÎñÆ--œ÷Ñ%øý^ô€ÌÚ2�BO�Èðù«§¨�¿´_ÉH|%#ñ—ŽÄ_6 _É|‘T9Z&�ÌÚ‘¾L¼ƒTt'Ápÿñ‘“H§3¼ùî†óôÔó+8ã¤#¨¬(Aõ•’îß‹–Ž&u•~úbš6ÙvŸU~À«‹FØsM^õ{øÂÙc øU3»‡PÍPÏ®æóáŸb“,Ê_X<}£.@(HÃ(†ÝW+ò'\‹7‚53ÈŠ7ßþkÖ Øttõâ÷{Y´pBñ�îßmË ‚ lô xõ(ªÏƒt:ƒªæP(oo° ÙÔ`%éX;BæU¸Ì¦HõnŬäØÅ‹èìîã�•vg/•ÎðöÊ�|êc'èÑ�â!Õ·Ã’sfÁICu€—š:è‰f¬V¢} û€û‡RE) §o²õÍ“�ª#òsÞÏùÐ…I;Å�¯êÒeõâ¥Ì£zŠ)43¬hR¸”rP�PUx‚Uôôö³ìчÄá?¾é�ìÜÝŽ ¾’‘¨Á*[ÚØ_1Å_B[{_ûί˜¼ðTŒ?�aS>BÅøÓhœ}g}ê*îþó´wô�ÉjÅ‹¿¤‘Šñgâ-e†Ã¹Gß×ú"éh ×ç¿X8wJÁ5­Z·�{îù8T_©#Ô5è¸ÎXT�b§æ$ƒnâŸÕeFš·ÒZØqúâB� ø8K¹5ï äóÿ G1í¹ ìF>@Q ïŸ<6�Û€Õä5M.§ï/‹¯¤�_ßõÏ¿2ôšÊµ·sþG— B¨¤£»M]¨œLV„øê·oá�{ºÀ�K¥2loÙÓϾɯ~÷ ûÚ»¨¯­bXMBQð—Ž™Ø^›l¥zwà ã¬3Nä}†X–|€µ²G(øËÇ¢x‚Ü|ûßèí‹íWŒ›Íjüö÷ÓÞÑeàþ‡[púŠª2¼¾zÈçë�ƸñWæ³—þ˜dR'š¿l �š¶°5›ì!Ù³•£Îà¤ã·‡ª™,�/ËddÅø‚È$gÏ9¾�Qu¡é·¿ ðqgØ·tÁ0K2�¢)ËWO‹|ŽMæÊ]JªŒ(NJÐrL!-ô5J‹Ê·ßRª™E*•á×w=t@÷o¯åѧ_GJ‰ê ¬šŒ�ìÙF8àá›—}’’HEQðxTó¥;‚îëÉçÞâcŸ¿†\yZ ržà0›'ïX‹ß+8ÿ¬Â]ñ�=ó&1ÃäÊÇåao8¤ÿþŸ˜àüz•AÇb[ÅI$¨²hNMžç iÍm�Ëcò†3(œ9-ý'5ó»Ö:a {XBÚJ¼Ì}�BXÞWL/=4lB¨üæ÷ÑÑuàM9~vëŸØ×®oÊ TNFñ†ÑRÝ$»ÖsúÒy¬X~'Ýû#Þ~öw4¿}?«^º‡îúæ,&ŒázÎ_kâ+W߬?5€¿r¢îån9›&ÝÉÜY™:iTA”²aËNƒ(5|gÒX㨙UŒ�ŽCõ޾nMøœrôpÆ4Dò ŽâóZ*{íU>Š æÍ�FŠT[>‹þ[¹ÝB¹0Qà(¨�JÂÃf³¯½—koø»÷v0ôôö£IÉÒÅóŒÌG:º‹l¢“L|!ŸÆÈº¾Jº¿ÒGãð N\z49õ¡ ¯½µ¦ ¶_µ¶™‰cG0eâ(<� ’}­Èl¬ͦ£ kœÃë+Ö²v£½°º²”c�šR’ŠîBËô;ÊÐt@LU “•Î�&Õè…¤-û£Îò¡ÃïS˜=©,/ Ba›!ý�—àÓ XÂ<ƒéen§y_@Z4…´ûÂ*õ“ð&ì[=„Êߟx™¦5›ÿé2§_Ýù+×êçñ•4à U#„ ›è"ѹ�XÛ»Äö¾K¼c‰Ž ôïy‹î-�Pª´ñ?_ùºí;®iàÛï}„ή>S»€bFÈÙD7RË0gÆ<»l¾ñîz3Û©úJpß ¡ß,˜VI]•}/ŽAÏ!›€`‘õ�YË ½¦ÊÖÜЩ»k«#(óNš³\ÇÏZJlx‚Ûĺ$w_IÞp=�]}ÜxË}C&ò˜1c8û쳩¨¨p=þßWþÜ€0¼øË';—rh¤DiÀÐF•¯–%Þ¾Šþ½osâqóùå�.sIøl`E“NÌ@ùXS}+Æ#É$:™6y4>¯½ÉØšuÛL§[ñ„¬’Í H`bc„écK�?}’ÈŒ¦X�!`ÊØRTUäm½Ì§^¥‘�)HËï[�ºœ3(-—�c»óèÀ<­ç$_ð¡xük¦ ×ýâöî³WËTWW3uêTN>ùd¾ñ�oðÇ?þ‘•+W"¥¤¹¹™¿ýío\~ùå®aÕº-ÜýçÇ‘¼%#ð„†Ù@§¼obgÒdw3ÉîÍœÿÑ%,>r–íœét†ç^z—t:£—©ELM&€l¢‹Æ†Z<ª]ôYŠO„âqŠXCB=!·xN5^�í9Ž3è:((è Ìx¢¦ÜO}MÀ]ä]A97f°7jÊE‹fÈ's&òÌ‘¯ÿSìh"9F”„jCõ–ðÄò×¹ó�Ø®Âï÷síµ×²fÍüq®¿þz.¸àf̘aûÜ·¾õ-&NœèÞqÏßÙÛÖ�Âuó�¨øY!ß±$ÏñŽux=ð©óN.8ïK¯¯$‘L"A¯H²(4-§²¢¡x}Ñ8Ž´g‘â%ÁÒùÃúUg2ï8'ÍÝ €ÞnÕ\u5*Ëü&½óRo…"ìš@Z¤Ý´áé·ÇöÒ<·ÝçŠñ}Å”º@åd|‘ìkïækß¹¥àFJKK™;wîà)QUåŽ;î(ª–=ò𦡍BÃf× ´�• d6E:ÞÁ¤ñ�Œn×¼k6l3qÅW’ãYz½×,¤fv•6ˆÝæzϧ²ÔÇa“ÊÝ*¹ƒ1@ÐH'êGŒ±øv²ðkŽºji±IÂdŠ|ÊV˜a^Îæ–s+ b j°š`Õt¤”\uÝmìܽ¯°t)aìØ±CòŽ:ê(Î;ÏÕOâ†_þ�öŽ^È�Ç_VLÙ¬Uá‹wRS]AMu…‹4Çô»›£xƒÄãI×]C~¿×ùÀ,ì:kñpç[sЛmÈÓ¬\âñ&4FL©GÚûéIaé—jd�„ õ+”f§#X QœÎ�¡=rßQ}a"uó@(üõágyðQ÷²øªª*jjj†ÄŠ¢põÕWÄÐtõôñßßmlõ¨šl  jFá” ¿Ï‹ßç-8g,‘ÄÙ›P O ’Ž®^‹´[T³ßo8›éAÌ€~lɼ·šŽ9ƒ1À60¹ÄKe¹¯tÈKŠ\:GUY´9£D3Ij…ÒŸ3ùèÁºy[©Ÿ‡ê+aÝÆm\}Ýí†CU¸œ¶~°5yòd>ýéO»»ûO�òÊ+A€7\�'Tc„¦Jähã Ö�L¥]ó¿×½ê&Tö–=d2v^[•vé~‡°3_ίòyN/Ð>gÆ6û?¾±¤ÀÖI·0Í(È“‰¶:tH{¬o7–�fõ�ñ“yo»täQøÂµ$Ri>þ…khk/Þ‹ïˆ#ŽØ/ðx<\rÉ%EµÆW¯¾I¿ME%P9ɰ¿E˜@ñ¢*Ø×ÞC{{a«Ÿ²ݬfSQóIxBµ€`ÕÚfRi;ÓÌœ6ÎxÌÙTo¡Ä‹BGàøB-pò` 0ÓÎá¼cçÜ)‹×ÏJ„åÚ½}] s �½}›´sEŒ\Ú°_Iš”œvþ×ÙÖ²g@‚Λ7Ïõý»îº‹={Ü¿;eÊ”¢Z`óÖVn¹ý¯zøªÁ®+ªþƒU“ЇMÍ-´ìj³›8n$>Ã,äK¿� ÇgåF²Y»8jÁ´f¬o,-`÷=�GΨr~tÜ@ p˜óÓ£‡G( ‹´;<ÒBä D„´Äî2ÿAi˜†¼Ï -çËoñ¶ú•R(” ŸK°lÉd’K®ø ï4mԦϙ3Çݳ_µŠ‹.º¨èw¯»î:W-�Éd¹çþÇÙѪçôCµsÁˆÉ­Îª7Tƒ¿|<‰dšûxºcŸ?�@@g½æ@/7óE†³©¹•õ› ö²äèà †é´<«Á«úk*ü”„ :×VŒæÙÕ”×Ñm»H+V™µQ’&웋ã^¬Sê f*o˜O¨r<øþOîæ/.ôƧM›VôØúõëyøá‡yþù狚‚_ÿú×®Ç6nÞÁ}<¥ïþU½„†Í1 "QŒ”ô8„âå/.çéçß*PÍKŽ™‹Ïç%Ý¥;tU:”ýúе4o·ïÿ›0¶�†új@�°U-ì ¡wQŸØXØŸkH aX°0Ô³xìÒºÏÞÑ�3粑¼°ú ör©<Ü[=n ¡ŠÑ€àŠkná7w=8${¾xñâ"9ÿ,­­zAƵ×^K&ãî@~ô£å”SNq=ö‹_ÿ‰�{ö_Dwóöâ Õ²aó¾~M!61g掚?� ¾o•ÎpFý`"™æN—Ö�œ²ˆPÐh${¶1Hüé`fÁØ‚.êy?ÏÉ68¬®:à‚íS“Û%Ön'œÎ¢¸¶[ËÕä˜Jõ†©Ÿr&þp øâWÌÝ÷=:d‡nÑ¢E®ïïØ±ƒ¾>=óæ›oòàƒ5!ßþö·ñûýÇâ‰$ß¼öV– TL4%N¢·°ùÓ²gÈdìýœ|>/žw •¥¤¢;ɦzŠ®EÕÏÃ�¿LÓj{"«´$ÌqGÍFUUâk�üÂxRJ<Šp+™^Œl…ŸÃ*6‚jR¸«v‚çôÜù‘› V4R;ñ$<¾]=|þÒòà#û·þÈ#�t}ûöíD£zyu,ãî»ï¦·×µc'sçÎåüóÏw=öÈ“/óÌ‹+�'Tƒ7RŸÓ2 êj* ¾sòñó¹ðü“‘Ù ‰Žõ 5‚53ñ†jhÙÕÆ—¿ù‹ÂHæði6s"RjÄ;6Øâ'gO«“.f½f}U�,ƶ€¿²ÌGa;#E°(¨S�Ü‘îZD"ñúK©½ˆêÆ#Q=AÖnÜÆ}åÇ<òä+ûEü’’\�µ´´˜à±Çã•WÜÏïóù¸øâ‹©¬¬t=þ�kn&Ú7ª|&ë¥oZ–To œs"Ç.Ê[Ô³N=šßþìºïÚH&ގꋬœHOo” /¹®Àó÷z=\táøý^ë�úÄâ¶_i•[ñ:C^7n= ƒj‘lŸ;‚çâ.˜†@Úûo›�²rïUŸÍð)§®ƒP<ÜÿÐ3œ÷Ù«tðe?×üùó‹Û¾};ɤ½HöþçŠ~~Á‚E!âæm;¹ùöûu-à/Ç_:! ѳ…°?ÍÝ·\Å ÿø/=z+·\ÿ5‚?©¾âíktû®¡—¤¿»ªpSïœz4‹�œ�–‰“èÙÆà= Ý!âpÐC8àq"‚Ã� ²²–ß§äwẩuü?'Éù�”–„�E;Xû@¤†Q3Ï¢²aŠÇO_4Æ×®¾‰¯^uíÖ+ù°ÃsM¦¤Óivì( ±ššš¸í¶ÛŠžïÆo$‰¸€äþŸaÃ&½z'4lBñ"3Iú¶/'�ÞÎŒ‰uL›Ø@@‰Ñ×úÑ]¯YºÒ©ôôõ³rmas�Úš n¹þ2@’ìÙJ6Ñ1ê[D3èO;P  Š¿BN(ÁR"ðéTŒß IDATÝ:œŒUˆº„¹–êšûÅJ þH5õc�¢qÚiø‚åôôFùÇ/sü™—𗟤ûöÀkîܹ® �FÙ¶ÍÝ‹¾âŠ+ 4Cn…Ãan¾ùfw-°}ZöY-k`‡™&ѹžž­Oгåôîx–tÿ^{D’ê! PQ^b÷�~þzçµx<*™x'±¶&W¨·�èîÀïUðû ÌÃØA@)´4Ò)Æîœ§Ù.Ǩè58Gõ¨{�SN ¬FG¾žzöu¾ôõ¸äŠÙóOÔò™wV$�FÙ´i“éåWTT0fÌæÌ™Ãܹs‹â^x! .t=vëï`ã½ÜΩǮg(­2ý{!åò‹ÎeÁaS‡L7’[o¸œSÇ"³)úZ_™µ¡o¢˜Ú/©|ª“F_'O1àñ ³üNRØeK’CõŒ”®Í1ÔCBE±óJåðiÔŽž‡Ç£wiÙ¹—ïßp¯¼¹’DâàLS=ztÑ/UU9å”S¨¯¯gäÈ‘ >œ††êëë‹:zVç»ßý.§Ÿ~zv�L¦ùêÕ7ñø_~†0 Ýh¼ ´t��*}­/1gÚñÜsëUt÷ô ‡¨¯­DËÄéÛ¾Üì  ×SH—�qbß@˜X€·°l½Òɶ*O®› × •î¦&�å;Z¡JI ¤šQ“.©!ÈjY~ùÛû¹õŽ¿q°×èÑ£)//w=VWWÇOúS‚Áà�{ñâÅœyæ™,[¶¬àØ«o®â/-ç¼³–à Õà ×ë›9qÔ´t”®M'\9‘òÚZ Ilß{$;Ö’?‘åæX×Ū¢wguZ6'¤)ZÚQ$îwcaw’ªGL§qâÑý±8o¼½†ë~v7;Iä  (Ê ð¥/}‰gŸ}–îîBõÛ×ÝÆÉÇ/¤´4D j²Ñwh(šM÷�ëÈ�©Á]Ö.§… ¡ÓˆÚšÍÜK— ±§~ ³„RÂ𱇣IÉ˯7qå5·pÑå?>dĽÒWUÝ„%K–°téR×cmí]üôV½*Yõ•⯘Pœ†rP—ÞŠ¼ ý/v0�ÑÈd LNèÅÒ‹>™ÖòÄ7kµ#Pûk†íòðôsopÅ·ÉSϽÁ¡\¡Pˆ &ì÷÷’É$›6mbùòåÜu×]|ë[ߢ©©è~Jnºé&×Ê!)%÷?´œÕëô°.X5Å*$©CKEÐ¥­/€Ü/å�ÎHçˆ[iÐÛf¢ÖS$’Y š›<,˜€ÈK} \F]ãL²é${Ö£e’ìÞö'¿�oº‡®C<)»V÷Úpüxœ¦¦&V¯^MSSkÖ¬¡­­�þþ~úúúˆF£$“IžyæÞ|óM×s >œýèG\zé¥Çvïíà÷~Œë®ú">Ÿ—pÝ|úZžs%Ô ¡®0ù2„/è'SY©‚9N¥Ql7ÂA<ªàòOM"òêݼ�>ýùÞ¿zŸ\·ïÊac˜:ï s;WÏn65=†ª¦Î?—·š¶ñ_—]wH`̘1455QRRRplëÖ­Ìš5ËÌ FEQxà�8묳Šj�yóæ±zõj×ã/>r+³¦�GÊ,ý»ß ÙۚדR3BcÍr ÒÖÞN䎛ÇòÁµ°„…R³Öq¾Ü$’5ͽ\tÃ{´u&­œqðšÕtai5šÉJ£.MºØ¢¼á—€?TƘ©‹(t÷è8{¤|8•uд,»·½ÍÑ gqÊ GR6l˜+ñ^yåúúúôPJÊA¥OÓ4®¿þú¢éb¿ßÏ5×\Sôû—_u“ñ þò‰Õ?D'nÀŠ Û¨ºó–÷'2ôÇ îc—[2ȶ­Æì6!í… VM$„B}ãLÁ265·ò•oþŒÖ�:â5jâ1E¥{ßV¢=»ùÆ¥Ÿ $:d 0ЀwÞyg¿Ï×ÔÔÄCéÛ̉ÍÍͼøâ‹üùÏæ'?ù �>úhQ†{»i¿6j<Á*|‘¬ãåÜ,„Ý£’ƒ |ÒÆ Å«/–q¶’ÓÜ|€UXjÆöu%aä’¥ÔÌFÒ÷¤Ÿ?Äð1zUÌE—_G{G7O.�Ï~òLTEeÔäcÙ±îYÚZV2jÊ.úìÙÜøË{ Cꀢö|0ášk®áŽ;î ££ƒÞÞ^º»»ééé) Ûr·ÜÇœ¾˜ÚšJU“ŒF” “xRc äè»|O ‡“žOwö¦�®CÖÐø`�-¬éH ª®FŽÓ;Züøçw›É›Ûïyˆ½{;�@EÍXÂåõôv¶Ðß³‹ÓN<’‰ãßwXµjÕ�síÚµ<ù䓬X±‚�7ÒÖÖ6$âtvõrÃÍÔÃBooxXÑüÉ¢9#Ǣ험Éjìl+hjµ¢6Íe3ûâ¶”¯3�BP;r-;Ûx䉗ÍC±X‚oþƒñ!…ºÆÙ ¨´lx‘Úšr>öÑ¥ö.#ay½Þ‚0•J™ø¿Õù{?×òV°q‹Žªþò‰eÝ{'-�ÜPCE·ƒÙ¬d뮂V9›lh�eÙõ=û–“ ÓÓÌmÒ,¯Ö;_=þÔ+ÄvmñôsoðÂ+ïp좹”Tޤ¬r$½ÛÙ¹ù>uÞ©üõÁålؼý =è™3g²gÏ6mÚÄž={صk›7o¦¹¹™µk׺î´y?VGW/mí]L7ÂŽ±ÌEÒÒ”~ëÖY)�P‘,Šlj)`þ7Š1À.öæÀ ¾h’Òþ¢- ¢ÒʲY�5ë¶ i…ðƒïä˜#ç * µ�sèïÙEwÛª‡Oåúk¿ÌYÿyÅA{Ð[·neÉ’%´··ÓÑÑA6›åƒ°ÊK#”—›@’½.q¼]úE‰—BáßþA±d–æ]ãçž/Æ/Ççþhnígöáµ I0TFGg7ûŠn´íëäöß?ÄEŸù‚%ÕTÖN¢c×jÚv¼Ç”iKùØG—rÿƒÏ”ÝÙÙIggç ŸSU…H8H8$ðS^¡®¶Š†úª*Ë©(+!à÷ûLIK§ÒÄIº{úØ×ÑM{»> ª§7J,ž KÐO�rÙ¶ôØÃ™6y Z&Aº¯Õ 1‹aöEu| +]ÂC,L¤~skÔ ;zc xØÊ›¶G™5¹ÒÜÌ¡ª¡ŒTÕKOœh4V$––<ø�ç8e鑌UOýØùôvl¥·sÑ®V¾zÉ<û⊮üꪬ(eÆ”qL?ŠñãF0nÌHÆ4Ö3¼¾†`ÀÐnÙZ6©'odV€š¦«V³‘ŠðøŠEõ¡i-»ö²½eÛvìfÃæ¬Û¸�Ö]mDÂAŽ[t—}ñ\„€Xû*½“¸CõKéÿ¤æbÿe< ˜2È¿ñÂ;í΃,�ÄÝàQô�Ïlß³I½Í2™LAù³uµîjcÙ?žåÒ‹ÎÇëQ9éX¶®zŒ};ÞeôÌÓ¹äóçpíõwt¢û|^Î:õÎ8i“Æ7RS]NeE ôAމN²ÝkèKõ eÆüß܃u™ç'5ÌŽd†¬¨>ªÔM©àÈ9£Pý'ÑÝÛOo_?>¯Jݰ*´lŠè®×IõluœÓZeaÔøH­hì/dQÜ<óVÁ¶y[¬´€P¥îIk´ìîg”±EÌì$™L¯§¯Ï3 1î¸çaÎ=k)�#j •ÖRV=†þέôµ7sÊÒ#XöÈó¬:��Æ�ÁåŸÏY§C0èGACÓRdbíôí\kkÕšëõ“AYÌ>Ë|g$©åKd²Ù8Ù”Þä1GLO¨–j9ˆîÚd´v•EÎ/à�C°‹ £”š3ÿêÌê ¼»=Akaø¸ òvÃ?Ð[Šé^dVcKK7°¿¯ƒ’’0¥‘ð „ùþ wèé$E¥ºa:(^vo~™Š² ÿyÎI]±ög•–„8nÑa<ü‡ëy{ùïøÏ³—P㤺7Ó·óz·>IlïÛdúwc­qÊaí:&¯ÙÕ«�×çîUؼ@3Ú]J§«ëtÿ�ëHt¬Í÷õ-?ÛA¡ä Sõë¹} Í&ýz(ZF:±x‡ô[�¿îhšß<¸•ݶœEú€ï½Ca€\bè+93ÑÓ—fúø2"!¯e¼«0beX²äDÞX±š½mƒ1}Ѫ¢²èˆY(Š XRC÷Þ�¤ã=ÔŒ˜JYi Ï8öÔ/Z8‹«¾ú)®¼ô”—…‰î]MÏžwHGwãñE(©ŸC¸f*þÒ‘¨Þ ŠÄ¬!›êGKu “r¥ÖÂ5 8w8XwBÛÔµ,tØŠ×X‰_èøå[ï¹™9€ôç‹AÞ^×ÅÝ�î k�^1Â{m¨ Ð|Èm{%™Ò˜6¾ÔÖZ‚x¬›Êš‘±`./¼ònQ`ÈÌÌ­ÝÌ Çͧ¦º¯/H&§¿»Õãeò´Ù¼ùÎZvïigXu?þî—øÊ?Æ´Écˆ÷´Ð¾e9ɾ�™¡´nå#�À¬DQ½$S)|ä*ËK)‰„ðFêˆwnp1ƒ'Q¨¦uØù`“V;ñµ´O'~ÇÏ©•¤t8“Ò¥]�þw*£ñûÇvðž}ˆ”üʉ Æ ×|Îê ΛVnŠFoMË�v0nâlfÏœÌ+¯5‹œJ~wÕF>~Ή(Bàõ‡‰vn§¿»…ºÆ™”••2q|#·ýü›L�8•Û^¥wïJ@#FÍø”Ž@‚X"É�7ßǧ/ù!Ëy�Úa•>g²>¤Ê&ÝYD¸U Pv'Üe¬”E=²@Zm©[ÉWa‰­k5Ž‹íoïNrõoÖ’ÉÚ>3èÛ_h¾ˆQ'‹g˜:®Ì “›è�JƈE;˜0i:‹ÍgåšÍtu÷Mevtö0¬º‚éSÇáõ…I'£$£í$ã�Ìž³€#æÏBK÷Ó×¾�¶æÉ&{ðGj¨¨?ŒŠsŠ�¾h?�/ƒó?w ϼ°‚”Ñ.î�¦ |ì¬ã)- £úJÈÄÚÐ21k©R¬ñBñ¸\ç»í©*$¾VàøIç¾€¥#RÑ×mnåõ5M¤þ¸Vá }Ù‚>/P÷$úÒLSJ$ä1ÇÀêó€’ñnz;[>|çŸ{&áp�XlLxr“<}ί"ôýhu�³¨1_ lôÛÇ2DŸ.žÉô³sãsdݨ–QrÁH£fœeîˆýÙ­âæÛ(ÚÔ¹F�¬ãÝçïD È$ºèkyÎ1qk?œÂ ï&õ'ÐVåc'~Χ(€˜�ªß¡ r“ÄVméá“ß]á)zkØ÷Š¦Ç‡ø6ŸFŸLM*­Ã““Æ”èc\ô¹ùr3ƒ… Ö·�ö]«éÚ³�Xï.b½»‰w·Òß³ƒhG3í;W²oû;h™„M“åuS –ÔòòkMœû™«yê¹·ö«²§¯?†ßëcá¼i(ž Z&®CÃb(4—.è?ŸÜ‘v¬aâ‹!Ÿì~&«ñãßod}¡ô? üK¹ÿ�2@ÂÐ5'åÞØµ/ÁÄÆ0å¥>Ó äçÿsÀsnŽ Ô2¤½$¢íÄc�¤ú;HÆô‰Û¶AÍB ŒÑ°RË®Ç�~q�k •AeWJvìlãÄcçQ^Á®%Ù݌̦]r–—ã8žÏ%�ž¾òØ[èHΟC '¾›ã—{ï¥÷Ú¹õ�­Î¸?mà8zFûS™ùk#*0ןŸh5†¼HËþÁ¬ÍW‘Faˆí™iš”k©Ù€©éaQ²¿�d_ _ÿò„‚�J5oßÅÿö ÙLPÕÏwÍ ”#(”t;FàTù͸G+ñµâK7ân �øÉT–›ÿÚL2] Ÿ@¯îpíO6cØ’ Mµ�Òˆ'²LÉOƒüˆ×V „e¸§1]L‘úT1òcåBc�–$ûÛ7e!Ñþ+Þ]@LðúÛk¹àœ¥”•FP¼!2‰n´Tß~Cö€~/…yNª5W„O:òZ!–à ;;‰Ÿ?ûÝ�lçÁv;/:m„ïÍ“rÁb`Œ ´%h¬ Q]á7EXm¹9BÖxÏ4ÆÈis¦°~\ÉÍ 6>'µ4BN:édþ¼ì×<ÁPLÁ¶»9çŒcõÈÅ Ù·ÝPµCi¾`t)„tÍ+äÓåî*ߊÿ 7ÙÙÓ® z{]_û¥ëÞÄû°TuLø3peî»Rêû¦Ž+%èS,3‚¥eæ�1ÿËlA°ÁÉBØ5Æ$-!ÙT”Pù&NÇC�¾t@Z`óÖ�L›4šIãQ½a²Én²É[Mž”N„p�Ù<¦÷îNx«Ã˜ÿ¬KÊW“|'Ü›×{:|ù'MÖ‘ñVÿ(@;T �ö`,�ëÛ�§Œ-1ENÂæÿ- †Këã×GŽA§„Îe}Lú¤YlÛ¾ÇlÈ´¿ëí•ùÄ9Kñû¼H-Cº—-îJnañÚ¥kË|Y«w&x¤”®>Òí½<ñSé,×ܾŽë]§£ž ¬ê39Ð:¬÷Ðç ™¦`g[‚ê2 ˆ€© ùÑò˜~B~ʨ0\R!½Y‘@Ç “’еS^;�ºº:ž~î­A'x»­X<ÉÈá5Ìž>™M‘ìÝ>2˜—ÞœWO�´—z{˜çB|W)/õæÏý—§[¹ó® ØíÀÏÙ�>2º?K¾€c7ñŸžheí–n»-­ »}3�iÅK,ɨÆRCÈ,í[_fÁÜ©¶‡ìÅf²lÝa­ Ì×øÙ^2küf.†—HM¢Iž=–œ½Kþ_j†äfÝ%ß‘à‘šæBü¬í™¼ÜÔÁ�îq�ìÖûµêŸÙ ·¸K[€?<ÖÊæ½†Ó¥E3`Pg†KÊÜ1»¬I{˜˜{È©¾Ýôµ­ãË_8›Êò’ý¾`EÔT•ë¿›Žéµ š,xiÆuå^ùZ ic �ÜZ˜KÝ [©õž5Kí¡[6ÐêXfm Ñ´©›¯ýr•³Ý èe^ß7h²_ëŸí¦´ ¨E4iH™dëÎ~&�)ÑF –(Àj,Ñ�RÜA4ËÏ ³�Ží£qÜT¯�_mÚ¯‹-‰„øÙ÷¿D$$Ö¾šl²{õ/ŠÔ¸DÒn. = n(¦,Ò+ÀáG4ïŒò…ëÞ¥³×­÷ 7?=ŒvZO `–õÆYvìŽ1kb™^;@)Š¥ž+sH=ôËyÿ9§,‡ä˜CËYŽ9v)âsó 7ª*,»ë{LžÐH&ÖF¼}5ÈÌ Ä—=Ÿ%¶¡9.%ešæ¾'°àä”üÝíq¾òóUlÛãÿ ¸˜ýëwP@Lp–áÄÝÑ4[wö3eT � jj€Ü$¹œ—o9os­ " ™T”`épfϜƟ–=;àÅü>N8v.ßû&OI:ÞNlïÛùÌ`Ü`àÌ|,_d†š%›W\ê¥Ka‡@+ þ•¿Zû]=þW�OâRèñ~2@νˆ^Il¶ÕîêM³qG”ñ#Ô†}¶ÐPÉVwýóÚÀü öÒÔd�dÌ„YìÝ×Ūu[]/jñ‘3ùîŸâ›—]@ȯÛû±¶wÉ&{ö\:<~=c, ˆãVHZ¤rXÀ®R¯0Þ¶]Q.ûùªbÄß\€ÞØ‹5´Mèu„戊Þþ kš{˜Ü¡4â5M@Ž Š�vm`Qý¦äãd HÇ: •5°àð¹ttö²fÃ6óB&ŒmàÇWž¯^t3§Ž#Ù³•hËs¤¢;mé`g P~�­•!´ nÓšæ¢-ЧsíöÞ~låæ>Ý{lÛí*Ü�$ÿú?K4ÁÁ_'¢o0µm U¾rÁ8Æ�,Õ[Ë)U½@¡¢(Ç0A%UUl•ÈÖcµ“NŪ¥­½›�[ZV]ÎÄq#ú=h™~úw½Fº· ä+\œ½b* SQ\ÚR÷vÙ"¾¾º“¯Þ´ª˜Ã—Nž:Ä: °ÐÈDÙ˜@øäi#9æ°ü>Õè3hDоoP'¬¢ÿ-„ÞŸPè} !PÔ|ÁH®a¨|%5ãñù‚EA˦IG[Ivm<ˆ·˜+uôO—¾hÔà‚f³<·‹Þ½�DJ+Füc†äj½và~ Ôyà¨Ù•üç)�”„½xr5ˆ©(UQM41Ç:3ÚA(†ÖÈúT¼^¯YG`»Eׯb莳”¸U÷ûl±–ºÂ‚i8™¢³'É�îÙÈß_*ÚG¹ ø8ðäÁ$’z`‹ëd‚–=qÞ^×ÅŒñ%”†¼&�/,‘†“(ìE‹?`õT!ðyU!‹lƤˆÓçV÷WìØ Dw$k\GŽTnN;¬iîåâšx}uÑ9È;€Ïlâj½”l9°Ë“\éé7ö©Œi­h…£>@¢ GÎ@{ª]sø=–ù¹BpȗͶË�“G¸k…D2ÃÏíââšèêKÍa¡We¿r(nC=ôOнÀ2`<0Åy°ic¶÷RS§«~ò‡|¤�—~ûÎ$¿WÁ£*68ÙÖPQ8ÕþPÄ­‹U}ÞòUQ÷š¦ñæÚN¾{ûzî}¢u “,3ˆ¿õPç}syЋ>g s+P™?­‚³�ΈÚŠº`qö¡7q6}ãïÒ°Ç„‹¾[{GŠyrhQÀþØøœºß±'Ư—måé7ۈƋ¶ÖI¿.c€‚Î7È­/ßĘZå\‘ Ê’ù5|┑üžF°2„ªè¶¿$èuj}èí@AëÖb„�'3Üô—-üõÙ]ôdzƒùN×£§vùúW<5€©ÀU’E1Føä©#8nÞ0ÂA^�jhKd @iȃϣXƨ iÍÒ æ¤¹Eƒ™¬FO4Íc¯îáæû›ééT˜ï~¬}¿ñ¯b€œIø8pŽ‘µÖUYêå”#j9|Z9G•èó �9ª*¨*ñ:´¼ÓÎ ›>ø#p‹Ùíí_ä -Z“)�uÛzyñÝx~{:í-œ>�žØÉ¼ŸDøW2@n•¢×ž€{×2ýCa#,˜Vαsk¨©Pòò{öÃé·3‡Ó%�rÿÑÀ<Ð#ÙÛ‘ä©7÷ò»¬n¸go* ô­[çc´o¿×�rëLô� Ç ¶(‚ _aêèÎ?a8',fö'²v‡0´Vþd²ËßÚÇýËw±º¹—x"K:;$Æy}ÏÞßÿ•ýƒÄeÀà¿ iHkѬ N^8ŒùS+)/ñâõ(x=ÂÖ¸ê@¬è‚t&K:£‘Jkt÷¥Y±¾›Ç_ÛËËM�û{ÚçÑ 8–=ÿêþAc€Ü àÑÏ�™ûóź*?SFE˜02Lc]ˆšr?¥‘ ‡HP%ðàóèØ�j™¦©i’TF#;|î[*IDAT‘ÒH$²DãúâYz£iöu'Ù±'Φ–(ë·G�½w†ºV—£÷éíÿ <è*X×Rà;À\ À~Ö1zTAIÈC$¤ zøü>Õ�ôHBÓôI©´Á©,ýñ,}± }±Œ³ÛÆÐC=žøðÌñáþ;0@nMG¯~9Ê#+> ×Ùe„q¯ wåXýA~¨ÿN �[UÌCÏ8.åý�´ZYCŸV ÐÁ‡ë�3oÈЧ¿Z<íw°^-Æožj\CèßQ ÄÿAÆ(Aop¹˜�Þ!#hh Õð!r“Ò­Ó­íAsÕœYãÞEOo¿Œ^ŒÙ÷EŠþ?,0}C•e”¢°æSIôiZ½†—Þ�žÉlá}FçÞÏõ¿"ƒ`Œ¦«åIEND®B`‚caronc-apprise-dfec414/apprise/assets/themes/default/apprise-warning-256x256.png000066400000000000000000001252741526720371000275630ustar00rootroot00000000000000‰PNG  IHDR\r¨f pHYs  šœ IDATxÚì�wœ]UÕ÷¿{ß~ïÜé-3ɤ÷J�PZD|D ò �¾*v_E…G^TéE)%”€ %$¡CBÒû$™L/·�÷�sî=gŸrï�I€$ÌÎg>™¹§ÞsöZ{­ßZë·`p ŽÁ18Çàƒcp ŽÁ18Çàƒcp ŽÁ18Çàçƒ�à ÕÀ Q ”?qã³à7ŽI}@7д?Æg}@3°Ø5øˆÀàøhG%p 0 Œ|€4Ûg¼_a|&-¿{½w ÈXþÏþ®iCQdŒß×Ë€ Àjàß@Ëà«TƒcïG?UÀ±ÀáÀLCØ÷ç± x xxØ $ŒŸäà«TƒÃ} �U}ª!ì‡æú�<º eðð¶a-l1,‡Á1¨>Öcž!äGM†¨:È¿ónCl^4”óƒSap|\Æ)À]Àv Ó0‹µ�éOÒxÛ�grÊàôÓeÆ ËÇXÐûûs‹ñÌÊŒg88]€j43€OŸ@Gè?´�ú)+ñSõ û)/ �ù)‰ø‰†}„ƒ>‚‰Ï§¿þtZ#‘ÌЛHÓÕ›¦«'EGWж®$�=):»S´vêÿÈc-°x xÃpÇ ØoljÀç�Àiè!·dDB’áC¢ ­�0ª!Ƹ¦šê¢Ä">EÈ¥H R !ôø ñö5ÍXz5�Œ±g4�LFÿ?™ÊÐÓ—¦»7MgwŠMÍ=¬ÚØÉÚ­]lÚÑÃúmÝô%2äsÍoK�{�§§Ú ØŸÆ?@OÀÙ'¨½à÷ ‚~I,âgôÐ㆕0}lÓÆ–QU$öí_¾§/Í®¶o½ßÆ[«ÛX¹±“Õ›:ééM“HeH¥54mŸ]®=!éjàæÁ©7¨>ŠáÊ�¯?ÝWB û¨¯ ÑPaÌP}UŸ<ª”Q 1‚y@= d*Ú-]¼»¶�U;ys[wö°½¥�îÞô¾T—7­è‰JƒcP| ‚?ø ð-ö@UósÈørfOª0ÌøCªÂ„‚ý[Ý…hšf˜õù^«fÙ.Œ¿ACXŽÒ,ÛÌ}ôß3Êùõkê×Î7É [wõ°iG+7vòÊ;-¼õ~;­�û$7(üxxyP *€bœf˜úG¡§âîÕJôô*N;v“G–RQ è—H)Šr‘çujÊï2÷’5åukÆ^šñ¹0~׌#%‚ŒE1hÆïšr¼zM«RÀÓìÏd4ú’Ú:“¼½¦�G^ØÎ¿^ÛIïÞã-è¹7 NÙA°/ÆàZ`ú@W|¿O Jf�/çÓÇaÞÌJ¢þ~­îª° C3€Ìý®)+»×j>Ða¯+ ,JC1h¶ëk-ƒìèìNñÜ뻸ÿÙ-¼³¶�®ž4©ô€�ƒð&ð]Ã"ƒ  _ÃŒ~ÌèIjʃLUÊœ©•œ|x- Õ‘~˜ó&JOn 6Ü\¹÷· eZ ¦b0-†b¦æ-mæÅ·ZxoCÍ-}{sKOßV¡1 ŽA�wÌþ¸` '8d\'QÇìIŒ#œÇŸW}vÍ"ìò zMšË7Ñ\]ëèM¤ysK—ïáÑ—¶óöûí{sn– NñAà6ÑýÏõ9Á ‡UóåS‡3qd)Ñ�ÏÓ§· ½éW Eè÷×~ßZ ‡ÚsS™ŒFo"ûkÛ¹ká&.Þ1ÐKoG /g0©hPXÆÀ¥@]¿ž€ò?ÇZËùÿ1œQ ±œÙîÔ¹ùò*X—±­ü‡¡yZ^]ÜøÐz-ÝI{w’Lÿ±ÃÀ/ «`P|L‡D¯À»8¢¿�mŠqüÌ>sܚꣀ@ò,\€°}&Ú~_¸ B±ÜÁ–�=Ü·h Ï,ÛÉÊ��¹Øbà 蕉™AðñõÀ×€Ëú{àÄ%|îÄ¡5µŠ!Õa„Ð-�Èý.….ì¦i+m^°¶Ï}V¹dWMkÈÐq5á)SNqÔ¬)Ã.çÞ‡)~ÞÖ�fXIBsÞúÖ]=¼ðænî]´e 8ÁeÀ †‹0¨òq†aþMîÏA5A¾»` sgU3ü{!„«ðÉ,ó–u-û`µÂ#·Ç, ©ÿ¯a -XÊI¹›bÒ\ò‚4e¡*ˆ}‰`ÉP@˸^»³'Å3Ëvqí=ï³ego/ó®áþsPœÃÜœŠNˆYÔ¨( °àÄ¡üç'›}†°BŸUƤו‚ÌÛìkÁ&À  ¡¸Eö­¥á–O`ün(M˨€u ,Uæ>rFnx`w?µ™=íýÊ4ìð1É&“ïxp?z±NQ£2àøÃjøâüa «‹æL{ÈVÔBŸ³tÁ'g ˆ}rëBØ–÷lAMØ/ýxÕV×ö¡BД”( ÍÕ*ÙÜÜÃ-�l`áË;ØÝ–èÏÖŸ^/ÞYTûã(Î~�žÜSÔ8eNgßÈ”1¥Šio5õ¥4„@HS X¬ƒþ®î¦L §ý šfqQö�B0­‚Œ«wòæê6þúÄ&z~[N›.B''iTÞ˜ \ƒ^£_Ô¨¯ ñ£/�ãЉ2gÚÛ€”„Ä'²«´0]‚(%<(ö×bÁÐwfà ÁtOrx�í\‰T†×W¶òóW°nkwNþ4ð=#TÈ8¸•"ãú¡€àÓÇ6ðÍÏ�"à—¦YoQº ÊŸóý \uD�Õ^CdÁ¹�'>¤W®¹üÝ�ÐÀ¢ ¬¸�¶ßÎ1ðÀ~}×jxn+]=Egï¾ <~° Šï û>~à;À�@I12®Œ‹ÎÇiÇ5à“RY½³Øš�2W/§ ¿T„ñ±a x[Â8L¼@GèÅ>~ (h5_°\Ó4il[ïËåø}¾vX¯™µ¢Ä€Ü(ëwφ_³ <7A|’ã©fúØ2¶íê-6ZPœm¸Ë8ˆr& `(zŠç³sÐ/øêé#9eN=¥%~›¯¯O )³Bë³¹N Á- '@š†È…÷!2/<RÓRº…¹¶Zá—]¡…�#T¸–E3¸‹ F Tk£½+É?žÙÂu÷¯ëßáíè)ã›Àþ3¦/æ�bv7¼„ÿû¥ñŒjŒê¦½M�¥”†|ù�"cQ¸úúnÊ@æV{ö�_/\~8ÂGîinJCsS$ôï-±ÿ�)£\Zsšýï­ïàû×¼ÅÚâ±�×�…æíAðÑ�#ÐYc+ ú>Á¼CkøÎYc( ûs‹£váÍšè õY\™G˜X¹—–ž¯Iì§!À¼‚¶}`Š@CÓ2V&¹$Û=¤3?¹þ]þ÷¶bk ö ³>/Ä>ºñytÚè‚…öµAþóSÃùÊi# ùUßÐd�4ü}„/·Ògw2Ý`›Ïš‹`Iêo.€ÕO—Ÿ[Xðóg¿]Q„z¿êçvÂ�~>ŸÜ{õÅb#&Ž“RNš]KmEˆë;è, F€¯+8€#¾ø¾/þRŒc;}l)?¹‚Õ›:Ù¶» ‰|èBoq¦ *€g\ ü˜ü­®8qv ?úÒxê«Ã¶Ébfò™oN Õ´wóõ…¥@Z°�ü‘sJ% °÷o­=€ÏÂ3à&äfÇp“èË|œz†�Ùa\W>‹ ˜B° ¢°° (ê¼–ïdirÐo«Ë’Ç‘5!>}Ì�ga.ô è=Tüx=$# ùûgÍÊ7>;FOêq€w¦¯oõ÷­¾¾5èðõ}RÍÍjŸ»Èn�Êüéÿ¤U'°†Ïðm­¤"Âc5Š�“³„M¸e^ A?Vzì;0¬Ã|žý bEØ�V•€Ï'˜;³†XÄÏ«+öâ'”èM^�þ:¨>¸{}8¾ÐŽ•¥A¾}Öhþã˜Åw7Ã{ÙUÖŒ ‡oö¸~ÝÏý(+·Õäµ*ëJ¥ HÿW{iù_¿“SÀºz«ØEÖÕÉ}nq9Mù]ZÜ#áªÈ©€|ŠÁëÈÂØ�â‰þˆ±F *?û!‡Œ+gXm„×W¶ÓÏ` zæéŠ;p (€rà†©•wŒlˆrÑ—Æ2sb¥K¢Žâ3M}éÇWþ–ˆXü{åàÈ �a’²f¾•PX„ÛpeÒ"l‰<ª{�}»ý3åwÜ�qü šð¹ H‹2qµ:ŠÇ ìÖ€0‚þº.IHãšâ6©‚w×µ³sOÁ¢¢&àPôÈTï Øûá7„ÿ“Åÿ/¿>‰Æº˜Ów7æ„îóû_ÞUx-yÿ¦‹ ÔßUYä2­Šf &¾tý;·bç²÷°E öÖ7À¨ªD¥ùrº"¢®‚Z'!,¼ýˆô›@8<ŠºÊ0'Í®eÅú6íè)t’±è4ò÷°Ÿg  àßŘý“FÅùí÷¦ JO°O+£i¾«®�ÃÔ·¯ú6kÂ=2 ,U�ÅøøÂâ»›&¾° ³ÈÖ ìã”\g¨Žà6ÿ?çvh43 ‘m>2@÷ _ný º»‘��y3kxoC·¥ND¯&Üo‡8„ÿèBøÑñ‡Öð­Ï�Æç“.‰:†Ù�e%·­Þö"ž,›o6ħZ.ÊCQH”\‚ü‘7úo£@HÓЄ@ÿä5šìù; ¹ïkMžéë�¦RˆX3s Ó§C2Ò��¤/¯Xš–-E&� “îCK'ѳv2hZÚ•)H½ ÷$ ü÷ì¬.ìM¤ùù�+xð¹¢Ê‹_ŽTý!à6à¬B;~fî¾ø‰&‚Ÿ)ÐØ*ø À¯�ù.sÇÈ<®�MHa\Ïšè ´6øP›{²Â^|½@aËboêÜúº£óVeR”p �?RM0R�/\‰ ”à •"}!�ûð™T7éD'é¾VÒ½{H÷µ“Nv’It¢eîçë�ÙØDU©t†¿<¸žkïYSÌÃü;p:ãР PÄøtÒμãÌ9ç“Mø|N0Ï£W„ߣˆ'Gð!D~l  šùøÍH€´™ôVßVšQ ½ì–PC?ÛG𠼎õJ²!ÿ¢°K‘�’Jê%Z3�`|(þp¾` !|Z£„ à ÄðGªÆ •�$TÚD0>Œ@´„�t_»ªÈrž„4—ŠY#³Š_ËY‡3Æ•‹øyé­–BSu z[¹§@áñmôª¾üÂbgÍor­Ð³"ÔÒ ´îoà,»6 úúj‹°…¬„ %W}‘ãÐCn2ïDw¦ÔJÁ÷d/P0X(òƒ~y¯ëTVÙ2›Xý,ü¡2Û÷À…ÈgIhŽ*ÂÀ,Ñ­ŒÒ&"Õñ…*H÷µ¢¥ûp2. ¢XE>)˜9¾œÞ¾4o½ßN&¿.9 ½yé~Õ™hsæ’§í*œ>¯�/~¢©(ð—]éØßˆíƒ'»�nâgS}­Ø‚w¦ B …T¶(ÌìÞX¯ÏÍl×�TO%Z;{yp:�¦µ­‹Î®:»zhimgOk-{:hë袯/I2•B A0 ^¡¢¼„ºš Jã1bÑ0¥ñ(ñ’(ÑH¨ ë�ìÚA÷ŽWIvï4*�Rã"+€LÆ!uÿ+ïXÅ­�l(Ƨ:ˈ *Û8 x²�ãzê1õœsÊp~0OZÐwá X|}²I=îi¼ª¯>#qH©øs( gf¡MXMòb|z±W¯ÊÁûã XèùÜ]|g·ñ"¼rç�ÁʆŸ€ ¨-ÿûÄK<ûÂë¬Z»™í;Zرs]ÝŇÎ}>IUE)ÃkÖPËÈáõLÛÄä #˜8¶©€"ØNoËJ�[ÑR=6P,.�1™ÊAxÝ?Öòû{×£NžT昈ž81"ßNs¦Uò½/ŽUË^Ã/…4Í÷B ا„õtdßJýå­dÙ–BË)£þ�yýíxŸfZ¸Z/ÙìM¨J}^�à‚ jyî0R=‰hí!ÊÙ~ð³ë¸ïáçú%ðÅŒp(HyYŒ!uU7gç|îDF ÷nõ˜êÞIoëzw¯Ð�¾"”€Ú ÝÜ?‘ÌpõÝ«¹õ‘�…ns=z^ËŠA —U¾Œž8á9&Œ(ᲯM6ܼ@Öìö@û-©ÀR1Óó�}ÂFt*’ÜJo‰ýëä?¾\Ü.àùMüâ„^C­LÎhn s66 ƒ{0bÔ?ó·ž¹'´ŒEÊ…«iïÖy@Ã$ RZÈå£OUVÿ[þö?¾üF’ɇzÿ�©cøîÿ9ƒùóf \´`†To+�[^$ÕÓ\¤; ™Ö€å wv§øþµoóüë» �àM`ÐóQ ßþ^O�,¿ #J¸è?Çôûl`Ÿ¶eé¶r¾¡Ì>©¤³š8–=½W©Ã¸*¬xBé`g ¶¯öj£PI¡œy mžš¥kAâ³¥ÌZ6�_úA@úôß…Mê~zŽ�‘]¨a¸(j9²fyxBX“jìY}–ªI‹R€ð…ˆÖÎÈ}�¶öN~ù›;ظyLJ6Á¶7·ðàã/r×}‹¨®*£¼¬DI,’�áÊñÈ@”TÏ.ÐRE®�Bá:$s¦Vòê{­ìhÉõ«G'­ýß�³¸øa>{wX]˜ï|a,ñ�l“ÒZÆ+Õl=œ©ÀR¸äÿ»±ûd“yòæÓ¼B÷î¥!|®µvK ¡ÏªåûX�èhÖCð%CˆÔL%R=™HÕ$•ãUŒ!T:¸„ “êÑ“c„W¨Ï£˜ÉB$jf!Ëœô›ÙÉ驚˜{Ž«×ná–¿=FGg÷‡>Ùºº{yöÅ7ylÑR6oÝI]M9µ5*‘”?RM¨t8™T7™D{ö"5ò‘U±°Ÿc©â¹×w³§#™Ï„˜…Þ�èßGp&ð{ã\¥ |ç¬q ¯�¹šñ®I>®L?Fé¯[fŸ"Ül¾¿½ÊϤ—¹�¡òIG¸a­>0È€íþ8(€ð[`^¾�œ<”ãgת¾¾!!Rš+®3W¥ÐÇDûUáTÌûœ¹/U‚PÔ¤ž,šoâVÐÍB1•†E˜û¹{—³Þp tIÃBe#mÖ´wô°eÛ.¶5·ÐÚÖE:£�F,¦º_¤ $»v*ø„¦ §Á¬á#\9>g]••–°zí&–½¹Šýeìiíä¯÷?CwO/S&Œ  +Ø@¨lÉ®d’]¿¯½€hTCŒdZcÉò=ùn¡ ¨D'»Iì àkÀÅùv˜wh gߨ§øZ)›D6̇JtáÉÎk~«õ`ñýe¼“.¸€ÅÍÈVZZƒ!\(Å•êAiîï"ìR)á7ª…WÚ®÷¤ –'R=U­ÿî}ðY~{ý=\}ý½ÜpûÃÜóг<ûïר¶c7£F4/‰˜çˆÖ’ìÜŠ–î6ß|f:ºoЋiV>=‘SÙc܆?R‰/Xšû{ÚäѼöæ*6mÝÉþ4–¼¶’¥¯¯dTS=ÃMãTø‚ˆ“ék%Ý×Öo%0kB9wô°jcg¾ËO¶Kæ0àaè䉞×Ùå»_KeYÈȳQv{* Äù�8½”–ª?7`€|B3ICsJÃð­íËĽ*Ð ×+­èOyovuÎZÙ’Òá'â�TçöHô%øÖÅ×ðèS‹ééuG¢'ŽÎ ¿½�©“Få>Kõî¡mÝBW@M-Ê ¼£K¹%D–�’FâC�QÒw·´qû=Oð‡›þIk›.µÕåŒÑÀè‘�4©¦ª¢”²xIî¼=½ v·´±{O;«ÖlbåšÍH4¡ª¢”K¾ÿEÎ;ëd[´0Eç–—èmYYà é�Jõçdä¤2œö× õÐЩŖŒ ¬Fïàã:baß={,ã‡Çè¼UHÒ�·v>i'êt!F@‰Ã��B½–´Y&Ç€¡¤µ€³Y¨ýþ̧/êŒB¯ÊMØ%�”;Ö²O† /ý·ý}aÁ„–!uU¼ðÈ©¬0W箯ÿëzIÿû×+9ú𩹿ûÚ7йuqaš­‚CÃÎ.¤iY÷Ê�~‹K“-m”Új†¡é š–d.eY AðG« ƇŒEÈÖ";Vñöòµ|ã¢kX¾jÀ¾ÉéŸ<Š›¯¹P'’Ý;h}ÿ ™þ}¬xÀâwZøòå¯êCøCô�ùk EïÝç9N˜]Ëq‡ÖB®pÆžœ“Íô3{ÔXš!ü8ýy,M:……ÿßYô#�|ò¶š5®ï$!‘¹È�Ñ2Ì›§^GÓ5ø¥‘ŸÜCϱ�ÕNS˜tn¿ç žùͽ¨·—¯á‹gž¨[ü¡r’�ÛÑÒ=û`�±œ S¨µL-“Bˤôüû¬ðÛ`νÒ2:}šqlº¯�DÇ&zv½KªGÏ£ñ‡â®nV]Mç}a>}}IÞ{#}}É~}“÷Vo`Ρ�$/�/EI²s[Ñ+­¦i «‹¢iÊ8¸h?PÀB`˜' ]æüÓFâ÷KÃŽ%‘¡6÷p­Êsóçqz·í2WAˆ²ú[Vuikc¥Äú XÚR…¥­Ç M˜~¾›oëmJgG¨l„¾êYâw~|--ý0ÿ­£yW+±H˜#›’³Š±ZúZßW¬¨½ka&�ù>4[Ž�ð§úÕÖÝ2‰›èm]ƒ–N"|¤?jsEs�šÁÔI#Y»~Ûvô/ùîµ·V3fÔP&Œ–3Ùü‘*R=»Hç…˘<ª”7W·±eg^ÀŽ¡8ð à«äI|ÿöYc¨© ¹ æ–BòóíI¯_a~¸—óªÅ—ßïç”f³½¹…7ß]SôsÊÒ—Í;Úä:Ƈһ{¹qÅ€£úßgáâtöxþC€MèY´û½¨þÄ\/(Ÿ=±‘QCcŽºy”ø éôÞw¡êéí£«»‡Oœp8¿÷öùc¤z÷�Iu¹Õ*{ °´‰øÐ£ •5Ù*,‹ ­«&$­%\9N§ûF etË@(�ˆ¼ÝŽTïn�[Dk�þ°£üÄñ‡Ñ—H²øÕâYºÞxg G̰͞ƚ\øØª Ѷ¶  dµãQ?ñO/Í[1 ¸èÜßÀäiå5qdœO3„€ßç4é…W÷—N?žì½f&_6‡ÀIÊa °ýZŠ[aŸä–•_XØ„í !‚9 FàÖ#À]轨µe F´þPÅݹêãík÷Ù \ùþ&™:–q£‡÷ïCøB$:6á0´…P A Þ‚`ÙJ†®¯ô–ö¯ÝÕÝKGG]ݽ¤R)BÁ€ªs-{Úyã�â,�t&ÃÖí»9å„Ã…ôœŠ“êÙiô"ðRšÁ`~6aDœ×W¶æë;CÏ©Y¸?çŒAÏ÷wA¿ä«gŒdò˜2Cа­ÒÒÃg·rð�OZò°¥”ÍbéfúKck €´e÷á™Ih]ù‘ÖÒa[øÑrOzdÁÌUÈ÷Ø‹é%©™F¤zRî«Öl⬯^ÆÚõ[÷é‹ÖPË’§ÿB$ʉaÇ–—HvlrÿÙ�V„’¡Gã©l;»v·ñä¿–òÔsKyoÕF¶ïl¡«»‡LF�‘K)ñû|”•Æ3j(‡NÏqGNçˆC'…,ˆ‰¥( -ÕG÷ηtr|‡YÆcç�fôú„á'ˆV+[Ú;º8çÿà ¯¼SÔs’Ròç_—3ÿÃìþ•hßHû†§òr ŠñªY/°zS'g\üŠ�HvŒÞß_-€{�Q^gŒ/ã¤#ëU~9+i‡ðy¢ý9e!¥#,ˆÜS8þ]R„s«´ÉZãLŠ/…šDXsœéÄÊâX'€×jï\i%¥Mó0+5}òeî¾ß7œiïè"�Î0÷¨™¹)ˆÕÓ»g•š‡áÖý'>ŒpÅ…Èðѧ^æÌóÆ}ÿ‹÷VodWK½½ ÒéLNd2R©4�]=lܼƒÅ¯.瞟ášîã­åkñIAI,J4Ö�¬M ýã�bCHvl![R_huëk}Ÿ@´_Ð$, …‚L?‚'Ÿ]FG˜Š¦ilÞ¶‹Ó?y¤iø#¤ºw’IvUA/�èèNòƪ|%ÇLFÏÜïÀ ÀO¼ÎôK¾þ¹QDÂ>¥¹†pâ\�véZ€ƒ£úNºøâ"gšÚªúìõ9ÜÁ%ÃOZ˃�‘ ,­$Í}ÕZ7À,/znmŽ \5™@¬67�zûúøÙ¯nfóÖæ$”³båzæ3“úÚ*‹+4؃l\�9ÊO¬~¦Q ¤ßçSÿZÆ—.¸‚ÎÒ€išÆêµ›yèñxô©—Ù¶c7ñ’ Cj!÷b„«Æ“Iv›íÀ„kbrîÿ¾ö bµø&dUW[ɰ†|üÅ¢îo{s ãÇ cÊ„¹ç„–!Ѿ™ü„éBq¤ÔT„xîµ]tt§òEØ^fÕ ì+àþ0Õk‡O[ϤÑe®ˆ¼ZãïV»�³˜ À úÆf]X•K¶‹/v¦_©2ßfýk©˜ðÂ.ÚØ~­ΊÐT g¨Ó EW65fr˜Þ5×øü�åk¹üêÛ?°D޾D’öŽnN9a>Ÿ>U|¡2£NÀ=yEøÃÄ,,À]ݽ\rÅ�¼¿nË>¹§¶ö.–¾þ ŸYÂëo¯fÖôñ”Åc–Ç' ć"}A];LwÁ3Ù*M²«™ qL™=”ŽÎn–Z]öÆ*¾vî©z‡jÀ,Õ]’L²€®)ÞTuYˆí»{ysu[>™�ÿÄY§Ýï!÷Ñ\™�ø«* pÄ´ª\ìÖ+„„‹¾Ôk˳¯Qö=ΞÏèh”§jÂ;„¥i¦_™»aü•åÔw)äÑ”¾“.í8lþ²µµ¶cÕ×Ü‘ðPùHd ¦|~ÅïîàƒO>»„ç^zÃ\³d€pÕD¼èÌÍÔdýó ›¶óþÚÍûü¾vînåÁÇþÍ1Ÿú6÷ýïs*W‚påâC�6Xšó¦‘IvеíµÝ—\ô­ÏÓ4´¶è°à]ÿXdî ®_‚Ó\²¿tJ•¥Á|oÈûƒŸ*¼v8îÐZ¢aÞˆ¨U°²ަ(µ�ú‡0ûÛhö] BMï0,®¹ö–ÿ5!É(t]NÙÖ3¼4We¤l±tr4ÒuÐM¤ÁÒáJåÜšu[xú¹e¸èìêáÏ·=¨äbõbClßÕ=u¹§'Awï×»­½“ÿóý_óÃË®Ï1 åÜÎx¥#æ[\o%�ìÚFÏ.µŒº,çâo¡è{¹åî'TÀ¶v†Å²Ì·p©s¡±&‚ó]ªÂ�¹Àþ j€¯xm¬« 1etBšì7–ÕO³NÍ5ÊT�OØj4ó뙊&„M-çм…ßzÚœRÒZFX„_|ÍjÁ€3¤˜»˜t‚iVŽô\s¸R9쿹•k,z~�<ù¢²Ê‡+Ç]…pQ`«¬„²xÉ~�·üõQ>ÿ•ËxÛVòˆÔm8B˜]k/̷ݳó R½­Ê¦“çÊìC&u«×læ©ç^SÜ‘`¼©è`›U |å´T—çµ¾bÈÞG®¾”x×SÆ–SUt·VÀtwõ0[PYWHáñÕr–…Ó]sL7gB(K¸æzÙ�ßCSµ˜ è³óú£à Šù_6Ò(uÕÿ~ã�÷Yôü«|˜ãg¿º‘Ö63¶ˆÖ*sÖyéYpæw>¬ž‰ã†(÷¸ôõ÷øòw®tøíáò1„+Ǻ¿S tn}IÙRYQÊÎ8ÞàJÈ?É ©Ý¿ÃU“TºxÏi®n�…ý\ðÙQù.WbÈÞGªp©×ÆhÈÇ‘S+ójÀL!™÷ª¡G(Ýj5—*¬û‚ÍEÈ5zv\4'èBâ À€k­u® ËÉogéÞ‰ "š«¾åâ„J›”ïu×}Oì“´ß~ùÜ»Zùù•·(O)V;Ófè [– ¿ßϾqÕUeÊ}®Y¿•s/¸Â:FjÁodº/;ú_éÞVzl¤ŸŸ9å(꫊SBo®bó6³/ ?R�ð…MpÙ �Wà“GÖÑPÎw¹KÙË\ž½U?Ë·ñ�‰åT–‡<Í�,†æ&øš‹eo{_V“ÿ_Ã;]U­¹Ç¦*Üý}”ÌÞ`~ ÆâNáÏÝŠßž$Z7CÙ÷ýµ›yæßîêŸ÷<¸ˆW^}×bÁø(r¨Bý…–¢¯M�P2m»á2¦MM¼$Šßï#ðS�QS]Î�º*ê«©«© ¢Io_‚�»ZÙ´e+×lâÍwÞgÙëïÑÖÑ5 {=jöîºþ§”—™j_ûzº6›­ø2®è° >ìX…nmwK;�:�Tª0C×ÿè\¾yþ§õ¤5M£§e][R:¯È’�dÇ{ë;øúUo°m—'gÀ6 ‰6Ù›<€s€^ç8|j³&Wº2õ£ÛM¾|~ò))mÂl«%ÏчI%—@Ji^³ŠP*!ÒÙ9kÒNa¶´ÇR昸—[{û¹‘kê—4α$ÔÀ®Ý­\ð£ßÒÝÝû‘)€M[v0uÒhÆ�1Ýé“èØF–Õ -…–ê&X:LYc|>Éè�:c3§�gÔðêj*(/‹SVZBUE)Ãk™2qÇ9ƒO8Ÿ4g™E#!–¼¶¢(Úñ`ÀÏ©'¡÷-“¤¯m=�.T÷é°*Kƒ¬XßÁJoѰ½Ýø‡ê|½8Áü;jF•«¯cx3V åþ·¯Y 9 `�0u©> ~m) Dš¥¥˜=Ô§9Í~‡‘)\° açP·‡ÊFâSVP�;ï]ÈÎ]mO½¾D’?Þt?íÝ@°No4j]í»¶Ó±ù´=õòý]VcÚ¤Ñüä{_â­߯™ŸžÛïû½ë¾'xÑ’×/„$œ«¥œ ѱÉÂA �¯žû©¢®ùÖòµ -»ôG‘�ˆ øÍ#’–Rx)§Ì©#ôÕ !‹*py&ŒŒSS6ÅU³è5!Œ¿E¿oMÓ4÷‰£9A€Âm.5DÔ¤êûk¨�5¬�.¼¦³W˜—ؾ‹ÉŸûÄ&T>Ú°ô{ØÓÚÁ¯®½‹ýa¼òê»<ðèsJ ´HõD%R‚Tw3­ï?LoËr2éÞá§ùcü�I™Ü€šË3‚ºšJnúÝ�¸æŠoSUYZô½v÷ôñÛëï¥Ëb5¢uøcõFˆ×#?@Ë�ìR»M�8²(|b{ó6X:ù…š¬`ãVÔܱy³jÕËwÈlC&?@§gy­þ'QOcmÄQï/¥´˜ê^4_*Ͼ—ïïV`-Iu¯�NÞ~¥ Wß_˜YÛ>@…7ï *©½’rì †ËG*®lÿîO®áíåkØ_Æâ¥ïpæ§çQV3V¹Z¦�to‹c‚§ºw’h_Gº¯�TÏnR=ͤºšIvn¡¯m½{VÓÛº–d×6Ò½{ÐÒº° {È~Æ”±L�8Š—¼­X!y�¹MÛ™>yLÎmB¢i©ŽÍz¦ £„Xÿ#“J.mqa|¼´ô]6n)\{1çÐILÎÕøItl6Ú°‹‚K”°äæ”–_°h™'_@)ð  ˆ1€ë¨­ 1¢!êbñZb긥ßbs„§ÉèLûdrÛòÅ[�—Ùë ëëÈàÈÞÓŒ4_a�¸¸ŽDé²ò›÷¯¯r`”HÕxeÛÓÏ-å‘'^d­í�\zå�êĨž’[éáÏt’DÇ&úZWÓÛ²ŠÞ=«èk[G²k;é¾62‰RÝ;ék]K×öetlþ7�[^´­Âú ;þ˜™ÜsÓeD#¡¢ï÷ÿþâÏd2æ;•6A®£’°¹‚úµR=ÍŠÕ ø™9mLQ×[ú†ÚýØŠå†Þ�á¯OYOUY^ëãtC6?pP œèµqDCŒªò�ã[j¹má0Óµ~ö¥wMȱ¡¸ÎgmÏY7•‘–‹ÃZΩ‰‚~«ÃZynÖ’T$„ôp!4¢µ3�¸±>::»¹þÖéîéc ½Â“Ï.QBmÑúY*âlˆèHzt}Çé^’�[éØòÝ;ßr¸“Ç�äï7^¦mEŒíÍ-ÜýÀ"Á‘Bå# ˆG˜]Tþn‹øý> øYxWÍHô…⊸ ) Ìo©HE$äãôã†ä»ä‰†l~à À³ó„ß'˜2¦Ü=®_D�yÍÒŽÚKˆ„»Á”{‘j“�ì¹4¼Ü„W¢�¥µ•Ö�h©#¿_ØB}•KC=wÝ̱×ÇSÿZÊó/¿Áþ8º{z¹îæû�0�þ½ý‘% N`Ôö\TF䢤ˆ…+ƺaÏÊH'Ú•IX[SN(X8_wT&§üÖ­}N;÷;}nRL6÷¥8ÏÓ4( 2¦)ê0ïÍWxû@ÙºzÏ9áÂ)§@Üûðiî6‚ŠjËE¸� u—fSFšft¯–YBhHD/±Œ=mü溿‘Ü‹—ôxqÉÛ<þôb#⢂áŠq@ÐKd£1ššHä±`ôµ­¥kÇëfŒÜ8×¹ æ3cJqfù{«7òê›+-à\ ¾P™e~ Këv#š‘kø¡ßOey\É+ð»÷´.‡iq8,!™¿ºÕwîneçî¶œÅ(„Þ]]u-oNÓ,V€^ßá“ʼn�’ºìùéw+5ˆ†}ÌšPŽÏ³ ACV?àjû„ƒ>š¢yWc%¶é’>«Ù¬Bé™ùg˜vžÇæA ”óeñMqò0ô¢,ç&ï€æ}ZNÁèû…+'*'Ͻô:wüýqÄñì¿_åŸ�üKù,V7Ëö,¤ *\Þ�–× HõìR25§OS:ß²§ƒ�;[��¨û¢áhê* :yEWÚ—÷¹TØ PŸÑܙՄ¼SƒÃ„Ó¼öojˆ�ù- ¹f$oáÕ0Sn­¥VÐЙ' TðÐÆ4ä5w4·÷ 9ï1ë{9!¯~~†Kàd QþË�\šN±®¯Lâ¶ö.þû×·¸úí£}‰$¾íAZö˜Ä!þH%¡²‘ÅY�E-�F—Ÿî]¦ÏDÂaÆŒ, +Ë‹+êíK¨…^n³4ËÕPWÚÎîºzzû­ÐÒ®Š¯8ŒÛyÜ©GÖH^¢|è•F®ûV–¨.3ÐÍuù'ã%H^O@ó~0šñ/cšŽÉãjt÷DÎmq^ÔÖŒ*{~Íš=èV¬©VŸ0}ßHÕ$Âe*GÞ²×WpÉÿü…ƒe¬Y·™ënþ‡Rÿ®�ð…\A¿B+ “OÕÀÍròçr5vv(Ô÷�ÞìÓzíŽÎž¢Š�Jã1“Èd–Gº¸(,¨Ñ€±M% ­�ä“ëC(¢Ø¯˜'ŽòÚXU¢,°iT\‰•²` ø§ryeWJ‰=mV³¬nd nåÂ&š‹ºËZf±O¾%ß2ݬÍebjNƒ#T>ŠhõD•Þµ‡Ÿÿ¿›?ò:ÿ}=®½á^–¯4©Á¤/dô4Äa gYØ6Ö$"ìÞÓ^Ô¹¢á� žã>ü‘jeNìniwÐ�» ½c°ÕèõÆy#n–ƒO Žœ–7÷á(Cv÷‰8ÜÕ4�‚¦úˆ»£öq¼q-ÇåŸ_1 g.�]¡(ÔßùL¬¢�Ü¢Í4ë6´š’ºC”/”H$¹ö/÷ñÂ+oq0Ž ö{K˜B¥#ÌÔ[Çsó蔄éBY?ö…*mքƺ ÛŠº¯X,báhÔôÎÂs"oP˜�›w8;—1aŒÊ˜œIváÝÈÇÒYª +`Ž“fçmZrø¾R x4ýðIh͈«ÙVÄâR m+¼f׆Yâ&‘ßµ·åï*DŸšKê�ËãpRyK xdÍB´9 ÂLmõK)r¨e}ÜyßüáÆp°ŽÅËÞåîûŸ´íi„üÔUG<}uÌ~=yRkèO³ûðš»Ô4ÍjÖTcMÝ/#¤ð—ÕFÖB ç-hv ¢X#6ß�ð)m<0®¼à‡¾ÈÅ¿¸žƒyd2n¼ãa#ó.»r—*å²ÚË‚V”�0UX8¿únQ„�%±V°PƒLª;§¬­J ¦Ì•d2É«Eö œ=s‚f’=@ÇüXˆæÒÔ¤´ÄϘ¡±ÉnÀa^†Ö†ñK“;_�á›àŸ·ÉT(F`ÿ[êùâšrì®E5Ecâ n´\Zö¼ÂŠ_Ø|Æ"2B*†ÏÃV ¨7Þ^Íw.¾ºèpÒ�8†N$áµ·VòÐcÏçÂnB‚e#Š,w‡ÎÍг}¡2Ç3½ç�EEÝ×�º*†ÔUZÞsÆè$ìŽÙXçÄÎÝm¬X½±à5¤”ŒV—;6“îÓ)ÑŠ±}E! ‹‚òKfŒ-�ìöGœæ©†Dó&úˆþºÑ–£ÜŠÂ•ä³€ _T²ƒä¿GÔXs¹¦¦›ÿšðQ1ò‡Ï»nÃV.¸è7´¶w”‚‹Åøþ÷¿ÏO<Á%—\Àn¼�Í[M:+¨Œ@¼ÑÅ]”E˜ÁRïäkaØéîéãñE‹‹SõU¹vçZªOI(ÊÍ ÄÑŽí±§‹»Æa3Æ)!ÀL²›LªÛœ�^B. -‡ê~Ÿ`̰’Én±  2ß>£‡–\Á5­ˆ¸»#î�5sŸ g*¯óïb´m~M,\R~íX‚›‘�~ʇI R­ÜÒî–6~tÙu¬Xµþ þsÎ9‡7Þxƒ«¯¾)% .`˶�üþ†{•}#•ŒnÇn˜‘w’P VG¤J�¤üýŸOÓÖÞUÄûL?R©æ³RŽYËú�ÿ IDAT‘C¥Ã B»ÿ颞ìéãZ˜Š2ÉN4£ÑHáÂö|Ûœ]¦‡ÕFˆ{ã’,A…À)^ü>AUEÐ&Dš"XöˆzÖü6Û„ ‹Öuk«­b M8�E­�¾ðOe.‚šLxµ·¤ [~Ê'TÚè8ö[ÿ÷·JGß{Ìš5‹wÞy‡;3ÊÊJ¾õ­o1nÜ8^xá…Ü>7Þù/,~Ë|+¾‘ê©Eøû–É(!6D HíÚÝÊ_ÿñdQ¤)¡`€ã�QÉs›l. |Kê¶Å¯./Êü˜5c¼…ªL#Õ³KUtL vã+¨©j%~ÊÞ(€‰^j+CýÒ�~[…Ø+ƛѼW÷ü&¸z”k2‘M3˜‰:nh¢�¸=-Áù�r«Â©1�p©3 ëÌó.á‰g^9h„Þçó1kÖ,î¸ã–-[Fee%úÓŸ;v,×]w�ë1?þ埠.oÀ«wµìiĆŽ�Ad¼çÁg–Ÿ|£¦ºœ�;ÔrÎ É®í×0­Æ­V&ËMw>ZÔ5F4Õ3ql“z�î�E~Å�Ù«ÇÕV©©H†‹QÇzm¨«ceɵÉF¦wZd·<¹†¦Óòª÷TPM3Á?×z!í”À9³¿räñKÔÝ=½|ㇿæÙ�¨Ÿß1jkk¹þúëyúé§9ýôÓùõ¯Íüùóùæ7¿IKK‹çqËW®ã¦»V>‹ÖN÷@|Ô÷,iÀoÃSÞyo¿ìG›ôsÎ5õ|vˆÐ(Èѳܻ[0Åz…ô°æ´´¼òG©y,Á’sM�ÿâ×·p8˜Fgg'Ç{,ååå,^¼˜Ÿÿüç¼ýöÛ�K¥ÒÜ~÷£lÜlòêK”på8+@-ø –�4²ÿôÑÚÞɹü’Þ¾DQ÷\Qç;ÿç³ ñLÏîñòGëÄÕ$ž›î|Di,âéû}Ì;z¡P 7‡“÷é³w³ªG6Äò‘…N¨È ÔW…ó®Åh¶–^VpkA°=eX©ãÂU™h¶õZX;ÕÏf p>ä_¹‘#½´ ¨ŒÊÇŒÕ:Tõ÷~r 7Ýñðoê×ÔÔpþùçó‡?ü�††º»»¹ôR½+üìÙ³9æ˜c<��3g›6mbâĉ9+àî>©†KG(¤9Üø_KðÕBŸ+®¾�õ·ý=~ò½sˆ„Í•2ѹ…t¢C™1B‰9LYdV­ÙÄÂg—•ýW‹pƧŽQ,Öî]+<&¼–¬ìL8¬6’� $¯,çS3ð¨)ø‘°/¯(kýòlŠñ�”€|ÑCä]ß5WKÄÛT³ò‘ jÇœD0R‘¥ÕW–Þ>>ÿåK¸÷¡wå�ÅbüñÜÿýìØ±ƒ›o¾™sÏ=—3Î8€{ï½—gžy†ÒÒR¾úÕ¯› B(bÖ¬YÜsÏ=¼ôÒKƒAŽ8âˆÜöënºOm�ŒÌuB²cB'Ôô™Hw"‘ä¡Çþ]ôw™1e Ÿ³öÔ4úZ×*á?0š±¢¹©�J¥¹ï¡±aÓö¢®sÂ1‡PSeVy&»w)Àbo&¬As|ÒT%àÏÛ;pÆ@@ÕDñX€PP"òšÒ¢àz_¬VÔ�¸V×XϧyÕ¨ ]>ÆûÚãì`a«IݸO }jfwKüð*ž}áµVø'MšÄ-·Ü¢E‹˜={6×]wç�wŸûÜçxòÉ'sz饗’J¥8óÌ39ú裘3g7ÜpË–-ã”SNáòË/稣ŽâÖ[M_½­½‹ÿùímÊ5ÃãlÌ<ö7d*‡í;[èí+®QJ àçÂožEy¹Éý—êÙE²«Ùvý±J†(seÝÆm\ó—âSµvá—Tügç[Þ+¼çbW�éÙÍgø Yvw[ò\i˜·ðçJ/�L®õŽ(dêS¨×fn!44$=‚ …·�€ÍüEd /� eÌÜÿŒR"|Êj'RV?ÕnC°fíf.þïëø÷Ëo°Â?qâDî¾ûn¦M›Æå—_ε×^K[[ɤ3kqÙ²eÜqÇœþù\yå•,_¾œ3Î8ƒX,ÆÕW_Íï~÷;¶mÛF:íLѽ÷¡E|þô9iîa9¥­�Aç–sB¢§f;W™Ò’(~q�¾pÆ œrÂÀZ£¯u�ҵح%R3Eïi\1•Nó�‹~WTz1À§CÓPN%ÚÝÁ¿"=ýüóUuy+âbÝ)/0l À$À•d­¼4H¾ÒkÍtké¥áÕâËþ¥52–NÙT]ÍeïÜvMËû(•Þ€Yð‡âÔŽ<Î~¡à�K^]Îùߺü€þP(Ä%—\´iÓ¸à‚ øÙÏ~Æ®]»\… ¯¯�Ûn»�ææff͚ŗ¾ô%–-[ÆèÑ£¹ð ټy³«ðgÇO¯ø3�ݹgˆÖ)ù÷^¦[Ey©£ØÆmLŸ<†Ë~t¾Éâ+ ÕÕL_Ûz“áIˆÕÏÊõdȺx—^yKÑáE¿ßÇEß:KY=z[VA&Y„Ë;@(P¨…Sy¸†,÷[xÒ«”—�Rñ|Í!,"?¤N>ê>7?= ¾ -ãÚ[Л9ء̻“�ÿ_Ó R>”ú±'*©s¸ÿ|ø_œóµËXùþÆNèkjjr“©¶¶–³Ï>›%K–pË-·uüË/¿Ìã�ë$¦;vì`þüù¬]»¶¨cW¯ÝÄŸ­½Dj¦‚ÁÁ—½¯L²Óð¥Íq鿜—ž»ºªŒ{nþo*+J-î`†Îm/+®ï`gúç#ÏsÛÝų~åœS5ÜtÒ}í$:· ²\ög¥/¢u˜mÅl¬ÉÛ´´¿ @�§Óh,ê·pˆ½œ‚j’�’S`ï$,¤ 4º‚*�ˆìi¿Ò¤rè,jGÍň(;uw÷ò§›ïç;?¾šöŒÈ³¦¦†«¯¾šo¼‘aÃôUwĈ477}žT*ÅÿøGZ[[©®®æÇ?þqÑǦÓîþçS¼¿ÖÌÆ¾‘Ê ~“ɤItnS8g͘Àm¼„òÒ£•¹�ÙÈý·]¡äütn}9—’›]ý#5j”lŪ üò··+œþùFc}5_<ãD#óO¿ßDçV2J„Aæ™kÂ'è‡rÈ•3dYô(3~\G8(�|^a¥Û±8Ô&õµk«>W_(½ƒíÙ»6@¸�‰ Oç×÷‡®!|Abeèh˜B0Záx 6mãwú;÷>ø4â�Ròõ¯�H$ÂÓO?ÍÿøÇ\ÏÑGMSS«W¯v€ƒG}43fÌ �Nóâ‹/rÿý÷³lÙ2þö·¿qÁ°`Áþú׿:޵^÷Š+®  rá…²fÝfîºï ~z¡¾¢ ô2ÜDûFƒøS_ mk •�Âg©øÔÉG1eÂ(xô9֬ߊßïcÆ”±œuƉF^}îmYE²c‹*UŒ¨§·�_þöö¢Q€Ï�6—)Gæ&\&ÕCÏ®wÍIœ7|è‚Á“)„j ­Í«²òÜZ¬­1x—$‚€_ðùùMŒG r<éBˆ\–’”2Gèan)„åós‘å}“)¤ú™A¼áË ¨4·KŸõúç�2›Ó-Ík&½Ñ²O¿O$Tþ+o |È¢¥ 9Ä6kìO>»„ÿwÍí¼·zò¸ì²ËøùÏΚ5k˜3g===<õÔSqĬ[·ŽüàôöörüñÇsÆg0zôhÇ9–,Y‚ èììdåÊ•TVVrñÅó›ßüÆáÿÏŸ??gqlݺ•‘#G’H$ˆEÃ<ýÀ˜4~dÎEëm[KOó[ƪ¯;��X=%�G)Ö�» ©Ò“ìÜJÇÖ— “Ò]J#¡lä'ðYÌÿů.ç”ýüÆŽÊÂ{®¢¢–ú±'˜ÂoW^sßøÁ•”ð—––RQQ�ϧúÌþóŸY»v-£G�æ¿þë¿èìì䪫®¢§§‡‘#GòÀðøã�sÑE1zôhº»»Ù¸q#+V¬`Íš5d2fÏžÍ�7ÞÈž={øå/ À÷¾÷=õb¨p8̱ÇËóÏ?ÏÂ… I$\|ñÅ466’HèH|Ww/?Ë’¢�<\6 _0®¬SÉ®mtn]\„Yl†Ò’ÝÍú1™”ÃÒ¶~�=µ¸_Ïõ÷¿úŽ"ü™d'=»Þ¶MR÷U>Yp¡1»+R�×ð”g/¥8*°hÈÏ´ñåÄc÷U<»b ³½±°´]¶[Ù/,dö»å`éÔ"}˜½8³ûIËùõ^f™ˆ”û°X*ÂÌ2B }~ÊjÇ1tâI„cUú½X¸ØSé ¯¿¹’s¿þß<öÔK¤ÒéBðçÏŸÏ7¾ñ ¾þõ¯³`ÁŽ<òHvìØÁÖ­:uVgg'>Ÿ�ùóçsÔQGqÇwðÊ+¯°víZ***�RÒÜÜÌ›o¾É}÷ÝÇ 7ÜÀÕW_Íþðþþ÷¿³gÏæÍ›Ç¨Q£X¾|9÷ß?óçÏgìØ±ºð%“üêW¿âª«®BÁ•W^É…^Èc�=æ¸×µ¶2qÜ&Œ1 i|‘Jíë-"®‘I´“h[� – m˜Ik2ÉNº¶/ÓRK9 !᪠JÓ�ûz–wÞ[WÔ³ýé¾dI.үݾáiÒIKG`KS¸ ¹w>LÞRu—㤄¿<¸Þë�và ¹X`¦á8Z­T–ùÂ)MTW†uwcu»Ýmð …Üç¦"‘Y‰”’Xy#Õ�S‰”ÖåŽ'w.Á›ï¬æŽ{çÞ_Ö¬Yüæ7¿aöìÙD£jrÍúõë9ûì³y饗hjjâá‡fúôéÜ{ï½,X°€ªª*b±™L†®®.Z[[Ès0ä–[náì³Ïæ¹çžcîܹ|å+_á†n ‘H�J¥())á§?ý)wÝu6ä·šF6 áéþ@u¥ ?u7¿AoëZKq�y¾P%þX�®ÐsR´L‚d÷.’�[ÈeÍi¶ïÆ÷(sš%ü¿ýÓ=\~õŸï§NžÃM¿»ˆ …ö»wÏ*º¶½¢˜îzÔÊÀ”\2ø44£4ÖÍ Ö<Ó�³ÏÂz­ÞDš™ç>K:ãzÌÃxm¯]€€Oà÷ ÅÔ×,ü{ÅÔìÙm}…8Dsã4Ëͪi6}èÆ1˜Ç¥ˆÄki7—Æqó—Ö9nrç®V.ýÕ_ø¯o_qÀ<çÖ[oeéÒ¥Ì�;—|�1cÆ�F9í´ÓH&“Œ1‚Ë/¿œx\7¯7nÜÈ]wÝE2™äÔSOÍåõïÞ½›�7²yóföìÙã:‰ÿüç?˜:UOŒzøá‡Y²d áp˜E‹1dÈ®¸âŠ‚ÂzçÞën¾_œ«&"ý×ðt_ ½»—Ó½})]ÆOwó›†ð{PSYæj&¡²2ýÇü# Þã¤qùìGçé?ÙûH´Ñ³óm§ŸïlhPd°Ty 3t—Æüýv¼@�F >CØŸ¬æðõÝïXxñéy¦VE¡ ZÞ‡ã–Ô£[]’º‘G0lâÉÄ+‡ën…íT½o!§žõnûÛ£ìØÙr@ÿ‚ hkkã¼óÎãÙgŸeúôéœýÿÙûî8©Ê³íë9SvÊv¶²ËÒW¤÷‚ÁŠŠ‚=5¯1‰1ñ%&úÙò»±DÅ^"Ö!‚ € Ho»ì.Û{™�^ÎóýqΜ6§Í² ¨Ïï‡ÈÌ™3gÎyîvÝ÷}ÝW_�Ç#àÓO?ÅܹsÁ²,Î8ã L�:UøìK/½„òòr¸Ýnüá€Íf3ý½ñ\CCƒ�B|ôÑGñ‹_ü_|1Í#ê”R|ôéìÞW!K :²OJØ®”RÙÕŠTµ­BD Ü]+ûþAýûâO¿Ñž®�êvâÑ{ÿƒô•àw,üMß��úTÒw„²†Õ¨É.µAzV æs&#ÏI+«•‘×K’è!€‰*Xþ2«¯�NI ~ƒ´ì(�|² †ƒ±Ødw4‰á@Y®¼éÜõ/¢±©ÍTØeù|ít8ÆSO=…]»ëÐW­Z…Õ«9oæ‚ .^ïîî¼™3gâ´ÓN3õ�)))BÈðÏþSxý“O>Á–-[zô;ªk›°è£a*`Kí›ÀÏGSu QŽ�£†Ê&Ø~€ýßÛÜùû«ðû›.EfF*)vØíV8Rì˜4n>]ô¦O%³_ÁŽC¼"!Ù ½¹€:EB&ª•Õ€nGï);4j, 7‚)�)ê-¿je¶ª7‚hYr’p+5­â‘žT„¿wf_ô~Ї� ‹5ñ>”®Á#O¿…óæý ·ì>!~РA˜;w.fÍš‡Ã�eË–áí·ß†ÝnÇ‚ bÿ8(·mÛ6~³Ëïûï¿�µk×"==·Ür‹¬»Ok-\¸—]v>ùä,Z´¨×~Ûk‹–bç^±Ž€±:�’98¡^¾Ÿ~SM|ÿx¶(p þö—_á‹ÿ<…ÿž¸ÿV,}ça¬üðqŒ5Tu×Â׸ÕÀ#eÈMŒ¯Q_bDþ ù1.Í]X¡ÑÙËè|@Óå³Xˆ©‰=ªUÀº=r1§²Rc¢Þñ§¸ RLÅáÎFñI§£dØYHÍì+ÿ„?ÂÓ ßÇ ¿û?¼¾h©81öº†ÁôéÓ±uëV:t}ôV¬X�ææfÌž=ÿûßÑÝÝ�SO=³gÏVñÞ¬4ˆ«¡ï½÷„é®»î˲˜;w.&L˜�`m- rrrpà 7 ¥¥7Üp–-[†›nº >_ïUBF"Qü¿‡^‘{éý`såhZ@£é“jŠ‚‚Hw B]‰èù …¸ø¼¸vÞ,L{’ü»(Ÿ’ä—d ”Ê@VÞ¦{9zÛZñõ ‚^z^K¦“W< ¯ï'é: zbhŸH$”_\�M¬ëç= J ˜Œ�£ÎCfî`0{v°|Õט=÷6<÷Ê¿QWßbx‰™™™˜9s&n¹åÓ�h½µÆ��Å‹cýúõèÛ·/Ö®]‹•+W¢¾¾iiiX¾|9ú÷ï/ðñ=ýôÓp:Åjn·Û�ë®»óçÏÇ–-[ ‘Ÿ/?·oߎwß}ðÔSO†Šõë×£¬¬ hiiÁ믿Žîîn,X°^x!ÚÚÚzý7³u^þ×É“�OÒ÷8 %+C ð6lA ý ‰H›{=ؾݵëAÙˆ©­­ßöJ5E\{©*N€¤€.% QRÐÓ^ ¦¨ßnqPIsŽœK>®Ñ¤fôÅI/CN¿1°ZåÀg,Ceun¾íïøÝŸŸ@m}³)«Ùe—áÝwßÅ矎… âÑG…Ýn?.ÿ”SNÁ‹/¾ˆmÛ¶aÆŒxðÁqê©§bæÌ™˜={6.½ôRìØ±ð /`åÊ•¨¬¬D¿~ý°`WÕ6{öl¼ýöÛxùe®ØfòäÉØ¼y3/^Œ›o¾YýÂá0^~ùe´¶¶bÒ¤I¸ñÆQVV†U«V�aìÞ½Ï?ÿ<~ùË_bòäÉxâ‰'Žéïâ¹wÑØÜ&ø”› ŽìaIlYªò–ZE^ þ¦íè(_а·ŽÆF¹™�4Æ�‹!Ü]‹Îò%ð7mãÇ“›}áïÁ¼J-�Âj!HV#j}b>€÷ÕÞZ’Š+Ï/Jo"”ç3BA#þ[Q ¯ˆ×ˆÅBŒPÂ+¯ˆ1ÂqÒ²âø{©ùÈ+‰¬¼Á’BL©®iÄÒëñÚ¢OáõúMmÀ©S§âá‡ÆäÉ“eÖàÒ]óçÏG0,X€1cÆ ‰àóÏ?Üz«ÕŠ×_×^{-ª««1zôh„Ãaäææ¢»»ÝÝ݈F£8‹‚[®¿Ýs‹°Ç(�çÈ×>•Ï|’Ï   À9±@žI�Dí677Ð…XùÌ‹h°lÄËåóãtwT=n—�SRʬ]¬‚�±¬¦E%|i±òÚoyô;¬ÝÖªu;¯W dʈé¹4,MlÖIÖÐêãWf¨„ªSuBP4d �š…ÌÜÁÂ�ŽDðÚÛKpËŸÁ3/¾oJø ñÉ'Ÿ`ãÆ�8í´Ó„.ºè"¬[·ÇL"‘/^Œ––¸ÝnœsÎ9ªnoü›Í†1cÆà¹çžCkk+ÒÓÓqî¹çâƒ>@FFî¿ÿ~¬^½ï¼óÆŽ‹»îº 6› çŸ>8€óÎ;ÑhÏ<ó < qóÍ7# ººÇMøãòñ²/±}çA±¢Ž±ÁÑçd³*D÷ÜšvÄ‹ˆ·aO5B]Õw×€�xuŒ¶ºð�æu3nqO¨Â]o6–L ù”c,ËR¡Öi²×àØ�w&>ª˜á'ÀéÎ(D^ñhUtÿÛûqÙµwâ‰.’µœj­´´4Ü{ィ©©Áœ9†S•0iÒ$|öÙg;vìQoö¼¼<œ{î¹xæ™g°råJ¼ú꫸ð ñÝwß ¥³÷Ýw22Ô›4«««yÛŽºº:,\È ­¨¨Àßþö7x<ž„Ï<üðØ5k:„ììlü÷¿ÿÅ£�>Šòòr¼üò˸æšk޹›o´šZÚñÚ¢¥K(Àì©}auæ;U¨€¨&ƒ¢÷š'Ì.R©tæ±FˆÒ£õ…ô€&]�ÚÄ» "ÝmÈ!°ZÄ@^óÏ—àBä×—–éBÑ «ã'í—0”“ð:JÖ=(ïàpòéHq¦Ë`›æÖüí±×ðГo µ½Ët>âĉxûí·eýåf¼…3Ï<‡ÂáÇ“~”.— 7Üpž|òI,X°&LÀСC1~üx\yå•p8xÿý÷1gÎäææ" ᫯¾’�#Þ‡ŸššŠçž{ß}÷¾ýö[Ì›7C† A4ÅêÕ«UïÃáÇñÙgŸ!##cÇŽ…ÍfÃ7ß|ƒ·Þz {÷îýAd?vï?Œ3gL@¿¢|^È, ŒaoC‚A¥œ� æC!#DI0Ou=5eAATK�5á~�gƒˆ¿üýwVÔ ¥3¬eý(3ë„´<€«ïØ>îѢܢª…&¤é~°T©ó/ºÓE<�añ²/1çªXò߯’·6M=ãp+--Åûï¿�›o¾9©Ï]qÅØ´i^zé%dggcþüù=z4Æ��ÿü‡+‰½í¶ÛP\\Œ>ø Ã`þüù2dˆ,CðôÓO£¨¨kÖ¬Á¿þÅÕ²û|>a8ç­·Þ*¤ÿÔVEEn½õVL›6 ^x!¶nýá�/ûËß"$™`K-‚Í•íüº/µFœžd�¨Ê-Ñ9oòH¯X+¤ñ‡4#÷/Ó¦CÍD£Ñ(«‹êS�" ÍûNµ‘R‘ˆªŸ‡Š3 kë›ÑÑééÑ&ëèèHŠ Gº²²²ðüóÏãÎ;ï4<655Û·oÇ{gàöÛoǰaÃðá‡bÿþýøî»ïpÙe—aÍš5°X,¸ýöÛñøã�£££'Ÿ|2æÎ� ¸øâ‹QQQ�«¯¾Ë—/Ç\ ‹Ñ—/_޵k×"%%>ø î5lÚ´©Ç¿ÿX¯�{ËñüëËBWÁ�-¬¡ÑÁÔ ÉIjOMŸv&ÀHé‡(4Á‰Æ(¼~ÍÈ]Ó ëyê Æ"cu'ëq’d”®Œ2‰BÐ!.¿Gܰ‹—^x† *î1è¯iWõ~ Ú€m6yäÜwß}ªÀa|y½^ÔÖruè;wîÄ믿®J¼yÇw€eYLœ86›Mâ[o½;wîÄâÅ‹pÛm·á¼óÎCHA•ÝÝÝ�çž{�@óæÍôiÓp"¯¾òWÖ ÄXpå�5!ŒêÂGˆ™ù|4ѧGOB÷ä@@C¡â™wùt@R@˜ÿ“($QŠH”êº"†RE‹ÆÉBd¾!Hq¤Â�š WZÆi¥@uÙײsõÍÃ%œ!Ж'«ôâø-[¶`Ïž=†ç¹ÿþûñì³Ï wjë®»îB(ÂŒ3pÕUW©nƲ²2<Ȩ >o¼ñvî܉’’ >wÞy'f̘!«ÃW®/¾øë×sC4.\·Û}Â*€öž~éYí†=c€ŒÕºñ9åv-wÝÈõcY#q& Vj•ú=>€‚D£T/  )ÏzY€¨ºd�Qù¦üâÍŽ‘·ìJY}(œ©Ù(: #¦ÌÃÈ©W`ä/æá¤qç£OÁz Ô IDAT‘«@Ð׎Ζ ¶pÃÕ`ðÀä½€h4ªËhëõzqå•W¢¼¼Üð\7Ýtþûßÿ¢°°Põý={öàÉ'ŸÀ1訡û„¤¤pÅLGŽAGG^xá°, «ÕŠÅ‹ äZ«««K¨߈Mßî‘Ü# Ÿì�ßLj¾3g’Ž¥'ª†R7Ž6¼Fù1¿nU¢¦<'íD£!°AÎõ/”ÐJª¥EÏÁéÎÂÐQç pÀ8Xmbz/53ƒFœ…ü~£xeΑ"4×ìB4"z8 ÃàoýuÒ�?‹é†Äž={0gÎÔÔ§g̘�åË— 9Êõâ‹/¢¦¦Æ Ãõ×_Ÿðþܹs1hÐ ¬[·Nð–,Y"tÚéY~éZµjJJJî¾yµwxðÒ[KdCAmîBØÜù&Œ¥9%Aˆº¥¥"¬o|°|í Çß—(üún„ \ ­º,ÆI{�Pa€p”E0Ìj^“€^jd`t©Ä)À  ?ÎÔ ½A�[t2R\¢ÅôyšÐÕZ%;~ìè“pùÅg%�¤Æcsµ•���üü|ìÛ·Ó¦M3Œ3Ë–-äI“Þ«­­¬óƒ>ˆœî7çææâÏþ3Þ|óMTVVâ®»îšmšššðÆo ãôÓOǬY³ ¯!˜RX'ÊZ²|=ÖmÜ!Š%±pœÄbÒÂë7 ©gæ(¤™*ó!¿Þ0íyɶ¢×6ôÞÖ”g=Ð¥å�CQ¨0nÈ+µø€a3�žÕWöjUuB¡°PP™âÌ@Ÿ‚RÙ�m¬ÚŽ˜Ä þ÷÷×Àív&u#[ZZ4­¤Ýn¸ókkkqÖYgaåÊ•†ç,--J‡• ç£�>ÂÞ½{…qZ¿þõ¯±yóf<úè£X¼x1æÍ›‡¯¿þZö¹ýë_عs'RRRpÛm·�Ðq}Oןïþ@PØ6Wì©} ã}\BçE­¿P�JŒ U�Z¥°îD{ �¾�˜%¨iÒ­píÒRz#~/�ÆH¡â'J Ý`I ©ûgø‹”I~pbwl^Ñ0ô/�& ¥µÿûÿžAUM¦O#üð´Œt4F,âÊ,øÎ¬ôìb¡ÈÈn·ÁáHÁ†M;Lo¬ììl\zé¥HOOWõ6oÞ,4ßø|>¬X±¥¥¥6l˜îySSS1sæLD£Q��àR�™™™8ýôÓ1räHÌ™3¸îºëð / ²²R«¨ªªÂµ×^‹’’¡=ø§´:=^Bpê´1´ºòê¬äšx’pùÕ­³²å]¯V­ªtÛ ¥ Ÿ3ëóý¯zãÃU<„O×7b_e·ÖGvA¥@ÏÍï®î(XJurú:?ZÃþÛ©è_zJÂëO>÷ö¨À»®À. I! JN:Uö�Í5;ðµ zŸa.œ5Æö7ýè[[[ÑÚªÞPaµZâùÖÖV\~ùåX¼x±á¹322ðÈ#�àÞ{ï•Qt/\¸ÕÕÕ°Z­X±bf̘�/¾øBµ|W×/Y²)))øíoû“ô^]´;öˆ€,c±Ã•?‰M@f\w*dªRhVH›“ä V 騲êZuCMYÖSµ�ÃOb&F+Ñ¡3OåYš{ŠœÅæõEŸbù*ÑZ>ðð+²jÁ´¬¾ÈÌ"�ì_+;GNŸL\3ÿ\ØLöðwtt ³SÕ[‚ÅbAqq±ªE¾ôÒKñÒK/Ö X,<ðÀxüñÇÖžŽŽ<òÈ#¸räÉ“'›ºÖ{V­Â‚ z•�ã„ñº¼øç+!·©=µ/¬®<ƒPSßúSª] Õ°žÈHUÂéé4ºý(í]m“¦ˆò²œ´¨ÒR_lÌL©"UçHS u§ç";o?Œ{­ìp5žY(ïH®’ÇÜ%¥§ŒEPA'Úê÷˾qÞÅg›.òz½ºÅÅÅšÖöÖ[oÅÝwß­k¹ãë�ü#Þ}÷]a€Æ«¯¾Š�7"''7Þx£�þÓ[»wïÆ9眃ݻw㧺V}¹ë6IAÆ GV©&úO5ÌÀå6¨8TîóxÙšS@ÔSÂõ{ýQttGô@UO@ 4¨G¼¾ˆj& ž FåŒ��?vGªŰ,þúÀó‰¿$ÇŸ¬F³„©×bs ïÀ‰…âµn/¢á€ì™>|ÿïL‚•\&@K ggg#++Kõ=–eѧOS sæÌÁÇŒ!C†€eYÜsÏ=€k¯½ãÆ�ÃÏËxyºýxñÍOàõ‰ÐæÊ…Õ�kJ¸%°´§L Ç¿y�šÍ&u�‰ëH£_³C—Å–ž(€ýÐÈìj-€Ðp›¨2®¶£oÉhÙkº•GêTO]^Q‹e+6€eÅïÏÎ/…Ã�…xn5èkGGóaHë°†Ÿ47]7ÇÔchhhÐtåóóóU9®¾új´··cÁ‚¦À±ó|ùå—à°š8%×ÏËŒð-V®Ù, cCJz±`Œši’c‰¶Z¿úUy*±®5yµ‘ìûU ~DbšŸ 󲜴¨…1HkgêýûÐ~5¨CÉ�SX «Ý!Ñè^,_µ1�ãÅ×ÿƒöN�p2[Š 9}‡ƒ«pæÆª­`c tÓusз ×ð1TVVj_dgg#;›£¨v»Ý˜1c¶nÝŠE‹izF«¨¨{öìÁĉ±páB´µµaÊ”)¸òÊ+–n“랇_‘í2«3‡ÏFé3I›rË%�yZ¥›®6è1þǫ̈m ¦­b¼,'­@³?¶ºÑ�ÄT‰xøbø“‹Ž—ý{Ç®CؽO¿Ô6 áÉgÉîKnѤ8ÅÚ{6Aýáod�5;+¿ûõ军©¬¬Lµ9à¸ðKJJpî¹çâ�7ÞÀºuëd º=]‡Ÿþ9JKKñÌ3ÏàÀ�ÈÍÍýY²M®úÆVlÞ¶O íi …AZ`RlJÝI ¡ƒX¨Î â_4yù×Xõ>­‘`º2lFhV¹4¶!gë‘ÿEhBk�¤T˜£sº³á�TôE"Q|½y§ ÕÕZËVnÀ·Û÷É^ë?ìtÂÐÞx>�¼½õÌS'aÜè“ C=ê«ûî»ï¿ÿ>.¿ürSèÝwß55!ÇétâÑGÅ×_�sÏ=Ï>ûìÏ’�ÄÚw° Ý&¦Ü3®¹–€jå¿z¨ŒZj=Š¡±-Ø#6£hª•¶ ./ `/¥‰QBnßÒ˾îëí¦oÈÿ=ö*§,ø¯q¥å"·h„LáÔ\'#WÈÎJÇõW�¯Û-ØÐЀ®®.Í÷‹‹‹U …”k÷îÝ7n®¾újœrÊ)†MDK—.Ř1c°fÍTUUý,ÑI®h4¦b ÅzJ€jðQ}¢»„sYŽ?W_ ¨–ÝÒBKG¸G2lF¬Ðz#fÑí‹j,¢Q@Y ;W^ ÓÐØŠ¦fó³øªjêñæ»Ken[~ÿq°ÚÅz‚ ¿­µò4Ùy3OÁ„±úÄ’=eŠF£8pாújŒ=Z¨¬¨¨À©§žšPÖ …°iÓ&LŸ>]tÑ�ªfÿx¯RÉhqQ©NìOŒ’Œ k64 qüŸÕQD#š ^QUmžp�dØŒ¨Ö�=j} RHò?–ÕѼq÷?þî_}›ÔgYŠO?[‡#ÕbŸÕæ@~ÿñ²¾ƒö¦2„ƒò2É'ÿþC ÙµmÛ6Üqǘ0a‚0`CéY\~ùåX²„x±zõjÜxã�˜6mZ‚bøy%·ÒR]˜6Iôþ؈à3EúÍ?C;tFV\ÙÚ®(j(Ý`K�Ý‘˰™ò¸µÎP=s½Fdsä¦D~#©Á­uº24óÞýÉ“jV©Ç²•pë�sÁXB�™;��ð¶ò^@;:›Ë�[2„/6*Èëƒ?þæ <µPuüvìØ�k®¹ÆÔ5444àî»ï&õ{ÓM7áÙgŸÅÎ�;�ÉT�Å—„ÀéH�Ó™—Ó§3)vìvì6RR$ÿo·ÁbaÀ²œueY± –eŽD�FG… G†¸ÃCèöùu³<=YwÜzl6«°û"þ~ ‡žû/‚„ ? ŠcŒ’€ &!³§ ¡ze 1K¡Å…³g Iï8‘S45¿nvsÍdå—ÂæH®çº+ÎÃÒP^™˜%1Ó\C)Åc�=†‡zÝÝݦ�¦ÖÖV¬Y³æ{µ–ƒ£¤8…9è×7ùyÙÈHKEZš i©.¤¥ºát¤ÀårÀ‘b—WÂMP –e † …Ðíõ# Áë ¢ÛëC{‡­í]hií@M}3*�Ô£ªº�Í=Å>û¬_àÆkΗÈ‹°§ZHß©#ÿD7ö�RòkÇè1Jud„êàlÂïˆD)v–{Œd÷¨ÀbU{È¡½3ˆìL‡Ê½åÜ!·R1$!€Íæ�yÑh mí]=ÚÁP{ìU¼öÜÿ."+(ÚöÃßÝÄ}/Cýá�è?âQÒܸõ¦¹øÓÝÏ$­vìØqÂÄì'—Àø1ÃpÊ/FcİAÈé“ —Ë�» 6«v»­g'¦Ú‚Á0 \.\N²3ÓÕì.ØX áH¡páPm]¨<Ò€Ýûcó¶}ز}º ¹\4û[µ «¾ú嵇#°Ù¬˜8vnºöB\pÎT…'C¨³`£uÿÒ9“TeÇÒ$fˆü¢�ÔM5!?‰ëHƒ­]áÉn2 à+§)߈Å(j;Lz#õ;©ˆ$Z’þö„NÖßS&�„ÛÅ‘€Ø�éÈë7-5ß�çE}Ù �4„X@¤º�øÕ5bûŽýKêâô`jÝpòÉ'ÿ „Þåt _Q† í�Éã‡cöYS0dP?ùs $+a“ô¸W=ÉFÁÒÀȤlŒ�Qy‹Èò¯ à×@s{|8 ?ÀŒ ÀXÀXìÂìHm”\Ü7cGÅØ‘CqׯC8�§Û‹ŒôTY¼/~žE¨£aO•â�ÄY‚ÒwÅù0MD¤0¸ÉpIï·+ˆ¯¾kÕ»¤¯ Áœ¬ب¦ ¥=„@0 §Ãª¢Rn 'Xq£¨ü –}áDSK;^}k þð›+u“×o4<­ˆ= "!/Zjv ¿d‚°÷Ï £G¦æèc/"£ ö"ñ��ÿ‰yásul”�€R^øùtšjŒ>) BƆ õK”•Å‹ÑpHöÃ!ÈÏëƒêÚÆ£š7ßY†K.8¥ƒû�Ž**§h$ª=M `ÁRжÚÝÈÊ? W¦�š˜wéÙø`ñj¾¦ºD Ç´\·0¿Æ�.ÅÍ×^„3gLPÝ‚DâþÆÂÄB]ˆú[ ´rñ;ï†KúS5 ’tƒ¹kWꆄ® ïA ÖdÜ›8ö-ö ÖæA°m©ä©åõ]ÿ¸CO$�ˆ6˜gÞ' úÖ_ˆ#¨†W`lýÕÖªÍÍfäÕ86¹;y—BõŠ*ëýS°î4Vù‹~o‡¬Í8ù¤½&@÷=ô’ìßé}ú#-»ŸlsÖ•o�[[› ¿ûõå°óS|ä¶t…ÃaÜÿý(--•‘{öÖ**ÌÁ�·]ƒE ïÅ{/? þø¦ ¼å û›ámÜ oÝFxë6!в_£(ü¼â6•þa!çË“ðèI­·ôó=ñ¢ðS1}^HMá'¨»RøÍÉ=-ᇪõ§ÐàÆQÔþktÿéÑ‹*·Á‡Ãu>½º,À=U°A TèöEÑДiWY ÊÆË‚ÅÄJ$D,&Q¦LÕk‚´c÷!|¸xµìæ÷;étY…b°» M‡dÛãŒS'×ÐÑÑÁá]]Xºt)† ‚x^¯÷(;Ì×­¿º«>~ ~&Œ¦zþXÄË^xªVÀ[·aO5¢ÁÑ•N4‘´hÒ$I4µ_-Ö7~ªÓŠ e–š%ûЊû¥ù{jâ·Büåþu’áu'*•ußµ!¨? xƒÙ-�ŒX@uر¿Ó0¥¯ˆ¿ÝÕ./›9|p¯ ÔS/¼O·¨)-V; MeÑTù O"zÿsýÅp9ƒØ½{7þóŸÿࢋ.:f…?ãF•bÓÊ—ñàÝ¿FA^X,Hç%±±0"¾t×m€§ò3„:öƒFƒ 4 "±´ñ ÚÛŠIK”‚o‚!šÂ Ä¨†ÕWUbw�‘©Aì¯ô0”Œ?¬Ì( Sͺ讄"6 ºî?› ÂQ[öuè1yyY5µ,I<û(¸’`Õ9Wž0&�ʆÕÊð|ÿü\ i!‰ÿŸÇ·8)ؼmZZ;ze³†Â„Ba̘6ña ÎÔ>èn?"¡1P°HÍ*ÒâýŠòQVQƒ½*ðõ×_ã�7ÞÐÚÓuÒ�üù÷W㩇þ€œì ÈçEÄB�ˆt×ÂßüB�å`#^ôl;!aëOÈ…�U¥‡ÀÇ fÙ$ÎcÔ)BU¬$5;« ?Çö«jý㪀jÿ†¨‚&~–ÒäãÿªzÞXvíIÀ_xëX(hpµÚ,2Ó¬(ÊsòÂ-W C„�\‘A0àAÉ�IⱄA§Ç›@ôq4«¶¾Ó&�FnN¦ðV›]mG¸›L¸¬@jV1¬v§ €§üb4}¸õõúä =Yy9Y¸í×ópß�7âôSÆË„žõÃß² Á¶}ˆxkAc!r¯×ÞÊ�´H¨¾ì�E$BO$ Zrè6«cõÍ�z¦såÀŸTq%þñÊïy/ ªÂJ©�{OÍÝkµßõÕö|üeƒÞGÿ üX)€r÷h…ÁP ãOΧÞ.I ?>ˆðùÏÔô¸ÓúJ"ÅnÃúM;ø±OG¿�¢ÑN=e¬�3Oqf àiF$ÔÍ]媳Še”b·¡¤_>[Õ»@ß¼‹Ïij�üç�=é©¢ˆòÊ2Ð~Þº�\:ŒFÄ{¨bÅõ…„H,´ô Æ-°JË.çu ¦v±>õ1'ÜjVTq^V³…6ñûÔZz‰Äì³&Ó¡ñ¦Ý0€RÝ"cëOU<ÚÞXV�C5šž` ÀuÉìG¦{ø%­7Z‚¨o ‚¥r|I©Y¥Z·¶BÎT:¤?Æ�9©W…î?Ÿ®AYy�D€,È) b± 4æ]Í tÊ®ôŒ0urï“ý‹ °ä�GñÜcw`Ѐ"É|xîO4ÐŽöòÏàkÚ!ôˆI0J@¢;-'È1þß,ËÆ„n3鱉¤Ô”Ðóî]Ï@ IÞ©Ò¤ÐKãi×üëtÿAGùèøô £ií cͶ–Éfo*€—5ÕK±·Ü“`-¨ t–“tw5£³UL·Y­\6ç,¡ž¿·ÖÝ.”m¦ÔÌ"¤e—Èb˜†ƒ_Ê>ãt¦à®¿¤çÝrJŠóñ×?^‡-«_Å”‰#${‚Ó<‘@º¶¡£rb�(º”Š”X¬\ ­%¥Š@ —YuV�Ô…ŽêZ0ý¾H†Ëy‚ª öT(ýRßx­¿F½?SxPÍ^©~U–üª¹ÿDsî_¼¬ÚèY©óùæ&øƒ±Éfo…7gì"yªzœeQ: )6‹@±D$ƒ@Áã ¡¼þárÚ9…C×¶¨05uM8Xv¤×@k['œŽŒ3Œ‚Ô¬b´7슔bÑ« ®ô‹DÖ_éfÇÄo¢ê×”èú³Ši?É7ý]7�º‚ˆÅXܵp:½š•²{<�Y½©¢ÜT‹äÁJ �ÈÉL@-"›¬�LÂ�nddÃáJ^3r>ÿb³lâËÑ®ý«0ûì©HOsóà¤vG:¸šÓ'Ï<üGÜñ»+‘›�Á1“8G {ÑQ½þ¶h¨c…3s2œGF K CoEÜ ŒÕ !hQ$s‚.U R!ërÓmªI¬oùðŒ¶�±•×ÒÔ0®&2ä>õ§bóщýÕ”&ˆôx¸‡aêšáÖý¯ìGm³&0Þ àI©Xz(Kàj©¾é cÔÐ ¸�R}�¥ ã–Ñß݆Ìì"8Ý™‚Æî[˜6Æbû΃½¦Û0aì0ô-Èá®�a`³;ÑÝVÉÜ@4ì…+­ö”T¡gþŒéã±èË!sûdâšy³ðæ ÷`Xi ²Ïý¶hÈoó>tT¯ñƒP^¤ c±Á‘Q‚Œ¢ipd !V± ~4·tÀíq«#!Oh,"^%Ø¥RߟTjP¿‰…R­–Údßà5SÂO…<¿:ɇagUË}õÜzÖxÒ%Õáàæ,¿ÚOÜq¨ ¯,©B$ªy’Mž9^  —W–Äx…™bÁ€"nà'#­ ¢Eã€ûvµ×¡ï€Ñ`x¶ ‚á'D}C WÖöŠ†Â‚8ûôÉ`î‚ìŽt}íøjF6ÊF‘Ö§?OiÆý< Ÿ®X/³�W_> üå&\6çLX-YÊ ð4ì‚§a;BÝõ Þ�#£?2úN€«O)kŠ$*"¦ر§ /½ù ž\ø>ÆŒŠ‚¼>B&ƒ±¥"Ü]­ÿ˜M×™ËãëÕê÷@ð5�Ê\þ¸b~¦4Š}¸¼=1Ù¡n¡ß€hý@ßõ§FeÁÐ-¤zõÓ*l?Ø©'‹/‚+ÂñR°ÀmT'a6µ‡0id¬Fšû—ybÙ !@,AÀ×�œÂRáX»Ý†ÉãG ¼²¶W: ¬¢“& çH4‛3]-eÜf!‘@Ü…°9Óý\T˜‹CåÕ8\U‡_L�ÿñg\6çLô]u:MqF IDAT^ºjÐrø ÞJ‡Åß Àbs!«ÿt¤æ ƒÅž&|Nzo֬ߎ»þï%<õâ‡X»á;44µaï�J\7¶øðìiˆ†:Á†»5mŠkYscóÍ •ÙZC5¡N� —¿§í²Dáúk÷T�ûõ¾›•ÜI­aŸÔðÖèA0r¥"?ÿ‘~\©7þÛàªdÁ¿ÞPapíÁ“T‘Â(…#Å‚þ….¡«TÿCðÏ‚€¯6[ Ò³ „‡çp¤àܳ§¢¼¢•Gê{E lܼ ¿ºæB!F·Ú�ˆ†ýúZãn žzdŽ›Ղþý 1uÒHü¿;®GA^X­‘Y‹R°‘Z«¾†§a(�ʬ:c±Áݧ}� kJˆÄÓáhȶí<„ëûw<óÒ¿q¸ª� r´¶w¡ /cF ¶µÍ•ƒ`‡™Â/¢ˆ‘‰ÒŠH½ÖŸ^ÊP³ž€ðQjù‰ä>« þ”šÐwDÃúk3ý˜rñ©z?¿ºûŸ˜R\¾© ‹¿Ò­ü{&È?�…€�þ¤õf{WcOʈ9ÄÒ`%¡r�èîl€+­\©Y²´Ø¬³¦ Ž`ÿÁ*DcGÇ!èócYL™4RIR³ŠÐÑt4ã ]c®ô¾‚¢ÈÏËÆ°Òþ`#s÷£aº[ ¥âKDC]q¿†§Ç³À™Y‚ì’ipe–ôGˆ‘Á“Ï¿�;îy Mê³b1õ ­8æT¸Ý<ƒcãp3z»ô79K¯fIã9qV@P 1Ì¢þò);ê©>‰Û¯Ù©Îj~‘põëvüi¶üšój¸-�x�¼�(zó šõG] Àó})¸æ Ñª.B”Âå´ ¤Ð%‰}‰¼)H‘ànF ]m5HMÏ…“ŸÿÌ/&ŽÂÐÁýPu¤­=¤�¯ý‡Žà¬Ó&¡OV<ýÈ Å•Oëaáû"þv¤õ‹Í!dôžÿ]�{Ñ^ó-üGøºˆq~Z!²Š'!=,6§Ì$„ ©© Ͻö1î}ä5¬Û´Óðš›ZÚQ�ß“Æ C »ao=(RÙp¤—�˜sñ% ”›óÔ©Ç´®AÞO î^S>v—ÌÔÄ5Ô„?&ñ—{ýåYzt÷–ªó~¹½o}¦ ì¿àÍ£y²–^Ø;À5 ¨*ùP˜ÅɃR’ "äF÷BÒ ÃMIŠ¢µ±)ŽT¤eäÉôÀ’¾8ûŒÉÈÊLÃÎ=e=fæXfý8çÌÉ‚²ÚÝt7IÒ‚,‚ÝMpgÀbsHAÀÓ€†²Ïáï86->,VrLGFß±°;2 ËGó‚ûÞÇ«qûÝÏ`ù› ùï¥k×¾r\yÉÙBV€06PÊò^€žû¯&Èj™µ ‚¹4—®Î¬>MBð¡ªÔä¯ïö�~‰5T˜ E$ 'l5ê÷7�Q?ðOíB»GØ¿„ÏÈ}¯ `0Õ¢y¯?м>ä8D‘‚rôZ€Rt4W€±X‘–Y AÇ œÎŒ]ŠK/<Ñh˪Á²ÉkâºúfŒUŠ~EyÜw3 oGµp­±H]Mû yöµ!Ð݈öÚmèjÜ ×ËNÝYQPzì®lÁJÇ¥ûUáW¿¯¿³ �]Þ¤¯ÙÁë`ö™“…×lÎ>yŽ€²a�M¦ÌÅûú´îºÂ)£yŒ¢>Þ¼ðJ¯-üÊæ'j‚�*� D§ÍWîúSÃ{£÷»Ô‡çrëã/ëñï5ºx×{Áñç±VQ‰œ§v> Ž1hôÐ Ø,D�(ê@Å´ ä˜®¶|pº³`OqÉPs—Ó�éSÇàÊ¹ç  ?lŒab±¢±˜aŽ:cát¤`êäQ°X,B5`wGµ¬Œˆ:ð6!ämF,âç=IœŸÞ9%“‘ž?„aäéNT©Ç‹o.Ám}µõÍGçví.ÃÌÓ&ñõ üÃLÉ@¸«JaÀ�ºÿLÆøÒ˜�Å·‰}ü”R‰P&/øbŽŸh‚}‚P D!ú¿K­�PQ‘hß5£«Ù~õ÷=¾î|nº¼Q=þ¯0Iüi>Úåð€Óµ¸lfÆ Ë% 0RAç…Œ«$Bñ #´sête ¯xú«Í!þ"OÁ54¶¡¼¢�ÍmhhjEW—^Ÿ1–C8v¤¥ºP�Ÿƒ¢‚ èߥCúÁ£ò�MûÑT¹‘«_౩çÂ]üZ¤¸³‘Õw ܙŠDZ ÀßÑéÁ{­Æ{¯Æáªº^ƒàÆŒŒÕ?›MlVò5|‹PW…A>Šˆ–NJ *ÒtÇ>™é œ�4!^6ò©ÏòSâ)í¤­½‰EJq‘N¬·'nDCà�e B!Öð7¡I*ñ˜—Wâ™#¦íÑ~ à<Þû>ªeí¥½ð€©Ð¨ X±± £†¤ÃbÓN-qE,Rjà`ÀƒÚÃߢµ~?úœ€‚’1ª£° úæû=‰‚•d , lV«`ñ¥p>‹ÀÓzX 2K4;%¢½É.…¬¢qBJO¹–¯þO>ÿ–W÷úDÜ=*ñÊÛËpë  ±”3wÂݵ¡ŒÙu$E/ æ „ým*àLYýDÏBÚÑGtB 58N+ßO5ŒÄ ?Zi?³Ù‰ž m³˾nÔþ/k½Ò$céÅý¸ À5rT}–—RÜ/Ut›‰V8•cD°†�†ái=‚æÚݰÙpº³¶ñ¿i,†‚i³Áf³ÂfµðÕ†ÒZqîÁû:kÑV·+¡TÒëâÏŸâÈ@þÐ3¯Eê‘Ô×·à�w?‹¼ð>ZÚ:� O¥ Mm8û´‰ÈÌps{�±‚0D| ½ìäQ•RC<мЫ¡óD�i¿ŒÅXëªX}wœ�A;žï¹ë¯}~îý{M=–®×-x; à×½µ‡,½¼'÷¸^ëÍæöN*ICªÛ*p‰DLÈ”ÿÿ”� ³µ͵» x@cQP6ÊÝXå"ô=® ¸‘d±ˆA;Úêv£ùÈU�YJÙ?IáÐ3`KI�¡@Cs;Þ|÷3Üò§Çq Û™µVK['òr2ñ‹ñÃÅPÊæBÄ× &Æ«:%þDòŽ¡¦’yK �새ò‹{‚j2ùΠ Ùé\“œŽX«Ú�R]TQ2IØHøµ­c[w¿¸Ï¨çÿrU½µŽEåÈbp�‚ªkì° \6³ Ãð¼"Èh:#ñ˜ï ®ÄnCÎò3„À–âBŠÃ »#6››ö·D pn.�E‹… û‹ zAiDUéı %0xÂ5°Xm‚røàãÕxýÝÏ„Á"Çkef¤bí'Oc`I_ÑOì< _ãV]ŽZ[ÈÕºµçj >UE÷ãŠW«©GètÛzÕÁºÄJ¿˜„Þ+¦ãͰ†ÂoFñ <*Uw-ÜkÄ÷÷ ¸Ô_¯-ë1Ø�0GK¹ì¯èFE­CJÒÔ)—Ð1j´k‘°±ˆ�îVQqÄSªðˆBM@h’wô(PpÉœC “,!@mCËq~èìòâÞG^Ç¿^¸[xÍž9�ö2ÄB �†ôHèµç" p�»!êÂ/¸öDÂRÈ{z�ê#�°÷è¤ãÌÍI0+ü¬æ½Û~°ÓHø)/[½º˜c°ë<©‰`„Y|µµápT7UO Ê]2jˆii¥m¨lº�bJ ?Ì‚PVæR껳”çOuͼYè_\€ïc-]¹k×'û‰î¾S4-¸”¿�ªŒÖ¢:ô[Ê÷ÍY|õû/¥ ¤tÊj©Ò¢ñùi\•Ò¡i¹ýT’ï':ÖŸ˜Py¬jöüÁîÅ0£÷$/[½º,Ç`/²àˆ æÈP; ÃAFš ýò�’²XÈrî2¶\I7a`(X 16�VÜA2ŠENÄ¿ƒJK;%¥Éò&&±Ô7ìFzÞPÁ�t»°Ù,X»~û÷¢¶î8€kçÍ‚ÍÊ9vŒÕ6F4Ðj¨Ð’³à= i)yˆv^_ëÅàNÚ—_>ÿ�ê³û˜�>(5ð¥ô8¿çÕO�`Ŧ&½³Ô€ë¹i>À1”ô0]K=VÔú0aD&œv«¤HüHý?Âp5<1VOä Ša¥Üy©bn�R°Å2eަ‹È†›ˆí˱ˆ6G:R\Ù@XT˜‹m;¢¾±õ{ œŽL›4RŒñœY{j@ÙÈqº ­rãx�”k?NÙEu¬&‘{e&iÆÔ­>+Ðz‰n¿N¢a©/…1=¯¸4:V{ñè¿¡ÛÓ3¨ÏøðX<-Ë1Ü _¸QË `Y ¶)€‰#2eq©ÌBÇÁ>™%WWbØ ¥ ˆzŠQñ¾'™B¤™‰Ò ûÛ�–3 Ã1ù¸\¸�)X¹vK�J“�:þjhÁÓÇ‹ NŒ„±"â­;ÆO ^“؈ ½&º€‚šrÌÒ›¨6öijñç ÖØi§¬9ŒœR¯~L ÁSï•cëݦ¶ZÞ›¦'š àj4x¼¸\· @‚òÔ E(� D,ßUpÄ�ÑST6ØDù½PŒ:£1®À•Q$¼^:¤Ûv@UMãqW�ÝHOscÚ¤‘³cKEÔß 6êïÅGkN,•ÔÜz®¾ÔêÇ1‹º¾ uÊñÝÔäÔbÐÏSt ŠVnnƳÿ®0ºÙsT«ýb9Æû±À@cµnQSkCJR‘ê¶É›…Â--ÌQô«»ÿ =D�-ˆD$PQ&q–^Ñj@(Q†ÜA@,âƒ+½P-Œ>.YÓãNÅ£Yßí*ÃE³§ñŒÄ”÷„»kaÄó§ÍD ÀX~¬™âoØÓcÅ‘4ðPù¿õ! V.¸*_@õýº¼~”5WúlªØG{¼¥U ~ÜöäNúաoxêXîËqØ�_ƒ« è£öf0ÌÂë�bÔÐTÆ"ÁßÖR–W"§S•žC£¢�Hžµ½±7�“{ª 4ዉba€¸2‹…ð!';Ñh›¾Ý{Ü@4ÑšFÌ›sº ÐÖ”LD-ü€Qí8Ýœ§Iä"¦BÆi&ÛH¤#ÌT z¨)ïªuý<Ü#vµYwÍ ¿4+`ô»´ã~J)~óèT5êV󖸀ÿDW~M¼+£º-šÛCp»¬èχ@¼Ù eÀñ¬Àç'­ô“a˜‚ZU¡6vc#"E�#äoƒ;«?¬v—p�¥ƒûaÝÆhië<îJ âH(Âð“Smî|Û÷kÆç¢"�NÒ% ^jã£R]}‹/mࡆ-ôfP~uTVºýÔÄ5S˜ê�0j#~êýÃølS“‘kó[ßë}b9Nûñ €1†ip舃ú¹��ž"”2$ѧ”'!LÂÔ!™2�lj¢WÉ SÑ×——"KÀ¸‚a¸Â{™rˆøÛ‘–[*|Æé°Ãåp`õº­ß ¸uÇA\7–0ÚŒ06P6‚ˆ¿ jœñvÛDÅ ?F*ðÉÕQ¹+®dê!G)ø§÷H[{`óQ‚›',Õâ÷‹¯�»Úð�÷Ê�\ÿ%þ€ý±(ÀVó¤jZ­ZF N…Ëa•Yp(bx®ì¢•´Ÿ$=�X °òRí-ñdÄ2`(køÏÄ"~Xm8Rs…ï+*ÌÁÎ=娮m:î À”âôic„ûfuf!ØqˆBIl®Ö¤ùGªÌ „莅@R]p/yÁ—~+1°Ä ’ì=M®ÏAïwjû@[Wÿûì^ÔµêN¾nâ]ÿ¶ã±G,Çq?vðhæ|h0TB,Ú»B1$V YêHY¸Ã+�8o¿Z¯€!Ñ)(R²k‡âØsò™a;ÜÙa±Ú9‡©X¹fóq)¥¨«oÁÙ§MÒ‚ bab¡c–ˆé¶¸•ç›_¤-»¦\}% �öx3Y¶Ú4^ªVßɧ0dõ•ÇýÚ…¿rv”uýè_Øx¼öˆÇwí7Wð­Z:Âp;-()pI… ;bL;5(íþƒ<§¯¦¤h¿ÌÊ˽F‚9PEª�+¸¡peö”Õà�E8PV�ƒåÕÇÝ ðtûÑ¿8Çž$(BÊFz¡. 1ïO( JHB95• úÔÔy©)«/Ž1—;›&øL�˜~Öð\ÿüw…Q­?7ÜóÙã¹?ÿu/8:q͵ô«F¨ôH´ªööc)+™w¯½G©tê´Æ6_Twã¨ÄQþ¡Çû@Y!VôµW"äm‘ŒE§¸ûŽ_"ÕíÄ÷±¶î<ˆpD¤—b,v“‚-�Û¥W¼ŸPÊ�ÚæïGÜâKh¹(…Ê(.5A3Û‡!Z縲‰ï#Î,—_üyRjFøµ'QJñéú¼ø±a“ØN^6ðcWA7h×vÑ€÷–×¢Zš&Q‘^±\[ÚÔcð\yá–VpË-•kh�XI‹=·iذžæý2ºç¢‚>¸ã·W|/  ¼¢‘H²É¹ñ{&\N˜©øo�c�åÊh¥ tÖ¢ë«vÖ0¾W’‘ê7igåGiË<�xŸB¢¤u7ˆ�ç§&Ó}Ð)öÙ]Þ…'Þ)‡ÜÎËDð§ .½q¿žöcxãÓ#hï ‚R*¹�úˆ.ѪW¶²ê=\*Ý Æünà½)Äåk=„ ·Qv!ó.>Çžtü5n("ù¹ü†¥¬È}/(*©u—{5J¬œJ£ÆÀMõyï‡ x?[ ×3 ùèÊ«}Èΰ¡8Ï!n"¥@È&·2B±Pb?€ªÐSªQ=(-MŽ^bIYƒ¤d%” Á•YÂ) ¤ºè“�Ž_l9&7sú/Faé;!§O†Ìú{ª×¨‚qÉôø›r=á§*^1èŸ7þ~bBIÉòjˆÕ‹¬nرâ›&üþÉÝfNù¯ð³H\‹¡Ã1–â@U7údØQ”Ë�cä|ÿPa bHb©/d�„/@Z¯Â+ c’v,ÒÄ*Åø¿£Á.¤¤æÂæÈ¾g`I!¶ï:„êºÞ£z+?ýær<ñÀ- ÙoÝFDƒí2—Ó{‹¯õ67 ¡ò\½4<#2KkRø…Ñeæ= $í)èÒ�óײøËzÜÿÊADb†ç~ \©/~VÚk¯4¡òXŒbw™™év”¸äVW6lTÙýG%S~¨M¸Üƒˆ+u.A…2€:(½��¯i¹'óƒC›Í†ìÌt|¾ö[D"G5äY™iøåüYxèîqþÌ)P|ù›w"Ø~ð8<>‘À3qú­´ªPŸ—O×â'Ñ�' ’ˆõ…k§T÷zV~Ó„û^9ȰÏãp}¢?+ýð€úéá8{Ê»�•nE¿·‚Ü#±‰ �š�•„¢Š.@Ñ{€aˆÚg!�’Hù (‹Àï×-x°¼Ëkz|ÃæÌž†Çï¿W\ròr³÷‰…·áÛö÷êC"ŠöBcÜ=“eS(dU‡ hºI6EÒ©´Þ‰ÍDæW¼¼[_ø¿ÚÞ‚ß=¹ÛŒåߎ¯û‡&l?Dü ܤ ½Ç¼¯¢îŠ\�ŒUXÒã/›˜Mú�D€Üº‹$ê @µ IzEC]pfÃjãÈOlV Fž</[‡`(œÔM*)Îóÿþô›ËQÜ7 #NK¡,"þFxެEÄWojë'’„Páúã¬=D28“ÈžƒxY4‘œKÙC�”/A“÷TËšþ¸|„®ð³,Ň«ëpÇ?M±=UóàvóQÐ~¨ ¼>0@®æ3¦À¾J/üÁ(FN‡´®\ îAê P ÅÑGñeÀƒH ŒBŠl�zhAÄá±\™„׳2Ó0jø løf7¼>㡯ù¹YøÕU³ñÚSÿ‹Q'Lp÷#�Vø›¶Ã׸ 4„r3‘ýI$Í0"Ñó&T¤I“�GÍýOFècîB} /9 ƒyÄ_�DY<ùn9žþà°™ïp~é¾Q€ÀrÓé…•u~Ô60iD–èÂK7:Qq÷AuCåñDÊ‘©XÊ*¤…H•O4Ø »»lÎLAœ”`úä‘èèò¢¦®Y•; /'s/˜�ÿz®¼ä ØíV™�Ä"~š·Ã×¼±@‹à~S™€*Ó~$Á]§* #Qq@%›Ð3k­Z˜ìù’˜Ï§®ÎXCágY¼zשּׁ5sÚo\Ê{?ØEpb¬Aàj¦‡8fh:®¿¨in» œ #êÉÏ�´…å ü©¥¤žá™…-Üp<†€†‡ ÿ¬Í‘ŽÂsÁXl¢`Àâpe6lÞƒòŠ:tz¼ÈÏÍÂèáƒ0ql)õ/„#Åž 0Á¶ýð·îæ'K-8‘¸èTE詊RÐ Ž>; .ðDÖ=iZèãƒF’žl$)Îêf>(¥ð¢øëÂ}ø|³)O~€ q é¼j < øµ0_Cú¹q͹Å()t�ÂF:IX%�WÜ"©Ðbé¿™¸ T6ª6ÌDrž”Ô|ä ™‹Í!{"¤dˆL8ÒÁ%âÎd uÀ×°Qs/l…ž¸Ý=|µ^á$…—&SÐ#w÷Ö@ƒÏSJqðH7þòÂ^ì¯òšù‚p„75'‚PYN àð€ÓôÕ;°ÝÁž 2Óìè›ëà01Èç&V¨b$ãÁ`ø‰#� ~õϲ?�U°¦¤Âjsƒ0á|†”Â).&Aø£�VZwÃW¿lÄ÷ƒ{X‰]“TáPÐdO(3W”e‘ì c.AõŠâ>k·µâ//ìEE½)vîmÎÂ1âù³àV7¸é�Â�Åî2(¥(�¦ÈËkðJC¤C+‰)| >y(® (‘Ò›«�4B ³ a_#!°9³9o‚QßÝ3üMßÁß²_ƒ€]ú¼yÇBÀ…ïâSœͪXê£ô(&'>q4· þ^øßXV�{_Ù�.¯©ÔýG®Ðx" Á‰¹Ü^p•™ƒOŸ˜ƒyg÷…Ûiâ|.,`T)ÅãÿÏ0q€�fê*ÅÿÇi͆(�ƬÆ«583A¬)‚ÅBD­ˆ…»ˆÛçB­þúÞRñó¨wB…kO�B≬ÚOý{ͺüæ8ã¯{ý<ðÚ,Ý`šÉù]pE>¾M�NT ½ñóa¢«qØ€TÌ›Y„ÁýÒD�Ç� ?F"€‡Ú1¾z¸`‘bŒö|B†®KòÂjx/�!” Õ å D¡"«°Z­€ÕB`³öF—¶Ú¨/%¥’4.çÿ�£«ý},„� ÞR|4@ÒÉ¿zc¹• �RŠ·>«Æßß<„Š:Sñ~€GÜv" Љ®®lx€Y\z†"{{PßÀè!é°ÛÙø+9uÎ@× 2IDAT¸Z_d�$>ñBSèÅÆ#�Ѝr>�ÃÎðñ?ý8hFÞ…T/±½t^ù<3«£;Œ?üc7Þ_UoÀÔì†6p¥½¯œèOãǴÆXÉ„†+#ÕŠ?\9¥%n DçkãP/'fDËnª²Ñg ïþ§¹lª†4&ÞžÐB¯ u¤=É$lb9#5î¯ê‹aÛ�NÜýâ~Ôµ˜æe¨ä�MÙ�Ah~Œ&f=¸<¬©ßvþô|Ìšš‡Ü,/¤ ³‹cÀ�‰C ¯ðã„?ná¢.ªƒ‡€ÃnAŠ�Qñˆâi‘Hð¡šöë�vc‘;À¦Žî#¸ø²ªÀ8è§Õ(”€ü3¬€Ó¿6kÜýgðS\DÒ•÷0’íéWºû+¾i«ë°m§w_ºÖ¸‡ü~÷þ§µ2ÁU>Âÿ¿©eeÈÿoïÌb£ªâ0þ»sçNg:�éNW ¥XQÀº‹ BŒKâ†/&¾ãƒú®‰†ÄÄðàƒÄ(>��A%Ĥ. ìQYZllKW`Êt–Îr¯çtzgk;C[J{¾äöN'™Ûé½ç|ç¿�ïOûj/o½°’ -~2õEÊÐÊN2Cä_núq:žÝ6¦Ê†¬%øˆÒ5Ò_ºÒkš†i[ÚÏ^cß7Ýœ¿:ÆD¼ Ú‚€\ñ¿•¯Q°tÑì/ĘēVòÆîfV®p§9ìÛ�…åà˜yã��ÊÜN §=X7Ð[*0¥µç€b¡…®ö“.‹eYÄ&×úCì?ÔÃÑß‹Ú}x¸²ü¬¯å�w€O„³¾ºCcûÖ*vvT³¾ÕO™ÇH·´{ òlrêå^gÊoOM94÷2C|0³ ÇÝòßÓxdMxŠÊ˜¦™ú\0çÌå‡;9úÛ0IÓ*ô‹†�€/–ëXîP|¼\ˆ[Pbh¬oññÔæ*vvÔPæ5DÚ0夗;rèxÝNÜ.ÇŒ=«³eù´”Ô—xÇÄBŸG9²H&Õ俥�FœTL@13‡�sä—A~<5ÂŮ۳‘äÎeîB4èZ΃_ÀžÞöúAÝ¡Q^æd×#5¼òL#µ.YG�½ë�ÉßåÄ®ò9‹Þ¦›]-8ibÛÍnò¨Z)ÍßôÝs™þ¹Ã¦+h¦Èf:¡â·îN?ñ‡nFùúX‡; ÄH$‹ºþA`Bª{ÙC@Æ¢l¾Ös‡;®áÅmu´5—ávéèú¤Hˆ#-Vàq9díÿ<ȬbžéÜ»õ`e‘@fP.Ÿ>W“=Ÿ…�HšD¢ ®öŽsàhÇNŽjæÛqQÉw˜PC]ÀLxø¨¡Èz‰öU¥lßZÃm~Ö6{¥ É”%PåsáÔµ«ùލ!©àn`ð&¢}ÙÆ¹º¨áÔ¨ôÔU•P_]Bk£—ÖmM^Ö4•Ræ1ò$á²SƒS&w~wbúInoÿ•;ÛŠ$èîÓÝ¢g Ì¿a†nN0x#Ê­ÛñBëñgÂßÀ¯À„$œ‚"€»Žà1DÀð%f!FRhü@×5 Ý�Ó©±¢ÒEk£—UunÖ4y¹¯Ù˪z—Ãé°í,´'ô4ÛO‘ËÏEöŸÉ²Ý¤iK@$š o8BW_ˆ®þ0½CºûCÜĈ',âI“dÒºS?>bÀaD`ï$У†œ"€Å7¢¢ðuD™ióBþqéQWUBm…‹J¿A…× ¦ÂE¥Ï ¼ÌÀïÕ)uë¸]N Q�LçpaéVÕÿ„÷wî6ß4/§hÑŒ\\nÓÐ1]&†n¢&M30¼©¸9ÈX(…Ð ö5g°äg«Xµq•Õ§ C˜8¶€ÑÙ<Ú€êýÁ©Ï­ÿt­sk¾@ÙúF€ÅW¤Ï¿¡$�ÛD×5\.Cwa˜&†n 4aøØý©ŸÇËÞfòð]ñvü‰)Œ™C$fóÂê­ìüè4sg3iX„æ3½uǻ֗ÏÝT¶þSpšx.ˆÅ™©îÛ¯Ÿ™�¡iñ^k: %t„ÐA耎+!ƒÕoTSµë#^yó ¦oB$xLæM/à®Å£øËò1l{u9'šÏpçÃoàMϯo)bx¶ïöÂÒ-‹êËKt@לAa÷̾*·Ë@ „†R: %4¤(š¦ÓÒ`÷‡µ¼´v3Mí~<©E<ô×L[ò³úOjšüdÎòÚ3·°ÿ`#;Ï�œœÂí×åܳø¡5ÀÖ¶•MÕ€Ù9ƒ<ŹtM „pœ @ ”p®ŸŽ‘�ÅË5tu÷`Y6O?¿ÝôòÈ=3Øðâ/œ™Æ²‡^¤1”Î�ÔnfMŸÀ†Ê0<É”LĘa‰ÅµGC%õå%BËK÷jÀŒñ…ðûÌxW(Jhñ  !Ñ@èÔžô°êµ·¾tu7Wí¦²ú3²Œâ•'áOðòʆ=膛¡¹©œn ¢t~¯É´ñi3 K«”VXZ•bê¢x`ª¡iØJ9Îã%—Nö’ÆòÛ'×ÒŽ|m¢=ý·��0!v8H^NgÚ:º‡®ž(n· ¡lÓÆ¥’0gé�–‘æ•”èŽ[rz­”@Jç‚@Æ8–?VÎÞ ݇ê›xiÃ~43@Ó5{1ebºég÷G Œ5 iu£ŒšD¢Ï¤i@²ßg¸ý^#>M…@)�2>5u—‘4�e—S¹ý½KË?Öl&heâr¹ùáâî^:žç76p¤ñsî(�„Œt Ÿ['9`º�d Hv¹4t]Ö ”r&�Ž7¥€]‡\\wë“l}ûƒKc¶®sªå,O¿°�Üt�GoË'jÂëõòçGïbHr{|d‹xKS]É iŽ©$J�nz1R'pÿÖqàÐÑo…Õ¶‡ýµÛX2ÿ2ÆgE�v”§ºQ2ŠîF©/pÃÁ2MBQK"¥D�?µ�Ôœ‰ºÆ‘ÆÎÏß…6ÀŸÊÞD÷çÇ‹õ í¾/ì¨ø© uÅBêéµd_ØB& 'á�¬!àU<þ«Ÿ8(§œpµ¯ãq»ø}ü><î8Aªùøï~ÒŽ&B8€¥d´PX¶¢½+&��O·…ëÚBÑ‘Cò«k¥þÈq¶¼{„…%£¹rÒv×À4 b1 €‚¡9Lºl$ãF %73‰€×…BÒÙ¡ñ³³?y–¡™>¤Ýr)A"-!;èê�ÕA£n]IkÑÒªwZƒá‘á¾>R¤°rÕfæL-àî[治汘E^N÷Ýqó§æãÓBv:„D‚’LÉ7Z62z )-Ä9²¢$¶’(Þ¯m£­#¶hÖˆßö�õM´w´sùèLòó²èìêá�+¶P˜ë¥táLnZ<“­«`é¼1 ¢Øá 2Á¶b(ÛBJ[ÚH+B¬¯Óqî�,P2>æ•¢ú“6€�zYE“N;b3‡dz§x{Xxý5„£‚³ÁN.;œ’É™5m<{jO²äîgÙòn7,ø>ÂîDijN©…PqŽÙ ìóï;÷´ò÷�M5Àï�¨îÀq¡Îèµ£†'@äWMÈbî´áþ+äÐç.n½ïº{ú8ÝdpîpFå £(iƒ’ç{§e¶ãÜBI%múú¢üîùCœF«//ù°l}£Ðûè�;º­ ^SåeùÀê%¢ CH²²óxçý£´wtðÉÁ&.˜Ž—Ò9áý�+%Ê:ÿ’GÙT}úMàÁ²õ�’~¬RÔ½^"€åoU·œ<ÐÐN̲PJaŸëo¨–î]x>ÚžÞ>BÝç2uI %c ,„Š¡¤DÉxéߪnf冦“Àòþtú JVÑHݺY�e�ûj�t,ÎHs»²zqB‹¢aôÚIèºÆŠ'n£h`ëvn�Sr¤”BI�¤rw3¿Yy°Ç²U)°ï’¤®|¦(*ÝQ ¼ºpFfÖ¢âl’ÜøÜ:¦i0hp!^¯›Xçg(E©87V*~âm[ÆyŠp$Ê ÿjbEES3p3°ã[ ç}ðÜØá‰ ®�žÉ´ñIð¤$º0 ý[¿,NvímeåMìmèØü8v—f‡_Ÿ)FܸCË€;&&M˜2zó¯DANâ8|¬‹÷jÛøï¾6ª÷÷/:òìÿ§P‡×ÍÔG,Ýp4bqú×L¿ÏÈOà&5©»¢´#tt[Mg;¢;€·�ʺu³º‹–n·¾šõwQÇçôW‰�¤é@Šó-´mÀ‰¯H{y1çÿ`¥DO&¬ó IEND®B`‚caronc-apprise-dfec414/apprise/assets/themes/default/apprise-warning-72x72.png000066400000000000000000000173511526720371000274070ustar00rootroot00000000000000‰PNG  IHDRHHUí³G pHYs  šœ›IDATxÚíœy|Õ•ï¿÷Võ*©µX‹%˲åUÞñŠñ‚ÁB€’8�—!l’&!KÂ0 yla†%À˜Âî°1±�m¼à,¼H¶d[’µt«÷ªûþ¨¥«%ÙÁ&™÷yíOÚÝ]]uïOçüÎ9¿{nÁÿõ!þN× E@‚€Ð<Ç@H  ˆ©ÿÒ�`4Ð̦Cl |G‹²@ÐlV;€�ögÙÿÉU'_æõ6îCJ(êýŸ.‘R`šŠLÖ$™6‰' Sõ=ohV/Û¯­ÿ“|X ŒñºNeY€ºª5åA*K * PVä£0¬ hhR`˜ŠDÊ ÏÒÍÐÞ•âPgŠ–ö$MâêLÓÇwË€¥À¶d€ê�«�³€ çÃÁƒ?¡ŒãÆ3¼:LmE˜Ê²¡€æAq( �"™28x8Åþö»[zÙøa7k¶æ@G%uO¿vÿ£t ð Ì9ïÔ1ÎZ8„I## ©QR䄌c¹¼ò ZÑM³¿-Áûu³le+ë¶wyînnüGèxàa` ŽSÌEgã¸Ñ%”Ehš´AÉ¿œ}.ï}¯<ÿQ 5àà¦iÒÑ�dÛž(÷½°‡µÛ:½gØ | Xó÷è§Àuvئ,âã_–ŒaÑô >�€_G‘„Âı^^ÙÀ)”ʇL)ƒd*Ë›Úùõ£²¿-é|•®þý¿ bààkÎg-¬æûç�"Ôñéšf®&AHiƒ4Àål°„ó�Èó&\ÛQùn急”ÙgB&ݱ¿zäC–­h%“uó$p Ð},Õ>!?œ)йþ¢Î=i(~Ÿ†�BJ¤© 4M"„Ì�#‰�!dÎÂ\Ëòüßþ.÷”�ïe_Ç%è×Y4£‚ºªë¶w’H™�€·lŽúL,¨Á¥³†ñã%c^F·­F—]“h>‰OJ4M =“%� ó]M 8 åyÍ· ¥,~òø™kQ˺v·DùÁí›Ù±'æõðu;ÙüT <œ ÌžTÆe猠²,ˆOÓÑ4‰.š.ñiº&Ð5ë½&µ€Éq‘:ê0”m3 ¡zhÒW€�>„˜ÊDi”‘ÄÌÄ1R] L—£&ÑÞ4Wݹ…?¯owNº8øèÓhð°HX0­œïœYOY$`ó�D“Ÿî#- t‰.5¤t@‘ @YÁnàHv ¥c j@óå[ e¦É&»È&ÚÉÄö“înB)Ã�vÉT†ëïßÎso¶8?Z | èø4z 8à„Ée|÷+#(- àÓ¥m9ŸÏzµ,H¢k]ÓÐ5iñ’ífʦc�°ý@ ý…H=„™Ž¢Œd7rØÒ‡V¹ˆ[îý=¯½µ�X<JáóéÔÕVrü´q,>éx&�¯ÇÌÆÉô$Ù±�tl( ¤XošÜáiðå¿•¤| ¡¾ˆKÏII‘©I¤”H!Ñt ©Y€Ia�¢i:H‰”šM°‰—œ%Áò TM#P\O x8I6y¸QK=ÌÆÝ?ºî^ÚwÓ�Ó‹ÓÕcoóAV¯ßÎãϽÁ+o®gÄð¡Œ9_A BúÈÆ[Qü~�)£"4гkÜáÔ0ðÚ'h±�‘ê¥?úæÊKhš†”Vt²±Á±��R"4+Ò¤� I‹¬…´¢Bà+J lϾô.›·71fÌ(‚‘Ádb­(#•ÇWÊÌ 5<õâ;d Ó Vq+„Uàf³-:øÝ²·hëèföÌI–AjAÒÑý„tÆÖ°¥±‡C�)'ÑÝ`'•Çäb6™�ø×ï40¾>‚O×�v´òé¶+éšíR–[iºns‘ý¹´Ìq�uÙpåT~rË+<òÔ+|eñ‰¶Ýœˆ3T3ï¸ ¤&BC ;¿‘Ò¶ÛݤåRšH©¡I‰®[ÃXàH_²¨žúÑ�˜¶œq¨£“áuÕŒk�™‰c¦»=$.@™ÈLaÑF6¶�Lo+¾ìAª »™?)Â…_;ô;wï'‘LóÑžVö4d””ÁH¶c¤£€`ä�Ú:“liìA)Š€rà ÙÀÏ�¢êò Ÿ3)š°MZ Ž[9<#m—“Öqš¨›I缯 ’?­iã…—V¹M$RÄzœ¼p…ŃHG›J!„À_XCaí\ Ï$\>‘pÅ$‚¥£�þ"L#�ÊF©6N˜XÆä©'°áýÝtuÇhÜÝ‚RŠs¦¢é~2½­(3‹‚É£"¬ØØîXÑxàu`ßÇèÀ<€‹ÏAEiÐ*lKѤD: I\pœÏ4)Ñ4Í=ÞÉšÁ!\?ÁÒQ\{ësìi>�wá}­mÔÖT2mê$@‘M¶#õ0zù,žYþ.zc-+WofëŽ=Äâ†CaùXÐü™nÌL”!% ÆM:žÕ‰Æâ¬Ûø!sgMdäè2½0Ó=( à“‚¿l>L&«|¶À÷ø_h¡]„NWÂçfVZ¶ó˵špÒÀùNJËÊ<àH)¬¨æ)´@ »—pëÝO“Jgò.n&Û:Y0g*ƒÊ«ÈôD DxuM ÿèV®Þ̪5[xmÅzÞ^³™5ë·3¨4Â豓Ð%d‡PF’ÚA&„ëXûÞ‡d ƒ�»ö󭯞 J‘‰íG)! aXo½×Nk{`(° Øã*ž�öi@•°pf¥­œD_)+w‰RÓ>…Òú\ »®nu À¶$ „Ž?2Œ{þ#ÑÞÄ€æ»ñý�<¿|Y$P2#“À¯Ë~Çíomç�¯­æ’«ná�Ç–ã+¨&T5Ýo&ÎW fâ¸álؼ“õ›v(†Ð¹œLþùÜèšÀ^@¸Ø¼úZÐ[øª˜6®”Ù“á³ P), qˆYº.§¹©®[¯Vd�¢Ù‘MJÍʃÅìí.çŽ{Ÿ¦³;zıyk#§Ÿ:�ªêZ²½­T•JN9igéD.øêç˜<~»šÒÙ¥7ždÕÚ-Œ[OCò©NÌt>™¡#YÊúM�†IQa˜ÏŸ8ƒTÏ^T¦×­í†V…xkC;§ý�À¡�,h 0`òØ~Í*—ì*RáU$T®–R ˜JÚ©#`WATXùOAå$xì¥~Üã<ü~¿% öÄøÍoŸ&•KFáWÝL¬îfư(ã«sÞ‚"^yðNÿ„ôÆ“üøú»1 ƒPÅd”@–¦TS)`Åê-ÖK°Ôc#Ö8/:s¸·¬:m +¶Ã:C‡¨©ÚÅ ´5á©©(Ë¥œBS¹H* ç'BÚ!^(ƪ�‡xý­u >œ /¼�›o¾™_|‘íÛ·sûí·‡xì™WزmþÈ0ôPÂL�‘#�ÊÆ�‰&î¾n1£GÔp°ý0?õ'ô@)ÒW„P&£k ‡ƒìüÈ PRù‘õsÓË)/ñ;X|ÉÆ# *› UWHI¡?_I°ùGaóŠ@ ÊÎdK±B „#µJ4_ˆL¸�¥�/§iÿAKŽüéO¹óÎ;ùþ÷¿ÏâÅ‹ihhàßø‹/v¯û/×ý©Tš`ùD„ ¸|çL.oãò ¿`­e ^}s�m%%( ¤Hǧë¤Ò«Ê}e«Ð^ü�P¿ó(e€Ðèˆjn*Q)f&îžBx¸hÌÐBJ‹ÜuÍy€ß¹RÐQ ËJ”ûñJÁ rú‹0ËÍDN.Vá)!-N‘ÁSin‡ë~u?­sÒË”)Sðù¬Á˜¦ÉöíÛ‰Å\Õ�+®¸‚aÆY‹]ÝQ®ûÕ}é#T1Áå4!z°œ�;¹¿\YŒTR °½)NoÜJ'&6Ô[n™ì°ÕuÁñÊ\"ÒÓL0 $âǧçò!pÝÊy5s»ËM^³8ÈG¤j Q³œ+ÿ÷�lÜ’_,O™2Å�X†aðì³Ïrÿý÷»ßÏš5‹3Î8M³\jÙWðÖ;ïˆ G•£H½€`Ù~ûð‹ö5Î�Š™I`¤ºñçõ•›èì²€?iÁtŒL/f&îFXg" +¡�2:â ¡Î �¨×5AI¡/È•B ° å~ž' {B¾¯€â!ÓIÈ*¾sÅM¼½zs¿sôhÕªU<ôÐCtvvæV"¯¹†ºº:â‰$ÿ~ÛC˜Ê$T9Åâ -Ä=�¾Áº�–¼)*àÂóO#Ù±-XÆþžRÞYû>¦iéÔçž±�T×L3ërþ’TÃð"çm(p þ`@£0¬[~ªú3}N#÷’›òø¾ ©¦¼~‡z‚œý­Ÿ²zÝÖ�ïÇ�G·£‹RŠ-[¶°mÛ6–.]êSYYÉUW]å¾ßôþN~ûà2ô@ �ÒјfŠÆ]Í®¼qóu—RH‘Žî£¨f6þîU¶~°Ç–RæS]!kF`¼ô$C+CÞ¿x‘£ATø}’‚�ž¿x`˞³J¥È·¥@󅨨ŸCEý\Þ\³‹³–ü„;÷,pDyy¹û¾··—––Òé4O?ý4»vír¿»øâ‹™9s¦kEO<÷ Mû(‰W]8“ýá½çZÎþ"Ò&X6†qñ—ʘ?ª‹Ø¾U˜™Jeùð£fI«±á××_BÍ ÉÎ�ÀòÖ š *vÆ¡º}d@“ ûdÞrT^Š£„§ä„Kj¨5�ŒácùëëøÕm�Ðz°ý¯/®54äÔÕÕÅÙgŸÍ´iÓ7n3fÌÈ[í˜3gK–,á‘Gà÷/¿Í??›“G ‡«,ÍYå–£ÍL/Ÿ?¾Žµó¦2mòÎ;séÎ÷0R�nés$)U,š±½\÷t€Y‚—ùQÉá¥Ü|Çç 3tì‚‘Z6oß˽>Ç[«ÞûØ lãÆ�£¨È%C¾øÅ/ròÉ'£ë:º®#¥ì×äpã�7òä“O’ÉdhÜÕÌS/¼Î¤ñ# ”Ô“�°XÒ1n#KZXzýB¤/H¦õ T¶÷ã­¤ AÀçÖðaiãÍ­Læ›O^úc/Þ•U�¡å°ÆwÿŽ%—\wLàÔ××»áÛ)PÃá0~¿¿8nš_]Í 7Üà¾ÿÝ ¯³jÍf|áJü‘:¼©¦5àl¢�tOf6‘×-"ú­ÒÒG“r¿KIO? †aµ¾¹¡Üsݦ¼º�‚âÁ¤Q–ÿi=±Ã0�­'¯ºšÊÊʼ”¡££ƒU«VqÏ=÷pÙe—1oÞ<„\}õÕ¤R�ø|>.¸àF� @O´—ûù=]ÑþHBõil0� ‘¦©ˆ%ܶÇÃŽµd E*mö#f„ư±ó¨Ÿ°ˆO"“NpÚ‚±†�yÇÏ‹`{ö졼¼œyóæq饗r÷Ýw³j•¥Sßzë­477Û…h†‚‚.½ôR÷·/¿¾šWß|-X�¿°&-¿ßŠ­:jS–óÞ4Ý1WåÜ/=­k¤3&‰¤áí¥T5‚í{â<öôËd²%õèàŠ‹¾ò‰ª¨¨Èå6›6ñØL&ÃM7ÝÄK/½Ä=÷ÜÃ5×\à /¼�çžÿv˃tG{m¿0'¹¨üF+•—³y|™¬¢3êÔä�thI¥�šÞDS�æ}(Š*&pÿÝÏóç•ë˜2a“Æ�%ÖÙÄüé5Œo¨gÛŽ�ß8zôhÊʬz'›Íò—¿üå¨Ç/]º4/yìûØÓ|€ÿ¼ÿY~våèÁRÒ©.;š Œ‚RÊNÕ…²¢­+¿çѱ ИL›–y©\B—ñÑÞÞY»¥÷=´Œd2KyÍ’Ý»ùÑeç“577³téR®½öZ.»ì2žzê©¿¹ð‘§^¶Dx©¡+ÍÌ>yýX*�>lr æ$×=mNìêIcš¹ä'PXʶÆ}$í¤ëͷ׳vÃVΟNw[#Uf”3O[À²?¾õ±Bè3Ï<ã�>J&“é×á!¥ RTÈàÊ2JŠ‹¬„-�ÉÐÕãPûa:÷x$ ëuüX«J7’�žÖÕ§aMå[–ò¶ôYÇeM“÷wõ8Ãjz€ÀJàŠÎž4]±´µ�ÄÈJÚÚó³â›nYʬé2r6�žçü/Ïá�ï�Å�ÞÀ¢Ñh!á á³gLäÄ9S™>y$£‡•à)Œl•M¢TLe7:HéÇ ÀGû£lÜÞÊÊ5[ ü\{Õ…$Ú7“Mtôq/åI]l T.Ô+o‹Ÿ]Ÿ­~ß�ë Ûk“½í]é‚Îî å%”2Éd“h}r“–í<üĸü¢s)¯�@WÛ–|í4îºÿ™£÷ûi’šªrfNÇÙ§/ä óÇ“éÝO&ÖŠ™ÝI¶]`ØHyeJ7³L».¬¨›nræ¬IH_£{%Éley|bY�Ù§CÍÌÏ÷”«ªs¨3Åî÷�¼Hx:¼íÍÎ;Ô‘`T�µ�Žw0¸jX¿ÉÞ»ô9Ÿ:Ÿúºétü�§×òú›u|ÐØ4 8uµUœvÒ |ûk'1¼R’êj$Öüg·©ª?7ô5?Ã�,NQÙ8f6f§ÈÇ<Ü£”•äæO*¿�ïµµmn0¶ôÕ¤¯4è%Ú›ÅTŠt2FME˜òAÅ}¡Á/o}PT�žG„&ÎúÒ|W w[DÂ!Î?ûd~sÓ?ó³ËO¡BßE¼}+ÊÌâîU±'˜ë9T¹I+c@prÖàX„ʳ:�7ÑÍFõí µ­ìÙ?ïw†½Ãéaì»ph_�ƲþQuE”EühRP0Q¾*¶}�Î÷µdd}-'L kgp™dgs‚ý­Ö_⤅3ùÉ÷ÎgəӨwb$Ú •Ž  j*þÂj²ÉNÌlÂÎé¼%€ê×´©<}‡9 0ÝÆM¥úüFyu¬ÇÌI*ç^J™|ÐåΧwy[†Ÿ N`nÖT#KŠt†Ö i3“ ¡a4M­±<C)Åî½-œ¹øDŠKÓ{è}Š+†O\yùù|ó˳¨C¥Ú WS4ø8V¾ÛÄÆ­»¨¯�ß§“‰Ø=òEÑšyVæ­âÝÈå±&+ïQn�–KsMž ø?�îdÇÞ@¸kƒL?€Ò¶üº¸+š‘ãFQò¡IÀèaþ 3ˆ%5vïmÉ!ÚÕƒišœ8w†‘¦Ô×Ω'Íet5h™‹*ˆTM¤å°àªkïâ7÷=ÃK¯­æÔEÇ3¤vÙdF:ÖWÜë“ñªþ5Vž[õÇ̳Báù }ÈY)“ƒ‡“\ûÛíζ«ÕÀÍ6vw4_N¦ÍÊ’Bu5V‡†R¨t'LÆÂóD0 “@ÀO¤0̬iPév’=͈tþPƒj§ŠÔqû½ÏóýŸÝÁÖ»I§3d2YÚwñÅ“NÀ’‰•íÓ݇;ÜIl59̼¼GxŠV§‡ÚÛ" Š_?ò!›{œŠâF<{;ޤ}¸« ¤i—�7ŠÊ²�µÙÍî„ÃÅ— „�²©(éÄatiâó‡¨1‡w·wñ¯¿¸—–‹hÏü×Ïùü‚éô¶®!ÝÓÔ4¦�¸ôÙŸÑÏj0Ým ¶dfƒcä\ÔÌX`YÚŽ==\ø‹õNýõ¾½Üýk Të�s3YU™JLAì†I ÊÄ4’¤ã�$bm¤zÛ1Ó½–V'%RøÅ\ðƒ»Ž*½®}o;ß:ï „ *Huï¶"[ÅÃ%ý­K 0=‚žêg9 …[x,ϱžØÁ¶ÝQ'´_ ¼ûq{×ßnmKÊšŠ Uƒ‚v Œ°�Âênö{‰µyÅ’&QF/'ÏdùkëŽx�®îá`�¹³§¢Ì,™ÞCý€a@qËÃ7ärŽslPj«Bvg«EÖN¿Žf“¶´Ûo¤øUµ#gðÒëy‚!ƒË¹ýß.笓Ǔj[‡™‰˜Eçq ¦[6ä”AÛ’Qž"U¹r«À$k˜¼¶öWßµÛ¨g[? €S f²jîGÍ1i © Y[Ÿ¤ŠÝd. @*ƒšÊT`0ïmiDÁ•—žÃonü'ÆVô�íÚŠ‘êö.l»`xË… f®ÞÊë ÈßdçD«¬a°|Õ~òŸ[�-š½À»qüè"ß'P8 ü8äâÔ¹ƒ9å„Á…u{¯˜ÓÕÚÿ˜®IBP0œÂÂ%ÕµËjfr:¾ŽX‹õÑ@œ]Њ<Í(_ ²\,–ÈòØËÍÜúD£Û \à£íñIö¬¾¤²Ÿû`oLvEÓÔV†‰è–‹9Öã¸VŽ$(#NÐlC$öYå…‘²»e9bôÊß΀:ŽW2tº%•R4î‹që�<¸ÜÕ¨:°¶‰¿ðq'ûIxØœÞt ¡76Åðû%ëÃ. ¡r@a¤i‚�_ÚyLßJ1‹Î¯·È¹•Ê¿Ù*ç~ËV´pËc;Y¹ÑíjÛmò«Ç2Ñ¿ußü0¬ÚHXcÂÈbÎ?µ–aÕö®CáÙy(t­Ÿ�Ÿ¿\ôs©þU½”>büGûcÜõÌnÞz¯�h<ëüh¥m9{�u‚ŸÖ­)žÎ½¤ÐÇüãÊ8”—l€º¶§åMïØì%ä\¹û¹Ý¼øö÷¤P»�û�«>éÄ>Í{wœ܇}‹Š¢°ÆÂéåœûùZ†V… 4J }ý–zèwË qÄÚKõÖ-mIþë{yþÍb û¦µë®+þ–I}·Ç¹¸�ç^ç,ªæä™•„ƒº½�óXv<[Yº©¬ŽØÞ„ÁŸ×·ñü[­¬ÙÚÙWð{kûú½ŸÆd>«û ÂÚ¼0ûÞ3Ç3s|)“GEQS@AHws(çU¡0M0LÃPô& ö´ô²©±‡5[³~G¿»KD�7€ßcídîø´&òYß�ª ˜Žµäd`Rßò¦0¤1xP€ÒˆŸ¢�†O—¤³&±¸Ag4ÍÁÃ)¢qc sgíóe;Pl²³ãOõñßu3Ö^�ÁX÷/›gƒ5«‰ýã<’vɳюJí¥ªƒ¶Øõ™<þwÁÓ°6®ìg1ÖN¿J¬fÒ"r]o]6ÍX7zKÙÏ„½Dõ™?þ/“D2ì�ŠIEND®B`‚caronc-apprise-dfec414/apprise/attachment/000077500000000000000000000000001526720371000207125ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/attachment/__init__.py000066400000000000000000000031661526720371000230310ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Used for testing from ..manager_attachment import AttachmentManager from .base import AttachBase # Initalize our Attachment Manager Singleton A_MGR = AttachmentManager() __all__ = [ # Reference "AttachBase", "AttachmentManager", ] caronc-apprise-dfec414/apprise/attachment/base.py000066400000000000000000000412241526720371000222010ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import base64 import contextlib import mimetypes import os import time from .. import exception from ..common import ContentLocation from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..url import URLBase from ..utils.parse import parse_bool class AttachBase(URLBase): """This is the base class for all supported attachment types.""" # For attachment type detection; this amount of data is read into memory # 128KB (131072B) max_detect_buffer_size = 131072 # Unknown mimetype unknown_mimetype = "application/octet-stream" # Our filename when we can't otherwise determine one unknown_filename = "apprise-attachment" # Our filename extension when we can't otherwise determine one unknown_filename_extension = ".obj" # The strict argument is a flag specifying whether the list of known MIME # types is limited to only the official types registered with IANA. When # strict is True, only the IANA types are supported; when strict is False # (the default), some additional non-standard but commonly used MIME types # are also recognized. strict = False # The maximum file-size we will accept for an attachment size. If this is # set to zero (0), then no check is performed # 1 MB = 1048576 bytes # 5 MB = 5242880 bytes # 1 GB = 1048576000 bytes max_file_size = 1048576000 # By default all attachments types are inaccessible. # Developers of items identified in the attachment plugin directory # are requried to set a location location = ContentLocation.INACCESSIBLE # Here is where we define all of the arguments we accept on the url # such as: schema://whatever/?overflow=upstream&format=text # These act the same way as tokens except they are optional and/or # have default values set if mandatory. This rule must be followed template_args = { "cache": { "name": _("Cache Age"), "type": "int", # We default to (600) which means we cache for 10 minutes "default": 600, }, "mime": { "name": _("Forced Mime Type"), "type": "string", }, "name": { "name": _("Forced File Name"), "type": "string", }, "verify": { "name": _("Verify SSL"), # SSL Certificate Authority Verification "type": "bool", # Provide a default "default": True, }, } def __init__(self, name=None, mimetype=None, cache=None, **kwargs): """Initialize some general logging and common server arguments that will keep things consistent when working with the configurations that inherit this class. Optionally provide a filename to over-ride name associated with the actual file retrieved (from where-ever). The mime-type is automatically detected, but you can over-ride this by explicitly stating what it should be. By default we cache our responses so that subsiquent calls does not cause the content to be retrieved again. For local file references this makes no difference at all. But for remote content, this does mean more then one call can be made to retrieve the (same) data. This method can be somewhat inefficient if disabled. Only disable caching if you understand the consequences. You can alternatively set the cache value to an int identifying the number of seconds the previously retrieved can exist for before it should be considered expired. """ super().__init__(**kwargs) if not mimetypes.inited: # Ensure mimetypes has been initialized mimetypes.init() # Attach Filename (does not have to be the same as path) self._name = name # The mime type of the attached content. This is detected if not # otherwise specified. self._mimetype = mimetype # The detected_mimetype, this is only used as a fallback if the # mimetype wasn't forced by the user self.detected_mimetype = None # The detected filename by calling child class. A detected filename # is always used if no force naming was specified. self.detected_name = None # Absolute path to attachment self.download_path = None # Track open file pointers self.__pointers = set() # Set our cache flag; it can be True, False, None, or a (positive) # integer... nothing else if cache is not None: try: self.cache = cache if isinstance(cache, bool) else int(cache) except (TypeError, ValueError): err = f"An invalid cache value ({cache}) was specified." self.logger.warning(err) raise AppriseImproperlyConfigured(err) from None # Some simple error checking if self.cache < 0: err = f"A negative cache value ({cache}) was specified." self.logger.warning(err) raise AppriseImproperlyConfigured(err) else: self.cache = None # Validate mimetype if specified if self._mimetype and ( next( ( t for t in mimetypes.types_map.values() if self._mimetype == t ), None, ) is None ): err = f"An invalid mime-type ({mimetype}) was specified." self.logger.warning(err) raise AppriseImproperlyConfigured(err) return @property def path(self): """Returns the absolute path to the filename. If this is not known or is know but has been considered expired (due to cache setting), then content is re-retrieved prior to returning. """ if not self.exists(): # we could not obtain our path return None return self.download_path @property def name(self): """Returns the filename.""" if self._name: # return our fixed content return self._name if not self.exists(): # we could not obtain our name return None if not self.detected_name: # If we get here, our download was successful but we don't have a # filename based on our content. ext = mimetypes.guess_extension(self.mimetype) self.detected_name = ( f"{self.unknown_filename}" f"{ext if ext else self.unknown_filename_extension}" ) return self.detected_name @property def mimetype(self): """Returns mime type (if one is present). Content is cached once determied to prevent overhead of future calls. """ if not self.exists(): # we could not obtain our attachment return None if self._mimetype: # return our pre-calculated cached content return self._mimetype if not self.detected_mimetype: # guess_type() returns: (type, encoding) and sets type to None # if it can't otherwise determine it. with contextlib.suppress(TypeError): # Directly reference _name and detected_name to prevent # recursion loop (as self.name calls this function) self.detected_mimetype, _ = mimetypes.guess_type( self._name if self._name else self.detected_name, strict=self.strict, ) # Return our mime type return ( self.detected_mimetype if self.detected_mimetype else self.unknown_mimetype ) def exists(self, retrieve_if_missing=True): """Simply returns true if the object has downloaded and stored the attachment AND the attachment has not expired.""" if self.location == ContentLocation.INACCESSIBLE: # our content is inaccessible return False cache = ( self.template_args["cache"]["default"] if self.cache is None else self.cache ) try: if ( self.download_path and os.path.isfile(self.download_path) and cache ): # We have enough reason to look further into our cached content # and verify it has not expired. if cache is True: # return our fixed content as is; we will always cache it return True # Verify our cache time to determine whether we will get our # content again. age_in_sec = time.time() - os.stat(self.download_path).st_mtime if age_in_sec <= cache: return True except OSError: # The file is not present pass return False if not retrieve_if_missing else self.download() def base64(self, encoding="ascii"): """Returns the attachment object as a base64 string otherwise None is returned if an error occurs. If encoding is set to None, then it is not encoded when returned """ if not self: # We could not access the attachment self.logger.error( f"Could not access attachment {self.url(privacy=True)}." ) raise exception.AppriseFileNotFound("Attachment Missing") try: with self.open() as f: # Prepare our Attachment in Base64 return ( base64.b64encode(f.read()).decode(encoding) if encoding else base64.b64encode(f.read()) ) except FileNotFoundError: # We no longer have a path to open raise exception.AppriseFileNotFound("Attachment Missing") from None except (TypeError, OSError) as e: self.logger.warning( "An I/O error occurred while reading {}.".format( self.name if self else "attachment" ) ) self.logger.debug(f"I/O Exception: {e!s}") raise exception.AppriseDiskIOError( "Attachment Access Error" ) from e def invalidate(self): """Release any temporary data that may be open by child classes. Externally fetched content should be automatically cleaned up when this function is called. This function should also reset the following entries to None: - detected_name : Should identify a human readable filename - download_path: Must contain a absolute path to content - detected_mimetype: Should identify mimetype of content """ # Close every tracked pointer without letting cleanup errors escape # from invalidate() or __del__(). while self.__pointers: with contextlib.suppress(OSError): self.__pointers.pop().close() self.detected_name = None self.download_path = None self.detected_mimetype = None return def download(self): """This function must be over-ridden by inheriting classes. Inherited classes MUST populate: - detected_name: Should identify a human readable filename - download_path: Must contain a absolute path to content - detected_mimetype: Should identify mimetype of content If a download fails, you should ensure these values are set to None. """ raise NotImplementedError( "download() is implimented by the child class." ) def open(self, mode="rb"): """Return our file pointer and track it (we'll auto close later)""" pointer = open(self.path, mode=mode) # noqa: SIM115 self.__pointers.add(pointer) return pointer def chunk(self, size=5242880): """A Generator that yield chunks of a file with the specified size. By default the chunk size is set to 5MB (5242880 bytes) """ with self.open() as file: while True: chunk = file.read(size) if not chunk: break yield chunk def __enter__(self): """Support with keyword.""" return self.open() def __exit__(self, value_type, value, traceback): """Stub to do nothing; but support exit of with statement gracefully.""" return @staticmethod def parse_url(url, verify_host=True, mimetype_db=None, sanitize=True): """Parses the URL and returns it broken apart into a dictionary. This is very specific and customized for Apprise. Args: url (str): The URL you want to fully parse. verify_host (:obj:`bool`, optional): a flag kept with the parsed URL which some child classes will later use to verify SSL keys (if SSL transactions take place). Unless under very specific circumstances, it is strongly recomended that you leave this default value set to True. Returns: A dictionary is returned containing the URL fully parsed if successful, otherwise None is returned. """ results = URLBase.parse_url( url, verify_host=verify_host, sanitize=sanitize ) if not results: # We're done; we failed to parse our url return results # Allow overriding the default config mime type if "mime" in results["qsd"]: results["mimetype"] = ( results["qsd"].get("mime", "").strip().lower() ) # Allow overriding the default file name if "name" in results["qsd"]: # Strip any directory components to prevent path traversal # (e.g. ?name=/etc/passwd yields "passwd"). If the result is # empty after stripping, treat it as though ?name= was not # specified so the caller falls through to the next name source. _fname = os.path.basename(results["qsd"].get("name", "")).strip() if _fname: results["name"] = _fname # Our cache value if "cache" in results["qsd"]: # First try to get it's integer value try: results["cache"] = int(results["qsd"]["cache"]) except (ValueError, TypeError): # No problem, it just isn't an integer; now treat it as a bool # instead: results["cache"] = parse_bool(results["qsd"]["cache"]) return results def __len__(self): """Returns the filesize of the attachment.""" if not self: return 0 try: return os.path.getsize(self.path) if self.path else 0 except OSError: # OSError can occur if the file is inaccessible return 0 def __bool__(self): """Allows the Apprise object to be wrapped in an based 'if statement'. True is returned if our content was downloaded correctly. """ return bool(self.path) def __del__(self): """Perform any house cleaning.""" self.invalidate() caronc-apprise-dfec414/apprise/attachment/file.py000066400000000000000000000115301526720371000222030ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import os import re from ..common import ContentLocation from ..locale import gettext_lazy as _ from ..utils.disk import path_decode from .base import AttachBase class AttachFile(AttachBase): """A wrapper for File based attachment sources.""" # The default descriptive name associated with the service service_name = _("Local File") # The default protocol protocol = "file" # Content is local to the same location as the apprise instance # being called (server-side) location = ContentLocation.LOCAL def __init__(self, path, **kwargs): """Initialize Local File Attachment Object.""" super().__init__(**kwargs) # Store path but mark it dirty since we have not performed any # verification at this point. self.dirty_path = path_decode(path) # Track our file as it was saved self.__original_path = os.path.normpath(path) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = {} if self._mimetype: # A mime-type was enforced params["mime"] = self._mimetype if self._name: # A name was enforced params["name"] = self._name return "file://{path}{params}".format( path=self.quote(self.__original_path), params=( "?{}".format(self.urlencode(params, safe="/")) if params else "" ), ) def download(self, **kwargs): """Perform retrieval of our data. For file base attachments, our data already exists, so we only need to validate it. """ if self.location == ContentLocation.INACCESSIBLE: # our content is inaccessible return False # Ensure any existing content set has been invalidated self.invalidate() try: if not os.path.isfile(self.dirty_path): return False except OSError: return False if ( self.max_file_size > 0 and os.path.getsize(self.dirty_path) > self.max_file_size ): # The content to attach is to large self.logger.error( "Content exceeds allowable maximum file length" f" ({int(self.max_file_size / 1024)}KB):" f" {self.url(privacy=True)}" ) # Return False (signifying a failure) return False # We're good to go if we get here. Set our minimum requirements of # a call do download() before returning a success self.download_path = self.dirty_path self.detected_name = os.path.basename(self.download_path) # We don't need to set our self.detected_mimetype as it can be # pulled at the time it's needed based on the detected_name return True @staticmethod def parse_url(url): """Parses the URL so that we can handle all different file paths and return it as our path object.""" results = AttachBase.parse_url(url, verify_host=False) if not results: # We're done early; it's not a good URL return results match = re.match(r"file://(?P[^?]+)(\?.*)?", url, re.I) if not match: return None results["path"] = AttachFile.unquote(match.group("path")) return results caronc-apprise-dfec414/apprise/attachment/http.py000066400000000000000000000350401526720371000222450ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations import contextlib import os import re from tempfile import NamedTemporaryFile import threading from typing import Any, Optional import requests from ..common import ContentLocation from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import URL_ENCODED_PATH_SAFE_CHARS from .base import AttachBase class AttachHTTP(AttachBase): """A wrapper for HTTP based attachment sources.""" # The default descriptive name associated with the service service_name = _("Web Based") # The default protocol protocol = "http" # The default secure protocol secure_protocol = "https" # The number of bytes in memory to read from the remote source at a time chunk_size = 8192 # Web based requests are remote/external to our current location location = ContentLocation.HOSTED # thread safe loading _lock = threading.Lock() def __init__( self, headers: Optional[dict[str, str]] = None, http_session: Optional[requests.Session] = None, download_dir: Optional[str] = None, **kwargs: Any, ) -> None: """Initialize an HTTP attachment. ``headers`` adds request headers. ``download_dir`` selects the temporary-file directory and defaults to the system location. """ super().__init__(**kwargs) self.schema = "https" if self.secure else "http" self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "/" self.headers = {} if headers: # Store our extra headers self.headers.update(headers) # Applications may provide a policy-aware Requests session. self.http_session = http_session # Applications may store downloads outside the system temp directory. self.download_dir = download_dir # Where our content is written to upon a call to download. self._temp_file = None # Preserve storage failures for callers that need a detailed error. self.download_error = None # Our Query String Dictionary; we use this to track arguments # specified that aren't otherwise part of this class self.qsd = { k: v for k, v in kwargs.get("qsd", {}).items() if k not in self.template_args } return def download(self, **kwargs: Any) -> bool: """Perform retrieval of the configuration based on the specified request.""" self.download_error = None if self.location == ContentLocation.INACCESSIBLE: # our content is inaccessible return False # prepare header headers = { "User-Agent": self.app_id, } # Apply any/all header over-rides defined headers.update(self.headers) auth = None if self.user: auth = (self.user, self.password) url = f"{self.schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath # Where our request object will temporarily live. r = None # Always call throttle before any remote server i/o is made self.throttle() with self._lock: if self.exists(retrieve_if_missing=False): # Due to locking; it's possible a concurrent thread already # handled the retrieval in which case we can safely move on self.logger.trace( "HTTP Attachment %s already retrieved", self._temp_file.name, ) return True # Ensure any existing content set has been invalidated self.invalidate() self.logger.debug( "HTTP Attachment Fetch URL:" f" {url} (cert_verify={self.verify_certificate!r})" ) try: # Use the caller's transport when it needs destination policy. http_get = ( self.http_session.get if self.http_session is not None else requests.get ) with http_get( url, headers=headers, auth=auth, params=self.qsd, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, stream=True, ) as r: # Handle Errors r.raise_for_status() # Reject every 3xx response when redirects are disabled, # including responses without a Location header. if not self.redirects and 300 <= r.status_code < 400: self.logger.error( "HTTP redirect encountered but redirect " "following is disabled:" f" {self.url(privacy=True)}" ) return False # Get our file-size (if known) try: file_size = int(r.headers.get("Content-Length", "0")) except (TypeError, ValueError): # Handle edge case where Content-Length is a bad value file_size = 0 # Perform a little Q/A on file limitations and restrictions if ( self.max_file_size > 0 and file_size > self.max_file_size ): # The content is too large. self.logger.error( "HTTP response exceeds allowable maximum file" f" length ({int(self.max_file_size / 1024)}KB):" f" {self.url(privacy=True)}" ) # Return False (signifying a failure) return False # Detect config format based on mime if the format isn't # already enforced self.detected_mimetype = r.headers.get("Content-Type") d = r.headers.get("Content-Disposition", "") result = re.search( r"filename=['\"]?(?P[^'\"]+)['\"]?", d, re.I ) if result: self.detected_name = result.group("name").strip() # Keep the file on Windows; __del__ cleans it up. self._temp_file = NamedTemporaryFile( # noqa: SIM115 delete=False, dir=self.download_dir, ) # Get our chunk size chunk_size = self.chunk_size # Track all bytes written to disk bytes_written = 0 # If we get here, we can now safely write our content to # disk for chunk in r.iter_content(chunk_size=chunk_size): # filter out keep-alive chunks if chunk: # Enforce the limit while streaming because the # server may omit or misreport Content-Length. if ( self.max_file_size > 0 and bytes_written + len(chunk) > self.max_file_size ): # The downloaded content is too large. self.logger.error( "HTTP response exceeds allowable" " maximum file length" f" ({int(self.max_file_size / 1024)}" f"KB): {self.url(privacy=True)}" ) # Invalidate any variables previously set self.invalidate() # Return False (signifying a failure) return False self._temp_file.write(chunk) bytes_written = self._temp_file.tell() # Ensure our content is flushed to disk for post-processing self._temp_file.flush() # Set our minimum requirements for a successful download() # call self.download_path = self._temp_file.name if not self.detected_name: self.detected_name = os.path.basename(self.fullpath) except requests.RequestException as e: self.logger.error( "A Connection error occurred retrieving HTTP " f"configuration from {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Invalidate any variables previously set self.invalidate() # Return False (signifying a failure) return False except OSError as e: # IOError remains for compatibility; modern Python raises # OSError here. self.download_error = e # Could not open and/or write the temporary file self.logger.error( "Could not write attachment to disk:" f" {self.url(privacy=True)}" ) # Invalidate any variables previously set self.invalidate() # Return False (signifying a failure) return False # Return our success return True def invalidate(self): """Close our temporary file.""" if self._temp_file: self.logger.trace("Attachment cleanup of %s", self._temp_file.name) # Do not let temporary-file cleanup errors escape from __del__(). with contextlib.suppress(OSError): self._temp_file.close() with contextlib.suppress(OSError): # Ensure our file is removed (if it exists) os.unlink(self._temp_file.name) # Reset our temporary file to prevent from entering # this block again self._temp_file = None super().invalidate() def __del__(self): """Tidy memory if open.""" self.invalidate() def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Our URL parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) # Prepare our cache value if self.cache is not None: if isinstance(self.cache, bool) or not self.cache: cache = "yes" if self.cache else "no" else: cache = int(self.cache) # Set our cache value params["cache"] = cache if self._mimetype: # A format was enforced params["mime"] = self._mimetype if self._name: # A name was enforced params["name"] = self._name # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Apply any remaining entries to our URL params.update(self.qsd) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=self.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=self.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, hostname=self.quote(self.host, safe=""), port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=self.quote( self.fullpath, safe=URL_ENCODED_PATH_SAFE_CHARS ), params=self.urlencode(params, safe="/"), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = AttachBase.parse_url(url, sanitize=False) if not results: # We're done early as we couldn't load the results return results # Add our headers that the user can potentially over-ride if they wish # to to our returned result set results["headers"] = results["qsd-"] results["headers"].update(results["qsd+"]) return results caronc-apprise-dfec414/apprise/attachment/memory.py000066400000000000000000000157061526720371000226050ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import base64 import io import os import re from typing import Any import uuid from .. import exception from ..common import ContentLocation from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from .base import AttachBase class AttachMemory(AttachBase): """A wrapper for Memory based attachment sources.""" # The default descriptive name associated with the service service_name = _("Memory") # The default protocol protocol = "memory" # Content is local to the same location as the apprise instance # being called (server-side) location = ContentLocation.LOCAL def __init__( self, content=None, name=None, mimetype=None, encoding="utf-8", **kwargs, ): """Initialize Memory Based Attachment Object.""" # Create our BytesIO object self._data = io.BytesIO() if content is None: # Empty; do nothing pass elif isinstance(content, str): content = content.encode(encoding) if mimetype is None: mimetype = "text/plain" if not name: # Generate a unique filename name = str(uuid.uuid4()) + ".txt" elif not isinstance(content, bytes): raise AppriseImproperlyConfigured( "Provided content for memory attachment is invalid" ) # Store our content if content: self._data.write(content) if mimetype is None: # Default mimetype mimetype = "application/octet-stream" if not name: # Generate a unique filename name = str(uuid.uuid4()) + ".dat" # Initialize our base object super().__init__(name=name, mimetype=mimetype, **kwargs) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "mime": self._mimetype, } return "memory://{name}?{params}".format( name=self.quote(self._name), params=self.urlencode(params, safe="/"), ) def open(self, *args: Any, **kwargs: Any) -> io.BytesIO: """Return an independent handle to our memory object.""" self._data.seek(0, 0) # Return a new handle that callers can safely close. return io.BytesIO(self._data.getvalue()) def __enter__(self): """Support with clause.""" # Return our object self._data.seek(0, 0) return self._data def download(self, **kwargs): """Handle memory download() call.""" if self.location == ContentLocation.INACCESSIBLE: # our content is inaccessible return False if self.max_file_size > 0 and len(self) > self.max_file_size: # The content to attach is to large self.logger.error( "Content exceeds allowable maximum memory size" f" ({int(self.max_file_size / 1024)}KB):" f" {self.url(privacy=True)}" ) # Return False (signifying a failure) return False return True def base64(self, encoding="ascii"): """We need to over-ride this since the base64 sub-library seems to close our file descriptor making it no longer referencable.""" if not self: # We could not access the attachment self.logger.error( f"Could not access attachment {self.url(privacy=True)}." ) raise exception.AppriseFileNotFound("Attachment Missing") self._data.seek(0, 0) return ( base64.b64encode(self._data.read()).decode(encoding) if encoding else base64.b64encode(self._data.read()) ) def invalidate(self): """Removes data.""" if not self._data.closed: self._data.truncate(0) return def exists(self): """Over-ride exists() call.""" size = len(self) return bool( self.location != ContentLocation.INACCESSIBLE and size > 0 and ( self.max_file_size <= 0 or (self.max_file_size > 0 and size <= self.max_file_size) ) ) @staticmethod def parse_url(url): """Parses the URL so that we can handle all different file paths and return it as our path object.""" results = AttachBase.parse_url(url, verify_host=False) if not results: # We're done early; it's not a good URL return results if "name" not in results: # Allow fall-back to be from URL match = re.match(r"memory://(?P[^?]+)(\?.*)?", url, re.I) if match: # Store our filename only (ignore any defined paths) results["name"] = os.path.basename( AttachMemory.unquote(match.group("path")) ) return results @property def path(self): """Return the filename.""" if not self.exists(): # we could not obtain our path return None return self._name def __len__(self): """Returns the size of he memory attachment.""" return self._data.getbuffer().nbytes def __bool__(self): """Allows the Apprise object to be wrapped in an based 'if statement'. True is returned if our content was downloaded correctly. """ return self.exists() caronc-apprise-dfec414/apprise/cli.py000066400000000000000000001507551526720371000177200ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import contextlib import logging import os from os.path import exists, isfile import platform import re import shutil import sys import textwrap import time import click from . import ( Apprise, AppriseAsset, AppriseConfig, AppriseResultStatus, NotificationManager, NotifyTemplate, PersistentStore, __copyright__, __license__, __title__, __version__, ) from .apprise import ( abandoned_call_descriptions, any_abandoned_calls_still_running, ) from .common import ( NOTIFY_FORMATS, NOTIFY_TYPES, PERSISTENT_STORE_MODES, ContentLocation, NotifyFormat, NotifyType, PersistentStoreMode, PersistentStoreState, ) from .logger import logger from .utils.disk import bytes_to_str, dir_size, path_decode from .utils.parse import GET_SCHEMA_RE, parse_list from .utils.template import TEMPLATE_NAME_RE, normalize_name # By default we allow looking 1 level down recursively in Apprise configuration # files. DEFAULT_RECURSION_DEPTH = 1 # Defines the environment variable to parse if defined. This is ONLY # Referenced if: # - No Configuration Files were found/loaded/specified # - No URLs were provided directly into the CLI Call DEFAULT_ENV_APPRISE_URLS = "APPRISE_URLS" # Defines the override path for the configuration files read DEFAULT_ENV_APPRISE_CONFIG_PATH = "APPRISE_CONFIG_PATH" # Defines the override path for the plugins to load DEFAULT_ENV_APPRISE_PLUGIN_PATH = "APPRISE_PLUGIN_PATH" # Defines the override path for the persistent storage DEFAULT_ENV_APPRISE_STORAGE_PATH = "APPRISE_STORAGE_PATH" # Environment variable controlling the storage retention period DEFAULT_ENV_APPRISE_STORAGE_PRUNE_DAYS = "APPRISE_STORAGE_PRUNE_DAYS" # Environment variable controlling generated storage URL ID length DEFAULT_ENV_APPRISE_STORAGE_UID_LENGTH = "APPRISE_STORAGE_UID_LENGTH" # Environment variables holding a value that is not a valid number, as # (name, value). main() reports them and exits instead of guessing. INVALID_ENV: list[tuple[str, str]] = [] def _env_int(name: str, default: int) -> int: """Return environment variable ``name`` as an int, else ``default``.""" # An unset or empty variable quietly uses the default value = os.environ.get(name, "").strip() if not value: return default try: return int(value) except ValueError: # Let the module load so main() can explain the problem clearly INVALID_ENV.append((name, value)) return default # Default number of days to prune persistent storage DEFAULT_STORAGE_PRUNE_DAYS = _env_int( DEFAULT_ENV_APPRISE_STORAGE_PRUNE_DAYS, 30 ) # The default URL ID Length DEFAULT_STORAGE_UID_LENGTH = _env_int( DEFAULT_ENV_APPRISE_STORAGE_UID_LENGTH, 8 ) # Grace period for abandoned service calls to finish before forced exit. # Normal interpreter shutdown would otherwise wait on those threads. CLI_TIMEOUT_EXIT_GRACE_SECONDS = 5.0 # Poll interval while waiting for abandoned service calls to finish. CLI_TIMEOUT_EXIT_POLL_INTERVAL = 0.25 # Defines our click context settings adding -h to the additional options that # can be specified to get the help menu to come up CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} # Define our default configuration we use if nothing is otherwise specified DEFAULT_CONFIG_PATHS = ( # Legacy Path Support "~/.apprise", "~/.apprise.conf", "~/.apprise.yml", "~/.apprise.yaml", "~/.config/apprise", "~/.config/apprise.conf", "~/.config/apprise.yml", "~/.config/apprise.yaml", # Plugin Support Extended Directory Search Paths "~/.apprise/apprise", "~/.apprise/apprise.conf", "~/.apprise/apprise.yml", "~/.apprise/apprise.yaml", "~/.config/apprise/apprise", "~/.config/apprise/apprise.conf", "~/.config/apprise/apprise.yml", "~/.config/apprise/apprise.yaml", # Global Configuration File Support "/etc/apprise", "/etc/apprise.yml", "/etc/apprise.yaml", "/etc/apprise/apprise", "/etc/apprise/apprise.conf", "/etc/apprise/apprise.yml", "/etc/apprise/apprise.yaml", ) # Define our paths to search for plugins DEFAULT_PLUGIN_PATHS = ( "~/.apprise/plugins", "~/.config/apprise/plugins", # Global Plugin Support "/var/lib/apprise/plugins", ) # # General Options and Defaults # DEFAULT_NOTIFY_TYPE = NotifyType.INFO NOTIFY_TYPE_CHOICES: tuple[NotifyType, ...] = ( NotifyType.INFO, NotifyType.SUCCESS, NotifyType.WARNING, NotifyType.FAILURE, ) DEFAULT_NOTIFY_FORMAT = NotifyFormat.TEXT NOTIFY_FORMAT_CHOICES: tuple[NotifyFormat, ...] = ( NotifyFormat.TEXT, NotifyFormat.MARKDOWN, NotifyFormat.HTML, ) # # Persistent Storage # DEFAULT_STORAGE_PATH = "~/.local/share/apprise/cache" # Storage Mode DEFAULT_STORAGE_MODE = PersistentStoreMode.AUTO # Create an ordered list of options (first is default) PERSISTENT_STORE_MODE_CHOICES: tuple[PersistentStoreMode, ...] = ( PersistentStoreMode.AUTO, PersistentStoreMode.FLUSH, PersistentStoreMode.MEMORY, ) # Detect Windows if platform.system() == "Windows": # Default Config Search Path for Windows Users DEFAULT_CONFIG_PATHS = ( "%APPDATA%\\Apprise\\apprise", "%APPDATA%\\Apprise\\apprise.conf", "%APPDATA%\\Apprise\\apprise.yml", "%APPDATA%\\Apprise\\apprise.yaml", "%LOCALAPPDATA%\\Apprise\\apprise", "%LOCALAPPDATA%\\Apprise\\apprise.conf", "%LOCALAPPDATA%\\Apprise\\apprise.yml", "%LOCALAPPDATA%\\Apprise\\apprise.yaml", # # Global Support # # C:\ProgramData\Apprise "%ALLUSERSPROFILE%\\Apprise\\apprise", "%ALLUSERSPROFILE%\\Apprise\\apprise.conf", "%ALLUSERSPROFILE%\\Apprise\\apprise.yml", "%ALLUSERSPROFILE%\\Apprise\\apprise.yaml", # C:\Program Files\Apprise "%PROGRAMFILES%\\Apprise\\apprise", "%PROGRAMFILES%\\Apprise\\apprise.conf", "%PROGRAMFILES%\\Apprise\\apprise.yml", "%PROGRAMFILES%\\Apprise\\apprise.yaml", # C:\Program Files\Common Files "%COMMONPROGRAMFILES%\\Apprise\\apprise", "%COMMONPROGRAMFILES%\\Apprise\\apprise.conf", "%COMMONPROGRAMFILES%\\Apprise\\apprise.yml", "%COMMONPROGRAMFILES%\\Apprise\\apprise.yaml", ) # Default Plugin Search Path for Windows Users DEFAULT_PLUGIN_PATHS = ( "%APPDATA%\\Apprise\\plugins", "%LOCALAPPDATA%\\Apprise\\plugins", # # Global Support # # C:\ProgramData\Apprise\plugins "%ALLUSERSPROFILE%\\Apprise\\plugins", # C:\Program Files\Apprise\plugins "%PROGRAMFILES%\\Apprise\\plugins", # C:\Program Files\Common Files "%COMMONPROGRAMFILES%\\Apprise\\plugins", ) # # Persistent Storage # DEFAULT_STORAGE_PATH = "%APPDATA%/Apprise/cache" class PersistentStorageMode: """Persistent Storage Modes.""" # List all detected configuration loaded LIST = "list" # Prune persistent storage based on age PRUNE = "prune" # Reset all (regardless of age) CLEAR = "clear" # Define the types in a list for validation purposes PERSISTENT_STORAGE_MODES = ( PersistentStorageMode.LIST, PersistentStorageMode.PRUNE, PersistentStorageMode.CLEAR, ) if os.environ.get(DEFAULT_ENV_APPRISE_STORAGE_PATH, "").strip(): # Override Default Storage Path DEFAULT_STORAGE_PATH = os.environ.get(DEFAULT_ENV_APPRISE_STORAGE_PATH) def _log_runtime_env(): """Log Python, OS, encoding, and optional-dependency versions. Only runs when the logger is at DEBUG level or below. """ # Skip all work when DEBUG output is not enabled if not logger.isEnabledFor(logging.DEBUG): return import contextlib import locale # One-line environment summary logger.debug("Apprise: %s", __version__) logger.debug("Python: %s", platform.python_version()) logger.debug("Platform: %s", platform.platform()) logger.debug("Encoding: %s", locale.getpreferredencoding(False)) # Collect unique runtime-dep import names from all enabled plugins dep_names = set() for module in NotificationManager(): for plugin in module.get("plugin", ()): # Skip disabled plugins if not getattr(plugin, "enabled", True): continue fn = getattr(plugin, "runtime_deps", None) if not callable(fn): continue with contextlib.suppress(Exception): dep_names.update(fn()) # Nothing to report if not dep_names: return # packages_distributions() was added in Python 3.11; on older # Python we skip the dep listing but the env summary above is # still emitted. try: from importlib.metadata import ( PackageNotFoundError, packages_distributions, version as pkg_version, ) except ImportError: return # Resolve import names -> pip distribution names, then fetch # versions. packages_distributions() returns: # {import_name: [dist_name, ...]} try: dist_map = packages_distributions() except Exception: return # Build a sorted list of "name=version" pairs for resolved packages resolved = [] for name in sorted(dep_names): dist_names = dist_map.get(name, []) if not dist_names: # Import name not mapped to any installed distribution continue try: ver = pkg_version(dist_names[0]) except (PackageNotFoundError, Exception): # Package listed but not actually installed; skip silently continue resolved.append("{}={}".format(dist_names[0], ver)) if resolved: logger.debug("Runtime deps: %s", ", ".join(resolved)) def print_version_msg(): """Prints version message when -V or --version is specified.""" result = [] result.append(f"{__title__} v{__version__}") result.append(__copyright__) result.append(f"This code is licensed under the {__license__} License.") click.echo("\n".join(result)) class CustomHelpCommand(click.Command): def format_help(self, ctx, formatter): formatter.write_text("Usage:") formatter.write_text( " apprise [OPTIONS] [APPRISE_URL [APPRISE_URL2 [APPRISE_URL3]]]" ) formatter.write_text( " apprise storage [OPTIONS] [ACTION] [UID1 [UID2 [UID3]]]" ) # Custom help message formatter.write_text("") content = ( ("Send a notification to the services identified by their URLs"), ( "the content provided within the title, body and " "notification-type." ), "", ( "For a list of all of the supported services and information" " on how to use " ), "them, check out https://github.com/caronc/apprise", ) for line in content: formatter.write_text(line) # Display options and arguments in the default format self.format_options(ctx, formatter) self.format_epilog(ctx, formatter) # Custom 'Actions:' section after the 'Options:' formatter.write_text("") formatter.write_text("Actions:") actions = [ ( "storage", "Access the persistent storage disk administration", [ ( "list", ( "List all URL IDs associated with detected" " URL(s). This is also the default action" " run if nothing is provided" ), ), ( "prune", ( "Eliminates stale entries found based on " "--storage-prune-days (-SPD)" ), ), ( "clean", "Removes any persistent data created by Apprise", ), ], ) ] # # Some variables # # actions are indented this many spaces # sub actions double this value action_indent = 2 # label padding (for alignment) action_label_width = 10 space = " " space_re = re.compile(r"\r*\n") cols = 80 indent = 10 # Format each action and its subactions for action, description, sub_actions in actions: # Our action indent ai = " " * action_indent # Format the main action description formatted_description = space_re.split( textwrap.fill( description, width=(cols - indent - action_indent), initial_indent=space * indent, subsequent_indent=space * indent, ) ) for no, line in enumerate(formatted_description): if not no: formatter.write_text( f"{ai}{action:<{action_label_width}}{line}" ) else: # pragma: no cover # Note: no branch is set intentionally since this is not # tested since in 2025.08.13 when this was set up # it never entered this area of the code. But we # know it works because we repeat this process with # our sub-options below formatter.write_text( f"{ai}{space:<{action_label_width}}{line}" ) # Format each subaction ai = " " * (action_indent * 2) for action, description in sub_actions: formatted_description = space_re.split( textwrap.fill( description, width=(cols - indent - (action_indent * 3)), initial_indent=space * (indent - action_indent), subsequent_indent=space * (indent - action_indent), ) ) for no, line in enumerate(formatted_description): if not no: formatter.write_text( f"{ai}{action:<{action_label_width}}{line}" ) else: formatter.write_text( f"{ai}{space:<{action_label_width}}{line}" ) # Include any epilog or additional text self.format_epilog(ctx, formatter) def _wait_for_abandoned_calls(timeout: float) -> bool: """Wait up to ``timeout`` for abandoned service calls to finish. Return ``True`` once no tracked calls remain. """ # Log descriptions with private URL details hidden. Background work owned # entirely by a plugin is not tracked here. descriptions = abandoned_call_descriptions() logger.debug( "One or more services timed out. Waiting up to %.1fs more for " "these services to finish: %s", timeout, ", ".join(descriptions) if descriptions else "(no services are still running)", ) # Count our short waits so the CLI never exceeds the grace period. elapsed = 0.0 while elapsed < timeout: # Stop early as soon as every background call is done. if not any_abandoned_calls_still_running(): logger.debug( "Timed-out service calls finished after %.2fs.", elapsed, ) return True # The final sleep may be shorter than the normal polling interval. sleep_for = min(CLI_TIMEOUT_EXIT_POLL_INTERVAL, timeout - elapsed) time.sleep(sleep_for) elapsed += sleep_for # Check once more in case the final call ended during the last sleep. still_running = any_abandoned_calls_still_running() if still_running: logger.debug( "%.1fs elapsed and some timed-out services are still running; " "exiting now.", timeout, ) # Tell the caller whether normal shutdown is now safe. return not still_running def _force_exit(apobj: Apprise, status: AppriseResultStatus) -> None: """Unconditionally end the process, skipping Python's normal shutdown sequence entirely. """ # os._exit() skips normal object cleanup, so flush every store first. # A failed flush must not stop the remaining services from trying. for service in apobj: try: logger.trace( "Flushing persistent store for service: %s", service.url(privacy=True), ) service.flush_store() except Exception as e: logger.warning( "Could not save one service's persistent data before " "exiting; continuing with the remaining services." ) logger.debug("Persistent store save error: %s", e) # os._exit() skips atexit hooks, so flush logging and stdio manually. # Each flush is independent; one failure should not block the others. with contextlib.suppress(Exception): logging.shutdown() with contextlib.suppress(Exception): sys.stdout.flush() with contextlib.suppress(Exception): sys.stderr.flush() # Hard-exit last so abandoned worker threads cannot keep the CLI alive. os._exit(status) @click.command(context_settings=CONTEXT_SETTINGS, cls=CustomHelpCommand) @click.option( "--body", "-b", default=None, type=str, help=( "Specify the message body. If no body is specified then " "content is read from ." ), ) @click.option( "--title", "-t", default=None, type=str, help="Specify the message title. This field is completely optional.", ) @click.option( "--plugin-path", "-P", default=None, type=str, multiple=True, metavar="PATH", help="Specify one or more plugin paths to scan.", ) @click.option( "--storage-path", "-S", default=DEFAULT_STORAGE_PATH, type=str, metavar="PATH", help=( "Specify the path to the persistent storage location " f"(default={DEFAULT_STORAGE_PATH})." ), ) @click.option( "--storage-prune-days", "-SPD", default=DEFAULT_STORAGE_PRUNE_DAYS, type=int, help=( "Define the number of days the storage prune should run using." " Setting this to zero (0) will eliminate all accumulated content. By" f" default this value is {DEFAULT_STORAGE_PRUNE_DAYS} days." ), ) @click.option( "--storage-uid-length", "-SUL", default=DEFAULT_STORAGE_UID_LENGTH, type=int, help=( "Define the number of unique characters to store persistent cache in." f" By default this value is {DEFAULT_STORAGE_UID_LENGTH} characters." ), ) @click.option( "--storage-mode", "-SM", default=DEFAULT_STORAGE_MODE.value, type=str, metavar="MODE", help=( "Specify the persistent storage operational mode " f"(default={DEFAULT_STORAGE_MODE.value}). " 'Possible values are: "{}".'.format( '", "'.join(mode.value for mode in PERSISTENT_STORE_MODE_CHOICES) ) ), ) @click.option( "--config", "-c", default=None, type=str, multiple=True, metavar="CONFIG_URL", help="Specify one or more configuration locations.", ) @click.option( "--attach", "-a", default=None, type=str, multiple=True, metavar="ATTACHMENT_URL", help="Specify one or more attachments.", ) @click.option( "--notification-type", "-n", default=DEFAULT_NOTIFY_TYPE.value, type=str, metavar="TYPE", help=( f"Specify the message type (default={DEFAULT_NOTIFY_TYPE.value}). " 'Possible values are: "{}".'.format( '", "'.join(nt.value for nt in NOTIFY_TYPE_CHOICES) ) ), ) @click.option( "--input-format", "-i", default=DEFAULT_NOTIFY_FORMAT.value, type=str, metavar="FORMAT", help=( f"Specify the message input format " f"(default={DEFAULT_NOTIFY_FORMAT.value}). " 'Possible values are: "{}".'.format( '", "'.join(fmt.value for fmt in NOTIFY_FORMAT_CHOICES) ) ), ) @click.option( "--theme", "-T", default="default", type=str, metavar="THEME", help="Specify the default theme.", ) @click.option( "--template-var", "-tv", "template_var", default=None, type=str, multiple=True, metavar="NAME=VALUE", help=( "Supply a value used by a YAML configuration written with " "${NAME}. Use multiple --template-var (-tv) entries for more " "than one. A value not given here is looked for in the " "configuration's own default and then in " "APPRISE_TEMPLATE_." ), ) @click.option( "--tag", "-g", default=None, type=str, multiple=True, metavar="TAG", help=( "Specify one or more tags to filter which services to notify. Use " "multiple --tag (-g) entries to match ANY tag. Use comma separators " "to require ALL tags (strict match). Omit to notify untagged services " 'only, or use "all" to notify everything.' ), ) @click.option( "--limit", "-L", default=0, type=float, metavar="SECONDS", help=( "Give up on the whole run if it's taking too long, in seconds " "(whole number or decimal, e.g. 2.5). By default (0) there is " "no limit -- see --service-limit to cap each service on its own " "instead." ), ) @click.option( "--service-limit", "-SL", default=None, type=float, metavar="SECONDS", help=( "Give up on any single service if it's taking too long, in " "seconds (whole number or decimal, e.g. 2.5), or 0 to turn this " "off entirely. Leave unset to use Apprise's own default of " f"{AppriseAsset._service_timeout:g} seconds." ), ) @click.option( "--disable-async", "-Da", is_flag=True, help="Send all notifications sequentially", ) @click.option( "--dry-run", "-d", is_flag=True, help=( "Perform a trial run but only prints the notification " "services to-be triggered to stdout. Notifications are never " "sent using this mode." ), ) @click.option( "--details", "-l", is_flag=True, help="Prints details about the current services supported by Apprise.", ) @click.option( "--recursion-depth", "-R", default=DEFAULT_RECURSION_DEPTH, type=int, help=( "The number of recursive import entries that can be " "loaded from within Apprise configuration. By default " f"this is set to {DEFAULT_RECURSION_DEPTH}." ), ) @click.option( "--verbose", "-v", count=True, help=( "Makes the operation more talkative. Use multiple v to " "increase the verbosity. I.e.: -vvvv" ), ) @click.option( "--interpret-escapes", "-e", is_flag=True, help="Enable interpretation of backslash escapes", ) @click.option( "--interpret-emojis", "-j", is_flag=True, help="Enable interpretation of :emoji: definitions", ) @click.option("--debug", "-D", is_flag=True, help="Debug mode") @click.option( "--version", "-V", is_flag=True, help="Display the apprise version and exit.", ) @click.argument( "urls", nargs=-1, metavar="SERVICE_URL [SERVICE_URL2 [SERVICE_URL3]]", ) @click.pass_context def main( ctx, body, title, config, attach, urls, notification_type, theme, template_var, tag, input_format, dry_run, limit, service_limit, recursion_depth, verbose, disable_async, details, interpret_escapes, interpret_emojis, plugin_path, storage_path, storage_mode, storage_prune_days, storage_uid_length, debug, version, ): """Send the supplied title, body, and notification type to the services identified by the given URLs or configuration sources. For a list of all of the supported services and information on how to use them, check out https://github.com/caronc/apprise """ # Note: Click ignores the return values of functions it wraps, If you # want to return a specific error code, you must call ctx.exit() # as you will see below. debug = bool(debug) if debug: # Verbosity must be a minimum of 3 verbose = 3 if verbose < 3 else verbose # Logging ch = logging.StreamHandler(sys.stdout) if verbose > 3: # -vvvv: Most Verbose Debug Logging logger.setLevel(logging.TRACE) elif verbose > 2: # -vvv: Debug Logging logger.setLevel(logging.DEBUG) elif verbose > 1: # -vv: INFO Messages logger.setLevel(logging.INFO) elif verbose > 0: # -v: WARNING Messages logger.setLevel(logging.WARNING) else: # No verbosity means we display ERRORS only AND any deprecation # warnings logger.setLevel(logging.ERROR) # Format our logger formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") ch.setFormatter(formatter) logger.addHandler(ch) # Update our asyncio logger asyncio_logger = logging.getLogger("asyncio") for handler in logger.handlers: asyncio_logger.addHandler(handler) asyncio_logger.setLevel(logger.level) # Log environment and runtime-dependency versions when debugging _log_runtime_env() if version: print_version_msg() ctx.exit(0) # Simple Error Checking notification_type = notification_type.strip().lower() if notification_type not in NOTIFY_TYPES: click.echo( f"The --notification-type (-n) value of {notification_type} is not" " supported." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a parameter # issue. For consistency, we also return a 2 ctx.exit(2) input_format = input_format.strip().lower() if input_format not in NOTIFY_FORMATS: click.echo( f"The --input-format (-i) value of {input_format} is not" " supported." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a parameter # issue. For consistency, we also return a 2 ctx.exit(2) storage_mode = storage_mode.strip().lower() if storage_mode not in PERSISTENT_STORE_MODES: click.echo( f"The --storage-mode (-SM) value of {storage_mode} is not" " supported." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a parameter # issue. For consistency, we also return a 2 ctx.exit(2) # # Apply Environment Overrides if defined # config_paths = DEFAULT_CONFIG_PATHS if DEFAULT_ENV_APPRISE_CONFIG_PATH in os.environ: logger.debug( f"Loading provided {DEFAULT_ENV_APPRISE_CONFIG_PATH} " "environment variable" ) config_paths = re.split( r"[\r\n;]+", os.environ.get(DEFAULT_ENV_APPRISE_CONFIG_PATH).strip(), ) elif "APPRISE_CONFIG" in os.environ: logger.deprecate( "The APPRISE_CONFIG environment variable has been renamed to " f"{DEFAULT_ENV_APPRISE_CONFIG_PATH}" ) config_paths = re.split( r"[\r\n;]+", os.environ.get("APPRISE_CONFIG").strip(), ) plugin_paths_ = DEFAULT_PLUGIN_PATHS if DEFAULT_ENV_APPRISE_PLUGIN_PATH in os.environ: logger.debug( f"Loading provided {DEFAULT_ENV_APPRISE_PLUGIN_PATH} environment " "variable" ) plugin_paths_ = re.split( r"[\r\n;]+", os.environ.get(DEFAULT_ENV_APPRISE_PLUGIN_PATH).strip(), ) if DEFAULT_ENV_APPRISE_STORAGE_PATH in os.environ: logger.debug( f"Loading provided {DEFAULT_ENV_APPRISE_STORAGE_PATH} environment " "variable" ) storage_path = os.environ.get(DEFAULT_ENV_APPRISE_STORAGE_PATH).strip() # # Continue with initialization process # # Prepare a default set of plugin paths to scan; anything specified # on the CLI always trumps plugin_paths = ( plugin_path if plugin_path else [path for path in plugin_paths_ if exists(path_decode(path))] ) if INVALID_ENV: for name, value in INVALID_ENV: click.echo( f"The {name} environment variable value of {value!r} is not" " a valid number." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a # parameter issue. For consistency, we also return a 2 ctx.exit(2) if storage_uid_length < 2: click.echo( "The --storage-uid-length (-SUL) value can not be lower " "than two (2)." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a # parameter issue. For consistency, we also return a 2 ctx.exit(2) # Prepare our asset asset = AppriseAsset( # Our body format body_format=input_format, # Interpret Escapes interpret_escapes=interpret_escapes, # Interpret Emojis interpret_emojis=None if not interpret_emojis else True, # Set the theme theme=theme, # Async mode allows a user to send all of their notifications # asynchronously. This was made an option incase there are problems # in the future where it is better that everything runs sequentially/ # synchronously instead. async_mode=disable_async is not True, # Load our plugins plugin_paths=plugin_paths, # Load our persistent storage path storage_path=path_decode(storage_path), # Our storage URL ID Length storage_idlen=storage_uid_length, # Define if we flush to disk as soon as possible or not when required storage_mode=storage_mode, # Apply the optional per-service delivery budget to every plugin. service_timeout=service_limit, ) # Create our Apprise object a = Apprise(asset=asset, debug=debug, location=ContentLocation.LOCAL) # Track if we are performing a storage action storage_action = bool(urls and "storage".startswith(urls[0])) # Save the original state; the storage block below always needs these. _in_storage_mode = storage_action _raw_urls = urls if storage_action: # Scan storage args for URL-filter entries. If any are found, # remap urls to only those entries and flip storage_action so the # priority chain (rule #1 below) loads them into 'a' (our Apprise # instance). This mirrors the sending notification process. _sfp = list(urls[1:]) if _sfp and any( m.startswith(_sfp[0]) for m in PERSISTENT_STORAGE_MODES ): # Skip the action word ("list", "prune", "clear") _sfp = _sfp[1:] _url_filters = [_f for _f in _sfp if GET_SCHEMA_RE.match(_f)] if _url_filters: # Hand off to rule #1 by replacing urls and clearing the flag. urls = _url_filters storage_action = False if details: # Print details and exit results = a.details(show_requirements=True, show_disabled=True) # Sort our results: plugins = sorted( results["schemas"], key=lambda i: str(i["service_name"]) ) for entry in plugins: protocols = ( [] if not entry["protocols"] else [p for p in entry["protocols"] if isinstance(p, str)] ) protocols.extend( [] if not entry["secure_protocols"] else [ p for p in entry["secure_protocols"] if isinstance(p, str) ] ) if len(protocols) == 1: # Simplify view by swapping {schema} with the single # protocol value # Convert tuple to list entry["details"]["templates"] = list( entry["details"]["templates"] ) for x in range(len(entry["details"]["templates"])): entry["details"]["templates"][x] = re.sub( r"^[^}]+}://", f"{protocols[0]}://", entry["details"]["templates"][x], ) fg = "green" if entry["enabled"] else "red" if entry["category"] == "custom": # Identify these differently fg = "cyan" # Flip the enable switch so it forces the requirements # to be displayed entry["enabled"] = False click.echo( click.style( "{} {:<30} ".format( "+" if entry["enabled"] else "-", str(entry["service_name"]), ), fg=fg, bold=True, ), nl=(not entry["enabled"] or len(protocols) == 1), ) if not entry["enabled"]: if entry["requirements"]["details"]: click.echo(" " + str(entry["requirements"]["details"])) if entry["requirements"]["packages_required"]: click.echo(" Python Packages Required:") for req in entry["requirements"]["packages_required"]: click.echo(" - " + req) if entry["requirements"]["packages_recommended"]: click.echo(" Python Packages Recommended:") for req in entry["requirements"]["packages_recommended"]: click.echo(" - " + req) # new line padding between entries if entry["category"] == "native": click.echo() continue if len(protocols) > 1: click.echo( "| Schema(s): {}".format( ", ".join(protocols), ) ) prefix = " - " click.echo( "{}{}".format( prefix, f"\n{prefix}".join(entry["details"]["templates"]) ) ) # new line padding between entries click.echo() ctx.exit(0) # end if details() # The priorities of what is accepted are parsed in order below: # 1. URLs by command line # 2. Configuration by command line # 3. URLs by environment variable: APPRISE_URLS # 4. Default Configuration File(s) # elif urls and not storage_action: if tag: # Ignore any tags specified logger.warning( "--tag (-g) entries are ignored when using specified URLs" ) tag = None # Load our URLs (if any defined) for url in urls: a.add(url) if config: # Provide a warning to the end user if they specified both logger.warning( "You defined both URLs and a --config (-c) entry; " "Only the URLs will be referenced." ) elif config: # We load our configuration file(s) now only if no URLs were specified # Specified config entries trump all a.add( AppriseConfig(paths=config, asset=asset, recursion=recursion_depth) ) elif os.environ.get(DEFAULT_ENV_APPRISE_URLS, "").strip(): logger.debug( f"Loading provided {DEFAULT_ENV_APPRISE_URLS} environment variable" ) if tag: # Ignore any tags specified logger.warning( "--tag (-g) entries are ignored when using specified URLs" ) tag = None # Attempt to use our APPRISE_URLS environment variable (if populated) a.add(os.environ[DEFAULT_ENV_APPRISE_URLS].strip()) else: # Load default configuration a.add( AppriseConfig( paths=[f for f in config_paths if isfile(path_decode(f))], asset=asset, recursion=recursion_depth, ) ) # Restore storage_action in case it was flipped above to allow URL- # filter entries to be loaded via rule #1. storage_action = _in_storage_mode if not dry_run and not (a or storage_action): click.echo( "You must specify at least one service URL or populated " "configuration file." ) click.echo("Try 'apprise --help' for more information.") ctx.exit(1) # Each --tag value is a comma-separated AND group; values are ORed. tags = None if not tag else [parse_list(t) for t in tag] # Split once so equals signs remain valid inside the value. template_vars = {} # Names are matched without regard to case, so track what we have seen. template_seen = set() for entry in template_var: name, sep, value = entry.partition("=") name = name.strip() if not sep or not TEMPLATE_NAME_RE.match(name): # Only the name is echoed; the rest of the entry may be a # secret and must not reach the screen or a captured log. click.echo( "The --template-var (-tv) name '{}' is not in the" " expected NAME=VALUE format.".format(name) ) click.echo("Try 'apprise --help' for more information.") # Match Click's exit code for invalid parameters. ctx.exit(2) if normalize_name(name) in template_seen: click.echo( "The --template-var (-tv) name '{}' was provided more than" " once.".format(name) ) click.echo("Try 'apprise --help' for more information.") # Match Click's exit code for invalid parameters. ctx.exit(2) template_seen.add(normalize_name(name)) template_vars[name] = value # Determine if we're dealing with URLs or url_ids based on the first # entry provided. if storage_action: # # Storage Mode # - urls are now to be interpreted as best matching namespaces # if storage_prune_days < 0: click.echo( "The --storage-prune-days (-SPD) value can not be lower " "than zero (0)." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a # parameter issue. For consistency, we also return a 2 ctx.exit(2) # Number of columns to assume in the terminal. In future, maybe this # can be detected and made dynamic. The actual column count is 80, but # 5 characters are already reserved for the counter on the left (columns, _) = shutil.get_terminal_size(fallback=(80, 24)) # Pop 'storage' off of the head of the original url list. # _raw_urls always holds the unmodified list even when urls was # remapped to URL-filter entries for the priority chain above. filter_uids = _raw_urls[1:] action = PERSISTENT_STORAGE_MODES[0] if filter_uids: action_ = next( # pragma: no branch ( a for a in PERSISTENT_STORAGE_MODES if a.startswith(filter_uids[0]) ), None, ) if action_: # pop 'action' off the head of our list filter_uids = filter_uids[1:] action = action_ # True when URL-filter entries were present in the storage args; # detected by whether urls was remapped to a new list object above. _had_url_filters = _raw_urls is not urls # Build the uid-prefix filter list from non-URL entries only. # URL-filter entries were already loaded into 'a' by rule #1. uid_filter_list = [ _f.lower() for _f in filter_uids if not GET_SCHEMA_RE.match(_f) ] # Single pass: collect plugin uids, applying the uid-prefix filter uids = {} for plugin in a if not tags else a.find(tag=tags): id_ = plugin.url_id() if not id_: # No persistent storage uid; skip this plugin continue if uid_filter_list and not next( (u for u in uid_filter_list if id_.startswith(u)), None ): # Plugin uid does not match any uid-prefix filter logger.trace( "Storage: plugin uid '%s' excluded by filter %s", id_, uid_filter_list, ) continue if id_ not in uids: uids[id_] = { "plugins": [plugin], "state": PersistentStoreState.UNUSED.value, "size": 0, } else: # It's possible to have more than one URL point to the same # location (thus match against the same url id more than once uids[id_]["plugins"].append(plugin) if action == PersistentStorageMode.LIST: # Scope disk_scan based on what filters were provided: # - URL filters: scope to resolved plugin uids plus any # plain uid-prefix strings; skip scan if none resolved # - Plain-string or no filter: namespace=uid_filter_list # (empty list means return all on-disk directories) if _had_url_filters: # Derive namespace from the plugins resolved by URL filters _disk_ns = list(uids.keys()) + uid_filter_list if not _disk_ns: # URL filters yielded no plugins; skip the disk scan detected_uid = {} else: detected_uid = PersistentStore.disk_scan( # Use our asset path as already properly parsed path=asset.storage_path, namespace=_disk_ns, ) else: detected_uid = PersistentStore.disk_scan( # Use our asset path as it has already been properly # parsed path=asset.storage_path, # Provide filter if specified; empty = all directories namespace=uid_filter_list, ) for id_ in detected_uid: size, _ = dir_size(os.path.join(asset.storage_path, id_)) if id_ in uids: uids[id_]["state"] = PersistentStoreState.ACTIVE.value uids[id_]["size"] = size elif not tags: uids[id_] = { "plugins": [], # No cross reference (wasted space?) "state": PersistentStoreState.STALE.value, # Acquire disk space "size": size, } for idx, (uid, meta) in enumerate(uids.items()): fg = ( "green" if meta["state"] == PersistentStoreState.ACTIVE.value else ( "red" if meta["state"] == PersistentStoreState.STALE.value else "white" ) ) if idx > 0: # New line click.echo() click.echo(f"{idx + 1: 4d}. ", nl=False) click.echo( click.style( "{:<52} {:<8} {}".format( uid, bytes_to_str(meta["size"]), meta["state"] ), fg=fg, bold=True, ) ) for entry in meta["plugins"]: url = entry.url(privacy=True) click.echo( "{:>7} {}".format( "-", ( url if len(url) <= (columns - 8) else f"{url[: columns - 11]}..." ), ) ) if entry.tags: click.echo( "{:>10}: {}".format( "tags", ", ".join(sorted(str(t) for t in entry.tags)), ) ) else: # PersistentStorageMode.PRUNE or PersistentStorageMode.CLEAR if action == PersistentStorageMode.CLEAR: storage_prune_days = 0 # Match disk-scan filtering when choosing prune namespaces: # - URL and tag filters use resolved UIDs plus explicit prefixes. # - An empty filtered result prunes nothing. # - Otherwise, pass explicit prefixes or no filter through. if _had_url_filters or tags: # Exit when filters resolve nothing because None prunes all. _prune_ns = list(uids.keys()) + uid_filter_list if not _prune_ns: ctx.exit(0) else: _prune_ns = uid_filter_list or None # clean up storage results = PersistentStore.disk_prune( # Use our asset path as it has already been properly parsed path=asset.storage_path, # Provide our namespaces if they exist namespace=_prune_ns, # Convert expiry from days to seconds expires=storage_prune_days * 60 * 60 * 24, action=not dry_run, ) ctx.exit(0) # end if disk_prune() ctx.exit(0) # end if storage() if not dry_run: if body is None: logger.trace("No --body (-b) specified; reading from stdin") # if no body was specified, then read from STDIN body = click.get_text_stream("stdin").read() # Send the notification. AppriseResult.status maps directly to the # process exit values handled after the dry-run branch. notify_start = time.monotonic() result = a.notify( body=body, title=title, notify_type=notification_type, tag=tags, attach=attach, template=template_vars, timeout=limit, ) status = result.status # Status report logger.debug( "Finished in %.2fs. %d service(s) tried (%s): %d sent / " "%d failed / %d timed out.", time.monotonic() - notify_start, len(result), status.name, result.success_count, result.failed_count, result.timeout_count, ) else: # Number of columns to assume in the terminal. In future, maybe this # can be detected and made dynamic. The actual column count is 80, but # 5 characters are already reserved for the counter on the left (columns, _) = shutil.get_terminal_size(fallback=(80, 24)) # Initialize our URL response; This is populated within the for/loop # below; but plays a factor at the end when we need to determine if # we iterated at least once in the loop. url = None # Count matching entries that a real run would skip. unresolved = 0 # Show pending entries and the values they still need. for idx, service in enumerate(a.find(tag=tags, resolve=False)): entry = service if isinstance(service, NotifyTemplate): # Resolve supplied values or display the pending entry. built = Apprise._resolve_template(service, template_vars) if built is not None: service = built url = service.url(privacy=True) click.echo( "{: 4d}. {}".format( idx + 1, ( url if len(url) <= (columns - 8) else f"{url[: columns - 9]}..." ), ) ) # Share our URL ID if service.url_id(): uid = service.url_id() elif isinstance(service, NotifyTemplate): # Explain why this pending entry has no identifier. uid = "- template; unresolved, would not send -" unresolved += 1 else: uid = "- n/a -" click.echo("{:>10}: {}".format("uid", uid)) if service.tags: click.echo( "{:>10}: {}".format( "tags", ", ".join(sorted(str(t) for t in service.tags)), ) ) names = getattr(entry, "template_names", ()) if names: required = set(getattr(entry, "template_required", ())) click.echo( "{:>10}: {}".format( "template", ", ".join( "{} ({})".format( name, "required" if name in required else "has default", ) for name in names ), ) ) if unresolved: click.echo() click.echo( "{} of {} matched entries could not be resolved and would" " not be sent. Check the log for the reason.".format( unresolved, idx + 1 ) ) # Dry-run has no AppriseResult, so map its outcome to the same enum. # A missing value maps to the same outcome a real run would give. if url is None: status = AppriseResultStatus.NOMATCH elif not unresolved: status = AppriseResultStatus.SUCCESS elif unresolved == idx + 1: status = AppriseResultStatus.FAILURE else: status = AppriseResultStatus.PARTIAL if status == AppriseResultStatus.TIMEOUT and not _wait_for_abandoned_calls( CLI_TIMEOUT_EXIT_GRACE_SECONDS ): _force_exit(a, status) # AppriseResultStatus values map directly to CLI exit codes. # Exit code 2 is left to Click for bad command-line parameters. ctx.exit(status) caronc-apprise-dfec414/apprise/common.py000066400000000000000000000166261526720371000204370ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from enum import Enum # isoformat is spelled out for compatibility with Python v3.6 AWARE_DATE_ISO_FORMAT = "%Y-%m-%dT%H:%M:%S.%f%z" NAIVE_DATE_ISO_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" # Compact separators (no space after "," or ":"). JSON_COMPACT_SEPARATORS = (",", ":") class NotifyType(str, Enum): """A simple mapping of notification types most commonly used with all types of logging and notification services.""" INFO = "info" SUCCESS = "success" WARNING = "warning" FAILURE = "failure" # Define our types so we can verify if we need to NOTIFY_TYPES: frozenset[str] = frozenset(e.value for e in NotifyType) class NotifyImageSize(str, Enum): """A list of pre-defined image sizes to make it easier to work with defined plugins.""" XY_32 = "32x32" XY_72 = "72x72" XY_128 = "128x128" XY_256 = "256x256" # Define our image sizes so we can verify if we need to NOTIFY_IMAGE_SIZES: frozenset[str] = frozenset( e.value for e in NotifyImageSize ) class NotifyFormat(str, Enum): """A list of pre-defined text message formats that can be passed via the apprise library.""" TEXT = "text" HTML = "html" MARKDOWN = "markdown" # Define our formats so we can verify if we need to NOTIFY_FORMATS: frozenset[str] = frozenset(e.value for e in NotifyFormat) class OverflowMode(str, Enum): """A list of pre-defined modes of how to handle the text when it exceeds the defined maximum message size.""" # Send the data as is; untouched. Let the upstream server decide how the # content is handled. Some upstream services might gracefully handle this # with expected intentions; others might not. UPSTREAM = "upstream" # Always truncate the text when it exceeds the maximum message size and # send it anyway TRUNCATE = "truncate" # Split the message into multiple smaller messages that fit within the # limits of what is expected. The smaller messages are sent SPLIT = "split" # Define our modes so we can verify if we need to OVERFLOW_MODES: frozenset[str] = frozenset(e.value for e in OverflowMode) class ConfigFormat(str, Enum): """A list of pre-defined config formats that can be passed via the apprise library.""" # A text based configuration. This consists of a list of URLs delimited by # a new line. pound/hashtag (#) or semi-colon (;) can be used as comment # characters. TEXT = "text" # YAML files allow a more rich of an experience when settig up your # apprise configuration files. YAML = "yaml" # Define our configuration formats mostly used for verification CONFIG_FORMATS: frozenset[str] = frozenset(e.value for e in ConfigFormat) class ContentIncludeMode(str, Enum): """The different Content inclusion modes. All content based plugins will have one of these associated with it. """ # - Content inclusion of same type only; hence a file:// can include # a file:// # - Cross file inclusion is not allowed unless insecure_includes (a flag) # is set to True. In these cases STRICT acts as type ALWAYS STRICT = "strict" # This content type can never be included NEVER = "never" # This content can always be included ALWAYS = "always" # Define our file inclusion types so we can verify if we need to CONTENT_INCLUDE_MODES: frozenset[str] = frozenset( e.value for e in ContentIncludeMode ) class ContentLocation(str, Enum): """This is primarily used for handling file attachments. The idea is to track the source of the attachment itself. We don't want remote calls to a server to access local attachments for example. By knowing the attachment type and cross-associating it with how we plan on accessing the content, we can make a judgement call (for security reasons) if we will allow it. Obviously local uses of apprise can access both local and remote type files. """ # Content is located locally (on the same server as apprise) LOCAL = "local" # Content is located in a remote location HOSTED = "hosted" # Content is inaccessible INACCESSIBLE = "n/a" # Define our location types so we can verify if we need to CONTENT_LOCATIONS: frozenset[str] = frozenset(e.value for e in ContentLocation) class PersistentStoreMode(str, Enum): # Allow persistent storage; write on demand AUTO = "auto" # Always flush every change to disk after it's saved. This has higher i/o # but enforces disk reflects what was set immediately FLUSH = "flush" # memory based store only MEMORY = "memory" # Define our persistent storage modes so we can verify if we need to PERSISTENT_STORE_MODES: frozenset[str] = frozenset( e.value for e in PersistentStoreMode ) class PersistentStoreState(str, Enum): """Defines the persistent states describing what has been cached.""" # Persistent Directory is actively cross-referenced against a matching URL ACTIVE = "active" # Persistent Directory is no longer being used or has no cross-reference STALE = "stale" # Persistent Directory is not utilizing any disk space at all, however # it potentially could if the plugin it successfully cross-references # is utilized UNUSED = "unused" # Define our persistent storage states so we can verify if we need to PERSISTENT_STORE_STATES: frozenset[str] = frozenset( e.value for e in PersistentStoreState ) # This is a reserved tag that is automatically assigned to every # Notification Plugin MATCH_ALL_TAG = "all" # Will cause notification to trigger under any circumstance even if an # exclusive tagging was provided. MATCH_ALWAYS_TAG = "always" # Maximum number of retries a service may attempt after an initial failure. # This cap prevents runaway retry loops; the effective value is clamped to # [0, APPRISE_MAX_SERVICE_RETRY] everywhere it is consumed. APPRISE_MAX_SERVICE_RETRY = 10 # Maximum inter-retry wait (in seconds). Prevents accidental multi-hour # sleeps from a misconfigured wait= value; clamped to # [0.0, APPRISE_MAX_SERVICE_WAIT] everywhere it is consumed. APPRISE_MAX_SERVICE_WAIT = 20.0 caronc-apprise-dfec414/apprise/compat/000077500000000000000000000000001526720371000200455ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/compat/__init__.py000066400000000000000000000030461526720371000221610ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Compatibility helpers for supported older platforms and libraries. Modules can be removed with their tests once their target versions are no longer supported. """ caronc-apprise-dfec414/apprise/compat/markdown33.py000066400000000000000000000047571526720371000224240ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Support Python-Markdown 3.3 as shipped with Rocky Linux 9. from html import escape import re from markdown import Markdown from markdown.postprocessors import Postprocessor from markdown.util import ETX, STX # A backslash-escaped character that Python-Markdown left as a placeholder _ESCAPED_PLACEHOLDER_RE = re.compile(f"{STX}([0-9]+){ETX}") class _UnescapePostprocessor(Postprocessor): """Restore backslash-escaped characters as valid HTML.""" def run(self, text: str) -> str: """Replace each escape placeholder with its HTML-safe character.""" # Python-Markdown 3.3 restores these after the HTML is built, so # "\&" or "\<" came back raw. Newer releases leave nothing to # replace here. return _ESCAPED_PLACEHOLDER_RE.sub( lambda m: escape(chr(int(m.group(1))), quote=False), text ) def escape_placeholders_as_html(md: Markdown) -> None: """Make ``md`` write backslash-escaped characters as valid HTML.""" # This replaces Python-Markdown 3.3's own step of the same name. md.postprocessors.register(_UnescapePostprocessor(md), "unescape", 10) caronc-apprise-dfec414/apprise/compat/py39.py000066400000000000000000000040041526720371000212210ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Support Python 3.9 as shipped with Rocky Linux 9. from dataclasses import dataclass as _dataclass import sys from typing import Any, Callable, TypeVar _T = TypeVar("_T") # dataclass(slots=...) was added in Python 3.10 _HAS_SLOTS = sys.version_info >= (3, 10) def dataclass_compat(*dargs: Any, **dkwargs: Any) -> Callable[[_T], _T]: """dataclass() wrapper that drops ``slots=`` on Python 3.9.""" if not _HAS_SLOTS: # Python 3.9 rejects slots=, so the class is built without them dkwargs.pop("slots", None) # Any other error, on any Python, reaches the caller unchanged return _dataclass(*dargs, **dkwargs) caronc-apprise-dfec414/apprise/config/000077500000000000000000000000001526720371000200275ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/config/__init__.py000066400000000000000000000031671526720371000221470ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Used for testing from ..manager_config import ConfigurationManager from .base import ConfigBase # Initalize our Config Manager Singleton C_MGR = ConfigurationManager() __all__ = [ # Reference "ConfigBase", "ConfigurationManager", ] caronc-apprise-dfec414/apprise/config/base.py000066400000000000000000002252461526720371000213260ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations from collections import deque import os import re import time from .. import common, plugins from ..asset import AppriseAsset from ..exception import AppriseImproperlyConfigured, AppriseTemplateError from ..locale import AppriseLocale from ..logger import logging from ..manager_config import ConfigurationManager from ..manager_plugins import NotificationManager from ..tag import AppriseTag from ..template import NotifyTemplate from ..url import URL_TOKEN_ALIASES, URLBase from ..utils.cwe312 import cwe312_loggable from ..utils.parse import ( GET_SCHEMA_RE, QSD_FULL_MODE_KEYS, parse_bool, parse_list, parse_urls, ) from ..utils.template import ( TemplatePlaceholderMap, TemplateSchema, ) from ..utils.time import zoneinfo from ..utils.yaml import ( YAML_REAPPLY_SKIP, dropped_template, template_in_schema, template_references, templated_tag, ) # Test whether token is valid or not VALID_TOKEN = re.compile(r"(?P[a-z0-9][a-z0-9_]+)", re.I) # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() # Grant access to our Configuration Manager Singleton C_MGR = ConfigurationManager() class ConfigBase(URLBase): """This is the base class for all supported configuration sources.""" # The Default Encoding to use if not otherwise detected encoding = "utf-8" # The default expected configuration format unless otherwise # detected by the sub-modules default_config_format = common.ConfigFormat.TEXT # This is only set if the user overrides the config format on the URL # this should always initialize itself as None config_format = None # Don't read any more of this amount of data into memory as there is no # reason we should be reading in more. This is more of a safe guard then # anything else. 128KB (131072B) max_buffer_size = 131072 # By default all configuration is not includable using the 'include' # line found in configuration files. allow_cross_includes = common.ContentIncludeMode.NEVER # the config path manages the handling of relative include config_path = os.getcwd() def __init__( self, cache: bool | int = True, recursion: int = 0, insecure_includes: bool = False, **kwargs: object, ) -> None: """Initialize behavior shared by all configuration plugins. ``cache=True`` retains parsed services after the first read. ``cache=False`` reads and parses the source whenever :meth:`services` is called, and a non-negative integer retains results for that many seconds. Reloading is especially significant for an ``http://`` or ``https://`` source because it performs another network request. ``recursion`` controls how many levels of ``include`` entries are followed. Zero ignores all includes; one reads sources included by this source; two also follows includes found in those child sources, and so on. A low limit protects against unexpectedly large or circular trees. Each configuration plugin declares an :class:`~apprise.common.ContentIncludeMode`. ``ALWAYS`` sources may be included normally, ``NEVER`` sources cannot be included, and ``STRICT`` sources may only be included by a compatible source type. For example, a local ``file://`` configuration can include another local file, but an ``http://`` configuration cannot include a local file. ``insecure_includes=True`` relaxes the ``STRICT`` compatibility check, effectively treating strict sources as always includable; it never overrides ``NEVER``. This is useful when trusted in-memory content must include a local file, but it should not be enabled for untrusted input. Additional keyword arguments are handled by :class:`URLBase`. The optional ``encoding`` and ``format`` values select the source encoding and force ``text`` or ``yaml`` parsing respectively. """ super().__init__(**kwargs) # Tracks the time the content was last retrieved on. This place a role # for cases where we are not caching our response and are required to # re-retrieve our settings. self._cached_time = None # Tracks previously loaded content for speed self._cached_services = None # Initialize our recursion value self.recursion = recursion # Initialize our insecure_includes flag self.insecure_includes = insecure_includes if "encoding" in kwargs: # Store the encoding self.encoding = kwargs.get("encoding") fmt = kwargs.get("format") if fmt: try: self.config_format = ( fmt if isinstance(fmt, common.ConfigFormat) else common.ConfigFormat(fmt.lower()) ) except (AttributeError, ValueError): err = f"An invalid config format ({fmt}) was specified." self.logger.warning(err) raise AppriseImproperlyConfigured(err) from None # Set our cache flag; it can be True or a (positive) integer try: self.cache = cache if isinstance(cache, bool) else int(cache) if self.cache < 0: err = f"A negative cache value ({cache}) was specified." self.logger.warning(err) raise AppriseImproperlyConfigured(err) except (ValueError, TypeError): err = f"An invalid cache value ({cache}) was specified." self.logger.warning(err) raise AppriseImproperlyConfigured(err) from None return def services( self, asset: AppriseAsset | None = None, **kwargs: object, ) -> list[plugins.NotifyBase]: """Read, parse, and return all services defined by this source. A valid cache is returned immediately. Otherwise :meth:`read` obtains the raw text, the selected text or YAML parser creates service plugins, and any permitted ``include`` entries are loaded recursively. Included services are appended to the direct services in the returned list. ``asset`` overrides the source's asset for newly parsed services and is propagated to included sources. A read failure or source with no usable content returns an empty list. The result becomes this object's mutable cache; methods such as ``len()``, iteration, indexing, and :meth:`pop` all operate on that same list. """ if not self.expired(): # We already have cached results to return; use them return self._cached_services # Our cached response object self._cached_services = [] # read() causes the child class to do whatever it takes for the # config plugin to load the data source and return unparsed content # None is returned if there was an error or simply no data content = self.read(**kwargs) if not isinstance(content, str): # Set the time our content was cached at self._cached_time = time.time() # Nothing more to do; return our empty cache list return self._cached_services # Our Configuration format uses a default if one wasn't one detected # or enfored. config_format = ( self.default_config_format if self.config_format is None else self.config_format ) # Dynamically load our parse_ function based on our config format fn = getattr(ConfigBase, f"config_parse_{config_format.value}") # Initialize our asset object asset = asset if isinstance(asset, AppriseAsset) else self.asset # Execute our config parse function which always returns a tuple # of our services and our configuration services, configs = fn(content=content, asset=asset) # Free memory del content # Add entry to our service list self._cached_services.extend(services) # Configuration files were detected; recursively populate them # If we have been configured to do so for url in configs: if self.recursion > 0: # Attempt to acquire the schema at the very least to allow # our configuration based urls. schema = GET_SCHEMA_RE.match(url) if schema is None: # Plan B is to assume we're dealing with a file schema = "file" if not os.path.isabs(url): # We're dealing with a relative path; prepend # our current config path url = os.path.join(self.config_path, url) url = f"{schema}://{URLBase.quote(url)}" else: # Ensure our schema is always in lower case schema = schema.group("schema").lower() # Some basic validation if schema not in C_MGR: ConfigBase.logger.error( f"Unsupported include schema {schema}." ) continue # CWE-312 (Secure Logging) Handling loggable_url = cwe312_loggable(url, asset.secure_logging) # Parse the included configuration URL into constructor args. results = C_MGR[schema].parse_url(url) if not results: # The included configuration URL could not be parsed. self.logger.error( f"Unparseable include URL {loggable_url}" ) continue # Handle cross inclusion based on allow_cross_includes rules if ( C_MGR[schema].allow_cross_includes == common.ContentIncludeMode.STRICT and schema not in self.schemas() and not self.insecure_includes ) or C_MGR[ schema ].allow_cross_includes == common.ContentIncludeMode.NEVER: # Prevent the loading if insecure base protocols ConfigBase.logger.warning( f"Including {schema}:// based configuration is" f" prohibited. Ignoring URL {loggable_url}" ) continue # Prepare our Asset Object results["asset"] = asset # No cache is required because we're just lumping this in # and associating it with the cache value we've already # declared (prior to our recursion) results["cache"] = False # Recursion can never be parsed from the URL; we decrement # it one level results["recursion"] = self.recursion - 1 # Insecure Includes flag can never be parsed from the URL results["insecure_includes"] = self.insecure_includes try: # Attempt to create an instance of our plugin using the # parsed URL information cfg_plugin = C_MGR[results["schema"]](**results) except Exception as e: # the arguments are invalid or can not be used. self.logger.error( f"Could not load include URL: {loggable_url}" ) self.logger.debug(f"Loading Exception: {e!s}") continue # Add services found in the included configuration. self._cached_services.extend(cfg_plugin.services(asset=asset)) else: # CWE-312 (Secure Logging) Handling loggable_url = cwe312_loggable(url, asset.secure_logging) self.logger.debug( "Recursion limit reached; ignoring Include URL: %s", loggable_url, ) if self._cached_services: self.logger.debug( f"Loaded {len(self._cached_services)} entries from" f" {self.url(privacy=asset.secure_logging)}" ) else: self.logger.warning( "Failed to load Apprise configuration from" f" {self.url(privacy=asset.secure_logging)}" ) # Set the time our content was cached at self._cached_time = time.time() return self._cached_services def read(self) -> str | None: """This object should be implimented by the child classes.""" return None def expired(self) -> bool: """Simply returns True if the configuration should be considered as expired or False if content should be retrieved.""" if isinstance(self._cached_services, list) and self.cache: # We have enough reason to look further into our cached content # and verify it has not expired. if self.cache is True: # we have not expired, return False return False # Verify our cache time to determine whether we will get our # content again. age_in_sec = time.time() - self._cached_time if age_in_sec <= self.cache: # We have not expired; return False return False # If we reach here our configuration should be considered # missing and/or expired. return True @staticmethod def __normalize_tag_groups(group_tags: dict[str, set[str]]) -> None: """ Used to normalize a tag assign map which looks like: { 'group': set('{tag1}', '{group1}', '{tag2}'), 'group1': set('{tag2}','{tag3}'), } Then normalized it (merging groups); with respect to the above, the output would be: { 'group': set('{tag1}', '{tag2}', '{tag3}), 'group1': set('{tag2}','{tag3}'), } """ # Prepare a key set list we can use tag_groups = {str(x) for x in group_tags} def _expand(tags, ignore=None): """Expands based on tag provided and returns a set. this also updates the group_tags while it goes """ # Prepare ourselves a return set results = set() ignore = set() if ignore is None else ignore # track groups groups = set() for tag in tags: if tag in ignore: continue # Track our groups groups.add(tag) # Store what we know is worth keeping if tag not in group_tags: # pragma: no cover # handle cases where the tag doesn't exist group_tags[tag] = set() results |= group_tags[tag] - tag_groups # Get simple tag assignments found = group_tags[tag] & tag_groups if not found: continue for gtag in found: if gtag in ignore: continue # Go deeper (recursion) ignore.add(tag) group_tags[gtag] = _expand({gtag}, ignore=ignore) results |= group_tags[gtag] # Pop ignore ignore.remove(tag) return results for tag in tag_groups: # Get our tags group_tags[tag] |= _expand({tag}) if not group_tags[tag]: ConfigBase.logger.warning( f"The group {tag} has no tags assigned to it" ) del group_tags[tag] @staticmethod def parse_url( url: str, verify_host: bool = True, ) -> dict[str, object] | None: """Parses the URL and returns it broken apart into a dictionary. This is very specific and customized for Apprise. Args: url (str): The URL you want to fully parse. verify_host (:obj:`bool`, optional): a flag kept with the parsed URL which some child classes will later use to verify SSL keys (if SSL transactions take place). Unless under very specific circumstances, it is strongly recomended that you leave this default value set to True. Returns: A dictionary is returned containing the URL fully parsed if successful, otherwise None is returned. """ results = URLBase.parse_url(url, verify_host=verify_host) if not results: # We're done; we failed to parse our url return results # Allow overriding the default config format if "format" in results["qsd"]: results["format"] = results["qsd"].get("format") if results["format"] not in common.CONFIG_FORMATS: URLBase.logger.warning( "Unsupported format specified {}".format(results["format"]) ) del results["format"] # Defines the encoding of the payload if "encoding" in results["qsd"]: results["encoding"] = results["qsd"].get("encoding") # Our cache value if "cache" in results["qsd"]: # First try to get it's integer value try: results["cache"] = int(results["qsd"]["cache"]) except (ValueError, TypeError): # No problem, it just isn't an integer; now treat it as a bool # instead: results["cache"] = parse_bool(results["qsd"]["cache"]) return results @staticmethod def detect_config_format( content: str, **kwargs: object, ) -> common.ConfigFormat | None: """Takes the specified content and attempts to detect the format type. The function returns the actual format type if detected, otherwise it returns None """ # Detect Format Logic: # - A pound/hashtag (#) is alawys a comment character so we skip over # lines matched here. # - Detection begins on the first non-comment and non blank line # matched. # - If we find a string followed by a colon, we know we're dealing # with a YAML file. # - If we find a string that starts with a URL, or our tag # definitions (accepting commas) followed by an equal sign we know # we're dealing with a TEXT format. # Define what a valid line should look like. # The tag group allows an optional "N:" priority prefix so that # "2:endpoint=ntfy://..." is recognised as TEXT format. # A tag group assignment such as "family=me,wife" carries tags instead # of a URL on the right hand side; it is TEXT format as well. valid_line_re = re.compile( r"^\s*(?P([;#]+(?P.*))|" r"(?P((?P[ \t,a-z0-9_:-]+)=)?[a-z0-9]+://.*)|" r"((?P[a-z0-9]+):.*)|" r"(?P[ \t,a-z0-9_:-]+=[a-z0-9, \t_-]+))?$", re.I, ) try: # split our content up to read line by line content = re.split(r"\r*\n", content) except TypeError: # content was not expected string type ConfigBase.logger.error("Invalid Apprise configuration specified.") return None # By default set our return value to None since we don't know # what the format is yet config_format = None # iterate over each line of the file to attempt to detect it # stop the moment a the type has been determined for line, entry in enumerate(content, start=1): result = valid_line_re.match(entry) if not result: # Invalid syntax ConfigBase.logger.error( "Undetectable Apprise configuration found " f"based on line {line}." ) # Take an early exit return None # Attempt to detect configuration if result.group("yaml"): config_format = common.ConfigFormat.YAML ConfigBase.logger.debug( f"Detected YAML configuration based on line {line}." ) break elif result.group("text") or result.group("assign"): config_format = common.ConfigFormat.TEXT ConfigBase.logger.debug( f"Detected TEXT configuration based on line {line}." ) break # If we reach here, we have a comment entry # Adjust default format to TEXT config_format = common.ConfigFormat.TEXT return config_format @staticmethod def config_parse( content: str, asset: AppriseAsset | None = None, config_format: str | common.ConfigFormat | None = None, **kwargs: object, ) -> tuple[list[object], list[str]]: """Takes the specified config content and loads it based on the specified config_format. If a format isn't specified, then it is auto detected. """ if config_format is None: # Detect the format config_format = ConfigBase.detect_config_format(content) if not config_format: # We couldn't detect configuration ConfigBase.logger.error("Could not detect configuration") return ([], []) try: config_format = ( config_format if isinstance(config_format, common.ConfigFormat) else common.ConfigFormat(config_format.lower()) ) except (AttributeError, ValueError): ConfigBase.logger.error( f"An invalid configuration format ({config_format}) was" " specified" ) return ([], []) # Dynamically load our parse_ function based on our config format fn = getattr(ConfigBase, f"config_parse_{config_format.value}") # Execute our config parse function which always returns a list return fn(content=content, asset=asset) @staticmethod def config_parse_text( content: str, asset: AppriseAsset | None = None, ) -> tuple[list[object], list[str]]: """Parse the specified content as though it were a simple text file only containing a list of URLs. Return a tuple that looks like (services, configs) where: - services contains a list of loaded notification plugins - configs contains a list of additional configuration files referenced. You may also optionally associate an asset with the notification. The file syntax is: # # pound/hashtag allow for line comments # # One or more tags can be idenified using comma's (,) to separate # them. = # Or you can use this format (no tags associated) # you can also use the keyword 'include' and identify a # configuration location (like this file) which will be included # as additional configuration entries when loaded. include # Assign tag contents to a group identifier = """ # A list of loaded Notification Services services = [] # A list of additional configuration files referenced using # the include keyword configs = [] # Track all of the tags we want to assign later on group_tags = {} # Track our entries to preload preloaded = [] # Prepare our Asset Object asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() # Define what a valid line should look like. # The tags group allows an optional leading "N:" priority prefix so # that entries like "2:endpoint=ntfy://..." are parsed correctly. valid_line_re = re.compile( r"^\s*(?P([;#]+(?P.*))|" r"(\s*(?P[a-z0-9, \t_:-]+)\s*=|=)?\s*" r"((?P[a-z0-9]{1,32}://.*)|(?P[a-z0-9, \t_-]+))|" r"include\s+(?P.+))?\s*$", re.I, ) try: # split our content up to read line by line content = re.split(r"\r*\n", content) except TypeError: # content was not expected string type ConfigBase.logger.error( "Invalid Apprise TEXT based configuration specified." ) return ([], []) for line, entry in enumerate(content, start=1): result = valid_line_re.match(entry) if not result: # Invalid syntax ConfigBase.logger.error( "Invalid Apprise TEXT configuration format found " f"{entry} on line {line}." ) # Assume this is a file we shouldn't be parsing. It's owner # can read the error printed to screen and take action # otherwise. return ([], []) # Retrieve our line url, assign, config = ( result.group("url"), result.group("assign"), result.group("config"), ) if not (url or config or assign): # Comment/empty line; do nothing continue if config: # CWE-312 (Secure Logging) Handling loggable_url = cwe312_loggable(config, asset.secure_logging) ConfigBase.logger.debug(f"Include URL: {loggable_url}") # Store our include line configs.append(config.strip()) continue # CWE-312 (Secure Logging) Handling loggable_url = cwe312_loggable(url, asset.secure_logging) if assign: groups = set(parse_list(result.group("tags"), cast=str)) if not groups: # no tags were assigned ConfigBase.logger.warning( "Unparseable tag assignment - no group(s) " f"on line {line}" ) continue # Get our tags tags = set(parse_list(assign, cast=str)) if not tags: # no tags were assigned ConfigBase.logger.warning( "Unparseable tag assignment - no tag(s) to assign " f"on line {line}" ) continue # Update our tag group map for tag_group in groups: if tag_group not in group_tags: group_tags[tag_group] = set() # ensure our tag group is never included in the assignment group_tags[tag_group] |= tags - {tag_group} continue # Acquire our url tokens results = plugins.url_to_dict( url, secure_logging=asset.secure_logging ) if results is None: # url_to_dict() already logged an error with the URL; # repeat at debug level with line number for context. ConfigBase.logger.debug( f"Unparseable URL {loggable_url} on line {line}." ) continue # Build a list of tags to associate with the newly added # notifications if any were set results["tag"] = set(parse_list(result.group("tags"), cast=str)) # A retry count on a service tag (e.g. "alerts:3=slack://...") is # not valid here: per-service retry belongs on the URL (?retry=N) # and call-time retry overrides belong on the notify() filter. if any( AppriseTag.parse(t).retry is not None for t in results["tag"] ): ConfigBase.logger.warning( "You can not specify a retry count as part of a service" f" tag on line {line}; use ?retry=N in the URL instead." ) continue # Set our Asset Object results["asset"] = asset # Store our preloaded entries preloaded.append( { "results": results, "line": line, "loggable_url": loggable_url, } ) # # Normalize Tag Groups # - Expand Groups of Groups so that they don't exist # ConfigBase.__normalize_tag_groups(group_tags) # # URL Processing # for entry in preloaded: # Point to our results entry for easier reference below results = entry["results"] # # Apply our tag groups if they're defined # for group, tags in group_tags.items(): # Detect if anything assigned to this tag also maps back to a # group. If so we want to add the group to our list if next( (True for tag in results["tag"] if tag in tags), False ): results["tag"].add(group) try: # Attempt to create an instance of our plugin using the # parsed URL information plugin = N_MGR[results["schema"]](**results) # Create log entry of loaded URL ConfigBase.logger.debug( "Loaded URL: %s", plugin.url(privacy=results["asset"].secure_logging), ) except Exception as e: # the arguments are invalid or can not be used. ConfigBase.logger.error( "Could not load URL {} on line {}.".format( entry["loggable_url"], entry["line"] ) ) ConfigBase.logger.debug(f"Loading Exception: {e!s}") continue # if we reach here, we successfully loaded our data services.append(plugin) # Return what was loaded return (services, configs) @staticmethod def config_parse_yaml( content: str, asset: AppriseAsset | None = None, ) -> tuple[list[object], list[str]]: """Parse the specified content as though it were a yaml file specifically formatted for Apprise. Return a tuple that looks like (services, configs) where: - services contains a list of loaded notification plugins - configs contains a list of additional configuration files referenced. You may optionally associate an asset with the notification. """ try: # PyYAML is only loaded once a YAML configuration is parsed. import yaml except ImportError: # Only possible when PyYAML was removed from the install. ConfigBase.logger.error( "YAML configuration requires the PyYAML package." ) return ([], []) # A list of loaded Notification Services services = [] # A list of additional configuration files referenced using # the include keyword configs = [] # Group Assignments group_tags = {} # Track our entries to preload preloaded = [] try: # Load our data safely. PyYAML keeps the last value when a mapping # repeats a key, matching Apprise's long-standing behavior. result = yaml.load(content, Loader=yaml.SafeLoader) except ( AttributeError, yaml.parser.ParserError, yaml.error.MarkedYAMLError, ) as e: # Invalid content ConfigBase.logger.error("Invalid Apprise YAML data specified.") ConfigBase.logger.debug(f"YAML Exception:{os.linesep}{e}") return ([], []) # The host decides whether this configuration may use templates. asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() ConfigBase.logger.debug( "Apprise YAML template variables are {}.".format( "enabled" if asset.allow_templates else "disabled" ) ) if not isinstance(result, dict): # Invalid content ConfigBase.logger.error( "Invalid Apprise YAML based configuration specified." ) return ([], []) # YAML Version version = result.get("version", 1) if version != 1: # Invalid syntax ConfigBase.logger.error( f"Invalid Apprise YAML version specified {version}." ) return ([], []) # # global asset object # # # template root directive # # Only declared variables are replaced; other ${...} text is literal. # template_schema = TemplateSchema() placeholders = None entries = None if asset.allow_templates: # Disabled templates leave the section and markers untouched. entries = result.get("template", None) if entries is not None: try: template_schema = TemplateSchema.parse(entries) except AppriseTemplateError as e: ConfigBase.logger.error( f"Invalid Apprise YAML template section. {e}" ) return ([], []) if template_schema: try: placeholders = TemplatePlaceholderMap( template_schema, content ) except AppriseTemplateError as e: ConfigBase.logger.error(str(e)) return ([], []) # Prepare our default timezone default_timezone = asset.tzinfo # Acquire our asset tokens tokens = result.get("asset", None) if tokens and isinstance(tokens, dict): raw_tz = tokens.get("timezone", tokens.get("tz")) if isinstance(raw_tz, str): default_timezone = zoneinfo(re.sub(r"[^\w/-]+", "", raw_tz)) if not default_timezone: ConfigBase.logger.warning( 'Ignored invalid timezone "%s"', raw_tz ) default_timezone = asset.tzinfo else: asset._tzinfo = default_timezone elif raw_tz is not None: ConfigBase.logger.warning( 'Ignored invalid timezone "%r"', raw_tz ) # A missing value leaves the language of the asset we were # given alone; it is not reset here. raw_lang = tokens.get("language", tokens.get("lang")) if isinstance(raw_lang, str) and raw_lang.strip(): language = AppriseLocale.normalize_language(raw_lang) if not language: ConfigBase.logger.warning( 'Ignored invalid language "%s"', raw_lang ) else: asset._language = language elif raw_lang is not None: # %r quotes a string on its own, so no quotes are added # around it here. ConfigBase.logger.warning( "Ignored invalid language %r", raw_lang ) # Iterate over remaining tokens for k, v in tokens.items(): if k in ("lang", "language", "timezone", "tz"): # These validated settings were already applied above. continue if k.startswith("_") or k.endswith("_"): # Entries are considered reserved if they start or end # with an underscore ConfigBase.logger.warning(f'Ignored asset key "{k}".') continue if not ( hasattr(asset, k) and isinstance(getattr(asset, k), (bool, str)) ): # We can't set a function or non-string set value ConfigBase.logger.warning(f'Invalid asset key "{k}".') continue if v is None: # Convert to an empty string v = "" if isinstance(v, (bool, str)) and isinstance( getattr(asset, k), bool ): # If the object in the Asset is a boolean, then # we want to convert the specified string to # match that. setattr(asset, k, parse_bool(v)) elif isinstance(v, str): # Set our asset object with the new value setattr(asset, k, v.strip()) else: # we must set strings with a string ConfigBase.logger.warning(f'Invalid asset value to "{k}".') continue # # global tag root directive # global_tags = set() tags = result.get("tag", result.get("tags", None)) if tags and isinstance(tags, (list, tuple, str)): # Store any preset tags global_tags = set(parse_list(tags, cast=str)) # # groups root directive # groups = result.get("groups", None) if isinstance(groups, dict): # # Dictionary # for groups_, tags in groups.items(): for group in parse_list(groups_, cast=str): if isinstance(tags, (list, tuple)): tags_ = set() for e in tags: if isinstance(e, dict): tags_ |= set(e.keys()) else: tags_ |= set(parse_list(e, cast=str)) # Final assignment tags = tags_ else: tags = set(parse_list(tags, cast=str)) if group not in group_tags: group_tags[group] = tags else: group_tags[group] |= tags elif isinstance(groups, (list, tuple)): # # List of Dictionaries # # Iterate over each group defined and store it for no, entry in enumerate(groups): if not isinstance(entry, dict): ConfigBase.logger.warning( f"No assignment for group {entry}, entry #{no + 1}" ) continue for groups_, tags in entry.items(): for group in parse_list(groups_, cast=str): if isinstance(tags, (list, tuple)): tags_ = set() for e in tags: if isinstance(e, dict): tags_ |= set(e.keys()) else: tags_ |= set(parse_list(e, cast=str)) # Final assignment tags = tags_ else: tags = set(parse_list(tags, cast=str)) if group not in group_tags: group_tags[group] = tags else: group_tags[group] |= tags # include root directive # includes = result.get("include", None) if isinstance(includes, str): # Support a single inline string or multiple ones separated by a # comma and/or space includes = parse_urls(includes) elif not isinstance(includes, (list, tuple)): # Not a problem; we simply have no includes includes = [] # Iterate over each config URL for _no, url in enumerate(includes): if isinstance(url, str): # Support a single inline string or multiple ones separated by # a comma and/or space configs.extend(parse_urls(url)) elif isinstance(url, dict): # Store the url and ignore arguments associated configs.extend(u for u in url) # # urls root directive # urls = result.get("urls", None) if not isinstance(urls, (list, tuple)): # Not a problem; we simply have no urls urls = [] # Explaining declaration mistakes requires walking every URL value. # Keep that optional work behind DEBUG even though an unused # declaration is emitted as a warning within a debugging session. explain = asset.allow_templates and ConfigBase.logger.isEnabledFor( logging.DEBUG ) if explain: # This is guidance only: undeclared ${NAME} text remains literal, # and unused declarations are valid. Inspect the already-parsed # URL values so helpful diagnostics do not require a custom YAML # loader or a second parse solely to recover source locations. referenced = template_references(urls) declared = set(template_schema.names) for name in sorted(referenced - declared): ConfigBase.logger.debug( "Template entry '{}' is not defined in the template" " section; it is kept as written.".format(name) ) for name in sorted(declared - referenced): ConfigBase.logger.warning( "Template entry '{}' is defined but not referenced by a" " service entry.".format(name) ) # Iterate over each URL for no, url in enumerate(urls): # Our results object is what we use to instantiate our object if # we can. Reset it to None on each iteration results = [] # CWE-312 (Secure Logging) Handling loggable_url = cwe312_loggable(url, asset.secure_logging) if isinstance(url, str): # We're just a simple URL string... if placeholders and template_in_schema( url, template_schema.names ): # The schema selects the temporary plugin parser before a # template value exists. Allowing it to vary would make the # same configuration change type at notification time. ConfigBase.logger.error( "Template variables are not permitted in URL schemas" " (YAML entry #{})".format(no + 1) ) continue if placeholders: url = placeholders.encode_url(url) schema = GET_SCHEMA_RE.match(url) if schema is None: # Log invalid entries so that maintainer of config # config file at least has something to take action # with. ConfigBase.logger.warning( f"Invalid URL {loggable_url}, entry #{no + 1}" ) continue # We found a valid schema worthy of tracking; store it's # details: results_ = plugins.url_to_dict( url, secure_logging=asset.secure_logging ) if results_ is None: if placeholders and placeholders.used(url): # Some services must validate this field while parsing. ConfigBase.logger.error( "A template variable can not be used at this" " position for {}://, entry #{}".format( schema.group("schema").lower(), no + 1 ) ) continue # url_to_dict() already logged an error with the URL; # repeat at debug level with entry number for context. ConfigBase.logger.debug( f"Unparseable URL {loggable_url}, entry #{no + 1}" ) continue # Parsing can succeed while silently dropping a marker. offender = dropped_template(url, results_, placeholders) if offender: # Do not keep a partially parsed template entry. ConfigBase.logger.error( "Template variable '{}' can not be used at this" " position for {}://, entry #{}".format( offender, schema.group("schema").lower(), no + 1 ) ) continue # A plain URL string has no settings written under it. results.append((results_, {}, {})) elif isinstance(url, dict): # We are a url string with additional unescaped options. In # this case we want to iterate over all of our options so we # can at least tell the end user what entries were ignored # due to errors it = iter(url.items()) # Track the URL to-load url_ = None # Keep the original key for matching sibling YAML settings. url_key = None # Track last acquired schema schema = None # Distinguish a forbidden variable schema from an ordinary # mapping that simply contains no recognizable URL key. invalid_schema = False for key, tokens_ in it: if ( placeholders and isinstance(key, str) and "://" in key and template_in_schema(key, template_schema.names) ): # Mapping-style URL keys obey the same fixed-schema # rule as plain URL strings. The quick separator check # only distinguishes URL-shaped keys from literal YAML # setting names. template_in_schema() locates the first # separator itself and rejects a declared marker only # when that marker occurs before it, so a later :// in # the path cannot bypass this check. ConfigBase.logger.error( "Template variables are not permitted in URL" " schemas (YAML entry #{})".format(no + 1) ) invalid_schema = True break encoded = ( placeholders.encode_url(key) if placeholders else key ) if not isinstance(encoded, str): # Non-text sibling settings cannot identify a URL. continue # Test our schema schema_ = GET_SCHEMA_RE.match(encoded) if schema_ is None: # Non-schema key -- may be a sibling token sitting # before the URL key in the YAML mapping. Sibling # tokens are collected later via url.items(), so # nothing is "ignored" here. Skip silently. continue # Store our schema schema = schema_.group("schema").lower() # Store our URL and Schema Regex url_ = encoded url_key = key # Update our token assignment tokens = tokens_ # We're done break if invalid_schema: continue if url_ is None: # the loop above failed to match anything ConfigBase.logger.warning( f"Unsupported URL, entry #{no + 1}" ) continue # Read sibling settings from the whole mapping, not just the # items that follow the URL key. YAML mapping order must not # affect the result. # # Settings are applied from highest to lowest priority: # 1. Child settings nested below the URL # 2. Sibling settings beside the URL # 3. Values parsed from the URL and its query string # # This lets both of the following YAML forms work: # # # sibling form (same indentation) # - mailtos://_: # smtp: smtp.example.com # from: no-reply@example.com # # # child form (indented one level deeper) # - mailtos://_: # smtp: smtp.example.com # from: no-reply@example.com sibling_tokens = { k: v for k, v in url.items() if k not in (url_key, "schema") } if placeholders: # Template YAML values, but keep setting names literal. try: sibling_tokens = placeholders.encode_obj( sibling_tokens ) tokens = placeholders.encode_obj(tokens) except AppriseTemplateError as e: ConfigBase.logger.error( "Could not prepare template values for entry" " #{}. {}".format(no + 1, e) ) continue results_ = plugins.url_to_dict( url_, secure_logging=asset.secure_logging ) if results_ is None: if placeholders and placeholders.used(url_): # Report placeholders rejected during URL parsing. ConfigBase.logger.error( "A template variable can not be used at this" " position for {}://, entry #{}".format( schema, no + 1 ) ) continue # Setup dictionary results_ = { # Minimum requirements "schema": schema, } else: # Check the parsed URL before applying YAML settings. offender = dropped_template(url_, results_, placeholders) if offender: # Settings underneath cannot repair a lost URL marker. ConfigBase.logger.error( "Template variable '{}' can not be used at this" " position for {}://, entry #{}".format( offender, schema, no + 1 ) ) continue if isinstance(tokens, (list, tuple, set)): # Keep YAML names before aliases are mapped (smtp to # smtp_host). Name and from can both feed from_addr, # so a listing must show which name was written. written_ = dict(sibling_tokens) if sibling_tokens else {} # Normalize shared sibling settings once before expanding # the list. Each entry then starts with the same mapped # values, such as smtp becoming smtp_host. if sibling_tokens and schema in N_MGR: sibling_tokens = ConfigBase._special_token_handler( schema, sibling_tokens ) # populate and/or override any results populated # by parse_url() for entries in tokens: # Copy ourselves a template of our parsed URL # as a base to work with r = results_.copy() # Apply sibling tokens as a shared base before # per-entry tokens (sibling < per-entry) if sibling_tokens: r.update(sibling_tokens) # We are a url string with additional options if isinstance(entries, dict): # Tags you just can't over-ride if "schema" in entries: del entries["schema"] # Add this entry's written names over shared ones. written_entry = dict(written_) written_entry.update(entries) # support our special tokens if schema in N_MGR: entries = ConfigBase._special_token_handler( schema, entries ) # Extend our dictionary with our new entries r.update(entries) # Keep every YAML contribution together so later # URL post-processing cannot overwrite it. Child # values are added last and therefore keep priority # over sibling values. yaml_ = {} if sibling_tokens: yaml_.update(sibling_tokens) yaml_.update(entries) # add our results to our global set results.append((r, yaml_, written_entry)) elif isinstance(tokens, dict): # The URL key selects the schema. A child setting must not # replace it, matching the list-expansion path above. tokens.pop("schema", None) # Record the names as written before any mapping. written_ = dict(sibling_tokens) if sibling_tokens else {} written_.update(tokens) # Normalize siblings and children separately before they # are merged. Otherwise a sibling alias such as smtp could # map onto smtp_host and replace an explicit child value. if schema in N_MGR: if sibling_tokens: sibling_tokens = ConfigBase._special_token_handler( schema, sibling_tokens ) tokens = ConfigBase._special_token_handler( schema, tokens ) # Merge from lowest to highest YAML priority: siblings # first, then the settings nested below the URL. if sibling_tokens: merged = sibling_tokens.copy() merged.update(tokens) tokens = merged # Copy ourselves a template of our parsed URL as # a base to work with r = results_.copy() # add our result set r.update(tokens) # add our results to our global set results.append((r, dict(tokens), written_)) elif sibling_tokens: # A URL with a null child can still receive settings from # sibling keys. Preserve their written names before aliases # and plugin-specific names are normalized. written_ = dict(sibling_tokens) if schema in N_MGR: sibling_tokens = ConfigBase._special_token_handler( schema, sibling_tokens ) # Copy ourselves a template of our parsed URL as # a base to work with r = results_.copy() # Apply the sibling token overrides r.update(sibling_tokens) # add our results to our global set results.append((r, dict(sibling_tokens), written_)) else: # add our results to our global set results.append((results_, {}, {})) else: # Unsupported ConfigBase.logger.warning( f"Unsupported Apprise YAML entry #{no + 1}" ) continue # Track our entries entry = 0 # Prepare our results for post-processing results = deque(results) while len(results): # Increment our entry count entry += 1 # Grab our first item results_, yaml_tokens_, written_tokens_ = results.popleft() if results_["schema"] not in N_MGR: # the arguments are invalid or can not be used. ConfigBase.logger.warning( "An invalid Apprise schema ({}) in YAML configuration " "entry #{}, item #{}".format( results_["schema"], no + 1, entry ) ) continue # tag is a special keyword that is managed by Apprise object. # The below ensures our tags are set correctly. # # Only a tags key written in the YAML is an alias of tag. A # ?tags= found on the URL belongs to the plugin that read it. yaml_tags = "tags" in yaml_tokens_ if "tag" in results_: # Tidy our list up results_["tag"] = ( set(parse_list(results_["tag"], cast=str)) | global_tags ) if yaml_tags: ConfigBase.logger.warning( ( "URL #{}: {} contains both 'tag' and 'tags' " "keyword" ).format(no + 1, url) ) del results_["tags"] elif yaml_tags: # Tidy our list up results_["tag"] = ( set(parse_list(results_["tags"], cast=str)) | global_tags ) # Should not carry forward del results_["tags"] else: # Just use the global settings results_["tag"] = global_tags if templated_tag(results_, placeholders): # Template values must not choose notification recipients. ConfigBase.logger.error( "Template variables are not permitted in tag/tags" " (YAML entry #{}, item #{})".format(no + 1, entry) ) continue # A retry count on a service tag is not valid here: per-service # retry belongs on the URL (retry: key) and call-time retry # overrides belong on the notify() filter. _retry_tags = [ t for t in results_["tag"] if AppriseTag.parse(t).retry is not None ] if _retry_tags: ConfigBase.logger.warning( "You can not specify a retry count as part of a" " service tag (YAML entry #{}, item #{}); use the" " 'retry:' key instead.".format(no + 1, entry) ) continue for key in list(results_.keys()): # Strip out any tokens we know that we can't accept and # warn the user match = ( VALID_TOKEN.match(key) if isinstance(key, str) else None ) if not match: ConfigBase.logger.warning( f"Ignoring invalid token ({key}) found in YAML " f"configuration entry #{no + 1}, item #{entry}" ) del results_[key] yaml_tokens_.pop(key, None) written_tokens_.pop(key, None) if ConfigBase.logger.isEnabledFor(logging.TRACE): ConfigBase.logger.trace( "URL #%d: %s unpacked as:%s%s", no + 1, url, os.linesep, os.linesep.join( [f'{k}="{a}"' for k, a in results_.items()] ), ) # Prepare our Asset Object results_["asset"] = asset # Full URL parsers have already applied the query string. Hide # qsd during their second normalization pass so those lower- # priority values cannot overwrite YAML settings. Minimal # @notify results keep qsd because they still need that pass. orig_qsd = results_.get("qsd") if all( k in results_ for k in QSD_FULL_MODE_KEYS ) and isinstance(orig_qsd, dict): # These extra fields reliably identify a full parse result. del results_["qsd"] # Handle post processing of result set results_ = URLBase.post_process_parse_url_results(results_) # Restore YAML's priority after either normalization path. # Special keys were already assembled and stay untouched. if yaml_tokens_: results_.update( { k: v for k, v in yaml_tokens_.items() if k not in YAML_REAPPLY_SKIP } ) # Keep the original query mapping available downstream. if orig_qsd is not None: results_["qsd"] = orig_qsd # Store our preloaded entries preloaded.append( { "results": results_, "settings": written_tokens_, "entry": no + 1, "item": entry, } ) # # Normalize Tag Groups # - Expand Groups of Groups so that they don't exist # ConfigBase.__normalize_tag_groups(group_tags) # # URL Processing # for entry in preloaded: # Point to our results entry for easier reference below results = entry["results"] # # Apply our tag groups if they're defined # for group, tags in group_tags.items(): # Detect if anything assigned to this tag also maps back to a # group. If so we want to add the group to our list if next( (True for tag in results["tag"] if tag in tags), False ): results["tag"].add(group) if placeholders: needed = placeholders.used(results) if not needed: # Restore any escaped but undeclared ${NAME} text. results = placeholders.substitute(results, {}) else: # Keep the entry pending until its values are available. services.append( NotifyTemplate( results=results, placeholders=placeholders, schema=template_schema, names=needed, settings=entry["settings"], asset=results.get("asset"), entry=entry["entry"], item=entry["item"], ) ) continue # Now we generate our plugin try: # Attempt to create an instance of our plugin using the # parsed URL information plugin = N_MGR[results["schema"]](**results) # Create log entry of loaded URL ConfigBase.logger.debug( "Loaded URL: %s", plugin.url(privacy=results["asset"].secure_logging), ) except Exception as e: # the arguments are invalid or can not be used. ConfigBase.logger.error( "Could not load Apprise YAML configuration " "entry #{}, item #{}".format(entry["entry"], entry["item"]) ) ConfigBase.logger.debug(f"Loading Exception: {e!s}") continue # if we reach here, we successfully loaded our data services.append(plugin) preloaded.clear() return (services, configs) def pop(self, index: int = -1) -> object: """Removes an indexed Notification Service from the stack and returns it. By default, the last element of the list is removed. """ if not isinstance(self._cached_services, list): # Generate ourselves a list of content we can pull from self.services() # Pop the element off of the stack return self._cached_services.pop(index) def clear_cache(self) -> None: """Cleans cache""" self._cached_services = None self._cached_time = None @staticmethod def _special_token_handler( schema: str, tokens: dict[str, object], ) -> dict[str, object]: """Normalize YAML keys before constructing a notification plugin. Applies common aliases, collects prefixed template values, and maps plugin argument or URL-token names to constructor arguments. """ # Create a copy of our dictionary tokens = tokens.copy() # Normalize common aliases such as `pass` before plugin-specific keys. # YAML input does not pass through the equivalent URL parsing step. for alias, canonical in URL_TOKEN_ALIASES.items(): if alias in tokens and ( canonical not in tokens or tokens[canonical] is None ): # Use the alias when the standard key is absent or null. tokens[canonical] = tokens.pop(alias) elif alias in tokens: # Keep the explicitly set standard key instead of its alias. del tokens[alias] for kw, meta in N_MGR[schema].template_kwargs.items(): # Determine our prefix: prefix = meta.get("prefix", "+") # Detect any matches matches = { k[1:]: str(v) for k, v in tokens.items() if isinstance(k, str) and k.startswith(prefix) } if not matches: # we're done with this entry continue if not isinstance(tokens.get(kw), dict): # Invalid; correct it tokens[kw] = {} # strip out processed tokens tokens = { k: v for k, v in tokens.items() if not isinstance(k, str) or not k.startswith(prefix) } # Update our entries tokens[kw].update(matches) # Map YAML keys through the templates defined by each service. For # example, an email recipient can be supplied as a YAML sub-key: # # urls: # - mailto://user:pass@domain: # - to: user1@hotmail.com # - to: user2@hotmail.com # # The email template maps `to` to the constructor's `targets` value. class_templates = plugins.details(N_MGR[schema]) for key in list(tokens.keys()): # Keys may match query arguments or named URL parts. Arguments # take priority when both templates define the same name. if key in class_templates["args"]: entry = class_templates["args"][key] elif key in class_templates["tokens"]: entry = class_templates["tokens"][key] else: # No need to handle non-arg entries continue # Prefer an alias, then fall back to a constructor mapping. map_to = entry.get("alias_of", entry.get("map_to", "")) if map_to == key: # We're already good as we are now continue if not isinstance(map_to, str): # Some URL shortcuts represent several values. Only the # plugin's URL parser can split them safely. ConfigBase.logger.warning( f"The {schema}:// {key} keyword can only be defined" " from within a URL; ignoring it." ) del tokens[key] continue # Use the destination's type details when available. meta = class_templates["tokens"].get( map_to, class_templates["args"].get(map_to) ) if not isinstance(meta, dict) or "type" not in meta: meta = entry # Untyped aliases pass their value through as a string. arg_type = meta.get("type", "string") # Remove the original key before adding its mapped value. value = tokens[key] del tokens[key] # Follow one additional mapping to the constructor argument. abs_map = meta.get("map_to", map_to) # List destinations collect repeated YAML values. is_list = re.search(r"^list:.*", arg_type, re.IGNORECASE) if abs_map not in tokens: tokens[abs_map] = [] if is_list else meta.get("default") elif is_list and not isinstance(tokens.get(abs_map), list): # Preserve an existing scalar before appending another value. tokens[abs_map] = [tokens[abs_map]] # Convert string fields supplied with another YAML scalar type. if re.search( r"^(choice:)?string", arg_type, re.IGNORECASE ) and not isinstance(value, str): value = str(value) # Store the value under its constructor argument name. if isinstance(tokens.get(abs_map), list): tokens[abs_map].append(value) else: tokens[abs_map] = value # Return our tokens return tokens def __getitem__(self, index: int) -> object: """Return the cached service at ``index``, loading it if necessary.""" if not isinstance(self._cached_services, list): # Generate ourselves a list of content we can pull from self.services() return self._cached_services[index] def __iter__(self) -> object: """Iterate over cached services, loading the source if necessary.""" if not isinstance(self._cached_services, list): # Generate ourselves a list of content we can pull from self.services() return iter(self._cached_services) def __len__(self) -> int: """Return the number of parsed services, loading when necessary. The configuration source itself is not counted. Services obtained from permitted nested includes are included in the total. """ if not isinstance(self._cached_services, list): # Generate ourselves a list of content we can pull from self.services() return len(self._cached_services) def __bool__(self) -> bool: """Allows the Apprise object to be wrapped in an 'if statement'. True is returned if our content was downloaded correctly. """ if not isinstance(self._cached_services, list): # Generate ourselves a list of content we can pull from self.services() return bool(self._cached_services) caronc-apprise-dfec414/apprise/config/file.py000066400000000000000000000137451526720371000213320ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import os import re from ..common import ConfigFormat, ContentIncludeMode from ..locale import gettext_lazy as _ from ..utils.disk import path_decode from .base import ConfigBase class ConfigFile(ConfigBase): """A wrapper for File based configuration sources.""" # The default descriptive name associated with the service service_name = _("Local File") # The default protocol protocol = "file" # Configuration file inclusion can only be of the same type allow_cross_includes = ContentIncludeMode.STRICT def __init__(self, path, **kwargs): """Initialize File Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) # Store our file path as it was set self.path = path_decode(path) # Track the file as it was saved self.__original_path = os.path.normpath(path) # Update the config path to be relative to our file we just loaded self.config_path = os.path.dirname(self.path) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Prepare our cache value if isinstance(self.cache, bool) or not self.cache: cache = "yes" if self.cache else "no" else: cache = int(self.cache) # Define any URL parameters params = { "encoding": self.encoding, "cache": cache, } if self.config_format: # A format was enforced; make sure it's passed back with the url params["format"] = self.config_format return "file://{path}{params}".format( path=self.quote(self.__original_path), params=f"?{self.urlencode(params)}" if params else "", ) def read(self, **kwargs): """Perform retrieval of the configuration based on the specified request.""" response = None try: if ( self.max_buffer_size > 0 and os.path.getsize(self.path) > self.max_buffer_size ): # Content exceeds maximum buffer size self.logger.error( "File size exceeds maximum allowable buffer length" f" ({int(self.max_buffer_size / 1024)}KB)." ) return None except OSError: # getsize() can throw this acception if the file is missing # and or simply isn't accessible self.logger.error(f"File is not accessible: {self.path}") return None # Always call throttle before any server i/o is made self.throttle() try: with open(self.path, encoding=self.encoding) as f: # Store our content for parsing response = f.read() except (ValueError, UnicodeDecodeError): # A result of our strict encoding check; if we receive this # then the file we're opening is not something we can # understand the encoding of.. self.logger.error( f"File not using expected encoding ({self.encoding}) :" f" {self.path}" ) return None except OSError: # IOError is present for backwards compatibility with Python # versions older then 3.3. >= 3.3 throw OSError now. # Could not open and/or read the file; this is not a problem since # we scan a lot of default paths. self.logger.error(f"File can not be opened for read: {self.path}") return None # Detect config format based on file extension if it isn't already # enforced if ( self.config_format is None and re.match(r"^.*\.ya?ml\s*$", self.path, re.I) is not None ): # YAML Filename Detected self.default_config_format = ConfigFormat.YAML # Return our response object return response @staticmethod def parse_url(url): """Parses the URL so that we can handle all different file paths and return it as our path object.""" results = ConfigBase.parse_url(url, verify_host=False) if not results: # We're done early; it's not a good URL return results match = re.match(r"[a-z0-9]+://(?P[^?]+)(\?.*)?", url, re.I) if not match: return None results["path"] = ConfigFile.unquote(match.group("path")) return results caronc-apprise-dfec414/apprise/config/http.py000066400000000000000000000242131526720371000213620ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import re import requests from ..common import ConfigFormat, ContentIncludeMode from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import URL_ENCODED_PATH_SAFE_CHARS from .base import ConfigBase # Support YAML formats # text/yaml # text/x-yaml # application/yaml # application/x-yaml MIME_IS_YAML = re.compile(r"(text|application)/(x-)?yaml", re.I) # Support TEXT formats # text/plain # text/html MIME_IS_TEXT = re.compile(r"text/(plain|html)", re.I) class ConfigHTTP(ConfigBase): """A wrapper for HTTP based configuration sources.""" # The default descriptive name associated with the service service_name = _("Web Based") # The default protocol protocol = "http" # The default secure protocol secure_protocol = "https" # If an HTTP error occurs, define the number of characters you still want # to read back. This is useful for debugging purposes, but nothing else. # The idea behind enforcing this kind of restriction is to prevent abuse # from queries to services that may be untrusted. max_error_buffer_size = 2048 # Configuration file inclusion can always include this type allow_cross_includes = ContentIncludeMode.ALWAYS def __init__(self, headers=None, **kwargs): """Initialize HTTP Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.schema = "https" if self.secure else "http" self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "/" self.headers = {} if headers: # Store our extra headers self.headers.update(headers) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Prepare our cache value if isinstance(self.cache, bool) or not self.cache: cache = "yes" if self.cache else "no" else: cache = int(self.cache) # Define any arguments set params = { "encoding": self.encoding, "cache": cache, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) if self.config_format: # A format was enforced; make sure it's passed back with the url params["format"] = self.config_format # Append our headers into our args params.update({f"+{k}": v for k, v in self.headers.items()}) # Determine Authentication auth = "" if self.password: auth = "{user}:{password}@".format( user=self.quote(self.user or "", safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=self.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}/?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, hostname=self.quote(self.host, safe=""), port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=self.quote( self.fullpath, safe=URL_ENCODED_PATH_SAFE_CHARS ), params=self.urlencode(params), ) def read(self, **kwargs): """Perform retrieval of the configuration based on the specified request.""" # prepare XML Object headers = { "User-Agent": self.app_id, } # Apply any/all header over-rides defined headers.update(self.headers) auth = None if self.user or self.password: # Basic Auth also permits a password with an empty username. auth = (self.user or "", self.password) url = f"{self.schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath self.logger.debug( f"HTTP POST URL: {url} (cert_verify={self.verify_certificate!r})" ) # Prepare our response object response = None # Where our request object will temporarily live. r = None # Always call throttle before any remote server i/o is made self.throttle() try: # Make our request with requests.post( url, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, stream=True, ) as r: # Handle Errors r.raise_for_status() # raise_for_status() only covers 4xx/5xx; when redirect # following is disabled any 3xx must be treated as a failure # so we do not silently return a redirect stub as config. # Using a status-code range rather than r.is_redirect # catches 3xx responses that lack a Location header. if not self.redirects and 300 <= r.status_code < 400: self.logger.error( "HTTP redirect encountered but redirect " "following is disabled:" f" {self.url(privacy=True)}" ) return None # Get our file-size (if known) try: file_size = int(r.headers.get("Content-Length", "0")) except (TypeError, ValueError): # Handle edge case where Content-Length is a bad value file_size = 0 # Store our response if ( self.max_buffer_size > 0 and file_size > self.max_buffer_size ): # Provide warning of data truncation self.logger.error( "HTTP config response exceeds maximum buffer length " f"({int(self.max_buffer_size / 1024)}KB);" ) # Return None - buffer execeeded return None # Store our result (but no more than our buffer length) response = r.text[: self.max_buffer_size + 1] # Verify that our content did not exceed the buffer size: if len(response) > self.max_buffer_size: # Provide warning of data truncation self.logger.error( "HTTP config response exceeds maximum buffer length " f"({int(self.max_buffer_size / 1024)}KB);" ) # Return None - buffer execeeded return None # Detect config format based on mime if the format isn't # already enforced content_type = r.headers.get( "Content-Type", "application/octet-stream" ) if self.config_format is None and content_type: if MIME_IS_YAML.match(content_type) is not None: # YAML data detected based on header content self.default_config_format = ConfigFormat.YAML elif MIME_IS_TEXT.match(content_type) is not None: # TEXT data detected based on header content self.default_config_format = ConfigFormat.TEXT except requests.RequestException as e: self.logger.error( "A Connection error occurred retrieving HTTP " f"configuration from {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return None (signifying a failure) return None # Return our response object return response @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = ConfigBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # Add our headers that the user can potentially over-ride if they wish # to to our returned result set results["headers"] = results["qsd-"] results["headers"].update(results["qsd+"]) return results caronc-apprise-dfec414/apprise/config/memory.py000066400000000000000000000052551526720371000217200ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from ..locale import gettext_lazy as _ from .base import ConfigBase class ConfigMemory(ConfigBase): """For information that was loaded from memory and does not persist anywhere.""" # The default descriptive name associated with the service service_name = _("Memory") # The default protocol protocol = "memory" def __init__(self, content, **kwargs): """Initialize Memory Object. Memory objects just store the raw configuration in memory. There is no external reference point. It's always considered cached. """ super().__init__(**kwargs) # Store our raw config into memory self.content = content if self.config_format is None: # Detect our format if possible self.config_format = ConfigMemory.detect_config_format( self.content ) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" return "memory://" def read(self, **kwargs): """Simply return content stored into memory.""" return self.content @staticmethod def parse_url(url): """Memory objects have no parseable URL.""" # These URLs can not be parsed return None caronc-apprise-dfec414/apprise/conversion/000077500000000000000000000000001526720371000207475ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/conversion/__init__.py000066400000000000000000000075141526720371000230670ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Conversion is split by format, HTML, CommonMark repair, and dialect fitting. # Supported helpers are re-exported from ``apprise.conversion``. from .commonmark import ( commonmark_can_close_emphasis, commonmark_can_open_emphasis, commonmark_decode_backslash_escapes, commonmark_emphasis_run, commonmark_escape_link_url, commonmark_find_backtick_run, commonmark_headings_to_bold, commonmark_index_backtick_runs, commonmark_lookahead_closer_widths, commonmark_match_emphasis, commonmark_materialize_repair, commonmark_new_scan_budget, commonmark_pick_emphasis_sentinel, commonmark_render_emphasis_events, commonmark_render_emphasis_markers, commonmark_repair_chunk, commonmark_scan_angle_dest, commonmark_scan_autolink_dest, commonmark_scan_closer_runs, commonmark_scan_delimiter_run, commonmark_scan_paren_dest, commonmark_scan_repair_region, commonmark_sections, ) from .dialect import split_dialect_chunk, truncate_dialect_chunk from .format import ( convert_between, html_to_markdown, html_to_text, markdown_to_html, text_to_html, text_to_markdown, ) from .html import ( BLOCKQUOTE_DEPTH_MAX, LIST_DEPTH_MAX, MAX_FRAME_DEPTH, HTMLConverter, HTMLMarkdownConverter, HTMLTagReducer, ) __all__ = [ "BLOCKQUOTE_DEPTH_MAX", "LIST_DEPTH_MAX", "MAX_FRAME_DEPTH", "HTMLConverter", "HTMLMarkdownConverter", "HTMLTagReducer", "commonmark_can_close_emphasis", "commonmark_can_open_emphasis", "commonmark_decode_backslash_escapes", "commonmark_emphasis_run", "commonmark_escape_link_url", "commonmark_find_backtick_run", "commonmark_headings_to_bold", "commonmark_index_backtick_runs", "commonmark_lookahead_closer_widths", "commonmark_match_emphasis", "commonmark_materialize_repair", "commonmark_new_scan_budget", "commonmark_pick_emphasis_sentinel", "commonmark_render_emphasis_events", "commonmark_render_emphasis_markers", "commonmark_repair_chunk", "commonmark_scan_angle_dest", "commonmark_scan_autolink_dest", "commonmark_scan_closer_runs", "commonmark_scan_delimiter_run", "commonmark_scan_paren_dest", "commonmark_scan_repair_region", "commonmark_sections", "convert_between", "html_to_markdown", "html_to_text", "markdown_to_html", "split_dialect_chunk", "text_to_html", "text_to_markdown", "truncate_dialect_chunk", ] caronc-apprise-dfec414/apprise/conversion/commonmark.py000066400000000000000000003014331526720371000234700ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Scan and repair CommonMark before plugins translate it to service dialects. from bisect import bisect_left, bisect_right from functools import lru_cache import re import unicodedata from ..common import NotifyFormat from ..utils.format import smart_split # Keep related constants together and define dependent patterns afterward. # CommonMark ASCII punctuation; Unicode categories cover other characters. _CM_ASCII_PUNCTUATION = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" # CommonMark expands tabs to the next four-column stop in block indentation. _TAB_SIZE = 4 # Preserve list and blockquote markers from Apprise's HTML converter. # Quote spacing applies only when followed by ">". _QUOTE_UNIT = r"(?: {0,3}>[ \t]?)" # Match a list marker and its leading indentation; padding is measured later. _LIST_MARKER_CHAR_RE = re.compile(r"^ {0,3}(?:[-*+]|[0-9]{1,9}[.)])") # Match a fence of at least three backticks or tildes and its info string. # Backtick fence info cannot contain another backtick. _FENCE_SHAPE_RE = re.compile(r"^(?:(`{3,})[^\n`]*|(~{3,})[^\n]*)$") # Match a heading with up to three leading spaces and one to six "#". # A heading marker must be followed by whitespace or the end of the line. _ATX_HEADING_START_RE = re.compile(r"^ {0,3}#{1,6}(?:[ \t]|$)") # Match a blockquote using the shared quote-prefix pattern. _QUOTE_START_RE = re.compile(r"^" + _QUOTE_UNIT) # A thematic break: three or more of the same character among -, _, *, # optionally separated by spaces or tabs, with 0-3 leading spaces. _THEMATIC_BREAK_RE = re.compile( r"^ {0,3}(?:-[ \t]*){3,}$" r"|^ {0,3}(?:_[ \t]*){3,}$" r"|^ {0,3}(?:\*[ \t]*){3,}$" ) # A setext underline turns the open paragraph above it into a heading. _SETEXT_UNDERLINE_RE = re.compile(r"^ {0,3}(?:=+|-+)[ \t]*$") # These HTML block tags interrupt a CommonMark paragraph. _HTML_BLOCK_TAGS = ( "address|article|aside|base|basefont|blockquote|body|caption|" "center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|" "figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|" "hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|" "ol|optgroup|option|p|param|pre|script|section|source|style|" "summary|table|tbody|td|textarea|tfoot|th|thead|title|tr|track|ul" ) # Raw HTML blocks close at a matching raw-tag end rather than a blank line. _HTML_RAW_TAGS = frozenset(("script", "pre", "style", "textarea")) # Any complete raw-tag closer ends the block, even if its name differs from # the opener. Partial closing tags do not count. _HTML_RAW_TAG_CLOSE_RE = re.compile( r"", re.IGNORECASE ) # Named groups identify each HTML block's closing rule. _HTML_BLOCK_START_RE = re.compile( r"^ {0,3}(?:" r"(?P", search_from, limit) elif m.group("pi"): terminator = text.find("?>", search_from, limit) elif m.group("decl"): terminator = text.find(">", search_from, limit) elif m.group("cdata"): terminator = text.find("]]>", search_from, limit) else: # Per spec, any of the four raw tags' exact closing form ends # the block -- not only the one that opened it -- while a # partial form such as " 3: pos = line_end + 1 continue slack_end_pos = prefix_end_pos + _chars_for_width( line[prefix_end_pos:], slack_width, prefix_end_col ) rest = line[slack_end_pos:] rest_col = prefix_end_col + slack_width fence_open = _match_fence_open(rest, rest_col) if fence_open: fence_char, fence_len = fence_open start = pos # Find the closer or the boundary of an unfinished nested fence. end = _find_fence_end( text, line_end + 1, quote_depth, list_width, fence_char, fence_len, ) spans.append((start, end, "fence")) paragraph_open = False pos = end continue html_m = _match_html_block_start(rest) if html_m: start = pos end = _commonmark_html_block_end( text, pos + slack_end_pos, line_end, html_m, quote_depth, list_width, ) spans.append((start, end, "html")) paragraph_open = False pos = end continue if not paragraph_open and _HTML_CUSTOM_TAG_RE.match(rest): # Type 7 closes at the next blank line, the same as type 6 -- # and cannot outlive its own quote/list container either. start = pos end = _blank_line_end(text, line_end + 1, quote_depth, list_width) spans.append((start, end, "html")) paragraph_open = False pos = end continue if paragraph_open and _SETEXT_UNDERLINE_RE.match(rest): # The underline turns the open paragraph into a heading. paragraph_open = False elif paragraph_open: # The list marker was removed from ``rest``; a non-empty marker # or another block start interrupts the open paragraph. has_nonempty_marker = bool(marker_width) and bool(rest.strip()) paragraph_open = not ( has_nonempty_marker or _looks_like_new_block(rest) ) else: # Ordinary non-empty text starts a paragraph. A bare list or # quote marker does not. paragraph_open = bool(rest) and not _looks_like_new_block(rest) pos = line_end + 1 return spans def _commonmark_fenced_code_spans(text): """Find backtick and tilde fenced code blocks as ``(start, end)`` offsets. A thin filter over ``_commonmark_leaf_block_spans()`` -- see that function for why fences and HTML blocks must be resolved together in one pass rather than independently. """ return [ (start, end) for start, end, kind in _commonmark_leaf_block_spans(text) if kind == "fence" ] def _commonmark_html_block_spans(text): """Find CommonMark HTML block ranges as ``(start, end)`` offsets. This filters types 1-7 from the shared fence and HTML scan. """ return [ (start, end) for start, end, kind in _commonmark_leaf_block_spans(text) if kind == "html" ] def commonmark_headings_to_bold(body): """Turn ``# Heading`` lines into bold text. - For services that have bold but no headings. - Code blocks, code spans, and HTML are left alone. - List and quote prefixes such as ``> `` are kept. Example: ``"# Alert"`` becomes ``"**Alert**"``. """ # Find fenced code and HTML blocks together to avoid a duplicate scan. protected = sorted( _commonmark_code_spans(body) + [ (start, end) for start, end, _ in _commonmark_leaf_block_spans(body) ] ) # Both inputs are ordered, so one forward cursor avoids rescanning. span_idx = 0 # Create the scanner only when the first ambiguous heading needs it. container_scanner = None out = [] pos = 0 n = len(body) while pos < n: line_end = body.find("\n", pos) line_end = line_end if line_end != -1 else n raw_line = body[pos:line_end] line = _strip_cr(raw_line) # Skip past any protected region that ends at or before this line. while span_idx < len(protected) and protected[span_idx][1] <= pos: span_idx += 1 in_protected = ( span_idx < len(protected) and protected[span_idx][0] <= pos ) replacement = None if not in_protected: ( quote_depth, quote_end_col, marker_width, prefix_end_pos, prefix_end_col, ) = _scan_opening_prefix(line) slack_width = _leading_width(line[prefix_end_pos:], prefix_end_col) ( _, _, extra_slack, container_scanner, ) = _resolve_prefix_widths( body, pos, quote_depth, quote_end_col, marker_width, slack_width, container_scanner, ) # More than three columns after a list or quote is indented # code, not a heading. if extra_slack <= 3: slack_end_pos = prefix_end_pos + _chars_for_width( line[prefix_end_pos:], slack_width, prefix_end_col ) heading_m = _ATX_HEADING_SHAPE_RE.match(line[slack_end_pos:]) if heading_m: heading = heading_m.group("content") or "" # Strip a trailing closing sequence like " ##" before # trimming ordinary whitespace, so "## Alert ##" # becomes "Alert", not "Alert ##". heading = _ATX_CLOSING_SEQUENCE_RE.sub("", heading).strip() prefix = line[:slack_end_pos] replacement = ( f"{prefix}**{heading}**" if heading else prefix ) # A trailing "\r" (if any) is not restored on a converted heading, # matching CommonMark's own line-ending-agnostic ATX heading rule. out.append(replacement if replacement is not None else raw_line) if line_end < n: out.append("\n") pos = line_end + 1 return "".join(out) def commonmark_decode_backslash_escapes(text): r"""Remove the backslash from escaped punctuation. - Use it on text that is not Markdown, such as a link URL or label. - Only punctuation that CommonMark allows to be escaped is changed, so Windows paths keep their backslashes. Example: ``r"a\_b"`` becomes ``"a_b"``, but ``r"C:\Users"`` stays the same. """ out = [] # Scan the text without allocating intermediate match objects. i = 0 n = len(text) while i < n: ch = text[i] if ch == "\\" and i + 1 < n and text[i + 1] in _CM_ASCII_PUNCTUATION: # Discard the backslash and keep only the escaped character. out.append(text[i + 1]) i += 2 continue out.append(ch) i += 1 return "".join(out) def commonmark_escape_link_url(url): r"""Make a Markdown link URL safe inside a ```` style link. - Removes Markdown backslash escapes. - ``&``, ``<`` and ``>`` become HTML entities. - ``|`` becomes ``%7C`` so it is not read as the label separator. Example: ``r"a\|b&c"`` becomes ``"a%7Cb&c"``. """ # Step 1: strip CommonMark backslash escapes so we recover the raw URL # characters before re-applying any encoding the target dialect needs. url = commonmark_decode_backslash_escapes(url) # Step 2: re-encode the characters that the delimiter syntax # would mis-parse if left bare in the URL string. # "&" must come first to avoid double-encoding the entities below. url = url.replace("&", "&").replace("<", "<") url = url.replace(">", ">") # "|" is the separator between the URL and label inside ; # percent-encode it so it cannot be mistaken for the delimiter. return url.replace("|", "%7C") def commonmark_new_scan_budget(body): """Create a work limit shared by the link scanners for one message. - Pass the result as ``budget`` to ``commonmark_scan_angle_dest()`` and ``commonmark_scan_paren_dest()``. - Each scan uses up some of it. Once it is gone, scans only look a short distance ahead, so a message full of broken links cannot make scanning slow. Example: ``commonmark_new_scan_budget("abc")`` returns ``[12]``. """ return [len(body) * SCAN_BUDGET_MULTIPLIER] def commonmark_scan_angle_dest(body, i, n, budget=None): r"""Find where a ``]()`` link ends. - ``i`` is the index of the ``]``. - Returns the index of the closing ``>``, or ``None`` if not found. - Escaped characters such as ``\>`` are skipped. - ``budget`` is optional (see ``commonmark_new_scan_budget()``). Example: in ``"[x]()"``, ``i=2`` returns ``14``. """ # Start immediately after the opening ``](<`` sequence. start = i + 3 k = start # After the shared budget is spent, allow a small local scan. Include room # for ``>)`` so a URL at the fallback length can still close. limit = ( n if budget is None or budget[0] > 0 else min(n, start + MIN_SCAN_ALLOWANCE + 2) ) # Scan until a complete two-character terminator can no longer fit. while k < limit - 1: if body[k] == "\\" and k + 1 < n: # Skip escape sequences -- they cannot be the terminator. k += 2 continue if body[k] == ">" and body[k + 1] == ")": if budget is not None: budget[0] -= k - start return k k += 1 if budget is not None: budget[0] -= k - start return None def commonmark_scan_paren_dest(text, i, n, budget=None): """Find where a ``](url)`` link ends. - ``i`` is the index of the ``(``. - Returns the index of the matching ``)``, or ``None`` if the URL is not valid (for example, it contains a space or ``<``). - Balanced brackets inside the URL are allowed. - ``budget`` is optional (see ``commonmark_new_scan_budget()``). Example: in ``"[x](a_(b))"``, ``i=3`` returns ``9``. """ depth = 1 start = i + 1 k = start # After the budget is spent, leave room for a nearby closing ``)``. limit = ( n if budget is None or budget[0] > 0 else min(n, start + MIN_SCAN_ALLOWANCE + 1) ) while k < limit: ch = text[k] if ch == "\\" and k + 1 < n: k += 2 continue if ch == "(": depth += 1 elif ch == ")": depth -= 1 if depth == 0: if budget is not None: budget[0] -= k - start return k else: is_control = ord(ch) < 0x20 or ord(ch) == 0x7F if ch == "<" or is_control or _cm_is_whitespace(ch): if budget is not None: budget[0] -= k - start return None k += 1 if budget is not None: budget[0] -= k - start return None def commonmark_scan_autolink_dest(text, i, n): """Check whether ```` at ``i`` is an autolink. - ``i`` is the index of the ``<``. - Returns ``(end, still_valid)``: - ``end`` is the index of the closing ``>``, or ``None``. - ``still_valid`` is ``False`` once a space or other character that is not allowed is seen. It stays ``True`` if the text simply ends first. Examples: - ``""`` returns ``(12, True)``. - ``""`` returns ``(None, False)``. """ match = _AUTOLINK_SCHEME_RE.match(text, i + 1) if not match: return None, False k = match.end() while k < n: ch = text[k] if ch == ">": return k, True # A nested "<", a control character, or whitespace rules out a # genuine autolink -- CommonMark allows none of these in its # destination. is_control = ord(ch) < 0x20 or ord(ch) == 0x7F if ch == "<" or is_control or _cm_is_whitespace(ch): return None, False k += 1 # Ran out of text before finding a terminator or a disqualifier. return None, True # Unicode's Private Use areas, where placeholder characters are taken from _PRIVATE_USE_RANGES = ( (0xE000, 0xF8FF), (0xF0000, 0xFFFFD), (0x100000, 0x10FFFD), ) # The total number of Private Use characters _PRIVATE_USE_TOTAL = sum(end - start + 1 for start, end in _PRIVATE_USE_RANGES) # One Private Use character, in any of the areas above _PRIVATE_USE_CLASS = "".join( f"{chr(start)}-{chr(end)}" for start, end in _PRIVATE_USE_RANGES ) _PRIVATE_USE_RE = re.compile(f"[{_PRIVATE_USE_CLASS}]") # A run of anything that is not a Private Use character _NON_PRIVATE_USE_RE = re.compile(f"[^{_PRIVATE_USE_CLASS}]+") def _private_use_chars(): """Yield every Private Use character, in slot order.""" for start, end in _PRIVATE_USE_RANGES: yield from map(chr, range(start, end + 1)) def commonmark_pick_emphasis_sentinel(body): r"""Pick a marker character that does not appear anywhere in ``body``. - Converters temporarily swap ``*`` and ``_`` runs for numbered placeholders like ``S0S``, ``S1S`` (``S`` is the marker). Once they know which runs pair up, the placeholders are replaced with the service's bold or italic syntax. - The marker must not already be in the message, or user text could be mistaken for a placeholder. - It is taken from Unicode's Private Use area, which normal text almost never uses. Usually this is ``"\ue000"``. - If the message already uses every Private Use character, a two-character marker is returned instead. Examples: - ``commonmark_pick_emphasis_sentinel("hi *there*")`` returns ``"\ue000"``. - ``commonmark_pick_emphasis_sentinel("x\ue000")`` returns ``"\ue001"``. """ if not _PRIVATE_USE_RE.search(body): # Most messages use no Private Use characters at all return chr(_PRIVATE_USE_RANGES[0][0]) # Keep only the Private Use characters; a bounded set of them is all # that is needed to see which are in use private = _NON_PRIVATE_USE_RE.sub("", body) present = set(private) for char in _private_use_chars(): if char not in present: # The first Private Use character the message does not use return char # Every one is taken, so pair one with a character that never follows # it. A character used k times has at most k different followers, so # one used fewer times than there are Private Use characters always # has a free partner; only a few need counting to find one. first = next( char for char in _private_use_chars() if private.count(char) < _PRIVATE_USE_TOTAL - 1 ) follows = re.compile(re.escape(first) + f"(?=([{_PRIVATE_USE_CLASS}]))") taken = {match.group(1) for match in follows.finditer(body)} # A pair is always two different characters taken.add(first) return first + next( char for char in _private_use_chars() if char not in taken ) def _commonmark_emphasis_marker_pattern(sentinel): """Match sentinel-wrapped indexes in the accompanying delimiter list.""" escaped = re.escape(sentinel) return re.compile(escaped + r"(\d+)" + escaped) def commonmark_emphasis_run(body, i, n, delimiters, out, sentinel): """Swap the ``*`` or ``_`` run at ``i`` for a numbered placeholder. - Adds a description of the run to ``delimiters`` (its character, length, and whether it can open or close emphasis). - Appends ``sentinel + index + sentinel`` to ``out``. - Returns the index just past the run. - Call ``commonmark_render_emphasis_markers()`` when done. Example: for ``"**bold**"`` at ``i=0`` with ``sentinel="S"``, ``out`` gets ``"S0S"`` and ``2`` is returned. """ ch = body[i] # Measure the full delimiter run starting at i. j = i while j < n and body[j] == ch: j += 1 prev_ch = body[i - 1] if i > 0 else None next_ch = body[j] if j < n else None delimiters.append( { "char": ch, "numdelims": j - i, "origdelims": j - i, "can_open": commonmark_can_open_emphasis(ch, prev_ch, next_ch), "can_close": commonmark_can_close_emphasis(ch, prev_ch, next_ch), "events": [], } ) index = len(delimiters) - 1 out.append(f"{sentinel}{index}{sentinel}") # Return the position right after the full delimiter run. return j def commonmark_render_emphasis_events(events, strong_markers, regular_markers): """Build the markers to output for one ``*`` or ``_`` run. - ``events`` is the run's list of ``("open" | "close", is_strong)``. - Returns ``(close_part, open_part)``. Closing markers come first. Example: ``[("open", True)]`` with strong markers ``("", "")`` returns ``("", "")``. """ close_part = "".join( (strong_markers[1] if is_strong else regular_markers[1]) for kind, is_strong in events if kind == "close" ) # Open markers nest around content collected after them, so the # outermost open (the first one recorded) must render last. open_part = "".join( (strong_markers[0] if is_strong else regular_markers[0]) for kind, is_strong in reversed(events) if kind == "open" ) return close_part, open_part def commonmark_render_emphasis_markers( text, delimiters, strong_markers, regular_markers, sentinel ): """Replace the placeholders with the service's bold and italic markers. - ``text`` and ``delimiters`` come from ``commonmark_emphasis_run()``. - ``strong_markers`` and ``regular_markers`` are ``(open, close)`` pairs. - A run with no partner is put back as its original ``*`` or ``_``. Example: ``"**hi** and *x*"`` becomes ``"S0ShiS1S and S2SxS3S"``, and with ``("", "")`` and ``("", "")`` this returns ``"hi and x"``. """ commonmark_match_emphasis(delimiters) def _substitute(match): descriptor = delimiters[int(match.group(1))] leftover = descriptor["char"] * descriptor["numdelims"] close_part, open_part = commonmark_render_emphasis_events( descriptor["events"], strong_markers, regular_markers ) return close_part + leftover + open_part marker_re = _commonmark_emphasis_marker_pattern(sentinel) return marker_re.sub(_substitute, text) def _commonmark_escape_literal(text): """Escape syntax characters in fragments known to be literal text.""" return "".join( f"\\{ch}" if ch in _COMMONMARK_LITERAL_CHARS else ch for ch in text ) def _scan_angle_terminator(text, start, n): """Find ``>)`` or report a trailing ``>`` split from the next chunk.""" k = start while k < n: if text[k] == "\\" and k + 1 < n: # Skip escape sequences -- they cannot be the terminator. k += 2 continue if text[k] == ">": if k + 1 < n and text[k + 1] == ")": return k, False if k + 1 == n: # Unescaped ">" is the last character of this chunk. return None, True k += 1 return None, False # These checks depend only on their arguments, so their results are safe to # cache. Repeated characters can then reuse earlier Unicode lookups. @lru_cache(maxsize=8192) def _cm_is_whitespace(ch): """Return whether ``ch`` is CommonMark whitespace or a boundary.""" if ch is None: return True if ch in " \t\n\r\f\v": return True return unicodedata.category(ch) == "Zs" @lru_cache(maxsize=8192) def _cm_is_punctuation(ch): """Return whether ``ch`` is CommonMark punctuation or a symbol.""" if ch is None: # Callers classify a missing boundary as whitespace first, so this # fallback is defensive only. return False if ch in _CM_ASCII_PUNCTUATION: return True return unicodedata.category(ch)[0] in ("P", "S") @lru_cache(maxsize=8192) def _cm_flanking(prev_ch, next_ch): """Return a delimiter run's left- and right-flanking flags.""" left_flanking = not _cm_is_whitespace(next_ch) and ( not _cm_is_punctuation(next_ch) or _cm_is_whitespace(prev_ch) or _cm_is_punctuation(prev_ch) ) right_flanking = not _cm_is_whitespace(prev_ch) and ( not _cm_is_punctuation(prev_ch) or _cm_is_whitespace(next_ch) or _cm_is_punctuation(next_ch) ) return left_flanking, right_flanking @lru_cache(maxsize=8192) def commonmark_can_open_emphasis(delim_char, prev_ch, next_ch): """Return whether a ``*`` or ``_`` run can start emphasis. - ``prev_ch`` and ``next_ch`` are the characters around the run (``None`` at the start or end of the text). - Follows the CommonMark rules: a run followed by a space cannot open. Example: ``commonmark_can_open_emphasis("*", " ", "x")`` is ``True``. """ left_flanking, right_flanking = _cm_flanking(prev_ch, next_ch) if not left_flanking: return False if delim_char != "_": return True # Prevent right-flanking intraword underscores from opening emphasis. return not right_flanking or _cm_is_punctuation(prev_ch) @lru_cache(maxsize=8192) def commonmark_can_close_emphasis(delim_char, prev_ch, next_ch): """Return whether a ``*`` or ``_`` run can end emphasis. - ``prev_ch`` and ``next_ch`` are the characters around the run (``None`` at the start or end of the text). - Follows the CommonMark rules: a run after a space cannot close. Example: ``commonmark_can_close_emphasis("*", "x", " ")`` is ``True``. """ left_flanking, right_flanking = _cm_flanking(prev_ch, next_ch) if not right_flanking: return False if delim_char != "_": return True # Prevent left-flanking intraword underscores from closing emphasis. return not left_flanking or _cm_is_punctuation(next_ch) def commonmark_scan_delimiter_run( text, i, boundary_prev_ch=None, boundary_next_ch=None ): """Measure the ``*`` or ``_`` run at ``i``. - Returns ``(end, prev_ch, next_ch)``: the index after the run and the characters on either side (``None`` at an edge). - ``boundary_prev_ch`` and ``boundary_next_ch`` give the neighbours when ``text`` is only part of a longer message. Example: ``commonmark_scan_delimiter_run("**word", 0)`` returns ``(2, None, "w")``. """ ch = text[i] n = len(text) j = i while j < n and text[j] == ch: j += 1 prev_ch = text[i - 1] if i > 0 else boundary_prev_ch next_ch = text[j] if j < n else boundary_next_ch return j, prev_ch, next_ch def commonmark_lookahead_closer_widths( next_chunk, boundary_prev_ch=None, boundary_next_ch=None ): """Find which emphasis runs could be closed by text that follows. - ``next_chunk`` is the start of the next piece of the message. - Returns ``{char: width}`` for each ``*`` or ``_`` closer found. - Markers inside code or links do not count. Examples: - ``commonmark_lookahead_closer_widths("tail** end")`` returns ``{"*": 2}``. - ``commonmark_lookahead_closer_widths("`**` x")`` returns ``{}``. """ if not next_chunk: return {} # Scan once for all pending openers. widths = {} i = 0 n = len(next_chunk) # Index backticks so ``*`` and ``_`` inside code are not treated as markup. backtick_runs = commonmark_index_backtick_runs(next_chunk) while i < n: ch = next_chunk[i] # Skip escape pairs -- an escaped delimiter can never close a span. if ch == "\\" and i + 1 < n: i += 2 continue if ch == "`": j = i while j < n and next_chunk[j] == ch: j += 1 run = j - i close = commonmark_find_backtick_run(backtick_runs, j, run) if close is not None: # Complete code span -- its contents are not delimiters. i = close + run continue # Ignore the rest because it is still part of the unfinished code. break # Both destination forms below start with "]", so check that # cheap single-character condition first instead of paying for # a startswith() call at every position regardless of ch. if ch == "]" and next_chunk.startswith("](<", i): close = commonmark_scan_angle_dest(next_chunk, i, n) if close is not None: # Skip this URL so markup characters inside it stay text. i = close + 2 continue # Ignore the rest because it is still part of the unfinished URL. break if ch == "]" and next_chunk.startswith("](", i): close = commonmark_scan_paren_dest(next_chunk, i + 1, n) if close is not None: # Skip this URL so markup characters inside it stay text. i = close + 1 continue # Same reasoning as an unterminated code span above. break if ch == "<": close, still_valid = commonmark_scan_autolink_dest( next_chunk, i, n ) if close is not None: # Skip this autolink; its markup-like characters are text. i = close + 1 continue if still_valid: # Same reasoning as an unterminated code span above. break # Not an autolink -- fall through and scan it as ordinary text. if ch in "*_": j, prev_ch, next_ch = commonmark_scan_delimiter_run( next_chunk, i, boundary_prev_ch=boundary_prev_ch, boundary_next_ch=boundary_next_ch, ) run = j - i if commonmark_can_close_emphasis(ch, prev_ch, next_ch): width = min(run, 3) if width > widths.get(ch, 0): widths[ch] = width i = j continue i += 1 return widths def commonmark_match_emphasis(delimiters): """Pair up opening and closing ``*`` and ``_`` runs. - ``delimiters`` comes from ``commonmark_emphasis_run()``. - Follows the CommonMark pairing rules. - Adds ``("open" | "close", is_strong)`` entries to each run's ``"events"`` list. Returns the same list. Example: for ``"*text*"`` the first run gets ``[("open", False)]`` and the second gets ``[("close", False)]``. """ total = len(delimiters) # Link surviving markup positions in both directions. Matching can then # skip removed entries instead of repeatedly walking over them. prev_active = list(range(-1, total - 1)) next_active = list(range(1, total + 1)) # Use a mutable cell because removing the first entry changes the head. head = [0 if total else total] def _unlink(index): # Remove one entry by linking its neighbors directly together. before = prev_active[index] after = next_active[index] if before == -1: head[0] = after else: next_active[before] = after if after != total: prev_active[after] = before # Remember failed searches so later closers skip known-impossible ranges. search_bound = {} closer_index = head[0] while closer_index < total: closer = delimiters[closer_index] if not closer["can_close"]: closer_index = next_active[closer_index] continue bucket = (closer["char"], closer["can_open"], closer["origdelims"] % 3) bound = search_bound.get(bucket, -1) opener_index = prev_active[closer_index] found = -1 while opener_index > bound: opener = delimiters[opener_index] if opener["char"] == closer["char"] and opener["can_open"]: # Apply the multiple-of-three ambiguity rule. ambiguous = ( (closer["can_open"] or opener["can_close"]) and closer["origdelims"] % 3 != 0 and (opener["origdelims"] + closer["origdelims"]) % 3 == 0 ) if not ambiguous: found = opener_index break opener_index = prev_active[opener_index] if found == -1: # Cache the failed range for later closers in this bucket. search_bound[bucket] = closer_index - 1 if not closer["can_open"]: # This run cannot participate in a later match. next_pos = next_active[closer_index] _unlink(closer_index) closer_index = next_pos else: closer_index = next_active[closer_index] continue opener = delimiters[found] # Strong emphasis (2 delimiters) is preferred whenever both # sides have enough width left for it. use_delims = ( 2 if closer["numdelims"] >= 2 and opener["numdelims"] >= 2 else 1 ) opener["numdelims"] -= use_delims closer["numdelims"] -= use_delims opener["events"].append(("open", use_delims == 2)) closer["events"].append(("close", use_delims == 2)) # Markup inside this match cannot pair with text outside it. Link the # opener and closer directly so the enclosed entries are skipped. if found + 1 != closer_index: next_active[found] = closer_index prev_active[closer_index] = found if opener["numdelims"] == 0: _unlink(found) if closer["numdelims"] == 0: next_pos = next_active[closer_index] _unlink(closer_index) closer_index = next_pos # Retry a partially consumed closer on the next pass. # Anything still reachable was not fully consumed by a match. still_active = [False] * total pos = head[0] while pos < total: still_active[pos] = True pos = next_active[pos] for index, descriptor in enumerate(delimiters): descriptor["extendable"] = still_active[index] return delimiters def commonmark_repair_chunk( text, pending, next_chunk=None, next_chunk_boundary_ch=None, record_atoms=None, ): """Fix one piece of a split Markdown message so it displays correctly. - When a long message is split, bold or italic text can be cut in half. This closes it at the end of the piece. - ``pending`` tracks styles still open from earlier pieces. Start with ``{}`` and pass the returned state to the next call. - ``next_chunk`` is the text that comes next, used to check that a style really does close later. - Returns ``(fixed_text, pending)``. Example: ``commonmark_repair_chunk("**hello", {}, " world**")`` returns ``("**hello**", {"**": 1})``. ``record_atoms`` (optional) is a list that collects ``(start, end, kind, payload)`` entries. Each ``kind`` is one of: - ``"plain"``: ordinary text, safe to cut anywhere. - ``"literal"``: one escape, code span, or link that must stay whole. - ``"delimiter"``: a ``*`` or ``_`` run. - ``"consumed"``: markup already closed by an earlier piece. - ``"resume"``: text plus the updated ``pending`` state. """ def _record(start, end, kind, payload): """Record one parsed section when the caller requests reusable data.""" if record_atoms is not None: record_atoms.append((start, end, kind, payload)) # Collect fragments for one final join. out = [] # Collect source-ordered delimiter runs for one matching pass. delimiters = [] # Preserve the caller's state dictionary. pending = dict(pending) # Track possible link-label openings in this chunk. link_stack = [] # Track labels so delayed records can be restored to source order. had_link_label = False # Initialize the single-pass scanner. i = 0 n = len(text) # Index backtick runs for efficient matching. backtick_runs = commonmark_index_backtick_runs(text) # Pick a placeholder character proven not to collide with this # chunk's own real content before recording any delimiter runs. sentinel = commonmark_pick_emphasis_sentinel(text) # Resume code, links, or escapes started in the previous chunk. # Keep these fragments together and save the resulting state so a later # prefix query can replay this section without rebuilding it. _resume_start = len(out) in_code_width = pending.pop("in_code", None) if in_code_width: # Search this chunk for the carried code span's closing fence. close = commonmark_find_backtick_run(backtick_runs, 0, in_code_width) if close is not None: # Carried content plus the leftover fence are plain text now. out.append(_commonmark_escape_literal(text[:close])) i = close + in_code_width else: # Still doesn't close in this chunk either; carry it onward. out.append(_commonmark_escape_literal(text)) pending["in_code"] = in_code_width i = n _record( 0, i, "resume", {"text": "".join(out[_resume_start:]), "pending": dict(pending)}, ) elif pending.pop("in_link_dest", False): # Complete a terminator split between the previous and current chunk. if pending.pop("dest_gt", False) and n and text[0] == ")": out.append("\\)") i = 1 else: # Find the end of a destination already treated as literal text. close, trailing_gt = _scan_angle_terminator(text, 0, n) if close is not None: # Escape the remaining destination and its closing marker. out.append(_commonmark_escape_literal(text[:close])) out.append("\\>\\)") i = close + 2 else: # Carry the destination and any split closing marker forward. out.append(_commonmark_escape_literal(text)) pending["in_link_dest"] = True if trailing_gt: pending["dest_gt"] = True i = n _record( 0, i, "resume", {"text": "".join(out[_resume_start:]), "pending": dict(pending)}, ) elif pending.pop("in_autolink", False): # Complete a still-forming autolink split from the previous chunk. close = text.find(">") if text else -1 if close != -1: # Escape the remaining destination and its closing marker. out.append(_commonmark_escape_literal(text[:close])) out.append("\\>") i = close + 1 else: # Still doesn't close in this chunk either; carry it onward. out.append(_commonmark_escape_literal(text)) pending["in_autolink"] = True i = n _record( 0, i, "resume", {"text": "".join(out[_resume_start:]), "pending": dict(pending)}, ) elif pending.pop("in_escape", False): # The prior backslash already escapes this chunk's first character. if text: out.append(text[0]) i = 1 _record( 0, i, "resume", { "text": "".join(out[_resume_start:]), "pending": dict(pending), }, ) else: # Still nothing to consume; keep waiting for a real chunk. pending["in_escape"] = True # Scan the remainder of this chunk. while i < n: # Remember where this parsed section begins so it can be reused later. start = i ch = text[i] # Preserve escapes already present in the CommonMark source. if ch == "\\": if i + 1 < n: out.append(text[i : i + 2]) i += 2 _record(start, i, "literal", out[-1]) continue # Carry a trailing backslash's escape state into the next chunk. out.append(ch) pending["in_escape"] = True i = n # Save the state because this escape continues in the next chunk. _record( start, i, "resume", {"text": out[-1], "pending": dict(pending)} ) continue # Preserve complete code spans or carry split spans forward. if ch == "`": j = i # Measure the opening backtick run. while j < n and text[j] == "`": j += 1 run = j - i # Look for the matching close run in the pre-built index. close = commonmark_find_backtick_run(backtick_runs, j, run) if close is not None: # Complete span in this chunk: copy verbatim. out.append(text[i : close + run]) i = close + run _record(start, i, "literal", out[-1]) continue # Drop a split fence and carry its width. Some targets decode # escapes before rendering, so escaping the fence is unsafe. pending["in_code"] = run out.append(_commonmark_escape_literal(text[j:])) i = n # Save the new state because this code span continues later. _record( start, i, "resume", {"text": out[-1], "pending": dict(pending)} ) continue # Preserve a complete standalone autolink or carry a split one # forward. Its interior must never be scanned for emphasis # delimiters -- a "*" or "_" inside a URL is literal, not markup. if ch == "<": close, still_valid = commonmark_scan_autolink_dest(text, i, n) if close is not None: # Complete autolink in this chunk: copy verbatim. out.append(text[i : close + 1]) i = close + 1 _record(start, i, "literal", out[-1]) continue if still_valid: # Carry the still-forming autolink onward, rendered as # escaped literal text in the meantime. out.append(_commonmark_escape_literal(text[i:])) pending["in_autolink"] = True i = n # This sets pending too -- same reasoning as above. _record( start, i, "resume", {"text": out[-1], "pending": dict(pending)}, ) continue # Not an autolink -- fall through and preserve it literally. # Classify delimiters from source text, not unresolved placeholders. if ch in "*_": j, prev_ch, next_ch = commonmark_scan_delimiter_run( text, i, boundary_next_ch=(next_chunk[0] if next_chunk else None), ) run = j - i # Consume closers already rendered in the previous chunk. # Save consumed markers so reused scans update state too. consumed_markers = [] while run > 0: marker = ch * 2 if run >= 2 else ch if pending.get(marker, 0) > 0: pending[marker] -= 1 run -= len(marker) consumed_markers.append(marker) else: break if run > 0: # Record remaining width for the chunk-wide matching pass. descriptor = { "char": ch, "numdelims": run, "origdelims": run, "can_open": commonmark_can_open_emphasis( ch, prev_ch, next_ch ), "can_close": commonmark_can_close_emphasis( ch, prev_ch, next_ch ), "events": [], } delimiters.append(descriptor) index = len(delimiters) - 1 out.append(f"{sentinel}{index}{sentinel}") # Save a description before matching changes the live copy. # Each reused prefix must begin with the same original values. _record( start, j, "delimiter", { "char": descriptor["char"], "numdelims": descriptor["numdelims"], "origdelims": descriptor["origdelims"], "can_open": descriptor["can_open"], "can_close": descriptor["can_close"], "events": [], "consumed_markers": consumed_markers, }, ) else: # The previous chunk consumed this run. Record that state # change even though this section produces no output. _record( start, j, "consumed", {"consumed_markers": consumed_markers}, ) i = j continue # Track link labels for carry-over across chunk boundaries. if ch == "[": # Keep output and source positions until the link completes. link_stack.append((len(out), i)) out.append(ch) i += 1 # Add the label record after its outcome is known. had_link_label = True continue if text.startswith("](<", i): # We are at the "](<" that may close a pending link label. close = commonmark_scan_angle_dest(text, i, n) if close is not None and link_stack: # Record the opener separately from the completed link ending. open_index, open_start = link_stack.pop() _record(open_start, open_start + 1, "literal", out[open_index]) out.append(text[i : close + 2]) _record(start, close + 2, "literal", text[i : close + 2]) i = close + 2 continue # Keep an incomplete link as escaped literal text. if link_stack: open_index, open_start = link_stack.pop() _record(open_start, open_start + 1, "literal", out[open_index]) # The shared scanner stops at a real ``[``, so reaching this point # without a label is safe to record as literal or continued text. _dest_start = len(out) if close is not None: out.append("\\]\\(") out.append(_commonmark_escape_literal(text[i + 3 : close])) out.append("\\>\\)") i = close + 2 _record(start, i, "literal", "".join(out[_dest_start:])) else: out.append("\\]\\(") out.append(_commonmark_escape_literal(text[i + 3 :])) pending["in_link_dest"] = True # Record a trailing ">" that may close in the next chunk. _, trailing_gt = _scan_angle_terminator(text, i + 3, n) if trailing_gt: pending["dest_gt"] = True i = n _record( start, i, "resume", { "text": "".join(out[_dest_start:]), "pending": dict(pending), }, ) continue if text.startswith("](", i): # A bare "](" (the angle-bracket form above already handled # "](<") that may close a pending link label with an # unbracketed destination. close = commonmark_scan_paren_dest(text, i + 1, n) if close is not None and link_stack: # Record the opener separately from the completed link ending. open_index, open_start = link_stack.pop() _record(open_start, open_start + 1, "literal", out[open_index]) out.append(text[i : close + 1]) _record(start, close + 1, "literal", text[i : close + 1]) i = close + 1 continue # This failed label now has its final literal value. if link_stack: open_index, open_start = link_stack.pop() _record(open_start, open_start + 1, "literal", out[open_index]) if close is not None: # Without a matching ``[``, escape this URL as literal text so # any ``*`` or ``_`` inside it cannot become markup. _dest_start = len(out) out.append("\\]\\(") out.append(_commonmark_escape_literal(text[i + 2 : close])) out.append("\\)") i = close + 1 _record(start, i, "literal", "".join(out[_dest_start:])) continue # Leave the unfinished URL untouched and do not inspect its markup. # Only angle-style URLs carry state because Apprise creates them. out.append(text[i:]) i = n # No cross-chunk state changed, so record this as literal text. _record(start, i, "literal", out[-1]) continue # Group ordinary characters to reduce repeated append calls. # An unhandled markup-like character stands alone so the # following character is checked normally on the next pass. if ch in "\\`<*_[]": i += 1 else: while i < n and text[i] not in "\\`<*_[]": i += 1 out.append(text[start:i]) # Plain text can be safely shortened when rebuilding a smaller prefix. _record(start, i, "plain", out[-1]) # Escape labels that did not complete within this chunk. for open_index, open_start in link_stack: out[open_index] = "\\" + out[open_index] _record(open_start, open_start + 1, "literal", out[open_index]) if record_atoms is not None and had_link_label: # Restore source order after adding delayed label records. record_atoms.sort(key=lambda atom: atom[0]) return _commonmark_resolve_emphasis( out, delimiters, sentinel, text_len=n, last_char=(text[-1] if text else None), pending=pending, next_chunk=next_chunk, next_chunk_boundary_ch=next_chunk_boundary_ch, ) def _commonmark_resolve_emphasis( out, delimiters, sentinel, text_len, last_char, pending, next_chunk, next_chunk_boundary_ch, closer_widths=None, ): """Resolve ``*`` and ``_`` runs and return ``(text, new_pending)``. ``text_len`` and ``last_char`` describe the requested prefix. ``closer_widths`` may supply precomputed closing-marker widths so the function does not need to scan ``next_chunk`` again. """ # Resolve emphasis after every run in the chunk is known. commonmark_match_emphasis(delimiters) # Force eligible unmatched openers closed for this chunk and carry their # state forward. Runs already ruled out by CommonMark stay literal. new_pending = dict(pending) # Treat a final single marker as literal and a final empty pair as noise. last_index = len(delimiters) - 1 trailing_empty = ( bool(delimiters) and bool(out) and out[-1] == f"{sentinel}{last_index}{sentinel}" ) # Classify edge runs using the characters around the lookahead slice, # unless a caller already worked this out from a shared index. if closer_widths is None: lookahead_closer_widths = commonmark_lookahead_closer_widths( next_chunk, boundary_prev_ch=last_char, boundary_next_ch=next_chunk_boundary_ch, ) else: lookahead_closer_widths = closer_widths # Track forced opens so the tail can close them in reverse order. forced_open_groups = [] for index, descriptor in enumerate(delimiters): # Carry an opener only when lookahead contains a possible closer. available_width = lookahead_closer_widths.get(descriptor["char"], 0) if ( not descriptor["can_open"] or not descriptor["extendable"] or descriptor["numdelims"] <= 0 or not available_width ): continue # Drop empty trailing runs unless that would empty the whole chunk. whole_chunk_is_this_run = text_len == descriptor["origdelims"] if ( trailing_empty and index == last_index and not whole_chunk_is_this_run ): # Drop leftover pairs and preserve at most one literal marker. descriptor["numdelims"] %= 2 continue # Record at most one strong and one regular verified expectation. char = descriptor["char"] opened = [] to_allocate = descriptor["numdelims"] if available_width >= 2 and to_allocate >= 2: opened.append(char * 2) to_allocate -= 2 if available_width != 2 and to_allocate >= 1: opened.append(char) to_allocate -= 1 # Escape unallocated width so dialect adapters keep it literal. excess = _commonmark_escape_literal(char * to_allocate) descriptor["numdelims"] = 0 # Emit literal excess before the portion closed for this chunk. real_close_part, _ = commonmark_render_emphasis_events( descriptor["events"], (char * 2, char * 2), (char, char) ) descriptor["_forced_text"] = real_close_part + excess + "".join(opened) for marker in opened: new_pending[marker] = new_pending.get(marker, 0) + 1 forced_open_groups.append(opened) # Close forced-open runs from innermost to outermost. for opened in reversed(forced_open_groups): out.extend(opened) def _substitute(match): descriptor = delimiters[int(match.group(1))] if "_forced_text" in descriptor: return descriptor["_forced_text"] char = descriptor["char"] # Escape unmatched width that cannot become emphasis. leftover = _commonmark_escape_literal(char * descriptor["numdelims"]) close_part, open_part = commonmark_render_emphasis_events( descriptor["events"], (char * 2, char * 2), (char, char) ) return close_part + leftover + open_part marker_re = _commonmark_emphasis_marker_pattern(sentinel) resolved_text = marker_re.sub(_substitute, "".join(out)) return resolved_text, new_pending # --------------------------------------------------------------------------- # Shared repair scans the source once, then rebuilds many shorter prefixes from # recorded sections. Links fall back to a direct repair because their meaning # can change at each cut. Closing ``*`` and ``_`` runs use a shared lookup too. def _cm_build_range_max_table(values): """Precompute range maximums for quick repeated lookups. Each row covers larger power-of-two windows. ``_cm_range_max()`` combines two of those windows to find the largest value in any requested range. """ n = len(values) table = [list(values)] width = 1 while width * 2 <= n: prev_row = table[-1] table.append( [ max(prev_row[k], prev_row[k + width]) for k in range(n - width * 2 + 1) ] ) width *= 2 return table def _cm_range_max(table, lo, hi): """Return ``max(values[lo:hi])`` using a table built by ``_cm_build_range_max_table()``. Requires ``hi > lo``. """ length = hi - lo level = length.bit_length() - 1 row = table[level] width = 1 << level # Cover the requested range from both ends; overlap does not affect max(). return max(row[lo], row[hi - width]) class _CmCloserRunIndex: """Find the widest closing-marker run in a requested text range. ``commonmark_scan_closer_runs()`` builds one index for ``*`` and one for ``_`` so repeated prefix checks do not scan the same text again. """ __slots__ = ("_ends", "_starts", "_table") def __init__(self, starts, ends, widths): # Runs arrive in source order, so no sorting is needed. Keep every run # for boundary checks; a zero width means it cannot close markup. self._starts = starts self._ends = ends self._table = _cm_build_range_max_table(widths) def widest_in_window(self, window_start, window_end): """Return the widest closing run visible in this range, up to 3. Partly visible runs are shortened to the visible width. Call ``straddles()`` first and use a direct scan when a boundary cuts a run. """ starts = self._starts ends = self._ends n = len(starts) if n == 0 or window_start >= window_end: return 0 # The first table row stores usable widths; zero means ignore the run. run_widths = self._table[0] # At most one non-overlapping run can cross the window's left edge. idx = bisect_right(starts, window_start) - 1 best = 0 if idx >= 0 and ends[idx] > window_start: visible = min(ends[idx], window_end) - window_start best = min(visible, run_widths[idx]) idx += 1 else: idx += 1 stop = bisect_left(starts, window_end) if stop > idx: # These runs are inside the window, so one lookup covers them. best = max(best, _cm_range_max(self._table, idx, stop)) return best def straddles(self, position): """Return whether ``position`` falls inside a recorded marker run. A boundary inside a run changes which neighboring characters are visible, so callers must directly rescan that range for a safe result. """ starts = self._starts idx = bisect_right(starts, position) - 1 return idx >= 0 and starts[idx] < position < self._ends[idx] def commonmark_scan_closer_runs(text, boundary_next_ch=None): """Index the ``*`` and ``_`` runs in ``text`` that could close emphasis. - Built once so the same text can be checked many times quickly. - Stops at the first escape, code span, or link, because those can change the meaning of text after them. - Returns ``(index_by_char, covered_end)``: - ``index_by_char`` has one index for ``*`` and one for ``_``. - ``covered_end`` is how far into ``text`` the index can be used. Example: ``commonmark_scan_closer_runs("a** b_ `c*`")`` gives a ``covered_end`` of ``7``, where the code span starts. """ starts = {"*": [], "_": []} ends = {"*": [], "_": []} widths = {"*": [], "_": []} i = 0 n = len(text) while i < n: ch = text[i] if ch in "\\`<]": # Stop here because ranges beginning later may interpret this # escape, code span, or link differently. break if ch in "*_": j, prev_ch, next_ch = commonmark_scan_delimiter_run( text, i, boundary_next_ch=boundary_next_ch ) # Record every run so boundaries that split an unusable run are # detected too. Store zero when it cannot close markup. starts[ch].append(i) ends[ch].append(j) widths[ch].append( min(j - i, 3) if commonmark_can_close_emphasis(ch, prev_ch, next_ch) else 0 ) i = j continue i += 1 index_by_char = { ch: _CmCloserRunIndex(starts[ch], ends[ch], widths[ch]) for ch in ("*", "_") } return index_by_char, i def commonmark_scan_repair_region(text, pending, lookahead_span): """Scan text once so many shorter cuts of it can be repaired quickly. - Used when searching for the longest piece that fits a length limit. - Pass the results to ``commonmark_materialize_repair()``, with the same ``pending`` and ``lookahead_span``. - Returns ``(recorded_sections, covered_end, sentinel)``: - ``recorded_sections``: ``(start, end, kind, payload)`` entries (see ``commonmark_repair_chunk()``). - ``covered_end``: how far the sections can be reused. - ``sentinel``: the marker from ``commonmark_pick_emphasis_sentinel()``. Example: ``"**hello world**"`` records a ``"delimiter"``, a ``"plain"`` section ``"hello world"``, and another ``"delimiter"``. """ bracket_pos = text.find("[") covered_end = len(text) if bracket_pos == -1 else bracket_pos region = text[:covered_end] # Start lookahead at the reusable region's end, which may be shortened by a # link opening. Read it from the supplied text so it need not be predicted. lookahead_end = covered_end + lookahead_span lookahead = text[covered_end:lookahead_end] lookahead_boundary_ch = text[lookahead_end : lookahead_end + 1] or None atoms = [] commonmark_repair_chunk( region, pending, next_chunk=(lookahead or None), next_chunk_boundary_ch=lookahead_boundary_ch, record_atoms=atoms, ) # An unfinished ``<`` may become an autolink when more text is available. # Keep only the recorded sections before it, whose meaning cannot change. for atom_index, (atom_start, _end, kind, payload) in enumerate(atoms): if kind == "plain" and payload == "<": covered_end = atom_start region = text[:covered_end] atoms = atoms[:atom_index] break # One unique placeholder is safe for every shorter prefix of this region. sentinel = commonmark_pick_emphasis_sentinel(region) return atoms, covered_end, sentinel def commonmark_materialize_repair( body, offset, cut, pending, scan_atoms, covered_end, sentinel, lookahead_span, closer_index=None, closer_covered_end=None, ): """Repair the first ``cut`` characters of ``body`` from ``offset``, reusing a scan from ``commonmark_scan_repair_region()``. - Gives the same result as ``commonmark_repair_chunk()`` with ``next_chunk`` set to the next ``lookahead_span`` characters. - Pass the values from ``commonmark_scan_repair_region()`` with the same ``pending`` state. - ``closer_index`` and ``closer_covered_end`` (from ``commonmark_scan_closer_runs()``) are optional but save a rescan. Example: after scanning ``"**hello world**"``, a ``cut`` of ``7`` returns ``("**hello**", {"**": 1})``. """ def _fallback(): """Repair directly when the shared scan cannot be reused.""" abs_cut = offset + cut lookahead_end = abs_cut + lookahead_span lookahead = body[abs_cut:lookahead_end] boundary_next_ch = body[lookahead_end : lookahead_end + 1] or None return commonmark_repair_chunk( body[offset:abs_cut], pending, next_chunk=lookahead or None, next_chunk_boundary_ch=boundary_next_ch, ) if cut <= 0: # Even empty input can update carried state, so use the normal repair. return _fallback() if cut > covered_end: # The shared scan stopped before this cut, usually at a link opening. return _fallback() # Recorded sections cover the reusable text in order with no gaps. ends = [atom[1] for atom in scan_atoms] split = bisect_left(ends, cut) tail_text = None if ends[split] == cut: # This recorded section ends at the cut, so include it in full. included = scan_atoms[: split + 1] else: # The cut lands inside this recorded section. atom_start, _atom_end, kind, payload = scan_atoms[split] if kind != "plain": # Escapes, code spans, links, and markup runs must remain whole. # Repair directly rather than guessing how part of one behaves. return _fallback() # A run of ordinary characters carries no escaping ambiguity, so # any prefix of it is trivially valid on its own. included = scan_atoms[:split] tail_text = payload[: cut - atom_start] # Rebuild the prefix from recorded sections. Use a private state copy so # each query replays carried-marker changes independently. out = [] delimiters = [] working_pending = dict(pending) for _start, _end, kind, payload in included: if kind == "resume": # Replace working state with the snapshot saved for this section. out.append(payload["text"]) working_pending = dict(payload["pending"]) elif kind == "delimiter": # Copy mutable markup data so independent queries cannot change it. delimiters.append( { "char": payload["char"], "numdelims": payload["numdelims"], "origdelims": payload["origdelims"], "can_open": payload["can_open"], "can_close": payload["can_close"], "events": [], } ) out.append(f"{sentinel}{len(delimiters) - 1}{sentinel}") for marker in payload["consumed_markers"]: working_pending[marker] = working_pending.get(marker, 0) - 1 elif kind == "consumed": # This run produced no text, but its state change still applies. for marker in payload["consumed_markers"]: working_pending[marker] = working_pending.get(marker, 0) - 1 else: out.append(payload) if tail_text is not None: out.append(tail_text) abs_cut = offset + cut lookahead_end = abs_cut + lookahead_span last_char = body[abs_cut - 1] if cut else None # Express this prefix's lookahead range relative to the shared scan. rel_start = cut rel_end = cut + lookahead_span closer_widths = None if closer_index is not None and rel_end <= closer_covered_end: straddling = any( index.straddles(rel_start) or index.straddles(rel_end) for index in closer_index.values() ) if not straddling: # A boundary inside a marker run requires the direct scan below. closer_widths = {} for ch, index in closer_index.items(): width = index.widest_in_window(rel_start, rel_end) if width: closer_widths[ch] = width if closer_widths is not None: return _commonmark_resolve_emphasis( out, delimiters, sentinel, text_len=cut, last_char=last_char, pending=working_pending, next_chunk=None, next_chunk_boundary_ch=None, closer_widths=closer_widths, ) lookahead = body[abs_cut:lookahead_end] boundary_next_ch = body[lookahead_end : lookahead_end + 1] or None return _commonmark_resolve_emphasis( out, delimiters, sentinel, text_len=cut, last_char=last_char, pending=working_pending, next_chunk=(lookahead or None), next_chunk_boundary_ch=boundary_next_ch, ) def _section_text_end(text: str, start: int, tail: int) -> int: """Return the end of intro or section text starting near ``start``. Text ends at trailing whitespace or before the next heading line. """ # Check each "#" before the trailing whitespace hash_idx = text.find("#", start, tail) while hash_idx != -1: # Walk back over the whitespace just before this "#" ws_idx = hash_idx while ws_idx > start and text[ws_idx - 1].isspace(): ws_idx -= 1 # The text ends at the first line break in that whitespace for idx in range(ws_idx, hash_idx): if text[idx] in "\r\n": return idx # This "#" is not at the start of a line; try the next one hash_idx = text.find("#", hash_idx + 1, tail) # Otherwise the text runs up to the trailing whitespace return max(start, tail) def commonmark_sections( markdown: str, name_maxlen: int, value_maxlen: int ) -> tuple[str, list[tuple[str, str]]]: r"""Split Markdown into intro text and ``(heading, text)`` sections. - Used for services that show messages as titled fields. - Headings are cut to ``name_maxlen``. - Section text longer than ``value_maxlen`` continues in extra sections with the same heading. - A heading with no text under it gets ``""``. Example: ``"Intro\n# A\nsome text\n# B"`` returns ``("Intro", [("A", "some text"), ("B", "")])``. """ # Text before the first heading becomes the description description = "" # Skip the leading whitespace n = len(markdown) idx = 0 while idx < n and markdown[idx].isspace(): idx += 1 if idx < n and markdown[idx] != "#": # The description runs until the first heading line tail = len(markdown.rstrip()) end = _section_text_end(markdown, idx + 1, tail) description = markdown[idx:end].strip() if description: # Strip description from our string since it has been handled # now. markdown = markdown.replace(description, "", 1) # Where the trailing whitespace begins n = len(markdown) tail = len(markdown.rstrip()) # Find each heading and the text written below it. sections: list[tuple[str, str]] = [] pos = 0 while True: # Every "#" may open a heading hash_idx = markdown.find("#", pos) if hash_idx == -1: break # The heading runs to the end of its line line_end = markdown.find("\n", hash_idx) if line_end == -1: line_end = n # Skip the "#", space, tab and vertical tab characters after it idx = hash_idx + 1 while idx < line_end and markdown[idx] in "# \t\v": idx += 1 if idx == line_end: if idx == hash_idx + 1: # A lone "#" at the end of a line is not a heading pos = hash_idx + 1 continue # Keep at least the last character as the heading name idx -= 1 # Section names have a length limit of their own name = markdown[idx:line_end].strip("#`* \r\n\t\v")[:name_maxlen] # Skip the line break and any whitespace after the heading idx = min(line_end + 1, n) while idx < n and markdown[idx].isspace(): idx += 1 # Section text needs two or more characters and no leading "#" text = "" pos = idx if idx + 1 < n and markdown[idx] != "#": pos = _section_text_end(markdown, idx + 2, tail) text = markdown[idx:pos].strip() if not text: # A heading with nothing written below it sections.append((name, "")) continue # Continue long sections in sections with the same name. sections.extend( (name, part) for part in smart_split(text, value_maxlen, NotifyFormat.TEXT) ) return description, sections caronc-apprise-dfec414/apprise/conversion/dialect.py000066400000000000000000000316361526720371000227370ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Fit plugin dialect conversions to service limits after CommonMark repair. # Plugins may also reuse these helpers for custom dialect output. from bisect import bisect_right from collections.abc import Sequence import re from typing import Callable, Optional from ..common import NotifyFormat from ..utils.format import HTML_ENTITY_MAXLEN, html_tag_end from .commonmark import ( commonmark_materialize_repair, commonmark_repair_chunk, commonmark_scan_closer_runs, commonmark_scan_repair_region, ) # Only CommonMark markup can make a longer converted prefix fit again. # Use it to avoid unnecessary per-position checks for plain text. _COMMONMARK_MARKUP_RE = re.compile(r"[\\`*_\[\]()<>]") # Limit repeat checks so results do not depend on CPU speed. Most repaired # prefixes that fit appear just above the best result, so check more there and # reserve a few checks for longer prefixes near the other end of the range. _VERIFY_SCAN_UPWARD_PROBES = 24 _VERIFY_SCAN_DOWNWARD_PROBES = 8 def _longest_fitting_prefix(body, offset, limit, dialect_convert, pending): """Return the longest fitting conversion, consumed length, and state. Search from ``offset`` while using the remaining body as repair lookahead. The consumed length is relative to ``offset`` for easy cursor advancement. """ n = len(body) - offset # Cap lookahead because every fitting probe scans it again. Preserve the # next character separately so an edge run still sees its true neighbor. lookahead_span = min(max(1, limit * 8), 2048) def _repair(mid): abs_mid = offset + mid lookahead_end = abs_mid + lookahead_span lookahead = body[abs_mid:lookahead_end] boundary_next_ch = body[lookahead_end : lookahead_end + 1] or None return commonmark_repair_chunk( body[offset:abs_mid], pending, next_chunk=lookahead or None, next_chunk_boundary_ch=boundary_next_ch, ) def _fits(mid): candidate, candidate_pending = _repair(mid) candidate = dialect_convert(candidate) return len(candidate) <= limit, candidate, candidate_pending best_converted, best_len, best_pending = "", 0, pending # Grow geometrically so both expanding and shrinking conversions find a # useful search boundary without starting at the full remaining body. probe = min(n, max(1, limit)) hi = None while True: fits, candidate, candidate_pending = _fits(probe) if not fits: hi = probe break best_converted, best_len, best_pending = ( candidate, probe, candidate_pending, ) if probe >= n: # The entire remaining body fits -- nothing more to search. return best_converted, best_len, best_pending probe = min(n, probe * 2) # The first failed probe bounds the later verification window. first_fail = hi # Later checks only need a short range beyond the first failed length. verify_end = min(n, first_fail + min(lookahead_span, 24)) # Scan once so every tested prefix can reuse the parsed sections. shared_span = verify_end shared_text = body[offset : offset + shared_span + lookahead_span] scan_atoms, covered_end, sentinel = commonmark_scan_repair_region( shared_text, pending, lookahead_span ) # Index closing ``*`` and ``_`` runs once for the same repeated checks. closer_index, closer_covered_end = commonmark_scan_closer_runs(shared_text) def _repair_shared(mid): # Directly repair prefixes that cannot reuse the shared scan. return commonmark_materialize_repair( body, offset, mid, pending, scan_atoms, covered_end, sentinel, lookahead_span, closer_index=closer_index, closer_covered_end=closer_covered_end, ) def _fits_shared(mid): candidate, candidate_pending = _repair_shared(mid) candidate = dialect_convert(candidate) return len(candidate) <= limit, candidate, candidate_pending # Bisect between the largest fit and first overflow. Dialect repair can # occasionally make a longer prefix shorter, so verify that case below. lo, hi = best_len + 1, hi - 1 while lo <= hi: mid = (lo + hi) // 2 fits, candidate, candidate_pending = _fits_shared(mid) if fits: # This prefix fits. Try a longer one. best_converted, best_len, best_pending = ( candidate, mid, candidate_pending, ) lo = mid + 1 else: # Too long. Try a shorter prefix. hi = mid - 1 # A longer repaired prefix can become shorter anywhere bisection skipped. # Verify back to the best known fit instead of assuming a local boundary. # Plain text cannot create a later repaired fit, so skip its scan. verify_region = body[offset + best_len : offset + verify_end] if _COMMONMARK_MARKUP_RE.search(verify_region): # Check above the best result and keep the longest prefix that fits. upward_end = min(verify_end, best_len + _VERIFY_SCAN_UPWARD_PROBES) best_upward = None for mid in range(best_len + 1, upward_end + 1): fits, candidate, candidate_pending = _fits_shared(mid) if fits: best_upward = (candidate, mid, candidate_pending) # Check downward; the first fit is the longest in this range. for probes, mid in enumerate( range(verify_end, upward_end, -1), start=1 ): fits, candidate, candidate_pending = _fits_shared(mid) if fits: return candidate, mid, candidate_pending if probes >= _VERIFY_SCAN_DOWNWARD_PROBES: break if best_upward is not None: return best_upward if best_len == 0: # Not even a single character fits. Force progress anyway. best_converted, best_pending = _repair_shared(1) best_converted = dialect_convert(best_converted) best_len = 1 return best_converted, best_len, best_pending def _html_safe_cuts(body: str) -> list[int]: """Return cut positions outside HTML tags and entities. The end of ``body`` is always included. """ cuts = [] # Track where the current entity ends entity_end = -1 idx = 0 n = len(body) while idx < n: # A cut here would fall just before this character if idx > entity_end: cuts.append(idx) ch = body[idx] if ch == "<": # Jump over the whole tag, quoted attribute values included idx = html_tag_end(body, idx) if idx == -1: # An unclosed tag runs to the end, so no later cut is safe break elif ch == "&": # Protect an entity up to and including its ``;`` semi = body.find(";", idx, idx + HTML_ENTITY_MAXLEN) if semi != -1 and not any(c.isspace() for c in body[idx:semi]): entity_end = semi idx += 1 # The whole body is always a valid cut cuts.append(len(body)) return cuts def _longest_fitting_cut( body: str, offset: int, limit: int, dialect_convert: Callable[[str], str], cuts: Sequence[int], ) -> tuple[str, int, Sequence[int]]: """Return the longest converted prefix ending at one of ``cuts``. Nothing is repaired. The shortest prefix is returned when none fit, ensuring the caller always makes progress. """ # Only cuts past the current offset are candidates first = bisect_right(cuts, offset) lo, hi = first, len(cuts) - 1 best = None # Grow quickly to find the range that may contain the best cut. span = max(1, limit) while lo <= hi: # Use the last cut within the probed length (at least one cut) probe = max(lo, bisect_right(cuts, offset + span, lo) - 1) candidate = dialect_convert(body[offset : cuts[probe]]) if len(candidate) > limit: # Too long; the answer lies below this cut hi = probe - 1 break # This prefix fits; keep growing best = (candidate, cuts[probe] - offset, cuts) lo = probe + 1 span *= 2 # Find the longest safe prefix in the remaining range. while lo <= hi: mid = (lo + hi) // 2 candidate = dialect_convert(body[offset : cuts[mid]]) if len(candidate) <= limit: # This prefix fits. Try a longer one. best = (candidate, cuts[mid] - offset, cuts) lo = mid + 1 else: # Too long. Try a shorter prefix. hi = mid - 1 if best is None: # Not even the shortest safe prefix fits. Force progress anyway. best = ( dialect_convert(body[offset : cuts[first]]), cuts[first] - offset, cuts, ) return best def split_dialect_chunk( body: str, limit: int, dialect_convert: Callable[[str], str], body_format: Optional[NotifyFormat] = None, ) -> list[str]: """Split converted content into pieces within ``limit``. The converter must be deterministic and generally preserve input order. CommonMark is repaired around cuts; HTML keeps tags and entities whole; plain text is cut as it is. """ if not body: return [dialect_convert(body)] converted = dialect_convert(body) if len(converted) <= limit: return [converted] if body_format == NotifyFormat.HTML: # Split HTML only at positions that keep tags and entities whole fit, state = _longest_fitting_cut, _html_safe_cuts(body) elif body_format == NotifyFormat.TEXT: # Plain text may be cut anywhere and is never repaired as markup fit, state = _longest_fitting_cut, range(len(body) + 1) else: # Carry repair state so later pieces consume real closing markers. fit, state = _longest_fitting_prefix, {} pieces = [] # Advance a cursor to avoid copying the full remaining body per piece. n = len(body) offset = 0 while offset < n: piece, consumed, state = fit( body, offset, limit, dialect_convert, state ) # Skip redundant empty closers, but always return at least one piece. if piece or not pieces: pieces.append(piece) offset += consumed return pieces def truncate_dialect_chunk( body: str, limit: int, dialect_convert: Callable[[str], str], body_format: Optional[NotifyFormat] = None, ) -> str: """Return the longest converted prefix within ``limit``. HTML cuts keep tags and entities whole. Plain text is not repaired as markup, and all content after the cut is discarded. """ if not body: return dialect_convert(body) converted = dialect_convert(body) if len(converted) <= limit: return converted if body_format == NotifyFormat.HTML: # Keep tags and entities whole; no CommonMark repair for HTML piece, _, _ = _longest_fitting_cut( body, 0, limit, dialect_convert, _html_safe_cuts(body) ) return piece if body_format == NotifyFormat.TEXT: # Plain text is cut as it is, with no markup repair piece, _, _ = _longest_fitting_cut( body, 0, limit, dialect_convert, range(len(body) + 1) ) return piece # Truncate mode has no prior or subsequent repair state. piece, _, _ = _longest_fitting_prefix(body, 0, limit, dialect_convert, {}) return piece caronc-apprise-dfec414/apprise/conversion/format.py000066400000000000000000000117651526720371000226230ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Convert Apprise's TEXT, HTML, and MARKDOWN formats. Service dialects are # handled separately by ``conversion/dialect.py`` and plugin hooks. from collections.abc import Sequence import re from string import punctuation from typing import Optional from markdown import Markdown from ..common import NotifyFormat from ..compat.markdown33 import escape_placeholders_as_html from ..url import URLBase from .html import HTMLConverter, HTMLMarkdownConverter # CommonMark syntax characters, including backslash itself. # Plain text has no escape state, so escape every occurrence. An "&" is # only escaped when it would otherwise read as an entity such as "&". _COMMONMARK_ESCAPABLE_RE = re.compile( r"([\\_*\[\]()~`>#+=|{}.!-]|&(?=#?[0-9A-Za-z]{1,32};))" ) def convert_between(from_format, to_format, content): """Convert content between supported Apprise formats. The original content is returned when no converter exists for the pair. """ # Fetch the converter registered for this format pair. convert = CONVERTERS.get((from_format, to_format)) # Preserve the original content when no conversion is available return convert(content) if convert else content # Notification-friendly line-break and table extensions. _MARKDOWN_EXTENSIONS = ( "markdown.extensions.nl2br", "markdown.extensions.tables", ) def markdown_to_html( content: str, extensions: Optional[Sequence[str]] = None ) -> str: """Convert Markdown content to HTML. ``extensions`` replaces the default Python-Markdown extensions. """ # Use the notification defaults unless the caller picks its own. md = Markdown( extensions=list( _MARKDOWN_EXTENSIONS if extensions is None else extensions ), ) # Match CommonMark by allowing all ASCII punctuation to be escaped. md.ESCAPED_CHARS = sorted(set(md.ESCAPED_CHARS) | set(punctuation)) # Keep escaped characters valid HTML on every Python-Markdown release. escape_placeholders_as_html(md) return md.convert(content) def text_to_html(content): """Convert plain text content to HTML.""" # First eliminate any carriage returns return URLBase.escape_html(content, convert_new_lines=True) def text_to_markdown(content): """Converts specified content from plain text to CommonMark. Escapes CommonMark-significant characters, including backslashes, so plain text renders literally in Markdown destinations. """ # Prefix each Markdown control character so it stays ordinary text. return _COMMONMARK_ESCAPABLE_RE.sub(r"\\\1", content) def html_to_text(content): """Convert HTML content to plain text.""" # Initialize the plain-text parser. parser = HTMLConverter() # Feed and finalize the HTML document parser.feed(content) parser.close() # Return the finalized parser output. return parser.converted def html_to_markdown(content): """Convert HTML content to CommonMark.""" # Initialize the Markdown parser. parser = HTMLMarkdownConverter() # Feed and finalize the HTML document parser.feed(content) parser.close() # Return the finalized parser output. return parser.converted # Map each supported format pair to the function that converts it. CONVERTERS = { # Each key describes the input format followed by the requested output. (NotifyFormat.MARKDOWN, NotifyFormat.HTML): markdown_to_html, (NotifyFormat.TEXT, NotifyFormat.HTML): text_to_html, (NotifyFormat.HTML, NotifyFormat.TEXT): html_to_text, (NotifyFormat.HTML, NotifyFormat.MARKDOWN): html_to_markdown, (NotifyFormat.TEXT, NotifyFormat.MARKDOWN): text_to_markdown, } caronc-apprise-dfec414/apprise/conversion/html.py000066400000000000000000002121331526720371000222670ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Generic HTML parsers for plain text, CommonMark, and reduced tag sets. # Service-specific markup belongs in plugins. from collections import Counter import contextlib from html import escape from html.parser import HTMLParser import re from typing import Optional from .commonmark import ( commonmark_find_backtick_run, commonmark_index_backtick_runs, ) # Cap list indentation so deeply nested input stays linear. LIST_DEPTH_MAX = 4 # Apply the same depth cap to blockquote prefixes. BLOCKQUOTE_DEPTH_MAX = 4 # Bound the context-stack depth so adversarially nested HTML cannot # exhaust memory. Frames beyond this limit are silently dropped. MAX_FRAME_DEPTH = 200 # Collapse source line breaks and nearby whitespace as HTML does. Starting # at the first space keeps long runs efficient. HTML_NEWLINE_RE = re.compile(r"(?:(? elements so text inside keeps its line breaks. self._pre_depth = 0 # Initialize internal result list self._result = [] # Initialize public result field (not populated until close() is # called) self.converted = "" def close(self): """Finalize the converted content.""" # Flush trailing text that resembles an unfinished entity, such as # the end of "q&a". HTMLParser.close(self) # Combine buffered fragments into one string. string = "".join(self._finalize(self._result)) # Publish the normalized result. self.converted = string.strip() def _finalize(self, result): """Join text fragments and collapse repeated block endings. For example, ``[{be}, " Hello ", {be}, {be}, " World!"]`` becomes ``"\nHello\nWorld!"``. """ # None means the last visited item was a block end. accum = None for item in result: if item == self.BLOCK_END: # Multiple consecutive block ends; do nothing. if accum is None: continue # First block end; yield the current string, plus a newline. yield accum.strip() + "\n" accum = None # Multiple consecutive strings; combine them. elif accum is not None: accum += item # First consecutive string; store it. else: accum = item # Yield the last string if we have not already done so. if accum is not None: yield accum.strip() def handle_data(self, data, *args, **kwargs): """Store our data if it is not on the ignore list.""" # Ignore data while an ignored container is active. if self._do_store: # Collapse whitespace before buffering visible text. Text # inside

 keeps its line breaks and indentation, and only
            # its line break style is normalized.
            content = (
                data.replace("\r\n", "\n").replace("\r", "\n")
                if self._pre_depth
                else self.WS_TRIM.sub(" ", data)
            )

            # Preserve the normalized fragment for final assembly.
            self._result.append(content)

    def handle_starttag(self, tag, attrs):
        """Process our starting HTML Tag."""

        # Toggle storage according to the newly opened container.
        self._do_store = tag not in self.IGNORE_TAGS

        # Track entry into preformatted text.
        if tag == "pre":
            self._pre_depth += 1

        # Start block elements on a fresh output line.
        if tag in self.BLOCK_TAGS:
            self._result.append(self.BLOCK_END)

        # Prefix each list item with a plain-text bullet.
        if tag == "li":
            self._result.append("- ")

        # Preserve explicit HTML line breaks.
        elif tag == "br":
            self._result.append("\n")

        # Render horizontal rules on their own line.
        elif tag == "hr":
            # Remove spacing that would precede the rule.
            if self._result and isinstance(self._result[-1], str):
                self._result[-1] = self._result[-1].rstrip(" ")

            self._result.append("\n---\n")

        # Mark the start of quoted plain text.
        elif tag == "blockquote":
            self._result.append(" >")

    def handle_endtag(self, tag):
        """Edge case handling of open/close tags."""

        # Resume storage after leaving an ignored container.
        self._do_store = True

        # Track the exit from preformatted text.
        if tag == "pre" and self._pre_depth:
            self._pre_depth -= 1

        # Close block elements with a line boundary.
        if tag in self.BLOCK_TAGS:
            self._result.append(self.BLOCK_END)


class HTMLMarkdownConverter(HTMLConverter):
    """Convert HTML to notification-friendly CommonMark.

    Plugins may adapt this output to their own Markdown dialects.
    """

    # Define tags that create Markdown block boundaries.
    BLOCK_TAGS = frozenset(
        {
            "p",
            "h1",
            "h2",
            "h3",
            "h4",
            "h5",
            "h6",
            "div",
            "td",
            "th",
            "pre",
            "samp",
            "label",
            "li",
        }
    )

    # Paragraph-like blocks need blank-line separation in CommonMark.
    _PARA_LIKE_TAGS = frozenset({"p", "div", "td", "th", "label"})

    # Reuse a plain paragraph boundary outside blockquotes.
    PARA_BREAK = _ParaBreak()

    # Escape characters that could become inline Markdown.
    MARKDOWN_ESCAPE = re.compile(
        r"([\\`*_~#\[\]()!<>])", re.DOTALL | re.MULTILINE
    )

    # Block syntax only needs escaping at the start of a line.
    _ORDERED_MARKER_RE = re.compile(r"^\d+\.(?=[ \t]|$)")

    # Match bullet markers at the start of a line.
    _BULLET_MARKER_RE = re.compile(r"^[-+](?=[ \t]|$)")

    # Match thematic breaks and setext heading underlines.
    # Uses separate alternates (no backreference) to avoid ReDoS on
    # inputs like "- - - - - - - - ... -x".
    _THEMATIC_BREAK_RE = re.compile(r"^(?:-[ \t]*){3,}$|^(?:=[ \t]*){3,}$")

    # Collapse ASCII whitespace when flattening table cells.
    _CELL_WHITESPACE_RUN = re.compile(r"[ \t\r\f\v]+")

    # Escape angle-bracket link destinations.
    HREF_ESCAPE = re.compile(r"([\\<>])")

    # Remove ASCII controls, Unicode format/zero-width characters, and
    # BiDi override/embedding controls that browsers silently discard during
    # URL parsing.  These characters can be prepended to a dangerous scheme
    # (e.g. U+200B before "javascript:") or used to visually reverse a URL
    # (e.g. U+202E before "javascript:") so they appear safe on screen while
    # containing an exploitable scheme once the override is decoded.
    _HREF_CONTROL_CHARS = re.compile(
        "[\x00-\x1f\x7f\u00ad\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]"
    )

    # Detect URI schemes before sanitizing links.
    _HREF_SCHEME = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):")

    # Block executable and local URI schemes while allowing app links.
    # "blob" is included because blob: URLs can carry arbitrary payloads.
    _UNSAFE_HREF_SCHEMES = frozenset(
        {"javascript", "vbscript", "data", "file", "blob"}
    )

    # Trim ASCII whitespace without removing deliberate non-breaking spaces.
    _ASCII_WHITESPACE = " \t\n\r\x0b\x0c"

    def __init__(self, **kwargs):
        """Initialize the HTML-to-Markdown converter."""

        # Initialize the base converter
        super().__init__(**kwargs)

        # Cache indentation by list depth.
        self._indent_cache = [""]

        # Cache prefixes by blockquote depth.
        self._quote_prefix_cache = [""]

        # Track open tags for constant-time unmatched-close checks.
        self._tag_open_counts = {}

        # Track whether the current frame emitted real content.
        self._content_seq = 0

        # Context stack.  The sentinel root frame at index 0 is never
        # popped and represents top-level content outside every tag.
        # Each frame is a dict with these keys:
        #   tag         -- str|None  tag that opened this frame
        #   do_store    -- bool      store text nodes encountered here?
        #   preserve_cr -- bool      keep whitespace literally (pre/code)?
        #   list_type   -- str|None  'ul', 'ol', or None
        #   depth       -- int       list nesting depth (1 = outermost),
        #                            clamped to LIST_DEPTH_MAX
        #   counter     -- int|None  next item number for 'ol' lists
        #   list_do_store -- bool    do_store of the list's own parent
        #                            (the nearest non-ul/ol/li ancestor),
        #                            saved here when the ul/ol is pushed
        #                            so 
  • can read it directly instead # of walking back up the stack # quote_depth -- int blockquote nesting depth (1 = outermost), # clamped to BLOCKQUOTE_DEPTH_MAX; 0 # means "not inside a blockquote" # list_indent -- str spaces a continuation line needs to # stay part of the current
  • -- # "" outside any list. Combined with # quote_depth's "> " (in whichever # order they were actually nested) # by _write_boundary() for any line # that needs to restate it. # list_indent_base -- str list_indent from *outside* the # entire list-nesting chain (e.g. # from an enclosing
    ) -- # captured once where that chain # starts (depth 0 -> 1) and inherited # unchanged by every level nested # inside it after that.
  • adds # only its own marker's width on top # of *this*, not the immediately # enclosing list's full list_indent, # since that would double-count: a # nested marker's own leading spaces # (from _indent(depth)) already cover # every outer list level's width. # href -- str|None href of an enclosing tag; set # only on frames pushed by # buffer -- list[str] raw text accumulated inside a # code/pre/samp frame; only present # on frames pushed by those tags -- # the matching close tag needs the # full text up front to size a # delimiter that cannot collide with # backticks already in the content # The root frame gives every child a complete set of settings. self._stack = [default_frame()] def _make_frame(self, tag, **overrides): """Create a frame from the current context.""" # Use defaults only if a caller cleared the normal root frame. parent = self._stack[-1] if self._stack else default_frame() # Build the new frame by inheriting all context keys from the parent. # Callers pass **overrides to replace only the keys they need changed. frame = { "tag": tag, "do_store": parent["do_store"], "preserve_cr": parent["preserve_cr"], "list_type": parent["list_type"], "depth": parent["depth"], "counter": parent["counter"], "list_do_store": parent["list_do_store"], "quote_depth": parent["quote_depth"], "list_indent": parent["list_indent"], "list_indent_base": parent["list_indent_base"], } # Apply caller-supplied overrides on top of the inherited defaults. frame.update(overrides) return frame def _indent(self, depth): """Return cached indentation for a list depth.""" # Depth 1 is the outermost level and needs no indentation, # so shift by one: depth 1 -> index 0 (empty string). idx = max(depth - 1, 0) # Work against the shared per-instance indentation cache. cache = self._indent_cache # Extend the cache on demand -- each level adds two spaces. while len(cache) <= idx: cache.append(cache[-1] + " ") return cache[idx] def _quote_prefix(self, depth): """Return the prefix for a quote depth.""" # Clamp negative depths to zero (should not occur in practice). idx = max(depth, 0) # Work against the shared per-instance quote-prefix cache. cache = self._quote_prefix_cache # Extend the cache on demand -- each nesting level prepends "> ". while len(cache) <= idx: cache.append(_QuoteMarker(cache[-1] + "> ")) return cache[idx] def _continuation_prefix(self, ctx): """Return the prefix that any continuation line must carry.""" # Start with whatever list indentation this context inherited. prefix = ctx["list_indent"] # If we are inside a blockquote, append its ">" leader as well. if ctx["quote_depth"]: prefix += self._quote_prefix(ctx["quote_depth"]) return prefix def _write_boundary(self, ctx, strong=False): """Append a line break or a nesting-aware paragraph break.""" # Compute the prefix that any following line must carry. prefix = self._continuation_prefix(ctx) if not strong: # Weak boundary -- a single line break in the output. self._result.append(self.BLOCK_END) # Restate any active quote or list prefix so the next # content line opens inside the correct nesting context. if prefix: self._result.append( _QuoteMarker(prefix) if ctx["quote_depth"] else _ListIndent(prefix) ) # The ordinary boundary is complete; nothing more to do. return # Strong boundary -- blank-line paragraph separation. if prefix: # Carry nesting context into the paragraph break so _finalize() # can restate it on the first line of the next paragraph. self._result.append( _ParaBreak(prefix, in_quote=bool(ctx["quote_depth"])) ) else: # No active nesting -- use the cached bare paragraph break. self._result.append(self.PARA_BREAK) def _open_line_boundary(self, ctx, strong=False): """Start a block unless a structural marker owns the line.""" if self._result and isinstance(self._result[-1], _Marker): # A structural marker is already on the line -- suppress the # boundary so the block content follows it directly. return self._write_boundary(ctx, strong=strong) def _emit(self, text): """Append content and advance the empty-span sequence counter.""" self._result.append(text) # Each emission is a unique sequence point; comparing the counter # value at open-time vs. close-time reveals whether the frame was # empty without scanning the result buffer. self._content_seq += 1 def _push_frame(self, frame): """Push a frame, returning whether the depth limit accepted it. ``True`` means the frame and tag counter were stored. ``False`` means the depth cap rejected it. Delimiter-producing callers must emit only after a successful push or their Markdown would remain unmatched. """ # Reject excessive nesting without emitting unmatched delimiters. if len(self._stack) >= MAX_FRAME_DEPTH: return False # Add the frame to the context stack. self._stack.append(frame) # Maintain a per-tag count of currently open frames so that # _pop_to() can reject unmatched closing tags in O(1). tag = frame["tag"] self._tag_open_counts[tag] = self._tag_open_counts.get(tag, 0) + 1 return True def _pop_to(self, tag): """Pop through the nearest match and report whether one existed.""" # Reject unmatched closing tags without scanning the stack if not self._tag_open_counts.get(tag): return False # Search backward for the nearest matching frame for i in range(len(self._stack) - 1, 0, -1): if self._stack[i]["tag"] == tag: # Keep tag counts synchronized with removed frames. for frame in self._stack[i:]: self._tag_open_counts[frame["tag"]] -= 1 del self._stack[i:] return True return False def _close_emphasis_frame(self, frame): """Close an emphasis frame, dropping it if it collected no content.""" # Compare the global emission counter now vs. when the frame opened; # any difference means at least one _emit() was called inside the span. has_content = self._content_seq != frame.get( "content_seq_at_open", self._content_seq ) if not has_content: # The span opened but collected nothing -- erase from the opening # delimiter onward so we do not emit an empty ** or * pair. del self._result[frame["emphasis_result_start"] :] return # CommonMark requires that the closing delimiter not be preceded by # whitespace. Relocate any trailing whitespace to after the delimiter. trailing = "" while self._result and type(self._result[-1]) is str: last = self._result[-1] rstripped = last.rstrip(self._ASCII_WHITESPACE) # This fragment has no trailing whitespace -- stop scanning. if rstripped == last: break # Accumulate trailing whitespace in front-to-back order. trailing = last[len(rstripped) :] + trailing if rstripped: # Replace the fragment with its trimmed version and stop. self._result[-1] = rstripped break # The entire fragment was whitespace -- remove it and keep going. self._result.pop() # Emit the closing delimiter (e.g. "**" or "*"). self._emit(frame["emphasis_delim"]) # Re-append any whitespace that was after the span content so it # falls outside the closed delimiter. if trailing: self._result.append(trailing) @staticmethod def _escape_cell_pipes(text): """Escape cell delimiters outside CommonMark code spans.""" # Accumulate escaped fragments without rebuilding the output string. out = [] # Initialize the single-pass scanner. i = 0 n = len(text) # Index code delimiters before inspecting table separators. backtick_runs = commonmark_index_backtick_runs(text) while i < n: ch = text[i] # Keep escape pairs intact so a backslash-escaped "|" does not # trigger an extra escape on the next pass. if ch == "\\" and i + 1 < n: out.append(text[i : i + 2]) i += 2 continue if ch == "`": j = i # Measure the backtick run length. while j < n and text[j] == "`": j += 1 run = j - i # Find the matching closing run of the same length. close = commonmark_find_backtick_run(backtick_runs, j, run) if close is not None: # Inside a code span, "|" is a literal character -- # copy the entire span verbatim without escaping. out.append(text[i : close + run]) i = close + run continue # No matching close -- not a code span; preserve the # raw backticks and let the outer Markdown renderer see them. out.append(text[i:j]) i = j continue if ch == "|": # Outside code spans, "|" is a Markdown table delimiter -- # escape it so it does not break the table structure. out.append("\\|") i += 1 continue # All other characters are safe to pass through unchanged. out.append(ch) i += 1 # Publish the escaped cell content. return "".join(out) def _close_cell_frame(self, frame): """Capture a completed table cell and store its text.""" # Splice out all result tokens written since this cell was opened. start = frame["cell_result_start"] chunk = self._result[start:] del self._result[start:] if not frame["do_store"]: # This cell belongs to a suppressed or nested table -- discard it # so we do not emit empty cells or corrupt the outer table. return # Finalize the buffered tokens into a plain string and strip edges. text = "".join(self._finalize(chunk)).strip(self._ASCII_WHITESPACE) # CommonMark table cells must live on a single source line. # Collapse embedded newlines to spaces and then normalize runs. if "\n" in text: text = " ".join(text.split("\n")) text = self._CELL_WHITESPACE_RUN.sub(" ", text).strip( self._ASCII_WHITESPACE ) # Escape cell delimiters except where code spans make them literal. if "|" in text: text = self._escape_cell_pipes(text) # Add the completed value to its enclosing row. self._stack[-1]["cells"].append(text) def _close_pre_frame(self, ctx, tag): """Render a completed code/pre/samp frame's buffered content.""" # Join the buffered literal content content = "".join(ctx.get("buffer", ())) # Size the delimiter beyond any embedded backtick run run = self._longest_backtick_run(content) if tag == "code": # Inline code uses a delimiter wider than its content. # CommonMark requires padding around edge backticks. delim = "`" * (run + 1) pad = ( " " if not content or content[0] == "`" or content[-1] == "`" else "" ) self._emit(delim + pad + content + pad + delim) return # Fenced block -- widen the fence past the longest run of # backticks already present in the content fence = "`" * max(3, run + 1) # Restate list and quote prefixes on every fenced-block line. prefix = self._continuation_prefix(ctx) # Prefix every fenced line inside a list or quote if prefix: marker = ( _QuoteMarker(prefix) if ctx["quote_depth"] else _ListIndent(prefix) ) content = ("\n" + prefix).join(content.split("\n")) # Emit the opening fence and terminate its line. self._emit(fence) self._result.append(self.BLOCK_END) # Restate nesting before the code content if prefix: self._result.append(marker) # Emit the literal code body and terminate its final line. self._emit(content) self._result.append(self.BLOCK_END) # Restate nesting before the closing fence if prefix: self._result.append(marker) # Emit the closing fence and restore a block boundary. self._emit(fence) self._result.append(self.BLOCK_END) def _close_row_frame(self, frame): """Render a completed table row as a Markdown pipe-table line.""" # Retrieve the cells collected while the row was open. cells = frame["cells"] if not cells: # An entirely empty row (no cells at all) is not useful -- drop it. return # Retrieve the enclosing table frame, if one exists. table = frame.get("table_frame") # The first row of a table is treated as the header row; CommonMark # requires a separator line of dashes ("---") immediately after it. is_header = table is None or table["table_rows"] == 0 if table is not None: # Count this row so that subsequent rows know they are not headers. table["table_rows"] += 1 # Use the restored parent context for row boundaries and prefixes. ctx = self._stack[-1] # Ensure the row starts on its own line within any active nesting. self._open_line_boundary(ctx, strong=False) # Emit the pipe-delimited cell values. self._emit("| " + " | ".join(cells) + " |") if is_header: # Emit the mandatory separator line beneath the header row. # Restate list or quote prefixes so it stays inside any nesting. self._write_boundary(ctx, strong=False) self._emit("| " + " | ".join(["---"] * len(cells)) + " |") if table is None: # A standalone row has no enclosing table to supply the trailing # paragraph boundary, so we must emit it here. self._write_boundary(ctx, strong=True) def _close_table_frame(self, table_frame=None): """Emit the paragraph boundary that follows a completed table.""" parent = self._stack[-1] # Empty visible tables already received their only needed boundary. # Suppressed nested tables still need spacing inside their cell. if ( table_frame is not None and not table_frame.get("table_rows") and table_frame.get("table_do_store") ): return if parent["do_store"]: # Separate the table from any following content with a blank line. self._write_boundary(parent, strong=True) def _close_stale_cell(self): """Close a stale cell hidden beneath malformed inline frames.""" # Walk backward until a cell or its structural boundary is found. for i in range(len(self._stack) - 1, 0, -1): frame = self._stack[i] tag = frame["tag"] # A row or table boundary means no stale cell exists here. if tag in ("tr", "table"): return if tag in ("td", "th"): # Silently drop any interleaved inline frames above the cell. while len(self._stack) > i + 1: # Remove the nearest orphaned inline frame. orphan = self._stack[-1] # Keep its open-tag counter synchronized. self._tag_open_counts[orphan["tag"]] = max( 0, self._tag_open_counts.get(orphan["tag"], 0) - 1, ) self._stack.pop() # Finalize the cell before its sibling begins. self._pop_to(tag) self._close_cell_frame(frame) return def _close_stale_row(self): """Finalize an unterminated row before its table advances.""" # A stale row always has a stale cell inside it -- close that first. self._close_stale_cell() # Inspect the frame exposed after closing any stale cell. top = self._stack[-1] # If the top of the stack is now a row frame, close it too. if top["tag"] == "tr": self._pop_to("tr") self._close_row_frame(top) def close(self): """Recover any frames left open at end-of-document, then finalize.""" # Flush trailing text before closing frames so it stays inside them. HTMLParser.close(self) # HTML does not require all tags to be closed, so we walk the stack # top-down and synthesize the missing close events for each open frame. # Only frames that emit Markdown structure need explicit finalization; # list and blockquote frames just restore context silently via _pop_to. while len(self._stack) > 1: top = self._stack[-1] tag = top["tag"] # Remove the frame from the stack (and update open-tag counts). self._pop_to(tag) if top.get("emphasis_delim"): # Emphasis frame -- emit or drop the closing delimiter. self._close_emphasis_frame(top) elif tag in ("td", "th"): # Cell frame -- finalize and store its content. self._close_cell_frame(top) elif tag == "tr": # Row frame -- emit the pipe-table line. self._close_row_frame(top) elif tag == "table": # Table frame -- emit the trailing paragraph boundary. self._close_table_frame(top) elif tag in ("code", "pre", "samp") and top["do_store"]: # Preformatted frame -- emit the fenced code block. self._close_pre_frame(top, tag) # Hand off to the base converter to assemble the final output string. super().close() @staticmethod def _longest_backtick_run(text): """Return the longest consecutive backtick run.""" # Track both the active run and the maximum observed width. longest = current = 0 # Scan the content one character at a time for ch in text: if ch == "`": current += 1 longest = max(longest, current) # Reset the active run on non-backtick content else: current = 0 # Use the maximum to size a collision-free delimiter. return longest def _finalize(self, result): """Combine tokens into normalized Markdown lines.""" # Track whether a line is waiting after a boundary. accum = None # Whether accum, so far, is composed only of marker fragments accum_is_marker = False # True when a marker absorbed a boundary without content. marker_boundary_passed = False # True when a complete line is waiting to be flushed. terminated = False # Set when a plain (no prefix at all) _ParaBreak has been seen since # accum was last extended with real content para_break_seen = False # Active list or quote prefix for the next paragraph line. para_break_prefix = None # Whether the active paragraph prefix belongs to a quote. para_break_in_quote = False # Set when a boundary arrives *after* the line was already terminated # -- i.e. anything beyond the single boundary that did the terminating. boundary_after_terminate = False for item in result: # Boundaries update state but do not immediately emit text. # Collect boundary state before flushing the current line if item == self.BLOCK_END or isinstance(item, _ParaBreak): if isinstance(item, _ParaBreak): # The latest break carries the current nesting context. if item.prefix: para_break_prefix = item.prefix para_break_in_quote = item.in_quote else: para_break_seen = True para_break_prefix = None para_break_in_quote = False # Absorb boundaries until real content arrives. if accum is None: continue # Keep an unused structural marker pending if accum_is_marker: marker_boundary_passed = True continue # Remember boundaries beyond the one ending this line if terminated: boundary_after_terminate = True terminated = True continue # Flush the previous line before adding new content. if terminated: if para_break_prefix is not None: # Keep blank lines inside an active quote if para_break_in_quote: blank = para_break_prefix.rstrip( self._ASCII_WHITESPACE ) sep = "\n" + blank + "\n" # Lists use an ordinary blank paragraph line else: sep = "\n\n" elif para_break_seen: sep = "\n\n" # Fall back to a single line break else: sep = "\n" # Emit the completed line with its chosen separator. yield accum.rstrip(self._ASCII_WHITESPACE) + sep # Seed the next line with any active list or quote prefix. if para_break_prefix is not None: accum = ( _QuoteMarker(para_break_prefix) if para_break_in_quote else _ListIndent(para_break_prefix) ) accum_is_marker = True marker_boundary_passed = boundary_after_terminate else: accum = None accum_is_marker = False marker_boundary_passed = False # Reset state gathered for the completed line terminated = False para_break_seen = False para_break_prefix = None para_break_in_quote = False boundary_after_terminate = False if accum is not None: # Determine whether this token is generated structure. item_is_marker = isinstance(item, _Marker) if ( marker_boundary_passed and accum_is_marker and item_is_marker ): # A new marker after the old one sat through a boundary # unused -- start over with this one accum = item accum_is_marker = True else: # Append ordinary content to the pending line. accum += item accum_is_marker = accum_is_marker and item_is_marker marker_boundary_passed = False else: # Seed a new line with this token accum = item accum_is_marker = isinstance(item, _Marker) para_break_seen = False para_break_prefix = None para_break_in_quote = False # Flush the final content line without adding another newline. if accum is not None and not accum_is_marker: yield accum.rstrip(self._ASCII_WHITESPACE) def _escape_line_start(self, content): """Escape text that could become block syntax.""" # Look for an ordered-list marker ordered = self._ORDERED_MARKER_RE.match(content) if ordered: # Escape just the '.', not the digits themselves dot = ordered.end() - 1 return content[:dot] + "\\" + content[dot:] # Escape an unordered-list marker if self._BULLET_MARKER_RE.match(content): return "\\" + content # Ignore surrounding whitespace while checking the full line trimmed = content.strip(self._ASCII_WHITESPACE) # Escape thematic breaks and setext underlines if trimmed and self._THEMATIC_BREAK_RE.match(trimmed): idx = content.index(trimmed[0]) return content[:idx] + "\\" + content[idx:] # Leave ordinary line starts unchanged. return content def handle_data(self, data, *args, **kwargs): """Store escaped text for the current context.""" # Read all text-handling flags from the active frame. ctx = self._stack[-1] # Ignore text inside suppressed containers. if not ctx["do_store"]: return # Preserve literal whitespace inside code blocks if ctx["preserve_cr"]: # Buffer raw text so the closing delimiter can be sized safely. ctx["buffer"].append(data) return # Preserve deliberate non-breaking spaces even at block boundaries. if not data.strip() and "\xa0" in data: self._emit("\xa0") return # Collapse ordinary whitespace outside preformatted content. content = self.WS_TRIM.sub(" ", data) # Drop indentation-only text around block boundaries. if not content.strip() and ( not self._result or self._result[-1] == self.BLOCK_END or isinstance(self._result[-1], (_Marker, _ParaBreak)) ): return # Detect text that begins a fresh Markdown source line. is_line_start = ( not self._result or self._result[-1] == self.BLOCK_END or isinstance(self._result[-1], (_Marker, _ParaBreak)) or ( type(self._result[-1]) is str and self._result[-1].endswith("\n") ) ) # Escape Markdown punctuation in ordinary text. content = self.MARKDOWN_ESCAPE.sub(r"\\\1", content) if is_line_start: # Prevent plain text from becoming a Markdown block content = self._escape_line_start(content) # Keep leading whitespace outside an emphasis delimiter. if ctx.get("emphasis_delim") and not ctx["emphasis_started"]: stripped = content.lstrip(self._ASCII_WHITESPACE) # Move leading whitespace before the opening delimiter if stripped != content: leading = content[: len(content) - len(stripped)] delim = self._result.pop() self._result.append(leading) self._result.append(delim) content = stripped if not content: # The close handler removes an empty span. return # Mark the emphasis span as started ctx["emphasis_started"] = True # Append the final escaped fragment to the output stream. self._emit(content) def handle_starttag(self, tag, attrs): """Handle an opening HTML tag.""" # Capture the parent context before opening another frame. ctx = self._stack[-1] # Treat nested tags inside preformatted content as literal text. if ctx["preserve_cr"]: return # Track list type, depth, and numbering. if tag in ("ul", "ol"): # CommonMark ordered lists default to one. counter = 1 if tag == "ol": # Read an optional starting number from ordered lists. start_attr = next((v for k, v in attrs if k == "start"), None) if start_attr is not None: with contextlib.suppress(TypeError, ValueError): # Markdown list markers cannot represent negative # values. counter = max(0, int(start_attr)) # Prepare the list-specific state inherited by its items. overrides = { "do_store": False, "list_type": tag, "depth": min(ctx["depth"] + 1, LIST_DEPTH_MAX), "counter": (counter if tag == "ol" else None), } # Only refresh list_do_store when this is the outermost list (its # parent isn't itself a ul/ol). if ctx["tag"] not in ("ul", "ol"): overrides["list_do_store"] = ctx["do_store"] # Capture indentation once at the start of a nested list chain. if ctx["depth"] == 0: overrides["list_indent_base"] = ctx["list_indent"] # A direct child list continues its parent item without a break. if ctx["do_store"] and ctx["tag"] != "li": self._open_line_boundary(ctx, strong=True) # Open the list frame and suppress whitespace-only text nodes. self._push_frame(self._make_frame(tag, **overrides)) return # Emit a list marker and enable item content. if tag == "li": # An item value resets numbering for it and following siblings. if ctx["list_type"] == "ol" and ctx["counter"] is not None: # Read an optional per-item numbering override. value_attr = next((v for k, v in attrs if k == "value"), None) if value_attr is not None: with contextlib.suppress(TypeError, ValueError): # Match the non-negative
      constraint. ctx["counter"] = max(0, int(value_attr)) # Indent scales with nesting depth (depth 1 = no indent), capped at # LIST_DEPTH_MAX indent = self._indent(ctx["depth"]) if ctx["list_type"] == "ol" and ctx["counter"] is not None: # Render the current ordered-list number. marker_text = "{}{}. ".format(indent, ctx["counter"]) else: # Render a bullet for unordered or malformed list items. marker_text = "{}- ".format(indent) # Tag the marker as converter-generated structure marker = _ListMarker(marker_text) # Re-enable storage, unless something outside the list itself (e.g. # ) is suppressing content do_store = ctx["list_do_store"] # Snapshot content state for constant-time empty-item detection. # Gate marker emission on the return value: at MAX_FRAME_DEPTH # the frame is discarded and we must not append the marker, # otherwise there is nothing to pop it on . if not self._push_frame( self._make_frame( tag, do_store=do_store, content_seq_at_open=self._content_seq, list_indent=( ctx["list_indent_base"] + " " * len(marker_text) ), ) ): return if do_store: # Reuse a pending quote or list marker on this line. if not ( self._result and isinstance(self._result[-1], _Marker) ): self._result.append(self.BLOCK_END) # Restate an enclosing quote prefix if ctx["quote_depth"]: self._result.append( self._quote_prefix(ctx["quote_depth"]) ) self._result.append(marker) return # Extend a pending quote/list marker, or begin a fresh quoted line. if tag == "blockquote": # Clamp quote depth to keep generated prefixes bounded depth = min(ctx["quote_depth"] + 1, BLOCKQUOTE_DEPTH_MAX) # Open a frame carrying the increased quote depth. # Gate marker emission on the return value: at MAX_FRAME_DEPTH # the frame is discarded and we must not append a _QuoteMarker, # otherwise there is nothing to pop it on
  • . new_frame = self._make_frame(tag, quote_depth=depth) if not self._push_frame(new_frame): return if ctx["do_store"]: last = self._result[-1] if self._result else None # Extend an existing quote prefix if isinstance(last, _QuoteMarker) or ( isinstance(last, _ParaBreak) and last.in_quote ): if depth > ctx["quote_depth"]: self._result.append(_QuoteMarker("> ")) elif isinstance(last, _Marker): self._result.append(self._quote_prefix(depth)) # Separate a quote from preceding text. elif isinstance(last, str): self._write_boundary(ctx, strong=True) if depth > ctx["quote_depth"]: self._result.append(_QuoteMarker("> ")) # Emit the prefix directly at the start of a context. else: self._result.append( _QuoteMarker( ctx["list_indent"] + self._quote_prefix(depth) ) ) return # Table section tags are transparent. if tag == "table": # Markdown cannot represent a table nested inside a cell. nested_in_cell = bool( self._tag_open_counts.get("td") or self._tag_open_counts.get("th") ) # Separate a representable table from preceding content. if ctx["do_store"] and not nested_in_cell: self._open_line_boundary(ctx, strong=True) self._push_frame( self._make_frame( tag, do_store=False, # Nested table cells remain suppressed. table_do_store=( False if nested_in_cell else ctx["do_store"] ), table_rows=0, ) ) return if tag == "tr": # Finalize a malformed, unterminated previous row first. self._close_stale_row() # Capture the table or container that owns the new row. row_ctx = self._stack[-1] # Remember whether this row belongs to a real table frame. is_table = row_ctx["tag"] == "table" self._push_frame( self._make_frame( tag, do_store=False, cells=[], # A stray row becomes a standalone one-row table. table_frame=(row_ctx if is_table else None), table_do_store=( row_ctx["table_do_store"] if is_table else row_ctx["do_store"] ), ) ) return if tag in ("td", "th"): # Finalize an unterminated previous cell first. self._close_stale_cell() # Capture the row expected to own the new cell. cell_ctx = self._stack[-1] if cell_ctx["tag"] == "tr": # Record where this cell's buffered output begins. self._push_frame( self._make_frame( tag, do_store=cell_ctx["table_do_store"], cell_result_start=len(self._result), ) ) return # No enclosing -- this isn't really a table cell (e.g. a stray # with no surrounding table structure at all). # Buffer preformatted content until its closing tag. if tag in ("code", "pre", "samp"): # Preserve raw whitespace until the matching closing tag. self._push_frame( self._make_frame(tag, preserve_cr=True, buffer=[]) ) # Resume text capture inside the document body. elif tag == "body": # The document body always resumes visible text capture. self._push_frame(self._make_frame(tag, do_store=True)) # Suppress content inside ignored containers. elif tag in self.IGNORE_TAGS: # Ignored containers inherit context but disable text storage. self._push_frame(self._make_frame(tag, do_store=False)) # Do not emit Markdown markers for suppressed content. if not ctx["do_store"]: return # Start block-level content on the correct boundary. if tag in self.BLOCK_TAGS: self._open_line_boundary(ctx, strong=tag in self._PARA_LIKE_TAGS) # Emit Markdown syntax for the current tag. if tag == "br": # CommonMark needs two trailing spaces for a visible hard break. self._emit(" \n" + self._continuation_prefix(ctx)) elif tag == "hr": # A void
    supplies its own boundaries; the leading blank line # prevents it from becoming a setext heading. self._open_line_boundary(ctx, strong=True) self._emit("---") self._result.append(self.BLOCK_END) elif tag == "h1": # Render a level-one ATX heading marker. self._emit("# ") elif tag == "h2": # Render a level-two ATX heading marker. self._emit("## ") elif tag == "h3": # Render a level-three ATX heading marker. self._emit("### ") elif tag == "h4": # Render a level-four ATX heading marker. self._emit("#### ") elif tag == "h5": # Render a level-five ATX heading marker. self._emit("##### ") elif tag == "h6": # Render a level-six ATX heading marker. self._emit("###### ") # Open an emphasis frame for bold or italic content elif tag in ("strong", "b", "em", "i"): # A frame validates closing tags and relocates edge whitespace. delim = "**" if tag in ("strong", "b") else "*" # Emit a delimiter only when its frame passed the depth limit. if self._push_frame( self._make_frame( tag, emphasis_delim=delim, emphasis_started=False, content_seq_at_open=self._content_seq, # Exact delimiter index used when removing an empty span. emphasis_result_start=len(self._result), ) ): # Buffer the opening delimiter for the matching close tag. self._result.append(delim) # Open a Markdown link when an href is available elif tag == "a": # Keep the target on a frame so nested label markup is included. href = next( (v for k, v in attrs if k == "href"), None, ) # Sanitize the destination before emitting Markdown if href is not None: # Remove controls that browsers ignore during URL parsing. href = self._HREF_CONTROL_CHARS.sub("", href) # Strip all leading/trailing whitespace, including Unicode # space characters that would otherwise bypass scheme checks. href = href.strip() # Extract an explicit URI scheme when one is present. scheme = self._HREF_SCHEME.match(href) # Neutralize schemes that can execute or expose local content if ( scheme and scheme.group(1).lower() in self._UNSAFE_HREF_SCHEMES ): # Preserve link shape while neutralizing unsafe targets. href = "#" if href is not None: # Angle destinations safely contain parentheses. safe_href = self.HREF_ESCAPE.sub(r"\\\1", href) target = "<{}>".format(safe_href) # Store the target only when its frame passes the depth limit. if self._push_frame(self._make_frame(tag, href=target)): # Frame accepted: begin the CommonMark link label. self._emit("[") def handle_endtag(self, tag): """Handle a closing HTML tag.""" # Capture context before any pop so do_store and any buffered code/pre # content can still be read for this closing tag ctx = self._stack[-1] if tag == "a": # Find the href stored in the nearest
    frame href = None # Search backward for the matching anchor for frame in reversed(self._stack): if frame["tag"] == "a": href = frame.get("href") break self._pop_to(tag) # Bare or suppressed anchors do not create link frames. if href is not None: # Complete the Markdown link self._emit("](" + href + ")") return # Track list type, depth, and numbering. if tag in ("ul", "ol"): # Ignore a list closer that has no corresponding open frame. if not self._pop_to(tag): return # Continue rendering in the restored parent context. parent = self._stack[-1] if parent["do_store"]: self._write_boundary(parent, strong=parent["tag"] != "li") return # Emit a list marker and enable item content. if tag == "li": # A stray closing tag with no open
  • anywhere on the stack is # a no-op -- nothing below should run against an unrelated frame. if not self._tag_open_counts.get(tag): return # Locate the actual li frame -- the top frame may be an unclosed # child (e.g. inside
  • textbold
  • ). li_frame = next( (f for f in reversed(self._stack) if f["tag"] == "li"), ctx, ) # Empty items are dropped and do not advance ordered-list # numbering. has_content = li_frame[ "do_store" ] and self._content_seq != li_frame.get( "content_seq_at_open", self._content_seq ) # Block boundary closes the item content -- only if the item # actually emitted a marker when it opened. if li_frame["do_store"]: self._result.append(self.BLOCK_END) # Pop the li frame (and any unclosed children above it). self._pop_to(tag) # The enclosing list is exposed after the item frame is removed. parent = self._stack[-1] # Increment numbering only for items that emitted content if parent["list_type"] == "ol" and has_content: parent["counter"] += 1 return # Track blockquote depth and emit its prefix. if tag == "blockquote": # Restore the quote's parent context -- a stray closing tag with # no open
    anywhere on the stack is a no-op. if not self._pop_to(tag): return # Continue rendering in the restored outer context. parent = self._stack[-1] if parent["do_store"]: self._write_boundary(parent, strong=True) return # Close only matching table frames; ignore stray closing tags. if tag in ("td", "th"): # Ignore stray cell closers instead of emitting a block boundary. if ctx["tag"] == tag: # Remove the frame before storing its value on the row. self._pop_to(tag) self._close_cell_frame(ctx) return elif tag == "tr": # Recover any cell left open by malformed HTML self._close_stale_cell() row_ctx = self._stack[-1] if row_ctx["tag"] == "tr": # Remove the row before rendering it in the parent context. self._pop_to(tag) self._close_row_frame(row_ctx) return elif tag == "table": # Recover any row left open by malformed HTML self._close_stale_row() table_ctx = self._stack[-1] if table_ctx["tag"] == "table": # Remove the table before emitting its trailing boundary. self._pop_to(tag) self._close_table_frame(table_ctx) return # Buffer preformatted content until its closing tag. if tag in ("code", "pre", "samp"): # Render only the matching preformatted frame if ctx["do_store"] and ctx["tag"] == tag: self._close_pre_frame(ctx, tag) # Restore the context that preceded the preformatted element. self._pop_to(tag) return # Do not emit Markdown markers for suppressed content. if not ctx["do_store"] or ctx["preserve_cr"]: if tag == "body" or tag in self.IGNORE_TAGS: self._pop_to(tag) return # Paragraph-like tags strongly separate following bare text. if tag in self.BLOCK_TAGS: self._write_boundary(ctx, strong=tag in self._PARA_LIKE_TAGS) if tag in ("strong", "b", "em", "i"): # Only a matching emphasis frame emits a closing delimiter. if ctx["tag"] == tag and ctx.get("emphasis_delim"): self._pop_to(tag) self._close_emphasis_frame(ctx) return # Pop frames created by container tags. if tag == "body" or tag in self.IGNORE_TAGS: # Restore storage flags inherited from the enclosing frame. self._pop_to(tag) class HTMLTagReducer(HTMLParser): """Rewrite HTML so it only uses a small set of tags. This holds the parts every reducer shares. A subclass sets the class attributes below and handles its own extra tags in ``_start()``, ``_end()`` and ``_text()``, passing anything else back up here. - Tags in ``INLINE_MAP`` are written as the tag they map to. - Headings become bold text on their own line. - List items become "- " (or "1. ") lines and table rows become lines with cells split by " | ". - Images are replaced by their alt text. - Anything inside a ``SKIP_TAGS`` tag is hidden. - Any other tag is dropped and only its text is kept. - Every opened tag is closed, so the result is always balanced. """ # Source tag -> the tag written in its place INLINE_MAP: dict[str, str] = {} # Tags written as bold text on a line of their own HEADINGS: tuple[str, ...] = ("h1", "h2", "h3", "h4", "h5", "h6") # Tags whose content is never shown SKIP_TAGS: tuple[str, ...] = ("script", "style") # What a line break looks like in the output LINE_BREAK = "\n" # Characters swapped in text before it is escaped (str.translate) TEXT_MAP: dict[int, str] = {} def __init__(self) -> None: super().__init__(convert_charrefs=True) def reset(self) -> None: """Clear the parser and every piece of reducer state.""" # HTMLParser calls this from __init__, and reduce() calls it again # so an instance can be reused. super().reset() # The rewritten message, one piece at a time self.out: list[str] = [] # Open output tags as (source tag, written tag) pairs self.open_tags: list[tuple[str, str]] = [] # Open counts allow quick membership checks. self.open_count: Counter[str] = Counter() # One entry per open list; a number for numbered lists, else None self.lists: list[Optional[int]] = [] # Depth of tags whose content is hidden self.skip = 0 # Number of cells written on the current table row self.cells = 0 def _line(self) -> None: """Start a new line unless we are already on an empty one.""" if self.out and self.out[-1] != self.LINE_BREAK: self.out.append(self.LINE_BREAK) def _open(self, source: str, tag: str, attrs: str = "") -> None: """Write a supported tag and remember it so it gets closed.""" self.out.append(f"<{tag}{attrs}>") self.open_tags.append((source, tag)) self.open_count[source] += 1 def _close(self, source: str) -> None: """Close ``source`` and anything opened after it.""" # Ignore a closing tag that was never opened if not self.open_count[source]: return while True: opened, tag = self.open_tags.pop() self.open_count[opened] -= 1 self.out.append(f"") if opened == source: break def handle_starttag( self, tag: str, attrs: list[tuple[str, Optional[str]]] ) -> None: """Hide skipped tags; hand every other tag to ``_start()``.""" if tag in self.SKIP_TAGS: # Hide everything until this tag closes self.skip += 1 return self._start(tag, dict(attrs)) def _start(self, tag: str, values: dict[str, Optional[str]]) -> None: """Write the markup for an opening tag. - ``values`` holds the tag's attributes by name. """ if tag in self.INLINE_MAP: self._open(tag, self.INLINE_MAP[tag]) elif tag in self.HEADINGS: # Headings are bold text on their own line self._line() self._open(tag, "b") elif tag in ("ul", "ol"): # Lists always begin on a new line self._line() self.lists.append(1 if tag == "ol" else None) elif tag == "li": # Each list item is its own line with a bullet or number self._line() number = self.lists[-1] if self.lists else None # Indent by nesting depth, capped so very deep nesting can # not make the output grow out of control depth = min(len(self.lists), LIST_DEPTH_MAX) indent = " " * max(0, depth - 1) if number is None: self.out.append(f"{indent}- ") else: self.out.append(f"{indent}{number}. ") self.lists[-1] = number + 1 elif tag == "tr": # Each table row is its own line self._line() self.cells = 0 elif tag in ("td", "th"): # Separate the cells of a row if self.cells: self.out.append(" | ") self.cells += 1 elif tag == "img" and values.get("alt"): # Images cannot be shown; keep their description self.out.append(escape(values["alt"], quote=False)) def handle_startendtag( self, tag: str, attrs: list[tuple[str, Optional[str]]] ) -> None: """Treat ``
    `` style tags like their opening tag.""" if tag in ("br", "hr", "img"): self.handle_starttag(tag, attrs) def handle_endtag(self, tag: str) -> None: """Show content again after a skipped tag; else call ``_end()``.""" if tag in self.SKIP_TAGS: # Show content again once the hidden tag closes self.skip = max(0, self.skip - 1) return self._end(tag) def _end(self, tag: str) -> None: """Close a tag and end its line when it needs one.""" # Close any supported tag this one opened self._close(tag) if tag in ("ul", "ol") and self.lists: # Leave this list self.lists.pop() if tag in self.HEADINGS or tag in ("ul", "ol", "li", "tr"): # These end their line self._line() def handle_data(self, data: str) -> None: """Write text unless it sits inside a skipped tag.""" if not self.skip: self._text(data) def _text(self, data: str) -> None: """Write text, keeping the line breaks written inside it.""" if not data.strip(): # Whitespace between tags only lays out the HTML source self._write(HTML_NEWLINE_RE.sub(" ", data)) return pos = 0 for match in HTML_NEWLINE_RE.finditer(data): self._write(data[pos : match.start()]) pos = match.end() # Count the line breaks, with "\r\n" counting once breaks = ( match.group().replace("\r\n", "\n").replace("\r", "\n") ).count("\n") if not self.out or self.out[-1] == self.LINE_BREAK: # A tag already ended this line, or nothing is written yet breaks -= 1 self.out.extend([self.LINE_BREAK] * max(0, breaks)) self._write(data[pos:]) def _write(self, data: str) -> None: """Write one stretch of text with its special characters escaped.""" if not self.out or self.out[-1] == self.LINE_BREAK: # Do not start a new line with a space data = data.lstrip() if data: self.out.append(escape(data.translate(self.TEXT_MAP), quote=False)) def reduce(self, html: str) -> str: """Return ``html`` rewritten with only the supported tags.""" # Start clean so nothing from an earlier call leaks in self.reset() self.feed(html) self.close() # Drop line breaks and spaces left at the very end while self.out and ( self.out[-1] == self.LINE_BREAK or not self.out[-1].strip() ): self.out.pop() # Close any tag still open so the message stays balanced while self.open_tags: self.out.append(f"") return "".join(self.out) caronc-apprise-dfec414/apprise/decorators/000077500000000000000000000000001526720371000207275ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/decorators/__init__.py000066400000000000000000000026601526720371000230440ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from .notify import notify __all__ = ["notify"] caronc-apprise-dfec414/apprise/decorators/base.py000066400000000000000000000221671526720371000222230ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE.USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import inspect from typing import Any, Optional from .. import common from ..logger import logger from ..manager_plugins import NotificationManager from ..plugins.base import NotifyBase from ..utils.logic import dict_full_update from ..utils.parse import URL_DETAILS_RE, parse_url, url_assembly # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() class CustomNotifyPlugin(NotifyBase): """Apprise Custom Plugin Hook. This gets initialized based on @notify decorator definitions """ # Our Custom notification; identify the URL users can go to learn # more about the service this wrapper supports: service_url = "https://appriseit.com/library/extending/decorator/" # Over-ride our category since this inheritance of the NotifyBase class # should be treated differently. category = "custom" # Support Attachments attachment_support = True # Allow persistent storage support storage_mode = common.PersistentStoreMode.AUTO # Define object templates templates = ("{schema}://",) @staticmethod def parse_url(url: str) -> Optional[dict[str, Any]]: """Parses the URL and returns arguments retrieved.""" results = parse_url(url, verify_host=False, simple=True) if not results: # The URL could not be parsed at all return results # Apply the same query string handling every other plugin gets, so # options such as ?verify=, ?user= and ?pass= behave the same. results = NotifyBase.post_process_parse_url_results(results) # Options such as ?format=, ?retry= and ?optional= work here too return NotifyBase.parse_url_options(results) def url(self, privacy=False, *args, **kwargs): """General URL assembly.""" return f"{self.secure_protocol}://" @staticmethod def instantiate_plugin(url, send_func, name=None, body_format=None): """The function used to add a new notification plugin based on the schema parsed from the provided URL into our supported matrix structure. ``body_format`` declares the destination format(s) this wrapper accepts. When omitted, the wrapper passes every format through. """ if not isinstance(url, str): msg = ( f"An invalid custom notify url/schema ({url}) provided in " f"function {send_func.__name__}." ) logger.warning(msg) return None # Validate that our schema is okay re_match = URL_DETAILS_RE.match(url) if not re_match: msg = ( f"An invalid custom notify url/schema ({url}) provided in " f"function {send_func.__name__}." ) logger.warning(msg) return None # Acquire our schema schema = re_match.group("schema").lower() if not re_match.group("base"): url = f"{schema}://" # Keep a default set of arguments to apply to all called references base_args = parse_url( url, default_schema=schema, verify_host=False, simple=True ) if schema in N_MGR: # we're already handling this object msg = ( f"The schema ({url}) is already defined and could not be " f"loaded from custom notify function {send_func.__name__}." ) logger.warning(msg) return None # We define our own custom wrapper class so that we can initialize # some key default configuration values allowing calls to our # `Apprise.details()` to correctly differentiate one custom plugin # that was loaded from another class CustomNotifyPluginWrapper(CustomNotifyPlugin): # Our Service Name service_name = ( name if isinstance(name, str) and name else f"Custom - {schema}" ) # Store our matched schema secure_protocol = schema # Explicit body_format= declares this wrapper's destination. # Without one, accept every format as pass-through input. notify_format = ( body_format if body_format is not None else ( common.NotifyFormat.TEXT, common.NotifyFormat.HTML, common.NotifyFormat.MARKDOWN, ) ) requirements = { # Define our required packaging in order to work "details": f"Source: {inspect.getfile(send_func)}" } # Assign our send() function __send = staticmethod(send_func) # Update our default arguments _base_args = base_args def __init__(self, **kwargs): """Our initialization.""" # init parent super().__init__(**kwargs) self._default_args = {} # Some variables do not need to be set kwargs.pop("secure", None) # Apply our updates based on what was parsed dict_full_update(self._default_args, self._base_args) dict_full_update(self._default_args, kwargs) # Update our arguments (applying them to what we originally) # initialized as self._default_args["url"] = url_assembly(**self._default_args) def send( self, body, title="", notify_type=common.NotifyType.INFO, *args, **kwargs, ): """Our send() call which triggers our hook.""" # The decorator does not support split/truncate overflow, so # every delivery is index 0 of 1. Drop them rather than pass # along values that never carry any real meaning here. kwargs.pop("index", None) kwargs.pop("total", None) response = False try: # Enforce a boolean response result = self.__send( body, title, notify_type.value, *args, meta=self._default_args, **kwargs, ) # None and True are both considered successful # False however is passed along further upstream response = True if result is None else bool(result) except Exception as e: # Unhandled Exception self.logger.warning( "An exception occured sending a %s notification.", N_MGR[self.secure_protocol].service_name, ) self.logger.debug( "%s Exception: %s", N_MGR[self.secure_protocol], e ) return False if response: self.logger.info( "Sent %s notification.", N_MGR[self.secure_protocol].service_name, ) else: self.logger.warning( "Failed to send %s notification.", N_MGR[self.secure_protocol].service_name, ) return response # Store our plugin into our core map file return N_MGR.add( plugin=CustomNotifyPluginWrapper, schemas=schema, send_func=send_func, url=url, ) caronc-apprise-dfec414/apprise/decorators/notify.py000066400000000000000000000117321526720371000226150ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from .base import CustomNotifyPlugin def notify(on, name=None, body_format=None): """Register a function as an Apprise notification plugin. ``on`` identifies the URL protocol that triggers the function. @notify(on="foobar") def your_declaration(body, title, notify_type, meta, *args, **kwargs): ... ``name`` optionally sets the plugin name exposed through the API. @notify(on="foobar", name="My Foobar Process") def your_action(body, title, notify_type, meta, *args, **kwargs): ... ``body_format`` declares accepted prepared formats. Without it, message content passes through without conversion. @notify(on="foobar", body_format=NotifyFormat.MARKDOWN) def your_action(body, title, notify_type, meta, *args, **kwargs): ... ``meta`` contains parsed URL and configuration values for the call. @notify(on="foobar", name="My Foobar Process") def your_action(body, title, notify_type, body_format, meta, attach, *args, **kwargs): ... Arguments break down as follows: body: The message body associated with the notification title: The message title associated with the notification notify_type: The message type (info, success, warning, and failure) body_format: The resolved format delivered to your function. body_passthrough: True if the caller left the source format undeclared, so automatic format conversion was skipped. meta: Combines the URL arguments specified on the `on` call with the ones loaded from a users configuration. This is a dictionary that presents itself like this: { 'schema': 'http', 'url': 'http://hostname', 'host': 'hostname', 'user': 'john', 'password': 'doe', 'port': 80, 'path': '/', 'fullpath': '/test.php', 'query': 'test.php', 'qsd': {'key': 'value', 'key2': 'value2'}, 'verify': True, 'asset': , 'tag': set(), } Entries appear only when available. A simple ``foobar://`` URL produces: { 'schema': 'foobar', 'url': 'foobar://', 'verify': True, 'asset': , 'tag': set(), } attach: An array AppriseAttachment objects (if any were provided) @notify body_format: Declares the prepared body format(s) your wrapper accepts; omitted means pass-through for every format. Functions that do not need every parameter may use: @notify(on="foobar", name="My Foobar Process") def your_action(body, title, *args, **kwargs) End the function signature with ``*args`` and ``**kwargs`` for forward compatibility. Return ``True`` for success or ``False`` for failure. A missing return value is treated as success. """ def wrapper(func): """Instantiate our custom (notification) plugin.""" # Generate CustomNotifyPlugin.instantiate_plugin( url=on, send_func=func, name=name, body_format=body_format ) return func return wrapper caronc-apprise-dfec414/apprise/dispatch.py000066400000000000000000001003731526720371000207370ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Run notification attempts and build their results. This module handles retries, priority groups, time limits, and failures. """ from __future__ import annotations from collections.abc import Iterator import logging import math import time from typing import TYPE_CHECKING, Any, Callable, Optional, Union from .asset import AppriseAsset from .common import APPRISE_MAX_SERVICE_RETRY from .exception import AppriseImproperlyConfigured from .logger import NotifyLogEntry, _ServiceLogCapture, logger from .plugins.base import ( NotifyBase, _delivery_memo, _delivery_tracker, ) from .result import ( AppriseResult, AppriseResultStatus, NotifyAttempt, NotifyResult, ) if TYPE_CHECKING: # Import Apprise only for static analysis. CallState reaches it through # the instance it is given, so importing it here at runtime would only # create a cycle back through this module. from .apprise import Apprise # One (service, notify()-kwargs) pair as produced by _create_notify_gen() # and threaded through every dispatch primitive below. ServiceCall = tuple[NotifyBase, dict[str, Any]] # What dispatching one batch produced: its (ok, results), or the exception # that stopped it. BatchOutcome = Union[tuple[bool, list[NotifyResult]], BaseException] def safe_attr(service: NotifyBase, name: str, default: Any) -> Any: """Read a plugin attribute, returning ``default`` if it raises. This also protects callers when a plugin property fails while being read. """ try: return getattr(service, name, default) except Exception: # Use the backup value instead of hiding the original plugin failure. return default def service_metadata( service: NotifyBase, ) -> tuple[str, str, Optional[str], tuple[str, ...], int]: """Return ``(name, url, url_id, tags, weight)`` with safe defaults. A plugin may fail while providing these details, so each value has a simple backup. """ # Each of these is plugin-supplied, so read it defensively. name = safe_attr(service, "service_name", "Unknown") try: # Hide private URL values before placing the URL in a result. url = service.url(privacy=True) except Exception: # A result still needs a usable URL-shaped value. url = "unknown://" try: # The short ID helps callers match this result to a service. url_id = service.url_id() except Exception: # Not every malformed or custom service can create an ID. url_id = None try: # Weight normally says how many targets the service represents. weight = len(service) except Exception: # Count an unknown service as one target. weight = 1 try: # Sort tags for stable output; custom tags may fail to become text. tag = tuple(sorted(str(t) for t in safe_attr(service, "tags", ()))) except Exception: # Bad custom tags should not stop the result from being returned. tag = () # Always return the same five fields, even when some use backups. return name, url, url_id, tag, weight def service_crashed(service: NotifyBase, e: BaseException) -> NotifyResult: """Log an unexpected dispatch error and return a failed result. Service details use safe defaults so a broken plugin cannot hide the original failure or stop the rest of the batch. """ # Gather as many safe service details as the plugin can provide. name, url, url_id, tag, weight = service_metadata(service) # Report the failure using the safe name rather than re-reading it. logger.warning("Notification service '%s' raised an exception.", name) logger.debug("Notification Exception: %s", str(e)) # Keep the usual result shape even though no normal attempt completed. return NotifyResult( name=name, url=url, url_id=url_id, tag=tag, # A raising optional property is treated as "not optional". optional=safe_attr(service, "optional", False), weight=weight, max_attempts=1, # Add one failure because no normal attempt details are available. attempts=[NotifyAttempt(status=AppriseResultStatus.FAILURE)], ) def dispatch_crashed(e: BaseException) -> None: """Log a batch failure that escaped normal service handling. With no later priority group to try, the caller receives a failed result instead of the exception. """ # Keep the normal message short and place details at debug level. logger.warning("Notification dispatch raised an exception.") logger.debug("Notification Exception: %s", str(e)) def compute_deadline( service: NotifyBase, call_deadline: Optional[float] ) -> Optional[float]: """Return the time when Apprise must stop waiting for one service. The earlier of these limits wins: - The service's ``service_timeout`` setting. - The shared deadline created by ``notify(timeout=...)`` for the call. The worker receives the same deadline, so time spent in a queue still counts. ``None`` means that neither limit is enabled. """ # Read the service limit, using the normal asset default if needed. service_timeout = getattr( service.asset, "_service_timeout", AppriseAsset._service_timeout, ) # Turn the number of seconds into one fixed end time. deadline = time.monotonic() + service_timeout if service_timeout else None if call_deadline is not None: # The first limit reached always wins. deadline = ( call_deadline if deadline is None else min(deadline, call_deadline) ) # TRACE keeps large batches from spamming normal DEBUG output. logger.trace( "Deadline for '%s': %s", safe_attr(service, "service_name", "Unknown"), "none" if deadline is None else "{:.3f}s from now".format(deadline - time.monotonic()), ) return deadline def timeout_result( service: NotifyBase, elapsed: float, max_attempts: int, ) -> NotifyResult: """Build a timeout result after Apprise stops waiting for a service. The worker may still be running. ``NotifyResult`` handles optional services. """ # Preserve normal service details in the timeout response. name, url, url_id, tag, weight = service_metadata(service) # Record the timeout as the only attempt known to this caller. return NotifyResult( name=name, url=url, url_id=url_id, tag=tag, optional=safe_attr(service, "optional", False), weight=weight, # Preserve the configured retry count even when the outer wait wins. max_attempts=max_attempts, attempts=[ NotifyAttempt( status=AppriseResultStatus.TIMEOUT, elapsed=elapsed, logs=[timeout_log_entry(name, elapsed)], ) ], ) def timeout_log_entry(name: str, elapsed: float) -> NotifyLogEntry: """Log and return a consistent error entry for a service timeout.""" # Use the same readable message in application logs and the result. message = f"Service '{name}' did not finish within {elapsed:.3f}s." # A live log_callback still sees this, but the capture does not keep a # copy: the caller stores the entry below on the attempt itself, and # keeping both would list the same timeout twice in the merged logs. logger.error(message, extra={"apprise_store": False}) # Store the level as text because NotifyLogEntry is public result data. return NotifyLogEntry(level="ERROR", message=message) def validate_timeout(value: Union[int, float]) -> float: """Validate a timeout value shared by notify()/async_notify().""" # Booleans are numbers in Python, but are not useful time limits. if not isinstance(value, (int, float)) or isinstance(value, bool): raise AppriseImproperlyConfigured("timeout must be an int or float.") # Reject negative or infinite values; zero means no call-wide limit. if not math.isfinite(value) or value < 0: raise AppriseImproperlyConfigured("timeout must be >= 0 and finite.") return float(value) def aggregate_status( ok: bool, results: list[NotifyResult] ) -> AppriseResultStatus: """Combine service results into SUCCESS, PARTIAL, FAILURE, or TIMEOUT. A real success makes a mixed batch PARTIAL; otherwise FAILURE beats TIMEOUT. """ if ok: # The dispatcher confirmed that every required service succeeded. return AppriseResultStatus.SUCCESS # Count actual deliveries, not optional services that were allowed to fail. if any( attempt.status == AppriseResultStatus.SUCCESS for result in results for attempt in result.attempts ): # At least one real delivery worked, so the batch partly succeeded. return AppriseResultStatus.PARTIAL if any(r.status == AppriseResultStatus.FAILURE for r in results): # A normal failure is more useful to report than a timeout. return AppriseResultStatus.FAILURE if any(r.status == AppriseResultStatus.TIMEOUT for r in results): # Nothing succeeded normally, but at least one service timed out. return AppriseResultStatus.TIMEOUT # Use failure when no more specific result explains the call. return AppriseResultStatus.FAILURE def template_status( status: AppriseResultStatus, skipped: list, ) -> AppriseResultStatus: """Prevent skipped template entries from reporting a clean success.""" if not skipped: # Nothing was skipped, so the original status is complete. return status if status == AppriseResultStatus.SUCCESS: # Delivered entries succeeded, but the full request was incomplete. return AppriseResultStatus.PARTIAL if status == AppriseResultStatus.NOMATCH: # Every matching template entry was skipped before dispatch. return AppriseResultStatus.FAILURE return status def resolve_retry_count(service: NotifyBase, kwargs: dict[str, Any]) -> int: """Consume a retry override and clamp it to the plugin URL limit.""" # Pop first so a valid override does not read a broken plugin property. retry = kwargs.pop("_retry_override", None) if retry is None: # No override, so fall back to the service's normal retry count. retry = safe_attr(service, "retry", 0) try: # Accept numeric text. Infinity raises OverflowError during conversion. retry = int(retry) except (TypeError, ValueError, OverflowError): # If the override is invalid, try the service's configured value. try: retry = int(safe_attr(service, "retry", 0)) except (TypeError, ValueError, OverflowError): # Neither value is a number; fall back to no retries at all. retry = 0 # Never allow a negative or unexpectedly large retry loop. return max(0, min(retry, APPRISE_MAX_SERVICE_RETRY)) def configured_max_attempts( service: NotifyBase, kwargs: dict[str, Any] ) -> int: """Return the attempt limit without consuming the worker's override.""" # Resolve a copy so the worker receives the original private override. return resolve_retry_count(service, dict(kwargs)) + 1 def finalize_service_result( service: NotifyBase, retry: int, attempts: list[NotifyAttempt], ) -> tuple[bool, NotifyResult]: """Build one service result from its completed delivery attempts.""" # Optional services can fail quietly, but keep a log breadcrumb. optional = safe_attr(service, "optional", False) succeeded = any(a.status == AppriseResultStatus.SUCCESS for a in attempts) # Read plugin details only after every attempt has finished. name, url, url_id, tag, weight = service_metadata(service) if not succeeded and optional: logger.info( "Optional service '%s' did not send successfully; continuing.", name, ) # Combine service details with the ordered list of attempts. notify_result = NotifyResult( name=name, url=url, url_id=url_id, tag=tag, optional=optional, weight=weight, max_attempts=retry + 1, attempts=attempts, ) # Return both the convenient boolean and the detailed result. return bool(notify_result), notify_result class CallState: """Everything one notify() or async_notify() call shares. Both entry points settle their options, take the same early exits, escalate the same way, and build their results the same way. Only the dispatch of a batch differs, so that is all each one keeps for itself. This is the outermost of three tiers: one whole call here, one priority chain in ``TagChain``, one service's attempts in ``RetryRunner``. Tag filtering stays with the ``Apprise`` instance, so the few helpers needed for it are reached through ``apprise``. """ __slots__ = ( "apprise", "capture", "chains", "deadline", "log_callback", "log_level", "results", "skipped", "start", "tag", ) def __init__( self, apprise: Apprise, tag: Any, timeout: Union[int, float], log_callback: Optional[ Callable[[NotifyLogEntry, Optional[NotifyBase]], None] ], log_level: Optional[int], ) -> None: """Settle every per-call option the two entry points share.""" # Reject an unusable time limit before any work begins. timeout = validate_timeout(timeout) # The instance being notified, and the filter it was given. self.apprise = apprise self.tag = tag # A value passed to this call wins over the instance default. self.log_callback = ( log_callback if log_callback is not None else apprise._log_callback ) self.log_level = apprise._resolve_call_level( log_level if log_level is not None else apprise._log_level, self.log_callback, ) # Entries held back because a template value was never supplied. self.skipped: list[NotifyBase] = [] # Every NotifyResult dispatched across every chain and priority # group, in the order each batch was run. self.results: list[NotifyResult] = [] # The escalation chains; escalate() fills this in. self.chains: list[TagChain] = [] # Wall-clock start for AppriseResult.elapsed -- covers the entire # call, including argument validation, not just service dispatch. self.start = time.monotonic() # Apply the call timeout without replacing each service's own limit. self.deadline: Optional[float] = ( self.start + timeout if timeout else None ) # Capture orchestration logs not owned by a service attempt. # The capture reads its limits from the asset already held by Apprise. self.capture = _ServiceLogCapture( service=None, log_callback=self.log_callback, level=self.log_level, memory_size=apprise.asset.result_log_memory_size, disk_size=apprise.asset.result_log_disk_size, ) def __enter__(self) -> CallState: """Begin capturing this call's orchestration logs.""" # The capture stays attached for the whole call, early exits included. self.capture.__enter__() return self def __exit__(self, *args: object) -> None: """Stop capturing once the call is over.""" self.capture.__exit__(*args) def result(self, status: AppriseResultStatus) -> AppriseResult: """Build this call's result, timed from when the call started.""" # A skipped template entry keeps a clean success from being reported. return AppriseResult( status=template_status(status, self.skipped), results=self.results, elapsed=time.monotonic() - self.start, call_logs=self.capture.entries, ) def prepare(self, all_calls: list[ServiceCall]) -> list[ServiceCall]: """Apply the per-call overrides every service call carries.""" # The first retry suffix its tag filter matches wins per service. all_calls = self.apprise._inject_per_service_retries( all_calls, self.tag ) # Every service is told the capture level, and the callback when # one is active. Build the pair once, outside the rebuild below. internal: dict[str, Any] = {"_log_level": self.log_level} if self.log_callback is not None: internal["_log_callback"] = self.log_callback # One pass rebuilds each service's kwargs with both keys added. return [ (service, dict(kwargs, **internal)) for service, kwargs in all_calls ] def escalate(self, all_calls: list[ServiceCall]) -> None: """Turn the matched services into independent escalation chains.""" # Each OR tag becomes its own chain, starting at its top priority. chains = self.apprise._build_tag_chains(all_calls, self.tag) self.chains = [TagChain(key, groups) for key, groups in chains.items()] def rounds(self) -> Iterator[list[TagChain]]: """Yield each round of chains that still have a group to try. The caller dispatches the chains it is handed and reports each one back through ``settle()``, then builds its result with ``finish()``. """ while True: if self.apprise.asset.abort_on_chain_failure and any( not chain.succeeded and chain.exhausted for chain in self.chains ): # Stop as soon as one chain is out of priority groups. The # default instead lets every chain run to completion, so # every defined URL gets an attempt. return active = [chain for chain in self.chains if chain.pending] if not active: # Every chain has either succeeded or been exhausted. return yield active def settle(self, chain: TagChain, outcome: BatchOutcome) -> None: """Record one chain's batch, whether it ran or crashed. ``outcome`` is the ``(ok, results)`` the dispatch returned, or the exception it raised. """ if isinstance(outcome, BaseException): # Safety net; a crash escalates like a failure. chain.crashed(outcome) return ok, batch_results = outcome self.results.extend(batch_results) chain.settle(ok) def unprepared(self, e: Exception) -> AppriseResult: """Explain a call that never reached a service, and fail it. This is for a problem with the call itself: a bad argument, or a message Apprise cannot use. A single service that fails is reported against that service instead and never comes here. """ # Say what went wrong; a silent FAILURE is impossible to diagnose. logger.warning("Could not prepare the notification: %s", e) logger.debug("Notification Exception: %r", e) return self.result(AppriseResultStatus.FAILURE) def flat(self, outcome: BatchOutcome) -> AppriseResult: """Build the result for one batch sent without escalation.""" if isinstance(outcome, BaseException): # Safety net; report the crash instead of raising it. dispatch_crashed(outcome) outcome = (False, []) ok, results = outcome self.results.extend(results) return self.result(aggregate_status(ok, self.results)) def finish(self) -> AppriseResult: """Build the result once the chains have nothing left to run.""" # A chain that never succeeded leaves the whole call unsuccessful. success = all(chain.succeeded for chain in self.chains) return self.result(aggregate_status(success, self.results)) class TagChain: """Track service groups as they move through a priority chain. The chain stops after its first successful group. """ __slots__ = ("groups", "index", "key", "priorities", "succeeded") def __init__(self, key: str, groups: dict[int, list]) -> None: """Prepare one chain from its priority groups.""" # The OR tag this chain covers. "" represents the catch-all chain. self.key = key # Its service groups, and those group priorities in order. self.groups = groups self.priorities = sorted(groups) # Where the chain sits now, and whether it is finished. self.index = 0 self.succeeded = False @property def exhausted(self) -> bool: """True once every priority group has been tried.""" # The index moves forward once for every failed group. return self.index >= len(self.priorities) @property def pending(self) -> bool: """True while this chain still has a group left to try.""" # A successful or exhausted chain has no more work. return not self.succeeded and not self.exhausted @property def priority(self) -> int: """Priority of the group this chain will try next.""" # Priorities were sorted when the chain was created. return self.priorities[self.index] @property def batch(self) -> list: """Services in the group this chain will try next.""" # Look up the group selected by the current priority. return self.groups[self.priority] def settle(self, ok: bool) -> None: """Record how the current group did. A success finishes the chain. A failure moves it on to the next, less urgent, priority group. """ if ok: # A successful group completes this chain immediately. logger.trace( "Chain '%s' priority group %s succeeded.", self.key, self.priority, ) self.succeeded = True return # A failed group allows the next, less urgent group to run. logger.trace( "Chain '%s' priority group %s failed; escalating.", self.key, self.priority, ) self.index += 1 def crashed(self, e: BaseException) -> None: """Record a group that raised, and move the chain along.""" # Treat an unexpected error like a failed priority group. logger.warning( "Notification chain '%s' priority group %s raised an exception.", self.key, self.priority, ) logger.debug("Notification Exception: %s", str(e)) self.index += 1 class RetryRunner: """Track one service's attempts for both delivery modes. The caller runs the service and waits between attempts. """ def __init__( self, service: NotifyBase, kwargs: dict[str, Any], deadline: Optional[float], ) -> None: """Prepare the retry options for one service call.""" # The service being notified, and the caller's absolute time limit. self.service = service self.deadline = deadline # Read the name once so a failing plugin property cannot break later # log messages. self.name = safe_attr(service, "service_name", "Unknown") # How many extra tries are allowed, and the pause between them. self.retry = resolve_retry_count(service, kwargs) self.wait = safe_attr(service, "wait", 0.0) # Pop the per-call overrides so they stay internal. self.log_callback = kwargs.pop("_log_callback", None) self.log_level = kwargs.pop("_log_level", None) # Every call actually made, in order. self.attempts: list[NotifyAttempt] = [] # Remember successful targets only while retries are active. self._token = _delivery_tracker.set(set()) if self.retry else None # Somewhere for a plugin to park work its own retries can reuse. self._memo_token = _delivery_memo.set({}) if self.retry else None @property def total(self) -> int: """Total attempts allowed, counting the first one.""" # retry counts only extra tries, so include the original call. return self.retry + 1 def begin(self, attempt: int) -> Optional[float]: """Start an attempt, or record TIMEOUT and return ``None`` if late.""" # Do not start new delivery work after the caller's time limit. if self.deadline is not None and time.monotonic() >= self.deadline: logger.trace( "Deadline already passed for '%s'; skipping attempt %d/%d.", self.name, attempt + 1, self.total, ) # No call began, so there is no delivery time to record. self.attempts.append( NotifyAttempt( status=AppriseResultStatus.TIMEOUT, logs=[timeout_log_entry(self.name, 0.0)], ) ) return None # Save a start time so the result can report the attempt's duration. logger.trace( "Starting attempt %d/%d for '%s'.", attempt + 1, self.total, self.name, ) return time.monotonic() def capture(self) -> _ServiceLogCapture: """Build the log capture that wraps a single attempt.""" # Use WARNING unless the caller requested another capture level. return _ServiceLogCapture( self.service, log_callback=self.log_callback, level=( self.log_level if self.log_level is not None else logging.WARNING ), ) def crashed(self, e: Exception) -> None: """Log a plugin that raised, so it counts as a failed attempt.""" # Keep the main message short and the exception detail at DEBUG. logger.warning( "Notification service '%s' raised an exception.", self.name, ) logger.debug("Notification Exception: %s", str(e)) def record( self, attempt: int, result: bool, started: float, capture: _ServiceLogCapture, ) -> bool: """Store an attempt and return whether the caller can stop early.""" # Measure the service call without including the earlier retry wait. elapsed = time.monotonic() - started logger.trace( "Attempt %d/%d for '%s' finished in %.3fs: %s.", attempt + 1, self.total, self.name, elapsed, "success" if result else "failure", ) # Keep the outcome and any logs captured while this attempt ran. self.attempts.append( NotifyAttempt( # Convert the plugin's True/False answer to a result status. status=( AppriseResultStatus.SUCCESS if result else AppriseResultStatus.FAILURE ), elapsed=elapsed, logs=capture.entries, ) ) # True tells the retry loop that no more attempts are needed. return bool(result) def delay(self, attempt: int) -> float: """Return the retry delay without waiting past the deadline. Zero means no wait, including after the final attempt. """ if attempt >= self.retry: # That was the last try, so there is nothing to announce. return 0.0 logger.warning( "Attempt %d/%d for '%s' failed; trying again.", attempt + 1, self.total, self.name, ) if self.wait <= 0: # A zero wait starts the next attempt immediately. return 0.0 if self.deadline is None: # With no time limit, use the full configured wait. return self.wait # Shorten the wait when the deadline will arrive first. return min(self.wait, max(0.0, self.deadline - time.monotonic())) def close(self) -> None: """Drop the delivery tracker so it cannot outlive this call.""" if self._token is not None: # Restore the previous tracking state for this thread or task. _delivery_tracker.reset(self._token) if self._memo_token is not None: # The notification is over; its working state goes with it. _delivery_memo.reset(self._memo_token) def result(self) -> tuple[bool, NotifyResult]: """Return the finished (success, NotifyResult) pair.""" # All attempts are now complete and ready for the public result. return finalize_service_result(self.service, self.retry, self.attempts) def prepared_failure( service: NotifyBase, kwargs: dict[str, Any] ) -> Optional[tuple[bool, NotifyResult]]: """Return a failed result for a service that never became callable. ``None`` means the service is ready and should be delivered normally. """ # The error rides along with the call rather than being reported when # it happened, so a fallback an escalation never reaches is never # blamed for it. error = kwargs.pop("_prepare_error", None) if error is None: return None # Report it exactly like a plugin that raised while being called, so # an optional service stays optional and a chain still escalates. notify_result = service_crashed(service, error) return bool(notify_result), notify_result def call_with_retry( service: NotifyBase, kwargs: dict[str, Any], deadline: Optional[float], ) -> tuple[bool, NotifyResult]: """Run one service with retries, logging, and a fixed end time. Ordered and worker-thread delivery share this path. """ # A service that could not be prepared is never called at all. failed = prepared_failure(service, kwargs) if failed is not None: return failed # Keep shared retry state in one small helper object. runner = RetryRunner(service, kwargs, deadline) try: # The first pass is normal delivery; later passes are retries. for attempt in range(runner.total): started = runner.begin(attempt) if started is None: break # Treat validation errors and plugin crashes alike: report # them, then let the next attempt still get a turn. with runner.capture() as capture: try: result = service.notify(**kwargs) except Exception as e: runner.crashed(e) result = False if runner.record(attempt, result, started, capture): # A successful delivery ends the retry loop. break # Wait only when another attempt remains and time allows it. delay = runner.delay(attempt) if delay > 0: time.sleep(delay) finally: # Always clean up tracking, even after an unexpected exception. runner.close() # Build the public result from every attempt made above. return runner.result() caronc-apprise-dfec414/apprise/emojis.py000066400000000000000000002544461526720371000204410ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import re import time from .logger import logger # All Emoji's are wrapped in this character DELIM = ":" # the map simply contains the emoji that should be mapped to the regular # expression it should be swapped on. # This list was based on: https://github.com/ikatyang/emoji-cheat-sheet EMOJI_MAP = { # # Face Smiling # DELIM + r"grinning" + DELIM: "😄", DELIM + r"smile" + DELIM: "😄", DELIM + r"(laughing|satisfied)" + DELIM: "😆", DELIM + r"rofl" + DELIM: "🤣", DELIM + r"slightly_smiling_face" + DELIM: "🙂", DELIM + r"wink" + DELIM: "😉", DELIM + r"innocent" + DELIM: "😇", DELIM + r"smiley" + DELIM: "😃", DELIM + r"grin" + DELIM: "😃", DELIM + r"sweat_smile" + DELIM: "😅", DELIM + r"joy" + DELIM: "😂", DELIM + r"upside_down_face" + DELIM: "🙃", DELIM + r"blush" + DELIM: "😊", # # Face Affection # DELIM + r"smiling_face_with_three_hearts" + DELIM: "🥰", DELIM + r"star_struck" + DELIM: "🤩", DELIM + r"kissing" + DELIM: "😗", DELIM + r"kissing_closed_eyes" + DELIM: "😚", DELIM + r"smiling_face_with_tear" + DELIM: "🥲", DELIM + r"heart_eyes" + DELIM: "�", DELIM + r"kissing_heart" + DELIM: "😘", DELIM + r"relaxed" + DELIM: "☺�", DELIM + r"kissing_smiling_eyes" + DELIM: "😙", # # Face Tongue # DELIM + r"yum" + DELIM: "😋", DELIM + r"stuck_out_tongue_winking_eye" + DELIM: "😜", DELIM + r"stuck_out_tongue_closed_eyes" + DELIM: "�", DELIM + r"stuck_out_tongue" + DELIM: "😛", DELIM + r"zany_face" + DELIM: "🤪", DELIM + r"money_mouth_face" + DELIM: "🤑", # # Face Hand # DELIM + r"hugs" + DELIM: "🤗", DELIM + r"shushing_face" + DELIM: "🤫", DELIM + r"hand_over_mouth" + DELIM: "🤭", DELIM + r"thinking" + DELIM: "🤔", # # Face Neutral Skeptical # DELIM + r"zipper_mouth_face" + DELIM: "�", DELIM + r"neutral_face" + DELIM: "�", DELIM + r"no_mouth" + DELIM: "😶", DELIM + r"smirk" + DELIM: "�", DELIM + r"roll_eyes" + DELIM: "🙄", DELIM + r"face_exhaling" + DELIM: "😮�💨", DELIM + r"raised_eyebrow" + DELIM: "🤨", DELIM + r"expressionless" + DELIM: "😑", DELIM + r"face_in_clouds" + DELIM: "😶�🌫�", DELIM + r"unamused" + DELIM: "😒", DELIM + r"grimacing" + DELIM: "😬", DELIM + r"lying_face" + DELIM: "🤥", # # Face Sleepy # DELIM + r"relieved" + DELIM: "😌", DELIM + r"sleepy" + DELIM: "😪", DELIM + r"sleeping" + DELIM: "😴", DELIM + r"pensive" + DELIM: "😔", DELIM + r"drooling_face" + DELIM: "🤤", # # Face Unwell # DELIM + r"mask" + DELIM: "😷", DELIM + r"face_with_head_bandage" + DELIM: "🤕", DELIM + r"vomiting_face" + DELIM: "🤮", DELIM + r"hot_face" + DELIM: "🥵", DELIM + r"woozy_face" + DELIM: "🥴", DELIM + r"face_with_spiral_eyes" + DELIM: "😵�💫", DELIM + r"face_with_thermometer" + DELIM: "🤒", DELIM + r"nauseated_face" + DELIM: "🤢", DELIM + r"sneezing_face" + DELIM: "🤧", DELIM + r"cold_face" + DELIM: "🥶", DELIM + r"dizzy_face" + DELIM: "😵", DELIM + r"exploding_head" + DELIM: "🤯", # # Face Hat # DELIM + r"cowboy_hat_face" + DELIM: "🤠", DELIM + r"disguised_face" + DELIM: "🥸", DELIM + r"partying_face" + DELIM: "🥳", # # Face Glasses # DELIM + r"sunglasses" + DELIM: "😎", DELIM + r"monocle_face" + DELIM: "�", DELIM + r"nerd_face" + DELIM: "🤓", # # Face Concerned # DELIM + r"confused" + DELIM: "😕", DELIM + r"slightly_frowning_face" + DELIM: "�", DELIM + r"open_mouth" + DELIM: "😮", DELIM + r"astonished" + DELIM: "😲", DELIM + r"pleading_face" + DELIM: "🥺", DELIM + r"anguished" + DELIM: "😧", DELIM + r"cold_sweat" + DELIM: "😰", DELIM + r"cry" + DELIM: "😢", DELIM + r"scream" + DELIM: "😱", DELIM + r"persevere" + DELIM: "😣", DELIM + r"sweat" + DELIM: "😓", DELIM + r"tired_face" + DELIM: "😫", DELIM + r"worried" + DELIM: "😟", DELIM + r"frowning_face" + DELIM: "☹�", DELIM + r"hushed" + DELIM: "😯", DELIM + r"flushed" + DELIM: "😳", DELIM + r"frowning" + DELIM: "😦", DELIM + r"fearful" + DELIM: "😨", DELIM + r"disappointed_relieved" + DELIM: "😥", DELIM + r"sob" + DELIM: "😭", DELIM + r"confounded" + DELIM: "😖", DELIM + r"disappointed" + DELIM: "😞", DELIM + r"weary" + DELIM: "😩", DELIM + r"yawning_face" + DELIM: "🥱", # # Face Negative # DELIM + r"triumph" + DELIM: "😤", DELIM + r"angry" + DELIM: "😠", DELIM + r"smiling_imp" + DELIM: "😈", DELIM + r"skull" + DELIM: "💀", DELIM + r"(pout|rage)" + DELIM: "😡", DELIM + r"cursing_face" + DELIM: "🤬", DELIM + r"imp" + DELIM: "👿", DELIM + r"skull_and_crossbones" + DELIM: "☠�", # # Face Costume # DELIM + r"(hankey|poop|shit)" + DELIM: "💩", DELIM + r"japanese_ogre" + DELIM: "👹", DELIM + r"ghost" + DELIM: "👻", DELIM + r"space_invader" + DELIM: "👾", DELIM + r"clown_face" + DELIM: "🤡", DELIM + r"japanese_goblin" + DELIM: "👺", DELIM + r"alien" + DELIM: "👽", DELIM + r"robot" + DELIM: "🤖", # # Cat Face # DELIM + r"smiley_cat" + DELIM: "😺", DELIM + r"joy_cat" + DELIM: "😹", DELIM + r"smirk_cat" + DELIM: "😼", DELIM + r"scream_cat" + DELIM: "🙀", DELIM + r"pouting_cat" + DELIM: "😾", DELIM + r"smile_cat" + DELIM: "😸", DELIM + r"heart_eyes_cat" + DELIM: "😻", DELIM + r"kissing_cat" + DELIM: "😽", DELIM + r"crying_cat_face" + DELIM: "😿", # # Monkey Face # DELIM + r"see_no_evil" + DELIM: "🙈", DELIM + r"speak_no_evil" + DELIM: "🙊", DELIM + r"hear_no_evil" + DELIM: "🙉", # # Heart # DELIM + r"love_letter" + DELIM: "💌", DELIM + r"gift_heart" + DELIM: "�", DELIM + r"heartpulse" + DELIM: "💗", DELIM + r"revolving_hearts" + DELIM: "💞", DELIM + r"heart_decoration" + DELIM: "💟", DELIM + r"broken_heart" + DELIM: "💔", DELIM + r"mending_heart" + DELIM: "���🩹", DELIM + r"orange_heart" + DELIM: "🧡", DELIM + r"green_heart" + DELIM: "💚", DELIM + r"purple_heart" + DELIM: "💜", DELIM + r"black_heart" + DELIM: "🖤", DELIM + r"cupid" + DELIM: "💘", DELIM + r"sparkling_heart" + DELIM: "💖", DELIM + r"heartbeat" + DELIM: "💓", DELIM + r"two_hearts" + DELIM: "💕", DELIM + r"heavy_heart_exclamation" + DELIM: "��", DELIM + r"heart_on_fire" + DELIM: "���🔥", DELIM + r"heart" + DELIM: "��", DELIM + r"yellow_heart" + DELIM: "💛", DELIM + r"blue_heart" + DELIM: "💙", DELIM + r"brown_heart" + DELIM: "🤎", DELIM + r"white_heart" + DELIM: "�", # # Emotion # DELIM + r"kiss" + DELIM: "💋", DELIM + r"anger" + DELIM: "💢", DELIM + r"dizzy" + DELIM: "💫", DELIM + r"dash" + DELIM: "💨", DELIM + r"speech_balloon" + DELIM: "💬", DELIM + r"left_speech_bubble" + DELIM: "🗨�", DELIM + r"thought_balloon" + DELIM: "💭", DELIM + r"100" + DELIM: "💯", DELIM + r"(boom|collision)" + DELIM: "💥", DELIM + r"sweat_drops" + DELIM: "💦", DELIM + r"hole" + DELIM: "🕳�", DELIM + r"eye_speech_bubble" + DELIM: "���🗨�", DELIM + r"right_anger_bubble" + DELIM: "🗯�", DELIM + r"zzz" + DELIM: "💤", # # Hand Fingers Open # DELIM + r"wave" + DELIM: "👋", DELIM + r"raised_hand_with_fingers_splayed" + DELIM: "��", DELIM + r"vulcan_salute" + DELIM: "🖖", DELIM + r"raised_back_of_hand" + DELIM: "🤚", DELIM + r"(raised_)?hand" + DELIM: "✋", # # Hand Fingers Partial # DELIM + r"ok_hand" + DELIM: "👌", DELIM + r"pinched_fingers" + DELIM: "🤌", DELIM + r"pinching_hand" + DELIM: "�", DELIM + r"v" + DELIM: "✌�", DELIM + r"crossed_fingers" + DELIM: "🤞", DELIM + r"love_you_gesture" + DELIM: "🤟", DELIM + r"metal" + DELIM: "🤘", DELIM + r"call_me_hand" + DELIM: "🤙", # # Hand Single Finger # DELIM + r"point_left" + DELIM: "👈", DELIM + r"point_right" + DELIM: "👉", DELIM + r"point_up_2" + DELIM: "👆", DELIM + r"(fu|middle_finger)" + DELIM: "🖕", DELIM + r"point_down" + DELIM: "👇", DELIM + r"point_up" + DELIM: "��", # # Hand Fingers Closed # DELIM + r"(\+1|thumbsup)" + DELIM: "�", DELIM + r"(-1|thumbsdown)" + DELIM: "👎", DELIM + r"fist" + DELIM: "✊", DELIM + r"(fist_(raised|oncoming)|(face)?punch)" + DELIM: "👊", DELIM + r"fist_left" + DELIM: "🤛", DELIM + r"fist_right" + DELIM: "🤜", # # Hands # DELIM + r"clap" + DELIM: "�", DELIM + r"raised_hands" + DELIM: "🙌", DELIM + r"open_hands" + DELIM: "�", DELIM + r"palms_up_together" + DELIM: "🤲", DELIM + r"handshake" + DELIM: "�", DELIM + r"pray" + DELIM: "�", # # Hand Prop # DELIM + r"writing_hand" + DELIM: "��", DELIM + r"nail_care" + DELIM: "💅", DELIM + r"selfie" + DELIM: "🤳", # # Body Parts # DELIM + r"muscle" + DELIM: "💪", DELIM + r"mechanical_arm" + DELIM: "🦾", DELIM + r"mechanical_leg" + DELIM: "🦿", DELIM + r"leg" + DELIM: "🦵", DELIM + r"foot" + DELIM: "🦶", DELIM + r"ear" + DELIM: "👂", DELIM + r"ear_with_hearing_aid" + DELIM: "🦻", DELIM + r"nose" + DELIM: "👃", DELIM + r"brain" + DELIM: "🧠", DELIM + r"anatomical_heart" + DELIM: "🫀", DELIM + r"lungs" + DELIM: "�", DELIM + r"tooth" + DELIM: "🦷", DELIM + r"bone" + DELIM: "🦴", DELIM + r"eyes" + DELIM: "👀", DELIM + r"eye" + DELIM: "��", DELIM + r"tongue" + DELIM: "👅", DELIM + r"lips" + DELIM: "👄", # # Person # DELIM + r"baby" + DELIM: "👶", DELIM + r"child" + DELIM: "🧒", DELIM + r"boy" + DELIM: "👦", DELIM + r"girl" + DELIM: "👧", DELIM + r"adult" + DELIM: "🧑", DELIM + r"blond_haired_person" + DELIM: "👱", DELIM + r"man" + DELIM: "👨", DELIM + r"bearded_person" + DELIM: "🧔", DELIM + r"man_beard" + DELIM: "🧔�♂�", DELIM + r"woman_beard" + DELIM: "🧔�♀�", DELIM + r"red_haired_man" + DELIM: "👨�🦰", DELIM + r"curly_haired_man" + DELIM: "👨�🦱", DELIM + r"white_haired_man" + DELIM: "👨�🦳", DELIM + r"bald_man" + DELIM: "👨�🦲", DELIM + r"woman" + DELIM: "👩", DELIM + r"red_haired_woman" + DELIM: "👩�🦰", DELIM + r"person_red_hair" + DELIM: "🧑�🦰", DELIM + r"curly_haired_woman" + DELIM: "👩�🦱", DELIM + r"person_curly_hair" + DELIM: "🧑�🦱", DELIM + r"white_haired_woman" + DELIM: "👩�🦳", DELIM + r"person_white_hair" + DELIM: "🧑�🦳", DELIM + r"bald_woman" + DELIM: "👩�🦲", DELIM + r"person_bald" + DELIM: "🧑�🦲", DELIM + r"blond_(haired_)?woman" + DELIM: "👱�♀�", DELIM + r"blond_haired_man" + DELIM: "👱�♂�", DELIM + r"older_adult" + DELIM: "🧓", DELIM + r"older_man" + DELIM: "👴", DELIM + r"older_woman" + DELIM: "👵", # # Person Gesture # DELIM + r"frowning_person" + DELIM: "�", DELIM + r"frowning_man" + DELIM: "��♂�", DELIM + r"frowning_woman" + DELIM: "��♀�", DELIM + r"pouting_face" + DELIM: "🙎", DELIM + r"pouting_man" + DELIM: "🙎�♂�", DELIM + r"pouting_woman" + DELIM: "🙎�♀�", DELIM + r"no_good" + DELIM: "🙅", DELIM + r"(ng|no_good)_man" + DELIM: "🙅�♂�", DELIM + r"(ng_woman|no_good_woman)" + DELIM: "🙅�♀�", DELIM + r"ok_person" + DELIM: "🙆", DELIM + r"ok_man" + DELIM: "🙆�♂�", DELIM + r"ok_woman" + DELIM: "🙆�♀�", DELIM + r"(information_desk|tipping_hand_)person" + DELIM: "�", DELIM + r"(sassy_man|tipping_hand_man)" + DELIM: "��♂�", DELIM + r"(sassy_woman|tipping_hand_woman)" + DELIM: "��♀�", DELIM + r"raising_hand" + DELIM: "🙋", DELIM + r"raising_hand_man" + DELIM: "🙋�♂�", DELIM + r"raising_hand_woman" + DELIM: "🙋�♀�", DELIM + r"deaf_person" + DELIM: "�", DELIM + r"deaf_man" + DELIM: "��♂�", DELIM + r"deaf_woman" + DELIM: "��♀�", DELIM + r"bow" + DELIM: "🙇", DELIM + r"bowing_man" + DELIM: "🙇�♂�", DELIM + r"bowing_woman" + DELIM: "🙇�♀�", DELIM + r"facepalm" + DELIM: "🤦", DELIM + r"man_facepalming" + DELIM: "🤦�♂�", DELIM + r"woman_facepalming" + DELIM: "🤦�♀�", DELIM + r"shrug" + DELIM: "🤷", DELIM + r"man_shrugging" + DELIM: "🤷�♂�", DELIM + r"woman_shrugging" + DELIM: "🤷�♀�", # # Person Role # DELIM + r"health_worker" + DELIM: "🧑�⚕�", DELIM + r"man_health_worker" + DELIM: "👨�⚕�", DELIM + r"woman_health_worker" + DELIM: "👩�⚕�", DELIM + r"student" + DELIM: "🧑�🎓", DELIM + r"man_student" + DELIM: "👨�🎓", DELIM + r"woman_student" + DELIM: "👩�🎓", DELIM + r"teacher" + DELIM: "🧑��", DELIM + r"man_teacher" + DELIM: "👨��", DELIM + r"woman_teacher" + DELIM: "👩��", DELIM + r"judge" + DELIM: "🧑�⚖�", DELIM + r"man_judge" + DELIM: "👨�⚖�", DELIM + r"woman_judge" + DELIM: "👩�⚖�", DELIM + r"farmer" + DELIM: "🧑�🌾", DELIM + r"man_farmer" + DELIM: "👨�🌾", DELIM + r"woman_farmer" + DELIM: "👩�🌾", DELIM + r"cook" + DELIM: "🧑��", DELIM + r"man_cook" + DELIM: "👨��", DELIM + r"woman_cook" + DELIM: "👩��", DELIM + r"mechanic" + DELIM: "🧑�🔧", DELIM + r"man_mechanic" + DELIM: "👨�🔧", DELIM + r"woman_mechanic" + DELIM: "👩�🔧", DELIM + r"factory_worker" + DELIM: "🧑��", DELIM + r"man_factory_worker" + DELIM: "👨��", DELIM + r"woman_factory_worker" + DELIM: "👩��", DELIM + r"office_worker" + DELIM: "🧑�💼", DELIM + r"man_office_worker" + DELIM: "👨�💼", DELIM + r"woman_office_worker" + DELIM: "👩�💼", DELIM + r"scientist" + DELIM: "🧑�🔬", DELIM + r"man_scientist" + DELIM: "👨�🔬", DELIM + r"woman_scientist" + DELIM: "👩�🔬", DELIM + r"technologist" + DELIM: "🧑�💻", DELIM + r"man_technologist" + DELIM: "👨�💻", DELIM + r"woman_technologist" + DELIM: "👩�💻", DELIM + r"singer" + DELIM: "🧑�🎤", DELIM + r"man_singer" + DELIM: "👨�🎤", DELIM + r"woman_singer" + DELIM: "👩�🎤", DELIM + r"artist" + DELIM: "🧑�🎨", DELIM + r"man_artist" + DELIM: "👨�🎨", DELIM + r"woman_artist" + DELIM: "👩�🎨", DELIM + r"pilot" + DELIM: "🧑�✈�", DELIM + r"man_pilot" + DELIM: "👨�✈�", DELIM + r"woman_pilot" + DELIM: "👩�✈�", DELIM + r"astronaut" + DELIM: "🧑�🚀", DELIM + r"man_astronaut" + DELIM: "👨�🚀", DELIM + r"woman_astronaut" + DELIM: "👩�🚀", DELIM + r"firefighter" + DELIM: "🧑�🚒", DELIM + r"man_firefighter" + DELIM: "👨�🚒", DELIM + r"woman_firefighter" + DELIM: "👩�🚒", DELIM + r"cop" + DELIM: "👮", DELIM + r"police(_officer|man)" + DELIM: "👮�♂�", DELIM + r"policewoman" + DELIM: "👮�♀�", DELIM + r"detective" + DELIM: "🕵�", DELIM + r"male_detective" + DELIM: "🕵��♂�", DELIM + r"female_detective" + DELIM: "🕵��♀�", DELIM + r"guard" + DELIM: "💂", DELIM + r"guardsman" + DELIM: "💂�♂�", DELIM + r"guardswoman" + DELIM: "💂�♀�", DELIM + r"ninja" + DELIM: "🥷", DELIM + r"construction_worker" + DELIM: "👷", DELIM + r"construction_worker_man" + DELIM: "👷�♂�", DELIM + r"construction_worker_woman" + DELIM: "👷�♀�", DELIM + r"prince" + DELIM: "🤴", DELIM + r"princess" + DELIM: "👸", DELIM + r"person_with_turban" + DELIM: "👳", DELIM + r"man_with_turban" + DELIM: "👳�♂�", DELIM + r"woman_with_turban" + DELIM: "👳�♀�", DELIM + r"man_with_gua_pi_mao" + DELIM: "👲", DELIM + r"woman_with_headscarf" + DELIM: "🧕", DELIM + r"person_in_tuxedo" + DELIM: "🤵", DELIM + r"man_in_tuxedo" + DELIM: "🤵�♂�", DELIM + r"woman_in_tuxedo" + DELIM: "🤵�♀�", DELIM + r"person_with_veil" + DELIM: "👰", DELIM + r"man_with_veil" + DELIM: "👰�♂�", DELIM + r"(bride|woman)_with_veil" + DELIM: "👰�♀�", DELIM + r"pregnant_woman" + DELIM: "🤰", DELIM + r"breast_feeding" + DELIM: "🤱", DELIM + r"woman_feeding_baby" + DELIM: "👩��", DELIM + r"man_feeding_baby" + DELIM: "👨��", DELIM + r"person_feeding_baby" + DELIM: "🧑��", # # Person Fantasy # DELIM + r"angel" + DELIM: "👼", DELIM + r"santa" + DELIM: "🎅", DELIM + r"mrs_claus" + DELIM: "🤶", DELIM + r"mx_claus" + DELIM: "🧑�🎄", DELIM + r"superhero" + DELIM: "🦸", DELIM + r"superhero_man" + DELIM: "🦸�♂�", DELIM + r"superhero_woman" + DELIM: "🦸�♀�", DELIM + r"supervillain" + DELIM: "🦹", DELIM + r"supervillain_man" + DELIM: "🦹�♂�", DELIM + r"supervillain_woman" + DELIM: "🦹�♀�", DELIM + r"mage" + DELIM: "🧙", DELIM + r"mage_man" + DELIM: "🧙�♂�", DELIM + r"mage_woman" + DELIM: "🧙�♀�", DELIM + r"fairy" + DELIM: "🧚", DELIM + r"fairy_man" + DELIM: "🧚�♂�", DELIM + r"fairy_woman" + DELIM: "🧚�♀�", DELIM + r"vampire" + DELIM: "🧛", DELIM + r"vampire_man" + DELIM: "🧛�♂�", DELIM + r"vampire_woman" + DELIM: "🧛�♀�", DELIM + r"merperson" + DELIM: "🧜", DELIM + r"merman" + DELIM: "🧜�♂�", DELIM + r"mermaid" + DELIM: "🧜�♀�", DELIM + r"elf" + DELIM: "�", DELIM + r"elf_man" + DELIM: "��♂�", DELIM + r"elf_woman" + DELIM: "��♀�", DELIM + r"genie" + DELIM: "🧞", DELIM + r"genie_man" + DELIM: "🧞�♂�", DELIM + r"genie_woman" + DELIM: "🧞�♀�", DELIM + r"zombie" + DELIM: "🧟", DELIM + r"zombie_man" + DELIM: "🧟�♂�", DELIM + r"zombie_woman" + DELIM: "🧟�♀�", # # Person Activity # DELIM + r"massage" + DELIM: "💆", DELIM + r"massage_man" + DELIM: "💆�♂�", DELIM + r"massage_woman" + DELIM: "💆�♀�", DELIM + r"haircut" + DELIM: "💇", DELIM + r"haircut_man" + DELIM: "💇�♂�", DELIM + r"haircut_woman" + DELIM: "💇�♀�", DELIM + r"walking" + DELIM: "🚶", DELIM + r"walking_man" + DELIM: "🚶�♂�", DELIM + r"walking_woman" + DELIM: "🚶�♀�", DELIM + r"standing_person" + DELIM: "�", DELIM + r"standing_man" + DELIM: "��♂�", DELIM + r"standing_woman" + DELIM: "��♀�", DELIM + r"kneeling_person" + DELIM: "🧎", DELIM + r"kneeling_man" + DELIM: "🧎�♂�", DELIM + r"kneeling_woman" + DELIM: "🧎�♀�", DELIM + r"person_with_probing_cane" + DELIM: "🧑�🦯", DELIM + r"man_with_probing_cane" + DELIM: "👨�🦯", DELIM + r"woman_with_probing_cane" + DELIM: "👩�🦯", DELIM + r"person_in_motorized_wheelchair" + DELIM: "🧑�🦼", DELIM + r"man_in_motorized_wheelchair" + DELIM: "👨�🦼", DELIM + r"woman_in_motorized_wheelchair" + DELIM: "👩�🦼", DELIM + r"person_in_manual_wheelchair" + DELIM: "🧑�🦽", DELIM + r"man_in_manual_wheelchair" + DELIM: "👨�🦽", DELIM + r"woman_in_manual_wheelchair" + DELIM: "👩�🦽", DELIM + r"runn(er|ing)" + DELIM: "�", DELIM + r"running_man" + DELIM: "��♂�", DELIM + r"running_woman" + DELIM: "��♀�", DELIM + r"(dancer|woman_dancing)" + DELIM: "💃", DELIM + r"man_dancing" + DELIM: "🕺", DELIM + r"business_suit_levitating" + DELIM: "🕴�", DELIM + r"dancers" + DELIM: "👯", DELIM + r"dancing_men" + DELIM: "👯�♂�", DELIM + r"dancing_women" + DELIM: "👯�♀�", DELIM + r"sauna_person" + DELIM: "🧖", DELIM + r"sauna_man" + DELIM: "🧖�♂�", DELIM + r"sauna_woman" + DELIM: "🧖�♀�", DELIM + r"climbing" + DELIM: "🧗", DELIM + r"climbing_man" + DELIM: "🧗�♂�", DELIM + r"climbing_woman" + DELIM: "🧗�♀�", # # Person Sport # DELIM + r"person_fencing" + DELIM: "🤺", DELIM + r"horse_racing" + DELIM: "�", DELIM + r"skier" + DELIM: "⛷�", DELIM + r"snowboarder" + DELIM: "�", DELIM + r"golfing" + DELIM: "��", DELIM + r"golfing_man" + DELIM: "���♂�", DELIM + r"golfing_woman" + DELIM: "���♀�", DELIM + r"surfer" + DELIM: "�", DELIM + r"surfing_man" + DELIM: "��♂�", DELIM + r"surfing_woman" + DELIM: "��♀�", DELIM + r"rowboat" + DELIM: "🚣", DELIM + r"rowing_man" + DELIM: "🚣�♂�", DELIM + r"rowing_woman" + DELIM: "🚣�♀�", DELIM + r"swimmer" + DELIM: "�", DELIM + r"swimming_man" + DELIM: "��♂�", DELIM + r"swimming_woman" + DELIM: "��♀�", DELIM + r"bouncing_ball_person" + DELIM: "⛹�", DELIM + r"(basketball|bouncing_ball)_man" + DELIM: "⛹��♂�", DELIM + r"(basketball|bouncing_ball)_woman" + DELIM: "⛹��♀�", DELIM + r"weight_lifting" + DELIM: "��", DELIM + r"weight_lifting_man" + DELIM: "���♂�", DELIM + r"weight_lifting_woman" + DELIM: "���♀�", DELIM + r"bicyclist" + DELIM: "🚴", DELIM + r"biking_man" + DELIM: "🚴�♂�", DELIM + r"biking_woman" + DELIM: "🚴�♀�", DELIM + r"mountain_bicyclist" + DELIM: "🚵", DELIM + r"mountain_biking_man" + DELIM: "🚵�♂�", DELIM + r"mountain_biking_woman" + DELIM: "🚵�♀�", DELIM + r"cartwheeling" + DELIM: "🤸", DELIM + r"man_cartwheeling" + DELIM: "🤸�♂�", DELIM + r"woman_cartwheeling" + DELIM: "🤸�♀�", DELIM + r"wrestling" + DELIM: "🤼", DELIM + r"men_wrestling" + DELIM: "🤼�♂�", DELIM + r"women_wrestling" + DELIM: "🤼�♀�", DELIM + r"water_polo" + DELIM: "🤽", DELIM + r"man_playing_water_polo" + DELIM: "🤽�♂�", DELIM + r"woman_playing_water_polo" + DELIM: "🤽�♀�", DELIM + r"handball_person" + DELIM: "🤾", DELIM + r"man_playing_handball" + DELIM: "🤾�♂�", DELIM + r"woman_playing_handball" + DELIM: "🤾�♀�", DELIM + r"juggling_person" + DELIM: "🤹", DELIM + r"man_juggling" + DELIM: "🤹�♂�", DELIM + r"woman_juggling" + DELIM: "🤹�♀�", # # Person Resting # DELIM + r"lotus_position" + DELIM: "🧘", DELIM + r"lotus_position_man" + DELIM: "🧘�♂�", DELIM + r"lotus_position_woman" + DELIM: "🧘�♀�", DELIM + r"bath" + DELIM: "🛀", DELIM + r"sleeping_bed" + DELIM: "🛌", # # Family # DELIM + r"people_holding_hands" + DELIM: "🧑���🧑", DELIM + r"two_women_holding_hands" + DELIM: "👭", DELIM + r"couple" + DELIM: "👫", DELIM + r"two_men_holding_hands" + DELIM: "👬", DELIM + r"couplekiss" + DELIM: "�", DELIM + r"couplekiss_man_woman" + DELIM: "👩����💋�👨", DELIM + r"couplekiss_man_man" + DELIM: "👨����💋�👨", DELIM + r"couplekiss_woman_woman" + DELIM: "👩����💋�👩", DELIM + r"couple_with_heart" + DELIM: "💑", DELIM + r"couple_with_heart_woman_man" + DELIM: "👩����👨", DELIM + r"couple_with_heart_man_man" + DELIM: "👨����👨", DELIM + r"couple_with_heart_woman_woman" + DELIM: "👩����👩", DELIM + r"family_man_woman_boy" + DELIM: "👨�👩�👦", DELIM + r"family_man_woman_girl" + DELIM: "👨�👩�👧", DELIM + r"family_man_woman_girl_boy" + DELIM: "👨�👩�👧�👦", DELIM + r"family_man_woman_boy_boy" + DELIM: "👨�👩�👦�👦", DELIM + r"family_man_woman_girl_girl" + DELIM: "👨�👩�👧�👧", DELIM + r"family_man_man_boy" + DELIM: "👨�👨�👦", DELIM + r"family_man_man_girl" + DELIM: "👨�👨�👧", DELIM + r"family_man_man_girl_boy" + DELIM: "👨�👨�👧�👦", DELIM + r"family_man_man_boy_boy" + DELIM: "👨�👨�👦�👦", DELIM + r"family_man_man_girl_girl" + DELIM: "👨�👨�👧�👧", DELIM + r"family_woman_woman_boy" + DELIM: "👩�👩�👦", DELIM + r"family_woman_woman_girl" + DELIM: "👩�👩�👧", DELIM + r"family_woman_woman_girl_boy" + DELIM: "👩�👩�👧�👦", DELIM + r"family_woman_woman_boy_boy" + DELIM: "👩�👩�👦�👦", DELIM + r"family_woman_woman_girl_girl" + DELIM: "👩�👩�👧�👧", DELIM + r"family_man_boy" + DELIM: "👨�👦", DELIM + r"family_man_boy_boy" + DELIM: "👨�👦�👦", DELIM + r"family_man_girl" + DELIM: "👨�👧", DELIM + r"family_man_girl_boy" + DELIM: "👨�👧�👦", DELIM + r"family_man_girl_girl" + DELIM: "👨�👧�👧", DELIM + r"family_woman_boy" + DELIM: "👩�👦", DELIM + r"family_woman_boy_boy" + DELIM: "👩�👦�👦", DELIM + r"family_woman_girl" + DELIM: "👩�👧", DELIM + r"family_woman_girl_boy" + DELIM: "👩�👧�👦", DELIM + r"family_woman_girl_girl" + DELIM: "👩�👧�👧", # # Person Symbol # DELIM + r"speaking_head" + DELIM: "🗣�", DELIM + r"bust_in_silhouette" + DELIM: "👤", DELIM + r"busts_in_silhouette" + DELIM: "👥", DELIM + r"people_hugging" + DELIM: "🫂", DELIM + r"family" + DELIM: "👪", DELIM + r"footprints" + DELIM: "👣", # # Animal Mammal # DELIM + r"monkey_face" + DELIM: "�", DELIM + r"monkey" + DELIM: "�", DELIM + r"gorilla" + DELIM: "�", DELIM + r"orangutan" + DELIM: "🦧", DELIM + r"dog" + DELIM: "�", DELIM + r"dog2" + DELIM: "�", DELIM + r"guide_dog" + DELIM: "🦮", DELIM + r"service_dog" + DELIM: "��🦺", DELIM + r"poodle" + DELIM: "�", DELIM + r"wolf" + DELIM: "�", DELIM + r"fox_face" + DELIM: "🦊", DELIM + r"raccoon" + DELIM: "�", DELIM + r"cat" + DELIM: "�", DELIM + r"cat2" + DELIM: "�", DELIM + r"black_cat" + DELIM: "��⬛", DELIM + r"lion" + DELIM: "�", DELIM + r"tiger" + DELIM: "�", DELIM + r"tiger2" + DELIM: "�", DELIM + r"leopard" + DELIM: "�", DELIM + r"horse" + DELIM: "�", DELIM + r"racehorse" + DELIM: "�", DELIM + r"unicorn" + DELIM: "🦄", DELIM + r"zebra" + DELIM: "🦓", DELIM + r"deer" + DELIM: "🦌", DELIM + r"bison" + DELIM: "🦬", DELIM + r"cow" + DELIM: "�", DELIM + r"ox" + DELIM: "�", DELIM + r"water_buffalo" + DELIM: "�", DELIM + r"cow2" + DELIM: "�", DELIM + r"pig" + DELIM: "�", DELIM + r"pig2" + DELIM: "�", DELIM + r"boar" + DELIM: "�", DELIM + r"pig_nose" + DELIM: "�", DELIM + r"ram" + DELIM: "�", DELIM + r"sheep" + DELIM: "�", DELIM + r"goat" + DELIM: "�", DELIM + r"dromedary_camel" + DELIM: "�", DELIM + r"camel" + DELIM: "�", DELIM + r"llama" + DELIM: "🦙", DELIM + r"giraffe" + DELIM: "🦒", DELIM + r"elephant" + DELIM: "�", DELIM + r"mammoth" + DELIM: "🦣", DELIM + r"rhinoceros" + DELIM: "�", DELIM + r"hippopotamus" + DELIM: "🦛", DELIM + r"mouse" + DELIM: "�", DELIM + r"mouse2" + DELIM: "�", DELIM + r"rat" + DELIM: "�", DELIM + r"hamster" + DELIM: "�", DELIM + r"rabbit" + DELIM: "�", DELIM + r"rabbit2" + DELIM: "�", DELIM + r"chipmunk" + DELIM: "��", DELIM + r"beaver" + DELIM: "🦫", DELIM + r"hedgehog" + DELIM: "🦔", DELIM + r"bat" + DELIM: "🦇", DELIM + r"bear" + DELIM: "�", DELIM + r"polar_bear" + DELIM: "����", DELIM + r"koala" + DELIM: "�", DELIM + r"panda_face" + DELIM: "�", DELIM + r"sloth" + DELIM: "🦥", DELIM + r"otter" + DELIM: "🦦", DELIM + r"skunk" + DELIM: "🦨", DELIM + r"kangaroo" + DELIM: "🦘", DELIM + r"badger" + DELIM: "🦡", DELIM + r"(feet|paw_prints)" + DELIM: "�", # # Animal Bird # DELIM + r"turkey" + DELIM: "🦃", DELIM + r"chicken" + DELIM: "�", DELIM + r"rooster" + DELIM: "�", DELIM + r"hatching_chick" + DELIM: "�", DELIM + r"baby_chick" + DELIM: "�", DELIM + r"hatched_chick" + DELIM: "�", DELIM + r"bird" + DELIM: "�", DELIM + r"penguin" + DELIM: "�", DELIM + r"dove" + DELIM: "🕊�", DELIM + r"eagle" + DELIM: "🦅", DELIM + r"duck" + DELIM: "🦆", DELIM + r"swan" + DELIM: "🦢", DELIM + r"owl" + DELIM: "🦉", DELIM + r"dodo" + DELIM: "🦤", DELIM + r"feather" + DELIM: "🪶", DELIM + r"flamingo" + DELIM: "🦩", DELIM + r"peacock" + DELIM: "🦚", DELIM + r"parrot" + DELIM: "🦜", # # Animal Amphibian # DELIM + r"frog" + DELIM: "�", # # Animal Reptile # DELIM + r"crocodile" + DELIM: "�", DELIM + r"turtle" + DELIM: "�", DELIM + r"lizard" + DELIM: "🦎", DELIM + r"snake" + DELIM: "�", DELIM + r"dragon_face" + DELIM: "�", DELIM + r"dragon" + DELIM: "�", DELIM + r"sauropod" + DELIM: "🦕", DELIM + r"t-rex" + DELIM: "🦖", # # Animal Marine # DELIM + r"whale" + DELIM: "�", DELIM + r"whale2" + DELIM: "�", DELIM + r"dolphin" + DELIM: "�", DELIM + r"(seal|flipper)" + DELIM: "🦭", DELIM + r"fish" + DELIM: "�", DELIM + r"tropical_fish" + DELIM: "�", DELIM + r"blowfish" + DELIM: "�", DELIM + r"shark" + DELIM: "🦈", DELIM + r"octopus" + DELIM: "�", DELIM + r"shell" + DELIM: "�", # # Animal Bug # DELIM + r"snail" + DELIM: "�", DELIM + r"butterfly" + DELIM: "🦋", DELIM + r"bug" + DELIM: "�", DELIM + r"ant" + DELIM: "�", DELIM + r"bee" + DELIM: "�", DELIM + r"honeybee" + DELIM: "🪲", DELIM + r"(lady_)?beetle" + DELIM: "�", DELIM + r"cricket" + DELIM: "🦗", DELIM + r"cockroach" + DELIM: "🪳", DELIM + r"spider" + DELIM: "🕷�", DELIM + r"spider_web" + DELIM: "🕸�", DELIM + r"scorpion" + DELIM: "🦂", DELIM + r"mosquito" + DELIM: "🦟", DELIM + r"fly" + DELIM: "🪰", DELIM + r"worm" + DELIM: "🪱", DELIM + r"microbe" + DELIM: "🦠", # # Plant Flower # DELIM + r"bouquet" + DELIM: "�", DELIM + r"cherry_blossom" + DELIM: "🌸", DELIM + r"white_flower" + DELIM: "💮", DELIM + r"rosette" + DELIM: "��", DELIM + r"rose" + DELIM: "🌹", DELIM + r"wilted_flower" + DELIM: "🥀", DELIM + r"hibiscus" + DELIM: "🌺", DELIM + r"sunflower" + DELIM: "🌻", DELIM + r"blossom" + DELIM: "🌼", DELIM + r"tulip" + DELIM: "🌷", # # Plant Other # DELIM + r"seedling" + DELIM: "🌱", DELIM + r"potted_plant" + DELIM: "🪴", DELIM + r"evergreen_tree" + DELIM: "🌲", DELIM + r"deciduous_tree" + DELIM: "🌳", DELIM + r"palm_tree" + DELIM: "🌴", DELIM + r"cactus" + DELIM: "🌵", DELIM + r"ear_of_rice" + DELIM: "🌾", DELIM + r"herb" + DELIM: "🌿", DELIM + r"shamrock" + DELIM: "☘�", DELIM + r"four_leaf_clover" + DELIM: "�", DELIM + r"maple_leaf" + DELIM: "�", DELIM + r"fallen_leaf" + DELIM: "�", DELIM + r"leaves" + DELIM: "�", DELIM + r"mushroom" + DELIM: "�", # # Food Fruit # DELIM + r"grapes" + DELIM: "�", DELIM + r"melon" + DELIM: "�", DELIM + r"watermelon" + DELIM: "�", DELIM + r"(orange|mandarin|tangerine)" + DELIM: "�", DELIM + r"lemon" + DELIM: "�", DELIM + r"banana" + DELIM: "�", DELIM + r"pineapple" + DELIM: "�", DELIM + r"mango" + DELIM: "🥭", DELIM + r"apple" + DELIM: "�", DELIM + r"green_apple" + DELIM: "�", DELIM + r"pear" + DELIM: "�", DELIM + r"peach" + DELIM: "�", DELIM + r"cherries" + DELIM: "�", DELIM + r"strawberry" + DELIM: "�", DELIM + r"blueberries" + DELIM: "�", DELIM + r"kiwi_fruit" + DELIM: "�", DELIM + r"tomato" + DELIM: "�", DELIM + r"olive" + DELIM: "🫒", DELIM + r"coconut" + DELIM: "🥥", # # Food Vegetable # DELIM + r"avocado" + DELIM: "🥑", DELIM + r"eggplant" + DELIM: "�", DELIM + r"potato" + DELIM: "🥔", DELIM + r"carrot" + DELIM: "🥕", DELIM + r"corn" + DELIM: "🌽", DELIM + r"hot_pepper" + DELIM: "🌶�", DELIM + r"bell_pepper" + DELIM: "🫑", DELIM + r"cucumber" + DELIM: "🥒", DELIM + r"leafy_green" + DELIM: "🥬", DELIM + r"broccoli" + DELIM: "🥦", DELIM + r"garlic" + DELIM: "🧄", DELIM + r"onion" + DELIM: "🧅", DELIM + r"peanuts" + DELIM: "🥜", DELIM + r"chestnut" + DELIM: "🌰", # # Food Prepared # DELIM + r"bread" + DELIM: "�", DELIM + r"croissant" + DELIM: "�", DELIM + r"baguette_bread" + DELIM: "🥖", DELIM + r"flatbread" + DELIM: "🫓", DELIM + r"pretzel" + DELIM: "🥨", DELIM + r"bagel" + DELIM: "🥯", DELIM + r"pancakes" + DELIM: "🥞", DELIM + r"waffle" + DELIM: "🧇", DELIM + r"cheese" + DELIM: "🧀", DELIM + r"meat_on_bone" + DELIM: "�", DELIM + r"poultry_leg" + DELIM: "�", DELIM + r"cut_of_meat" + DELIM: "🥩", DELIM + r"bacon" + DELIM: "🥓", DELIM + r"hamburger" + DELIM: "�", DELIM + r"fries" + DELIM: "�", DELIM + r"pizza" + DELIM: "�", DELIM + r"hotdog" + DELIM: "🌭", DELIM + r"sandwich" + DELIM: "🥪", DELIM + r"taco" + DELIM: "🌮", DELIM + r"burrito" + DELIM: "🌯", DELIM + r"tamale" + DELIM: "🫔", DELIM + r"stuffed_flatbread" + DELIM: "🥙", DELIM + r"falafel" + DELIM: "🧆", DELIM + r"egg" + DELIM: "🥚", DELIM + r"fried_egg" + DELIM: "�", DELIM + r"shallow_pan_of_food" + DELIM: "🥘", DELIM + r"stew" + DELIM: "�", DELIM + r"fondue" + DELIM: "🫕", DELIM + r"bowl_with_spoon" + DELIM: "🥣", DELIM + r"green_salad" + DELIM: "🥗", DELIM + r"popcorn" + DELIM: "�", DELIM + r"butter" + DELIM: "🧈", DELIM + r"salt" + DELIM: "🧂", DELIM + r"canned_food" + DELIM: "🥫", # # Food Asian # DELIM + r"bento" + DELIM: "�", DELIM + r"rice_cracker" + DELIM: "�", DELIM + r"rice_ball" + DELIM: "�", DELIM + r"rice" + DELIM: "�", DELIM + r"curry" + DELIM: "�", DELIM + r"ramen" + DELIM: "�", DELIM + r"spaghetti" + DELIM: "�", DELIM + r"sweet_potato" + DELIM: "�", DELIM + r"oden" + DELIM: "�", DELIM + r"sushi" + DELIM: "�", DELIM + r"fried_shrimp" + DELIM: "�", DELIM + r"fish_cake" + DELIM: "�", DELIM + r"moon_cake" + DELIM: "🥮", DELIM + r"dango" + DELIM: "�", DELIM + r"dumpling" + DELIM: "🥟", DELIM + r"fortune_cookie" + DELIM: "🥠", DELIM + r"takeout_box" + DELIM: "🥡", # # Food Marine # DELIM + r"crab" + DELIM: "🦀", DELIM + r"lobster" + DELIM: "🦞", DELIM + r"shrimp" + DELIM: "�", DELIM + r"squid" + DELIM: "🦑", DELIM + r"oyster" + DELIM: "🦪", # # Food Sweet # DELIM + r"icecream" + DELIM: "�", DELIM + r"shaved_ice" + DELIM: "�", DELIM + r"ice_cream" + DELIM: "�", DELIM + r"doughnut" + DELIM: "�", DELIM + r"cookie" + DELIM: "�", DELIM + r"birthday" + DELIM: "🎂", DELIM + r"cake" + DELIM: "�", DELIM + r"cupcake" + DELIM: "�", DELIM + r"pie" + DELIM: "🥧", DELIM + r"chocolate_bar" + DELIM: "�", DELIM + r"candy" + DELIM: "�", DELIM + r"lollipop" + DELIM: "�", DELIM + r"custard" + DELIM: "�", DELIM + r"honey_pot" + DELIM: "�", # # Drink # DELIM + r"baby_bottle" + DELIM: "�", DELIM + r"milk_glass" + DELIM: "🥛", DELIM + r"coffee" + DELIM: "☕", DELIM + r"teapot" + DELIM: "🫖", DELIM + r"tea" + DELIM: "�", DELIM + r"sake" + DELIM: "�", DELIM + r"champagne" + DELIM: "�", DELIM + r"wine_glass" + DELIM: "�", DELIM + r"cocktail" + DELIM: "�", DELIM + r"tropical_drink" + DELIM: "�", DELIM + r"beer" + DELIM: "�", DELIM + r"beers" + DELIM: "�", DELIM + r"clinking_glasses" + DELIM: "🥂", DELIM + r"tumbler_glass" + DELIM: "🥃", DELIM + r"cup_with_straw" + DELIM: "🥤", DELIM + r"bubble_tea" + DELIM: "🧋", DELIM + r"beverage_box" + DELIM: "🧃", DELIM + r"mate" + DELIM: "🧉", DELIM + r"ice_cube" + DELIM: "🧊", # # Dishware # DELIM + r"chopsticks" + DELIM: "🥢", DELIM + r"plate_with_cutlery" + DELIM: "��", DELIM + r"fork_and_knife" + DELIM: "�", DELIM + r"spoon" + DELIM: "🥄", DELIM + r"(hocho|knife)" + DELIM: "🔪", DELIM + r"amphora" + DELIM: "�", # # Place Map # DELIM + r"earth_africa" + DELIM: "�", DELIM + r"earth_americas" + DELIM: "🌎", DELIM + r"earth_asia" + DELIM: "�", DELIM + r"globe_with_meridians" + DELIM: "�", DELIM + r"world_map" + DELIM: "🗺�", DELIM + r"japan" + DELIM: "🗾", DELIM + r"compass" + DELIM: "🧭", # # Place Geographic # DELIM + r"mountain_snow" + DELIM: "��", DELIM + r"mountain" + DELIM: "⛰�", DELIM + r"volcano" + DELIM: "🌋", DELIM + r"mount_fuji" + DELIM: "🗻", DELIM + r"camping" + DELIM: "��", DELIM + r"beach_umbrella" + DELIM: "��", DELIM + r"desert" + DELIM: "��", DELIM + r"desert_island" + DELIM: "��", DELIM + r"national_park" + DELIM: "��", # # Place Building # DELIM + r"stadium" + DELIM: "��", DELIM + r"classical_building" + DELIM: "��", DELIM + r"building_construction" + DELIM: "��", DELIM + r"bricks" + DELIM: "🧱", DELIM + r"rock" + DELIM: "🪨", DELIM + r"wood" + DELIM: "🪵", DELIM + r"hut" + DELIM: "🛖", DELIM + r"houses" + DELIM: "��", DELIM + r"derelict_house" + DELIM: "��", DELIM + r"house" + DELIM: "�", DELIM + r"house_with_garden" + DELIM: "�", DELIM + r"office" + DELIM: "�", DELIM + r"post_office" + DELIM: "�", DELIM + r"european_post_office" + DELIM: "�", DELIM + r"hospital" + DELIM: "�", DELIM + r"bank" + DELIM: "�", DELIM + r"hotel" + DELIM: "�", DELIM + r"love_hotel" + DELIM: "�", DELIM + r"convenience_store" + DELIM: "�", DELIM + r"school" + DELIM: "�", DELIM + r"department_store" + DELIM: "�", DELIM + r"factory" + DELIM: "�", DELIM + r"japanese_castle" + DELIM: "�", DELIM + r"european_castle" + DELIM: "�", DELIM + r"wedding" + DELIM: "💒", DELIM + r"tokyo_tower" + DELIM: "🗼", DELIM + r"statue_of_liberty" + DELIM: "🗽", # # Place Religious # DELIM + r"church" + DELIM: "⛪", DELIM + r"mosque" + DELIM: "🕌", DELIM + r"hindu_temple" + DELIM: "🛕", DELIM + r"synagogue" + DELIM: "�", DELIM + r"shinto_shrine" + DELIM: "⛩�", DELIM + r"kaaba" + DELIM: "🕋", # # Place Other # DELIM + r"fountain" + DELIM: "⛲", DELIM + r"tent" + DELIM: "⛺", DELIM + r"foggy" + DELIM: "�", DELIM + r"night_with_stars" + DELIM: "🌃", DELIM + r"cityscape" + DELIM: "��", DELIM + r"sunrise_over_mountains" + DELIM: "🌄", DELIM + r"sunrise" + DELIM: "🌅", DELIM + r"city_sunset" + DELIM: "🌆", DELIM + r"city_sunrise" + DELIM: "🌇", DELIM + r"bridge_at_night" + DELIM: "🌉", DELIM + r"hotsprings" + DELIM: "♨�", DELIM + r"carousel_horse" + DELIM: "🎠", DELIM + r"ferris_wheel" + DELIM: "🎡", DELIM + r"roller_coaster" + DELIM: "🎢", DELIM + r"barber" + DELIM: "💈", DELIM + r"circus_tent" + DELIM: "🎪", # # Transport Ground # DELIM + r"steam_locomotive" + DELIM: "🚂", DELIM + r"railway_car" + DELIM: "🚃", DELIM + r"bullettrain_side" + DELIM: "🚄", DELIM + r"bullettrain_front" + DELIM: "🚅", DELIM + r"train2" + DELIM: "🚆", DELIM + r"metro" + DELIM: "🚇", DELIM + r"light_rail" + DELIM: "🚈", DELIM + r"station" + DELIM: "🚉", DELIM + r"tram" + DELIM: "🚊", DELIM + r"monorail" + DELIM: "�", DELIM + r"mountain_railway" + DELIM: "🚞", DELIM + r"train" + DELIM: "🚋", DELIM + r"bus" + DELIM: "🚌", DELIM + r"oncoming_bus" + DELIM: "�", DELIM + r"trolleybus" + DELIM: "🚎", DELIM + r"minibus" + DELIM: "�", DELIM + r"ambulance" + DELIM: "🚑", DELIM + r"fire_engine" + DELIM: "🚒", DELIM + r"police_car" + DELIM: "🚓", DELIM + r"oncoming_police_car" + DELIM: "🚔", DELIM + r"taxi" + DELIM: "🚕", DELIM + r"oncoming_taxi" + DELIM: "🚖", DELIM + r"car" + DELIM: "🚗", DELIM + r"(red_car|oncoming_automobile)" + DELIM: "🚘", DELIM + r"blue_car" + DELIM: "🚙", DELIM + r"pickup_truck" + DELIM: "🛻", DELIM + r"truck" + DELIM: "🚚", DELIM + r"articulated_lorry" + DELIM: "🚛", DELIM + r"tractor" + DELIM: "🚜", DELIM + r"racing_car" + DELIM: "��", DELIM + r"motorcycle" + DELIM: "��", DELIM + r"motor_scooter" + DELIM: "🛵", DELIM + r"manual_wheelchair" + DELIM: "🦽", DELIM + r"motorized_wheelchair" + DELIM: "🦼", DELIM + r"auto_rickshaw" + DELIM: "🛺", DELIM + r"bike" + DELIM: "🚲", DELIM + r"kick_scooter" + DELIM: "🛴", DELIM + r"skateboard" + DELIM: "🛹", DELIM + r"roller_skate" + DELIM: "🛼", DELIM + r"busstop" + DELIM: "�", DELIM + r"motorway" + DELIM: "🛣�", DELIM + r"railway_track" + DELIM: "🛤�", DELIM + r"oil_drum" + DELIM: "🛢�", DELIM + r"fuelpump" + DELIM: "⛽", DELIM + r"rotating_light" + DELIM: "🚨", DELIM + r"traffic_light" + DELIM: "🚥", DELIM + r"vertical_traffic_light" + DELIM: "🚦", DELIM + r"stop_sign" + DELIM: "🛑", DELIM + r"construction" + DELIM: "🚧", # # Transport Water # DELIM + r"anchor" + DELIM: "⚓", DELIM + r"(sailboat|boat)" + DELIM: "⛵", DELIM + r"canoe" + DELIM: "🛶", DELIM + r"speedboat" + DELIM: "🚤", DELIM + r"passenger_ship" + DELIM: "🛳�", DELIM + r"ferry" + DELIM: "⛴�", DELIM + r"motor_boat" + DELIM: "🛥�", DELIM + r"ship" + DELIM: "🚢", # # Transport Air # DELIM + r"airplane" + DELIM: "✈�", DELIM + r"small_airplane" + DELIM: "🛩�", DELIM + r"flight_departure" + DELIM: "🛫", DELIM + r"flight_arrival" + DELIM: "🛬", DELIM + r"parachute" + DELIM: "🪂", DELIM + r"seat" + DELIM: "💺", DELIM + r"helicopter" + DELIM: "�", DELIM + r"suspension_railway" + DELIM: "🚟", DELIM + r"mountain_cableway" + DELIM: "🚠", DELIM + r"aerial_tramway" + DELIM: "🚡", DELIM + r"artificial_satellite" + DELIM: "🛰�", DELIM + r"rocket" + DELIM: "🚀", DELIM + r"flying_saucer" + DELIM: "🛸", # # Hotel # DELIM + r"bellhop_bell" + DELIM: "🛎�", DELIM + r"luggage" + DELIM: "🧳", # # Time # DELIM + r"hourglass" + DELIM: "⌛", DELIM + r"hourglass_flowing_sand" + DELIM: "�", DELIM + r"watch" + DELIM: "⌚", DELIM + r"alarm_clock" + DELIM: "�", DELIM + r"stopwatch" + DELIM: "��", DELIM + r"timer_clock" + DELIM: "��", DELIM + r"mantelpiece_clock" + DELIM: "🕰�", DELIM + r"clock12" + DELIM: "🕛", DELIM + r"clock1230" + DELIM: "🕧", DELIM + r"clock1" + DELIM: "�", DELIM + r"clock130" + DELIM: "🕜", DELIM + r"clock2" + DELIM: "🕑", DELIM + r"clock230" + DELIM: "�", DELIM + r"clock3" + DELIM: "🕒", DELIM + r"clock330" + DELIM: "🕞", DELIM + r"clock4" + DELIM: "🕓", DELIM + r"clock430" + DELIM: "🕟", DELIM + r"clock5" + DELIM: "🕔", DELIM + r"clock530" + DELIM: "🕠", DELIM + r"clock6" + DELIM: "🕕", DELIM + r"clock630" + DELIM: "🕡", DELIM + r"clock7" + DELIM: "🕖", DELIM + r"clock730" + DELIM: "🕢", DELIM + r"clock8" + DELIM: "🕗", DELIM + r"clock830" + DELIM: "🕣", DELIM + r"clock9" + DELIM: "🕘", DELIM + r"clock930" + DELIM: "🕤", DELIM + r"clock10" + DELIM: "🕙", DELIM + r"clock1030" + DELIM: "🕥", DELIM + r"clock11" + DELIM: "🕚", DELIM + r"clock1130" + DELIM: "🕦", # Sky & Weather DELIM + r"new_moon" + DELIM: "🌑", DELIM + r"waxing_crescent_moon" + DELIM: "🌒", DELIM + r"first_quarter_moon" + DELIM: "🌓", DELIM + r"moon" + DELIM: "🌔", DELIM + r"(waxing_gibbous_moon|full_moon)" + DELIM: "🌕", DELIM + r"waning_gibbous_moon" + DELIM: "🌖", DELIM + r"last_quarter_moon" + DELIM: "🌗", DELIM + r"waning_crescent_moon" + DELIM: "🌘", DELIM + r"crescent_moon" + DELIM: "🌙", DELIM + r"new_moon_with_face" + DELIM: "🌚", DELIM + r"first_quarter_moon_with_face" + DELIM: "🌛", DELIM + r"last_quarter_moon_with_face" + DELIM: "🌜", DELIM + r"thermometer" + DELIM: "🌡�", DELIM + r"sunny" + DELIM: "☀�", DELIM + r"full_moon_with_face" + DELIM: "�", DELIM + r"sun_with_face" + DELIM: "🌞", DELIM + r"ringed_planet" + DELIM: "�", DELIM + r"star" + DELIM: "�", DELIM + r"star2" + DELIM: "🌟", DELIM + r"stars" + DELIM: "🌠", DELIM + r"milky_way" + DELIM: "🌌", DELIM + r"cloud" + DELIM: "��", DELIM + r"partly_sunny" + DELIM: "⛅", DELIM + r"cloud_with_lightning_and_rain" + DELIM: "⛈�", DELIM + r"sun_behind_small_cloud" + DELIM: "🌤�", DELIM + r"sun_behind_large_cloud" + DELIM: "🌥�", DELIM + r"sun_behind_rain_cloud" + DELIM: "🌦�", DELIM + r"cloud_with_rain" + DELIM: "🌧�", DELIM + r"cloud_with_snow" + DELIM: "🌨�", DELIM + r"cloud_with_lightning" + DELIM: "🌩�", DELIM + r"tornado" + DELIM: "🌪�", DELIM + r"fog" + DELIM: "🌫�", DELIM + r"wind_face" + DELIM: "🌬�", DELIM + r"cyclone" + DELIM: "🌀", DELIM + r"rainbow" + DELIM: "🌈", DELIM + r"closed_umbrella" + DELIM: "🌂", DELIM + r"open_umbrella" + DELIM: "☂�", DELIM + r"umbrella" + DELIM: "☔", DELIM + r"parasol_on_ground" + DELIM: "⛱�", DELIM + r"zap" + DELIM: "⚡", DELIM + r"snowflake" + DELIM: "��", DELIM + r"snowman_with_snow" + DELIM: "☃�", DELIM + r"snowman" + DELIM: "⛄", DELIM + r"comet" + DELIM: "☄�", DELIM + r"fire" + DELIM: "🔥", DELIM + r"droplet" + DELIM: "💧", DELIM + r"ocean" + DELIM: "🌊", # # Event # DELIM + r"jack_o_lantern" + DELIM: "🎃", DELIM + r"christmas_tree" + DELIM: "🎄", DELIM + r"fireworks" + DELIM: "🎆", DELIM + r"sparkler" + DELIM: "🎇", DELIM + r"firecracker" + DELIM: "🧨", DELIM + r"sparkles" + DELIM: "✨", DELIM + r"balloon" + DELIM: "🎈", DELIM + r"tada" + DELIM: "🎉", DELIM + r"confetti_ball" + DELIM: "🎊", DELIM + r"tanabata_tree" + DELIM: "🎋", DELIM + r"bamboo" + DELIM: "�", DELIM + r"dolls" + DELIM: "🎎", DELIM + r"flags" + DELIM: "�", DELIM + r"wind_chime" + DELIM: "�", DELIM + r"rice_scene" + DELIM: "🎑", DELIM + r"red_envelope" + DELIM: "🧧", DELIM + r"ribbon" + DELIM: "🎀", DELIM + r"gift" + DELIM: "�", DELIM + r"reminder_ribbon" + DELIM: "🎗�", DELIM + r"tickets" + DELIM: "🎟�", DELIM + r"ticket" + DELIM: "🎫", # # Award Medal # DELIM + r"medal_military" + DELIM: "🎖�", DELIM + r"trophy" + DELIM: "�", DELIM + r"medal_sports" + DELIM: "�", DELIM + r"1st_place_medal" + DELIM: "🥇", DELIM + r"2nd_place_medal" + DELIM: "🥈", DELIM + r"3rd_place_medal" + DELIM: "🥉", # # Sport # DELIM + r"soccer" + DELIM: "⚽", DELIM + r"baseball" + DELIM: "⚾", DELIM + r"softball" + DELIM: "🥎", DELIM + r"basketball" + DELIM: "�", DELIM + r"volleyball" + DELIM: "�", DELIM + r"football" + DELIM: "�", DELIM + r"rugby_football" + DELIM: "�", DELIM + r"tennis" + DELIM: "🎾", DELIM + r"flying_disc" + DELIM: "�", DELIM + r"bowling" + DELIM: "🎳", DELIM + r"cricket_game" + DELIM: "�", DELIM + r"field_hockey" + DELIM: "�", DELIM + r"ice_hockey" + DELIM: "�", DELIM + r"lacrosse" + DELIM: "�", DELIM + r"ping_pong" + DELIM: "�", DELIM + r"badminton" + DELIM: "�", DELIM + r"boxing_glove" + DELIM: "🥊", DELIM + r"martial_arts_uniform" + DELIM: "🥋", DELIM + r"goal_net" + DELIM: "🥅", DELIM + r"golf" + DELIM: "⛳", DELIM + r"ice_skate" + DELIM: "⛸�", DELIM + r"fishing_pole_and_fish" + DELIM: "🎣", DELIM + r"diving_mask" + DELIM: "🤿", DELIM + r"running_shirt_with_sash" + DELIM: "🎽", DELIM + r"ski" + DELIM: "🎿", DELIM + r"sled" + DELIM: "🛷", DELIM + r"curling_stone" + DELIM: "🥌", # # Game # DELIM + r"dart" + DELIM: "🎯", DELIM + r"yo_yo" + DELIM: "🪀", DELIM + r"kite" + DELIM: "�", DELIM + r"gun" + DELIM: "🔫", DELIM + r"8ball" + DELIM: "🎱", DELIM + r"crystal_ball" + DELIM: "🔮", DELIM + r"magic_wand" + DELIM: "🪄", DELIM + r"video_game" + DELIM: "🎮", DELIM + r"joystick" + DELIM: "🕹�", DELIM + r"slot_machine" + DELIM: "🎰", DELIM + r"game_die" + DELIM: "🎲", DELIM + r"jigsaw" + DELIM: "🧩", DELIM + r"teddy_bear" + DELIM: "🧸", DELIM + r"pinata" + DELIM: "🪅", DELIM + r"nesting_dolls" + DELIM: "🪆", DELIM + r"spades" + DELIM: "♠�", DELIM + r"hearts" + DELIM: "♥�", DELIM + r"diamonds" + DELIM: "♦�", DELIM + r"clubs" + DELIM: "♣�", DELIM + r"chess_pawn" + DELIM: "♟�", DELIM + r"black_joker" + DELIM: "�", DELIM + r"mahjong" + DELIM: "🀄", DELIM + r"flower_playing_cards" + DELIM: "🎴", # # Arts & Crafts # DELIM + r"performing_arts" + DELIM: "🎭", DELIM + r"framed_picture" + DELIM: "🖼�", DELIM + r"art" + DELIM: "🎨", DELIM + r"thread" + DELIM: "🧵", DELIM + r"sewing_needle" + DELIM: "🪡", DELIM + r"yarn" + DELIM: "🧶", DELIM + r"knot" + DELIM: "🪢", # # Clothing # DELIM + r"eyeglasses" + DELIM: "👓", DELIM + r"dark_sunglasses" + DELIM: "🕶�", DELIM + r"goggles" + DELIM: "🥽", DELIM + r"lab_coat" + DELIM: "🥼", DELIM + r"safety_vest" + DELIM: "🦺", DELIM + r"necktie" + DELIM: "👔", DELIM + r"t?shirt" + DELIM: "👕", DELIM + r"jeans" + DELIM: "👖", DELIM + r"scarf" + DELIM: "🧣", DELIM + r"gloves" + DELIM: "🧤", DELIM + r"coat" + DELIM: "🧥", DELIM + r"socks" + DELIM: "🧦", DELIM + r"dress" + DELIM: "👗", DELIM + r"kimono" + DELIM: "👘", DELIM + r"sari" + DELIM: "🥻", DELIM + r"one_piece_swimsuit" + DELIM: "🩱", DELIM + r"swim_brief" + DELIM: "🩲", DELIM + r"shorts" + DELIM: "🩳", DELIM + r"bikini" + DELIM: "👙", DELIM + r"womans_clothes" + DELIM: "👚", DELIM + r"purse" + DELIM: "👛", DELIM + r"handbag" + DELIM: "👜", DELIM + r"pouch" + DELIM: "�", DELIM + r"shopping" + DELIM: "��", DELIM + r"school_satchel" + DELIM: "🎒", DELIM + r"thong_sandal" + DELIM: "🩴", DELIM + r"(mans_)?shoe" + DELIM: "👞", DELIM + r"athletic_shoe" + DELIM: "👟", DELIM + r"hiking_boot" + DELIM: "🥾", DELIM + r"flat_shoe" + DELIM: "🥿", DELIM + r"high_heel" + DELIM: "👠", DELIM + r"sandal" + DELIM: "👡", DELIM + r"ballet_shoes" + DELIM: "🩰", DELIM + r"boot" + DELIM: "👢", DELIM + r"crown" + DELIM: "👑", DELIM + r"womans_hat" + DELIM: "👒", DELIM + r"tophat" + DELIM: "🎩", DELIM + r"mortar_board" + DELIM: "🎓", DELIM + r"billed_cap" + DELIM: "🧢", DELIM + r"military_helmet" + DELIM: "🪖", DELIM + r"rescue_worker_helmet" + DELIM: "⛑�", DELIM + r"prayer_beads" + DELIM: "📿", DELIM + r"lipstick" + DELIM: "💄", DELIM + r"ring" + DELIM: "�", DELIM + r"gem" + DELIM: "💎", # # Sound # DELIM + r"mute" + DELIM: "🔇", DELIM + r"speaker" + DELIM: "🔈", DELIM + r"sound" + DELIM: "🔉", DELIM + r"loud_sound" + DELIM: "🔊", DELIM + r"loudspeaker" + DELIM: "📢", DELIM + r"mega" + DELIM: "📣", DELIM + r"postal_horn" + DELIM: "📯", DELIM + r"bell" + DELIM: "🔔", DELIM + r"no_bell" + DELIM: "🔕", # # Music # DELIM + r"musical_score" + DELIM: "🎼", DELIM + r"musical_note" + DELIM: "🎵", DELIM + r"notes" + DELIM: "🎶", DELIM + r"studio_microphone" + DELIM: "🎙�", DELIM + r"level_slider" + DELIM: "🎚�", DELIM + r"control_knobs" + DELIM: "🎛�", DELIM + r"microphone" + DELIM: "🎤", DELIM + r"headphones" + DELIM: "🎧", DELIM + r"radio" + DELIM: "📻", # # Musical Instrument # DELIM + r"saxophone" + DELIM: "🎷", DELIM + r"accordion" + DELIM: "🪗", DELIM + r"guitar" + DELIM: "🎸", DELIM + r"musical_keyboard" + DELIM: "🎹", DELIM + r"trumpet" + DELIM: "🎺", DELIM + r"violin" + DELIM: "🎻", DELIM + r"banjo" + DELIM: "🪕", DELIM + r"drum" + DELIM: "�", DELIM + r"long_drum" + DELIM: "🪘", # # Phone # DELIM + r"iphone" + DELIM: "📱", DELIM + r"calling" + DELIM: "📲", DELIM + r"phone" + DELIM: "☎�", DELIM + r"telephone(_receiver)?" + DELIM: "📞", DELIM + r"pager" + DELIM: "📟", DELIM + r"fax" + DELIM: "📠", # # Computer # DELIM + r"battery" + DELIM: "🔋", DELIM + r"electric_plug" + DELIM: "🔌", DELIM + r"computer" + DELIM: "💻", DELIM + r"desktop_computer" + DELIM: "🖥�", DELIM + r"printer" + DELIM: "🖨�", DELIM + r"keyboard" + DELIM: "⌨�", DELIM + r"computer_mouse" + DELIM: "🖱�", DELIM + r"trackball" + DELIM: "🖲�", DELIM + r"minidisc" + DELIM: "💽", DELIM + r"floppy_disk" + DELIM: "💾", DELIM + r"cd" + DELIM: "💿", DELIM + r"dvd" + DELIM: "📀", DELIM + r"abacus" + DELIM: "🧮", # # Light & Video # DELIM + r"movie_camera" + DELIM: "🎥", DELIM + r"film_strip" + DELIM: "🎞�", DELIM + r"film_projector" + DELIM: "📽�", DELIM + r"clapper" + DELIM: "🎬", DELIM + r"tv" + DELIM: "📺", DELIM + r"camera" + DELIM: "📷", DELIM + r"camera_flash" + DELIM: "📸", DELIM + r"video_camera" + DELIM: "📹", DELIM + r"vhs" + DELIM: "📼", DELIM + r"mag" + DELIM: "�", DELIM + r"mag_right" + DELIM: "🔎", DELIM + r"candle" + DELIM: "🕯�", DELIM + r"bulb" + DELIM: "💡", DELIM + r"flashlight" + DELIM: "🔦", DELIM + r"(izakaya_)?lantern" + DELIM: "�", DELIM + r"diya_lamp" + DELIM: "🪔", # # Book Paper # DELIM + r"notebook_with_decorative_cover" + DELIM: "📔", DELIM + r"closed_book" + DELIM: "📕", DELIM + r"(open_)?book" + DELIM: "📖", DELIM + r"green_book" + DELIM: "📗", DELIM + r"blue_book" + DELIM: "📘", DELIM + r"orange_book" + DELIM: "📙", DELIM + r"books" + DELIM: "📚", DELIM + r"notebook" + DELIM: "📓", DELIM + r"ledger" + DELIM: "📒", DELIM + r"page_with_curl" + DELIM: "📃", DELIM + r"scroll" + DELIM: "📜", DELIM + r"page_facing_up" + DELIM: "📄", DELIM + r"newspaper" + DELIM: "📰", DELIM + r"newspaper_roll" + DELIM: "🗞�", DELIM + r"bookmark_tabs" + DELIM: "📑", DELIM + r"bookmark" + DELIM: "🔖", DELIM + r"label" + DELIM: "��", # # Money # DELIM + r"moneybag" + DELIM: "💰", DELIM + r"coin" + DELIM: "🪙", DELIM + r"yen" + DELIM: "💴", DELIM + r"dollar" + DELIM: "💵", DELIM + r"euro" + DELIM: "💶", DELIM + r"pound" + DELIM: "💷", DELIM + r"money_with_wings" + DELIM: "💸", DELIM + r"credit_card" + DELIM: "💳", DELIM + r"receipt" + DELIM: "🧾", DELIM + r"chart" + DELIM: "💹", # # Mail # DELIM + r"envelope" + DELIM: "✉�", DELIM + r"e-?mail" + DELIM: "📧", DELIM + r"incoming_envelope" + DELIM: "📨", DELIM + r"envelope_with_arrow" + DELIM: "📩", DELIM + r"outbox_tray" + DELIM: "📤", DELIM + r"inbox_tray" + DELIM: "📥", DELIM + r"package" + DELIM: "📦", DELIM + r"mailbox" + DELIM: "📫", DELIM + r"mailbox_closed" + DELIM: "📪", DELIM + r"mailbox_with_mail" + DELIM: "📬", DELIM + r"mailbox_with_no_mail" + DELIM: "📭", DELIM + r"postbox" + DELIM: "📮", DELIM + r"ballot_box" + DELIM: "🗳�", # # Writing # DELIM + r"pencil2" + DELIM: "��", DELIM + r"black_nib" + DELIM: "✒�", DELIM + r"fountain_pen" + DELIM: "🖋�", DELIM + r"pen" + DELIM: "🖊�", DELIM + r"paintbrush" + DELIM: "🖌�", DELIM + r"crayon" + DELIM: "��", DELIM + r"(memo|pencil)" + DELIM: "�", # # Office # DELIM + r"briefcase" + DELIM: "💼", DELIM + r"file_folder" + DELIM: "�", DELIM + r"open_file_folder" + DELIM: "📂", DELIM + r"card_index_dividers" + DELIM: "🗂�", DELIM + r"date" + DELIM: "📅", DELIM + r"calendar" + DELIM: "📆", DELIM + r"spiral_notepad" + DELIM: "🗒�", DELIM + r"spiral_calendar" + DELIM: "🗓�", DELIM + r"card_index" + DELIM: "📇", DELIM + r"chart_with_upwards_trend" + DELIM: "📈", DELIM + r"chart_with_downwards_trend" + DELIM: "📉", DELIM + r"bar_chart" + DELIM: "📊", DELIM + r"clipboard" + DELIM: "📋", DELIM + r"pushpin" + DELIM: "📌", DELIM + r"round_pushpin" + DELIM: "�", DELIM + r"paperclip" + DELIM: "📎", DELIM + r"paperclips" + DELIM: "🖇�", DELIM + r"straight_ruler" + DELIM: "�", DELIM + r"triangular_ruler" + DELIM: "�", DELIM + r"scissors" + DELIM: "✂�", DELIM + r"card_file_box" + DELIM: "🗃�", DELIM + r"file_cabinet" + DELIM: "🗄�", DELIM + r"wastebasket" + DELIM: "🗑�", # # Lock # DELIM + r"lock" + DELIM: "🔒", DELIM + r"unlock" + DELIM: "🔓", DELIM + r"lock_with_ink_pen" + DELIM: "�", DELIM + r"closed_lock_with_key" + DELIM: "�", DELIM + r"key" + DELIM: "🔑", DELIM + r"old_key" + DELIM: "��", # # Tool # DELIM + r"hammer" + DELIM: "🔨", DELIM + r"axe" + DELIM: "🪓", DELIM + r"pick" + DELIM: "��", DELIM + r"hammer_and_pick" + DELIM: "⚒�", DELIM + r"hammer_and_wrench" + DELIM: "🛠�", DELIM + r"dagger" + DELIM: "🗡�", DELIM + r"crossed_swords" + DELIM: "⚔�", DELIM + r"bomb" + DELIM: "💣", DELIM + r"boomerang" + DELIM: "🪃", DELIM + r"bow_and_arrow" + DELIM: "�", DELIM + r"shield" + DELIM: "🛡�", DELIM + r"carpentry_saw" + DELIM: "🪚", DELIM + r"wrench" + DELIM: "🔧", DELIM + r"screwdriver" + DELIM: "🪛", DELIM + r"nut_and_bolt" + DELIM: "🔩", DELIM + r"gear" + DELIM: "⚙�", DELIM + r"clamp" + DELIM: "🗜�", DELIM + r"balance_scale" + DELIM: "⚖�", DELIM + r"probing_cane" + DELIM: "🦯", DELIM + r"link" + DELIM: "🔗", DELIM + r"chains" + DELIM: "⛓�", DELIM + r"hook" + DELIM: "�", DELIM + r"toolbox" + DELIM: "🧰", DELIM + r"magnet" + DELIM: "🧲", DELIM + r"ladder" + DELIM: "🪜", # # Science # DELIM + r"alembic" + DELIM: "⚗�", DELIM + r"test_tube" + DELIM: "🧪", DELIM + r"petri_dish" + DELIM: "🧫", DELIM + r"dna" + DELIM: "🧬", DELIM + r"microscope" + DELIM: "🔬", DELIM + r"telescope" + DELIM: "🔭", DELIM + r"satellite" + DELIM: "📡", # # Medical # DELIM + r"syringe" + DELIM: "💉", DELIM + r"drop_of_blood" + DELIM: "🩸", DELIM + r"pill" + DELIM: "💊", DELIM + r"adhesive_bandage" + DELIM: "🩹", DELIM + r"stethoscope" + DELIM: "🩺", # # Household # DELIM + r"door" + DELIM: "🚪", DELIM + r"elevator" + DELIM: "🛗", DELIM + r"mirror" + DELIM: "🪞", DELIM + r"window" + DELIM: "🪟", DELIM + r"bed" + DELIM: "��", DELIM + r"couch_and_lamp" + DELIM: "🛋�", DELIM + r"chair" + DELIM: "🪑", DELIM + r"toilet" + DELIM: "🚽", DELIM + r"plunger" + DELIM: "🪠", DELIM + r"shower" + DELIM: "🚿", DELIM + r"bathtub" + DELIM: "�", DELIM + r"mouse_trap" + DELIM: "🪤", DELIM + r"razor" + DELIM: "🪒", DELIM + r"lotion_bottle" + DELIM: "🧴", DELIM + r"safety_pin" + DELIM: "🧷", DELIM + r"broom" + DELIM: "🧹", DELIM + r"basket" + DELIM: "🧺", DELIM + r"roll_of_paper" + DELIM: "🧻", DELIM + r"bucket" + DELIM: "🪣", DELIM + r"soap" + DELIM: "🧼", DELIM + r"toothbrush" + DELIM: "🪥", DELIM + r"sponge" + DELIM: "🧽", DELIM + r"fire_extinguisher" + DELIM: "🧯", DELIM + r"shopping_cart" + DELIM: "🛒", # # Other Object # DELIM + r"smoking" + DELIM: "🚬", DELIM + r"coffin" + DELIM: "⚰�", DELIM + r"headstone" + DELIM: "🪦", DELIM + r"funeral_urn" + DELIM: "⚱�", DELIM + r"nazar_amulet" + DELIM: "🧿", DELIM + r"moyai" + DELIM: "🗿", DELIM + r"placard" + DELIM: "🪧", # # Transport Sign # DELIM + r"atm" + DELIM: "�", DELIM + r"put_litter_in_its_place" + DELIM: "🚮", DELIM + r"potable_water" + DELIM: "🚰", DELIM + r"wheelchair" + DELIM: "♿", DELIM + r"mens" + DELIM: "🚹", DELIM + r"womens" + DELIM: "🚺", DELIM + r"restroom" + DELIM: "🚻", DELIM + r"baby_symbol" + DELIM: "🚼", DELIM + r"wc" + DELIM: "🚾", DELIM + r"passport_control" + DELIM: "🛂", DELIM + r"customs" + DELIM: "🛃", DELIM + r"baggage_claim" + DELIM: "🛄", DELIM + r"left_luggage" + DELIM: "🛅", # # Warning # DELIM + r"warning" + DELIM: "⚠�", DELIM + r"children_crossing" + DELIM: "🚸", DELIM + r"no_entry" + DELIM: "⛔", DELIM + r"no_entry_sign" + DELIM: "🚫", DELIM + r"no_bicycles" + DELIM: "🚳", DELIM + r"no_smoking" + DELIM: "🚭", DELIM + r"do_not_litter" + DELIM: "🚯", DELIM + r"non-potable_water" + DELIM: "🚱", DELIM + r"no_pedestrians" + DELIM: "🚷", DELIM + r"no_mobile_phones" + DELIM: "📵", DELIM + r"underage" + DELIM: "🔞", DELIM + r"radioactive" + DELIM: "☢�", DELIM + r"biohazard" + DELIM: "☣�", # # Arrow # DELIM + r"arrow_up" + DELIM: "⬆�", DELIM + r"arrow_upper_right" + DELIM: "↗�", DELIM + r"arrow_right" + DELIM: "➡�", DELIM + r"arrow_lower_right" + DELIM: "↘�", DELIM + r"arrow_down" + DELIM: "⬇�", DELIM + r"arrow_lower_left" + DELIM: "↙�", DELIM + r"arrow_left" + DELIM: "⬅�", DELIM + r"arrow_upper_left" + DELIM: "↖�", DELIM + r"arrow_up_down" + DELIM: "↕�", DELIM + r"left_right_arrow" + DELIM: "↔�", DELIM + r"leftwards_arrow_with_hook" + DELIM: "↩�", DELIM + r"arrow_right_hook" + DELIM: "↪�", DELIM + r"arrow_heading_up" + DELIM: "⤴�", DELIM + r"arrow_heading_down" + DELIM: "⤵�", DELIM + r"arrows_clockwise" + DELIM: "🔃", DELIM + r"arrows_counterclockwise" + DELIM: "🔄", DELIM + r"back" + DELIM: "🔙", DELIM + r"end" + DELIM: "🔚", DELIM + r"on" + DELIM: "🔛", DELIM + r"soon" + DELIM: "🔜", DELIM + r"top" + DELIM: "�", # # Religion # DELIM + r"place_of_worship" + DELIM: "�", DELIM + r"atom_symbol" + DELIM: "⚛�", DELIM + r"om" + DELIM: "🕉�", DELIM + r"star_of_david" + DELIM: "✡�", DELIM + r"wheel_of_dharma" + DELIM: "☸�", DELIM + r"yin_yang" + DELIM: "☯�", DELIM + r"latin_cross" + DELIM: "��", DELIM + r"orthodox_cross" + DELIM: "☦�", DELIM + r"star_and_crescent" + DELIM: "☪�", DELIM + r"peace_symbol" + DELIM: "☮�", DELIM + r"menorah" + DELIM: "🕎", DELIM + r"six_pointed_star" + DELIM: "🔯", # # Zodiac # DELIM + r"aries" + DELIM: "♈", DELIM + r"taurus" + DELIM: "♉", DELIM + r"gemini" + DELIM: "♊", DELIM + r"cancer" + DELIM: "♋", DELIM + r"leo" + DELIM: "♌", DELIM + r"virgo" + DELIM: "�", DELIM + r"libra" + DELIM: "♎", DELIM + r"scorpius" + DELIM: "�", DELIM + r"sagittarius" + DELIM: "�", DELIM + r"capricorn" + DELIM: "♑", DELIM + r"aquarius" + DELIM: "♒", DELIM + r"pisces" + DELIM: "♓", DELIM + r"ophiuchus" + DELIM: "⛎", # # Av Symbol # DELIM + r"twisted_rightwards_arrows" + DELIM: "🔀", DELIM + r"repeat" + DELIM: "�", DELIM + r"repeat_one" + DELIM: "🔂", DELIM + r"arrow_forward" + DELIM: "▶�", DELIM + r"fast_forward" + DELIM: "�", DELIM + r"next_track_button" + DELIM: "��", DELIM + r"play_or_pause_button" + DELIM: "��", DELIM + r"arrow_backward" + DELIM: "◀�", DELIM + r"rewind" + DELIM: "�", DELIM + r"previous_track_button" + DELIM: "��", DELIM + r"arrow_up_small" + DELIM: "🔼", DELIM + r"arrow_double_up" + DELIM: "�", DELIM + r"arrow_down_small" + DELIM: "🔽", DELIM + r"arrow_double_down" + DELIM: "�", DELIM + r"pause_button" + DELIM: "��", DELIM + r"stop_button" + DELIM: "��", DELIM + r"record_button" + DELIM: "��", DELIM + r"eject_button" + DELIM: "��", DELIM + r"cinema" + DELIM: "🎦", DELIM + r"low_brightness" + DELIM: "🔅", DELIM + r"high_brightness" + DELIM: "🔆", DELIM + r"signal_strength" + DELIM: "📶", DELIM + r"vibration_mode" + DELIM: "📳", DELIM + r"mobile_phone_off" + DELIM: "📴", # # Gender # DELIM + r"female_sign" + DELIM: "♀�", DELIM + r"male_sign" + DELIM: "♂�", DELIM + r"transgender_symbol" + DELIM: "⚧�", # # Math # DELIM + r"heavy_multiplication_x" + DELIM: "✖�", DELIM + r"heavy_plus_sign" + DELIM: "➕", # noqa: RUF001 DELIM + r"heavy_minus_sign" + DELIM: "➖", # noqa: RUF001 DELIM + r"heavy_division_sign" + DELIM: "➗", DELIM + r"infinity" + DELIM: "♾�", # # Punctuation # DELIM + r"bangbang" + DELIM: "‼�", DELIM + r"interrobang" + DELIM: "��", DELIM + r"question" + DELIM: "�", DELIM + r"grey_question" + DELIM: "�", DELIM + r"grey_exclamation" + DELIM: "�", DELIM + r"(heavy_exclamation_mark|exclamation)" + DELIM: "�", DELIM + r"wavy_dash" + DELIM: "〰�", # # Currency # DELIM + r"currency_exchange" + DELIM: "💱", DELIM + r"heavy_dollar_sign" + DELIM: "💲", # # Other Symbol # DELIM + r"medical_symbol" + DELIM: "⚕�", DELIM + r"recycle" + DELIM: "♻�", DELIM + r"fleur_de_lis" + DELIM: "⚜�", DELIM + r"trident" + DELIM: "🔱", DELIM + r"name_badge" + DELIM: "📛", DELIM + r"beginner" + DELIM: "🔰", DELIM + r"o" + DELIM: "⭕", DELIM + r"white_check_mark" + DELIM: "✅", DELIM + r"ballot_box_with_check" + DELIM: "☑�", DELIM + r"heavy_check_mark" + DELIM: "✔�", DELIM + r"x" + DELIM: "�", DELIM + r"negative_squared_cross_mark" + DELIM: "�", DELIM + r"curly_loop" + DELIM: "➰", DELIM + r"loop" + DELIM: "➿", DELIM + r"part_alternation_mark" + DELIM: "〽�", DELIM + r"eight_spoked_asterisk" + DELIM: "✳�", DELIM + r"eight_pointed_black_star" + DELIM: "✴�", DELIM + r"sparkle" + DELIM: "��", DELIM + r"copyright" + DELIM: "©�", DELIM + r"registered" + DELIM: "®�", DELIM + r"tm" + DELIM: "™�", # # Keycap # DELIM + r"hash" + DELIM: "#�⃣", DELIM + r"asterisk" + DELIM: "*�⃣", DELIM + r"zero" + DELIM: "0�⃣", DELIM + r"one" + DELIM: "1�⃣", DELIM + r"two" + DELIM: "2�⃣", DELIM + r"three" + DELIM: "3�⃣", DELIM + r"four" + DELIM: "4�⃣", DELIM + r"five" + DELIM: "5�⃣", DELIM + r"six" + DELIM: "6�⃣", DELIM + r"seven" + DELIM: "7�⃣", DELIM + r"eight" + DELIM: "8�⃣", DELIM + r"nine" + DELIM: "9�⃣", DELIM + r"keycap_ten" + DELIM: "🔟", # # Alphanum # DELIM + r"capital_abcd" + DELIM: "🔠", DELIM + r"abcd" + DELIM: "🔡", DELIM + r"1234" + DELIM: "🔢", DELIM + r"symbols" + DELIM: "🔣", DELIM + r"abc" + DELIM: "🔤", DELIM + r"a" + DELIM: "🅰�", DELIM + r"ab" + DELIM: "🆎", DELIM + r"b" + DELIM: "🅱�", DELIM + r"cl" + DELIM: "🆑", DELIM + r"cool" + DELIM: "🆒", DELIM + r"free" + DELIM: "🆓", DELIM + r"information_source" + DELIM: "ℹ�", # noqa: RUF001 DELIM + r"id" + DELIM: "🆔", DELIM + r"m" + DELIM: "Ⓜ�", DELIM + r"new" + DELIM: "🆕", DELIM + r"ng" + DELIM: "🆖", DELIM + r"o2" + DELIM: "🅾�", DELIM + r"ok" + DELIM: "🆗", DELIM + r"parking" + DELIM: "🅿�", DELIM + r"sos" + DELIM: "🆘", DELIM + r"up" + DELIM: "🆙", DELIM + r"vs" + DELIM: "🆚", DELIM + r"koko" + DELIM: "�", DELIM + r"sa" + DELIM: "🈂�", DELIM + r"u6708" + DELIM: "🈷�", DELIM + r"u6709" + DELIM: "🈶", DELIM + r"u6307" + DELIM: "🈯", DELIM + r"ideograph_advantage" + DELIM: "�", DELIM + r"u5272" + DELIM: "🈹", DELIM + r"u7121" + DELIM: "🈚", DELIM + r"u7981" + DELIM: "🈲", DELIM + r"accept" + DELIM: "🉑", DELIM + r"u7533" + DELIM: "🈸", DELIM + r"u5408" + DELIM: "🈴", DELIM + r"u7a7a" + DELIM: "🈳", DELIM + r"congratulations" + DELIM: "㊗�", DELIM + r"secret" + DELIM: "㊙�", DELIM + r"u55b6" + DELIM: "🈺", DELIM + r"u6e80" + DELIM: "🈵", # # Geometric # DELIM + r"red_circle" + DELIM: "🔴", DELIM + r"orange_circle" + DELIM: "🟠", DELIM + r"yellow_circle" + DELIM: "🟡", DELIM + r"green_circle" + DELIM: "🟢", DELIM + r"large_blue_circle" + DELIM: "🔵", DELIM + r"purple_circle" + DELIM: "🟣", DELIM + r"brown_circle" + DELIM: "🟤", DELIM + r"black_circle" + DELIM: "⚫", DELIM + r"white_circle" + DELIM: "⚪", DELIM + r"red_square" + DELIM: "🟥", DELIM + r"orange_square" + DELIM: "🟧", DELIM + r"yellow_square" + DELIM: "🟨", DELIM + r"green_square" + DELIM: "🟩", DELIM + r"blue_square" + DELIM: "🟦", DELIM + r"purple_square" + DELIM: "🟪", DELIM + r"brown_square" + DELIM: "🟫", DELIM + r"black_large_square" + DELIM: "⬛", DELIM + r"white_large_square" + DELIM: "⬜", DELIM + r"black_medium_square" + DELIM: "◼�", DELIM + r"white_medium_square" + DELIM: "◻�", DELIM + r"black_medium_small_square" + DELIM: "◾", DELIM + r"white_medium_small_square" + DELIM: "◽", DELIM + r"black_small_square" + DELIM: "▪�", DELIM + r"white_small_square" + DELIM: "▫�", DELIM + r"large_orange_diamond" + DELIM: "🔶", DELIM + r"large_blue_diamond" + DELIM: "🔷", DELIM + r"small_orange_diamond" + DELIM: "🔸", DELIM + r"small_blue_diamond" + DELIM: "🔹", DELIM + r"small_red_triangle" + DELIM: "🔺", DELIM + r"small_red_triangle_down" + DELIM: "🔻", DELIM + r"diamond_shape_with_a_dot_inside" + DELIM: "💠", DELIM + r"radio_button" + DELIM: "🔘", DELIM + r"white_square_button" + DELIM: "🔳", DELIM + r"black_square_button" + DELIM: "🔲", # # Flag # DELIM + r"checkered_flag" + DELIM: "�", DELIM + r"triangular_flag_on_post" + DELIM: "🚩", DELIM + r"crossed_flags" + DELIM: "🎌", DELIM + r"black_flag" + DELIM: "�", DELIM + r"white_flag" + DELIM: "��", DELIM + r"rainbow_flag" + DELIM: "���🌈", DELIM + r"transgender_flag" + DELIM: "���⚧�", DELIM + r"pirate_flag" + DELIM: "��☠�", # # Country Flag # DELIM + r"ascension_island" + DELIM: "🇦🇨", DELIM + r"andorra" + DELIM: "🇦🇩", DELIM + r"united_arab_emirates" + DELIM: "🇦🇪", DELIM + r"afghanistan" + DELIM: "🇦🇫", DELIM + r"antigua_barbuda" + DELIM: "🇦🇬", DELIM + r"anguilla" + DELIM: "🇦🇮", DELIM + r"albania" + DELIM: "🇦🇱", DELIM + r"armenia" + DELIM: "🇦🇲", DELIM + r"angola" + DELIM: "🇦🇴", DELIM + r"antarctica" + DELIM: "🇦🇶", DELIM + r"argentina" + DELIM: "🇦🇷", DELIM + r"american_samoa" + DELIM: "🇦🇸", DELIM + r"austria" + DELIM: "🇦🇹", DELIM + r"australia" + DELIM: "🇦🇺", DELIM + r"aruba" + DELIM: "🇦🇼", DELIM + r"aland_islands" + DELIM: "🇦🇽", DELIM + r"azerbaijan" + DELIM: "🇦🇿", DELIM + r"bosnia_herzegovina" + DELIM: "🇧🇦", DELIM + r"barbados" + DELIM: "🇧🇧", DELIM + r"bangladesh" + DELIM: "🇧🇩", DELIM + r"belgium" + DELIM: "🇧🇪", DELIM + r"burkina_faso" + DELIM: "🇧🇫", DELIM + r"bulgaria" + DELIM: "🇧🇬", DELIM + r"bahrain" + DELIM: "🇧🇭", DELIM + r"burundi" + DELIM: "🇧🇮", DELIM + r"benin" + DELIM: "🇧🇯", DELIM + r"st_barthelemy" + DELIM: "🇧🇱", DELIM + r"bermuda" + DELIM: "🇧🇲", DELIM + r"brunei" + DELIM: "🇧🇳", DELIM + r"bolivia" + DELIM: "🇧🇴", DELIM + r"caribbean_netherlands" + DELIM: "🇧🇶", DELIM + r"brazil" + DELIM: "🇧🇷", DELIM + r"bahamas" + DELIM: "🇧🇸", DELIM + r"bhutan" + DELIM: "🇧🇹", DELIM + r"bouvet_island" + DELIM: "🇧🇻", DELIM + r"botswana" + DELIM: "🇧🇼", DELIM + r"belarus" + DELIM: "🇧🇾", DELIM + r"belize" + DELIM: "🇧🇿", DELIM + r"canada" + DELIM: "🇨🇦", DELIM + r"cocos_islands" + DELIM: "🇨🇨", DELIM + r"congo_kinshasa" + DELIM: "🇨🇩", DELIM + r"central_african_republic" + DELIM: "🇨🇫", DELIM + r"congo_brazzaville" + DELIM: "🇨🇬", DELIM + r"switzerland" + DELIM: "🇨🇭", DELIM + r"cote_divoire" + DELIM: "🇨🇮", DELIM + r"cook_islands" + DELIM: "🇨🇰", DELIM + r"chile" + DELIM: "🇨🇱", DELIM + r"cameroon" + DELIM: "🇨🇲", DELIM + r"cn" + DELIM: "🇨🇳", DELIM + r"colombia" + DELIM: "🇨🇴", DELIM + r"clipperton_island" + DELIM: "🇨🇵", DELIM + r"costa_rica" + DELIM: "🇨🇷", DELIM + r"cuba" + DELIM: "🇨🇺", DELIM + r"cape_verde" + DELIM: "🇨🇻", DELIM + r"curacao" + DELIM: "🇨🇼", DELIM + r"christmas_island" + DELIM: "🇨🇽", DELIM + r"cyprus" + DELIM: "🇨🇾", DELIM + r"czech_republic" + DELIM: "🇨🇿", DELIM + r"de" + DELIM: "🇩🇪", DELIM + r"diego_garcia" + DELIM: "🇩🇬", DELIM + r"djibouti" + DELIM: "🇩🇯", DELIM + r"denmark" + DELIM: "🇩🇰", DELIM + r"dominica" + DELIM: "🇩🇲", DELIM + r"dominican_republic" + DELIM: "🇩🇴", DELIM + r"algeria" + DELIM: "🇩🇿", DELIM + r"ceuta_melilla" + DELIM: "🇪🇦", DELIM + r"ecuador" + DELIM: "🇪🇨", DELIM + r"estonia" + DELIM: "🇪🇪", DELIM + r"egypt" + DELIM: "🇪🇬", DELIM + r"western_sahara" + DELIM: "🇪🇭", DELIM + r"eritrea" + DELIM: "🇪🇷", DELIM + r"es" + DELIM: "🇪🇸", DELIM + r"ethiopia" + DELIM: "🇪🇹", DELIM + r"(eu|european_union)" + DELIM: "🇪🇺", DELIM + r"finland" + DELIM: "🇫🇮", DELIM + r"fiji" + DELIM: "🇫🇯", DELIM + r"falkland_islands" + DELIM: "🇫🇰", DELIM + r"micronesia" + DELIM: "🇫🇲", DELIM + r"faroe_islands" + DELIM: "🇫🇴", DELIM + r"fr" + DELIM: "🇫🇷", DELIM + r"gabon" + DELIM: "🇬🇦", DELIM + r"(uk|gb)" + DELIM: "🇬🇧", DELIM + r"grenada" + DELIM: "🇬🇩", DELIM + r"georgia" + DELIM: "🇬🇪", DELIM + r"french_guiana" + DELIM: "🇬🇫", DELIM + r"guernsey" + DELIM: "🇬🇬", DELIM + r"ghana" + DELIM: "🇬🇭", DELIM + r"gibraltar" + DELIM: "🇬🇮", DELIM + r"greenland" + DELIM: "🇬🇱", DELIM + r"gambia" + DELIM: "🇬🇲", DELIM + r"guinea" + DELIM: "🇬🇳", DELIM + r"guadeloupe" + DELIM: "🇬🇵", DELIM + r"equatorial_guinea" + DELIM: "🇬🇶", DELIM + r"greece" + DELIM: "🇬🇷", DELIM + r"south_georgia_south_sandwich_islands" + DELIM: "🇬🇸", DELIM + r"guatemala" + DELIM: "🇬🇹", DELIM + r"guam" + DELIM: "🇬🇺", DELIM + r"guinea_bissau" + DELIM: "🇬🇼", DELIM + r"guyana" + DELIM: "🇬🇾", DELIM + r"hong_kong" + DELIM: "🇭🇰", DELIM + r"heard_mcdonald_islands" + DELIM: "🇭🇲", DELIM + r"honduras" + DELIM: "🇭🇳", DELIM + r"croatia" + DELIM: "🇭🇷", DELIM + r"haiti" + DELIM: "🇭🇹", DELIM + r"hungary" + DELIM: "🇭🇺", DELIM + r"canary_islands" + DELIM: "🇮🇨", DELIM + r"indonesia" + DELIM: "🇮🇩", DELIM + r"ireland" + DELIM: "🇮🇪", DELIM + r"israel" + DELIM: "🇮🇱", DELIM + r"isle_of_man" + DELIM: "🇮🇲", DELIM + r"india" + DELIM: "🇮🇳", DELIM + r"british_indian_ocean_territory" + DELIM: "🇮🇴", DELIM + r"iraq" + DELIM: "🇮🇶", DELIM + r"iran" + DELIM: "🇮🇷", DELIM + r"iceland" + DELIM: "🇮🇸", DELIM + r"it" + DELIM: "🇮🇹", DELIM + r"jersey" + DELIM: "🇯🇪", DELIM + r"jamaica" + DELIM: "🇯🇲", DELIM + r"jordan" + DELIM: "🇯🇴", DELIM + r"jp" + DELIM: "🇯🇵", DELIM + r"kenya" + DELIM: "🇰🇪", DELIM + r"kyrgyzstan" + DELIM: "🇰🇬", DELIM + r"cambodia" + DELIM: "🇰🇭", DELIM + r"kiribati" + DELIM: "🇰🇮", DELIM + r"comoros" + DELIM: "🇰🇲", DELIM + r"st_kitts_nevis" + DELIM: "🇰🇳", DELIM + r"north_korea" + DELIM: "🇰🇵", DELIM + r"kr" + DELIM: "🇰🇷", DELIM + r"kuwait" + DELIM: "🇰🇼", DELIM + r"cayman_islands" + DELIM: "🇰🇾", DELIM + r"kazakhstan" + DELIM: "🇰🇿", DELIM + r"laos" + DELIM: "🇱🇦", DELIM + r"lebanon" + DELIM: "🇱🇧", DELIM + r"st_lucia" + DELIM: "🇱🇨", DELIM + r"liechtenstein" + DELIM: "🇱🇮", DELIM + r"sri_lanka" + DELIM: "🇱🇰", DELIM + r"liberia" + DELIM: "🇱🇷", DELIM + r"lesotho" + DELIM: "🇱🇸", DELIM + r"lithuania" + DELIM: "🇱🇹", DELIM + r"luxembourg" + DELIM: "🇱🇺", DELIM + r"latvia" + DELIM: "🇱🇻", DELIM + r"libya" + DELIM: "🇱🇾", DELIM + r"morocco" + DELIM: "🇲🇦", DELIM + r"monaco" + DELIM: "🇲🇨", DELIM + r"moldova" + DELIM: "🇲🇩", DELIM + r"montenegro" + DELIM: "🇲🇪", DELIM + r"st_martin" + DELIM: "🇲🇫", DELIM + r"madagascar" + DELIM: "🇲🇬", DELIM + r"marshall_islands" + DELIM: "🇲🇭", DELIM + r"macedonia" + DELIM: "🇲🇰", DELIM + r"mali" + DELIM: "🇲🇱", DELIM + r"myanmar" + DELIM: "🇲🇲", DELIM + r"mongolia" + DELIM: "🇲🇳", DELIM + r"macau" + DELIM: "🇲🇴", DELIM + r"northern_mariana_islands" + DELIM: "🇲🇵", DELIM + r"martinique" + DELIM: "🇲🇶", DELIM + r"mauritania" + DELIM: "🇲🇷", DELIM + r"montserrat" + DELIM: "🇲🇸", DELIM + r"malta" + DELIM: "🇲🇹", DELIM + r"mauritius" + DELIM: "🇲🇺", DELIM + r"maldives" + DELIM: "🇲🇻", DELIM + r"malawi" + DELIM: "🇲🇼", DELIM + r"mexico" + DELIM: "🇲🇽", DELIM + r"malaysia" + DELIM: "🇲🇾", DELIM + r"mozambique" + DELIM: "🇲🇿", DELIM + r"namibia" + DELIM: "🇳🇦", DELIM + r"new_caledonia" + DELIM: "🇳🇨", DELIM + r"niger" + DELIM: "🇳🇪", DELIM + r"norfolk_island" + DELIM: "🇳🇫", DELIM + r"nigeria" + DELIM: "🇳🇬", DELIM + r"nicaragua" + DELIM: "🇳🇮", DELIM + r"netherlands" + DELIM: "🇳🇱", DELIM + r"norway" + DELIM: "🇳🇴", DELIM + r"nepal" + DELIM: "🇳🇵", DELIM + r"nauru" + DELIM: "🇳🇷", DELIM + r"niue" + DELIM: "🇳🇺", DELIM + r"new_zealand" + DELIM: "🇳🇿", DELIM + r"oman" + DELIM: "🇴🇲", DELIM + r"panama" + DELIM: "🇵🇦", DELIM + r"peru" + DELIM: "🇵🇪", DELIM + r"french_polynesia" + DELIM: "🇵🇫", DELIM + r"papua_new_guinea" + DELIM: "🇵🇬", DELIM + r"philippines" + DELIM: "🇵🇭", DELIM + r"pakistan" + DELIM: "🇵🇰", DELIM + r"poland" + DELIM: "🇵🇱", DELIM + r"st_pierre_miquelon" + DELIM: "🇵🇲", DELIM + r"pitcairn_islands" + DELIM: "🇵🇳", DELIM + r"puerto_rico" + DELIM: "🇵🇷", DELIM + r"palestinian_territories" + DELIM: "🇵🇸", DELIM + r"portugal" + DELIM: "🇵🇹", DELIM + r"palau" + DELIM: "🇵🇼", DELIM + r"paraguay" + DELIM: "🇵🇾", DELIM + r"qatar" + DELIM: "🇶🇦", DELIM + r"reunion" + DELIM: "🇷🇪", DELIM + r"romania" + DELIM: "🇷🇴", DELIM + r"serbia" + DELIM: "🇷🇸", DELIM + r"ru" + DELIM: "🇷🇺", DELIM + r"rwanda" + DELIM: "🇷🇼", DELIM + r"saudi_arabia" + DELIM: "🇸🇦", DELIM + r"solomon_islands" + DELIM: "🇸🇧", DELIM + r"seychelles" + DELIM: "🇸🇨", DELIM + r"sudan" + DELIM: "🇸🇩", DELIM + r"sweden" + DELIM: "🇸🇪", DELIM + r"singapore" + DELIM: "🇸🇬", DELIM + r"st_helena" + DELIM: "🇸🇭", DELIM + r"slovenia" + DELIM: "🇸🇮", DELIM + r"svalbard_jan_mayen" + DELIM: "🇸🇯", DELIM + r"slovakia" + DELIM: "🇸🇰", DELIM + r"sierra_leone" + DELIM: "🇸🇱", DELIM + r"san_marino" + DELIM: "🇸🇲", DELIM + r"senegal" + DELIM: "🇸🇳", DELIM + r"somalia" + DELIM: "🇸🇴", DELIM + r"suriname" + DELIM: "🇸🇷", DELIM + r"south_sudan" + DELIM: "🇸🇸", DELIM + r"sao_tome_principe" + DELIM: "🇸🇹", DELIM + r"el_salvador" + DELIM: "🇸🇻", DELIM + r"sint_maarten" + DELIM: "🇸🇽", DELIM + r"syria" + DELIM: "🇸🇾", DELIM + r"swaziland" + DELIM: "🇸🇿", DELIM + r"tristan_da_cunha" + DELIM: "🇹🇦", DELIM + r"turks_caicos_islands" + DELIM: "🇹🇨", DELIM + r"chad" + DELIM: "🇹🇩", DELIM + r"french_southern_territories" + DELIM: "🇹🇫", DELIM + r"togo" + DELIM: "🇹🇬", DELIM + r"thailand" + DELIM: "🇹🇭", DELIM + r"tajikistan" + DELIM: "🇹🇯", DELIM + r"tokelau" + DELIM: "🇹🇰", DELIM + r"timor_leste" + DELIM: "🇹🇱", DELIM + r"turkmenistan" + DELIM: "🇹🇲", DELIM + r"tunisia" + DELIM: "🇹🇳", DELIM + r"tonga" + DELIM: "🇹🇴", DELIM + r"tr" + DELIM: "🇹🇷", DELIM + r"trinidad_tobago" + DELIM: "🇹🇹", DELIM + r"tuvalu" + DELIM: "🇹🇻", DELIM + r"taiwan" + DELIM: "🇹🇼", DELIM + r"tanzania" + DELIM: "🇹🇿", DELIM + r"ukraine" + DELIM: "🇺🇦", DELIM + r"uganda" + DELIM: "🇺🇬", DELIM + r"us_outlying_islands" + DELIM: "🇺🇲", DELIM + r"united_nations" + DELIM: "🇺🇳", DELIM + r"us" + DELIM: "🇺🇸", DELIM + r"uruguay" + DELIM: "🇺🇾", DELIM + r"uzbekistan" + DELIM: "🇺🇿", DELIM + r"vatican_city" + DELIM: "🇻🇦", DELIM + r"st_vincent_grenadines" + DELIM: "🇻🇨", DELIM + r"venezuela" + DELIM: "🇻🇪", DELIM + r"british_virgin_islands" + DELIM: "🇻🇬", DELIM + r"us_virgin_islands" + DELIM: "🇻🇮", DELIM + r"vietnam" + DELIM: "🇻🇳", DELIM + r"vanuatu" + DELIM: "🇻🇺", DELIM + r"wallis_futuna" + DELIM: "🇼🇫", DELIM + r"samoa" + DELIM: "🇼🇸", DELIM + r"kosovo" + DELIM: "🇽🇰", DELIM + r"yemen" + DELIM: "🇾🇪", DELIM + r"mayotte" + DELIM: "🇾🇹", DELIM + r"south_africa" + DELIM: "🇿🇦", DELIM + r"zambia" + DELIM: "🇿🇲", DELIM + r"zimbabwe" + DELIM: "🇿🇼", # # Subdivision Flag # DELIM + r"england" + DELIM: "�������", DELIM + r"scotland" + DELIM: "�������", DELIM + r"wales" + DELIM: "�������", } # Define our singletons EMOJI_COMPILED_MAP = None # List of (compiled_fullmatch_pattern, emoji) for resolving alternation keys _EMOJI_PATTERN_LIST = None def apply_emojis(content): """Takes the content and swaps any matched emoji's found with their utf-8 encoded mapping.""" global EMOJI_COMPILED_MAP, _EMOJI_PATTERN_LIST if EMOJI_COMPILED_MAP is None: t_start = time.time() # Perform our compilation EMOJI_COMPILED_MAP = re.compile( r"(" + "|".join(EMOJI_MAP.keys()) + r")", re.IGNORECASE ) # - EMOJI_MAP keys are regex patterns (e.g. ":(\+1|thumbsup):") # - x.group() from a match cannot be used as a dict key directly. # # Build a per-entry compiled pattern for fullmatch lookups so # alternation/optional-group keys resolve correctly. _EMOJI_PATTERN_LIST = [ (re.compile(r"(?:" + pat + r")", re.IGNORECASE), emoji) for pat, emoji in EMOJI_MAP.items() ] logger.trace(f"Emoji engine loaded in {time.time() - t_start:.4f}s") def _lookup(m): text = m.group() for pat, emoji in _EMOJI_PATTERN_LIST: if pat.fullmatch(text): return emoji return text try: return EMOJI_COMPILED_MAP.sub(_lookup, content) except TypeError: # No change; but force string return return "" caronc-apprise-dfec414/apprise/exception.py000066400000000000000000000072331526720371000211370ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import errno class AppriseException(Exception): """Base class for exceptions raised by Apprise.""" def __init__(self, message, error_code=0): super().__init__(message) self.error_code = error_code class ApprisePluginException(AppriseException): """Raised when a notification plugin cannot complete an operation.""" def __init__(self, message, error_code=600): super().__init__(message, error_code=error_code) class AppriseImproperlyConfigured( AppriseException, TypeError, ValueError, AttributeError ): """Raised when Apprise receives missing, invalid, or conflicting settings. It remains compatible with code that catches the built-in exceptions historically raised for these errors: ``TypeError``, ``ValueError``, and ``AttributeError``. """ def __init__(self, message, error_code=errno.EINVAL): super().__init__(message, error_code=error_code) class AppriseTemplateError(AppriseImproperlyConfigured): """Raised for undeclared, missing, or invalid template values. ``variable`` is for local logs only because it may reveal configuration details. """ def __init__(self, message, variable=None, error_code=errno.EINVAL): super().__init__(message, error_code=error_code) self.variable = variable class AppriseDiskIOError(AppriseException, OSError): """Raised when a disk I/O operation fails.""" def __init__(self, message, error_code=errno.EIO): super().__init__(message, error_code=error_code) # Expose the standard I/O error code and message for compatibility # with code that catches the built-in OSError. self.errno = error_code self.strerror = message def __str__(self): """Return the original message while exposing standard I/O details.""" return str(self.args[0]) class AppriseInvalidData(AppriseException): """Raised when Apprise cannot safely use supplied or stored data.""" def __init__(self, message, error_code=errno.EINVAL): super().__init__(message, error_code=error_code) class AppriseFileNotFound(AppriseDiskIOError, FileNotFoundError): """Raised when an attachment or stored file cannot be found.""" def __init__(self, message): super().__init__(message, error_code=errno.ENOENT) caronc-apprise-dfec414/apprise/i18n/000077500000000000000000000000001526720371000173415ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/__init__.py000066400000000000000000000000001526720371000214400ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/ar/000077500000000000000000000000001526720371000177435ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/ar/LC_MESSAGES/000077500000000000000000000000001526720371000215305ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/ar/LC_MESSAGES/apprise.po000066400000000000000000002133651526720371000235450ustar00rootroot00000000000000# Arabic translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: ar\n" "Language-Team: ar \n" "Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " "&& n%100<=10 ? 3 : n%100>=11 ? 4 : 5);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "التحقق من SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "تتبع إعادة التوجيه" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "مهلة القراءة من المقبس" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "مهلة الاتصال بالمقبس" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "عمر التخزين المؤقت" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "نوع MIME الم�روض" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "اسم المل� الم�روض" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "مل� محلي" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "على الويب" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "الذاكرة" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "المخطط" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "لا توجد متطلبات." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "يلزم تثبيت حزم لكي يعمل." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "ي�نصح بتثبيت حزم لتحسين الأداء." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "اسم مستخدم التطبيق" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "م�تاح API" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "الهات� المستهد�" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "الأهدا�" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "المرسل" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "وضع الرسائل القصيرة" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "وضع الد�عات" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "اسم المضي�" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "المن�ذ" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "اسم المستخدم" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "كلمة المرور" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "الرمز" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "الوسوم" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "طريقة الاستعلام" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "إصدار واجهة Apprise" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "ترويسة HTTP" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "اسم المستخدم" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "رمز النداء المستهد�" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "تأخير إعادة الإرسال" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "الإعدادات المحلية" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "الجهاز المستهد�" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "الصوت" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "المستوى" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "مستوى الصوت" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "النقر" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "الشارة" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "ال�ئة" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "المجموعة" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "تضمين الصورة" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "رابط الأيقونة" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "المكالمة" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "م�تاح التش�ير" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "وضع التجاوز" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "تنسيق الإشعار" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "ت�سير الرموز التعبيرية" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "تخزين دائم" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "المنطقة الزمنية" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "محاولات إعادة الخدمة" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "الانتظار بين المحاولات" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "خدمة اختيارية" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "بريد المرسل" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "ال�ريق المستهد�" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "سياسة التصعيد" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "طرق التنبيه" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "مهلة انتظار ال�ريق" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "الرقم التسلسلي" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "المدة (مللي ثانية)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "مدة التلاشي (مللي ثانية)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "رقم مصباح LED" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "البريد الإلكتروني المصدر" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "البريد الإلكتروني المستهد�" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "بريد الرد" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "نسخة" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "نسخة مخ�ية" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "السر" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "رقم الهات� المستهد�" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "المجموعة المستهد�ة" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "رقم هات� المرسل" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "مجموعة التوجيه" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "محار� يونيكود" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "سر API" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "معرّ� المرسل" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "البلد" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "الصلاحية" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "رمز API" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "رمز الويب هوك" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "اسم البوت" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "القناة المستهد�ة" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "البوت المستهد�" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "المنطقة" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "معرّ� الشركة" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "بث" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "طريقة الجلب" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "إر�اق المل� باسم" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "إضا�ات الحمولة" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "معاملات GET" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "رمز الوصول إلى API" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "عنوان المصدر" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "الأولوية" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "مجموعات الإرسال" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "يجب تثبيت libdbus-1.so.x." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "إشعار DBus" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "مدى الإلحاح" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "المحور X" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "المحور Y" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "معرّ� الويب هوك" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "تحويل النص إلى كلام" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "صورة الأ�اتار" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "رابط الأ�اتار" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "معرّ� المحادثة" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "إظهار التذييل" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "شعار التذييل" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "استخدام الحقول" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "أعلام Discord" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "الإشارة إلى المستخدمين أو الأدوار" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "مسار القالب" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "تجميع المر�قات" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "رموز القالب" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "الرقم التسلسلي للجهاز" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "وضع API" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "التحديث الآن" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "توقيع النص" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "أيقونة بترميز Base64 (واجهة النص)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "صورة بترميز Base64 (واجهة الصور)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "الرابط" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "الإطار" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "نوع التنقيط" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "نواة التنقيط" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "م�تاح المهمة" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "نا�ذة منبثقة" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "المسار" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "مهلة الخادم" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "اسم النسخة" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "معرّ� SID للحساب" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "رقم هات� المرسل أو معرّ� المرسل" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "اسم المنطقة" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "م�تاح الوصول" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "إلى معرّ� المستخدم" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "إلى معرّ� القناة" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "مسار الويب هوك" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "الوضع" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "اسم المحادثة" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "اسم مستخدم API" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "كلمة مرور API" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "رمز الوصول" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "المالك" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "المستودع" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "النوع" #: apprise/plugins/github.py:222 msgid "Number" msgstr "الرقم" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "التسميات" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "المكلَّ�ون" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "يجب تثبيت libdbus-1.so.x أو libdbus-2.so.x." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "إشعار GLib" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "يلزم وجود بيئة Gnome محلية." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "إشعار Gnome" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "م�اتيح التكامل" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "الإجراء" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "م�تاح إزالة التكرار" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "البيانات الوص�ية" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "مساحة العمل" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "م�تاح الويب هوك" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "م�تاح المحادثة" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "المنصة" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "الموضوع" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "مدخلات البيانات" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "معرّ� البوت" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "الإصدار" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "مثبّت" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "رمز وصول طويل الأمد" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "معرّ� الإشعار" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "بادئة المسار" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "الرمز أو اسم المستخدم" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "الأحداث" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "إضا�ة رموز" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "إزالة رموز" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "م�تاح التكامل" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "المسار" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "معرّ� مصدر التنبيه" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "مستوى التصعيد المستهد�" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "الجدول المستهد�" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "المستخدم المستهد�" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "الأهدا� " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "الكيان" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "الاسم البديل" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "الت�اصيل" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "تعيين الإجراءات" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "معرّ� الجهاز" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "اسم الجهاز" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "رقم الهات� المصدر" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "المدة" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "م�تاح الخادم" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "م�تاح API للجهاز" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "معرّ� التطبيق" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "إصدار التطبيق" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "رمز وصول التطبيق" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "أيقونة مخصصة" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "نوع الأيقونة" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "الدورات" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "رمز البوت" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "رابط النقر" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "اللون" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "يعمل �قط مع Mac OS X 10.8 والإصدارات الأحدث. كما يتطلب إمكانية الوصول محلياً " "إلى /usr/local/bin/terminal-notifier." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "إشعار MacOSX" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "رابط ال�تح أو النقر" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "إصدار Terminal-Notifier" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "المرسل" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "الرد إلى" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "النطاق" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "اسم المرسل" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "ترويسة البريد" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "الظهور" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "نص مخ�ي" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "رمز اللغة" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "تخزين النتائج مؤقتاً" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "مر�قات حساسة" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "الإشارة إلى المستخدمين أو الوسوم" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "المستخدم" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "معرّ� القناة المستهد�ة" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "أقصى عدد لأجزاء الرسالة" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "مرجع مخصص" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "الطابور المستهد�" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "معرّ� العميل" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "استخدام جلسة" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "الاحت�اظ بالرسائل" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "معرّ� القالب" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "م�تاح المصادقة" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "رابط قصير" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "تعيين القالب" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "بادئة الرابط" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "معرّ� الغر�ة" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "إشعار صامت" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "معرّ� حدث Discord" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "المصدر" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "معرّ� المشروع" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "خطا� الرسائل" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "ألوان IRC" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "البادئة" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "اسم الحملة" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "اسم القالب" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "مرجع العميل" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "جودة عالية" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "ترويسة ال�اكس" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "المر�ق" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "اسم مل� المر�ق" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "التأخير" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "البريد الإلكتروني" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "الإجراءات" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "نوع المصادقة" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "اسم دخول API" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "قبول الردود" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "الغرض" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "نطاق المستأجر" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "بريد الحساب أو معرّ� الكائن" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "سر العميل" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "الح�ظ �ي العناصر المرسلة" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "القالب" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "معرّ� المشغّل المستهد�" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "تضمين الشريحة" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "العنوان ال�رعي" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "اللغة" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "تمكين المحتوى" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "�ك ترميز معاملات القالب" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "بيانات مخصصة" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "بيانات الاستجابة" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "معرّ� المستخدم المستهد�" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "معرّ� المجموعة المستهد�ة" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "المكوّن" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "ال�ئة البرمجية" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "درجة الخطورة" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "ت�اصيل مخصصة" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "معرّ� التكامل" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "معرّ� طر� ثالث" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "إضا�ات Meta" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "الم�تاح الرئيسي" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "الجهاز" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "�ضاء الأسماء" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "الشارات" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "نوع الرسالة" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "المعرّ� المستهد�" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "الرسالة القصيرة المستهد�ة" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "القنوات" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "معرّ� المصادقة" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "رمز المصادقة" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "م�تاح المزوّد" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "م�تاح الإشعارات" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "م�تاح التطبيق" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "سر التطبيق" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "الم�تاح السري" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "إظهار الحالة" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "م�تاح المستخدم" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "عنوان الرابط" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "ال�اصل الزمني لإعادة المحاولة الطارئة" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "انتهاء الصلاحية" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "رمز المستخدم" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "مواضيع المجموعة" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "القناة" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "اسم الويب هوك" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "الم�تاح الخاص" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "الاهتزاز" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "مستوى المعلومات" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "مستوى النجاح" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "مستوى التحذير" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "مستوى ال�شل" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "م�تاح API السري" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "الموضوع المستهد�" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "معرّ� التطبيق" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "منتدى Reddit ال�رعي المستهد�" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "النوعية" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "معرّ� الوسم" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "نص الوسم" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "هل هذا إعلان؟" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "إرسال الردود" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "يحتوي على حرق للأحداث" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "علامة إعادة الإرسال" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "عنوان المرسل" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "معرّ� القناة" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "رابط مضمّن" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "الرمز أو كلمة المرور" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "البيئة" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "وضع المصادقة" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "ويب هوك" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "معرّ� الغر�ة المستهد�ة" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "وضع الويب هوك" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "استخدام الأ�اتار" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "المر�ق النظامي" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "تسجيل معرّ� العملية" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "المؤسسة" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "بيانات القالب" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "اسم المرسل" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "رمز الجلسة" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "معرّ� م�تاح الوصول" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "م�تاح الوصول السري" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "عناوين البريد المستهد�ة" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "الوميض" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "التسمية" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "معرّ� الخدمة" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "كلمة مرور الخدمة" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "معرّ� المساحة" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "رقم هات� المستلم" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "نوع الوسائط" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "المهلة" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "صوت النطق" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "م�تاح العميل" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "إشعار حرج" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "الخدمة" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "الموقع" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "سيناريو التنبيه" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "التص�ية" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "معرّ� خارجي" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "الحالة" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "العضو المستهد�" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "مشترك" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "وسم" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "الرمز القصير المستهد�" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "رمز وصول OAuth" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "الرمز A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "الرمز B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "الرمز C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "المعرّ� المش�ّر المستهد�" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "مسار سير العمل" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "تضمين التذييل" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "استخدام الكتل" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "تضمين الطابع الزمني" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "وضع الرسالة" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "المضي�" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "اسم الدخول" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "النقل الحر�ي" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "جهة الاتصال المستهد�ة" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "للاختبار �قط" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "البوابة" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "الم�تاح العام" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "القيمة الابتدائية" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "الغر�" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "م�تاح التوجيه المستهد�" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "معرّ� الكيان" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "نوع التنبيه" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "رابط الصورة" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "رابط الصوت" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "لون نص خاص" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "المبلغ" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "العملة" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "الاسم" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "المعرّ�" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "الر�ع" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "التسجيل �ي STDERR" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "معرّ� الدردشة المستهد�ة" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "الكش� عن مالك البوت" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "معاينة ص�حة الويب" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "تجميع مر�قات الصور/ال�يديو" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "معرّ� محادثة الموضوع" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "إصدار Markdown" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "موضع المحتوى" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "مسار قالب الرسالة المنسقة" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "معرّ� مل� المراسلة" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "معرّ� البوابة" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "معرّ� Threema المستهد�" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "اسم مضي� API" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "رابط الصورة" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "نوع الحدث" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "الأولوية (توا�ق Pushover)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "طريقة الإشعار: رسالة قصيرة أو مكالمة" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "م�تاح المستهلك" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "سر المستهلك" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "سر الوصول" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "رمز المصادقة" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "معرّ�ات المستلمين" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "رابط أ�اتار البوت" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "بريد المستخدم" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "رمز وصول البوت" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "معرّ�ات الغر�" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "معرّ� الشركة" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "سر التطبيق" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "معرّ� الوكيل" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "القسم المستهد�" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "الوسم المستهد�" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "م�تاح ويب هوك البوت" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "معرّ� هات� المرسل" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "يلزم وجود بيئة Microsoft Windows محلية." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "معرّ� سير العمل" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "التوقيع" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "استخدام رابط Power Automate" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "معرّ� توجيه Power Automate" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "الت�ا� النص" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "إصدار API" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "المواضيع" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "رمز المجموعة أو الخيار" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "رمز التطبيق" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "الرمز أو الم�تاح" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "أولوية المعلومات" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "أولوية النجاح" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "أولوية التحذير" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "أولوية ال�شل" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "تسمية الرابط" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "رمز التحقق" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "التد�ق المستهد�" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "ثبّت pika لـ AMQP 0.9.1، أو python-qpid-proton لـ AMQP 1.0، أو كليهما." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "الم�باد�ل" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "المضي� الا�تراضي" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "دائم" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "إلزامي" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "مدة الصلاحية (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "ترويسات الرسالة" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "خادم SMTP" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "الوضع الآمن" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "وضع أمان PGP" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "مسار م�تاح PGP العام" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "مسار م�تاح PGP الخاص" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "دليل م�اتيح الويب" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "مر�قات مضمّنة" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "بريد المستلم" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "مل� م�تاح OAuth2" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "رابط صورة مخصص" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "لون الإشعار" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "الاسم الحقيقي" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "الاسم المستعار" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "الانضمام إلى القنوات" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "وضع المصادقة" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "الاسم البديل للغر�ة المستهد�ة" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "استكشا� الخادم" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "�رض الخادم الأصلي على معرّ�ات الغر�" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "إصدار واجهة Matrix" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "التش�ير من الطر� إلى الطر�" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "التحقق التلقائي من الجهاز" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "مل� م�تاح خاص بتنسيق PEM" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "مل� الاشتراك" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "خادم XMPP" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "جلب قائمة جهات الاتصال" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "استخدام الموضوع" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "الح�اظ على الاتصال" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "الاسم المستعار �ي MUC" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "ربط قناة SCRAM-PLUS" caronc-apprise-dfec414/apprise/i18n/de/000077500000000000000000000000001526720371000177315ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/de/LC_MESSAGES/000077500000000000000000000000001526720371000215165ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/de/LC_MESSAGES/apprise.po000066400000000000000000002026331526720371000235270ustar00rootroot00000000000000# German translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: de\n" "Language-Team: de \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "SSL überprüfen" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "Weiterleitungen folgen" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "Socket-Lesezeitlimit" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "Socket-Verbindungszeitlimit" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "Cache-Alter" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "Erzwungener MIME-Typ" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "Erzwungener Dateiname" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "Lokale Datei" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "Webbasiert" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "Speicher" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "Schema" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "Keine Abhängigkeiten." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "Pakete sind zum Betrieb erforderlich." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "Pakete werden empfohlen, um die Funktionalität zu verbessern." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "App-Benutzername" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "API-Schlüssel" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "Ziel-Telefon" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "Ziele" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "Absender" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "SMS-Modus" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "Stapelmodus" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "Hostname" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "Port" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "Benutzername" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "Passwort" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "Token" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "Schlagwörter" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "Abfragemethode" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Apprise-API-Version" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "HTTP-Kopfzeile" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "Benutzername" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "Ziel-Rufzeichen" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "Verzögerung beim erneuten Senden" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "Gebietsschema" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "Zielgerät" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "Ton" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "Stufe" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "Lautstärke" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "Klick" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "Abzeichen" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "Kategorie" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "Gruppe" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "Bild einschließen" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "Symbol-URL" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "Anruf" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "Verschlüsselungsschlüssel" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "Überlaufmodus" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "Benachrichtigungsformat" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "Emojis interpretieren" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "Dauerhafter Speicher" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "Zeitzone" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "Dienst-Wiederholungen" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "Wartezeit zwischen Versuchen" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "Optionaler Dienst" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "Absender-E-Mail" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "Zielteam" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "Eskalationsrichtlinie" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "Alarmierungsarten" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "Team-Wartezeit" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "Seriennummer" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "Dauer (ms)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "Überblendzeit (ms)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "LED-Nummer" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "Quell-E-Mail" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "Ziel-E-Mail" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "Antwort-E-Mail" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "Kopie" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "Blindkopie" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "Geheimnis" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "Ziel-Telefonnummer" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "Zielgruppe" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "Absender-Telefonnummer" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "Routing-Gruppe" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Unicode-Zeichen" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "API-Geheimnis" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "Absenderkennung" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "Land" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "Gültigkeit" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "API-Token" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Webhook-Token" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "Bot-Name" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "Zielkanal" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "Ziel-Bot" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "Region" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "Firmen-ID" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "An alle senden" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "Abrufmethode" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "Datei anhängen als" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "Zusätzliche Nutzdaten" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "GET-Parameter" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "API-Zugriffstoken" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "Ursprungsadresse" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "Priorität" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "Sendergruppen" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "libdbus-1.so.x muss installiert sein." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "DBus-Benachrichtigung" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "Dringlichkeit" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "X-Achse" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Y-Achse" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "Webhook-Kennung" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "Sprachausgabe" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "Avatar-Bild" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "Avatar-URL" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "Thread-Kennung" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "Fußzeile anzeigen" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "Logo der Fußzeile" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "Felder verwenden" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Discord-Flags" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "Benutzer oder Rollen erwähnen" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "Vorlagenpfad" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "Anhänge zusammenfassen" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "Vorlagen-Token" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "Geräteseriennummer" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "API-Modus" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Jetzt aktualisieren" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Textsignatur" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Symbol als Base64 (Text-API)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Bild als Base64 (Bild-API)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "Link" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "Rahmen" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Dithering-Typ" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Dithering-Kern" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "Aufgabenschlüssel" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "Modal" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "Pfad" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Server-Zeitlimit" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "Instanzname" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "Konto-SID" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "Absender-Telefonnummer oder Absenderkennung" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "Regionsname" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "Zugriffsschlüssel" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "An Benutzerkennung" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "An Kanalkennung" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "Webhook-Pfad" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "Modus" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "Thread-Name" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "API-Benutzername" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "API-Passwort" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "Zugriffstoken" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "Eigentümer" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "Repository" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "Typ" #: apprise/plugins/github.py:222 msgid "Number" msgstr "Nummer" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "Bezeichnungen" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "Zuständige" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "libdbus-1.so.x oder libdbus-2.so.x muss installiert sein." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "GLib-Benachrichtigung" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "Eine lokale Gnome-Umgebung ist erforderlich." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Gnome-Benachrichtigung" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "Integrationsschlüssel" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "Aktion" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "Deduplizierungsschlüssel" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "Metadaten" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "Arbeitsbereich" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Webhook-Schlüssel" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "Thread-Schlüssel" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "Plattform" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "Thema" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "Dateneinträge" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "Bot-Kennung" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Version" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Angeheftet" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "Langlebiges Zugriffstoken" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "Benachrichtigungskennung" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "Pfadpräfix" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "Token oder Benutzername" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "Ereignisse" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "Token hinzufügen" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "Token entfernen" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "Integrationsschlüssel" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "Route" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "Alarmquellen-ID" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "Ziel-Eskalation" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "Ziel-Zeitplan" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "Zielbenutzer" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "Ziele " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "Entität" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "Alias" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "Details" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "Aktionszuordnung" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "Gerätekennung" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "Gerätename" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "Quell-Telefonnummer" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Dauer" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "Serverschlüssel" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "Geräte-API-Schlüssel" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "App-Kennung" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "App-Version" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "App-Zugriffstoken" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "Eigenes Symbol" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "Symboltyp" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "Zyklen" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "Bot-Token" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "Klick-URL" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "Farbe" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Funktioniert nur mit Mac OS X 10.8 und neuer. Zusätzlich muss /usr/local/bin/" "terminal-notifier lokal verfügbar sein." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "MacOSX-Benachrichtigung" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "URL zum Öffnen oder Klicken" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Terminal-Notifier-Version" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "Absender" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "Antwort an" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "Domäne" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "Absendername" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "E-Mail-Kopfzeile" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "Sichtbarkeit" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "Spoiler-Text" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "Sprachcode" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "Ergebnisse zwischenspeichern" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "Vertrauliche Anhänge" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "Benutzer oder Schlagwörter erwähnen" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "Benutzer" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "Kennung des Zielkanals" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "Maximale Anzahl Nachrichtenteile" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "Eigene Referenz" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "Ziel-Warteschlange" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "Client-Kennung" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "Sitzung verwenden" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "Nachrichten aufbewahren" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "Vorlagenkennung" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "Authentifizierungsschlüssel" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "Kurz-URL" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "Vorlagenzuordnung" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "URL-Präfix" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "Raumkennung" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "Stille Benachrichtigung" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "Discord-Ereigniskennung" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "Quelle" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "Projektkennung" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "Nachrichten-Hook" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "IRC-Farben" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "Präfix" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "Kampagnenname" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "Vorlagenname" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "Client-Referenz" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "Hohe Qualität" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "Fax-Kopfzeile" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "Anhang" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "Dateiname des Anhangs" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "Verzögerung" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "E-Mail" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "Aktionen" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "Authentifizierungstyp" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "API-Anmeldename" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "Antworten annehmen" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "Zweck" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "Mandantendomäne" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "Konto-E-Mail oder Objektkennung" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "Client-Geheimnis" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "In gesendeten Elementen speichern" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "Vorlage" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "Ziel-Spielerkennung" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "Segment einschließen" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "Untertitel" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "Sprache" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "Inhalte aktivieren" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "Vorlagenargumente dekodieren" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "Eigene Daten" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "Rückmeldedaten" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "Kennung des Zielbenutzers" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "Kennung der Zielgruppe" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "Komponente" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "Klasse" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "Schweregrad" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "Eigene Details" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "Integrationskennung" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "Kennung von Drittanbietern" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Meta-Extras" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "Hauptschlüssel" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "Gerät" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "Namensraum" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "Abzeichen" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "Nachrichtentyp" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "Zielkennung" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "Ziel-SMS" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "Kanäle" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "Authentifizierungskennung" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "Authentifizierungstoken" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "Anbieterschlüssel" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "Push-Schlüssel" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "Anwendungsschlüssel" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "Anwendungsgeheimnis" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "Geheimer Schlüssel" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "Status anzeigen" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "Benutzerschlüssel" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "URL-Titel" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "Notfall-Wiederholungsintervall" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "Ablauf" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "Benutzertoken" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "Gruppenthemen" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "Kanal" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "Webhook-Name" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "Privater Schlüssel" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "Vibration" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "Info-Stufe" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "Erfolgsstufe" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "Warnstufe" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "Fehlerstufe" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "Geheimer API-Schlüssel" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "Zielthema" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "Anwendungskennung" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "Ziel-Subreddit" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "Art" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "Flair-Kennung" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "Flair-Text" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "Ist Werbung?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "Antworten senden" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "Ist ein Spoiler" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "Kennzeichen für erneutes Senden" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "Absenderadresse" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "Kanalkennung" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "Eingebettete URL" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "Token oder Passwort" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "Umgebung" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "Authentifizierungsmodus" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "Kennung des Zielraums" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Webhook-Modus" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "Avatar verwenden" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "Einrichtung" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "PID protokollieren" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "Organisation" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "Vorlagendaten" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "Absendername" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "Sitzungstoken" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "Kennung des Zugriffsschlüssels" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "Geheimer Zugriffsschlüssel" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "Ziel-E-Mails" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "Blitz" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "Bezeichnung" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "Dienstkennung" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "Dienstpasswort" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "Bereichskennung" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "Telefonnummer des Empfängers" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "Medientyp" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "Zeitlimit" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "Sprachausgabestimme" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "Client-Schlüssel" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "Kritische Benachrichtigung" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "Dienst" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "Standort" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "Alarmszenario" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "Filterung" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "Externe Kennung" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "Status" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "Zielmitglied" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "Geteilt" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "Tag" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "Ziel-Kurzwahl" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "OAuth-Zugriffstoken" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "Token A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "Token B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "Token C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "Codierte Zielkennung" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "Arbeitsablaufpfad" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "Fußzeile einschließen" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "Blöcke verwenden" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "Zeitstempel einschließen" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "Nachrichtenmodus" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "Host" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "Anmeldename" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "Transliterieren" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "Zielkontakt" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "Nur Test" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "Gateway" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "Öffentlicher Schlüssel" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "Startwert" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "Räume" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "Ziel-Routingschlüssel" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "Entitätskennung" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "Alarmtyp" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "Bildlink" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "Tonlink" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "Besondere Textfarbe" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "Betrag" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "Währung" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "Name" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "Kennung" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "Hochladen" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "Auf STDERR protokollieren" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "Kennung des Ziel-Chats" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "Bot-Eigentümer erkennen" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "Vorschau der Webseite" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "Foto-/Video-Anhänge gruppieren" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "Thread-Kennung des Themas" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Markdown-Version" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "Platzierung des Inhalts" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "Pfad der Vorlage für formatierte Nachrichten" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "Kennung des Nachrichtenprofils" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "Gateway-Kennung" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "Ziel-Threema-Kennung" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "API-Hostname" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "Bild-URL" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "Ereignistyp" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "Priorität (Pushover-Kompatibilität)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "Benachrichtigungsmethode: SMS oder Anruf" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "Consumer-Schlüssel" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "Consumer-Geheimnis" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "Zugriffsgeheimnis" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "Authentifizierungstoken" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "Empfängerkennungen" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "Avatar-URL des Bots" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "Benutzer-E-Mail" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "Bot-Zugriffstoken" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "Raumkennungen" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "Unternehmenskennung" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "App-Geheimnis" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "Agentenkennung" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "Zielabteilung" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "Ziel-Schlagwort" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "Webhook-Schlüssel des Bots" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "Kennung des Absender-Telefons" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "Eine lokale Microsoft-Windows-Umgebung ist erforderlich." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "Arbeitsablaufkennung" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "Signatur" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Power-Automate-URL verwenden" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "Power-Automate-Routingkennung" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "Text umbrechen" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "API-Version" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "Themen" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "Gruppen- oder Optionscode" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "App-Token" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "Token oder Schlüssel" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "Priorität für Informationen" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "Priorität für Erfolge" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "Priorität für Warnungen" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "Priorität für Fehler" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "Beschriftung des Links" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "Bestätigungstoken" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "Ziel-Stream" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "Installieren Sie pika für AMQP 0.9.1, python-qpid-proton für AMQP 1.0 oder " "beides." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "Exchange" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "Virtueller Host" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "Persistent" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "Verpflichtend" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "Lebensdauer (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "Nachrichten-Header" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "SMTP-Server" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "Sicherer Modus" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "PGP-Sicherheitsmodus" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "Pfad des öffentlichen PGP-Schlüssels" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "Pfad des privaten PGP-Schlüssels" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "Web-Schlüsselverzeichnis" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "Eingebettete Anhänge" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "Empfänger-E-Mail" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "OAuth2-Schlüsseldatei" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "Eigene Bild-URL" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "Farbe der Benachrichtigung" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "Echter Name" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "Spitzname" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "Kanäle betreten" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "Authentifizierungsmodus" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "Alias des Zielraums" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "Servererkennung" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "Heimserver bei Raumkennungen erzwingen" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Matrix-API-Version" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "Ende-zu-Ende-Verschlüsselung" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "Automatische Geräteprüfung" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "PEM-Datei mit privatem Schlüssel" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "Abonnementdatei" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "XMPP-Server" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "Kontaktliste abrufen" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "Betreff verwenden" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "Verbindung aufrechterhalten" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "MUC-Spitzname" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "SCRAM-PLUS-Kanalbindung" caronc-apprise-dfec414/apprise/i18n/en/000077500000000000000000000000001526720371000177435ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/en/LC_MESSAGES/000077500000000000000000000000001526720371000215305ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/en/LC_MESSAGES/apprise.po000066400000000000000000001776501526720371000235530ustar00rootroot00000000000000# English translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: en\n" "Language-Team: en \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "Verify SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "Follow Redirects" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "Socket Read Timeout" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "Socket Connect Timeout" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "Cache Age" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "Forced Mime Type" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "Forced File Name" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "Local File" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "Web Based" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "Memory" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "Schema" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "No dependencies." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "Packages are required to function." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "Packages are recommended to improve functionality." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "App User Name" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "API Key" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "Target Phone" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "Targets" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "From" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "SMS Mode" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "Batch Mode" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "Hostname" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "Port" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "Username" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "Password" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "Token" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "Tags" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "Query Method" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Apprise API Version" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "HTTP Header" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "User Name" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "Target Callsign" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "Resend Delay" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "Locale" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "Target Device" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "Sound" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "Level" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "Volume" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "Click" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "Badge" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "Category" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "Group" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "Include Image" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "Icon URL" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "Call" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "Encryption Key" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "Overflow Mode" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "Notify Format" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "Interpret Emojis" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "Persistent Storage" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "Timezone" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "Service Retry" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "Inter-Retry Wait" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "Optional Service" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "From Email" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "Target Team" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "Escalation Policy" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "Alert Methods" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "Team Wait" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "Serial Number" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "Duration (ms)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "Fade Time (ms)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "LED Number" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "Source Email" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "Target Email" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "Reply To Email" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "Carbon Copy" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "Blind Carbon Copy" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "Secret" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "Target Phone No" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "Target Group" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "From Phone No" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "Route Group" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Unicode Characters" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "API Secret" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "Sender ID" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "Country" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "validity" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "API Token" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Webhook Token" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "Bot Name" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "Target Channel" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "Target Bot" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "Region" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "Company ID" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "Broadcast" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "Fetch Method" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "Attach File As" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "Payload Extras" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "GET Params" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "API Access Token" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "Originating Address" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "Priority" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "Transmitter Groups" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "libdbus-1.so.x must be installed." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "DBus Notification" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "Urgency" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "X-Axis" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Y-Axis" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "Webhook ID" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "Text To Speech" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "Avatar Image" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "Avatar URL" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "Thread ID" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "Display Footer" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "Footer Logo" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "Use Fields" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Discord Flags" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "Ping Users/Roles" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "Template Path" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "Batch Attachments" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "Template Tokens" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "Device Serial Number" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "API Mode" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Refresh Now" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Text Signature" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Icon Base64 (Text API)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Image Base64 (Image API)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "Link" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "Border" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Dither Type" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Dither Kernel" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "Task Key" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "Modal" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "Path" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Server Timeout" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "Instance Name" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "Account SID" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "From Phone No / Sender ID" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "Region Name" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "Access Key" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "To User ID" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "To Channel ID" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "Webhook Path" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "Mode" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "Thread Name" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "API Username" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "API Password" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "Access Token" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "Owner" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "Repository" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "Type" #: apprise/plugins/github.py:222 msgid "Number" msgstr "Number" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "Labels" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "Assignees" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "libdbus-1.so.x or libdbus-2.so.x must be installed." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "GLib Notification" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "A local Gnome environment is required." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Gnome Notification" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "Integration Keys" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "Action" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "Deduplication Key" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "Metadata" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "Workspace" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Webhook Key" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "Thread Key" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "Platform" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "Topic" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "Data Entries" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "Bot ID" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Version" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Sticky" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "Long-Lived Access Token" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "Notification ID" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "Path Prefix" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "Token or Username" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "Events" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "Add Tokens" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "Remove Tokens" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "Integration Key" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "Route" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "Alert Source ID" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "Target Escalation" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "Target Schedule" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "Target User" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "Targets " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "Entity" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "Alias" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "Details" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "Action Mapping" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "Device ID" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "Device Name" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "Source Phone No" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Duration" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "Server Key" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "Device API Key" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "App ID" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "App Version" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "App Access Token" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "Custom Icon" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "Icon Type" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "Cycles" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "Bot Token" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "Click URL" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "Color" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "MacOSX Notification" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "Open/Click URL" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Terminal-Notifier Version" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "Sender" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "Reply To" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "Domain" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "From Name" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "Email Header" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "Visibility" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "Spoiler Text" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "Language Code" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "Cache Results" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "Sensitive Attachments" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "Ping Users/Tags" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "User" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "Target Channel ID" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "Max Message Parts" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "Custom Reference" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "Target Queue" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "Client ID" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "Use Session" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "Retain Messages" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "Template ID" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "Authentication Key" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "Short URL" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "Template Mapping" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "URL Prefix" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "Room ID" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "Silent Notification" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "Discord Event ID" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "Source" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "Project ID" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "Message Hook" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "IRC Colors" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "Prefix" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "Campaign Name" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "Template Name" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "Client Reference" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "High Quality" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "Fax Header" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "Attach" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "Attach Filename" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "Delay" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "Email" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "Actions" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "Authentication Type" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "API Login" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "Accept Replies" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "Purpose" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "Tenant Domain" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "Account Email or Object ID" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "Client Secret" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "Save to Sent Items" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "Template" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "Target Player ID" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "Include Segment" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "Subtitle" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "Language" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "Enable Contents" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "Decode Template Args" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "Custom Data" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "Postback Data" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "Target User ID" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "Target Group ID" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "Component" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "Class" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "Severity" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "Custom Details" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "Integration ID" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "Third Party ID" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Meta Extras" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "Master Key" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "Device" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "Namespace" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "Badges" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "Message Type" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "Target ID" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "Target SMS" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "Channels" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "Auth ID" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "Auth Token" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "Provider Key" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "Pushkey" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "Application Key" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "Application Secret" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "Secret Key" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "Show Status" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "User Key" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "URL Title" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "Emergency Retry Interval" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "Expire" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "User Token" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "Group Topics" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "Channel" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "Webhook Name" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "Private Key" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "Vibration" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "Info Level" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "Success Level" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "Warning Level" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "Failure Level" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "Secret API Key" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "Target Topic" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "Application ID" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "Target Subreddit" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "Kind" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "Flair ID" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "Flair Text" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "Is Ad?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "Send Replies" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "Is Spoiler" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "Resubmit Flag" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "From Address" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "Channel ID" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "Embed URL" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "Token / Password" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "Environment" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "Authentication Mode" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "Target Room ID" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Webhook Mode" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "Use Avatar" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "Facility" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "Log PID" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "Organization" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "Template Data" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "Sender Name" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "Session Token" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "Access Key ID" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "Secret Access Key" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "Target Emails" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "Flash" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "Label" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "Service ID" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "Service Password" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "Space ID" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "Recipient Phone Number" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "Media Type" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "Timeout" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "TTS Voice" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "Client Key" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "Critical Notification" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "Service" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "Location" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "Alerting Scenario" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "Filtering" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "External ID" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "Status" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "Target Member" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "Shared" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "Tag" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "Target Short Code" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "OAuth Access Token" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "Token A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "Token B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "Token C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "Target Encoded ID" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "Workflow Path" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "Include Footer" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "Use Blocks" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "Include Timestamp" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "Message Mode" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "Host" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "Login" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "Transliterate" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "Target Contact" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "Test Only" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "Gateway" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "Public Key" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "Seed" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "Rooms" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "Target Routing Key" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "Entity ID" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "Alert Type" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "Image Link" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "Sound Link" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "Special Text Color" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "Amount" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "Currency" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "Name" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "Identifier" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "Upload" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "Log to STDERR" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "Target Chat ID" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "Detect Bot Owner" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "Web Page Preview" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "Group Photo/Video Attachments" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "Topic Thread ID" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Markdown Version" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "Content Placement" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "Rich Message Template Path" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "Messaging Profile ID" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "Gateway ID" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "Target Threema ID" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "API Hostname" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "Image URL" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "Event type" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "Priority (Pushover compatibility)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "Notification Method: sms or call" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "Consumer Key" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "Consumer Secret" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "Access Secret" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "Authentication Token" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "Receiver IDs" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "Bot Avatar URL" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "User Email" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "Bot Access Token" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "Room IDs" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "Corp ID" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "App Secret" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "Agent ID" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "Target Department" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "Target Tag" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "Bot Webhook Key" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "From Phone ID" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "A local Microsoft Windows environment is required." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "Workflow ID" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "Signature" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Use Power Automate URL" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "Power Automate Routing ID" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "Wrap Text" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "API Version" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "Topics" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "Group / Option Code" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "App Token" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "Token or Key" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "Info Priority" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "Success Priority" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "Warning Priority" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "Failure Priority" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "Link Label" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "Verification Token" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "Target Stream" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "Exchange" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "Virtual Host" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "Persistent" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "Mandatory" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "Time To Live" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "Message Headers" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "SMTP Server" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "Secure Mode" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "PGP Security Mode" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "PGP Public Key Path" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "PGP Private Key Path" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "Web Key Directory" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "Inline Attachments" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "To Email" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "OAuth2 KeyFile" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "Custom Image URL" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "Notification Color" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "Real Name" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "Nickname" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "Join Channels" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "Auth Mode" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "Target Room Alias" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "Server Discovery" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "Force Home Server on Room IDs" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Matrix API Version" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "End-to-End Encryption" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "Automatic Device Verification" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "PEM Private KeyFile" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "Subscripion File" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "XMPP Server" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "Get Roster" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "Use Subject" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "Keep Connection Alive" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "MUC Nickname" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "SCRAM-PLUS Channel Binding" caronc-apprise-dfec414/apprise/i18n/es/000077500000000000000000000000001526720371000177505ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/es/LC_MESSAGES/000077500000000000000000000000001526720371000215355ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/es/LC_MESSAGES/apprise.po000066400000000000000000002060371526720371000235500ustar00rootroot00000000000000# Spanish translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: es\n" "Language-Team: es \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "Verificar el SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "Seguir redirecciones" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "Tiempo de espera de lectura del socket" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "Tiempo de espera de conexión del socket" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "Antigüedad de la caché" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "Tipo MIME forzado" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "Nombre de archivo forzado" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "Archivo local" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "Basado en la web" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "Memoria" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "Esquema" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "Sin dependencias." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "Se requieren paquetes para funcionar." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "Se recomiendan paquetes para mejorar la funcionalidad." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "Nombre de usuario de la aplicación" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "Clave de la API" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "Teléfono de destino" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "Destinos" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "Remitente" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "Modo SMS" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "Modo por lotes" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "Nombre del host" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "Puerto" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "Nombre de usuario" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "Contraseña" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "Token" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "Etiquetas" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "Método de consulta" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Versión de la API de Apprise" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "Cabecera HTTP" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "Nombre de usuario" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "Indicativo de destino" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "Retraso de reenvío" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "Configuración regional" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "Dispositivo de destino" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "Sonido" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "Nivel" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "Volumen" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "Clic" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "Insignia" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "Categoría" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "Grupo" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "Incluir la imagen" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "URL del icono" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "Llamada" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "Clave de cifrado" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "Modo de desbordamiento" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "Formato de notificación" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "Interpretar emojis" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "Almacenamiento persistente" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "Zona horaria" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "Reintentos del servicio" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "Espera entre reintentos" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "Servicio opcional" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "Correo del remitente" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "Equipo de destino" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "Política de escalado" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "Métodos de alerta" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "Espera del equipo" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "Número de serie" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "Duración (ms)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "Tiempo de transición (ms)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "Número de LED" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "Correo de origen" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "Correo de destino" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "Correo de respuesta" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "Copia de carbón" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "Copia de carbón oculta" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "Secreto" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "Número de teléfono de destino" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "Grupo de destino" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "Número de teléfono del remitente" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "Grupo de enrutamiento" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Caracteres Unicode" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "Secreto de la API" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "Identificador del remitente" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "País" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "validez" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "Token de la API" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Token del webhook" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "Nombre del bot" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "Canal de destino" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "Bot de destino" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "Región" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "ID de la empresa" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "Difusión" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "Método de obtención" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "Adjuntar el archivo como" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "Extras de la carga útil" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "Parámetros GET" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "Token de acceso a la API" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "Dirección de origen" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "Prioridad" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "Grupos de transmisores" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "libdbus-1.so.x debe estar instalado." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "Notificación de DBus" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "Urgencia" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "Eje X" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Eje Y" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "Identificador del webhook" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "Síntesis de voz" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "Imagen del avatar" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "URL del avatar" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "Identificador del hilo" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "Mostrar el pie de página" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "Logotipo del pie de página" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "Usar campos" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Indicadores de Discord" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "Mencionar usuarios o roles" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "Ruta de la plantilla" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "Agrupar los archivos adjuntos" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "Tokens de la plantilla" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "Número de serie del dispositivo" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "Modo de la API" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Actualizar ahora" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Firma del texto" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Icono en Base64 (API de texto)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Imagen en Base64 (API de imagen)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "Enlace" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "Borde" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Tipo de tramado" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Núcleo de tramado" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "Clave de la tarea" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "Modal" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "Ruta" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Tiempo de espera del servidor" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "Nombre de la instancia" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "SID de la cuenta" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "Número de teléfono del remitente o identificador del remitente" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "Nombre de la región" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "Clave de acceso" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "Al identificador del usuario" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "Al identificador del canal" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "Ruta del webhook" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "Modo" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "Nombre del hilo" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "Nombre de usuario de la API" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "Contraseña de la API" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "Token de acceso" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "Propietario" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "Repositorio" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "Tipo" #: apprise/plugins/github.py:222 msgid "Number" msgstr "Número" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "Etiquetas" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "Asignados" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "libdbus-1.so.x o libdbus-2.so.x debe estar instalado." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "Notificación de GLib" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "Se requiere un entorno Gnome local." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Notificación de Gnome" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "Claves de integración" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "Acción" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "Clave de deduplicación" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "Metadatos" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "Espacio de trabajo" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Clave del webhook" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "Clave del hilo" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "Plataforma" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "Tema" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "Entradas de datos" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "Identificador del bot" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Versión" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Fijado" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "Token de acceso de larga duración" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "Identificador de la notificación" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "Prefijo de la ruta" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "Token o nombre de usuario" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "Eventos" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "Añadir tokens" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "Quitar tokens" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "Clave de integración" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "Ruta" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "ID de la fuente de alertas" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "Escalada de destino" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "Horario de destino" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "Usuario de destino" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "Destinos " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "Entidad" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "Alias" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "Detalles" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "Asignación de acciones" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "Identificador del dispositivo" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "Nombre del dispositivo" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "Número de teléfono de origen" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Duración" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "Clave del servidor" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "Clave de la API del dispositivo" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "Identificador de la aplicación" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "Versión de la aplicación" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "Token de acceso de la aplicación" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "Icono personalizado" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "Tipo de icono" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "Ciclos" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "Token del bot" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "URL del clic" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "Color" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Solo funciona con Mac OS X 10.8 y versiones posteriores. Además, requiere " "que /usr/local/bin/terminal-notifier sea accesible localmente." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "Notificación de MacOSX" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "URL de apertura o de clic" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Versión de Terminal-Notifier" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "Remitente" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "Responder a" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "Dominio" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "Nombre del remitente" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "Encabezado del correo" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "Visibilidad" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "Texto oculto" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "Código de idioma" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "Almacenar los resultados en caché" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "Archivos adjuntos sensibles" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "Mencionar usuarios o etiquetas" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "Usuario" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "Identificador del canal de destino" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "Número máximo de partes del mensaje" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "Referencia personalizada" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "Cola de destino" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "Identificador del cliente" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "Usar una sesión" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "Conservar los mensajes" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "Identificador de la plantilla" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "Clave de autenticación" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "URL corta" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "Asignación de la plantilla" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "Prefijo de la URL" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "Identificador de la sala" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "Notificación silenciosa" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "Identificador del evento de Discord" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "Origen" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "Identificador del proyecto" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "Enlace de mensajes" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "Colores de IRC" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "Prefijo" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "Nombre de la campaña" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "Nombre de la plantilla" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "Referencia del cliente" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "Alta calidad" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "Encabezado del fax" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "Adjuntar" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "Nombre del archivo adjunto" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "Retraso" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "Correo electrónico" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "Acciones" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "Tipo de autenticación" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "Usuario de la API" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "Aceptar respuestas" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "Propósito" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "Dominio del inquilino" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "Correo de la cuenta o identificador del objeto" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "Secreto del cliente" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "Guardar en elementos enviados" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "Plantilla" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "Identificador del jugador de destino" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "Incluir el segmento" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "Subtítulo" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "Idioma" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "Activar el contenido" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "Decodificar los argumentos de la plantilla" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "Datos personalizados" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "Datos de retorno" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "Identificador del usuario de destino" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "Identificador del grupo de destino" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "Componente" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "Clase" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "Gravedad" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "Detalles personalizados" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "Identificador de la integración" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "Identificador de terceros" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Extras de Meta" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "Clave maestra" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "Dispositivo" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "Espacio de nombres" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "Insignias" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "Tipo de mensaje" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "Identificador de destino" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "SMS de destino" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "Canales" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "Identificador de autenticación" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "Token de autenticación" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "Clave del proveedor" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "Clave de notificación" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "Clave de la aplicación" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "Secreto de la aplicación" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "Clave secreta" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "Mostrar el estado" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "Clave del usuario" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "Título de la URL" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "Intervalo de reintento de emergencia" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "Caducidad" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "Token del usuario" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "Temas del grupo" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "Canal" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "Nombre del webhook" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "Clave privada" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "Vibración" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "Nivel de información" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "Nivel de éxito" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "Nivel de advertencia" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "Nivel de fallo" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "Clave secreta de la API" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "Tema de destino" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "Identificador de la aplicación" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "Subreddit de destino" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "Tipo" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "Identificador del flair" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "Texto del flair" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "¿Es un anuncio?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "Enviar las respuestas" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "Contiene spoiler" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "Indicador de reenvío" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "Dirección del remitente" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "Identificador del canal" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "URL incrustada" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "Token o contraseña" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "Entorno" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "Modo de autenticación" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "Identificador de la sala de destino" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Modo del webhook" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "Usar el avatar" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "Servicio" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "Registrar el PID" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "Organización" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "Datos de la plantilla" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "Nombre del remitente" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "Token de sesión" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "Identificador de la clave de acceso" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "Clave de acceso secreta" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "Correos de destino" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "Flash" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "Etiqueta" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "Identificador del servicio" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "Contraseña del servicio" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "Identificador del espacio" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "Número de teléfono del destinatario" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "Tipo de medio" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "Tiempo de espera" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "Voz de síntesis de voz" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "Clave del cliente" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "Notificación crítica" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "Servicio" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "Ubicación" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "Escenario de alerta" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "Filtrado" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "Identificador externo" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "Estado" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "Miembro de destino" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "Compartido" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "Etiqueta" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "Código corto de destino" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "Token de acceso de OAuth" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "Token A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "Token B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "Token C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "Identificador codificado de destino" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "Ruta del flujo de trabajo" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "Incluir el pie de página" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "Usar bloques" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "Incluir la marca de tiempo" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "Modo del mensaje" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "Servidor" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "Usuario" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "Transliterar" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "Contacto de destino" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "Solo prueba" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "Pasarela" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "Clave pública" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "Semilla" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "Salas" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "Clave de enrutamiento de destino" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "Identificador de la entidad" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "Tipo de alerta" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "Enlace de la imagen" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "Enlace del sonido" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "Color de texto especial" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "Importe" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "Moneda" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "Nombre" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "Identificador" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "Subir" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "Registrar en STDERR" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "Identificador del chat de destino" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "Detectar al propietario del bot" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "Vista previa de la página web" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "Agrupar adjuntos de foto/vídeo" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "Identificador del hilo del tema" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Versión de Markdown" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "Ubicación del contenido" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "Ruta de la plantilla de mensaje enriquecido" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "Identificador del perfil de mensajería" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "Identificador de la pasarela" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "Identificador de Threema de destino" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "Nombre del host de la API" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "URL de la imagen" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "Tipo de evento" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "Prioridad (compatibilidad con Pushover)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "Método de notificación: sms o llamada" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "Clave del consumidor" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "Secreto del consumidor" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "Secreto de acceso" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "Token de autenticación" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "Identificadores de los destinatarios" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "URL del avatar del bot" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "Correo del usuario" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "Token de acceso del bot" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "Identificadores de las salas" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "Identificador de la empresa" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "Secreto de la aplicación" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "Identificador del agente" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "Departamento de destino" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "Etiqueta de destino" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "Clave del webhook del bot" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "Identificador del teléfono del remitente" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "Se requiere un entorno Microsoft Windows local." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "Identificador del flujo de trabajo" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "Firma" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Usar una URL de Power Automate" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "Identificador de enrutamiento de Power Automate" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "Ajustar el texto" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "Versión de la API" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "Temas" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "Código de grupo o de opción" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "Token de la aplicación" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "Token o clave" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "Prioridad de la información" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "Prioridad del éxito" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "Prioridad de la advertencia" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "Prioridad del fallo" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "Etiqueta del enlace" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "Token de verificación" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "Flujo de destino" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "Instale pika para AMQP 0.9.1, python-qpid-proton para AMQP 1.0, o ambos." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "Intercambio" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "Host virtual" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "Persistente" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "Obligatorio" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "Tiempo de vida" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "Cabeceras del mensaje" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "Servidor SMTP" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "Modo seguro" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "Modo de seguridad de PGP" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "Ruta de la clave pública de PGP" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "Ruta de la clave privada de PGP" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "Directorio de claves web" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "Archivos adjuntos incrustados" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "Correo del destinatario" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "Archivo de clave de OAuth2" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "URL de imagen personalizada" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "Color de la notificación" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "Nombre real" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "Apodo" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "Unirse a los canales" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "Modo de autenticación" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "Alias de la sala de destino" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "Descubrimiento del servidor" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "Forzar el servidor de origen en los identificadores de sala" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Versión de la API de Matrix" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "Cifrado de extremo a extremo" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "Verificación automática del dispositivo" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "Archivo de clave privada PEM" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "Archivo de suscripción" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "Servidor XMPP" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "Obtener la lista de contactos" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "Usar el asunto" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "Mantener la conexión activa" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "Apodo de MUC" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "Vinculación de canal SCRAM-PLUS" caronc-apprise-dfec414/apprise/i18n/fr/000077500000000000000000000000001526720371000177505ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/fr/LC_MESSAGES/000077500000000000000000000000001526720371000215355ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/fr/LC_MESSAGES/apprise.po000066400000000000000000002053021526720371000235420ustar00rootroot00000000000000# French translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: fr\n" "Language-Team: fr \n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "Vérifier le SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "Suivre les redirections" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "Délai de lecture du socket" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "Délai de connexion du socket" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "Âge du cache" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "Type MIME forcé" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "Nom de fichier forcé" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "Fichier local" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "Sur le Web" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "Mémoire" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "Schéma" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "Aucune dépendance." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "Des paquets sont requis pour fonctionner." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "Des paquets sont recommandés pour améliorer les fonctionnalités." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "Nom d'utilisateur de l'application" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "Clé d'API" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "Téléphone cible" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "Cibles" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "Expéditeur" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "Mode SMS" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "Mode par lots" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "Nom d'hôte" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "Port" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "Nom d'utilisateur" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "Mot de passe" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "Jeton" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "Étiquettes" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "Méthode de requête" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Version de l'API Apprise" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "En-tête HTTP" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "Nom d'utilisateur" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "Indicatif cible" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "Délai de réexpédition" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "Paramètres régionaux" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "Appareil cible" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "Son" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "Niveau" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "Volume" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "Clic" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "Badge" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "Catégorie" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "Groupe" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "Inclure l'image" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "URL de l'icône" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "Appel" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "Clé de chiffrement" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "Mode de débordement" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "Format de notification" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "Interpréter les émojis" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "Stockage persistant" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "Fuseau horaire" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "Nouvelles tentatives du service" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "Attente entre les tentatives" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "Service optionnel" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "Courriel de l'expéditeur" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "Équipe cible" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "Politique d'escalade" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "Méthodes d'alerte" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "Délai d'attente de l'équipe" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "Numéro de série" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "Durée (ms)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "Temps de fondu (ms)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "Numéro de la DEL" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "Courriel source" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "Courriel cible" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "Courriel de réponse" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "Copie conforme" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "Copie conforme invisible" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "Secret" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "Numéro de téléphone cible" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "Groupe cible" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "Numéro de téléphone de l'expéditeur" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "Groupe de routage" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Caractères Unicode" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "Secret d'API" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "Identifiant de l'expéditeur" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "Pays" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "validité" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "Jeton d'API" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Jeton du webhook" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "Nom du bot" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "Canal cible" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "Bot cible" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "Région" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "ID de l'entreprise" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "Diffusion" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "Méthode de récupération" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "Joindre le fichier sous" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "Extras de la charge utile" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "Paramètres GET" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "Jeton d'accès à l'API" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "Adresse d'origine" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "Priorité" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "Groupes d'émetteurs" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "libdbus-1.so.x doit être installé." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "Notification DBus" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "Urgence" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "Axe X" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Axe Y" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "Identifiant du webhook" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "Synthèse vocale" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "Image de l'avatar" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "URL de l'avatar" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "Identifiant du fil" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "Afficher le pied de page" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "Logo du pied de page" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "Utiliser les champs" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Indicateurs Discord" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "Mentionner les utilisateurs ou les rôles" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "Chemin du modèle" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "Grouper les pièces jointes" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "Jetons du modèle" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "Numéro de série de l'appareil" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "Mode d'API" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Rafraîchir maintenant" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Signature du texte" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Icône en Base64 (API texte)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Image en Base64 (API image)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "Lien" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "Bordure" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Type de tramage" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Noyau de tramage" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "Clé de la tâche" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "Modale" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "Chemin" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Délai d'attente du serveur" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "Nom de l'instance" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "SID du compte" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "Numéro de téléphone de l'expéditeur ou identifiant d'expéditeur" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "Nom de la région" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "Clé d'accès" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "Vers l'identifiant d'utilisateur" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "Vers l'identifiant du canal" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "Chemin du webhook" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "Mode" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "Nom du fil" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "Nom d'utilisateur de l'API" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "Mot de passe de l'API" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "Jeton d'accès" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "Propriétaire" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "Dépôt" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "Type" #: apprise/plugins/github.py:222 msgid "Number" msgstr "Numéro" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "Étiquettes" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "Responsables" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "libdbus-1.so.x ou libdbus-2.so.x doit être installé." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "Notification GLib" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "Un environnement Gnome local est requis." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Notification Gnome" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "Clés d'intégration" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "Action" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "Clé de déduplication" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "Métadonnées" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "Espace de travail" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Clé du webhook" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "Clé du fil" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "Plateforme" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "Sujet" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "Entrées de données" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "Identifiant du bot" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Version" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Épinglé" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "Jeton d'accès de longue durée" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "Identifiant de la notification" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "Préfixe du chemin" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "Jeton ou nom d'utilisateur" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "Événements" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "Ajouter des jetons" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "Retirer des jetons" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "Clé d'intégration" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "Route" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "ID de la source d'alertes" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "Escalade cible" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "Horaire cible" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "Utilisateur cible" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "Cibles " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "Entité" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "Alias" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "Détails" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "Correspondance des actions" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "Identifiant de l'appareil" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "Nom de l'appareil" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "Numéro de téléphone source" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Durée" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "Clé du serveur" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "Clé d'API de l'appareil" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "Identifiant de l'application" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "Version de l'application" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "Jeton d'accès de l'application" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "Icône personnalisée" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "Type d'icône" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "Cycles" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "Jeton du bot" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "URL du clic" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "Couleur" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Ne fonctionne qu'avec Mac OS X 10.8 et les versions ultérieures. Nécessite " "également que /usr/local/bin/terminal-notifier soit accessible localement." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "Notification MacOSX" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "URL d'ouverture ou de clic" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Version de Terminal-Notifier" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "Expéditeur" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "Répondre à" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "Domaine" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "Nom de l'expéditeur" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "En-tête du courriel" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "Visibilité" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "Texte masqué" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "Code de langue" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "Mettre les résultats en cache" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "Pièces jointes sensibles" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "Mentionner les utilisateurs ou les étiquettes" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "Utilisateur" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "Identifiant du canal cible" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "Nombre maximal de parties du message" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "Référence personnalisée" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "File d'attente cible" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "Identifiant du client" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "Utiliser une session" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "Conserver les messages" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "Identifiant du modèle" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "Clé d'authentification" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "URL courte" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "Correspondance du modèle" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "Préfixe de l'URL" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "Identifiant de la salle" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "Notification silencieuse" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "Identifiant de l'événement Discord" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "Source" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "Identifiant du projet" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "Crochet de message" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "Couleurs IRC" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "Préfixe" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "Nom de la campagne" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "Nom du modèle" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "Référence du client" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "Haute qualité" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "En-tête du télécopieur" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "Pièce jointe" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "Nom du fichier joint" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "Délai" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "Courriel" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "Actions" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "Type d'authentification" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "Identifiant de l'API" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "Accepter les réponses" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "Objet" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "Domaine du locataire" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "Courriel du compte ou identifiant de l'objet" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "Secret du client" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "Enregistrer dans les éléments envoyés" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "Modèle" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "Identifiant du joueur cible" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "Inclure le segment" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "Sous-titre" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "Langue" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "Activer le contenu" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "Décoder les arguments du modèle" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "Données personnalisées" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "Données de retour" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "Identifiant de l'utilisateur cible" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "Identifiant du groupe cible" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "Composant" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "Classe" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "Gravité" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "Détails personnalisés" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "Identifiant de l'intégration" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "Identifiant tiers" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Extras Meta" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "Clé maîtresse" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "Appareil" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "Espace de noms" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "Badges" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "Type de message" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "Identifiant cible" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "SMS cible" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "Canaux" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "Identifiant d'authentification" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "Jeton d'authentification" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "Clé du fournisseur" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "Clé de notification" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "Clé de l'application" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "Secret de l'application" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "Clé secrète" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "Afficher l'état" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "Clé de l'utilisateur" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "Titre de l'URL" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "Intervalle de répétition d'urgence" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "Expiration" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "Jeton de l'utilisateur" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "Sujets du groupe" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "Canal" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "Nom du webhook" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "Clé privée" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "Vibration" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "Niveau d'information" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "Niveau de succès" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "Niveau d'avertissement" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "Niveau d'échec" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "Clé d'API secrète" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "Sujet cible" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "Identifiant de l'application" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "Sous-Reddit cible" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "Genre" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "Identifiant du flair" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "Texte du flair" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "Est-ce une publicité ?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "Envoyer les réponses" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "Contient un spoiler" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "Indicateur de renvoi" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "Adresse de l'expéditeur" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "Identifiant du canal" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "URL intégrée" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "Jeton ou mot de passe" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "Environnement" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "Mode d'authentification" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "Identifiant de la salle cible" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Mode webhook" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "Utiliser l'avatar" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "Service" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "Journaliser le PID" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "Organisation" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "Données du modèle" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "Nom de l'expéditeur" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "Jeton de session" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "Identifiant de la clé d'accès" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "Clé d'accès secrète" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "Courriels cibles" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "Flash" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "Étiquette" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "Identifiant du service" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "Mot de passe du service" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "Identifiant de l'espace" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "Numéro de téléphone du destinataire" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "Type de média" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "Délai d'attente" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "Voix de synthèse" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "Clé du client" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "Notification critique" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "Service" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "Emplacement" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "Scénario d'alerte" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "Filtrage" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "Identifiant externe" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "État" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "Membre cible" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "Partagé" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "Étiquette" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "Code court cible" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "Jeton d'accès OAuth" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "Jeton A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "Jeton B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "Jeton C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "Identifiant encodé cible" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "Chemin du flux de travail" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "Inclure le pied de page" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "Utiliser les blocs" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "Inclure l'horodatage" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "Mode de message" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "Hôte" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "Identifiant" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "Translittérer" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "Contact cible" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "Test seulement" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "Passerelle" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "Clé publique" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "Graine" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "Salles" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "Clé de routage cible" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "Identifiant de l'entité" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "Type d'alerte" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "Lien de l'image" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "Lien du son" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "Couleur de texte spéciale" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "Montant" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "Devise" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "Nom" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "Identifiant" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "Téléverser" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "Journaliser vers STDERR" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "Identifiant de la conversation cible" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "Détecter le propriétaire du bot" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "Aperçu de la page Web" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "Regrouper les pièces jointes photo/vidéo" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "Identifiant du fil du sujet" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Version de Markdown" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "Placement du contenu" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "Chemin du modèle de message enrichi" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "Identifiant du profil de messagerie" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "Identifiant de la passerelle" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "Identifiant Threema cible" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "Nom d'hôte de l'API" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "URL de l'image" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "Type d'événement" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "Priorité (compatibilité Pushover)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "Méthode de notification : sms ou appel" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "Clé du consommateur" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "Secret du consommateur" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "Secret d'accès" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "Jeton d'authentification" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "Identifiants des destinataires" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "URL de l'avatar du bot" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "Courriel de l'utilisateur" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "Jeton d'accès du bot" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "Identifiants des salles" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "Identifiant de l'entreprise" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "Secret de l'application" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "Identifiant de l'agent" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "Service cible" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "Étiquette cible" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "Clé du webhook du bot" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "Identifiant du téléphone de l'expéditeur" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "Un environnement Microsoft Windows local est requis." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "Identifiant du flux de travail" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "Signature" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Utiliser une URL Power Automate" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "Identifiant de routage Power Automate" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "Renvoyer le texte à la ligne" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "Version de l'API" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "Sujets" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "Code de groupe ou d'option" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "Jeton de l'application" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "Jeton ou clé" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "Priorité des informations" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "Priorité des succès" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "Priorité des avertissements" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "Priorité des échecs" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "Libellé du lien" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "Jeton de vérification" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "Flux cible" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "Installez pika pour AMQP 0.9.1, python-qpid-proton pour AMQP 1.0, ou les " "deux." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "Échange" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "Hôte virtuel" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "Persistant" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "Obligatoire" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "Durée de vie" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "En-têtes du message" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "Serveur SMTP" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "Mode sécurisé" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "Mode de sécurité PGP" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "Chemin de la clé publique PGP" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "Chemin de la clé privée PGP" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "Répertoire de clés Web" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "Pièces jointes intégrées" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "Courriel du destinataire" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "Fichier de clé OAuth2" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "URL d'image personnalisée" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "Couleur de la notification" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "Nom réel" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "Pseudonyme" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "Rejoindre les canaux" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "Mode d'authentification" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "Alias de la salle cible" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "Découverte du serveur" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "Forcer le serveur d'origine sur les identifiants de salle" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Version de l'API Matrix" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "Chiffrement de bout en bout" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "Vérification automatique de l'appareil" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "Fichier de clé privée PEM" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "Fichier d'abonnement" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "Serveur XMPP" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "Obtenir la liste de contacts" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "Utiliser le sujet" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "Maintenir la connexion active" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "Pseudonyme MUC" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "Liaison de canal SCRAM-PLUS" caronc-apprise-dfec414/apprise/i18n/hi/000077500000000000000000000000001526720371000177415ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/hi/LC_MESSAGES/000077500000000000000000000000001526720371000215265ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/hi/LC_MESSAGES/apprise.po000066400000000000000000002260441526720371000235410ustar00rootroot00000000000000# Hindi translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: hi\n" "Language-Team: hi \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "SSL सत�यापित करें" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "प�नर�निर�देशन का पालन करें" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "सॉकेट पढ़ने की समय सीमा" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "सॉकेट कनेक�ट करने की समय सीमा" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "कैश की आय�" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "बाध�य MIME प�रकार" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "बाध�य फ़ाइल नाम" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "स�थानीय फ़ाइल" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "वेब पर आधारित" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "स�मृति" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "स�कीमा" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "कोई निर�भरता नहीं।" #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "काम करने के लि� पैकेज आवश�यक हैं।" #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "कार�यक�षमता बढ़ाने के लि� पैकेज की सलाह दी जाती है।" #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "�प का उपयोगकर�ता नाम" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "API क�ंजी" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "लक�षित फ़ोन" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "लक�ष�य" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "प�रेषक" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "SMS मोड" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "बैच मोड" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "होस�ट नाम" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "पोर�ट" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "उपयोगकर�ता नाम" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "पासवर�ड" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "टोकन" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "टैग" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "क�वेरी विधि" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Apprise API का संस�करण" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "HTTP शीर�षलेख" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "उपयोगकर�ता नाम" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "लक�षित कॉल संकेत" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "प�नः भेजने में विलंब" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "क�षेत�रीय सेटिंग" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "लक�षित उपकरण" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "ध�वनि" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "स�तर" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "ध�वनि स�तर" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "क�लिक" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "बैज" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "श�रेणी" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "समूह" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "छवि शामिल करें" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "आइकन का URL" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "कॉल" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "कूटलेखन क�ंजी" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "अतिप�रवाह मोड" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "अधिसूचना स�वरूप" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "इमोजी की व�याख�या करें" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "स�थायी भंडारण" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "समय क�षेत�र" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "सेवा के प�नः प�रयास" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "प�रयासों के बीच प�रतीक�षा" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "वैकल�पिक सेवा" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "प�रेषक का ईमेल" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "लक�षित टीम" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "�स�केलेशन नीति" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "अलर�ट के तरीके" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "टीम प�रतीक�षा समय" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "क�रम संख�या" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "अवधि (मिलीसेकंड)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "ध�ंधलाने का समय (मिलीसेकंड)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "LED संख�या" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "स�रोत ईमेल" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "लक�षित ईमेल" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "उत�तर के लि� ईमेल" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "प�रतिलिपि" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "ग�प�त प�रतिलिपि" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "ग�प�त क�ंजी" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "लक�षित फ़ोन नंबर" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "लक�षित समूह" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "प�रेषक का फ़ोन नंबर" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "मार�ग समूह" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "यूनिकोड वर�ण" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "API ग�प�त क�ंजी" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "प�रेषक की आईडी" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "देश" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "वैधता" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "API टोकन" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "वेबह�क टोकन" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "बॉट का नाम" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "लक�षित चैनल" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "लक�षित बॉट" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "क�षेत�र" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "कंपनी आईडी" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "प�रसारण" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "प�राप�त करने की विधि" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "फ़ाइल इस नाम से जोड़ें" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "पेलोड के अतिरिक�त मान" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "GET पैरामीटर" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "API पह��च टोकन" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "मूल पता" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "प�राथमिकता" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "ट�रांसमीटर समूह" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "libdbus-1.so.x स�थापित होना चाहि�।" #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "DBus अधिसूचना" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "तात�कालिकता" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "X अक�ष" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Y अक�ष" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "वेबह�क की आईडी" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "पाठ से वाणी" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "अवतार छवि" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "अवतार का URL" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "सूत�र की आईडी" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "पादलेख दिखा��" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "पादलेख का लोगो" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "फ़ील�ड का उपयोग करें" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Discord फ़�लैग" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "उपयोगकर�ताओं या भूमिकाओं का उल�लेख करें" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "टेम�पलेट का पथ" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "अन�लग�नकों को �क साथ भेजें" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "टेम�पलेट टोकन" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "उपकरण की क�रम संख�या" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "API मोड" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "अभी ताज़ा करें" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "पाठ हस�ताक�षर" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Base64 में आइकन (पाठ API)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Base64 में छवि (छवि API)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "लिंक" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "किनारा" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "डिदरिंग का प�रकार" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "डिदरिंग कर�नेल" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "कार�य क�ंजी" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "मोडल" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "पथ" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "सर�वर की समय सीमा" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "इंस�टेंस का नाम" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "खाते का SID" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "प�रेषक का फ़ोन नंबर या प�रेषक की आईडी" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "क�षेत�र का नाम" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "पह��च क�ंजी" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "उपयोगकर�ता आईडी को" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "चैनल आईडी को" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "वेबह�क का पथ" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "मोड" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "सूत�र का नाम" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "API उपयोगकर�ता नाम" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "API पासवर�ड" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "पह��च टोकन" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "स�वामी" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "रिपॉज़िटरी" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "प�रकार" #: apprise/plugins/github.py:222 msgid "Number" msgstr "संख�या" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "लेबल�स" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "असाइनी" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "libdbus-1.so.x या libdbus-2.so.x स�थापित होना चाहि�।" #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "GLib अधिसूचना" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "स�थानीय Gnome वातावरण आवश�यक है।" #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Gnome अधिसूचना" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "�कीकरण क�ंजिया�" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "क�रिया" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "ड�प�लिकेट-रोधी क�ंजी" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "मेटाडेटा" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "कार�यस�थान" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "वेबह�क क�ंजी" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "सूत�र क�ंजी" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "प�लेटफ़ॉर�म" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "विषय" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "डेटा प�रविष�टिया�" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "बॉट की आईडी" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "संस�करण" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "पिन किया गया" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "दीर�घकालिक पह��च टोकन" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "अधिसूचना की आईडी" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "पथ का उपसर�ग" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "टोकन या उपयोगकर�ता नाम" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "घटना��" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "टोकन जोड़ें" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "टोकन हटा��" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "�कीकरण क�ंजी" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "मार�ग" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "अलर�ट स�रोत आईडी" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "लक�षित उन�नयन" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "लक�षित समय सारणी" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "लक�षित उपयोगकर�ता" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "लक�ष�य " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "इकाई" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "उपनाम" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "विवरण" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "क�रिया की मैपिंग" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "उपकरण की आईडी" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "उपकरण का नाम" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "स�रोत फ़ोन नंबर" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "अवधि" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "सर�वर क�ंजी" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "उपकरण की API क�ंजी" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "�प की आईडी" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "�प का संस�करण" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "�प का पह��च टोकन" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "कस�टम आइकन" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "आइकन का प�रकार" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "चक�र" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "बॉट टोकन" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "क�लिक का URL" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "रंग" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "केवल Mac OS X 10.8 और उससे न� संस�करणों पर काम करता है। इसके अलावा /usr/local/bin/" "terminal-notifier स�थानीय रूप से उपलब�ध होना चाहि�।" #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "MacOSX अधिसूचना" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "खोलने या क�लिक करने का URL" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Terminal-Notifier का संस�करण" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "प�रेषक" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "उत�तर यहा� भेजें" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "डोमेन" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "प�रेषक का नाम" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "ईमेल शीर�षलेख" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "दृश�यता" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "छिपा ह�आ पाठ" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "भाषा कोड" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "परिणाम कैश में रखें" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "संवेदनशील अन�लग�नक" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "उपयोगकर�ताओं या टैग का उल�लेख करें" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "उपयोगकर�ता" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "लक�षित चैनल की आईडी" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "संदेश के अधिकतम भाग" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "कस�टम संदर�भ" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "लक�षित कतार" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "क�लाइंट की आईडी" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "सत�र का उपयोग करें" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "संदेश बना� रखें" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "टेम�पलेट की आईडी" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "प�रमाणीकरण क�ंजी" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "छोटा URL" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "टेम�पलेट की मैपिंग" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "URL का उपसर�ग" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "कक�ष की आईडी" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "मौन अधिसूचना" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "Discord घटना की आईडी" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "स�रोत" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "परियोजना की आईडी" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "संदेश ह�क" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "IRC के रंग" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "उपसर�ग" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "अभियान का नाम" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "टेम�पलेट का नाम" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "क�लाइंट संदर�भ" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "उच�च ग�णवत�ता" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "फ़ैक�स शीर�षलेख" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "अन�लग�नक" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "अन�लग�नक का फ़ाइल नाम" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "विलंब" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "ईमेल" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "क�रिया��" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "प�रमाणीकरण का प�रकार" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "API लॉगिन" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "उत�तर स�वीकार करें" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "प�रयोजन" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "किरायेदार का डोमेन" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "खाते का ईमेल या ऑब�जेक�ट की आईडी" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "क�लाइंट की ग�प�त क�ंजी" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "भेजे ग� आइटम में सहेजें" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "टेम�पलेट" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "लक�षित प�लेयर की आईडी" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "खंड शामिल करें" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "उपशीर�षक" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "भाषा" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "सामग�री सक�षम करें" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "टेम�पलेट के तर�क विकोडित करें" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "कस�टम डेटा" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "पोस�टबैक डेटा" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "लक�षित उपयोगकर�ता की आईडी" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "लक�षित समूह की आईडी" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "घटक" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "वर�ग" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "गंभीरता" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "कस�टम विवरण" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "�कीकरण की आईडी" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "तृतीय पक�ष की आईडी" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Meta के अतिरिक�त मान" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "म�ख�य क�ंजी" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "उपकरण" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "नामस�थान" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "बैज" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "संदेश का प�रकार" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "लक�षित आईडी" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "लक�षित SMS" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "चैनल" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "प�रमाणीकरण की आईडी" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "प�रमाणीकरण टोकन" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "प�रदाता क�ंजी" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "अधिसूचना क�ंजी" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "अन�प�रयोग क�ंजी" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "अन�प�रयोग की ग�प�त क�ंजी" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "ग�प�त क�ंजी" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "स�थिति दिखा��" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "उपयोगकर�ता क�ंजी" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "URL का शीर�षक" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "आपातकालीन प�नः प�रयास अंतराल" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "समाप�ति" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "उपयोगकर�ता टोकन" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "समूह के विषय" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "चैनल" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "वेबह�क का नाम" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "निजी क�ंजी" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "कंपन" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "सूचना स�तर" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "सफलता स�तर" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "चेतावनी स�तर" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "विफलता स�तर" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "ग�प�त API क�ंजी" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "लक�षित विषय" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "अन�प�रयोग की आईडी" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "लक�षित सबरेडिट" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "किस�म" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "फ़�लेयर की आईडी" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "फ़�लेयर का पाठ" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "क�या यह विज�ञापन है?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "उत�तर भेजें" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "इसमें स�पॉइलर है" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "प�नः प�रस�त�त करने का फ़�लैग" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "प�रेषक का पता" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "चैनल की आईडी" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "अंतर�निहित URL" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "टोकन या पासवर�ड" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "वातावरण" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "प�रमाणीकरण मोड" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "वेबह�क" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "लक�षित कक�ष की आईडी" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "वेबह�क मोड" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "अवतार का उपयोग करें" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "स�विधा" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "PID दर�ज करें" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "संगठन" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "टेम�पलेट का डेटा" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "प�रेषक का नाम" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "सत�र टोकन" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "पह��च क�ंजी की आईडी" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "ग�प�त पह��च क�ंजी" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "लक�षित ईमेल" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "फ़�लैश" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "लेबल" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "सेवा की आईडी" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "सेवा का पासवर�ड" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "स�थान की आईडी" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "प�राप�तकर�ता का फ़ोन नंबर" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "मीडिया का प�रकार" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "समय सीमा" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "वाणी संश�लेषण की आवाज़" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "क�लाइंट क�ंजी" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "अत�यावश�यक अधिसूचना" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "सेवा" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "स�थान" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "चेतावनी परिदृश�य" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "छानना" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "बाहरी आईडी" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "स�थिति" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "लक�ष�य सदस�य" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "सा�ा" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "टैग" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "लक�षित लघ� कोड" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "OAuth पह��च टोकन" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "टोकन A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "टोकन B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "टोकन C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "लक�षित कूटबद�ध आईडी" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "कार�यप�रवाह का पथ" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "पादलेख शामिल करें" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "ब�लॉक का उपयोग करें" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "समय चिह�न शामिल करें" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "संदेश मोड" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "होस�ट" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "लॉगिन" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "लिप�यंतरण करें" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "लक�षित संपर�क" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "केवल परीक�षण" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "गेटवे" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "सार�वजनिक क�ंजी" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "बीज" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "कक�ष" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "लक�षित मार�ग क�ंजी" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "इकाई की आईडी" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "चेतावनी का प�रकार" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "छवि का लिंक" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "ध�वनि का लिंक" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "विशेष पाठ रंग" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "राशि" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "म�द�रा" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "नाम" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "पहचानकर�ता" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "अपलोड" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "STDERR में दर�ज करें" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "लक�षित चैट की आईडी" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "बॉट के स�वामी का पता लगा��" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "वेब पृष�ठ की �लक" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "फ़ोटो/वीडियो अटैचमेंट को समूहित करें" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "विषय के सूत�र की आईडी" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Markdown का संस�करण" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "सामग�री का स�थान" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "सजे संदेश के टेम�पलेट का पथ" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "संदेश प�रोफ़ाइल की आईडी" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "गेटवे की आईडी" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "लक�षित Threema आईडी" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "API का होस�ट नाम" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "छवि का URL" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "घटना का प�रकार" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "प�राथमिकता (Pushover अन�कूलता)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "अधिसूचना की विधि: sms या कॉल" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "उपभोक�ता क�ंजी" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "उपभोक�ता की ग�प�त क�ंजी" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "पह��च की ग�प�त क�ंजी" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "प�रमाणीकरण टोकन" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "प�राप�तकर�ताओं की आईडी" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "बॉट के अवतार का URL" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "उपयोगकर�ता का ईमेल" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "बॉट का पह��च टोकन" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "कक�षों की आईडी" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "कंपनी की आईडी" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "�प की ग�प�त क�ंजी" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "�जेंट की आईडी" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "लक�षित विभाग" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "लक�षित टैग" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "बॉट की वेबह�क क�ंजी" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "प�रेषक के फ़ोन की आईडी" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "स�थानीय Microsoft Windows वातावरण आवश�यक है।" #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "कार�यप�रवाह की आईडी" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "हस�ताक�षर" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Power Automate का URL उपयोग करें" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "Power Automate की मार�ग आईडी" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "पाठ लपेटें" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "API का संस�करण" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "विषय" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "समूह या विकल�प कोड" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "�प टोकन" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "टोकन या क�ंजी" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "सूचना की प�राथमिकता" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "सफलता की प�राथमिकता" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "चेतावनी की प�राथमिकता" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "विफलता की प�राथमिकता" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "लिंक का लेबल" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "सत�यापन टोकन" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "लक�षित धारा" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "AMQP 0.9.1 के लि� pika, AMQP 1.0 के लि� python-qpid-proton, या दोनों इंस�टॉल करें।" #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "�क�सचेंज" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "वर�च�अल होस�ट" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "स�थायी" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "अनिवार�य" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "जीवनकाल (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "संदेश हेडर" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "SMTP सर�वर" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "स�रक�षित मोड" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "PGP स�रक�षा मोड" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "PGP सार�वजनिक क�ंजी का पथ" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "PGP निजी क�ंजी का पथ" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "वेब क�ंजी निर�देशिका" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "अंतर�निहित अन�लग�नक" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "प�राप�तकर�ता का ईमेल" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "OAuth2 क�ंजी फ़ाइल" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "कस�टम छवि का URL" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "अधिसूचना का रंग" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "वास�तविक नाम" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "उपनाम" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "चैनल में शामिल हों" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "प�रमाणीकरण मोड" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "लक�षित कक�ष का उपनाम" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "सर�वर की खोज" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "कक�ष आईडी पर मूल सर�वर बाध�य करें" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Matrix API का संस�करण" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "सिरे से सिरे तक कूटलेखन" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "उपकरण का स�वचालित सत�यापन" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "PEM निजी क�ंजी फ़ाइल" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "सदस�यता फ़ाइल" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "XMPP सर�वर" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "संपर�क सूची प�राप�त करें" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "विषय का उपयोग करें" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "कनेक�शन बना� रखें" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "MUC उपनाम" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "SCRAM-PLUS चैनल बंधन" caronc-apprise-dfec414/apprise/i18n/id/000077500000000000000000000000001526720371000177355ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/id/LC_MESSAGES/000077500000000000000000000000001526720371000215225ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/id/LC_MESSAGES/apprise.po000066400000000000000000002012541526720371000235310ustar00rootroot00000000000000# Indonesian translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: id\n" "Language-Team: id \n" "Plural-Forms: nplurals=1; plural=0;\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "Verifikasi SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "Ikuti pengalihan" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "Batas waktu baca soket" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "Batas waktu sambung soket" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "Umur tembolok" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "Jenis MIME yang dipaksakan" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "Nama berkas yang dipaksakan" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "Berkas lokal" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "Berbasis web" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "Memori" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "Skema" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "Tidak ada dependensi." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "Paket diperlukan agar dapat berfungsi." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "Paket disarankan untuk meningkatkan fungsi." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "Nama pengguna aplikasi" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "Kunci API" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "Telepon tujuan" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "Tujuan" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "Pengirim" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "Mode SMS" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "Mode gabungan" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "Nama host" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "Porta" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "Nama pengguna" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "Kata sandi" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "Token" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "Tanda" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "Metode kueri" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Versi API Apprise" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "Tajuk HTTP" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "Nama pengguna" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "Tanda panggil tujuan" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "Tunda pengiriman ulang" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "Pengaturan lokal" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "Perangkat tujuan" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "Suara" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "Tingkat" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "Volume" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "Klik" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "Lencana" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "Kategori" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "Grup" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "Sertakan gambar" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "URL ikon" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "Panggilan" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "Kunci enkripsi" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "Mode luapan" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "Format pemberitahuan" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "Tafsirkan emoji" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "Penyimpanan permanen" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "Zona waktu" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "Percobaan ulang layanan" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "Tunggu antar percobaan" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "Layanan opsional" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "Surel pengirim" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "Tim tujuan" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "Kebijakan Eskalasi" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "Metode Peringatan" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "Waktu Tunggu Tim" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "Nomor seri" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "Durasi (ms)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "Waktu peredupan (ms)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "Nomor LED" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "Surel sumber" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "Surel tujuan" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "Surel untuk balasan" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "Salinan" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "Salinan tersembunyi" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "Rahasia" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "Nomor telepon tujuan" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "Grup tujuan" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "Nomor telepon pengirim" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "Grup perutean" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Karakter Unicode" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "Rahasia API" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "ID pengirim" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "Negara" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "masa berlaku" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "Token API" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Token webhook" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "Nama bot" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "Saluran tujuan" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "Bot tujuan" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "Wilayah" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "ID perusahaan" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "Siaran" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "Metode pengambilan" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "Lampirkan berkas sebagai" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "Tambahan muatan" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "Parameter GET" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "Token akses API" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "Alamat asal" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "Prioritas" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "Grup pemancar" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "libdbus-1.so.x harus dipasang." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "Pemberitahuan DBus" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "Tingkat urgensi" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "Sumbu X" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Sumbu Y" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "ID webhook" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "Pengubah teks ke suara" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "Gambar avatar" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "URL avatar" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "ID utas" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "Tampilkan catatan kaki" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "Logo catatan kaki" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "Gunakan bidang" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Tanda Discord" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "Sebut pengguna atau peran" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "Jalur templat" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "Gabungkan lampiran" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "Token templat" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "Nomor seri perangkat" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "Mode API" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Segarkan sekarang" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Tanda tangan teks" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Ikon dalam Base64 (API teks)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Gambar dalam Base64 (API gambar)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "Tautan" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "Bingkai" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Jenis dithering" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Kernel dithering" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "Kunci tugas" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "Modal" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "Jalur" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Batas waktu server" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "Nama instansi" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "SID akun" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "Nomor telepon pengirim atau ID pengirim" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "Nama wilayah" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "Kunci akses" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "Ke ID pengguna" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "Ke ID saluran" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "Jalur webhook" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "Mode" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "Nama utas" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "Nama pengguna API" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "Kata sandi API" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "Token akses" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "Pemilik" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "Repositori" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "Jenis" #: apprise/plugins/github.py:222 msgid "Number" msgstr "Nomor" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "Label" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "Penerima tugas" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "libdbus-1.so.x atau libdbus-2.so.x harus dipasang." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "Pemberitahuan GLib" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "Diperlukan lingkungan Gnome lokal." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Pemberitahuan Gnome" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "Kunci integrasi" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "Tindakan" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "Kunci deduplikasi" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "Metadata" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "Ruang kerja" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Kunci webhook" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "Kunci utas" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "Platform" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "Topik" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "Entri data" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "ID bot" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Versi" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Disematkan" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "Token akses jangka panjang" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "ID pemberitahuan" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "Awalan jalur" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "Token atau nama pengguna" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "Peristiwa" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "Tambah token" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "Hapus token" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "Kunci integrasi" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "Rute" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "ID Sumber Peringatan" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "Eskalasi tujuan" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "Jadwal tujuan" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "Pengguna tujuan" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "Tujuan " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "Entitas" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "Alias" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "Rincian" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "Pemetaan tindakan" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "ID perangkat" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "Nama perangkat" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "Nomor telepon sumber" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Durasi" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "Kunci server" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "Kunci API perangkat" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "ID aplikasi" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "Versi aplikasi" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "Token akses aplikasi" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "Ikon khusus" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "Jenis ikon" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "Siklus" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "Token bot" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "URL klik" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "Warna" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Hanya bekerja dengan Mac OS X 10.8 dan yang lebih baru. Selain itu, /usr/" "local/bin/terminal-notifier harus dapat diakses secara lokal." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "Pemberitahuan MacOSX" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "URL untuk dibuka atau diklik" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Versi Terminal-Notifier" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "Pengirim" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "Balas ke" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "Domain" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "Nama pengirim" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "Tajuk surel" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "Keterlihatan" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "Teks tersembunyi" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "Kode bahasa" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "Simpan hasil di tembolok" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "Lampiran sensitif" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "Sebut pengguna atau tanda" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "Pengguna" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "ID saluran tujuan" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "Jumlah maksimum bagian pesan" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "Rujukan khusus" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "Antrean tujuan" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "ID klien" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "Gunakan sesi" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "Simpan pesan" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "ID templat" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "Kunci autentikasi" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "URL pendek" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "Pemetaan templat" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "Awalan URL" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "ID ruang" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "Pemberitahuan senyap" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "ID peristiwa Discord" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "Sumber" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "ID proyek" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "Hook pesan" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "Warna IRC" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "Awalan" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "Nama kampanye" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "Nama templat" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "Rujukan klien" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "Kualitas tinggi" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "Tajuk faks" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "Lampiran" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "Nama berkas lampiran" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "Tunda" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "Surel" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "Tindakan" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "Jenis autentikasi" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "Nama masuk API" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "Terima balasan" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "Tujuan penggunaan" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "Domain penyewa" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "Surel akun atau ID objek" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "Rahasia klien" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "Simpan ke item terkirim" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "Templat" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "ID pemain tujuan" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "Sertakan segmen" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "Subjudul" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "Bahasa" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "Aktifkan isi" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "Uraikan argumen templat" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "Data khusus" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "Data umpan balik" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "ID pengguna tujuan" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "ID grup tujuan" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "Komponen" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "Kelas" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "Tingkat keparahan" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "Rincian khusus" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "ID integrasi" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "ID pihak ketiga" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Tambahan Meta" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "Kunci utama" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "Perangkat" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "Ruang nama" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "Lencana" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "Jenis pesan" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "ID tujuan" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "SMS tujuan" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "Saluran" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "ID autentikasi" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "Token autentikasi" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "Kunci penyedia" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "Kunci pemberitahuan" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "Kunci aplikasi" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "Rahasia aplikasi" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "Kunci rahasia" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "Tampilkan status" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "Kunci pengguna" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "Judul URL" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "Selang percobaan ulang darurat" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "Kedaluwarsa" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "Token pengguna" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "Topik grup" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "Saluran" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "Nama webhook" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "Kunci privat" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "Getaran" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "Tingkat informasi" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "Tingkat keberhasilan" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "Tingkat peringatan" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "Tingkat kegagalan" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "Kunci API rahasia" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "Topik tujuan" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "ID aplikasi" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "Subreddit tujuan" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "Macam" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "ID flair" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "Teks flair" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "Apakah iklan?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "Kirim balasan" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "Mengandung spoiler" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "Tanda pengiriman ulang" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "Alamat pengirim" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "ID saluran" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "URL tersemat" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "Token atau kata sandi" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "Lingkungan" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "Mode autentikasi" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "ID ruang tujuan" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Mode webhook" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "Gunakan avatar" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "Fasilitas" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "Catat PID" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "Organisasi" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "Data templat" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "Nama pengirim" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "Token sesi" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "ID kunci akses" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "Kunci akses rahasia" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "Surel tujuan" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "Kilat" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "Label" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "ID layanan" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "Kata sandi layanan" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "ID ruang" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "Nomor telepon penerima" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "Jenis media" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "Batas waktu" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "Suara pengubah teks" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "Kunci klien" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "Pemberitahuan penting" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "Layanan" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "Lokasi" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "Skenario peringatan" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "Penyaringan" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "ID eksternal" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "Status" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "Anggota Tujuan" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "Dibagikan" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "Tag" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "Kode pendek tujuan" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "Token akses OAuth" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "Token A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "Token B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "Token C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "ID tersandi tujuan" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "Jalur alur kerja" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "Sertakan catatan kaki" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "Gunakan blok" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "Sertakan cap waktu" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "Mode pesan" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "Host" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "Nama masuk" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "Alihkan huruf" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "Kontak tujuan" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "Hanya uji" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "Gerbang" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "Kunci publik" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "Benih" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "Ruang" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "Kunci perutean tujuan" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "ID entitas" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "Jenis peringatan" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "Tautan gambar" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "Tautan suara" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "Warna teks khusus" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "Jumlah" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "Mata uang" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "Nama" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "Pengenal" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "Unggah" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "Catat ke STDERR" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "ID obrolan tujuan" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "Deteksi pemilik bot" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "Pratinjau halaman web" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "Kelompokkan lampiran foto/video" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "ID utas topik" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Versi Markdown" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "Penempatan isi" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "Jalur templat pesan berformat" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "ID profil perpesanan" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "ID gerbang" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "ID Threema tujuan" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "Nama host API" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "URL gambar" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "Jenis peristiwa" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "Prioritas (kompatibilitas Pushover)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "Metode pemberitahuan: sms atau panggilan" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "Kunci konsumen" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "Rahasia konsumen" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "Rahasia akses" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "Token autentikasi" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "ID penerima" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "URL avatar bot" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "Surel pengguna" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "Token akses bot" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "ID ruang" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "ID perusahaan" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "Rahasia aplikasi" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "ID agen" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "Departemen tujuan" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "Tanda tujuan" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "Kunci webhook bot" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "ID telepon pengirim" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "Diperlukan lingkungan Microsoft Windows lokal." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "ID alur kerja" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "Tanda tangan" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Gunakan URL Power Automate" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "ID perutean Power Automate" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "Bungkus teks" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "Versi API" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "Topik" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "Kode grup atau pilihan" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "Token aplikasi" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "Token atau kunci" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "Prioritas informasi" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "Prioritas keberhasilan" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "Prioritas peringatan" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "Prioritas kegagalan" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "Label tautan" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "Token verifikasi" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "Aliran tujuan" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "Instal pika untuk AMQP 0.9.1, python-qpid-proton untuk AMQP 1.0, atau " "keduanya." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "Exchange" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "Host Virtual" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "Persisten" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "Wajib" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "Waktu Hidup (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "Header Pesan" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "Server SMTP" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "Mode aman" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "Mode keamanan PGP" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "Jalur kunci publik PGP" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "Jalur kunci privat PGP" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "Direktori kunci web" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "Lampiran tersemat" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "Surel penerima" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "Berkas kunci OAuth2" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "URL gambar khusus" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "Warna pemberitahuan" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "Nama asli" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "Nama panggilan" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "Masuk ke saluran" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "Mode autentikasi" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "Alias ruang tujuan" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "Penemuan server" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "Paksa server asal pada ID ruang" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Versi API Matrix" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "Enkripsi ujung ke ujung" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "Verifikasi perangkat otomatis" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "Berkas kunci privat PEM" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "Berkas langganan" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "Server XMPP" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "Ambil daftar kontak" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "Gunakan subjek" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "Jaga sambungan tetap hidup" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "Nama panggilan MUC" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "Pengikatan saluran SCRAM-PLUS" caronc-apprise-dfec414/apprise/i18n/it/000077500000000000000000000000001526720371000177555ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/it/LC_MESSAGES/000077500000000000000000000000001526720371000215425ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/it/LC_MESSAGES/apprise.po000066400000000000000000002037771526720371000235650ustar00rootroot00000000000000# Italian translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # albano battistella , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: it\n" "Language-Team: it \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "Verifica SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "Segui i reindirizzamenti" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "Timeout di lettura del socket" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "Timeout di connessione del socket" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "Età della cache" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "Mime Type forzato" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "Nome file forzato" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "File locale" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "Basato sul Web" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "Memoria" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "Schema" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "Nessuna dipendenza." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "I pacchetti sono richiesti per il funzionamento." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "I pacchetti sono consigliati per migliorare le funzionalità." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "Nome utente dell'app" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "Chiave API" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "Telefono di destinazione" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "Destinatari" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "Da" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "Modalità SMS" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "Modalità batch" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "Nome host" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "Porta" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "Nome utente" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "Password" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "Token" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "Tag" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "Metodo di query" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Versione dell'API di Apprise" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "Intestazione HTTP" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "Nome utente" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "Nominativo di destinazione" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "Ritardo di reinvio" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "Impostazione locale" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "Dispositivo di destinazione" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "Suono" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "Livello" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "Volume" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "Clic" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "Badge" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "Categoria" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "Gruppo" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "Includi immagine" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "URL dell'icona" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "Chiamata" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "Chiave di crittografia" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "Modalità di overflow" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "Formato di notifica" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "Interpreta emoji" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "Archiviazione persistente" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "Fuso orario" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "Tentativi del servizio" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "Attesa tra i tentativi" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "Servizio opzionale" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "Email del mittente" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "Team di destinazione" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "Criterio di escalation" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "Metodi di avviso" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "Attesa del team" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "Numero di serie" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "Durata (ms)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "Tempo di dissolvenza (ms)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "Numero del LED" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "Email di origine" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "Email di destinazione" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "Email di risposta" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "Copia conoscenza" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "Copia conoscenza nascosta" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "Segreto" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "Numero di telefono di destinazione" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "Gruppo di destinazione" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "Numero di telefono mittente" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "Gruppo di instradamento" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Caratteri Unicode" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "Segreto API" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "ID mittente" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "Paese" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "validità" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "Token API" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Token Webhook" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "Nome del bot" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "Canale di destinazione" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "Bot di destinazione" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "Regione" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "ID azienda" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "Trasmissione" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "Metodo di recupero" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "Allega file come" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "Elementi extra del payload" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "Parametri GET" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "Token di accesso API" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "Indirizzo di origine" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "Priorità" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "Gruppi di trasmissione" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "La libreria libdbus-1.so.x deve essere installata." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "Notifica DBus" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "Urgenza" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "Asse X" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Asse Y" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "ID Webhook" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "Sintesi vocale" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "Immagine dell'avatar" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "URL dell'avatar" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "ID discussione" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "Mostra piè di pagina" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "Logo del piè di pagina" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "Usa campi" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Flag di Discord" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "Menziona utenti o ruoli" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "Percorso del modello" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "Allegati in batch" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "Token del modello" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "Numero di serie del dispositivo" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "Modalità API" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Aggiorna ora" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Firma di testo" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Icona Base64 (Text API)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Immagine Base64 (Image API)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "Collegamento" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "Bordo" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Tipo di dither" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Kernel di dither" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "Chiave dell'attività" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "Finestra modale" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "Percorso" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Timeout del server" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "Nome dell'istanza" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "SID dell'account" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "Numero telefonico mittente / ID mittente" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "Nome della regione" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "Chiave di accesso" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "ID utente destinatario" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "ID canale destinatario" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "Percorso del Webhook" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "Modalità" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "Nome della discussione" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "Nome utente API" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "Password API" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "Token di accesso" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "Proprietario" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "Repository" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "Tipo" #: apprise/plugins/github.py:222 msgid "Number" msgstr "Numero" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "Etichette" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "Assegnatari" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "La libreria libdbus-1.so.x o libdbus-2.so.x deve essere installata." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "Notifica GLib" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "È richiesto un ambiente locale Gnome." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Notifica Gnome" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "Chiavi di integrazione" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "Azione" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "Chiave di deduplicazione" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "Metadati" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "Area di lavoro" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Chiave del Webhook" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "Chiave della discussione" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "Piattaforma" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "Argomento" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "Voci di dati" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "ID del bot" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Versione" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Permanente" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "Token di accesso a lungo termine" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "ID notifica" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "Prefisso del percorso" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "Token o Nome utente" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "Eventi" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "Aggiungi token" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "Rimuovi token" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "Chiave di integrazione" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "Rotta" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "ID origine avvisi" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "Escalation di destinazione" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "Pianificazione di destinazione" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "Utente di destinazione" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "Destinatari " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "Entità" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "Alias" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "Dettagli" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "Mappatura delle azioni" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "ID dispositivo" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "Nome dispositivo" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "Numero di telefono mittente" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Durata" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "Chiave del server" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "Chiave API del dispositivo" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "ID dell'app" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "Versione dell'app" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "Token di accesso dell'app" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "Icona personalizzata" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "Tipo di icona" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "Cicli" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "Token del bot" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "URL del clic" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "Colore" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Funziona solo con Mac OS X 10.8 e versioni successive. Richiede inoltre che /" "usr/local/bin/terminal-notifier sia accessibile localmente." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "Notifica MacOSX" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "Apri/Fai clic su URL" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Versione di Terminal-Notifier" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "Mittente" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "Rispondi a" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "Dominio" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "Nome del mittente" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "Intestazione dell'email" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "Visibilità" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "Testo dello spoiler" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "Codice lingua" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "Risultati della cache" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "Allegati sensibili" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "Menziona utenti o tag" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "Utente" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "ID del canale di destinazione" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "Numero massimo di parti del messaggio" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "Riferimento personalizzato" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "Coda di destinazione" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "ID client" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "Usa sessione" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "Conserva i messaggi" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "ID del modello" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "Chiave di autenticazione" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "URL breve" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "Mappatura del modello" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "Prefisso URL" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "ID stanza" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "Notifica silenziosa" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "ID evento Discord" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "Origine" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "ID progetto" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "Hook del messaggio" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "Colori IRC" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "Prefisso" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "Nome della campagna" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "Nome del modello" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "Riferimento client" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "Alta qualità" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "Intestazione fax" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "Allega" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "Nome del file allegato" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "Ritardo" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "Email" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "Azioni" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "Tipo di autenticazione" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "Login API" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "Accetta risposte" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "Scopo" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "Dominio Tenant" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "Email dell'account o Object ID" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "Segreto client" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "Salva in Posta inviata" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "Modello" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "Player ID di destinazione" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "Includi segmento" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "Sottotitolo" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "Lingua" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "Abilita contenuti" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "Decodifica argomenti del modello" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "Dati personalizzati" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "Dati di postback" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "ID utente di destinazione" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "ID gruppo di destinazione" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "Componente" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "Classe" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "Gravità" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "Dettagli personalizzati" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "ID integrazione" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "ID di terze parti" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Elementi meta extra" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "Chiave principale" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "Dispositivo" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "Spazio dei nomi" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "Badge" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "Tipo di messaggio" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "ID di destinazione" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "SMS di destinazione" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "Canali" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "ID di autenticazione" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "Token di autenticazione" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "Chiave del fornitore" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "Chiave di notifica" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "Chiave dell'applicazione" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "Segreto dell'applicazione" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "Chiave segreta" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "Mostra stato" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "Chiave dell'utente" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "Titolo dell'URL" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "Intervallo tentativi di emergenza" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "Scadenza" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "Token utente" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "Argomenti del gruppo" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "Canale" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "Nome del Webhook" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "Chiave privata" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "Vibrazione" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "Livello info" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "Livello successo" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "Livello avviso" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "Livello errore" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "Chiave API segreta" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "Argomento di destinazione" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "ID applicazione" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "Subreddit di destinazione" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "Tipo" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "ID Flair" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "Testo Flair" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "È un annuncio?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "Invia risposte" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "È uno spoiler" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "Flag di rinvio" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "Indirizzo mittente" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "ID canale" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "URL incorporato" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "Token o password" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "Ambiente" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "Modalità di autenticazione" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "ID stanza di destinazione" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Modalità Webhook" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "Usa avatar" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "Servizio" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "Registra il PID" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "Organizzazione" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "Dati del modello" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "Nome del mittente" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "Token di sessione" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "ID della chiave di accesso" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "Chiave di accesso segreta" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "Email di destinazione" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "Flash" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "Etichetta" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "ID servizio" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "Password del servizio" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "ID spazio" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "Numero di telefono del destinatario" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "Tipo di contenuto" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "Tempo di attesa" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "Voce TTS" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "Chiave del client" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "Notifica critica" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "Servizio" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "Posizione" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "Scenario di avviso" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "Filtraggio" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "ID esterno" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "Stato" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "Membro di destinazione" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "Condiviso" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "Etichetta" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "Short Code di destinazione" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "Token di accesso OAuth" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "Token A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "Token B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "Token C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "ID codificato di destinazione" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "Percorso del workflow" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "Includi piè di pagina" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "Usa blocchi" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "Includi timestamp" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "Modalità messaggio" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "Host" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "Login" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "Traslittera" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "Contatto di destinazione" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "Solo test" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "Gateway" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "Chiave pubblica" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "Seme" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "Stanze" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "Routing Key di destinazione" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "ID entità" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "Tipo di avviso" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "Collegamento immagine" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "Collegamento audio" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "Colore testo speciale" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "Importo" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "Valuta" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "Nome" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "Identificatore" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "Caricamento" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "Registra su STDERR" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "ID chat di destinazione" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "Rileva proprietario del bot" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "Anteprima pagina web" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "Raggruppa allegati foto/video" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "ID discussione dell'argomento" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Versione Markdown" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "Posizionamento del contenuto" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "Percorso del modello di messaggio formattato" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "ID del profilo di messaggistica" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "ID Gateway" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "ID Threema di destinazione" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "Nome host dell'API" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "URL dell'immagine" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "Tipo di evento" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "Priorità (compatibilità con Pushover)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "Metodo di notifica: sms o chiamata" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "Chiave del consumatore" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "Segreto del consumatore" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "Segreto di accesso" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "Token di autenticazione" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "ID dei destinatari" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "URL dell'avatar del bot" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "Email utente" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "Token di accesso del bot" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "ID stanze" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "ID aziendale" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "Segreto dell'app" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "ID dell'agente" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "Dipartimento di destinazione" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "Tag di destinazione" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "Chiave Webhook del bot" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "ID telefono mittente" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "È richiesto un ambiente locale Microsoft Windows." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "ID workflow" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "Firma" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Usa URL di Power Automate" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "ID di routing di Power Automate" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "A capo automatico" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "Versione API" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "Argomenti" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "Codice del gruppo o dell'opzione" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "Token dell'app" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "Token o chiave" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "Priorità delle informazioni" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "Priorità dei successi" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "Priorità degli avvisi" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "Priorità degli errori" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "Etichetta del collegamento" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "Token di verifica" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "Stream di destinazione" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "Installa pika per AMQP 0.9.1, python-qpid-proton per AMQP 1.0, o entrambi." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "Exchange" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "Host virtuale" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "Persistente" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "Obbligatorio" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "Durata (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "Intestazioni del messaggio" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "Server SMTP" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "Modalità sicura" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "Modalità di sicurezza PGP" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "Percorso della chiave pubblica PGP" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "Percorso della chiave privata PGP" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "Directory delle chiavi web" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "Allegati incorporati" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "Email del destinatario" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "KeyFile OAuth2" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "URL dell'immagine personalizzata" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "Colore della notifica" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "Nome reale" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "Nickname" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "Entra nei canali" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "Modalità di autenticazione" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "Alias della stanza di destinazione" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "Rilevamento del server" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "Forza Home Server sugli ID delle stanze" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Versione API Matrix" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "Crittografia end-to-end" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "Verifica automatica del dispositivo" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "KeyFile privato PEM" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "File di iscrizione" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "Server XMPP" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "Ottieni l'elenco dei contatti" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "Usa oggetto" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "Mantieni attiva la connessione" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "Nickname MUC" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "Channel Binding SCRAM-PLUS" caronc-apprise-dfec414/apprise/i18n/ja/000077500000000000000000000000001526720371000177335ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/ja/LC_MESSAGES/000077500000000000000000000000001526720371000215205ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/ja/LC_MESSAGES/apprise.po000066400000000000000000002077311526720371000235350ustar00rootroot00000000000000# Japanese translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: ja\n" "Language-Team: ja \n" "Plural-Forms: nplurals=1; plural=0;\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "SSL を検証" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "リダイレクト�従�" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "ソケット読��り�タイムアウト" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "ソケット接続�タイムアウト" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "キャッシュ�有効期間" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "強制�る MIME タイプ" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "強制�るファイル�" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "ローカルファイル" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "ウェブ上" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "メモリー" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "スキーマ" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "�存関係��り��ん。" #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "動作��パッケージ�必���。" #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "機能を高�る���パッケージ�導入を推奨���。" #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "アプリ�ユーザー�" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "API キー" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "宛先�電話" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "宛先" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "�信元" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "SMS モード" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "一括モード" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "ホスト�" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "�ート" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "ユーザー�" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "パスワード" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "トークン" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "タグ" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "クエリー方�" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Apprise API ��ージョン" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "HTTP ヘッダー" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "ユーザー�" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "宛先�コールサイン" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "�����待�時間" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "ロケール" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "宛先�端末" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "サウンド" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "レベル" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "音�" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "クリック" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "�ッジ" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "カテゴリー" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "グループ" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "画�を��る" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "アイコン� URL" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "通話" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "暗�化キー" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "超�時�動作" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "通知�形�" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "絵文字を解釈�る" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "永続ストレージ" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "タイムゾーン" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "サービス��試行回数" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "�試行�間隔" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "任��サービス" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "�信元�メール" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "宛先��ーム" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "エスカレーション�リシー" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "アラート方法" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "�ーム待機時間" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "シリアル番�" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "継続時間 (ミリ秒)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "フェード時間 (ミリ秒)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "LED 番�" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "�信元�メールアドレス" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "宛先�メールアドレス" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "返信先�メールアドレス" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "カーボンコピー" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "ブラインドカーボンコピー" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "シークレット" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "宛先�電話番�" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "宛先�グループ" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "�信元�電話番�" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "ルーティンググループ" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Unicode 文字" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "API シークレット" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "�信者 ID" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "国" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "有効期間" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "API トークン" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Webhook �トークン" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "ボット���" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "宛先��ャン�ル" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "宛先�ボット" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "リージョン" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "会社 ID" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "ブロードキャスト" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "�得方�" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "添付ファイル�" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "ペイロード�追加項目" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "GET パラメーター" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "API アクセストークン" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "発信元アドレス" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "優先度" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "�信機グループ" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "libdbus-1.so.x �インストール�れ��る必���り��。" #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "DBus 通知" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "緊急度" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "X 軸" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Y 軸" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "Webhook � ID" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "音声読�上�" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "ア�ター画�" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "ア�ター� URL" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "スレッド� ID" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "フッターを表示" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "フッター�ロゴ" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "フィールドを使�" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Discord �フラグ" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "ユーザー���ロール�メンション" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "テンプレート�パス" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "添付ファイルを���る" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "テンプレート�トークン" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "端末�シリアル番�" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "API モード" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "今��更新" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "テキスト�署�" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Base64 �アイコン (テキスト API)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Base64 �画� (画� API)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "リンク" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "枠線" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "ディザリング�種類" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "ディザリング�カー�ル" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "タスクキー" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "モーダル" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "パス" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "サー�ー�タイムアウト" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "インスタンス�" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "アカウント� SID" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "�信元�電話番�����信者 ID" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "リージョン�" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "アクセスキー" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "宛先�ユーザー ID" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "宛先��ャン�ル ID" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "Webhook �パス" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "モード" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "スレッド�" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "API �ユーザー�" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "API �パスワード" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "アクセストークン" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "所有者" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "リ�ジトリ" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "種類" #: apprise/plugins/github.py:222 msgid "Number" msgstr "番�" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "ラベル一覧" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "担当者" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "" "libdbus-1.so.x ��� libdbus-2.so.x �インストール�れ��る必���り��。" #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "GLib 通知" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "ローカル� Gnome 環境�必���。" #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Gnome 通知" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "連�キー一覧" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "アクション" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "�複排除キー" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "メタデータ" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "ワークスペース" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Webhook �キー" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "スレッド�キー" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "プラットフォーム" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "トピック" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "データ項目" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "ボット� ID" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "�ージョン" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "固定表示" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "長期間有効�アクセストークン" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "通知� ID" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "パス�接頭辞" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "トークン���ユーザー�" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "イベント" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "トークンを追加" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "トークンを削除" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "連�キー" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "ルート" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "アラートソースID" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "宛先�エスカレーション" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "宛先�スケジュール" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "宛先�ユーザー" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "宛先 " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "エンティティー" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "エイリアス" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "詳細" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "アクション�対応付�" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "端末� ID" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "端末�" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "発信元�電話番�" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "継続時間" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "サー�ーキー" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "端末� API キー" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "アプリ� ID" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "アプリ��ージョン" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "アプリ�アクセストークン" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "カスタムアイコン" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "アイコン�種類" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "繰り返�回数" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "ボット�トークン" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "クリック時� URL" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "色" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Mac OS X 10.8 以����動作���。���/usr/local/bin/terminal-notifier �" "ローカル�アクセス��る必���り��。" #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "MacOSX 通知" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "開�����クリック�る URL" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Terminal-Notifier ��ージョン" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "�信者" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "返信先" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "ドメイン" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "�信者�" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "メール�ヘッダー" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "公開範囲" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "隠�テキスト" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "言語コード" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "�果をキャッシュ�る" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "�り扱��注��る添付ファイル" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "ユーザー���タグ�メンション" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "ユーザー" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "宛先��ャン�ル ID" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "メッセージ�最大分割数" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "カスタム��照番�" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "宛先�キュー" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "クライアント ID" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "セッションを使�" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "メッセージを���る" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "テンプレート� ID" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "�証キー" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "短縮 URL" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "テンプレート�対応付�" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "URL �接頭辞" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "ルーム� ID" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "サイレント通知" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "Discord �イベント ID" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "ソース" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "プロジェクト� ID" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "メッセージフック" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "IRC �色" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "接頭辞" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "キャンペーン�" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "テンプレート�" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "クライアント��照番�" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "高�質" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "FAX �ヘッダー" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "添付ファイル" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "添付ファイル���" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "�延" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "メール" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "アクション" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "�証�種類" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "API �ログイン�" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "返信を��付�る" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "用途" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "テナント�ドメイン" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "アカウント�メール���オブジェクト ID" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "クライアントシークレット" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "�信済�アイテム��存" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "テンプレート" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "宛先�プレーヤー ID" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "セグメントを��る" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "サブタイトル" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "言語" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "本文を有効��る" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "テンプレート引数をデコード�る" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "カスタムデータ" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "�スト�ックデータ" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "宛先�ユーザー ID" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "宛先�グループ ID" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "コン�ー�ント" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "クラス" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "�大度" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "カスタム�詳細" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "連�� ID" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "サードパーティー� ID" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Meta �追加項目" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "マスターキー" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "端末" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "��空間" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "�ッジ" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "メッセージ�種類" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "宛先� ID" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "宛先� SMS" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "�ャン�ル" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "�証 ID" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "�証トークン" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "プロ�イダー�キー" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "通知キー" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "アプリケーションキー" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "アプリケーションシークレット" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "シークレットキー" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "ステータスを表示" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "ユーザーキー" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "URL �タイトル" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "緊急通知��試行間隔" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "有効期�" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "ユーザートークン" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "グループ�トピック" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "�ャン�ル" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "Webhook ���" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "秘密�" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "�イブレーション" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "情報レベル" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "�功レベル" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "警告レベル" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "失敗レベル" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "シークレット API キー" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "宛先�トピック" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "アプリケーション� ID" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "宛先�サブレディット" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "種別" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "フレア� ID" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "フレア�テキスト" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "広告���?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "返信を�る" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "�タ�レを�む" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "�投稿フラグ" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "�信元アドレス" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "�ャン�ル� ID" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "埋�込む URL" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "トークン���パスワード" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "環境" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "�証モード" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "宛先�ルーム ID" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Webhook モード" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "ア�ターを使�" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "ファシリティー" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "PID を記録�る" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "組織" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "テンプレート�データ" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "�信者�" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "セッショントークン" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "アクセスキー ID" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "シークレットアクセスキー" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "宛先�メールアドレス" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "フラッシュ" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "ラベル" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "サービス ID" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "サービス�パスワード" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "スペース� ID" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "�信者�電話番�" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "メディア�種類" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "タイムアウト" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "読�上�音声" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "クライアントキー" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "���通知" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "サービス" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "場所" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "通知シナリオ" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "フィルタリング" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "外部 ID" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "ステータス" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "宛先�メン�ー" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "共有" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "タグ" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "宛先�ショートコード" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "OAuth アクセストークン" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "トークン A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "トークン B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "トークン C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "エンコード�れ�宛先 ID" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "ワークフロー�パス" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "フッターを��る" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "ブロックを使�" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "タイムスタンプを��る" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "メッセージ�モード" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "ホスト" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "ログイン�" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "文字を転写�る" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "宛先�連絡先" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "テスト��" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "ゲートウェイ" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "公開�" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "シード" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "ルーム" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "宛先�ルーティングキー" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "エンティティー� ID" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "アラート�種類" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "画��リンク" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "音声�リンク" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "特別�テキスト�色" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "金�" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "通貨" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "��" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "識別�" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "アップロード" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "STDERR �記録�る" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "宛先��ャット ID" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "ボット�所有者を検出�る" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "ウェブページ�プレビュー" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "写真/動画�添付ファイルを���る" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "トピック�スレッド ID" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Markdown ��ージョン" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "本文��置" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "リッ�メッセージ�テンプレート�パス" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "メッセージプロファイル� ID" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "ゲートウェイ� ID" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "宛先� Threema ID" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "API �ホスト�" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "画�� URL" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "イベント�種類" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "優先度 (Pushover 互�)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "通知方法: sms ���通話" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "コンシューマーキー" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "コンシューマーシークレット" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "アクセスシークレット" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "�証トークン" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "�信者� ID" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "ボット�ア�ター� URL" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "ユーザー�メールアドレス" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "ボット�アクセストークン" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "ルーム� ID" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "�業� ID" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "アプリ�シークレット" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "エージェント� ID" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "宛先�部署" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "宛先�タグ" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "ボット� Webhook キー" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "�信元�電話 ID" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "ローカル� Microsoft Windows 環境�必���。" #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "ワークフロー� ID" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "署�" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Power Automate � URL を使�" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "Power Automate �ルーティング ID" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "テキストを折り返�" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "API ��ージョン" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "トピック" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "グループ���オプション�コード" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "アプリ�トークン" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "トークン���キー" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "情報�優先度" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "�功�優先度" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "警告�優先度" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "失敗�優先度" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "リンク�ラベル" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "検証トークン" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "宛先�ストリーム" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "AMQP 0.9.1 �� pika を�AMQP 1.0 �� python-qpid-proton を����両方をイ" "ンストール������。" #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "エクス�ェンジ" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "仮想ホスト" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "永続化" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "必須" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "有効期間 (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "メッセージヘッダー" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "SMTP サー�ー" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "セキュアモード" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "PGP �セキュリティーモード" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "PGP 公開��パス" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "PGP 秘密��パス" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "ウェブ�ディレクトリー" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "本文�埋�込む添付ファイル" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "�信者�メールアドレス" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "OAuth2 �キーファイル" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "カスタム画�� URL" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "通知�色" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "本�" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "ニック�ーム" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "�ャン�ル��加�る" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "�証モード" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "宛先�ルームエイリアス" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "サー�ー�検出" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "ルーム ID �ホームサー�ーを強制�る" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Matrix API ��ージョン" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "エンドツーエンド暗�化" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "端末�自動検証" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "PEM 形��秘密�ファイル" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "購読ファイル" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "XMPP サー�ー" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "連絡先一覧を�得�る" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "件�を使�" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "接続を維��る" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "MUC �ニック�ーム" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "SCRAM-PLUS ��ャ�ル�インディング" caronc-apprise-dfec414/apprise/i18n/ko/000077500000000000000000000000001526720371000177525ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/ko/LC_MESSAGES/000077500000000000000000000000001526720371000215375ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/ko/LC_MESSAGES/apprise.po000066400000000000000000002025221526720371000235450ustar00rootroot00000000000000# Korean translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: ko\n" "Language-Team: ko \n" "Plural-Forms: nplurals=1; plural=0;\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "SSL 확�" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "리디렉션 따�가기" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "소켓 �기 제한 시간" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "소켓 연결 제한 시간" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "�시 유효 기간" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "강제 지정할 MIME 형�" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "강제 지정할 파� �름" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "로컬 파�" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "웹 기반" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "메모리" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "스키마" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "�존성� 없습니다." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "작�하려면 패키지가 필요합니다." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "기능� 높�려면 패키지 설치를 권장합니다." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "앱 사용� �름" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "API 키" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "대� 전화" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "대�" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "보내는 사람" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "SMS 모드" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "�괄 모드" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "호스트 �름" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "�트" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "사용� �름" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "비밀번호" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "토�" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "태그" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "질� 방�" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Apprise API 버전" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "HTTP 헤�" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "사용� �름" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "대� 호출 부호" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "재전송 지연 시간" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "지역 설정" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "대� 기기" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "소리" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "수준" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "�량" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "�릭" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "배지" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "분류" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "그룹" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "�미지 �함" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "아�콘 URL" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "통화" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "암호화 키" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "초과 시 처리 방�" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "알림 형�" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "�모지 해�" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "�구 저장소" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "시간대" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "서비스 재시� 횟수" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "재시� 간 대기 시간" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "선� 서비스" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "보내는 �메�" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "대� 팀" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "�스컬레�션 정책" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "알림 방법" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "팀 대기 시간" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "�련번호" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "지� 시간 (밀리초)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "페�드 시간 (밀리초)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "LED 번호" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "�본 �메�" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "대� �메�" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "회신 �메�" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "참조" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "숨� 참조" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "시�릿" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "대� 전화번호" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "대� 그룹" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "보내는 전화번호" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "�우팅 그룹" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "유니코드 문�" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "API 시�릿" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "발신� ID" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "국가" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "유효 기간" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "API 토�" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "웹후� 토�" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "봇 �름" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "대� 채�" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "대� 봇" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "지역" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "회사 ID" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "브로드�스트" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "가져오기 방�" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "첨부 파� �름" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "페�로드 추가 항목" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "GET 매개변수" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "API 액세스 토�" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "발신 주소" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "우선순위" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "송신기 그룹" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "libdbus-1.so.x가 설치�어 있어야 합니다." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "DBus 알림" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "긴급�" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "X축" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Y축" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "웹후� ID" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "�성 �기" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "아바타 �미지" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "아바타 URL" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "스레드 ID" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "꼬리� 표시" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "꼬리� 로고" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "필드 사용" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Discord 플래그" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "사용� �는 역할 멘션" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "템플릿 경로" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "첨부 파� 묶어 보내기" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "템플릿 토�" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "기기 �련번호" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "API 모드" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "지금 새로 고침" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "�스트 서명" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Base64 아�콘 (�스트 API)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Base64 �미지 (�미지 API)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "��" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "테�리" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "디�� 방�" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "디�� 커�" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "작업 키" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "모달" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "경로" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "서버 제한 시간" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "�스턴스 �름" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "계정 SID" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "보내는 전화번호 �는 발신� ID" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "지역 �름" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "액세스 키" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "받는 사용� ID" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "받는 채� ID" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "웹후� 경로" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "모드" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "스레드 �름" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "API 사용� �름" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "API 비밀번호" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "액세스 토�" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "소유�" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "저장소" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "형�" #: apprise/plugins/github.py:222 msgid "Number" msgstr "번호" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "레�블 목�" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "담당�" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "libdbus-1.so.x �는 libdbus-2.so.x가 설치�어 있어야 합니다." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "GLib 알림" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "로컬 Gnome 환경� 필요합니다." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Gnome 알림" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "연� 키 목�" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "�작" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "중복 제거 키" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "메타��터" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "작업 공간" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "웹후� 키" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "스레드 키" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "플랫�" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "주제" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "��터 항목" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "봇 ID" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "버전" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "고정" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "장기 액세스 토�" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "알림 ID" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "경로 접�사" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "토� �는 사용� �름" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "�벤트" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "토� 추가" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "토� 삭제" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "연� 키" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "경로 설정" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "알림 소스 ID" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "대� �스컬레�션" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "대� �정" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "대� 사용�" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "대� " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "엔티티" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "별칭" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "세부 정보" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "�작 매핑" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "기기 ID" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "기기 �름" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "�본 전화번호" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "지� 시간" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "서버 키" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "기기 API 키" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "앱 ID" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "앱 버전" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "앱 액세스 토�" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "사용� 지정 아�콘" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "아�콘 형�" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "반복 횟수" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "봇 토�" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "�릭 URL" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "색" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Mac OS X 10.8 ���서만 작�합니다. �한 /usr/local/bin/terminal-notifier� " "로컬로 접근할 수 있어야 합니다." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "MacOSX 알림" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "열기 �는 �릭 URL" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Terminal-Notifier 버전" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "발신�" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "회신 주소" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "�메�" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "보내는 사람 �름" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "�메� 헤�" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "공개 범위" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "가려진 �스트" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "언어 코드" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "결과 �시� 저장" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "민�한 첨부 파�" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "사용� �는 태그 멘션" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "사용�" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "대� 채� ID" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "최대 메시지 분할 수" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "사용� 지정 참조" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "대� 대기열" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "���언트 ID" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "세션 사용" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "메시지 보관" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "템플릿 ID" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "�� 키" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "짧� URL" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "템플릿 매핑" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "URL 접�사" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "방 ID" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "무� 알림" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "Discord �벤트 ID" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "�본" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "프로�트 ID" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "메시지 후�" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "IRC 색�" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "접�사" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "캠페� �름" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "템플릿 �름" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "���언트 참조" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "고품질" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "팩스 헤�" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "첨부 파�" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "첨부 파� �름" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "지연" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "�메�" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "�작" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "�� 방�" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "API 로그� �름" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "회신 받기" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "용�" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "테넌트 �메�" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "계정 �메� �는 �체 ID" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "���언트 시�릿" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "보낸 항목� 저장" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "템플릿" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "대� 플레�어 ID" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "세그먼트 �함" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "부제목" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "언어" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "내용 사용" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "템플릿 �수 디코딩" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "사용� 지정 ��터" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "�스트백 ��터" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "대� 사용� ID" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "대� 그룹 ID" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "구성 요소" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "�래스" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "심��" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "사용� 지정 세부 정보" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "연� ID" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "서드파티 ID" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Meta 추가 항목" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "마스터 키" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "기기" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "�름 공간" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "배지" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "메시지 형�" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "대� ID" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "대� SMS" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "채�" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "�� ID" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "�� 토�" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "공급� 키" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "알림 키" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "애플리케�션 키" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "애플리케�션 시�릿" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "비밀 키" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "�태 표시" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "사용� 키" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "URL 제목" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "긴급 재시� 간격" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "만료" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "사용� 토�" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "그룹 주제" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "채�" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "웹후� �름" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "개� 키" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "진�" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "정보 수준" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "성공 수준" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "경고 수준" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "실패 수준" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "비밀 API 키" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "대� 주제" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "애플리케�션 ID" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "대� 서브레딧" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "종류" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "플레어 ID" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "플레어 �스트" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "광고입니까?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "회신 보내기" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "스��러 �함" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "재등� 플래그" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "보내는 주소" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "채� ID" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "임베드 URL" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "토� �는 비밀번호" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "환경" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "�� 모드" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "웹후�" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "대� 방 ID" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "웹후� 모드" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "아바타 사용" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "�실리티" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "PID 기�" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "조�" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "템플릿 ��터" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "발신� �름" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "세션 토�" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "액세스 키 ID" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "비밀 액세스 키" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "대� �메� 주소" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "플래시" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "레�블" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "서비스 ID" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "서비스 비밀번호" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "스페�스 ID" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "수신� 전화번호" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "미디어 형�" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "제한 시간" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "�성 �기 목소리" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "���언트 키" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "중요 알림" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "서비스" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "위치" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "알림 시나리오" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "필터�" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "외부 ID" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "�태" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "대� 구성�" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "공유" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "태그" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "대� 단축 번호" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "OAuth 액세스 토�" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "토� A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "토� B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "토� C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "�코딩� 대� ID" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "워�플로 경로" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "꼬리� �함" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "블� 사용" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "타임스탬프 �함" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "메시지 모드" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "호스트" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "로그� �름" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "�역" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "대� 연�처" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "테스트만" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "게�트웨�" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "공개 키" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "시드" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "방" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "대� �우팅 키" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "엔티티 ID" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "알림 종류" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "�미지 ��" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "소리 ��" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "특별 �스트 색" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "금액" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "통화" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "�름" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "�별�" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "업로드" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "STDERR� 기�" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "대� 대화 ID" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "봇 소유� 확�" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "웹 페�지 미리 보기" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "사진/��� 첨부 파� 묶기" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "주제 스레드 ID" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Markdown 버전" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "내용 배치" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "서� 있는 메시지 템플릿 경로" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "메시지 프로필 ID" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "게�트웨� ID" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "대� Threema ID" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "API 호스트 �름" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "�미지 URL" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "�벤트 종류" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "우선순위 (Pushover 호환)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "알림 방�: sms �는 통화" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "컨슈머 키" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "컨슈머 시�릿" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "액세스 시�릿" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "�� 토�" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "수신� ID" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "봇 아바타 URL" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "사용� �메�" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "봇 액세스 토�" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "방 ID" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "기업 ID" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "앱 시�릿" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "��전트 ID" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "대� 부서" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "대� 태그" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "봇 웹후� 키" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "보내는 전화 ID" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "로컬 Microsoft Windows 환경� 필요합니다." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "워�플로 ID" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "서명" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Power Automate URL 사용" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "Power Automate �우팅 ID" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "�스트 줄바꿈" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "API 버전" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "주제" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "그룹 �는 옵션 코드" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "앱 토�" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "토� �는 키" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "정보 우선순위" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "성공 우선순위" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "경고 우선순위" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "실패 우선순위" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "�� 레�블" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "확� 토�" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "대� 스트림" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "AMQP 0.9.1�는 pika를, AMQP 1.0�는 python-qpid-proton�, �는 둘 다 설치하세" "요." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "�스체�지" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "가� 호스트" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "�구 저장" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "필수" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "유효 기간 (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "메시지 헤�" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "SMTP 서버" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "보안 모드" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "PGP 보안 모드" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "PGP 공개 키 경로" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "PGP 개� 키 경로" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "웹 키 디렉터리" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "본문� 넣는 첨부 파�" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "받는 �메�" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "OAuth2 키 파�" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "사용� 지정 �미지 URL" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "알림 색" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "실제 �름" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "별명" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "채� 참가" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "�� 모드" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "대� 방 별칭" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "서버 �색" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "방 ID� 홈 서버 강제 �용" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Matrix API 버전" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "종단 간 암호화" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "기기 �� 확�" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "PEM 개� 키 파�" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "구� 파�" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "XMPP 서버" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "연�처 목� 가져오기" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "제목 사용" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "연결 유지" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "MUC 별명" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "SCRAM-PLUS 채� 바�딩" caronc-apprise-dfec414/apprise/i18n/ms/000077500000000000000000000000001526720371000177605ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/ms/LC_MESSAGES/000077500000000000000000000000001526720371000215455ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/ms/LC_MESSAGES/apprise.po000066400000000000000000002013611526720371000235530ustar00rootroot00000000000000# Malay translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: ms\n" "Language-Team: ms \n" "Plural-Forms: nplurals=1; plural=0;\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "Sahkan SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "Ikut pengalihan" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "Had masa bacaan soket" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "Had masa sambungan soket" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "Usia cache" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "Jenis MIME yang dipaksa" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "Nama fail yang dipaksa" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "Fail tempatan" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "Berasaskan web" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "Memori" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "Skema" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "Tiada kebergantungan." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "Pakej diperlukan untuk berfungsi." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "Pakej disyorkan untuk menambah baik kefungsian." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "Nama pengguna aplikasi" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "Kunci API" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "Telefon sasaran" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "Sasaran" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "Penghantar" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "Mod SMS" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "Mod berkelompok" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "Nama hos" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "Port" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "Nama pengguna" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "Kata laluan" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "Token" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "Tanda" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "Kaedah pertanyaan" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Versi API Apprise" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "Pengepala HTTP" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "Nama pengguna" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "Tanda panggilan sasaran" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "Lengah hantar semula" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "Tetapan setempat" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "Peranti sasaran" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "Bunyi" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "Tahap" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "Kelantangan" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "Klik" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "Lencana" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "Kategori" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "Kumpulan" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "Sertakan imej" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "URL ikon" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "Panggilan" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "Kunci penyulitan" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "Mod limpahan" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "Format pemberitahuan" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "Tafsir emoji" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "Storan kekal" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "Zon waktu" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "Cubaan semula perkhidmatan" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "Tunggu antara cubaan" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "Perkhidmatan pilihan" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "E-mel penghantar" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "Pasukan sasaran" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "Polisi Eskalasi" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "Kaedah Amaran" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "Masa Menunggu Pasukan" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "Nombor siri" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "Tempoh (ms)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "Masa pudar (ms)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "Nombor LED" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "E-mel sumber" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "E-mel sasaran" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "E-mel untuk balasan" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "Salinan" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "Salinan tersembunyi" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "Rahsia" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "Nombor telefon sasaran" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "Kumpulan sasaran" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "Nombor telefon penghantar" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "Kumpulan penghalaan" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Aksara Unicode" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "Rahsia API" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "ID penghantar" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "Negara" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "kesahan" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "Token API" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Token webhook" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "Nama bot" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "Saluran sasaran" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "Bot sasaran" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "Wilayah" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "ID syarikat" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "Siaran" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "Kaedah pengambilan" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "Lampirkan fail sebagai" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "Tambahan muatan" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "Parameter GET" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "Token akses API" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "Alamat asal" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "Keutamaan" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "Kumpulan pemancar" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "libdbus-1.so.x mesti dipasang." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "Pemberitahuan DBus" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "Tahap desakan" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "Paksi X" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Paksi Y" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "ID webhook" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "Teks ke pertuturan" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "Imej avatar" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "URL avatar" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "ID bebenang" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "Tunjukkan pengaki" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "Logo pengaki" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "Guna medan" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Bendera Discord" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "Sebut pengguna atau peranan" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "Laluan templat" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "Gabungkan lampiran" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "Token templat" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "Nombor siri peranti" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "Mod API" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Muat semula sekarang" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Tandatangan teks" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Ikon dalam Base64 (API teks)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Imej dalam Base64 (API imej)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "Pautan" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "Sempadan" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Jenis dithering" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Kernel dithering" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "Kunci tugas" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "Modal" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "Laluan" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Had masa pelayan" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "Nama tika" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "SID akaun" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "Nombor telefon penghantar atau ID penghantar" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "Nama wilayah" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "Kunci akses" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "Ke ID pengguna" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "Ke ID saluran" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "Laluan webhook" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "Mod" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "Nama bebenang" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "Nama pengguna API" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "Kata laluan API" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "Token akses" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "Pemilik" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "Repositori" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "Jenis" #: apprise/plugins/github.py:222 msgid "Number" msgstr "Nombor" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "Label-label" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "Penerima tugasan" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "libdbus-1.so.x atau libdbus-2.so.x mesti dipasang." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "Pemberitahuan GLib" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "Persekitaran Gnome tempatan diperlukan." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Pemberitahuan Gnome" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "Kunci-kunci penyatuan" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "Tindakan" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "Kunci nyahduplikasi" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "Metadata" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "Ruang kerja" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Kunci webhook" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "Kunci bebenang" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "Platform" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "Topik" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "Entri data" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "ID bot" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Versi" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Disematkan" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "Token akses jangka panjang" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "ID pemberitahuan" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "Awalan laluan" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "Token atau nama pengguna" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "Peristiwa" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "Tambah token" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "Buang token" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "Kunci penyatuan" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "Laluan penghantaran" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "ID Sumber Amaran" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "Peningkatan sasaran" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "Jadual sasaran" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "Pengguna sasaran" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "Sasaran " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "Entiti" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "Alias" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "Perincian" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "Pemetaan tindakan" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "ID peranti" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "Nama peranti" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "Nombor telefon sumber" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Tempoh" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "Kunci pelayan" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "Kunci API peranti" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "ID aplikasi" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "Versi aplikasi" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "Token akses aplikasi" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "Ikon tersuai" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "Jenis ikon" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "Kitaran" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "Token bot" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "URL klik" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "Warna" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Hanya berfungsi dengan Mac OS X 10.8 dan lebih baru. Selain itu, /usr/local/" "bin/terminal-notifier mesti boleh dicapai secara tempatan." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "Pemberitahuan MacOSX" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "URL untuk dibuka atau diklik" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Versi Terminal-Notifier" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "Penghantar" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "Balas kepada" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "Domain" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "Nama penghantar" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "Pengepala e-mel" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "Keterlihatan" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "Teks tersembunyi" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "Kod bahasa" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "Simpan hasil dalam cache" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "Lampiran sensitif" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "Sebut pengguna atau tanda" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "Pengguna" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "ID saluran sasaran" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "Bilangan maksimum bahagian mesej" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "Rujukan tersuai" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "Baris gilir sasaran" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "ID klien" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "Guna sesi" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "Simpan mesej" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "ID templat" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "Kunci pengesahan" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "URL pendek" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "Pemetaan templat" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "Awalan URL" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "ID bilik" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "Pemberitahuan senyap" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "ID peristiwa Discord" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "Sumber" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "ID projek" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "Cangkuk mesej" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "Warna IRC" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "Awalan" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "Nama kempen" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "Nama templat" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "Rujukan klien" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "Kualiti tinggi" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "Pengepala faks" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "Lampiran" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "Nama fail lampiran" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "Lengah" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "E-mel" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "Tindakan" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "Jenis pengesahan" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "Nama masuk API" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "Terima balasan" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "Tujuan" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "Domain penyewa" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "E-mel akaun atau ID objek" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "Rahsia klien" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "Simpan dalam item dihantar" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "Templat" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "ID pemain sasaran" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "Sertakan segmen" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "Sari kata" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "Bahasa" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "Bolehkan kandungan" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "Nyahkod argumen templat" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "Data tersuai" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "Data maklum balas" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "ID pengguna sasaran" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "ID kumpulan sasaran" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "Komponen" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "Kelas" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "Tahap keterukan" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "Perincian tersuai" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "ID penyatuan" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "ID pihak ketiga" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Tambahan Meta" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "Kunci induk" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "Peranti" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "Ruang nama" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "Lencana" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "Jenis mesej" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "ID sasaran" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "SMS sasaran" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "Saluran" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "ID pengesahan" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "Token pengesahan" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "Kunci penyedia" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "Kunci pemberitahuan" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "Kunci aplikasi" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "Rahsia aplikasi" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "Kunci rahsia" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "Tunjukkan status" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "Kunci pengguna" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "Tajuk URL" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "Selang cubaan semula kecemasan" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "Luput" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "Token pengguna" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "Topik kumpulan" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "Saluran" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "Nama webhook" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "Kunci peribadi" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "Getaran" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "Tahap maklumat" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "Tahap kejayaan" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "Tahap amaran" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "Tahap kegagalan" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "Kunci API rahsia" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "Topik sasaran" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "ID aplikasi" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "Subreddit sasaran" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "Jenis kandungan" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "ID flair" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "Teks flair" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "Adakah iklan?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "Hantar balasan" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "Mengandungi spoiler" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "Bendera hantar semula" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "Alamat penghantar" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "ID saluran" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "URL terbenam" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "Token atau kata laluan" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "Persekitaran" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "Mod pengesahan" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "ID bilik sasaran" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Mod webhook" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "Guna avatar" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "Kemudahan" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "Rekod PID" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "Organisasi" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "Data templat" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "Nama penghantar" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "Token sesi" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "ID kunci akses" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "Kunci akses rahsia" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "E-mel sasaran" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "Denyar" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "Label" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "ID perkhidmatan" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "Kata laluan perkhidmatan" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "ID ruang" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "Nombor telefon penerima" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "Jenis media" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "Had masa" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "Suara teks ke pertuturan" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "Kunci klien" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "Pemberitahuan kritikal" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "Perkhidmatan" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "Lokasi" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "Senario amaran" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "Penapisan" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "ID luaran" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "Status" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "Ahli Sasaran" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "Dikongsi" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "Tag" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "Kod pendek sasaran" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "Token akses OAuth" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "Token A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "Token B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "Token C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "ID terkod sasaran" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "Laluan aliran kerja" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "Sertakan pengaki" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "Guna blok" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "Sertakan cap masa" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "Mod mesej" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "Hos" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "Nama masuk" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "Alih huruf" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "Kenalan sasaran" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "Ujian sahaja" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "Get laluan" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "Kunci awam" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "Benih" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "Bilik" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "Kunci penghalaan sasaran" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "ID entiti" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "Jenis amaran" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "Pautan imej" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "Pautan bunyi" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "Warna teks khas" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "Jumlah" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "Mata wang" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "Nama" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "Pengenal" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "Muat naik" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "Rekod ke STDERR" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "ID sembang sasaran" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "Kesan pemilik bot" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "Pratonton halaman web" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "Kumpulkan lampiran foto/video" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "ID bebenang topik" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Versi Markdown" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "Penempatan kandungan" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "Laluan templat mesej berformat" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "ID profil pemesejan" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "ID get laluan" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "ID Threema sasaran" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "Nama hos API" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "URL imej" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "Jenis peristiwa" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "Keutamaan (keserasian Pushover)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "Kaedah pemberitahuan: sms atau panggilan" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "Kunci pengguna API" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "Rahsia pengguna API" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "Rahsia akses" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "Token pengesahan" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "ID penerima" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "URL avatar bot" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "E-mel pengguna" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "Token akses bot" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "ID bilik" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "ID syarikat" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "Rahsia aplikasi" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "ID ejen" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "Bahagian sasaran" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "Tanda sasaran" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "Kunci webhook bot" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "ID telefon penghantar" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "Persekitaran Microsoft Windows tempatan diperlukan." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "ID aliran kerja" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "Tandatangan" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Guna URL Power Automate" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "ID penghalaan Power Automate" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "Balut teks" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "Versi API" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "Topik" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "Kod kumpulan atau pilihan" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "Token aplikasi" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "Token atau kunci" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "Keutamaan maklumat" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "Keutamaan kejayaan" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "Keutamaan amaran" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "Keutamaan kegagalan" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "Label pautan" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "Token pengesahan" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "Strim sasaran" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "Pasang pika untuk AMQP 0.9.1, python-qpid-proton untuk AMQP 1.0, atau kedua-" "duanya." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "Exchange" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "Hos Maya" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "Kekal" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "Wajib" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "Tempoh Hayat (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "Pengepala Mesej" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "Pelayan SMTP" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "Mod selamat" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "Mod keselamatan PGP" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "Laluan kunci awam PGP" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "Laluan kunci peribadi PGP" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "Direktori kunci web" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "Lampiran terbenam" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "E-mel penerima" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "Fail kunci OAuth2" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "URL imej tersuai" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "Warna pemberitahuan" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "Nama sebenar" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "Nama panggilan" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "Sertai saluran" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "Mod pengesahan" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "Alias bilik sasaran" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "Penemuan pelayan" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "Paksa pelayan asal pada ID bilik" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Versi API Matrix" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "Penyulitan hujung ke hujung" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "Pengesahan peranti automatik" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "Fail kunci peribadi PEM" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "Fail langganan" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "Pelayan XMPP" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "Dapatkan senarai kenalan" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "Guna subjek" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "Kekalkan sambungan" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "Nama panggilan MUC" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "Pengikatan saluran SCRAM-PLUS" caronc-apprise-dfec414/apprise/i18n/nl/000077500000000000000000000000001526720371000177525ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/nl/LC_MESSAGES/000077500000000000000000000000001526720371000215375ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/nl/LC_MESSAGES/apprise.po000066400000000000000000002026661526720371000235560ustar00rootroot00000000000000# Dutch translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: nl\n" "Language-Team: nl \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "SSL verifiëren" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "Omleidingen volgen" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "Time-out voor lezen van socket" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "Time-out voor verbinden van socket" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "Ouderdom van de cache" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "Afgedwongen MIME-type" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "Afgedwongen bestandsnaam" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "Lokaal bestand" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "Op het web" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "Geheugen" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "Schema" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "Geen afhankelijkheden." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "Er zijn pakketten nodig om te werken." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "Pakketten worden aanbevolen om de functionaliteit te verbeteren." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "Gebruikersnaam van de app" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "API-sleutel" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "Doeltelefoon" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "Doelen" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "Afzender" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "SMS-modus" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "Batchmodus" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "Hostnaam" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "Poort" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "Gebruikersnaam" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "Wachtwoord" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "Token" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "Labels" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "Opvraagmethode" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Versie van de Apprise-API" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "HTTP-header" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "Gebruikersnaam" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "Doelroepnaam" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "Vertraging voor opnieuw verzenden" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "Landinstelling" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "Doelapparaat" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "Geluid" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "Niveau" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "Volume" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "Klik" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "Badge" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "Categorie" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "Groep" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "Afbeelding toevoegen" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "Pictogram-URL" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "Oproep" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "Versleutelingssleutel" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "Overloopmodus" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "Meldingsopmaak" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "Emoji's interpreteren" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "Blijvende opslag" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "Tijdzone" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "Nieuwe pogingen van de dienst" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "Wachttijd tussen pogingen" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "Optionele dienst" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "E-mailadres van de afzender" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "Doelteam" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "Escalatiebeleid" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "Waarschuwingsmethoden" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "Wachttijd team" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "Serienummer" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "Duur (ms)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "Overvloeitijd (ms)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "Lednummer" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "Bron-e-mailadres" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "Doel-e-mailadres" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "E-mailadres voor antwoorden" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "Kopie" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "Blinde kopie" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "Geheim" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "Doeltelefoonnummer" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "Doelgroep" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "Telefoonnummer van de afzender" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "Routeringsgroep" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Unicode-tekens" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "API-geheim" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "Afzender-id" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "Land" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "geldigheid" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "API-token" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Webhook-token" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "Botnaam" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "Doelkanaal" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "Doelbot" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "Regio" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "Bedrijfs-ID" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "Uitzenden" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "Ophaalmethode" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "Bestand bijvoegen als" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "Extra's voor de payload" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "GET-parameters" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "API-toegangstoken" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "Oorsprongsadres" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "Prioriteit" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "Zendergroepen" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "libdbus-1.so.x moet geïnstalleerd zijn." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "DBus-melding" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "Urgentie" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "X-as" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Y-as" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "Webhook-id" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "Tekst-naar-spraak" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "Avatarafbeelding" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "Avatar-URL" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "Draad-id" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "Voettekst weergeven" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "Logo in de voettekst" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "Velden gebruiken" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Discord-vlaggen" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "Gebruikers of rollen vermelden" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "Pad naar het sjabloon" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "Bijlagen samenvoegen" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "Sjabloontokens" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "Serienummer van het apparaat" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "API-modus" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Nu vernieuwen" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Tekstondertekening" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Pictogram in Base64 (tekst-API)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Afbeelding in Base64 (afbeeldings-API)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "Koppeling" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "Rand" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Type rasteren" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Kern voor rasteren" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "Taaksleutel" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "Modaal" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "Pad" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Time-out van de server" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "Naam van de instantie" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "SID van het account" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "Telefoonnummer van de afzender of afzender-id" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "Naam van de regio" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "Toegangssleutel" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "Naar gebruikers-id" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "Naar kanaal-id" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "Pad naar de webhook" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "Modus" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "Naam van de draad" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "API-gebruikersnaam" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "API-wachtwoord" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "Toegangstoken" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "Eigenaar" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "Repository" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "Type" #: apprise/plugins/github.py:222 msgid "Number" msgstr "Nummer" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "Labels" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "Toegewezen personen" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "libdbus-1.so.x of libdbus-2.so.x moet geïnstalleerd zijn." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "GLib-melding" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "Er is een lokale Gnome-omgeving vereist." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Gnome-melding" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "Integratiesleutels" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "Actie" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "Ontdubbelingssleutel" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "Metagegevens" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "Werkruimte" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Webhook-sleutel" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "Draadsleutel" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "Platform" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "Onderwerp" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "Gegevensvermeldingen" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "Bot-id" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Versie" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Vastgezet" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "Langlevend toegangstoken" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "Meldings-id" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "Voorvoegsel van het pad" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "Token of gebruikersnaam" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "Gebeurtenissen" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "Tokens toevoegen" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "Tokens verwijderen" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "Integratiesleutel" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "Route" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "Waarschuwingsbron-ID" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "Doelescalatie" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "Doelschema" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "Doelgebruiker" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "Doelen " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "Entiteit" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "Alias" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "Details" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "Actietoewijzing" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "Apparaat-id" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "Apparaatnaam" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "Telefoonnummer van de bron" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Duur" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "Serversleutel" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "API-sleutel van het apparaat" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "App-id" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "Versie van de app" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "Toegangstoken van de app" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "Eigen pictogram" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "Type pictogram" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "Cycli" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "Bottoken" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "URL van de klik" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "Kleur" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Werkt alleen met Mac OS X 10.8 en nieuwer. Daarnaast moet /usr/local/bin/" "terminal-notifier lokaal beschikbaar zijn." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "MacOSX-melding" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "URL om te openen of aan te klikken" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Versie van Terminal-Notifier" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "Afzender" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "Antwoorden naar" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "Domein" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "Naam van de afzender" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "E-mailheader" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "Zichtbaarheid" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "Verborgen tekst" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "Taalcode" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "Resultaten in de cache bewaren" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "Gevoelige bijlagen" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "Gebruikers of labels vermelden" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "Gebruiker" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "Id van het doelkanaal" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "Maximaal aantal berichtdelen" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "Eigen referentie" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "Doelwachtrij" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "Client-id" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "Een sessie gebruiken" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "Berichten bewaren" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "Sjabloon-id" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "Verificatiesleutel" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "Korte URL" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "Sjabloontoewijzing" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "Voorvoegsel van de URL" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "Kamer-id" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "Stille melding" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "Id van de Discord-gebeurtenis" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "Bron" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "Project-id" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "Berichthook" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "IRC-kleuren" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "Voorvoegsel" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "Naam van de campagne" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "Naam van het sjabloon" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "Clientreferentie" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "Hoge kwaliteit" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "Faxheader" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "Bijlage" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "Bestandsnaam van de bijlage" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "Vertraging" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "E-mail" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "Acties" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "Type verificatie" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "API-aanmeldnaam" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "Antwoorden aanvaarden" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "Doel" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "Tenantdomein" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "E-mailadres van het account of object-id" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "Clientgeheim" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "Opslaan in verzonden items" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "Sjabloon" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "Id van de doelspeler" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "Segment toevoegen" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "Ondertitel" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "Taal" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "Inhoud inschakelen" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "Sjabloonargumenten decoderen" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "Eigen gegevens" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "Terugkoppelingsgegevens" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "Id van de doelgebruiker" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "Id van de doelgroep" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "Onderdeel" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "Klasse" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "Ernst" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "Eigen details" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "Integratie-id" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "Id van derden" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Meta-extra's" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "Hoofdsleutel" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "Apparaat" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "Naamruimte" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "Badges" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "Berichttype" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "Doel-id" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "Doel-sms" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "Kanalen" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "Verificatie-id" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "Verificatietoken" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "Sleutel van de aanbieder" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "Meldingssleutel" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "Toepassingssleutel" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "Toepassingsgeheim" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "Geheime sleutel" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "Status weergeven" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "Gebruikerssleutel" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "Titel van de URL" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "Interval voor noodherhaling" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "Verloopt" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "Gebruikerstoken" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "Groepsonderwerpen" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "Kanaal" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "Naam van de webhook" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "Privésleutel" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "Trillen" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "Informatieniveau" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "Succesniveau" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "Waarschuwingsniveau" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "Foutniveau" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "Geheime API-sleutel" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "Doelonderwerp" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "Toepassings-id" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "Doelsubreddit" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "Soort" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "Flair-id" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "Flairtekst" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "Is dit een advertentie?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "Antwoorden verzenden" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "Bevat een spoiler" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "Vlag voor opnieuw verzenden" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "Adres van de afzender" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "Kanaal-id" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "Ingesloten URL" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "Token of wachtwoord" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "Omgeving" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "Verificatiemodus" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "Id van de doelkamer" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Webhookmodus" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "Avatar gebruiken" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "Voorziening" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "PID vastleggen" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "Organisatie" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "Sjabloongegevens" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "Naam van de afzender" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "Sessietoken" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "Id van de toegangssleutel" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "Geheime toegangssleutel" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "Doel-e-mailadressen" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "Flits" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "Label" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "Dienst-id" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "Wachtwoord van de dienst" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "Ruimte-id" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "Telefoonnummer van de ontvanger" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "Mediatype" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "Time-out" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "Stem voor tekst-naar-spraak" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "Clientsleutel" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "Kritieke melding" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "Dienst" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "Locatie" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "Waarschuwingsscenario" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "Filteren" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "Externe id" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "Status" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "Doellid" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "Gedeeld" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "Label" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "Doelkortcode" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "OAuth-toegangstoken" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "Token A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "Token B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "Token C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "Gecodeerde doel-id" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "Pad naar de werkstroom" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "Voettekst toevoegen" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "Blokken gebruiken" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "Tijdstempel toevoegen" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "Berichtmodus" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "Host" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "Aanmeldnaam" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "Translitereren" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "Doelcontact" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "Alleen testen" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "Gateway" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "Openbare sleutel" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "Startwaarde" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "Kamers" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "Routeringssleutel van het doel" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "Entiteit-id" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "Type waarschuwing" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "Koppeling naar de afbeelding" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "Koppeling naar het geluid" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "Bijzondere tekstkleur" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "Bedrag" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "Valuta" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "Naam" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "Identificatie" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "Uploaden" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "Vastleggen in STDERR" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "Id van de doelchat" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "Eigenaar van de bot opsporen" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "Voorbeeld van de webpagina" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "Foto-/videobijlagen groeperen" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "Draad-id van het onderwerp" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Markdown-versie" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "Plaatsing van de inhoud" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "Pad naar het sjabloon voor opgemaakte berichten" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "Id van het berichtenprofiel" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "Gateway-id" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "Threema-id van het doel" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "API-hostnaam" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "URL van de afbeelding" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "Type gebeurtenis" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "Prioriteit (compatibel met Pushover)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "Meldingsmethode: sms of oproep" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "Consumersleutel" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "Consumergeheim" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "Toegangsgeheim" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "Verificatietoken" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "Id's van de ontvangers" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "Avatar-URL van de bot" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "E-mailadres van de gebruiker" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "Toegangstoken van de bot" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "Kamer-id's" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "Bedrijfs-id" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "Appgeheim" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "Agent-id" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "Doelafdeling" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "Doellabel" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "Webhook-sleutel van de bot" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "Id van de telefoon van de afzender" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "Er is een lokale Microsoft Windows-omgeving vereist." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "Werkstroom-id" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "Ondertekening" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Power Automate-URL gebruiken" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "Routerings-id van Power Automate" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "Tekst afbreken" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "API-versie" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "Onderwerpen" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "Groeps- of optiecode" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "Apptoken" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "Token of sleutel" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "Prioriteit voor informatie" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "Prioriteit voor successen" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "Prioriteit voor waarschuwingen" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "Prioriteit voor fouten" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "Label van de koppeling" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "Verificatietoken" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "Doelstroom" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "Installeer pika voor AMQP 0.9.1, python-qpid-proton voor AMQP 1.0, of beide." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "Exchange" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "Virtuele host" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "Persistent" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "Verplicht" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "Levensduur (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "Berichtheaders" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "SMTP-server" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "Beveiligde modus" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "PGP-beveiligingsmodus" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "Pad naar de openbare PGP-sleutel" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "Pad naar de privé-PGP-sleutel" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "Map met websleutels" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "Ingesloten bijlagen" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "E-mailadres van de ontvanger" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "OAuth2-sleutelbestand" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "Eigen afbeeldings-URL" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "Kleur van de melding" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "Echte naam" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "Bijnaam" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "Kanalen binnengaan" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "Verificatiemodus" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "Alias van de doelkamer" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "Serverdetectie" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "Thuisserver afdwingen bij kamer-id's" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Versie van de Matrix-API" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "Eind-tot-eindversleuteling" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "Automatische verificatie van het apparaat" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "PEM-bestand met privésleutel" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "Abonnementsbestand" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "XMPP-server" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "Contactenlijst ophalen" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "Onderwerp gebruiken" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "Verbinding in stand houden" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "MUC-bijnaam" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "SCRAM-PLUS-kanaalbinding" caronc-apprise-dfec414/apprise/i18n/pl/000077500000000000000000000000001526720371000177545ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/pl/LC_MESSAGES/000077500000000000000000000000001526720371000215415ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/pl/LC_MESSAGES/apprise.po000066400000000000000000002043211526720371000235460ustar00rootroot00000000000000# Polish translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: pl\n" "Language-Team: pl \n" "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " "|| n%100>=20) ? 1 : 2);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "Weryfikuj SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "Podążaj za przekierowaniami" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "Limit czasu odczytu gniazda" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "Limit czasu połączenia gniazda" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "Wiek pamięci podręcznej" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "Wymuszony typ MIME" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "Wymuszona nazwa pliku" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "Plik lokalny" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "Oparte na sieci" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "Pamięć" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "Schemat" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "Brak zależności." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "Do działania wymagane są pakiety." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "Zalecane są pakiety, aby poprawić funkcjonalność." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "Nazwa użytkownika aplikacji" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "Klucz API" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "Telefon docelowy" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "Cele" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "Nadawca" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "Tryb SMS" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "Tryb wsadowy" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "Nazwa hosta" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "Port" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "Nazwa użytkownika" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "Hasło" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "Token" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "Znaczniki" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "Metoda zapytania" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Wersja API Apprise" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "Nagłówek HTTP" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "Nazwa użytkownika" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "Docelowy znak wywoławczy" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "Opóźnienie ponownego wysłania" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "Ustawienia regionalne" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "Urządzenie docelowe" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "Dźwięk" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "Poziom" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "Głośność" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "Kliknięcie" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "Plakietka" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "Kategoria" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "Grupa" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "Dołącz obraz" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "Adres URL ikony" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "Połączenie" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "Klucz szyfrowania" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "Tryb przepełnienia" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "Format powiadomienia" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "Interpretuj emoji" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "Trwała pamięć" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "Strefa czasowa" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "Ponowne próby usługi" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "Oczekiwanie między próbami" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "Usługa opcjonalna" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "Adres e-mail nadawcy" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "Zespół docelowy" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "Zasady eskalacji" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "Metody alarmowania" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "Czas oczekiwania zespołu" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "Numer seryjny" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "Czas trwania (ms)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "Czas przejścia (ms)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "Numer diody LED" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "Źródłowy adres e-mail" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "Docelowy adres e-mail" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "Adres e-mail do odpowiedzi" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "Kopia" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "Kopia ukryta" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "Sekret" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "Docelowy numer telefonu" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "Grupa docelowa" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "Numer telefonu nadawcy" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "Grupa trasowania" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Znaki Unicode" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "Sekret API" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "Identyfikator nadawcy" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "Kraj" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "ważność" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "Token API" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Token webhooka" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "Nazwa bota" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "Kanał docelowy" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "Bot docelowy" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "Region" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "Identyfikator firmy" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "Rozgłoszenie" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "Metoda pobierania" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "Załącz plik jako" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "Dodatki ładunku" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "Parametry GET" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "Token dostępu API" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "Adres źródłowy" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "Priorytet" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "Grupy nadajników" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "Biblioteka libdbus-1.so.x musi być zainstalowana." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "Powiadomienie DBus" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "Pilność" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "Oś X" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Oś Y" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "Identyfikator webhooka" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "Synteza mowy" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "Obraz awatara" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "Adres URL awatara" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "Identyfikator wątku" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "Pokaż stopkę" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "Logo w stopce" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "Użyj pól" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Flagi Discorda" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "Wspomnij użytkowników lub role" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "Ścieżka szablonu" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "Grupuj załączniki" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "Tokeny szablonu" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "Numer seryjny urządzenia" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "Tryb API" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Odśwież teraz" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Podpis tekstowy" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Ikona w Base64 (API tekstowe)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Obraz w Base64 (API obrazów)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "Odnośnik" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "Obramowanie" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Typ ditheringu" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Jądro ditheringu" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "Klucz zadania" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "Modalny" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "Ścieżka" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Limit czasu serwera" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "Nazwa instancji" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "SID konta" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "Numer telefonu nadawcy lub identyfikator nadawcy" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "Nazwa regionu" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "Klucz dostępu" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "Do identyfikatora użytkownika" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "Do identyfikatora kanału" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "Ścieżka webhooka" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "Tryb" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "Nazwa wątku" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "Nazwa użytkownika API" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "Hasło API" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "Token dostępu" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "Właściciel" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "Repozytorium" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "Typ" #: apprise/plugins/github.py:222 msgid "Number" msgstr "Numer" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "Etykiety" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "Przypisane osoby" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "Biblioteka libdbus-1.so.x lub libdbus-2.so.x musi być zainstalowana." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "Powiadomienie GLib" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "Wymagane jest lokalne środowisko Gnome." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Powiadomienie Gnome" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "Klucze integracji" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "Akcja" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "Klucz deduplikacji" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "Metadane" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "Przestrzeń robocza" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Klucz webhooka" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "Klucz wątku" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "Platforma" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "Temat" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "Wpisy danych" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "Identyfikator bota" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Wersja" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Przypięte" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "Długotrwały token dostępu" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "Identyfikator powiadomienia" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "Przedrostek ścieżki" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "Token lub nazwa użytkownika" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "Zdarzenia" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "Dodaj tokeny" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "Usuń tokeny" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "Klucz integracji" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "Trasa" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "Identyfikator źródła alertów" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "Docelowa eskalacja" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "Docelowy harmonogram" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "Użytkownik docelowy" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "Cele " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "Podmiot" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "Alias" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "Szczegóły" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "Mapowanie akcji" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "Identyfikator urządzenia" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "Nazwa urządzenia" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "Źródłowy numer telefonu" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Czas trwania" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "Klucz serwera" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "Klucz API urządzenia" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "Identyfikator aplikacji" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "Wersja aplikacji" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "Token dostępu aplikacji" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "Własna ikona" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "Typ ikony" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "Cykle" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "Token bota" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "Adres URL kliknięcia" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "Kolor" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Działa tylko z systemem Mac OS X 10.8 i nowszym. Dodatkowo wymaga, aby /usr/" "local/bin/terminal-notifier był dostępny lokalnie." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "Powiadomienie MacOSX" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "Adres URL do otwarcia lub kliknięcia" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Wersja Terminal-Notifier" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "Nadawca" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "Odpowiedz do" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "Domena" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "Nazwa nadawcy" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "Nagłówek wiadomości e-mail" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "Widoczność" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "Ukryty tekst" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "Kod języka" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "Zapisuj wyniki w pamięci podręcznej" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "Poufne załączniki" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "Wspomnij użytkowników lub znaczniki" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "Użytkownik" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "Identyfikator kanału docelowego" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "Maksymalna liczba części wiadomości" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "Własny odnośnik" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "Kolejka docelowa" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "Identyfikator klienta" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "Użyj sesji" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "Zachowuj wiadomości" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "Identyfikator szablonu" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "Klucz uwierzytelniania" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "Krótki adres URL" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "Mapowanie szablonu" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "Przedrostek adresu URL" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "Identyfikator pokoju" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "Ciche powiadomienie" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "Identyfikator zdarzenia Discorda" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "Źródło" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "Identyfikator projektu" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "Zaczep wiadomości" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "Kolory IRC" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "Przedrostek" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "Nazwa kampanii" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "Nazwa szablonu" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "Odnośnik klienta" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "Wysoka jakość" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "Nagłówek faksu" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "Załącznik" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "Nazwa pliku załącznika" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "Opóźnienie" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "E-mail" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "Akcje" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "Typ uwierzytelniania" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "Login API" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "Przyjmuj odpowiedzi" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "Przeznaczenie" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "Domena najemcy" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "Adres e-mail konta lub identyfikator obiektu" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "Sekret klienta" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "Zapisz w elementach wysłanych" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "Szablon" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "Identyfikator docelowego odtwarzacza" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "Dołącz segment" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "Podtytuł" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "Język" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "Włącz treść" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "Dekoduj argumenty szablonu" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "Własne dane" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "Dane zwrotne" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "Identyfikator użytkownika docelowego" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "Identyfikator grupy docelowej" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "Składnik" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "Klasa" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "Waga" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "Własne szczegóły" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "Identyfikator integracji" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "Identyfikator strony trzeciej" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Dodatki Meta" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "Klucz główny" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "Urządzenie" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "Przestrzeń nazw" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "Plakietki" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "Typ wiadomości" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "Identyfikator docelowy" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "Docelowy SMS" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "Kanały" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "Identyfikator uwierzytelniania" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "Token uwierzytelniania" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "Klucz dostawcy" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "Klucz powiadomień" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "Klucz aplikacji" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "Sekret aplikacji" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "Klucz tajny" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "Pokaż status" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "Klucz użytkownika" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "Tytuł adresu URL" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "Odstęp ponawiania alarmu" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "Wygaśnięcie" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "Token użytkownika" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "Tematy grupy" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "Kanał" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "Nazwa webhooka" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "Klucz prywatny" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "Wibracje" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "Poziom informacji" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "Poziom powodzenia" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "Poziom ostrzeżenia" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "Poziom błędu" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "Tajny klucz API" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "Temat docelowy" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "Identyfikator aplikacji" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "Docelowy subreddit" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "Rodzaj" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "Identyfikator etykiety" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "Tekst etykiety" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "Czy to reklama?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "Wysyłaj odpowiedzi" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "Zawiera spoiler" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "Flaga ponownego wysłania" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "Adres nadawcy" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "Identyfikator kanału" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "Osadzony adres URL" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "Token lub hasło" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "Środowisko" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "Tryb uwierzytelniania" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "Identyfikator pokoju docelowego" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Tryb webhooka" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "Użyj awatara" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "Usługa" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "Zapisuj PID" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "Organizacja" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "Dane szablonu" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "Nazwa nadawcy" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "Token sesji" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "Identyfikator klucza dostępu" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "Tajny klucz dostępu" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "Docelowe adresy e-mail" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "Flash" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "Etykieta" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "Identyfikator usługi" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "Hasło usługi" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "Identyfikator przestrzeni" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "Numer telefonu odbiorcy" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "Typ multimediów" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "Limit czasu" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "Głos syntezatora mowy" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "Klucz klienta" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "Powiadomienie krytyczne" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "Usługa" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "Lokalizacja" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "Scenariusz alarmowania" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "Filtrowanie" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "Identyfikator zewnętrzny" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "Status" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "Docelowy członek" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "Współdzielone" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "Etykieta" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "Docelowy kod skrócony" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "Token dostępu OAuth" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "Token A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "Token B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "Token C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "Zakodowany identyfikator docelowy" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "Ścieżka przepływu pracy" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "Dołącz stopkę" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "Użyj bloków" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "Dołącz znacznik czasu" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "Tryb wiadomości" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "Host" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "Login" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "Transliteruj" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "Kontakt docelowy" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "Tylko test" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "Brama" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "Klucz publiczny" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "Ziarno" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "Pokoje" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "Docelowy klucz trasowania" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "Identyfikator podmiotu" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "Typ alarmu" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "Odnośnik do obrazu" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "Odnośnik do dźwięku" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "Specjalny kolor tekstu" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "Kwota" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "Waluta" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "Nazwa" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "Identyfikator" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "Przesyłanie" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "Zapisuj do STDERR" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "Identyfikator docelowej rozmowy" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "Wykryj właściciela bota" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "Podgląd strony internetowej" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "Grupuj załączniki zdjęć/filmów" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "Identyfikator wątku tematu" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Wersja Markdown" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "Rozmieszczenie treści" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "Ścieżka szablonu wiadomości sformatowanej" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "Identyfikator profilu wiadomości" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "Identyfikator bramy" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "Docelowy identyfikator Threema" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "Nazwa hosta API" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "Adres URL obrazu" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "Typ zdarzenia" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "Priorytet (zgodność z Pushover)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "Metoda powiadomienia: sms lub połączenie" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "Klucz konsumenta" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "Sekret konsumenta" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "Sekret dostępu" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "Token uwierzytelniania" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "Identyfikatory odbiorców" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "Adres URL awatara bota" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "Adres e-mail użytkownika" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "Token dostępu bota" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "Identyfikatory pokojów" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "Identyfikator firmy" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "Sekret aplikacji" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "Identyfikator agenta" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "Dział docelowy" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "Docelowy znacznik" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "Klucz webhooka bota" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "Identyfikator telefonu nadawcy" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "Wymagane jest lokalne środowisko Microsoft Windows." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "Identyfikator przepływu pracy" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "Podpis" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Użyj adresu URL Power Automate" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "Identyfikator trasowania Power Automate" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "Zawijaj tekst" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "Wersja API" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "Tematy" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "Kod grupy lub opcji" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "Token aplikacji" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "Token lub klucz" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "Priorytet informacji" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "Priorytet powodzenia" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "Priorytet ostrzeżeń" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "Priorytet błędów" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "Etykieta odnośnika" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "Token weryfikacyjny" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "Strumień docelowy" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "Zainstaluj pika dla AMQP 0.9.1, python-qpid-proton dla AMQP 1.0 lub oba." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "Exchange" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "Host wirtualny" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "Trwały" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "Obowiązkowy" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "Czas życia (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "Nagłówki wiadomości" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "Serwer SMTP" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "Tryb bezpieczny" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "Tryb bezpieczeństwa PGP" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "Ścieżka klucza publicznego PGP" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "Ścieżka klucza prywatnego PGP" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "Katalog kluczy sieciowych" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "Załączniki osadzone" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "Adres e-mail odbiorcy" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "Plik klucza OAuth2" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "Własny adres URL obrazu" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "Kolor powiadomienia" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "Imię i nazwisko" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "Pseudonim" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "Dołącz do kanałów" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "Tryb uwierzytelniania" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "Alias pokoju docelowego" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "Wykrywanie serwera" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "Wymuś serwer domowy dla identyfikatorów pokojów" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Wersja API Matrix" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "Szyfrowanie end-to-end" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "Automatyczna weryfikacja urządzenia" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "Plik klucza prywatnego PEM" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "Plik subskrypcji" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "Serwer XMPP" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "Pobierz listę kontaktów" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "Użyj tematu" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "Utrzymuj połączenie" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "Pseudonim MUC" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "Powiązanie kanału SCRAM-PLUS" caronc-apprise-dfec414/apprise/i18n/pt/000077500000000000000000000000001526720371000177645ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/pt/LC_MESSAGES/000077500000000000000000000000001526720371000215515ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/pt/LC_MESSAGES/apprise.po000066400000000000000000002056701526720371000235660ustar00rootroot00000000000000# Portuguese translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: pt\n" "Language-Team: pt \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "Verificar o SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "Seguir os redirecionamentos" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "Tempo limite de leitura do socket" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "Tempo limite de ligação do socket" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "Idade da cache" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "Tipo MIME forçado" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "Nome de ficheiro forçado" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "Ficheiro local" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "Baseado na web" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "Memória" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "Esquema" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "Sem dependências." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "São necessários pacotes para funcionar." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "Recomendam-se pacotes para melhorar a funcionalidade." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "Nome de utilizador da aplicação" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "Chave da API" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "Telefone de destino" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "Destinos" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "Remetente" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "Modo SMS" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "Modo por lotes" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "Nome do anfitrião" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "Porta" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "Nome de utilizador" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "Palavra-passe" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "Token" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "Etiquetas" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "Método de consulta" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Versão da API do Apprise" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "Cabeçalho HTTP" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "Nome de utilizador" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "Indicativo de destino" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "Atraso de reenvio" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "Configuração regional" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "Dispositivo de destino" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "Som" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "Nível" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "Volume" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "Clique" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "Insígnia" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "Categoria" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "Grupo" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "Incluir a imagem" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "URL do ícone" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "Chamada" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "Chave de cifragem" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "Modo de excesso" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "Formato da notificação" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "Interpretar os emojis" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "Armazenamento persistente" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "Zona horária" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "Tentativas do serviço" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "Espera entre tentativas" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "Serviço opcional" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "Correio eletrónico do remetente" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "Equipa de destino" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "Política de escalonamento" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "Métodos de alerta" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "Espera da equipa" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "Número de série" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "Duração (ms)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "Tempo de desvanecimento (ms)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "Número do LED" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "Correio eletrónico de origem" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "Correio eletrónico de destino" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "Correio eletrónico de resposta" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "Cópia" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "Cópia oculta" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "Segredo" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "Número de telefone de destino" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "Grupo de destino" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "Número de telefone do remetente" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "Grupo de encaminhamento" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Caracteres Unicode" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "Segredo da API" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "Identificador do remetente" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "País" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "validade" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "Token da API" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Token do webhook" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "Nome do bot" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "Canal de destino" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "Bot de destino" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "Região" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "ID da empresa" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "Transmissão" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "Método de obtenção" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "Anexar o ficheiro como" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "Extras da carga útil" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "Parâmetros GET" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "Token de acesso da API" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "Endereço de origem" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "Prioridade" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "Grupos de transmissores" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "O libdbus-1.so.x tem de estar instalado." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "Notificação do DBus" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "Urgência" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "Eixo X" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Eixo Y" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "Identificador do webhook" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "Síntese de voz" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "Imagem do avatar" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "URL do avatar" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "Identificador do tópico" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "Mostrar o rodapé" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "Logótipo do rodapé" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "Utilizar os campos" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Indicadores do Discord" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "Mencionar utilizadores ou funções" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "Caminho do modelo" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "Agrupar os anexos" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "Tokens do modelo" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "Número de série do dispositivo" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "Modo da API" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Atualizar agora" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Assinatura do texto" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "�cone em Base64 (API de texto)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Imagem em Base64 (API de imagem)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "Ligação" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "Contorno" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Tipo de pontilhado" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Núcleo de pontilhado" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "Chave da tarefa" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "Modal" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "Caminho" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Tempo limite do servidor" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "Nome da instância" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "SID da conta" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "Número de telefone do remetente ou identificador do remetente" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "Nome da região" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "Chave de acesso" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "Para o identificador do utilizador" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "Para o identificador do canal" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "Caminho do webhook" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "Modo" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "Nome do tópico" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "Nome de utilizador da API" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "Palavra-passe da API" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "Token de acesso" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "Proprietário" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "Repositório" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "Tipo" #: apprise/plugins/github.py:222 msgid "Number" msgstr "Número" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "Etiquetas" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "Responsáveis" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "O libdbus-1.so.x ou o libdbus-2.so.x tem de estar instalado." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "Notificação do GLib" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "É necessário um ambiente Gnome local." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Notificação do Gnome" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "Chaves de integração" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "Ação" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "Chave de desduplicação" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "Metadados" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "�rea de trabalho" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Chave do webhook" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "Chave do tópico" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "Plataforma" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "Tópico" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "Entradas de dados" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "Identificador do bot" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Versão" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Fixado" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "Token de acesso de longa duração" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "Identificador da notificação" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "Prefixo do caminho" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "Token ou nome de utilizador" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "Eventos" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "Adicionar tokens" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "Remover tokens" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "Chave de integração" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "Rota" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "ID da fonte de alertas" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "Escalada de destino" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "Horário de destino" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "Utilizador de destino" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "Destinos " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "Entidade" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "Alias" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "Detalhes" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "Mapeamento das ações" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "Identificador do dispositivo" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "Nome do dispositivo" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "Número de telefone de origem" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Duração" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "Chave do servidor" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "Chave da API do dispositivo" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "Identificador da aplicação" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "Versão da aplicação" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "Token de acesso da aplicação" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "�cone personalizado" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "Tipo de ícone" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "Ciclos" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "Token do bot" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "URL do clique" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "Cor" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Só funciona com o Mac OS X 10.8 e versões posteriores. Também exige que o /" "usr/local/bin/terminal-notifier esteja acessível localmente." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "Notificação do MacOSX" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "URL de abertura ou de clique" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Versão do Terminal-Notifier" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "Remetente" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "Responder a" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "Domínio" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "Nome do remetente" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "Cabeçalho do correio eletrónico" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "Visibilidade" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "Texto oculto" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "Código do idioma" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "Guardar os resultados em cache" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "Anexos sensíveis" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "Mencionar utilizadores ou etiquetas" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "Utilizador" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "Identificador do canal de destino" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "Número máximo de partes da mensagem" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "Referência personalizada" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "Fila de destino" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "Identificador do cliente" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "Utilizar uma sessão" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "Manter as mensagens" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "Identificador do modelo" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "Chave de autenticação" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "URL curto" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "Mapeamento do modelo" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "Prefixo do URL" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "Identificador da sala" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "Notificação silenciosa" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "Identificador do evento do Discord" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "Origem" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "Identificador do projeto" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "Gancho de mensagens" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "Cores do IRC" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "Prefixo" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "Nome da campanha" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "Nome do modelo" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "Referência do cliente" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "Alta qualidade" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "Cabeçalho do fax" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "Anexar" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "Nome do ficheiro anexado" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "Atraso" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "Correio eletrónico" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "Ações" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "Tipo de autenticação" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "Utilizador da API" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "Aceitar respostas" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "Finalidade" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "Domínio do inquilino" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "Correio eletrónico da conta ou identificador do objeto" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "Segredo do cliente" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "Guardar nos itens enviados" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "Modelo" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "Identificador do jogador de destino" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "Incluir o segmento" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "Subtítulo" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "Idioma" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "Ativar o conteúdo" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "Descodificar os argumentos do modelo" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "Dados personalizados" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "Dados de retorno" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "Identificador do utilizador de destino" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "Identificador do grupo de destino" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "Componente" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "Classe" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "Gravidade" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "Detalhes personalizados" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "Identificador da integração" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "Identificador de terceiros" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Extras da Meta" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "Chave principal" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "Dispositivo" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "Espaço de nomes" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "Insígnias" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "Tipo de mensagem" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "Identificador de destino" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "SMS de destino" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "Canais" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "Identificador de autenticação" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "Token de autenticação" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "Chave do fornecedor" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "Chave de notificação" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "Chave da aplicação" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "Segredo da aplicação" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "Chave secreta" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "Mostrar o estado" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "Chave do utilizador" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "Título do URL" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "Intervalo de repetição de emergência" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "Validade" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "Token do utilizador" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "Tópicos do grupo" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "Canal" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "Nome do webhook" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "Chave privada" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "Vibração" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "Nível de informação" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "Nível de sucesso" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "Nível de aviso" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "Nível de falha" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "Chave secreta da API" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "Tópico de destino" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "Identificador da aplicação" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "Subreddit de destino" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "Género" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "Identificador do flair" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "Texto do flair" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "É um anúncio?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "Enviar as respostas" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "Contém spoiler" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "Indicador de reenvio" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "Endereço do remetente" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "Identificador do canal" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "URL incorporado" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "Token ou palavra-passe" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "Ambiente" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "Modo de autenticação" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "Identificador da sala de destino" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Modo do webhook" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "Utilizar o avatar" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "Serviço" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "Registar o PID" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "Organização" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "Dados do modelo" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "Nome do remetente" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "Token da sessão" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "Identificador da chave de acesso" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "Chave de acesso secreta" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "Correios eletrónicos de destino" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "Flash" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "Etiqueta" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "Identificador do serviço" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "Palavra-passe do serviço" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "Identificador do espaço" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "Número de telefone do destinatário" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "Tipo de suporte" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "Tempo limite" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "Voz de síntese" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "Chave do cliente" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "Notificação crítica" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "Serviço" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "Localização" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "Cenário de alerta" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "Filtragem" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "Identificador externo" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "Estado" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "Membro de destino" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "Compartilhado" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "Etiqueta" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "Código curto de destino" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "Token de acesso OAuth" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "Token A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "Token B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "Token C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "Identificador codificado de destino" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "Caminho do fluxo de trabalho" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "Incluir o rodapé" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "Utilizar blocos" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "Incluir a data e a hora" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "Modo da mensagem" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "Anfitrião" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "Utilizador" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "Transliterar" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "Contacto de destino" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "Apenas teste" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "Gateway" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "Chave pública" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "Semente" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "Salas" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "Chave de encaminhamento de destino" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "Identificador da entidade" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "Tipo de alerta" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "Ligação da imagem" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "Ligação do som" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "Cor de texto especial" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "Montante" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "Moeda" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "Nome" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "Identificador" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "Enviar" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "Registar no STDERR" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "Identificador da conversa de destino" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "Detetar o proprietário do bot" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "Pré-visualização da página web" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "Agrupar anexos de foto/vídeo" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "Identificador do tópico do assunto" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Versão do Markdown" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "Colocação do conteúdo" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "Caminho do modelo de mensagem formatada" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "Identificador do perfil de mensagens" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "Identificador do gateway" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "Identificador Threema de destino" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "Nome do anfitrião da API" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "URL da imagem" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "Tipo de evento" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "Prioridade (compatibilidade com o Pushover)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "Método de notificação: sms ou chamada" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "Chave do consumidor" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "Segredo do consumidor" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "Segredo de acesso" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "Token de autenticação" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "Identificadores dos destinatários" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "URL do avatar do bot" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "Correio eletrónico do utilizador" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "Token de acesso do bot" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "Identificadores das salas" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "Identificador da empresa" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "Segredo da aplicação" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "Identificador do agente" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "Departamento de destino" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "Etiqueta de destino" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "Chave do webhook do bot" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "Identificador do telefone do remetente" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "É necessário um ambiente Microsoft Windows local." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "Identificador do fluxo de trabalho" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "Assinatura" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Utilizar um URL do Power Automate" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "Identificador de encaminhamento do Power Automate" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "Ajustar o texto" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "Versão da API" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "Tópicos" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "Código de grupo ou de opção" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "Token da aplicação" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "Token ou chave" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "Prioridade das informações" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "Prioridade dos sucessos" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "Prioridade dos avisos" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "Prioridade das falhas" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "Etiqueta da ligação" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "Token de verificação" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "Fluxo de destino" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "Instale pika para AMQP 0.9.1, python-qpid-proton para AMQP 1.0, ou ambos." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "Exchange" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "Host virtual" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "Persistente" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "Obrigatório" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "Tempo de vida" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "Cabeçalhos da mensagem" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "Servidor SMTP" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "Modo seguro" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "Modo de segurança do PGP" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "Caminho da chave pública PGP" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "Caminho da chave privada PGP" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "Diretório de chaves web" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "Anexos incorporados" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "Correio eletrónico do destinatário" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "Ficheiro de chave OAuth2" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "URL de imagem personalizada" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "Cor da notificação" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "Nome verdadeiro" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "Alcunha" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "Entrar nos canais" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "Modo de autenticação" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "Alias da sala de destino" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "Deteção do servidor" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "Forçar o servidor de origem nos identificadores de sala" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Versão da API do Matrix" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "Cifragem de ponta a ponta" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "Verificação automática do dispositivo" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "Ficheiro de chave privada PEM" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "Ficheiro de subscrição" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "Servidor XMPP" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "Obter a lista de contactos" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "Utilizar o assunto" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "Manter a ligação ativa" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "Alcunha MUC" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "Vinculação de canal SCRAM-PLUS" caronc-apprise-dfec414/apprise/i18n/ru/000077500000000000000000000000001526720371000177675ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/ru/LC_MESSAGES/000077500000000000000000000000001526720371000215545ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/ru/LC_MESSAGES/apprise.po000066400000000000000000002220541526720371000235640ustar00rootroot00000000000000# Russian translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: ru\n" "Language-Team: ru \n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "Провер�ть SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "Следовать перенаправлени�м" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "Тайм-аут чтени� �окета" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "Тайм-аут подключени� �окета" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "Срок дей�тви� кеша" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "Принудительный тип MIME" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "Принудительное им� файла" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "Локальный файл" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "В интернете" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "Пам�ть" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "Схема" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "Зави�имо�тей нет." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "Дл� работы требуют�� пакеты." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "Дл� ра�ширени� возможно�тей рекомендуют�� пакеты." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "Им� пользовател� приложени�" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "Ключ API" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "Телефон получател�" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "Получатели" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "Отправитель" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "Режим SMS" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "Пакетный режим" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "Им� хо�та" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "Порт" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "Им� пользовател�" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "Пароль" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "Токен" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "Метки" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "Метод запро�а" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Вер�и� API Apprise" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "Заголовок HTTP" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "Им� пользовател�" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "Позывной получател�" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "Задержка повторной отправки" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "Региональные на�тройки" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "У�трой�тво получател�" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "Звук" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "Уровень" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "Громко�ть" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "�ажатие" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "Значок" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "Категори�" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "Группа" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "Включить изображение" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "URL значка" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "Звонок" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "Ключ шифровани�" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "Режим переполнени�" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "Формат уведомлени�" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "Обрабатывать �модзи" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "По�то�нное хранилище" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "Ча�овой по��" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "Повторные попытки �лужбы" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "Ожидание между попытками" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "�еоб�зательна� �лужба" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "Электронна� почта отправител�" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "Команда-получатель" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "Политика ��калации" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "Спо�обы оповещени�" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "Ожидание команды" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "Серийный номер" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "Длительно�ть (м�)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "Врем� затухани� (м�)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "�омер �ветодиода" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "�дре� �лектронной почты и�точника" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "�дре� �лектронной почты получател�" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "�дре� дл� ответа" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "Копи�" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "Скрыта� копи�" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "Секрет" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "�омер телефона получател�" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "Группа получателей" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "�омер телефона отправител�" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "Группа маршрутизации" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Символы Unicode" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "Секрет API" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "Идентификатор отправител�" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "Страна" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "�рок дей�тви�" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "Токен API" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Токен веб-хука" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "Им� бота" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "Канал-получатель" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "Бот-получатель" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "Регион" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "ID компании" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "Ра��ылка в�ем" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "Метод получени�" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "Прикрепить файл как" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "Дополнени� полезной нагрузки" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "Параметры GET" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "Токен до�тупа к API" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "�дре� и�точника" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "Приоритет" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "Группы передатчиков" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "Должна быть у�тановлена библиотека libdbus-1.so.x." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "Уведомление DBus" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "Срочно�ть" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "О�ь X" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "О�ь Y" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "Идентификатор веб-хука" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "Синтез речи" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "Изображение аватара" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "URL аватара" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "Идентификатор об�уждени�" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "Показывать нижний колонтитул" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "Логотип в нижнем колонтитуле" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "И�пользовать пол�" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Флаги Discord" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "Упоминать пользователей или роли" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "Путь к шаблону" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "Объедин�ть вложени�" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "Токены шаблона" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "Серийный номер у�трой�тва" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "Режим API" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Обновить �ейча�" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Подпи�ь тек�та" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Значок в Base64 (тек�товый API)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Изображение в Base64 (API изображений)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "С�ылка" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "Рамка" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Тип �глаживани�" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Ядро �глаживани�" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "Ключ задачи" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "Модальное окно" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "Путь" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Тайм-аут �ервера" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "Им� �кземпл�ра" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "SID учётной запи�и" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "�омер телефона отправител� или идентификатор отправител�" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "�азвание региона" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "Ключ до�тупа" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "Идентификатор пользовател� получател�" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "Идентификатор канала получател�" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "Путь веб-хука" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "Режим" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "�азвание об�уждени�" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "Им� пользовател� API" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "Пароль API" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "Токен до�тупа" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "Владелец" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "Репозиторий" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "Тип" #: apprise/plugins/github.py:222 msgid "Number" msgstr "�омер" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "Метки" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "И�полнители" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "Должна быть у�тановлена библиотека libdbus-1.so.x или libdbus-2.so.x." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "Уведомление GLib" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "Требует�� локальна� �реда Gnome." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Уведомление Gnome" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "Ключи интеграции" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "Дей�твие" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "Ключ дедупликации" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "Метаданные" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "Рабоча� обла�ть" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Ключ веб-хука" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "Ключ об�уждени�" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "Платформа" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "Тема" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "Запи�и данных" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "Идентификатор бота" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Вер�и�" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Закреплено" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "Долгоживущий токен до�тупа" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "Идентификатор уведомлени�" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "Префик� пути" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "Токен или им� пользовател�" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "Событи�" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "Добавить токены" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "Удалить токены" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "Ключ интеграции" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "Маршрут" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "Идентификатор и�точника оповещений" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "Э�калаци� получател�" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "Ра�пи�ание получател�" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "Пользователь-получатель" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "Получатели " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "Объект" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "П�евдоним" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "Подробно�ти" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "Сопо�тавление дей�твий" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "Идентификатор у�трой�тва" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "Им� у�трой�тва" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "�омер телефона и�точника" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Длительно�ть" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "Ключ �ервера" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "Ключ API у�трой�тва" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "Идентификатор приложени�" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "Вер�и� приложени�" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "Токен до�тупа приложени�" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "Свой значок" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "Тип значка" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "Циклы" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "Токен бота" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "URL нажати�" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "Цвет" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Работает только в Mac OS X 10.8 и новее. Кроме того, требует�� локальный " "до�туп к /usr/local/bin/terminal-notifier." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "Уведомление MacOSX" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "URL дл� открыти� или нажати�" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Вер�и� Terminal-Notifier" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "Отправитель" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "Ответить" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "Домен" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "Им� отправител�" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "Заголовок пи�ьма" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "Видимо�ть" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "Скрытый тек�т" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "Код �зыка" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "Кешировать результаты" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "Конфиденциальные вложени�" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "Упоминать пользователей или метки" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "Пользователь" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "Идентификатор канала-получател�" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "Мак�имальное чи�ло ча�тей �ообщени�" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "Сво� ��ылка" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "Очередь получател�" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "Идентификатор клиента" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "И�пользовать �еан�" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "Сохран�ть �ообщени�" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "Идентификатор шаблона" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "Ключ аутентификации" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "Короткий URL" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "Сопо�тавление шаблона" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "Префик� URL" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "Идентификатор комнаты" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "Тихое уведомление" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "Идентификатор �обыти� Discord" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "И�точник" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "Идентификатор проекта" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "Хук �ообщений" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "Цвета IRC" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "Префик�" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "�азвание кампании" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "�азвание шаблона" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "С�ылка клиента" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "Вы�окое каче�тво" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "Заголовок фак�а" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "Вложение" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "Им� файла вложени�" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "Задержка" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "Электронна� почта" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "Дей�тви�" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "Тип аутентификации" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "Логин API" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "Принимать ответы" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "�азначение" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "Домен арендатора" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "Электронна� почта учётной запи�и или идентификатор объекта" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "Секрет клиента" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "Сохран�ть в отправленных" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "Шаблон" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "Идентификатор игрока-получател�" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "Включить �егмент" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "Подзаголовок" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "Язык" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "Включить �одержимое" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "Декодировать аргументы шаблона" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "Свои данные" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "Данные обратной передачи" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "Идентификатор пользовател�-получател�" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "Идентификатор группы получателей" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "Компонент" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "Кла��" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "Серьёзно�ть" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "Свои подробно�ти" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "Идентификатор интеграции" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "Сторонний идентификатор" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Дополнени� Meta" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "Главный ключ" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "У�трой�тво" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "Про�тран�тво имён" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "Значки" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "Тип �ообщени�" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "Идентификатор получател�" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "SMS получател�" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "Каналы" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "Идентификатор аутентификации" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "Токен аутентификации" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "Ключ по�тавщика" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "Ключ уведомлений" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "Ключ приложени�" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "Секрет приложени�" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "Секретный ключ" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "Показывать �о�то�ние" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "Ключ пользовател�" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "Заголовок URL" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "Интервал �к�тренных повторов" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "Срок дей�тви�" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "Токен пользовател�" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "Темы группы" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "Канал" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "�азвание веб-хука" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "Закрытый ключ" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "Вибраци�" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "Уровень информации" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "Уровень у�пеха" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "Уровень предупреждени�" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "Уровень �бо�" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "Секретный ключ API" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "Тема получател�" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "Идентификатор приложени�" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "Целевой �убреддит" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "Вид" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "Идентификатор метки" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "Тек�т метки" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "Это реклама?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "Отправл�ть ответы" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "Содержит �пойлер" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "Флаг повторной отправки" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "�дре� отправител�" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "Идентификатор канала" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "В�троенный URL" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "Токен или пароль" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "Среда" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "Режим аутентификации" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Веб-хук" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "Идентификатор комнаты-получател�" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Режим веб-хука" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "И�пользовать аватар" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "Под�и�тема" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "Запи�ывать PID" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "Организаци�" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "Данные шаблона" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "Им� отправител�" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "Токен �еан�а" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "Идентификатор ключа до�тупа" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "Секретный ключ до�тупа" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "�дре�а �лектронной почты получателей" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "В�пышка" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "Подпи�ь" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "Идентификатор �лужбы" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "Пароль �лужбы" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "Идентификатор про�тран�тва" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "�омер телефона получател�" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "Тип �одержимого" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "Тайм-аут" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "Голо� �интеза речи" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "Ключ клиента" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "Критиче�кое уведомление" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "Служба" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "Ра�положение" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "Сценарий оповещени�" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "Фильтраци�" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "Внешний идентификатор" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "Со�то�ние" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "Целевой уча�тник" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "Общий" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "Метка" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "Короткий код получател�" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "Токен до�тупа OAuth" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "Токен A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "Токен B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "Токен C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "Закодированный идентификатор получател�" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "Путь рабочего проце��а" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "Включить нижний колонтитул" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "И�пользовать блоки" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "Включить метку времени" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "Режим �ообщени�" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "Хо�т" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "Логин" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "Тран�литерировать" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "Контакт-получатель" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "Только проверка" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "Шлюз" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "Открытый ключ" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "�ачальное значение" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "Комнаты" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "Ключ маршрутизации получател�" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "Идентификатор объекта" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "Тип оповещени�" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "С�ылка на изображение" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "С�ылка на звук" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "О�обый цвет тек�та" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "Сумма" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "Валюта" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "Им�" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "Идентификатор" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "Загрузка" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "Запи�ывать в STDERR" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "Идентификатор чата-получател�" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "Определ�ть владельца бота" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "Предварительный про�мотр �траницы" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "Группировать вложени� фото/видео" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "Идентификатор об�уждени� темы" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Вер�и� Markdown" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "Размещение �одержимого" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "Путь к шаблону форматированного �ообщени�" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "Идентификатор профил� �ообщений" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "Идентификатор шлюза" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "Идентификатор Threema получател�" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "Им� хо�та API" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "URL изображени�" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "Тип �обыти�" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "Приоритет (�овме�тимо�ть � Pushover)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "Спо�об уведомлени�: sms или звонок" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "Ключ потребител�" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "Секрет потребител�" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "Секрет до�тупа" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "Токен аутентификации" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "Идентификаторы получателей" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "URL аватара бота" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "Электронна� почта пользовател�" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "Токен до�тупа бота" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "Идентификаторы комнат" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "Идентификатор организации" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "Секрет приложени�" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "Идентификатор агента" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "Отдел-получатель" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "Метка получател�" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "Ключ веб-хука бота" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "Идентификатор телефона отправител�" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "Требует�� локальна� �реда Microsoft Windows." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "Идентификатор рабочего проце��а" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "Подпи�ь" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "И�пользовать URL Power Automate" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "Идентификатор маршрутизации Power Automate" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "Перено�ить тек�т" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "Вер�и� API" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "Темы" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "Код группы или параметра" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "Токен приложени�" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "Токен или ключ" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "Приоритет информации" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "Приоритет у�пеха" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "Приоритет предупреждений" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "Приоритет �боев" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "Подпи�ь ��ылки" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "Токен проверки" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "Поток-получатель" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "У�тановите pika дл� AMQP 0.9.1, python-qpid-proton дл� AMQP 1.0 или оба." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "Обменник" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "Виртуальный хо�т" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "По�то�нное" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "Об�зательное" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "Врем� жизни (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "Заголовки �ообщени�" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "Сервер SMTP" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "Защищённый режим" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "Режим безопа�но�ти PGP" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "Путь к открытому ключу PGP" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "Путь к закрытому ключу PGP" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "Каталог веб-ключей" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "В�троенные вложени�" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "Электронна� почта получател�" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "Файл ключа OAuth2" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "Свой URL изображени�" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "Цвет уведомлени�" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "�а�то�щее им�" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "П�евдоним" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "Входить в каналы" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "Режим аутентификации" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "П�евдоним комнаты-получател�" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "Обнаружение �ервера" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "Принудительно и�пользовать домашний �ервер дл� идентификаторов комнат" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Вер�и� API Matrix" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "Сквозное шифрование" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "�втоматиче�ка� проверка у�трой�тва" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "Файл закрытого ключа PEM" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "Файл подпи�ки" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "Сервер XMPP" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "Получить �пи�ок контактов" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "И�пользовать тему" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "Поддерживать �оединение" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "П�евдоним MUC" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "Прив�зка канала SCRAM-PLUS" caronc-apprise-dfec414/apprise/i18n/th/000077500000000000000000000000001526720371000177545ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/th/LC_MESSAGES/000077500000000000000000000000001526720371000215415ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/th/LC_MESSAGES/apprise.po000066400000000000000000002307371526720371000235600ustar00rootroot00000000000000# Thai translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: th\n" "Language-Team: th \n" "Plural-Forms: nplurals=1; plural=0;\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "ตรวจสอบ SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "ติดตาม�ารเปลี่ยนเส้นทาง" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "เวลารอ�ารอ่านซ็อ�เ�็ต" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "เวลารอ�ารเชื่อมต่อซ็อ�เ�็ต" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "อายุของ�คช" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "ชนิด MIME ที่บังคับใช้" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "ชื่อไฟล์ที่บังคับใช้" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "ไฟล์ในเครื่อง" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "อยู่บนเว็บ" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "หน่วยความจำ" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "สคีมา" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "ไม่มีสิ่งที่ต้องติดตั้งเพิ่ม" #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "ต้องติดตั้ง�พ็�เ�จเพื่อให้ใช้งานได้" #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "�นะนำให้ติดตั้ง�พ็�เ�จเพื่อเพิ่มความสามารถ" #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "ชื่อผู้ใช้ของ�อป" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "คีย์ API" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "โทรศัพท์ปลายทาง" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "ปลายทาง" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "ผู้ส่ง" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "โหมด SMS" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "โหมดรวมชุด" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "ชื่อโฮสต์" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "พอร์ต" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "ชื่อผู้ใช้" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "รหัสผ่าน" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "โทเค็น" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "�ท็�" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "วิธี�ารสอบถาม" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "รุ่นของ Apprise API" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "ส่วนหัว HTTP" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "ชื่อผู้ใช้" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "รหัสเรีย�ขานปลายทาง" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "เวลาหน่วง�่อนส่งซ้ำ" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "�ารตั้งค่าภูมิภาค" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "อุป�รณ์ปลายทาง" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "เสียง" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "ระดับ" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "ระดับเสียง" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "�ารคลิ�" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "ป้าย" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "หมวดหมู่" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "�ลุ่ม" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "�นบรูปภาพ" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "URL ของไอคอน" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "�ารโทร" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "คีย์เข้ารหัส" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "โหมดเมื่อข้อความยาวเ�ิน" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "รูป�บบ�าร�จ้งเตือน" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "�ปลงอิโมจิ" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "ที่จัดเ�็บ�บบถาวร" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "เขตเวลา" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "จำนวนครั้งที่ลองใหม่ของบริ�าร" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "เวลารอระหว่าง�ารลองใหม่" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "บริ�ารที่เลือ�ใช้ได้" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "อีเมลผู้ส่ง" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "ทีมปลายทาง" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "นโยบาย�ารย�ระดับ" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "วิธี�าร�จ้งเตือน" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "เวลารอของทีม" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "หมายเลขเครื่อง" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "ระยะเวลา (มิลลิวินาที)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "เวลาหรี่�สง (มิลลิวินาที)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "หมายเลขหลอด LED" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "อีเมลต้นทาง" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "อีเมลปลายทาง" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "อีเมลสำหรับตอบ�ลับ" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "สำเนา" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "สำเนาลับ" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "รหัสลับ" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "หมายเลขโทรศัพท์ปลายทาง" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "�ลุ่มปลายทาง" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "หมายเลขโทรศัพท์ผู้ส่ง" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "�ลุ่ม�าร�ำหนดเส้นทาง" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "อั�ขระ Unicode" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "รหัสลับ API" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "รหัสผู้ส่ง" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "ประเทศ" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "อายุ�ารใช้งาน" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "โทเค็น API" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "โทเค็น webhook" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "ชื่อบอท" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "ช่องปลายทาง" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "บอทปลายทาง" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "ภูมิภาค" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "รหัสบริษัท" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "ออ�อา�าศ" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "วิธี�ารดึงข้อมูล" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "�นบไฟล์ในชื่อ" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "ข้อมูลเพิ่มเติมที่ส่งไป" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "พารามิเตอร์ GET" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "โทเค็นเข้าถึง API" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "ที่อยู่ต้นทาง" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "ความสำคั�" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "�ลุ่มเครื่องส่ง" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "ต้องติดตั้ง libdbus-1.so.x" #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "�าร�จ้งเตือน DBus" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "ความเร่งด่วน" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "��น X" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "��น Y" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "รหัส webhook" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "�ารอ่านข้อความเป็นเสียง" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "รูปประจำตัว" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "URL ของรูปประจำตัว" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "รหัส�ระทู้" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "�สดงส่วนท้าย" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "โลโ�้ในส่วนท้าย" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "ใช้ฟิลด์" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "�ฟล็�ของ Discord" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "�ล่าวถึงผู้ใช้หรือบทบาท" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "พาธของ�ม่�บบ" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "รวมไฟล์�นบเข้าด้วย�ัน" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "โทเค็นของ�ม่�บบ" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "หมายเลขเครื่องของอุป�รณ์" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "โหมด API" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "รีเฟรชทันที" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "ลายเซ็นข้อความ" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "ไอคอน�บบ Base64 (API ข้อความ)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "รูปภาพ�บบ Base64 (API รูปภาพ)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "ลิง�์" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "เส้นขอบ" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "ชนิด�ารเ�ลี่ยสี" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "เคอร์เนล�ารเ�ลี่ยสี" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "คีย์ของงาน" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "หน้าต่างซ้อน" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "พาธ" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "เวลารอของเซิร์ฟเวอร์" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "ชื่ออินส�ตนซ์" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "SID ของบั�ชี" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "หมายเลขโทรศัพท์ผู้ส่งหรือรหัสผู้ส่ง" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "ชื่อภูมิภาค" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "คีย์เข้าถึง" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "ไปยังรหัสผู้ใช้" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "ไปยังรหัสช่อง" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "พาธของ webhook" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "โหมด" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "ชื่อ�ระทู้" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "ชื่อผู้ใช้ API" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "รหัสผ่าน API" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "โทเค็นเข้าถึง" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "เจ้าของ" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "รีโพสิทอรี" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "ชนิด" #: apprise/plugins/github.py:222 msgid "Number" msgstr "หมายเลข" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "ป้าย�ำ�ับ" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "ผู้รับผิดชอบ" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "ต้องติดตั้ง libdbus-1.so.x หรือ libdbus-2.so.x" #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "�าร�จ้งเตือน GLib" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "ต้องมีสภาพ�วดล้อม Gnome ในเครื่อง" #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "�าร�จ้งเตือน Gnome" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "คีย์�ารเชื่อมต่อหลายราย�าร" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "�าร�ระทำ" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "คีย์ตัดราย�ารซ้ำ" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "ข้อมูล�ำ�ับ" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "พื้นที่ทำงาน" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "คีย์ของ webhook" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "คีย์ของ�ระทู้" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "�พลตฟอร์ม" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "หัวข้อ" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "ราย�ารข้อมูล" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "รหัสบอท" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "รุ่น" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "ปั�หมุด" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "โทเค็นเข้าถึงอายุยาว" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "รหัส�าร�จ้งเตือน" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "คำนำหน้าพาธ" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "โทเค็นหรือชื่อผู้ใช้" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "เหตุ�ารณ์" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "เพิ่มโทเค็น" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "ลบโทเค็น" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "คีย์�ารเชื่อมต่อ" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "เส้นทาง" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "รหัส�หล่งที่มา�าร�จ้งเตือน" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "ระดับ�ารส่งต่อปลายทาง" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "ตารางเวรปลายทาง" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "ผู้ใช้ปลายทาง" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "ปลายทาง " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "เอนทิตี" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "ชื่อ�ทน" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "รายละเอียด" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "�ารจับคู่�าร�ระทำ" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "รหัสอุป�รณ์" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "ชื่ออุป�รณ์" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "หมายเลขโทรศัพท์ต้นทาง" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "ระยะเวลา" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "คีย์ของเซิร์ฟเวอร์" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "คีย์ API ของอุป�รณ์" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "รหัส�อป" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "รุ่นของ�อป" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "โทเค็นเข้าถึงของ�อป" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "ไอคอนที่�ำหนดเอง" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "ชนิดของไอคอน" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "จำนวนรอบ" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "โทเค็นของบอท" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "URL เมื่อคลิ�" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "สี" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "ใช้งานได้�ับ Mac OS X 10.8 ขึ้นไปเท่านั้น �ละต้องเข้าถึง /usr/local/bin/terminal-" "notifier ในเครื่องได้" #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "�าร�จ้งเตือน MacOSX" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "URL ที่จะเปิดหรือคลิ�" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "รุ่นของ Terminal-Notifier" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "ผู้ส่ง" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "ตอบ�ลับไปที่" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "โดเมน" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "ชื่อผู้ส่ง" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "ส่วนหัวอีเมล" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "�ารมองเห็น" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "ข้อความที่ซ่อนไว้" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "รหัสภาษา" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "เ�็บผลลัพธ์ไว้ใน�คช" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "ไฟล์�นบที่เป็นความลับ" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "�ล่าวถึงผู้ใช้หรือ�ท็�" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "ผู้ใช้" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "รหัสช่องปลายทาง" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "จำนวนส่วนของข้อความสูงสุด" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "รหัสอ้างอิงที่�ำหนดเอง" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "คิวปลายทาง" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "รหัสไคลเอ็นต์" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "ใช้เซสชัน" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "เ�็บข้อความไว้" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "รหัส�ม่�บบ" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "คีย์ยืนยันตัวตน" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "URL �บบสั้น" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "�ารจับคู่�ม่�บบ" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "คำนำหน้า URL" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "รหัสห้อง" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "�าร�จ้งเตือน�บบไม่มีเสียง" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "รหัสเหตุ�ารณ์ของ Discord" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "ต้นทาง" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "รหัสโครง�าร" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "ฮุ�ของข้อความ" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "สีของ IRC" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "คำนำหน้า" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "ชื่อ�คมเป�" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "ชื่อ�ม่�บบ" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "รหัสอ้างอิงของไคลเอ็นต์" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "คุณภาพสูง" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "ส่วนหัวของโทรสาร" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "ไฟล์�นบ" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "ชื่อไฟล์�นบ" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "เวลาหน่วง" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "อีเมล" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "�าร�ระทำ" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "ชนิด�ารยืนยันตัวตน" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "ชื่อเข้าใช้ API" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "รับ�ารตอบ�ลับ" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "วัตถุประสงค์" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "โดเมนของผู้เช่า" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "อีเมลของบั�ชีหรือรหัสวัตถุ" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "รหัสลับของไคลเอ็นต์" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "บันทึ�ไว้ในราย�ารที่ส่ง�ล้ว" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "�ม่�บบ" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "รหัสผู้เล่นปลายทาง" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "รวม�ลุ่มเป้าหมาย" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "คำบรรยายรอง" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "ภาษา" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "เปิดใช้เนื้อหา" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "ถอดรหัสอาร์�ิวเมนต์ของ�ม่�บบ" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "ข้อมูลที่�ำหนดเอง" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "ข้อมูลตอบ�ลับ" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "รหัสผู้ใช้ปลายทาง" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "รหัส�ลุ่มปลายทาง" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "ส่วนประ�อบ" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "คลาส" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "ระดับความรุน�รง" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "รายละเอียดที่�ำหนดเอง" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "รหัส�ารเชื่อมต่อ" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "รหัสจา�บุคคลที่สาม" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "ข้อมูลเพิ่มเติมของ Meta" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "คีย์หลั�" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "อุป�รณ์" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "เนมสเปซ" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "ป้าย" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "ชนิดข้อความ" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "รหัสปลายทาง" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "SMS ปลายทาง" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "ช่อง" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "รหัส�ารยืนยันตัวตน" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "โทเค็น�ารยืนยันตัวตน" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "คีย์ของผู้ให้บริ�าร" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "คีย์�าร�จ้งเตือน" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "คีย์ของ�อปพลิเคชัน" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "รหัสลับของ�อปพลิเคชัน" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "คีย์ลับ" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "�สดงสถานะ" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "คีย์ของผู้ใช้" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "ชื่อของ URL" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "ช่วงเวลาลองใหม่�รณีฉุ�เฉิน" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "วันหมดอายุ" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "โทเค็นของผู้ใช้" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "หัวข้อของ�ลุ่ม" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "ช่อง" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "ชื่อของ webhook" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "คีย์ส่วนตัว" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "�ารสั่น" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "ระดับข้อมูล" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "ระดับความสำเร็จ" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "ระดับคำเตือน" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "ระดับความล้มเหลว" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "คีย์ API ลับ" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "หัวข้อปลายทาง" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "รหัส�อปพลิเคชัน" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "ซับเรดดิตปลายทาง" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "ชนิดเนื้อหา" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "รหัสป้าย�ำ�ับ" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "ข้อความป้าย�ำ�ับ" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "เป็นโฆษณาหรือไม่" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "ส่ง�ารตอบ�ลับ" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "มีเนื้อหาสปอยล์" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "�ฟล็��ารส่งซ้ำ" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "ที่อยู่ผู้ส่ง" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "รหัสช่อง" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "URL ที่�ังไว้" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "โทเค็นหรือรหัสผ่าน" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "สภาพ�วดล้อม" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "โหมด�ารยืนยันตัวตน" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "รหัสห้องปลายทาง" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "โหมด webhook" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "ใช้รูปประจำตัว" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "หมวดของระบบ" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "บันทึ� PID" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "องค์�ร" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "ข้อมูลของ�ม่�บบ" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "ชื่อผู้ส่ง" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "โทเค็นของเซสชัน" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "รหัสคีย์เข้าถึง" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "คีย์เข้าถึงลับ" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "อีเมลปลายทาง" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "�ฟลช" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "ป้ายชื่อ" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "รหัสบริ�าร" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "รหัสผ่านของบริ�าร" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "รหัสพื้นที่" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "หมายเลขโทรศัพท์ผู้รับ" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "ชนิดสื่อ" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "เวลารอ" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "เสียงอ่านข้อความ" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "คีย์ของไคลเอ็นต์" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "�าร�จ้งเตือนสำคั�" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "บริ�าร" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "ตำ�หน่ง" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "สถาน�ารณ์�าร�จ้งเตือน" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "�าร�รอง" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "รหัสภายนอ�" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "สถานะ" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "สมาชิ�เป้าหมาย" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "�ชร์" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "�ท็�" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "รหัสสั้นปลายทาง" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "โทเค็นเข้าถึง OAuth" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "โทเค็น A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "โทเค็น B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "โทเค็น C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "รหัสปลายทางที่เข้ารหัสไว้" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "พาธของ�ระบวนงาน" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "�สดงส่วนท้าย" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "ใช้บล็อ�" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "�สดงเวลาที่บันทึ�" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "โหมดข้อความ" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "โฮสต์" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "ชื่อเข้าใช้" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "ถอดอั�ษร" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "ผู้ติดต่อปลายทาง" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "ทดสอบเท่านั้น" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "เ�ตเวย์" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "คีย์สาธารณะ" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "ค่าเริ่มต้นสุ่ม" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "ห้อง" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "คีย์�ำหนดเส้นทางปลายทาง" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "รหัสเอนทิตี" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "ชนิด�าร�จ้งเตือน" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "ลิง�์รูปภาพ" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "ลิง�์เสียง" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "สีข้อความพิเศษ" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "จำนวนเงิน" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "ส�ุลเงิน" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "ชื่อ" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "ตัวระบุ" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "�ารอัปโหลด" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "บันทึ�ไปที่ STDERR" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "รหัส�ชทปลายทาง" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "ตรวจหาเจ้าของบอท" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "ตัวอย่างหน้าเว็บ" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "จัด�ลุ่มไฟล์�นบรูปภาพ/วิดีโอ" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "รหัส�ระทู้ของหัวข้อ" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "รุ่นของ Markdown" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "ตำ�หน่งของเนื้อหา" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "พาธของ�ม่�บบข้อความมีรูป�บบ" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "รหัสโปรไฟล์�ารส่งข้อความ" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "รหัสเ�ตเวย์" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "รหัส Threema ปลายทาง" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "ชื่อโฮสต์ของ API" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "URL ของรูปภาพ" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "ชนิดของเหตุ�ารณ์" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "ความสำคั� (รองรับ�บบ Pushover)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "วิธี�จ้งเตือน: sms หรือโทร" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "คีย์ของผู้ใช้ API" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "รหัสลับของผู้ใช้ API" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "รหัสลับเข้าถึง" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "โทเค็นยืนยันตัวตน" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "รหัสผู้รับ" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "URL รูปประจำตัวของบอท" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "อีเมลของผู้ใช้" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "โทเค็นเข้าถึงของบอท" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "รหัสห้อง" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "รหัสองค์�ร" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "รหัสลับของ�อป" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "รหัสเจ้าหน้าที่" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "�ผน�ปลายทาง" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "�ท็�ปลายทาง" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "คีย์ webhook ของบอท" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "รหัสโทรศัพท์ผู้ส่ง" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "ต้องมีสภาพ�วดล้อม Microsoft Windows ในเครื่อง" #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "รหัส�ระบวนงาน" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "ลายเซ็น" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "ใช้ URL ของ Power Automate" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "รหัส�ำหนดเส้นทางของ Power Automate" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "ตัดบรรทัดข้อความ" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "รุ่นของ API" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "หัวข้อ" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "รหัส�ลุ่มหรือตัวเลือ�" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "โทเค็นของ�อป" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "โทเค็นหรือคีย์" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "ความสำคั�ของข้อมูล" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "ความสำคั�ของความสำเร็จ" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "ความสำคั�ของคำเตือน" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "ความสำคั�ของความล้มเหลว" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "ป้ายชื่อของลิง�์" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "โทเค็น�ารยืนยัน" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "สตรีมปลายทาง" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "ติดตั้ง pika สำหรับ AMQP 0.9.1, python-qpid-proton สำหรับ AMQP 1.0 หรือทั้งสองอย่าง" #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "เอ็�ซ์เชนจ์" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "โฮสต์เสมือน" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "คงทน" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "บังคับ" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "อายุข้อความ (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "ส่วนหัวข้อความ" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "เซิร์ฟเวอร์ SMTP" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "โหมดปลอดภัย" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "โหมดความปลอดภัยของ PGP" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "พาธของคีย์สาธารณะ PGP" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "พาธของคีย์ส่วนตัว PGP" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "ไดเร�ทอรีคีย์บนเว็บ" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "ไฟล์�นบ�บบ�ังในเนื้อหา" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "อีเมลผู้รับ" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "ไฟล์คีย์ OAuth2" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "URL รูปภาพที่�ำหนดเอง" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "สีของ�าร�จ้งเตือน" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "ชื่อจริง" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "ชื่อเล่น" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "เข้าร่วมช่อง" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "โหมด�ารยืนยันตัวตน" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "ชื่อ�ทนของห้องปลายทาง" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "�ารค้นหาเซิร์ฟเวอร์" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "บังคับใช้เซิร์ฟเวอร์ต้นสัง�ัด�ับรหัสห้อง" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "รุ่นของ API ของ Matrix" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "�ารเข้ารหัส�บบต้นทางถึงปลายทาง" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "�ารยืนยันอุป�รณ์อัตโนมัติ" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "ไฟล์คีย์ส่วนตัว�บบ PEM" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "ไฟล์�ารสมัครรับข้อมูล" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "เซิร์ฟเวอร์ XMPP" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "ดึงรายชื่อผู้ติดต่อ" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "ใช้หัวเรื่อง" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "คง�ารเชื่อมต่อไว้" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "ชื่อเล่นใน MUC" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "�ารผู�ช่องทาง�บบ SCRAM-PLUS" caronc-apprise-dfec414/apprise/i18n/tl/000077500000000000000000000000001526720371000177605ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/tl/LC_MESSAGES/000077500000000000000000000000001526720371000215455ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/tl/LC_MESSAGES/apprise.po000066400000000000000000002040401526720371000235500ustar00rootroot00000000000000# Tagalog translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: tl\n" "Language-Team: tl \n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "I-verify ang SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "Sundan ang mga redirect" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "Timeout sa pagbasa ng socket" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "Timeout sa pagkonekta ng socket" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "Tanda ng cache" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "Sapilitang uri ng MIME" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "Sapilitang pangalan ng file" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "Lokal na file" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "Nasa web" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "Memorya" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "Skema" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "Walang kailangang dependency." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "Kailangan ng mga package upang gumana." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "Inirerekomenda ang mga package upang mapabuti ang gamit." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "Pangalan ng user ng app" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "Susi ng API" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "Telepono ng padadalhan" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "Mga padadalhan" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "Nagpadala" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "Mode ng SMS" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "Pinagsamang mode" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "Pangalan ng host" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "Port" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "Pangalan ng user" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "Password" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "Token" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "Mga tag" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "Paraan ng query" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Bersyon ng Apprise API" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "Header ng HTTP" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "Pangalan ng user" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "Callsign ng padadalhan" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "Pagkaantala ng muling pagpadala" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "Panrehiyong setting" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "Device na padadalhan" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "Tunog" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "Antas" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "Lakas ng tunog" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "Pag-click" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "Badge" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "Kategorya" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "Grupo" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "Isama ang larawan" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "URL ng icon" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "Tawag" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "Susi ng pag-encrypt" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "Mode kapag sumobra" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "Format ng abiso" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "Bigyang-kahulugan ang mga emoji" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "Panatilihing imbakan" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "Time zone" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "Muling pagsubok ng serbisyo" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "Paghihintay sa pagitan ng mga pagsubok" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "Opsyonal na serbisyo" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "Email ng nagpadala" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "Pangkat na padadalhan" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "Patakaran sa Escalation" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "Mga Paraan ng Alerto" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "Paghihintay ng Team" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "Serial number" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "Tagal (ms)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "Tagal ng paglabo (ms)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "Numero ng LED" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "Pinagmulang email" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "Email ng padadalhan" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "Email para sa sagot" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "Kopya" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "Nakatagong kopya" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "Sekreto" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "Numero ng telepono ng padadalhan" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "Grupong padadalhan" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "Numero ng telepono ng nagpadala" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "Grupo ng ruta" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Mga karakter na Unicode" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "Sekreto ng API" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "ID ng nagpadala" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "Bansa" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "bisa" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "Token ng API" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Token ng webhook" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "Pangalan ng bot" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "Channel na padadalhan" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "Bot na padadalhan" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "Rehiyon" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "ID ng kumpanya" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "I-broadcast" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "Paraan ng pagkuha" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "Ilakip ang file bilang" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "Karagdagan sa payload" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "Mga parameter ng GET" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "Token ng access sa API" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "Pinagmulang address" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "Prayoridad" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "Mga grupo ng transmitter" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "Kailangang naka-install ang libdbus-1.so.x." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "Abiso ng DBus" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "Pagkaapurahan" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "X na aksis" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Y na aksis" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "ID ng webhook" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "Pagsasalita ng teksto" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "Larawan ng avatar" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "URL ng avatar" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "ID ng thread" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "Ipakita ang footer" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "Logo sa footer" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "Gamitin ang mga field" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Mga flag ng Discord" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "Banggitin ang mga user o role" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "Landas ng template" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "Pagsamahin ang mga kalakip" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "Mga token ng template" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "Serial number ng device" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "Mode ng API" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "I-refresh ngayon" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Lagda ng teksto" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Icon sa Base64 (Text API)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Larawan sa Base64 (Image API)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "Link" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "Hangganan" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Uri ng dithering" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Kernel ng dithering" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "Susi ng gawain" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "Modal" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "Landas" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Timeout ng server" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "Pangalan ng instance" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "SID ng account" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "Numero ng telepono ng nagpadala o ID ng nagpadala" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "Pangalan ng rehiyon" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "Susi ng access" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "Sa ID ng user" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "Sa ID ng channel" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "Landas ng webhook" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "Mode" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "Pangalan ng thread" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "Pangalan ng user ng API" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "Password ng API" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "Token ng access" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "May-ari" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "Repository" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "Uri" #: apprise/plugins/github.py:222 msgid "Number" msgstr "Numero" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "Mga label" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "Mga itinalaga" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "Kailangang naka-install ang libdbus-1.so.x o libdbus-2.so.x." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "Abiso ng GLib" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "Kailangan ng lokal na Gnome environment." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Abiso ng Gnome" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "Mga susi ng integrasyon" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "Aksyon" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "Susi ng deduplikasyon" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "Metadata" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "Workspace" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Susi ng webhook" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "Susi ng thread" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "Plataporma" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "Paksa" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "Mga entry ng datos" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "ID ng bot" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Bersyon" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Nakapaskil" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "Pangmatagalang token ng access" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "ID ng abiso" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "Unlapi ng landas" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "Token o pangalan ng user" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "Mga kaganapan" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "Magdagdag ng token" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "Mag-alis ng token" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "Susi ng integrasyon" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "Ruta" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "ID ng Pinagmulan ng Alerto" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "Escalation na padadalhan" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "Iskedyul na padadalhan" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "User na padadalhan" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "Mga padadalhan " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "Entity" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "Alyas" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "Mga detalye" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "Pagtutugma ng aksyon" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "ID ng device" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "Pangalan ng device" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "Pinagmulang numero ng telepono" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Tagal" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "Susi ng server" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "Susi ng API ng device" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "ID ng app" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "Bersyon ng app" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "Token ng access ng app" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "Pasadyang icon" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "Uri ng icon" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "Mga siklo" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "Token ng bot" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "URL ng pag-click" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "Kulay" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Gumagana lamang sa Mac OS X 10.8 at mas bago. Kailangan rin na naa-access " "nang lokal ang /usr/local/bin/terminal-notifier." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "Abiso ng MacOSX" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "URL na bubuksan o i-click" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Bersyon ng Terminal-Notifier" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "Nagpadala" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "Sagutin sa" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "Domain" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "Pangalan ng nagpadala" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "Header ng email" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "Pagkakita" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "Nakatagong teksto" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "Code ng wika" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "Itago ang resulta sa cache" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "Mga sensitibong kalakip" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "Banggitin ang mga user o tag" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "User" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "ID ng channel na padadalhan" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "Pinakadaming bahagi ng mensahe" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "Pasadyang sanggunian" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "Pila na padadalhan" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "ID ng client" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "Gumamit ng session" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "Itago ang mga mensahe" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "ID ng template" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "Susi ng pagpapatotoo" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "Maikling URL" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "Pagtutugma ng template" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "Unlapi ng URL" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "ID ng room" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "Tahimik na abiso" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "ID ng kaganapan sa Discord" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "Pinagmulan" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "ID ng proyekto" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "Hook ng mensahe" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "Mga kulay ng IRC" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "Unlapi" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "Pangalan ng kampanya" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "Pangalan ng template" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "Sanggunian ng client" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "Mataas na kalidad" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "Header ng fax" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "Kalakip" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "Pangalan ng file ng kalakip" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "Pagkaantala" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "Email" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "Mga aksyon" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "Uri ng pagpapatotoo" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "Login sa API" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "Tanggapin ang mga sagot" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "Layunin" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "Domain ng tenant" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "Email ng account o ID ng object" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "Sekreto ng client" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "Itabi sa mga naipadala" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "Template" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "ID ng player na padadalhan" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "Isama ang segment" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "Subtitulo" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "Wika" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "Paganahin ang nilalaman" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "I-decode ang mga argumento ng template" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "Pasadyang datos" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "Datos ng postback" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "ID ng user na padadalhan" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "ID ng grupong padadalhan" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "Bahagi" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "Klase" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "Kalubhaan" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "Pasadyang detalye" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "ID ng integrasyon" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "ID ng third party" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Karagdagan ng Meta" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "Pangunahing susi" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "Device" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "Namespace" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "Mga badge" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "Uri ng mensahe" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "ID ng padadalhan" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "SMS na padadalhan" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "Mga channel" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "ID ng pagpapatotoo" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "Token ng pagpapatotoo" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "Susi ng provider" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "Susi ng abiso" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "Susi ng aplikasyon" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "Sekreto ng aplikasyon" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "Sekretong susi" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "Ipakita ang status" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "Susi ng user" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "Titulo ng URL" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "Agwat ng muling pagsubok sa emergency" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "Pag-expire" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "Token ng user" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "Mga paksa ng grupo" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "Channel" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "Pangalan ng webhook" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "Pribadong susi" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "Pag-vibrate" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "Antas ng impormasyon" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "Antas ng tagumpay" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "Antas ng babala" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "Antas ng pagpalya" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "Sekretong susi ng API" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "Paksang padadalhan" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "ID ng aplikasyon" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "Subreddit na padadalhan" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "Klase ng nilalaman" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "ID ng flair" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "Teksto ng flair" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "Patalastas ba?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "Ipadala ang mga sagot" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "May spoiler" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "Flag ng muling pagsumite" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "Address ng nagpadala" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "ID ng channel" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "Naka-embed na URL" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "Token o password" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "Kapaligiran" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "Mode ng pagpapatotoo" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "ID ng room na padadalhan" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Mode ng webhook" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "Gamitin ang avatar" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "Pasilidad" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "Itala ang PID" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "Organisasyon" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "Datos ng template" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "Pangalan ng nagpadala" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "Token ng session" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "ID ng susi ng access" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "Sekretong susi ng access" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "Mga email na padadalhan" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "Flash" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "Label" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "ID ng serbisyo" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "Password ng serbisyo" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "ID ng space" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "Numero ng telepono ng tatanggap" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "Uri ng media" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "Timeout" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "Boses ng pagsasalita" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "Susi ng client" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "Kritikal na abiso" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "Serbisyo" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "Lokasyon" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "Senaryo ng pag-alerto" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "Pagsala" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "Panlabas na ID" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "Status" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "Target na Miyembro" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "Ibinahagi" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "Tag" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "Maikling code na padadalhan" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "Token ng access ng OAuth" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "Token A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "Token B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "Token C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "Naka-encode na ID ng padadalhan" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "Landas ng workflow" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "Isama ang footer" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "Gumamit ng mga block" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "Isama ang timestamp" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "Mode ng mensahe" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "Host" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "Login" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "Isalin ang titik" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "Contact na padadalhan" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "Pagsubok lamang" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "Gateway" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "Pampublikong susi" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "Binhi" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "Mga room" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "Susi ng ruta ng padadalhan" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "ID ng entity" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "Uri ng alerto" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "Link ng larawan" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "Link ng tunog" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "Espesyal na kulay ng teksto" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "Halaga" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "Pera" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "Pangalan" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "Pantukoy" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "Pag-upload" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "Itala sa STDERR" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "ID ng chat na padadalhan" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "Alamin ang may-ari ng bot" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "Preview ng web page" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "Igrupo ang mga attachment na larawan/video" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "ID ng thread ng paksa" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Bersyon ng Markdown" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "Lagay ng nilalaman" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "Landas ng template ng pormatadong mensahe" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "ID ng profile ng pagmemensahe" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "ID ng gateway" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "Threema ID na padadalhan" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "Pangalan ng host ng API" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "URL ng larawan" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "Uri ng kaganapan" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "Prayoridad (angkop sa Pushover)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "Paraan ng abiso: sms o tawag" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "Susi ng consumer" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "Sekreto ng consumer" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "Sekreto ng access" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "Token ng pagpapatotoo" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "Mga ID ng tatanggap" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "URL ng avatar ng bot" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "Email ng user" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "Token ng access ng bot" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "Mga ID ng room" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "ID ng kompanya" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "Sekreto ng app" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "ID ng ahente" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "Departamentong padadalhan" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "Tag na padadalhan" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "Susi ng webhook ng bot" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "ID ng telepono ng nagpadala" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "Kailangan ng lokal na Microsoft Windows environment." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "ID ng workflow" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "Lagda" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Gamitin ang URL ng Power Automate" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "ID ng ruta ng Power Automate" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "Ibalot ang teksto" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "Bersyon ng API" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "Mga paksa" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "Code ng grupo o opsyon" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "Token ng app" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "Token o susi" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "Prayoridad ng impormasyon" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "Prayoridad ng tagumpay" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "Prayoridad ng babala" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "Prayoridad ng pagpalya" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "Label ng link" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "Token ng pagpapatunay" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "Stream na padadalhan" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "I-install ang pika para sa AMQP 0.9.1, python-qpid-proton para sa AMQP 1.0, " "o pareho." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "Exchange" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "Virtual Host" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "Persistent" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "Kinakailangan" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "Tagal ng Buhay (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "Mga Header ng Mensahe" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "SMTP server" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "Sigurong mode" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "Mode ng seguridad ng PGP" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "Landas ng pampublikong susi ng PGP" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "Landas ng pribadong susi ng PGP" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "Direktoryo ng susi sa web" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "Naka-embed na kalakip" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "Email ng tatanggap" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "File ng susi ng OAuth2" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "Pasadyang URL ng larawan" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "Kulay ng abiso" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "Tunay na pangalan" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "Palayaw" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "Sumali sa mga channel" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "Mode ng pagpapatotoo" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "Alyas ng room na padadalhan" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "Pagtuklas ng server" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "Ipilit ang home server sa mga ID ng room" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Bersyon ng API ng Matrix" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "End-to-end na pag-encrypt" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "Awtomatikong pagpapatunay ng device" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "File ng pribadong susi na PEM" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "File ng subscription" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "XMPP server" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "Kunin ang listahan ng contact" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "Gamitin ang subject" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "Panatilihing bukas ang koneksyon" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "Palayaw sa MUC" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "Pag-ugnay ng channel na SCRAM-PLUS" caronc-apprise-dfec414/apprise/i18n/tr/000077500000000000000000000000001526720371000177665ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/tr/LC_MESSAGES/000077500000000000000000000000001526720371000215535ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/tr/LC_MESSAGES/apprise.po000066400000000000000000002031211526720371000235550ustar00rootroot00000000000000# Turkish translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: tr\n" "Language-Team: tr \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "SSL doğrula" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "Yönlendirmeleri izle" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "Yuva okuma zaman aşımı" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "Yuva bağlantı zaman aşımı" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "Önbellek yaşı" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "Zorunlu MIME türü" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "Zorunlu dosya adı" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "Yerel dosya" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "Web tabanlı" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "Bellek" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "Şema" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "Bağımlılık yok." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "Çalışması için paketler gereklidir." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "İşlevselliği artırmak için paketler önerilir." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "Uygulama kullanıcı adı" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "API anahtarı" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "Hedef telefon" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "Hedefler" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "Gönderen" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "SMS kipi" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "Toplu kip" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "Sunucu adı" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "Kapı" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "Kullanıcı adı" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "Parola" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "Belirteç" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "Etiketler" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "Sorgu yöntemi" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Apprise API sürümü" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "HTTP başlığı" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "Kullanıcı adı" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "Hedef çağrı işareti" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "Yeniden gönderme gecikmesi" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "Yerel ayar" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "Hedef aygıt" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "Ses" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "Düzey" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "Ses düzeyi" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "Tıklama" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "Simge rozeti" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "Kategori" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "Grup" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "Resmi ekle" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "Simge adresi" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "Arama" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "Şifreleme anahtarı" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "Taşma kipi" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "Bildirim biçimi" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "Emojileri yorumla" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "Kalıcı depolama" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "Saat dilimi" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "Hizmet yeniden denemeleri" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "Denemeler arası bekleme" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "İsteğe bağlı hizmet" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "Gönderen e-postası" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "Hedef takım" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "Eskalasyon Politikası" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "Uyarı Yöntemleri" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "Ekip Bekleme Süresi" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "Seri numarası" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "Süre (ms)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "Geçiş süresi (ms)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "LED numarası" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "Kaynak e-posta" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "Hedef e-posta" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "Yanıt e-postası" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "Bilgi" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "Gizli bilgi" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "Parola" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "Hedef telefon numarası" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "Hedef grup" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "Gönderen telefon numarası" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "Yönlendirme grubu" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Unicode karakterler" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "API parolası" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "Gönderen kimliği" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "Ülke" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "geçerlilik" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "API belirteci" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Web kancası belirteci" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "Bot adı" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "Hedef kanal" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "Hedef bot" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "Bölge" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "Şirket kimliği" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "Herkese Gönder" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "Getirme yöntemi" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "Dosyayı şu adla ekle" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "Yük ekleri" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "GET parametreleri" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "API erişim belirteci" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "Kaynak adres" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "Öncelik" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "Verici grupları" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "libdbus-1.so.x kurulu olmalıdır." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "DBus bildirimi" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "Aciliyet" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "X ekseni" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Y ekseni" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "Web kancası kimliği" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "Metin okuma" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "Avatar resmi" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "Avatar adresi" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "Konu kimliği" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "Alt bilgiyi göster" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "Alt bilgi logosu" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "Alanları kullan" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Discord bayrakları" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "Kullanıcıları veya rolleri an" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "Şablon yolu" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "Ekleri birleştir" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "Şablon belirteçleri" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "Aygıt seri numarası" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "API kipi" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Şimdi yenile" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Metin imzası" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Base64 simge (metin API'si)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Base64 resim (resim API'si)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "Bağlantı" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "Kenarlık" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Karıştırma türü" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Karıştırma çekirdeği" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "Görev anahtarı" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "Kalıcı pencere" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "Yol" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Sunucu zaman aşımı" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "Örnek adı" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "Hesap SID'si" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "Gönderen telefon numarası veya gönderen kimliği" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "Bölge adı" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "Erişim anahtarı" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "Hedef kullanıcı kimliği" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "Hedef kanal kimliği" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "Web kancası yolu" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "Kip" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "Konu adı" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "API kullanıcı adı" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "API parolası" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "Erişim belirteci" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "Sahip" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "Depo" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "Tür" #: apprise/plugins/github.py:222 msgid "Number" msgstr "Numara" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "Etiketler" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "Atananlar" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "libdbus-1.so.x veya libdbus-2.so.x kurulu olmalıdır." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "GLib bildirimi" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "Yerel bir Gnome ortamı gereklidir." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Gnome bildirimi" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "Tümleştirme anahtarları" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "Eylem" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "Yinelenenleri kaldırma anahtarı" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "Üst veriler" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "Çalışma alanı" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Web kancası anahtarı" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "Konu anahtarı" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "Platform" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "Konu" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "Veri girdileri" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "Bot kimliği" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Sürüm" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Sabitlenmiş" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "Uzun ömürlü erişim belirteci" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "Bildirim kimliği" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "Yol ön eki" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "Belirteç veya kullanıcı adı" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "Olaylar" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "Belirteç ekle" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "Belirteç kaldır" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "Tümleştirme anahtarı" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "Yol" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "Uyarı Kaynağı Kimliği" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "Hedef yükseltme" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "Hedef takvim" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "Hedef kullanıcı" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "Hedefler " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "Varlık" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "Takma ad" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "Ayrıntılar" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "Eylem eşlemesi" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "Aygıt kimliği" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "Aygıt adı" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "Kaynak telefon numarası" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Süre" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "Sunucu anahtarı" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "Aygıt API anahtarı" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "Uygulama kimliği" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "Uygulama sürümü" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "Uygulama erişim belirteci" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "Özel simge" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "Simge türü" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "Çevrimler" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "Bot belirteci" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "Tıklama adresi" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "Renk" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Yalnızca Mac OS X 10.8 ve daha yeni sürümlerde çalışır. Ayrıca /usr/local/" "bin/terminal-notifier dosyasının yerel olarak erişilebilir olması gerekir." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "MacOSX bildirimi" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "Açma veya tıklama adresi" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Terminal-Notifier sürümü" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "Gönderen" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "Yanıt adresi" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "Alan adı" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "Gönderen adı" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "E-posta başlığı" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "Görünürlük" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "Gizlenen metin" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "Dil kodu" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "Sonuçları önbelleğe al" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "Hassas ekler" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "Kullanıcıları veya etiketleri an" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "Kullanıcı" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "Hedef kanal kimliği" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "En fazla ileti parçası" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "Özel başvuru" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "Hedef kuyruk" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "İstemci kimliği" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "Oturum kullan" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "İletileri sakla" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "Şablon kimliği" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "Kimlik doğrulama anahtarı" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "Kısa adres" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "Şablon eşlemesi" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "Adres ön eki" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "Oda kimliği" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "Sessiz bildirim" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "Discord olay kimliği" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "Kaynak" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "Proje kimliği" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "İleti kancası" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "IRC renkleri" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "Ön ek" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "Kampanya adı" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "Şablon adı" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "İstemci başvurusu" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "Yüksek kalite" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "Faks başlığı" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "Ek" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "Ek dosya adı" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "Gecikme" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "E-posta" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "Eylemler" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "Kimlik doğrulama türü" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "API oturum adı" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "Yanıtları kabul et" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "Amaç" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "Kiracı alan adı" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "Hesap e-postası veya nesne kimliği" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "İstemci parolası" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "Gönderilmiş öğelere kaydet" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "Şablon" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "Hedef oynatıcı kimliği" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "Bölümü ekle" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "Alt başlık" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "Dil" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "İçeriği etkinleştir" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "Şablon argümanlarını çöz" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "Özel veriler" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "Geri bildirim verileri" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "Hedef kullanıcı kimliği" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "Hedef grup kimliği" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "Bileşen" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "Sınıf" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "Önem derecesi" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "Özel ayrıntılar" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "Tümleştirme kimliği" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "Üçüncü taraf kimliği" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Meta ekleri" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "Ana anahtar" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "Aygıt" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "Ad alanı" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "Simge rozetleri" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "İleti türü" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "Hedef kimliği" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "Hedef SMS" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "Kanallar" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "Kimlik doğrulama kimliği" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "Kimlik doğrulama belirteci" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "Sağlayıcı anahtarı" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "Bildirim anahtarı" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "Uygulama anahtarı" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "Uygulama parolası" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "Gizli anahtar" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "Durumu göster" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "Kullanıcı anahtarı" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "Adres başlığı" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "Acil yeniden deneme aralığı" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "Son kullanma" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "Kullanıcı belirteci" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "Grup konuları" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "Kanal" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "Web kancası adı" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "Özel anahtar" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "Titreşim" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "Bilgi düzeyi" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "Başarı düzeyi" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "Uyarı düzeyi" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "Hata düzeyi" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "Gizli API anahtarı" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "Hedef konu" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "Uygulama kimliği" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "Hedef alt Reddit" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "Tür" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "Etiket kimliği" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "Etiket metni" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "Reklam mı?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "Yanıtları gönder" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "Sürpriz bozan içerir" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "Yeniden gönderme bayrağı" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "Gönderen adresi" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "Kanal kimliği" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "Gömülü adres" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "Belirteç veya parola" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "Ortam" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "Kimlik doğrulama kipi" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Web kancası" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "Hedef oda kimliği" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Web kancası kipi" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "Avatarı kullan" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "Hizmet birimi" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "PID'yi kaydet" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "Kuruluş" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "Şablon verileri" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "Gönderen adı" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "Oturum belirteci" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "Erişim anahtarı kimliği" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "Gizli erişim anahtarı" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "Hedef e-postalar" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "Flaş" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "Etiket" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "Hizmet kimliği" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "Hizmet parolası" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "Alan kimliği" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "Alıcı telefon numarası" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "Ortam türü" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "Zaman aşımı" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "Metin okuma sesi" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "İstemci anahtarı" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "Kritik bildirim" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "Hizmet" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "Konum" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "Uyarı senaryosu" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "Süzme" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "Dış kimlik" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "Durum" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "Hedef Üye" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "Paylaşılan" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "Etiket" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "Hedef kısa kod" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "OAuth erişim belirteci" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "Belirteç A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "Belirteç B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "Belirteç C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "Hedef kodlanmış kimlik" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "İş akışı yolu" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "Alt bilgiyi ekle" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "Blokları kullan" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "Zaman damgasını ekle" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "İleti kipi" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "Sunucu" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "Oturum adı" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "Harf çevirisi yap" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "Hedef kişi" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "Yalnızca sınama" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "Geçit" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "Açık anahtar" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "Tohum" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "Odalar" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "Hedef yönlendirme anahtarı" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "Varlık kimliği" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "Uyarı türü" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "Resim bağlantısı" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "Ses bağlantısı" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "Özel metin rengi" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "Tutar" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "Para birimi" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "Ad" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "Tanımlayıcı" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "Yükleme" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "STDERR'e kaydet" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "Hedef sohbet kimliği" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "Bot sahibini algıla" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "Web sayfası ön izlemesi" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "Fotoğraf/video eklerini grupla" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "Konu başlığı kimliği" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Markdown sürümü" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "İçerik yerleşimi" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "Biçimli ileti şablonu yolu" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "İletileşme profili kimliği" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "Geçit kimliği" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "Hedef Threema kimliği" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "API sunucu adı" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "Resim adresi" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "Olay türü" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "Öncelik (Pushover uyumluluğu)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "Bildirim yöntemi: sms veya arama" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "Tüketici anahtarı" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "Tüketici parolası" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "Erişim parolası" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "Kimlik doğrulama belirteci" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "Alıcı kimlikleri" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "Bot avatar adresi" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "Kullanıcı e-postası" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "Bot erişim belirteci" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "Oda kimlikleri" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "Kurum kimliği" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "Uygulama parolası" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "Temsilci kimliği" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "Hedef bölüm" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "Hedef etiket" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "Bot web kancası anahtarı" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "Gönderen telefon kimliği" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "Yerel bir Microsoft Windows ortamı gereklidir." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "İş akışı kimliği" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "İmza" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Power Automate adresini kullan" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "Power Automate yönlendirme kimliği" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "Metni kaydır" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "API sürümü" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "Konular" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "Grup veya seçenek kodu" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "Uygulama belirteci" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "Belirteç veya anahtar" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "Bilgi önceliği" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "Başarı önceliği" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "Uyarı önceliği" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "Hata önceliği" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "Bağlantı etiketi" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "Doğrulama belirteci" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "Hedef akış" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "AMQP 0.9.1 için pika, AMQP 1.0 için python-qpid-proton veya her ikisini " "kurun." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "Exchange" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "Sanal Ana Bilgisayar" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "Kalıcı" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "Zorunlu" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "Yaşam Süresi (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "Mesaj Başlıkları" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "SMTP sunucusu" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "Güvenli kip" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "PGP güvenlik kipi" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "PGP açık anahtar yolu" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "PGP özel anahtar yolu" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "Web anahtar dizini" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "Gömülü ekler" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "Alıcı e-postası" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "OAuth2 anahtar dosyası" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "Özel resim adresi" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "Bildirim rengi" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "Gerçek ad" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "Takma ad" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "Kanallara katıl" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "Kimlik doğrulama kipi" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "Hedef oda takma adı" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "Sunucu keşfi" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "Oda kimliklerinde ana sunucuyu zorla" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Matrix API sürümü" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "Uçtan uca şifreleme" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "Kendiliğinden aygıt doğrulama" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "PEM özel anahtar dosyası" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "Abonelik dosyası" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "XMPP sunucusu" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "Kişi listesini al" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "Konuyu kullan" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "Bağlantıyı açık tut" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "MUC takma adı" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "SCRAM-PLUS kanal bağlama" caronc-apprise-dfec414/apprise/i18n/vi/000077500000000000000000000000001526720371000177575ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/vi/LC_MESSAGES/000077500000000000000000000000001526720371000215445ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/vi/LC_MESSAGES/apprise.po000066400000000000000000002062661526720371000235630ustar00rootroot00000000000000# Vietnamese translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: vi\n" "Language-Team: vi \n" "Plural-Forms: nplurals=1; plural=0;\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "Xác minh SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "�i theo chuyển hướng" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "Th�i gian ch� đ�c socket" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "Th�i gian ch� kết nối socket" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "Tuổi của bộ đệm" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "Loại MIME bắt buộc" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "Tên tệp bắt buộc" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "Tệp cục bộ" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "Trên web" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "Bộ nhớ" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "Lược đồ" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "Không có phụ thuộc." #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "Cần có các gói để hoạt động." #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "Nên cài các gói để cải thiện tính năng." #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "Tên ngư�i dùng ứng dụng" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "Khóa API" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "�iện thoại đích" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "�ích đến" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "Ngư�i gửi" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "Chế độ SMS" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "Chế độ theo lô" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "Tên máy chủ" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "Cổng" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "Tên ngư�i dùng" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "Mật khẩu" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "Mã thông báo" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "Thẻ" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "Phương thức truy vấn" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Phiên bản API Apprise" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "Tiêu đ� HTTP" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "Tên ngư�i dùng" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "Tín hiệu g�i đích" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "�ộ trễ gửi lại" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "Thiết lập vùng" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "Thiết bị đích" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "Âm thanh" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "Mức" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "Âm lượng" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "Nhấp" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "Huy hiệu" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "Danh mục" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "Nhóm" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "Kèm hình ảnh" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "URL biểu tượng" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "Cuộc g�i" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "Khóa mã hóa" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "Chế độ khi vượt quá" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "�ịnh dạng thông báo" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "Diễn giải emoji" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "Lưu trữ lâu dài" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "Múi gi�" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "Số lần thử lại của dịch vụ" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "Th�i gian ch� giữa các lần thử" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "Dịch vụ tùy ch�n" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "Email ngư�i gửi" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "Nhóm trực đích" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "Chính sách leo thang" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "Phương thức cảnh báo" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "Th�i gian ch� nhóm" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "Số sê-ri" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "Th�i lượng (ms)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "Th�i gian m� dần (ms)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "Số hiệu đèn LED" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "Email nguồn" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "Email đích" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "Email để trả l�i" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "Bản sao" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "Bản sao ẩn" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "Mã bí mật" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "Số điện thoại đích" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "Nhóm đích" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "Số điện thoại ngư�i gửi" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "Nhóm định tuyến" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Ký tự Unicode" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "Mã bí mật API" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "ID ngư�i gửi" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "Quốc gia" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "hiệu lực" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "Mã thông báo API" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Mã thông báo webhook" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "Tên bot" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "Kênh đích" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "Bot đích" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "Vùng" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "ID công ty" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "Phát cho tất cả" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "Phương thức lấy dữ liệu" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "�ính kèm tệp dưới tên" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "Phần bổ sung của dữ liệu gửi" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "Tham số GET" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "Mã thông báo truy cập API" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "�ịa chỉ xuất phát" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "�ộ ưu tiên" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "Nhóm bộ phát" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "Phải cài đặt libdbus-1.so.x." #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "Thông báo DBus" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "Mức khẩn cấp" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "Trục X" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Trục Y" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "ID webhook" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "Chuyển văn bản thành gi�ng nói" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "Ảnh đại diện" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "URL ảnh đại diện" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "ID chủ đ�" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "Hiện chân trang" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "Logo ở chân trang" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "Dùng các trư�ng" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "C� của Discord" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "Nhắc ngư�i dùng hoặc vai trò" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "�ư�ng dẫn mẫu" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "Gộp các tệp đính kèm" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "Mã thông báo của mẫu" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "Số sê-ri thiết bị" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "Chế độ API" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Làm mới ngay" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Chữ ký văn bản" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Biểu tượng dạng Base64 (API văn bản)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Hình ảnh dạng Base64 (API hình ảnh)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "Liên kết" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "�ư�ng vi�n" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Kiểu tạo hạt" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Lõi tạo hạt" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "Khóa công việc" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "Hộp thoại" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "�ư�ng dẫn" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Th�i gian ch� máy chủ" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "Tên phiên bản" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "SID của tài khoản" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "Số điện thoại ngư�i gửi hoặc ID ngư�i gửi" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "Tên vùng" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "Khóa truy cập" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "�ến ID ngư�i dùng" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "�ến ID kênh" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "�ư�ng dẫn webhook" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "Chế độ" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "Tên chủ đ�" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "Tên ngư�i dùng API" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "Mật khẩu API" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "Mã thông báo truy cập" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "Chủ sở hữu" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "Kho mã" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "Loại" #: apprise/plugins/github.py:222 msgid "Number" msgstr "Số" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "Các nhãn" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "Ngư�i được giao" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "Phải cài đặt libdbus-1.so.x hoặc libdbus-2.so.x." #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "Thông báo GLib" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "Cần có môi trư�ng Gnome cục bộ." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Thông báo Gnome" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "Các khóa tích hợp" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "Hành động" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "Khóa chống trùng lặp" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "Dữ liệu mô tả" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "Không gian làm việc" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Khóa webhook" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "Khóa chủ đ�" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "N�n tảng" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "Chủ đ�" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "Mục dữ liệu" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "ID bot" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Phiên bản" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "�ược ghim" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "Mã thông báo truy cập dài hạn" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "ID thông báo" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "Ti�n tố đư�ng dẫn" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "Mã thông báo hoặc tên ngư�i dùng" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "Sự kiện" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "Thêm mã thông báo" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "Xóa mã thông báo" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "Khóa tích hợp" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "Tuyến" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "ID nguồn cảnh báo" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "Mức chuyển tiếp đích" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "Lịch trực đích" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "Ngư�i dùng đích" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "�ích đến " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "Thực thể" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "Bí danh" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "Chi tiết" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "�nh xạ hành động" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "ID thiết bị" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "Tên thiết bị" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "Số điện thoại nguồn" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Th�i lượng" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "Khóa máy chủ" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "Khóa API thiết bị" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "ID ứng dụng" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "Phiên bản ứng dụng" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "Mã thông báo truy cập ứng dụng" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "Biểu tượng riêng" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "Loại biểu tượng" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "Số chu kỳ" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "Mã thông báo bot" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "URL khi nhấp" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "Màu" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Chỉ hoạt động với Mac OS X 10.8 trở lên. Ngoài ra, cần truy cập được /usr/" "local/bin/terminal-notifier trên máy." #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "Thông báo MacOSX" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "URL để mở hoặc nhấp" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Phiên bản Terminal-Notifier" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "Ngư�i gửi" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "Trả l�i đến" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "Tên mi�n" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "Tên ngư�i gửi" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "Tiêu đ� email" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "Khả năng xem" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "Văn bản ẩn" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "Mã ngôn ngữ" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "Lưu kết quả vào bộ đệm" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "Tệp đính kèm nhạy cảm" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "Nhắc ngư�i dùng hoặc thẻ" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "Ngư�i dùng" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "ID kênh đích" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "Số phần tối đa của tin nhắn" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "Tham chiếu riêng" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "Hàng đợi đích" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "ID ứng dụng khách" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "Dùng phiên" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "Giữ lại tin nhắn" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "ID mẫu" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "Khóa xác thực" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "URL ngắn" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "�nh xạ mẫu" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "Ti�n tố URL" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "ID phòng" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "Thông báo im lặng" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "ID sự kiện Discord" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "Nguồn" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "ID dự án" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "Móc tin nhắn" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "Màu IRC" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "Ti�n tố" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "Tên chiến dịch" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "Tên mẫu" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "Tham chiếu của khách" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "Chất lượng cao" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "Tiêu đ� fax" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "Tệp đính kèm" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "Tên tệp đính kèm" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "�ộ trễ" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "Email" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "Hành động" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "Loại xác thực" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "Tên đăng nhập API" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "Nhận trả l�i" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "Mục đích" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "Tên mi�n của tổ chức" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "Email tài khoản hoặc ID đối tượng" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "Mã bí mật của ứng dụng khách" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "Lưu vào thư đã gửi" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "Mẫu" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "ID ngư�i chơi đích" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "Kèm phân khúc" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "Phụ đ�" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "Ngôn ngữ" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "Bật nội dung" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "Giải mã tham số của mẫu" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "Dữ liệu riêng" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "Dữ liệu phản hồi" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "ID ngư�i dùng đích" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "ID nhóm đích" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "Thành phần" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "Lớp" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "Mức nghiêm tr�ng" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "Chi tiết riêng" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "ID tích hợp" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "ID bên thứ ba" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Phần bổ sung của Meta" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "Khóa chính" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "Thiết bị" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "Không gian tên" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "Huy hiệu" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "Loại tin nhắn" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "ID đích" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "SMS đích" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "Kênh" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "ID xác thực" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "Mã thông báo xác thực" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "Khóa nhà cung cấp" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "Khóa thông báo" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "Khóa ứng dụng" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "Mã bí mật ứng dụng" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "Khóa bí mật" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "Hiện trạng thái" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "Khóa ngư�i dùng" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "Tiêu đ� URL" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "Khoảng th�i gian thử lại khẩn cấp" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "Hết hạn" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "Mã thông báo ngư�i dùng" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "Chủ đ� của nhóm" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "Kênh" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "Tên webhook" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "Khóa riêng" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "Rung" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "Mức thông tin" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "Mức thành công" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "Mức cảnh báo" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "Mức lỗi" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "Khóa API bí mật" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "Chủ đ� đích" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "ID ứng dụng" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "Subreddit đích" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "Loại nội dung" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "ID nhãn" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "Văn bản nhãn" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "Là quảng cáo?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "Gửi trả l�i" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "Có nội dung tiết lộ" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "C� gửi lại" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "�ịa chỉ ngư�i gửi" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "ID kênh" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "URL nhúng" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "Mã thông báo hoặc mật khẩu" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "Môi trư�ng" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "Chế độ xác thực" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "ID phòng đích" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Chế độ webhook" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "Dùng ảnh đại diện" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "Phân hệ" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "Ghi lại PID" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "Tổ chức" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "Dữ liệu của mẫu" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "Tên ngư�i gửi" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "Mã thông báo phiên" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "ID khóa truy cập" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "Khóa truy cập bí mật" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "Các email đích" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "�èn nháy" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "Nhãn" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "ID dịch vụ" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "Mật khẩu dịch vụ" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "ID không gian" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "Số điện thoại ngư�i nhận" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "Loại nội dung" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "Th�i gian ch�" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "Gi�ng đ�c" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "Khóa ứng dụng khách" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "Thông báo quan tr�ng" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "Dịch vụ" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "Vị trí" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "Tình huống cảnh báo" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "L�c" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "ID bên ngoài" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "Trạng thái" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "Thành viên đích" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "Chia sẻ" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "Thẻ" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "Mã ngắn đích" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "Mã thông báo truy cập OAuth" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "Mã thông báo A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "Mã thông báo B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "Mã thông báo C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "ID đã mã hóa của đích" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "�ư�ng dẫn luồng công việc" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "Kèm chân trang" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "Dùng khối" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "Kèm dấu th�i gian" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "Chế độ tin nhắn" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "Máy chủ" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "Tên đăng nhập" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "Chuyển tự" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "Liên hệ đích" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "Chỉ thử nghiệm" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "Cổng kết nối" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "Khóa công khai" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "Hạt giống" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "Các phòng" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "Khóa định tuyến đích" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "ID thực thể" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "Loại cảnh báo" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "Liên kết hình ảnh" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "Liên kết âm thanh" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "Màu văn bản đặc biệt" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "Số ti�n" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "Ti�n tệ" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "Tên" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "Mã định danh" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "Tải lên" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "Ghi lại vào STDERR" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "ID cuộc trò chuyện đích" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "Phát hiện chủ sở hữu bot" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "Xem trước trang web" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "Nhóm tệp đính kèm ảnh/video" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "ID luồng của chủ đ�" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Phiên bản Markdown" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "Vị trí nội dung" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "�ư�ng dẫn mẫu tin nhắn có định dạng" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "ID hồ sơ tin nhắn" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "ID cổng kết nối" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "ID Threema đích" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "Tên máy chủ API" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "URL hình ảnh" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "Loại sự kiện" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "�ộ ưu tiên (tương thích Pushover)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "Cách thông báo: sms hoặc g�i" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "Khóa ngư�i dùng API" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "Mã bí mật ngư�i dùng API" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "Mã bí mật truy cập" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "Mã thông báo xác thực" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "Các ID ngư�i nhận" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "URL ảnh đại diện của bot" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "Email ngư�i dùng" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "Mã thông báo truy cập của bot" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "Các ID phòng" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "ID doanh nghiệp" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "Mã bí mật ứng dụng" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "ID nhân viên" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "Phòng ban đích" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "Thẻ đích" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "Khóa webhook của bot" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "ID điện thoại ngư�i gửi" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "Cần có môi trư�ng Microsoft Windows cục bộ." #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "ID luồng công việc" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "Chữ ký" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "Dùng URL Power Automate" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "ID định tuyến Power Automate" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "Ngắt dòng văn bản" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "Phiên bản API" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "Chủ đ�" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "Mã nhóm hoặc tùy ch�n" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "Mã thông báo ứng dụng" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "Mã thông báo hoặc khóa" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "�ộ ưu tiên của thông tin" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "�ộ ưu tiên của thành công" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "�ộ ưu tiên của cảnh báo" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "�ộ ưu tiên của lỗi" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "Nhãn liên kết" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "Mã thông báo xác minh" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "Luồng đích" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "Cài đặt pika cho AMQP 0.9.1, python-qpid-proton cho AMQP 1.0, hoặc cả hai." #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "Exchange" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "Máy chủ ảo" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "Lưu b�n" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "Bắt buộc" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "Th�i gian tồn tại (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "Tiêu đ� tin nhắn" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "Máy chủ SMTP" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "Chế độ bảo mật" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "Chế độ bảo mật PGP" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "�ư�ng dẫn khóa công khai PGP" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "�ư�ng dẫn khóa riêng PGP" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "Thư mục khóa trên web" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "Tệp đính kèm nhúng" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "Email ngư�i nhận" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "Tệp khóa OAuth2" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "URL hình ảnh riêng" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "Màu thông báo" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "Tên thật" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "Biệt danh" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "Tham gia các kênh" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "Chế độ xác thực" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "Bí danh phòng đích" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "Tìm kiếm máy chủ" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "Buộc dùng máy chủ gốc cho ID phòng" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Phiên bản API Matrix" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "Mã hóa đầu cuối" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "Tự động xác minh thiết bị" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "Tệp khóa riêng PEM" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "Tệp đăng ký" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "Máy chủ XMPP" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "Lấy danh sách liên hệ" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "Dùng tiêu đ�" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "Giữ kết nối mở" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "Biệt danh MUC" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "Liên kết kênh SCRAM-PLUS" caronc-apprise-dfec414/apprise/i18n/zh/000077500000000000000000000000001526720371000177625ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/zh/LC_MESSAGES/000077500000000000000000000000001526720371000215475ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/i18n/zh/LC_MESSAGES/apprise.po000066400000000000000000002004121526720371000235510ustar00rootroot00000000000000# Chinese translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 2.1.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-10-24 15:11-0400\n" "PO-Revision-Date: 2026-09-25 12:00-0400\n" "Last-Translator: Chris Caron \n" "Language: zh\n" "Language-Team: zh \n" "Plural-Forms: nplurals=1; plural=0;\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:97 apprise/url.py:158 msgid "Verify SSL" msgstr "验� SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "跟��定�" #: apprise/url.py:178 msgid "Socket Read Timeout" msgstr "套接字读�超时" #: apprise/url.py:192 msgid "Socket Connect Timeout" msgstr "套接字连接超时" #: apprise/attachment/base.py:83 msgid "Cache Age" msgstr "缓存有效期" #: apprise/attachment/base.py:89 msgid "Forced Mime Type" msgstr "强制的 MIME 类型" #: apprise/attachment/base.py:93 msgid "Forced File Name" msgstr "强制的文件�" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "本地文件" #: apprise/attachment/http.py:50 apprise/config/http.py:55 msgid "Web Based" msgstr "基于网络" #: apprise/attachment/memory.py:46 apprise/config/memory.py:37 msgid "Memory" msgstr "内存" #: apprise/plugins/__init__.py:282 msgid "Schema" msgstr "架构" #: apprise/plugins/__init__.py:421 msgid "No dependencies." msgstr "没有�赖项。" #: apprise/plugins/__init__.py:424 msgid "Packages are required to function." msgstr "需�安装软件包�能使用。" #: apprise/plugins/__init__.py:428 msgid "Packages are recommended to improve functionality." msgstr "建议安装软件包以增强功能。" #: apprise/plugins/africas_talking.py:142 msgid "App User Name" msgstr "应用用户�" #: apprise/plugins/africas_talking.py:148 apprise/plugins/brevo.py:159 #: apprise/plugins/burstsms.py:105 apprise/plugins/clicksend.py:99 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:120 #: apprise/plugins/exotel.py:214 apprise/plugins/fcm/__init__.py:144 #: apprise/plugins/flowtriq.py:113 apprise/plugins/httpsms.py:78 #: apprise/plugins/jira.py:231 apprise/plugins/join.py:141 #: apprise/plugins/kavenegar.py:116 apprise/plugins/kumulos.py:88 #: apprise/plugins/mailersend.py:128 apprise/plugins/mailgun.py:140 #: apprise/plugins/messagebird.py:79 apprise/plugins/notifyre.py:131 #: apprise/plugins/octopush.py:137 apprise/plugins/one_signal.py:115 #: apprise/plugins/opsgenie.py:229 apprise/plugins/pagerduty.py:154 #: apprise/plugins/pinglet.py:138 apprise/plugins/pingram.py:182 #: apprise/plugins/postmark.py:118 apprise/plugins/prowl.py:122 #: apprise/plugins/resend.py:108 apprise/plugins/resend.py:146 #: apprise/plugins/sendgrid.py:117 apprise/plugins/seven.py:76 #: apprise/plugins/smsmanager.py:107 apprise/plugins/smtp2go.py:119 #: apprise/plugins/sparkpost.py:170 apprise/plugins/splunk.py:166 #: apprise/plugins/techuluspush.py:98 apprise/plugins/telnyx.py:88 #: apprise/plugins/trigv.py:143 apprise/plugins/twilio.py:197 #: apprise/plugins/vapid/__init__.py:162 apprise/plugins/vonage.py:81 #: apprise/plugins/wpush.py:158 msgid "API Key" msgstr "API 密钥" #: apprise/plugins/africas_talking.py:155 apprise/plugins/fortysixelks.py:107 msgid "Target Phone" msgstr "目标电�" #: apprise/plugins/africas_talking.py:160 apprise/plugins/amqp/base.py:183 #: apprise/plugins/aprs.py:191 apprise/plugins/bark.py:179 #: apprise/plugins/brevo.py:176 apprise/plugins/bulksms.py:148 #: apprise/plugins/bulkvs.py:111 apprise/plugins/burstsms.py:132 #: apprise/plugins/clickatell.py:91 apprise/plugins/clicksend.py:113 #: apprise/plugins/cliq.py:191 apprise/plugins/d7networks.py:111 #: apprise/plugins/dapnet.py:139 apprise/plugins/dingtalk.py:115 #: apprise/plugins/eight00com.py:129 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:149 apprise/plugins/exotel.py:191 #: apprise/plugins/fcm/__init__.py:169 apprise/plugins/flock.py:126 #: apprise/plugins/fortysixelks.py:112 apprise/plugins/gorush.py:180 #: apprise/plugins/home_assistant.py:155 apprise/plugins/httpsms.py:98 #: apprise/plugins/humhub.py:150 apprise/plugins/irc/base.py:150 #: apprise/plugins/join.py:173 apprise/plugins/kavenegar.py:136 #: apprise/plugins/kook.py:179 apprise/plugins/line.py:94 #: apprise/plugins/mailersend.py:145 apprise/plugins/mailgun.py:151 #: apprise/plugins/mastodon.py:211 apprise/plugins/matrix/base.py:340 #: apprise/plugins/mattermost.py:195 apprise/plugins/messagebird.py:100 #: apprise/plugins/mobilemessage.py:145 apprise/plugins/mqtt.py:178 #: apprise/plugins/msg91.py:124 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:135 apprise/plugins/notifiarr.py:104 #: apprise/plugins/notifyre.py:144 apprise/plugins/ntfy.py:242 #: apprise/plugins/octopush.py:154 apprise/plugins/office365.py:288 #: apprise/plugins/one_signal.py:143 apprise/plugins/onebot.py:167 #: apprise/plugins/pingram.py:205 apprise/plugins/plivo.py:118 #: apprise/plugins/postmark.py:134 apprise/plugins/pushbullet.py:106 #: apprise/plugins/pushed.py:108 apprise/plugins/pushover.py:399 #: apprise/plugins/pushsafer.py:377 apprise/plugins/pushy.py:98 #: apprise/plugins/reddit.py:173 apprise/plugins/resend.py:125 #: apprise/plugins/revolt.py:113 apprise/plugins/ringcentral.py:220 #: apprise/plugins/rocketchat.py:168 apprise/plugins/sendgrid.py:134 #: apprise/plugins/sendpulse.py:141 apprise/plugins/serwersms.py:162 #: apprise/plugins/seven.py:89 apprise/plugins/sfr.py:114 #: apprise/plugins/signal_api.py:164 apprise/plugins/simplepush.py:195 #: apprise/plugins/sinch.py:141 apprise/plugins/slack.py:289 #: apprise/plugins/smpp.py:133 apprise/plugins/smsc.py:157 #: apprise/plugins/smseagle.py:173 apprise/plugins/smsmanager.py:120 #: apprise/plugins/sns.py:172 apprise/plugins/telegram.py:609 #: apprise/plugins/telnyx.py:108 apprise/plugins/threema.py:116 #: apprise/plugins/trigv.py:156 apprise/plugins/twilio.py:177 #: apprise/plugins/twist.py:124 apprise/plugins/twitter.py:194 #: apprise/plugins/vapid/__init__.py:168 apprise/plugins/voipms.py:109 #: apprise/plugins/vonage.py:109 apprise/plugins/wechat.py:219 #: apprise/plugins/whatsapp.py:167 apprise/plugins/wxpusher.py:140 #: apprise/plugins/xmpp/base.py:135 apprise/plugins/zalo.py:125 #: apprise/plugins/zulip.py:154 msgid "Targets" msgstr "目标" #: apprise/plugins/africas_talking.py:176 msgid "From" msgstr "��方" #: apprise/plugins/africas_talking.py:182 msgid "SMS Mode" msgstr "短信模�" #: apprise/plugins/africas_talking.py:191 apprise/plugins/bulksms.py:181 #: apprise/plugins/bulkvs.py:132 apprise/plugins/burstsms.py:167 #: apprise/plugins/clicksend.py:131 apprise/plugins/d7networks.py:145 #: apprise/plugins/dapnet.py:168 apprise/plugins/exotel.py:223 #: apprise/plugins/home_assistant.py:172 apprise/plugins/jira.py:283 #: apprise/plugins/mailgun.py:188 apprise/plugins/mastodon.py:256 #: apprise/plugins/mobilemessage.py:182 apprise/plugins/octopush.py:174 #: apprise/plugins/one_signal.py:190 apprise/plugins/opsgenie.py:308 #: apprise/plugins/plivo.py:141 apprise/plugins/signal_api.py:186 #: apprise/plugins/smseagle.py:212 apprise/plugins/smsmanager.py:153 #: apprise/plugins/smtp2go.py:152 apprise/plugins/sparkpost.py:210 #: apprise/plugins/twitter.py:219 msgid "Batch Mode" msgstr "批�模�" #: apprise/plugins/amqp/base.py:163 apprise/plugins/apprise_api.py:126 #: apprise/plugins/bark.py:153 apprise/plugins/betterstack.py:152 #: apprise/plugins/custom_form.py:140 apprise/plugins/custom_json.py:122 #: apprise/plugins/custom_xml.py:122 apprise/plugins/emby.py:86 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:126 #: apprise/plugins/flowtriq.py:119 apprise/plugins/fluxer.py:187 #: apprise/plugins/goalert.py:151 apprise/plugins/gorush.py:155 #: apprise/plugins/gotify.py:130 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:124 apprise/plugins/humhub.py:139 #: apprise/plugins/irc/base.py:118 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:461 apprise/plugins/mastodon.py:189 #: apprise/plugins/matrix/base.py:295 apprise/plugins/mattermost.py:161 #: apprise/plugins/misskey.py:118 apprise/plugins/mqtt.py:150 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:102 #: apprise/plugins/notica.py:127 apprise/plugins/notifico.py:167 #: apprise/plugins/ntfy.py:212 apprise/plugins/onebot.py:137 #: apprise/plugins/parseplatform.py:91 apprise/plugins/pinglet.py:144 #: apprise/plugins/pushdeer.py:76 apprise/plugins/pushjet.py:72 #: apprise/plugins/rocketchat.py:121 apprise/plugins/rsyslog.py:181 #: apprise/plugins/signal_api.py:123 apprise/plugins/smseagle.py:136 #: apprise/plugins/sogs.py:209 apprise/plugins/synology.py:86 #: apprise/plugins/workflows.py:139 apprise/plugins/xmpp/base.py:102 msgid "Hostname" msgstr "主机�" #: apprise/plugins/amqp/base.py:168 apprise/plugins/apprise_api.py:131 #: apprise/plugins/bark.py:158 apprise/plugins/betterstack.py:156 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:91 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:131 apprise/plugins/flowtriq.py:124 #: apprise/plugins/fluxer.py:191 apprise/plugins/goalert.py:156 #: apprise/plugins/gorush.py:160 apprise/plugins/gotify.py:141 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:129 #: apprise/plugins/humhub.py:144 apprise/plugins/irc/base.py:123 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:465 #: apprise/plugins/mastodon.py:199 apprise/plugins/matrix/base.py:300 #: apprise/plugins/mattermost.py:177 apprise/plugins/misskey.py:128 #: apprise/plugins/mqtt.py:155 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:107 apprise/plugins/notica.py:131 #: apprise/plugins/notifico.py:171 apprise/plugins/ntfy.py:216 #: apprise/plugins/onebot.py:142 apprise/plugins/parseplatform.py:96 #: apprise/plugins/pinglet.py:149 apprise/plugins/pushdeer.py:80 #: apprise/plugins/pushjet.py:77 apprise/plugins/rocketchat.py:126 #: apprise/plugins/rsyslog.py:186 apprise/plugins/signal_api.py:128 #: apprise/plugins/smpp.py:113 apprise/plugins/smseagle.py:141 #: apprise/plugins/sogs.py:214 apprise/plugins/synology.py:91 #: apprise/plugins/trigv.py:164 apprise/plugins/workflows.py:144 #: apprise/plugins/xmpp/base.py:107 msgid "Port" msgstr "端�" #: apprise/plugins/apprise_api.py:137 apprise/plugins/bark.py:164 #: apprise/plugins/bluesky.py:126 apprise/plugins/custom_form.py:151 #: apprise/plugins/custom_json.py:133 apprise/plugins/custom_xml.py:133 #: apprise/plugins/emby.py:98 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:79 apprise/plugins/gorush.py:166 #: apprise/plugins/home_assistant.py:135 apprise/plugins/jira.py:237 #: apprise/plugins/kodi.py:107 apprise/plugins/lametric.py:472 #: apprise/plugins/matrix/base.py:306 apprise/plugins/nextcloud.py:127 #: apprise/plugins/nextcloudtalk.py:113 apprise/plugins/notica.py:137 #: apprise/plugins/notifico.py:177 apprise/plugins/ntfy.py:222 #: apprise/plugins/opsgenie.py:235 apprise/plugins/pushjet.py:89 #: apprise/plugins/rocketchat.py:132 apprise/plugins/serwersms.py:132 #: apprise/plugins/signal_api.py:134 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:97 msgid "Username" msgstr "用户�" #: apprise/plugins/amqp/base.py:178 apprise/plugins/apprise_api.py:141 #: apprise/plugins/aprs.py:176 apprise/plugins/bark.py:168 #: apprise/plugins/bluesky.py:131 apprise/plugins/bulkvs.py:91 #: apprise/plugins/custom_form.py:155 apprise/plugins/custom_json.py:137 #: apprise/plugins/custom_xml.py:137 apprise/plugins/dapnet.py:124 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:102 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:84 #: apprise/plugins/gorush.py:170 apprise/plugins/growl.py:151 #: apprise/plugins/home_assistant.py:139 apprise/plugins/humhub.py:134 #: apprise/plugins/irc/base.py:133 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:310 apprise/plugins/mqtt.py:166 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:118 #: apprise/plugins/notica.py:141 apprise/plugins/notifico.py:181 #: apprise/plugins/ntfy.py:226 apprise/plugins/pushjet.py:93 #: apprise/plugins/reddit.py:148 apprise/plugins/rocketchat.py:136 #: apprise/plugins/serwersms.py:137 apprise/plugins/signal_api.py:138 #: apprise/plugins/simplepush.py:179 apprise/plugins/smpp.py:102 #: apprise/plugins/smsc.py:144 apprise/plugins/synology.py:101 #: apprise/plugins/twist.py:102 apprise/plugins/voipms.py:89 #: apprise/plugins/xmpp/base.py:118 msgid "Password" msgstr "密�" #: apprise/plugins/apprise_api.py:146 apprise/plugins/betterstack.py:146 #: apprise/plugins/bulksms.py:121 apprise/plugins/chanify.py:75 #: apprise/plugins/dingtalk.py:97 apprise/plugins/exotel.py:167 #: apprise/plugins/feishu.py:81 apprise/plugins/gotify.py:124 #: apprise/plugins/incidentio.py:127 apprise/plugins/kook.py:173 #: apprise/plugins/lauther.py:116 apprise/plugins/lfgalarm.py:99 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:120 #: apprise/plugins/notifiarr.py:92 apprise/plugins/ntfy.py:231 #: apprise/plugins/pushme.py:63 apprise/plugins/ryver.py:100 #: apprise/plugins/serverchan.py:71 apprise/plugins/slack.py:339 #: apprise/plugins/synology.py:106 apprise/plugins/zulip.py:137 msgid "Token" msgstr "令牌" #: apprise/plugins/apprise_api.py:160 apprise/plugins/jira.py:302 #: apprise/plugins/ntfy.py:290 apprise/plugins/opsgenie.py:295 #: apprise/plugins/pagertree.py:148 apprise/plugins/squadcast.py:194 msgid "Tags" msgstr "标签" #: apprise/plugins/apprise_api.py:164 msgid "Query Method" msgstr "查询方�" #: apprise/plugins/apprise_api.py:170 msgid "Apprise API Version" msgstr "Apprise API 版本" #: apprise/plugins/apprise_api.py:184 apprise/plugins/custom_form.py:184 #: apprise/plugins/custom_json.py:160 apprise/plugins/custom_xml.py:160 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:162 apprise/plugins/notica.py:157 #: apprise/plugins/pagertree.py:157 apprise/plugins/synology.py:131 msgid "HTTP Header" msgstr "HTTP 标头" #: apprise/plugins/amqp/base.py:174 apprise/plugins/aprs.py:171 #: apprise/plugins/bulkvs.py:86 apprise/plugins/clicksend.py:94 #: apprise/plugins/dapnet.py:119 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:130 apprise/plugins/mqtt.py:161 #: apprise/plugins/reddit.py:143 apprise/plugins/sendpulse.py:112 #: apprise/plugins/smtp2go.py:109 apprise/plugins/sparkpost.py:160 msgid "User Name" msgstr "用户�" #: apprise/plugins/aprs.py:182 apprise/plugins/aprs.py:203 #: apprise/plugins/dapnet.py:130 apprise/plugins/dapnet.py:163 msgid "Target Callsign" msgstr "目标呼�" #: apprise/plugins/aprs.py:208 msgid "Resend Delay" msgstr "��延迟" #: apprise/plugins/aprs.py:215 msgid "Locale" msgstr "区域设置" #: apprise/plugins/bark.py:173 apprise/plugins/fcm/__init__.py:158 #: apprise/plugins/gorush.py:175 apprise/plugins/home_assistant.py:150 #: apprise/plugins/pushbullet.py:90 apprise/plugins/pushover.py:387 #: apprise/plugins/pushsafer.py:367 apprise/plugins/pushy.py:86 msgid "Target Device" msgstr "目标设备" #: apprise/plugins/bark.py:192 apprise/plugins/gorush.py:210 #: apprise/plugins/lametric.py:517 apprise/plugins/lauther.py:139 #: apprise/plugins/macosx.py:166 apprise/plugins/pushover.py:416 #: apprise/plugins/pushsafer.py:393 apprise/plugins/pushy.py:111 msgid "Sound" msgstr "声音" #: apprise/plugins/bark.py:197 apprise/plugins/pushward.py:153 msgid "Level" msgstr "级别" #: apprise/plugins/bark.py:202 apprise/plugins/pushward.py:183 #: apprise/plugins/simplepush.py:221 msgid "Volume" msgstr "音�" #: apprise/plugins/bark.py:208 apprise/plugins/ilert.py:173 #: apprise/plugins/incidentio.py:156 apprise/plugins/ntfy.py:272 #: apprise/plugins/pagerduty.py:195 msgid "Click" msgstr "点击" #: apprise/plugins/bark.py:212 apprise/plugins/gorush.py:214 #: apprise/plugins/pushy.py:115 msgid "Badge" msgstr "角标" #: apprise/plugins/bark.py:217 apprise/plugins/github.py:245 msgid "Category" msgstr "类别" #: apprise/plugins/bark.py:221 apprise/plugins/join.py:159 #: apprise/plugins/lauther.py:158 apprise/plugins/pagerduty.py:186 #: apprise/plugins/youlmk.py:222 msgid "Group" msgstr "群组" #: apprise/plugins/bark.py:225 apprise/plugins/cliq.py:222 #: apprise/plugins/dbus.py:229 apprise/plugins/discord.py:236 #: apprise/plugins/fcm/__init__.py:198 apprise/plugins/flock.py:137 #: apprise/plugins/fluxer.py:287 apprise/plugins/glib.py:189 #: apprise/plugins/gnome.py:154 apprise/plugins/growl.py:175 #: apprise/plugins/ilert.py:177 apprise/plugins/join.py:184 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:109 #: apprise/plugins/macosx.py:157 apprise/plugins/matrix/base.py:351 #: apprise/plugins/mattermost.py:218 apprise/plugins/notifiarr.py:126 #: apprise/plugins/ntfy.py:258 apprise/plugins/one_signal.py:169 #: apprise/plugins/pagerduty.py:214 apprise/plugins/ryver.py:125 #: apprise/plugins/slack.py:307 apprise/plugins/telegram.py:620 #: apprise/plugins/vapid/__init__.py:214 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:176 apprise/plugins/youlmk.py:229 msgid "Include Image" msgstr "包�图片" #: apprise/plugins/bark.py:231 apprise/plugins/lauther.py:147 #: apprise/plugins/mattermost.py:214 apprise/plugins/revolt.py:130 msgid "Icon URL" msgstr "图标网�" #: apprise/plugins/bark.py:235 apprise/plugins/streamlabs.py:122 msgid "Call" msgstr "通�" #: apprise/plugins/bark.py:240 apprise/plugins/pushover.py:448 msgid "Encryption Key" msgstr "加密密钥" #: apprise/plugins/base.py:349 msgid "Overflow Mode" msgstr "超长处�方�" #: apprise/plugins/base.py:364 msgid "Notify Format" msgstr "通知格�" #: apprise/plugins/base.py:380 msgid "Interpret Emojis" msgstr "解�表情符�" #: apprise/plugins/base.py:390 msgid "Persistent Storage" msgstr "�久化存储" #: apprise/plugins/base.py:400 msgid "Timezone" msgstr "时区" #: apprise/plugins/base.py:409 msgid "Service Retry" msgstr "�务�试次数" #: apprise/plugins/base.py:417 msgid "Inter-Retry Wait" msgstr "�试间隔" #: apprise/plugins/base.py:425 msgid "Optional Service" msgstr "�选�务" #: apprise/plugins/betterstack.py:162 apprise/plugins/email/base.py:178 #: apprise/plugins/pingram.py:243 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:164 msgid "From Email" msgstr "��方邮箱" #: apprise/plugins/betterstack.py:166 apprise/plugins/jira.py:259 #: apprise/plugins/opsgenie.py:257 msgid "Target Team" msgstr "目标团队" #: apprise/plugins/betterstack.py:186 msgid "Escalation Policy" msgstr "�级策略" #: apprise/plugins/betterstack.py:190 msgid "Alert Methods" msgstr "告警方�" #: apprise/plugins/betterstack.py:194 msgid "Team Wait" msgstr "团队等待时间" #: apprise/plugins/blink1.py:152 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:179 msgid "Serial Number" msgstr "�列�" #: apprise/plugins/blink1.py:189 msgid "Duration (ms)" msgstr "�续时间(毫秒)" #: apprise/plugins/blink1.py:196 msgid "Fade Time (ms)" msgstr "��时间(毫秒)" #: apprise/plugins/blink1.py:203 msgid "LED Number" msgstr "LED 编�" #: apprise/plugins/brevo.py:166 apprise/plugins/mailersend.py:135 #: apprise/plugins/postmark.py:124 apprise/plugins/resend.py:115 #: apprise/plugins/sendgrid.py:124 msgid "Source Email" msgstr "��邮箱" #: apprise/plugins/brevo.py:171 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:140 apprise/plugins/mailgun.py:146 #: apprise/plugins/office365.py:283 apprise/plugins/one_signal.py:126 #: apprise/plugins/pingram.py:189 apprise/plugins/postmark.py:129 #: apprise/plugins/pushbullet.py:101 apprise/plugins/pushsafer.py:372 #: apprise/plugins/resend.py:120 apprise/plugins/sendgrid.py:129 #: apprise/plugins/sendpulse.py:136 apprise/plugins/slack.py:272 #: apprise/plugins/threema.py:106 msgid "Target Email" msgstr "目标邮箱" #: apprise/plugins/brevo.py:187 apprise/plugins/postmark.py:150 #: apprise/plugins/ses.py:206 msgid "Reply To Email" msgstr "回�邮箱" #: apprise/plugins/brevo.py:195 apprise/plugins/email/base.py:244 #: apprise/plugins/mailersend.py:159 apprise/plugins/mailgun.py:180 #: apprise/plugins/office365.py:308 apprise/plugins/pingram.py:252 #: apprise/plugins/postmark.py:158 apprise/plugins/resend.py:159 #: apprise/plugins/sendgrid.py:154 apprise/plugins/sendpulse.py:171 #: apprise/plugins/ses.py:233 apprise/plugins/smtp2go.py:144 #: apprise/plugins/sparkpost.py:202 msgid "Carbon Copy" msgstr "抄�" #: apprise/plugins/brevo.py:199 apprise/plugins/email/base.py:248 #: apprise/plugins/mailersend.py:163 apprise/plugins/mailgun.py:184 #: apprise/plugins/office365.py:312 apprise/plugins/pingram.py:256 #: apprise/plugins/postmark.py:162 apprise/plugins/resend.py:163 #: apprise/plugins/sendgrid.py:158 apprise/plugins/sendpulse.py:175 #: apprise/plugins/ses.py:237 apprise/plugins/smtp2go.py:148 #: apprise/plugins/sparkpost.py:206 msgid "Blind Carbon Copy" msgstr "密�" #: apprise/plugins/bulksms.py:127 apprise/plugins/dingtalk.py:104 #: apprise/plugins/nextcloudtalk.py:124 apprise/plugins/signl4.py:100 msgid "Secret" msgstr "密文" #: apprise/plugins/bulksms.py:134 apprise/plugins/bulkvs.py:104 #: apprise/plugins/burstsms.py:125 apprise/plugins/clickatell.py:84 #: apprise/plugins/clicksend.py:106 apprise/plugins/d7networks.py:104 #: apprise/plugins/dingtalk.py:110 apprise/plugins/eight00com.py:122 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:184 #: apprise/plugins/httpsms.py:91 apprise/plugins/kavenegar.py:129 #: apprise/plugins/messagebird.py:93 apprise/plugins/mobilemessage.py:138 #: apprise/plugins/msg91.py:117 apprise/plugins/notifyre.py:137 #: apprise/plugins/octopush.py:147 apprise/plugins/plivo.py:111 #: apprise/plugins/ringcentral.py:214 apprise/plugins/serwersms.py:149 #: apprise/plugins/seven.py:82 apprise/plugins/signal_api.py:150 #: apprise/plugins/sinch.py:128 apprise/plugins/smpp.py:126 #: apprise/plugins/smsc.py:150 apprise/plugins/smseagle.py:152 #: apprise/plugins/smsmanager.py:113 apprise/plugins/sns.py:159 #: apprise/plugins/telnyx.py:101 apprise/plugins/threema.py:99 #: apprise/plugins/twilio.py:164 apprise/plugins/voipms.py:102 #: apprise/plugins/vonage.py:102 apprise/plugins/whatsapp.py:148 msgid "Target Phone No" msgstr "目标电���" #: apprise/plugins/bulksms.py:141 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:393 msgid "Target Group" msgstr "目标群组" #: apprise/plugins/bulksms.py:160 apprise/plugins/bulkvs.py:97 #: apprise/plugins/bulkvs.py:123 apprise/plugins/clickatell.py:79 #: apprise/plugins/eight00com.py:115 apprise/plugins/eight00com.py:143 #: apprise/plugins/fortysixelks.py:101 apprise/plugins/httpsms.py:84 #: apprise/plugins/httpsms.py:116 apprise/plugins/ringcentral.py:208 #: apprise/plugins/signal_api.py:143 apprise/plugins/sinch.py:121 #: apprise/plugins/smpp.py:119 apprise/plugins/smsmanager.py:135 #: apprise/plugins/telnyx.py:94 apprise/plugins/telnyx.py:126 #: apprise/plugins/twilio.py:157 apprise/plugins/voipms.py:95 #: apprise/plugins/vonage.py:95 msgid "From Phone No" msgstr "��方电���" #: apprise/plugins/bulksms.py:166 msgid "Route Group" msgstr "路由群组" #: apprise/plugins/bulksms.py:173 apprise/plugins/d7networks.py:137 #: apprise/plugins/exotel.py:229 apprise/plugins/mobilemessage.py:164 msgid "Unicode Characters" msgstr "Unicode 字符" #: apprise/plugins/burstsms.py:112 apprise/plugins/threema.py:93 #: apprise/plugins/vonage.py:88 msgid "API Secret" msgstr "API 密文" #: apprise/plugins/burstsms.py:119 apprise/plugins/mobilemessage.py:131 #: apprise/plugins/smsc.py:172 msgid "Sender ID" msgstr "��方标识" #: apprise/plugins/burstsms.py:153 msgid "Country" msgstr "国家或地区" #: apprise/plugins/burstsms.py:162 msgid "validity" msgstr "有效期" #: apprise/plugins/chanify.py:48 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/clickatell.py:46 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:73 apprise/plugins/eight00com.py:109 #: apprise/plugins/rocketchat.py:141 apprise/plugins/simplepush.py:173 msgid "API Token" msgstr "API 令牌" #: apprise/plugins/cliq.py:168 apprise/plugins/discord.py:176 #: apprise/plugins/fluxer.py:215 apprise/plugins/google_chat.py:132 #: apprise/plugins/stackfield.py:93 apprise/plugins/webexteams.py:172 msgid "Webhook Token" msgstr "Webhook 令牌" #: apprise/plugins/cliq.py:175 apprise/plugins/discord.py:165 #: apprise/plugins/flock.py:107 apprise/plugins/fluxer.py:203 #: apprise/plugins/ryver.py:107 apprise/plugins/slack.py:224 #: apprise/plugins/viber.py:100 apprise/plugins/zulip.py:125 msgid "Bot Name" msgstr "机器人�称" #: apprise/plugins/cliq.py:179 apprise/plugins/irc/base.py:144 #: apprise/plugins/mattermost.py:183 apprise/plugins/notifiarr.py:98 #: apprise/plugins/pushbullet.py:95 apprise/plugins/pushed.py:102 #: apprise/plugins/rocketchat.py:151 apprise/plugins/slack.py:283 #: apprise/plugins/trigv.py:151 apprise/plugins/twist.py:113 #: apprise/plugins/xmpp/base.py:129 msgid "Target Channel" msgstr "目标频�" #: apprise/plugins/cliq.py:185 msgid "Target Bot" msgstr "目标机器人" #: apprise/plugins/cliq.py:212 apprise/plugins/ses.py:182 #: apprise/plugins/sinch.py:158 apprise/plugins/sns.py:152 msgid "Region" msgstr "区域" #: apprise/plugins/cliq.py:218 msgid "Company ID" msgstr "公� ID" #: apprise/plugins/cliq.py:228 apprise/plugins/simplepush.py:206 msgid "Broadcast" msgstr "广播" #: apprise/plugins/custom_form.py:167 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "Fetch Method" msgstr "获�方�" #: apprise/plugins/custom_form.py:173 msgid "Attach File As" msgstr "附件命�为" #: apprise/plugins/custom_form.py:188 apprise/plugins/custom_json.py:164 #: apprise/plugins/custom_xml.py:164 apprise/plugins/pagertree.py:161 msgid "Payload Extras" msgstr "附加负载内容" #: apprise/plugins/custom_form.py:192 apprise/plugins/custom_json.py:168 #: apprise/plugins/custom_xml.py:168 msgid "GET Params" msgstr "GET �数" #: apprise/plugins/d7networks.py:98 msgid "API Access Token" msgstr "API 访问令牌" #: apprise/plugins/d7networks.py:128 apprise/plugins/seven.py:106 msgid "Originating Address" msgstr "�起地�" #: apprise/plugins/amqp/base.py:222 apprise/plugins/dapnet.py:151 #: apprise/plugins/exotel.py:241 apprise/plugins/gorush.py:201 #: apprise/plugins/gotify.py:154 apprise/plugins/growl.py:163 #: apprise/plugins/ilert.py:163 apprise/plugins/jira.py:288 #: apprise/plugins/join.py:190 apprise/plugins/lametric.py:495 #: apprise/plugins/lauther.py:133 apprise/plugins/ntfy.py:284 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pinglet.py:178 #: apprise/plugins/prowl.py:142 apprise/plugins/pushover.py:410 #: apprise/plugins/pushsafer.py:388 apprise/plugins/simplepush.py:216 #: apprise/plugins/smseagle.py:188 apprise/plugins/squadcast.py:184 #: apprise/plugins/youlmk.py:177 msgid "Priority" msgstr "优先级" #: apprise/plugins/dapnet.py:157 msgid "Transmitter Groups" msgstr "�射机群组" #: apprise/plugins/dbus.py:157 msgid "libdbus-1.so.x must be installed." msgstr "必须安装 libdbus-1.so.x。" #: apprise/plugins/dbus.py:161 msgid "DBus Notification" msgstr "DBus 通知" #: apprise/plugins/dbus.py:205 apprise/plugins/glib.py:166 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:143 #: apprise/plugins/trigv.py:190 msgid "Urgency" msgstr "紧急程度" #: apprise/plugins/dbus.py:217 apprise/plugins/glib.py:177 msgid "X-Axis" msgstr "X 轴" #: apprise/plugins/dbus.py:223 apprise/plugins/glib.py:183 msgid "Y-Axis" msgstr "Y 轴" #: apprise/plugins/discord.py:170 apprise/plugins/fluxer.py:208 #: apprise/plugins/ifttt.py:104 apprise/plugins/zoom.py:140 msgid "Webhook ID" msgstr "Webhook 标识" #: apprise/plugins/discord.py:189 apprise/plugins/fluxer.py:238 msgid "Text To Speech" msgstr "文字转语音" #: apprise/plugins/discord.py:194 apprise/plugins/fluxer.py:243 msgid "Avatar Image" msgstr "头�图片" #: apprise/plugins/discord.py:199 apprise/plugins/fluxer.py:248 #: apprise/plugins/ntfy.py:264 msgid "Avatar URL" msgstr "头�网�" #: apprise/plugins/discord.py:203 apprise/plugins/fluxer.py:252 #: apprise/plugins/pushover.py:422 apprise/plugins/trigv.py:181 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:212 apprise/plugins/fluxer.py:259 msgid "Thread ID" msgstr "�题标识" #: apprise/plugins/discord.py:216 apprise/plugins/fluxer.py:267 msgid "Display Footer" msgstr "显示页脚" #: apprise/plugins/discord.py:221 apprise/plugins/fluxer.py:272 msgid "Footer Logo" msgstr "页脚徽标" #: apprise/plugins/discord.py:226 apprise/plugins/fluxer.py:277 msgid "Use Fields" msgstr "使用字段" #: apprise/plugins/discord.py:231 apprise/plugins/fluxer.py:282 msgid "Discord Flags" msgstr "Discord 标记" #: apprise/plugins/discord.py:245 apprise/plugins/fluxer.py:293 msgid "Ping Users/Roles" msgstr "��用户或角色" #: apprise/plugins/discord.py:249 apprise/plugins/slack.py:346 #: apprise/plugins/workflows.py:201 msgid "Template Path" msgstr "模�路径" #: apprise/plugins/discord.py:257 msgid "Batch Attachments" msgstr "�并��附件" #: apprise/plugins/discord.py:267 apprise/plugins/mailgun.py:202 #: apprise/plugins/pingram.py:265 apprise/plugins/slack.py:356 #: apprise/plugins/sparkpost.py:224 apprise/plugins/telegram.py:679 #: apprise/plugins/workflows.py:222 msgid "Template Tokens" msgstr "模�令牌" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "设备�列�" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "API 模�" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "立�刷新" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "文本签�" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Base64 图标(文本接�)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Base64 图片(图片接�)" #: apprise/plugins/dot.py:180 apprise/plugins/simplepush.py:231 #: apprise/plugins/youlmk.py:210 msgid "Link" msgstr "链接" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "边框" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "抖动类型" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "抖动核" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "任务密钥" #: apprise/plugins/emby.py:113 msgid "Modal" msgstr "模�窗�" #: apprise/plugins/enigma2.py:131 apprise/plugins/fluxer.py:197 #: apprise/plugins/goalert.py:162 apprise/plugins/gotify.py:135 #: apprise/plugins/mattermost.py:173 apprise/plugins/notica.py:146 #: apprise/plugins/pinglet.py:155 msgid "Path" msgstr "路径" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "�务器超时" #: apprise/plugins/evolution.py:137 msgid "Instance Name" msgstr "实例�称" #: apprise/plugins/exotel.py:161 apprise/plugins/sinch.py:107 #: apprise/plugins/twilio.py:143 msgid "Account SID" msgstr "账户 SID" #: apprise/plugins/exotel.py:173 msgid "From Phone No / Sender ID" msgstr "��方电���或��方标识" #: apprise/plugins/exotel.py:234 apprise/plugins/jira.py:276 #: apprise/plugins/mailgun.py:170 apprise/plugins/opsgenie.py:274 #: apprise/plugins/pagerduty.py:199 apprise/plugins/pingram.py:227 #: apprise/plugins/sparkpost.py:192 apprise/plugins/squadcast.py:167 msgid "Region Name" msgstr "区域�称" #: apprise/plugins/feishu.py:50 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:100 apprise/plugins/twitter.py:176 msgid "Access Key" msgstr "访问密钥" #: apprise/plugins/flock.py:112 msgid "To User ID" msgstr "目标用户标识" #: apprise/plugins/flock.py:119 msgid "To Channel ID" msgstr "目标频�标识" #: apprise/plugins/flowtriq.py:130 apprise/plugins/matrix/base.py:373 msgid "Webhook Path" msgstr "Webhook 路径" #: apprise/plugins/fcm/__init__.py:183 apprise/plugins/fcm/__init__.py:189 #: apprise/plugins/fluxer.py:229 apprise/plugins/kook.py:199 #: apprise/plugins/lametric.py:511 apprise/plugins/mattermost.py:230 #: apprise/plugins/notifyre.py:158 apprise/plugins/ntfy.py:298 #: apprise/plugins/office365.py:320 apprise/plugins/pingram.py:233 #: apprise/plugins/sns.py:204 apprise/plugins/vapid/__init__.py:179 #: apprise/plugins/webexteams.py:198 apprise/plugins/zoom.py:159 msgid "Mode" msgstr "模�" #: apprise/plugins/fluxer.py:263 msgid "Thread Name" msgstr "�题�称" #: apprise/plugins/fortysixelks.py:59 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:90 apprise/plugins/mobilemessage.py:120 msgid "API Username" msgstr "API 用户�" #: apprise/plugins/fortysixelks.py:95 apprise/plugins/mobilemessage.py:125 msgid "API Password" msgstr "API 密�" #: apprise/plugins/freemobile.py:49 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/github.py:197 apprise/plugins/groupme.py:131 #: apprise/plugins/line.py:83 apprise/plugins/mastodon.py:194 #: apprise/plugins/matrix/base.py:315 apprise/plugins/misskey.py:123 #: apprise/plugins/onebot.py:148 apprise/plugins/pushbullet.py:84 #: apprise/plugins/pushover.py:381 apprise/plugins/smseagle.py:147 #: apprise/plugins/spugpush.py:69 apprise/plugins/streamlabs.py:108 #: apprise/plugins/whatsapp.py:122 msgid "Access Token" msgstr "访问令牌" #: apprise/plugins/github.py:204 msgid "Owner" msgstr "所有者" #: apprise/plugins/github.py:210 msgid "Repository" msgstr "仓库" #: apprise/plugins/github.py:216 apprise/plugins/octopush.py:190 msgid "Type" msgstr "类型" #: apprise/plugins/github.py:222 msgid "Number" msgstr "编�" #: apprise/plugins/github.py:237 apprise/plugins/ilert.py:188 msgid "Labels" msgstr "标签" #: apprise/plugins/github.py:241 msgid "Assignees" msgstr "负责人" #: apprise/plugins/glib.py:124 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "必须安装 libdbus-1.so.x 或 libdbus-2.so.x。" #: apprise/plugins/glib.py:128 msgid "GLib Notification" msgstr "GLib 通知" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "需�本地的 Gnome 环境。" #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Gnome 通知" #: apprise/plugins/goalert.py:168 msgid "Integration Keys" msgstr "集�密钥列表" #: apprise/plugins/goalert.py:187 apprise/plugins/ilert.py:153 #: apprise/plugins/incidentio.py:146 apprise/plugins/jira.py:309 #: apprise/plugins/opsgenie.py:299 apprise/plugins/pagerduty.py:220 #: apprise/plugins/pagertree.py:127 apprise/plugins/signl4.py:137 #: apprise/plugins/splunk.py:203 apprise/plugins/squadcast.py:174 msgid "Action" msgstr "�作" #: apprise/plugins/goalert.py:193 apprise/plugins/ilert.py:159 #: apprise/plugins/incidentio.py:152 apprise/plugins/pagerduty.py:226 #: apprise/plugins/squadcast.py:180 msgid "Deduplication Key" msgstr "去�密钥" #: apprise/plugins/goalert.py:202 apprise/plugins/incidentio.py:165 #: apprise/plugins/pinglet.py:196 msgid "Metadata" msgstr "元数�" #: apprise/plugins/google_chat.py:120 msgid "Workspace" msgstr "工作区" #: apprise/plugins/google_chat.py:126 apprise/plugins/squadcast.py:150 msgid "Webhook Key" msgstr "Webhook 密钥" #: apprise/plugins/google_chat.py:138 msgid "Thread Key" msgstr "�题密钥" #: apprise/plugins/gorush.py:192 msgid "Platform" msgstr "平�" #: apprise/plugins/gorush.py:206 apprise/plugins/ntfy.py:236 #: apprise/plugins/pinglet.py:166 msgid "Topic" msgstr "主题" #: apprise/plugins/fcm/__init__.py:216 apprise/plugins/gorush.py:227 msgid "Data Entries" msgstr "数��目" #: apprise/plugins/groupme.py:124 msgid "Bot ID" msgstr "机器人标识" #: apprise/plugins/amqp/base.py:194 apprise/plugins/growl.py:169 #: apprise/plugins/mqtt.py:196 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "版本" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "置顶" #: apprise/plugins/home_assistant.py:144 msgid "Long-Lived Access Token" msgstr "长期访问令牌" #: apprise/plugins/home_assistant.py:167 msgid "Notification ID" msgstr "通知标识" #: apprise/plugins/home_assistant.py:184 msgid "Path Prefix" msgstr "路径�缀" #: apprise/plugins/humhub.py:128 msgid "Token or Username" msgstr "令牌或用户�" #: apprise/plugins/ifttt.py:110 msgid "Events" msgstr "事件" #: apprise/plugins/ifttt.py:130 msgid "Add Tokens" msgstr "添加令牌" #: apprise/plugins/ifttt.py:134 msgid "Remove Tokens" msgstr "移除令牌" #: apprise/plugins/ilert.py:136 apprise/plugins/pagerduty.py:161 #: apprise/plugins/pushward.py:135 apprise/plugins/spike.py:69 msgid "Integration Key" msgstr "集�密钥" #: apprise/plugins/ilert.py:168 apprise/plugins/lauther.py:166 msgid "Route" msgstr "路由" #: apprise/plugins/incidentio.py:121 msgid "Alert Source ID" msgstr "告警�� ID" #: apprise/plugins/jira.py:241 apprise/plugins/opsgenie.py:239 msgid "Target Escalation" msgstr "目标�级�程" #: apprise/plugins/jira.py:247 apprise/plugins/opsgenie.py:245 msgid "Target Schedule" msgstr "目标排�" #: apprise/plugins/irc/base.py:138 apprise/plugins/jira.py:253 #: apprise/plugins/line.py:89 apprise/plugins/mastodon.py:205 #: apprise/plugins/matrix/base.py:322 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:131 apprise/plugins/opsgenie.py:251 #: apprise/plugins/pushed.py:96 apprise/plugins/rocketchat.py:157 #: apprise/plugins/slack.py:277 apprise/plugins/twitter.py:188 #: apprise/plugins/wechat.py:199 apprise/plugins/xmpp/base.py:124 #: apprise/plugins/zulip.py:144 msgid "Target User" msgstr "目标用户" #: apprise/plugins/jira.py:265 apprise/plugins/opsgenie.py:263 msgid "Targets " msgstr "目标 " #: apprise/plugins/jira.py:294 apprise/plugins/opsgenie.py:287 msgid "Entity" msgstr "实体" #: apprise/plugins/jira.py:298 apprise/plugins/opsgenie.py:291 msgid "Alias" msgstr "别�" #: apprise/plugins/jira.py:320 apprise/plugins/opsgenie.py:318 msgid "Details" msgstr "详细信�" #: apprise/plugins/jira.py:324 apprise/plugins/opsgenie.py:322 #: apprise/plugins/splunk.py:214 msgid "Action Mapping" msgstr "�作映射" #: apprise/plugins/join.py:148 msgid "Device ID" msgstr "设备标识" #: apprise/plugins/join.py:154 msgid "Device Name" msgstr "设备�称" #: apprise/plugins/kavenegar.py:123 apprise/plugins/messagebird.py:86 #: apprise/plugins/notifyre.py:164 apprise/plugins/plivo.py:104 msgid "Source Phone No" msgstr "��电���" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:144 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "�续时间" #: apprise/plugins/kumulos.py:100 msgid "Server Key" msgstr "�务器密钥" #: apprise/plugins/lametric.py:437 msgid "Device API Key" msgstr "设备 API 密钥" #: apprise/plugins/lametric.py:443 apprise/plugins/one_signal.py:104 #: apprise/plugins/parseplatform.py:102 msgid "App ID" msgstr "应用标识" #: apprise/plugins/lametric.py:449 msgid "App Version" msgstr "应用版本" #: apprise/plugins/lametric.py:456 msgid "App Access Token" msgstr "应用访问令牌" #: apprise/plugins/lametric.py:501 msgid "Custom Icon" msgstr "自定义图标" #: apprise/plugins/lametric.py:505 msgid "Icon Type" msgstr "图标类型" #: apprise/plugins/lametric.py:522 msgid "Cycles" msgstr "循环次数" #: apprise/plugins/lark.py:48 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:68 apprise/plugins/revolt.py:99 #: apprise/plugins/telegram.py:594 apprise/plugins/zalo.py:113 msgid "Bot Token" msgstr "机器人令牌" #: apprise/plugins/lauther.py:143 apprise/plugins/wpush.py:211 msgid "Click URL" msgstr "点击网�" #: apprise/plugins/lauther.py:151 msgid "Color" msgstr "颜色" #: apprise/plugins/macosx.py:109 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "仅支� Mac OS X 10.8 �更高版本。此外还需�能在本地访问 /usr/local/bin/" "terminal-notifier。" #: apprise/plugins/macosx.py:116 msgid "MacOSX Notification" msgstr "MacOSX 通知" #: apprise/plugins/macosx.py:170 msgid "Open/Click URL" msgstr "打开或点击的网�" #: apprise/plugins/macosx.py:175 msgid "Terminal-Notifier Version" msgstr "Terminal-Notifier 版本" #: apprise/plugins/macosx.py:181 apprise/plugins/octopush.py:143 msgid "Sender" msgstr "��方" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:167 #: apprise/plugins/office365.py:316 apprise/plugins/pingram.py:238 #: apprise/plugins/resend.py:151 msgid "Reply To" msgstr "回�地�" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:135 #: apprise/plugins/sendpulse.py:117 apprise/plugins/smtp2go.py:114 #: apprise/plugins/sparkpost.py:165 msgid "Domain" msgstr "域�" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:162 #: apprise/plugins/postmark.py:145 apprise/plugins/resend.py:141 #: apprise/plugins/ses.py:211 apprise/plugins/smtp2go.py:136 #: apprise/plugins/sparkpost.py:187 msgid "From Name" msgstr "��方�称" #: apprise/plugins/email/base.py:257 apprise/plugins/mailgun.py:198 #: apprise/plugins/smtp2go.py:162 apprise/plugins/sparkpost.py:220 msgid "Email Header" msgstr "邮件标头" #: apprise/plugins/mastodon.py:225 apprise/plugins/misskey.py:144 msgid "Visibility" msgstr "��性" #: apprise/plugins/mastodon.py:231 msgid "Spoiler Text" msgstr "��文本" #: apprise/plugins/mastodon.py:235 msgid "Idempotency-Key" msgstr "Idempotency-Key" #: apprise/plugins/mastodon.py:239 msgid "Language Code" msgstr "语言代�" #: apprise/plugins/mastodon.py:243 apprise/plugins/twitter.py:211 msgid "Cache Results" msgstr "缓存结果" #: apprise/plugins/mastodon.py:248 msgid "Sensitive Attachments" msgstr "�感附件" #: apprise/plugins/mastodon.py:264 msgid "Ping Users/Tags" msgstr "��用户或标签" #: apprise/plugins/irc/base.py:129 apprise/plugins/mattermost.py:157 #: apprise/plugins/xmpp/base.py:113 msgid "User" msgstr "用户" #: apprise/plugins/mattermost.py:189 apprise/plugins/twist.py:119 msgid "Target Channel ID" msgstr "目标频�标识" #: apprise/plugins/mobilemessage.py:170 msgid "Max Message Parts" msgstr "消�最大分段数" #: apprise/plugins/mobilemessage.py:177 msgid "Custom Reference" msgstr "自定义�考�" #: apprise/plugins/mqtt.py:172 msgid "Target Queue" msgstr "目标队列" #: apprise/plugins/mqtt.py:189 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:202 apprise/plugins/office365.py:270 #: apprise/plugins/ringcentral.py:193 apprise/plugins/sendpulse.py:122 msgid "Client ID" msgstr "客户端标识" #: apprise/plugins/mqtt.py:206 msgid "Use Session" msgstr "使用会�" #: apprise/plugins/mqtt.py:211 msgid "Retain Messages" msgstr "�留消�" #: apprise/plugins/msg91.py:103 apprise/plugins/sendpulse.py:158 msgid "Template ID" msgstr "模�标识" #: apprise/plugins/msg91.py:110 msgid "Authentication Key" msgstr "认�密钥" #: apprise/plugins/msg91.py:139 msgid "Short URL" msgstr "短网�" #: apprise/plugins/msg91.py:149 apprise/plugins/whatsapp.py:204 msgid "Template Mapping" msgstr "模�映射" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:153 msgid "URL Prefix" msgstr "网��缀" #: apprise/plugins/nextcloudtalk.py:64 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:130 msgid "Room ID" msgstr "房间标识" #: apprise/plugins/nextcloudtalk.py:148 apprise/plugins/telegram.py:632 msgid "Silent Notification" msgstr "�默通知" #: apprise/plugins/notifiarr.py:122 msgid "Discord Event ID" msgstr "Discord 事件标识" #: apprise/plugins/notifiarr.py:132 apprise/plugins/pagerduty.py:168 msgid "Source" msgstr "��" #: apprise/plugins/fcm/__init__.py:154 apprise/plugins/notifico.py:188 msgid "Project ID" msgstr "项目标识" #: apprise/plugins/notifico.py:197 msgid "Message Hook" msgstr "消�钩�" #: apprise/plugins/notifico.py:212 msgid "IRC Colors" msgstr "IRC 颜色" #: apprise/plugins/notifico.py:218 msgid "Prefix" msgstr "�缀" #: apprise/plugins/notifyre.py:169 msgid "Campaign Name" msgstr "活动�称" #: apprise/plugins/notifyre.py:173 apprise/plugins/whatsapp.py:129 msgid "Template Name" msgstr "模��称" #: apprise/plugins/notifyre.py:177 msgid "Client Reference" msgstr "客户端�考�" #: apprise/plugins/notifyre.py:181 msgid "High Quality" msgstr "高质�" #: apprise/plugins/notifyre.py:186 msgid "Fax Header" msgstr "传真标头" #: apprise/plugins/ntfy.py:254 msgid "Attach" msgstr "附件" #: apprise/plugins/ntfy.py:268 msgid "Attach Filename" msgstr "附件文件�" #: apprise/plugins/ntfy.py:276 msgid "Delay" msgstr "延迟" #: apprise/plugins/ntfy.py:280 apprise/plugins/twist.py:108 msgid "Email" msgstr "电�邮件" #: apprise/plugins/ntfy.py:294 msgid "Actions" msgstr "�作" #: apprise/plugins/ntfy.py:307 msgid "Authentication Type" msgstr "认�类型" #: apprise/plugins/octopush.py:131 msgid "API Login" msgstr "API 登录�" #: apprise/plugins/octopush.py:179 msgid "Accept Replies" msgstr "接�回�" #: apprise/plugins/octopush.py:184 msgid "Purpose" msgstr "用途" #: apprise/plugins/office365.py:258 msgid "Tenant Domain" msgstr "租户域�" #: apprise/plugins/office365.py:265 msgid "Account Email or Object ID" msgstr "账户邮箱或对象标识" #: apprise/plugins/office365.py:277 apprise/plugins/ringcentral.py:200 #: apprise/plugins/sendpulse.py:129 msgid "Client Secret" msgstr "客户端密文" #: apprise/plugins/office365.py:326 msgid "Save to Sent Items" msgstr "�存到已��项目" #: apprise/plugins/one_signal.py:110 apprise/plugins/sendgrid.py:147 msgid "Template" msgstr "模�" #: apprise/plugins/one_signal.py:121 msgid "Target Player ID" msgstr "目标播放器标识" #: apprise/plugins/one_signal.py:137 msgid "Include Segment" msgstr "包��众分组" #: apprise/plugins/one_signal.py:157 msgid "Subtitle" msgstr "副标题" #: apprise/plugins/one_signal.py:161 apprise/plugins/sfr.py:126 #: apprise/plugins/whatsapp.py:142 msgid "Language" msgstr "语言" #: apprise/plugins/one_signal.py:175 msgid "Enable Contents" msgstr "�用正文" #: apprise/plugins/one_signal.py:181 msgid "Decode Template Args" msgstr "解�模��数" #: apprise/plugins/one_signal.py:200 msgid "Custom Data" msgstr "自定义数�" #: apprise/plugins/one_signal.py:204 msgid "Postback Data" msgstr "回传数�" #: apprise/plugins/onebot.py:153 apprise/plugins/wxpusher.py:134 msgid "Target User ID" msgstr "目标用户标识" #: apprise/plugins/onebot.py:160 apprise/plugins/serwersms.py:156 #: apprise/plugins/signal_api.py:157 apprise/plugins/smseagle.py:159 #: apprise/plugins/whatsapp.py:160 msgid "Target Group ID" msgstr "目标群组标识" #: apprise/plugins/pagerduty.py:174 msgid "Component" msgstr "组件" #: apprise/plugins/pagerduty.py:190 msgid "Class" msgstr "类" #: apprise/plugins/pagerduty.py:208 msgid "Severity" msgstr "严�程度" #: apprise/plugins/pagerduty.py:235 msgid "Custom Details" msgstr "自定义详细信�" #: apprise/plugins/pagertree.py:114 msgid "Integration ID" msgstr "集�标识" #: apprise/plugins/pagertree.py:133 msgid "Third Party ID" msgstr "第三方标识" #: apprise/plugins/pagertree.py:165 msgid "Meta Extras" msgstr "Meta 附加内容" #: apprise/plugins/parseplatform.py:108 msgid "Master Key" msgstr "主密钥" #: apprise/plugins/parseplatform.py:121 msgid "Device" msgstr "设备" #: apprise/plugins/pinglet.py:161 msgid "Namespace" msgstr "命�空间" #: apprise/plugins/pinglet.py:192 msgid "Badges" msgstr "角标" #: apprise/plugins/matrix/base.py:385 apprise/plugins/pingram.py:176 msgid "Message Type" msgstr "消�类型" #: apprise/plugins/pingram.py:194 msgid "Target ID" msgstr "目标标识" #: apprise/plugins/pingram.py:200 msgid "Target SMS" msgstr "目标短信" #: apprise/plugins/pingram.py:222 apprise/plugins/signalgrid.py:102 msgid "Channels" msgstr "频�" #: apprise/plugins/plivo.py:92 msgid "Auth ID" msgstr "认�标识" #: apprise/plugins/plivo.py:98 apprise/plugins/simplepush.py:235 #: apprise/plugins/sinch.py:114 apprise/plugins/twilio.py:150 msgid "Auth Token" msgstr "认�令牌" #: apprise/plugins/prowl.py:129 msgid "Provider Key" msgstr "�务商密钥" #: apprise/plugins/pushdeer.py:86 msgid "Pushkey" msgstr "推�密钥" #: apprise/plugins/pushed.py:84 msgid "Application Key" msgstr "应用密钥" #: apprise/plugins/pushed.py:90 apprise/plugins/reddit.py:161 msgid "Application Secret" msgstr "应用密文" #: apprise/plugins/pushjet.py:83 msgid "Secret Key" msgstr "�密密钥" #: apprise/plugins/pushme.py:82 apprise/plugins/signal_api.py:178 #: apprise/plugins/smseagle.py:194 msgid "Show Status" msgstr "显示状�" #: apprise/plugins/pushover.py:375 msgid "User Key" msgstr "用户密钥" #: apprise/plugins/pushover.py:427 msgid "URL Title" msgstr "网�标题" #: apprise/plugins/pushover.py:432 msgid "Emergency Retry Interval" msgstr "紧急�试间隔" #: apprise/plugins/pushover.py:438 msgid "Expire" msgstr "过期时间" #: apprise/plugins/pushover.py:455 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:67 msgid "User Token" msgstr "用户令牌" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "群组主题" #: apprise/plugins/pushplus.py:241 apprise/plugins/wpush.py:183 msgid "Channel" msgstr "频�" #: apprise/plugins/pushplus.py:258 msgid "Webhook Name" msgstr "Webhook �称" #: apprise/plugins/pushsafer.py:361 msgid "Private Key" msgstr "�钥" #: apprise/plugins/pushsafer.py:398 msgid "Vibration" msgstr "振动" #: apprise/plugins/pushward.py:159 msgid "Info Level" msgstr "信�级别" #: apprise/plugins/pushward.py:165 msgid "Success Level" msgstr "�功级别" #: apprise/plugins/pushward.py:171 msgid "Warning Level" msgstr "警告级别" #: apprise/plugins/pushward.py:177 msgid "Failure Level" msgstr "失败级别" #: apprise/plugins/pushy.py:80 msgid "Secret API Key" msgstr "�密 API 密钥" #: apprise/plugins/fcm/__init__.py:163 apprise/plugins/pushy.py:92 #: apprise/plugins/simplepush.py:184 apprise/plugins/sns.py:165 #: apprise/plugins/wxpusher.py:129 msgid "Target Topic" msgstr "目标主题" #: apprise/plugins/qq.py:47 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:154 msgid "Application ID" msgstr "应用标识" #: apprise/plugins/reddit.py:168 msgid "Target Subreddit" msgstr "目标�版�" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "�类" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "标记标识" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "标记文字" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "是�为广告?" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "��回�" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "�剧�内容" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "�新�交标记" #: apprise/plugins/resend.py:136 msgid "From Address" msgstr "��方地�" #: apprise/plugins/revolt.py:105 msgid "Channel ID" msgstr "频�标识" #: apprise/plugins/revolt.py:132 msgid "Embed URL" msgstr "嵌入网�" #: apprise/plugins/ringcentral.py:187 msgid "Token / Password" msgstr "令牌或密�" #: apprise/plugins/ringcentral.py:240 msgid "Environment" msgstr "环境" #: apprise/plugins/ringcentral.py:253 msgid "Authentication Mode" msgstr "认�模�" #: apprise/plugins/rocketchat.py:147 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:328 apprise/plugins/rocketchat.py:163 msgid "Target Room ID" msgstr "目标房间标识" #: apprise/plugins/matrix/base.py:367 apprise/plugins/rocketchat.py:179 #: apprise/plugins/ryver.py:119 msgid "Webhook Mode" msgstr "Webhook 模�" #: apprise/plugins/rocketchat.py:184 msgid "Use Avatar" msgstr "使用头�" #: apprise/plugins/rsyslog.py:174 apprise/plugins/syslog.py:145 msgid "Facility" msgstr "系统设施" #: apprise/plugins/rsyslog.py:204 apprise/plugins/syslog.py:162 msgid "Log PID" msgstr "记录进程�" #: apprise/plugins/ryver.py:94 apprise/plugins/zulip.py:131 msgid "Organization" msgstr "组织" #: apprise/plugins/sendgrid.py:167 apprise/plugins/sendpulse.py:184 msgid "Template Data" msgstr "模�数�" #: apprise/plugins/serwersms.py:143 apprise/plugins/sfr.py:132 msgid "Sender Name" msgstr "��方�称" #: apprise/plugins/ses.py:158 apprise/plugins/sns.py:134 msgid "Session Token" msgstr "会�令牌" #: apprise/plugins/ses.py:170 apprise/plugins/sns.py:140 msgid "Access Key ID" msgstr "访问密钥标识" #: apprise/plugins/ses.py:176 apprise/plugins/sns.py:146 msgid "Secret Access Key" msgstr "�密访问密钥" #: apprise/plugins/ses.py:189 apprise/plugins/smtp2go.py:125 #: apprise/plugins/sparkpost.py:176 msgid "Target Emails" msgstr "目标邮箱" #: apprise/plugins/seven.py:114 apprise/plugins/smseagle.py:204 msgid "Flash" msgstr "闪光" #: apprise/plugins/seven.py:118 msgid "Label" msgstr "标注" #: apprise/plugins/sfr.py:59 msgid "Société Française du Radiotéléphone" msgstr "Société Française du Radiotéléphone" #: apprise/plugins/sfr.py:91 msgid "Service ID" msgstr "�务标识" #: apprise/plugins/sfr.py:96 msgid "Service Password" msgstr "�务密�" #: apprise/plugins/sfr.py:102 msgid "Space ID" msgstr "空间标识" #: apprise/plugins/sfr.py:108 msgid "Recipient Phone Number" msgstr "收件人电���" #: apprise/plugins/sfr.py:139 msgid "Media Type" msgstr "媒体类型" #: apprise/plugins/sfr.py:146 msgid "Timeout" msgstr "超时" #: apprise/plugins/sfr.py:152 msgid "TTS Voice" msgstr "语音朗读音色" #: apprise/plugins/signalgrid.py:96 msgid "Client Key" msgstr "客户端密钥" #: apprise/plugins/signalgrid.py:119 msgid "Critical Notification" msgstr "��通知" #: apprise/plugins/signl4.py:113 msgid "Service" msgstr "�务" #: apprise/plugins/signl4.py:117 msgid "Location" msgstr "�置" #: apprise/plugins/signl4.py:121 msgid "Alerting Scenario" msgstr "告警场景" #: apprise/plugins/signl4.py:125 msgid "Filtering" msgstr "过滤" #: apprise/plugins/signl4.py:130 msgid "External ID" msgstr "外部标识" #: apprise/plugins/signl4.py:143 msgid "Status" msgstr "状�" #: apprise/plugins/simplepush.py:189 msgid "Target Member" msgstr "目标�员" #: apprise/plugins/simplepush.py:211 msgid "Shared" msgstr "共享" #: apprise/plugins/simplepush.py:227 msgid "Tag" msgstr "标签" #: apprise/plugins/sinch.py:135 apprise/plugins/twilio.py:171 msgid "Target Short Code" msgstr "目标短代�" #: apprise/plugins/slack.py:232 msgid "OAuth Access Token" msgstr "OAuth 访问令牌" #: apprise/plugins/slack.py:241 msgid "Token A" msgstr "令牌 A" #: apprise/plugins/slack.py:250 msgid "Token B" msgstr "令牌 B" #: apprise/plugins/slack.py:259 msgid "Token C" msgstr "令牌 C" #: apprise/plugins/slack.py:266 msgid "Target Encoded ID" msgstr "编��的目标标识" #: apprise/plugins/slack.py:295 msgid "Workflow Path" msgstr "工作�路径" #: apprise/plugins/slack.py:313 msgid "Include Footer" msgstr "包�页脚" #: apprise/plugins/slack.py:321 msgid "Use Blocks" msgstr "使用区�" #: apprise/plugins/slack.py:327 msgid "Include Timestamp" msgstr "包�时间戳" #: apprise/plugins/slack.py:333 apprise/plugins/twitter.py:205 msgid "Message Mode" msgstr "消�模�" #: apprise/plugins/smpp.py:66 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:108 msgid "Host" msgstr "主机" #: apprise/plugins/smsc.py:139 msgid "Login" msgstr "登录�" #: apprise/plugins/smsc.py:176 msgid "Transliterate" msgstr "音译" #: apprise/plugins/smseagle.py:166 msgid "Target Contact" msgstr "目标�系人" #: apprise/plugins/smseagle.py:199 msgid "Test Only" msgstr "仅测试" #: apprise/plugins/smsmanager.py:144 msgid "Gateway" msgstr "网关" #: apprise/plugins/sogs.py:221 msgid "Public Key" msgstr "公钥" #: apprise/plugins/sogs.py:228 msgid "Seed" msgstr "��" #: apprise/plugins/sogs.py:235 msgid "Rooms" msgstr "房间" #: apprise/plugins/spike.py:49 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:118 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:173 msgid "Target Routing Key" msgstr "目标路由密钥" #: apprise/plugins/splunk.py:180 msgid "Entity ID" msgstr "实体标识" #: apprise/plugins/spugpush.py:49 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:128 msgid "Alert Type" msgstr "告警类型" #: apprise/plugins/streamlabs.py:134 msgid "Image Link" msgstr "图片链接" #: apprise/plugins/streamlabs.py:139 msgid "Sound Link" msgstr "声音链接" #: apprise/plugins/streamlabs.py:150 msgid "Special Text Color" msgstr "特殊文字颜色" #: apprise/plugins/streamlabs.py:156 msgid "Amount" msgstr "金�" #: apprise/plugins/streamlabs.py:162 msgid "Currency" msgstr "货�" #: apprise/plugins/streamlabs.py:168 msgid "Name" msgstr "�称" #: apprise/plugins/streamlabs.py:174 msgid "Identifier" msgstr "标识符" #: apprise/plugins/synology.py:119 msgid "Upload" msgstr "上传" #: apprise/plugins/syslog.py:168 msgid "Log to STDERR" msgstr "记录到 STDERR" #: apprise/plugins/telegram.py:603 apprise/plugins/zalo.py:120 msgid "Target Chat ID" msgstr "目标�天标识" #: apprise/plugins/telegram.py:626 msgid "Detect Bot Owner" msgstr "检测机器人所有者" #: apprise/plugins/telegram.py:637 msgid "Web Page Preview" msgstr "网页预览" #: apprise/plugins/telegram.py:642 msgid "Group Photo/Video Attachments" msgstr "将照片/视频附件分组" #: apprise/plugins/telegram.py:647 msgid "Topic Thread ID" msgstr "主题�题标识" #: apprise/plugins/telegram.py:654 msgid "Markdown Version" msgstr "Markdown 版本" #: apprise/plugins/telegram.py:663 msgid "Content Placement" msgstr "内容�置" #: apprise/plugins/telegram.py:669 msgid "Rich Message Template Path" msgstr "富文本消�模�路径" #: apprise/plugins/telnyx.py:132 msgid "Messaging Profile ID" msgstr "消��置标识" #: apprise/plugins/threema.py:86 msgid "Gateway ID" msgstr "网关标识" #: apprise/plugins/threema.py:111 msgid "Target Threema ID" msgstr "目标 Threema 标识" #: apprise/plugins/trigv.py:160 msgid "API Hostname" msgstr "API 主机�" #: apprise/plugins/trigv.py:186 msgid "Image URL" msgstr "图片网�" #: apprise/plugins/trigv.py:196 msgid "Event type" msgstr "事件类型" #: apprise/plugins/trigv.py:200 msgid "Priority (Pushover compatibility)" msgstr "优先级(兼容 Pushover)" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "通知方�:短信或通�" #: apprise/plugins/twitter.py:164 msgid "Consumer Key" msgstr "使用方密钥" #: apprise/plugins/twitter.py:170 msgid "Consumer Secret" msgstr "使用方密文" #: apprise/plugins/twitter.py:182 msgid "Access Secret" msgstr "访问密文" #: apprise/plugins/viber.py:50 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:82 msgid "Authentication Token" msgstr "认�令牌" #: apprise/plugins/viber.py:88 msgid "Receiver IDs" msgstr "接收方标识" #: apprise/plugins/viber.py:106 msgid "Bot Avatar URL" msgstr "机器人头�网�" #: apprise/plugins/voipms.py:84 msgid "User Email" msgstr "用户邮箱" #: apprise/plugins/vapid/__init__.py:189 apprise/plugins/vonage.py:134 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:179 msgid "Bot Access Token" msgstr "机器人访问令牌" #: apprise/plugins/webexteams.py:185 msgid "Room IDs" msgstr "房间标识" #: apprise/plugins/wechat.py:175 msgid "Corp ID" msgstr "�业标识" #: apprise/plugins/wechat.py:182 msgid "App Secret" msgstr "应用密文" #: apprise/plugins/wechat.py:190 msgid "Agent ID" msgstr "应用代�标识" #: apprise/plugins/wechat.py:206 msgid "Target Department" msgstr "目标部门" #: apprise/plugins/wechat.py:213 msgid "Target Tag" msgstr "目标标签" #: apprise/plugins/wecombot.py:100 msgid "Bot Webhook Key" msgstr "机器人 Webhook 密钥" #: apprise/plugins/whatsapp.py:135 msgid "From Phone ID" msgstr "��方电�标识" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "需�本地的 Microsoft Windows 环境。" #: apprise/plugins/workflows.py:151 msgid "Workflow ID" msgstr "工作�标识" #: apprise/plugins/workflows.py:159 msgid "Signature" msgstr "签�" #: apprise/plugins/workflows.py:182 msgid "Use Power Automate URL" msgstr "使用 Power Automate 网�" #: apprise/plugins/workflows.py:189 msgid "Power Automate Routing ID" msgstr "Power Automate 路由标识" #: apprise/plugins/workflows.py:196 msgid "Wrap Text" msgstr "自动�行" #: apprise/plugins/workflows.py:211 msgid "API Version" msgstr "API 版本" #: apprise/plugins/wpush.py:167 msgid "Topics" msgstr "主题" #: apprise/plugins/wpush.py:201 msgid "Group / Option Code" msgstr "群组或选项代�" #: apprise/plugins/wxpusher.py:122 msgid "App Token" msgstr "应用令牌" #: apprise/plugins/youlmk.py:159 msgid "Token or Key" msgstr "令牌或密钥" #: apprise/plugins/youlmk.py:183 msgid "Info Priority" msgstr "信�优先级" #: apprise/plugins/youlmk.py:189 msgid "Success Priority" msgstr "�功优先级" #: apprise/plugins/youlmk.py:195 msgid "Warning Priority" msgstr "警告优先级" #: apprise/plugins/youlmk.py:201 msgid "Failure Priority" msgstr "失败优先级" #: apprise/plugins/youlmk.py:216 msgid "Link Label" msgstr "链接标注" #: apprise/plugins/zoom.py:146 msgid "Verification Token" msgstr "验�令牌" #: apprise/plugins/zulip.py:149 msgid "Target Stream" msgstr "目标频��" #: apprise/plugins/amqp/base.py:102 msgid "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0, or both." msgstr "" "为 AMQP 0.9.1 安装 pika,为 AMQP 1.0 安装 python-qpid-proton,或两者都安装。" #: apprise/plugins/amqp/base.py:204 msgid "Exchange" msgstr "交�机" #: apprise/plugins/amqp/base.py:208 msgid "Virtual Host" msgstr "虚拟主机" #: apprise/plugins/amqp/base.py:212 msgid "Persistent" msgstr "�久化" #: apprise/plugins/amqp/base.py:217 msgid "Mandatory" msgstr "强制" #: apprise/plugins/amqp/base.py:228 msgid "Time To Live" msgstr "生存时间 (TTL)" #: apprise/plugins/amqp/base.py:243 msgid "Message Headers" msgstr "消�头" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "SMTP �务器" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:150 msgid "Secure Mode" msgstr "安全模�" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "PGP 安全模�" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "PGP 公钥路径" #: apprise/plugins/email/base.py:220 msgid "PGP Private Key Path" msgstr "PGP �钥路径" #: apprise/plugins/email/base.py:228 msgid "Web Key Directory" msgstr "网络密钥目录" #: apprise/plugins/email/base.py:234 msgid "Inline Attachments" msgstr "内嵌附件" #: apprise/plugins/email/base.py:239 msgid "To Email" msgstr "收件邮箱" #: apprise/plugins/fcm/__init__.py:149 msgid "OAuth2 KeyFile" msgstr "OAuth2 密钥文件" #: apprise/plugins/fcm/__init__.py:194 msgid "Custom Image URL" msgstr "自定义图片网�" #: apprise/plugins/fcm/__init__.py:206 msgid "Notification Color" msgstr "通知颜色" #: apprise/plugins/irc/base.py:160 msgid "Real Name" msgstr "真实姓�" #: apprise/plugins/irc/base.py:161 msgid "Nickname" msgstr "昵称" #: apprise/plugins/irc/base.py:163 msgid "Join Channels" msgstr "加入频�" #: apprise/plugins/irc/base.py:168 msgid "Auth Mode" msgstr "认�模�" #: apprise/plugins/matrix/base.py:334 msgid "Target Room Alias" msgstr "目标房间别�" #: apprise/plugins/matrix/base.py:357 msgid "Server Discovery" msgstr "�务器�现" #: apprise/plugins/matrix/base.py:362 msgid "Force Home Server on Room IDs" msgstr "对房间标识强制使用主�务器" #: apprise/plugins/matrix/base.py:379 msgid "Matrix API Version" msgstr "Matrix API 版本" #: apprise/plugins/matrix/base.py:391 msgid "End-to-End Encryption" msgstr "端到端加密" #: apprise/plugins/matrix/base.py:396 msgid "Automatic Device Verification" msgstr "自动设备验�" #: apprise/plugins/vapid/__init__.py:203 msgid "PEM Private KeyFile" msgstr "PEM �钥文件" #: apprise/plugins/vapid/__init__.py:209 msgid "Subscripion File" msgstr "订阅文件" #: apprise/plugins/xmpp/base.py:145 msgid "XMPP Server" msgstr "XMPP �务器" #: apprise/plugins/xmpp/base.py:157 msgid "Get Roster" msgstr "获��系人列表" #: apprise/plugins/xmpp/base.py:162 msgid "Use Subject" msgstr "使用主题" #: apprise/plugins/xmpp/base.py:167 msgid "Keep Connection Alive" msgstr "��连接" #: apprise/plugins/xmpp/base.py:173 msgid "MUC Nickname" msgstr "MUC 昵称" #: apprise/plugins/xmpp/base.py:177 msgid "SCRAM-PLUS Channel Binding" msgstr "SCRAM-PLUS 通�绑定" caronc-apprise-dfec414/apprise/locale.py000066400000000000000000000350161526720371000204000ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations from collections.abc import Callable, Iterator import contextlib import contextvars import ctypes import locale import os from os.path import abspath, dirname, join import re from typing import Optional, Union from .logger import logger # This gets toggled to True if we succeed GETTEXT_LOADED = False try: # Initialize gettext import gettext # Toggle our flag GETTEXT_LOADED = True except ImportError: # gettext isn't available; no problem; Use the library features without # multi-language support. pass # The translation function used for lazy strings inside lang_at(). ACTIVE_GETTEXT = contextvars.ContextVar("apprise_active_gettext", default=None) class AppriseLocale: """Load and switch between gettext languages as needed.""" # Define our translation domain _domain = "apprise" # The path to our translations _locale_dir = abspath(join(dirname(__file__), "i18n")) # Locale regular expression # # Matches a locale such as en_CA.UTF-8 or ca_ES.UTF-8@valencia: # - A 2 or 3 letter language with an optional region (en_CA or en-CA). # - An optional codeset (.UTF-8) and modifier (@euro), both discarded. # - A codeset may hold dots and underscores (C.ANSI_X3.4-1968). _local_re = re.compile( r"^\s*((?PC|POSIX)|(?P([a-z]{2,3}))" r"([_-](?P[a-z0-9]{2,8}))?([_-][a-z0-9]{2,8})*)" r"(\.(?P[a-z0-9._-]+))?" r"(@(?P[a-z0-9_-]+))?\s*$", re.IGNORECASE, ) # The environment variables that name a language, in the order gettext # itself consults them. LANGUAGE is the GNU extension and wins; LC_ALL # over-rides every category; LC_MESSAGES is the category that governs # translated text; LANG is the fallback for all of them. _env_variables = ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG") # The same list without LANGUAGE. These name a locale rather than a # language, and the first one set decides whether text is translated at # all. _locale_variables = ("LC_ALL", "LC_MESSAGES", "LANG") # Define our default encoding _default_encoding = "utf-8" # The function to assign `_` by default _fn = "gettext" # The language we should fall back to if all else fails _default_language = "en" def __init__(self, language: Optional[str] = None) -> None: """Use the requested language or detect it from the operating system. The configured default language is used when detection fails. """ # Cache previously loaded translations self._gtobjs = {} # Get our language self.lang = AppriseLocale.detect_language(language) # Our mapping to our _fn self.__fn_map = None if GETTEXT_LOADED is False: # We're done return # Add language self.add(self.lang) def add( self, lang: Optional[str] = None, set_default: bool = True, ) -> bool: """Add a language to our list.""" lang = lang if lang else self._default_language if lang not in self._gtobjs: # Try the regional catalog before its base language. languages = AppriseLocale.language_candidates(lang) if not languages: logger.warning( "The language specified (%s) is not supported.", lang ) return False # Load our gettext object and install our language try: self._gtobjs[lang] = gettext.translation( self._domain, localedir=self._locale_dir, languages=languages, fallback=False, ) # Keep the selected gettext function without installing it. self.__fn_map = getattr(self._gtobjs[lang], self._fn) except FileNotFoundError: # The translation directory does not exist logger.debug( "Could not load translation path: %s", join(self._locale_dir, lang), ) # Fallback (handle case where self.lang does not exist) if self.lang not in self._gtobjs: self._gtobjs[self.lang] = gettext self.__fn_map = getattr(self._gtobjs[self.lang], self._fn) return False logger.trace( "Loaded language %s (searched: %s)", lang, ", ".join(languages), ) if set_default: logger.debug("Language set to %s", lang) self.lang = lang return True @contextlib.contextmanager def lang_at( self, lang: Optional[str], mapto: str = _fn, ) -> Iterator[Optional[Callable[[str], str]]]: """Temporarily use a language and yield its translation function. For example:: with at.lang_at('fr'): # Strings use French within this block. """ if GETTEXT_LOADED is False: # Do nothing yield None # No translation support is available. return # Normalize the requested language. lang = AppriseLocale.detect_language(lang, detect_fallback=False) if not lang or ( lang not in self._gtobjs and not self.add(lang, set_default=False) ): # Nothing usable was asked for, or no catalog exists for it; fall # back to the language we were already set up with fn = getattr(self._gtobjs[self.lang], mapto) else: fn = getattr(self._gtobjs[lang], mapto) # Use this translation for lazy strings within the context. token = ACTIVE_GETTEXT.set(fn if mapto == self._fn else None) try: # Provide the selected translation function. yield fn finally: # Restore the translation used by the surrounding context. ACTIVE_GETTEXT.reset(token) return @property def gettext(self) -> Callable[[str], str]: """Return the current language gettext() function. Useful for assigning to `_` """ return self._gtobjs[self.lang].gettext @staticmethod def normalize_language(lang: Optional[str]) -> Optional[str]: """Normalize a language code, or return None when it is invalid. Matching is case insensitive. A hyphen or an underscore separates a region, so ``en-CA``, ``en_CA``, and ``EN-ca`` all become ``en_CA``. A codeset or modifier is dropped, so ``en_CA.UTF-8@euro`` becomes ``en_CA``. Only the first value of a preference list is used. """ if not isinstance(lang, str): return None # Use the first language in a preference list. A comma separates an # Accept-Language list and a colon separates a LANGUAGE= one. entry = re.split(r"[,:]", lang, maxsplit=1)[0] # Ignore a quality value such as ;q=0.9. entry = entry.split(";")[0] result = AppriseLocale._local_re.match(entry) if not result or not result.group("lang"): # The value is not a supported language format. return None # Language codes are lower case, regions are upper case (en_CA) language = result.group("lang").lower() country = result.group("country") # Drop unsupported regions or script names such as Hans in zh-Hans. if not country or not re.match(r"^[a-z]{2}$", country, re.I): return language return f"{language}_{country.upper()}" @staticmethod def is_ansii_locale(value: Optional[str]) -> bool: """Return True when ``value`` names the C (or POSIX) locale.""" if not isinstance(value, str): return False # C, POSIX, and C.UTF-8 all mean the same thing here result = AppriseLocale._local_re.match(value) return bool(result and result.group("ansii")) @staticmethod def language_candidates(lang: Optional[str]) -> list[str]: """Return matching catalogs in preference order. For example, ``en_CA`` searches ``en_CA`` and then ``en``. Invalid values return an empty list. """ language = AppriseLocale.normalize_language(lang) if not language: # No catalog can match an invalid value. return [] # Retry a regional language without its region. return ( [language, language.split("_")[0]] if "_" in language else [language] ) @staticmethod def detect_language( lang: Optional[str] = None, detect_fallback: bool = True, ) -> Optional[str]: """Return a normalized language from the input or the system.""" # Preserve valid regions; return None for invalid values. if not isinstance(lang, str): if detect_fallback is False: # no detection enabled; we're done return None # Posix lookup lookup = os.environ.get # The locale in charge of translated text. Asking for C (or # POSIX) is asking for no translation at all, and it silences # LANGUAGE too, so detection stops there. # # This follows GNU gettext, which documents LANGUAGE as being # ignored under the C locale. Python's own gettext.find() skips # that rule and lets LANGUAGE through; we do not, because # LC_ALL=C is how a script asks for untranslated output. localename = next( ( value for value in ( lookup(variable) for variable in AppriseLocale._locale_variables ) if value ), None, ) if localename and AppriseLocale.is_ansii_locale(localename): logger.debug( "The %s locale asks for no translation; using %s", localename, AppriseLocale._default_language, ) return AppriseLocale._default_language localename = None for variable in AppriseLocale._env_variables: localename = lookup(variable, None) if localename: # Normalization applies the same rules and drops any # encoding suffix such as .UTF-8 on its own. language = AppriseLocale.normalize_language(localename) if language: return language # Windows handling if hasattr(ctypes, "windll"): windll = ctypes.windll.kernel32 try: lang = locale.windows_locale[ windll.GetUserDefaultUILanguage() ] # Our detected windows language return AppriseLocale.normalize_language(lang) except (TypeError, KeyError): # Fallback to posix detection pass # Built in locale library check try: # Acquire our locale lang = locale.getlocale()[0] # Compatibility for Python >= 3.12 if lang == "C": lang = AppriseLocale._default_language except (ValueError, TypeError) as e: # An invalid environment locale can make getlocale() fail. # Warn the user so they can correct their locale variables. logger.warning(f"Language detection failure / {e!s}") return None return AppriseLocale.normalize_language(lang) def __getstate__(self) -> dict: """Return state that can be pickled.""" state = self.__dict__.copy() # Remove the unpicklable entries. del state["_gtobjs"] del state["_AppriseLocale__fn_map"] return state def __setstate__(self, state: dict) -> None: """Restore pickled state and reload its language.""" self.__dict__.update(state) # Our mapping to our _fn self.__fn_map = None self._gtobjs = {} self.add(state["lang"], set_default=True) # # Prepare our default LOCALE Singleton # LOCALE = AppriseLocale() class LazyTranslation: """Translate text only when it is converted to a string.""" def __init__(self, text: str, *args, **kwargs) -> None: """Store our text.""" self.text = text super().__init__(*args, **kwargs) def __str__(self) -> str: if GETTEXT_LOADED is False: return self.text # A lang_at() context overrides the configured language. fn = ACTIVE_GETTEXT.get() return fn(self.text) if fn else LOCALE.gettext(self.text) # Lazy translation handling def gettext_lazy(text: str) -> LazyTranslation: """Return text that is translated when converted to a string.""" return LazyTranslation(text=text) # Identify our Translatable content Translatable = Union[str, LazyTranslation] caronc-apprise-dfec414/apprise/logger.py000066400000000000000000001043251526720371000204200ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations import asyncio from collections.abc import Iterator import contextlib import contextvars from datetime import datetime, timezone from io import StringIO import json import logging import os import struct import tempfile import threading from typing import TYPE_CHECKING, Any, Callable, Optional, Union from .common import AWARE_DATE_ISO_FORMAT, JSON_COMPACT_SEPARATORS from .exception import AppriseDiskIOError if TYPE_CHECKING: # Import NotifyBase only for static analysis. Importing it at runtime # would create a cycle through apprise.url and this logger module. from .plugins.base import NotifyBase # The root identifier needed to monitor 'apprise' logging LOGGER_NAME = "apprise" # Tracks the service capture active in the current execution context. _active_capture: contextvars.ContextVar[Optional[_ServiceLogCapture]] = ( contextvars.ContextVar("apprise_active_log_capture", default=None) ) # Keeps call-level logs isolated when notifications run concurrently. _active_call_capture: contextvars.ContextVar[Optional[_ServiceLogCapture]] = ( contextvars.ContextVar("apprise_active_call_log_capture", default=None) ) # Define a verbosity level that is a noisier then debug mode logging.TRACE = logging.DEBUG - 1 # Define a verbosity level that is always used even when no verbosity is set # from the command line. The idea here is to allow for deprecation notices logging.DEPRECATE = logging.ERROR + 1 # Assign our Levels into our logging object logging.addLevelName(logging.DEPRECATE, "DEPRECATION WARNING") logging.addLevelName(logging.TRACE, "TRACE") def trace(self, message, *args, **kwargs): """ Verbose Debug Logging - Trace """ if self.isEnabledFor(logging.TRACE): self._log(logging.TRACE, message, args, **kwargs) def deprecate(self, message, *args, **kwargs): """Deprication Warning Logging.""" if self.isEnabledFor(logging.DEPRECATE): self._log(logging.DEPRECATE, message, args, **kwargs) # Assign our Loggers for use in Apprise logging.Logger.trace = trace logging.Logger.deprecate = deprecate # Create ourselve a generic (singleton) logging reference logger = logging.getLogger(LOGGER_NAME) def _safe_internal_log(level: int, message: str, *args: Any) -> None: """Report a capture problem without letting logging handlers raise.""" # Logging is best-effort while the original notification continues. with contextlib.suppress(Exception): # Capture handlers already block their own re-entry. This guard also # contains failures raised by application-provided handlers. logger.log(level, message, *args) class NotifyLogEntry: """A log entry captured during notification processing.""" def __init__( self, level: str, message: str, time: Optional[datetime] = None ) -> None: """Initialize a captured message and its UTC timestamp.""" # The already-formatted log message self.message = message # The log level name (e.g. "WARNING", "ERROR") self.level = level # The log time self.time = time if time is not None else datetime.now(timezone.utc) def __eq__(self, other: object) -> bool: """Check equality of two entries by their (time, level, message).""" if not isinstance(other, NotifyLogEntry): return NotImplemented return (self.time, self.level, self.message) == ( other.time, other.level, other.message, ) def __hash__(self) -> int: """Hash on the same (time, level, message) for __eq__ uses.""" return hash((self.time, self.level, self.message)) def __lt__(self, other: NotifyLogEntry) -> bool: """Order by time alone, for chronological playback""" if not isinstance(other, NotifyLogEntry): return NotImplemented return self.time < other.time def __le__(self, other: NotifyLogEntry) -> bool: """Order by time alone, for chronological playback""" if not isinstance(other, NotifyLogEntry): return NotImplemented return self.time <= other.time def __gt__(self, other: NotifyLogEntry) -> bool: """See __lt__.""" if not isinstance(other, NotifyLogEntry): return NotImplemented return self.time > other.time def __ge__(self, other: NotifyLogEntry) -> bool: """See __lt__.""" if not isinstance(other, NotifyLogEntry): return NotImplemented return self.time >= other.time def asdict(self) -> dict[str, Any]: """Return this entry as a plain, JSON-serializable dict.""" return { "level": self.level, "message": self.message, "time": self.time.strftime(AWARE_DATE_ISO_FORMAT), } def json(self) -> str: """Return this entry as a JSON string.""" return json.dumps(self.asdict(), separators=JSON_COMPACT_SEPARATORS) def __str__(self) -> str: """Format the entry like a conventional Python log line.""" # Match Python logging's familiar default-style output. asctime = "{},{:03d}".format( self.time.strftime("%Y-%m-%d %H:%M:%S"), self.time.microsecond // 1000, ) return "{} - {} - {}".format(asctime, self.level, self.message) def __repr__(self) -> str: """Return an unambiguous representation of this entry.""" return "".format( self.level, self.message, self.time ) class _LogCaptureBudget: """Share result-log memory and disk limits across one notify call.""" # Store each entry's size and the next entry in this capture. _HEADER = struct.Struct("!QQ") # Update only the next-entry location when linking records. _LINK = struct.Struct("!Q") # This value marks the last record in one capture's disk chain. _END = (1 << 64) - 1 # This message is retained when the configured disk allowance is full. _LIMIT_MESSAGE = ( "Result log storage is full; additional entries were not retained." ) # This message is retained when temporary storage stops working. _DISK_MESSAGE = ( "Result log disk storage failed; additional entries may be missing." ) def __init__(self, memory_size: int = 0, disk_size: int = 0) -> None: """Set byte limits; a zero disk limit keeps memory unbounded.""" # Maximum memory shared by all captures in this notification call. self.memory_size = memory_size # Maximum temporary disk space shared by the same captures. self.disk_size = disk_size # Memory currently reserved by captured entries. self.memory_used = 0 # Disk space currently reserved by captured entries. self.disk_used = 0 # Stop further writes after the first disk error. self.disk_failed = False # Open the temporary file only when an entry spills to disk. self._disk: Optional[Any] = None # The final store to close releases the shared temporary file. self._store_count = 0 # Report each storage problem once per notification call. self._reported_warnings: set[str] = set() # Protect shared limits and disk access across service threads. self._lock = threading.RLock() def register_store(self) -> None: """Keep shared disk storage open for one capture store.""" with self._lock: # Every active store receives one ownership count. self._store_count += 1 def reserve_memory(self, size: int) -> bool: """Reserve memory for one entry before it spills to disk.""" with self._lock: # No disk allowance preserves the original all-memory behavior. if not self.disk_size: return True # Spill the complete entry rather than splitting it across stores. if self.memory_used + size > self.memory_size: return False # Reserve the entry's bytes before another thread can claim them. self.memory_used += size return True def claim_warning(self, message: str) -> bool: """Return true once for each storage warning in this notify call.""" with self._lock: # A warning already claimed by another capture is not repeated. if message in self._reported_warnings: return False # Remember the warning before its caller writes to the logger. self._reported_warnings.add(message) return True def write( self, payload: bytes, previous: Optional[int] ) -> tuple[Optional[int], Optional[str]]: """Write one entry and return its offset or a safe warning.""" # Disk accounting includes the record header and its log content. size = self._HEADER.size + len(payload) with self._lock: # A failed or full disk store cannot accept another record. if self.disk_failed or self.disk_used + size > self.disk_size: return ( None, self._DISK_MESSAGE if self.disk_failed else self._LIMIT_MESSAGE, ) # Reserve space first so parallel captures cannot exceed the cap. self.disk_used += size try: if self._disk is None: # Share one anonymous file across this notification. self._disk = tempfile.TemporaryFile( # noqa: SIM115 mode="w+b" ) # Append new content without disturbing earlier records. self._disk.seek(0, os.SEEK_END) # New records always begin at the current end of the file. offset = self._disk.tell() # New records begin as the final item in their capture. header = self._HEADER.pack(len(payload), self._END) # A short write means the record cannot be trusted later. if self._disk.write(header) != len(header): raise AppriseDiskIOError("short result log header write") # Write the complete log entry immediately after its header. if self._disk.write(payload) != len(payload): raise AppriseDiskIOError("short result log entry write") if previous is not None: # Link this capture's entries across the shared file. self._disk.seek(previous + self._LINK.size) # Point the prior record at the new record's location. link = self._LINK.pack(offset) if self._disk.write(link) != len(link): raise AppriseDiskIOError("short result log link write") # Make completed writes available to result readers. self._disk.flush() return offset, None except Exception as e: # Return unused space after a failed write. self.disk_used -= size if self.claim_warning(self._DISK_MESSAGE): # Keep the main warning concise and details at debug level. _safe_internal_log(logging.WARNING, self._DISK_MESSAGE) _safe_internal_log( logging.DEBUG, "Result log storage exception: %s", str(e), ) # Future entries skip disk instead of repeating the failure. self.disk_failed = True return None, self._DISK_MESSAGE def read(self, offset: int) -> tuple[bytes, int]: """Read and validate one serialized entry at an owned offset.""" with self._lock: # A closed result no longer has disk content to replay. if self._disk is None: raise AppriseDiskIOError("result log storage is closed") # Move directly to the record owned by this capture. self._disk.seek(offset) # Read the fixed-size details that describe the stored entry. header = self._disk.read(self._HEADER.size) # Reject partial records before decoding their content. if len(header) != self._HEADER.size: raise AppriseDiskIOError("truncated result log header") # Recover the content length and the next record location. size, next_offset = self._HEADER.unpack(header) # A record cannot be larger than the complete disk allowance. if size > self.disk_size: raise AppriseDiskIOError("invalid result log entry size") # Read only the number of bytes declared by this record. payload = self._disk.read(size) # Do not pass incomplete content to the JSON decoder. if len(payload) != size: raise AppriseDiskIOError("truncated result log entry") return payload, next_offset def release_store(self) -> None: """Close shared disk storage after its final capture is released.""" with self._lock: # This store no longer needs access to the shared file. self._store_count -= 1 # Another store still needs the file, or no file was opened. if self._store_count or self._disk is None: return try: # The last store owns final cleanup of temporary storage. self._disk.close() except Exception as e: # Report cleanup errors while still clearing the file below. _safe_internal_log( logging.WARNING, "Result log storage could not be closed.", ) _safe_internal_log( logging.DEBUG, "Result log close exception: %s", str(e), ) finally: # Never leave a closed or failed handle available for reuse. self._disk = None class _NotifyLogStore: """Keep captured entries in memory, spilling later entries to disk.""" # Reuse the shared message when the configured allowance is full. _LIMIT_MESSAGE = _LogCaptureBudget._LIMIT_MESSAGE # Reuse the shared message when temporary storage stops working. _DISK_MESSAGE = _LogCaptureBudget._DISK_MESSAGE # Return a safe entry when retained disk content cannot be replayed. _READ_MESSAGE = "Stored result logs could not be read." def __init__(self, budget: _LogCaptureBudget) -> None: """Create an empty store using the call's shared byte budget.""" # Register first so the shared file stays open for this store. self._budget = budget self._budget.register_store() # Memory always holds the oldest retained entries. self._memory: list[NotifyLogEntry] = [] # This is where the first disk-backed entry can be found. self._first_offset: Optional[int] = None # This is where the newest disk-backed entry can be found. self._last_offset: Optional[int] = None # Count the disk entries this store can read. self._disk_count = 0 # A final notice explains why later entries are missing. self._notice: Optional[NotifyLogEntry] = None # Closed stores ignore late entries and no longer read from disk. self._closed = False # Iteration and appends can occur from different worker threads. self._lock = threading.RLock() @staticmethod def _encode(entry: NotifyLogEntry) -> bytes: """Serialize an entry for length-prefixed disk storage.""" return entry.json().encode("utf-8") @staticmethod def _decode(payload: bytes) -> NotifyLogEntry: """Restore an entry read from disk.""" # Convert the stored JSON text back into its plain fields. data = json.loads(payload.decode("utf-8")) # Rebuild the same public entry type used by in-memory logs. return NotifyLogEntry( level=data["level"], message=data["message"], time=datetime.strptime(data["time"], AWARE_DATE_ISO_FORMAT), ) def append(self, entry: NotifyLogEntry) -> None: """Append an entry without allowing storage errors to escape.""" # Measure the encoded entry before choosing memory or disk. payload = self._encode(entry) # Include the small disk header in all shared byte accounting. size = self._budget._HEADER.size + len(payload) with self._lock: # Late messages cannot reopen a completed result. if self._closed: return # After data is lost, keep its warning as the final entry. if self._notice is not None: return # Once this store spills, keep every later entry behind it on disk. if self._first_offset is None and self._budget.reserve_memory( size ): # Fill memory before using slower disk storage. self._memory.append(entry) return # The last offset joins this entry to only this store's chain. offset, warning = self._budget.write(payload, self._last_offset) if offset is None: # Keep a readable explanation in place of discarded entries. self._set_notice( warning or self._DISK_MESSAGE, log=self._budget.claim_warning( warning or self._DISK_MESSAGE ), ) return if self._first_offset is None: # Remember where replay for this store begins. self._first_offset = offset # This entry is now the final item in this store's disk chain. self._last_offset = offset # Count only entries that completed their disk write. self._disk_count += 1 def _set_notice(self, message: str, log: bool = True) -> None: """Retain one bounded warning when entries must be discarded.""" if self._notice is None: # The notice becomes the final entry returned to the caller. self._notice = NotifyLogEntry("WARNING", message) if log: # Also tell the application when this warning is first owned. _safe_internal_log(logging.WARNING, message) def __iter__(self) -> Iterator[NotifyLogEntry]: """Yield retained entries in capture order.""" with self._lock: # Memory comes first because it contains the oldest entries. yield from self._memory if self._first_offset is not None: try: # Follow only this store's entries in the shared file. offset = self._first_offset for index in range(self._disk_count): # Read and restore one entry at its known location. payload, next_offset = self._budget.read(offset) yield self._decode(payload) if ( next_offset == self._budget._END and index + 1 < self._disk_count ): # The entry chain ended sooner than expected. raise AppriseDiskIOError( "truncated result log chain" ) # Continue from the location saved in this record. offset = next_offset except Exception as e: # Replace unreadable content with one useful warning. _safe_internal_log(logging.WARNING, self._READ_MESSAGE) _safe_internal_log( logging.DEBUG, "Result log read exception: %s", str(e), ) yield NotifyLogEntry("WARNING", self._READ_MESSAGE) if self._notice is not None: # This stays last because later entries were discarded. yield self._notice def __len__(self) -> int: """Return the number of retained entries and any storage warning.""" with self._lock: return ( len(self._memory) + self._disk_count + (self._notice is not None) ) def __getitem__( self, index: Union[int, slice] ) -> Union[NotifyLogEntry, list[NotifyLogEntry]]: """Return an entry or slice using normal sequence behavior.""" with self._lock: # Return simple in-memory indexes without reading saved entries. if isinstance(index, int) and 0 <= index < len(self._memory): # The lock keeps another thread from changing the list here. return self._memory[index] # Slices, negative indexes, and saved entries need the full list. # Iteration already joins memory, saved entries, and warning data. return list(self)[index] def __eq__(self, other: object) -> bool: """Compare retained entries with another iterable.""" if isinstance(other, _NotifyLogStore): return list(self) == list(other) if isinstance(other, (list, tuple)): return list(self) == list(other) return NotImplemented def close(self) -> None: """Release any temporary file owned by this store.""" with self._lock: if not self._closed: # Clear locations so a closed store cannot replay disk data. self._closed = True self._first_offset = None self._last_offset = None self._disk_count = 0 # Release this store's ownership of shared temporary storage. self._budget.release_store() def __del__(self) -> None: """Best-effort cleanup when callers do not close the result.""" with contextlib.suppress(Exception): # Interpreter shutdown may already have cleared module globals. self.close() class _ServiceLogCapture(logging.Handler): """Capture logs for one service or one notification call. The capture mode depends on ``service``: - A service captures entries from its current notification attempt. - ``service=None`` captures orchestration entries for the whole call. ``log_callback`` receives entries live and should return promptly. """ def __init__( self, service: Optional[NotifyBase] = None, log_callback: Optional[ Callable[[NotifyLogEntry, Optional[NotifyBase]], None] ] = None, level: int = logging.WARNING, memory_size: int = 0, disk_size: int = 0, ) -> None: """Prepare a service-level or call-level log handler.""" super().__init__(level=level) # A service capture can reuse the budget owned by its enclosing call. call_capture = _active_call_capture.get() # Standalone and call-level captures create their own shared budget. self._budget = ( call_capture._budget if service is not None and call_capture is not None else _LogCaptureBudget(memory_size, disk_size) ) # Each handler owns one ordered view into that shared budget. self._entries = _NotifyLogStore(self._budget) # Call-level orchestration messages use the shared logger. self._logger = getattr(service, "logger", logger) # None identifies a call-level entry to the callback. self._service = service # The optional callback receives entries as they are captured. self._log_callback = log_callback # This token restores an enclosing service capture on exit. self._token: Optional[contextvars.Token] = None # This token restores an enclosing call capture on exit. self._call_token: Optional[contextvars.Token] = None # Prevent a callback from logging recursively through this handler. self._in_emit = False def emit(self, record: logging.LogRecord) -> None: """Store a record owned by this capture.""" # Ignore logging triggered by this handler's own callback or warnings. if self._in_emit: return # Set the guard before formatting or invoking user callback code. self._in_emit = True try: self._emit(record) finally: # Always allow the next independent record through. self._in_emit = False def _emit(self, record: logging.LogRecord) -> None: """Process a record after the reentrancy check.""" try: if not getattr(record, "apprise_capture", True): # Keep sensitive local messages out of results and streams. return if self._service is not None: # Per-service: only the exact active capture accepts. if _active_capture.get() is not self: return else: # Accept only unclaimed records from this notification call. if ( _active_call_capture.get() is not self or _active_capture.get() is not None ): return entry = NotifyLogEntry( level=record.levelname, # Preserve normal logging behavior for formatted messages. # The outer handler catches malformed format strings. message=record.getMessage(), # Match the UTC-aware timestamps used by other results. time=datetime.fromtimestamp(record.created, tz=timezone.utc), ) # Retain the entry before notifying a live callback. A record # marked apprise_store=False is delivered live but not kept, # for a message the caller already stores somewhere itself. if getattr(record, "apprise_store", True): self._entries.append(entry) # Copy the reference in case application code later replaces it. callback = self._log_callback if callback is not None: self._invoke_log_callback(entry, callback) except Exception: # Use logging's normal error path for formatting or storage errors. self.handleError(record) def _invoke_log_callback( self, entry: NotifyLogEntry, callback: Callable[[NotifyLogEntry, Optional[NotifyBase]], None], ) -> None: """Call ``log_callback(entry, service)``. ``service`` is ``None`` for call-level entries. The return value is ignored. """ try: result = callback(entry, self._service) except Exception as e: _safe_internal_log( logging.WARNING, "The log_callback function raised an exception.", ) _safe_internal_log( logging.DEBUG, "log_callback Exception: %s", str(e), ) return if asyncio.iscoroutine(result): # Close unsupported async callbacks without leaking a warning. result.close() _safe_internal_log( logging.WARNING, "The log_callback function must be synchronous; its " "returned coroutine was ignored. Schedule async work " "from inside the callback instead.", ) @property def entries(self) -> _NotifyLogStore: """Return entries captured at or above this handler's level.""" return self._entries def __enter__(self) -> _ServiceLogCapture: """Attach the handler and mark its capture context.""" # Begin receiving records from the logger owned by this capture. self._logger.addHandler(self) if self._service is not None: # Mark this as the active capture for one service attempt. self._token = _active_capture.set(self) else: # Mark this as the active capture for the complete notify call. self._call_token = _active_call_capture.set(self) return self def __exit__(self, *_: object) -> None: """Detach the handler and restore the enclosing capture.""" # Stop receiving records before changing the active context. self._logger.removeHandler(self) if self._token is not None: # Restore any service capture that surrounded this one. _active_capture.reset(self._token) if self._call_token is not None: # Restore any call capture that surrounded this one. _active_call_capture.reset(self._call_token) class LogCapture: """A class used to allow one to instantiate loggers that write to memory for temporary purposes. e.g.: 1. with LogCapture() as captured: 2. 3. # Send our notification(s) 4. aobj.notify("hello world") 5. 6. # retrieve our logs produced by the above call via our 7. # `captured` StringIO object we have access to within the `with` 8. # block here: 9. print(captured.getvalue()) """ def __init__( self, path=None, level=None, name=LOGGER_NAME, delete=True, fmt="%(asctime)s - %(levelname)s - %(message)s", ): """Instantiate a temporary log capture object. If a path is specified, then log content is sent to that file instead of a StringIO object. You can optionally specify a logging level such as logging.INFO if you wish, otherwise by default the script uses whatever logging has been set globally. If you set delete to `False` then when using log files, they are not automatically cleaned up afterwards. Optionally over-ride the fmt as well if you wish. """ # Our memory buffer placeholder self.__buffer_ptr = StringIO() # Store our file path as it will determine whether or not we write to # memory and a file self.__path = path self.__delete = delete # Our logging level tracking self.__level = level self.__restore_level = None # Acquire a pointer to our logger self.__logger = logging.getLogger(name) # Prepare our handler self.__handler = ( logging.StreamHandler(self.__buffer_ptr) if not self.__path else logging.FileHandler(self.__path, mode="a", encoding="utf-8") ) # Use the specified level, otherwise take on the already # effective level of our logger self.__handler.setLevel( self.__level if self.__level is not None else self.__logger.getEffectiveLevel() ) # Prepare our formatter self.__handler.setFormatter(logging.Formatter(fmt)) def __enter__(self): """Allows logger manipulation within a 'with' block.""" if self.__level is not None: # Temporary adjust our log level if required self.__restore_level = self.__logger.getEffectiveLevel() if self.__restore_level > self.__level: # Bump our log level up for the duration of our `with` self.__logger.setLevel(self.__level) else: # No restoration required self.__restore_level = None else: # Do nothing but enforce that we have nothing to restore to self.__restore_level = None if self.__path: # If a path has been identified, ensure we can write to the path # and that the file exists with open(self.__path, "a"): os.utime(self.__path, None) # Update our buffer pointer self.__buffer_ptr = open(self.__path) # Add our handler self.__logger.addHandler(self.__handler) # return our memory pointer return self.__buffer_ptr def __exit__(self, exc_type, exc_value, tb): """Removes the handler gracefully when the with block has completed.""" # Flush our content self.__handler.flush() self.__buffer_ptr.flush() # Drop our handler self.__logger.removeHandler(self.__handler) if self.__restore_level is not None: # Restore level self.__logger.setLevel(self.__restore_level) if self.__path: # Close our file pointer self.__buffer_ptr.close() self.__handler.close() if self.__delete: with contextlib.suppress(OSError): # Always remove file afterwards os.unlink(self.__path) return exc_type is None caronc-apprise-dfec414/apprise/manager.py000066400000000000000000001040571526720371000205550ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import contextlib import hashlib import inspect import os from os.path import abspath, dirname, join import re import sys import threading import time from .logger import logger from .utils.disk import path_decode from .utils.module import import_module from .utils.parse import parse_list from .utils.singleton import Singleton class PluginManager(metaclass=Singleton): """Designed to be a singleton object to maintain all initialized loading of modules in memory.""" # Description (used for logging) name = "Singleton Plugin" # Memory Space _id = "undefined" # Our Module Python path name module_name_prefix = f"apprise.{_id}" # The module path to scan module_path = join(abspath(dirname(__file__)), _id) # For filtering our result when scanning a module module_filter_re = re.compile(r"^(?P((?!_)[A-Za-z0-9]+))$") # thread safe loading _lock = threading.Lock() def __init__(self, *args, **kwargs): """Over-ride our class instantiation to provide a singleton.""" self._module_map = None self._schema_map = None # This contains a mapping of all plugins dynamicaly loaded at runtime # from external modules such as the @notify decorator # # The elements here will be additionally added to the _schema_map if # there is no conflict otherwise. # The structure looks like the following: # Module path, e.g. /usr/share/apprise/plugins/my_notify_hook.py # { # 'path': path, # # 'notify': { # 'schema': { # 'name': 'Custom schema name', # 'fn_name': 'name_of_function_decorator_was_found_on', # 'url': 'schema://any/additional/info/found/on/url' # 'plugin': # }, # 'schema2': { # 'name': 'Custom schema name', # 'fn_name': 'name_of_function_decorator_was_found_on', # 'url': 'schema://any/additional/info/found/on/url' # 'plugin': # } # } # Note: that the inherits from # NotifyBase self._custom_module_map = {} # Track manually disabled modules (by their schema) self._disabled = set() # Reference counter for optional runtime libraries declared via # runtime_deps(). Maps top-level package name -> count of currently # *enabled* plugins that depend on it. When a plugin is disabled and # its counter reaches zero the library *may* be evicted from # sys.modules - but only when evict_on_disable is True. self._dep_counter = {} # Controls whether libraries are evicted from sys.modules when their # dep counter reaches zero. Defaults to False so that third-party # projects embedding Apprise are not surprised by modules disappearing. # The Apprise API sets this to True at startup to reclaim memory from # optional libraries whose plugins are all disabled. self.evict_on_disable = False # Hash of all paths previously scanned so we don't waste # effort/overhead doing it again self._paths_previously_scanned = set() # Track loaded module paths to prevent from loading them again self._loaded = set() def unload_modules(self, disable_native=False): """Reset our object and unload all modules.""" with self._lock: if self._custom_module_map: # Handle Custom Module Assignments for meta in self._custom_module_map.values(): if meta["name"] not in self._module_map: # Nothing to remove continue # For the purpose of tidying up un-used modules in memory loaded = [ m for m in sys.modules if m.startswith(self._module_map[meta["name"]]["path"]) ] for module_path in loaded: del sys.modules[module_path] # Reset disabled plugins (if any) for schema in self._disabled: self._schema_map[schema].enabled = True self._disabled.clear() # Reset the library dependency counter (evict_on_disable is an # intentional configuration choice and is NOT reset here) self._dep_counter = {} # Reset our variables self._schema_map = {} self._custom_module_map = {} if disable_native: self._module_map = {} else: self._module_map = None self._loaded = set() # Reset our path cache self._paths_previously_scanned = set() def load_modules(self, path=None, name=None, force=False): """Load our modules into memory.""" # Default value module_name_prefix = self.module_name_prefix if name is None else name module_path = self.module_path if path is None else path with self._lock: if not force and module_path in self._loaded: # We're done return # Our base reference module_count = len(self._module_map) if self._module_map else 0 schema_count = len(self._schema_map) if self._schema_map else 0 if not self: # Initialize our maps self._module_map = {} self._schema_map = {} self._custom_module_map = {} # Used for the detection of additional Notify Services objects # The .py extension is optional as we support loading directories # too module_re = re.compile( r"^(?P(?!base|_)[a-z0-9_]+)(\.py)?$", re.I ) t_start = time.time() for f in os.listdir(module_path): tl_start = time.time() match = module_re.match(f) if not match: # keep going continue # Store our notification/plugin name: module_name = match.group("name") module_pyname = f"{module_name_prefix}.{module_name}" if module_name in self._module_map: logger.warning( "%s(s) (%s) already loaded; ignoring %s", self.name, module_name, os.path.join(module_path, f), ) continue try: module = __import__( module_pyname, globals(), locals(), fromlist=[module_name], ) except ImportError: # No problem, we can try again another way... module = import_module( os.path.join(module_path, f), module_pyname ) if not module: # logging found in import_module and not needed here continue module_class = None for m_class in [ obj for obj in dir(module) if self.module_filter_re.match(obj) ]: # Get our plugin plugin = getattr(module, m_class) if not hasattr(plugin, "app_id"): # Filter out non-notification modules logger.trace( "(%s.%s) import failed; no app_id defined in %s", self.name, m_class, os.path.join(module_path, f), ) continue # Add our plugin name to our module map self._module_map[module_name] = { "plugin": {plugin}, "module": module, "path": f"{module_name_prefix}.{module_name}", "native": True, } fn = getattr(plugin, "schemas", None) schemas = set() if not callable(fn) else fn(plugin) # map our schema to our plugin for schema in schemas: if schema in self._schema_map: logger.error( f"{self.name} schema ({schema}) mismatch" " detected -" f" {self._schema_map[schema]} already maps to" f" {plugin}" ) continue # Assign plugin self._schema_map[schema] = plugin # Store our class module_class = m_class break if not module_class: # Not a library we can load as it doesn't follow the simple # rule that the class must bear the same name as the # notification file itself. logger.trace( "%s (%s) import failed; no filename/Class " "match found in %s", self.name, module_name, os.path.join(module_path, f), ) continue logger.trace( f"{self.name} {module_name} loaded" f" in {time.time() - tl_start:.6f}s" ) # Track the directory loaded so we never load it again self._loaded.add(module_path) logger.debug( f"{len(self._module_map) - module_count} {self.name}(s) and" f" {len(self._schema_map) - schema_count} Schema(s) loaded in" f" {time.time() - t_start:.4f}s" ) # Build the runtime dependency reference counter so that # disable() can evict libraries when their last user is # disabled. This is done here (inside the lock) by iterating # _module_map directly to avoid a recursive lock acquisition. self._build_dep_counter() def module_detection(self, paths, cache=True): """Leverage the @notify decorator and load all objects found matching this.""" # A simple restriction that we don't allow periods in the filename at # all so it can't be hidden (Linux OS's) and it won't conflict with # Python path naming. This also prevents us from loading any python # file that starts with an underscore or dash # We allow for __init__.py as well module_re = re.compile( r"^(?P[_a-z0-9][a-z0-9._-]+)?(\.py)?$", re.I ) # Validate if we're a loadable Python file or not valid_python_file_re = re.compile(r".+\.py(o|c)?$", re.IGNORECASE) if isinstance(paths, str): paths = [ paths, ] if not paths or not isinstance(paths, (tuple, list)): # We're done return def _import_module(path): # Since our plugin name can conflict (as a module) with another # we want to generate random strings to avoid steping on # another's namespace if not (path and valid_python_file_re.match(path)): # Ignore file/module type logger.trace("Plugin Scan: Skipping %s", path) return t_start = time.time() module_name = hashlib.sha1(path.encode("utf-8")).hexdigest() module_pyname = "{prefix}.{name}".format( prefix="apprise.custom.module", name=module_name ) if module_pyname in self._custom_module_map: # First clear out existing entries for schema in self._custom_module_map[module_pyname]["notify"]: # Remove any mapped modules to this file del self._schema_map[schema] # Reset del self._custom_module_map[module_pyname] # Load our module module = import_module(path, module_pyname) if not module: # No problem, we can't use this object logger.warning("Failed to load custom module: %s", path_) return # Print our loaded modules if any if module_pyname in self._custom_module_map: logger.debug( "Custom module %s - %d schema(s)" " (name=%s) loaded in %.6fs", path_, len(self._custom_module_map[module_pyname]["notify"]), module_name, (time.time() - t_start), ) # Add our plugin name to our module map self._module_map[module_name] = { "plugin": set(), "module": module, "path": module_pyname, "native": False, } for schema, _meta in self._custom_module_map[module_pyname][ "notify" ].items(): # For mapping purposes; map our element in our main list self._module_map[module_name]["plugin"].add( self._schema_map[schema] ) # Log our success logger.info("Loaded custom notification: %s://", schema) else: # The code reaches here if we successfully loaded the Python # module but no hooks/triggers were found. So we can safely # just remove/ignore this entry del sys.modules[module_pyname] return # end of _import_module() return for path_ in paths: path = path_decode(path_) if ( cache and path in self._paths_previously_scanned ) or not os.path.exists(path): # We're done as we've already scanned this continue # Store our path as a way of hashing it has been handled self._paths_previously_scanned.add(path) if os.path.isdir(path) and not os.path.isfile( os.path.join(path, "__init__.py") ): logger.debug("Scanning for custom plugins in: %s", path) for entry in os.listdir(path): re_match = module_re.match(entry) if not re_match: # keep going logger.trace("Plugin Scan: Ignoring %s", entry) continue new_path = os.path.join(path, entry) if os.path.isdir(new_path): # Update our path new_path = os.path.join(path, entry, "__init__.py") if not os.path.isfile(new_path): logger.trace( "Plugin Scan: Ignoring %s", os.path.join(path, entry), ) continue if not cache or ( new_path not in self._paths_previously_scanned ): # Load our module _import_module(new_path) # Add our subdir path self._paths_previously_scanned.add(new_path) else: if os.path.isdir(path): # This logic is safe to apply because we already # validated the directories state above; update our # path path = os.path.join(path, "__init__.py") if cache and path in self._paths_previously_scanned: continue self._paths_previously_scanned.add(path) # directly load as is re_match = module_re.match(os.path.basename(path)) # must be a match and must have a .py extension if not re_match or not re_match.group(1): # keep going logger.trace("Plugin Scan: Ignoring %s", path) continue # Load our module _import_module(path) return None def add(self, plugin, schemas=None, url=None, send_func=None, force=False): """Ability to manually add Notification services to our stack.""" if not self: # Lazy load self.load_modules() # Acquire a list of schemas p_schemas = parse_list(plugin.secure_protocol, plugin.protocol) if isinstance(schemas, str): schemas = [ schemas, ] elif schemas is None: # Default schemas = p_schemas if not schemas or not isinstance(schemas, (set, tuple, list)): # We're done logger.error( "The schemas provided (type %s) is" " unsupported; loaded from %s.", type(schemas), send_func.__name__ if send_func else plugin.__class__.__name__, ) return False # Convert our schemas into a set schemas = {s.lower() for s in schemas} | set(p_schemas) # Valdation conflict = [s for s in schemas if s in self] if conflict: if force: # Force implies that we unmap any conflicting schema entries # at the Apprise level, but we do not unload any previously # imported modules. This ensures other classes can safely # subclass from prior notify classes. logger.debug( "The schema(s) (%s) are already defined and will be " "force loaded; overriding %s%s.", ", ".join(conflict), "custom notify function " if send_func else "", send_func.__name__ if send_func else plugin.__class__.__name__, ) self.remove(*conflict, unload=False) else: logger.warning( "The schema(s) (%s) are already defined and could not be " "loaded from %s%s.", ", ".join(conflict), "custom notify function " if send_func else "", send_func.__name__ if send_func else plugin.__class__.__name__, ) return False # Re-check for conflicts after unmapping conflict = [s for s in schemas if s in self] if conflict: logger.warning( "The schema(s) (%s) are already defined and could not be " "loaded from %s%s.", ", ".join(conflict), "custom notify function " if send_func else "", send_func.__name__ if send_func else plugin.__class__.__name__, ) return False if send_func: # Acquire the function name fn_name = send_func.__name__ # Acquire the python filename path path = inspect.getfile(send_func) # Acquire our path to our module module_name = str(send_func.__module__) if module_name not in self._custom_module_map: # Support non-dynamic includes as well... self._custom_module_map[module_name] = { # Name can be useful for indexing back into the # _module_map object; this is the key to do it with: "name": module_name.split(".")[-1], # The path to the module loaded "path": path, # Initialize our template "notify": {}, } for schema in schemas: self._custom_module_map[module_name]["notify"][schema] = { # The name of the send function the @notify decorator # wrapped "fn_name": fn_name, # The URL that was provided in the @notify decorator call # associated with the 'on=' "url": url, } else: module_name = hashlib.sha1( "".join(schemas).encode("utf-8") ).hexdigest() module_pyname = "{prefix}.{name}".format( prefix="apprise.adhoc.module", name=module_name ) # Add our plugin name to our module map self._module_map[module_name] = { "plugin": {plugin}, "module": None, "path": module_pyname, "native": False, } for schema in schemas: # Assign our mapping self._schema_map[schema] = plugin return True def remove(self, *schemas, unload=True): """Removes a loaded element (if defined)""" if not self: # Lazy load self.load_modules() for schema in schemas: with contextlib.suppress(KeyError): self._unmap_schema(schema, unload=unload) def plugins(self, include_disabled=True): """Return all of our loaded plugins.""" if not self: # Lazy load self.load_modules() for module in self._module_map.values(): for plugin in module["plugin"]: if not include_disabled and not plugin.enabled: continue yield plugin def schemas(self, include_disabled=True): """Return all of our loaded schemas. if include_disabled == True, then even disabled notifications are returned """ if not self: # Lazy load self.load_modules() # Return our list return ( list(self._schema_map.keys()) if include_disabled else [s for s in self._schema_map if self._schema_map[s].enabled] ) def _build_dep_counter(self): """Build the runtime library reference counter from loaded plugins. Iterates `_module_map` directly (rather than calling `self.plugins()`) so it is safe to call while `_lock` is held inside `load_modules`. Only enabled plugins contribute to the counter - plugins already disabled because their optional library is not installed are not counted, as there is nothing in memory to evict for them. """ self._dep_counter = {} if not self._module_map: return for module in self._module_map.values(): for plugin in module["plugin"]: # Guard: attachment and other non-notify plugin types do not # carry `enabled` or `runtime_deps`; skip them safely. if not getattr(plugin, "enabled", False): continue fn = getattr(plugin, "runtime_deps", None) if not callable(fn): continue for lib in fn(): self._dep_counter[lib] = self._dep_counter.get(lib, 0) + 1 def _evict_library(self, lib_name): """Remove a library and all its submodules from `sys.modules`. Called when `evict_on_disable` is `True` and the reference counter for *lib_name* reaches zero - meaning no enabled plugin requires it. """ to_remove = [ k for k in sys.modules if k == lib_name or k.startswith(lib_name + ".") ] evicted = 0 for key in to_remove: try: del sys.modules[key] evicted += 1 except KeyError: logger.trace("Eviction skipped: '%s' not in sys.modules", key) if evicted: logger.debug( "Evicted %d module(s) for '%s' from memory", evicted, lib_name, ) elif to_remove: # Keys were found but all raised KeyError (race condition) logger.trace( "Eviction of '%s' found %d candidate(s) but removed none", lib_name, len(to_remove), ) def _update_dep_counter(self, plugin, delta): """Increment or decrement dep counters for *plugin* by *delta* (+1/-1). Evicts libraries from memory when `evict_on_disable` is `True` and the counter reaches zero. """ fn = getattr(plugin, "runtime_deps", None) if not callable(fn): return for lib in fn(): count = self._dep_counter.get(lib, 0) + delta self._dep_counter[lib] = max(0, count) logger.trace( "Dep counter '%s': %d -> %d", lib, count - delta, self._dep_counter[lib], ) if self.evict_on_disable and count <= 0: self._evict_library(lib) def disable(self, *schemas): """Disables the modules associated with the specified schemas.""" if not self: # Lazy load self.load_modules() for schema in schemas: if schema not in self._schema_map: continue plugin = self._schema_map[schema] if not plugin.enabled: continue # Disable via the plugin classmethod so subclasses can hook in plugin.disable() self._disabled.add(schema) logger.debug("Disabled %s plugin (%s://)", self.name, schema) # Decrement dep counters; evict if evict_on_disable and zero self._update_dep_counter(plugin, -1) def enable_only(self, *schemas): """Disables the modules associated with the specified schemas.""" if not self: # Lazy load self.load_modules() # convert to set for faster indexing schemas = set(schemas) for plugin in self.plugins(): # Get our plugin's schema list p_schemas = set( parse_list(plugin.secure_protocol, plugin.protocol) ) if not schemas & p_schemas: if plugin.enabled: # Disable it (only if previously enabled); this prevents us # from adjusting schemas that were disabled due to missing # libraries or other environment reasons plugin.disable() self._disabled |= p_schemas logger.debug( "Disabled %s plugin (%s)", self.name, ", ".join(f"{s}://" for s in p_schemas), ) # Decrement dep counters; evict at zero if evict_on_disable self._update_dep_counter(plugin, -1) continue # If we reach here, our schema was flagged to be enabled if p_schemas & self._disabled: # Previously disabled; no worries, let's clear this up self._disabled -= p_schemas plugin.enable() logger.debug( "Enabled %s plugin (%s)", self.name, ", ".join(f"{s}://" for s in p_schemas), ) # Increment dep counters for the re-enabled plugin self._update_dep_counter(plugin, +1) def __contains__(self, schema): """Checks if a schema exists.""" if not self: # Lazy load self.load_modules() return schema in self._schema_map def __delitem__(self, schema): """ removes schema map and also unloads it from memory """ self._unmap_schema(schema, unload=True) def __setitem__(self, schema, plugin): """Support fast assigning of Plugin/Notification Objects.""" if not self: # Lazy load self.load_modules() # Set default values if not otherwise set if not plugin.service_name: # Assign service name if one doesn't exist plugin.service_name = f"{schema}://" p_schemas = set(parse_list(plugin.secure_protocol, plugin.protocol)) if not p_schemas: # Assign our protocol plugin.secure_protocol = schema p_schemas.add(schema) elif schema not in p_schemas: # Add our others (if defined) plugin.secure_protocol = { schema, *parse_list(plugin.secure_protocol), } p_schemas.add(schema) if not self.add(plugin, schemas=p_schemas): raise KeyError("Conflicting Assignment") def _unmap_schema(self, schema, *, unload=True): """Unmap a schema entry without necessarily unloading modules. This function removes the schema mapping and updates internal cross references. When unload is True (default), modules are removed from sys.modules when they are no longer referenced by Apprise. When unload is False, the unmapping is performed but any imported modules remain intact in sys.modules. """ if not self: # Lazy load self.load_modules() # Get our plugin (otherwise we throw a KeyError) which is intended on # unmap action that doesn't align. plugin = self._schema_map[schema] # Our list of all schema entries p_schemas = {schema} for key in list(self._module_map.keys()): if plugin in self._module_map[key]["plugin"]: # Remove our plugin self._module_map[key]["plugin"].remove(plugin) # Custom Plugin Entry; Clean up cross reference module_pyname = self._module_map[key]["path"] if ( not self._module_map[key]["native"] and module_pyname in self._custom_module_map ): notify = self._custom_module_map[module_pyname]["notify"] del notify[schema] if not self._custom_module_map[module_pyname]["notify"]: # # Last custom loaded element # # Free up custom object entry del self._custom_module_map[module_pyname] if not self._module_map[key]["plugin"]: # # Last element # if self._module_map[key]["native"]: # Get our plugin's schema list p_schemas = { s for s in parse_list( plugin.secure_protocol, plugin.protocol ) if s in self._schema_map } # Free system memory only when unload=True if unload and self._module_map[key]["module"]: with contextlib.suppress(KeyError): del sys.modules[self._module_map[key]["path"]] # Free last remaining pointer in module map del self._module_map[key] for schema in p_schemas: # Final tidy del self._schema_map[schema] def __getitem__(self, schema): """Returns the indexed plugin identified by the schema specified.""" if not self: # Lazy load self.load_modules() return self._schema_map[schema] def __iter__(self): """Returns an iterator so we can iterate over our loaded modules.""" if not self: # Lazy load self.load_modules() return iter(self._module_map.values()) def __len__(self): """Returns the number of modules/plugins loaded.""" if not self: # Lazy load self.load_modules() return len(self._module_map) def __bool__(self): """Determines if object has loaded or not.""" return bool(self._loaded and self._module_map is not None) caronc-apprise-dfec414/apprise/manager_attachment.py000066400000000000000000000041261526720371000227610ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from os.path import abspath, dirname, join import re from .manager import PluginManager class AttachmentManager(PluginManager): """Designed to be a singleton object to maintain all initialized attachment plugins/modules in memory.""" # Description (used for logging) name = "Attachment Plugin" # Filename Prefix to filter on fname_prefix = "Attach" # Memory Space _id = "attachment" # Our Module Python path name module_name_prefix = f"apprise.{_id}" # The module path to scan module_path = join(abspath(dirname(__file__)), _id) # For filtering our result set module_filter_re = re.compile( r"^(?P" + fname_prefix + r"(?!Base)[A-Za-z0-9]+)$" ) caronc-apprise-dfec414/apprise/manager_config.py000066400000000000000000000041421526720371000220740ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from os.path import abspath, dirname, join import re from .manager import PluginManager class ConfigurationManager(PluginManager): """Designed to be a singleton object to maintain all initialized configuration plugins/modules in memory.""" # Description (used for logging) name = "Configuration Plugin" # Filename Prefix to filter on fname_prefix = "Config" # Memory Space _id = "config" # Our Module Python path name module_name_prefix = f"apprise.{_id}" # The module path to scan module_path = join(abspath(dirname(__file__)), _id) # For filtering our result set module_filter_re = re.compile( r"^(?P" + fname_prefix + r"(?!Base|Format)[A-Za-z0-9]+)$" ) caronc-apprise-dfec414/apprise/manager_plugins.py000066400000000000000000000041601526720371000223100ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from os.path import abspath, dirname, join import re from .manager import PluginManager class NotificationManager(PluginManager): """Designed to be a singleton object to maintain all initialized notifications in memory.""" # Description (used for logging) name = "Notification Plugin" # Filename Prefix to filter on fname_prefix = "Notify" # Memory Space _id = "plugins" # Our Module Python path name module_name_prefix = f"apprise.{_id}" # The module path to scan module_path = join(abspath(dirname(__file__)), _id) # For filtering our result set module_filter_re = re.compile( r"^(?P" + fname_prefix + r"(?!Base|Format|ImageSize|Type)[A-Za-z0-9]+)$" ) caronc-apprise-dfec414/apprise/persistent_store.py000066400000000000000000001673601526720371000225650ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import base64 import binascii import builtins from collections.abc import Sequence, Set as AbstractSet import contextlib from datetime import datetime, timedelta, timezone import glob import gzip import hashlib from itertools import chain import json import os import re import tempfile import time from typing import Any, Optional, Union import zlib from . import exception from .common import ( AWARE_DATE_ISO_FORMAT, JSON_COMPACT_SEPARATORS, NAIVE_DATE_ISO_FORMAT, PersistentStoreMode, ) from .exception import AppriseImproperlyConfigured from .logger import logger from .utils.disk import path_decode # Used for writing/reading time stored in cache file EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) def _ntf_tidy(ntf): """Reusable NamedTemporaryFile Cleanup.""" if ntf: # Cleanup with contextlib.suppress(OSError): ntf.close() try: os.unlink(ntf.name) logger.trace("Persistent temporary file removed: %s", ntf.name) except (FileNotFoundError, AttributeError): # AttributeError: something weird was passed in, no action required # FileNotFound: no worries; we were removing it anyway pass except OSError as e: logger.error( "Persistent temporary file removal failed: %s", ntf.name ) logger.debug("Persistent Storage Exception: %s", str(e)) class CacheObject: hash_engine = hashlib.sha256 hash_length = 6 def __init__( self, value: Any = None, expires: Union[bool, float, int, datetime, None] = False, persistent: bool = True, ) -> None: """Tracks our objects and associates a time limit with them.""" self.__value = value self.__class_name = value.__class__.__name__ self.__expires = None if expires: self.set_expiry(expires) # Whether or not we persist this object to disk or not self.__persistent = bool(persistent) def set( self, value: Any, expires: Union[bool, float, int, datetime, None] = None, persistent: Optional[bool] = None, ) -> None: """Replace the value and optionally update its metadata. Existing expiry and persistence settings remain when their arguments are ``None``. """ self.__value = value self.__class_name = value.__class__.__name__ if expires is not None: self.set_expiry(expires) if persistent is not None: self.__persistent = bool(persistent) def set_expiry( self, expires: Union[datetime, bool, float, int, None] = None ) -> None: """Sets a new expiry.""" if isinstance(expires, datetime): self.__expires = expires.astimezone(timezone.utc) elif expires in (None, False): # Accepted - no expiry self.__expires = None elif expires is True: # Force expiry to now self.__expires = datetime.now(tz=timezone.utc) elif isinstance(expires, (float, int)): self.__expires = datetime.now(tz=timezone.utc) + timedelta( seconds=expires ) else: # Unsupported raise AppriseImproperlyConfigured( f"An invalid expiry time ({expires} was specified" ) def hash(self) -> str: """Our checksum to track the validity of our data.""" return self.hash_engine( str(self).encode("utf-8"), usedforsecurity=False ).hexdigest() def json(self) -> Optional[dict[str, Any]]: """Returns our preparable json object.""" return { "v": self.__value, "x": ( (self.__expires - EPOCH).total_seconds() if self.__expires else None ), "c": ( self.__class_name if not isinstance(self.__value, datetime) else ( "aware_datetime" if self.__value.tzinfo else "naive_datetime" ) ), "!": self.hash()[: self.hash_length], } @staticmethod def instantiate( content: dict[str, Any], persistent: bool = True, verify: bool = True, ) -> Optional["CacheObject"]: """Loads back data read in and returns a CacheObject or None if it could not be loaded. You can pass in the contents of CacheObject.json() and you'll receive a copy assuming the hash checks okay """ try: value = content["v"] expires = content["x"] if expires is not None: expires = datetime.fromtimestamp(expires, timezone.utc) # Acquire some useful integrity objects class_name = content.get("c", "") if not isinstance(class_name, str): raise AppriseImproperlyConfigured( "Class name not expected string" ) hashsum = content.get("!", "") if not isinstance(hashsum, str): raise AppriseImproperlyConfigured( "SHA1SUM not expected string" ) except (TypeError, KeyError) as e: logger.trace(f"CacheObject could not be parsed from {content}") logger.trace("CacheObject exception: %s", str(e)) return None if class_name in ("aware_datetime", "naive_datetime", "datetime"): # If datetime is detected, it will fall under the naive category iso_format = ( AWARE_DATE_ISO_FORMAT if class_name[0] == "a" else NAIVE_DATE_ISO_FORMAT ) try: # Python v3.6 Support value = datetime.strptime(value, iso_format) except (TypeError, ValueError): # TypeError is thrown if content is not string # ValueError is thrown if the string is not a valid format logger.trace( f"CacheObject (dt) corrupted loading from {content}" ) return None elif class_name == "bytes": try: # Convert our object back to a bytes value = base64.b64decode(value) except binascii.Error: logger.trace( f"CacheObject (bin) corrupted loading from {content}" ) return None # Initialize our object co = CacheObject(value, expires, persistent=persistent) if verify and co.hash()[: co.hash_length] != hashsum: # Our object was tampered with logger.debug(f"Tampering detected with cache entry {co}") del co return None return co @property def value(self) -> Any: """Returns our value.""" return self.__value @property def persistent(self) -> bool: """Returns our persistent value.""" return self.__persistent @property def expires(self) -> Optional[datetime]: """Returns the datetime the object will expire.""" return self.__expires @property def expires_sec(self) -> Optional[float]: """Returns the number of seconds from now the object will expire.""" return ( None if self.__expires is None else max( 0.0, ( self.__expires - datetime.now(tz=timezone.utc) ).total_seconds(), ) ) def __bool__(self) -> bool: """Returns True it the object hasn't expired, and False if it has.""" if self.__expires is None: # No Expiry return True # Calculate if we've expired or not return self.__expires > datetime.now(tz=timezone.utc) def __eq__(self, other) -> bool: """Handles equality == flag.""" if isinstance(other, CacheObject): return str(self) == str(other) return self.__value == other def __str__(self) -> str: """String output of our data.""" persistent = "+" if self.persistent else "-" return f"{self.__class_name}:{persistent}:{self.__value} expires: " + ( "never" if self.__expires is None else self.__expires.strftime(NAIVE_DATE_ISO_FORMAT) ) class CacheJSONEncoder(json.JSONEncoder): """A JSON Encoder for handling each of our cache objects.""" def default(self, entry): if isinstance(entry, datetime): return entry.strftime( AWARE_DATE_ISO_FORMAT if entry.tzinfo is not None else NAIVE_DATE_ISO_FORMAT ) elif isinstance(entry, CacheObject): return entry.json() elif isinstance(entry, bytes): return base64.b64encode(entry).decode("utf-8") return super().default(entry) class PersistentStore: """An object to make working with persistent storage easier. read() and write() are used for direct file i/o set(), get() are used for caching """ # The maximum file-size we will allow the persistent store to grow to # 1 MB = 1048576 bytes max_file_size = 1048576 # 30 days in seconds default_file_expiry = 2678400 # File encoding to use encoding = "utf-8" # Default data set base_key = "default" # Directory to store cache __cache_key = "cache" # Our Temporary working directory temp_dir = "tmp" # The directory our persistent store content gets placed in data_dir = "var" # Our Persistent Store File Extension __extension = ".psdata" # Identify our backup file extension __backup_extension = "._psbak" # Used to verify the key specified is valid # - must start with an alpha_numeric # - following optional characters can include period, underscore and # equal __valid_key = re.compile(r"[a-z0-9][a-z0-9._-]*", re.I) # Reference only __not_found_ref = (None, None) def __init__( self, path: Optional[str] = None, namespace: str = "default", mode: Optional[Union[str, PersistentStoreMode]] = None, ) -> None: """Provide the namespace to work within. namespaces can only contain alpha-numeric characters with the exception of '-' (dash), '_' (underscore), and '.' (period). The namespace must be be relative to the current URL being controlled. """ # Initalize our mode so __del__() calls don't go bad on the # error checking below self.__mode = None # Populated only once and after size() is called self.__exclude_list = None # Files to renew on calls to flush self.__renew = set() if not isinstance(namespace, str) or not self.__valid_key.fullmatch( namespace ): raise AppriseImproperlyConfigured( f"Persistent Storage namespace ({namespace}) provided is" " invalid" ) if isinstance(path, str): # A storage path has been defined if mode is None: # Store Default if no mode was provided along side of it mode = PersistentStoreMode.AUTO # Store our information self.__base_path = os.path.join(path_decode(path), namespace) self.__temp_path = os.path.join(self.__base_path, self.temp_dir) self.__data_path = os.path.join(self.__base_path, self.data_dir) else: # If no storage path is provide we set our mode to MEMORY mode = PersistentStoreMode.MEMORY self.__base_path = None self.__temp_path = None self.__data_path = None # Tracks when we have content to flush self.__dirty = False # A caching value to track persistent storage disk size self.__cache_size = None self.__cache_files = {} # Internal Cache self._cache = None try: # Store our mode self.__mode = ( mode if isinstance(mode, PersistentStoreMode) else PersistentStoreMode(mode.lower()) ) except (AttributeError, ValueError): err = f"An invalid persistent storage mode ({mode}) was specified." logger.warning(err) raise AppriseImproperlyConfigured(err) from None # Prepare our environment self.__prepare() def read( self, key: Optional[str] = None, compress: bool = True, expires: Union[bool, float, int] = False, ) -> Optional[bytes]: """Read stored content as bytes. With ``expires=False``, the entry is marked for renewal so pruning does not remove it. """ # Use the default key so renewal targets the file actually read. if key is None: key = self.base_key try: with self.open(key, mode="rb", compress=compress) as fd: results = fd.read(self.max_file_size) if expires is False: self.__renew.add( os.path.join( self.__data_path, f"{key}{self.__extension}" ) ) return results except (FileNotFoundError, exception.AppriseDiskIOError): # FileNotFoundError: No problem # exception.AppriseDiskIOError: # - Logging of error already occurred inside self.open() pass except (OSError, zlib.error, EOFError, UnicodeDecodeError) as e: # We can't access the file or it does not exist logger.warning("Could not read with persistent key: %s", key) logger.debug("Persistent Storage Exception: %s", str(e)) # return none return None def write( self, data: Union[bytes, str, Any], key: Optional[str] = None, compress: bool = True, _recovery: bool = False, ) -> bool: """Write content when it fits within the file-size limit. Content is stored as bytes. ``_recovery`` is for internal use. """ if key is None: key = self.base_key elif not isinstance(key, str) or not self.__valid_key.fullmatch(key): raise AppriseImproperlyConfigured( f"Persistent Storage key ({key} provided is invalid" ) if not isinstance(data, (bytes, str)): # Accept readable objects when they return bytes or text. if not (hasattr(data, "read") and callable(data.read)): raise AppriseImproperlyConfigured( f"Invalid data type {type(data)} provided to Persistent" " Storage" ) try: # Read in our data data = data.read() if not isinstance(data, (bytes, str)): raise AppriseImproperlyConfigured( f"Invalid data type {type(data)} provided to" " Persistent Storage" ) except Exception as e: logger.warning( "Could read() from potential iostream with persistent " "key: %s", key, ) logger.debug("Persistent Storage Exception: %s", str(e)) raise exception.AppriseDiskIOError( f"Invalid data type {type(data)} provided to Persistent" " Storage" ) from None if self.__mode == PersistentStoreMode.MEMORY: # Nothing further can be done return False if _recovery: # Attempt to recover from a bad directory structure or setup self.__prepare() # generate our filename based on the key provided io_file = os.path.join(self.__data_path, f"{key}{self.__extension}") # Calculate the files current filesize try: prev_size = os.stat(io_file).st_size except FileNotFoundError: # No worries, no size to accommodate prev_size = 0 except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning("Could not write with persistent key: %s", key) logger.debug("Persistent Storage Exception: %s", str(e)) return False # Create a temporary file to write our content into # ntf = NamedTemporaryFile ntf = None new_file_size = 0 try: if isinstance(data, str): data = data.encode(self.encoding) ntf = tempfile.NamedTemporaryFile( # noqa: SIM115 mode="wb", dir=self.__temp_path, delete=False ) # Close our file ntf.close() # Pointer to our open call open_ = open if not compress else gzip.open with open_(ntf.name, mode="wb") as fd: # Write our content fd.write(data) # Get our file size new_file_size = os.stat(ntf.name).st_size # Log our progress logger.trace( "Wrote %d bytes of data to persistent key: %s", new_file_size, key, ) except FileNotFoundError: # This happens if the directory path is gone preventing the file # from being created... if not _recovery: return self.write( data=data, key=key, compress=compress, _recovery=True ) # We've already made our best effort to recover if we are here in # our code base... we're going to have to exit # Tidy our Named Temporary File _ntf_tidy(ntf) # Early Exit return False except (OSError, UnicodeEncodeError, zlib.error) as e: # We can't access the file or it does not exist logger.warning("Could not write to persistent key: %s", key) logger.debug("Persistent Storage Exception: %s", str(e)) # Tidy our Named Temporary File _ntf_tidy(ntf) return False if ( self.max_file_size > 0 and (new_file_size + self.size() - prev_size) > self.max_file_size ): # The content to store is too large. logger.warning( "Persistent content exceeds allowable maximum file length" f" ({int(self.max_file_size / 1024)}KB); provide" f" {int(new_file_size / 1024)}KB" ) return False # Return our final move if not self.__move(ntf.name, io_file): # Attempt to restore things as they were # Tidy our Named Temporary File _ntf_tidy(ntf) return False # Resetour reference variables self.__cache_size = None self.__cache_files.clear() # Content installed return True def __move(self, src, dst): """Moves the new file in place and handles the old if it exists already If the transaction fails in any way, the old file is swapped back. Function returns True if successful and False if not. """ # A temporary backup of the file we want to move in place dst_backup = ( dst[: -len(self.__backup_extension)] + self.__backup_extension ) # # Backup the old file (if it exists) allowing us to have a restore # point in the event of a failure # try: # make sure the file isn't already present; if it is; remove it os.unlink(dst_backup) logger.trace( "Removed previous persistent backup file: %s", dst_backup ) except FileNotFoundError: # no worries; we were removing it anyway pass except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning( "Could not previous persistent data backup: %s", dst_backup ) logger.debug("Persistent Storage Exception: %s", str(e)) return False try: # Back our file up so we have a fallback os.rename(dst, dst_backup) logger.trace( "Persistent storage backup file created: %s", dst_backup ) except FileNotFoundError: # Not a problem; this is a brand new file we're writing # There is nothing to backup pass except OSError as e: # This isn't good... we couldn't put our new file in place logger.warning( "Could not install persistent content %s -> %s", dst, os.path.basename(dst_backup), ) logger.debug("Persistent Storage Exception: %s", str(e)) return False # # Now place the new file # try: os.rename(src, dst) logger.trace("Persistent file installed: %s", dst) except OSError as e: # This isn't good... we couldn't put our new file in place # Begin fall-back process before leaving the funtion logger.warning( "Could not install persistent content %s -> %s", src, os.path.basename(dst), ) logger.debug("Persistent Storage Exception: %s", str(e)) try: # Restore our old backup (if it exists) os.rename(dst_backup, dst) logger.trace("Restoring original persistent content: %s", dst) except FileNotFoundError: # Not a problem pass except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning( "Failed to restore original persistent file: %s", dst ) logger.debug("Persistent Storage Exception: %s", str(e)) return False return True def open( self, key: Optional[str] = None, mode: str = "r", buffering: int = -1, encoding: Optional[str] = None, errors: Optional[str] = None, newline: Optional[str] = None, closefd: bool = True, opener: Optional[Any] = None, compress: bool = False, compresslevel: int = 9, ) -> Any: """Open a stored file by key, using the default when omitted.""" if key is None: key = self.base_key elif not isinstance(key, str) or not self.__valid_key.fullmatch(key): raise AppriseImproperlyConfigured( f"Persistent Storage key ({key} provided is invalid" ) if self.__mode == PersistentStoreMode.MEMORY: # Memory-only storage has no file that can be opened. raise exception.AppriseFileNotFound( "Persistent storage has no disk-backed file" ) io_file = os.path.join(self.__data_path, f"{key}{self.__extension}") try: return ( open( io_file, mode=mode, buffering=buffering, encoding=encoding, errors=errors, newline=newline, closefd=closefd, opener=opener, ) if not compress else gzip.open( io_file, compresslevel=compresslevel, encoding=encoding, errors=errors, newline=newline, ) ) except FileNotFoundError: # pass along (but wrap with Apprise exception) raise exception.AppriseFileNotFound( f"No such file or directory: '{io_file}'" ) from None except (OSError, zlib.error) as e: # We can't access the file or it does not exist logger.warning("Could not read with persistent key: %s", key) logger.debug("Persistent Storage Exception: %s", str(e)) raise exception.AppriseDiskIOError(str(e)) from None def get( self, key: str, default: Any = None, lazy: bool = True, ) -> Any: """Fetches from cache.""" if self._cache is None and not self.__load_cache(): return default if ( key in self._cache and self.__mode != PersistentStoreMode.MEMORY and not self.__dirty ): # ensure we renew our content self.__renew.add(self.cache_file) return self._cache[key].value if self._cache.get(key) else default def set( self, key: str, value: Any, expires: Union[float, int, datetime, bool, None] = None, persistent: bool = True, lazy: bool = True, ) -> bool: """Cache reference.""" if self._cache is None and not self.__load_cache(): return False cache = CacheObject(value, expires, persistent=persistent) # Fetch our cache value try: if lazy and cache == self._cache[key]: # We're done; nothing further to do return True except KeyError: pass # Replacing a saved entry changes what is on disk too replaced = key in self._cache and self._cache[key].persistent # Store our new cache self._cache[key] = CacheObject(value, expires, persistent=persistent) # Set our dirty flag, but never clear it. An earlier change may # still be waiting to be written. self.__dirty = self.__dirty or persistent or replaced if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: # Flush changes to disk return self.flush() return True def clear(self, *args: str) -> Optional[bool]: """Remove selected cache keys, or all keys when none are given. Examples: ``clear("key")``, ``clear("key1", "key2")``, or ``clear()``. """ if self._cache is None and not self.__load_cache(): return False if args: for arg in args: try: del self._cache[arg] # Set our dirty flag (if not set already) self.__dirty = True except KeyError: pass elif self._cache: # Request to remove everything and there is something to remove # Set our dirty flag (if not set already) self.__dirty = True # Reset our object self._cache.clear() if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: # Flush changes to disk return self.flush() def prune(self) -> bool: """Eliminates expired cache entries.""" if self._cache is None and not self.__load_cache(): return False change = False for key in list(self._cache.keys()): if key not in self: # It's identified as being expired if not change and self._cache[key].persistent: # track change only if content was persistent change = True # Set our dirty flag self.__dirty = True del self._cache[key] if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: # Flush changes to disk return self.flush() return change def __load_cache(self, _recovery=False): """Loads our cache. _recovery is reserved for internal usage and should not be changed """ # Prepare our dirty flag self.__dirty = False if self.__mode == PersistentStoreMode.MEMORY: # Nothing further to do self._cache = {} return True # Prepare our cache file cache_file = self.cache_file try: with gzip.open(cache_file, "rb") as f: # Read our ontent from disk self._cache = {} for k, v in json.loads(f.read().decode(self.encoding)).items(): co = CacheObject.instantiate(v) if co: # Verify our object before assigning it self._cache[k] = co elif not self.__dirty: # Track changes from our loadset self.__dirty = True except ( UnicodeDecodeError, json.decoder.JSONDecodeError, zlib.error, TypeError, AttributeError, EOFError, ): # Let users known there was a problem logger.warning( "Corrupted access persistent cache content: %s", cache_file ) if not _recovery: try: os.unlink(cache_file) logger.trace( "Removed previous persistent cache content: %s", cache_file, ) except FileNotFoundError: # no worries; we were removing it anyway pass except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning( "Could not remove persistent cache content: %s", cache_file, ) logger.debug("Persistent Storage Exception: %s", str(e)) return False return self.__load_cache(_recovery=True) return False except FileNotFoundError: # No problem; no cache to load self._cache = {} except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning( "Could not load persistent cache for namespace %s", os.path.basename(self.__base_path), ) logger.debug("Persistent Storage Exception: %s", str(e)) return False # Ensure our dirty flag is set to False return True def __prepare(self, flush=True): """Prepares a working environment.""" if self.__mode != PersistentStoreMode.MEMORY: # Ensure our path exists try: os.makedirs(self.__base_path, mode=0o770, exist_ok=True) except OSError as e: # Permission error logger.debug( "Could not create persistent store directory %s", self.__base_path, ) logger.debug("Persistent Storage Exception: %s", str(e)) # Mode changed back to MEMORY self.__mode = PersistentStoreMode.MEMORY # Ensure our path exists try: os.makedirs(self.__temp_path, mode=0o770, exist_ok=True) except OSError as e: # Permission error logger.debug( "Could not create persistent store directory %s", self.__temp_path, ) logger.debug("Persistent Storage Exception: %s", str(e)) # Mode changed back to MEMORY self.__mode = PersistentStoreMode.MEMORY try: os.makedirs(self.__data_path, mode=0o770, exist_ok=True) except OSError as e: # Permission error logger.debug( "Could not create persistent store directory %s", self.__data_path, ) logger.debug("Persistent Storage Exception: %s", str(e)) # Mode changed back to MEMORY self.__mode = PersistentStoreMode.MEMORY if self.__mode is PersistentStoreMode.MEMORY: logger.warning( "The persistent storage could not be fully initialized; " "operating in MEMORY mode" ) else: if self._cache: # Recovery taking place self.__dirty = True logger.warning( "The persistent storage environment was disrupted" ) if self.__mode is PersistentStoreMode.FLUSH and flush: # Flush changes to disk return self.flush(_recovery=True) def flush( self, force: bool = False, _recovery: bool = False, ) -> bool: """Save the cache to disk.""" if self.__mode == PersistentStoreMode.MEMORY: # nothing to do return True while self.__renew: # update our files path = self.__renew.pop() ftime = time.time() try: # (access_time, modify_time) os.utime(path, (ftime, ftime)) logger.trace("file timestamp updated: %s", path) except FileNotFoundError: # No worries... move along pass except OSError as e: # We can't access the file or it does not exist logger.debug("Could not update file timestamp: %s", path) logger.debug("Persistent Storage Exception: %s", str(e)) if self._cache is None: # The cache was never loaded, so there is nothing to save return True if not force and self.__dirty is False: # Nothing further to do logger.trace("Persistent cache is consistent with memory map") return True if _recovery: # Attempt to recover from a bad directory structure or setup self.__prepare(flush=False) # Unset our size lazy setting self.__cache_size = None self.__cache_files.clear() # Prepare our cache file cache_file = self.cache_file if not self._cache: # # We're deleting the cache file s there are no entries left in it # backup_file = ( cache_file[: -len(self.__backup_extension)] + self.__backup_extension ) try: os.unlink(backup_file) logger.trace( "Removed previous persistent cache backup: %s", backup_file ) except FileNotFoundError: # no worries; we were removing it anyway pass except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning( "Could not remove persistent cache backup: %s", backup_file ) logger.debug("Persistent Storage Exception: %s", str(e)) return False try: os.rename(cache_file, backup_file) logger.trace( "Persistent cache backup file created: %s", backup_file ) except FileNotFoundError: # Not a problem; do not create a log entry pass except OSError as e: # This isn't good... we couldn't put our new file in place logger.warning( "Could not remove stale persistent cache file: %s", cache_file, ) logger.debug("Persistent Storage Exception: %s", str(e)) return False return True # # If we get here, we need to update our file based cache # # ntf = NamedTemporaryFile ntf = None try: ntf = tempfile.NamedTemporaryFile( # noqa: SIM115 mode="w+", encoding=self.encoding, dir=self.__temp_path, delete=False, ) ntf.close() except FileNotFoundError: # This happens if the directory path is gone preventing the file # from being created... if not _recovery: return self.flush(force=True, _recovery=True) # We've already made our best effort to recover if we are here in # our code base... we're going to have to exit # Tidy our Named Temporary File _ntf_tidy(ntf) # Early Exit return False except OSError as e: logger.error( "Persistent temporary directory inaccessible: %s", self.__temp_path, ) logger.debug("Persistent Storage Exception: %s", str(e)) # Tidy our Named Temporary File _ntf_tidy(ntf) # Early Exit return False try: # write our content currently saved to disk to our temporary file with gzip.open(ntf.name, "wb") as f: # Write our content to disk f.write( json.dumps( { k: v for k, v in self._cache.items() if v and v.persistent }, separators=JSON_COMPACT_SEPARATORS, cls=CacheJSONEncoder, ).encode(self.encoding) ) except TypeError as e: # JSON object contains content that can not be encoded to disk logger.error( "Persistent temporary file can not be written to " "due to bad input data: %s", ntf.name, ) logger.debug("Persistent Storage Exception: %s", str(e)) # Tidy our Named Temporary File _ntf_tidy(ntf) # Early Exit return False except (OSError, EOFError, zlib.error) as e: logger.error( "Persistent temporary file inaccessible: %s", ntf.name ) logger.debug("Persistent Storage Exception: %s", str(e)) # Tidy our Named Temporary File _ntf_tidy(ntf) # Early Exit return False if not self.__move(ntf.name, cache_file): # Attempt to restore things as they were # Tidy our Named Temporary File _ntf_tidy(ntf) return False # Ensure our dirty flag is set to False self.__dirty = False return True def files( self, exclude: bool = True, lazy: bool = True, ) -> list[str]: """Returns the total files.""" if lazy and exclude in self.__cache_files: # Take an early exit with our cached results return self.__cache_files[exclude] elif self.__mode == PersistentStoreMode.MEMORY: # Take an early exit # exclude is our cache switch and can be either True or False. # For the below, we just set both cases and set them up as an # empty record self.__cache_files.update({True: [], False: []}) return [] if not lazy or self.__exclude_list is None: # A list of criteria that should be excluded from the size count self.__exclude_list = ( # Exclude backup cache file from count re.compile( re.escape( os.path.join( self.__base_path, f"{self.__cache_key}{self.__backup_extension}", ) ) ), # Exclude temporary files re.compile(re.escape(self.__temp_path) + r"[/\\].+"), # Exclude custom backup persistent files re.compile( re.escape(self.__data_path) + r"[/\\].+" + re.escape(self.__backup_extension) ), ) try: if exclude: self.__cache_files[exclude] = [ path for path in filter( os.path.isfile, glob.glob( os.path.join(self.__base_path, "**", "*"), recursive=True, ), ) if next( (False for p in self.__exclude_list if p.match(path)), True, ) ] else: # No exclusion list applied self.__cache_files[exclude] = list( filter( os.path.isfile, glob.glob( os.path.join(self.__base_path, "**", "*"), recursive=True, ), ) ) except OSError: # We can't access the directory or it does not exist self.__cache_files[exclude] = [] return self.__cache_files[exclude] @staticmethod def disk_scan( path: str, namespace: Optional[ Union[str, Sequence[str], AbstractSet[str]] ] = None, closest: bool = True, ) -> list[str]: """Scan a path and return the detected namespaces.""" logger.trace("Persistent path can of: %s", path) def is_namespace(x): """Validate what was detected is a valid namespace.""" return os.path.isdir( os.path.join(path, x) ) and PersistentStore.__valid_key.fullmatch(x) # Handle our namespace searching if namespace: if isinstance(namespace, str): namespace = [namespace] elif not isinstance(namespace, (tuple, set, list)): raise AppriseImproperlyConfigured( "namespace must be None, a string, or a tuple/set/list " "of strings" ) try: # Acquire all of the files in question namespaces = ( [ ns for ns in filter(is_namespace, os.listdir(path)) if not namespace or next( (True for n in namespace if ns.startswith(n)), False ) ] if closest else [ ns for ns in filter(is_namespace, os.listdir(path)) if not namespace or ns in namespace ] ) except FileNotFoundError: # no worries; Nothing to do logger.debug("Disk Prune path not found; nothing to clean.") return [] except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.error("Disk Scan detetcted inaccessible path: %s", path) logger.debug("Persistent Storage Exception: %s", str(e)) return [] return namespaces @staticmethod def disk_prune( path: str, namespace: Optional[ Union[str, Sequence[str], AbstractSet[str]] ] = None, expires: Optional[Union[int, float]] = None, action: bool = False, ) -> dict[str, list[dict[str, Union[str, bool]]]]: """Find expired persistent files below ``path``. ``namespace`` limits the scan. ``action=False`` previews matches; ``action=True`` removes them and records the result. """ # Prepare our File Expiry. A missing or negative value falls # back to the default expiry. expires = datetime.now() - timedelta( seconds=expires if isinstance(expires, (float, int)) and expires >= 0 else PersistentStore.default_file_expiry ) # Get our namespaces namespaces = PersistentStore.disk_scan(path, namespace) # Track matches map_ = {} for namespace in namespaces: # Prepare our map map_[namespace] = [] # Reference Directories base_dir = os.path.join(path, namespace) data_dir = os.path.join(base_dir, PersistentStore.data_dir) temp_dir = os.path.join(base_dir, PersistentStore.temp_dir) # Careful to only focus on files created by this Persistent Store # object files = [ os.path.join( base_dir, f"{PersistentStore.__cache_key}" f"{PersistentStore.__extension}", ), os.path.join( base_dir, f"{PersistentStore.__cache_key}" f"{PersistentStore.__backup_extension}", ), ] # Update our files (applying what was defined above too) valid_data_re = re.compile( r".*(" + re.escape(PersistentStore.__extension) + r"|" + re.escape(PersistentStore.__backup_extension) + r")$" ) files = [ path for path in filter( os.path.isfile, chain( glob.glob( os.path.join(data_dir, "*"), recursive=False ), files, ), ) if valid_data_re.match(path) ] # Now all temporary files files.extend( list( filter( os.path.isfile, glob.glob( os.path.join(temp_dir, "*"), recursive=False ), ) ) ) # Track if we should do a directory sweep later on dir_sweep = True # Scan our files for file in files: try: mtime = datetime.fromtimestamp(os.path.getmtime(file)) except FileNotFoundError: # no worries; we were removing it anyway continue except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.error( "Disk Prune (ns=%s, clean=%s) detetcted inaccessible " "file: %s", namespace, "yes" if action else "no", file, ) logger.debug("Persistent Storage Exception: %s", str(e)) # No longer worth doing a directory sweep dir_sweep = False continue if expires < mtime: continue # # Handle Removing # record = { "path": file, "removed": False, } if action: try: os.unlink(file) # Update our record record["removed"] = True logger.info( "Disk Prune (ns=%s, clean=%s) removed persistent " "file: %s", namespace, "yes" if action else "no", file, ) except FileNotFoundError: # no longer worth doing a directory sweep dir_sweep = False # otherwise, no worries; we were removing the file # anyway except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.error( "Disk Prune (ns=%s, clean=%s) failed to remove " "persistent file: %s", namespace, "yes" if action else "no", file, ) logger.debug( "Persistent Storage Exception: %s", str(e) ) # No longer worth doing a directory sweep dir_sweep = False # Store our record map_[namespace].append(record) # Memory tidy del files if dir_sweep: # Gracefully cleanup our namespace directory. It's okay if we # fail; This just means there were files in the directory. for dirpath in (temp_dir, data_dir, base_dir): if action: try: os.rmdir(dirpath) logger.info( "Disk Prune (ns=%s, clean=%s) removed " "persistent dir: %s", namespace, "yes" if action else "no", dirpath, ) except OSError: # do nothing; pass return map_ def size( self, exclude: bool = True, lazy: bool = True, ) -> int: """Returns the total size of the persistent storage in bytes.""" if lazy and self.__cache_size is not None: # Take an early exit return self.__cache_size elif self.__mode == PersistentStoreMode.MEMORY: # Take an early exit self.__cache_size = 0 return self.__cache_size # Get a list of files (file paths) in the given directory try: self.__cache_size = sum( os.stat(path).st_size for path in self.files(exclude=exclude, lazy=lazy) ) except OSError: # We can't access the directory or it does not exist self.__cache_size = 0 return self.__cache_size def __del__(self) -> None: """Deconstruction of our object.""" if self.__mode == PersistentStoreMode.AUTO: # Flush changes to disk self.flush() def __delitem__(self, key: str) -> None: """Remove a cache entry by its key.""" if self._cache is None and not self.__load_cache(): raise KeyError("Could not initialize cache") try: if self._cache[key].persistent: # Set our dirty flag in advance self.__dirty = True # Store our new cache del self._cache[key] except KeyError: # Nothing to do raise if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: # Flush changes to disk self.flush() return def __contains__(self, key: str) -> bool: """Return whether storage contains a non-expired key.""" if self._cache is None and not self.__load_cache(): return False return key in self._cache and self._cache[key] def __setitem__(self, key: str, value: Any) -> None: """Sets a cache value without disrupting existing settings in place.""" if self._cache is None and not self.__load_cache(): raise KeyError("Could not initialize cache") if key not in self._cache and not self.set(key, value): raise KeyError("Could not set cache") else: # Update our value self._cache[key].set(value) if self._cache[key].persistent: # Set our dirty flag in advance self.__dirty = True if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: # Flush changes to disk self.flush() return def __getitem__(self, key: str) -> Any: """Returns the indexed value.""" if self._cache is None and not self.__load_cache(): raise KeyError("Could not initialize cache") result = self.get(key, default=self.__not_found_ref, lazy=False) if result is self.__not_found_ref: raise KeyError(f" {key} not found in cache") return result def keys(self) -> builtins.set[str]: """Returns our keys.""" if self._cache is None and not self.__load_cache(): # There are no keys to return return {}.keys() return self._cache.keys() def delete( self, *args: str, all: Optional[bool] = None, temp: Optional[bool] = None, cache: Optional[bool] = None, validate: bool = True, ) -> bool: """Delete selected files, cached data, or all stored content. Examples: ``delete("key", "key2")`` or ``delete(all=True)``. """ # Our failure flag has_error = False valid_key_re = re.compile( r"^(?P.+)(" + re.escape(self.__backup_extension) + r"|" + re.escape(self.__extension) + r")$", re.I, ) # Default asignments if all is None: all = bool(not (len(args) or temp or cache)) if temp is None: temp = bool(all) if cache is None: cache = bool(all) if cache and self._cache: # Reset our object self._cache.clear() # Reset dirt flag self.__dirty = False for path in self.files(exclude=False): # Some information we use to validate the actions of our clean() # call. This is so we don't remove anything we shouldn't base = os.path.dirname(path) fname = os.path.basename(path) # Clean printable path details ppath = os.path.join(os.path.dirname(base), fname) if base == self.__base_path and cache: # We're handling a cache file (hopefully) result = valid_key_re.match(fname) key = ( None if not result else ( result["key"] if self.__valid_key.fullmatch(result["key"]) else None ) ) if validate and key != self.__cache_key: # We're not dealing with a cache key logger.debug( "Persistent File cleanup ignoring file: %s", path ) continue # # We should proceed with removing the file if we get here # elif base == self.__data_path and (args or all): # We're handling a file found in our custom data path result = valid_key_re.match(fname) key = ( None if not result else ( result["key"] if self.__valid_key.fullmatch(result["key"]) else None ) ) if validate and key is None: # we're set to validate and a non-valid file was found logger.debug( "Persistent File cleanup ignoring file: %s", path ) continue elif not all and (key is None or key not in args): # no match found logger.debug( "Persistent File cleanup ignoring file: %s", path ) continue # # We should proceed with removing the file if we get here # elif base == self.__temp_path and temp: # # This directory is a temporary path and nothing in here needs # to be further verified. Proceed with the removing of the file # pass else: # No match; move on logger.debug("Persistent File cleanup ignoring file: %s", path) continue try: os.unlink(path) logger.info("Removed persistent file: %s", ppath) except FileNotFoundError: # no worries; we were removing it anyway pass except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point has_error = True logger.error("Failed to remove persistent file: %s", ppath) logger.debug("Persistent Storage Exception: %s", str(e)) # Reset our reference variables self.__cache_size = None self.__cache_files.clear() return not has_error @property def cache_file(self) -> str: """Returns the full path to the namespace directory.""" return os.path.join( self.__base_path, f"{self.__cache_key}{self.__extension}", ) @property def path(self) -> Optional[str]: """Returns the full path to the namespace directory.""" return self.__base_path @property def mode(self) -> PersistentStoreMode: """Returns the Persistent Storage mode.""" return self.__mode caronc-apprise-dfec414/apprise/plugins/000077500000000000000000000000001526720371000202435ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/plugins/__init__.py000066400000000000000000000466201526720371000223640ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import copy import os from ..common import ( NOTIFY_IMAGE_SIZES, NOTIFY_TYPES, NotifyImageSize, NotifyType, ) from ..locale import LazyTranslation, gettext_lazy as _ from ..logger import logger from ..manager_plugins import NotificationManager from ..utils.cwe312 import cwe312_loggable from ..utils.parse import GET_SCHEMA_RE, parse_list # Used for testing from .base import NotifyBase # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() __all__ = [ "NOTIFY_IMAGE_SIZES", "NOTIFY_TYPES", "NotifyBase", # Reference "NotifyImageSize", "NotifyType", # Tokenizer "url_to_dict", ] def _sanitize_token(tokens, default_delimiter): """This is called by the details() function and santizes the output by populating expected and consistent arguments if they weren't otherwise specified.""" # Used for tracking groups group_map = {} # Iterate over our tokens for key in tokens: for element in tokens[key]: # Perform translations (if detected to do so) if isinstance(tokens[key][element], LazyTranslation): tokens[key][element] = str(tokens[key][element]) if "alias_of" in tokens[key]: # Do not touch this field continue elif "name" not in tokens[key]: # Default to key tokens[key]["name"] = key if "map_to" not in tokens[key]: # Default type to key tokens[key]["map_to"] = key # Track our map_to objects if tokens[key]["map_to"] not in group_map: group_map[tokens[key]["map_to"]] = set() group_map[tokens[key]["map_to"]].add(key) if "type" not in tokens[key]: # Default type to string tokens[key]["type"] = "string" elif tokens[key]["type"].startswith("list"): if "delim" not in tokens[key]: # Default list delimiter (if not otherwise specified) tokens[key]["delim"] = default_delimiter if key in group_map[tokens[key]["map_to"]]: # pragma: no branch # Remove ourselves from the list group_map[tokens[key]["map_to"]].remove(key) # Pointing to the set directly so we can dynamically update # ourselves tokens[key]["group"] = group_map[tokens[key]["map_to"]] elif ( tokens[key]["type"].startswith("choice") and "default" not in tokens[key] and "values" in tokens[key] and len(tokens[key]["values"]) == 1 ): # If there is only one choice; then make it the default # - support dictionaries too tokens[key]["default"] = ( tokens[key]["values"][0] if not isinstance(tokens[key]["values"], dict) else next(iter(tokens[key]["values"])) ) if "values" in tokens[key] and isinstance(tokens[key]["values"], dict): # Convert values into a list if it was defined as a dictionary tokens[key]["values"] = list(tokens[key]["values"].keys()) if "regex" in tokens[key]: # Verify that we are a tuple; convert strings to tuples if isinstance(tokens[key]["regex"], str): # Default tuple setup tokens[key]["regex"] = (tokens[key]["regex"], None) elif not isinstance(tokens[key]["regex"], (list, tuple)): # Invalid regex del tokens[key]["regex"] if "required" not in tokens[key]: # Default required is False tokens[key]["required"] = False if "private" not in tokens[key]: # Private flag defaults to False if not set tokens[key]["private"] = False return def details(plugin): """Provides templates that can be used by developers to build URLs dynamically. If a list of templates is provided, then they will be used over the default value. If a list of tokens are provided, then they will over-ride any additional settings built from this script and/or will be appended to them afterwards. """ # Our unique list of parsing will be based on the provided templates # if none are provided we will use our own templates = tuple(plugin.templates) # The syntax is simple # { # # The token_name must tie back to an entry found in the # # templates list. # 'token_name': { # # # Types include 'string', 'int', 'choice', 'list', 'float', # # and 'email'. # # # 'choice' and 'list' may identify their value type after a # # colon; the default is 'string'. # # # 'email' is one address (local-part@domain). URL builders # # keep its '@' unescaped, matching plugin URLs. # 'type': 'choice:string', # # # values will only exist the type must be a fixed # # list of inputs (generated from type choice for example) # # # If this is a choice:bool then you should ALWAYS define # # this list as a (True, False) such as ('Yes, 'No') or # # ('Enabled', 'Disabled'), etc # 'values': [ 'http', 'https' ], # # # Identifies if the entry specified is required or not # 'required': True, # # # Identifies all tokens detected to be associated with the # # list:string # # This is ony present in list:string objects and is only set # # if this element acts as an alias for several other # # kwargs/fields. # 'group': [], # # # Identify a default value # 'default': 'http', # # # Optional Verification Entries min and max are for floats # # and/or integers # 'min': 4, # 'max': 5, # # # A list will always identify a delimiter. If this is # # part of a path, this may be a '/', or it could be a # # comma and/or space. delimiters are always in a list # # eg (if space and/or comma is a delimiter the entry # # would look like: 'delim': [',' , ' ' ] # 'delim': None, # # # Use regex if you want to share the regular expression # # required to validate the field. The regex will never # # accommodate the prefix (if one is specified). That is # # up to the user building the URLs to include the prefix # # on the URL when constructing it. # # The format is ('regex', 'reg options') # 'regex': (r'[A-Z0-9]+', 'i'), # # # A Prefix is always a string, to differentiate between # # multiple arguments, sometimes content is prefixed. # 'prefix': '@', # # # By default the key of this object is to be interpreted # # as the argument to the notification in question. However # # To accommodate cases where there are multiple types that # # all map to the same entry, one can find a map_to value. # 'map_to': 'function_arg', # # # Some arguments act as an alias_of an already defined object # # This plays a role more with configuration file generation # # since yaml files allow you to define different argumuments # # in line to simplify things. If this directive is set, then # # it should be treated exactly the same as the object it is # # an alias of # 'alias_of': 'function_arg', # # # Advise developers to consider the potential sensitivity # # of this field owned by the user. This is for passwords, # # and api keys, etc... # 'private': False, # }, # } # Template tokens identify the arguments required to initialize the # plugin itself. It identifies all of the tokens and provides some # details on their use. Each token defined should in some way map # back to at least one URL {token} defined in the templates # Since we nest a dictionary within a dictionary, a simple copy isn't # enough. a deepcopy allows us to manipulate this object in this # funtion without obstructing the original. template_tokens = copy.deepcopy(plugin.template_tokens) # Arguments and/or Options either have a default value and/or are # optional to be set. # # Since we nest a dictionary within a dictionary, a simple copy isn't # enough. a deepcopy allows us to manipulate this object in this # funtion without obstructing the original. template_args = copy.deepcopy(plugin.template_args) # Our template keyword arguments ?+key=value&-key=value # Basically the user provides both the key and the value. this is only # possibly by identifying the key prefix required for them to be # interpreted hence the +/- keys are built into apprise by default for easy # reference. In these cases, entry might look like '+' being the prefix: # { # 'arg_name': { # 'name': 'label', # 'prefix': '+', # } # } # # Since we nest a dictionary within a dictionary, a simple copy isn't # enough. a deepcopy allows us to manipulate this object in this # funtion without obstructing the original. template_kwargs = copy.deepcopy(plugin.template_kwargs) # We automatically create a schema entry template_tokens["schema"] = { "name": _("Schema"), "type": "choice:string", "required": True, "values": parse_list(plugin.secure_protocol, plugin.protocol), } # Sanitize our tokens _sanitize_token(template_tokens, default_delimiter=("/",)) # Delimiter(s) are space and/or comma _sanitize_token(template_args, default_delimiter=(",", " ")) _sanitize_token(template_kwargs, default_delimiter=(",", " ")) # Argument/Option Handling for key in list(template_args.keys()): if "alias_of" in template_args[key]: # Check if the mapped reference is a list; if it is, then # we need to store a different delimiter alias_of = template_tokens.get(template_args[key]["alias_of"], {}) if ( alias_of.get("type", "").startswith("list") and "delim" not in template_args[key] ): # Set a default delimiter of a comma and/or space if one # hasn't already been specified template_args[key]["delim"] = (",", " ") # _lookup_default looks up what the default value if "_lookup_default" in template_args[key]: template_args[key]["default"] = getattr( plugin, template_args[key]["_lookup_default"] ) # Tidy as we don't want to pass this along in response del template_args[key]["_lookup_default"] # _exists_if causes the argument to only exist IF after checking # the return of an internal variable requiring a check if "_exists_if" in template_args[key]: if not getattr(plugin, template_args[key]["_exists_if"]): # Remove entire object del template_args[key] else: # We only nee to remove this key del template_args[key]["_exists_if"] # notify_format may be one NotifyFormat or a tuple of supported # formats. `values` stays the broad three-item set because ?format= # remains a standard Apprise URL parameter on every plugin. The # additive `supported` field tells newer consumers which destination # formats this plugin actually renders natively, with index 0 as the # default. Keeping older `values`/`default` consumers working avoids # making the service metadata update a breaking change. # sort=False preserves declaration order because parse_list() sorts # alphabetically by default, which would change the fallback format. if "format" in template_args: formats = parse_list(plugin.notify_format, sort=False) template_args["format"]["supported"] = formats if len(formats) > 1: # A single-format plugin's default was already correctly # resolved above via _lookup_default; only a multi-format # plugin needs correcting here (that mechanism would # otherwise leave the raw tuple in place). template_args["format"]["default"] = formats[0] return { "templates": templates, "tokens": template_tokens, "args": template_args, "kwargs": template_kwargs, } def requirements(plugin): """Provides a list of packages and its requirement details.""" requirements = { # Use the description to provide a human interpretable description of # what is required to make the plugin work. This is only nessisary # if there are package dependencies "details": "", # Define any required packages needed for the plugin to run. This is # an array of strings that simply look like lines in the # `requirements.txt` file... # # A single string is perfectly acceptable: # 'packages_required' = 'cryptography' # # Multiple entries should look like the following # 'packages_required' = [ # 'cryptography < 3.4`, # ] # "packages_required": [], # Recommended packages identify packages that are not required to make # your plugin work, but would improve it's use or grant it access to # full functionality (that might otherwise be limited). # Similar to `packages_required`, you would identify each entry in # the array as you would in a `requirements.txt` file. # # - Do not re-provide entries already in the `packages_required` "packages_recommended": [], } # Populate our template differently if we don't find anything above if not ( hasattr(plugin, "requirements") and isinstance(plugin.requirements, dict) ): # We're done early return requirements # Get our required packages req_packages = plugin.requirements.get("packages_required") if isinstance(req_packages, str): # Convert to list req_packages = [req_packages] elif not isinstance(req_packages, (set, list, tuple)): # Allow one to set the required packages to None (as an example) req_packages = [] requirements["packages_required"] = [str(p) for p in req_packages] # Get our recommended packages opt_packages = plugin.requirements.get("packages_recommended") if isinstance(opt_packages, str): # Convert to list opt_packages = [opt_packages] elif not isinstance(opt_packages, (set, list, tuple)): # Allow one to set the recommended packages to None (as an example) opt_packages = [] requirements["packages_recommended"] = [str(p) for p in opt_packages] # Get our package details req_details = plugin.requirements.get("details") if not req_details: if not (req_packages or opt_packages): req_details = _("No dependencies.") elif req_packages: req_details = _("Packages are required to function.") else: # opt_packages req_details = _( "Packages are recommended to improve functionality." ) else: # Store our details if defined requirements["details"] = req_details # Return our compiled package requirements return requirements def url_to_dict(url, secure_logging=True): """Takes an apprise URL and returns the tokens associated with it if they can be acquired based on the plugins available. None is returned if the URL could not be parsed, otherwise the tokens are returned. These tokens can be loaded into apprise through it's add() function. """ # swap hash (#) tag values with their html version url_ = url.replace("/#", "/%23") # CWE-312 (Secure Logging) Handling loggable_url = cwe312_loggable(url, secure_logging) # Attempt to acquire the schema at the very least to allow our plugins to # determine if they can make a better interpretation of a URL geared for # them. schema = GET_SCHEMA_RE.match(url_) if schema is None: # Not a valid URL; take an early exit logger.error(f"Unsupported URL: {loggable_url}") return None # Ensure our schema is always in lower case schema = schema.group("schema").lower() if schema not in N_MGR: # Give the user the benefit of the doubt that the user may be using # one of the URLs provided to them by their notification service. # Before we fail for good, just scan all the plugins that support the # native_url() parse function results = None for plugin in N_MGR.plugins(): results = plugin.parse_native_url(url_) if results: break if not results: logger.error(f"Unparseable URL {loggable_url}") return None logger.trace( "URL {} unpacked as:{}{}".format( url, os.linesep, os.linesep.join([f'{k}="{v}"' for k, v in results.items()]), ) ) else: # Parse the service URL into constructor arguments. results = N_MGR[schema].parse_url(url_) if not results: logger.error( f"Unparseable {N_MGR[schema].service_name} URL {loggable_url}" ) return None logger.trace( "{} URL {} unpacked as:{}{}".format( N_MGR[schema].service_name, url, os.linesep, os.linesep.join([f'{k}="{v}"' for k, v in results.items()]), ) ) # Return our results return results caronc-apprise-dfec414/apprise/plugins/africas_talking.py000066400000000000000000000507461526720371000237520ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this plugin, you must have a Africas Talking Account setup; See here: # https://account.africastalking.com/ # From here... acquire your APIKey # # API Details: https://developers.africastalking.com/docs/sms/sending/bulk from __future__ import annotations from json import loads from typing import Any import requests from ..common import NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..utils.parse import ( is_phone_no, parse_bool, parse_phone_no, validate_regex, ) from .base import NotifyBase class AfricasTalkingSMSMode: """Africas Talking SMS Mode.""" # BulkSMS Mode BULKSMS = "bulksms" # Premium Mode PREMIUM = "premium" # Sandbox Mode SANDBOX = "sandbox" # Define the types in a list for validation purposes AFRICAS_TALKING_SMS_MODES = ( AfricasTalkingSMSMode.BULKSMS, AfricasTalkingSMSMode.PREMIUM, AfricasTalkingSMSMode.SANDBOX, ) # Recipient ``statusCode`` values returned inside ``SMSMessageData``. # These are not HTTP status codes. AFRICAS_TALKING_HTTP_ERROR_MAP = { 100: "Processed", 101: "Sent", 102: "Queued", 401: "Risk Hold", 402: "Invalid Sender ID", 403: "Invalid Phone Number", 404: "Unsupported Number Type", 405: "Insufficient Balance", 406: "User In Blacklist", 407: "Could Not Route", 409: "Do Not Disturb Rejection", 500: "Internal Server Error", 501: "Gateway Error", 502: "Rejected By Gateway", } # Recipient statusCode values meaning the message was accepted AFRICAS_TALKING_RECIPIENT_OK = (100, 101, 102) class NotifyAfricasTalking(NotifyBase): """A wrapper for Africas Talking Notifications.""" # The default descriptive name associated with the Notification service_name = "Africas Talking" # The services URL service_url = "https://africastalking.com/" # The default secure protocol secure_protocol = "atalk" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/africas_talking/" # Africas Talking API Request URLs notify_url = { AfricasTalkingSMSMode.BULKSMS: ( "https://api.africastalking.com/version1/messaging" ), AfricasTalkingSMSMode.PREMIUM: ( "https://content.africastalking.com/version1/messaging" ), AfricasTalkingSMSMode.SANDBOX: ( "https://api.sandbox.africastalking.com/version1/messaging" ), } # The maximum allowable characters allowed in the title per message title_maxlen = 0 # The maximum allowable characters allowed in the body per message body_maxlen = 160 # The maximum amount of phone numbers that can reside within a single # batch transfer default_batch_size = 50 # Define object templates templates = ("{schema}://{appuser}@{apikey}/{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "appuser": { "name": _("App User Name"), "type": "string", "regex": (r"^[A-Z0-9_-]+$", "i"), "required": True, }, "apikey": { "name": _("API Key"), "type": "string", "required": True, "private": True, "regex": (r"^[A-Z0-9_-]+$", "i"), }, "target_phone": { "name": _("Target Phone"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "apikey": { "alias_of": "apikey", }, "from": { # Your registered short code or alphanumeric "name": _("From"), "type": "string", "default": "AFRICASTKNG", "map_to": "sender", }, "mode": { "name": _("SMS Mode"), "type": "choice:string", "values": AFRICAS_TALKING_SMS_MODES, "default": AFRICAS_TALKING_SMS_MODES[0], }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, appuser, apikey, targets=None, sender=None, batch=None, mode=None, **kwargs, ): """Initialize Africas Talking Object.""" super().__init__(**kwargs) self.appuser = validate_regex( appuser, *self.template_tokens["appuser"]["regex"] ) if not self.appuser: msg = ( f"The Africas Talking appuser specified ({appuser}) is" " invalid." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) self.apikey = validate_regex( apikey, *self.template_tokens["apikey"]["regex"] ) if not self.apikey: msg = ( f"The Africas Talking apikey specified ({apikey}) is invalid." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Prepare Sender self.sender = ( self.template_args["from"]["default"] if sender is None else sender ) # Prepare Batch Mode Flag self.batch = ( self.template_args["batch"]["default"] if batch is None else batch ) self.mode = ( self.template_args["mode"]["default"] if not isinstance(mode, str) else mode.lower() ) if isinstance(mode, str) and mode: self.mode = next( ( a for a in AFRICAS_TALKING_SMS_MODES if a.startswith(mode.lower()) ), None, ) if self.mode not in AFRICAS_TALKING_SMS_MODES: msg = ( f"The Africas Talking mode specified ({mode}) is invalid." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) else: self.mode = self.template_args["mode"]["default"] # Parse our targets self.targets = [] for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = is_phone_no(target) if not result: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) continue # store valid phone number # Carry forward '+' if defined, otherwise do not... self.targets.append( ("+" + result["full"]) if target.lstrip()[0] == "+" else result["full"] ) def send( self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, **kwargs: Any, ) -> bool: """Perform Africas Talking Notification.""" if not self.targets: # There is no one to email; we're done self.logger.warning( "There are no Africas Talking recipients to notify" ) return False headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", "apiKey": self.apikey, } # error tracking (used for function return) has_error = False # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size # Retry only recipients that have not accepted the message. pending = [t for t in self.targets if not self.is_delivered(t)] for index in range(0, len(pending), batch_size): # Our batch of phone numbers _batch = pending[index : index + batch_size] # Prepare our payload payload = { "username": self.appuser, "to": ",".join(_batch), "from": self.sender, "message": body, } # Acquire our URL notify_url = self.notify_url[self.mode] self.logger.debug( "Africas Talking POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Africas Talking Payload: {payload!s}") # Printable target detail p_target = ( _batch[0] if batch_size == 1 else f"{len(_batch)} target(s)" ) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) # Sample response # { # "SMSMessageData": { # "Message": "Sent to 1/1 Total Cost: KES 0.8000", # "Recipients": [{ # "statusCode": 101, # "number": "+254711XXXYYY", # "status": "Success", # "cost": "KES 0.8000", # "messageId": "ATPid_SampleTxnId123" # }] # } # } # The service answers with 201 Created; accept any 2xx if not (200 <= r.status_code < 300): # We had a problem status_str = ( NotifyAfricasTalking.http_response_code_lookup( r.status_code ) ) self.logger.warning( "Failed to send Africas Talking notification to {}: " "{}{}error={}.".format( p_target, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Africas Talking " f"notification to {p_target}." ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue # Work out which numbers in this batch accepted the message delivered = self._accepted(r, _batch) for target in _batch: if target not in delivered: # Mark our failure; a retry sends to it again has_error = True continue # Delivered; a retry can safely skip this recipient. self.mark_delivered(target) if delivered: self.logger.info( "Sent Africas Talking notification to {}/{}" " recipient(s).".format(len(delivered), len(_batch)) ) return not has_error def _accepted(self, r: Any, batch: list[str]) -> list[str]: """Return the numbers in batch the service accepted. - An unreadable body trusts the 2xx reply for the whole batch. - Otherwise a number counts only when its Recipients entry has a success statusCode (Processed, Sent or Queued). """ try: content = loads(r.content) except (TypeError, ValueError): # ValueError -- r.content is not valid JSON # TypeError -- r.content is None (e.g. empty body) content = {} # Dig out our list of recipients data = ( content.get("SMSMessageData") if isinstance(content, dict) else None ) recipients = data.get("Recipients") if isinstance(data, dict) else None if not isinstance(recipients, list): # Nothing to inspect; the 2xx reply is all we have to go on return list(batch) # Collect the digits of every number in the reply, and of the ones # that were accepted numbers = set() accepted = [] for entry in recipients: if not isinstance(entry, dict): continue number = "".join( c for c in str(entry.get("number", "")) if c.isdigit() ).lstrip("0") numbers.add(number) try: code = int(entry.get("statusCode")) except (TypeError, ValueError): code = None if code in AFRICAS_TALKING_RECIPIENT_OK: accepted.append(number) continue # Use the recipient map without changing shared HTTP messages. status_str = AFRICAS_TALKING_HTTP_ERROR_MAP.get(code, "") self.logger.warning( "Africas Talking rejected {}: {}{}statusCode={}.".format( entry.get("number"), status_str, ", " if status_str else "", entry.get("statusCode"), ) ) # Targets are stored as digits with an optional leading "+" digits = {target: target.lstrip("+0") for target in batch} # Targets whose own number appears in the reply, accepted or not exact = {t for t, d in digits.items() if d in numbers} result = set() for number in accepted: # An exact match always identifies the right target matched = [t for t, d in digits.items() if d == number] if not matched: # Replies may change local numbers to international form. # Match ending digits only when they identify one target. matched = [ t for t, d in digits.items() if t not in exact and (number.endswith(d) or d.endswith(number)) ] if len(matched) != 1: continue result.update(matched) # Keep the batch order return [target for target in batch if target in result] @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.appuser, self.apikey) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "batch": "yes" if self.batch else "no", } if self.sender != self.template_args["from"]["default"]: # Set our sender if it was set params["from"] = self.sender if self.mode != self.template_args["mode"]["default"]: # Set our mode params["mode"] = self.mode # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{appuser}@{apikey}/{targets}?{params}".format( schema=self.secure_protocol, appuser=NotifyAfricasTalking.quote(self.appuser, safe=""), apikey=self.pprint(self.apikey, privacy, safe=""), targets="/".join( [NotifyAfricasTalking.quote(x, safe="+") for x in self.targets] ), params=NotifyAfricasTalking.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets if targets > 0 else 1 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # The Application User ID results["appuser"] = NotifyAfricasTalking.unquote(results["user"]) # Prepare our targets results["targets"] = [] # Our Application APIKey if "apikey" in results["qsd"] and len(results["qsd"]["apikey"]): # Store our apikey if specified as keyword results["apikey"] = NotifyAfricasTalking.unquote( results["qsd"]["apikey"] ) # This means our host is actually a phone number (target) results["targets"].append( NotifyAfricasTalking.unquote(results["host"]) ) else: # First item is our apikey results["apikey"] = NotifyAfricasTalking.unquote(results["host"]) # Store our remaining targets found on path results["targets"].extend( NotifyAfricasTalking.split_path(results["fullpath"]) ) # The 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["sender"] = NotifyAfricasTalking.unquote( results["qsd"]["from"] ) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyAfricasTalking.parse_phone_no( results["qsd"]["to"] ) # Get our Mode if "mode" in results["qsd"] and len(results["qsd"]["mode"]): results["mode"] = NotifyAfricasTalking.unquote( results["qsd"]["mode"] ) # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyAfricasTalking.template_args["batch"]["default"] ) ) return results caronc-apprise-dfec414/apprise/plugins/amqp/000077500000000000000000000000001526720371000212015ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/plugins/amqp/__init__.py000066400000000000000000000027311526720371000233150ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """AMQP Notifications.""" from .base import NotifyAMQP __all__ = [ "NotifyAMQP", ] caronc-apprise-dfec414/apprise/plugins/amqp/base.py000066400000000000000000000566501526720371000225010ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Publish notifications to brokers such as RabbitMQ, ActiveMQ Artemis, # Apache Qpid, or Azure Service Bus. # # Install the library for the AMQP version your broker supports: # - AMQP 0-9-1 (most brokers, RabbitMQ's default): pip install pika # - AMQP 1.0 (the OASIS standard): pip install python-qpid-proton # # Select one with ?version=. By default, Apprise prefers 0.9.1 when pika is # installed and otherwise uses 1.0. # # Your Apprise URL is assembled as: # amqp://user:password@hostname/target # amqps://user:password@hostname:port/target1/target2 # # - AMQP 0-9-1 targets are routing keys. With the default exchange, use a # queue name; use ?exchange= for another exchange. # - AMQP 1.0 targets are queue or topic addresses. Put RabbitMQ addresses # such as /queues/name in ?to= to preserve their slashes. # # Resources: # - https://www.rabbitmq.com/tutorials/amqp-concepts # - https://www.rabbitmq.com/docs/amqp # - https://docs.oasis-open.org/amqp/core/v1.0/amqp-core-overview-v1.0.html """AMQP Notifications""" from __future__ import annotations import re from time import time from typing import Any, Optional from ...common import NotifyFormat, NotifyType from ...exception import AppriseImproperlyConfigured from ...locale import gettext_lazy as _ from ...url import PrivacyMode from ...utils.parse import parse_bool, parse_list from ..base import NotifyBase from .common import ( AMQP_CONTENT_TYPES, AMQP_SHORTSTR_MAX, AMQP_VERSION_MAP, AMQP_VERSION_OPTIONS, AMQP_VERSIONS, AMQPConfig, AMQPError, AMQPMessage, AMQPVersion, ) from .v091 import AMQP091Adapter from .v10 import AMQP10Adapter # The adapter that speaks each version AMQP_ADAPTERS = { AMQPVersion.V091: AMQP091Adapter, AMQPVersion.V10: AMQP10Adapter, } # A version is digits split by dots or dashes, with an optional leading v AMQP_VERSION_FORMAT = re.compile(r"v?\d+(?:[.-]\d+)*", re.IGNORECASE) # Strips everything but the digits from a version, so 0.9.1 becomes 091 AMQP_VERSION_DIGITS = re.compile(r"[^0-9]+") class NotifyAMQP(NotifyBase): """A wrapper for AMQP Notifications.""" # Each optional package enables one AMQP version. The plugin still loads # when neither is installed so it can report the missing dependency. requirements = { # Either package enables a supported version "details": _( "Install pika for AMQP 0.9.1, python-qpid-proton for AMQP 1.0," " or both." ), "packages_recommended": [ AMQP091Adapter.package, AMQP10Adapter.package, ], } # The default descriptive name associated with the Notification service_name = "AMQP" # The services URL service_url = "https://www.amqp.org/" # The default protocol protocol = "amqp" # The default secure protocol secure_protocol = "amqps" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/amqp/" # The default ports for both AMQP versions default_insecure_port = 5672 default_secure_port = 5671 # AMQP has no title field, so the title is placed in the body title_maxlen = 0 # Character limit used when split or truncate overflow is requested body_maxlen = 16777216 # Publish to each target without an added delay request_rate_per_sec = 0 # Preserve the selected body format and label its content type notify_format = ( NotifyFormat.TEXT, NotifyFormat.MARKDOWN, NotifyFormat.HTML, ) # Define object templates templates = ( "{schema}://{host}", "{schema}://{host}:{port}", "{schema}://{user}:{password}@{host}", "{schema}://{user}:{password}@{host}:{port}", "{schema}://{host}/{targets}", "{schema}://{host}:{port}/{targets}", "{schema}://{user}:{password}@{host}/{targets}", "{schema}://{user}:{password}@{host}:{port}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("User Name"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "version": { "name": _("Version"), "type": "choice:string", "values": AMQP_VERSIONS, # 1.0 is used instead when only its library is installed "default": AMQPVersion.V091, }, "ver": { "alias_of": "version", }, "exchange": { "name": _("Exchange"), "type": "string", }, "vhost": { "name": _("Virtual Host"), "type": "string", }, "persistent": { "name": _("Persistent"), "type": "bool", "default": True, }, "mandatory": { "name": _("Mandatory"), "type": "bool", "default": True, }, "priority": { "name": _("Priority"), "type": "int", "min": 0, "max": 255, }, "ttl": { "name": _("Time To Live"), "type": "int", "min": 0, # AMQP 1.0 stores it as an unsigned 32-bit value "max": 4294967295, }, "to": { "alias_of": "targets", }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("Message Headers"), "prefix": "+", }, } def __init__( self, targets: Optional[list[str]] = None, version: Optional[str] = None, exchange: Optional[str] = None, vhost: Optional[str] = None, persistent: Optional[bool] = None, mandatory: Optional[bool] = None, priority: Optional[int] = None, ttl: Optional[int] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any, ) -> None: """Initialize AMQP Object.""" super().__init__(**kwargs) # The options we were given that only one AMQP version understands exclusive = [ key for key, value in ( ("exchange", exchange), ("mandatory", mandatory), ) if value is not None and value != "" ] if version is None: # The versions those options belong to implied = {AMQP_VERSION_OPTIONS[key] for key in exclusive} # Version-specific options select their version. Otherwise use # the first supported version with an installed library. self.version = ( implied.pop() if len(implied) == 1 else next( (v for v in AMQP_VERSIONS if AMQP_ADAPTERS[v].enabled), AMQPVersion.V091, ) ) else: # Accept 0.9, 0-9-1, v1, 1.0 and similar spellings, but nothing # written around them value = str(version).strip() self.version = ( AMQP_VERSION_MAP.get(AMQP_VERSION_DIGITS.sub("", value)) if AMQP_VERSION_FORMAT.fullmatch(value) else None ) if self.version is None: msg = f"An invalid AMQP version ({version}) was specified." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # The adapter that speaks our version self.adapter = AMQP_ADAPTERS[self.version] if not self.adapter.enabled: msg = ( f"AMQP {self.version} requires the {self.adapter.package}" " package; install it with:" f" pip install {self.adapter.package}" ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Options that belong to the other version are ignored ignored = [ key for key in exclusive if AMQP_VERSION_OPTIONS[key] != self.version ] for key in ignored: self.logger.warning( "The AMQP %s option is only used by AMQP %s; ignoring it.", key, AMQP_VERSION_OPTIONS[key], ) # Our exchange; "" is the broker's default exchange self.exchange = ( exchange if isinstance(exchange, str) and "exchange" not in ignored else "" ) # Our virtual host; None leaves it to the broker self.vhost = vhost if isinstance(vhost, str) and vhost else None if self.version == AMQPVersion.V091: # AMQP 0-9-1 cannot carry longer names than this for key, value in ( ("exchange", self.exchange), ("vhost", self.vhost), ): if value and len(value.encode("utf-8")) > AMQP_SHORTSTR_MAX: msg = ( f"The AMQP {key} specified is longer than" f" {AMQP_SHORTSTR_MAX} bytes." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Whether the broker should keep our messages on disk self.persistent = ( self.template_args["persistent"]["default"] if persistent is None else parse_bool( persistent, self.template_args["persistent"]["default"] ) ) # Whether a message nobody can receive counts as a failure self.mandatory = ( self.template_args["mandatory"]["default"] if mandatory is None or "mandatory" in ignored else parse_bool( mandatory, self.template_args["mandatory"]["default"] ) ) # Our optional message priority and expiry self.priority = self._bounded_int("priority", priority) self.ttl = self._bounded_int("ttl", ttl) # Extra headers to attach to every message self.headers = ( {str(k): str(v) for k, v in headers.items()} if isinstance(headers, dict) else {} ) # Our targets (routing keys or addresses) self.targets = [] self.invalid_targets = [] for target in parse_list(targets): if ( self.version == AMQPVersion.V091 and len(target.encode("utf-8")) > AMQP_SHORTSTR_MAX ): self.logger.warning( "Dropped AMQP routing key longer than %d bytes (%s).", AMQP_SHORTSTR_MAX, target, ) self.invalid_targets.append(target) continue self.targets.append(target) def _bounded_int(self, key: str, value: Any) -> Optional[int]: """Return ``value`` as an int within the limits of ``key``.""" # Nothing set means the broker's own default applies if value is None or value == "": return None # Our limits are defined with our template arguments limits = self.template_args[key] try: result = int(value) except (TypeError, ValueError): # Not a number at all result = None if result is None or not limits["min"] <= result <= limits["max"]: msg = f"An invalid AMQP {key} ({value}) was specified." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) return result def send( self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, body_format: Optional[NotifyFormat] = None, **kwargs: Any, ) -> bool: """Perform AMQP Notification.""" # With an exchange (such as a fanout one) and no targets, publish # once with an empty routing key targets = self.targets or ([""] if self.exchange else []) if not targets: # There is nowhere to publish to self.logger.warning("There are no AMQP targets to notify.") return False # A retry skips targets that already accepted this message targets = [t for t in targets if not self.is_delivered(t)] if not targets: # Every target was already reached return True # Prepare how we reach the broker port = self.port or ( self.default_secure_port if self.secure else self.default_insecure_port ) config = AMQPConfig( host=self.host, port=port, secure=self.secure, verify_certificate=self.verify_certificate, user=self.user, password=self.password, vhost=self.vhost, connect_timeout=self.socket_connect_timeout, read_timeout=self.socket_read_timeout, exchange=self.exchange, mandatory=self.mandatory, ) # Prepare our message once; every target receives the same one message = AMQPMessage( # Replace invalid characters instead of failing the send body=body.encode("utf-8", errors="replace"), content_type=AMQP_CONTENT_TYPES.get( body_format, AMQP_CONTENT_TYPES[NotifyFormat.TEXT] ), app_id=self.app_id, timestamp=int(time()), persistent=self.persistent, priority=self.priority, ttl=self.ttl, headers=self.headers or None, ) # For logging url = f"{self.host}:{port}" self.logger.debug( "AMQP %s connect: %s (cert_verify=%s)", self.version, url, self.verify_certificate, ) # Always throttle before remote server I/O self.throttle() adapter = self.adapter(config) try: # Connect and log in adapter.connect() except AMQPError as e: # Nothing can be published without a connection self.logger.warning("Could not connect to AMQP server %s.", url) self.logger.debug("AMQP Exception: %s", str(e)) return False has_error = False try: for target in targets: # Name where the message is going for our logs where = target or f"exchange {self.exchange}" # Always throttle before remote server I/O self.throttle() self.logger.debug("AMQP publish to %s/%s", url, where) self.logger.trace("AMQP Payload: %s", body) try: # Publish and wait for the broker to accept it adapter.publish(target, message) except AMQPError as e: # Mark our failure and move on to our next target self.logger.warning( "Failed to publish AMQP notification to %s.", where ) self.logger.debug("AMQP Exception: %s", str(e)) has_error = True continue # Delivered; a retry can safely skip this target self.mark_delivered(target) self.logger.info("Sent AMQP notification to %s.", where) finally: # Always let go of our connection adapter.close() return not has_error @property def url_identifier(self) -> tuple[Any, ...]: """Return the fields that identify this broker connection. Targets are intentionally excluded. """ return ( self.secure_protocol if self.secure else self.protocol, self.version, self.user, self.password, self.host, self.port or ( self.default_secure_port if self.secure else self.default_insecure_port ), self.vhost, ) def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str: """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "version": self.version, "persistent": "yes" if self.persistent else "no", } if self.version == AMQPVersion.V091: # Only AMQP 0-9-1 uses these params["mandatory"] = "yes" if self.mandatory else "no" if self.exchange: params["exchange"] = self.exchange if self.vhost: params["vhost"] = self.vhost if self.priority is not None: params["priority"] = str(self.priority) if self.ttl is not None: params["ttl"] = str(self.ttl) # Keep targets containing slashes in to= so they remain intact targets = (*self.targets, *self.invalid_targets) in_path, in_to = [], [] for target in targets: (in_to if "/" in target or "\\" in target else in_path).append( target ) if in_to: params["to"] = ",".join(in_to) # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyAMQP.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyAMQP.quote(self.user, safe=""), ) default_port = ( self.default_secure_port if self.secure else self.default_insecure_port ) return "{schema}://{auth}{hostname}{port}/{targets}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), targets="/".join(NotifyAMQP.quote(x, safe="") for x in in_path), params=NotifyAMQP.urlencode(params), ) def __len__(self) -> int: """Returns the number of targets associated with this notification.""" # Always return at least 1 so the framework counts this instance return len(self.targets) if self.targets else 1 @staticmethod def parse_url(url: str) -> Optional[dict[str, Any]]: """Parse an AMQP URL into constructor arguments.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # Our targets are found in the path results["targets"] = NotifyAMQP.split_path(results["fullpath"]) # Values in to= keep the slashes used by many AMQP 1.0 addresses if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyAMQP.parse_list( NotifyAMQP.unquote(results["qsd"]["to"]) ) # The AMQP version to use; version= wins over its ver= alias if "ver" in results["qsd"] and len(results["qsd"]["ver"]): results["version"] = NotifyAMQP.unquote(results["qsd"]["ver"]) if "version" in results["qsd"] and len(results["qsd"]["version"]): results["version"] = NotifyAMQP.unquote(results["qsd"]["version"]) # The exchange to publish to if "exchange" in results["qsd"] and len(results["qsd"]["exchange"]): results["exchange"] = NotifyAMQP.unquote( results["qsd"]["exchange"] ) # The virtual host to use if "vhost" in results["qsd"] and len(results["qsd"]["vhost"]): results["vhost"] = NotifyAMQP.unquote(results["qsd"]["vhost"]) # Message flags if "persistent" in results["qsd"] and len( results["qsd"]["persistent"] ): results["persistent"] = parse_bool(results["qsd"]["persistent"]) if "mandatory" in results["qsd"] and len(results["qsd"]["mandatory"]): results["mandatory"] = parse_bool(results["qsd"]["mandatory"]) # Message priority and expiry if "priority" in results["qsd"] and len(results["qsd"]["priority"]): results["priority"] = NotifyAMQP.unquote( results["qsd"]["priority"] ) if "ttl" in results["qsd"] and len(results["qsd"]["ttl"]): results["ttl"] = NotifyAMQP.unquote(results["qsd"]["ttl"]) # Add our message headers and tidy entries by unquoting them results["headers"] = { NotifyAMQP.unquote(x): NotifyAMQP.unquote(y) for x, y in results["qsd+"].items() } return results @staticmethod def runtime_deps() -> tuple[str, ...]: """Return the optional packages imported by this plugin.""" return ("pika", "proton") caronc-apprise-dfec414/apprise/plugins/amqp/common.py000066400000000000000000000100231526720371000230370ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Shared AMQP configuration.""" from __future__ import annotations from typing import Optional from ...common import NotifyFormat from ...compat.py39 import dataclass_compat as dataclass from ...exception import AppriseException class AMQPVersion: """The AMQP protocol versions a notification can be published with.""" # AMQP 0-9-1 is what most brokers (such as RabbitMQ) speak by default V091 = "0.9.1" # AMQP 1.0 is the OASIS standard and a very different protocol V10 = "1.0" # All of our supported versions, in the order they are preferred AMQP_VERSIONS = ( AMQPVersion.V091, AMQPVersion.V10, ) # Accept common spellings by matching only the version digits AMQP_VERSION_MAP = { "0": AMQPVersion.V091, "09": AMQPVersion.V091, "091": AMQPVersion.V091, "1": AMQPVersion.V10, "10": AMQPVersion.V10, } # Options that select a version when ?version= is omitted AMQP_VERSION_OPTIONS = { "exchange": AMQPVersion.V091, "mandatory": AMQPVersion.V091, } # Content types used to label each supported body format AMQP_CONTENT_TYPES = { NotifyFormat.TEXT: "text/plain; charset=utf-8", NotifyFormat.MARKDOWN: "text/markdown; charset=utf-8", NotifyFormat.HTML: "text/html; charset=utf-8", } # Maximum byte length of AMQP 0-9-1 short strings AMQP_SHORTSTR_MAX = 255 class AMQPError(AppriseException): """Raised by an adapter when it cannot connect or publish.""" @dataclass(frozen=True, slots=True) class AMQPConfig: """How to reach and log into the AMQP broker.""" # Where the broker lives, and whether TLS is used to reach it host: str port: int secure: bool = False verify_certificate: bool = True # Our login; no user means the protocol's default/anonymous login user: Optional[str] = None password: Optional[str] = None # The virtual host to use; None leaves it to the broker vhost: Optional[str] = None # How long to wait to connect, and then for each reply (seconds) connect_timeout: float = 4.0 read_timeout: float = 4.0 # AMQP 0-9-1 exchange and unroutable-message handling exchange: str = "" mandatory: bool = True @dataclass(frozen=True, slots=True) class AMQPMessage: """One message, built once and published to every target.""" # The encoded body and how to read it body: bytes content_type: str # Who sent it and when (seconds since the epoch) app_id: str timestamp: int # Storage, priority, and queue expiry in milliseconds persistent: bool = True priority: Optional[int] = None ttl: Optional[int] = None # Extra application headers to attach headers: Optional[dict[str, str]] = None caronc-apprise-dfec414/apprise/plugins/amqp/v091.py000066400000000000000000000164361526720371000222640ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """AMQP 0-9-1 publishing with pika. Pika documentation: https://pika.readthedocs.io/ Publishes use broker confirmation: https://www.rabbitmq.com/docs/confirms """ from __future__ import annotations import math import ssl from typing import Any, Optional import certifi from .common import AMQPConfig, AMQPError, AMQPMessage # Default our global support flag PIKA_SUPPORT_AVAILABLE = False try: import pika from pika.exceptions import AMQPError as PikaError # We're good to go! PIKA_SUPPORT_AVAILABLE = True except ImportError: # AMQP 0-9-1 cannot be used without pika pika = None PikaError = AMQPError # Everything that can go wrong while talking to the broker. OSError covers # socket and TLS failures, while ValueError and TypeError cover values pika # refuses to encode. PIKA_FAILURES = (PikaError, OSError, ValueError, TypeError) class AMQP091Adapter: """Publish messages over AMQP 0-9-1.""" # Only usable when pika could be imported enabled = PIKA_SUPPORT_AVAILABLE # The package to install to use this version package = "pika" def __init__(self, config: AMQPConfig) -> None: """Prepare an adapter that is not connected yet.""" # How to reach the broker self.config = config # Our connection, and the channel opened on it when publishing self.connection: Optional[Any] = None self.channel: Optional[Any] = None def connect(self) -> None: """Connect and log into the broker, or raise ``AMQPError``.""" cfg = self.config try: # Prepare TLS when amqps:// is used ssl_options = None if cfg.secure: context = ssl.create_default_context(cafile=certifi.where()) if not cfg.verify_certificate: # Accept self-signed or otherwise unverified servers context.check_hostname = False context.verify_mode = ssl.CERT_NONE ssl_options = pika.SSLOptions( context, server_hostname=cfg.host ) # Prepare our connection details params = { "host": cfg.host, "port": cfg.port, "virtual_host": cfg.vhost or "/", "ssl_options": ssl_options, # Fail fast; Apprise already handles retries itself "connection_attempts": 1, # Bound the time spent on the socket and the login "socket_timeout": cfg.connect_timeout, "stack_timeout": cfg.connect_timeout + cfg.read_timeout, # A broker that is low on memory or disk blocks publishers. # Give up instead of waiting forever. "blocked_connection_timeout": cfg.read_timeout, # Heartbeats are how pika notices a broker that stopped # answering. The broker's own proposal is often 60 seconds, # which can stall a send for minutes, so ask for one close # to our read timeout instead. "heartbeat": max(1, math.ceil(cfg.read_timeout)), } if cfg.user: # Otherwise pika logs in with its default guest account params["credentials"] = pika.PlainCredentials( cfg.user, cfg.password or "" ) # Establish our connection self.connection = pika.BlockingConnection( pika.ConnectionParameters(**params) ) except PIKA_FAILURES as e: # Leave nothing half open behind us self.close() raise AMQPError(str(e) or type(e).__name__) from None def publish(self, target: str, message: AMQPMessage) -> None: """Publish ``message`` using ``target`` as the routing key.""" cfg = self.config try: if self.channel is None or not self.channel.is_open: # The broker closes a channel after some errors (such as a # missing exchange), so open a new one when we need to. self.channel = self.connection.channel() # Have the broker confirm every message it accepts self.channel.confirm_delivery() # Publish our message and wait for the broker to confirm it. # A mandatory message nobody can receive raises an error. self.channel.basic_publish( exchange=cfg.exchange, routing_key=target, body=message.body, properties=pika.BasicProperties( content_type=message.content_type, # 2 asks the broker to write the message to disk delivery_mode=2 if message.persistent else 1, priority=message.priority, # AMQP 0-9-1 carries the expiry as text (milliseconds) expiration=( None if message.ttl is None else str(message.ttl) ), timestamp=message.timestamp, app_id=message.app_id, headers=message.headers, ), mandatory=cfg.mandatory, ) except PIKA_FAILURES as e: # The message was refused, or the connection was lost raise AMQPError(str(e) or type(e).__name__) from None def close(self) -> None: """Close our connection; this never raises.""" # Forget our connection first so close() is safe to call twice connection, self.connection, self.channel = ( self.connection, None, None, ) try: if connection is not None and connection.is_open: # Let the broker know we are leaving connection.close() except Exception: # The connection is already broken; there is nothing to clean pass caronc-apprise-dfec414/apprise/plugins/amqp/v10.py000066400000000000000000000205321526720371000221630ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """AMQP 1.0 publishing with Apache Qpid Proton. Qpid Proton documentation: https://qpid.apache.org/proton/ A publish succeeds only after the broker accepts the message. """ from __future__ import annotations import contextlib from typing import Any, Optional import certifi from .common import AMQPConfig, AMQPError, AMQPMessage # Default our global support flag PROTON_SUPPORT_AVAILABLE = False try: from proton import Endpoint, Message, ProtonException, SSLDomain from proton.utils import BlockingConnection # We're good to go! PROTON_SUPPORT_AVAILABLE = True except ImportError: # AMQP 1.0 cannot be used without python-qpid-proton Endpoint = None Message = None SSLDomain = None BlockingConnection = None ProtonException = AMQPError # Everything that can go wrong while talking to the broker. OSError covers # socket failures, while the rest cover values proton refuses to encode. PROTON_FAILURES = (ProtonException, OSError, KeyError, ValueError, TypeError) class AMQP10Adapter: """Publish messages over AMQP 1.0.""" # Only usable when python-qpid-proton could be imported enabled = PROTON_SUPPORT_AVAILABLE # The package to install to use this version package = "python-qpid-proton" def __init__(self, config: AMQPConfig) -> None: """Prepare an adapter that is not connected yet.""" # How to reach the broker self.config = config # Our connection once we are connected self.connection: Optional[Any] = None def connect(self) -> None: """Connect and log into the broker, or raise ``AMQPError``.""" cfg = self.config try: # Prepare TLS when amqps:// is used ssl_domain = None if cfg.secure: ssl_domain = SSLDomain(SSLDomain.MODE_CLIENT) if cfg.verify_certificate: # Check the server certificate and its host name ssl_domain.set_trusted_ca_db(certifi.where()) ssl_domain.set_peer_authentication( SSLDomain.VERIFY_PEER_NAME ) else: # Accept self-signed or otherwise unverified servers ssl_domain.set_peer_authentication( SSLDomain.ANONYMOUS_PEER ) # Log in with our user when we have one; otherwise anonymously options = ( { "user": cfg.user, "password": cfg.password or "", "allowed_mechs": "PLAIN", } if cfg.user else {"allowed_mechs": "ANONYMOUS"} ) if cfg.vhost: # Proton would otherwise check the TLS certificate against # the virtual host instead of the server we connect to options["virtual_host"] = cfg.vhost options["sni"] = cfg.host # Wrap IPv6 addresses in brackets so the port stays separate host = f"[{cfg.host}]" if ":" in cfg.host else cfg.host # Establish our connection. Proton turns a 0 timeout into 60 # seconds, so never hand it anything smaller than a tick. self.connection = BlockingConnection( "{schema}://{host}:{port}".format( schema="amqps" if cfg.secure else "amqp", host=host, port=cfg.port, ), timeout=max(cfg.connect_timeout, 0.1), ssl_domain=ssl_domain, # Fail fast; Apprise already handles retries itself reconnect=False, **options, ) # Every wait from here on is for a reply to something we sent self.connection.timeout = max(cfg.read_timeout, 0.1) except PROTON_FAILURES as e: # Leave nothing half open behind us self.close() raise AMQPError(str(e) or type(e).__name__) from None def publish(self, target: str, message: AMQPMessage) -> None: """Publish ``message`` to the ``target`` address.""" try: # Prepare our message. The body is sent as raw bytes so that # any client (including an AMQP 0-9-1 one) reads exactly the # text we sent. msg = Message( body=message.body, content_type=message.content_type, durable=message.persistent, creation_time=message.timestamp, properties=message.headers, ) # Send the bytes as a plain data section; otherwise proton # wraps them in an AMQP typed value msg.inferred = True if message.priority is not None: # Otherwise the AMQP 1.0 default priority (4) applies msg.priority = message.priority if message.ttl is not None: # Proton takes the expiry in seconds msg.ttl = message.ttl / 1000 # Open a link to the address we are publishing to sender = self.connection.create_sender(target) try: # Send our message and wait for the broker to accept it sender.send(msg) finally: # Release our link; the connection stays open. Any error # means the broker already closed it for us. with contextlib.suppress(Exception): sender.close() except PROTON_FAILURES as e: # The address or the message was refused, or the connection # was lost raise AMQPError(str(e) or type(e).__name__) from None def close(self) -> None: """Close our connection within our read timeout; this never raises.""" # Forget our connection first so close() is safe to call twice connection, self.connection = self.connection, None if connection is None: # We never connected return # The AMQP connection proton's blocking wrapper drives conn = connection.conn with contextlib.suppress(Exception): # Let the broker know we are leaving, and give it until our read # timeout to say goodbye conn.close() connection.wait( lambda: not (conn.state & Endpoint.REMOTE_ACTIVE), timeout=max(self.config.read_timeout, 0.1), msg="Closing connection", ) with contextlib.suppress(Exception): # Shut the socket ourselves. Proton would otherwise wait forever # on a broker that stopped answering. conn.transport.close_tail() conn.transport.close_head() with contextlib.suppress(Exception): # Free what is left. The goodbye was already waited for above, # so do not wait for it a second time. connection.timeout = 0.1 connection.close() caronc-apprise-dfec414/apprise/plugins/apprise_api.py000066400000000000000000000532141526720371000231160ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps import logging import re from typing import Optional, Union import requests from .. import exception from ..common import NotifyFormat, NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import URL_PATH_SAFE_CHARS, parse_list, validate_regex from ..utils.sanitize import sanitize_payload from .base import NotifyBase class AppriseAPIMethod: """Defines the method used to post to the remote server.""" JSON = "json" FORM = "form" APPRISE_API_METHODS = ( AppriseAPIMethod.FORM, AppriseAPIMethod.JSON, ) class AppriseAPIVersion: """Defines how the configuration ID is sent to Apprise API.""" V1 = "1" V2 = "2" APPRISE_API_VERSIONS = ( AppriseAPIVersion.V1, AppriseAPIVersion.V2, ) class NotifyAppriseAPI(NotifyBase): """A wrapper for Apprise (Persistent) API Notifications.""" # The default descriptive name associated with the Notification service_name = "Apprise API" # The services URL service_url = "https://github.com/caronc/apprise-api" # The default protocol protocol = "apprise" # The default secure protocol secure_protocol = "apprises" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/apprise_api/" # Support attachments attachment_support = True # Relay content unchanged; the downstream Apprise server converts it. notify_format = ( NotifyFormat.TEXT, NotifyFormat.HTML, NotifyFormat.MARKDOWN, ) # Depending on the number of transactions/notifications taking place, this # could take a while. 30 seconds should be enough to perform the task socket_read_timeout = 30.0 # Disable throttle rate for Apprise API requests since they are normally # local anyway request_rate_per_sec = 0.0 # Define object templates templates = ( "{schema}://{host}/{token}", "{schema}://{host}:{port}/{token}", "{schema}://:{password}@{host}/{token}", "{schema}://:{password}@{host}:{port}/{token}", "{schema}://{user}@{host}/{token}", "{schema}://{user}@{host}:{port}/{token}", "{schema}://{user}:{password}@{host}/{token}", "{schema}://{user}:{password}@{host}:{port}/{token}", ) # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, "token": { "name": _("Token"), "type": "string", "required": True, "private": True, "regex": (r"^[A-Z0-9_-]{1,128}$", "i"), }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "tags": { "name": _("Tags"), "type": "string", }, "method": { "name": _("Query Method"), "type": "choice:string", "values": APPRISE_API_METHODS, "default": APPRISE_API_METHODS[0], }, "version": { "name": _("Apprise API Version"), "type": "choice:string", "values": APPRISE_API_VERSIONS, "default": AppriseAPIVersion.V2, }, "to": { "alias_of": "token", }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, } def __init__( self, token=None, tags=None, method=None, version=None, headers=None, **kwargs, ): """Initialize Apprise API Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"The Apprise API token specified ({token}) is invalid." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) self.method = ( self.template_args["method"]["default"] if not isinstance(method, str) else method.lower() ) if self.method not in APPRISE_API_METHODS: msg = f"The method specified ({method}) is invalid." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Version 2 keeps the configuration ID out of the HTTP URL. Version 1 # remains available for older Apprise API servers. self.version = ( self.template_args["version"]["default"] if not isinstance(version, str) else version ) if self.version not in APPRISE_API_VERSIONS: msg = f"The Apprise API version specified ({version}) is invalid." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Build list of tags self.__tags = parse_list(tags) self.headers = {} if headers: # Store our extra headers self.headers.update(headers) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "method": self.method, "version": self.version, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) if self.__tags: params["tags"] = ",".join(list(self.__tags)) # Determine Authentication auth = "" if self.password: auth = "{user}:{password}@".format( user=NotifyAppriseAPI.quote(self.user or "", safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyAppriseAPI.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 fullpath = self.fullpath.strip("/") return ( "{schema}://{auth}{hostname}{port}{fullpath}{token}" "/?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a # valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=( "/{}/".format( NotifyAppriseAPI.quote( fullpath, safe=URL_PATH_SAFE_CHARS ) ) if fullpath else "/" ), token=self.pprint(self.token, privacy, safe=""), params=NotifyAppriseAPI.urlencode(params), ) ) def resolve_format( self, body_format: Optional[Union[str, NotifyFormat]] = None ) -> NotifyFormat: """Keep the caller's format so this relay does not convert content. The downstream Apprise server performs the required conversion. With no source format, normal passthrough rules apply. """ if body_format in self._formats(): # Target the source format so no conversion takes place return NotifyFormat(body_format) # No source format; fall back to the normal resolution return super().resolve_format(body_format) def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, body_format=None, body_passthrough=None, **kwargs, ): """Perform Apprise API Notification.""" # Prepare HTTP Headers headers = { "User-Agent": self.app_id, } # Apply any/all header over-rides defined headers.update(self.headers) attachments = [] files = [] if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access Apprise API attachment" f" {attachment.url(privacy=True)}." ) return False try: # Our Attachment filename filename = ( attachment.name if attachment.name else f"file{no:03}.dat" ) if self.method == AppriseAPIMethod.JSON: # Output must be in a DataURL format (that's what # PushSafer calls it): attachments.append( { "filename": filename, "base64": attachment.base64(), "mimetype": attachment.mimetype, } ) else: # AppriseAPIMethod.FORM files.append( ( f"file{no:02d}", ( filename, # file handle safely closed in # `finally`; inline open intentional open(attachment.path, "rb"), # noqa: SIM115 attachment.mimetype, ), ) ) except (TypeError, OSError, exception.AppriseException): # We could not access the attachment self.logger.error( "Could not access AppriseAPI attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending AppriseAPI attachment" f" {attachment.url(privacy=True)}" ) # prepare Apprise API Object payload = { # Apprise API Payload "title": title, "body": body, "type": notify_type.value, } if body_passthrough: # For passthrough content, relay an explicit URL format only. if self._format_override is not None: payload["format"] = self._format_override.value elif body_format is not None: # Label unchanged content with its declared source format. payload["format"] = body_format.value if self.__tags: payload["tag"] = self.__tags if self.method == AppriseAPIMethod.JSON: headers["Content-Type"] = "application/json" if attachments: payload["attachments"] = attachments payload = dumps(payload) auth = None if self.user or self.password: # An empty username is valid for password-only Apprise API # administrator accounts. auth = (self.user or "", self.password) # Set our schema schema = "https" if self.secure else "http" url = f"{schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" fullpath = self.fullpath.strip("/") url += "{}".format("/" + fullpath) if fullpath else "" url += "/notify" if self.version == AppriseAPIVersion.V1: # Version 1 places the configuration ID in the request path. url += f"/{self.token}" else: # Version 2 keeps the ID out of the request URL and access logs. headers["X-Apprise-Config-ID"] = self.token # Some entries can not be over-ridden headers.update( { # Our response to be in JSON format always "Accept": "application/json", # Pass our Source UUID4 Identifier "X-Apprise-ID": self.asset._uid, # Pass our current recursion count to our upstream server "X-Apprise-Recursion-Count": str(self.asset._recursion + 1), } ) # Some Debug Logging if self.logger.isEnabledFor(logging.DEBUG): # Due to attachments; output can be quite heavy and io intensive # To accommodate this, we only show our debug payload information # if required. self.logger.debug( "Apprise API POST URL:" f" {url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug( "Apprise API Payload: %s", sanitize_payload(payload) ) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, data=payload, headers=headers, auth=auth, files=files if files else None, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyAppriseAPI.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Apprise API notification: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Return; we're done return False else: self.logger.info( "Sent Apprise API notification; method=%s.", self.method ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Apprise API " f"notification to {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False except OSError as e: self.logger.warning( "An I/O error occurred while reading one of the " "attached files." ) self.logger.debug(f"I/O Exception: {e!s}") return False finally: for file in files: # Ensure all files are closed file[1][1].close() return True @staticmethod def parse_native_url(url): """ Support http://hostname/notify/token and http://hostname/path/notify/token """ result = re.match( r"^http(?Ps?)://(?P[A-Z0-9._-]+)" r"(:(?P[0-9]+))?" r"(?P/[^?]+?)?/notify/(?P[A-Z0-9_-]{1,32})/?" r"(?P\?.+)?$", url, re.I, ) if result: results = NotifyAppriseAPI.parse_url( "{schema}://{hostname}{port}{path}/{token}/{params}".format( schema=( NotifyAppriseAPI.secure_protocol if result.group("secure") else NotifyAppriseAPI.protocol ), hostname=result.group("hostname"), port=( "" if not result.group("port") else ":{}".format(result.group("port")) ), path=( "" if not result.group("path") else result.group("path") ), token=result.group("token"), # The "params" group already includes its own leading # "?" (it's captured by the regex), so it's used as-is. params=result.group("params") or "", ) ) if results and not results.get("version"): # A /notify/{token} URL is the Version 1 endpoint shape, so # default to it unless the URL already specified a version. results["version"] = AppriseAPIVersion.V1 return results return None @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # Add our headers that the user can potentially over-ride if they wish # to to our returned result set and tidy entries by unquoting them results["headers"] = { NotifyAppriseAPI.unquote(x): NotifyAppriseAPI.unquote(y) for x, y in results["qsd+"].items() } # Support the passing of tags in the URL if "tags" in results["qsd"] and len(results["qsd"]["tags"]): results["tags"] = NotifyAppriseAPI.parse_list( results["qsd"]["tags"] ) # Support the 'to' & 'token' variable so that we can support rooms # this way too. if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyAppriseAPI.unquote( results["qsd"]["token"] ) elif "to" in results["qsd"] and len(results["qsd"]["to"]): results["token"] = NotifyAppriseAPI.unquote(results["qsd"]["to"]) else: # Start with a list of path entries to work with entries = NotifyAppriseAPI.split_path(results["fullpath"]) if entries: # use our last entry found results["token"] = entries[-1] # pop our last entry off entries = entries[:-1] # re-assemble our full path results["fullpath"] = "/".join(entries) # Set method if specified if "method" in results["qsd"] and len(results["qsd"]["method"]): results["method"] = NotifyAppriseAPI.unquote( results["qsd"]["method"] ) # Support the same long and short version arguments as Matrix. if "version" in results["qsd"] and len(results["qsd"]["version"]): results["version"] = NotifyAppriseAPI.unquote( results["qsd"]["version"] ) elif "v" in results["qsd"] and len(results["qsd"]["v"]): results["version"] = NotifyAppriseAPI.unquote(results["qsd"]["v"]) return results caronc-apprise-dfec414/apprise/plugins/aprs.py000066400000000000000000000626711526720371000215760ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # To use this plugin, you need to be a licensed ham radio operator # # Plugin constraints: # # - message length = 67 chars max. # - message content = ASCII 7 bit # - APRS messages will be sent without msg ID, meaning that # ham radio operators cannot acknowledge them # - Bring your own APRS-IS passcode. If you don't know what # this is or how to get it, then this plugin is not for you # - Do NOT change the Device/ToCall ID setting UNLESS this # module is used outside of Apprise. This identifier helps # the ham radio community with determining the software behind # a given APRS message. # - With great (ham radio) power comes great responsibility; do # not use this plugin for spamming other ham radio operators # # In order to digest text input which is not in plain English, # users can install the optional 'unidecode' package as part # of their venv environment. Details: see plugin description # # # You're done at this point, you only need to know your user/pass that # you signed up with. # The following URLs would be accepted by Apprise: # - aprs://{user}:{password}@{callsign} # - aprs://{user}:{password}@{callsign1}/{callsign2} # Optional parameters: # - locale --> APRS-IS target server to connect with # Default: EURO --> 'euro.aprs2.net' # Details: https://www.aprs2.net/ # # APRS message format specification: # http://www.aprs.org/doc/APRS101.PDF # from __future__ import annotations import contextlib from itertools import chain import re import socket import sys from typing import Any from .. import __version__ from ..common import NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_call_sign, parse_call_sign from .base import NotifyBase # Fixed APRS-IS server locales # Default is 'EURO' # See https://www.aprs2.net/ for details # Select the rotating server in case you # don"t care about a specific locale APRS_LOCALES = { "NOAM": "noam.aprs2.net", "SOAM": "soam.aprs2.net", "EURO": "euro.aprs2.net", "ASIA": "asia.aprs2.net", "AUNZ": "aunz.aprs2.net", "ROTA": "rotate.aprs2.net", } # Identify all unsupported characters APRS_BAD_CHARMAP = { r"Ä": "Ae", r"Ö": "Oe", r"Ü": "Ue", r"ä": "ae", r"ö": "oe", r"ü": "ue", r"ß": "ss", } # Our compiled mapping of bad characters APRS_COMPILED_MAP = re.compile(r"(" + "|".join(APRS_BAD_CHARMAP.keys()) + r")") class NotifyAprs(NotifyBase): """A wrapper for APRS Notifications via APRS-IS.""" # The default descriptive name associated with the Notification service_name = "Aprs" # The services URL service_url = "https://www.aprs2.net/" # The default secure protocol secure_protocol = "aprs" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/aprs/" # APRS default port, supported by all core servers # Details: https://www.aprs-is.net/Connecting.aspx notify_port = 10152 # The maximum length of the APRS message body body_maxlen = 67 # Apprise APRS Device ID / TOCALL ID # This is a FIXED value which is associated with this plugin. # Its value MUST NOT be changed. If you use this APRS plugin # code OUTSIDE of Apprise, please request your own TOCALL ID. # Details: see https://github.com/aprsorg/aprs-deviceid # # Do NOT use the generic "APRS" TOCALL ID !!!!! # device_id = "APPRIS" # A title can not be used for APRS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # Helps to reduce the number of login-related errors where the # APRS-IS server "isn't ready yet". If we try to receive the rx buffer # without this grace perid in place, we may receive "incomplete" responses # where the login response lacks information. In case you receive too many # "Rx: APRS-IS msg is too short - needs to have at least two lines" error # messages, you might want to increase this value to a larger time span # Per previous experience, do not use values lower than 0.5 (seconds) request_rate_per_sec = 0.8 # Encoding of retrieved content aprs_encoding = "latin-1" # Define object templates templates = ("{schema}://{user}:{password}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "target_callsign": { "name": _("Target Callsign"), "type": "string", "regex": ( r"^[a-z0-9]{2,5}(-[a-z0-9]{1,2})?$", "i", ), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "to": { "name": _("Target Callsign"), "type": "string", "map_to": "targets", }, "delay": { "name": _("Resend Delay"), "type": "float", "min": 0.0, "max": 5.0, "default": 0.0, }, "locale": { "name": _("Locale"), "type": "choice:string", "values": APRS_LOCALES, "default": "EURO", }, }, ) def __init__(self, targets=None, locale=None, delay=None, **kwargs): """Initialize APRS Object.""" super().__init__(**kwargs) # Our (future) socket sobject self.sock = None # Parse our targets self.targets = [] """ Check if the user has provided credentials """ if not (self.user and self.password): msg = "An APRS user/pass was not provided." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) """ Check if the user tries to use a read-only access to APRS-IS. We need to send content, meaning that read-only access will not work """ if self.password == "-1": msg = "APRS read-only passwords are not supported." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) """ Check if the password is numeric """ if not self.password.isnumeric(): msg = "Invalid APRS-IS password" self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) """ Convert given user name (FROM callsign) and device ID to to uppercase """ self.user = self.user.upper() self.device_id = self.device_id.upper() """ Check if the user has provided a locale for the APRS-IS-server and validate it, if necessary """ if locale and locale.upper() not in APRS_LOCALES: msg = ( "Unsupported APRS-IS server locale. " "Received: {}. Valid: {}".format( locale, ", ".join(str(x) for x in APRS_LOCALES) ) ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Update our delay if delay is None: self.delay = NotifyAprs.template_args["delay"]["default"] else: try: self.delay = float(delay) if ( self.delay < NotifyAprs.template_args["delay"]["min"] or self.delay >= NotifyAprs.template_args["delay"]["max"] ): raise ValueError() except (TypeError, ValueError): msg = f"Unsupported APRS-IS delay ({delay}) specified. " self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) from None # Bump up our request_rate self.request_rate_per_sec += self.delay # Set the transmitter group self.locale = ( NotifyAprs.template_args["locale"]["default"] if not locale else locale.upper() ) # Used for URL generation afterwards only self.invalid_targets = [] for target in parse_call_sign(targets): # Validate targets and drop bad ones # We just need to know if the call sign (including SSID, if # provided) is valid and can then process the input as is result = is_call_sign(target) if not result: self.logger.warning( f"Dropping invalid Amateur radio call sign ({target}).", ) self.invalid_targets.append(target.upper()) continue # Store entry self.targets.append(target.upper()) return def socket_close(self): """Closes the socket connection whereas present.""" if self.sock: with contextlib.suppress(Exception): self.sock.close() self.sock = None def socket_open(self): """Establishes the connection to the APRS-IS socket server.""" self.logger.debug( "Creating socket connection with APRS-IS" f" {APRS_LOCALES[self.locale]}:{self.notify_port}" ) try: self.sock = socket.create_connection( (APRS_LOCALES[self.locale], self.notify_port), self.socket_connect_timeout, ) except ConnectionError as e: self.logger.debug("Socket Exception socket_open: %s", e) self.sock = None return False except socket.gaierror as e: self.logger.debug("Socket Exception socket_open: %s", e) self.sock = None return False except socket.timeout as e: self.logger.debug("Socket Timeout Exception socket_open: %s", e) self.sock = None return False except Exception as e: self.logger.debug("General Exception socket_open: %s", e) self.sock = None return False # We are connected. # getpeername() is not supported by every OS. Therefore, # we MAY receive an exception even though we are # connected successfully. try: # Get the physical host/port of the server host, port = self.sock.getpeername() # and create debug info self.logger.debug(f"Connected to {host}:{port}") except ValueError: # Seens as if we are running on an operating # system that does not support getpeername() # Create a minimal log file entry self.logger.debug("Connected to APRS-IS") # Return success return True def aprsis_login(self): """Generate the APRS-IS login string, send it to the server and parse the response. Returns True/False wrt whether the login was successful """ self.logger.debug("socket_login: init") # Check if we are connected if not self.sock: self.logger.warning("socket_login: Not connected to APRS-IS") return False # APRS-IS login string, see https://www.aprs-is.net/Connecting.aspx login_str = ( f"user {self.user} pass {self.password} vers apprise" f" {__version__}\r\n" ) # Send the data & abort in case of error if not self.socket_send(login_str): self.logger.warning( "socket_login: Login to APRS-IS unsuccessful," " exception occurred" ) self.socket_close() return False rx_buf = self.socket_receive(len(login_str) + 100) # Abort the remaining process in case an error has occurred if not rx_buf: self.logger.warning( "socket_login: Login to APRS-IS " "unsuccessful, exception occurred" ) self.socket_close() return False # APRS-IS sends at least two lines of data # The data that we need is in line #2 so # let's split the content and see what we have rx_lines = rx_buf.splitlines() if len(rx_lines) < 2: self.logger.warning( "socket_login: APRS-IS msg is too short" " - needs to have at least two lines" ) self.socket_close() return False # Now split the 2nd line's content and extract # both call sign and login status try: _, _, callsign, status, _ = rx_lines[1].split(" ", 4) except ValueError: # ValueError is returned if there were not enough elements to # populate the response self.logger.warning( "socket_login: received invalid response from APRS-IS" ) self.socket_close() return False if callsign != self.user: self.logger.warning(f"socket_login: call signs differ: {callsign}") self.socket_close() return False if status.startswith("unverified"): self.logger.warning( "socket_login: invalid APRS-IS password for given call sign" ) self.socket_close() return False # all validations are successful; we are connected return True def socket_send(self, tx_data): """Generic "Send data to a socket".""" self.logger.debug("socket_send: init") # Check if we are connected if not self.sock: self.logger.warning("socket_send: Not connected to APRS-IS") return False # Encode our data if we are on Python3 or later payload = ( tx_data.encode("utf-8") if sys.version_info[0] >= 3 else tx_data ) # Always call throttle before any remote server i/o is made self.throttle() # Try to open the socket # Send the content to APRS-IS try: self.sock.setblocking(True) self.sock.settimeout(self.socket_connect_timeout) self.sock.sendall(payload) except socket.gaierror as e: self.logger.warning(f"Socket Exception socket_send: {e!s}") self.sock = None return False except socket.timeout as e: self.logger.warning(f"Socket Timeout Exception socket_send: {e!s}") self.sock = None return False except Exception as e: self.logger.warning(f"General Exception socket_send: {e!s}") self.sock = None return False self.logger.debug("socket_send: successful") # mandatory on several APRS-IS servers # helps to reduce the number of errors where # the server only returns an abbreviated message return True def socket_reset(self): """Resets the socket's buffer.""" self.logger.debug("socket_reset: init") _ = self.socket_receive(0) self.logger.debug("socket_reset: successful") return True def socket_receive(self, rx_len): """Generic "Receive data from a socket".""" self.logger.debug("socket_receive: init") # Check if we are connected if not self.sock: self.logger.warning("socket_receive: not connected to APRS-IS") return False # len is zero in case we intend to # reset the socket if rx_len > 0: self.logger.debug("socket_receive: Receiving data from APRS-IS") # Receive content from the socket try: self.sock.setblocking(False) self.sock.settimeout(self.socket_connect_timeout) rx_buf = self.sock.recv(rx_len) except socket.gaierror as e: self.logger.warning(f"Socket Exception socket_receive: {e!s}") self.sock = None return False except socket.timeout as e: self.logger.warning( f"Socket Timeout Exception socket_receive: {e!s}" ) self.sock = None return False except Exception as e: self.logger.warning(f"General Exception socket_receive: {e!s}") self.sock = None return False rx_buf = ( rx_buf.decode(self.aprs_encoding) if sys.version_info[0] >= 3 else rx_buf ) # There will be no data in case we reset the socket if rx_len > 0: self.logger.debug(f"Received content: {rx_buf}") self.logger.debug("socket_receive: successful") return rx_buf.rstrip() def send( self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, **kwargs: Any, ) -> bool: """Perform APRS Notification.""" if not self.targets: # There is no one to notify; we're done self.logger.warning( "There are no amateur radio call signs to notify" ) return False # prepare payload payload = body # sock object is "None" if we were unable to establish a connection # In case of errors, the error message has already been sent # to the logger object if not self.socket_open(): return False # We have established a successful connection # to the socket server. Now send the login information if not self.aprsis_login(): return False # Login & authorization confirmed # reset what is in our buffer self.socket_reset() # error tracking (used for function return) has_error = False # Create a copy of the targets list targets = list(self.targets) self.logger.debug("Starting Payload setup") # Prepare the outgoing message # Due to APRS's contraints, we need to do # a lot of filtering before we can send # the actual message # # First remove all characters from the # payload that would break APRS # see https://www.aprs.org/doc/APRS101.PDF pg. 71 payload = re.sub(r"[{}|~]+", "", payload) payload = APRS_COMPILED_MAP.sub( # pragma: no branch lambda x: APRS_BAD_CHARMAP[x.group()], payload ) # APRS-IS ends packets at line breaks, so join the message into one # line. The pattern starts at the first space to scan long runs once. # See https://www.aprs-is.net/Connecting.aspx payload = re.sub( r"(?:(?{self.device_id}::{targets[index]:9}:{payload}" ) # and send the content to the socket # Note that there will be no response from APRS and # that all exceptions are handled within the 'send' method self.logger.debug(f"Sending APRS message: {buffer}") # send the content if not self.socket_send(buffer): has_error = True break # Delivered; a retry can safely skip this callsign. self.mark_delivered(targets[index]) # Finally, reset our socket buffer # we DO NOT read from the socket as we # would simply listen to the default APRS-IS stream self.socket_reset() self.logger.debug("Closing socket.") self.socket_close() self.logger.info( "Sent %d/%d APRS-IS notification(s)", index + 1, len(targets) ) return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = {} if self.locale != NotifyAprs.template_args["locale"]["default"]: # Store our locale if not default params["locale"] = self.locale if self.delay != NotifyAprs.template_args["delay"]["default"]: # Store our locale if not default params["delay"] = f"{self.delay:.2f}" # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Setup Authentication auth = "{user}:{password}@".format( user=NotifyAprs.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) return "{schema}://{auth}{targets}?{params}".format( schema=self.secure_protocol, auth=auth, targets="/".join( chain( [self.pprint(x, privacy, safe="") for x in self.targets], [ self.pprint(x, privacy, safe="") for x in self.invalid_targets ], ) ), params=NotifyAprs.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.user, self.password, self.locale) def __len__(self): """Returns the number of targets associated with this notification.""" targets = len(self.targets) return targets if targets > 0 else 1 def __del__(self): """Ensure we close any lingering connections.""" self.socket_close() @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # All elements are targets results["targets"] = [NotifyAprs.unquote(results["host"])] # All entries after the hostname are additional targets results["targets"].extend(NotifyAprs.split_path(results["fullpath"])) # Get Delay (if set) if "delay" in results["qsd"] and len(results["qsd"]["delay"]): results["delay"] = NotifyAprs.unquote(results["qsd"]["delay"]) # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyAprs.parse_list(results["qsd"]["to"]) # Set our APRS-IS server locale's key value and convert it to uppercase if "locale" in results["qsd"] and len(results["qsd"]["locale"]): results["locale"] = NotifyAprs.unquote( results["qsd"]["locale"] ).upper() return results caronc-apprise-dfec414/apprise/plugins/bark.py000066400000000000000000000621771526720371000215510ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # API: https://github.com/Finb/bark-server/blob/master/docs/API_V2.md#python # import base64 import json import secrets import requests try: from cryptography.hazmat.primitives.ciphers.aead import AESGCM BARK_AESGCM_SUPPORT = True except ImportError: BARK_AESGCM_SUPPORT = False from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..exception import AppriseImproperlyConfigured, ApprisePluginException from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_bool, parse_list from .base import NotifyBase # Sounds generated off of: https://github.com/Finb/Bark/tree/master/Sounds BARK_SOUNDS = ( "alarm.caf", "anticipate.caf", "bell.caf", "birdsong.caf", "bloom.caf", "calypso.caf", "chime.caf", "choo.caf", "descent.caf", "electronic.caf", "fanfare.caf", "glass.caf", "gotosleep.caf", "healthnotification.caf", "horn.caf", "ladder.caf", "mailsent.caf", "minuet.caf", "multiwayinvitation.caf", "newmail.caf", "newsflash.caf", "noir.caf", "paymentsuccess.caf", "shake.caf", "sherwoodforest.caf", "silence.caf", "spell.caf", "suspense.caf", "telegraph.caf", "tiptoes.caf", "typewriters.caf", "update.caf", ) # Supported Level Entries class NotifyBarkLevel: """Defines the Bark Level options.""" ACTIVE = "active" TIME_SENSITIVE = "timeSensitive" PASSIVE = "passive" CRITICAL = "critical" BARK_LEVELS = ( NotifyBarkLevel.ACTIVE, NotifyBarkLevel.TIME_SENSITIVE, NotifyBarkLevel.PASSIVE, NotifyBarkLevel.CRITICAL, ) BARK_AES_KEY_LENGTHS = frozenset({16, 24, 32}) BARK_GCM_IV_RANDOM_BYTES = 9 BARK_GCM_IV_LENGTH = 12 class NotifyBark(NotifyBase): """A wrapper for Notify Bark Notifications.""" # The default descriptive name associated with the Notification service_name = "Bark" requirements = { "packages_recommended": "cryptography", } # The services URL service_url = "https://github.com/Finb/Bark" # The default protocol protocol = "bark" # The default secure protocol secure_protocol = "barks" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/bark/" # Allows the user to specify the NotifyImageSize object; this is supported # through the webhook image_size = NotifyImageSize.XY_128 # Define object templates templates = ( "{schema}://{host}/{targets}", "{schema}://{host}:{port}/{targets}", "{schema}://{user}:{password}@{host}/{targets}", "{schema}://{user}:{password}@{host}:{port}/{targets}", ) # Define our template arguments template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, "target_device": { "name": _("Target Device"), "type": "string", "map_to": "targets", "private": True, }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, "private": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "sound": { "name": _("Sound"), "type": "choice:string", "values": BARK_SOUNDS, }, "level": { "name": _("Level"), "type": "choice:string", "values": BARK_LEVELS, }, "volume": { "name": _("Volume"), "type": "int", "min": 0, "max": 10, }, "click": { "name": _("Click"), "type": "string", }, "badge": { "name": _("Badge"), "type": "int", "min": 0, }, "category": { "name": _("Category"), "type": "string", }, "group": { "name": _("Group"), "type": "string", }, "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, "icon": { "name": _("Icon URL"), "type": "string", }, "call": { "name": _("Call"), "type": "bool", "default": False, }, "key": { "name": _("Encryption Key"), "type": "string", "private": True, "map_to": "encryption_key", }, "to": { "alias_of": "targets", }, }, ) def __init__( self, targets=None, include_image=True, sound=None, category=None, group=None, level=None, click=None, badge=None, volume=None, icon=None, call=None, encryption_key=None, **kwargs, ): """Initialize Notify Bark Object.""" super().__init__(**kwargs) # Prepare our URL self.notify_url = "{}://{}{}/push".format( "https" if self.secure else "http", self.host, ( f":{self.port}" if (self.port and isinstance(self.port, int)) else "" ), ) # Assign our category self.category = category if isinstance(category, str) else None # Assign our group self.group = group if isinstance(group, str) else None # Initialize device list self.targets = parse_list(targets) # Place an image inline with the message body self.include_image = include_image # A clickthrough option for notifications self.click = click # Badge try: # Acquire our badge count if we can: # - We accept both the integer form as well as a string # representation self.badge = int(badge) if self.badge < 0: raise ValueError() except TypeError: # NoneType means use Default; this is an okay exception self.badge = None except ValueError: self.badge = None self.logger.warning( "The specified Bark badge (%s) is not valid", badge ) # Sound (easy-lookup) self.sound = ( None if not sound else next( (f for f in BARK_SOUNDS if f.startswith(sound.lower())), None ) ) if sound and not self.sound: self.logger.warning( "The specified Bark sound (%s) was not found", sound ) # Volume self.volume = None if volume is not None: try: self.volume = int(volume) if volume is not None else None if self.volume is not None and not (0 <= self.volume <= 10): raise ValueError() except (TypeError, ValueError): self.logger.warning( "The specified Bark volume (%s) is not valid. " "Must be between 0 and 10", volume, ) # Call self.call = parse_bool(call) # Encryption is off unless an encryption key was explicitly given self.encryption_key = None self._cipher = None if encryption_key: # The Bark app expects a raw ASCII key, so reject anything # that can't be represented as one up front try: encryption_key_bytes = encryption_key.encode("ascii") except (AttributeError, UnicodeEncodeError): msg = ( "The Bark encryption key must contain only ASCII " "characters." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) from None # AES-GCM only accepts 128/192/256-bit (16/24/32 byte) keys if len(encryption_key_bytes) not in BARK_AES_KEY_LENGTHS: msg = ( "The Bark encryption key must contain exactly 16, 24, or " "32 ASCII characters." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Fail closed; never fall back to sending plaintext when the # caller asked for encryption but the library isn't available if not BARK_AESGCM_SUPPORT: msg = ( "Bark encryption requires the 'cryptography' package. " "Install Apprise with the 'all-plugins' extra." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # We're ready to encrypt every outbound payload with this key self.encryption_key = encryption_key self._cipher = AESGCM(encryption_key_bytes) # Icon URL self.icon = icon if isinstance(icon, str) else None # Level self.level = ( None if not level else next((f for f in BARK_LEVELS if f[0] == level[0]), None) ) if level and not self.level: self.logger.warning( "The specified Bark level (%s) is not valid", level ) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Bark Notification.""" # error tracking (used for function return) has_error = False if not self.targets: # We have nothing to notify; we're done self.logger.warning("There are no Bark devices to notify") return False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json; charset=utf-8", } # Prepare our payload (sample below) # { # "body": "Test Bark Server", # "markdown": "# Markdown Content", # "device_key": "nysrshcqielvoxsa", # "title": "bleem", # "category": "category", # "sound": "minuet.caf", # "badge": 1, # "icon": "https://day.app/assets/images/avatar.jpg", # "group": "test", # "level": "active", # "volume": 5, # "call": 1, # "url": "https://mritd.com" # } payload = { "title": title if title else self.app_desc, } if self.notify_format == NotifyFormat.MARKDOWN: payload["markdown"] = body else: payload["body"] = body # Acquire our image url if configured to do so image_url = ( None if not self.include_image else self.image_url(notify_type) ) # Use custom icon if provided, otherwise use default image if self.icon: payload["icon"] = self.icon elif image_url: payload["icon"] = image_url if self.sound: payload["sound"] = self.sound if self.click: payload["url"] = self.click if self.badge: payload["badge"] = self.badge if self.level: payload["level"] = self.level if self.category: payload["category"] = self.category if self.group: payload["group"] = self.group if self.volume: payload["volume"] = self.volume if self.call: payload["call"] = 1 auth = None if self.user: auth = (self.user, self.password) # Create a copy of the targets targets = list(self.targets) while len(targets) > 0: # Retrieve our device key target = targets.pop() # Skip a target that already accepted this message so # a retry does not deliver it twice. if self.is_delivered(target): continue private_target = self.pprint( target, privacy=True, mode=PrivacyMode.Secret, safe="", ) if self._cipher is not None: # Encrypt the payload; on success this replaces the # plaintext fields with a device_key/ciphertext/iv wire # payload for this target try: ciphertext, iv = self._encrypt_payload(payload) request_payload = { "device_key": target, "ciphertext": ciphertext, "iv": iv, } except Exception as e: # Fail closed; never fall back to sending this # target's notification as plaintext self.logger.warning("Failed to encrypt Bark notification.") self.logger.debug( "Bark encryption failed with %s.", type(e).__name__ ) has_error = True continue else: # Plaintext payload; just tag on this target's device key request_payload = {"device_key": target, **payload} self.logger.debug( "Bark POST URL:" f" {self.notify_url} " f"(cert_verify={self.verify_certificate!r}, " f"encrypted={self._cipher is not None!r})" ) if self._cipher is None: # Never log ciphertext/iv; there's nothing readable in it self.logger.debug(f"Bark Payload: {request_payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=json.dumps(request_payload), headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyBark.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Bark notification to {}: " "{}{}error={}.".format( private_target, status_str, ", " if status_str else "", r.status_code, ) ) if self._cipher is None: # The response body is only safe to log when we # sent a plaintext request in the first place self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000], ) # Mark our failure has_error = True continue else: self.logger.info( f"Sent Bark notification to {private_target}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Bark " f"notification to {private_target}." ) if self._cipher is None: # Suppress low-level exception text once encryption # is active; it may echo back request fragments self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue # Delivered; a retry can safely skip this target. self.mark_delivered(target) return not has_error def _encrypt_payload(self, payload): """Encrypt one Bark parameter object with a fresh AES-GCM IV.""" # Generate a fresh IV for every request; Bark's client rebuilds # this string as raw bytes, so it must land on exactly 12 ASCII # characters (a 96-bit GCM nonce) iv = secrets.token_urlsafe(BARK_GCM_IV_RANDOM_BYTES) if len(iv) != BARK_GCM_IV_LENGTH or not iv.isascii(): raise ApprisePluginException( "Bark generated an incompatible AES-GCM IV" ) # Bark decrypts a compact JSON object back into its parameters plaintext = json.dumps( payload, ensure_ascii=False, separators=(",", ":"), ).encode("utf-8") # No additional authenticated data; tag is appended to the # ciphertext automatically (GCM "combined" mode) ciphertext = self._cipher.encrypt( iv.encode("ascii"), plaintext, None, ) # Bark expects the ciphertext base64-encoded and the IV as-is return base64.b64encode(ciphertext).decode("ascii"), iv @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol if self.secure else self.protocol, self.user, self.password, self.host, self.port, self.encryption_key, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "image": "yes" if self.include_image else "no", } if self.sound: params["sound"] = self.sound if self.click: params["click"] = self.click if self.badge: params["badge"] = str(self.badge) if self.level: params["level"] = self.level if self.volume: params["volume"] = str(self.volume) if self.category: params["category"] = self.category if self.group: params["group"] = self.group if self.icon: params["icon"] = self.icon if self.call: params["call"] = "yes" if self.encryption_key: params["key"] = self.pprint( self.encryption_key, privacy, mode=PrivacyMode.Secret, safe="", ) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyBark.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyBark.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}/{targets}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), targets="/".join( [ self.pprint( f"{x}", privacy, mode=PrivacyMode.Secret, safe="", ) for x in self.targets ] ), params=NotifyBark.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return len(self.targets) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # Apply our targets results["targets"] = NotifyBark.split_path(results["fullpath"]) # Category if "category" in results["qsd"] and results["qsd"]["category"]: results["category"] = NotifyBark.unquote( results["qsd"]["category"].strip() ) # Group if "group" in results["qsd"] and results["qsd"]["group"]: results["group"] = NotifyBark.unquote( results["qsd"]["group"].strip() ) # Badge if "badge" in results["qsd"] and results["qsd"]["badge"]: results["badge"] = NotifyBark.unquote( results["qsd"]["badge"].strip() ) # Volume if "volume" in results["qsd"] and results["qsd"]["volume"]: results["volume"] = NotifyBark.unquote( results["qsd"]["volume"].strip() ) # Level if "level" in results["qsd"] and results["qsd"]["level"]: results["level"] = NotifyBark.unquote( results["qsd"]["level"].strip() ) # Click (URL) if "click" in results["qsd"] and results["qsd"]["click"]: results["click"] = NotifyBark.unquote( results["qsd"]["click"].strip() ) # Sound if "sound" in results["qsd"] and results["qsd"]["sound"]: results["sound"] = NotifyBark.unquote( results["qsd"]["sound"].strip() ) # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyBark.parse_list(results["qsd"]["to"]) # use image= for consistency with the other plugins results["include_image"] = parse_bool( results["qsd"].get("image", True) ) # Icon URL if "icon" in results["qsd"] and results["qsd"]["icon"]: results["icon"] = NotifyBark.unquote( results["qsd"]["icon"].strip() ) # Call results["call"] = parse_bool(results["qsd"].get("call", False)) # Encryption Key if "key" in results["qsd"] and results["qsd"]["key"]: results["encryption_key"] = NotifyBark.unquote( results["qsd"]["key"] ) return results @staticmethod def runtime_deps(): """Return optional runtime dependency package names.""" return ("cryptography",) caronc-apprise-dfec414/apprise/plugins/base.py000066400000000000000000002203241526720371000215320ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import asyncio from collections.abc import Generator import contextlib import contextvars from datetime import tzinfo from functools import partial import math import re import time from typing import Any, ClassVar, Optional, TypedDict, Union from zoneinfo import ZoneInfo from ..apprise_attachment import AppriseAttachment from ..common import ( APPRISE_MAX_SERVICE_RETRY, APPRISE_MAX_SERVICE_WAIT, NOTIFY_FORMATS, OVERFLOW_MODES, NotifyFormat, NotifyImageSize, NotifyType, OverflowMode, PersistentStoreMode, ) from ..conversion import ( commonmark_repair_chunk, convert_between, split_dialect_chunk, truncate_dialect_chunk, ) from ..exception import AppriseImproperlyConfigured from ..locale import Translatable, gettext_lazy as _ from ..persistent_store import PersistentStore from ..url import URLBase from ..utils.format import smart_split from ..utils.parse import parse_bool, parse_list from ..utils.time import zoneinfo class RequirementsSpec(TypedDict, total=False): """Defines our plugin requirements.""" packages_required: Optional[Union[str, list[str]]] packages_recommended: Optional[Union[str, list[str]]] details: Optional[Translatable] class _PayloadPrecappedToken: """Prove that ``Apprise.notify()`` capped the source before conversion. Only this token skips a second cap; truthy values from direct plugin callers do not bypass ``payload_max_size``. """ _PAYLOAD_PRECAPPED = _PayloadPrecappedToken() # Remembers successful targets during retries. It remains unused when retries # are disabled, preserving the original plugin behavior. _delivery_tracker: contextvars.ContextVar[ Optional[set[tuple[Optional[int], Any]]] ] = contextvars.ContextVar("apprise_delivery_tracker", default=None) # Holds state that ``remember()`` and ``recall()`` share across attempts. # It stays unset when retries are disabled because nothing will read it. _delivery_memo: contextvars.ContextVar[Optional[dict[Any, Any]]] = ( contextvars.ContextVar("apprise_delivery_memo", default=None) ) # Identifies the current message piece so split messages are tracked one piece # at a time. _delivery_index: contextvars.ContextVar[int] = contextvars.ContextVar( "apprise_delivery_index", default=0 ) # Marks a whole message piece as accepted. A private object is used so it # can never match a key a plugin tracks its own targets under. _PIECE_DELIVERED = object() class NotifyBase(URLBase): """This is the base class for all notification services.""" # An internal flag used to test the state of the plugin. If set to # False, then the plugin is not used. Plugins can disable themselves # due to enviroment issues (such as missing libraries, or platform # dependencies that are not present). By default all plugins are # enabled. enabled = True @staticmethod def runtime_deps(): """Return a tuple of top-level Python package names that this plugin imported as optional runtime dependencies. The plugin manager uses this to maintain a reference counter per library. When every plugin that declared a given library is disabled, its counter reaches zero and the manager evicts the library from `sys.modules`, releasing the associated Python objects from memory. Names must be the importable top-level namespace - the same string you would pass to `import` - not the pip install name: ('paho',) # paho-mqtt installs as 'paho' ('slixmpp',) ('cryptography',) Submodules are handled automatically; declaring the top-level name is sufficient. Override this in any plugin that conditionally imports a heavy optional library. Return an empty tuple (the default) when the plugin has no optional dependencies that are worth evicting. """ return () @classmethod def enable(self): """Mark this plugin as enabled. This is the counterpart to :meth:`disable`. Calling this restores the plugin to an active state so it will be used for notifications again. Note that if the plugin's runtime dependencies were evicted from memory by the plugin manager, re-enabling will restore the flag but the plugin may not function until the process is restarted. """ self.enabled = True @classmethod def disable(self): """Mark this plugin as disabled. The plugin will not be used for notifications. The plugin manager calls this when honouring `APPRISE_DENY_SERVICES` / `APPRISE_ALLOW_SERVICES` and uses the result of :method:`runtime_deps` to decrement its per-library reference counters, potentially evicting unused libraries from `sys.modules`. """ self.enabled = False # The category allows for parent inheritance of this object to alter # this when it's function/use is intended to behave differently. The # following category types exist: # # native: Is a native plugin written/stored in `apprise/plugins/Notify*` # custom: Is a custom plugin written/stored in a users plugin directory # that they loaded at execution time. category = "native" # Some plugins may require additional packages above what is provided # already by Apprise. # # Use this section to relay this information to the users of the script to # help guide them with what they need to know if they plan on using your # plugin. The below configuration should otherwise accommodate all normal # situations and will not requrie any updating: requirements: ClassVar[RequirementsSpec] = { # Use the description to provide a human interpretable description of # what is required to make the plugin work. This is only nessisary # if there are package dependencies. Setting this to default will # cause a general response to be returned. Only set this if you plan # on over-riding the default. Always consider language support here. # So before providing a value do the following in your code base: # # from apprise.AppriseLocale import gettext_lazy as _ # # 'details': _('My detailed requirements') "details": None, # Define any required packages needed for the plugin to run. This is # an array of strings that simply look like lines residing in a # `requirements.txt` file... # # As an example, an entry may look like: # 'packages_required': [ # 'cryptography < 3.4`, # ] "packages_required": [], # Recommended packages identify packages that are not required to make # your plugin work, but would improve it's use or grant it access to # full functionality (that might otherwise be limited). # Similar to `packages_required`, you would identify each entry in # the array as you would in a `requirements.txt` file. # # - Do not re-provide entries already in the `packages_required` "packages_recommended": [], } # The services URL service_url = None # A URL that takes you to the setup/help of the specific protocol setup_url = None # Most Servers do not like more then 1 request per 5 seconds, so 5.5 gives # us a safe play range. Override the one defined already in the URLBase request_rate_per_sec = 5.5 # Allows the user to specify the NotifyImageSize object image_size = None # The maximum allowable characters allowed in the body per message body_maxlen = 32768 # Defines the maximum allowable characters in the title; set this to zero # if a title can't be used. Titles that are not used but are defined are # automatically placed into the body title_maxlen = 250 # Set the maximum line count; if this is set to anything larger then zero # the message (prior to it being sent) will be truncated to this number # of lines. Setting this to zero disables this feature. body_max_line_count = 0 # Persistent storage default settings persistent_storage = True # Timezone Default; by setting it to None, the timezone detected # on the server is used timezone = None # Default Notify Format notify_format = NotifyFormat.TEXT # Default Overflow Mode overflow_mode = OverflowMode.UPSTREAM # Our default is to no not use persistent storage beyond in-memory # reference storage_mode = PersistentStoreMode.MEMORY # Default Emoji Interpretation interpret_emojis = False # Default retry count for failed notifications. Plugins may override # via ?retry=N in their URL; the asset's default_service_retry is used # when no per-plugin value is set. # Always in [0, APPRISE_MAX_SERVICE_RETRY]. service_retry = 0 # Default wait (seconds) between retry attempts. Plugins may override # via ?wait=N in their URL; the asset's default_service_wait is used when # no per-plugin value is set. Always in [0.0, APPRISE_MAX_SERVICE_WAIT]. service_wait = 0.0 # When set to True, a delivery failure for this individual service is # silently absorbed by all three dispatch paths (sequential, thread-pool, # and asyncio coroutine). The overall notify() / async_notify() call # still returns True even if this particular endpoint could not be # reached, provided that every *required* (non-optional) service in # the same batch succeeded. # # This flag is designed for "nice to have" services -- endpoints that # you want to notify when they are available but whose unavailability # must not be treated as a delivery failure by the calling application. # # Concrete example -- four Kodi home screens tagged "media": # # Configure every Kodi entry with optional=yes. When the application # calls notify(tag="media"), Apprise attempts delivery to whichever # screens are currently powered on and reachable. If none of the four # respond (all are off or unreachable), the call still returns True # because every failing service was optional. The application is # never penalised for transient screen availability. # # Important behaviour details: # - Retries are still performed up to the configured retry count # before a service is considered to have failed. The optional flag # only fires *after* all retry attempts have been exhausted without # a successful delivery. In other words, optional does not bypass # the retry loop -- it only changes the meaning of the final result. # - Setting optional=True does NOT skip the service. Delivery is # still attempted on every call. Only the *outcome* is affected: # a failure is reported as True instead of False to the caller. # - A batch that mixes optional and required services returns False # only if one of the *required* (optional=False) services fails. # Optional failures are never included in the aggregate result. # - The flag is per-instance. Different URLs that share a tag can # have different optional values, so you can mix required and # optional endpoints within the same tag group. # # Ways to enable this flag: # - URL query parameter : append ?optional=yes to the service URL # - YAML configuration : add optional: yes under the URL entry # - Python constructor : MyPlugin(host="...", optional=True) # - Direct assignment : server.optional = True # # Accepted truthy values : "yes", "true", "on", "1", True # Accepted falsy values : "no", "false", "off", "0", False # # Defaults to False -- every delivery failure is propagated to the # caller unchanged. optional = False # Support Attachments; this defaults to being disabled. # Since apprise allows you to send attachments without a body or title # defined, by letting Apprise know the plugin won't support attachments # up front, it can quickly pass over and ignore calls to these end points. # You must set this to true if your application can handle attachments. # You must also consider a flow change to your notification if this is set # to True as well as now there will be cases where both the body and title # may not be set. There will never be a case where a body, or attachment # isn't set in the same call to your notify() function. attachment_support = False # Default Title HTML Tagging # When a title is specified for a notification service that doesn't accept # titles, by default apprise tries to give a plesant view and convert the # title so that it can be placed into the body. The default is to just # use a tag. The below causes the title to get generated: default_html_tag_id = "b" # Here is where we define all of the arguments we accept on the url # such as: schema://whatever/?overflow=upstream&format=text # These act the same way as tokens except they are optional and/or # have default values set if mandatory. This rule must be followed template_args = dict( URLBase.template_args, **{ "overflow": { "name": _("Overflow Mode"), "type": "choice:string", "values": OVERFLOW_MODES, # Provide a default "default": overflow_mode, # look up default using the following parent class value at # runtime. The variable name identified here (in this case # overflow_mode) is checked and it's result is placed over-top # of the 'default'. This is done because once a parent class # inherits this one, the overflow_mode already set as a default # 'could' be potentially over-ridden and changed to a different # value. "_lookup_default": "overflow_mode", }, "format": { "name": _("Notify Format"), "type": "choice:string", # ?format= is accepted for any of the 3 values on every # plugin. Single-format plugins force the requested # value directly; multi-format plugins decide later # whether to honor it. plugins.details() exposes the # plugin's native subset through the additive # format.supported field. "values": NOTIFY_FORMATS, # Provide a default "default": notify_format, # look up default using the following parent class value at # runtime. "_lookup_default": "notify_format", }, "emojis": { "name": _("Interpret Emojis"), # SSL Certificate Authority Verification "type": "bool", # Provide a default "default": interpret_emojis, # look up default using the following parent class value at # runtime. "_lookup_default": "interpret_emojis", }, "store": { "name": _("Persistent Storage"), # Use Persistent Storage "type": "bool", # Provide a default "default": persistent_storage, # look up default using the following parent class value at # runtime. "_lookup_default": "persistent_storage", }, "tz": { "name": _("Timezone"), "type": "string", # Provide a default "default": timezone, # look up default using the following parent class value at # runtime. "_lookup_default": "timezone", }, "retry": { "name": _("Service Retry"), "type": "int", "min": 0, "max": APPRISE_MAX_SERVICE_RETRY, "default": service_retry, "_lookup_default": "service_retry", }, "wait": { "name": _("Inter-Retry Wait"), "type": "float", "min": 0.0, "max": APPRISE_MAX_SERVICE_WAIT, "default": service_wait, "_lookup_default": "service_wait", }, "optional": { "name": _("Optional Service"), "type": "bool", "default": optional, "_lookup_default": "optional", }, }, ) # # Overflow Defaults / Configuration applicable to SPLIT mode only # # Display Count [X/X] # ^^^^^^ # \\\\\\ # 6 characters (space + count) # Display Count [XX/XX] # ^^^^^^^^ # \\\\\\\\ # 8 characters (space + count) # Display Count [XXX/XXX] # ^^^^^^^^^^ # \\\\\\\\\\ # 10 characters (space + count) # Display Count [XXXX/XXXX] # ^^^^^^^^^^^^ # \\\\\\\\\\\\ # 12 characters (space + count) # # Given the above + some buffer we come up with the following: # If this value is exceeded, display counts automatically shut off overflow_max_display_count_width = 12 # The number of characters to reserver for whitespace buffering # This is detected automatically, but you can enforce a value if # you desire: overflow_buffer = 0 # the min accepted length of a title to allow for a counter display overflow_display_count_threshold = 130 # Whether or not when over-flow occurs, if the title should be repeated # each time the message is split up # - None: Detect # - True: Always display title once # - False: Display the title for each occurance overflow_display_title_once = None # If this is set to to True: # The title_maxlen should be considered as a subset of the body_maxlen # Hence: len(title) + len(body) should never be greater then body_maxlen # # If set to False, then there is no corrorlation between title_maxlen # restrictions and that of body_maxlen overflow_amalgamate_title = False # Identifies the timezone to use; if this is not over-ridden, then the # timezone defined in the AppriseAsset() object is used instead. The # Below is expected to be in a ZoneInfo type already. You can have this # automatically initialized by specifying ?tz= on the Apprise URLs __tzinfo = None @classmethod def __init_subclass__(cls, **kwargs): """Automatically wrap any __len__ defined on a subclass so that it multiplies its base target count by (retry + 1). This ensures every plugin's __len__ reflects the total number of transmission attempts (targets * retry-factor) without requiring each plugin to be updated individually. """ super().__init_subclass__(**kwargs) if "__len__" in cls.__dict__: _orig = cls.__dict__["__len__"] def _retry_aware_len(self, _orig=_orig): return _orig(self) * (getattr(self, "retry", 0) + 1) cls.__len__ = _retry_aware_len def __init__(self, **kwargs): """Initialize some general configuration that will keep things consistent when working with the notifiers that will inherit this class.""" super().__init__(**kwargs) # Initialize retry count from (in priority order): # 1. kwargs["retry"] (from ?retry= URL param or YAML retry: key) # 2. asset.default_service_retry # Always clamped to [0, APPRISE_MAX_SERVICE_RETRY]. _retry_raw = kwargs.get("retry") if _retry_raw is not None: try: _retry_val = int(_retry_raw) if _retry_val < 0: msg = f"Service retry count must be >= 0; got {_retry_raw}" self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) self.retry = min(_retry_val, APPRISE_MAX_SERVICE_RETRY) except (TypeError, ValueError): self.logger.warning( "Invalid retry value specified (%s); using default", _retry_raw, ) self.retry = min( max(0, self.asset.default_service_retry), APPRISE_MAX_SERVICE_RETRY, ) else: self.retry = min( max(0, self.asset.default_service_retry), APPRISE_MAX_SERVICE_RETRY, ) # Initialize wait (inter-retry delay) from (in priority order): # 1. kwargs["wait"] (from ?wait= URL param or YAML wait: key) # 2. asset.default_service_wait # Only valid non-negative finite floats are accepted; integers are # promoted to float. Invalid values fall back to the asset default. # Negative values are invalid and use the same fallback. _wait_raw = kwargs.get("wait") if _wait_raw is not None: try: _wait_val = float(_wait_raw) if not math.isfinite(_wait_val) or _wait_val < 0.0: msg = f"Service retry wait must be >= 0; got {_wait_raw}" self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) self.wait = min(_wait_val, APPRISE_MAX_SERVICE_WAIT) except (TypeError, ValueError): self.logger.warning( "Invalid wait value specified (%s); using default", _wait_raw, ) self.wait = min( max(0.0, self.asset.default_service_wait), APPRISE_MAX_SERVICE_WAIT, ) else: self.wait = min( max(0.0, self.asset.default_service_wait), APPRISE_MAX_SERVICE_WAIT, ) # Initialize the optional= flag from kwargs if provided, otherwise # fall back to the class-level attribute (default: False). # # Priority order (highest to lowest): # 1. kwargs["optional"] -- supplied via ?optional=yes/no URL # parameter, YAML optional: yes/no key, or a direct Python # keyword argument to the constructor. # 2. The class-level 'optional' attribute on the plugin class # (or any of its superclasses). Plugin authors can ship # with optional=True as the default simply by overriding # the class attribute -- no extra __init__ boilerplate needed. # # parse_bool() is used for the string -> bool conversion so that # all of the canonical truthy/falsy representations that Apprise # accepts elsewhere are valid here too: "yes"/"no", "true"/"false", # "on"/"off", "1"/"0", and native Python booleans. The function # returns the 'default' argument (False) for unrecognised strings, # so garbage input silently falls back to the safe behaviour # (propagate failures) rather than raising an exception. _optional_raw = kwargs.get("optional") if _optional_raw is not None: # An explicit value was passed in -- parse it and store on # this instance, shadowing the class-level attribute only for # this particular service object. self.optional = parse_bool(_optional_raw) # else: kwargs did not include "optional". Leave self.optional # unset so that normal Python attribute lookup falls through to # the class-level default (NotifyBase.optional = False, or the # value set by a plugin subclass that overrides it). # Store our interpret_emoji's setting # If asset emoji value is set to a default of True and the user # specifies it to be false, this is accepted and False over-rides. # # If asset emoji value is set to a default of None, a user may # optionally over-ride this and set it to True from the Apprise # URL. ?emojis=yes # # If asset emoji value is set to a default of False, then all emoji's # are turned off (no user over-rides allowed) # # Our Persistent Storage object is initialized on demand self.__store = None # Take a default self.interpret_emojis = self.asset.interpret_emojis if "emojis" in kwargs: # possibly over-ride default self.interpret_emojis = bool( self.interpret_emojis in (None, True) and parse_bool( kwargs.get("emojis", False), default=NotifyBase.template_args["emojis"]["default"], ) ) # Store an explicit ?format= request for multi-format plugins. # Those plugins keep their declared notify_format tuple intact, # then resolve the one active destination later for each send. # Single-format plugins still force self.notify_format directly, # preserving the established URL override behavior. self._format_override: Optional[NotifyFormat] = None if "format" in kwargs: value = kwargs["format"] try: requested = ( value if isinstance(value, NotifyFormat) else NotifyFormat(value.lower()) ) except (AttributeError, ValueError): err = ( f"An invalid notification format ({value}) was specified." ) self.logger.warning(err) raise AppriseImproperlyConfigured(err) from None if len(self._formats()) == 1: # Single-format plugin: force the requested destination # directly onto self.notify_format. self.notify_format = requested else: # Multi-format plugin: remember the request and validate # it during per-send resolution. self._format_override = requested if "tz" in kwargs: value = kwargs["tz"] self.__tzinfo = zoneinfo(value) if not self.__tzinfo: err = ( "An invalid notification timezone " f"({value}) was specified." ) self.logger.warning(err) raise AppriseImproperlyConfigured(err) from None if "overflow" in kwargs: value = kwargs["overflow"] try: self.overflow_mode = ( value if isinstance(value, OverflowMode) else OverflowMode(value.lower()) ) except (AttributeError, ValueError): err = f"An invalid overflow method ({value}) was specified." self.logger.warning(err) raise AppriseImproperlyConfigured(err) from None # Prepare our Persistent Storage switch. Turning it off keeps the # URL identifier working; it only stops anything being written to # disk for it (see the store property below). self.persistent_storage = parse_bool( kwargs.get("store", NotifyBase.persistent_storage) ) def image_url( self, notify_type: NotifyType, image_size: Optional[NotifyImageSize] = None, logo: bool = False, extension: Optional[str] = None, ) -> Optional[str]: """Returns Image URL if possible.""" image_size = self.image_size if image_size is None else image_size if not image_size: return None return self.asset.image_url( notify_type=notify_type, image_size=image_size, logo=logo, extension=extension, ) def image_path( self, notify_type: NotifyType, extension: Optional[str] = None, ) -> Optional[str]: """Returns the path of the image if it can.""" if not self.image_size: return None return self.asset.image_path( notify_type=notify_type, image_size=self.image_size, extension=extension, ) def image_raw( self, notify_type: NotifyType, extension: Optional[str] = None, ) -> Optional[bytes]: """Returns the raw image if it can.""" if not self.image_size: return None return self.asset.image_raw( notify_type=notify_type, image_size=self.image_size, extension=extension, ) def color( self, notify_type: NotifyType, color_type: Optional[type] = None, ) -> Union[str, int, tuple[int, int, int]]: """Returns the html color (hex code) associated with the notify_type.""" return self.asset.color( notify_type=notify_type, color_type=color_type, ) def ascii( self, notify_type: NotifyType, ) -> str: """Returns the ascii characters associated with the notify_type.""" return self.asset.ascii( notify_type=notify_type, ) def _formats(self) -> tuple[NotifyFormat, ...]: """Supported notify formats as a tuple. ``notify_format`` accepts one format or a list. The first entry is the default when no override or input format matches. """ # Preserve declaration order because the first entry is the # default. parse_list() sorts alphabetically by default, which # would silently choose the wrong fallback format here. return tuple( NotifyFormat(f) for f in parse_list(self.notify_format, sort=False) ) def resolve_format( self, body_format: Optional[Union[str, NotifyFormat]] = None ) -> NotifyFormat: """Resolve which format this send should actually render as. Resolution order: - A single-format plugin always uses its one declared format. - A multi-format plugin honors an explicit ?format= override, when that override is one of its declared formats. - Otherwise, if the caller's body_format matches a declared format, align to it directly (no conversion needed). - Otherwise, fall back to the plugin's default, formats[0]. """ formats = self._formats() if len(formats) == 1: return formats[0] if self._format_override is not None: if self._format_override in formats: return self._format_override # The requested destination is not one this plugin declared. # Fall through to input alignment/default instead of failing # delivery. The actual fallback (input alignment vs # formats[0]) is decided below, so it is not named here. if body_format is not None: self.logger.debug( "%s does not support format %s; ignoring override.", self.service_name, self._format_override.value, ) if body_format in formats: # body_format may arrive as a plain string (e.g. from the # CLI) rather than a NotifyFormat; normalize to the actual # matched member so callers can always rely on .value etc. return ( body_format if isinstance(body_format, NotifyFormat) else NotifyFormat(body_format) ) return formats[0] def is_delivered(self, key: Any, per_message: bool = False) -> bool: """Return whether ``key`` accepted the current message piece. Use a stable target identifier, such as a phone number, room ID, token, or email address. This always returns ``False`` when retries are disabled. Pair it with :meth:`mark_delivered`:: for target in self.targets: if self.is_delivered(target): continue if self._post(target, body): self.mark_delivered(target) A long message is delivered in pieces, and each piece is tracked on its own. Set ``per_message`` for something sent once for the whole notification, such as an icon, so it is not repeated for every piece. """ tracker = _delivery_tracker.get() # No tracker means retries are off; nothing has been recorded. if tracker is None: return False return self._slot(key, per_message) in tracker def mark_delivered(self, key: Any, per_message: bool = False) -> None: """Record a successful target for the current message piece. Call this only after the target accepts the message. A later retry can then skip it. See :meth:`is_delivered` for an example. Set ``per_message`` for something that belongs to the whole notification rather than one piece of it. """ tracker = _delivery_tracker.get() if tracker is not None: tracker.add(self._slot(key, per_message)) def remember( self, key: Any, value: Any, per_message: bool = False ) -> None: """Keep ``value`` available to later attempts of this notification. Values expire with the notification and are separate for each split piece. Set ``per_message`` to share one value across all pieces. With retries off, nothing is stored. """ memo = _delivery_memo.get() # Without retries, no later attempt needs this value. if memo is not None: memo[self._slot(key, per_message)] = value def recall( self, key: Any, default: Any = None, per_message: bool = False ) -> Any: """Return what :meth:`remember` stored, else ``default``. An earlier attempt of this same notification is the only thing that can have stored it. Pass the same ``per_message`` that :meth:`remember` was given. """ memo = _delivery_memo.get() # An absent memo reads the same way as one that was never written. if memo is None: return default return memo.get(self._slot(key, per_message), default) @staticmethod def _slot(key: Any, per_message: bool) -> tuple[Optional[int], Any]: """Return the entry ``key`` is recorded under. Split-message pieces use separate slots. ``per_message`` instead shares one slot across the notification. Including the type keeps look-alike values distinct, while ``repr`` supports container keys. """ piece = None if per_message else _delivery_index.get() return (piece, (type(key), repr(key))) def _timed_send(self, **kwargs: Any) -> bool: """Send one prepared call and log its duration at DEBUG. Prefer a format-specific sender when available, otherwise use send(). """ # The format may arrive as a NotifyFormat, a plain string such as # "html", or None when a caller skipped it entirely. resolved = kwargs.get("body_format") fn = getattr( self, f"send_{getattr(resolved, 'value', resolved)}", None ) send_fn = fn if callable(fn) else self.send # Track each piece of a split message separately. index_token = _delivery_index.set(kwargs.get("index", 0)) send_start = time.monotonic() try: # A retry does not send a piece that was already accepted. if self.is_delivered(_PIECE_DELIVERED): self.logger.debug( "%s already accepted this message piece; skipping.", self.service_name, ) return True result = send_fn(**kwargs) if result: # Remember the accepted piece so a retry can skip it. self.mark_delivered(_PIECE_DELIVERED) finally: # Always restore the previous piece, even if send() raised. _delivery_index.reset(index_token) self.logger.debug( "%s send() completed in %.2fs.", self.service_name, time.monotonic() - send_start, ) return result def notify(self, *args: Any, **kwargs: Any) -> bool: """Performs notification.""" try: # Build a list of dictionaries that can be used to call send(). send_calls = list(self._build_send_calls(*args, **kwargs)) except TypeError: # Internal error return False else: # Loop through each call, one at a time. (Use a list rather than a # generator to call all the partials, even in case of a failure.) the_calls = [self._timed_send(**kwargs2) for kwargs2 in send_calls] return all(the_calls) async def async_notify(self, *args: Any, **kwargs: Any) -> bool: """Performs notification for asynchronous callers.""" try: # Build a list of dictionaries that can be used to call send(). send_calls = list(self._build_send_calls(*args, **kwargs)) except TypeError: # Internal error return False else: # Deferred import to dodge a circular import (apprise.apprise # loads this module first). Fine by call time. from ..apprise import get_shared_executor # Use Apprise's own shared pool, not the loop's default one -- # keeps a stuck send() from starving unrelated executor work. # TODO: let plugins supply a native async_send() instead. executor = get_shared_executor() async def do_send(**kwargs2): """Run one prepared send() call in the executor.""" send = partial(self._timed_send, **kwargs2) # Copy the current logging details into the worker thread. ctx = contextvars.copy_context() # Submit directly so cancellation still leaves access to the # worker future. A running send cannot be interrupted, so its # future is needed until the background work finishes. cf_future = executor.submit(ctx.run, send) try: result = await asyncio.wrap_future(cf_future) except asyncio.CancelledError: # Cancellation stops waiting, but cannot stop a running # function inside a worker thread. if cf_future.running(): # Import here to avoid modules loading each other # before they are ready. from ..apprise import track_abandoned_future from ..dispatch import service_metadata # Keep a safe description until the worker finishes. name, url, _, _, _ = service_metadata(self) track_abandoned_future(cf_future, name, url) # Let the caller continue handling the cancellation. raise # Forward the normal send result to async_notify(). return result # Await this plugin's pieces in order so split text and attachments # arrive predictably. Services still run concurrently one level up, # and each blocking send remains in the shared executor. results = [] for kwargs2 in send_calls: # Wait for each piece before starting the next one. results.append(await do_send(**kwargs2)) # Every prepared piece must succeed for this service to succeed. return all(results) def _build_send_calls( self, body: Optional[str] = None, title: Optional[str] = None, notify_type: Union[str, NotifyType] = NotifyType.INFO, overflow: Optional[Union[str, OverflowMode]] = None, attach: Optional[Union[list[str], AppriseAttachment]] = None, body_format: Optional[Union[str, NotifyFormat]] = None, body_passthrough: Optional[bool] = None, _payload_precapped: object = None, **kwargs: Any, ) -> Generator[dict[str, Any], None, None]: """Prepare the arguments for each ``send()`` call. Parameters: - ``body`` and ``title`` provide the message content. - ``notify_type`` identifies the message severity. - ``overflow`` controls upstream, truncation, or splitting. - ``attach`` supplies one or more attachments. - ``body_format`` identifies the source or resolved format. - ``body_passthrough`` preserves content with no declared format. - ``_payload_precapped`` confirms Apprise already applied its cap. - Extra keyword arguments are accepted for caller compatibility. Yields one mapping per final ``send()`` call, including its zero-based ``index`` and the total number of calls. """ # Normalization of notify_type before it ever reaches a plugin. if not isinstance(notify_type, NotifyType): with contextlib.suppress(AttributeError, ValueError): notify_type = NotifyType(notify_type.lower()) # Direct plugin calls bypass Apprise's per-server resolution, so # resolve here and remember whether a source format was declared. # Keep the source format for conversion after size checks. source_format = None if body_passthrough is None: body_passthrough = body_format is None source_format = body_format body_format = self.resolve_format(body_format) if not self.enabled: # Deny notifications issued to services that are disabled msg = f"{self.service_name} is currently disabled on this system." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Prepare attachments if required if attach is not None and not isinstance(attach, AppriseAttachment): try: attach = AppriseAttachment(attach, asset=self.asset) except TypeError: # bad attachments raise # Handle situations where the body is None body = body if body else "" elif not (body or attach): # If there is not an attachment at the very least, a body must be # present msg = "No message body or attachment was specified." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) if not body and not self.attachment_support: # If no body was specified, then we know that an attachment # was. This is logic checked earlier in the code. # # Knowing this, if the plugin itself doesn't support sending # attachments, there is nothing further to do here, just move # along. msg = ( f"{self.service_name} does not support " "attachments; service skipped" ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Handle situations where the title is None title = title if title else "" if source_format is not None: # Cap direct-call source content before converting it to the # resolved format, matching Apprise.notify(). title, body = self._enforce_payload_cap(title, body) _payload_precapped = _PAYLOAD_PRECAPPED # Convert our body body = convert_between(source_format, body_format, body) # A title is only converted when it is blended into the body if title and self.title_maxlen <= 0: title = convert_between(source_format, body_format, title) # Truncate mode keeps only the first of multiple attachments. # Prepare it once for all calls made by this service. overflow = self.overflow_mode if overflow is None else overflow if attach and len(attach) > 1 and overflow == OverflowMode.TRUNCATE: # Save the first attachment. attach_ = AppriseAttachment(attach[0], asset=self.asset) else: # Reuse the original attachment collection. attach_ = attach # Apply overflow and dialect resizing to every chunk first so plugins # receive the final call count before delivery begins. all_pieces = [ self._apply_dialect_overflow( chunk, overflow, body_format, body_passthrough ) for chunk in self._apply_overflow( body=body, title=title, overflow=overflow, body_format=body_format, body_passthrough=body_passthrough, _payload_precapped=_payload_precapped, ) ] total = sum(len(pieces) for pieces in all_pieces) # Select one attachment-bearing call before split delivery begins. # This avoids duplicates and shared state during concurrent sends. attach_index = self._attachment_send_index(total) if attach_ else None # Track each call's position across all chunks so plugins can place # attachments relative to the first or last delivery. index = 0 for pieces in all_pieces: for piece in pieces: # Send notification yield { "body": piece["body"], "title": piece["title"], "notify_type": notify_type, # Attach only on the one call chosen above; never repeat # it across overflow chunks or dialect-split pieces. "attach": attach_ if index == attach_index else None, "body_format": body_format, "body_passthrough": body_passthrough, "index": index, "total": total, } index += 1 def _enforce_payload_cap(self, title: str, body: str) -> tuple[str, str]: """Apply the asset's payload_max_size to a title and body. Returns the (possibly trimmed) title and body, logging a warning when anything was removed. """ original_len = len(title) + len(body) title, body = self.asset.enforce_payload_max_size(title, body) if len(title) + len(body) < original_len: self.logger.warning( "%s payload trimmed to stay within the configured " "payload_max_size of %d characters.", self.service_name, self.asset._payload_max_size, ) return title, body def _attachment_send_index(self, total: int) -> int: """Return the flat call index that should carry an attachment. The default is the first call. Plugins may use ``total`` to select a later call when their delivery order requires it. """ return 0 def _apply_overflow( self, body: Optional[str], title: Optional[str] = None, overflow: Optional[Union[str, OverflowMode]] = None, body_format: Optional[NotifyFormat] = None, body_passthrough: Optional[bool] = None, _payload_precapped: object = None, ) -> list[dict[str, str]]: """Apply upstream, truncation, or split overflow rules. The title may be merged into the body when the service has no title field. ``body_format`` guides splitting and Markdown repair, while ``body_passthrough`` skips format conversion for an undeclared source. Returns one or more independently deliverable title/body mappings: [ { title: 'the title goes here', body: 'the message body goes here', }, { title: 'the title goes here', body: 'the continued message body goes here', }, ] """ response: list[dict[str, str]] = [] # Tidy title = "" if not title else title.strip() body = "" if not body else body.rstrip() # Direct plugin calls still need the source-size cap. Only Apprise's # private token confirms it was already applied before conversion. if _payload_precapped is not _PAYLOAD_PRECAPPED: title, body = self._enforce_payload_cap(title, body) # Default overflow mode if overflow is None: overflow = self.overflow_mode if body_passthrough is None: body_passthrough = body_format is None body_format = self.resolve_format(body_format) # If the service does not support a title, amalgamate into body if self.title_maxlen <= 0 and len(title) > 0: if body_format == NotifyFormat.HTML: body = ( f"<{self.default_html_tag_id}>{title}" f"" f"
    \r\n{body}" ) elif body_format == NotifyFormat.MARKDOWN and not body_passthrough: # Declared Markdown/CommonMark can receive a heading. # Undeclared content uses the normal plain-text title layout. title = title.lstrip("\r\n \t\v\f#-") if title: body = f"# {title}\n{body}" else: body = f"{title}\r\n{body}" title = "" # Enforce line count if self.body_max_line_count > 0: lines = re.split(r"\r*\n", body) body = "\r\n".join(lines[0 : self.body_max_line_count]) # UPSTREAM mode: do not touch content if overflow == OverflowMode.UPSTREAM: response.append({"body": body, "title": title}) return response # a value of '2' allows for the \r\n that is applied when amalgamating overflow_buffer = ( max(2, self.overflow_buffer) if (self.title_maxlen == 0 and len(title)) else self.overflow_buffer ) # # TRUNCATE and SPLIT require sizing logic # # Handle situations where body and title are amalgamated title_maxlen = ( self.title_maxlen if not self.overflow_amalgamate_title else min( len(title) + self.overflow_max_display_count_width, self.title_maxlen, self.body_maxlen, ) ) if len(title) > title_maxlen: # Truncate our title title = title[:title_maxlen].rstrip() # Compute body_maxlen as per legacy logic if ( self.overflow_amalgamate_title and (self.body_maxlen - overflow_buffer) >= title_maxlen ): # status quo body_maxlen = ( self.body_maxlen if not title else (self.body_maxlen - title_maxlen) ) - overflow_buffer else: # If the body fits, we're done body_maxlen = ( self.body_maxlen if not self.overflow_amalgamate_title else (self.body_maxlen - overflow_buffer) ) # If the body fits, we are done if body_maxlen > 0 and len(body) <= body_maxlen: response.append({"body": body, "title": title}) return response # TRUNCATE mode: hard truncation (no smart-splitting) if overflow == OverflowMode.TRUNCATE: truncated = body[:body_maxlen].lstrip("\r\n\x0b\x0c").rstrip() if body_format == NotifyFormat.MARKDOWN and not body_passthrough: # An odd run of trailing backslashes means the last one # lost the character it escaped; drop it so it does not # dangle at the end of the message. if (len(truncated) - len(truncated.rstrip("\\"))) % 2: truncated = truncated[:-1].rstrip() # Repair truncated constructs before dialect conversion. # Bounded discarded text can identify a possible closer. lookahead_end = body_maxlen + body_maxlen * 8 lookahead = body[body_maxlen:lookahead_end] boundary_next_ch = ( body[lookahead_end : lookahead_end + 1] or None ) truncated, _ = commonmark_repair_chunk( truncated, {}, next_chunk=lookahead or None, next_chunk_boundary_ch=boundary_next_ch, ) response.append({"body": truncated, "title": title}) return response # # SPLIT mode # # Detect if we only display our title once or not (legacy logic) if self.overflow_display_title_once is None: # Detect if we only display our title once or not: overflow_display_title_once = bool( self.overflow_amalgamate_title and body_maxlen < self.overflow_display_count_threshold ) else: overflow_display_title_once = self.overflow_display_title_once # SPLIT mode with repeated title (with/without counter) if not overflow_display_title_once and not ( # edge case: amalgamated title but no body space self.overflow_amalgamate_title and body_maxlen <= 0 ): # Decide whether to show a counter (legacy condition) show_counter = ( title and len(body) > body_maxlen and ( ( self.overflow_amalgamate_title and body_maxlen >= self.overflow_display_count_threshold ) or ( not self.overflow_amalgamate_title and title_maxlen > self.overflow_display_count_threshold ) ) and ( title_maxlen > (self.overflow_max_display_count_width + overflow_buffer) and self.title_maxlen >= self.overflow_display_count_threshold ) ) effective_body_maxlen = body_maxlen if show_counter: # introduce padding for the counter effective_body_maxlen -= overflow_buffer # Use smart splitting instead of naive slicing chunks = smart_split( body, effective_body_maxlen, body_format, ) count = len(chunks) template = "" if show_counter: digits = len(str(count)) overflow_display_count_width = 4 + (digits * 2) if ( overflow_display_count_width <= self.overflow_max_display_count_width ): # Truncate title further if needed to make room for counter t_max = title_maxlen - overflow_display_count_width if len(title) > t_max: title = title[:t_max] template = f" [{{:0{digits}d}}/{{:0{digits}d}}]" else: # Too many messages; fall back to repeated title without # counter displayed show_counter = False response = [] for idx, chunk_body in enumerate(chunks, start=1): suffix = template.format(idx, count) if show_counter else "" response.append( { "body": chunk_body.lstrip("\r\n\x0b\x0c").rstrip(), "title": f"{title}{suffix}", # Preserve this repeated title if dialect conversion # splits the chunk again. "repeat_title": True, } ) else: # # SPLIT mode, display title once and move on # (this covers both overflow_display_title_once=True # and the edge case body_maxlen <= 0 with amalgamated title) # response = [] consumed = 0 remainder = body if body_maxlen > 0 and body: # First chunk uses body_maxlen (which already accounts for # the title) first_chunks = smart_split( body, body_maxlen, body_format, ) first_body = first_chunks[0] if first_chunks else "" consumed = len(first_body) remainder = body[consumed:] response.append( { "body": first_body.lstrip("\r\n\x0b\x0c").rstrip(), "title": title, } ) else: # body_maxlen <= 0 or no body; send title only, still honouring # body response.append( { "body": "", "title": title, } ) # remainder stays as full body; will be split below # Remaining chunks: no title, use the full body_maxlen of the # service if remainder: more_chunks = smart_split( remainder, self.body_maxlen, body_format, ) for chunk_body in more_chunks: response.append( { "body": chunk_body.lstrip("\r\n\x0b\x0c").rstrip(), "title": "", } ) if body_format == NotifyFormat.MARKDOWN and not body_passthrough: # Repair chunks before dialect conversion. Raw lookahead classifies # a trailing delimiter against its real neighbor. pending = {} for idx, chunk in enumerate(response): next_body = ( response[idx + 1]["body"] if idx + 1 < len(response) else None ) chunk["body"], pending = commonmark_repair_chunk( chunk["body"], pending, next_chunk=next_body ) return response def _apply_dialect_overflow( self, chunk: dict[str, str], overflow: Union[str, OverflowMode], body_format: Optional[NotifyFormat], body_passthrough: bool, ) -> list[dict[str, str]]: """Re-split or re-truncate one chunk after ``dialect_convert()``. Passthrough bodies and plugins without an override remain unchanged. The first piece keeps its title. Later pieces keep it only when the source chunk requested a repeated title. """ has_dialect = ( type(self).dialect_convert is not NotifyBase.dialect_convert ) if not has_dialect or body_passthrough: return [chunk] def convert_fn(body: str) -> str: return self.dialect_convert(body, body_format) if overflow == OverflowMode.SPLIT: bodies = split_dialect_chunk( chunk["body"], self.body_maxlen, convert_fn, body_format ) elif overflow == OverflowMode.TRUNCATE: bodies = [ truncate_dialect_chunk( chunk["body"], self.body_maxlen, convert_fn, body_format ) ] else: # UPSTREAM: convert without splitting and let the service # itself handle an oversized payload. bodies = [convert_fn(chunk["body"])] # Repeated titles follow every new piece; one-time titles stay first. repeat_title = chunk.get("repeat_title", False) return [ { "body": body, "title": chunk["title"] if (idx == 0 or repeat_title) else "", } for idx, body in enumerate(bodies) ] def dialect_convert( self, body: str, body_format: Optional[NotifyFormat] = None, *args: Any, **kwargs: Any, ) -> str: """Convert one chunk to the service's final markup dialect. Keep overrides stateless, accept ``*args, **kwargs``, and return unsupported formats unchanged. The framework reapplies size limits. """ return body def send( self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, **kwargs: Any, ) -> bool: """Should preform the actual notification itself.""" raise NotImplementedError( "send() is not implemented by the child class." ) def __len__(self): """Returns the number of HTTP requests this instance will make, factoring in the configured retry count. Subclasses that override this are automatically wrapped by __init_subclass__ to apply the same retry multiplier, so they should return their raw target count without worrying about retry. """ return self.retry + 1 def url_parameters( self, *args: Any, **kwargs: Any, ) -> dict[str, Any]: """Provides a default set of parameters to work with. This can greatly simplify URL construction in the acommpanied url() function in all defined plugin services. """ params = {"overflow": self.overflow_mode.value} # Single-format plugins always serialize their effective format. # Multi-format plugins only serialize format= when the user asked # for an explicit override. Omitting it keeps plain multi-format # URLs flexible on a round trip, so the next send can still align # to that call's body_format. formats = self._formats() if len(formats) == 1: params["format"] = formats[0].value elif self._format_override is not None: params["format"] = self._format_override.value # Timezone Information (if ZoneInfo) if self.__tzinfo and isinstance(self.__tzinfo, ZoneInfo): params["tz"] = self.__tzinfo.key # Persistent Storage Setting if self.persistent_storage != NotifyBase.persistent_storage: params["store"] = "yes" if self.persistent_storage else "no" # Always emit retry=, wait=, and optional= in the URL regardless # of whether they hold their zero/False defaults. Emitting them # unconditionally keeps the URL fully self-describing: a service # URL that is round-tripped through parse_url() -> __init__() will # reconstruct an identical object with the same settings. This # property is important for configuration-file generators, UI # tooling, and any code that serialises service objects to strings # and needs to restore them later without silent data loss. # # retry= and wait= are emitted as strings because URL query strings # are inherently text. The receiving __init__() will re-parse them # with int() and float() respectively. # # optional= is emitted as the canonical lower-case pair "yes"/"no" # that parse_bool() recognises on the receiving end, ensuring the # round-trip is lossless even for the boolean case. params["retry"] = str(self.retry) params["wait"] = str(self.wait) params["optional"] = "yes" if self.optional else "no" params.update(super().url_parameters(*args, **kwargs)) # return default parameters return params @staticmethod def parse_url( url: str, verify_host: bool = True, plus_to_space: bool = False, ) -> Optional[dict[str, Any]]: """Parses the URL and returns it broken apart into a dictionary. This is very specific and customized for Apprise. In addition to the fields extracted by URLBase.parse_url(), this method extracts the NotifyBase-level query-string parameters: ``format``, ``overflow``, ``emojis``, ``tz``, ``store``, ``retry``, ``wait``, and ``optional``. The extracted values are placed directly in the returned results dict under their respective keys, ready to be consumed by NotifyBase.__init__() (or a subclass). Child classes should call this method via ``super()`` and then layer their own parameter extraction on top of the returned dict. Args: url (str): The URL you want to fully parse. verify_host (:obj:`bool`, optional): a flag kept with the parsed URL which some child classes will later use to verify SSL keys (if SSL transactions take place). Unless under very specific circumstances, it is strongly recomended that you leave this default value set to True. Returns: A dictionary is returned containing the URL fully parsed if successful, otherwise None is returned. """ results = URLBase.parse_url( url, verify_host=verify_host, plus_to_space=plus_to_space ) if not results: # We're done; we failed to parse our url return results # Apply the query string options every notification supports return NotifyBase.parse_url_options(results) @staticmethod def parse_url_options(results: dict[str, Any]) -> dict[str, Any]: """Apply the query string options every notification supports. This reads ``format``, ``overflow``, ``emojis``, ``tz``, ``store``, ``retry``, ``wait`` and ``optional`` from ``results["qsd"]``. """ if not isinstance(results.get("qsd"), dict): # There is no query string to read options from return results # Allow overriding the default format if "format" in results["qsd"]: results["format"] = results["qsd"].get("format", "").lower() if results["format"] not in NOTIFY_FORMATS: URLBase.logger.warning( "Unsupported format specified %r", results["qsd"]["format"], ) del results["format"] # Allow overriding the default overflow if "overflow" in results["qsd"]: results["overflow"] = results["qsd"].get("overflow", "").lower() if results["overflow"] not in OVERFLOW_MODES: URLBase.logger.warning( "Unsupported overflow mode specified " f"{results['qsd']['overflow']!r}" ) del results["overflow"] # Allow emoji's override if "emojis" in results["qsd"]: results["emojis"] = parse_bool(results["qsd"].get("emojis")) # Store our persistent storage boolean # Allow overriding the default timezone if "tz" in results["qsd"]: results["tz"] = results["qsd"].get("tz", "") if "store" in results["qsd"]: results["store"] = results["qsd"]["store"] # Global per-plugin retry count (?retry=N) if "retry" in results["qsd"] and results["qsd"]["retry"]: results["retry"] = results["qsd"]["retry"] # Global per-plugin inter-retry wait (?wait=N) if "wait" in results["qsd"] and results["qsd"]["wait"]: results["wait"] = results["qsd"]["wait"] # Global optional flag (?optional=yes/no). # # parse_bool() converts the raw query-string value to a native # Python bool using Apprise's canonical truth table: "yes", "true", # "on", "1" -> True; "no", "false", "off", "0" -> False. # Unrecognised strings fall back to False (the safe default). # # The parsed bool is stored in results["optional"] so that # NotifyBase.__init__() receives a value it can pass straight # through parse_bool() again without loss (parse_bool(True) is # True and parse_bool(False) is False). # # When ?optional= is absent from the URL entirely, this block is # skipped and results["optional"] is never set. NotifyBase.__init__ # will then leave self.optional at its class-level default (False), # which means the absence of the parameter is indistinguishable from # passing ?optional=no -- both result in failures being propagated. if "optional" in results["qsd"]: results["optional"] = parse_bool(results["qsd"]["optional"]) return results @staticmethod def parse_native_url(url: str) -> Optional[dict[str, Any]]: """This is a base class that can be optionally over-ridden by child classes who can build their Apprise URL based on the one provided by the notification service they choose to use. The intent of this is to make Apprise a little more userfriendly to people who aren't familiar with constructing URLs and wish to use the ones that were just provied by their notification serivice that they're using. This function will return None if the passed in URL can't be matched as belonging to the notification service. Otherwise this function should return the same set of results that parse_url() does. """ return None @property def store(self): """Returns a pointer to our persistent store for use. The best use cases are: self.store.get('key') self.store.set('key', 'value') self.store.delete('key1', 'key2', ...) You can also access the keys this way: self.store['key'] And clear them: del self.store['key'] """ if self.__store is None: # Initialize our persistent store for use. With storage turned # off the store still works, it simply keeps everything in # memory so nothing is written to disk for this URL. self.__store = PersistentStore( namespace=self.url_id(), path=self.asset.storage_path, mode=( self.asset.storage_mode if self.persistent_storage else PersistentStoreMode.MEMORY ), ) return self.__store def flush_store(self) -> None: """Write this service's persistent store to disk if it was used.""" if self.__store is not None: self.__store.flush() @property def tzinfo(self) -> tzinfo: """Returns our tzinfo file associated with this plugin if set otherwise the default timezone is returned. """ return self.__tzinfo if self.__tzinfo else self.asset.tzinfo caronc-apprise-dfec414/apprise/plugins/betterstack.py000066400000000000000000000442231526720371000231350ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Better Stack offers two ways to receive notifications: # # Incidents (pages whoever is on-call): # 1. Visit https://betterstack.com/settings/api-tokens/0 and pick your # team. # 2. Copy (or create) a token in the "Uptime API tokens" section. # 3. Better Stack needs the e-mail of a user on your account to show # who requested the incident. # # Your Apprise URL is assembled as: # betterstack://{token}/{email} # # A global API token (https://betterstack.com/settings/global-api-tokens) # can raise an incident for any team. List the teams after the e-mail: # betterstack://{token}/{email}/{team}/{team2} # # Logs (adds an entry to a log source): # 1. Visit https://telemetry.betterstack.com/ and create (or open) a # log source of the type "HTTP". # 2. Copy the "Source token" and the "Ingesting host" it shows you. # # Your Apprise URL is assembled as: # betterstack://{source_token}@{ingesting_host} # # Resources: # - https://betterstack.com/docs/uptime/api/create-a-new-incident/ # - https://betterstack.com/docs/logs/ingesting-data/http/logs/ from __future__ import annotations from datetime import datetime, timezone from json import dumps from typing import Any, Optional, Union import requests from ..common import NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..utils.parse import is_email, parse_list, validate_regex from .base import NotifyBase # Extend HTTP Error Messages # Based on the response codes listed in the Better Stack API docs BETTERSTACK_HTTP_ERROR_MAP = { 402: "Quota or spending limit reached.", 403: "Invalid source token.", 404: "Authentication failed.", 406: "Payload could not be parsed.", 413: "Payload too large.", } class BetterStackMode: """Tracks the mode of operation for the Better Stack plugin.""" # Create an incident that alerts the on-call person INCIDENT = "incident" # Add an entry to a log source LOGS = "logs" # The ways Better Stack can alert the on-call person, mapped to the # payload field that switches each one on BETTERSTACK_ALERT_METHODS = { "call": "call", "sms": "sms", "email": "email", "push": "push", "critical": "critical_alert", } # Map Apprise notification types to Better Stack log levels BETTERSTACK_LOG_LEVELS = { NotifyType.INFO: "info", NotifyType.SUCCESS: "info", NotifyType.WARNING: "warn", NotifyType.FAILURE: "error", } class NotifyBetterStack(NotifyBase): """A wrapper for Better Stack Notifications.""" # The default descriptive name associated with the Notification service_name = "Better Stack" # The services URL service_url = "https://betterstack.com/" # The default secure protocol secure_protocol = "betterstack" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/betterstack/" # Better Stack Incidents API URL incident_url = "https://incidents.betterstack.com/api/v3/incidents" # Define object URL templates templates = ( # Incident mode "{schema}://{token}", "{schema}://{token}/{from_addr}", "{schema}://{token}/{targets}", "{schema}://{token}/{from_addr}/{targets}", # Logs mode "{schema}://{token}@{host}", "{schema}://{token}@{host}:{port}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("Token"), "type": "string", "private": True, "required": True, }, "host": { "name": _("Hostname"), "type": "string", }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "from_addr": { "name": _("From Email"), "type": "string", }, "targets": { "name": _("Target Team"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "token": { "alias_of": "token", }, "from": { "alias_of": "from_addr", }, "to": { "alias_of": "targets", }, "policy": { "name": _("Escalation Policy"), "type": "string", }, "alert": { "name": _("Alert Methods"), "type": "list:string", }, "team_wait": { "name": _("Team Wait"), "type": "int", "min": 0, }, }, ) def __init__( self, token: str, from_addr: Optional[str] = None, targets: Optional[Union[str, list[str]]] = None, policy: Optional[str] = None, alert: Optional[Union[str, list[str]]] = None, team_wait: Optional[Union[int, str]] = None, **kwargs: Any, ) -> None: """Initialize Better Stack Object.""" super().__init__(**kwargs) # An ingesting host is only given when sending logs self.mode = ( BetterStackMode.LOGS if self.host else BetterStackMode.INCIDENT ) # Our API or source token self.token = validate_regex(token) if not self.token: msg = "An invalid Better Stack token ({}) was specified.".format( token ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # The e-mail of the user requesting the incident self.from_addr = None if from_addr: result = is_email(from_addr) if not result: msg = ( "An invalid Better Stack From Email ({}) was " "specified.".format(from_addr) ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) self.from_addr = result["full_email"] # The teams to raise an incident for. Team names may hold spaces. self.targets = parse_list(targets, allow_whitespace=False) # The escalation policy to use self.policy = policy.strip() if policy else None # The alert methods to switch on. None leaves the choice to the # Better Stack team settings. self.alert = None if alert: self.alert = [] for method in parse_list(alert): method = method.lower() if method not in BETTERSTACK_ALERT_METHODS: msg = ( "An invalid Better Stack alert method ({}) was " "specified.".format(method) ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Store each method only once if method not in self.alert: self.alert.append(method) # Seconds to wait before escalating to the whole team self.team_wait = None if team_wait not in (None, ""): try: self.team_wait = int(team_wait) if self.team_wait < 0: raise ValueError() except (TypeError, ValueError): msg = ( "An invalid Better Stack team wait ({}) was " "specified.".format(team_wait) ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) from None def send( self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, **kwargs: Any, ) -> bool: """Perform Better Stack Notification.""" if self.mode == BetterStackMode.LOGS: # Prepare our log entry payload = { "dt": datetime.now(timezone.utc).isoformat(), "level": BETTERSTACK_LOG_LEVELS.get(notify_type, "info"), "message": body, } # Only include a title when one was given if title: payload["title"] = title # Prepare our URL url = "https://{host}{port}".format( host=self.host, port="" if self.port is None else ":{}".format(self.port), ) return self._post(url, payload) # Prepare our incident payload payload = { "summary": title if title else body, } # The body becomes the full description when a title is used if title: payload["name"] = title payload["description"] = body if self.from_addr: payload["requester_email"] = self.from_addr if self.policy: payload["policy_id"] = self.policy if self.team_wait is not None: payload["team_wait"] = self.team_wait if self.alert is not None: # Switch on the methods asked for and switch off the rest for method, field in BETTERSTACK_ALERT_METHODS.items(): payload[field] = method in self.alert # Without teams, the team that owns the token gets the incident has_error = False for team in self.targets if self.targets else [None]: # Skip a team that already has this incident so a retry # does not raise it twice if self.is_delivered(team): continue # Name the team that should own the incident if team: payload["team_name"] = team if not self._post(self.incident_url, payload): # Mark our failure has_error = True continue # Delivered, so a retry can safely skip this team self.mark_delivered(team) return not has_error def _post(self, url: str, payload: dict[str, Any]) -> bool: """Post ``payload`` to ``url`` and return whether it was accepted.""" # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Authorization": "Bearer {}".format(self.token), } self.logger.debug( "Better Stack POST URL: %s (cert_verify=%s)", url, self.verify_certificate, ) self.logger.debug("Better Stack Payload: %s", payload) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code not in ( requests.codes.ok, requests.codes.created, requests.codes.accepted, requests.codes.no_content, ): # We had a problem status_str = NotifyBetterStack.http_response_code_lookup( r.status_code, BETTERSTACK_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Better Stack %s: %s%serror=%s.", self.mode, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug("Response Details:\r\n%s", r.content) # Return; we're done return False self.logger.info("Sent Better Stack %s.", self.mode) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Better Stack %s.", self.mode, ) self.logger.debug("Socket Exception: %s", str(e)) # Return; we're done return False return True @property def url_identifier(self) -> tuple[Any, ...]: """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol, self.mode, self.token, self.host, self.port if self.port else 443, ) def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str: """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = {} if self.policy: params["policy"] = self.policy if self.alert is not None: params["alert"] = ",".join(self.alert) if self.team_wait is not None: params["team_wait"] = str(self.team_wait) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) if self.mode == BetterStackMode.LOGS: return "{schema}://{token}@{host}{port}/?{params}".format( schema=self.secure_protocol, token=self.pprint(self.token, privacy, safe=""), host=self.host, port="" if self.port is None else ":{}".format(self.port), params=NotifyBetterStack.urlencode(params), ) # Our requester e-mail (if any) leads the path, then our teams entries = [self.from_addr] if self.from_addr else [] entries += self.targets return "{schema}://{token}/{entries}?{params}".format( schema=self.secure_protocol, token=self.pprint(self.token, privacy, safe=""), entries="".join( "{}/".format(NotifyBetterStack.quote(e, safe="@")) for e in entries ), params=NotifyBetterStack.urlencode(params), ) def __len__(self) -> int: """Returns the number of targets associated with this notification.""" # One incident is raised per team, and always at least one return ( len(self.targets) if self.mode == BetterStackMode.INCIDENT and self.targets else 1 ) @staticmethod def parse_url(url: str) -> Optional[dict[str, Any]]: """Parses the URL and returns enough arguments that can allow us to re-instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results if results.get("user"): # Logs mode keeps the source token in the user field results["token"] = NotifyBetterStack.unquote(results["user"]) elif not ("token" in results["qsd"] and results["qsd"]["token"]): # Incident mode keeps the API token in the host field results["token"] = NotifyBetterStack.unquote(results["host"]) results["host"] = None # Support ?token= for an easier yaml configuration if "token" in results["qsd"] and results["qsd"]["token"]: results["token"] = NotifyBetterStack.unquote( results["qsd"]["token"] ) # Support ?from= for the requester e-mail if "from" in results["qsd"] and results["qsd"]["from"]: results["from_addr"] = NotifyBetterStack.unquote( results["qsd"]["from"] ) # Our path holds an optional requester e-mail followed by teams results["targets"] = [] for entry in NotifyBetterStack.split_path(results["fullpath"]): if not results.get("from_addr") and is_email(entry): # The first e-mail found is our requester results["from_addr"] = entry continue results["targets"].append(entry) # Support ?to= for teams if "to" in results["qsd"] and results["qsd"]["to"]: results["targets"] += NotifyBetterStack.parse_list( results["qsd"]["to"], allow_whitespace=False ) if "policy" in results["qsd"] and results["qsd"]["policy"]: results["policy"] = NotifyBetterStack.unquote( results["qsd"]["policy"] ) if "alert" in results["qsd"] and results["qsd"]["alert"]: results["alert"] = NotifyBetterStack.unquote( results["qsd"]["alert"] ) if "team_wait" in results["qsd"] and results["qsd"]["team_wait"]: results["team_wait"] = NotifyBetterStack.unquote( results["qsd"]["team_wait"] ) return results caronc-apprise-dfec414/apprise/plugins/blink1.py000066400000000000000000000274031526720371000220030ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Sources: # - https://blink1.thingm.com/ # - https://github.com/todbot/blink1-python # - https://github.com/todbot/blink1/blob/main/docs/blink1-hid-commands.md import time from ..common import NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from .base import NotifyBase # Default our global support flag NOTIFY_BLINK1_ENABLED = False try: import hid as blink1_hid NOTIFY_BLINK1_ENABLED = True except ImportError: # No problem -- hidapi is an optional dependency. Users who wish to # use this plugin must install it: pip install hidapi pass # # blink(1) USB HID constants # # USB vendor / product identifiers for all blink(1) revisions BLINK1_VENDOR_ID = 0x27B8 BLINK1_PRODUCT_ID = 0x01ED # HID report size (report ID byte + 8 payload bytes) BLINK1_REPORT_ID = 0x01 BLINK1_REPORT_SIZE = 9 # Command byte: 'c' == fade to RGB with a time argument BLINK1_CMD_FADE = ord("c") class Blink1LED: """ Defines the LED to focus on """ ALL = 0 FIRST = 1 SECOND = 2 BLINK1_LED_CHOICES = { Blink1LED.ALL: "all", Blink1LED.FIRST: "1", Blink1LED.SECOND: "2", } # Maps inbound strings to Blink1LED constants; unknown values fall back to ALL BLINK1_LED_MAP = { "0": Blink1LED.ALL, "all": Blink1LED.ALL, "a": Blink1LED.ALL, "1": Blink1LED.FIRST, "2": Blink1LED.SECOND, } # How long (ms) to hold the LED colour after the fade completes BLINK1_DEFAULT_DURATION_MS = 5000 BLINK1_MIN_DURATION_MS = 0 BLINK1_MAX_DURATION_MS = 300000 # 5 minutes # Fade transition time (ms); 0 = instant BLINK1_DEFAULT_FADE_MS = 0 BLINK1_MIN_FADE_MS = 0 BLINK1_MAX_FADE_MS = 10000 # 10 seconds def _blink1_fade_buf(red, green, blue, fade_ms, ledn): """Build the 9-byte HID feature-report buffer for a fade-to-RGB command. The blink(1) wire format (all values unsigned 8-bit unless noted): [0] REPORT_ID (0x01) [1] command 'c' (0x63) [2] red [3] green [4] blue [5] fade_time high byte (fade_time = fade_ms // 10, 16-bit big-endian) [6] fade_time low byte [7] ledn (0=all, 1=LED1, 2=LED2) [8] 0x00 (padding) """ fade_time = int(fade_ms) // 10 th = (fade_time >> 8) & 0xFF tl = fade_time & 0xFF return [ BLINK1_REPORT_ID, BLINK1_CMD_FADE, int(red), int(green), int(blue), th, tl, int(ledn), 0x00, ] class NotifyBlink1(NotifyBase): """A wrapper for blink(1) USB LED notifications. Colors are driven by Apprise's notification-type color map (info=blue, success=green, warning=yellow, failure=red). No external blink1 library is required; the USB HID wire protocol is implemented directly via the hidapi package. """ # Set our global enabled flag enabled = NOTIFY_BLINK1_ENABLED requirements = { "packages_required": "hidapi", } # The default descriptive name associated with the notification service_name = _("blink(1)") # The services URL service_url = "https://blink1.thingm.com/" # The default protocol protocol = "blink1" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/blink1/" # blink(1) is a local USB device; a title field has no meaning here. title_maxlen = 0 # No throttling needed for a local USB device request_rate_per_sec = 0 # URL templates templates = ( "{schema}://", "{schema}://{serial}/", ) template_tokens = dict( NotifyBase.template_tokens, **{ "serial": { "name": _("Serial Number"), "type": "string", }, }, ) template_args = dict( NotifyBase.template_args, **{ "duration": { "name": _("Duration (ms)"), "type": "int", "min": BLINK1_MIN_DURATION_MS, "max": BLINK1_MAX_DURATION_MS, "default": BLINK1_DEFAULT_DURATION_MS, }, "fade": { "name": _("Fade Time (ms)"), "type": "int", "min": BLINK1_MIN_FADE_MS, "max": BLINK1_MAX_FADE_MS, "default": BLINK1_DEFAULT_FADE_MS, }, "ledn": { "name": _("LED Number"), "type": "choice:int", "values": BLINK1_LED_CHOICES, "default": Blink1LED.ALL, }, }, ) def __init__( self, serial=None, duration=None, fade=None, ledn=None, **kwargs, ): """Initialize Blink1 Object.""" super().__init__(**kwargs) # Device serial number; None means "first available device". # An underscore is accepted as a URL placeholder meaning "any". serial = serial.strip() if isinstance(serial, str) else None self.serial = serial if serial and serial != "_" else None # Duration (ms) to hold the LED colour before turning it off try: self.duration = int( BLINK1_DEFAULT_DURATION_MS if duration is None else duration ) if not ( BLINK1_MIN_DURATION_MS <= self.duration <= BLINK1_MAX_DURATION_MS ): raise AppriseImproperlyConfigured("out of range") except (TypeError, ValueError): msg = ( f"blink(1) duration ({duration}) must be between" f" {BLINK1_MIN_DURATION_MS} and" f" {BLINK1_MAX_DURATION_MS} ms." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) from None # Fade transition time (ms) try: self.fade = int(BLINK1_DEFAULT_FADE_MS if fade is None else fade) if not (BLINK1_MIN_FADE_MS <= self.fade <= BLINK1_MAX_FADE_MS): raise AppriseImproperlyConfigured("out of range") except (TypeError, ValueError): msg = ( f"blink(1) fade ({fade}) must be between" f" {BLINK1_MIN_FADE_MS} and {BLINK1_MAX_FADE_MS} ms." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) from None # LED selector; unrecognised values silently fall back to ALL self.ledn = ( BLINK1_LED_MAP.get(str(ledn).lower(), Blink1LED.ALL) if ledn is not None else Blink1LED.ALL ) def _open_device(self): """Open and return a hidapi device handle for the blink(1). Returns None and logs a warning when the device cannot be found. """ try: dev = blink1_hid.device() dev.open( BLINK1_VENDOR_ID, BLINK1_PRODUCT_ID, self.serial, ) return dev except OSError: msg = "Failed to open blink(1) device" if self.serial: msg += f" (serial={self.serial})" msg += "." self.logger.warning(msg) return None def _send_fade(self, dev, red, green, blue, fade_ms): """Send a single fade-to-RGB HID feature report. Returns True on success, False if the report could not be sent. """ buf = _blink1_fade_buf(red, green, blue, fade_ms, self.ledn) try: rc = dev.send_feature_report(buf) except OSError as e: self.logger.warning("blink(1) HID write failed: %s", e) return False if rc != BLINK1_REPORT_SIZE: self.logger.warning( "blink(1) HID write returned %d (expected %d).", rc, BLINK1_REPORT_SIZE, ) return False return True @property def url_identifier(self): """Returns all fields that uniquely identify this connection.""" return ( self.protocol, self.serial, self.ledn, ) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform blink(1) Notification.""" # Resolve the RGB triple for this notification type r, g, b = self.color(notify_type=notify_type, color_type=tuple) dev = self._open_device() if dev is None: return False try: # Always throttle before any device I/O self.throttle() if not self._send_fade(dev, r, g, b, self.fade): return False # Hold for fade + duration, then switch off time.sleep((self.fade + self.duration) / 1000.0) # Turn off: instant fade to black if not self._send_fade(dev, 0, 0, 0, 0): return False finally: dev.close() self.logger.info("Sent blink(1) notification.") return True def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" params = { "duration": str(self.duration), "fade": str(self.fade), "ledn": str(self.ledn), } params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) serial = self.serial if self.serial else "_" return f"{self.protocol}://{serial}/?{NotifyBlink1.urlencode(params)}" @staticmethod def parse_url(url): """Parses the URL and returns enough arguments to re-instantiate.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: return results # The hostname, when present, is the device serial number. # An underscore or empty value means "first available device". host = results.get("host", "") if host and host != "_": results["serial"] = NotifyBlink1.unquote(host) if results["qsd"].get("duration"): results["duration"] = NotifyBlink1.unquote( results["qsd"]["duration"] ) if results["qsd"].get("fade"): results["fade"] = NotifyBlink1.unquote(results["qsd"]["fade"]) if results["qsd"].get("ledn"): results["ledn"] = NotifyBlink1.unquote(results["qsd"]["ledn"]) return results @staticmethod def runtime_deps(): """Return optional runtime dependency package names.""" return ("hid",) caronc-apprise-dfec414/apprise/plugins/bluesky.py000066400000000000000000000624501526720371000223020ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # 1. Create a BlueSky account # 2. Access Settings -> Privacy and Security # 3. Generate an App Password. Optionally grant yourself access to Direct # Messages if you want to be able to send them # 4. Assemble your Apprise URL like: # bluesky://handle@you-token-here # from copy import deepcopy from datetime import datetime, timedelta, timezone import json import re import requests from ..attachment.base import AttachBase from ..common import NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.http import is_secure_http_url, read_bounded_response from .base import NotifyBase # For parsing handles HANDLE_HOST_PARSE_RE = re.compile(r"(?P[^.]+)\.+(?P.+)$") IS_USER = re.compile(r"^\s*@?(?P[A-Z0-9_]+)(\.+(?P.+))?$", re.I) class NotifyBlueSky(NotifyBase): """A wrapper for BlueSky Notifications.""" # The default descriptive name associated with the Notification service_name = "BlueSky" # The services URL service_url = "https://bluesky.us/" # Protocol secure_protocol = ("bsky", "bluesky") # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/bluesky/" # Support attachments attachment_support = True # XRPC Suffix URLs; Structured as: # https://host/{suffix} # Taken right from google.auth.helpers: clock_skew = timedelta(seconds=10) # 1 hour in seconds (the lifetime of our token) access_token_lifetime_sec = timedelta(seconds=3600) # Detect your Decentralized Identitifer (DID), then you can get your Auth # Token. xrpc_suffix_did = "/xrpc/com.atproto.identity.resolveHandle" xrpc_suffix_session = "/xrpc/com.atproto.server.createSession" xrpc_suffix_record = "/xrpc/com.atproto.repo.createRecord" xrpc_suffix_blob = "/xrpc/com.atproto.repo.uploadBlob" plc_directory = "https://plc.directory/{did}" # BlueSky is kind enough to return how many more requests we're allowed to # continue to make within it's header response as: # RateLimit-Reset: The epoc time (in seconds) we can expect our # rate-limit to be reset. # RateLimit-Remaining: an integer identifying how many requests we're # still allow to make. request_rate_per_sec = 0 # For Tracking Purposes ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) # Remaining messages ratelimit_remaining = 1 # The default BlueSky host to use if one isn't specified bluesky_default_host = "bsky.social" # Identity and DID documents are small, so we cap what we will read # back from the servers that hand them to us. max_response_bytes = 1024 * 1024 # Our message body size body_maxlen = 280 # BlueSky does not support a title title_maxlen = 0 # Define object templates templates = ("{schema}://{user}@{password}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("Username"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, }, ) def __init__(self, **kwargs): """Initialize BlueSky Object.""" super().__init__(**kwargs) # Our access token self.__access_token = self.store.get("access_token") self.__refresh_token = None self.__access_token_expiry = datetime.now(timezone.utc) self.__endpoint = self.store.get("endpoint") if not self.user: msg = "A BlueSky UserID/Handle must be specified." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Set our default host self.host = self.bluesky_default_host self.__endpoint = ( f"https://{self.host}" if not self.host else self.__endpoint ) # Identify our Handle (if define) results = HANDLE_HOST_PARSE_RE.match(self.user) if results: self.user = results.group("handle").strip() self.host = results.group("host").strip() return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform BlueSky Notification.""" if not self.__access_token and not self.login(): # We failed to authenticate - we're done return False # Track our returning blob IDs as they're stored on the BlueSky server blobs = [] if attach and self.attachment_support: url = f"{self.__endpoint}{self.xrpc_suffix_blob}" # We need to upload our payload first so that we can source it # in remaining messages for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access attachment" f" {attachment.url(privacy=True)}." ) return False if not re.match(r"^image/.*", attachment.mimetype, re.I): # Only support images at this time self.logger.warning( "Ignoring unsupported BlueSky attachment" f" {attachment.url(privacy=True)}." ) continue self.logger.debug( "Preparing BlueSky attachment" f" {attachment.url(privacy=True)}" ) # Upload our image and get our blob associated with it postokay, response = self._fetch( url, payload=attachment, ) if not postokay: # We can't post our attachment return False # Prepare our filename filename = ( attachment.name if attachment.name else f"file{no:03}.dat" ) if not (isinstance(response, dict) and response.get("blob")): self.logger.debug( "Could not attach the file to BlueSky: %s (mime=%s)", filename, attachment.mimetype, ) continue blobs.append((response.get("blob"), filename)) # Prepare our URL did, endpoint = self.get_identifier() url = f"{endpoint}{self.xrpc_suffix_record}" # prepare our batch of payloads to create payloads = [] payload = { "collection": "app.bsky.feed.post", "repo": did, "record": { "text": body, # 'YYYY-mm-ddTHH:MM:SSZ' "createdAt": datetime.now(tz=timezone.utc).strftime("%FT%XZ"), "$type": "app.bsky.feed.post", }, } if blobs: for no, blob in enumerate(blobs, start=1): # Deep copy so each post gets its own record; a shallow # copy would share one record between every post payload_ = deepcopy(payload) if no > 1: # # multiple instances # # 1. update createdAt time # 2. Change text to identify image no payload_["record"]["createdAt"] = datetime.now( tz=timezone.utc ).strftime("%FT%XZ") payload_["record"]["text"] = f"{no:02d}/{len(blobs):02d}" payload_["record"]["embed"] = { "images": [ { "image": blob[0], "alt": blob[1], } ], "$type": "app.bsky.embed.images", } payloads.append(payload_) else: payloads.append(payload) for no, payload in enumerate(payloads, start=1): # Skip a post that was already created so a retry does not # publish it twice if self.is_delivered(no): continue # Send Login Information postokay, response = self._fetch( url, payload=json.dumps(payload), ) if not postokay: # We failed # Bad responses look like: # { # 'error': 'InvalidRequest', # 'message': 'reason' # } return False # Posted; a retry can safely skip this post. self.mark_delivered(no) return True def get_identifier(self, user=None): """Performs a Decentralized User Lookup and returns the identifier.""" if user is None: user = self.user user = f"{user}.{self.host}" if "." not in user else f"{user}" did_key = f"did.{user}" endpoint_key = f"endpoint.{user}" did = self.store.get(did_key) endpoint = self.store.get(endpoint_key) if did and endpoint: # Early return return did, endpoint # Step 1: Acquire DID from bsky.app url = f"https://public.api.bsky.app{self.xrpc_suffix_did}" params = {"handle": user} # Resolve the handle to a DID postokay, response = self._fetch( url, params=params, method="GET", # A public service; our token does not belong here auth=False, max_response_bytes=self.max_response_bytes, ) if not postokay or not isinstance(response, dict): # We failed return (False, False) # Store our DID did = response.get("did") if not isinstance(did, str): self.logger.warning("An unparseable BlueSky DID was returned") return (False, False) # Step 2: Use DID to find the PDS if did.startswith("did:plc:"): did_url = self.plc_directory.format(did=did) elif did.startswith("did:web:"): # Convert to domain did_url = f"https://{did[8:]}/.well-known/did.json" else: self.logger.warning( f"Unknown BlueSky DID scheme detected in {did}" ) return (False, False) # Acquire our DID document postokay, service_response = self._fetch( did_url, method="GET", # The document can live on any host at all auth=False, max_response_bytes=self.max_response_bytes, ) if not postokay or not isinstance(service_response, dict): # We failed self.logger.warning( f"Could not fetch the DID document for {did}; " f"ensure {did_url} is available." ) return (False, False) # Extract the list of services from the DID document. services = service_response.get("service") if not isinstance(services, list): services = [] endpoint = next( ( s.get("serviceEndpoint") for s in services if isinstance(s, dict) and s.get("type") == "AtprotoPersonalDataServer" ), None, ) # Step 3: Send to correct endpoint if not is_secure_http_url(endpoint) or "?" in endpoint: self.logger.warning( "Failed to resolve a usable BlueSky PDS endpoint" ) return (False, False) self.store.set(did_key, did) self.store.set(endpoint_key, endpoint) return (did, endpoint) def login(self): """A simple wrapper to authenticate with the BlueSky Server.""" # Acquire our Decentralized Identitifer did, self.__endpoint = self.get_identifier(self.user) if not did: return False url = f"{self.__endpoint}{self.xrpc_suffix_session}" payload = { "identifier": did, "password": self.password, } # Send Login Information postokay, response = self._fetch( url, payload=json.dumps(payload), # Our password is in the payload above credentials=True, max_response_bytes=self.max_response_bytes, ) # Our response object looks like this (content has been altered for # presentation purposes): # { # 'did': 'did:plc:ruk414jakghak402j1jqekj2', # 'didDoc': { # '@context': [ # 'https://www.w3.org/ns/did/v1', # 'https://w3id.org/security/multikey/v1', # 'https://w3id.org/security/suites/secp256k1-2019/v1' # ], # 'id': 'did:plc:ruk414jakghak402j1jqekj2', # 'alsoKnownAs': ['at://apprise.bsky.social'], # 'verificationMethod': [ # { # 'id': 'did:plc:ruk414jakghak402j1jqekj2#atproto', # 'type': 'Multikey', # 'controller': 'did:plc:ruk414jakghak402j1jqekj2', # 'publicKeyMultibase' 'redacted' # } # ], # 'service': [ # { # 'id': '#atproto_pds', # 'type': 'AtprotoPersonalDataServer', # 'serviceEndpoint': # 'https://woodtuft.us-west.host.bsky.network' # } # ] # }, # 'handle': 'apprise.bsky.social', # 'email': 'whoami@gmail.com', # 'emailConfirmed': True, # 'emailAuthFactor': False, # 'accessJwt': 'redacted', # 'refreshJwt': 'redacted', # 'active': True, # } if not postokay or not response: # We failed return False # Acquire our Token self.__access_token = response.get("accessJwt") # Handle other optional arguments we can use self.__access_token_expiry = ( self.access_token_lifetime_sec + datetime.now(timezone.utc) - self.clock_skew ) # The Refresh Token self.__refresh_token = response.get("refreshJwt", self.__refresh_token) self.store.set( "access_token", self.__access_token, self.__access_token_expiry ) self.store.set( "refresh_token", self.__refresh_token, self.__access_token_expiry ) self.store.set("endpoint", self.__endpoint) self.logger.info( f"Authenticated to BlueSky as {self.user}.{self.host}" ) return True def _loggable_content(self, body, credentials=False): """Return enough of a reply for a useful debug log entry. A reply to a request that carried our password is held back while secure logging is on. """ if credentials and self.asset.secure_logging: return "" if not isinstance(body, (bytes, str)): # Nothing worth showing, and this is called while handling an # error, so it must not raise one of its own. return b"" return body[:2000] def _fetch( self, url, payload=None, params=None, method="POST", content_type=None, credentials=False, auth=True, max_response_bytes=None, ): """Wrapper to BlueSky API requests object. Set ``credentials`` when the payload contains a password. This hides the payload from logs and prevents redirects to another server. Set ``auth`` to False for lookups made against somebody else's server, so our session token is not handed to them. Set ``max_response_bytes`` to discard replies larger than the limit. """ # use what was specified, otherwise build headers dynamically headers = { "User-Agent": self.app_id, "Content-Type": ( payload.mimetype if isinstance(payload, AttachBase) else ( "application/x-www-form-urlencoded; charset=utf-8" if method == "GET" else "application/json" ) ), } if auth and self.__access_token: # Set our token headers["Authorization"] = f"Bearer {self.__access_token}" # Some Debug Logging self.logger.debug( f"BlueSky {method} URL:" f" {url} (cert_verify={self.verify_certificate})" ) if credentials and self.asset.secure_logging: # Our login payload holds the account password. loggable_payload = "" elif isinstance(payload, AttachBase): loggable_payload = "attach: " + payload.name else: loggable_payload = str(payload) self.logger.debug("BlueSky Payload: %s", loggable_payload) # By default set wait to None wait = None if self.ratelimit_remaining == 0: # Determine how long we should wait for or if we should wait at # all. This isn't fool-proof because we can't be sure the client # time (calling this script) is completely synced up with the # Twitter server. One would hope we're on NTP and our clocks are # the same allowing this to role smoothly: now = datetime.now(timezone.utc).replace(tzinfo=None) if now < self.ratelimit_reset: # We need to throttle for the difference in seconds # We add 0.3 seconds to the end just to allow a grace # period. wait = (self.ratelimit_reset - now).total_seconds() + 0.3 # Always call throttle before any remote server i/o is made; self.throttle(wait=wait) # Initialize a default value for our content value content = {} # acquire our request mode fn = requests.post if method == "POST" else requests.get try: r = fn( url, data=( payload if not isinstance(payload, AttachBase) else payload.open() ), params=params, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, # Never redirect a request carrying credentials allow_redirects=False if credentials else self.redirects, stream=max_response_bytes is not None, ) if max_response_bytes is None: body = r.content else: body = read_bounded_response(r, max_response_bytes) if body is None: self.logger.warning( "BlueSky reply from %s passed %d bytes; discarding" " it.", url, max_response_bytes, ) return (False, {}) # Get our JSON content if it's possible try: content = json.loads(body) except ( AttributeError, RecursionError, TypeError, ValueError, ): # TypeError = body is not a String # ValueError = body is Unparsable # AttributeError = body is None # RecursionError = body is nested too deeply to parse content = {} self.logger.debug( "Failed to parse BlueSky JSON response; body: %r", self._loggable_content(body, credentials), ) # Rate limit handling... our header objects at this point are: # 'RateLimit-Limit': '10', # Total # of requests per hour # 'RateLimit-Remaining': '9', # Requests remaining # 'RateLimit-Reset': '1741631362', # Epoch Time # 'RateLimit-Policy': '10;w=86400' # NoEntries;w= try: # Capture rate limiting if possible self.ratelimit_remaining = int( r.headers.get("ratelimit-remaining") ) self.ratelimit_reset = datetime.fromtimestamp( int(r.headers.get("ratelimit-reset")), timezone.utc ).replace(tzinfo=None) except (TypeError, ValueError): # This is returned if we could not retrieve this information # gracefully accept this state and move on pass if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyBlueSky.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send BlueSky {} to {}: {}error={}.".format( method, url, ", " if status_str else "", r.status_code ) ) self.logger.debug( "Response Details:\r\n%r", self._loggable_content(body, credentials), ) # Mark our failure return (False, content) except requests.RequestException as e: self.logger.warning( f"Exception received when sending BlueSky {method} to {url}: " ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure return (False, content) except OSError as e: self.logger.warning( "An I/O error occurred while handling {}.".format( payload.name if isinstance(payload, AttachBase) else payload ) ) self.logger.debug(f"I/O Exception: {e!s}") return (False, content) return (True, content) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol[0], self.user, self.password, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Apply our other parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) user = self.user if self.host != self.bluesky_default_host: user += f".{self.host}" # our URL return "{schema}://{user}@{password}?{params}".format( schema=self.secure_protocol[0], user=NotifyBlueSky.quote(user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), params=NotifyBlueSky.urlencode(params), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results if not results.get("password") and results["host"]: results["password"] = NotifyBlueSky.unquote(results["host"]) # Do not use host field results["host"] = None return results caronc-apprise-dfec414/apprise/plugins/brevo.py000066400000000000000000000503641526720371000217420ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # API Reference: https://developers.brevo.com/reference/getting-started-1 from json import dumps import logging from os.path import splitext import requests from .. import exception from ..common import NotifyFormat, NotifyType from ..conversion import convert_between from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..utils.parse import is_email, parse_list, validate_regex from ..utils.sanitize import sanitize_payload from .base import NotifyBase # Extend HTTP Error Messages (most common Brevo SMTP errors) BREVO_HTTP_ERROR_MAP = { 400: "Bad Request - Invalid payload or missing parameters.", 401: "Unauthorized - Invalid Brevo API key.", 402: "Payment Required - Plan limitation or credit issue.", 429: "Too Many Requests - Rate limit exceeded.", } # Comprehensive list of Brevo-supported extensions for Transactional Emails # Source: Brevo API Documentation & Transactional Attachment Guidelines BREVO_VALID_EXTENSIONS = ( # Documents & Text "xlsx", "xls", "ods", "docx", "docm", "doc", "csv", "pdf", "txt", "rtf", "msg", "pub", "mobi", "ppt", "pptx", "eps", "odt", "ics", "xml", "css", "html", "htm", "shtml", # Images "gif", "jpg", "jpeg", "png", "tif", "tiff", "bmp", "cgm", # Archives "zip", "tar", "ez", "pkpass", # Audio "mp3", "m4a", "m4v", "wma", "ogg", "flac", "wav", "aif", "aifc", "aiff", # Video "mp4", "mov", "avi", "mkv", "mpeg", "mpg", "wmv", ) class NotifyBrevo(NotifyBase): """A wrapper for Notify Brevo Notifications.""" # The default descriptive name associated with the Notification service_name = "Brevo" # The services URL service_url = "https://www.brevo.com/" # The default secure protocol secure_protocol = "brevo" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/brevo/" # Brevo can accept either HTML or plain text as the main message # body. HTML remains the default. notify_format = (NotifyFormat.HTML, NotifyFormat.TEXT) # The default Email API URL to use notify_url = "https://api.brevo.com/v3/smtp/email" # Support attachments attachment_support = True # Allow 300 requests per minute. # 60/300 = 0.2 request_rate_per_sec = 0.2 # The default subject to use if one isn't specified. default_empty_subject = "" # Define object templates templates = ( "{schema}://{apikey}:{from_email}", "{schema}://{apikey}:{from_email}/{targets}", ) # Define our template arguments template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, "regex": (r"^[a-zA-Z0-9._-]+$", "i"), }, "from_email": { "name": _("Source Email"), "type": "email", "required": True, }, "target_email": { "name": _("Target Email"), "type": "email", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "reply": { "name": _("Reply To Email"), "type": "string", "map_to": "reply_to", }, "to": { "alias_of": "targets", }, "cc": { "name": _("Carbon Copy"), "type": "list:string", }, "bcc": { "name": _("Blind Carbon Copy"), "type": "list:string", }, }, ) def __init__( self, apikey, from_email, targets=None, reply_to=None, cc=None, bcc=None, **kwargs, ): """Initialize Notify Brevo Object.""" super().__init__(**kwargs) # API Key (associated with project) self.apikey = validate_regex( apikey, *self.template_tokens["apikey"]["regex"] ) if not self.apikey: msg = f"An invalid Brevo API Key ({apikey}) was specified." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) result = is_email(from_email) if not result: msg = f"Invalid ~From~ email specified: {from_email}" self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Store email address self.from_email = result["full_email"] # Reply-to self.reply_to = None if reply_to: result = is_email(reply_to) if not result: msg = "An invalid Brevo Reply To ({}) was specified.".format( f"{reply_to}" ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) self.reply_to = ( result["name"] if result["name"] else False, result["full_email"], ) # Acquire Targets (To Emails) self.targets = [] # Acquire Carbon Copies self.cc = set() # Acquire Blind Carbon Copies self.bcc = set() # Validate recipients (to:) and drop bad ones: if targets: for recipient in parse_list(targets): result = is_email(recipient) if result: self.targets.append(result["full_email"]) continue self.logger.warning( f"Dropped invalid email ({recipient}) specified.", ) else: # add ourselves self.targets.append(self.from_email) # Validate recipients (cc:) and drop bad ones: for recipient in parse_list(cc): result = is_email(recipient) if result: self.cc.add(result["full_email"]) continue self.logger.warning( f"Dropped invalid Carbon Copy email ({recipient}) specified.", ) # Validate recipients (bcc:) and drop bad ones: for recipient in parse_list(bcc): result = is_email(recipient) if result: self.bcc.add(result["full_email"]) continue self.logger.warning( "Dropped invalid Blind Carbon Copy email " f"({recipient}) specified.", ) return @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.apikey, self.from_email) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Our URL parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) if len(self.cc) > 0: # Handle our Carbon Copy Addresses params["cc"] = ",".join(self.cc) if len(self.bcc) > 0: # Handle our Blind Carbon Copy Addresses params["bcc"] = ",".join(self.bcc) if self.reply_to: # Handle our reply to address params["reply"] = ( "{} <{}>".format(*self.reply_to) if self.reply_to[0] else self.reply_to[1] ) # a simple boolean check as to whether we display our target emails # or not has_targets = not ( len(self.targets) == 1 and self.targets[0] == self.from_email ) return "{schema}://{apikey}:{from_email}/{targets}?{params}".format( schema=self.secure_protocol, apikey=self.pprint(self.apikey, privacy, safe=""), # never encode email since it plays a huge role in our hostname from_email=self.from_email, targets=( "" if not has_targets else "/".join( [NotifyBrevo.quote(x, safe="") for x in self.targets] ) ), params=NotifyBrevo.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return max(len(self.targets), 1) def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, body_format=None, **kwargs, ): """Perform Brevo Notification.""" if not self.targets: # There is no one to email; we're done self.logger.warning( "There are no Brevo email recipients to notify" ) return False headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Accept": "application/json", "api-key": self.apikey, } # error tracking (used for function return) has_error = False # A Simple Email Payload Template payload_ = { "sender": { "email": self.from_email, }, # Placeholder, filled per target "to": [{"email": None}], "subject": title if title else self.default_empty_subject, } # Resolve the requested delivery representation for this send. # Brevo still wants both htmlContent and textContent, so the # resolved format decides which one is the original body and # which one is derived from it. use_html = self.resolve_format(body_format) == NotifyFormat.HTML if use_html: # HTML is the caller-selected representation; derive the # plain-text alternative for Brevo's companion field. payload_["htmlContent"] = body payload_["textContent"] = convert_between( NotifyFormat.HTML, NotifyFormat.TEXT, body ) else: # Plain text is the caller-selected representation; derive # the HTML alternative so the payload remains complete. payload_["textContent"] = body payload_["htmlContent"] = convert_between( NotifyFormat.TEXT, NotifyFormat.HTML, body ) if attach and self.attachment_support: attachments = [] # Send our attachments for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access Brevo attachment" f" {attachment.url(privacy=True)}." ) return False # Brevo does not track content/mime type and relies 100% # entirely on the filename extension as to whether or not it # will accept it or not. # # The below prepares a safe_name (which can't be .dat like # other plugins since Brevo rejects that type). For this # reason .txt is chosen intentionally for this circumstance. # Use the attachment name if available, otherwise default to a # generic name raw_name = ( attachment.name if attachment.name else f"file{no:03}.txt" ) # If the filename does NOT match a supported extension, append # .txt _, ext = splitext(raw_name) safe_name = ( f"{raw_name}.txt" if ( not ext or ext[1:].lower() not in BREVO_VALID_EXTENSIONS ) else raw_name ) try: attachments.append( { "content": attachment.base64(), "name": safe_name, } ) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access Brevo attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending Brevo attachment" f" {attachment.url(privacy=True)}" ) # Append our attachments to the payload payload_.update( { "attachment": attachments, } ) if self.reply_to: payload_["replyTo"] = {"email": self.reply_to[1]} targets = list(self.targets) while len(targets) > 0: target = targets.pop(0) # Skip a target that already accepted this message so # a retry does not deliver it twice. if self.is_delivered(target): continue # Create a copy of our template payload = payload_.copy() # the cc, bcc, to field must be unique or SendMail will fail, the # below code prepares this by ensuring the target isn't in the cc # list or bcc list. It also makes sure the cc list does not contain # any of the bcc entries cc = self.cc - self.bcc - {target} bcc = self.bcc - {target} # Set our main recipient payload["to"] = [{"email": target}] if len(cc): payload["cc"] = [{"email": email} for email in cc] if len(bcc): payload["bcc"] = [{"email": email} for email in bcc] # Some Debug Logging if self.logger.isEnabledFor(logging.DEBUG): # Due to attachments; output can be quite heavy and io # intensive. # To accommodate this, we only show our debug payload # information if required. self.logger.debug( "Brevo POST URL:" f" {self.notify_url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug( "Brevo Payload: %s", sanitize_payload(payload) ) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code not in ( requests.codes.ok, requests.codes.accepted, requests.codes.created, ): # We had a problem status_str = NotifyBrevo.http_response_code_lookup( r.status_code, BREVO_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Brevo notification to {}: " "{}{}error={}.".format( target, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: self.logger.info(f"Sent Brevo notification to {target}.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Brevo " f"notification to {target}." ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue # Delivered; a retry can safely skip this target. self.mark_delivered(target) return not has_error @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # Our URL looks like this: # {schema}://{apikey}:{from_email}/{targets} # # which actually equates to: # {schema}://{user}:{password}@{host}/{email1}/{email2}/etc.. # ^ ^ ^ # | | | # apikey -from addr- if not results.get("user"): # An API Key as not properly specified return None if not results.get("password"): # A From Email was not correctly specified return None # Prepare our API Key results["apikey"] = NotifyBrevo.unquote(results["user"]) # Prepare our From Email Address results["from_email"] = "{}@{}".format( NotifyBrevo.unquote(results["password"]), NotifyBrevo.unquote(results["host"]), ) # Acquire our targets results["targets"] = NotifyBrevo.split_path(results["fullpath"]) # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyBrevo.parse_list(results["qsd"]["to"]) # Handle Carbon Copy Addresses if "cc" in results["qsd"] and len(results["qsd"]["cc"]): results["cc"] = NotifyBrevo.parse_list(results["qsd"]["cc"]) # Handle Blind Carbon Copy Addresses if "bcc" in results["qsd"] and len(results["qsd"]["bcc"]): results["bcc"] = NotifyBrevo.parse_list(results["qsd"]["bcc"]) # Handle Reply To Address if "reply" in results["qsd"] and len(results["qsd"]["reply"]): results["reply_to"] = NotifyBrevo.unquote(results["qsd"]["reply"]) return results caronc-apprise-dfec414/apprise/plugins/bulksms.py000066400000000000000000000430141526720371000222770ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this service you will need a BulkSMS account # You will need credits (new accounts start with a few) # https://www.bulksms.com/account/ # # BulkSMS signs you in with an API Token. Create one under # Settings > Developers > API Tokens; it gives you a Token ID and a # Token Secret, used to build your Apprise URL: # bulksms://{token_id}:{token_secret}@{phone_no} # # The Token Secret can contain characters such as # that must be # URL encoded (# becomes %23). # # API is documented here: # - https://www.bulksms.com/developer/json/v1/#tag/Message from itertools import chain import json import re import requests from ..common import NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_phone_no, parse_bool, parse_phone_no from .base import NotifyBase IS_GROUP_RE = re.compile( r"^(@?(?P[A-Z0-9_-]+))$", re.IGNORECASE, ) class BulkSMSRoutingGroup: """The different categories of routing.""" ECONOMY = "ECONOMY" STANDARD = "STANDARD" PREMIUM = "PREMIUM" # Used for verification purposes BULKSMS_ROUTING_GROUPS = ( BulkSMSRoutingGroup.ECONOMY, BulkSMSRoutingGroup.STANDARD, BulkSMSRoutingGroup.PREMIUM, ) class BulkSMSEncoding: """The different categories of routing.""" TEXT = "TEXT" UNICODE = "UNICODE" BINARY = "BINARY" class NotifyBulkSMS(NotifyBase): """A wrapper for BulkSMS Notifications.""" # The default descriptive name associated with the Notification service_name = "BulkSMS" # The services URL service_url = "https://bulksms.com/" # All notification requests are secure secure_protocol = "bulksms" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/bulksms/" # BulkSMS uses the http protocol with JSON requests notify_url = "https://api.bulksms.com/v1/messages" # The maximum length of the body body_maxlen = 160 # The maximum amount of texts that can go out in one batch default_batch_size = 4000 # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # Define object templates templates = ("{schema}://{token_id}:{token_secret}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token_id": { "name": _("Token"), "type": "string", "required": True, "map_to": "user", }, "token_secret": { "name": _("Secret"), "type": "string", "private": True, "required": True, "map_to": "password", }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "target_group": { "name": _("Target Group"), "type": "string", "prefix": "@", "regex": (r"^[A-Z0-9 _-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "name": _("From Phone No"), "type": "string", "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "map_to": "source", }, "route": { "name": _("Route Group"), "type": "choice:string", "values": BULKSMS_ROUTING_GROUPS, "default": BulkSMSRoutingGroup.STANDARD, }, "unicode": { # Unicode characters "name": _("Unicode Characters"), "type": "bool", "default": True, }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, source=None, targets=None, unicode=None, batch=None, route=None, **kwargs, ): """Initialize BulkSMS Object.""" super().__init__(**kwargs) self.source = None if source: result = is_phone_no(source) if not result: msg = ( "The Account (From) Phone # specified " f"({source}) is invalid." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Tidy source self.source = "+{}".format(result["full"]) # Setup our route self.route = ( self.template_args["route"]["default"] if not isinstance(route, str) else route.upper() ) if self.route not in BULKSMS_ROUTING_GROUPS: msg = f"The route specified ({route}) is invalid." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Define whether or not we should set the unicode flag self.unicode = ( self.template_args["unicode"]["default"] if unicode is None else bool(unicode) ) # Define whether or not we should operate in a batch mode self.batch = ( self.template_args["batch"]["default"] if batch is None else bool(batch) ) # Parse our targets self.targets = [] self.groups = [] for target in parse_phone_no(targets): # Parse each phone number we found result = is_phone_no(target) if result: self.targets.append("+{}".format(result["full"])) continue group_re = IS_GROUP_RE.match(target) if group_re and not target.isdigit(): # If the target specified is all digits, it MUST have a @ # in front of it to eliminate any ambiguity self.groups.append(group_re.group("group")) continue self.logger.warning( f"Dropped invalid phone # and/or Group ({target}) specified.", ) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform BulkSMS Notification.""" if not (self.password and self.user): self.logger.warning( "There were no valid login credentials provided" ) return False if not (self.targets or self.groups): # We have nothing to notify self.logger.warning("There are no BulkSMS targets to notify") return False # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size # error tracking (used for function return) has_error = False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Prepare our payload payload = { # The To gets populated in the loop below "to": None, "body": body, "routingGroup": self.route, "encoding": ( BulkSMSEncoding.UNICODE if self.unicode else BulkSMSEncoding.TEXT ), # Options are NONE, ALL and ERRORS "deliveryReports": "ERRORS", } if self.source: payload.update( { "from": self.source, } ) # Authentication auth = (self.user, self.password) # Prepare our targets targets = ( list(self.targets) if batch_size == 1 else [ self.targets[index : index + batch_size] for index in range(0, len(self.targets), batch_size) ] ) targets += [{"type": "GROUP", "name": g} for g in self.groups] while len(targets): # Get our target to notify target = targets.pop(0) # Skip a target that already accepted this message so # a retry does not deliver it twice. if self.is_delivered(target): continue # Prepare our user payload["to"] = target # Printable reference if isinstance(target, dict): p_target = target["name"] elif isinstance(target, list): p_target = f"{len(target)} targets" else: p_target = target # Some Debug Logging self.logger.debug( "BulkSMS POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"BulkSMS Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=json.dumps(payload), headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) # The responsne might look like: # [ # { # "id": "string", # "type": "SENT", # "from": "string", # "to": "string", # "body": null, # "encoding": "TEXT", # "protocolId": 0, # "messageClass": 0, # "numberOfParts": 0, # "creditCost": 0, # "submission": {...}, # "status": {...}, # "relatedSentMessageId": "string", # "userSuppliedId": "string" # } # ] if r.status_code not in ( requests.codes.created, requests.codes.ok, ): # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code ) # set up our status code to use status_code = r.status_code self.logger.warning( "Failed to send BulkSMS notification to {}: " "{}{}error={}.".format( p_target, status_str, ", " if status_str else "", status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: self.logger.info( f"Sent BulkSMS notification to {p_target}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending BulkSMS: to %s ", p_target, ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue # Delivered; a retry can safely skip this target. self.mark_delivered(target) return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "unicode": "yes" if self.unicode else "no", "batch": "yes" if self.batch else "no", "route": self.route, } if self.source: params["from"] = self.source # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{user}:{password}@{targets}/?{params}".format( schema=self.secure_protocol, user=self.pprint(self.user, privacy, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), targets="/".join( chain( [ NotifyBulkSMS.quote(f"{x}", safe="+") for x in self.targets ], [ NotifyBulkSMS.quote(f"@{x}", safe="@") for x in self.groups ], ) ), params=NotifyBulkSMS.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol, self.user if self.user else None, self.password if self.password else None, ) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # # Note: Groups always require a separate request (and can not be # included in batch calculations) batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets + len(self.groups) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Get our entries; split_path() looks after unquoting content for us # by default results["targets"] = [ NotifyBulkSMS.unquote(results["host"]), *NotifyBulkSMS.split_path(results["fullpath"]), ] # Support the 'from' and 'source' variable so that we can support # targets this way too. # The 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyBulkSMS.unquote(results["qsd"]["from"]) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyBulkSMS.parse_phone_no( results["qsd"]["to"] ) # Unicode Characters results["unicode"] = parse_bool( results["qsd"].get( "unicode", NotifyBulkSMS.template_args["unicode"]["default"] ) ) # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyBulkSMS.template_args["batch"]["default"] ) ) # Allow one to define a route group if "route" in results["qsd"] and len(results["qsd"]["route"]): results["route"] = NotifyBulkSMS.unquote(results["qsd"]["route"]) return results caronc-apprise-dfec414/apprise/plugins/bulkvs.py000066400000000000000000000340411526720371000221250ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this service you will need a BulkVS account # You will need credits (new accounts start with a few) # https://www.bulkvs.com/ # API is documented here: # - https://portal.bulkvs.com/api/v1.0/documentation#/\ # Messaging/post_messageSend import json import requests from ..common import NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_phone_no, parse_bool, parse_phone_no from .base import NotifyBase class NotifyBulkVS(NotifyBase): """A wrapper for BulkVS Notifications.""" # The default descriptive name associated with the Notification service_name = "BulkVS" # The services URL service_url = "https://www.bulkvs.com/" # All notification requests are secure secure_protocol = "bulkvs" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/bulkvs/" # BulkVS uses the http protocol with JSON requests notify_url = "https://portal.bulkvs.com/api/v1.0/messageSend" # The maximum length of the body body_maxlen = 160 # The maximum amount of texts that can go out in one batch default_batch_size = 4000 # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # Define object templates templates = ( "{schema}://{user}:{password}@{from_phone}/{targets}", "{schema}://{user}:{password}@{from_phone}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "from_phone": { "name": _("From Phone No"), "type": "string", "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "map_to": "source", "required": True, }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "name": _("From Phone No"), "type": "string", "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "map_to": "source", }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__(self, source=None, targets=None, batch=None, **kwargs): """Initialize BulkVS Object.""" super().__init__(**kwargs) if not (self.user and self.password): msg = "A BulkVS user/pass was not provided." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) result = is_phone_no(source) if not result: msg = ( f"The Account (From) Phone # specified ({source}) is invalid." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Tidy source self.source = result["full"] # Define whether or not we should operate in a batch mode self.batch = ( self.template_args["batch"]["default"] if batch is None else bool(batch) ) # Parse our targets self.targets = [] has_error = False for target in parse_phone_no(targets): # Parse each phone number we found result = is_phone_no(target) if result: self.targets.append(result["full"]) continue has_error = True self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) if not targets and not has_error: # Default the SMS Message to ourselves self.targets.append(self.source) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform BulkVS Notification.""" if not self.targets: # We have nothing to notify self.logger.warning("There are no BulkVS targets to notify") return False # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size # error tracking (used for function return) has_error = False # Prepare our headers headers = { "User-Agent": self.app_id, "Accept": "application/json", "Content-Type": "application/json", } # Prepare our payload payload = { # The To gets populated in the loop below "From": self.source, "To": None, "Message": body, } # Authentication auth = (self.user, self.password) # Prepare our targets targets = ( list(self.targets) if batch_size == 1 else [ self.targets[index : index + batch_size] for index in range(0, len(self.targets), batch_size) ] ) while len(targets): # Get our target to notify target = targets.pop(0) # Skip a target that already accepted this message so # a retry does not deliver it twice. if self.is_delivered(target): continue # Prepare our user payload["To"] = target # Printable reference if isinstance(target, list): p_target = f"{len(target)} targets" else: p_target = target # Some Debug Logging self.logger.debug( "BulkVS POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"BulkVS Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=json.dumps(payload), headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) # A Response may look like: # { # "RefId": "5a66dee6-ff7a-40ee-8218-5805c074dc01", # "From": "13109060901", # "MessageType": "SMS|MMS", # "Results": [ # { # "To": "13105551212", # "Status": "SUCCESS" # }, # { # "To": "13105551213", # "Status": "SUCCESS" # } # ] # } if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code ) # set up our status code to use status_code = r.status_code self.logger.warning( "Failed to send BulkVS notification to {}: " "{}{}error={}.".format( p_target, status_str, ", " if status_str else "", status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: self.logger.info( f"Sent BulkVS notification to {p_target}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending BulkVS: to %s ", p_target, ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue # Delivered; a retry can safely skip this target. self.mark_delivered(target) return not has_error @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.source, self.user, self.password) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "batch": "yes" if self.batch else "no", } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # A nice way of cleaning up the URL length a bit targets = ( [] if len(self.targets) == 1 and self.targets[0] == self.source else self.targets ) return ( "{schema}://{user}:{password}@{source}/{targets}?{params}".format( schema=self.secure_protocol, source=self.source, user=self.pprint(self.user, privacy, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), targets="/".join( [NotifyBulkVS.quote(f"{x}", safe="+") for x in targets] ), params=NotifyBulkVS.urlencode(params), ) ) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if self.targets else 1 if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Support the 'from' and 'source' variable so that we can support # targets this way too. # The 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyBulkVS.unquote(results["qsd"]["from"]) # hostname will also be a target in this case results["targets"] = [ *NotifyBulkVS.parse_phone_no(results["host"]), *NotifyBulkVS.split_path(results["fullpath"]), ] else: # store our source results["source"] = NotifyBulkVS.unquote(results["host"]) # store targets results["targets"] = NotifyBulkVS.split_path(results["fullpath"]) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyBulkVS.parse_phone_no( results["qsd"]["to"] ) # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyBulkVS.template_args["batch"]["default"] ) ) return results caronc-apprise-dfec414/apprise/plugins/burstsms.py000066400000000000000000000404141526720371000225020ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Sign-up with https://burstsms.com/ # # Define your API Secret here and acquire your API Key # - https://can.transmitsms.com/profile # import requests from ..common import NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import ( is_phone_no, parse_bool, parse_phone_no, validate_regex, ) from .base import NotifyBase class BurstSMSCountryCode: # Australia AU = "au" # New Zeland NZ = "nz" # United Kingdom UK = "gb" # United States US = "us" BURST_SMS_COUNTRY_CODES = ( BurstSMSCountryCode.AU, BurstSMSCountryCode.NZ, BurstSMSCountryCode.UK, BurstSMSCountryCode.US, ) class NotifyBurstSMS(NotifyBase): """A wrapper for Burst SMS Notifications.""" # The default descriptive name associated with the Notification service_name = "Burst SMS" # The services URL service_url = "https://burstsms.com/" # The default protocol secure_protocol = "burstsms" # The maximum amount of SMS Messages that can reside within a single # batch transfer based on: # https://developer.transmitsms.com/#74911cf8-dec6-4319-a499-7f535a7fd08c default_batch_size = 500 # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/burstsms/" # Burst SMS uses the http protocol with JSON requests notify_url = "https://api.transmitsms.com/send-sms.json" # The maximum length of the body body_maxlen = 160 # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # Define object templates templates = ("{schema}://{apikey}:{secret}@{sender_id}/{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "required": True, "regex": (r"^[a-z0-9]+$", "i"), "private": True, }, "secret": { "name": _("API Secret"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9]+$", "i"), }, "sender_id": { "name": _("Sender ID"), "type": "string", "required": True, "map_to": "source", }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "alias_of": "sender_id", }, "key": { "alias_of": "apikey", }, "secret": { "alias_of": "secret", }, "country": { "name": _("Country"), "type": "choice:string", "values": BURST_SMS_COUNTRY_CODES, "default": BurstSMSCountryCode.US, }, # Validity # Expire a message send if it is undeliverable (defined in minutes) # If set to Zero (0); this is the default and sets the max validity # period "validity": {"name": _("validity"), "type": "int", "default": 0}, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, apikey, secret, source, targets=None, country=None, validity=None, batch=None, **kwargs, ): """Initialize Burst SMS Object.""" super().__init__(**kwargs) # API Key (associated with project) self.apikey = validate_regex( apikey, *self.template_tokens["apikey"]["regex"] ) if not self.apikey: msg = f"An invalid Burst SMS API Key ({apikey}) was specified." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # API Secret (associated with project) self.secret = validate_regex( secret, *self.template_tokens["secret"]["regex"] ) if not self.secret: msg = f"An invalid Burst SMS API Secret ({secret}) was specified." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) if not country: self.country = self.template_args["country"]["default"] else: self.country = country.lower().strip() if country not in BURST_SMS_COUNTRY_CODES: msg = ( f"An invalid Burst SMS country ({country}) was specified." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Set our Validity self.validity = self.template_args["validity"]["default"] if validity: try: self.validity = int(validity) except (ValueError, TypeError): msg = ( f"The Burst SMS Validity specified ({validity}) is" " invalid." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) from None # Prepare Batch Mode Flag self.batch = ( self.template_args["batch"]["default"] if batch is None else batch ) # The Sender ID self.source = validate_regex(source) if not self.source: msg = f"The Account Sender ID specified ({source}) is invalid." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Parse our targets self.targets = [] for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = is_phone_no(target) if not result: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) continue # store valid phone number self.targets.append(result["full"]) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Burst SMS Notification.""" if not self.targets: self.logger.warning( "There are no valid Burst SMS targets to notify." ) return False # error tracking (used for function return) has_error = False # Prepare our headers headers = { "User-Agent": self.app_id, "Accept": "application/json", } # Prepare our authentication auth = (self.apikey, self.secret) # Prepare our payload payload = { "countrycode": self.country, "message": body, # Sender ID "from": self.source, # The to gets populated in the loop below "to": None, } # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size # Create a copy of the targets list targets = list(self.targets) for index in range(0, len(targets), batch_size): # Skip a batch that already went out so a retry does # not deliver it to those recipients twice. if self.is_delivered(index): continue # Prepare our user payload["to"] = ",".join(self.targets[index : index + batch_size]) # Some Debug Logging self.logger.debug( "Burst SMS POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"Burst SMS Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=payload, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyBurstSMS.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Burst SMS notification to {} " "target(s): {}{}error={}.".format( len(self.targets[index : index + batch_size]), status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: self.logger.info( "Sent Burst SMS notification to " f"{len(self.targets[index : index + batch_size])} " "target(s)." ) except requests.RequestException as e: self.logger.warning( f"A Connection error occurred sending Burst SMS " "notification to " f"{len(self.targets[index : index + batch_size])} " "target(s)." ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue # Delivered; a retry can safely skip this batch. self.mark_delivered(index) return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "country": self.country, "batch": "yes" if self.batch else "no", } if self.validity: params["validity"] = str(self.validity) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{key}:{secret}@{source}/{targets}/?{params}".format( schema=self.secure_protocol, key=self.pprint(self.apikey, privacy, safe=""), secret=self.pprint( self.secret, privacy, mode=PrivacyMode.Secret, safe="" ), source=NotifyBurstSMS.quote(self.source, safe=""), targets="/".join( [NotifyBurstSMS.quote(x, safe="") for x in self.targets] ), params=NotifyBurstSMS.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.apikey, self.secret, self.source) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets if targets > 0 else 1 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # The hostname is our source (Sender ID) results["source"] = NotifyBurstSMS.unquote(results["host"]) # Get any remaining targets results["targets"] = NotifyBurstSMS.split_path(results["fullpath"]) # Get our account_side and auth_token from the user/pass config results["apikey"] = NotifyBurstSMS.unquote(results["user"]) results["secret"] = NotifyBurstSMS.unquote(results["password"]) # API Key if "key" in results["qsd"] and len(results["qsd"]["key"]): # Extract the API Key from an argument results["apikey"] = NotifyBurstSMS.unquote(results["qsd"]["key"]) # API Secret if "secret" in results["qsd"] and len(results["qsd"]["secret"]): # Extract the API Secret from an argument results["secret"] = NotifyBurstSMS.unquote( results["qsd"]["secret"] ) # Support the 'from' and 'source' variable so that we can support # targets this way too. # The 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyBurstSMS.unquote(results["qsd"]["from"]) if "source" in results["qsd"] and len(results["qsd"]["source"]): results["source"] = NotifyBurstSMS.unquote( results["qsd"]["source"] ) # Support country if "country" in results["qsd"] and len(results["qsd"]["country"]): results["country"] = NotifyBurstSMS.unquote( results["qsd"]["country"] ) # Support validity value if "validity" in results["qsd"] and len(results["qsd"]["validity"]): results["validity"] = NotifyBurstSMS.unquote( results["qsd"]["validity"] ) # Get Batch Mode Flag if "batch" in results["qsd"] and len(results["qsd"]["batch"]): results["batch"] = parse_bool(results["qsd"]["batch"]) # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyBurstSMS.parse_phone_no( results["qsd"]["to"] ) return results caronc-apprise-dfec414/apprise/plugins/chanify.py000066400000000000000000000155561526720371000222520ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Chanify # 1. Visit https://chanify.net/ # The API URL will look something like this: # https://api.chanify.net/v1/sender/token # import requests from ..common import NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..utils.parse import validate_regex from .base import NotifyBase class NotifyChanify(NotifyBase): """A wrapper for Chanify Notifications.""" # The default descriptive name associated with the Notification service_name = _("Chanify") # The services URL service_url = "https://chanify.net/" # The default secure protocol secure_protocol = "chanify" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/chanify/" # Notification URL notify_url = "https://api.chanify.net/v1/sender/{token}/" # Define object templates templates = ("{schema}://{token}",) # The title is not used title_maxlen = 0 # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("Token"), "type": "string", "private": True, "required": True, "regex": (r"^[A-Z0-9._-]+$", "i"), }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "token": { "alias_of": "token", }, }, ) def __init__(self, token, **kwargs): """Initialize Chanify Object.""" super().__init__(**kwargs) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"The Chanify token specified ({token}) is invalid." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Send our notification.""" # prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded", } # Our Message payload = {"text": body} self.logger.debug( "Chanify GET URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Chanify Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url.format(token=self.token), data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyChanify.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Chanify notification: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Return; we're done return False else: self.logger.info("Sent Chanify notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Chanify notification." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Prepare our parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) return "{schema}://{token}/?{params}".format( schema=self.secure_protocol, token=self.pprint(self.token, privacy, safe=""), params=NotifyChanify.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.token) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" # parse_url already handles getting the `user` and `password` fields # populated. results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Allow over-ride if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyChanify.unquote(results["qsd"]["token"]) else: results["token"] = NotifyChanify.unquote(results["host"]) return results caronc-apprise-dfec414/apprise/plugins/clickatell.py000066400000000000000000000251501526720371000227270ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from itertools import chain # To use this service you will need a Clickatell account to which you can get # your API_TOKEN at: # https://www.clickatell.com/ import requests from ..common import NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..utils.parse import is_phone_no, parse_phone_no, validate_regex from .base import NotifyBase class NotifyClickatell(NotifyBase): """A wrapper for Clickatell Notifications.""" # The default descriptive name associated with the Notification service_name = _("Clickatell") # The services URL service_url = "https://www.clickatell.com/" # All notification requests are secure secure_protocol = "clickatell" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/clickatell/" # Clickatell API Endpoint notify_url = "https://platform.clickatell.com/messages/http/send" # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 templates = ( "{schema}://{apikey}/{targets}", "{schema}://{source}@{apikey}/{targets}", ) template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Token"), "type": "string", "private": True, "required": True, }, "source": { "name": _("From Phone No"), "type": "string", "regex": (r"^[0-9\s)(+-]+$", "i"), }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) template_args = dict( NotifyBase.template_args, **{ "apikey": {"alias_of": "apikey"}, "to": { "alias_of": "targets", }, "from": { "alias_of": "source", }, }, ) def __init__(self, apikey, source=None, targets=None, **kwargs): """Initialize Clickatell Object.""" super().__init__(**kwargs) self.apikey = validate_regex(apikey) if not self.apikey: msg = f"An invalid Clickatell API Token ({apikey}) was specified." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) self.source = None if source: result = is_phone_no(source) if not result: msg = ( "The Account (From) Phone # specified " f"({source}) is invalid." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Tidy source self.source = result["full"] # Used for URL generation afterwards only self._invalid_targets = [] # Parse our targets self.targets = [] for target in parse_phone_no(targets, prefix=True): # Validate targets and drop bad ones: result = is_phone_no(target) if not result: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) self._invalid_targets.append(target) continue # store valid phone number self.targets.append(result["full"]) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.apikey, self.source) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" params = self.url_parameters(privacy=privacy, *args, **kwargs) return "{schema}://{source}{apikey}/{targets}/?{params}".format( schema=self.secure_protocol, source=f"{self.source}@" if self.source else "", apikey=self.pprint(self.apikey, privacy, safe="="), targets="/".join( [ NotifyClickatell.quote(t, safe="") for t in chain(self.targets, self._invalid_targets) ] ), params=self.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification. Always return 1 at least """ return len(self.targets) if self.targets else 1 def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Clickatell Notification.""" if not self.targets: # There were no targets to notify self.logger.warning("There were no Clickatell targets to notify") return False headers = { "User-Agent": self.app_id, "Accept": "application/json", "Content-Type": "application/json", } params_base = { "apiKey": self.apikey, "from": self.source, "content": body, } # error tracking (used for function return) has_error = False for target in self.targets: # Skip a target that already accepted this message so # a retry does not deliver it twice. if self.is_delivered(target): continue params = params_base.copy() params["to"] = target # Some Debug Logging self.logger.debug( "Clickatell GET URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"Clickatell Payload: {params}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.get( self.notify_url, params=params, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if ( r.status_code != requests.codes.ok and r.status_code != requests.codes.accepted ): # We had a problem status_str = self.http_response_code_lookup(r.status_code) self.logger.warning( "Failed to send Clickatell notification: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: self.logger.info( "Sent Clickatell notification to %s", target ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Clickatell: to %s ", target, ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue # Delivered; a retry can safely skip this target. self.mark_delivered(target) return not has_error @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't parse the URL return results results["targets"] = NotifyClickatell.split_path(results["fullpath"]) results["apikey"] = NotifyClickatell.unquote(results["host"]) if results["user"]: results["source"] = NotifyClickatell.unquote(results["user"]) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyClickatell.parse_phone_no( results["qsd"]["to"] ) # Support the 'from' and 'source' variable so that we can support # targets this way too. # The 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyClickatell.unquote( results["qsd"]["from"] ) return results caronc-apprise-dfec414/apprise/plugins/clicksend.py000066400000000000000000000302361526720371000225600ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this plugin, simply signup with clicksend: # https://www.clicksend.com/ # # You're done at this point, you only need to know your user/pass that # you signed up with. # The following URLs would be accepted by Apprise: # - clicksend://{user}:{password}@{phoneno} # - clicksend://{user}:{password}@{phoneno1}/{phoneno2} # The API reference used to build this plugin was documented here: # https://developers.clicksend.com/docs/rest/v3/ # from json import dumps import requests from ..common import NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_phone_no, parse_bool, parse_phone_no from .base import NotifyBase # Extend HTTP Error Messages CLICKSEND_HTTP_ERROR_MAP = { 401: "Unauthorized - Invalid Token.", } class NotifyClickSend(NotifyBase): """A wrapper for ClickSend Notifications.""" # The default descriptive name associated with the Notification service_name = "ClickSend" # The services URL service_url = "https://clicksend.com/" # The default secure protocol secure_protocol = "clicksend" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/clicksend/" # ClickSend uses the http protocol with JSON requests notify_url = "https://rest.clicksend.com/v3/sms/send" # The maximum length of the body body_maxlen = 160 # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # The maximum SMS batch size accepted by the ClickSend API default_batch_size = 1000 # Define object templates templates = ("{schema}://{user}:{apikey}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, "map_to": "password", }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "key": { "alias_of": "apikey", }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__(self, targets=None, batch=False, **kwargs): """Initialize ClickSend Object.""" super().__init__(**kwargs) # Prepare Batch Mode Flag self.batch = batch # Parse our targets self.targets = [] if not (self.user and self.password): msg = "A ClickSend user/pass was not provided." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = is_phone_no(target) if not result: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) continue # store valid phone number self.targets.append(result["full"]) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform ClickSend Notification.""" if len(self.targets) == 0: # There were no services to notify self.logger.warning("There were no ClickSend targets to notify.") return False headers = { "User-Agent": self.app_id, "Content-Type": "application/json; charset=utf-8", } # error tracking (used for function return) has_error = False # prepare JSON Object payload = {"messages": []} # Send in batches if identified to do so default_batch_size = 1 if not self.batch else self.default_batch_size for index in range(0, len(self.targets), default_batch_size): # Skip a batch that already went out so a retry does # not deliver it to those recipients twice. if self.is_delivered(index): continue payload["messages"] = [ { "source": "php", "body": body, "to": f"+{to}", } for to in self.targets[index : index + default_batch_size] ] self.logger.debug( "ClickSend POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"ClickSend Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=dumps(payload), auth=(self.user, self.password), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyClickSend.http_response_code_lookup( r.status_code, CLICKSEND_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send {} ClickSend notification{}: " "{}{}error={}.".format( len(payload["messages"]), ( f" to {self.targets[index]}" if default_batch_size == 1 else "(s)" ), status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: self.logger.info( "Sent {} ClickSend notification{}.".format( len(payload["messages"]), ( f" to {self.targets[index]}" if default_batch_size == 1 else "(s)" ), ) ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending {} ClickSend " "notification(s).".format(len(payload["messages"])) ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue # Delivered; a retry can safely skip this batch. self.mark_delivered(index) return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "batch": "yes" if self.batch else "no", } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Setup Authentication auth = "{user}:{password}@".format( user=NotifyClickSend.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) return "{schema}://{auth}{targets}?{params}".format( schema=self.secure_protocol, auth=auth, targets="/".join( [NotifyClickSend.quote(x, safe="") for x in self.targets] ), params=NotifyClickSend.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.user, self.password) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # All elements are targets results["targets"] = [NotifyClickSend.unquote(results["host"])] # All entries after the hostname are additional targets results["targets"].extend( NotifyClickSend.split_path(results["fullpath"]) ) # Get Batch Mode Flag results["batch"] = parse_bool(results["qsd"].get("batch", False)) # API Key if "key" in results["qsd"] and len(results["qsd"]["key"]): # Extract the API Key from an argument results["password"] = NotifyClickSend.unquote( results["qsd"]["key"] ) # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyClickSend.parse_phone_no( results["qsd"]["to"] ) return results caronc-apprise-dfec414/apprise/plugins/cliq.py000066400000000000000000000626701526720371000215600ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Zoho Cliq posts messages using a Webhook Token: # 1. Sign in to Zoho Cliq and open "Bots & Tools", then "Webhook Tokens". # 2. Complete the two-factor check and click "Generate New Token". # 3. Copy the token; it looks like: # 1001.2798931fdf4905dc5d37806b47d8253a.2d73063dae2ccf4a04810ddb0a1b2c3d # # Messages can go to a channel (by its unique name) or to a bot. A # channel's unique name is shown in its "Channel Info" page. # # Your Apprise URL should be assembled as: # cliq://{token}/#{channel} # cliq://{token}/@{bot} # # Accounts hosted outside of the US data center add ?region= (for # example ?region=eu for cliq.zoho.eu). # # Webhook Tokens can only post messages; uploading files requires a # full OAuth login, so attachments are not supported. # # Resources: # - https://www.zoho.com/cliq/help/platform/webhook-tokens.html # - https://www.zoho.com/cliq/help/restapi/v2/messages/ # - https://www.zoho.com/cliq/help/restapi/v2/messagecards/ from __future__ import annotations from json import dumps import re from typing import Any, Optional import requests from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..conversion import ( commonmark_emphasis_run, commonmark_find_backtick_run, commonmark_index_backtick_runs, commonmark_new_scan_budget, commonmark_pick_emphasis_sentinel, commonmark_render_emphasis_markers, commonmark_scan_angle_dest, commonmark_scan_autolink_dest, commonmark_scan_paren_dest, ) from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool, parse_list, validate_regex from .base import NotifyBase # Extend HTTP Error Messages # Based on: https://www.zoho.com/cliq/help/restapi/v2/ CLIQ_HTTP_ERROR_MAP = { 400: "Bad Request - Check the channel or bot name.", 401: "Unauthorized - Invalid Webhook Token.", 404: "The channel or bot could not be found.", 429: "Too many requests were made.", } class CliqRegion: """The Zoho data centers that host Cliq accounts.""" US = "us" EU = "eu" INDIA = "in" AUSTRALIA = "au" CHINA = "cn" JAPAN = "jp" SAUDI_ARABIA = "sa" UNITED_KINGDOM = "uk" CANADA = "ca" # Each region and the Cliq domain it is reached on CLIQ_REGION_DOMAINS = { CliqRegion.US: "cliq.zoho.com", CliqRegion.EU: "cliq.zoho.eu", CliqRegion.INDIA: "cliq.zoho.in", CliqRegion.AUSTRALIA: "cliq.zoho.com.au", CliqRegion.CHINA: "cliq.zoho.com.cn", CliqRegion.JAPAN: "cliq.zoho.jp", CliqRegion.SAUDI_ARABIA: "cliq.zoho.sa", CliqRegion.UNITED_KINGDOM: "cliq.zoho.uk", CliqRegion.CANADA: "cliq.zohocloud.ca", } # Look up a region from the domain found in a pasted Cliq URL CLIQ_DOMAIN_REGIONS = {v: k for k, v in CLIQ_REGION_DOMAINS.items()} # The regions in the order they are offered to users CLIQ_REGIONS = tuple(CLIQ_REGION_DOMAINS.keys()) # A channel is the default target, so its "#" is optional IS_CHANNEL = re.compile(r"^#?(?P[A-Z0-9_.-]+)$", re.I) # A bot is always written with a leading "@" IS_BOT = re.compile(r"^@(?P[A-Z0-9_.-]+)$", re.I) class NotifyCliq(NotifyBase): """A wrapper for Zoho Cliq Notifications.""" # The default descriptive name associated with the Notification service_name = "Zoho Cliq" # The services URL service_url = "https://www.zoho.com/cliq/" # The default secure protocol; the extra names are accepted aliases secure_protocol = ("cliq", "zcliq", "zohocliq") # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/cliq/" # Our message URL; {path} is "channelsbyname" or "bots" notify_url = "https://{domain}{company}/api/v2/{path}/{name}/message" # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_72 # Cliq renders its own flavour of Markdown notify_format = NotifyFormat.MARKDOWN # The maximum allowable characters allowed in the body per message body_maxlen = 5000 # The title is shown in a message card above the body title_maxlen = 200 # Define object URL templates templates = ( "{schema}://{token}/{targets}", "{schema}://{botname}@{token}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("Webhook Token"), "type": "string", "private": True, "required": True, "regex": (r"^[A-Z0-9]+(\.[A-Z0-9]+)+$", "i"), }, "botname": { "name": _("Bot Name"), "type": "string", }, "target_channel": { "name": _("Target Channel"), "type": "string", "prefix": "#", "map_to": "targets", }, "target_bot": { "name": _("Target Bot"), "type": "string", "prefix": "@", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "to": { "alias_of": "targets", }, "token": { "alias_of": "token", }, "botname": { "alias_of": "botname", }, "region": { "name": _("Region"), "type": "choice:string", "values": CLIQ_REGIONS, "default": CliqRegion.US, }, "company": { "name": _("Company ID"), "type": "string", }, "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, "broadcast": { "name": _("Broadcast"), "type": "bool", "default": False, }, }, ) def __init__( self, token: str, targets: Optional[Any] = None, botname: Optional[str] = None, region: Optional[str] = None, company: Optional[str] = None, include_image: bool = True, broadcast: bool = False, **kwargs: Any, ) -> None: """Initialize Zoho Cliq Object.""" super().__init__(**kwargs) # Our Webhook Token self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = ( f"An invalid Zoho Cliq Webhook Token ({token}) was specified." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # The data center our account lives in self.region = ( region.strip().lower() if isinstance(region, str) and region.strip() else CliqRegion.US ) if self.region not in CLIQ_REGION_DOMAINS: msg = f"An invalid Zoho Cliq region ({region}) was specified." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Users belonging to more than one company can pick which one self.company = None if company: self.company = validate_regex(company, r"^[A-Z0-9_-]+$", "i") if not self.company: msg = ( f"An invalid Zoho Cliq Company ID ({company}) was" " specified." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # The display name shown as the sender of our messages self.botname = botname.strip() if isinstance(botname, str) else None if not self.botname: self.botname = None # Show the Apprise image as the sender's avatar self.include_image = parse_bool(include_image) # Send bot messages to all of the bot's subscribers self.broadcast = parse_bool(broadcast) # Our channel and bot names, stored with their prefix self.targets = [] # Anything we could not make sense of; kept for url() self.invalid_targets = [] for target in parse_list(targets): # Bots must be checked first since "#" is optional on channels result = IS_BOT.match(target) if result: self.targets.append("@" + result.group("name")) continue result = IS_CHANNEL.match(target) if result: self.targets.append("#" + result.group("name")) continue self.logger.warning( "Dropped invalid Zoho Cliq target (%s) specified.", target ) self.invalid_targets.append(target) return def dialect_convert( self, body: str, body_format: Optional[str] = None, *args: Any, **kwargs: Any, ) -> str: """Translate declared CommonMark to Zoho Cliq formatting.""" # Only Markdown has anything for us to translate if body_format != NotifyFormat.MARKDOWN: return body return self._commonmark_to_cliq(body) # Adapt HTML-derived CommonMark to the Zoho Cliq formatting. # Direct Cliq formatting is left unchanged. # Syntax: https://www.zoho.com/cliq/help/chat-window/format-messages.html @classmethod def _commonmark_to_cliq(cls, body: str) -> str: """Translate CommonMark to Zoho Cliq formatting. CommonMark Zoho Cliq ------------------ ----------------- **bold** *bold* *italic* _italic_ [label]() [label](url) url \\* * """ # Accumulate translated characters one item at a time. out = [] # Record ``*`` markup in order, then match it after the scan. delimiters = [] # Track possible link-label openings in LIFO order. link_stack = [] # Initialize the single-pass scanner. i = 0 n = len(body) # Record backtick positions so matching code spans is quick. backtick_runs = commonmark_index_backtick_runs(body) # Pick a temporary marker that does not occur in the message. sentinel = commonmark_pick_emphasis_sentinel(body) # Bound the total work spent scanning labeled-link destinations. scan_budget = commonmark_new_scan_budget(body) while i < n: ch = body[i] # Drop CommonMark escapes because Cliq shows them literally. if ch == "\\" and i + 1 < n: out.append(body[i + 1]) i += 2 continue # Keep code spans as they are so their content is not styled. if ch == "`": j = i # Measure the run length by counting consecutive backticks. while j < n and body[j] == "`": j += 1 run = j - i # Search the pre-built index for the matching close run. close = commonmark_find_backtick_run(backtick_runs, j, run) # Unmatched backticks are kept as literal text. end = j if close is None else close + run out.append(body[i:end]) i = end continue # Record a possible CommonMark link-label opening. if ch == "[": link_stack.append(len(out)) out.append("[") i += 1 continue # Unwrap ``[label]()`` into Cliq's ``[label](url)``. if body.startswith("](<", i) and link_stack: # Scan forward with escape awareness for the ">)" terminator. close = commonmark_scan_angle_dest( body, i, n, budget=scan_budget ) if close is not None: # Protect the wrapping parenthesis from URL content. url = body[i + 3 : close].replace(")", "%29") link_stack.pop() out.append(f"]({url})") # Skip past the closing ">)" of the destination. i = close + 2 continue # Let the bare-link check handle this failed angle form. # Copy bare destinations without scanning their URL as emphasis. if body.startswith("](", i) and link_stack: close = commonmark_scan_paren_dest( body, i + 1, n, budget=scan_budget ) if close is not None: link_stack.pop() out.append(body[i : close + 1]) # Skip past the closing ")". i = close + 1 continue # This "](" does not finish a link, so retire its opener. link_stack.pop() # A "]" that never forms "](" cannot close a link. Retire the # innermost pending "[" so a later, unrelated "](" cannot # incorrectly reuse it as its own opener. if ch == "]" and link_stack and not body.startswith("](", i): link_stack.pop() # Drop autolink brackets; Cliq links the remaining URL itself. if ch == "<": close = commonmark_scan_autolink_dest(body, i, n)[0] if close is not None: out.append(body[i + 1 : close]) i = close + 1 continue # Map CommonMark emphasis to Cliq's ``*``/``_`` syntax. if ch == "*": i = commonmark_emphasis_run( body, i, n, delimiters, out, sentinel ) continue # Preserve ordinary characters. out.append(ch) i += 1 # Replace temporary markers with Cliq bold and italic markup. return commonmark_render_emphasis_markers( "".join(out), delimiters, ("*", "*"), ("_", "_"), sentinel ) def send( self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, **kwargs: Any, ) -> bool: """Perform Zoho Cliq Notification.""" if not self.targets: # We have nothing to notify self.logger.warning( "There are no Zoho Cliq channels or bots to notify." ) return False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json; charset=utf-8", } # Our Webhook Token is passed on the URL params = {"zapikey": self.token} # Cliq needs text, so a title on its own becomes the text payload = {"text": body if body else title} # Otherwise the title is shown in a card above the message if title and body: payload["card"] = { "title": title, "theme": "modern-inline", } # Acquire our image url if configured to do so image_url = ( None if not self.include_image else self.image_url(notify_type) ) # Cliq requires a name whenever the sender is changed if self.botname or image_url: payload["bot"] = {"name": self.botname or self.app_id} if image_url: payload["bot"]["image"] = image_url # Users in more than one company include it in the URL path company = f"/company/{self.company}" if self.company else "" # error tracking (used for function return) has_error = False for target in self.targets: # Skip a target that already accepted this message so a retry # does not deliver it twice. if self.is_delivered(target): continue # Our prefix tells us where the message is going is_bot = target[0] == "@" # Prepare our URL url = self.notify_url.format( domain=CLIQ_REGION_DOMAINS[self.region], company=company, path="bots" if is_bot else "channelsbyname", name=NotifyCliq.quote(target[1:], safe=""), ) # Bot messages can reach every subscriber instead of just us _payload = ( dict(payload, broadcast=True) if is_bot and self.broadcast else payload ) self.logger.debug( "Zoho Cliq POST URL: %s (cert_verify=%r)", url, self.verify_certificate, ) self.logger.debug("Zoho Cliq Payload: %s", _payload) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, params=params, data=dumps(_payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code not in ( requests.codes.ok, requests.codes.no_content, ): # We had a problem status_str = NotifyCliq.http_response_code_lookup( r.status_code, CLIQ_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Zoho Cliq notification to %s: " "%s%serror=%s.", target, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue self.logger.info("Sent Zoho Cliq notification to %s.", target) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Zoho Cliq " "notification to %s.", target, ) self.logger.debug("Socket Exception: %s", str(e)) # Mark our failure has_error = True continue # Delivered; a retry can safely skip this target. self.mark_delivered(target) return not has_error def __len__(self) -> int: """Returns the number of targets associated with this notification.""" # Always return at least 1 so the framework counts this instance return len(self.targets) if self.targets else 1 @property def url_identifier(self) -> tuple[Any, ...]: """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ # The region and company decide which account the token belongs to return ( self.secure_protocol[0], self.region, self.company, self.token, ) def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str: """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "image": "yes" if self.include_image else "no", "broadcast": "yes" if self.broadcast else "no", } # Only emit what differs from the defaults if self.region != CliqRegion.US: params["region"] = self.region if self.company: params["company"] = self.company # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Our bot name is placed in front of the token botname = ( "{}@".format(NotifyCliq.quote(self.botname, safe="")) if self.botname else "" ) return "{schema}://{botname}{token}/{targets}?{params}".format( schema=self.secure_protocol[0], botname=botname, token=self.pprint(self.token, privacy, safe=""), targets="/".join( NotifyCliq.quote(x, safe="@") for x in (self.targets + self.invalid_targets) ), params=NotifyCliq.urlencode(params), ) @staticmethod def parse_url(url: str) -> Optional[dict[str, Any]]: """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # The token is our host unless ?token= says otherwise results["token"] = NotifyCliq.unquote( results["qsd"]["token"] if results["qsd"].get("token") else results["host"] ) # Our channels and bots are in the path results["targets"] = NotifyCliq.split_path(results["fullpath"]) # Support ?to= for targets if results["qsd"].get("to"): results["targets"] += NotifyCliq.parse_list(results["qsd"]["to"]) # The bot name sits in front of the token if results["user"]: results["botname"] = NotifyCliq.unquote(results["user"]) # Support ?botname= as an override if results["qsd"].get("botname"): results["botname"] = NotifyCliq.unquote(results["qsd"]["botname"]) if results["qsd"].get("region"): results["region"] = NotifyCliq.unquote(results["qsd"]["region"]) if results["qsd"].get("company"): results["company"] = NotifyCliq.unquote(results["qsd"]["company"]) # Include our image unless told not to results["include_image"] = parse_bool( results["qsd"].get("image", True) ) results["broadcast"] = parse_bool( results["qsd"].get("broadcast", False) ) return results @staticmethod def parse_native_url(url: str) -> Optional[dict[str, Any]]: """ Support: https://cliq.zoho.com/api/v2/channelsbyname/{channel}/message ?zapikey={token} https://cliq.zoho.eu/company/{id}/api/v2/bots/{bot}/message ?zapikey={token} """ result = re.match( r"^https?://(?Pcliq\.zoho(cloud)?(\.[a-z]+){1,2})/+" r"(company/(?P[A-Z0-9_-]+)/+)?api/v2/+" r"(?Pchannelsbyname|bots)/(?P[^/?]+)/+message/*" r"\?(?P(.*&)?zapikey=(?P[^&]+).*)$", url, re.I, ) if not result: return None # Map the domain back to the region it serves region = CLIQ_DOMAIN_REGIONS.get(result.group("domain").lower()) if not region: return None # Rebuild our target with the prefix that describes it target = "{}{}".format( "@" if result.group("path").lower() == "bots" else "#", result.group("name"), ) return NotifyCliq.parse_url( "{schema}://{token}/{target}?region={region}{company}".format( schema=NotifyCliq.secure_protocol[0], token=result.group("token"), target=NotifyCliq.quote(target, safe="@"), region=region, company=( "&company={}".format(result.group("company")) if result.group("company") else "" ), ) ) caronc-apprise-dfec414/apprise/plugins/custom_form.py000066400000000000000000000462011526720371000231550ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import re import requests from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import URL_ENCODED_PATH_SAFE_CHARS from .base import NotifyBase class FORMPayloadField: """Identifies the fields available in the FORM Payload.""" VERSION = "version" TITLE = "title" MESSAGE = "message" MESSAGETYPE = "type" # Defines the method to send the notification METHODS = ( "POST", "GET", "DELETE", "PUT", "HEAD", "PATCH", "UPDATE", "OPTIONS", ) class NotifyForm(NotifyBase): """A wrapper for Form Notifications.""" # Support # - file* # - file? # - file*name # - file?name # - ?file # - *file # - file # The code will convert the ? or * to the digit increments __attach_as_re = re.compile( r"((?P(?P[a-z0-9_-]+)?" r"(?P[*?+$:.%]+)(?P[a-z0-9_-]+))" r"|(?P(?P[a-z0-9_-]+)(?P[*?+$:.%]?)))", re.IGNORECASE, ) # Our count attach_as_count = "{:02d}" # the default attach_as value attach_as_default = f"file{attach_as_count}" # The default descriptive name associated with the Notification service_name = "Form" # The default protocol protocol = "form" # The default secure protocol secure_protocol = "forms" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/form/" # Support attachments attachment_support = True # Pass-through Notify Formats. The endpoint receives whichever body # representation Apprise resolved for this send. notify_format = ( NotifyFormat.TEXT, NotifyFormat.HTML, NotifyFormat.MARKDOWN, ) # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # Disable throttle rate for Form requests since they are normally # local anyway request_rate_per_sec = 0 # Define the FORM version to place in all payloads # Version: Major.Minor, Major is only updated if the entire schema is # changed. If just adding new items (or removing old ones, only increment # the Minor! form_version = "1.0" # Define object templates templates = ( "{schema}://{host}", "{schema}://{host}:{port}", "{schema}://{user}@{host}", "{schema}://{user}@{host}:{port}", "{schema}://{user}:{password}@{host}", "{schema}://{user}:{password}@{host}:{port}", ) # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "method": { "name": _("Fetch Method"), "type": "choice:string", "values": METHODS, "default": METHODS[0], }, "attach-as": { "name": _("Attach File As"), "type": "string", "default": "file*", "map_to": "attach_as", }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, "payload": { "name": _("Payload Extras"), "prefix": ":", }, "params": { "name": _("GET Params"), "prefix": "-", }, } def __init__( self, headers=None, method=None, payload=None, params=None, attach_as=None, **kwargs, ): """Initialize Form Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "" self.method = ( self.template_args["method"]["default"] if not isinstance(method, str) else method.upper() ) if self.method not in METHODS: msg = f"The method specified ({method}) is invalid." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Custom File Attachment Over-Ride Support if not isinstance(attach_as, str): # Default value self.attach_as = self.attach_as_default self.attach_multi_support = True else: result = self.__attach_as_re.match(attach_as.strip()) if not result: msg = f"The attach-as specified ({attach_as}) is invalid." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) self.attach_as = "" self.attach_multi_support = False if result.group("match1"): if result.group("id1a"): self.attach_as += result.group("id1a") self.attach_as += self.attach_as_count self.attach_multi_support = True self.attach_as += result.group("id1b") else: # result.group('match2'): self.attach_as += result.group("id2") if result.group("wc2"): self.attach_as += self.attach_as_count self.attach_multi_support = True # A payload map allows users to over-ride the default mapping if # they're detected with the :overide=value. Normally this would # create a new key and assign it the value specified. However # if the key you specify is actually an internally mapped one, # then a re-mapping takes place using the value self.payload_map = { FORMPayloadField.VERSION: FORMPayloadField.VERSION, FORMPayloadField.TITLE: FORMPayloadField.TITLE, FORMPayloadField.MESSAGE: FORMPayloadField.MESSAGE, FORMPayloadField.MESSAGETYPE: FORMPayloadField.MESSAGETYPE, } self.params = {} if params: # Store our extra headers self.params.update(params) self.headers = {} if headers: # Store our extra headers self.headers.update(headers) self.payload_overrides = {} self.payload_extras = {} if payload: # Store our extra payload entries self.payload_extras.update(payload) for key in list(self.payload_extras.keys()): # Any values set in the payload to alter a system related one # alters the system key. Hence :message=msg maps the 'message' # variable that otherwise already contains the payload to be # 'msg' instead (containing the payload) if key in self.payload_map: self.payload_map[key] = self.payload_extras[key] self.payload_overrides[key] = self.payload_extras[key] del self.payload_extras[key] return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Form Notification.""" # Prepare HTTP Headers headers = { "User-Agent": self.app_id, } # Apply any/all header over-rides defined headers.update(self.headers) # Track our potential attachments files = [] if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access attachment" f" {attachment.url(privacy=True)}." ) return False try: files.append( ( ( self.attach_as.format(no) if self.attach_multi_support else self.attach_as ), ( ( attachment.name if attachment.name else f"file{no:03}.dat" ), # file handle safely closed in # `finally`; inline open intentional open(attachment.path, "rb"), # noqa: SIM115 attachment.mimetype, ), ) ) except OSError as e: self.logger.warning( "An I/O error occurred while opening {}.".format( attachment.name if attachment else "attachment" ) ) self.logger.debug(f"I/O Exception: {e!s}") return False if not self.attach_multi_support and no > 1: self.logger.warning( "Multiple attachments provided while " "form:// Multi-Attachment Support not enabled" ) # prepare Form Object payload = {} for key, value in ( (FORMPayloadField.VERSION, self.form_version), (FORMPayloadField.TITLE, title), (FORMPayloadField.MESSAGE, body), (FORMPayloadField.MESSAGETYPE, notify_type.value), ): if not self.payload_map[key]: # Do not store element in payload response continue payload[self.payload_map[key]] = value # Apply any/all payload over-rides defined payload.update(self.payload_extras) auth = None if self.user: auth = (self.user, self.password) # Set our schema schema = "https" if self.secure else "http" url = f"{schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath self.logger.debug( f"Form {self.method} URL:" f" {url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Form Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() # For GET the payload becomes URL query parameters; for all other # methods it is sent as the request body. if self.method == "GET": payload.update(self.params) try: r = requests.request( self.method, url, files=files if files else None, data=payload if self.method != "GET" else None, params=payload if self.method == "GET" else self.params, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code < 200 or r.status_code >= 300: # We had a problem status_str = NotifyForm.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Form %s notification: %s%serror=%s.", self.method, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Return; we're done return False else: self.logger.info("Sent Form %s notification.", self.method) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Form " f"notification to {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False except OSError as e: self.logger.warning( "An I/O error occurred while reading one of the " "attached files." ) self.logger.debug(f"I/O Exception: {e!s}") return False finally: for file in files: # Ensure all files are closed file[1][1].close() return True @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol if self.secure else self.protocol, self.user, self.password, self.host, self.port if self.port else (443 if self.secure else 80), self.fullpath.rstrip("/"), ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "method": self.method, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Append our GET params into our parameters params.update({f"-{k}": v for k, v in self.params.items()}) # Append our payload extra's into our parameters params.update({f":{k}": v for k, v in self.payload_extras.items()}) params.update({f":{k}": v for k, v in self.payload_overrides.items()}) if self.attach_as != self.attach_as_default: # Provide Attach-As extension details params["attach-as"] = self.attach_as # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyForm.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyForm.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=( NotifyForm.quote( self.fullpath, safe=URL_ENCODED_PATH_SAFE_CHARS ) if self.fullpath else "/" ), params=NotifyForm.urlencode(params), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # store any additional payload extra's defined results["payload"] = { NotifyForm.unquote(x): NotifyForm.unquote(y) for x, y in results["qsd:"].items() } # Add our headers that the user can potentially over-ride if they wish # to to our returned result set and tidy entries by unquoting them results["headers"] = { NotifyForm.unquote(x): NotifyForm.unquote(y) for x, y in results["qsd+"].items() } # Add our GET paramters in the event the user wants to pass these along results["params"] = { NotifyForm.unquote(x): NotifyForm.unquote(y) for x, y in results["qsd-"].items() } # Allow Attach-As Support which over-rides the name of the filename # posted with the form:// # the default is file01, file02, file03, etc if "attach-as" in results["qsd"] and len(results["qsd"]["attach-as"]): results["attach_as"] = results["qsd"]["attach-as"] # Set method if not otherwise set if "method" in results["qsd"] and len(results["qsd"]["method"]): results["method"] = NotifyForm.unquote(results["qsd"]["method"]) return results caronc-apprise-dfec414/apprise/plugins/custom_json.py000066400000000000000000000356461526720371000231760ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps import logging import requests from .. import exception from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import URL_ENCODED_PATH_SAFE_CHARS from ..utils.sanitize import sanitize_payload from .base import NotifyBase class JSONPayloadField: """Identifies the fields available in the JSON Payload.""" VERSION = "version" TITLE = "title" MESSAGE = "message" ATTACHMENTS = "attachments" MESSAGETYPE = "type" # Defines the method to send the notification METHODS = ( "POST", "GET", "DELETE", "PUT", "HEAD", "PATCH", "UPDATE", "OPTIONS", ) class NotifyJSON(NotifyBase): """A wrapper for JSON Notifications.""" # The default descriptive name associated with the Notification service_name = "JSON" # The default protocol protocol = "json" # The default secure protocol secure_protocol = "jsons" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/json/" # Support attachments attachment_support = True # Pass-through Notify Formats. The endpoint receives whichever body # representation Apprise resolved for this send. notify_format = ( NotifyFormat.TEXT, NotifyFormat.HTML, NotifyFormat.MARKDOWN, ) # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # Disable throttle rate for JSON requests since they are normally # local anyway request_rate_per_sec = 0 # Define the JSON version to place in all payloads # Version: Major.Minor, Major is only updated if the entire schema is # changed. If just adding new items (or removing old ones, only increment # the Minor! json_version = "1.0" # Define object templates templates = ( "{schema}://{host}", "{schema}://{host}:{port}", "{schema}://{user}@{host}", "{schema}://{user}@{host}:{port}", "{schema}://{user}:{password}@{host}", "{schema}://{user}:{password}@{host}:{port}", ) # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "method": { "name": _("Fetch Method"), "type": "choice:string", "values": METHODS, "default": METHODS[0], }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, "payload": { "name": _("Payload Extras"), "prefix": ":", }, "params": { "name": _("GET Params"), "prefix": "-", }, } def __init__( self, headers=None, method=None, payload=None, params=None, **kwargs ): """Initialize JSON Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "" self.method = ( self.template_args["method"]["default"] if not isinstance(method, str) else method.upper() ) if self.method not in METHODS: msg = f"The method specified ({method}) is invalid." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) self.params = {} if params: # Store our extra headers self.params.update(params) self.headers = {} if headers: # Store our extra headers self.headers.update(headers) self.payload_extras = {} if payload: # Store our extra payload entries self.payload_extras.update(payload) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform JSON Notification.""" # Prepare HTTP Headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Apply any/all header over-rides defined headers.update(self.headers) # Track our potential attachments attachments = [] if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access Custom JSON attachment" f" {attachment.url(privacy=True)}." ) return False try: attachments.append( { "filename": ( attachment.name if attachment.name else f"file{no:03}.dat" ), "base64": attachment.base64(), "mimetype": attachment.mimetype, } ) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access Custom JSON attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending Custom JSON attachment" f" {attachment.url(privacy=True)}" ) # Prepare JSON Object payload = { JSONPayloadField.VERSION: self.json_version, JSONPayloadField.TITLE: title, JSONPayloadField.MESSAGE: body, JSONPayloadField.ATTACHMENTS: attachments, JSONPayloadField.MESSAGETYPE: notify_type.value, } for key, value in self.payload_extras.items(): if key in payload: if not value: # Do not store element in payload response del payload[key] else: # Re-map payload[value] = payload[key] del payload[key] else: # Append entry payload[key] = value auth = None if self.user: auth = (self.user, self.password) # Set our schema schema = "https" if self.secure else "http" url = f"{schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath # Some Debug Logging if self.logger.isEnabledFor(logging.DEBUG): # Due to attachments; output can be quite heavy and io intensive # To accommodate this, we only show our debug payload information # if required. self.logger.debug( f"JSON POST URL: {url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug("JSON Payload: %s", sanitize_payload(payload)) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.request( self.method, url, data=dumps(payload), params=self.params, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code < 200 or r.status_code >= 300: # We had a problem status_str = NotifyJSON.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send JSON %s notification: %s%serror=%s.", self.method, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Return; we're done return False else: self.logger.info("Sent JSON %s notification.", self.method) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending JSON " f"notification to {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol if self.secure else self.protocol, self.user, self.password, self.host, self.port if self.port else (443 if self.secure else 80), self.fullpath.rstrip("/"), ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "method": self.method, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Append our GET params into our parameters params.update({f"-{k}": v for k, v in self.params.items()}) # Append our payload extra's into our parameters params.update({f":{k}": v for k, v in self.payload_extras.items()}) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyJSON.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyJSON.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=( NotifyJSON.quote( self.fullpath, safe=URL_ENCODED_PATH_SAFE_CHARS ) if self.fullpath else "/" ), params=NotifyJSON.urlencode(params), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # store any additional payload extra's defined results["payload"] = { NotifyJSON.unquote(x): NotifyJSON.unquote(y) for x, y in results["qsd:"].items() } # Add our headers that the user can potentially over-ride if they wish # to to our returned result set and tidy entries by unquoting them results["headers"] = { NotifyJSON.unquote(x): NotifyJSON.unquote(y) for x, y in results["qsd+"].items() } # Add our GET paramters in the event the user wants to pass these along results["params"] = { NotifyJSON.unquote(x): NotifyJSON.unquote(y) for x, y in results["qsd-"].items() } # Set method if not otherwise set if "method" in results["qsd"] and len(results["qsd"]["method"]): results["method"] = NotifyJSON.unquote(results["qsd"]["method"]) return results caronc-apprise-dfec414/apprise/plugins/custom_xml.py000066400000000000000000000443161526720371000230170ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import re import requests from .. import exception from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import URL_ENCODED_PATH_SAFE_CHARS from ..utils.sanitize import sanitize_payload from .base import NotifyBase class XMLPayloadField: """Identifies the fields available in the JSON Payload.""" VERSION = "Version" TITLE = "Subject" MESSAGE = "Message" MESSAGETYPE = "MessageType" # Defines the method to send the notification METHODS = ( "POST", "GET", "DELETE", "PUT", "HEAD", "PATCH", "UPDATE", "OPTIONS", ) class NotifyXML(NotifyBase): """A wrapper for XML Notifications.""" # The default descriptive name associated with the Notification service_name = "XML" # The default protocol protocol = "xml" # The default secure protocol secure_protocol = "xmls" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/xml/" # Support attachments attachment_support = True # Pass-through Notify Formats. The endpoint receives whichever body # representation Apprise resolved for this send. notify_format = ( NotifyFormat.TEXT, NotifyFormat.HTML, NotifyFormat.MARKDOWN, ) # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # Disable throttle rate for JSON requests since they are normally # local anyway request_rate_per_sec = 0 # XSD Information xsd_ver = "1.1" xsd_default_url = ( "https://raw.githubusercontent.com/caronc/apprise/master" "/apprise/assets/NotifyXML-{version}.xsd" ) # Define object templates templates = ( "{schema}://{host}", "{schema}://{host}:{port}", "{schema}://{user}@{host}", "{schema}://{user}@{host}:{port}", "{schema}://{user}:{password}@{host}", "{schema}://{user}:{password}@{host}:{port}", ) # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "method": { "name": _("Fetch Method"), "type": "choice:string", "values": METHODS, "default": METHODS[0], }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, "payload": { "name": _("Payload Extras"), "prefix": ":", }, "params": { "name": _("GET Params"), "prefix": "-", }, } def __init__( self, headers=None, method=None, payload=None, params=None, **kwargs ): """Initialize XML Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.payload = """ {{CORE}} {{ATTACHMENTS}} """ self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "" self.method = ( self.template_args["method"]["default"] if not isinstance(method, str) else method.upper() ) if self.method not in METHODS: msg = f"The method specified ({method}) is invalid." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # A payload map allows users to over-ride the default mapping if # they're detected with the :overide=value. Normally this would # create a new key and assign it the value specified. However # if the key you specify is actually an internally mapped one, # then a re-mapping takes place using the value self.payload_map = { XMLPayloadField.VERSION: XMLPayloadField.VERSION, XMLPayloadField.TITLE: XMLPayloadField.TITLE, XMLPayloadField.MESSAGE: XMLPayloadField.MESSAGE, XMLPayloadField.MESSAGETYPE: XMLPayloadField.MESSAGETYPE, } self.params = {} if params: # Store our extra headers self.params.update(params) self.headers = {} if headers: # Store our extra headers self.headers.update(headers) self.payload_overrides = {} self.payload_extras = {} if payload: # Store our extra payload entries (but tidy them up since they will # become XML Keys (they can't contain certain characters for k, v in payload.items(): key = re.sub(r"[^A-Za-z0-9_-]*", "", k) if not key: self.logger.warning( f"Ignoring invalid XML Stanza element name({k})" ) continue # Any values set in the payload to alter a system related one # alters the system key. Hence :message=msg maps the 'message' # variable that otherwise already contains the payload to be # 'msg' instead (containing the payload) if key in self.payload_map: self.payload_map[key] = v self.payload_overrides[key] = v else: self.payload_extras[key] = v # Set our xsd url self.xsd_url = ( None if self.payload_overrides or self.payload_extras else self.xsd_default_url.format(version=self.xsd_ver) ) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform XML Notification.""" # Prepare HTTP Headers headers = { "User-Agent": self.app_id, "Content-Type": "application/xml", } # Apply any/all header over-rides defined headers.update(self.headers) # Our XML Attachmement subsitution xml_attachments = "" payload_base = {} for key, value in ( (XMLPayloadField.VERSION, self.xsd_ver), ( XMLPayloadField.TITLE, NotifyXML.escape_html(title, whitespace=False), ), ( XMLPayloadField.MESSAGE, NotifyXML.escape_html(body, whitespace=False), ), ( XMLPayloadField.MESSAGETYPE, NotifyXML.escape_html(notify_type.value, whitespace=False), ), ): if not self.payload_map[key]: # Do not store element in payload response continue payload_base[self.payload_map[key]] = value # Apply our payload extras payload_base.update( { k: NotifyXML.escape_html(v, whitespace=False) for k, v in self.payload_extras.items() } ) # Base Entres xml_base = "".join( [f"<{k}>{v}" for k, v in payload_base.items()] ) attachments = [] if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access Custom XML attachment" f" {attachment.url(privacy=True)}." ) return False try: # Prepare our Attachment in Base64 entry = ''.format( NotifyXML.escape_html( ( attachment.name if attachment.name else f"file{no:03}.dat" ), whitespace=False, ), NotifyXML.escape_html( attachment.mimetype, whitespace=False ), ) entry += attachment.base64() entry += "" attachments.append(entry) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access Custom XML attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending Custom XML attachment" f" {attachment.url(privacy=True)}" ) # Update our xml_attachments record: xml_attachments = ( '' + "".join(attachments) + "" ) re_map = { "{{XSD_URL}}": ( f' xmlns:xsi="{self.xsd_url}"' if self.xsd_url else "" ), "{{ATTACHMENTS}}": xml_attachments, "{{CORE}}": xml_base, } # Iterate over above list and store content accordingly re_table = re.compile( r"(" + "|".join(re_map.keys()) + r")", re.IGNORECASE, ) auth = None if self.user: auth = (self.user, self.password) # Set our schema schema = "https" if self.secure else "http" url = f"{schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath payload = re_table.sub(lambda x: re_map[x.group()], self.payload) # Some Debug Logging if self.logger.isEnabledFor(logging.DEBUG): # Due to attachments; output can be quite heavy and io intensive # To accommodate this, we only show our debug payload information # if required. self.logger.debug( f"XML POST URL: {url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug("XML Payload: %s", sanitize_payload(payload)) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.request( self.method, url, data=payload, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code < 200 or r.status_code >= 300: # We had a problem status_str = NotifyXML.http_response_code_lookup(r.status_code) self.logger.warning( "Failed to send JSON %s notification: %s%serror=%s.", self.method, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Return; we're done return False else: self.logger.info("Sent XML %s notification.", self.method) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending XML " f"notification to {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol if self.secure else self.protocol, self.user, self.password, self.host, self.port if self.port else (443 if self.secure else 80), self.fullpath.rstrip("/"), ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "method": self.method, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Append our GET params into our parameters params.update({f"-{k}": v for k, v in self.params.items()}) # Append our payload extra's into our parameters params.update({f":{k}": v for k, v in self.payload_extras.items()}) params.update({f":{k}": v for k, v in self.payload_overrides.items()}) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyXML.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyXML.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=( NotifyXML.quote( self.fullpath, safe=URL_ENCODED_PATH_SAFE_CHARS ) if self.fullpath else "/" ), params=NotifyXML.urlencode(params), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # store any additional payload extra's defined results["payload"] = { NotifyXML.unquote(x): NotifyXML.unquote(y) for x, y in results["qsd:"].items() } # Add our headers that the user can potentially over-ride if they wish # to to our returned result set and tidy entries by unquoting them results["headers"] = { NotifyXML.unquote(x): NotifyXML.unquote(y) for x, y in results["qsd+"].items() } # Add our GET paramters in the event the user wants to pass these along results["params"] = { NotifyXML.unquote(x): NotifyXML.unquote(y) for x, y in results["qsd-"].items() } # Set method if not otherwise set if "method" in results["qsd"] and len(results["qsd"]["method"]): results["method"] = NotifyXML.unquote(results["qsd"]["method"]) return results caronc-apprise-dfec414/apprise/plugins/d7networks.py000066400000000000000000000376021526720371000227340ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this service you will need a D7 Networks account from their website # at https://d7networks.com/ # # After you've established your account you can get your api login credentials # (both user and password) from the API Details section from within your # account profile area: https://d7networks.com/accounts/profile/ # # API Reference: https://d7networks.com/docs/Messages/Send_Message/ from json import dumps, loads import requests from ..common import NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..utils.parse import ( is_phone_no, parse_bool, parse_phone_no, validate_regex, ) from .base import NotifyBase # Extend HTTP Error Messages D7NETWORKS_HTTP_ERROR_MAP = { 401: "Invalid Argument(s) Specified.", 403: "Unauthorized - Authentication Failure.", 412: "A Routing Error Occured", 500: "A Serverside Error Occured Handling the Request.", } class NotifyD7Networks(NotifyBase): """A wrapper for D7 Networks Notifications.""" # The default descriptive name associated with the Notification service_name = "D7 Networks" # The services URL service_url = "https://d7networks.com/" # All notification requests are secure secure_protocol = "d7sms" # Allow 300 requests per minute. # 60/300 = 0.2 request_rate_per_sec = 0.20 # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/d7networks/" # D7 Networks single notification URL notify_url = "https://api.d7networks.com/messages/v1/send" # The maximum length of the body body_maxlen = 160 # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # Define object templates templates = ("{schema}://{token}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("API Access Token"), "type": "string", "required": True, "private": True, }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "source": { # Originating address,In cases where the rewriting of the # sender's address is supported or permitted by the SMS-C. # This is used to transmit the message, this number is # transmitted as the originating address and is completely # optional. "name": _("Originating Address"), "type": "string", "map_to": "source", }, "from": { "alias_of": "source", }, "unicode": { # Unicode characters (default is 'auto') "name": _("Unicode Characters"), "type": "bool", "default": False, }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, token=None, targets=None, source=None, batch=False, unicode=None, **kwargs, ): """Initialize D7 Networks Object.""" super().__init__(**kwargs) # Prepare Batch Mode Flag self.batch = batch # Setup our source address (if defined) self.source = None if not isinstance(source, str) else source.strip() # Define whether or not we should set the unicode flag self.unicode = ( self.template_args["unicode"]["default"] if unicode is None else bool(unicode) ) # The token associated with the account self.token = validate_regex(token) if not self.token: msg = f"The D7 Networks token specified ({token}) is invalid." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Parse our targets self.targets = [] for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = result = is_phone_no(target) if not result: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) continue # store valid phone number self.targets.append(result["full"]) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Depending on whether we are set to batch mode or single mode this redirects to the appropriate handling.""" if len(self.targets) == 0: # There were no services to notify self.logger.warning("There were no D7 Networks targets to notify.") return False # error tracking (used for function return) has_error = False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Accept": "application/json", "Authorization": f"Bearer {self.token}", } payload = { "message_globals": { "channel": "sms", }, "messages": [ { # Populated later on "recipients": None, "content": body, "data_coding": # auto is a better substitute over 'text' as text # is easier to detect from a post than `unicode`. "auto" if not self.unicode else "unicode", } ], } # use the list directly targets = list(self.targets) if self.source: payload["message_globals"]["originator"] = self.source target = None while len(targets): if self.batch: # Prepare our payload payload["messages"][0]["recipients"] = self.targets # Reset our targets so we don't keep going. This is required # because we're in batch mode; we only need to loop once. targets = [] else: # We're not in a batch mode; so get our next target # Get our target(s) to notify target = targets.pop(0) # Skip a target that already accepted this message so # a retry does not deliver it twice. if self.is_delivered(target): continue # Prepare our payload payload["messages"][0]["recipients"] = [target] # Some Debug Logging self.logger.debug( "D7 Networks POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"D7 Networks Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code not in ( requests.codes.created, requests.codes.ok, ): # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code, D7NETWORKS_HTTP_ERROR_MAP ) try: # Update our status response if we can json_response = loads(r.content) status_str = json_response.get("message", status_str) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None # We could not parse JSON response. # We will just use the status we already have. self.logger.debug( "Failed to parse D7Networks JSON response; " "body: %r", (r.content or b"")[:2000], ) pass self.logger.warning( "Failed to send D7 Networks SMS notification to {}: " "{}{}error={}.".format( ", ".join(target) if self.batch else target, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: if self.batch: self.logger.info( "Sent D7 Networks batch SMS notification to " f"{len(self.targets)} target(s)." ) else: self.logger.info( f"Sent D7 Networks SMS notification to {target}." ) self.logger.debug(f"Response Details:\r\n{r.content}") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending" " D7 Networks:{} notification.".format( ", ".join(self.targets) ) ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue # Delivered; a retry can safely skip this target. self.mark_delivered(target) return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "batch": "yes" if self.batch else "no", "unicode": "yes" if self.unicode else "no", } if self.source: params["source"] = self.source # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{token}@{targets}/?{params}".format( schema=self.secure_protocol, token=self.pprint(self.token, privacy, safe=""), targets="/".join( [NotifyD7Networks.quote(x, safe="") for x in self.targets] ), params=NotifyD7Networks.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.token) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # return len(self.targets) if not self.batch else 1 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyD7Networks.unquote( results["qsd"]["token"] ) elif results["user"]: results["token"] = NotifyD7Networks.unquote(results["user"]) if results["password"]: # Support token containing a colon (:) results["token"] += ":" + NotifyD7Networks.unquote( results["password"] ) elif results["password"]: # Support token starting with a colon (:) results["token"] = ":" + NotifyD7Networks.unquote( results["password"] ) # Initialize our targets results["targets"] = [] # The store our first target stored in the hostname results["targets"].append(NotifyD7Networks.unquote(results["host"])) # Get our entries; split_path() looks after unquoting content for us # by default results["targets"].extend( NotifyD7Networks.split_path(results["fullpath"]) ) # Get Batch Mode Flag results["batch"] = parse_bool(results["qsd"].get("batch", False)) # Get Unicode Flag results["unicode"] = parse_bool(results["qsd"].get("unicode", False)) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyD7Networks.parse_phone_no( results["qsd"]["to"] ) # Support the 'from' and source variable if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyD7Networks.unquote( results["qsd"]["from"] ) elif "source" in results["qsd"] and len(results["qsd"]["source"]): results["source"] = NotifyD7Networks.unquote( results["qsd"]["source"] ) return results caronc-apprise-dfec414/apprise/plugins/dapnet.py000066400000000000000000000340041526720371000220710ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this plugin, sign up with Hampager (you need to be a licensed # ham radio operator # http://www.hampager.de/ # # You're done at this point, you only need to know your user/pass that # you signed up with. # The following URLs would be accepted by Apprise: # - dapnet://{user}:{password}@{callsign} # - dapnet://{user}:{password}@{callsign1}/{callsign2} # Optional parameters: # - priority (NORMAL or EMERGENCY). Default: NORMAL # - txgroups --> comma-separated list of DAPNET transmitter # groups. Default: 'dl-all' # https://hampager.de/#/transmitters/groups from json import dumps # The API reference used to build this plugin was documented here: # https://hampager.de/dokuwiki/doku.php#dapnet_api # import requests from requests.auth import HTTPBasicAuth from ..common import NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_call_sign, parse_bool, parse_call_sign, parse_list from .base import NotifyBase class DapnetPriority: NORMAL = 0 EMERGENCY = 1 DAPNET_PRIORITIES = { DapnetPriority.NORMAL: "normal", DapnetPriority.EMERGENCY: "emergency", } DAPNET_PRIORITY_MAP = { # Maps against string 'normal' "n": DapnetPriority.NORMAL, # Maps against string 'emergency' "e": DapnetPriority.EMERGENCY, # Entries to additionally support (so more like Dapnet's API) "0": DapnetPriority.NORMAL, "1": DapnetPriority.EMERGENCY, } class NotifyDapnet(NotifyBase): """A wrapper for DAPNET / Hampager Notifications.""" # The default descriptive name associated with the Notification service_name = "Dapnet" # The services URL service_url = "https://hampager.de/" # The default secure protocol secure_protocol = "dapnet" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/dapnet/" # Dapnet uses the http protocol with JSON requests notify_url = "http://www.hampager.de:8080/calls" # The maximum length of the body body_maxlen = 80 # A title can not be used for Dapnet Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # The maximum amount of emails that can reside within a single transmission default_batch_size = 50 # Define object templates templates = ("{schema}://{user}:{password}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "target_callsign": { "name": _("Target Callsign"), "type": "string", "regex": ( r"^[a-z0-9]{2,5}(-[a-z0-9]{1,2})?$", "i", ), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "priority": { "name": _("Priority"), "type": "choice:int", "values": DAPNET_PRIORITIES, "default": DapnetPriority.NORMAL, }, "txgroups": { "name": _("Transmitter Groups"), "type": "string", "default": "dl-all", "private": True, }, "to": { "name": _("Target Callsign"), "type": "string", "map_to": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, targets=None, priority=None, txgroups=None, batch=False, **kwargs ): """Initialize Dapnet Object.""" super().__init__(**kwargs) # Parse our targets self.targets = [] # The Priority of the message self.priority = int( NotifyDapnet.template_args["priority"]["default"] if priority is None else next( ( v for k, v in DAPNET_PRIORITY_MAP.items() if str(priority).lower().startswith(k) ), NotifyDapnet.template_args["priority"]["default"], ) ) if not (self.user and self.password): msg = "A Dapnet user/pass was not provided." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Get the transmitter group self.txgroups = parse_list( txgroups if txgroups else NotifyDapnet.template_args["txgroups"]["default"] ) # Prepare Batch Mode Flag self.batch = batch for target in parse_call_sign(targets): # Validate targets and drop bad ones: result = is_call_sign(target) if not result: self.logger.warning( f"Dropping invalid Amateur radio call sign ({target}).", ) continue # Store callsign without SSID and ignore duplicates if result["callsign"] not in self.targets: self.targets.append(result["callsign"]) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Dapnet Notification.""" if not self.targets: # There is no one to email; we're done self.logger.warning( "There are no Amateur radio callsigns to notify" ) return False # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size headers = { "User-Agent": self.app_id, "Content-Type": "application/json; charset=utf-8", } # error tracking (used for function return) has_error = False # Create a copy of the targets list targets = list(self.targets) for index in range(0, len(targets), batch_size): # Skip a batch that already went out so a retry does not # deliver it to those recipients twice. if self.is_delivered(index): continue # prepare JSON payload payload = { "text": body, "callSignNames": targets[index : index + batch_size], "transmitterGroupNames": self.txgroups, "emergency": self.priority == DapnetPriority.EMERGENCY, } self.logger.debug(f"DAPNET POST URL: {self.notify_url}") self.logger.debug(f"DAPNET Payload: {dumps(payload)}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=dumps(payload), headers=headers, auth=HTTPBasicAuth( username=self.user, password=self.password ), verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.created: # We had a problem self.logger.warning( "Failed to send DAPNET notification {} to {}: " "error={}.".format( payload["text"], f" to {self.targets}", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: self.logger.info( "Sent '{}' DAPNET notification {}".format( payload["text"], f"to {self.targets}" ) ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending DAPNET " f"notification to {self.targets}" ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue # Delivered; a retry can safely skip this batch. self.mark_delivered(index) return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "priority": ( DAPNET_PRIORITIES[self.template_args["priority"]["default"]] if self.priority not in DAPNET_PRIORITIES else DAPNET_PRIORITIES[self.priority] ), "batch": "yes" if self.batch else "no", "txgroups": ",".join(self.txgroups), } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Setup Authentication auth = "{user}:{password}@".format( user=NotifyDapnet.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) return "{schema}://{auth}{targets}?{params}".format( schema=self.secure_protocol, auth=auth, targets="/".join( [self.pprint(x, privacy, safe="") for x in self.targets] ), params=NotifyDapnet.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.user, self.password) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # All elements are targets results["targets"] = [NotifyDapnet.unquote(results["host"])] # All entries after the hostname are additional targets results["targets"].extend(NotifyDapnet.split_path(results["fullpath"])) # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyDapnet.parse_list(results["qsd"]["to"]) # Set our priority if "priority" in results["qsd"] and len(results["qsd"]["priority"]): results["priority"] = NotifyDapnet.unquote( results["qsd"]["priority"] ) # Check for one or multiple transmitter groups (comma separated) # and split them up, when necessary if "txgroups" in results["qsd"]: results["txgroups"] = [ x.lower() for x in NotifyDapnet.parse_list(results["qsd"]["txgroups"]) ] # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyDapnet.template_args["batch"]["default"] ) ) return results caronc-apprise-dfec414/apprise/plugins/dbus.py000066400000000000000000000347261526720371000215660ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import sys from ..common import NotifyImageSize, NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool from .base import NotifyBase # Default our global support flag NOTIFY_DBUS_SUPPORT_ENABLED = False # Image support is dependant on the GdkPixbuf library being available NOTIFY_DBUS_IMAGE_SUPPORT = False # Initialize our mainloops LOOP_GLIB = None LOOP_QT = None try: # D-Bus Message Bus Daemon 1.12.XX Essentials from dbus import Byte, ByteArray, DBusException, Interface, SessionBus # # now we try to determine which mainloop(s) we can access # # glib/dbus try: from dbus.mainloop.glib import DBusGMainLoop LOOP_GLIB = DBusGMainLoop() except ImportError: # pragma: no cover # No problem pass # qt try: from dbus.mainloop.qt import DBusQtMainLoop LOOP_QT = DBusQtMainLoop(set_as_default=True) except ImportError: # No problem pass # We're good as long as at least one NOTIFY_DBUS_SUPPORT_ENABLED = LOOP_GLIB is not None or LOOP_QT is not None # ImportError: When using gi.repository you must not import static modules # like "gobject". Please change all occurrences of "import gobject" to # "from gi.repository import GObject". # See: https://bugzilla.gnome.org/show_bug.cgi?id=709183 if "gobject" in sys.modules: # pragma: no cover del sys.modules["gobject"] try: # The following is required for Image/Icon loading only import gi gi.require_version("GdkPixbuf", "2.0") from gi.repository import GdkPixbuf NOTIFY_DBUS_IMAGE_SUPPORT = True except (ImportError, ValueError, AttributeError): # No problem; this will get caught in outer try/catch # A ValueError will get thrown upon calling gi.require_version() if # GDK/GTK isn't installed on the system but gi is. pass except ImportError: # No problem; we just simply can't support this plugin; we could # be in microsoft windows, or we just don't have the python-gobject # library available to us (or maybe one we don't support)? pass # Define our supported protocols and the loop to assign them. # The key to value pairs are the actual supported schema's matched # up with the Main Loop they should reference when accessed. MAINLOOP_MAP = { "qt": LOOP_QT, "kde": LOOP_QT, "dbus": LOOP_QT if LOOP_QT else LOOP_GLIB, } # Urgencies class DBusUrgency: LOW = 0 NORMAL = 1 HIGH = 2 DBUS_URGENCIES = { # Note: This also acts as a reverse lookup mapping DBusUrgency.LOW: "low", DBusUrgency.NORMAL: "normal", DBusUrgency.HIGH: "high", } DBUS_URGENCY_MAP = { # Maps against string 'low' "l": DBusUrgency.LOW, # Maps against string 'moderate' "m": DBusUrgency.LOW, # Maps against string 'normal' "n": DBusUrgency.NORMAL, # Maps against string 'high' "h": DBusUrgency.HIGH, # Maps against string 'emergency' "e": DBusUrgency.HIGH, # Entries to additionally support (so more like DBus's API) "0": DBusUrgency.LOW, "1": DBusUrgency.NORMAL, "2": DBusUrgency.HIGH, } class NotifyDBus(NotifyBase): """A wrapper for local DBus/Qt Notifications.""" # Set our global enabled flag enabled = NOTIFY_DBUS_SUPPORT_ENABLED requirements = { # Define our required packaging in order to work "details": _("libdbus-1.so.x must be installed.") } # The default descriptive name associated with the Notification service_name = _("DBus Notification") # The services URL service_url = "http://www.freedesktop.org/Software/dbus/" # The default protocols # Python 3 keys() does not return a list object, it is its own dict_keys() # object if we were to reference, we wouldn't be backwards compatible with # Python v2. So converting the result set back into a list makes us # compatible protocol = list(MAINLOOP_MAP.keys()) # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/dbus/" # No throttling required for DBus queries request_rate_per_sec = 0 # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # The number of milliseconds to keep the message present for message_timeout_ms = 13000 # Limit results to just the first 10 line otherwise there is just to much # content to display body_max_line_count = 10 # The following are required to hook into the notifications: dbus_interface = "org.freedesktop.Notifications" dbus_setting_location = "/org/freedesktop/Notifications" # No URL Identifier will be defined for this service as there simply isn't # enough details to uniquely identify one dbus:// from another. url_identifier = False # Define object templates templates = ("{schema}://",) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "urgency": { "name": _("Urgency"), "type": "choice:int", "values": DBUS_URGENCIES, "default": DBusUrgency.NORMAL, }, "priority": { # Apprise uses 'priority' everywhere; it's just a nice # consistent feel to be able to use it here as well. Just map # the value back to 'priority' "alias_of": "urgency", }, "x": { "name": _("X-Axis"), "type": "int", "min": 0, "map_to": "x_axis", }, "y": { "name": _("Y-Axis"), "type": "int", "min": 0, "map_to": "y_axis", }, "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, }, ) def __init__( self, urgency=None, x_axis=None, y_axis=None, include_image=True, **kwargs, ): """Initialize DBus Object.""" super().__init__(**kwargs) # Track our notifications self.registry = {} # Store our schema; default to dbus self.schema = kwargs.get("schema", "dbus") if self.schema not in MAINLOOP_MAP: msg = f"The schema specified ({self.schema}) is not supported." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # The urgency of the message self.urgency = int( NotifyDBus.template_args["urgency"]["default"] if urgency is None else next( ( v for k, v in DBUS_URGENCY_MAP.items() if str(urgency).lower().startswith(k) ), NotifyDBus.template_args["urgency"]["default"], ) ) # Our x/y axis settings if x_axis or y_axis: try: self.x_axis = int(x_axis) self.y_axis = int(y_axis) except (TypeError, ValueError): # Invalid x/y values specified msg = ( f"The x,y coordinates specified ({x_axis},{y_axis}) are" " invalid." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) from None else: self.x_axis = None self.y_axis = None # Track whether we want to add an image to the notification. self.include_image = include_image def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform DBus Notification.""" # Acquire our session try: session = SessionBus(mainloop=MAINLOOP_MAP[self.schema]) except DBusException as e: # Handle exception self.logger.warning("Failed to send DBus notification.") self.logger.debug(f"DBus Exception: {e}") return False # If there is no title, but there is a body, swap the two to get rid # of the weird whitespace if not title: title = body body = "" # acquire our dbus object dbus_obj = session.get_object( self.dbus_interface, self.dbus_setting_location, ) # Acquire our dbus interface dbus_iface = Interface( dbus_obj, dbus_interface=self.dbus_interface, ) # image path icon_path = ( None if not self.include_image else self.image_path(notify_type, extension=".ico") ) # Our meta payload meta_payload = {"urgency": Byte(self.urgency)} if not (self.x_axis is None and self.y_axis is None): # Set x/y access if these were set meta_payload["x"] = self.x_axis meta_payload["y"] = self.y_axis if NOTIFY_DBUS_IMAGE_SUPPORT and icon_path: try: # Use Pixbuf to create the proper image type image = GdkPixbuf.Pixbuf.new_from_file(icon_path) # Associate our image to our notification meta_payload["icon_data"] = ( image.get_width(), image.get_height(), image.get_rowstride(), image.get_has_alpha(), image.get_bits_per_sample(), image.get_n_channels(), ByteArray(image.get_pixels()), ) except Exception as e: self.logger.warning( "Could not load notification icon (%s).", icon_path ) self.logger.debug(f"DBus Exception: {e}") try: # Always call throttle() before any remote execution is made self.throttle() dbus_iface.Notify( # Application Identifier self.app_id, # Message ID (0 = New Message) 0, # Icon (str) - not used "", # Title str(title), # Body str(body), # Actions [], # Meta meta_payload, # Message Timeout self.message_timeout_ms, ) self.logger.info("Sent DBus notification.") except Exception as e: self.logger.warning("Failed to send DBus notification.") self.logger.debug(f"DBus Exception: {e}") return False return True def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "image": "yes" if self.include_image else "no", "urgency": ( DBUS_URGENCIES[self.template_args["urgency"]["default"]] if self.urgency not in DBUS_URGENCIES else DBUS_URGENCIES[self.urgency] ), } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # x in (x,y) screen coordinates # Use an explicit None check so a valid coordinate of 0 (a screen # edge) is preserved; this mirrors the send() payload which guards # on `is None` rather than truthiness. if self.x_axis is not None: params["x"] = str(self.x_axis) # y in (x,y) screen coordinates if self.y_axis is not None: params["y"] = str(self.y_axis) return f"{self.schema}://_/?{NotifyDBus.urlencode(params)}" @staticmethod def parse_url(url): """There are no parameters nessisary for this protocol; simply having gnome:// is all you need. This function just makes sure that is in place. """ results = NotifyBase.parse_url(url, verify_host=False) # Include images with our message results["include_image"] = parse_bool( results["qsd"].get("image", True) ) # DBus supports urgency, but we we also support the keyword priority # so that it is consistent with some of the other plugins if "priority" in results["qsd"] and len(results["qsd"]["priority"]): # We intentionally store the priority in the urgency section results["urgency"] = NotifyDBus.unquote(results["qsd"]["priority"]) if "urgency" in results["qsd"] and len(results["qsd"]["urgency"]): results["urgency"] = NotifyDBus.unquote(results["qsd"]["urgency"]) # handle x,y coordinates if "x" in results["qsd"] and len(results["qsd"]["x"]): results["x_axis"] = NotifyDBus.unquote(results["qsd"].get("x")) if "y" in results["qsd"] and len(results["qsd"]["y"]): results["y_axis"] = NotifyDBus.unquote(results["qsd"].get("y")) return results caronc-apprise-dfec414/apprise/plugins/deltachat.py000066400000000000000000000117101526720371000225460ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Delta Chat reuses Email delivery and adds its chat-specific headers. # # Steps to set this up: # 1. Get a mailbox for your bot. Any regular e-mail account works, but a # purpose-built "chatmail" relay (see https://chatmail.at/) is what # most Delta Chat users are already on. # 2. Note the account's SMTP host, port, username, and password. # 3. The person you want to notify must already have (or add) your bot's # address as a contact in Delta Chat; a first message from an unknown # address lands in their "Contact Requests" list until accepted. # # Your Apprise URL should be assembled as: # deltachat://user:password@smtp.example.com/friend@example.org # # Many chatmail relays reject unencrypted mail outright, so consider # pairing this with ?pgp=sign or ?pgp=encrypt. Email's PGP documentation # applies here unchanged. # # Resources: # - https://delta.chat/ # - https://github.com/deltachat/spec (the e-mail-chat protocol) # - https://docs.autocrypt.org/level1.html (the Autocrypt header format) from ..common import NotifyFormat, NotifyType from .email.base import NotifyEmail, PGPMode # Longest excerpt of the message body placed in the outer Subject header DELTACHAT_SUBJECT_EXCERPT_LEN = 50 class NotifyDeltaChat(NotifyEmail): """Send Delta Chat messages through Email's SMTP and PGP support.""" # The default descriptive name associated with the Notification service_name = "Delta Chat" # The services URL service_url = "https://delta.chat/" # The default protocol protocol = "deltachat" # The default secure protocol secure_protocol = "deltachats" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/deltachat/" # Delta Chat clients render messages as plain text notify_format = NotifyFormat.TEXT # Delta Chat has no separate subject/title field in the chat bubble # itself, so let the framework merge the title into the body for us title_maxlen = 0 @staticmethod def parse_url(url): """Parse an Email URL while always selecting plain text.""" results = NotifyEmail.parse_url(url) if results: results["format"] = "text" return results def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, body_format=None, **kwargs, ): """Perform Delta Chat Notification.""" # The framework has merged the title into the body. Build the chat # subject from that combined text. return super().send( body=body, title=self._chat_subject(body), notify_type=notify_type, attach=attach, body_format=body_format, **kwargs, ) def _protocol_headers(self, body): """Return Delta Chat's protocol headers.""" # Chat-Version is required by the spec on every outgoing message return {"Chat-Version": "1.0"} def _chat_subject(self, body): """Build a ``Chat:`` subject without exposing protected content.""" if self.pgp_mode == PGPMode.ENCRYPT: # Delta Chat recommends this placeholder when encrypted metadata # is not carried with Memoryhole. return "Chat: Encrypted message" if self.pgp_mode == PGPMode.SIGN: return "Chat:" excerpt = " ".join(body.split()) if len(excerpt) > DELTACHAT_SUBJECT_EXCERPT_LEN: excerpt = excerpt[:DELTACHAT_SUBJECT_EXCERPT_LEN] + "..." return f"Chat: {excerpt}" if excerpt else "Chat:" caronc-apprise-dfec414/apprise/plugins/dingtalk.py000066400000000000000000000317141526720371000224200ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import base64 import hashlib import hmac from json import dumps import re import time from typing import Any, Optional import requests from ..common import NotifyFormat, NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_list, validate_regex from .base import NotifyBase # Register at https://dingtalk.com # - Download their PC based software as it is the only way you can create # a custom robot. You can create a custom robot per group. You will # be provided an access_token that Apprise will need. # Syntax: # dingtalk://{access_token}/ # dingtalk://{access_token}/{optional_phone_no} # dingtalk://{access_token}/{phone_no_1}/{phone_no_2}/{phone_no_N/ # Some Phone Number Detection IS_PHONE_NO = re.compile(r"^\+?(?P[0-9\s)(+-]+)\s*$") class NotifyDingTalk(NotifyBase): """A wrapper for DingTalk Notifications.""" # The default descriptive name associated with the Notification service_name = "DingTalk" # The services URL service_url = "https://www.dingtalk.com/" # All notification requests are secure secure_protocol = "dingtalk" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/dingtalk/" # DingTalk API notify_url = "https://oapi.dingtalk.com/robot/send?access_token={token}" # Keep the title separate so send() can place it consistently in both # text and Markdown messages. # DingTalk renders both plain text and markdown natively. Text is # listed first, making it the default when nothing else applies. notify_format = (NotifyFormat.TEXT, NotifyFormat.MARKDOWN) # Define object templates templates = ( "{schema}://{token}/", "{schema}://{token}/{targets}/", "{schema}://{secret}@{token}/", "{schema}://{secret}@{token}/{targets}/", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("Token"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9]+$", "i"), }, "secret": { "name": _("Secret"), "type": "string", "private": True, "regex": (r"^[a-z0-9]+$", "i"), }, "target_phone_no": { "name": _("Target Phone No"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "to": { "alias_of": "targets", }, "token": { "alias_of": "token", }, "secret": { "alias_of": "secret", }, }, ) def __init__(self, token, targets=None, secret=None, **kwargs): """Initialize DingTalk Object.""" super().__init__(**kwargs) # Secret Key (associated with project) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"An invalid DingTalk API Token ({token}) was specified." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) self.secret = None if secret: self.secret = validate_regex( secret, *self.template_tokens["secret"]["regex"] ) if not self.secret: msg = f"An invalid DingTalk Secret ({token}) was specified." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Parse our targets self.targets = [] for target in parse_list(targets): # Validate targets and drop bad ones: result = IS_PHONE_NO.match(target) if result: # Further check our phone # for it's digit count result = "".join(re.findall(r"\d+", result.group("phone"))) if len(result) < 11 or len(result) > 14: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) continue # store valid phone number self.targets.append(result) continue self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) return def get_signature(self): """Calculates time-based signature so that we can send arbitrary messages.""" timestamp = str(round(time.time() * 1000)) secret_enc = self.secret.encode("utf-8") str_to_sign_enc = f"{timestamp}\n{self.secret}".encode() hmac_code = hmac.new( secret_enc, str_to_sign_enc, digestmod=hashlib.sha256 ).digest() signature = NotifyDingTalk.quote(base64.b64encode(hmac_code), safe="") return timestamp, signature def send( self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, body_format: Optional[NotifyFormat] = None, **kwargs: Any, ) -> bool: """Perform DingTalk Notification.""" payload = { "msgtype": "text", "at": { "atMobiles": self.targets, "isAtAll": False, }, } if body_format == NotifyFormat.MARKDOWN: # Keep the title on one line and drop any leading heading or # list markers so it reads cleanly as a heading title = " ".join(title.split()).lstrip("#- ") # ``markdown.title`` is only a preview, so repeat the title as # a heading in ``markdown.text``. # See: https://open.dingtalk.com/document/app/ # custom-robot-access payload["markdown"] = { # A title is mandatory for markdown messages "title": title if title else self.app_desc, "text": f"# {title}\n{body}" if title else body, } payload["msgtype"] = "markdown" else: # Plain text has no title field, so the title goes on the # first line payload["text"] = { "content": f"{title}\r\n{body}" if title else body, } # Our Notification URL notify_url = self.notify_url.format(token=self.token) params = None if self.secret: timestamp, signature = self.get_signature() params = { "timestamp": timestamp, "sign": signature, } # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Some Debug Logging self.logger.debug( "DingTalk URL:" f" {notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"DingTalk Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, data=dumps(payload), headers=headers, params=params, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyDingTalk.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send DingTalk notification: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) return False else: self.logger.info("Sent DingTalk notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occured sending DingTalk notification." ) self.logger.debug(f"Socket Exception: {e!s}") return False return True def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any arguments set. url_parameters() knows how to # serialize format= for a multi-format plugin; it is only # emitted when the user asked for an explicit override. args = self.url_parameters(privacy=privacy, *args, **kwargs) return "{schema}://{secret}{token}/{targets}/?{args}".format( schema=self.secure_protocol, secret=( "" if not self.secret else "{}@".format( self.pprint( self.secret, privacy, mode=PrivacyMode.Secret, safe="" ) ) ), token=self.pprint(self.token, privacy, safe=""), targets="/".join( [NotifyDingTalk.quote(x, safe="") for x in self.targets] ), args=NotifyDingTalk.urlencode(args), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.secret, self.token) def __len__(self): """Returns the number of targets associated with this notification.""" targets = len(self.targets) return targets if targets > 0 else 1 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to substantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results results["token"] = NotifyDingTalk.unquote(results["host"]) # if a user has been defined, use it's value as the secret if results.get("user"): results["secret"] = results.get("user") # Get our entries; split_path() looks after unquoting content for us # by default results["targets"] = NotifyDingTalk.split_path(results["fullpath"]) # Support the use of the `token` keyword argument if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyDingTalk.unquote(results["qsd"]["token"]) # Support the use of the `secret` keyword argument if "secret" in results["qsd"] and len(results["qsd"]["secret"]): results["secret"] = NotifyDingTalk.unquote( results["qsd"]["secret"] ) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyDingTalk.parse_list( results["qsd"]["to"] ) return results caronc-apprise-dfec414/apprise/plugins/discord.py000066400000000000000000001304771526720371000222600ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # For this to work correctly you need to create a webhook. To do this just # click on the little gear icon next to the channel you're part of. From # here you'll be able to access the Webhooks menu and create a new one. # # When you've completed, you'll get a URL that looks a little like this: # https://discord.com/api/webhooks/417429632418316298/\ # JHZ7lQml277CDHmQKMHI8qBe7bk2ZwO5UKjCiOAF7711o33MyqU344Qpgv7YTpadV_js # # Simplified, it looks like this: # https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN # # This plugin will simply work using the url of: # discord://WEBHOOK_ID/WEBHOOK_TOKEN # # API Documentation on Webhooks: # - https://discord.com/developers/docs/resources/webhook # from __future__ import annotations from datetime import datetime, timedelta, timezone from itertools import chain from json import dumps, loads from json.decoder import JSONDecodeError import re from typing import Any import requests from ..apprise_attachment import AppriseAttachment from ..attachment.base import AttachBase from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..conversion import commonmark_sections from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool, parse_list, validate_regex from ..utils.templates import TemplateType, apply_template from .base import NotifyBase # Used to detect user/role IDs and @here/@everyone tokens. USER_ROLE_DETECTION_RE = re.compile( r"\s*(?:&?)(?P[0-9]+)>?|@(?P[a-z0-9]+))", re.I ) # Discord draws any :name: it recognizes as an emoji, even in the middle # of a word, so 2001:db8:100::1 would show an emoji in place of :100:. # This finds every colon that opens a :name: so it can be escaped. These # are stepped over and left exactly as they are: # - Characters that are already escaped. # - Code, where Discord never draws emoji. # - Discord tokens such as <:party:123>, and <@123>. # - Web links, so an address like http://[fe80::1:2]/ keeps working. DISCORD_EMOJI_ESCAPE_RE = re.compile( r"(?P\\.|(?P`+).+?(?P=tick)|<[^<>\s]*>" r"|(?i:https?)://\S+)" r"|:(?=[a-z0-9_+-]+:)", re.S, ) class NotifyDiscord(NotifyBase): """A wrapper to Discord Notifications.""" # The default descriptive name associated with the Notification service_name = "Discord" # The services URL service_url = "https://discord.com/" # The default secure protocol secure_protocol = "discord" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/discord/" # Discord Webhook notify_url = "https://discord.com/api/webhooks" # Discord supports plain text and Markdown-style embeds. Plain text # remains the default. notify_format = (NotifyFormat.TEXT, NotifyFormat.MARKDOWN) # Support attachments attachment_support = True # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_256 # Discord is kind enough to return how many more requests we're allowed to # continue to make within it's header response as: # X-RateLimit-Reset: The epoc time (in seconds) we can expect our # rate-limit to be reset. # X-RateLimit-Remaining: an integer identifying how many requests we're # still allow to make. request_rate_per_sec = 0 # Taken right from google.auth.helpers: clock_skew = timedelta(seconds=10) # The maximum allowable characters allowed in the body per message body_maxlen = 2000 # The 2000 characters above defined by the body_maxlen include that of the # title. Setting this to True ensures overflow options behave properly overflow_amalgamate_title = True # Discord has a limit of the number of fields you can include in an # embeds message. This value allows the discord message to safely # break into multiple messages to handle these cases. discord_max_fields = 10 # Discord limits the length of each embed field name and value; see # https://docs.discord.com/developers/resources/message discord_field_name_maxlen = 256 discord_field_value_maxlen = 1024 # There is no reason we should exceed 35KB when reading in a JSON # file. If it is more than this, then it is not accepted max_discord_template_size = 35000 # Maximum number of attachments Discord accepts in a single message discord_max_attachments = 10 # Maximum total attachment bytes per message. # Discord's documented default is 25 MiB for most users. discord_max_attach_bytes = 25 * 1024 * 1024 # Define object templates templates = ( "{schema}://{webhook_id}/{webhook_token}", "{schema}://{botname}@{webhook_id}/{webhook_token}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "botname": { "name": _("Bot Name"), "type": "string", "map_to": "user", }, "webhook_id": { "name": _("Webhook ID"), "type": "string", "private": True, "required": True, }, "webhook_token": { "name": _("Webhook Token"), "type": "string", "private": True, "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "tts": { "name": _("Text To Speech"), "type": "bool", "default": False, }, "avatar": { "name": _("Avatar Image"), "type": "bool", "default": True, }, "avatar_url": { "name": _("Avatar URL"), "type": "string", }, "href": { "name": _("URL"), "type": "string", }, "url": { "alias_of": "href", }, # Send a message to the specified thread within a webhook's # channel. The thread will automatically be unarchived. "thread": { "name": _("Thread ID"), "type": "string", }, "footer": { "name": _("Display Footer"), "type": "bool", "default": False, }, "footer_logo": { "name": _("Footer Logo"), "type": "bool", "default": True, }, "fields": { "name": _("Use Fields"), "type": "bool", "default": True, }, "flags": { "name": _("Discord Flags"), "type": "int", "min": 0, }, "image": { "name": _("Include Image"), "type": "bool", "default": False, "map_to": "include_image", }, # Explicit ping targets. Examples: # - ping=12345,67890 # - ping=<@12345>,<@&67890>,@here "ping": { "name": _("Ping Users/Roles"), "type": "list:string", }, "template": { "name": _("Template Path"), "type": "string", "private": True, }, # When True (default) multiple attachments are grouped into # batches and sent in a single message where possible. # Set to no/false to revert to one attachment per message. "batch": { "name": _("Batch Attachments"), "type": "bool", "default": True, }, }, ) # Define our token control template_kwargs = { "tokens": { "name": _("Template Tokens"), "prefix": ":", }, } def __init__( self, webhook_id: str, webhook_token: str, tts: bool = False, avatar: bool = True, footer: bool = False, footer_logo: bool = True, include_image: bool = False, fields: bool = True, avatar_url: str | None = None, href: str | None = None, thread: str | None = None, flags: int | None = None, ping: list[str] | None = None, template: str | None = None, tokens: dict | None = None, batch: bool | None = None, **kwargs: Any, ) -> None: """Initialize Discord Object.""" super().__init__(**kwargs) # Webhook ID (associated with project) self.webhook_id = validate_regex(webhook_id) if not self.webhook_id: msg = ( f"An invalid Discord Webhook ID ({webhook_id}) was specified." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Webhook Token (associated with project) self.webhook_token = validate_regex(webhook_token) if not self.webhook_token: msg = ( "An invalid Discord Webhook Token " f"({webhook_token}) was specified." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Text To Speech self.tts = tts # Over-ride Avatar Icon self.avatar = avatar # Place a footer self.footer = footer # include a footer_logo in footer self.footer_logo = footer_logo # Place a thumbnail image inline with the message body self.include_image = include_image # Use Fields self.fields = fields # Specified Thread ID self.thread_id = thread # Avatar URL # This allows a user to provide an over-ride to the otherwise # dynamically generated avatar url images self.avatar_url = avatar_url # A URL to have the title link to self.href = href # A URL to have the title link to if flags: try: self.flags = int(flags) if self.flags < NotifyDiscord.template_args["flags"]["min"]: raise ValueError() except (TypeError, ValueError): msg = ( f"An invalid Discord flags setting ({flags}) was " "specified." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) from None else: self.flags = None # Ping targets (tokens from URL, already split by parse_list) self.ping: list[str] = parse_list(ping) # When True, group multiple attachments into a single message # where count and size limits allow; False reverts to one per # message (old behaviour) self.batch = ( self.template_args["batch"]["default"] if batch is None else parse_bool( batch, default=self.template_args["batch"]["default"], ) ) self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) # Default to 1.0 self.ratelimit_remaining = 1.0 # Our template object is just an AppriseAttachment object self.template = AppriseAttachment(asset=self.asset) if template: # Add our definition to our template self.template.add(template) if not len(self.template): # add() failed (unsupported schema, unparseable URL, etc.) msg = "The Discord template specified could not be loaded." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Enforce maximum file size self.template[0].max_file_size = self.max_discord_template_size # Template functionality self.tokens: dict = {} if isinstance(tokens, dict): self.tokens.update(tokens) elif tokens: msg = ( "The specified Discord Template Tokens " f"({tokens}) are not identified as a dictionary." ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # else: NoneType - this is okay return def gen_payload( self, body, title="", notify_type=NotifyType.INFO, **kwargs ): """Return a validated Discord webhook payload from template, or False on failure.""" # Acquire the first template attachment template = self.template[0] if not template: # We could not access the attachment self.logger.error( "Could not access Discord template" f" {template.url(privacy=True)}." ) return False # Acquire our image URL for potential token substitution image_url = self.image_url(notify_type) # Take a copy of our token dictionary tokens = self.tokens.copy() # Apply our standard template variable defaults tokens["app_body"] = body tokens["app_title"] = title tokens["app_type"] = notify_type.value tokens["app_id"] = self.app_id tokens["app_desc"] = self.app_desc tokens["app_color"] = self.color(notify_type) # app_color_hex is an explicit alias for app_color, provided so # templates can name the hex variant unambiguously alongside # app_color_int tokens["app_color_hex"] = self.color(notify_type) # Discord embed payloads require color as a decimal integer; # app_color_int provides that while app_color stays hex for # consistency with all other template-capable plugins tokens["app_color_int"] = self.color(notify_type, int) tokens["app_image_url"] = image_url tokens["app_url"] = self.app_url # Templates are always JSON; enforce JSON escaping tokens["app_mode"] = TemplateType.JSON # Stringify substitutions before JSON escaping; preserve app_mode safe_tokens = { k: ( v if k == "app_mode" or isinstance(v, str) else ("" if v is None else str(v)) ) for k, v in tokens.items() } try: with open(template.path) as fp: content = apply_template(fp.read(), **safe_tokens) except OSError: self.logger.error( "Discord template" f" {template.url(privacy=True)} could not be read." ) return False # Parse and validate as JSON try: content = loads(content) except (JSONDecodeError, ValueError) as e: self.logger.error( "Discord template" f" {template.url(privacy=True)} contains invalid JSON." ) self.logger.debug(f"JSONDecodeError: {e}") return False # Template must parse to a JSON object, not an array or scalar if not isinstance(content, dict): self.logger.error( "Discord template" f" {template.url(privacy=True)} must be a JSON object" " (got {}).".format(type(content).__name__) ) return False # A Discord webhook payload must have a non-empty 'content' string # or a non-empty 'embeds' list containing embed dicts has_content = ( isinstance(content.get("content"), str) and content["content"] ) has_embeds = ( isinstance(content.get("embeds"), list) and content["embeds"] ) if not (has_content or has_embeds): self.logger.error( "Discord template" f" {template.url(privacy=True)} must contain" " a non-empty 'content' string or a non-empty 'embeds'" " list." ) return False # If embeds is present, each entry must be a JSON object (dict) if has_embeds and not all( isinstance(e, dict) for e in content["embeds"] ): self.logger.error( "Discord template" f" {template.url(privacy=True)} contains" " an embed entry that is not a JSON object." ) return False # Return the validated payload content dict return content def dialect_convert( self, body: str, body_format: NotifyFormat | None = None, *args: Any, **kwargs: Any, ) -> str: """Escape each ``:name:`` so Discord shows it as text. This only happens when emoji support is turned off. """ if self.interpret_emojis is not False: # Left unset, Discord draws emoji as it always has. Turned on, # Apprise already converted the names it knows and Discord is # free to draw the rest. return body # Discord shows \: as a plain colon, in text and markdown alike. return DISCORD_EMOJI_ESCAPE_RE.sub( lambda m: m.group("skip") or "\\:", body ) def send( self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, attach: list[AttachBase] | None = None, body_format: NotifyFormat | None = None, **kwargs: Any, ) -> bool: """Perform Discord Notification.""" # body_format arrives as the resolved render target. if title and not kwargs.get("body_passthrough"): # The title skips dialect_convert(), so protect it here too. title = self.dialect_convert(title) # Use embeds only when the URL explicitly selects Markdown. Discord # already renders Markdown sent as regular content. use_embeds = self._format_override == NotifyFormat.MARKDOWN payload: dict[str, Any] = { "tts": self.tts, # If Text-To-Speech is set to True, then we do not want to wait # for the whole message before continuing. Otherwise, we wait "wait": self.tts is False, } if self.flags: # Set our flag if defined: payload["flags"] = self.flags # Acquire image_url image_url = self.image_url(notify_type) if self.avatar and (image_url or self.avatar_url): payload["avatar_url"] = ( self.avatar_url if self.avatar_url else image_url ) if self.user: # Optionally override the default username of the webhook payload["username"] = self.user # Associate our thread_id with our message params = {"thread_id": self.thread_id} if self.thread_id else None # Template mode bypasses ping detection and embed construction; # the template defines the complete payload content. if not self.template: # Embed mode can detect pings; ping= is additive there and # explicit for regular content. if use_embeds: if self.ping: payload.update( self.ping_payload(body, " ".join(self.ping)) ) else: payload.update(self.ping_payload(body)) # Regular content: no body parsing, ping= is exclusive. elif self.ping: payload.update(self.ping_payload(" ".join(self.ping))) if self.template: # Generate our payload from the user-supplied template file template_payload = self.gen_payload( body=body, title=title, notify_type=notify_type, **kwargs, ) if template_payload is False: # gen_payload() already logged the error; bail early return False # Merge template content over our base payload payload.update(template_payload) # Skip the message if an earlier attempt already posted it if not self.is_delivered("message"): if not self._send(payload, params=params): # We failed to post our message return False # Posted; a retry can safely skip it self.mark_delivered("message") elif body: # Track extra embed fields (if used) fields: list[dict[str, str]] = [] if use_embeds: # Use embeds for payload payload["embeds"] = [ { "author": { "name": self.app_id, "url": self.app_url, }, "title": title, "description": body, # Our color associated with our notification "color": self.color(notify_type, int), } ] if self.href: payload["embeds"][0]["url"] = self.href if self.footer: # Acquire logo URL logo_url = self.image_url(notify_type, logo=True) # Set Footer text to our app description payload["embeds"][0]["footer"] = { "text": self.app_desc, } if self.footer_logo and logo_url: payload["embeds"][0]["footer"]["icon_url"] = logo_url if self.include_image and image_url: payload["embeds"][0]["thumbnail"] = { "url": image_url, "height": 256, "width": 256, } if self.fields: # Break titles out so that we can sort them in embeds description, fields = self.extract_markdown_sections(body) # Swap first entry for description payload["embeds"][0]["description"] = description if fields: # Apply our additional parsing for a better # presentation payload["embeds"][0]["fields"] = fields[ : self.discord_max_fields ] fields = fields[self.discord_max_fields :] else: # TEXT, HTML, or markdown without an explicit request: # - No ping detection unless ping= was provided. # - If ping= was provided, ping_payload() already generated # payload["content"] starting with "👉 ...", and we # append it. payload["content"] = ( body if not title else f"{title}\r\n{body}" ) + payload.get("content", "") # Skip the message if an earlier attempt already posted it if not self.is_delivered("message"): if not self._send(payload, params=params): # We failed to post our message return False # Posted; a retry can safely skip it self.mark_delivered("message") # Send remaining fields (if any) if fields: payload["embeds"][0]["description"] = "" for i in range(0, len(fields), self.discord_max_fields): # Each extra message of fields is tracked on its own if self.is_delivered(("fields", i)): continue payload["embeds"][0]["fields"] = fields[ i : i + self.discord_max_fields ] if not self._send(payload, params=params): # We failed to post our message return False # Posted; a retry can safely skip these fields self.mark_delivered(("fields", i)) if attach and self.attachment_support: # Update our payload; the idea is to preserve it's other detected # and assigned values for re-use here too payload.update( { # Text-To-Speech "tts": False, # Wait until the upload has posted itself before continuing "wait": True, } ) # # Remove our text/title based content for attachment use # payload.pop("embeds", None) payload.pop("content", None) payload.pop("allow_mentions", None) # # Build attachment batches in strict serial order. # Each batch respects the per-message attachment count and # total byte limits. When batch=False every attachment is # its own message (legacy one-per-message behaviour). # max_per = self.discord_max_attachments if self.batch else 1 batches: list[list[AttachBase]] = [] current: list[AttachBase] = [] current_size = 0 for attachment in attach: # Size is only needed for byte-limit batching; skip the # stat()/download() it triggers when batching is off size = ( (len(attachment) if attachment else 0) if self.batch else 0 ) if current and ( len(current) >= max_per or ( self.batch and current_size + size > self.discord_max_attach_bytes ) ): # Current batch is full; flush and start a new one batches.append(current) current = [] current_size = 0 current.append(attachment) current_size += size # current always holds at least the last attachment here batches.append(current) # # Send each attachment batch # for index, batch in enumerate(batches): # Skip a batch an earlier attempt already posted if self.is_delivered(("attach", index)): continue if not self._send(payload, params=params, attach=batch): # We failed to post our attachment batch return False # Posted; a retry can safely skip this batch self.mark_delivered(("attach", index)) # Otherwise return return True def _send( self, payload: dict[str, Any], attach: list[AttachBase] | None = None, params: dict[str, str] | None = None, rate_limit: int = 1, **kwargs: Any, ) -> bool: """Wrapper to the requests (post) object.""" # Our headers headers = { "User-Agent": self.app_id, } # Construct Notify URL notify_url = ( f"{self.notify_url}/{self.webhook_id}/{self.webhook_token}" ) self.logger.debug( "Discord POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Discord Payload: {payload!s}") wait: float | None = None if self.ratelimit_remaining <= 0.0: # Determine how long we should wait for or if we should wait at # all. This isn't fool-proof because we can't be sure the client # time (calling this script) is completely synced up with the # Discord server. One would hope we're on NTP and our clocks are # the same allowing this to role smoothly: now = datetime.now(timezone.utc).replace(tzinfo=None) if now < self.ratelimit_reset: # We need to throttle for the difference in seconds wait = abs( ( self.ratelimit_reset - now + self.clock_skew ).total_seconds() ) # Always call throttle before any remote server i/o is made; self.throttle(wait=wait) # File handles opened for this call; all closed in `finally` handles: list[Any] = [] # Multipart file list built from the attach batch files: list[Any] | None = None attach_ok = True try: # Open our attachment path(s) if required: if attach: files = [] for idx, attachment in enumerate(attach): # Verify accessibility before opening if not attachment: self.logger.warning( "Could not access Discord attachment %s.", attachment.url(privacy=True), ) attach_ok = False break self.logger.debug( "Posting Discord attachment %s", attachment.url(privacy=True), ) # Catch OSError per attachment open try: handle = attachment.open() except OSError as e: self.logger.warning( "An I/O error occurred while reading %s.", attachment.name or "attachment", ) self.logger.debug("I/O Exception: %s", str(e)) attach_ok = False break # Register handle before appending to files handles.append(handle) files.append( ( f"files[{idx}]", ( attachment.name, handle, attachment.mimetype, ), ) ) if not attach_ok: return False else: headers["Content-Type"] = "application/json; charset=utf-8" r = requests.post( notify_url, params=params, data=( {"payload_json": dumps(payload)} if files else dumps(payload) ), headers=headers, files=files, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) # Handle rate limiting (if specified) try: # Store our rate limiting (if provided) self.ratelimit_remaining = float( r.headers.get("X-RateLimit-Remaining") ) self.ratelimit_reset = datetime.fromtimestamp( int(r.headers.get("X-RateLimit-Reset")), timezone.utc ).replace(tzinfo=None) except (TypeError, ValueError): # This is returned if we could not retrieve this # information gracefully accept this state and move on pass if r.status_code not in ( requests.codes.ok, requests.codes.no_content, ): # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code ) if ( r.status_code == requests.codes.too_many_requests and rate_limit > 0 ): # handle rate limiting self.logger.warning( "Discord rate limiting in effect; " "blocking for %.2f second(s)", self.ratelimit_remaining, ) # Try one more time before failing return self._send( payload=payload, attach=attach, params=params, rate_limit=rate_limit - 1, **kwargs, ) self.logger.warning( "Failed to send Discord %s: %s%serror=%s.", ( "{} attachment(s)".format(len(attach)) if attach else "notification" ), status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Return; we're done return False else: if attach: self.logger.info( "Sent Discord %d attachment(s).", len(attach) ) else: self.logger.info("Sent Discord notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred posting to Discord." ) self.logger.debug(f"Socket Exception: {e!s}") return False except OSError as e: # Catches I/O errors from requests.post() and any unexpected # file-system errors not caught by the per-attachment check above self.logger.warning("An I/O error occurred posting to Discord.") self.logger.debug(f"I/O Exception: {e!s}") return False finally: # Close all handles regardless of success or failure for handle in handles: handle.close() return True def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str: """Returns the URL built dynamically based on specified arguments.""" params: dict[str, str] = { "tts": "yes" if self.tts else "no", "avatar": "yes" if self.avatar else "no", "footer": "yes" if self.footer else "no", "footer_logo": "yes" if self.footer_logo else "no", "image": "yes" if self.include_image else "no", "fields": "yes" if self.fields else "no", "batch": "yes" if self.batch else "no", } if self.avatar_url: params["avatar_url"] = self.avatar_url if self.flags: params["flags"] = str(self.flags) if self.href: params["href"] = self.href if self.thread_id: params["thread"] = self.thread_id if self.ping: # Let Apprise urlencode handle list formatting params["ping"] = ",".join(self.ping) if self.template: # Include our template reference params["template"] = NotifyDiscord.quote( self.template[0].url(), safe="" ) # Store any template token entries if specified params.update({f":{k}": v for k, v in self.tokens.items()}) # Ensure our botname is set botname = ( f"{NotifyDiscord.quote(self.user, safe='')}@" if self.user else "" ) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return ( "{schema}://{bname}{webhook_id}/{webhook_token}/?{params}".format( schema=self.secure_protocol, bname=botname, webhook_id=self.pprint(self.webhook_id, privacy, safe=""), webhook_token=self.pprint( self.webhook_token, privacy, safe="" ), params=NotifyDiscord.urlencode(params), ) ) @property def url_identifier(self) -> tuple[str, str, str]: """Returns all of the identifiers that make this URL unique.""" return (self.secure_protocol, self.webhook_id, self.webhook_token) @staticmethod def parse_url(url: str) -> dict[str, Any] | None: """Parses the URL and returns arguments for instantiating this object. Syntax: discord://webhook_id/webhook_token """ results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Store our webhook ID webhook_id = NotifyDiscord.unquote(results["host"]) # Now fetch our tokens try: webhook_token = NotifyDiscord.split_path(results["fullpath"])[0] except IndexError: # Force some bad values that will get caught # in parsing later webhook_token = None results["webhook_id"] = webhook_id results["webhook_token"] = webhook_token # Text To Speech results["tts"] = parse_bool(results["qsd"].get("tts", False)) # Use sections # effectively detect multiple fields and break them off # into sections results["fields"] = parse_bool(results["qsd"].get("fields", True)) # Use Footer results["footer"] = parse_bool(results["qsd"].get("footer", False)) # Use Footer Logo results["footer_logo"] = parse_bool( results["qsd"].get("footer_logo", True) ) # Update Avatar Icon results["avatar"] = parse_bool(results["qsd"].get("avatar", True)) # Boolean to include an image or not results["include_image"] = parse_bool( results["qsd"].get( "image", NotifyDiscord.template_args["image"]["default"] ) ) if "botname" in results["qsd"]: # Alias to User results["user"] = NotifyDiscord.unquote(results["qsd"]["botname"]) if "flags" in results["qsd"]: # Alias to User results["flags"] = NotifyDiscord.unquote(results["qsd"]["flags"]) # Extract avatar url if it was specified if "avatar_url" in results["qsd"]: results["avatar_url"] = NotifyDiscord.unquote( results["qsd"]["avatar_url"] ) # Extract url if it was specified if "href" in results["qsd"]: results["href"] = NotifyDiscord.unquote(results["qsd"]["href"]) elif "url" in results["qsd"]: results["href"] = NotifyDiscord.unquote(results["qsd"]["url"]) # Markdown is implied results["format"] = NotifyFormat.MARKDOWN # Extract thread id if it was specified if "thread" in results["qsd"]: results["thread"] = NotifyDiscord.unquote(results["qsd"]["thread"]) # Markdown is implied results["format"] = NotifyFormat.MARKDOWN # Extract ping targets, comma/space separated if "ping" in results["qsd"]: results["ping"] = NotifyDiscord.unquote(results["qsd"]["ping"]) # Template Handling if "template" in results["qsd"] and results["qsd"]["template"]: results["template"] = NotifyDiscord.unquote( results["qsd"]["template"] ) # Store our template tokens results["tokens"] = results["qsd:"] # Batch attachments flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyDiscord.template_args["batch"]["default"], ) ) return results @staticmethod def parse_native_url(url: str) -> dict[str, Any] | None: """ Support https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN Support Legacy URL as well: https://discordapp.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN """ result = re.match( r"^https?://discord(app)?\.com/api/webhooks/" r"(?P[0-9]+)/" r"(?P[A-Z0-9_-]+)/?" r"(?P\?.+)?$", url, re.I, ) if result: return NotifyDiscord.parse_url( "{schema}://{webhook_id}/{webhook_token}/{params}".format( schema=NotifyDiscord.secure_protocol, webhook_id=result.group("webhook_id"), webhook_token=result.group("webhook_token"), params=( "" if not result.group("params") else result.group("params") ), ) ) return None def ping_payload(self, *args: str) -> dict[str, Any]: """ Takes one or more strings and applies the payload associated with pinging the users detected within. This returns a dict that may contain: - allow_mentions - content (starting with "👉 " and containing mention tokens) """ payload: dict[str, Any] = {} roles: set[str] = set() users: set[str] = set() parse: set[str] = set() for arg in args: # parse for user id's <@123> and role IDs <@&456> results = USER_ROLE_DETECTION_RE.findall(arg) if not results: continue for is_role, no, value in results: if value: parse.add(value) elif is_role: roles.add(no) else: # is_user users.add(no) if not (roles or users or parse): # Nothing to add return payload payload["allow_mentions"] = { "parse": list(parse), "users": list(users), "roles": list(roles), } payload["content"] = "👉 " + " ".join( chain( [f"@{value}" for value in parse], [f"<@&{value}>" for value in roles], [f"<@{value}>" for value in users], ) ) return payload @classmethod def extract_markdown_sections( cls, markdown: str, ) -> tuple[str, list[dict[str, str]]]: """Extract headers and their corresponding sections into embed fields.""" # Room left for a section's text once the code block markers # ("```md\n" and "\n```") are wrapped around it description, sections = commonmark_sections( markdown, cls.discord_field_name_maxlen, cls.discord_field_value_maxlen - 10, ) # Show each section as a code block. An empty one stays empty. fields = [ { "name": name, "value": "```md\n{}\n```".format(value.rstrip()) if value else "```\n```", } for name, value in sections ] return description, fields caronc-apprise-dfec414/apprise/plugins/dot.py000066400000000000000000000503061526720371000214070ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # API: https://dot.mindreset.tech/docs/service/open/text_api # https://dot.mindreset.tech/docs/service/open/image_api # # New API endpoints (v2): # - Text API: /api/authV2/open/device/:deviceId/text # - Image API: /api/authV2/open/device/:deviceId/image # # Note: Old endpoints (/api/open/text and /api/open/image) are deprecated # and will be removed in the future. Requests are automatically forwarded # to the new endpoints. # # Text API Fields: # - refreshNow (bool, optional, default true): controls display timing. # - title (string, optional): title text shown on screen. # - message (string, optional): body text shown on screen. # - signature (string, optional): footer/signature text. # - icon (string, optional): base64 PNG icon (40px x 40px). # - link (string, optional): tap-to-interact target URL. # - taskKey (string, optional): specify which text API content to update. # # Image API Fields: # - refreshNow (bool, optional, default true): controls display timing. # - image (string, required): base64 PNG image (296px x 152px). # - link (string, optional): tap-to-interact target URL. # - border (number, optional, default 0): 0=white, 1=black frame. # - ditherType (string, optional, default DIFFUSION): dithering mode. # - ditherKernel (string, optional, default FLOYD_STEINBERG): # dithering kernel. # - taskKey (string, optional): specify which image API content to update. # # Mode selection: # - text (default): smart dual-send mode. Body/title go to the Text API; # image= param or an attachment goes to the Image API. When both are # present, text is dispatched first, then image. If only one is # available, only that API is called. # - image: only the Image API is called; body and title are ignored. # # Providing image= in the URL without an explicit mode= leaves the mode # as the default (text), which will send the image alongside any text. from contextlib import suppress import json import logging import requests from ..common import NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_bool from ..utils.sanitize import sanitize_payload from .base import NotifyBase # Supported Dither Types DOT_DITHER_TYPES = ( "DIFFUSION", "ORDERED", "NONE", ) # Supported Dither Kernels DOT_DITHER_KERNELS = ( "THRESHOLD", "ATKINSON", "BURKES", "FLOYD_STEINBERG", "SIERRA2", "STUCKI", "JARVIS_JUDICE_NINKE", "DIFFUSION_ROW", "DIFFUSION_COLUMN", "DIFFUSION_2D", ) # Supported API modes; first entry is the default DOT_MODES = ("text", "image") class NotifyDot(NotifyBase): """A wrapper for Dot. Notifications.""" # The default descriptive name associated with the Notification service_name = "Dot." # The services URL service_url = "https://dot.mindreset.tech" # All notification requests are secure secure_protocol = "dot" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/dot/" # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # Support Attachments attachment_support = True # Define object templates templates = ("{schema}://{apikey}@{device_id}/",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "required": True, "private": True, }, "device_id": { "name": _("Device Serial Number"), "type": "string", "required": True, "map_to": "device_id", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "mode": { "name": _("API Mode"), "type": "choice:string", "values": DOT_MODES, "default": DOT_MODES[0], }, "refresh": { "name": _("Refresh Now"), "type": "bool", "default": True, "map_to": "refresh_now", }, "signature": { "name": _("Text Signature"), "type": "string", }, "icon": { "name": _("Icon Base64 (Text API)"), "type": "string", }, "image": { "name": _("Image Base64 (Image API)"), "type": "string", "map_to": "image_data", }, "link": { "name": _("Link"), "type": "string", }, "border": { "name": _("Border"), "type": "int", "min": 0, "max": 1, "default": 0, }, "dither_type": { "name": _("Dither Type"), "type": "choice:string", "values": DOT_DITHER_TYPES, "default": "DIFFUSION", }, "dither_kernel": { "name": _("Dither Kernel"), "type": "choice:string", "values": DOT_DITHER_KERNELS, "default": "FLOYD_STEINBERG", }, "task_key": { "name": _("Task Key"), "type": "string", }, }, ) def __init__( self, apikey=None, device_id=None, mode=DOT_MODES[0], refresh_now=None, signature=None, icon=None, link=None, border=None, dither_type=None, dither_kernel=None, image_data=None, task_key=None, **kwargs, ): """Initialize Notify Dot Object.""" super().__init__(**kwargs) # API Key (from user) self.apikey = apikey # Device ID tracks the Dot hardware serial. self.device_id = device_id # Refresh Now flag: True shows content immediately (default). self.refresh_now = ( parse_bool( refresh_now, self.template_args["refresh"]["default"], ) if refresh_now is not None else self.template_args["refresh"]["default"] ) # API mode self.mode = ( mode.lower() if isinstance(mode, str) and mode.lower() in DOT_MODES else DOT_MODES[0] ) if not isinstance(mode, str) or mode.lower() not in DOT_MODES: self.logger.warning( "Unsupported Dot mode (%s) specified; defaulting to '%s'.", mode, self.mode, ) # Signature text used by the Text API footer. self.signature = signature if isinstance(signature, str) else None # Icon for the Text API (base64 PNG 40x40, lower-left corner). self.icon = icon if isinstance(icon, str) else None # Image payload for the Image API (base64 PNG 296x152). self.image_data = ( image_data if image_data and isinstance(image_data, str) else None ) # Link for tap-to-interact navigation. self.link = link if isinstance(link, str) else None # Border for the Image API self.border = border # Dither type for Image API self.dither_type = dither_type # Dither kernel for the Image API self.dither_kernel = dither_kernel # Task Key for specifying which content to update self.task_key = ( task_key if task_key and isinstance(task_key, str) else None ) # Text API endpoint (v2) self.text_api_url = ( "https://dot.mindreset.tech/api/authV2/open/device/" f"{self.device_id}/text" ) # Image API endpoint (v2) self.image_api_url = ( "https://dot.mindreset.tech/api/authV2/open/device/" f"{self.device_id}/image" ) return def _post(self, api_url, payload, headers): """POST payload to api_url; return True on success, False otherwise.""" if self.logger.isEnabledFor(logging.DEBUG): self.logger.debug( "Dot POST URL: %s (cert_verify=%r)", api_url, self.verify_certificate, ) self.logger.debug("Dot Payload %s", sanitize_payload(payload)) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( api_url, data=json.dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code == requests.codes.ok: self.logger.info( "Sent Dot notification to %s.", self.device_id ) return True status_str = NotifyDot.http_response_code_lookup(r.status_code) self.logger.warning( "Failed to send Dot notification to %s: %s%serror=%d.", self.device_id, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) return False except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Dot notification to %s.", self.device_id, ) self.logger.debug("Socket Exception: %s", str(e)) return False def _send_text(self, body, title, icon_data, headers): """Send body/title via the Text API.""" payload = {"refreshNow": self.refresh_now} if title: payload["title"] = title if body: payload["message"] = body if self.signature: payload["signature"] = self.signature if icon_data: payload["icon"] = icon_data if self.link: payload["link"] = self.link if self.task_key is not None: payload["taskKey"] = self.task_key return self._post(self.text_api_url, payload, headers) def _send_image(self, image_data, headers): """Send image_data via the Image API.""" payload = { "image": image_data, "refreshNow": self.refresh_now, } if self.link: payload["link"] = self.link if self.border is not None: payload["border"] = self.border if self.dither_type is not None: payload["ditherType"] = self.dither_type if self.dither_kernel is not None: payload["ditherKernel"] = self.dither_kernel if self.task_key is not None: payload["taskKey"] = self.task_key return self._post(self.image_api_url, payload, headers) def _resolve_attachment(self, attach, warn_label): """Return base64 string from the first usable attachment, or None.""" if not (attach and self.attachment_support): return None if len(attach) > 1: self.logger.warning( "Multiple attachments provided; only the first" " one will be used as %s.", warn_label, ) try: attachment = attach[0] if attachment: return attachment.base64() except Exception as e: self.logger.warning("Failed to process attachment: %s", str(e)) return None def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Dot Notification.""" if not self.apikey: self.logger.warning("No API key was specified") return False if not self.device_id: self.logger.warning("No device ID was specified") return False headers = { "Authorization": f"Bearer {self.apikey}", "Content-Type": "application/json", "User-Agent": self.app_id, } if self.mode == "image": # Image-only mode: body and title are ignored. if title or body: self.logger.warning( "Title and body are not supported in image mode" " and will be ignored." ) image_data = ( self.image_data if isinstance(self.image_data, str) else self._resolve_attachment(attach, "image") ) if not image_data: self.logger.warning( "Image mode selected but no image data was provided." ) return False return self._send_image(image_data, headers) # Text mode (default): smart dual-send. # Body/title go to the Text API; image data or attachment goes # to the Image API. Text is always dispatched before image. image_data = ( self.image_data if isinstance(self.image_data, str) else self._resolve_attachment(attach, "image") ) has_text = bool(body or title) has_image = bool(image_data) if not has_text and not has_image: self.logger.warning( "Nothing to send to Dot. device %s.", self.device_id ) return False has_error = False if has_text and not self._send_text(body, title, self.icon, headers): has_error = True if has_image and not self._send_image(image_data, headers): has_error = True return not has_error @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another similar one. """ return ( self.secure_protocol, self.apikey, self.device_id, self.mode, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" params = {} params["refresh"] = "yes" if self.refresh_now else "no" # Include mode only when non-default if self.mode != DOT_MODES[0]: params["mode"] = self.mode if self.signature: params["signature"] = self.signature if self.icon: params["icon"] = self.icon if self.image_data: params["image"] = self.image_data if self.border is not None: params["border"] = str(self.border) if self.dither_type and self.dither_type != "DIFFUSION": params["dither_type"] = self.dither_type if self.dither_kernel and self.dither_kernel != "FLOYD_STEINBERG": params["dither_kernel"] = self.dither_kernel if self.link: params["link"] = self.link if self.task_key: params["task_key"] = self.task_key params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{apikey}@{device_id}/?{params}".format( schema=self.secure_protocol, apikey=self.pprint( self.apikey, privacy, mode=PrivacyMode.Secret, safe="" ), device_id=NotifyDot.quote(self.device_id, safe=""), params=NotifyDot.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return 1 if self.device_id else 0 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re-instantiate this object.""" results = NotifyBase.parse_url(url) if not results: return results # Determine mode: explicit ?mode= wins; path provides backward compat. mode = DOT_MODES[0] if results["qsd"].get("mode"): candidate = results["qsd"]["mode"].lower().strip() if candidate in DOT_MODES: mode = candidate else: NotifyDot.logger.warning( "Unsupported Dot mode (%s) specified; defaulting to '%s'.", candidate, DOT_MODES[0], ) else: path_tokens = NotifyDot.split_path(results.get("fullpath")) if path_tokens: candidate = path_tokens[0].lower() if candidate in DOT_MODES: mode = candidate else: NotifyDot.logger.warning( "Unsupported Dot mode (%s) specified;" " defaulting to '%s'.", candidate, DOT_MODES[0], ) results["mode"] = mode # Extract API key from user if results.get("user"): results["apikey"] = NotifyDot.unquote(results["user"]) # Extract device ID from hostname if results.get("host"): results["device_id"] = NotifyDot.unquote(results["host"]) # Refresh Now refresh_value = results["qsd"].get("refresh") if refresh_value: results["refresh_now"] = parse_bool(refresh_value.strip()) # Signature if results["qsd"].get("signature"): results["signature"] = NotifyDot.unquote( results["qsd"]["signature"].strip() ) # Icon if results["qsd"].get("icon"): results["icon"] = NotifyDot.unquote(results["qsd"]["icon"].strip()) # Link if results["qsd"].get("link"): results["link"] = NotifyDot.unquote(results["qsd"]["link"].strip()) # Border if results["qsd"].get("border"): with suppress(TypeError, ValueError): results["border"] = int(results["qsd"]["border"].strip()) # Dither Type if results["qsd"].get("dither_type"): results["dither_type"] = NotifyDot.unquote( results["qsd"]["dither_type"].strip() ) # Dither Kernel if results["qsd"].get("dither_kernel"): results["dither_kernel"] = NotifyDot.unquote( results["qsd"]["dither_kernel"].strip() ) # Image (Image API) if results["qsd"].get("image"): results["image_data"] = NotifyDot.unquote( results["qsd"]["image"].strip() ) # Task Key if results["qsd"].get("task_key"): results["task_key"] = NotifyDot.unquote( results["qsd"]["task_key"].strip() ) return results caronc-apprise-dfec414/apprise/plugins/eight00com.py000066400000000000000000000420611526720371000225570ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this service you will need an 800.com account # # 1. Visit https://www.800.com and sign in or create an account. # 2. Go to User Settings (top-right avatar -> Settings). # 3. In the API section, click "Generate Token". # 4. Copy the token immediately -- it is only displayed once. # # Your Apprise URL should be assembled as: # eight00com://TOKEN@FromPhoneNo # eight00com://TOKEN@FromPhoneNo/ToPhoneNo # eight00com://TOKEN@FromPhoneNo/ToPhoneNo1/ToPhoneNo2 # # Where: # TOKEN : Your 800.com Personal Access Token # FromPhoneNo : Your text-enabled 800.com number (e.g. 8005551234) # ToPhoneNo : Recipient phone number(s) # # The API also supports MMS (picture messaging). Apprise will # automatically send any provided attachments as MMS when possible. # # API Reference: # https://api.800.com/docs from json import dumps import requests from ..common import NotifyType from ..exception import AppriseImproperlyConfigured from ..locale import gettext_lazy as _ from ..utils.parse import is_phone_no, parse_phone_no, validate_regex from .base import NotifyBase # Extend HTTP Error Messages EIGHT00COM_HTTP_ERROR_MAP = { 401: "Unauthorized - Invalid or missing Bearer Token.", 422: "Validation Error - Check sender/recipient number format.", 429: "Too many requests - Rate limit exceeded.", } class NotifyEight00com(NotifyBase): """A wrapper for 800.com SMS/MMS Notifications.""" # The default descriptive name associated with the Notification service_name = "800.com" # The services URL service_url = "https://www.800.com" # All notification requests are sent over HTTPS secure_protocol = "eight00com" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/eight00com/" # 800.com REST API endpoint for outbound messages notify_url = "https://api.800.com/message" # The maximum SMS body length (800.com supports long-form texts # up to 600 characters) body_maxlen = 600 # A title cannot be used for SMS messages; setting this to zero # causes any title to be prepended to the body automatically. title_maxlen = 0 # 800.com supports MMS (picture messaging / attachments) attachment_support = True # Define object URL templates templates = ( "{schema}://{token}@{from_phone}", "{schema}://{token}@{from_phone}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("API Token"), "type": "string", "private": True, "required": True, }, "from_phone": { "name": _("From Phone No"), "type": "string", "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "map_to": "source", "required": True, }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "token": { "alias_of": "token", }, "from": { "name": _("From Phone No"), "type": "string", "map_to": "source", }, "to": { "alias_of": "targets", }, }, ) def __init__(self, token=None, source=None, targets=None, **kwargs): """Initialize 800.com Object.""" super().__init__(**kwargs) # Validate our Personal Access Token self.token = validate_regex(token) if not self.token: msg = "An 800.com Personal Access Token must be specified." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Validate the from (source) phone number result = is_phone_no(source) if not result: msg = "The 800.com from phone # ({}) is invalid.".format(source) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Store our source number as digits only self.source = result["full"] # Parse our targets list self.targets = [] # Track if we encountered any error parsing targets has_error = False for target in parse_phone_no(targets): # Validate each target phone number result = is_phone_no(target) if result: self.targets.append(result["full"]) continue has_error = True self.logger.warning( "Dropped invalid 800.com phone # (%s).", target, ) if not targets and not has_error: # Default: send to ourselves when no target is provided self.targets.append(self.source) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform 800.com SMS/MMS Notification.""" # Ensure we have at least one target if not self.targets: self.logger.warning("No 800.com targets to notify.") return False # Prepare our authorization header (shared by SMS and MMS) headers = { "User-Agent": self.app_id, "Authorization": "Bearer {}".format(self.token), } # Error tracking variable has_error = False # Work through a copy so the original list is preserved targets = list(self.targets) while targets: # Pop the next recipient target = targets.pop(0) # Skip a target that already accepted this message so a # retry does not deliver it twice. if self.is_delivered(target): continue # Build the E.164-style sender and recipient strings sender = "+{}".format(self.source) recipient = "+{}".format(target) self.logger.debug( "800.com POST URL: %s (cert_verify=%s)", self.notify_url, self.verify_certificate, ) if attach and self.attachment_support: # Send as MMS with attachment(s) if not self._send_mms( sender, recipient, body, attach, headers ): has_error = True continue elif not self._send_sms(sender, recipient, body, headers): # Send as a plain SMS has_error = True continue # Delivered; a retry can safely skip this target. self.mark_delivered(target) return not has_error def _send_sms(self, sender, recipient, body, headers): """Send a plain text SMS via the 800.com REST API.""" # Prepare our JSON payload payload = { "sender": sender, "recipient": recipient, "message": body, } # Copy headers and add JSON content type sms_headers = dict(headers) sms_headers["Content-Type"] = "application/json" self.logger.debug("800.com SMS Payload: %s", payload) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=dumps(payload), headers=sms_headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyEight00com.http_response_code_lookup( r.status_code, EIGHT00COM_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send 800.com SMS to %s: %s%serror=%s.", recipient, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000], ) return False else: self.logger.info("Sent 800.com SMS to %s.", recipient) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending 800.com SMS to %s.", recipient, ) self.logger.debug("Socket Exception: %s", str(e)) return False return True def _send_mms(self, sender, recipient, body, attach, headers): """Send an MMS with one or more attachments via the 800.com API. Uses Pattern B (multi-file multipart/form-data). """ # Accumulate open file handles for cleanup in the finally block handles = [] files = [] attach_ok = True try: for attachment in attach: # Guard 1: verify the attachment is accessible if not attachment: self.logger.warning( "Could not access 800.com attachment %s.", attachment.url(privacy=True), ) attach_ok = False break # Guard 2: catch I/O errors when opening the file try: handle = attachment.open() except OSError as exc: self.logger.warning( "An I/O error occurred reading 800.com attachment %s.", attachment.name, ) self.logger.debug("I/O Exception: %s", str(exc)) attach_ok = False break # Register handle immediately so finally closes it handles.append(handle) files.append( ( "media[]", ( attachment.name, handle, attachment.mimetype, ), ) ) if not attach_ok: # Bail early; finally block will close any open handles return False # Prepare the multipart form fields. # Do NOT set Content-Type -- requests sets the multipart # boundary header automatically when files is not None. data = { "sender": sender, "recipient": recipient, "message": body, } self.logger.debug("800.com MMS data: %s", data) # Always call throttle before any remote server i/o is made self.throttle() r = requests.post( self.notify_url, data=data, headers=headers, files=files, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyEight00com.http_response_code_lookup( r.status_code, EIGHT00COM_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send 800.com MMS to %s: %s%serror=%s.", recipient, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000], ) return False else: self.logger.info("Sent 800.com MMS to %s.", recipient) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending 800.com MMS to %s.", recipient, ) self.logger.debug("Socket Exception: %s", str(e)) return False finally: # Guard 3: close every file handle regardless of outcome for handle in handles: handle.close() return True @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.source, self.token) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Prepare our standard parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) # Omit the target list when the only target is ourselves targets = ( [] if len(self.targets) == 1 and self.targets[0] == self.source else self.targets ) return "{schema}://{token}@{source}/{targets}?{params}".format( schema=self.secure_protocol, token=self.pprint(self.token, privacy, safe=""), source=self.source, targets="/".join( NotifyEight00com.quote(x, safe="+") for x in targets ), params=NotifyEight00com.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return len(self.targets) if self.targets else 1 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re-instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: return results # Support ?from= to specify the source number separately, # in which case the hostname becomes an additional target. if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyEight00com.unquote( results["qsd"]["from"] ) results["targets"] = [ *NotifyEight00com.parse_phone_no(results["host"]), *NotifyEight00com.split_path(results["fullpath"]), ] else: # Hostname is the source (from) phone number results["source"] = NotifyEight00com.unquote(results["host"]) # Path entries are target phone numbers results["targets"] = NotifyEight00com.split_path( results["fullpath"] ) # Support ?to= as an alias for targets if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyEight00com.parse_phone_no( results["qsd"]["to"] ) # Support ?token= to override the token in the URL user field if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyEight00com.unquote( results["qsd"]["token"] ) else: # Token lives in the user position of the URL results["token"] = NotifyEight00com.unquote(results["user"] or "") return results caronc-apprise-dfec414/apprise/plugins/email/000077500000000000000000000000001526720371000213325ustar00rootroot00000000000000caronc-apprise-dfec414/apprise/plugins/email/__init__.py000066400000000000000000000035751526720371000234550ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from email import charset from .base import NotifyEmail from .common import ( SECURE_MODES, AppriseEmailException, EmailMessage, SecureMailMode, WebBaseLogin, ) from .templates import EMAIL_TEMPLATES # Globally Default encoding mode set to Quoted Printable. charset.add_charset("utf-8", charset.QP, charset.QP, "utf-8") __all__ = [ "EMAIL_TEMPLATES", "SECURE_MODES", "AppriseEmailException", "EmailMessage", "NotifyEmail", "SecureMailMode", "WebBaseLogin", ] caronc-apprise-dfec414/apprise/plugins/email/base.py000066400000000000000000001536731526720371000226350ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime from email.header import Header from email.mime.application import MIMEApplication from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import format_datetime, formataddr, make_msgid import re import smtplib from typing import Optional from ...common import NotifyFormat, NotifyType, PersistentStoreMode from ...conversion import convert_between from ...exception import AppriseImproperlyConfigured from ...locale import gettext_lazy as _ from ...logger import logger from ...url import PrivacyMode from ...utils import pgp as _pgp, wkd as _wkd from ...utils.parse import ( is_email, is_hostname, is_ipaddr, parse_bool, parse_emails, ) from ..base import NotifyBase from . import templates from .common import ( SECURE_MODES, AppriseEmailException, EmailMessage, SecureMailMode, WebBaseLogin, ) from .smtp import AppriseSMTPController class PGPMode: """PGP security mode for outbound email.""" # No PGP applied (default) NONE = "no" # Sign using the sender's private key; encrypt opportunistically when # a recipient public key is available (via WKD or pgppub=) SIGN = "sign" # Encrypt using the recipient's public key (fails if no key found) ENCRYPT = "encrypt" # Ordered tuple of all valid PGP modes; prefix matching uses this order PGP_MODES = ( PGPMode.NONE, PGPMode.SIGN, PGPMode.ENCRYPT, ) # The mode used when ?pgp= is absent or empty PGP_MODE_DEFAULT = PGPMode.NONE class NotifyEmail(NotifyBase): """ A wrapper to Email Notifications """ # The default descriptive name associated with the Notification service_name = "E-Mail" # The default simple (insecure) protocol protocol = "mailto" # The default secure protocol secure_protocol = "mailtos" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/email/" # Support attachments attachment_support = True # Our default is to no not use persistent storage beyond in-memory # reference; this allows us to auto-generate our config if needed storage_mode = PersistentStoreMode.AUTO # Email can send either HTML or plain text. HTML remains the default. notify_format = (NotifyFormat.HTML, NotifyFormat.TEXT) # Default SMTP Timeout (in seconds) socket_connect_timeout = 15 # Define object templates templates = ( "{schema}://{host}", "{schema}://{host}:{port}", "{schema}://{host}:{port}/{targets}", "{schema}://{host}/{targets}", "{schema}://{user}@{host}", "{schema}://{user}@{host}/{targets}", "{schema}://{user}@{host}:{port}", "{schema}://{user}@{host}/{targets}", "{schema}://{user}@{host}:{port}/{targets}", "{schema}://{user}:{password}@{host}", "{schema}://{user}:{password}@{host}/{targets}", "{schema}://{user}:{password}@{host}:{port}", "{schema}://{user}:{password}@{host}:{port}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, "host": { "name": _("Domain"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "target_email": { "name": _("Target Email"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) template_args = dict( NotifyBase.template_args, **{ "from": { "name": _("From Email"), "type": "string", "map_to": "from_addr", }, "name": { "name": _("From Name"), "type": "string", "map_to": "from_addr", }, "smtp": { "name": _("SMTP Server"), "type": "string", "map_to": "smtp_host", }, "mode": { "name": _("Secure Mode"), "type": "choice:string", "values": SECURE_MODES, "default": SecureMailMode.STARTTLS, "map_to": "secure_mode", }, "reply": { "name": _("Reply To"), "type": "list:string", "map_to": "reply_to", }, "pgp": { "name": _("PGP Security Mode"), "type": "choice:string", "values": PGP_MODES, "default": PGP_MODE_DEFAULT, "map_to": "pgp_mode", }, "pgppub": { "name": _("PGP Public Key Path"), "type": "string", "private": True, # By default persistent storage is referenced "default": "", "map_to": "pgp_key", }, "pgpprv": { "name": _("PGP Private Key Path"), "type": "string", "private": True, # By default persistent storage is referenced "default": "", "map_to": "pgp_privkey", }, "wkd": { "name": _("Web Key Directory"), "type": "bool", "default": False, "map_to": "use_wkd", }, "inline": { "name": _("Inline Attachments"), "type": "bool", "default": False, }, "to": { "name": _("To Email"), "type": "string", "map_to": "targets", }, "cc": { "name": _("Carbon Copy"), "type": "list:string", }, "bcc": { "name": _("Blind Carbon Copy"), "type": "list:string", }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("Email Header"), "prefix": "+", }, } def __init__( self, smtp_host=None, from_addr=None, secure_mode=None, targets=None, cc=None, bcc=None, reply_to=None, headers=None, pgp_mode=None, pgp_key=None, pgp_privkey=None, use_wkd=False, inline=None, **kwargs, ): """ Initialize Email Object The smtp_host and secure_mode can be automatically detected depending on how the URL was built """ super().__init__(**kwargs) # Acquire Email 'To' self.targets = [] # Acquire Carbon Copies self.cc = set() # Acquire Blind Carbon Copies self.bcc = set() # Acquire Reply To self.reply_to = set() # For tracking our email -> name lookups self.names = {} self.headers = {} if headers: # Store our extra headers self.headers.update(headers) # Now we want to construct the To and From email # addresses from the URL provided self.from_addr = [False, ""] # Now detect the SMTP Server self.smtp_host = smtp_host if isinstance(smtp_host, str) else "" # Now detect secure mode if secure_mode: self.secure_mode = ( None if not isinstance(secure_mode, str) else secure_mode.lower() ) else: self.secure_mode = ( SecureMailMode.INSECURE if not self.secure else self.template_args["mode"]["default"] ) if self.secure_mode not in SECURE_MODES: msg = "The secure mode specified ({}) is invalid.".format( secure_mode ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Validate recipients (cc:) and drop bad ones: for recipient in parse_emails(cc): email = is_email(recipient) if email: self.cc.add(email["full_email"]) # Index our name (if one exists) self.names[email["full_email"]] = ( email["name"] if email["name"] else False ) continue self.logger.warning( "Dropped invalid Carbon Copy email ({}) specified.".format( recipient ), ) # Validate recipients (bcc:) and drop bad ones: for recipient in parse_emails(bcc): email = is_email(recipient) if email: self.bcc.add(email["full_email"]) # Index our name (if one exists) self.names[email["full_email"]] = ( email["name"] if email["name"] else False ) continue self.logger.warning( "Dropped invalid Blind Carbon Copy email " "({}) specified.".format(recipient), ) # Validate recipients (reply-to:) and drop bad ones: for recipient in parse_emails(reply_to): email = is_email(recipient) if email: self.reply_to.add(email["full_email"]) # Index our name (if one exists) self.names[email["full_email"]] = ( email["name"] if email["name"] else False ) continue self.logger.warning( "Dropped invalid Reply To email ({}) specified.".format( recipient ), ) # Apply any defaults based on certain known configurations self.apply_email_defaults(secure_mode=secure_mode, **kwargs) if self.user: if self.host: # Prepare the bases of our email self.from_addr = [ self.app_id, "{}@{}".format( re.split(r"[\s@]+", self.user)[0], self.host, ), ] else: result = is_email(self.user) if result: # Prepare the bases of our email and include domain self.host = result["domain"] self.from_addr = [self.app_id, self.user] if from_addr: result = is_email(from_addr) if result: self.from_addr = ( result["name"] if result["name"] else False, result["full_email"], ) else: # Only update the string but use the already detected info self.from_addr[0] = from_addr result = is_email(self.from_addr[1]) if not result: # Parse Source domain based on from_addr msg = "Invalid ~From~ email specified: {}".format( "{} <{}>".format(self.from_addr[0], self.from_addr[1]) if self.from_addr[0] else "{}".format(self.from_addr[1]) ) self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Store our lookup self.names[self.from_addr[1]] = self.from_addr[0] if targets: # Validate recipients (to:) and drop bad ones: for recipient in parse_emails(targets): result = is_email(recipient) if result: self.targets.append( ( result["name"] if result["name"] else False, result["full_email"], ) ) continue self.logger.warning( "Dropped invalid To email ({}) specified.".format( recipient ), ) else: # If our target email list is empty we want to add ourselves to it self.targets.append((False, self.from_addr[1])) if not self.secure and self.secure_mode != SecureMailMode.INSECURE: # Enable Secure mode if not otherwise set self.secure = True if not self.port: # Assign our port based on our secure_mode if not otherwise # detected self.port = SECURE_MODES[self.secure_mode]["default_port"] # if there is still no smtp_host then we fall back to the hostname if not self.smtp_host: self.smtp_host = self.host # Track whether pgp_mode was explicitly provided so wkd= implication # does not override a deliberate pgp=none choice pgp_mode_explicit = pgp_mode is not None # Accept unambiguous prefixes such as "e" and "en". Treat "none" # as "no", and reject unknown values instead of disabling PGP. if not pgp_mode: self.pgp_mode = PGP_MODE_DEFAULT elif str(pgp_mode).lower() == "none": self.pgp_mode = PGPMode.NONE else: self.pgp_mode = next( (m for m in PGP_MODES if m.startswith(str(pgp_mode).lower())), None, ) if self.pgp_mode is None: msg = f"The Email PGP mode specified ({pgp_mode}) is invalid." self.logger.warning(msg) raise AppriseImproperlyConfigured(msg) # Parse string values such as "no" correctly. self.use_wkd = parse_bool(use_wkd) # wkd=yes implies pgp=encrypt when pgp= was not explicitly set if self.use_wkd and not pgp_mode_explicit: self.pgp_mode = PGPMode.ENCRYPT # Build a WKD controller when WKD key discovery is requested wkd_ctrl = ( _wkd.AppriseWKDController( asset=self.asset, verify_certificate=self.verify_certificate, request_timeout=self.request_timeout, allow_redirects=self.redirects, ) if self.use_wkd else None ) # Prepare our Pretty Good Privacy Object self.pgp = _pgp.ApprisePGPController( path=self.store.path, pub_keyfile=pgp_key, prv_keyfile=pgp_privkey, email=self.from_addr[1], asset=self.asset, wkd=wkd_ctrl, ) # We store so we can generate a URL later on self.pgp_key = pgp_key # Store the private key path so it can be round-tripped via url() self.pgp_privkey = pgp_privkey if self.pgp_mode != PGP_MODE_DEFAULT and not _pgp.PGP_SUPPORT: self.logger.warning( "PGP Support is not available on this installation; " "ask admin to install PGPy" ) # Store inline attachment mode (RFC 2387); fall back to the # template default when the value is None or unrecognisable self.inline = parse_bool( inline, self.template_args["inline"]["default"] ) return def _protocol_headers(self, body): """Return protocol headers supplied by an Email subclass.""" return {} def apply_email_defaults(self, secure_mode=None, port=None, **kwargs): """Apply provider defaults inferred from the sender address.""" if self.smtp_host: # Preserve an explicitly selected SMTP server. return # detect our email address using our user/host combo from_addr = ( "{}@{}".format( re.split(r"[\s@]+", self.user)[0], self.host, ) if self.user else self.host ) for i in range(len(templates.EMAIL_TEMPLATES)): # pragma: no branch self.logger.trace( "Scanning %s against %s", from_addr, templates.EMAIL_TEMPLATES[i][0], ) match = templates.EMAIL_TEMPLATES[i][1].match(from_addr) if match: self.logger.debug( f"Applying {templates.EMAIL_TEMPLATES[i][0]} Defaults" ) # A template may require a specific security mode. self.secure = templates.EMAIL_TEMPLATES[i][2].get( "secure", self.secure ) # The SMTP Host check is already done above; if it was # specified we wouldn't even reach this part of the code. self.smtp_host = templates.EMAIL_TEMPLATES[i][2].get( "smtp_host", self.smtp_host ) # The following can be over-ridden if defined manually in the # Apprise URL. Otherwise they take on the template value if not port: self.port = templates.EMAIL_TEMPLATES[i][2].get( "port", self.port ) if not secure_mode: self.secure_mode = templates.EMAIL_TEMPLATES[i][2].get( "secure_mode", self.secure_mode ) # Adjust email login based on the defined usertype. If no entry # was specified, then we default to having them all set (which # basically implies that there are no restrictions and use use # whatever was specified) login_type = templates.EMAIL_TEMPLATES[i][2].get( "login_type", [] ) if login_type: # only apply additional logic to our user if a login_type # was specified. if is_email(self.user): if WebBaseLogin.EMAIL not in login_type: # Email specified but login type # not supported; switch it to user id self.user = match.group("id") else: # Enforce our host information self.host = self.user.split("@")[1] elif WebBaseLogin.USERID not in login_type: # user specified but login type # not supported; switch it to email self.user = "{}@{}".format(self.user, self.host) if ( "from_user" in templates.EMAIL_TEMPLATES[i][2] and not self.from_addr[1] ): # Update our from address if defined self.from_addr[1] = "{}@{}".format( templates.EMAIL_TEMPLATES[i][2]["from_user"], self.host ) break def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, body_format=None, **kwargs, ): """Perform Email Notification.""" if not self.targets: # There is no one to email; we're done logger.warning("There are no Email recipients to notify") return False # error tracking (used for function return) has_error = False # Always call throttle before any remote server i/o is made self.throttle() try: with AppriseSMTPController( host=self.smtp_host, port=self.port, secure_mode=self.secure_mode, user=self.user, password=self.password, verify_certificate=self.verify_certificate, socket_connect_timeout=self.socket_connect_timeout, ) as smtp: # Prepare our headers headers = { "X-Application": self.app_id, } headers.update(self.headers) # Add any protocol headers supplied by a subclass. headers.update(self._protocol_headers(body)) # Build each message in its resolved text or HTML format. for message in NotifyEmail.prepare_emails( subject=title, body=body, notify_format=self.resolve_format(body_format), from_addr=self.from_addr, to=self.targets, cc=self.cc, bcc=self.bcc, reply_to=self.reply_to, smtp_host=self.smtp_host, attach=attach, headers=headers, names=self.names, pgp=self.pgp, pgp_mode=self.pgp_mode, inline=self.inline, tzinfo=self.tzinfo, ): # Skip a recipient that already accepted this message # so a retry does not deliver it twice. if self.is_delivered(message.recipient): continue if smtp.sendmail( self.from_addr[1], message.to_addrs, message.body ): self.logger.info("Sent Email to %s", message.recipient) # Delivered; a retry can safely skip this one. self.mark_delivered(message.recipient) else: self.logger.warning( 'Sending email to "%s" failed.', message.recipient, ) # Mark as failure has_error = True except ( OSError, smtplib.SMTPException, RuntimeError, AppriseImproperlyConfigured, ) as e: self.logger.warning( 'Connection error while submitting email to "%s"', self.smtp_host, ) self.logger.debug(f"Socket Exception: {e}") # Mark as failure has_error = True except AppriseEmailException as e: self.logger.debug(f"Socket Exception: {e}") # Mark as failure has_error = True # Reduce our dictionary (eliminate expired keys if any) self.pgp.prune() return not has_error def _url_address(self, email: str) -> str: """Return ``email`` with its display name (if any) for url().""" name = self.names.get(email) if not name: return email # Quote a name holding a list delimiter (or @) so it is not split # apart. Apprise parses the name back as is, so no RFC 2047 # encoding or escaping is wanted here. return ( f'"{name}" <{email}>' if any(c in name for c in ",;@") else f"{name} <{email}>" ) def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ # Define an URL parameters params = {} # Always emit pgp= when wkd=yes is set, even when the mode is the # default 'none'. Without this, a URL like ?pgp=none&wkd=yes would # round-trip as ?wkd=yes and the wkd=yes→pgp=encrypt implication # would silently re-enable encryption on re-parse. if self.pgp_mode != PGP_MODE_DEFAULT or self.use_wkd: params["pgp"] = self.pgp_mode # Store our public key back into the URL when one was supplied; # mask the value when building a privacy-safe URL because the key # path can reveal local filesystem layout or remote key server URLs if self.pgp_key is not None: params["pgppub"] = ( "****" if privacy else NotifyEmail.quote(self.pgp_key, safe=":\\/") ) # Store the private key path in the URL in the same way; masked # in privacy mode because private key paths are sensitive if self.pgp_privkey is not None: params["pgpprv"] = ( "****" if privacy else NotifyEmail.quote(self.pgp_privkey, safe=":\\/") ) # Include wkd= when Web Key Directory lookup is enabled if self.use_wkd: params["wkd"] = "yes" # Emit inline flag only when enabled if self.inline: params["inline"] = "yes" # Append our headers into our parameters params.update({"+{}".format(k): v for k, v in self.headers.items()}) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) from_addr = None if not self.user or self.from_addr[1] != "{}@{}".format( re.split(r"[\s@]+", self.user)[0], self.host ): # A custom email was provided. Write it out unless the next # parse would rebuild the same address from user@host. from_addr = self.from_addr[1] if self.smtp_host != self.host: # Apply our SMTP Host only if it differs from the provided hostname params["smtp"] = self.smtp_host if self.secure: # Mode is only required if we're dealing with a secure connection params["mode"] = self.secure_mode if self.from_addr[0] and self.from_addr[0] != self.app_id: # A custom name was provided params["from"] = ( self.from_addr[0] if not from_addr else formataddr( (self.from_addr[0], from_addr), charset="utf-8" ) ) elif from_addr: params["from"] = formataddr((False, from_addr), charset="utf-8") if self.cc: # Handle our Carbon Copy Addresses params["cc"] = ",".join([self._url_address(e) for e in self.cc]) if self.bcc: # Handle our Blind Carbon Copy Addresses params["bcc"] = ",".join([self._url_address(e) for e in self.bcc]) if self.reply_to: # Handle our Reply-To Addresses params["reply"] = ",".join( [self._url_address(e) for e in self.reply_to] ) # pull email suffix from username (if present) user = None if not self.user else self.user.split("@")[0] # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyEmail.quote(user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif user: # user url auth = "{user}@".format( user=NotifyEmail.quote(user, safe=""), ) # Default Port setup default_port = SECURE_MODES[self.secure_mode]["default_port"] # a simple boolean check as to whether we display our target emails # or not has_targets = not ( len(self.targets) == 1 and self.targets[0][1] == self.from_addr[1] ) return "{schema}://{auth}{hostname}{port}/{targets}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else ":{}".format(self.port) ), targets=( "" if not has_targets else "/".join( [ NotifyEmail.quote( "{}{}".format( "" if not e[0] else "{}:".format(e[0]), e[1] ), safe="", ) for e in self.targets ] ) ), params=NotifyEmail.urlencode(params, safe="*"), ) @property def url_identifier(self): """ Returns all of the identifiers that make this URL unique from another similar one. Targets or end points should never be identified here. """ return ( self.secure_protocol if self.secure else self.protocol, self.user, self.password, self.host, self.smtp_host, ( self.port if self.port else SECURE_MODES[self.secure_mode]["default_port"] ), ) def __len__(self): """ Returns the number of targets associated with this notification """ return len(self.targets) if self.targets else 1 @staticmethod def parse_url(url): """ Parses the URL and returns enough arguments that can allow us to re-instantiate this object. """ results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Prepare our target lists results["targets"] = [] if is_ipaddr(results["host"]): # Silently move on and do not disrupt any configuration pass elif not is_hostname( results["host"], ipv4=False, ipv6=False, underscore=False ): if is_email(NotifyEmail.unquote(results["host"])): # Don't lose defined email addresses results["targets"].append(NotifyEmail.unquote(results["host"])) # Clear invalid hosts so a later step can infer one. results["host"] = "" # Accept PGP mode prefixes and treat "none" as "no". pgp_raw = results["qsd"].get("pgp", "") if pgp_raw: pgp_mode = next( (m for m in PGP_MODES if m.startswith(str(pgp_raw).lower())), None, ) if pgp_mode is None: # Let __init__ report invalid and removed boolean values. pgp_mode = pgp_raw results["pgp_mode"] = pgp_mode # Get Web Key Directory flag if "wkd" in results["qsd"] and results["qsd"]["wkd"]: results["use_wkd"] = parse_bool(results["qsd"]["wkd"]) # wkd=yes implies pgp=encrypt when pgp= was not present in the URL if results.get("use_wkd") and "pgp_mode" not in results: results["pgp_mode"] = PGPMode.ENCRYPT # Get PGP Public Key Override (canonical name: pgppub=) if "pgppub" in results["qsd"] and results["qsd"]["pgppub"]: results["pgp_key"] = NotifyEmail.unquote(results["qsd"]["pgppub"]) # Get PGP Private Key Override (canonical name: pgpprv=) if "pgpprv" in results["qsd"] and results["qsd"]["pgpprv"]: results["pgp_privkey"] = NotifyEmail.unquote( results["qsd"]["pgpprv"] ) # Get inline attachment flag if "inline" in results["qsd"]: results["inline"] = parse_bool( results["qsd"]["inline"], NotifyEmail.template_args["inline"]["default"], ) # The From address is a must; either through the use of templates # from= entry and/or merging the user and hostname together, this # must be calculated or parse_url will fail. from_addr = "" # The server we connect to to send our mail to smtp_host = "" # Get our potential email targets; if none our found we'll just # add one to ourselves results["targets"] += NotifyEmail.split_path(results["fullpath"]) # Attempt to detect 'to' email address if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"].append(results["qsd"]["to"]) # Attempt to detect 'from' email address if "from" in results["qsd"] and len(results["qsd"]["from"]): from_addr = NotifyEmail.unquote(results["qsd"]["from"]) if "name" in results["qsd"] and len(results["qsd"]["name"]): from_addr = formataddr( (NotifyEmail.unquote(results["qsd"]["name"]), from_addr), charset="utf-8", ) elif "name" in results["qsd"] and len(results["qsd"]["name"]): # Extract from name to associate with from address from_addr = NotifyEmail.unquote(results["qsd"]["name"]) # Store SMTP Host if specified if "smtp" in results["qsd"] and len(results["qsd"]["smtp"]): # Extract the smtp server smtp_host = NotifyEmail.unquote(results["qsd"]["smtp"]) if "mode" in results["qsd"] and len(results["qsd"]["mode"]): # Extract the security mode override. results["secure_mode"] = results["qsd"]["mode"].lower() # Handle Carbon Copy Addresses if "cc" in results["qsd"] and len(results["qsd"]["cc"]): results["cc"] = results["qsd"]["cc"] # Handle Blind Carbon Copy Addresses if "bcc" in results["qsd"] and len(results["qsd"]["bcc"]): results["bcc"] = results["qsd"]["bcc"] # Handle Reply To Addresses if "reply" in results["qsd"] and len(results["qsd"]["reply"]): results["reply_to"] = results["qsd"]["reply"] results["from_addr"] = from_addr results["smtp_host"] = smtp_host # Add our Meta Headers that the user can provide with their outbound # emails results["headers"] = { NotifyBase.unquote(x): NotifyBase.unquote(y) for x, y in results["qsd+"].items() } return results @staticmethod def _get_charset(input_string): """Use UTF-8 only when text contains non-ASCII characters. Encoding ASCII unnecessarily can trigger spam filters such as Rspamd's ``SUBJ_EXCESS_QP`` rule. """ if not input_string: return None return "utf-8" if not all(ord(c) < 128 for c in input_string) else None @staticmethod def prepare_emails( subject, body, from_addr, to, cc: Optional[set] = None, bcc: Optional[set] = None, reply_to: Optional[set] = None, # SMTP host used in the Message-ID smtp_host=None, # Either HTML or plain text notify_format=NotifyFormat.HTML, attach=None, headers: Optional[dict] = None, # Display names keyed by address names=None, # Optional PGP controller pgp=None, # PGP mode string (PGPMode.NONE / PGPMode.SIGN / PGPMode.ENCRYPT) pgp_mode=PGP_MODE_DEFAULT, # When True, image attachments are embedded inline (RFC 2387) # so HTML bodies can reference them via inline=False, # Use the system timezone when none is provided. tzinfo=None, ): """Yield prepared email messages. - ``from_addr`` is ``(name, address)``; ``to`` contains those pairs. - ``cc`` and ``bcc`` are address sets; ``reply_to`` is one address. - ``smtp_host`` forms the Message-ID, and ``notify_format`` selects plain text or HTML. - ``attach`` contains Apprise attachments; ``headers`` adds headers. - ``names`` maps addresses to display names for CC and BCC entries. - ``pgp`` supplies signing or encryption. ``PGPMode.SIGN`` may also encrypt when a recipient key is available. """ if not to: # There is no one to email; we're done msg = "There are no Email recipients to notify" logger.warning(msg) raise AppriseEmailException(msg) from None elif pgp and pgp_mode != PGP_MODE_DEFAULT and not _pgp.PGP_SUPPORT: msg = "PGP Support unavailable; install PGPy library" logger.warning(msg) raise AppriseEmailException(msg) from None if headers is None: headers = {} if cc is None: cc = set() if bcc is None: bcc = set() if reply_to is None: reply_to = set() if not names: # Prepare a empty dictionary to prevent errors/warnings names = {} if not smtp_host: # Generate a host identifier (used for Message-ID Creation) smtp_host = from_addr[1].split("@")[1] if not tzinfo: # use server time tzinfo = datetime.now().astimezone().tzinfo if subject: # Email subjects must be one header line, so collapse whitespace # as described by RFC 5322 section 2.2.3. subject = " ".join(subject.split()) logger.debug(f"SMTP Host: {smtp_host}") # Create a copy of the targets list emails = list(to) while len(emails): # Get our email to notify to_name, to_addr = emails.pop(0) # Strip target out of cc list if in To or Bcc cc_ = cc - bcc - {to_addr} # Strip target out of bcc list if in To bcc_ = bcc - {to_addr} # Strip target out of reply_to list if in To reply_to_ = reply_to - {to_addr} # Format our cc addresses to support the Name field cc_ = [ formataddr((names.get(addr, False), addr), charset="utf-8") for addr in cc_ ] # Format our bcc addresses to support the Name field bcc_ = [ formataddr((names.get(addr, False), addr), charset="utf-8") for addr in bcc_ ] # Format our reply-to addresses to support the Name field reply_to_ = [ formataddr((names.get(addr, False), addr), charset="utf-8") for addr in reply_to_ ] logger.debug( "Email From: {}".format(formataddr(from_addr, charset="utf-8")) ) logger.debug("Email To: {}".format(to_addr)) if cc_: logger.debug("Email Cc: {}".format(", ".join(cc_))) if bcc_: logger.debug("Email Bcc: {}".format(", ".join(bcc_))) if reply_to_: logger.debug("Email Reply-To: {}".format(", ".join(reply_to_))) # Prepare inline attachments before building the MIME body: # - HTML appends missing image references. # - Plain text adds an [Image: name] marker. # Non-images remain regular attachments unless explicitly linked. cid_refs = set() if inline and attach: if notify_format == NotifyFormat.HTML: # Collect filenames already referenced via cid:; # decode %20 to spaces so filenames with spaces match # the raw attachment name regardless of how the caller # encoded the URI. cid_refs = { ref.replace("%20", " ") for ref in re.findall(r'cid:([^\s"\'>\)]+)', body) } # Build a set of all attachment filenames so we can # warn about cid: refs that have no matching file _attach_names = { _a.name or f"file{_no:03}.dat" for _no, _a in enumerate(attach, start=1) } # Warn when a cid: reference has no matching attachment. for _ref in sorted(cid_refs - _attach_names): logger.warning( "Email inline: no attachment matches " "cid:%s -- check the filename.", _ref, ) # Ignore missing files when selecting the MIME wrapper. # Explicit matching references may use any file type. cid_refs &= _attach_names # Append inline anchors for any image not yet listed img_appends = [] for _no, _a in enumerate(attach, start=1): _fname = _a.name or f"file{_no:03}.dat" if (_a.mimetype or "").lower().startswith( "image/" ) and _fname not in cid_refs: img_appends.append(_fname) cid_refs.add(_fname) if img_appends: # Encode spaces as %20 in cid: URIs so filenames # with spaces remain valid HTML attribute values body = body + "".join( '
    '.format( n.replace(" ", "%20") ) for n in img_appends ) else: # Plain text: annotate inline images with a short # text placeholder (images cannot be embedded here) txt_imgs = [] for _no, _a in enumerate(attach, start=1): _fname = _a.name or f"file{_no:03}.dat" if (_a.mimetype or "").lower().startswith("image/"): txt_imgs.append(_fname) if txt_imgs: body = ( body + "\n" + "\n".join(f"[Image: {n}]" for n in txt_imgs) ) # Prepare Email Message if notify_format == NotifyFormat.HTML: base = MIMEMultipart("alternative") base.attach( MIMEText( convert_between( NotifyFormat.HTML, NotifyFormat.TEXT, body ), "plain", "utf-8", ) ) base.attach(MIMEText(body, "html", "utf-8")) else: base = MIMEText(body, "plain", "utf-8") if attach: # Use multipart/related when any image will be embedded # inline (RFC 2387); otherwise keep multipart/mixed mixed = MIMEMultipart("related" if cid_refs else "mixed") mixed.attach(base) # Now store our attachments for no, attachment in enumerate(attach, start=1): if not attachment: # We could not load the attachment; take an early # exit since this isn't what the end user wanted # We could not access the attachment msg = "Could not access attachment {}.".format( attachment.url(privacy=True) ) logger.warning(msg) raise AppriseEmailException(msg) logger.debug( "Preparing Email attachment {}".format( attachment.url(privacy=True) ) ) with attachment.open() as abody: app = MIMEApplication(abody.read()) app.set_type(attachment.mimetype) # Prepare our attachment name filename = ( attachment.name if attachment.name else f"file{no:03}.dat" ) if filename in cid_refs: # Filename is referenced by a cid: anchor in # the HTML body; embed it inline so clients # render it in-place rather than as a download app.add_header( "Content-Disposition", 'inline; filename="{}"'.format( Header(filename, "utf-8") ), ) app.add_header( "Content-ID", # Encode spaces so the Content-ID matches # the cid: URI in the HTML anchor "<{}>".format(filename.replace(" ", "%20")), ) else: # Non-image or plain-text-email attachment; # keep it as a regular downloadable file app.add_header( "Content-Disposition", 'attachment; filename="{}"'.format( Header(filename, "utf-8") ), ) mixed.attach(app) base = mixed # Suppress a custom Autocrypt value only when the controller # supplies one of its own. autocrypt_added = False if pgp and pgp_mode == PGPMode.SIGN: logger.debug("Securing Email with PGP Signature") # RFC 3156 requires the signature to be computed over the CRLF # to guarantee all recipients can verify against it. sig_result = pgp.sign( re.sub(r"(?:\r\n|\n|\r(?!\n))", "\r\n", base.as_string()) ) if not sig_result: # Signing was requested but could not be performed; # this is a hard failure for the sign mode. Include the # plugin-specific hint here (pgp.py logs only generic # debug detail so it stays reusable across plugins). msg = ( "Unable to sign email via PGP; supply a private key" " via pgpprv= or place one in the cache directory" ) logger.warning(msg) raise AppriseEmailException(msg) # Unpack the signature and its hash algorithm label sig_str, micalg = sig_result # Build the RFC 3156 multipart/signed container signed = MIMEMultipart( "signed", micalg=micalg, protocol="application/pgp-signature", ) # First part: the original message body (must not be # modified after this point as the signature covers it) signed.attach(base) # Second part: the detached PGP signature block sig_part = MIMEBase("application", "pgp-signature") sig_part.set_payload(sig_str) sig_part.add_header( "Content-Disposition", "attachment", filename="signature.asc", ) signed.attach(sig_part) # Replace base with the signed container so the rest of # the function continues to work unchanged base = signed # Encrypt when the recipient already has a public key. # Sign mode remains usable when no recipient key exists. enc_content = pgp.encrypt( base.as_string(), to_addr, autogen=False ) if enc_content: logger.debug("PGP public key found; adding encryption") # Wrap the signed body in a multipart/encrypted # container (the signed payload becomes the ciphertext) enc = MIMEMultipart( "encrypted", protocol="application/pgp-encrypted", ) # Version identifier part (required by RFC 3156) ver_part = MIMEText("Version: 1", "plain") ver_part.set_type("application/pgp-encrypted") enc.attach(ver_part) # Encrypted data part data_part = MIMEBase("application", "octet-stream") data_part.set_payload(enc_content) enc.attach(data_part) # Replace base with the fully encrypted container base = enc # Advertise our key on the outer message so an Autocrypt-aware # recipient can encrypt future replies. autocrypt = pgp.autocrypt_header() if autocrypt: base.add_header("Autocrypt", autocrypt) autocrypt_added = True elif pgp and pgp_mode == PGPMode.ENCRYPT: logger.debug("Securing Email with PGP Encryption") # Set our header information to include in the encryption base["From"] = formataddr( (None, from_addr[1]), charset="utf-8" ) base["To"] = formataddr((None, to_addr), charset="utf-8") base["Subject"] = Header( subject, NotifyEmail._get_charset(subject) ) # External recipients need an existing key. Self-sends may # generate the sender's key when pgp_autogen permits it. autogen = ( None if to_addr.lower() == from_addr[1].lower() else False ) encrypted_content = pgp.encrypt( base.as_string(), to_addr, autogen=autogen ) if not encrypted_content: # Give Email users a plugin-specific recovery hint. msg = ( "Unable to encrypt email via PGP; supply a public key" " via pgppub= or place one in the cache directory" ) logger.warning(msg) raise AppriseEmailException(msg) # prepare our message base = MIMEMultipart( "encrypted", protocol="application/pgp-encrypted" ) # Advertise our key for encrypted replies. autocrypt = pgp.autocrypt_header() if autocrypt: base.add_header("Autocrypt", autocrypt) autocrypt_added = True # Set Encryption Info Part enc_payload = MIMEText("Version: 1", "plain") enc_payload.set_type("application/pgp-encrypted") base.attach(enc_payload) # Set Encrypted Data Part enc_payload = MIMEBase("application", "octet-stream") enc_payload.set_payload(encrypted_content) base.attach(enc_payload) # Header names are case-insensitive; keep only the first # Autocrypt value unless the controller supplied its own. autocrypt_seen = autocrypt_added for k, v in headers.items(): if k.strip().lower() == "autocrypt": if autocrypt_seen: continue autocrypt_seen = True base[k] = Header(v, NotifyEmail._get_charset(v)) base["Subject"] = Header( subject, NotifyEmail._get_charset(subject) ) base["From"] = formataddr(from_addr, charset="utf-8") base["To"] = formataddr((to_name, to_addr), charset="utf-8") base["Message-ID"] = make_msgid(domain=smtp_host) base["Date"] = format_datetime(datetime.now(tz=tzinfo)) if cc_: base["Cc"] = ",".join(cc_) if reply_to_: base["Reply-To"] = ",".join(reply_to_) yield EmailMessage( recipient=to_addr, to_addrs=[to_addr, *list(cc_), *list(bcc_)], body=base.as_string(), ) @staticmethod def runtime_deps(): """Return this plugin's optional package names.""" return ("pgpy",) caronc-apprise-dfec414/apprise/plugins/email/common.py000066400000000000000000000045031526720371000231760ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import dataclasses from ...exception import ApprisePluginException from .smtp import SMTP_DEFAULT_PORTS, SMTPSecureMode class AppriseEmailException(ApprisePluginException): """Raised when an email cannot be prepared.""" def __init__(self, message, error_code=601): super().__init__(message, error_code=error_code) class WebBaseLogin: """Identify the login format expected by an email provider.""" # User Login must be Email Based EMAIL = "Email" # Login must use a user ID USERID = "UserID" # Keep the established email name as an alias for the shared SMTP modes. SecureMailMode = SMTPSecureMode # Preserve the existing mode-to-default-port format for email callers. SECURE_MODES = { mode: {"default_port": port} for mode, port in SMTP_DEFAULT_PORTS.items() } @dataclasses.dataclass class EmailMessage: """Prepared email payload and recipients.""" recipient: str to_addrs: list[str] body: str caronc-apprise-dfec414/apprise/plugins/email/smtp.py000066400000000000000000000147221526720371000226750ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Shared SMTP connection handling for Email and its subclasses. import contextlib import smtplib import ssl from typing import Optional from ...exception import AppriseImproperlyConfigured from ...logger import logger class SMTPSecureMode: """SMTP transport security mode.""" # No transport security at all INSECURE = "insecure" # Implicit TLS from the first byte of the connection SSL = "ssl" # Plaintext connection upgraded to TLS via STARTTLS STARTTLS = "starttls" # Every valid secure mode SMTP_SECURE_MODES = ( SMTPSecureMode.STARTTLS, SMTPSecureMode.SSL, SMTPSecureMode.INSECURE, ) # Default SMTP submission port per security mode SMTP_DEFAULT_PORTS = { SMTPSecureMode.STARTTLS: 587, SMTPSecureMode.SSL: 465, SMTPSecureMode.INSECURE: 25, } # The exceptions every SMTP operation in this module guards against SMTP_EXCEPTIONS = (OSError, smtplib.SMTPException, RuntimeError) class AppriseSMTPController: """Manage one authenticated SMTP connection within a context manager. ``sendmail()`` returns false for delivery errors. Connection and login errors leave the context so the plugin can report the whole send as failed. with AppriseSMTPController( host=host, port=port, secure_mode=secure_mode, user=user, password=password, ) as smtp: for target in targets: message = build_message(target) if not smtp.sendmail(from_addr, [target], message): has_error = True """ def __init__( self, host: str, port: int, secure_mode: str, user: Optional[str] = None, password: Optional[str] = None, verify_certificate: bool = True, socket_connect_timeout: int = 15, ) -> None: self.host = host self.port = port self.secure_mode = secure_mode self.user = user self.password = password self.verify_certificate = verify_certificate self.socket_connect_timeout = socket_connect_timeout # Bind our socket variable to the current namespace self._socket = None def __enter__(self) -> "AppriseSMTPController": """Connects, secures, and authenticates the SMTP session.""" if self.secure_mode not in SMTP_SECURE_MODES: # Reject unknown modes rather than silently using plain SMTP. raise AppriseImproperlyConfigured( f"Invalid SMTP secure mode: {self.secure_mode!r}" ) # Configure certificate and hostname checks for encrypted connections. context = ssl.create_default_context() if not self.verify_certificate: context.check_hostname = False context.verify_mode = ssl.CERT_NONE try: logger.debug("Connecting to remote SMTP server...") socket_func = smtplib.SMTP socket_args = {} if self.secure_mode == SMTPSecureMode.SSL: logger.debug("Securing connection with SSL...") socket_func = smtplib.SMTP_SSL socket_args["context"] = context self._socket = socket_func( self.host, self.port, None, timeout=self.socket_connect_timeout, **socket_args, ) if self.secure_mode == SMTPSecureMode.STARTTLS: # Handle Secure Connections logger.debug("Securing connection with STARTTLS...") self._socket.starttls(context=context) logger.trace("Login ID: %s", self.user) if self.user and self.password: # Apply Login credentials logger.debug("Applying user credentials...") self._socket.login(self.user, self.password) except SMTP_EXCEPTIONS: # Close a connection that failed during STARTTLS or login. if self._socket is not None: with contextlib.suppress(*SMTP_EXCEPTIONS): self._socket.quit() self._socket = None raise return self def sendmail(self, from_addr: str, to_addrs: list, message: str) -> bool: """Send one prepared message and return whether it succeeded.""" try: # A non-empty result lists recipients SMTP refused. refused = self._socket.sendmail(from_addr, to_addrs, message) except SMTP_EXCEPTIONS as e: logger.debug("Socket Exception: %s", str(e)) return False if refused: for addr, (code, resp) in refused.items(): logger.warning( "SMTP recipient %s was refused: %s %s", addr, code, resp ) return False return True def __exit__(self, exc_type, exc_val, exc_tb) -> None: """Gracefully terminates the connection with the server.""" if self._socket is not None: with contextlib.suppress(*SMTP_EXCEPTIONS): # The socket may already be invalid after a TLS failure. self._socket.quit() caronc-apprise-dfec414/apprise/plugins/email/templates.py000066400000000000000000000241541526720371000237100ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import re from .common import SecureMailMode, WebBaseLogin # To attempt to make this script stupid proof, if we detect an email address # that is part of the this table, we can pre-use a lot more defaults if they # aren't otherwise specified on the users input. EMAIL_TEMPLATES = ( # Google GMail ( "Google Mail", re.compile( r"^((?P
    in two split_at = html_tag_adjust(text, start, split_at, window_end) elif body_format is NotifyFormat.MARKDOWN: # Markdown may also contain HTML entities. split_at = html_adjust(text, start, split_at) split_at = markdown_adjust(text, start, split_at) # Never separate an escaping backslash from the character it # escapes. An odd run of backslashes right before the split # means the last one still needs its partner, so keep them # together in the next chunk. chunk = text[start:split_at] if (len(chunk) - len(chunk.rstrip("\\"))) % 2: split_at -= 1 if split_at <= start: split_at = orig_split result.append(text[start:split_at]) start = split_at return result caronc-apprise-dfec414/apprise/utils/http.py000066400000000000000000000460041526720371000212570ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Opt-in HTTP destination checks for callers that fetch untrusted URLs. Apprise API imports HTTPPolicy, HTTPPolicySession and is_public_ip_address from here, so this module is shared between the two projects rather than being internal to Apprise. Anything in it that looks unused from inside Apprise alone is most likely there for that caller; check apprise-api before removing it. Requests and urllib3 are the only HTTP libraries involved, and nothing here needs, or should grow, another one. urllib3 is not held at arm's length though: pinning a connection to an address we already approved is not something its public interface can express, so this module subclasses its connection and pool classes and hooks two protected details. - ``_new_conn()`` is overridden to open our own pinned socket. - ``_dns_host`` is read rather than ``host``, because it is the name urllib3 itself resolves; ``host`` may have been normalized by then. Both have been present unchanged through urllib3 1.26 and 2.x. What is deliberately avoided is *importing* underscore-prefixed names out of urllib3, since those do move between releases and several are missing from 1.26 altogether; ``_DEFAULT_TIMEOUT`` below is defined locally for exactly that reason. Because of that coupling, the tests for this module are a compatibility gate rather than plain unit tests. Run them against the oldest urllib3 that Apprise supports (the 1.26 series, which RHEL and Rocky Linux ship) as well as the current one before accepting a change here. """ from concurrent.futures import ( ThreadPoolExecutor, TimeoutError as FutureTimeoutError, ) import ipaddress import socket from socket import timeout as SocketTimeout import sys import threading from urllib.parse import urlsplit import requests from requests.adapters import HTTPAdapter from urllib3.connection import HTTPConnection, HTTPSConnection from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool from urllib3.exceptions import ( ConnectTimeoutError, NewConnectionError, ) from urllib3.poolmanager import PoolManager from urllib3.util.connection import allowed_gai_family from .parse import is_hostname # Keep this sentinel local because urllib3's private equivalent varies by # version and is absent from the widely used 1.26.x series. _DEFAULT_TIMEOUT = object() # DNS work is shared so many policies cannot create unbounded thread pools. _DNS_POOL = ThreadPoolExecutor( max_workers=8, thread_name_prefix="apprise-http-resolve", ) _DNS_SLOTS = threading.BoundedSemaphore(16) # RFC 6598 shared space is neither private nor generally Internet-routable. _CGN_SHARED_V4 = ipaddress.ip_network("100.64.0.0/10") # Standard NAT64 prefixes carry an IPv4 destination in their final 32 bits. _NAT64_NETWORKS = ( ipaddress.ip_network("64:ff9b::/96"), ipaddress.ip_network("64:ff9b:1::/48"), ) def is_public_ip_address(value): """Return true for an ordinary public unicast IP address.""" try: address = ipaddress.ip_address(value) except ValueError: return False if isinstance(address, ipaddress.IPv6Address): # NAT64 inherits the safety classification of its embedded IPv4. for network in _NAT64_NETWORKS: if address in network: embedded = ipaddress.IPv4Address(int(address) & 0xFFFFFFFF) return is_public_ip_address(embedded) # Judge IPv4-mapped addresses by their embedded IPv4 address because # some Python releases misclassify the IPv6 wrapper as reserved. if address.ipv4_mapped is not None: return is_public_ip_address(address.ipv4_mapped) # Reject any address that is not a public unicast address. blocked = ( address.is_private or address.is_loopback or address.is_link_local or ( isinstance(address, ipaddress.IPv6Address) and address.is_site_local ) or address.is_reserved or address.is_unspecified or address.is_multicast or ( isinstance(address, ipaddress.IPv4Address) and address in _CGN_SHARED_V4 ) ) if blocked: return False if isinstance(address, ipaddress.IPv6Address): # IPv4-mapped addresses returned above; validate 6to4 addresses here. if address.sixtofour and not is_public_ip_address(address.sixtofour): return False # Teredo addresses embed an IPv4 address in their last 32 bits. if address.teredo and not all( is_public_ip_address(item) for item in address.teredo ): return False return True def is_secure_http_url(url): """Validate a URL before it is used as a request target. A valid URL: - uses HTTPS - points at a valid hostname or IP address - carries no username or password, even an empty one - has a usable port - has no fragment, which would not reach the server Private hosts remain valid for self-hosted services. Returns ``True`` when all checks pass and ``False`` otherwise. """ if not isinstance(url, str): return False try: parsed = urlsplit(url) # Reading an invalid port, or a malformed IPv6 address, raises a # ValueError here. port = parsed.port except ValueError: return False # Port zero cannot be connected to. if port == 0: return False # A fragment is never sent to a server. An empty one still counts, so # the character itself is what we look for. if "#" in url: return False # Anything that is not HTTPS is refused. if parsed.scheme.lower() != "https": return False # A URL with no destination host is of no use to us. hostname = parsed.hostname if not hostname: return False # Reject credentials embedded in the URL. The comparison is against # None so that an empty https://@example.com is caught too. if parsed.username is not None or parsed.password is not None: return False # Confirm the host itself is sane. This covers hostnames, IPv4 and # IPv6 alike; only the yes or no answer is needed here. return bool(is_hostname(hostname)) def read_bounded_response(r, max_bytes, chunk_size=65536): """Read and close a streamed response. Return ``None`` if the response exceeds ``max_bytes``; otherwise, return the bytes read. """ chunks = [] total = 0 try: for chunk in r.iter_content(chunk_size=chunk_size): if not chunk: continue total += len(chunk) if total > max_bytes: return None chunks.append(chunk) return b"".join(chunks) finally: r.close() def _release_dns_slot(_future): """Release a DNS slot only after its lookup has actually stopped.""" _DNS_SLOTS.release() def _set_socket_options(sock, options): """Apply the same socket options urllib3 would normally configure. Three shapes of option arrive here, and all three are supported on purpose: - ``(level, option, value)``, which is what urllib3 itself supplies. - ``(level, option, value, "tcp"|"udp")``, which urllib3-future adds so one option list can describe both transports. Apprise never requires urllib3-future, but a host may well have it installed, and an option it supplies must not raise here. - ``(level, option, None, length)``, Python's own form for an option given by length rather than by value. The tests build each shape by hand instead of installing anything, so they also pass on systems that ship the older urllib3 1.26 series. """ if options is None: return for opt in options: # A fourth item that is text names the transport the option belongs # to, so an option meant for UDP is not pushed onto a TCP socket. if len(opt) == 4 and isinstance(opt[3], str): socket_type = { "tcp": socket.SOCK_STREAM, "udp": socket.SOCK_DGRAM, }.get(opt[3].lower()) if socket_type == sock.type: sock.setsockopt(*opt[:3]) continue # This also preserves Python's (level, option, None, length) form. sock.setsockopt(*opt) class HTTPPolicy: """Validate URLs and pin connections to approved DNS answers. ``url_filter`` and ``address_filter`` return true for allowed values. They are optional so applications can enforce only the rules they need. """ def __init__( self, url_filter=None, address_filter=None, resolve_timeout=5.0, ): self.url_filter = url_filter self.address_filter = address_filter self.resolve_timeout = resolve_timeout def validate_url(self, url): """Raise InvalidURL when a request URL is not approved.""" if not self.url_filter: return try: allowed = self.url_filter(url) except Exception as exc: # A broken application policy must fail closed as a request error. raise requests.exceptions.InvalidURL( "Outbound HTTP URL policy failed" ) from exc if not allowed: # A policy that returns false must fail closed as a request error. raise requests.exceptions.InvalidURL( "URL denied by outbound HTTP policy" ) def resolve(self, host, port): """Resolve once and return approved connection addresses.""" # Refuse new work when slow DNS has consumed the bounded capacity. if not _DNS_SLOTS.acquire(blocking=False): raise socket.gaierror( socket.EAI_AGAIN, "DNS resolver capacity exhausted", ) try: # Submit a DNS lookup to the shared thread pool so it cannot block # the caller. future = _DNS_POOL.submit( socket.getaddrinfo, host, port, allowed_gai_family(), socket.SOCK_STREAM, ) except Exception as exc: _DNS_SLOTS.release() raise socket.gaierror( socket.EAI_AGAIN, "DNS resolver is unavailable", ) from exc # Timed-out native lookups retain their slot until the worker exits. future.add_done_callback(_release_dns_slot) try: answers = future.result(timeout=self.resolve_timeout) except FutureTimeoutError as exc: future.cancel() raise socket.gaierror( socket.EAI_AGAIN, "DNS resolution timed out", ) from exc except Exception as exc: # Normalize resolver failures for urllib3's connection handling. raise socket.gaierror( socket.EAI_NONAME, "DNS resolution failed", ) from exc # Preserve each full sockaddr so connecting cannot trigger new DNS. approved = [] for answer in answers: try: address = answer[4][0] except (IndexError, TypeError): # An unrecognizable resolver result cannot be trusted. continue if self.address_filter: try: if not self.address_filter(address): continue except Exception as exc: # A broken policy callback must fail closed. raise socket.gaierror( socket.EAI_NONAME, "Address policy failed", ) from exc approved.append(answer) if not approved: raise socket.gaierror( socket.EAI_NONAME, "No DNS address passed the outbound HTTP policy", ) return approved def create_connection( self, host, port, timeout=_DEFAULT_TIMEOUT, source_address=None, socket_options=None, ): """Connect to a previously validated DNS answer.""" error = None for family, socktype, proto, _canonname, sockaddr in self.resolve( host, port, ): sock = None try: # Apply the same socket settings urllib3 normally uses. sock = socket.socket(family, socktype, proto) _set_socket_options(sock, socket_options) if timeout is not _DEFAULT_TIMEOUT: sock.settimeout(timeout) if source_address: sock.bind(source_address) sock.connect(sockaddr) # Confirm the socket reached the exact address DNS approved. try: expected_address = ipaddress.ip_address(sockaddr[0]) peer_address = ipaddress.ip_address(sock.getpeername()[0]) except (IndexError, TypeError, ValueError) as exc: raise OSError( "Connected socket returned an invalid peer address" ) from exc if peer_address != expected_address: raise OSError( "Connected peer did not match the approved DNS address" ) return sock except OSError as exc: error = exc if sock is not None: sock.close() if error is not None: raise error raise OSError("getaddrinfo returned no usable addresses") class _PolicyConnectionMixin: """Route urllib3 connections through an HTTPPolicy.""" policy = None def _new_conn(self): """Open a pinned socket while retaining the original TLS hostname.""" try: sock = self.policy.create_connection( self._dns_host, self.port, timeout=self.timeout, source_address=self.source_address, socket_options=self.socket_options, ) except socket.gaierror as exc: # This exception exists across supported urllib3 releases. raise NewConnectionError( self, "Failed to resolve an approved address for " f"{self.host}: {exc}", ) from exc except SocketTimeout as exc: raise ConnectTimeoutError( self, f"Connection to {self.host} timed out. " f"(connect timeout={self.timeout})", ) from exc except OSError as exc: raise NewConnectionError( self, f"Failed to establish a new connection: {exc}", ) from exc sys.audit("http.client.connect", self, self.host, self.port) return sock def _policy_pool_classes(policy): """Build connection pools bound to one request policy.""" class PolicyHTTPConnection(_PolicyConnectionMixin, HTTPConnection): pass class PolicyHTTPSConnection(_PolicyConnectionMixin, HTTPSConnection): pass # Class attributes avoid leaking policy objects into urllib3 pool keys. PolicyHTTPConnection.policy = policy PolicyHTTPSConnection.policy = policy class PolicyHTTPConnectionPool(HTTPConnectionPool): """Use a policy-aware connection class for HTTP.""" ConnectionCls = PolicyHTTPConnection class PolicyHTTPSConnectionPool(HTTPSConnectionPool): """Use a policy-aware connection class for HTTPS.""" ConnectionCls = PolicyHTTPSConnection return { "http": PolicyHTTPConnectionPool, "https": PolicyHTTPSConnectionPool, } class _PolicyHTTPAdapter(HTTPAdapter): """Install policy-aware connection pools into Requests.""" def __init__(self, policy, *args, **kwargs): self.policy = policy super().__init__(*args, **kwargs) def init_poolmanager( self, connections, maxsize, block=False, **pool_kwargs, ): """Create the normal manager, then replace its pool classes.""" # These three belong to Requests' own adapter rather than to urllib3. # They are part of what it pickles, so an adapter that overrides this # method still has to set them. self._pool_connections = connections self._pool_maxsize = maxsize self._pool_block = block self.poolmanager = PoolManager( num_pools=connections, maxsize=maxsize, block=block, **pool_kwargs, ) self.poolmanager.pool_classes_by_scheme = _policy_pool_classes( self.policy ) def proxy_manager_for(self, proxy, **proxy_kwargs): """Reject proxies because they resolve targets outside this policy.""" raise requests.exceptions.ProxyError( "Proxies are disabled by outbound HTTP policy" ) class HTTPPolicySession(requests.Session): """A Requests session that validates every hop and pins its DNS result.""" def __init__(self, policy): """Create a session that enforces the given HTTPPolicy.""" super().__init__() self.policy = policy # Environment proxies must not reroute a checked destination. self.trust_env = False # Install a policy-aware adapter for all HTTP and HTTPS requests. adapter = _PolicyHTTPAdapter(policy) self.mount("http://", adapter) self.mount("https://", adapter) def send(self, request, **kwargs): """Validate the initial URL and every redirect before transmission.""" self.policy.validate_url(request.url) return super().send(request, **kwargs) caronc-apprise-dfec414/apprise/utils/json.py000066400000000000000000000042561526720371000212540ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import base64 from datetime import datetime import json from ..common import AWARE_DATE_ISO_FORMAT, NAIVE_DATE_ISO_FORMAT from ..locale import LazyTranslation class AppriseJSONEncoder(json.JSONEncoder): """A JSON Encoder for handling Apprise internals.""" def default(self, entry): if isinstance(entry, datetime): return entry.strftime( AWARE_DATE_ISO_FORMAT if entry.tzinfo is not None else NAIVE_DATE_ISO_FORMAT ) elif isinstance(entry, bytes): return base64.b64encode(entry).decode("utf-8") elif isinstance(entry, (set, frozenset, tuple)): return list(entry) elif isinstance(entry, LazyTranslation): return str(entry) return super().default(entry) caronc-apprise-dfec414/apprise/utils/logic.py000066400000000000000000000131571526720371000214000ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from itertools import chain from .. import common from ..tag import AppriseTag from .parse import parse_list def _token_matches_data(tok, data, match_all): """Return True if filter token *tok* matches any entry in *data*. When the token carries an explicit priority (e.g. "3:endpoint"), only data entries that are AppriseTag objects with that exact priority match. When the token has no priority prefix, any data entry with the same tag name matches regardless of its stored priority. """ ft = AppriseTag.parse(tok) tag_name = str(ft) if tag_name == match_all: return True if not ft.has_priority: # Name-only: works transparently for both str and AppriseTag in data # because AppriseTag.__hash__ == hash(tag_name) and __eq__ handles str. return tag_name in data # Priority-exact: must find a matching AppriseTag with the same priority. # Plain-string entries in data (backward compat) fall back to name-only. for item in data: if isinstance(item, AppriseTag): if str(item) == tag_name and item.priority == ft.priority: return True else: if str(item).lower() == tag_name: return True return False def is_exclusive_match( logic, data, match_all=common.MATCH_ALL_TAG, match_always=common.MATCH_ALWAYS_TAG, ): """The data variable should always be a set of strings that the logic can be compared against. It should be a set. If it isn't already, then it will be converted as such. These identify the tags themselves. Our logic should be a list as well: - top level entries are treated as an 'or' - second level (or more) entries are treated as 'and' examples: logic="tagA, tagB" = tagA or tagB logic=['tagA', 'tagB'] = tagA or tagB logic=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB logic=[('tagB', 'tagC')] = tagB and tagC Filter tokens may carry an explicit priority prefix (e.g. "3:endpoint"). When present, only server tags that are AppriseTag objects with that exact priority will match. Tokens without a priority prefix match any priority. If `match_always` is not set to None, then its value is added as an 'or' to all specified logic searches. """ if isinstance(logic, str): # Update our logic to support our delimiters logic = set(parse_list(logic)) if not logic: # If there is no logic to apply then we're done early; we only match # if there is also no data to match against return not data if not isinstance(logic, (list, tuple, set)): # garbage input return False if match_always: # Add our match_always to our logic searching if specified logic = chain(logic, [match_always]) # Track what we match against; but by default we do not match # against anything matched = False # Every entry here will be or'ed with the next for entry in logic: if not isinstance(entry, (str, list, tuple, set)): # Garbage entry in our logic found return False # All tokens within an entry are AND-ed: every token must match data. raw = parse_list(entry) if not raw: # We got a bogus set of tags to parse; match only if data is empty return not data if all(_token_matches_data(tok, data, match_all) for tok in raw): matched = True break # else: keep looking # Return True if we matched against our logic (or simply none was # specified). return matched def dict_full_update(dict1, dict2): """Takes 2 dictionaries (dict1 and dict2) that contain sub-dictionaries and gracefully merges them into dict1. This is similar to: dict1.update(dict2) except that internal dictionaries are also recursively applied. """ def _merge(dict1, dict2): for k in dict2: if ( k in dict1 and isinstance(dict1[k], dict) and isinstance(dict2[k], dict) ): _merge(dict1[k], dict2[k]) else: dict1[k] = dict2[k] _merge(dict1, dict2) return caronc-apprise-dfec414/apprise/utils/module.py000066400000000000000000000040061526720371000215610ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import contextlib import importlib.util import sys from ..logger import logger def import_module(path, name): """Load our module based on path.""" spec = importlib.util.spec_from_file_location(name, path) try: module = importlib.util.module_from_spec(spec) sys.modules[name] = module spec.loader.exec_module(module) except Exception as e: # module isn't loadable with contextlib.suppress(KeyError): del sys.modules[name] module = None logger.debug( "Module exception raised from %s (name=%s) %s", path, name, str(e) ) return module caronc-apprise-dfec414/apprise/utils/parse.py000066400000000000000000001322571526720371000214200ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import contextlib from functools import reduce import re from urllib.parse import quote, unquote, urlencode as _urlencode, urlparse from .disk import tidy_path # URL Indexing Table for returns via parse_url() # The below accepts and scans for: # - schema:// # - schema://path # - schema://path?kwargs # VALID_URL_RE = re.compile( r"^[\s]*((?P[^:\s]+):[/\\]+)?((?P[^?]+)" r"(\?(?P.+))?)?[\s]*$", ) VALID_QUERY_RE = re.compile(r"^(?P.*[/\\])(?P[^/\\]+)?$") # RFC 3986 path characters that are safe to leave unencoded when we # quote() a URL path. This is the pchar sub-delims set minus comma and # semicolon, which Apprise reserves as its own list delimiters. Shared by # parse_url() below and by every url() implementation that re-quotes a # previously parsed fullpath, so a literal path character (such as the # '@' in a jsDelivr version selector) is never re-percent-encoded when a # URL is reconstructed. URL_PATH_SAFE_CHARS = "/:@!$&'()*+=" # The same characters without the slash, for quoting one path segment. # An encoded slash (%2F) inside a segment then stays encoded. URL_SEGMENT_SAFE_CHARS = URL_PATH_SAFE_CHARS.replace("/", "") # The same characters plus '%', for quoting a path that is already # percent-encoded (the way parse_url() returns it). Existing escapes # such as %20 or %2F are kept instead of being encoded a second time. URL_ENCODED_PATH_SAFE_CHARS = URL_PATH_SAFE_CHARS + "%" # delimiters used to separate values when content is passed in by string. # This is useful when turning a string into a list STRING_DELIMITERS = r"[\[\]\;,\s]+" # String Delimiters without the whitespace STRING_DELIMITERS_NO_WS = r"[\[\]\;,]+" # The handling of custom arguments passed in the URL; we treat any # argument (which would otherwise appear in the qsd area of our parse_url() # function differently if they start with a +, - or : value NOTIFY_CUSTOM_ADD_TOKENS = re.compile(r"^( |\+)(?P.*)\s*") NOTIFY_CUSTOM_DEL_TOKENS = re.compile(r"^-(?P.*)\s*") NOTIFY_CUSTOM_COLON_TOKENS = re.compile(r"^:(?P.*)\s*") # Used for attempting to acquire the schema if the URL can't be parsed. GET_SCHEMA_RE = re.compile(r"\s*(?P[a-z0-9]{1,32})://.*$", re.I) # Used for validating that a provided entry is indeed a schema # this is slightly different then the GET_SCHEMA_RE above which # insists the schema is only valid with a :// entry. this one # extrapolates the individual entries URL_DETAILS_RE = re.compile( r"\s*(?P[a-z0-9]{1,32})(://(?P.*))?$", re.I ) # Regular expression based and expanded from: # http://www.regular-expressions.info/email.html # Extended to support colon (:) delimiter for parsing names from the URL # such as: # - 'Optional Name':user@example.com # - 'Optional Name' # # The expression also parses the general email as well such as: # - user@example.com # - label+user@example.com GET_EMAIL_RE = re.compile( r'(([\s"\']{0,32})?(?P[^:<\'"]{0,128})?[:<\s\'"]{1,32})?' r"(?P((?P
    1. apprise(1)
    2. apprise(1)

    NAME

    apprise - Push Notifications that work with just about every platform!

    SYNOPSIS

    apprise [options...] service-url...
    apprise storage [options...] [action] [url-or-uid...]

    DESCRIPTION

    Apprise allows you to send a notification to almost all of the most popular notification services available to us today such as: Discord, Telegram, Pushbullet, Slack, Twitter, etc.

    • One notification library to rule them all.
    • A common and intuitive notification syntax.
    • Supports the handling of images (to the notification services that will accept them).
    • It's incredibly lightweight.
    • Amazing response times because all messages sent asynchronously.

    OPTIONS

    The Apprise options are as follows:

    -b, --body=VALUE: Specify the message body. If no body is specified then content is read from stdin.

    -t, --title=VALUE: Specify the message title. This field is completely optional.

    -c, --config=CONFIG-URL: Specify one or more configuration locations.

    -a, --attach=ATTACH-URL: Specify one or more file attachment locations.

    -P, --plugin-path=PATH: Specify a path to scan for custom notification plugin support. You can create your own notification by simply creating a Python file that contains the @notify("schema") decorator.

    You can optionally choose to specify more than one --plugin-path (-P) to increase the modules included.

    -n, --notification-type=VALUE: Specify the message type (default=info). Possible values are "info", "success", "failure", and "warning".

    -i, --input-format=VALUE: Specify the input message format (default=text). Possible values are "text", "html", and "markdown".

    -T, --theme=VALUE: Specify the default theme.

    -tv, --template-var=NAME=VALUE: Supply a value used by a YAML configuration written with ${NAME}. Specify this option more than once to supply more than one value, but name each one only once. A value not provided here uses the default in the configuration's template: section, then APPRISE_TEMPLATE_<NAME>. A URL that still has no value available is not loaded.

    -g, --tag=VALUE: Specify one or more tags to filter which services to notify:

    • -g "tagA" -g "tagB": Match tagA OR tagB (Union).
    • -g "tagA,tagB": Match tagA AND tagB (Strict).
    • -g "all": Notify ALL services.
    • (Omitted): Notify untagged services only.

    Tag values may include an optional priority prefix and/or retry suffix:

    • -g "2:tagA": Match only entries for tagA assigned priority 2.
    • -g "tagA:3": Match all tagA entries, retrying each up to 3 times.
    • -g "2:tagA:3": Priority-2 tagA entries only, with up to 3 retries.

    -Da, --disable-async: Send notifications synchronously (one after the other) instead of all at once.

    -L, --limit=SECONDS: Give up on the whole run if it's taking too long, e.g. -L 10 or -L 2.5. By default (0) there's no limit; see --service-limit below to cap each service on its own instead. If a service is truly stuck, Apprise gives it a few extra seconds to wrap up, then exits anyway with status 5.

    -SL, --service-limit=SECONDS: Give up on any single service if it's taking too long, e.g. -SL 10 or -SL 2.5, or 0 to turn this off entirely. Leave it unset to use Apprise's own default of 60 seconds.

    -R, --recursion-depthINTEGER: The number of recursive import entries that can be loaded from within Apprise configuration. By default this is set to 1. If this is set to zero, then import statements found in any configuration is ignored.

    -e, --interpret-escapes Enable interpretation of backslash escapes. For example, this would convert sequences such as \n and \r to their respected ascii new-line and carriage return characters prior to the delivery of the notification.

    -j, --interpret-emojis Enable interpretation of emoji strings. For example, this would convert sequences such as :smile: or :grin: to their respected unicode emoji character.

    -S, --storage-path=PATH: Specify the path to the persistent storage caching location

    -SM, --storage-mode=MODE: Specify the persistent storage operational mode. Possible values are "auto", "flush", and "memory". The default is "auto" if not specified.

    -SPD, --storage-prune-days=INTEGER: Define the number of days the storage prune should run using. Setting this to zero (0) will eliminate all accumulated content. By default this value is 30 (days).

    -SUL, --storage-uid-length=INTEGER: Define the number of unique characters to store persistent cache in. By default this value is 8 (characters).

    -d, --dry-run: Perform a trial run but only prints the notification services to-be triggered to stdout. Notifications are never sent using this mode.

    -l, --details Prints details about the current services supported by Apprise.

    -v, --verbose: The more of these you specify, the more verbose the output is. e.g: -vvvv

    -D, --debug: A debug mode; useful for troubleshooting.

    -V, --version: Display the apprise version and exit.

    -h, --help: Show this message and exit.

    PERSISTENT STORAGE

    Persistent storage by default writes to the following location unless the environment variable APPRISE_STORAGE_PATH overrides it and/or --storage-path (-SP) is specified to override it:

    ~/.local/share/apprise/cache
    

    To utilize the persistent storage element associated with Apprise, simply specify the keyword storage

    $ apprise storage
    

    The storage action has the following sub actions:

    list: List all of the detected persistent storage elements and their state (stale, active, or unused). This is the default action if nothing further is identified. Results may be filtered by passing one or more 8-character UID prefix strings, or by passing full Apprise service URLs — each URL is resolved to its storage namespace automatically, making it easy to look up a specific plugin's cache entry without knowing the hash in advance.

    prune: Removes all persistent storage that has not been referenced for more than 30 days. You can optionally set the --storage-prune-days to alter this default value.

    clean: Removes all persistent storage regardless of age.

    EXIT STATUS

    apprise exits with a status of:

    • 0 if all of the notifications were sent successfully.
    • 1 if every notification failed, or the notification could not start.
    • 2 if there was an error specified on the command line such as not providing a valid argument.
    • 3 if one or more Apprise Service URLs were successfully loaded but none could be notified due to user filtering (via tags).
    • 4 if at least one notification was sent successfully and at least one other was not (whether it failed outright or ran out of time).
    • 5 if no notification was sent, and the only problem was that one or more services ran out of time.

    SERVICE URLS

    There are too many service URL and combinations to list here. It's best to visit the Apprise GitHub page and see what's available. Also try out the Apprise URL Builder to easily construct the URLS this tool supports and works with.

    The environment variable of APPRISE_URLS (comma/space delimited) can be specified to provide the default set of URLs you wish to notify if none are otherwise specified.

    EXAMPLES

    Send a notification to multiple services by chaining their URLs:

    $ apprise -vv -t "my title" -b "my notification body" \
       "mailto://myemail:mypass@gmail.com" \
       "pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b"
    

    If you don't specify a --body (-b) then stdin is used allowing you to use the tool as part of your every day administration:

    $ cat /proc/cpuinfo | apprise -vv -t "cpu info" \
        "mailto://myemail:mypass@gmail.com"
    

    Load in a configuration file which identifies all of your notification service URLs and notify them all:

    $ apprise -vv -t "my title" -b "my notification body" \
       --config=~/apprise.yml
    

    Load in a configuration file from a remote server that identifies all of your notification service URLs and only notify the ones tagged as devops.

    $ apprise -vv -t "my title" -b "my notification body" \
       --config=https://localhost/my/apprise/config \
       -t devops
    

    Tagging Logic Examples:

    Notify any service tagged with "devops" OR "admin" (Union):

    $ apprise -vv -t "Union Test" \
       --config=~/apprise.yml \
       -g devops -g admin
    

    Notify only services tagged with BOTH "devops" AND "critical" (Intersection):

    $ apprise -vv -t "Intersection Test" \
       --config=~/apprise.yml \
       -g devops,critical
    

    Notify only priority-2 "alerts" services (exclusive priority filter):

    $ apprise -vv -t "High Alert" \
       --config=~/apprise.yml \
       -g "2:alerts"
    

    Notify all "alerts" services and retry each up to 3 times on failure:

    $ apprise -vv -t "Critical Event" \
       --config=~/apprise.yml \
       -g "alerts:3"
    

    Include an attachment:

    $ apprise -vv -t "School Assignment" -b "See attached" \
       --attach=Documents/FinalReport.docx
    

    List all of the notifications loaded:

    $ apprise --dry-run --tag=all
    

    List all of the details around the current persistent storage setup:

    $ apprise storage list
    

    Look up the storage namespace for a specific notification URL (shows the UID even when no data has been written yet):

    $ apprise storage list "mailtos://user:pass@example.com"
    

    Prune all persistent storage that has not been referenced for at least 10 days or more

    $ apprise storage prune --storage-prune-days=10
    

    CUSTOM PLUGIN/NOTIFICATIONS

    Apprise can additionally allow you to define your own custom schema:// entries that you can trigger on and call services you've defined.

    By default apprise looks in the following local locations for custom plugin files and loads them:

    ~/.apprise/plugins
    ~/.config/apprise/plugins
    /var/lib/apprise/plugins
    

    The environment variable of APPRISE_PLUGIN_PATH can be specified to override the list identified above with one of your own. use a semi-colon (;), line-feed (\n), and/or carriage return (\r) to delimit multiple entries.

    Simply create your own python file with the following bare minimum content in it:

    from apprise.decorators import notify
    
    # This example assumes you want your function to trigger on foobar://
    # references:
    @notify(on="foobar", name="My Custom Notification")
    def my_wrapper(body, title, notify_type, *args, **kwargs):
    
         print("Define your custom code here")
    
         # Returning True/False will relay your status back through Apprise
         # Returning nothing (None by default) is always interpreted as True
         return True
    

    CONFIGURATION

    An Apprise configuration file can be in the format of either TEXT or YAML where TEXT is the easiest and most ideal solution for most users. However YAML configuration files grants the user a bit more leverage and access to some of the internal features of Apprise. Regardless of which format you choose, both provide the users the ability to leverage tagging which adds a more rich and powerful notification environment.

    Configuration files can be directly referenced via apprise when referencing the --config= (-c) CLI directive. You can identify as many as you like on the command line and all of them will be loaded. You can also point your configuration to a cloud location (by referencing http:// or https://. By default apprise looks in the following local locations for configuration files and loads them:

    ~/.apprise.conf
    ~/.apprise.yaml
    ~/.config/apprise.conf
    ~/.config/apprise.yaml
    
    ~/.apprise/apprise.conf
    ~/.apprise/apprise.yaml
    ~/.config/apprise/apprise.conf
    ~/.config/apprise/apprise.yaml
    
    /etc/apprise.conf
    /etc/apprise.yaml
    /etc/apprise/apprise.conf
    /etc/apprise/apprise.yaml
    

    The configuration files specified above can also be identified with a .yml extension or even just entirely removing the .conf extension altogether.

    The environment variable of APPRISE_CONFIG_PATH can be specified to override the list identified above with one of your own. use a semi-colon (;), line-feed (\n), and/or carriage return (\r) to delimit multiple entries.

    If a default configuration file is referenced in any way by the apprise tool, you no longer need to provide it a Service URL. Usage of the apprise tool simplifies to:

    $ apprise -vv -t "my title" -b "my notification body"
    

    If you leveraged tagging, you can define all of Apprise Service URLs in your configuration that you want and only specifically notify a subset of them:

    $ apprise -vv --title "Will Be Late Getting Home" \
        --body "Please go ahead and make dinner without me." \
        --tag=family
    

    TEMPLATE VARIABLES

    A YAML configuration can leave a value out of a URL and have it filled in later. Write ${NAME} where the value belongs and declare every name you use in a template: section:

    template:
      # A value written here is used when nothing else supplies one
      smtp_host: smtp.example.com
      # No value, so this one must always be supplied
      api_key:
    
    urls:
      - sendgrid://${API_KEY}:noreply@example.com/you@example.com:
          - tag: alerts
    

    Values are looked for in this order, stopping at the first that answers:

    • a value given with --template-var (-tv)
    • the default written in the template: section
    • the environment variable APPRISE_TEMPLATE_<NAME>

    A URL with no value available is not loaded, and apprise exits with a status of 4 if others were notified, or 1 if none were. Use --dry-run to see which values a configuration is still waiting for.

    ${NAME} only means something when NAME appears in the template: section. Anywhere else it is ordinary text and is sent exactly as written, so a password containing ${...} is never altered. Names ignore case, and a name may be used as often as you like. Values may be up to 1,024 characters. Extra supplied names are accepted and ignored; their names appear only in the local debug log, never with their values.

    The configuration author may place a variable directly in a URL or in a named YAML setting below it. URL markers can change any field, including the destination; whole-host markers also accept user@host and user:pass@host. A named setting changes only its option, so prefer it for values supplied by less-trusted callers.

    A variable may not appear before ://, may not be used for tag:/tags:, and may not be used as a setting's name. The service and tags are selected before values are filled in, so neither can be dynamic.

    This applies to YAML configuration only; TEXT configuration is passed through unchanged.

    ENVIRONMENT VARIABLES

    APPRISE_URLS: Specify the default URLs to notify IF none are otherwise specified on the command line explicitly. If the --config (-c) is specified, then this will override any reference to this variable. Use white space and/or a comma (,) to delimit multiple entries.

    APPRISE_CONFIG_PATH: Explicitly specify the config search path to use (overriding the default). Use a semi-colon (;), line-feed (\n), and/or carriage return (\r) to delimit multiple entries.

    APPRISE_PLUGIN_PATH: Explicitly specify the custom plugin search path to use (overriding the default). Use a semi-colon (;), line-feed (\n), and/or carriage return (\r) to delimit multiple entries.

    APPRISE_STORAGE_PATH: Explicitly specify the persistent storage path to use (overriding the default).

    APPRISE_TEMPLATE_<NAME>: Supply a value for a ${NAME} used by a YAML configuration. For example APPRISE_TEMPLATE_API_KEY fills in ${API_KEY}. A value supplied with --template-var (-tv) and a configuration default both take priority. Blank values are ignored.

    HTTP_PROXY, HTTPS_PROXY, NO_PROXY: Standard proxy variables honored by the underlying requests library (not Apprise-specific). Set HTTP_PROXY/HTTPS_PROXY to route outbound notifications through a proxy, and NO_PROXY to exempt specific hosts. SOCKS proxies (socks5h://...) additionally require the optional PySocks package (pip install pysocks) to be installed.

    BUGS

    If you find any bugs, please make them known at: https://github.com/caronc/apprise/issues

    DONATIONS

    If you found Apprise useful at all, please consider sponsoring or donating!

    Apprise is Copyright (C) 2026 Chris Caron lead2gold@gmail.com

    1. Chris Caron <lead2gold@gmail.com>
    2. September 2026
    3. apprise(1)
    caronc-apprise-dfec414/packaging/man/apprise.md000066400000000000000000000420471526720371000216120ustar00rootroot00000000000000apprise(1) -- Push Notifications that work with just about every platform! ========================================================================== ## SYNOPSIS `apprise` [...] ...
    `apprise` storage [...] [] [...]
    ## DESCRIPTION **Apprise** allows you to send a notification to _almost all_ of the most popular notification services available to us today such as: Discord, Telegram, Pushbullet, Slack, Twitter, etc. * One notification library to rule them all. * A common and intuitive notification syntax. * Supports the handling of images (to the notification services that will accept them). * It's incredibly lightweight. * Amazing response times because all messages sent asynchronously. ## OPTIONS The Apprise options are as follows: `-b`, `--body=`: Specify the message body. If no body is specified then content is read from . `-t`, `--title=`: Specify the message title. This field is completely optional. `-c`, `--config=`: Specify one or more configuration locations. `-a`, `--attach=`: Specify one or more file attachment locations. `-P`, `--plugin-path=`: Specify a path to scan for custom notification plugin support. You can create your own notification by simply creating a Python file that contains the `@notify("schema")` decorator. You can optionally choose to specify more than one **--plugin-path** (**-P**) to increase the modules included. `-n`, `--notification-type=`: Specify the message type (default=info). Possible values are "info", "success", "failure", and "warning". `-i`, `--input-format=`: Specify the input message format (default=text). Possible values are "text", "html", and "markdown". `-T`, `--theme=`: Specify the default theme. `-tv`, `--template-var=`: Supply a value used by a YAML configuration written with `${NAME}`. Specify this option more than once to supply more than one value, but name each one only once. A value not provided here uses the default in the configuration's `template:` section, then `APPRISE_TEMPLATE_`. A URL that still has no value available is not loaded. `-g`, `--tag=`: Specify one or more tags to filter which services to notify: * `-g "tagA" -g "tagB"`: Match tagA **OR** tagB (Union). * `-g "tagA,tagB"`: Match tagA **AND** tagB (Strict). * `-g "all"`: Notify **ALL** services. * `(Omitted)`: Notify **untagged** services only. Tag values may include an optional priority prefix and/or retry suffix: * `-g "2:tagA"`: Match **only** entries for tagA assigned priority 2. * `-g "tagA:3"`: Match all tagA entries, retrying each up to 3 times. * `-g "2:tagA:3"`: Priority-2 tagA entries only, with up to 3 retries. `-Da`, `--disable-async`: Send notifications synchronously (one after the other) instead of all at once. `-L`, `--limit=`: Give up on the whole run if it's taking too long, e.g. `-L 10` or `-L 2.5`. By default (`0`) there's no limit; see `--service-limit` below to cap each service on its own instead. If a service is truly stuck, Apprise gives it a few extra seconds to wrap up, then exits anyway with status **5**. `-SL`, `--service-limit=`: Give up on any single service if it's taking too long, e.g. `-SL 10` or `-SL 2.5`, or `0` to turn this off entirely. Leave it unset to use Apprise's own default of 60 seconds. `-R`, `--recursion-depth`: The number of recursive import entries that can be loaded from within Apprise configuration. By default this is set to 1. If this is set to zero, then import statements found in any configuration is ignored. `-e`, `--interpret-escapes` Enable interpretation of backslash escapes. For example, this would convert sequences such as \n and \r to their respected ascii new-line and carriage return characters prior to the delivery of the notification. `-j`, `--interpret-emojis` Enable interpretation of emoji strings. For example, this would convert sequences such as :smile: or :grin: to their respected unicode emoji character. `-S`, `--storage-path=`: Specify the path to the persistent storage caching location `-SM`, `--storage-mode=`: Specify the persistent storage operational mode. Possible values are "auto", "flush", and "memory". The default is "auto" if not specified. `-SPD`, `--storage-prune-days=`: Define the number of days the storage prune should run using. Setting this to zero (0) will eliminate all accumulated content. By default this value is 30 (days). `-SUL`, `--storage-uid-length=`: Define the number of unique characters to store persistent cache in. By default this value is 8 (characters). `-d`, `--dry-run`: Perform a trial run but only prints the notification services to-be triggered to **stdout**. Notifications are never sent using this mode. `-l`, `--details` Prints details about the current services supported by Apprise. `-v`, `--verbose`: The more of these you specify, the more verbose the output is. e.g: -vvvv `-D`, `--debug`: A debug mode; useful for troubleshooting. `-V`, `--version`: Display the apprise version and exit. `-h`, `--help`: Show this message and exit. ## PERSISTENT STORAGE Persistent storage by default writes to the following location unless the environment variable `APPRISE_STORAGE_PATH` overrides it and/or `--storage-path` (`-SP`) is specified to override it: ~/.local/share/apprise/cache To utilize the [persistent storage][pstorage] element associated with Apprise, simply specify the keyword **storage** $ apprise storage The **storage** action has the following sub actions: `list`: List all of the detected persistent storage elements and their state (**stale**, **active**, or **unused**). This is the default action if nothing further is identified. Results may be filtered by passing one or more 8-character UID prefix strings, or by passing full Apprise service URLs — each URL is resolved to its storage namespace automatically, making it easy to look up a specific plugin's cache entry without knowing the hash in advance. `prune`: Removes all persistent storage that has not been referenced for more than 30 days. You can optionally set the `--storage-prune-days` to alter this default value. `clean`: Removes all persistent storage regardless of age. ## EXIT STATUS **apprise** exits with a status of: * **0** if all of the notifications were sent successfully. * **1** if every notification failed, or the notification could not start. * **2** if there was an error specified on the command line such as not providing a valid argument. * **3** if one or more Apprise Service URLs were successfully loaded but none could be notified due to user filtering (via tags). * **4** if at least one notification was sent successfully and at least one other was not (whether it failed outright or ran out of time). * **5** if no notification was sent, and the only problem was that one or more services ran out of time. ## SERVICE URLS There are too many service URL and combinations to list here. It's best to visit the [Apprise GitHub page][serviceurls] and see what's available. Also try out the [Apprise URL Builder][buildurls] to easily construct the URLS this tool supports and works with. [serviceurls]: https://appriseit.com/services/ [buildurls]: https://appriseit.com/tools/url-builder/ The **environment variable** of `APPRISE_URLS` (comma/space delimited) can be specified to provide the default set of URLs you wish to notify if none are otherwise specified. ## EXAMPLES Send a notification to multiple services by chaining their URLs: $ apprise -vv -t "my title" -b "my notification body" \ "mailto://myemail:mypass@gmail.com" \ "pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b" If you don't specify a **--body** (**-b**) then stdin is used allowing you to use the tool as part of your every day administration: $ cat /proc/cpuinfo | apprise -vv -t "cpu info" \ "mailto://myemail:mypass@gmail.com" Load in a configuration file which identifies all of your notification service URLs and notify them all: $ apprise -vv -t "my title" -b "my notification body" \ --config=~/apprise.yml Load in a configuration file from a remote server that identifies all of your notification service URLs and only notify the ones tagged as _devops_. $ apprise -vv -t "my title" -b "my notification body" \ --config=https://localhost/my/apprise/config \ -t devops **Tagging Logic Examples:** Notify any service tagged with "devops" OR "admin" (Union): $ apprise -vv -t "Union Test" \ --config=~/apprise.yml \ -g devops -g admin Notify only services tagged with BOTH "devops" AND "critical" (Intersection): $ apprise -vv -t "Intersection Test" \ --config=~/apprise.yml \ -g devops,critical Notify only priority-2 "alerts" services (exclusive priority filter): $ apprise -vv -t "High Alert" \ --config=~/apprise.yml \ -g "2:alerts" Notify all "alerts" services and retry each up to 3 times on failure: $ apprise -vv -t "Critical Event" \ --config=~/apprise.yml \ -g "alerts:3" Include an attachment: $ apprise -vv -t "School Assignment" -b "See attached" \ --attach=Documents/FinalReport.docx List all of the notifications loaded: $ apprise --dry-run --tag=all List all of the details around the current persistent storage setup: $ apprise storage list Look up the storage namespace for a specific notification URL (shows the UID even when no data has been written yet): $ apprise storage list "mailtos://user:pass@example.com" Prune all persistent storage that has not been referenced for at least 10 days or more $ apprise storage prune --storage-prune-days=10 ## CUSTOM PLUGIN/NOTIFICATIONS Apprise can additionally allow you to define your own custom **schema://** entries that you can trigger on and call services you've defined. By default **apprise** looks in the following local locations for custom plugin files and loads them: ~/.apprise/plugins ~/.config/apprise/plugins /var/lib/apprise/plugins The **environment variable** of `APPRISE_PLUGIN_PATH` can be specified to override the list identified above with one of your own. use a semi-colon (`;`), line-feed (`\n`), and/or carriage return (`\r`) to delimit multiple entries. Simply create your own python file with the following bare minimum content in it: from apprise.decorators import notify # This example assumes you want your function to trigger on foobar:// # references: @notify(on="foobar", name="My Custom Notification") def my_wrapper(body, title, notify_type, *args, **kwargs): print("Define your custom code here") # Returning True/False will relay your status back through Apprise # Returning nothing (None by default) is always interpreted as True return True ## CONFIGURATION An [Apprise configuration file][config] can be in the format of either **TEXT** or **YAML** where TEXT is the easiest and most ideal solution for most users. However YAML configuration files grants the user a bit more leverage and access to some of the internal features of Apprise. Regardless of which format you choose, both provide the users the ability to leverage **tagging** which adds a more rich and powerful notification environment. Configuration files can be directly referenced via **apprise** when referencing the `--config=` (`-c`) CLI directive. You can identify as many as you like on the command line and all of them will be loaded. You can also point your configuration to a cloud location (by referencing `http://` or `https://`. By default **apprise** looks in the following local locations for configuration files and loads them: ~/.apprise.conf ~/.apprise.yaml ~/.config/apprise.conf ~/.config/apprise.yaml ~/.apprise/apprise.conf ~/.apprise/apprise.yaml ~/.config/apprise/apprise.conf ~/.config/apprise/apprise.yaml /etc/apprise.conf /etc/apprise.yaml /etc/apprise/apprise.conf /etc/apprise/apprise.yaml The **configuration files** specified above can also be identified with a `.yml` extension or even just entirely removing the `.conf` extension altogether. The **environment variable** of `APPRISE_CONFIG_PATH` can be specified to override the list identified above with one of your own. use a semi-colon (`;`), line-feed (`\n`), and/or carriage return (`\r`) to delimit multiple entries. If a default configuration file is referenced in any way by the **apprise** tool, you no longer need to provide it a Service URL. Usage of the **apprise** tool simplifies to: $ apprise -vv -t "my title" -b "my notification body" If you leveraged [tagging][tagging], you can define all of Apprise Service URLs in your configuration that you want and only specifically notify a subset of them: $ apprise -vv --title "Will Be Late Getting Home" \ --body "Please go ahead and make dinner without me." \ --tag=family ### TEMPLATE VARIABLES A **YAML** configuration can leave a value out of a URL and have it filled in later. Write `${NAME}` where the value belongs and declare every name you use in a `template:` section: template: # A value written here is used when nothing else supplies one smtp_host: smtp.example.com # No value, so this one must always be supplied api_key: urls: - sendgrid://${API_KEY}:noreply@example.com/you@example.com: - tag: alerts Values are looked for in this order, stopping at the first that answers: * a value given with `--template-var` (`-tv`) * the default written in the `template:` section * the environment variable `APPRISE_TEMPLATE_` A URL with no value available is not loaded, and **apprise** exits with a status of **4** if others were notified, or **1** if none were. Use `--dry-run` to see which values a configuration is still waiting for. `${NAME}` only means something when `NAME` appears in the `template:` section. Anywhere else it is ordinary text and is sent exactly as written, so a password containing `${...}` is never altered. Names ignore case, and a name may be used as often as you like. Values may be up to 1,024 characters. Extra supplied names are accepted and ignored; their names appear only in the local debug log, never with their values. The configuration author may place a variable directly in a URL or in a named YAML setting below it. URL markers can change any field, including the destination; whole-host markers also accept `user@host` and `user:pass@host`. A named setting changes only its option, so prefer it for values supplied by less-trusted callers. A variable may not appear before `://`, may not be used for `tag:`/`tags:`, and may not be used as a setting's name. The service and tags are selected before values are filled in, so neither can be dynamic. This applies to **YAML** configuration only; **TEXT** configuration is passed through unchanged. [config]: https://appriseit.com/getting-started/configuration/ [tagging]: https://appriseit.com/cli/usage/#tagging-and-filtering [pstorage]: https://appriseit.com/cli/persistent-storage/ ## ENVIRONMENT VARIABLES `APPRISE_URLS`: Specify the default URLs to notify IF none are otherwise specified on the command line explicitly. If the `--config` (`-c`) is specified, then this will override any reference to this variable. Use white space and/or a comma (`,`) to delimit multiple entries. `APPRISE_CONFIG_PATH`: Explicitly specify the config search path to use (overriding the default). Use a semi-colon (`;`), line-feed (`\n`), and/or carriage return (`\r`) to delimit multiple entries. `APPRISE_PLUGIN_PATH`: Explicitly specify the custom plugin search path to use (overriding the default). Use a semi-colon (`;`), line-feed (`\n`), and/or carriage return (`\r`) to delimit multiple entries. `APPRISE_STORAGE_PATH`: Explicitly specify the persistent storage path to use (overriding the default). `APPRISE_TEMPLATE_`: Supply a value for a `${NAME}` used by a YAML configuration. For example `APPRISE_TEMPLATE_API_KEY` fills in `${API_KEY}`. A value supplied with `--template-var` (`-tv`) and a configuration default both take priority. Blank values are ignored. `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`: Standard proxy variables honored by the underlying `requests` library (not Apprise-specific). Set `HTTP_PROXY`/`HTTPS_PROXY` to route outbound notifications through a proxy, and `NO_PROXY` to exempt specific hosts. SOCKS proxies (`socks5h://...`) additionally require the optional `PySocks` package (`pip install pysocks`) to be installed. ## BUGS If you find any bugs, please make them known at: ## DONATIONS If you found Apprise useful at all, [please consider sponsoring][sponsorship] or [donating][donating]! [sponsorship]: https://github.com/sponsors/caronc [donating]: https://www.paypal.com/donate/?hosted_button_id=CR6YF7KLQWQ5E ## COPYRIGHT Apprise is Copyright (C) 2026 Chris Caron caronc-apprise-dfec414/packaging/redhat/000077500000000000000000000000001526720371000203125ustar00rootroot00000000000000caronc-apprise-dfec414/packaging/redhat/python-apprise.rpmlintrc.el10000066400000000000000000000002221526720371000257640ustar00rootroot00000000000000from Config import * # Ignore false-positive spelling errors on brand names addFilter('spelling-error.*ntfy') addFilter('spelling-error.*ilert') caronc-apprise-dfec414/packaging/redhat/python-apprise.rpmlintrc.el9000066400000000000000000000005311526720371000257170ustar00rootroot00000000000000from Config import * # Ignore false-positive spelling errors on brand names addFilter('spelling-error.*ntfy') addFilter('spelling-error.*ilert') addFilter('spelling-error.*Cliq') addFilter('spelling-error.*Zoho') addFilter('spelling-error.*httpSMS') addFilter('invalid-license BSD-2-Clause') addFilter('obsolete-not-provided python.*-apprise') caronc-apprise-dfec414/packaging/redhat/python-apprise.spec000066400000000000000000000522151526720371000241550ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. ################################################################### %if 0%{?_module_build} %bcond_with tests %else # When bootstrapping Python, we cannot test this yet %bcond_without tests %endif # Handling of new python building structure (for backwards compatiblity) %global legacy_python_build 0 %if 0%{?fedora} && 0%{?fedora} <= 29 %global legacy_python_build 1 %endif %if 0%{?rhel} && 0%{?rhel} <= 9 %global legacy_python_build 1 %endif %global pypi_name apprise # Use up to 8 test workers; builders can lower this with _smp_ncpus_max %global pytest_workers %{lua: print(math.min(8, tonumber(rpm.expand("%{_smp_build_ncpus}")) or 1))} # Handle rpmlint false positives # - Prevent warnings: # en_US ntfy -> notify # en_US httpSMS -> HTTP # # rpmlint: ignore-spelling httpSMS ntfy # - RHEL9 does not recognize: BSD-2-Clause which is correct # # rpmlint: ignore invalid-license %global common_description %{expand: \ Apprise is a Python package that simplifies access to many popular \ notification services. It supports sending alerts to platforms such as: \ \ `46elks`, `800.com`, `AfricasTalking`, `AMQP`, \ `Apprise API`, `APRS`, \ `AWS SES`, `AWS SNS`, `Bark`, `Better Stack`, `Blink(1)`, `BlueSky`, \ `Brevo`, `Burst SMS`, `BulkSMS`, `BulkVS`, \ `Chanify`, `Clickatell`, `ClickSend`, `DAPNET`, `Delta Chat`, \ `DingTalk`, `Discord`, \ `Dot. (Quote/0)`, `E-Mail`, `Emby`, `Evolution API`, `Exotel`, \ `FCM`, `Feishu`, `Flock`, `Flowtriq`, `Fluxer`, `Free Mobile`, `GitHub`, \ `GoAlert`, `Google Chat`, `Gorush`, `Gotify`, `GroupMe`, `Growl`, \ `Guilded`, `Home Assistant`, `httpSMS`, \ `HumHub`, `IFTTT`, `ilert`, `incident.io`, \ `IRC`, `Jellyfin`, `Jira`, `Join`, `Kavenegar`, `KODI`, `Kook`, \ `Kumulos`, `LaMetric`, `Lark`, `Lauther`, `LFG Alarm`, `Line`, \ `MacOSX`, `Mailgun`, \ `MailerSend`, \ `Mastodon`, \ `Mattermost`, `Matrix`, `MessageBird`, `Microsoft Windows`, \ `Microsoft Teams`, `Misskey`, `Mobile Message`, \ `MQTT`, `MSG91`, `MyAndroid`, `Nexmo`, `Nextcloud`, \ `NextcloudTalk`, `Notica`, `Notifiarr`, `Notifico`, \ `Notifyre`, \ `ntfy`, `Octopush`, `Office365`, `OneBot`, `OneSignal`, \ `Opsgenie`, `PagerDuty`, \ `PagerTree`, `ParsePlatform`, `Pinglet`, `Pingram`, `Plivo`, `Prowl`, \ `Postmark`, `Pushalot`, `PushBullet`, \ `Pushjet`, `PushMe`, `Pushover`, `Pushplus`, `PushSafer`, `PushWard`, \ `Pushy`, `PushDeer`, \ `QQ Push`, `Revolt`, `Reddit`, `Resend`, `RingCentral`, `Rocket.Chat`, \ `RSyslog`, `SendGrid`, \ `SendPulse`, `ServerChan`, `SerwerSMS`, `Session Open Group Server`, \ `Seven`, `SFR`, \ `Signal`, `Signalgrid`, `SIGNL4`, `Simplepush`, \ `Sinch`, `Slack`, `SMPP`, `SMSC`, `SMSEagle`, `SMS Manager`, `SMTP2Go`, \ `SparkPost`, `Splunk`, `Spike`, `Spug Push`, `Squadcast`, \ `Stackfield`, `Super Toasty`, \ `Streamlabs`, `Stride`, \ `Synology Chat`, `Syslog`, `Techulus Push`, `Telegram`, `Telnyx`, \ `Threema Gateway`, `Trigv`, `Twilio`, `Twitter`, `Twist`, `Vapid`, \ `Viber`, `VictorOps`, \ `Voipms`, `Vonage`, `WebPush`, `WeChat (WeCom)`, `WeCom Bot`, `WhatsApp`, \ `Webex Teams`, `Workflows`, `WPUSH`, `WxPusher`, `XBMC`, `XMPP`, `YouLMK`, \ `Zalo`, `Zoho Cliq`, `Zoom`, and `Zulip`.} Name: python-%{pypi_name} Version: 2.1.0 Release: 1%{?dist} Summary: A simple wrapper to many popular notification services used today License: BSD-2-Clause URL: https://github.com/caronc/%{pypi_name} Source0: %{url}/archive/v%{version}/%{pypi_name}-%{version}.tar.gz BuildArch: noarch %description %{common_description} %package -n %{pypi_name} Summary: Notify messaging platforms from the command line Obsoletes: %{pypi_name} < %{version}-%{release} Provides: %{pypi_name} = %{version}-%{release} Requires: python3dist(click) >= 5.0 Requires: python%{python3_pkgversion}-%{pypi_name} = %{version}-%{release} %description -n %{pypi_name} An accompanied CLI tool that can be used as part of Apprise to issue notifications from the command line to you favorite services. %package -n python%{python3_pkgversion}-%{pypi_name} Summary: A simple wrapper to many popular notification services used today Obsoletes: python%{python3_pkgversion}-%{pypi_name} < %{version}-%{release} %{?python_provide:%python_provide python%{python3_pkgversion}-%{pypi_name}} BuildRequires: gettext BuildRequires: python%{python3_pkgversion}-devel %if %{legacy_python_build} BuildRequires: python3dist(setuptools) %else BuildRequires: python3dist(setuptools) >= 69 %endif BuildRequires: python3dist(wheel) BuildRequires: python3dist(requests) BuildRequires: python3dist(urllib3) BuildRequires: python3dist(requests-oauthlib) BuildRequires: python3dist(click) >= 5.0 BuildRequires: python3dist(markdown) BuildRequires: python3dist(pyyaml) BuildRequires: python3dist(babel) BuildRequires: python3dist(cryptography) BuildRequires: python3dist(certifi) BuildRequires: python3dist(tox) %if %{with tests} BuildRequires: python3dist(pytest) BuildRequires: python3dist(pytest-mock) BuildRequires: python3dist(pytest-xdist) %endif Requires: python3dist(requests) Requires: python3dist(urllib3) Requires: python3dist(requests-oauthlib) Requires: python3dist(markdown) Requires: python3dist(cryptography) Requires: python3dist(certifi) Requires: python3dist(pyyaml) Recommends: python3dist(hidapi) Recommends: python3dist(pika) Recommends: python3dist(python-qpid-proton) Recommends: python3dist(pynacl) Recommends: python3dist(paho-mqtt) >= 2.1.0 Recommends: python3dist(slixmpp) %if 0%{?legacy_python_build} == 0 # Logic for non-RHEL ≤ 9 systems %generate_buildrequires %pyproject_buildrequires %endif %description -n python%{python3_pkgversion}-%{pypi_name} %{common_description} %prep %autosetup -n %{pypi_name}-%{version} %build %if %{legacy_python_build} # backwards compatible %py3_build %else %pyproject_wheel %endif %install %if %{legacy_python_build} # backwards compatible %py3_install # Compile gettext catalogues from SOURCE into the INSTALLED tree pushd %{_builddir}/%{pypi_name}-%{version} for po in apprise/i18n/*/LC_MESSAGES/apprise.po; do [ -f "$po" ] || continue langdir="$(dirname "${po#apprise/i18n/}")" outdir="%{buildroot}%{python3_sitelib}/%{pypi_name}/i18n/${langdir}" install -d "$outdir" msguniq --use-first -o "$po" "$po" msgfmt -o "${outdir}/apprise.mo" "$po" done %else %pyproject_install %pyproject_save_files apprise # Compile gettext catalogues into the installed tree pushd %{buildroot}%{python3_sitelib}/apprise/i18n for po in */LC_MESSAGES/apprise.po; do [ -f "$po" ] || continue msguniq --use-first -o "$po" "$po" msgfmt -o "${po%.po}.mo" "$po" done %endif popd %{__install} -p -D -T -m 0644 packaging/man/%{pypi_name}.1 \ %{buildroot}%{_mandir}/man1/%{pypi_name}.1 %if %{with tests} %check %if %{legacy_python_build} # backwards compatible LANG=C.UTF-8 PYTHONPATH=%{buildroot}%{python3_sitelib}:%{_builddir}/%{name}-%{version} py.test-%{python3_version} -n %{pytest_workers} %else %pytest -n %{pytest_workers} %endif %endif %files -n python%{python3_pkgversion}-%{pypi_name} %license LICENSE %doc SECURITY.md README.md ACKNOWLEDGEMENTS.md CONTRIBUTING.md %{python3_sitelib}/%{pypi_name}/ # Exclude i18n as it is handled below with the lang(spoken) tag below %exclude %{python3_sitelib}/%{pypi_name}/cli.* %exclude %{python3_sitelib}/%{pypi_name}/__pycache__/cli*.py? %if %{legacy_python_build} # Handle egg-info vs. dist-info based on build backend %{python3_sitelib}/apprise-*.egg-info # Legacy: include all compiled locales that we produced under the package tree %lang(ar) %{python3_sitelib}/%{pypi_name}/i18n/ar/LC_MESSAGES/apprise.mo %lang(de) %{python3_sitelib}/%{pypi_name}/i18n/de/LC_MESSAGES/apprise.mo %lang(en) %{python3_sitelib}/%{pypi_name}/i18n/en/LC_MESSAGES/apprise.mo %lang(es) %{python3_sitelib}/%{pypi_name}/i18n/es/LC_MESSAGES/apprise.mo %lang(fr) %{python3_sitelib}/%{pypi_name}/i18n/fr/LC_MESSAGES/apprise.mo %lang(hi) %{python3_sitelib}/%{pypi_name}/i18n/hi/LC_MESSAGES/apprise.mo %lang(id) %{python3_sitelib}/%{pypi_name}/i18n/id/LC_MESSAGES/apprise.mo %lang(it) %{python3_sitelib}/%{pypi_name}/i18n/it/LC_MESSAGES/apprise.mo %lang(ja) %{python3_sitelib}/%{pypi_name}/i18n/ja/LC_MESSAGES/apprise.mo %lang(ko) %{python3_sitelib}/%{pypi_name}/i18n/ko/LC_MESSAGES/apprise.mo %lang(ms) %{python3_sitelib}/%{pypi_name}/i18n/ms/LC_MESSAGES/apprise.mo %lang(nl) %{python3_sitelib}/%{pypi_name}/i18n/nl/LC_MESSAGES/apprise.mo %lang(pl) %{python3_sitelib}/%{pypi_name}/i18n/pl/LC_MESSAGES/apprise.mo %lang(pt) %{python3_sitelib}/%{pypi_name}/i18n/pt/LC_MESSAGES/apprise.mo %lang(ru) %{python3_sitelib}/%{pypi_name}/i18n/ru/LC_MESSAGES/apprise.mo %lang(th) %{python3_sitelib}/%{pypi_name}/i18n/th/LC_MESSAGES/apprise.mo %lang(tl) %{python3_sitelib}/%{pypi_name}/i18n/tl/LC_MESSAGES/apprise.mo %lang(tr) %{python3_sitelib}/%{pypi_name}/i18n/tr/LC_MESSAGES/apprise.mo %lang(vi) %{python3_sitelib}/%{pypi_name}/i18n/vi/LC_MESSAGES/apprise.mo %lang(zh) %{python3_sitelib}/%{pypi_name}/i18n/zh/LC_MESSAGES/apprise.mo %else # Handle egg-info vs. dist-info based on build backend %{python3_sitelib}/apprise-*.dist-info/ # Localised Files %exclude %{python3_sitelib}/%{pypi_name}/i18n/ %lang(ar) %{python3_sitelib}/%{pypi_name}/i18n/ar/LC_MESSAGES/apprise.mo %lang(de) %{python3_sitelib}/%{pypi_name}/i18n/de/LC_MESSAGES/apprise.mo %lang(en) %{python3_sitelib}/%{pypi_name}/i18n/en/LC_MESSAGES/apprise.mo %lang(es) %{python3_sitelib}/%{pypi_name}/i18n/es/LC_MESSAGES/apprise.mo %lang(fr) %{python3_sitelib}/%{pypi_name}/i18n/fr/LC_MESSAGES/apprise.mo %lang(hi) %{python3_sitelib}/%{pypi_name}/i18n/hi/LC_MESSAGES/apprise.mo %lang(id) %{python3_sitelib}/%{pypi_name}/i18n/id/LC_MESSAGES/apprise.mo %lang(it) %{python3_sitelib}/%{pypi_name}/i18n/it/LC_MESSAGES/apprise.mo %lang(ja) %{python3_sitelib}/%{pypi_name}/i18n/ja/LC_MESSAGES/apprise.mo %lang(ko) %{python3_sitelib}/%{pypi_name}/i18n/ko/LC_MESSAGES/apprise.mo %lang(ms) %{python3_sitelib}/%{pypi_name}/i18n/ms/LC_MESSAGES/apprise.mo %lang(nl) %{python3_sitelib}/%{pypi_name}/i18n/nl/LC_MESSAGES/apprise.mo %lang(pl) %{python3_sitelib}/%{pypi_name}/i18n/pl/LC_MESSAGES/apprise.mo %lang(pt) %{python3_sitelib}/%{pypi_name}/i18n/pt/LC_MESSAGES/apprise.mo %lang(ru) %{python3_sitelib}/%{pypi_name}/i18n/ru/LC_MESSAGES/apprise.mo %lang(th) %{python3_sitelib}/%{pypi_name}/i18n/th/LC_MESSAGES/apprise.mo %lang(tl) %{python3_sitelib}/%{pypi_name}/i18n/tl/LC_MESSAGES/apprise.mo %lang(tr) %{python3_sitelib}/%{pypi_name}/i18n/tr/LC_MESSAGES/apprise.mo %lang(vi) %{python3_sitelib}/%{pypi_name}/i18n/vi/LC_MESSAGES/apprise.mo %lang(zh) %{python3_sitelib}/%{pypi_name}/i18n/zh/LC_MESSAGES/apprise.mo %endif %files -n %{pypi_name} %{_bindir}/%{pypi_name} %{_mandir}/man1/%{pypi_name}.1* %{python3_sitelib}/%{pypi_name}/cli.py %{python3_sitelib}/%{pypi_name}/__pycache__/cli*.py? %changelog * Sat Oct 24 2026 Chris Caron - 2.1.0-1 - Updated to v2.1.0 * Sat Oct 3 2026 Chris Caron - 2.0.1-1 - Updated to v2.0.1 * Sat Sep 26 2026 Chris Caron - 2.0.0-1 - Updated to major release of v2.0.0 * Sun Aug 30 2026 Chris Caron - 1.13.1-1 - Updated to v1.13.1 * Thu Aug 20 2026 Chris Caron - 1.13.0-1 - Updated to v1.13.0 * Thu Jul 16 2026 Fedora Release Engineering - 1.12.0-2 - Rebuilt for https://fedoraproject.org/wiki/Fedora_45_Mass_Rebuild * Sat Jul 4 2026 Chris Caron - 1.12.0-1 - Updated to v1.12.0 * Thu Jun 04 2026 Python Maint - 1.11.0-2 - Rebuilt for Python 3.15 * Fri May 29 2026 Chris Caron - 1.11.0-1 - Updated to v1.11.0 * Sun Apr 26 2026 Chris Caron - 1.10.0-1 - Updated to v1.10.0 * Sat Mar 21 2026 Chris Caron - 1.9.9-1 - Updated to v1.9.9 * Sun Mar 8 2026 Chris Caron - 1.9.8-1 - Updated to v1.9.8 * Tue Jan 20 2026 Chris Caron - 1.9.7-1 - Updated to v1.9.7 * Sun Jan 18 2026 Benjamin A. Beasley - 1.9.6-3 - Remove unnecessary pytest-runner, pytest-cov dependencies * Sat Jan 17 2026 Fedora Release Engineering - 1.9.6-2 - Rebuilt for https://fedoraproject.org/wiki/Fedora_44_Mass_Rebuild * Sun Dec 7 2025 Chris Caron - 1.9.6-1 - Updated to v1.9.6 * Tue Sep 30 2025 Chris Caron - 1.9.5-1 - Updated to v1.9.5 * Fri Sep 19 2025 Python Maint - 1.9.4-4 - Rebuilt for Python 3.14.0rc3 bytecode * Sat Aug 16 2025 Chris Caron - 1.9.4-3 - Spec file modernization BZ2377453 - Translation files patch added to allow v1.9.4 to build corectly * Fri Aug 15 2025 Python Maint - 1.9.4-2 - Rebuilt for Python 3.14.0rc2 bytecode * Sat Aug 2 2025 Chris Caron - 1.9.4 - Updated to v1.9.4 * Sun Mar 30 2025 Chris Caron - 1.9.3 - Updated to v1.9.3 * Sat Jan 18 2025 Fedora Release Engineering - 1.9.1-2 - Rebuilt for https://fedoraproject.org/wiki/Fedora_42_Mass_Rebuild * Wed Jan 8 2025 Chris Caron - 1.9.2 - Updated to v1.9.2 * Tue Dec 17 2024 Chris Caron - 1.9.1 - Updated to v1.9.1 * Mon Sep 2 2024 Chris Caron - 1.9.0 - Updated to v1.9.0 * Thu Jul 25 2024 Chris Caron - 1.8.1 - Updated to v1.8.1 * Fri Jul 19 2024 Fedora Release Engineering - 1.8.0-3 - Rebuilt for https://fedoraproject.org/wiki/Fedora_41_Mass_Rebuild * Fri Jun 07 2024 Python Maint - 1.8.0-2 - Rebuilt for Python 3.13 * Sat May 11 2024 Chris Caron - 1.8.0 - Updated to v1.8.0 * Sat Apr 13 2024 Chris Caron - 1.7.6 - Updated to v1.7.6 * Sat Mar 30 2024 Chris Caron - 1.7.5 - Updated to v1.7.5 * Sat Mar 9 2024 Chris Caron - 1.7.4 - Updated to v1.7.4 * Sun Mar 3 2024 Chris Caron - 1.7.3 - Updated to v1.7.3 * Sat Jan 27 2024 Chris Caron - 1.7.2 - Updated to v1.7.2 * Fri Jan 26 2024 Fedora Release Engineering - 1.6.0-3 - Rebuilt for https://fedoraproject.org/wiki/Fedora_40_Mass_Rebuild * Sun Jan 21 2024 Fedora Release Engineering - 1.6.0-2 - Rebuilt for https://fedoraproject.org/wiki/Fedora_40_Mass_Rebuild * Sun Oct 15 2023 Chris Caron - 1.6.0 - Updated to v1.6.0 * Sun Aug 27 2023 Chris Caron - 1.5.0 - Updated to v1.5.0 - apprise-fedora-rpm-testcase-handling.patch added for test handling * Fri Jul 21 2023 Fedora Release Engineering - 1.4.5-2 - Rebuilt for https://fedoraproject.org/wiki/Fedora_39_Mass_Rebuild * Thu Jul 6 2023 Chris Caron - 1.4.5 - Updated to v1.4.5 * Wed Jun 14 2023 Python Maint - 1.4.0-2 - Rebuilt for Python 3.12 * Mon May 15 2023 Chris Caron - 1.4.0 - Updated to v1.4.0 * Wed Feb 22 2023 Chris Caron - 1.3.0 - Updated to v1.3.0 * Fri Jan 20 2023 Fedora Release Engineering - 1.2.1-2 - Rebuilt for https://fedoraproject.org/wiki/Fedora_38_Mass_Rebuild * Wed Dec 28 2022 Chris Caron - 1.2.1-1 - Updated to v1.2.1 * Tue Nov 15 2022 Chris Caron - 1.2.0-1 - Updated to v1.2.0 * Sat Oct 8 2022 Chris Caron - 1.1.0-1 - Updated to v1.1.0 * Fri Oct 7 2022 Chris Caron - 1.0.0-3 - Python 2 Support dropped * Wed Aug 31 2022 Chris Caron - 1.0.0-2 - Rebuilt for RHEL9 Support * Sat Aug 6 2022 Chris Caron - 1.0.0-1 - Updated to v1.0.0 * Fri Jul 22 2022 Fedora Release Engineering - 0.9.9-3 - Rebuilt for https://fedoraproject.org/wiki/Fedora_37_Mass_Rebuild * Wed Jun 15 2022 Python Maint - 0.9.9-2 - Rebuilt for Python 3.11 * Thu Jun 2 2022 Chris Caron - 0.9.9-1 - Updated to v0.9.9 * Thu Apr 28 2022 Chris Caron - 0.9.8.3-1 - Updated to v0.9.8.3 * Sat Apr 23 2022 Chris Caron - 0.9.8.2-1 - Updated to v0.9.8.2 * Tue Apr 19 2022 Chris Caron - 0.9.8.1-1 - Updated to v0.9.8.1 * Mon Apr 18 2022 Chris Caron - 0.9.8-1 - Updated to v0.9.8 * Wed Feb 2 2022 Chris Caron - 0.9.7-1 - Updated to v0.9.7 * Wed Dec 1 2021 Chris Caron - 0.9.6-1 - Updated to v0.9.6 * Sat Sep 18 2021 Chris Caron - 0.9.5.1-2 - Updated to v0.9.5.1 * Sat Sep 18 2021 Chris Caron - 0.9.5-1 - Updated to v0.9.5 * Wed Aug 11 2021 Chris Caron - 0.9.4-1 - Updated to v0.9.4 * Fri Jul 23 2021 Fedora Release Engineering - 0.9.3-3 - Rebuilt for https://fedoraproject.org/wiki/Fedora_35_Mass_Rebuild * Fri Jun 04 2021 Python Maint - 0.9.3-2 - Rebuilt for Python 3.10 * Sun May 16 2021 Chris Caron - 0.9.3-1 - Updated to v0.9.3 * Sun May 2 2021 Chris Caron - 0.9.2-1 - Updated to v0.9.2 * Tue Feb 23 2021 Chris Caron - 0.9.1-2 - Added missing cryptography dependency * Tue Feb 23 2021 Chris Caron - 0.9.1-1 - Updated to v0.9.1 -* Wed Jan 27 2021 Fedora Release Engineering - 0.9.0-3 -- Rebuilt for https://fedoraproject.org/wiki/Fedora_34_Mass_Rebuild * Thu Jan 14 2021 Chris Caron - 0.9.0-2 - Fixed unit tests * Wed Dec 30 2020 Chris Caron - 0.9.0-1 - Updated to v0.9.0 * Sun Oct 4 2020 Chris Caron - 0.8.9-1 - Updated to v0.8.9 * Wed Sep 2 2020 Chris Caron - 0.8.8-1 - Updated to v0.8.8 * Thu Aug 13 2020 Chris Caron - 0.8.7-1 - Updated to v0.8.7 * Mon Aug 03 2020 Chris Caron - 0.8.6-4 - Updated SPEC so Fedora 33 Mass Rebuild would pass * Sat Aug 01 2020 Fedora Release Engineering - 0.8.6-3 - Second attempt - Rebuilt for https://fedoraproject.org/wiki/Fedora_33_Mass_Rebuild * Tue Jul 28 2020 Fedora Release Engineering - 0.8.6-2 - Rebuilt for https://fedoraproject.org/wiki/Fedora_33_Mass_Rebuild * Sat Jun 13 2020 Chris Caron - 0.8.6-1 - Updated to v0.8.6 * Tue May 26 2020 Miro Hron�ok - 0.8.5-2 - Rebuilt for Python 3.9 * Mon Mar 30 2020 Chris Caron - 0.8.5-1 - Updated to v0.8.5 * Sat Feb 1 2020 Chris Caron - 0.8.4-1 - Updated to v0.8.4 * Sun Jan 12 2020 Chris Caron - 0.8.3-1 - Updated to v0.8.3 * Mon Nov 25 2019 Chris Caron - 0.8.2-1 - Updated to v0.8.2 * Sun Oct 13 2019 Chris Caron - 0.8.1-1 - Updated to v0.8.1 * Fri Sep 20 2019 Chris Caron - 0.8.0-1 - Updated to v0.8.0 * Fri Jul 19 2019 Chris Caron - 0.7.9-1 - Updated to v0.7.9 * Thu Jun 6 2019 Chris Caron - 0.7.8-1 - Updated to v0.7.8 * Fri May 31 2019 Chris Caron - 0.7.7-1 - Updated to v0.7.7 * Tue Apr 16 2019 Chris Caron - 0.7.6-1 - Updated to v0.7.6 * Sun Apr 7 2019 Chris Caron - 0.7.5-1 - Updated to v0.7.5 * Sun Mar 10 2019 Chris Caron - 0.7.4-1 - Updated to v0.7.4 - Fedora review process added a man page, spec restructuring and 2 patch files to accommodate some valid points brought forth. These have already been pushed upstream and will be removed on the next version. * Fri Feb 22 2019 Chris Caron - 0.7.3-1 - Updated to v0.7.3 - Added Python 3 build support * Sun Sep 9 2018 Chris Caron - 0.5.0-1 - Updated to v0.5.0 * Sun Mar 11 2018 Chris Caron - 0.0.8-1 - Initial Release caronc-apprise-dfec414/packaging/verify_translations.py000066400000000000000000000103771526720371000235320ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Check that every built wheel carries each compiled translation catalog. Run it from the repository root after building: python packaging/verify_translations.py - Apprise only reads compiled ``.mo`` files. Without them, every language falls back to English. - Every ``.po`` file in the source tree needs a matching ``.mo`` in each wheel in ``dist/``. - Anything missing is listed and the script exits with an error. """ from __future__ import annotations import argparse from glob import glob from os.path import abspath, basename, dirname, join import sys from typing import Optional import zipfile # The repository root, one level up from this script ROOT_DIR = abspath(join(dirname(__file__), "..")) # Where our translations live in the source tree I18N_DIR = join(ROOT_DIR, "apprise", "i18n") def expected_catalogs() -> list[str]: """The wheel path of each compiled catalog a release must carry.""" # One per language with a catalog in the source tree catalogs = [] for po_path in sorted(glob(join(I18N_DIR, "*", "LC_MESSAGES", "*.po"))): # e.g. apprise/i18n/fr/LC_MESSAGES/apprise.mo language = basename(dirname(dirname(po_path))) name = basename(po_path)[:-3] + ".mo" catalogs.append(f"apprise/i18n/{language}/LC_MESSAGES/{name}") return catalogs def missing_catalogs(wheel_path: str, expected: list[str]) -> list[str]: """The expected compiled catalogs that ``wheel_path`` does not carry.""" # Read the wheel's file listing with zipfile.ZipFile(wheel_path) as wheel: present = set(wheel.namelist()) return [catalog for catalog in expected if catalog not in present] def main(argv: Optional[list[str]] = None) -> int: """Checks every wheel in the dist folder and reports the outcome.""" # Prepare our arguments parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument( "dist", nargs="?", default=join(ROOT_DIR, "dist"), help="folder holding the built wheels (default: dist)", ) args = parser.parse_args(argv) # There is nothing to check without a wheel wheels = sorted(glob(join(args.dist, "*.whl"))) if not wheels: print(f"No wheel found in {args.dist}") return 1 # Every wheel must carry every language expected = expected_catalogs() failed = False for wheel_path in wheels: missing = missing_catalogs(wheel_path, expected) if missing: failed = True name = basename(wheel_path) print(f"{name} is missing {len(missing)} catalogs:") for catalog in missing: print(f" - {catalog}") else: print( f"{basename(wheel_path)} carries all " f"{len(expected)} translation catalogs" ) return 1 if failed else 0 if __name__ == "__main__": sys.exit(main()) caronc-apprise-dfec414/pyproject.toml000066400000000000000000000227251526720371000200430ustar00rootroot00000000000000[build-system] requires = [ "setuptools>=69", "wheel", "babel", ] build-backend = "setuptools.build_meta" [project] name = "apprise" description = "Push Notifications that work with just about every platform!" readme = "README.md" authors = [ { name = "Chris Caron", email = "lead2gold@gmail.com" }, ] dynamic = ["version"] # Not supported yet for all Distributions license = { text = "BSD-2-Clause" } requires-python = ">=3.9" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "Operating System :: OS Independent", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Libraries :: Application Frameworks", ] dependencies = [ # Application dependencies "requests", "urllib3", "requests-oauthlib", "click>=5.0", "markdown", "PyYAML", # Root certificate authority bundle "certifi", # Always ship IANA tzdb on Windows so ZoneInfo works "tzdata; platform_system == 'Windows'", ] # Identifies all of the supported plugins keywords = [ "46elks", "800.com", "Africas Talking", "Alerts", "AMQP", "Apprise API", "Automated Packet Reporting System", "AWS", "Bark", "Better Stack", "Blink(1)", "BlueSky", "Brevo", "BulkSMS", "BulkVS", "Burst SMS", "Chanify", "Chat", "CLI", "Clickatell", "ClickSend", "D7Networks", "Dapnet", "DBus", "Delta Chat", "DingTalk", "Discord", "Dot", "Email", "Emby", "Enigma2", "Evolution API", "Exotel", "FCM", "Feishu", "Flock", "Flowtriq", "Fluxer", "Form", "Free Mobile", "GitHub", "Gnome", "GoAlert", "Google Chat", "Gorush", "Gotify", "GroupMe", "Growl", "Guilded", "Home Assistant", "httpSMS", "HumHub", "IFTTT", "ilert", "incident.io", "IRC", "Jellyfin", "Jira", "Join", "JSON", "Kavenegar", "KODI", "Kook", "Kumulos", "LaMetric", "Lark", "Lauther", "LFG Alarm", "Line", "MacOSX", "Mailgun", "MailerSend", "Mastodon", "Matrix", "Mattermost", "MessageBird", "Microsoft", "MobileMessage", "Misskey", "MQTT", "MSG91", "MSTeams", "Nextcloud", "NextcloudTalk", "Notica", "Notifiarr", "Notifico", "Notifyre", "Ntfy", "Office365", "OneBot", "OneSignal", "Opsgenie", "PagerDuty", "PagerTree", "ParsePlatform", "Pinglet", "Pingram", "Plivo", "Postmark", "Power Automate", "Prowl", "Push Notifications", "PushBullet", "PushDeer", "Pushed", "Pushjet", "PushMe", "Pushover", "Pushplus", "PushSafer", "PushWard", "Pushy", "QQ Push", "Quote/0", "Reddit", "Resend", "Revolt", "RingCentral", "Rocket.Chat", "RSyslog", "Ryver", "SendGrid", "SendPulse", "ServerChan", "SerwerSMS", "Session Open Group Server", "SES", "Seven", "SFR", "Signal", "Signalgrid", "SIGNL4", "Simplepush", "Sinch", "Slack", "SMPP", "SMS Manager", "SMSC", "SMSEagle", "SMTP2Go", "SNS", "SparkPost", "Spike", "Splunk", "SpugPush", "Squadcast", "Stackfield", "Streamlabs", "Stride", "Synology Chat", "Syslog", "Techulus", "Telegram", "Telnyx", "Threema Gateway", "Trigv", "Twilio", "Twist", "Twitter", "Vapid", "Viber", "VictorOps", "Voipms", "Vonage", "Webex", "Webpush", "WeChat (WeCom)", "WeCom Bot", "WhatsApp", "Windows", "Workflows", "WPUSH", "WxPusher", "XBMC", "XML", "XMPP", "YouLMK", "Zalo", "Zoho Cliq", "Zoom", "Zulip", ] [project.optional-dependencies] # All packages required to test/build against dev = [ "coverage", "mock", "tox", "pytest", "pytest-cov", "pytest-mock", "pytest-xdist", "ruff==0.15.8", "babel", "validate-pyproject", ] # Defines all Python libraries for Apprise to work with # all plugins all-plugins = [ # Used in many applications requiring cryptography # such as fcm:// and much more "cryptography", # Provides encrypted spush:// sends "PyNaCl", # Provides amqp:// support for AMQP 0.9.1 "pika", # AMQP 1.0 requires a source build on Linux. Install it separately with: # pip install python-qpid-proton # "python-qpid-proton", # provides growl:// support "gntp", # Provides mqtt:// support "paho-mqtt >= 2.1.0", # Enables PGP for mailto:// and deltachat:// "PGPy", # Provides blink1:// support "hidapi", # Provides smpp:// support "smpplib", # For xmpp:// support (slixmpp 1.16.0+ needs Python 3.11 or newer) "slixmpp >= 1.16.0; python_version >= '3.11'", ] windows = [ "pywin32", "tzdata", ] [dependency-groups] # Development environment for uv; "uv sync" installs this group by default. # Dependency groups are never published, so PyPI users are unaffected. dev = [ "apprise[dev,all-plugins]", ] [project.urls] Homepage = "https://appriseit.com" Source = "https://github.com/caronc/apprise" Tracker = "https://github.com/caronc/apprise/issues" Documentation = "https://appriseit.com" [project.scripts] apprise = "apprise.cli:main" [tool.setuptools] include-package-data = true [tool.setuptools.package-data] apprise = [ "assets/NotifyXML-*.xsd", "assets/themes/default/*.png", "assets/themes/default/*.ico", "i18n/*.py", "i18n/*/LC_MESSAGES/*.mo", "py.typed", "*.pyi", "*/*.pyi", ] [tool.setuptools.packages.find] where = [ ".", ] exclude = [ "tests*", "tools*", ] include = ["apprise*"] [tool.setuptools.dynamic] version = {attr = "apprise.__version__"} [tool.ruff] line-length = 79 # Respecting BSD-style 79-char limit target-version = "py39" exclude = [ "tests/data", "bin", "build", "dist", ".eggs", ".tox", ".local", ".venv", "venv", ] [tool.ruff.lint] select = [ "E", # pycodestyle errors "F", # pyflakes "W", # pycodestyle warnings "Q", # Quote handling ' -> " "I", # isort "UP", # pyupgrade "B", # flake8-bugbear "C4", # flake8-comprehensions "SIM", # flake8-simplify "T20", # flake8-print (catches stray `print`) "RUF", # Ruff-native rules ] ignore = [ "D100", # missing docstring in public module "D104", # missing docstring in public package "B008", # do not call `dict()` with keyword args "E741", # Ambiguous variable name (e.g., l, O, I) "W605", # Invalid escape sequence "B026", # Star-arg play a big part of Apprise; must be accepted for now # The following needs to be supported at some point and is of great value "RUF012", # typing.ClassVar implimentation "RUF029", # Use of async without await or async subcalls "UP032", # This is a massive undertaking and requires a massive rewrite # of test cases; this will take a while to fix, so turning off # for now ] [tool.ruff.lint.per-file-ignores] # Our translation report is a command line tool; printing is its whole job "packaging/i18n_status.py" = ["T201"] "packaging/verify_translations.py" = ["T201"] [tool.ruff.lint.pyupgrade] keep-runtime-typing = true [tool.ruff.lint.flake8-quotes] inline-quotes = "double" [tool.ruff.lint.isort] known-first-party = ["apprise"] force-sort-within-sections = true combine-as-imports = true order-by-type = true section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] [tool.ruff.lint.flake8-builtins] builtins-ignorelist = ["_"] [tool.pytest.ini_options] # Keep pytest-cov minimal, let Coverage.py control formatting. addopts = "-ra" testpaths = ["tests"] python_files = ["test_*.py", "tests/test_*.py"] filterwarnings = [ "once::Warning", # Ignore warnings from PGPy's dependencies and unfinished checks, which # Apprise cannot resolve. "ignore::Warning:pgpy(\\.|$)", # PGPy reports this internal encryption warning from Apprise's call site. "ignore:^Selected compression algorithm not in key preferences$:UserWarning", ] [tool.coverage.run] branch = true parallel = false data_file = ".coverage" source = ["apprise"] relative_files = true [tool.coverage.paths] # Normalise .tox and site-packages layouts back to 'apprise' source = [ "apprise", "/apprise/apprise", ".tox/*/lib/python*/site-packages/apprise", ".tox/pypy/site-packages/apprise" ] [tool.coverage.report] # Controls terminal and XML content when Coverage.py renders reports show_missing = true skip_covered = true skip_empty = true precision = 1 exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "if __name__ == .__main__.:", "raise NotImplementedError", "pass$", ] [tool.coverage.xml] # Ensure GitHub Action can find a predictable file output = "coverage.xml" caronc-apprise-dfec414/requirements.txt000066400000000000000000000005641526720371000204100ustar00rootroot00000000000000# # Note: This file is being kept for backwards compatibility with # legacy systems that point here. All future changes should # occur in pyproject.toml. Contents of this file can be found # in project.dependencies # Root certificate authority bundle certifi # Application dependencies requests urllib3 requests-oauthlib click >= 5.0 markdown PyYAML caronc-apprise-dfec414/setup.py000077500000000000000000000074541526720371000166460ustar00rootroot00000000000000#!/usr/bin/env python # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # 2025.07.10 NOTE: # setup.py (Temporary shim for RHEL9 Package Building Only) # Refer to tox for everything else; this will be removed once RHEL9 support # is dropped. # import glob import os import re from setuptools import find_packages, setup from setuptools.command.build_py import build_py class BuildPyWithTranslations(build_py): """Compile the translation catalogs before building the package. The ``.mo`` files are not kept in git, so without this a build from a git checkout ships no translations. """ def run(self) -> None: # Babel is already a build requirement (see pyproject.toml) from babel.messages.mofile import write_mo from babel.messages.pofile import read_po # Compile each language's catalog next to its source pattern = os.path.join("apprise", "i18n", "*", "LC_MESSAGES", "*.po") for po_path in glob.glob(pattern): # Read our human-editable catalog with open(po_path, "rb") as po_file: catalog = read_po(po_file) # Write the compiled copy that gettext actually reads with open(po_path[:-3] + ".mo", "wb") as mo_file: write_mo(mo_file, catalog) # Now build as normal; package_data picks up the .mo files super().run() def read_version() -> str: with open(os.path.join("apprise", "__init__.py"), encoding="utf-8") as f: for line in f: m = re.match(r'^__version__\s*=\s*[\'"]([^\'"]+)', line) if m: return m.group(1) raise RuntimeError("Version not found") # tox is not supported well in RHEL9 so this stub file is the only way to # successfully build the RPM; packaging/redhat/python-apprise.spec has # been updated accordingly to accommodate reference to this for older # versions of the distribution only setup( name="apprise", cmdclass={"build_py": BuildPyWithTranslations}, version=read_version(), packages=find_packages(exclude=["tests*", "packaging*"]), entry_points={ "console_scripts": [ "apprise = apprise.cli:main", ], }, package_data={ "apprise": [ "assets/NotifyXML-*.xsd", "assets/themes/default/*.png", "assets/themes/default/*.ico", "i18n/*.py", "i18n/*/LC_MESSAGES/*.mo", "py.typed", "*.pyi", "*/*.pyi", ], }, ) caronc-apprise-dfec414/tests/000077500000000000000000000000001526720371000162615ustar00rootroot00000000000000caronc-apprise-dfec414/tests/conftest.py000066400000000000000000000215761526720371000204730ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import concurrent.futures as cf import contextlib import gc import logging import mimetypes import os import sys from types import ModuleType import warnings import pytest from apprise import ( AttachmentManager, ConfigurationManager, NotificationManager, ) import apprise.apprise as apprise_module from apprise.logger import logger as apprise_logger from apprise.utils.singleton import Singleton sys.path.append(os.path.join(os.path.dirname(__file__), "helpers")) # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() # Grant access to our Config Manager Singleton C_MGR = ConfigurationManager() # Grant access to our Attachment Manager Singleton A_MGR = AttachmentManager() # Packages whose module globals are restored after each test RESTORED_PACKAGES = ("apprise", "helpers") def is_restored_module(name): """True for Apprise, test helper and test file modules.""" # Match the top-level package exactly so apprise_extra is left alone. top = name.partition(".")[0] return top in RESTORED_PACKAGES or top.startswith("test_") def restore_module_globals(snapshot): """Restore module globals changed by reload(). This keeps class identities stable between tests on the same worker. """ # Restore replaced globals, but keep module references in sync with # sys.modules. changed = False for current, saved in snapshot.values(): for key, value in saved.items(): if current.get(key) is not value and not isinstance( value, ModuleType ): current[key] = value changed = True if not changed: # No reload cleanup is needed. return # A module global that no longer matches sys.modules still points at a # fake or replaced module, such as a mocked gi. Put back what was there. for current, saved in snapshot.values(): for key, value in current.copy().items(): if ( not isinstance(value, ModuleType) or sys.modules.get(value.__name__) is value ): continue if key in saved: current[key] = saved[key] else: current.pop(key, None) # A reload builds a new manager class, and its singleton instance would # keep the old plugin modules alive. Keep only the current classes. for cls in list(Singleton._instances): module = sys.modules.get(cls.__module__) if getattr(module, cls.__name__, None) is not cls: Singleton._instances.pop(cls, None) # Drop modules created from reloaded classes so they are rebuilt later. # Copy the names first since a background thread may still import. for name in [ name for name in sys.modules.copy() if is_restored_module(name) and name not in snapshot ]: module = sys.modules.pop(name, None) if module is None: # Another thread already removed it. continue # Also remove the reference held by its parent package. Another # thread may remove it first, which is fine. parent, _, attr = name.rpartition(".") parent_module = sys.modules.get(parent) if getattr(parent_module, attr, None) is module: with contextlib.suppress(AttributeError): delattr(parent_module, attr) @pytest.hookimpl(hookwrapper=True) def pytest_runtest_setup(item): """Remember shared state before any fixture of the test runs.""" # Save the globals from project and test modules. copy() takes the # module list in one step, so a cleanup that imports cannot change it # part way through. item._apprise_modules = { name: (module.__dict__, dict(module.__dict__)) for name, module in sys.modules.copy().items() if is_restored_module(name) and module is not None } # Calls left by earlier tests were already waited on and reported. with apprise_module._abandoned_futures_lock: item._apprise_earlier_calls = { future for future, _, _ in apprise_module._abandoned_futures } yield @pytest.hookimpl(hookwrapper=True) def pytest_runtest_teardown(item, nextitem): """Clean up shared state around the test's own fixture teardown.""" # Timed-out calls keep running after notify(), so wait for this test's # while its mocks and patches are still in place. # Take the saved state off the item, since pytest keeps every item for # the whole run and the copies would otherwise stay in memory. earlier = item.__dict__.pop("_apprise_earlier_calls", set()) snapshot = item.__dict__.pop("_apprise_modules", None) with apprise_module._abandoned_futures_lock: running = [ future for future, _, _ in apprise_module._abandoned_futures if future not in earlier ] # Keep calls that exceed the timeout tracked as leaks, reported once. _, still_running = cf.wait(running, timeout=10) # Let every fixture, monkeypatch and mock undo itself first. yield # sys.modules is back to normal now, so module globals can be checked # against it. if snapshot: restore_module_globals(snapshot) # Warn last, so a warning treated as an error cannot skip the cleanup. if still_running: warnings.warn( f"{len(still_running)} background service call(s) still " "running 10s after the test finished.", ResourceWarning, stacklevel=1, ) @pytest.fixture(scope="function", autouse=True) def mimetypes_always_available(): """Use the test MIME database for every test.""" files = (os.path.join(os.path.dirname(__file__), "var", "mime.types"),) mimetypes.init(files=files) @pytest.fixture(scope="function", autouse=True) def no_throttling_everywhere(mocker): """Disable plugin throttling for every test. Function-scoped cleanup prevents patches from accumulating across the suite and slowing final teardown. """ # Ensure we're working with a clean slate for each test N_MGR.unload_modules() C_MGR.unload_modules() A_MGR.unload_modules() for plugin in N_MGR.plugins(): mocker.patch.object(plugin, "request_rate_per_sec", 0) @pytest.fixture(scope="function", autouse=True) def _reset_apprise_logger_state(): """Reset shared logger state around every test. CLI tests change the Apprise and asyncio loggers. Restoring their levels and handlers prevents those changes from affecting later log assertions. """ asyncio_logger = logging.getLogger("asyncio") original_level = apprise_logger.level original_handlers = list(apprise_logger.handlers) original_asyncio_level = asyncio_logger.level original_asyncio_handlers = list(asyncio_logger.handlers) # Many tests switch logging back on with logging.disable(NOTSET). Put # the global level back afterwards so the next test is not affected. original_disable = logging.root.manager.disable apprise_logger.setLevel(logging.NOTSET) asyncio_logger.setLevel(logging.NOTSET) asyncio_logger.handlers[:] = [] yield apprise_logger.setLevel(original_level) apprise_logger.handlers[:] = original_handlers asyncio_logger.setLevel(original_asyncio_level) asyncio_logger.handlers[:] = original_asyncio_handlers logging.disable(original_disable) @pytest.fixture(scope="function", autouse=True) def collect_all_garbage(): """Collect garbage after each test to isolate plugin finalizers.""" # Force garbage collection gc.collect() caronc-apprise-dfec414/tests/docker/000077500000000000000000000000001526720371000175305ustar00rootroot00000000000000caronc-apprise-dfec414/tests/docker/Dockerfile.el10000066400000000000000000000053461526720371000222720ustar00rootroot00000000000000# -*- coding: utf-8 -*- # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Base FROM rockylinux/rockylinux:10 ENV container=docker # Basic build tooling and RPM stack RUN \ rm -f /lib/systemd/system/multi-user.target.wants/*;\ rm -f /etc/systemd/system/*.wants/*;\ rm -f /lib/systemd/system/local-fs.target.wants/*; \ rm -f /lib/systemd/system/sockets.target.wants/*udev*; \ rm -f /lib/systemd/system/sockets.target.wants/*initctl*; \ rm -f /lib/systemd/system/basic.target.wants/*;\ rm -f /lib/systemd/system/anaconda.target.wants/*; \ echo "assumeyes=1" >> /etc/yum.conf; \ dnf -y update && \ dnf install -y epel-release; \ dnf install -y rpm-build python3-pip \ dnf-plugins-core 'dnf-command(config-manager)' \ 'dnf-command(builddep)' sudo rsync rpmdevtools && \ dnf config-manager --set-enabled crb && \ dnf -y install pyproject-rpm-macros rpmlint rubygem-ronn-ng && \ dnf clean all # Place our build file into the path COPY packaging/redhat/python-apprise.spec / RUN rpmspec -q --buildrequires /python-apprise.spec | cut -f1 -d' ' | \ xargs dnf install -y && dnf clean all # RPM build structure setup ENV FLAVOR=rpmbuild OS=centos DIST=el10 RUN useradd builder -u 1000 -m -G users,wheel &>/dev/null && \ echo "builder ALL=(ALL:ALL) NOPASSWD:ALL" >> /etc/sudoers VOLUME ["/apprise"] WORKDIR /apprise # RPMs should never be built as root USER builder caronc-apprise-dfec414/tests/docker/Dockerfile.el9000066400000000000000000000052321526720371000222140ustar00rootroot00000000000000# -*- coding: utf-8 -*- # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Base FROM rockylinux/rockylinux:9 ENV container=docker # Basic build tooling and RPM stack RUN \ rm -f /lib/systemd/system/multi-user.target.wants/*;\ rm -f /etc/systemd/system/*.wants/*;\ rm -f /lib/systemd/system/local-fs.target.wants/*; \ rm -f /lib/systemd/system/sockets.target.wants/*udev*; \ rm -f /lib/systemd/system/sockets.target.wants/*initctl*; \ rm -f /lib/systemd/system/basic.target.wants/*;\ rm -f /lib/systemd/system/anaconda.target.wants/*; \ echo "assumeyes=1" >> /etc/yum.conf; \ dnf -y update && \ dnf install -y epel-release; \ dnf install -y rpm-build rpmlint python3-pip rubygem-ronn \ dnf-plugins-core 'dnf-command(config-manager)' \ 'dnf-command(builddep)' sudo rsync rpmdevtools; \ dnf config-manager --set-enabled crb # Place our build file into the path COPY packaging/redhat/python-apprise.spec / RUN rpmspec -q --buildrequires /python-apprise.spec | cut -f1 -d' ' | \ xargs dnf install -y && dnf clean all # RPM Build Structure Setup ENV FLAVOR=rpmbuild OS=centos DIST=el9 RUN useradd builder -u 1000 -m -G users,wheel &>/dev/null && \ echo "builder ALL=(ALL:ALL) NOPASSWD:ALL" >> /etc/sudoers VOLUME ["/apprise"] WORKDIR /apprise # RPMs should never be built as root USER builder caronc-apprise-dfec414/tests/docker/Dockerfile.f44000066400000000000000000000060771526720371000221300ustar00rootroot00000000000000# -*- coding: utf-8 -*- # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. ## The following was added to accommodate: # https://bugzilla.redhat.com/show_bug.cgi?id=2216807 # # the switch was added and it didn't work: # dnf update -y --setopt=protected_packages=, # # The second work-around was to add --skip-broken # This also didn't work. the final option was to download the RPMs in # advance and just force them. # # Base FROM fedora:44 ENV container=docker # https://bugzilla.redhat.com/show_bug.cgi?id=2216807 workaround RUN dnf download -y --destdir BZ2216807 --resolve dnf-data && \ rpm -Uhi --force BZ2216807/* RUN \ rm -f /usr/lib/systemd/system/multi-user.target.wants/*;\ rm -f /etc/systemd/system/*.wants/*;\ rm -f /usr/lib/systemd/system/local-fs.target.wants/*; \ rm -f /usr/lib/systemd/system/sockets.target.wants/*udev*; \ rm -f /usr/lib/systemd/system/sockets.target.wants/*initctl*; \ rm -f /usr/lib/systemd/system/basic.target.wants/*;\ rm -f /usr/lib/systemd/system/anaconda.target.wants/*; \ echo "assumeyes=1" >> /etc/dnf/dnf.conf; \ dnf install -y epel-release; \ dnf install -y rpm-build rpmlint python3-pip rubygem-ronn \ dnf-plugins-core 'dnf-command(config-manager)' \ 'dnf-command(builddep)' sudo rsync rpmdevtools # Place our build file into the path COPY packaging/redhat/python-apprise.spec / RUN rpmspec -q --buildrequires /python-apprise.spec | cut -f1 -d' ' | \ xargs dnf install -y && dnf clean all # RPM Build Structure Setup ENV FLAVOR=rpmbuild OS=centos DIST=f44 RUN useradd builder -u 1000 -m -G users,wheel &>/dev/null && \ echo "builder ALL=(ALL:ALL) NOPASSWD:ALL" >> /etc/sudoers VOLUME ["/apprise"] WORKDIR /apprise # RPMs should never be built as root USER builder caronc-apprise-dfec414/tests/docker/Dockerfile.py310000066400000000000000000000041441526720371000224000ustar00rootroot00000000000000# -*- coding: utf-8 -*- # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Base FROM python:3.10-buster RUN apt-get update && \ apt-get install -y --no-install-recommends libdbus-1-dev libgirepository1.0-dev build-essential musl-dev bash dbus && \ rm -rf /var/lib/apt/lists/* RUN pip install --no-cache-dir dbus-python "PyGObject==3.44.2" # Apprise Setup VOLUME ["/apprise"] WORKDIR /apprise COPY requirements.txt / COPY dev-requirements.txt / ENV PYTHONPATH=/apprise ENV PYTHONPYCACHEPREFIX=/apprise/__pycache__/py310 RUN pip install --no-cache-dir -r /requirements.txt -r /dev-requirements.txt RUN addgroup --gid ${USER_GID:-1000} apprise RUN adduser --system --uid ${USER_UID:-1000} --ingroup apprise --home /apprise --no-create-home --disabled-password apprise USER apprise caronc-apprise-dfec414/tests/docker/Dockerfile.py311000066400000000000000000000041441526720371000224010ustar00rootroot00000000000000# -*- coding: utf-8 -*- # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Base FROM python:3.11-buster RUN apt-get update && \ apt-get install -y --no-install-recommends libdbus-1-dev libgirepository1.0-dev build-essential musl-dev bash dbus && \ rm -rf /var/lib/apt/lists/* RUN pip install --no-cache-dir dbus-python "PyGObject==3.44.2" # Apprise Setup VOLUME ["/apprise"] WORKDIR /apprise COPY requirements.txt / COPY dev-requirements.txt / ENV PYTHONPATH=/apprise ENV PYTHONPYCACHEPREFIX=/apprise/__pycache__/py311 RUN pip install --no-cache-dir -r /requirements.txt -r /dev-requirements.txt RUN addgroup --gid ${USER_GID:-1000} apprise RUN adduser --system --uid ${USER_UID:-1000} --ingroup apprise --home /apprise --no-create-home --disabled-password apprise USER apprise caronc-apprise-dfec414/tests/docker/Dockerfile.py312000066400000000000000000000041461526720371000224040ustar00rootroot00000000000000# -*- coding: utf-8 -*- # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Base FROM python:3.12-bookworm RUN apt-get update && \ apt-get install -y --no-install-recommends libdbus-1-dev libgirepository1.0-dev build-essential musl-dev bash dbus && \ rm -rf /var/lib/apt/lists/* RUN pip install --no-cache-dir dbus-python "PyGObject==3.44.2" # Apprise Setup VOLUME ["/apprise"] WORKDIR /apprise COPY requirements.txt / COPY dev-requirements.txt / ENV PYTHONPATH=/apprise ENV PYTHONPYCACHEPREFIX=/apprise/__pycache__/py312 RUN pip install --no-cache-dir -r /requirements.txt -r /dev-requirements.txt RUN addgroup --gid ${USER_GID:-1000} apprise RUN adduser --system --uid ${USER_UID:-1000} --ingroup apprise --home /apprise --no-create-home --disabled-password apprise USER apprise caronc-apprise-dfec414/tests/docker/Dockerfile.py39000066400000000000000000000041441526720371000223300ustar00rootroot00000000000000# -*- coding: utf-8 -*- # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Base FROM python:3.9-bookworm RUN apt-get update && \ apt-get install -y --no-install-recommends libdbus-1-dev libgirepository1.0-dev build-essential musl-dev bash dbus && \ rm -rf /var/lib/apt/lists/* RUN pip install --no-cache-dir dbus-python "PyGObject==3.44.2" # Apprise Setup VOLUME ["/apprise"] WORKDIR /apprise COPY requirements.txt / COPY dev-requirements.txt / ENV PYTHONPATH=/apprise ENV PYTHONPYCACHEPREFIX=/apprise/__pycache__/py39 RUN pip install --no-cache-dir -r /requirements.txt -r /dev-requirements.txt RUN addgroup --gid ${USER_GID:-1000} apprise RUN adduser --system --uid ${USER_UID:-1000} --ingroup apprise --home /apprise --no-create-home --disabled-password apprise USER apprise caronc-apprise-dfec414/tests/docker/Dockerfile.rawhide000066400000000000000000000061101526720371000231420ustar00rootroot00000000000000# -*- coding: utf-8 -*- # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. ## The following was added to accommodate: # https://bugzilla.redhat.com/show_bug.cgi?id=2216807 # # the switch was added and it didn't work: # dnf update -y --setopt=protected_packages=, # # The second work-around was to add --skip-broken # This also didn't work. the final option was to download the RPMs in # advance and just force them. # # Base FROM fedora:rawhide ENV container=docker # https://bugzilla.redhat.com/show_bug.cgi?id=2216807 workaround RUN dnf download -y --destdir BZ2216807 --resolve dnf-data && \ rpm -Uhi --force BZ2216807/* RUN \ rm -f /usr/lib/systemd/system/multi-user.target.wants/*;\ rm -f /etc/systemd/system/*.wants/*;\ rm -f /usr/lib/systemd/system/local-fs.target.wants/*; \ rm -f /usr/lib/systemd/system/sockets.target.wants/*udev*; \ rm -f /usr/lib/systemd/system/sockets.target.wants/*initctl*; \ rm -f /usr/lib/systemd/system/basic.target.wants/*;\ rm -f /usr/lib/systemd/system/anaconda.target.wants/*; \ echo "assumeyes=1" >> /etc/dnf/dnf.conf; \ dnf install -y epel-release; \ dnf install -y rpm-build rpmlint python3-pip rubygem-ronn \ dnf-plugins-core 'dnf-command(config-manager)' \ 'dnf-command(builddep)' sudo rsync rpmdevtools # Place our build file into the path COPY packaging/redhat/python-apprise.spec / RUN rpmspec -q --buildrequires /python-apprise.spec | cut -f1 -d' ' | \ xargs dnf install -y && dnf clean all # RPM Build Structure Setup ENV FLAVOR=rpmbuild OS=centos DIST=rawhide RUN useradd builder -u 1000 -m -G users,wheel &>/dev/null && \ echo "builder ALL=(ALL:ALL) NOPASSWD:ALL" >> /etc/sudoers VOLUME ["/apprise"] WORKDIR /apprise # RPMs should never be built as root USER builder caronc-apprise-dfec414/tests/helpers/000077500000000000000000000000001526720371000177235ustar00rootroot00000000000000caronc-apprise-dfec414/tests/helpers/__init__.py000066400000000000000000000034601526720371000220370ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from .asyncio import OuterEventLoop from .delivery import ( ATTACHMENT, OK_BODY, OK_FIELDS, delivery_marks, ok_response, ) from .environment import environ from .module import reload_plugin from .rest import AppriseURLTester __all__ = [ "ATTACHMENT", "OK_BODY", "OK_FIELDS", "AppriseURLTester", "OuterEventLoop", "delivery_marks", "environ", "ok_response", "reload_plugin", ] caronc-apprise-dfec414/tests/helpers/asyncio.py000066400000000000000000000034741526720371000217520ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import asyncio class OuterEventLoop: """An event loop that is easy to put up and tear down from synchronous test code.""" def __init__(self): self._loop = None def __enter__(self): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) self._loop = loop return loop def __exit__(self, type, value, traceback): asyncio.set_event_loop(None) self._loop.close() caronc-apprise-dfec414/tests/helpers/delivery.py000066400000000000000000000076411526720371000221300ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Shared responses, attachments, and recorders for delivery tests.""" import contextlib from json import dumps import os from unittest import mock import requests from apprise.plugins.base import NotifyBase # Attachment Directory TEST_VAR_DIR = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "var" ) ATTACHMENT = os.path.join(TEST_VAR_DIR, "apprise-test.gif") # Fields a service might look for in a good answer. It is deliberately # wide: one answer has to satisfy every service in the fleet, and a field # a service does not know about is simply ignored. OK_FIELDS = { "ok": True, "success": True, "status": "success", "id": "1", "uuid": "1", "sid": "1", "requestId": "1", "code": 0, "errcode": 0, "error": None, "channel": "C1", "access_token": "abc", "expires_in": 3600, "message_id": 1, "upload_url": "https://localhost/upload", "file_id": "F1", "file_name": "apprise-test.gif", "file_type": "image/gif", "file_url": "https://localhost/x", "media_id": 1, "media_id_string": "1", "content_uri": "mxc://localhost/1", "event_id": "e1", "room_id": "!r:localhost", "user_id": "@u:localhost", "url": "https://cdn.example/x", "post": {"id": 1}, "data": {"url": "https://cdn.example/x", "id": "1"}, "json": {"errors": []}, "result": {"message_id": 1, "id": "1", "status": "ok"}, "items": [{"id": "1"}], "messages": [{"status": "0", "message-id": "1"}], } # The same answer as a JSON document OK_BODY = dumps(OK_FIELDS) def ok_response(*args, **kwargs): """Return a response a service will treat as a success.""" response = requests.Request() response.status_code = requests.codes.ok response.content = OK_BODY response.text = OK_BODY response.headers = {"Content-Type": "application/json"} def iter_content(chunk_size=None): # A bounded read pulls the body in chunks, the way requests does return iter([OK_BODY.encode("utf-8")]) def close(): # Nothing to release on a stand-in pass response.iter_content = iter_content response.close = close return response @contextlib.contextmanager def delivery_marks(): """Collect every target a service records while the block runs.""" recorded = [] real = NotifyBase.mark_delivered def counted(self, key, per_message=False): recorded.append(key) return real(self, key, per_message) with mock.patch.object(NotifyBase, "mark_delivered", counted): yield recorded caronc-apprise-dfec414/tests/helpers/environment.py000066400000000000000000000047351526720371000226520ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import contextlib import locale # Disable logging for a cleaner testing output import logging import os logging.disable(logging.CRITICAL) @contextlib.contextmanager def environ(*remove, **update): """Temporarily updates the ``os.environ`` dictionary in-place. The ``os.environ`` dictionary is updated in-place so that the modification is sure to work in all situations. :param remove: Environment variable(s) to remove. :param update: Dictionary of environment variables and values to add/update. """ env_orig = os.environ.copy() # Query every category at once. getlocale() is mocked by some tests, and # feeding its stand-in value back would leave the whole process in the C # locale once this block exits. loc_orig = locale.setlocale(locale.LC_ALL) try: os.environ.update(update) for k in remove: os.environ.pop(k, None) yield finally: os.environ.clear() os.environ.update(env_orig) with contextlib.suppress(locale.Error): locale.setlocale(locale.LC_ALL, loc_orig) caronc-apprise-dfec414/tests/helpers/module.py000066400000000000000000000237531526720371000215740ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from importlib import import_module, reload from itertools import chain import re import sys from apprise import ( AttachmentManager, ConfigurationManager, NotificationManager, ) # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() # Grant access to our Attachment Manager Singleton A_MGR = AttachmentManager() # Grant access to our Configuration Manager Singleton C_MGR = ConfigurationManager() # For filtering our result when scanning a module # Identify any items below we should match on that we can freely # directly copy around between our modules. This should only # catch class/function/variables we want to allow explicity # copy/paste access with module_filter_re = re.compile( r"^(?P(Notify|Config|Attach)[A-Za-z0-9]+)$" ) def reload_plugin(name): """Reload built-in plugin module, e.g. `NotifyGnome`. Reloading plugin modules is needed when testing module-level code of notification plugins. See also https://stackoverflow.com/questions/31363311. """ A_MGR.unload_modules() reload(sys.modules["apprise.apprise_attachment"]) reload(sys.modules["apprise.attachment.base"]) new_apprise_attachment_mod = import_module("apprise.apprise_attachment") new_apprise_attach_base_mod = import_module("apprise.attachment.base") reload(sys.modules["apprise.manager_attachment"]) module_pyname = f"{N_MGR.module_name_prefix}.{name}" if module_pyname in sys.modules: reload(sys.modules[module_pyname]) new_notify_mod = import_module(module_pyname) A_MGR.unload_modules() reload(sys.modules["apprise.apprise_config"]) reload(sys.modules["apprise.config.base"]) new_apprise_configuration_mod = import_module("apprise.apprise_config") new_apprise_config_base_mod = import_module("apprise.config.base") reload(sys.modules["apprise.manager_config"]) C_MGR.unload_modules() module_pyname = f"{N_MGR.module_name_prefix}.{name}" if module_pyname in sys.modules: reload(sys.modules[module_pyname]) new_notify_mod = import_module(module_pyname) # Detect our class object class_matches = {} for class_name in [ obj for obj in dir(new_notify_mod) if module_filter_re.match(obj) ]: # Store our entry class_matches[class_name] = getattr(new_notify_mod, class_name) # the user running the tests did not correctly use reload_plugin() or # they did, but libraries around them have shifted. We need to error out # so the test can be fixed if not class_matches: raise AttributeError(f"Module {name} has no URLBase defined in it!") reload(sys.modules["apprise.manager_plugins"]) reload(sys.modules["apprise.apprise"]) reload(sys.modules["apprise.utils"]) reload(sys.modules["apprise.locale"]) reload(sys.modules["apprise"]) # Acquire all of the test files we have tests = [k for k in sys.modules if re.match(r"^test_.+$", k)] # Iterate over all of our test modules for module_name in tests: # Filter the test files by only those using the class_name we found # within our module possible_matches = [ m for m in dir(sys.modules[module_name]) if re.match( "^(?P{})$".format("|".join(class_matches.keys())), m ) ] if not possible_matches: continue # if we get here, we have test_ files that utilize the Class we just # reloaded # Fix reference to new plugin class in given module. # Needed for updating the module-level import reference like # `from apprise.plugins.NotifyABCDE import NotifyABCDE`. # # We reload NotifyABCDE and place it back in its spot test_mod = import_module(module_name) for class_name, class_plugin in class_matches.items(): if hasattr(test_mod, class_name): setattr(test_mod, class_name, class_plugin) # # Detect our Apprise Modules (include helpers) # apprise_modules = sorted( [k for k in sys.modules if re.match(r"^(apprise|helpers)(\.|.+)$", k)], reverse=True, ) # # This section below reloads our attachment classes # for entry in A_MGR: reload(sys.modules[entry["path"]]) for module_pyname in chain(apprise_modules, tests): detect = re.compile( r"^(?P(AppriseAttachment|AttachBase|" + entry["path"].split(".")[-1] + r"))$" ) possible_matches = [ m for m in dir(sys.modules[module_pyname]) if detect.match(m) ] if not possible_matches: continue apprise_mod = import_module(module_pyname) # Fix reference to new plugin class in given module. # Needed for updating the module-level import reference # like `from apprise. import AttachABCDE`. # # We reload NotifyABCDE and place it back in its spot # new_attach = import_module(entry['path']) for name in possible_matches: if name == "AppriseAttachment": setattr( apprise_mod, name, getattr(new_apprise_attachment_mod, name), ) elif name == "AttachBase": setattr( apprise_mod, name, getattr(new_apprise_attach_base_mod, name), ) else: module_pyname = f"{A_MGR.module_name_prefix}.{name}" new_attach_mod = import_module(module_pyname) # Detect our class object class_matches = {} for class_name in [ obj for obj in dir(new_attach_mod) if module_filter_re.match(obj) ]: # Store our entry class_matches[class_name] = getattr( new_attach_mod, class_name ) for class_name, class_plugin in class_matches.items(): if hasattr(apprise_mod, class_name): setattr(apprise_mod, class_name, class_plugin) # # This section below reloads our configuration classes # for entry in C_MGR: reload(sys.modules[entry["path"]]) for module_pyname in chain(apprise_modules, tests): detect = re.compile( r"^(?P(AppriseConfig|ConfigBase|" + entry["path"].split(".")[-1] + r"))$" ) possible_matches = [ m for m in dir(sys.modules[module_pyname]) if detect.match(m) ] if not possible_matches: continue apprise_mod = import_module(module_pyname) # Fix reference to new plugin class in given module. # Needed for updating the module-level import reference # like `from apprise. import ConfigABCDE`. # # We reload NotifyABCDE and place it back in its spot # new_attach = import_module(entry['path']) for name in possible_matches: if name == "AppriseConfig": setattr( apprise_mod, name, getattr(new_apprise_configuration_mod, name), ) elif name == "ConfigBase": setattr( apprise_mod, name, getattr(new_apprise_config_base_mod, name), ) else: module_pyname = f"{A_MGR.module_name_prefix}.{name}" new_config_mod = import_module(module_pyname) # Detect our class object class_matches = {} for class_name in [ obj for obj in dir(new_config_mod) if module_filter_re.match(obj) ]: # Store our entry class_matches[class_name] = getattr( new_config_mod, class_name ) for class_name, class_plugin in class_matches.items(): if hasattr(apprise_mod, class_name): setattr(apprise_mod, class_name, class_plugin) caronc-apprise-dfec414/tests/helpers/rest.py000066400000000000000000000675001526720371000212620ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging import os from random import choice import re from string import ascii_uppercase as str_alpha, digits as str_num from unittest import mock import requests from apprise import ( Apprise, AppriseAsset, AppriseAttachment, NotifyBase, NotifyType, PersistentStoreMode, ) from apprise.common import OverflowMode logging.disable(logging.CRITICAL) class AppriseURLTester: # Some exception handling we'll use req_exceptions = ( requests.ConnectionError(0, "requests.ConnectionError() not handled"), requests.RequestException( 0, "requests.RequestException() not handled" ), requests.HTTPError(0, "requests.HTTPError() not handled"), requests.ReadTimeout(0, "requests.ReadTimeout() not handled"), requests.TooManyRedirects( 0, "requests.TooManyRedirects() not handled" ), ) # Attachment Testing Directory __test_var_dir = os.path.join( os.path.dirname(os.path.dirname(__file__)), "var" ) # Our URLs we'll test against __tests = [] # Define how many characters exist per line row = 80 # Some variables we use to control the data we work with body_len = 1024 title_len = 1024 def __init__(self, tests=None, *args, **kwargs): """Our initialization.""" # Create a large body and title with random data self.body = "".join( choice(str_alpha + str_num + " ") for _ in range(self.body_len) ) self.body = "\r\n".join( [ self.body[i : i + self.row] for i in range(0, len(self.body), self.row) ] ) # Create our title using random data self.title = "".join( choice(str_alpha + str_num) for _ in range(self.title_len) ) if tests: self.__tests = tests @staticmethod def __iter_content(robj): """Build ``iter_content()`` for a mocked response. The configured body is returned as one chunk for streaming plugins. """ def _iter_content(chunk_size=None, *args, **kwargs): content = robj.content or b"" if isinstance(content, str): content = content.encode("utf-8") return iter([content]) if content else iter([]) return _iter_content def add(self, url, meta): """Adds a test suite to our object.""" self.__tests.append( { "url": url, "meta": meta, } ) def run_all(self, tmpdir=None): """Run all of our tests.""" # iterate over our dictionary and test it out for url, meta in self.__tests: self.run(url, meta, tmpdir) @mock.patch("requests.get") @mock.patch("requests.post") @mock.patch("requests.request") def run(self, url, meta, tmpdir, mock_request, mock_post, mock_get): """Run a specific test.""" if meta is False: # Prepare a default structure to make life easy meta = { "instance": TypeError, } # Our expected instance instance = meta.get("instance") # Our expected server objects self_ = meta.get("self") # Our expected privacy url # Don't set this if don't need to check it's value privacy_url = meta.get("privacy_url") # Our regular expression url_matches = meta.get("url_matches") # Detect our storage path (used to set persistent storage # mode storage_path = ( tmpdir if tmpdir and isinstance(tmpdir, str) and os.path.isdir(tmpdir) else None ) # Our storage mode to set storage_mode = meta.get( "storage_mode", ( PersistentStoreMode.MEMORY if not storage_path else PersistentStoreMode.AUTO ), ) # Debug Mode pdb = meta.get("pdb", False) # Whether or not we should include an image with our request; unless # otherwise specified, we assume that images are to be included include_image = meta.get("include_image", True) if include_image: # a default asset asset = AppriseAsset( storage_mode=storage_mode, storage_path=storage_path, ) else: # Disable images asset = AppriseAsset( image_path_mask=False, image_url_mask=False, storage_mode=storage_mode, storage_path=storage_path, ) asset.image_url_logo = None # Mock our request object robj = mock.Mock() robj.content = "" robj.iter_content = self.__iter_content(robj) mock_get.return_value = robj mock_post.return_value = robj mock_request.return_value = robj if pdb: # Makes it easier to debug with this peice of code # just add `pdb': True to the call that is failing import pdb pdb.set_trace() try: # We can now instantiate our object: obj = Apprise.instantiate( url, asset=asset, suppress_exceptions=False ) except Exception as e: # Handle our exception if instance is None: raise e if not isinstance(e, instance): raise e # We're okay if we get here return if obj is None: if instance is not None: # We're done (assuming this is what we were # expecting) raise AssertionError() # We're done because we got the results we expected return if instance is None: # Expected None but didn't get it raise AssertionError() if not isinstance(obj, instance): raise AssertionError() if isinstance(obj, NotifyBase): # Ensure we are not performing any type of thorttling obj.request_rate_per_sec = 0 # We loaded okay; now lets make sure we can reverse # this url assert isinstance(obj.url(), str) is True # Test that we support a url identifier url_id = obj.url_id() # It can be either disabled or a string; nothing else assert isinstance(url_id, str) or ( url_id is None and obj.url_identifier is False ) # Verify we can acquire a target count as an integer assert isinstance(len(obj), int) # Test url() with privacy=True assert isinstance(obj.url(privacy=True), str) is True # Some Simple Invalid Instance Testing assert instance.parse_url(None) is None assert instance.parse_url(object) is None assert instance.parse_url(42) is None # Assess that our privacy url is as expected if privacy_url and not obj.url(privacy=True).startswith( privacy_url ): raise AssertionError( f"URL: {url} Privacy URL:" f" '{obj.url(privacy=True)[: len(privacy_url)]}' !=" f" expected '{privacy_url}'" ) # Assess that our URL matches a set regex if url_matches and not re.search(url_matches, obj.url()): raise AssertionError( f"URL: {url} generated an reloadable " f"url() of {obj.url()} that does not match " f"'{url_matches}'" ) # Instantiate the exact same object again using the URL # from the one that was already created properly obj_cmp = Apprise.instantiate(obj.url()) if not isinstance(obj_cmp, NotifyBase): raise AssertionError( f"URL: {url} generated an un-reloadable " f"url() of {obj.url()}" ) # Our new object should produce the same url identifier elif obj.url_identifier != obj_cmp.url_identifier: raise AssertionError( f"URL: {url} URL Identifier: " f"'{obj_cmp.url_identifier}' != expected" f" '{obj.url_identifier}'" ) # Back our check up if obj.url_id() != obj_cmp.url_id(): raise AssertionError( f"URL: {url} URL ID(): '{obj_cmp.url_id()}' != expected" f" '{obj.url_id()}'" ) # Verify there is no change from the old and the new if len(obj) != len(obj_cmp): raise AssertionError( f"URL: {url} generated an reloadable " f"url() of {obj.url()} produced target miscount " f"{len(obj)} != {len(obj_cmp)}" ) # Tidy our object del obj_cmp del instance if self_: # Iterate over our expected entries inside of our # object for key, val in self_.items(): # Test that our object has the desired key assert hasattr(obj, key) is True assert getattr(obj, key) == val try: self.__notify(url, obj, meta, asset) except AssertionError: # Don't mess with these entries raise # Tidy our object and allow any possible defined destructors to # be executed. del obj @mock.patch("requests.get") @mock.patch("requests.post") @mock.patch("requests.head") @mock.patch("requests.put") @mock.patch("requests.delete") @mock.patch("requests.patch") @mock.patch("requests.request") def __notify( self, url, obj, meta, asset, mock_request, mock_patch, mock_del, mock_put, mock_head, mock_post, mock_get, ): """Perform notification testing against object specified.""" # # Prepare our options # # Allow notification type override, otherwise default to INFO notify_type = meta.get("notify_type", NotifyType.INFO) # Whether or not we're testing exceptions or not test_requests_exceptions = meta.get("test_requests_exceptions", False) # Our expected Query response (True, False, or exception type) response = meta.get("response", True) # Our expected Notify response (True or False) notify_response = meta.get("notify_response", response) # Our expected Notify Attachment response (True or False) attach_response = meta.get("attach_response", notify_response) # Test attachments # Don't set this if don't need to check it's value check_attachments = meta.get("check_attachments", True) # Debug/Trace Monitoring force_debug = meta.get("force_debug", False) # Allow us to force the server response code to be something other then # the defaults requests_response_code = meta.get( "requests_response_code", requests.codes.ok if response else requests.codes.not_found, ) # Allow us to force the server response text to be something other then # the defaults requests_response_text = meta.get("requests_response_text") requests_response_content = None if isinstance(requests_response_text, str): requests_response_content = requests_response_text.encode("utf-8") elif isinstance(requests_response_text, bytes): requests_response_content = requests_response_text requests_response_text = requests_response_text.decode("utf-8") elif not isinstance(requests_response_text, str): # Convert to string requests_response_text = dumps(requests_response_text) requests_response_content = requests_response_text.encode("utf-8") else: requests_response_content = "" requests_response_text = "" # A request robj = mock.Mock() robj.content = "" robj.text = "" robj.iter_content = self.__iter_content(robj) mock_get.return_value = robj mock_post.return_value = robj mock_head.return_value = robj mock_patch.return_value = robj mock_del.return_value = robj mock_put.return_value = robj mock_request.return_value = robj if test_requests_exceptions is False: # Handle our default response mock_put.return_value.status_code = requests_response_code mock_head.return_value.status_code = requests_response_code mock_del.return_value.status_code = requests_response_code mock_post.return_value.status_code = requests_response_code mock_get.return_value.status_code = requests_response_code mock_patch.return_value.status_code = requests_response_code mock_request.return_value.status_code = requests_response_code # Handle our default text response mock_get.return_value.content = requests_response_content mock_post.return_value.content = requests_response_content mock_del.return_value.content = requests_response_content mock_put.return_value.content = requests_response_content mock_head.return_value.content = requests_response_content mock_patch.return_value.content = requests_response_content mock_request.return_value.content = requests_response_content mock_get.return_value.text = requests_response_text mock_post.return_value.text = requests_response_text mock_put.return_value.text = requests_response_text mock_del.return_value.text = requests_response_text mock_head.return_value.text = requests_response_text mock_patch.return_value.text = requests_response_text mock_request.return_value.text = requests_response_text # Ensure there is no side effect set mock_post.side_effect = None mock_del.side_effect = None mock_put.side_effect = None mock_head.side_effect = None mock_get.side_effect = None mock_patch.side_effect = None mock_request.side_effect = None else: # Handle exception testing; first we turn the boolean flag # into a list of exceptions test_requests_exceptions = self.req_exceptions try: if test_requests_exceptions is False: # Verify we can acquire a target count as an integer targets = len(obj) # check that we're as expected resp = obj.notify( body=self.body, title=self.title, notify_type=notify_type ) if resp != notify_response: raise AssertionError( f"notify() call; notify_response={resp} " f"(expected {notify_response}) on {url}" ) if notify_response: # If we successfully got a response, there must have been # at least 1 target present assert targets > 0 # check that this doesn't change using different overflow # methods assert ( obj.notify( body=self.body, title=self.title, notify_type=notify_type, overflow=OverflowMode.UPSTREAM, ) == notify_response ) assert ( obj.notify( body=self.body, title=self.title, notify_type=notify_type, overflow=OverflowMode.TRUNCATE, ) == notify_response ) assert ( obj.notify( body=self.body, title=self.title, notify_type=notify_type, overflow=OverflowMode.SPLIT, ) == notify_response ) # # Handle varations of the Asset Object missing fields # # First make a backup app_id = asset.app_id app_desc = asset.app_desc # now clear records asset.app_id = None asset.app_desc = None # Notify should still work assert ( obj.notify( body=self.body, title=self.title, notify_type=notify_type, ) == notify_response ) # App ID only asset.app_id = app_id asset.app_desc = None # Notify should still work assert ( obj.notify( body=self.body, title=self.title, notify_type=notify_type, ) == notify_response ) # App Desc only asset.app_id = None asset.app_desc = app_desc if force_debug: # Enable access to areas otherwise locked away by the log # level original_is_enabled_for = obj.logger.isEnabledFor original_debug = obj.logger.debug try: # Force code paths guarded by isEnabledFor(DEBUG) obj.logger.isEnabledFor = mock.Mock(return_value=True) # Prevent actual logging emission (avoids pytest # capture closed stream) obj.logger.debug = mock.Mock() assert ( obj.notify( body=self.body, title=self.title, notify_type=notify_type, ) == notify_response ) finally: # Restore obj.logger.isEnabledFor = original_is_enabled_for obj.logger.debug = original_debug else: assert ( obj.notify( body=self.body, title=self.title, notify_type=notify_type, ) == notify_response ) # Restore asset.app_id = app_id asset.app_desc = app_desc if check_attachments: # Test single attachment support; even if the service # doesn't support attachments, it should still # gracefully ignore the data attach = os.path.join( self.__test_var_dir, "apprise-test.gif" ) assert ( obj.notify( body=self.body, title=self.title, notify_type=notify_type, attach=attach, ) == attach_response ) # Same results should apply to a list of attachments attach = AppriseAttachment( ( os.path.join( self.__test_var_dir, "apprise-test.gif" ), os.path.join( self.__test_var_dir, "apprise-test.png" ), os.path.join( self.__test_var_dir, "apprise-test.jpeg" ), ) ) assert ( obj.notify( body=self.body, title=self.title, notify_type=notify_type, attach=attach, ) == attach_response ) if obj.attachment_support: # # Services that support attachments should support # sending a attachment (or more) without a body or # title specified: # assert ( obj.notify( body=None, title=None, notify_type=notify_type, attach=attach, ) == attach_response ) # Turn off attachment support on the notifications # that support it so we can test that any logic we # have ot test against this flag is ran obj.attachment_support = False # # Notifications should still transmit as normal if # Attachment support is flipped off # assert ( obj.notify( body=self.body, title=self.title, notify_type=notify_type, attach=attach, ) == notify_response ) # # We should not be able to send a message without a # body or title in this circumstance # assert ( obj.notify( body=None, title=None, notify_type=notify_type, attach=attach, ) is False ) # Toggle Back obj.attachment_support = True else: # No Attachment support # # We should not be able to send a message without a # body or title in this circumstance # assert ( obj.notify( body=None, title=None, notify_type=notify_type, attach=attach, ) is False ) else: for exception in self.req_exceptions: mock_post.side_effect = exception mock_head.side_effect = exception mock_del.side_effect = exception mock_put.side_effect = exception mock_get.side_effect = exception mock_patch.side_effect = exception mock_request.side_effect = exception try: assert ( obj.notify( body=self.body, title=self.title, notify_type=notify_type, ) is False ) except AssertionError: # Don't mess with these entries raise except Exception: # We can't handle this exception type raise except AssertionError: # Don't mess with these entries raise except Exception as e: # Check that we were expecting this exception to happen try: if not isinstance(e, response): raise e except TypeError: raise e # noqa: B904 - intentional re-raising in test helper # # Do the test again but without a title defined # try: if test_requests_exceptions is False: # check that we're as expected assert ( obj.notify(body="body", notify_type=notify_type) == notify_response ) else: for exception in self.req_exceptions: mock_post.side_effect = exception mock_del.side_effect = exception mock_put.side_effect = exception mock_head.side_effect = exception mock_get.side_effect = exception mock_patch.side_effect = exception mock_request.side_effect = exception try: assert ( obj.notify(body=self.body, notify_type=notify_type) is False ) except AssertionError: # Don't mess with these entries raise except Exception: # We can't handle this exception type raise except AssertionError: # Don't mess with these entries raise except Exception as e: # Check that we were expecting this exception to happen if not isinstance(e, response): raise e return True caronc-apprise-dfec414/tests/test_api.py000066400000000000000000003263711526720371000204570ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import asyncio import concurrent.futures import inspect from inspect import cleandoc # Disable logging for a cleaner testing output import logging from os.path import dirname, join import re import sys from typing import ClassVar from unittest import mock from helpers import OuterEventLoop import pytest import requests from apprise import ( LOGGER_NAME, NOTIFY_FORMATS, Apprise, AppriseAsset, AppriseAttachment, AppriseResultStatus, NotificationManager, NotifyBase, NotifyFormat, NotifyImageSize, NotifyType, PersistentStoreMode, PrivacyMode, URLBase, __version__, plugins, ) from apprise.locale import LazyTranslation, gettext_lazy as _ from apprise.plugins.base import RequirementsSpec from apprise.utils.parse import parse_list logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = join(dirname(__file__), "var") # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() def test_apprise_object(): """ API: Apprise() object """ def do_notify(service, *args, **kwargs): return service.notify(*args, **kwargs) apprise_test(do_notify) def test_apprise_async(): """ API: Apprise() object asynchronous methods """ with OuterEventLoop() as loop: def do_notify(service, *args, **kwargs): return loop.run_until_complete( service.async_notify(*args, **kwargs) ) apprise_test(do_notify) def apprise_test(do_notify): """Exercise the core Apprise container and notification behavior.""" a = Apprise() # no items assert len(a) == 0 # Apprise object can also be directly tested with 'if' keyword # No entries results in a False response assert not a # Create an Asset object asset = AppriseAsset(theme="default") # We can load the device using our asset a = Apprise(asset=asset) # We can load our services up front as well services = [ "json://myhost", "kodi://kodi.server.local", ] a = Apprise(services=services) # 2 services loaded assert len(a) == 2 # Apprise object can also be directly tested with 'if' keyword # At least one entry results in a True response assert a # We can retrieve our URLs this way: assert len(a.urls()) == 2 # We can add another service assert ( a.add( "mmosts://mattermost.server.local/3ccdd113474722377935511fc85d3dd4" ) is True ) assert len(a) == 3 # Try adding nothing but delimiters assert a.add(",, ,, , , , ,") is False # The number of services added doesn't change assert len(a) == 3 # We can pop an object off of our stack by it's indexed value: obj = a.pop(0) assert isinstance(obj, NotifyBase) is True assert len(a) == 2 # We can retrieve elements from our list too by reference: assert isinstance(a[0].url(), str) is True # We can iterate over our list too: count = 0 for o in a: assert isinstance(o.url(), str) is True count += 1 # verify that we did indeed iterate over each element assert len(a) == count # We can empty our set a.clear() assert len(a) == 0 # An invalid schema assert a.add("this is not a parseable url at all") is False assert len(a) == 0 # An unsupported schema assert a.add("invalid://we.just.do.not.support.this.plugin.type") is False assert len(a) == 0 # A poorly formatted URL assert a.add("json://user:@@@:bad?no.good") is False assert len(a) == 0 # Add a service with our asset we created earlier assert ( a.add( "mmosts://mattermost.server.local/" "3ccdd113474722377935511fc85d3dd4", asset=asset, ) is True ) # Clear our service listings again a.clear() assert len(a) == 0 # No services to notify assert bool(do_notify(a, title="my title", body="my body")) is False # More Variations of Multiple Adding of URLs a = Apprise() assert a.add(services) assert len(a) == 2 a.clear() assert a.add("ntfys://user:pass@host/test, json://localhost") assert len(a) == 2 a.clear() assert a.add(["ntfys://user:pass@host/test", "json://localhost"]) assert len(a) == 2 a.clear() assert a.add(("ntfys://user:pass@host/test", "json://localhost")) assert len(a) == 2 a.clear() assert a.add({"ntfys://user:pass@host/test", "json://localhost"}) assert len(a) == 2 a.clear() # Pass a list entry containing 1 string with 2 elements in it # Mimic Home-Assistant core-2024.2.1 Issue: # - https://github.com/home-assistant/core/issues/110242 # # In this case, the first one will load, but not the second entry # This is by design; but captured here to illustrate the issue assert a.add(["ntfys://user:pass@host/test, json://localhost"]) assert len(a) == 1 assert a[0].url().startswith("ntfys://") a.clear() # Following thorugh with the problem of providing a list containing # an entry with 2 URLs in it... while the ntfys parsed okay above, # the same can't be said for other combinations. It's important # to always keep strings separately assert a.add(["mailto://user:pass@example.com, json://localhost"]) is False assert len(a) == 0 # Showing that the URLs were valid on their own: assert a.add(*["mailto://user:pass@example.com, json://localhost"]) assert len(a) == 2 assert a[0].url().startswith("mailto://") assert a[1].url().startswith("json://") a.clear() class BadNotification(NotifyBase): def __init__(self, **kwargs): super().__init__(**kwargs) # We fail whenever we're initialized raise TypeError() @staticmethod def parse_url(url, *args, **kwargs): # always parseable return NotifyBase.parse_url(url, verify_host=False) class GoodNotification(NotifyBase): def __init__(self, **kwargs): super().__init__(notify_format=NotifyFormat.HTML, **kwargs) def send(self, **kwargs): # Pretend everything is okay return True @staticmethod def parse_url(url, *args, **kwargs): # always parseable return NotifyBase.parse_url(url, verify_host=False) # Store our bad notification in our schema map N_MGR["bad"] = BadNotification # Store our good notification in our schema map N_MGR["good"] = GoodNotification # Just to explain what is happening here, we would have parsed the # url properly but failed when we went to go and create an instance # of it. assert a.add("bad://localhost") is False assert len(a) == 0 # We'll fail because we've got nothing to notify assert bool(do_notify(a, title="my title", body="my body")) is False # Clear our service listings again a.clear() assert a.add("good://localhost") is True assert len(a) == 1 # Bad Notification Types are not accepted assert ( bool(do_notify(a, title="my title", body="my body", notify_type="bad")) is False ) # Notifications of string type are accepted assert ( bool( do_notify( a, title="my title", body="my body", notify_type="warning" ) ) is True ) # Notifications of string type are accepted assert ( bool( do_notify( a, title="my title", body="my body", notify_type="warning" ) ) is True ) # Notifications of string type are accepted assert ( bool( do_notify( a, title="my title", body="my body", notify_type="warning" ) ) is True ) # Notifications where notify_type is of the NotifyType object is the # preferred choice assert ( bool( do_notify( a, title="my title", body="my body", notify_type=NotifyType.WARNING, ) ) is True ) # No Title/Body combo's assert bool(do_notify(a, title=None, body=None)) is False assert bool(do_notify(a, title="", body=None)) is False assert bool(do_notify(a, title=None, body="")) is False assert bool(do_notify(a, title=5, body=b"bytes")) is False assert bool(do_notify(a, title=b"bytes", body=10)) is False assert bool(do_notify(a, title=object(), body=b"bytes")) is False assert bool(do_notify(a, title=b"bytes", body=object())) is False # A Body must be present assert bool(do_notify(a, title="present", body=None)) is False # Other combinations work fine assert bool(do_notify(a, title=None, body="present")) is True assert bool(do_notify(a, title="present", body="present")) is True # Send Attachment with success attach = join(TEST_VAR_DIR, "apprise-test.gif") assert ( bool( do_notify( a, body="body", title="test", notify_type=NotifyType.INFO, attach=attach, ) ) is True ) # Send the attachment as an AppriseAttachment object assert ( bool( do_notify( a, body="body", title="test", notify_type=NotifyType.INFO, attach=AppriseAttachment(attach), ) ) is True ) # test a invalid attachment assert ( bool( do_notify( a, body="body", title="test", notify_type=NotifyType.INFO, attach="invalid://", ) ) is False ) # Repeat the same tests above... # however do it by directly accessing the object; this grants the similar # results: assert ( bool( do_notify( a[0], body="body", title="test", notify_type=NotifyType.INFO, attach=attach, ) ) is True ) # Send the attachment as an AppriseAttachment object assert ( bool( do_notify( a[0], body="body", title="test", notify_type=NotifyType.INFO, attach=AppriseAttachment(attach), ) ) is True ) # test a invalid attachment assert ( bool( do_notify( a[0], body="body", title="test", notify_type=NotifyType.INFO, attach="invalid://", ) ) is False ) class ThrowNotification(NotifyBase): def notify(self, **kwargs): # Pretend everything is okay raise TypeError() async def async_notify(self, **kwargs): # Pretend everything is okay (async) raise TypeError() class RuntimeNotification(NotifyBase): def notify(self, **kwargs): # Pretend everything is okay raise RuntimeError() async def async_notify(self, **kwargs): # Pretend everything is okay (async) raise TypeError() class FailNotification(NotifyBase): def notify(self, **kwargs): # Pretend everything is okay return False async def async_notify(self, **kwargs): # Pretend everything is okay (async) raise TypeError() # Store our bad notification in our schema map N_MGR["throw"] = ThrowNotification # Store our good notification in our schema map N_MGR["fail"] = FailNotification # Store our good notification in our schema map N_MGR["runtime"] = RuntimeNotification for async_mode in (True, False): # Create an Asset object asset = AppriseAsset(theme="default", async_mode=async_mode) # We can load the device using our asset a = Apprise(asset=asset) assert a.add("runtime://localhost") is True assert a.add("throw://localhost") is True assert a.add("fail://localhost") is True assert len(a) == 3 # Test when our notify both throws an exception and or just # simply returns False assert bool(do_notify(a, title="present", body="present")) is False # Create a Notification that throws an unexected exception class ThrowInstantiateNotification(NotifyBase): def __init__(self, **kwargs): # Pretend everything is okay raise TypeError() N_MGR.unload_modules() N_MGR["throw"] = ThrowInstantiateNotification # Store our good notification in our schema map N_MGR["good"] = GoodNotification # Reset our object a.clear() assert len(a) == 0 # Test our socket details # rto = Socket Read Timeout # cto = Socket Connect Timeout plugin = a.instantiate("good://localhost?rto=5.1&cto=10") assert isinstance(plugin, NotifyBase) assert plugin.socket_connect_timeout == pytest.approx(10.0) assert plugin.socket_read_timeout == pytest.approx(5.1) plugin = a.instantiate("good://localhost?rto=invalid&cto=invalid") assert isinstance(plugin, NotifyBase) assert plugin.socket_connect_timeout == URLBase.socket_connect_timeout assert plugin.socket_read_timeout == URLBase.socket_read_timeout # Reset our object a.clear() assert len(a) == 0 with pytest.raises(ValueError): # Encoding error AppriseAsset(encoding="ascii", storage_salt="ボールト") with pytest.raises(ValueError): # Not a valid storage salt (must be str or bytes) AppriseAsset(storage_salt=42) # Set our cache to be off plugin = a.instantiate("good://localhost?store=no", asset=asset) assert isinstance(plugin, NotifyBase) # The identifier still works; turning storage off only stops anything # being written to disk for it assert plugin.url_id(lazy=False) assert plugin.store.mode is PersistentStoreMode.MEMORY # Verify our cache is disabled assert "store=no" in plugin.url() with pytest.raises(ValueError): # idlen must be greater then 0 AppriseAsset(storage_idlen=-1) # Create a larger idlen asset = AppriseAsset(storage_idlen=32) plugin = a.instantiate("good://localhost", asset=asset) assert len(plugin.url_id()) == 32 # Instantiate a bad object plugin = a.instantiate(object, tag="bad_object") assert plugin is None # Instantiate a good object plugin = a.instantiate("good://localhost", tag="good") assert isinstance(plugin, NotifyBase) # Test simple tagging inside of the object assert "good" in plugin assert "bad" not in plugin # the in (__contains__ override) is based on or'ed content; so although # 'bad' isn't tagged as being in the plugin, 'good' is, so the return # value of this is True assert ["bad", "good"] in plugin assert {"bad", "good"} in plugin assert ("bad", "good") in plugin # We an add already substatiated instances into our Apprise object a.add(plugin) assert len(a) == 1 # We can add entries as a list too (to add more then one) a.add([plugin, plugin, plugin]) assert len(a) == 4 # Reset our object again a.clear() with pytest.raises(TypeError): a.instantiate("throw://localhost", suppress_exceptions=False) assert len(a) == 0 assert a.instantiate("throw://localhost", suppress_exceptions=True) is None assert len(a) == 0 # # We rince and repeat the same tests as above, however we do them # using the dict version # # Reset our object a.clear() assert len(a) == 0 # Instantiate a good object plugin = a.instantiate({"schema": "good", "host": "localhost"}, tag="good") assert isinstance(plugin, NotifyBase) # Test simple tagging inside of the object assert "good" in plugin assert "bad" not in plugin # the in (__contains__ override) is based on or'ed content; so although # 'bad' isn't tagged as being in the plugin, 'good' is, so the return # value of this is True assert ["bad", "good"] in plugin assert {"bad", "good"} in plugin assert ("bad", "good") in plugin # We an add already substatiated instances into our Apprise object a.add(plugin) assert len(a) == 1 # We can add entries as a list too (to add more then one) a.add([plugin, plugin, plugin]) assert len(a) == 4 # Reset our object again a.clear() with pytest.raises(TypeError): a.instantiate( {"schema": "throw", "host": "localhost"}, suppress_exceptions=False ) assert len(a) == 0 assert ( a.instantiate( {"schema": "throw", "host": "localhost"}, suppress_exceptions=True ) is None ) assert len(a) == 0 def test_apprise_pretty_print(): """ API: Apprise() Pretty Print tests """ # Privacy Print # PrivacyMode.Secret always returns the same thing to avoid guessing assert ( URLBase.pprint(None, privacy=True, mode=PrivacyMode.Secret) == "****" ) assert URLBase.pprint(42, privacy=True, mode=PrivacyMode.Secret) == "****" assert ( URLBase.pprint(object, privacy=True, mode=PrivacyMode.Secret) == "****" ) assert URLBase.pprint("", privacy=True, mode=PrivacyMode.Secret) == "****" assert URLBase.pprint("a", privacy=True, mode=PrivacyMode.Secret) == "****" assert ( URLBase.pprint("ab", privacy=True, mode=PrivacyMode.Secret) == "****" ) assert ( URLBase.pprint("abcdefghijk", privacy=True, mode=PrivacyMode.Secret) == "****" ) # PrivacyMode.Outer assert URLBase.pprint(None, privacy=True, mode=PrivacyMode.Outer) == "" assert URLBase.pprint(42, privacy=True, mode=PrivacyMode.Outer) == "" assert URLBase.pprint(object, privacy=True, mode=PrivacyMode.Outer) == "" assert URLBase.pprint("", privacy=True, mode=PrivacyMode.Outer) == "" assert URLBase.pprint("a", privacy=True, mode=PrivacyMode.Outer) == "a...a" assert ( URLBase.pprint("ab", privacy=True, mode=PrivacyMode.Outer) == "a...b" ) assert ( URLBase.pprint("abcdefghijk", privacy=True, mode=PrivacyMode.Outer) == "a...k" ) # PrivacyMode.Tail assert URLBase.pprint(None, privacy=True, mode=PrivacyMode.Tail) == "" assert URLBase.pprint(42, privacy=True, mode=PrivacyMode.Tail) == "" assert URLBase.pprint(object, privacy=True, mode=PrivacyMode.Tail) == "" assert URLBase.pprint("", privacy=True, mode=PrivacyMode.Tail) == "" assert URLBase.pprint("a", privacy=True, mode=PrivacyMode.Tail) == "...a" assert URLBase.pprint("ab", privacy=True, mode=PrivacyMode.Tail) == "...ab" assert ( URLBase.pprint("abcdefghijk", privacy=True, mode=PrivacyMode.Tail) == "...hijk" ) # Quoting settings assert URLBase.pprint(" ", privacy=False, safe="") == "%20" assert URLBase.pprint(" ", privacy=False, quote=False, safe="") == " " @mock.patch("requests.request") def test_apprise_tagging(mock_request): """ API: Apprise() object tagging functionality """ def do_notify(service, *args, **kwargs): return service.notify(*args, **kwargs) apprise_tagging_test(mock_request, do_notify) @mock.patch("requests.request") def test_apprise_tagging_async(mock_request): """ API: Apprise() object tagging functionality asynchronous methods """ with OuterEventLoop() as loop: def do_notify(service, *args, **kwargs): return loop.run_until_complete( service.async_notify(*args, **kwargs) ) apprise_tagging_test(mock_request, do_notify) def apprise_tagging_test(mock_request, do_notify): """Exercise tag filtering and grouped notification behavior.""" # A request robj = mock.Mock() robj.raw = mock.Mock() # Allow raw.read() calls robj.raw.read.return_value = "" robj.text = "" robj.content = "" mock_request.return_value = robj # Simulate a successful notification mock_request.return_value.status_code = requests.codes.ok # Create our object a = Apprise() # An invalid addition can't add the tag assert a.add("averyinvalidschema://localhost", tag="uhoh") is False assert ( a.add( {"schema": "averyinvalidschema", "host": "localhost"}, tag="uhoh" ) is False ) # Add entry and assign it to a tag called 'awesome' assert a.add("json://localhost/path1/", tag="awesome") is True assert ( a.add( {"schema": "json", "host": "localhost", "fullpath": "/path1/"}, tag="awesome", ) is True ) # Add another notification and assign it to a tag called 'awesome' # and another tag called 'local' assert a.add("json://localhost/path2/", tag=["mmost", "awesome"]) is True # notify the awesome tag; this would notify both services behind the # scenes assert ( bool(do_notify(a, title="my title", body="my body", tag="awesome")) is True ) # notify all of the tags assert ( bool( do_notify( a, title="my title", body="my body", tag=["awesome", "mmost"] ) ) is True ) # When we query against our loaded notifications for a tag that simply # isn't assigned to anything, we return NOMATCH. NOMATCH (different # then FAILURE) tells us that we litterally had nothing to query. We # didn't fail... but we also didn't do anything... assert ( do_notify(a, title="my title", body="my body", tag="missing").status == AppriseResultStatus.NOMATCH ) # Now to test the ability to and and/or notifications a = Apprise() # Add a tag by tuple assert a.add("json://localhost/tagA/", tag=("TagA",)) is True # Add 2 tags by string assert a.add("json://localhost/tagAB/", tag="TagA, TagB") is True # Add a tag using a set assert a.add("json://localhost/tagB/", tag={"TagB"}) is True # Add a tag by string (again) assert a.add("json://localhost/tagC/", tag="TagC") is True # Add 2 tags using a list assert a.add("json://localhost/tagCD/", tag=["TagC", "TagD"]) is True # Add a tag by string (again) assert a.add("json://localhost/tagD/", tag="TagD") is True # add a tag set by set (again) assert ( a.add("json://localhost/tagCDE/", tag={"TagC", "TagD", "TagE"}) is True ) # Expression: TagC and TagD # Matches the following only: # - json://localhost/tagCD/ # - json://localhost/tagCDE/ assert ( bool( do_notify( a, title="my title", body="my body", tag=[("TagC", "TagD")], ) ) is True ) # Expression: (TagY and TagZ) or TagX # Matches nothing, NOMATCH is returned in this case assert ( do_notify( a, title="my title", body="my body", tag=[("TagY", "TagZ"), "TagX"] ).status == AppriseResultStatus.NOMATCH ) # Expression: (TagY and TagZ) or TagA # Matches the following only: # - json://localhost/tagAB/ assert ( bool( do_notify( a, title="my title", body="my body", tag=[("TagY", "TagZ"), "TagA"], ) ) is True ) # Expression: (TagE and TagD) or TagB # Matches the following only: # - json://localhost/tagCDE/ # - json://localhost/tagAB/ # - json://localhost/tagB/ assert ( bool( do_notify( a, title="my title", body="my body", tag=[("TagE", "TagD"), "TagB"], ) ) is True ) # Garbage Entries in tag field just get stripped out. the below # is the same as notifying no tags at all. Since we have not added # any entries that do not have tags (that we can match against) # we fail. NOMATCH is returned as a way of letting us know that we # had Notifications to notify, but since none of them matched our tag # none were notified. assert ( do_notify( a, title="my title", body="my body", tag=[ (object,), ], ).status == AppriseResultStatus.NOMATCH ) def test_apprise_schemas(): """ API: Apprise().schema() tests """ # Clear loaded modules a = Apprise() # no items assert len(a) == 0 class TextNotification(NotifyBase): # set our default notification format notify_format = NotifyFormat.TEXT # Garbage Protocol Entries protocol = None secure_protocol = (None, object) class HtmlNotification(NotifyBase): protocol = ("html", "htm") secure_protocol = ("htmls", "htms") class MarkDownNotification(NotifyBase): protocol = "markdown" secure_protocol = "markdowns" schemas = URLBase.schemas(TextNotification) assert isinstance(schemas, set) is True # We didn't define a protocol or secure protocol assert len(schemas) == 0 # Store our notifications into our schema map N_MGR["text"] = TextNotification N_MGR["html"] = HtmlNotification N_MGR["markdown"] = MarkDownNotification schemas = URLBase.schemas(TextNotification) assert isinstance(schemas, set) is True # We didn't define a protocol or secure protocol one # but one got assigned in he above N_MGR call assert len(schemas) == 1 assert "text" in schemas schemas = URLBase.schemas(HtmlNotification) assert isinstance(schemas, set) is True assert len(schemas) == 4 assert "html" in schemas assert "htm" in schemas assert "htmls" in schemas assert "htms" in schemas # Invalid entries do not disrupt schema calls for garbage in (object(), None, 42): schemas = URLBase.schemas(garbage) assert isinstance(schemas, set) is True assert len(schemas) == 0 def test_apprise_urlbase_object(): """ API: Apprise() URLBase object testing """ results = URLBase.parse_url("https://localhost/path/?cto=3.0&verify=no") assert results.get("user") is None assert results.get("password") is None assert results.get("path") == "/path/" assert results.get("secure") is True assert results.get("verify") is False base = URLBase(**results) assert base.request_timeout == (3.0, 4.0) assert base.request_auth is None assert base.request_url == "https://localhost/path/" assert base.url().startswith("https://localhost/") results = URLBase.parse_url( "http://user:pass@localhost:34/path/here?rto=3.0&verify=yes" ) assert results.get("user") == "user" assert results.get("password") == "pass" assert results.get("fullpath") == "/path/here" assert results.get("secure") is False assert results.get("verify") is True base = URLBase(**results) assert base.request_timeout == (4.0, 3.0) assert base.request_auth == ("user", "pass") assert base.request_url == "http://localhost:34/path/here" assert base.url().startswith("http://user:pass@localhost:34/path/here") results = URLBase.parse_url("http://user@127.0.0.1/path/") assert results.get("user") == "user" assert results.get("password") is None assert results.get("fullpath") == "/path/" assert results.get("secure") is False assert results.get("verify") is True base = URLBase(**results) assert base.request_timeout == (4.0, 4.0) assert base.request_auth == ("user", None) assert base.request_url == "http://127.0.0.1/path/" assert base.url().startswith("http://user@127.0.0.1/path/") # redirect= defaults to True (follows redirects, matching requests default) results = URLBase.parse_url("https://localhost/path/?redirect=yes") assert results.get("redirect") is True base = URLBase(**results) assert base.redirects is True # redirect=yes is the default, so it must not appear in url() output assert "redirect" not in base.url() # redirect=no disables redirect following and round-trips in the URL results = URLBase.parse_url( "http://user:pass@localhost:34/path/here?redirect=no" ) assert results.get("redirect") is False base = URLBase(**results) assert base.redirects is False assert "redirect=no" in base.url() # redirect is absent from results when not specified -- URLBase inherits # the global default from the asset (default asset has http_redirects=True) results = URLBase.parse_url("http://user@127.0.0.1/path/") assert "redirect" not in results base = URLBase(**results) assert base.redirects is True assert "redirect" not in base.url() # AppriseAsset(http_redirects=False) flows through to every plugin when the # URL does not specify redirect= from apprise import AppriseAsset asset_no_redirect = AppriseAsset(http_redirects=False) results = URLBase.parse_url("http://user@127.0.0.1/path/") assert "redirect" not in results base = URLBase(**results, asset=asset_no_redirect) assert base.redirects is False # inherited the asset global # redirects matches the asset default -- no override to serialise assert "redirect" not in base.url() # URL-level redirect=yes overrides asset http_redirects=False; the # override must be preserved in url() so the round-trip is idempotent results = URLBase.parse_url("http://user@127.0.0.1/path/?redirect=yes") assert results.get("redirect") is True base = URLBase(**results, asset=asset_no_redirect) assert base.redirects is True # URL wins over asset assert "redirect=yes" in base.url() # differs from asset default, emitted # YAML config path: redirect= provided in the results dict directly # (not via qsd) -- exercises the elif "redirect" in results branch in # post_process_parse_url_results() results = URLBase.parse_url("http://user@127.0.0.1/path/") assert "redirect" not in results results["redirect"] = "no" URLBase.post_process_parse_url_results(results) assert results.get("redirect") is False results["redirect"] = "yes" URLBase.post_process_parse_url_results(results) assert results.get("redirect") is True # URL_TOKEN_ALIASES: 'pass' in top-level results is normalized to # 'password' by post_process_parse_url_results (covers the YAML config # path where a flat dict is passed rather than a parsed URL string) results = URLBase.parse_url("http://user@127.0.0.1/path/") results["pass"] = "secret" URLBase.post_process_parse_url_results(results) assert results.get("password") == "secret" assert "pass" not in results # URL_TOKEN_ALIASES: canonical 'password' wins when already set to a # real value -- 'pass' in top-level results must not overwrite an # explicitly provided URL-path password; a WARNING is logged so the # operator knows the alias was present but dropped (no values logged) results = URLBase.parse_url("http://user:realpass@127.0.0.1/path/") results["pass"] = "shouldnotwin" with mock.patch("apprise.url.logger") as mock_log: URLBase.post_process_parse_url_results(results) mock_log.warning.assert_called_once() call_args = mock_log.warning.call_args[0] # alias and canonical key names appear; actual values must not assert "pass" in call_args[1] assert "password" in call_args[2] assert results.get("password") == "realpass" assert "pass" not in results # URL_TOKEN_ALIASES: qsd 'pass=' overrides the URL-path password because # query-string parameters always take the highest priority results = URLBase.parse_url( "http://user:shouldnotwin@127.0.0.1/path/?pass=realpass" ) URLBase.post_process_parse_url_results(results) assert results.get("password") == "realpass" assert "pass" not in results # URL_TOKEN_ALIASES: 'pass' in qsd is also normalized to 'password' results = URLBase.parse_url("http://user@127.0.0.1/path/?pass=qsecret") URLBase.post_process_parse_url_results(results) assert results.get("password") == "qsecret" # Generic initialization base = URLBase(**{"schema": ""}) assert base.request_timeout == (4.0, 4.0) assert base.request_auth is None assert base.request_url == "http:///" assert base.url().startswith("http:///") base = URLBase() assert base.request_timeout == (4.0, 4.0) assert base.request_auth is None assert base.request_url == "http:///" assert base.url().startswith("http:///") # URLBase.__len__ defaults to 1; subclasses that support multiple # targets override this to return the actual count. assert len(base) == 1 # Invalid asset type -- a non-AppriseAsset value must be silently ignored # and a debug log must tell the caller what happened. The instance must # still receive a valid default AppriseAsset so nothing downstream breaks. with mock.patch("apprise.url.logger") as mock_log: base = URLBase(asset="not-an-asset") mock_log.debug.assert_called_once() call_args = mock_log.debug.call_args[0] # The type name appears in the message so the caller can identify it assert "str" in call_args[1] assert isinstance(base.asset, AppriseAsset) def test_apprise_urlbase_simple_results(): """A simple parse without a password still applies the query string.""" from apprise.utils.parse import parse_url # A simple parse leaves out the user and password entries entirely results = parse_url("custom://host?x=1", verify_host=False, simple=True) assert "password" not in results # No password and no user= override URLBase.post_process_parse_url_results(results) assert results["verify"] is True assert "password" not in results # A user= override with no password to fall back to results = parse_url( "custom://host?user=bob&verify=no", verify_host=False, simple=True ) URLBase.post_process_parse_url_results(results) assert results["user"] == "bob" assert results["password"] is None assert results["verify"] is False # The original user becomes the password when user= replaces it results = parse_url( "custom://secret@host?user=bob", verify_host=False, simple=True ) URLBase.post_process_parse_url_results(results) assert results["user"] == "bob" assert results["password"] == "secret" def test_apprise_unique_id(): """ API: Apprise() Input Formats tests """ # Default testing obj1 = Apprise.instantiate("json://user@127.0.0.1/path") obj2 = Apprise.instantiate("json://user@127.0.0.1/path/?arg=") assert obj1.url_identifier == obj2.url_identifier assert obj1.url_id() == obj2.url_id() # Second call leverages lazy reference (so it's much faster assert obj1.url_id() == obj2.url_id() # Disable Lazy Setting assert obj1.url_id(lazy=False) == obj2.url_id(lazy=False) # A variation such as providing a password or altering the path makes the # url_id() different: obj2 = Apprise.instantiate("json://user@127.0.0.1/path2/?arg=") # path assert obj1.url_id() != obj2.url_id() obj2 = Apprise.instantiate( "jsons://user@127.0.0.1/path/?arg=" ) # secure flag assert obj1.url_id() != obj2.url_id() obj2 = Apprise.instantiate("json://user2@127.0.0.1/path/?arg=") # user assert obj1.url_id() != obj2.url_id() obj2 = Apprise.instantiate("json://user@127.0.0.1:8080/path/?arg=") # port assert obj1.url_id() != obj2.url_id() obj2 = Apprise.instantiate( "json://user:pass@127.0.0.1/path/?arg=" ) # password assert obj1.url_id() != obj2.url_id() # Leverage salt setting asset = AppriseAsset(storage_salt="abcd") obj2 = Apprise.instantiate("json://user@127.0.0.1/path/", asset=asset) assert obj1.url_id(lazy=False) != obj2.url_id(lazy=False) asset = AppriseAsset(storage_salt=b"abcd") # same salt value produces a match again obj1 = Apprise.instantiate("json://user@127.0.0.1/path/", asset=asset) assert obj1.url_id() == obj2.url_id() # We'll add a good notification to our list class TesNoURLID(NotifyBase): """This class is just sets a use case where we don't return a url_identifier.""" # we'll use this as a key to make our service easier to find # in the next part of the testing service_name = "nourl" _url_identifier = False def send(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True @staticmethod def parse_url(url): return NotifyBase.parse_url(url, verify_host=False) @property def url_identifier(self): """No URL Identifier.""" return self._url_identifier N_MGR["nourl"] = TesNoURLID # setting URL Identifier to False disables the generator url = "nourl://" obj = Apprise.instantiate(url) # No generation takes place assert obj.url_id() is None # # Dictionary Testing # obj._url_identifier = { "abc": "123", "def": b"\0", "hij": 42, "klm": object, } # call uses cached value (from above) assert obj.url_id() is None # Tests dictionary key generation assert obj.url_id(lazy=False) is not None # List/Set/Tuple Testing # obj1 = Apprise.instantiate(url) obj1._url_identifier = ["123", b"\0", 42, object] # Tests dictionary key generation assert obj1.url_id() is not None obj2 = Apprise.instantiate(url) obj2._url_identifier = ("123", b"\0", 42, object) assert obj2.url_id() is not None assert obj1.url_id() == obj2.url_id() obj3 = Apprise.instantiate(url) obj3._url_identifier = {"123", b"\0", 42, object} assert obj3.url_id() is not None obj = Apprise.instantiate(url) obj._url_identifier = b"test" assert obj.url_id() is not None obj = Apprise.instantiate(url) obj._url_identifier = "test" assert obj.url_id() is not None # Testing Garbage for x in (31, object, 43.1): obj = Apprise.instantiate(url) obj._url_identifier = x assert obj.url_id() is not None def test_apprise_notify_formats(): """ API: Apprise() Input Formats tests """ # Need to set async_mode=False to call notify() instead of async_notify(). asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) # no items assert len(a) == 0 class TextNotification(NotifyBase): # set our default notification format notify_format = NotifyFormat.TEXT def __init__(self, **kwargs): super().__init__(**kwargs) def notify(self, **kwargs): # Pretend everything is okay return True class HtmlNotification(NotifyBase): # set our default notification format notify_format = NotifyFormat.HTML def __init__(self, **kwargs): super().__init__(**kwargs) def notify(self, **kwargs): # Pretend everything is okay return True class MarkDownNotification(NotifyBase): # set our default notification format notify_format = NotifyFormat.MARKDOWN def __init__(self, **kwargs): super().__init__(**kwargs) def notify(self, **kwargs): # Pretend everything is okay return True # Store our notifications into our schema map N_MGR["text"] = TextNotification N_MGR["html"] = HtmlNotification N_MGR["markdown"] = MarkDownNotification # Test Markdown; the above calls the markdown because our good:// # defined plugin above was defined to default to HTML which triggers # a markdown to take place if the body_format specified on the notify # call assert a.add("html://localhost") is True assert a.add("html://another.server") is True assert a.add("html://and.another") is True assert a.add("text://localhost") is True assert a.add("text://another.server") is True assert a.add("text://and.another") is True assert a.add("markdown://localhost") is True assert a.add("markdown://another.server") is True assert a.add("markdown://and.another") is True assert len(a) == 9 assert ( bool( a.notify( title="markdown", body="## Testing Markdown", body_format=NotifyFormat.MARKDOWN, ) ) is True ) assert ( bool( a.notify( title="text", body="Testing Text", body_format=NotifyFormat.TEXT, ) ) is True ) assert ( bool( a.notify( title="html", body="HTML", body_format=NotifyFormat.HTML ) ) is True ) def test_apprise_multi_format_resolution(caplog): """ API: multi-format notify_format resolution """ # Older pytest versions may leave logging disabled after set_level(). # Override that state only when needed, then restore it below. caplog.set_level(logging.DEBUG, logger=LOGGER_NAME) manual_override = not logging.getLogger(LOGGER_NAME).isEnabledFor( logging.DEBUG ) if manual_override: logging.disable(logging.NOTSET) try: class SingleFormatNotification(NotifyBase): """Test plugin that resolves to one fixed format.""" # Single-format plugins always use their declared format. notify_format = NotifyFormat.HTML def send(self, body, title="", notify_type=None, **kwargs): """Accept the prepared body without doing network work.""" return True class MultiFormatNotification(NotifyBase): """Test plugin that can render either HTML or Markdown.""" # Declares more than one supported format, index[0] is default notify_format = (NotifyFormat.HTML, NotifyFormat.MARKDOWN) received = [] def send(self, body, title="", notify_type=None, **kwargs): """Record fallback sends with no format-specific hook.""" self.received.append(("send", body)) return True def send_markdown( self, body, title="", notify_type=None, **kwargs ): """Record Markdown-specific dispatch.""" self.received.append(("send_markdown", body)) return True # Single-format plugin: _formats()/resolve_format() resolve to the # one declared format no matter what is passed in. single = SingleFormatNotification(host="localhost") assert single._formats() == (NotifyFormat.HTML,) assert single.resolve_format(None) == NotifyFormat.HTML assert single.resolve_format(NotifyFormat.TEXT) == NotifyFormat.HTML assert ( single.resolve_format(NotifyFormat.MARKDOWN) == NotifyFormat.HTML ) # A plain string is accepted assert single.resolve_format("text") == NotifyFormat.HTML # Multi-format plugin, no override, no matching input: default wins multi = MultiFormatNotification(host="localhost") assert multi._formats() == (NotifyFormat.HTML, NotifyFormat.MARKDOWN) assert multi.resolve_format(None) == NotifyFormat.HTML assert multi.resolve_format(NotifyFormat.TEXT) == NotifyFormat.HTML # Multi-format plugin, no override, input aligns to a declared format assert ( multi.resolve_format(NotifyFormat.MARKDOWN) == NotifyFormat.MARKDOWN ) # A plain string is supported resolved = multi.resolve_format("markdown") assert resolved == NotifyFormat.MARKDOWN assert isinstance(resolved, NotifyFormat) # Multi-format plugin, explicit ?format= override in the declared set multi_override = MultiFormatNotification( host="localhost", format="markdown" ) assert multi_override._format_override == NotifyFormat.MARKDOWN assert multi_override.resolve_format(NotifyFormat.HTML) == ( NotifyFormat.MARKDOWN ) # Multi-format plugin, ?format= override NOT in the declared set falls # back through to input alignment, then default, with a debug message. multi_bad_override = MultiFormatNotification( host="localhost", format="text" ) assert multi_bad_override._format_override == NotifyFormat.TEXT caplog.clear() assert multi_bad_override.resolve_format(NotifyFormat.MARKDOWN) == ( NotifyFormat.MARKDOWN ) assert "does not support format" in caplog.text assert "DEBUG" in caplog.text # No declared source format: same fallback, but silent -- matches # the historical no-noise conversion behavior. caplog.clear() assert multi_bad_override.resolve_format(None) == NotifyFormat.HTML assert "does not support format" not in caplog.text # send_{type}() dispatch: send_markdown() defined, used when resolved multi.received.clear() assert ( multi.notify(body="# hi", body_format=NotifyFormat.MARKDOWN) is True ) assert multi.received == [("send_markdown", "# hi")] # send_{type}() dispatch: no send_html() defined, falls back to send() multi.received.clear() assert multi.notify(body="hi", body_format=NotifyFormat.HTML) is True assert multi.received == [("send", "hi")] finally: if manual_override: logging.disable(logging.CRITICAL) def test_apprise_multi_format_conversion_cache(): """ API: multi-format resolution reuses one converted body per resolved target, same as the existing single-format conversion cache. """ asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) class HtmlOnlyNotification(NotifyBase): """Test plugin that resolves to HTML.""" notify_format = NotifyFormat.HTML def send(self, body, title="", notify_type=None, **kwargs): """Accept the prepared body without doing network work.""" return True class MultiFormatNotification(NotifyBase): """Test plugin that also resolves to HTML by default.""" # Resolves to HTML by default, same target as HtmlOnlyNotification notify_format = (NotifyFormat.HTML, NotifyFormat.MARKDOWN) def send(self, body, title="", notify_type=None, **kwargs): """Accept the prepared body without doing network work.""" return True N_MGR["htmlonly"] = HtmlOnlyNotification N_MGR["multi"] = MultiFormatNotification assert a.add("htmlonly://localhost") is True assert a.add("multi://localhost") is True assert len(a) == 2 with mock.patch( "apprise.apprise.convert_between", side_effect=lambda *a, **k: "converted", ) as mock_convert: assert ( bool(a.notify(body="hello", body_format=NotifyFormat.TEXT)) is True ) # Both services resolve to HTML; the body should only be converted # once and reused, exactly like the single-format cache today. assert mock_convert.call_count == 1 def test_apprise_payload_max_size_enforced_before_conversion(): """API: Apply the payload cap before conversion and title merging.""" asset = AppriseAsset(async_mode=False, payload_max_size=20) a = Apprise(asset=asset) class TitlelessNotification(NotifyBase): """Test plugin with no native title field.""" title_maxlen = 0 def __init__(self, *args, **kwargs): super().__init__(**kwargs) self.sent = [] def send(self, body, title="", notify_type=None, **kwargs): """Record what would have gone out over the wire.""" self.sent.append((title, body)) return True N_MGR["titleless"] = TitlelessNotification obj = TitlelessNotification(host="localhost", asset=asset) assert a.add(obj) is True title = "T" * 100 body = "B" * 1000 with mock.patch( "apprise.apprise.convert_between", side_effect=lambda src, dst, content: content, ) as mock_convert: assert bool(a.notify(title=title, body=body)) is True # Conversion only receives content that already fits the input cap. for call in mock_convert.call_args_list: assert len(call.kwargs["content"]) <= 20 # Title markup may add a few characters after the input cap is applied. assert len(obj.sent) == 1 delivered_title, delivered_body = obj.sent[0] assert delivered_title == "" assert len(delivered_body) < 30 def test_apprise_payload_max_size_not_reapplied_after_conversion(): """API: Cap source text once without truncating converted markup.""" class HTMLNotification(NotifyBase): """Test plugin whose declared format is HTML.""" notify_format = NotifyFormat.HTML title_maxlen = 0 def __init__(self, *args, **kwargs): super().__init__(**kwargs) self.sent = [] def send(self, body, title="", notify_type=None, **kwargs): """Record what would have gone out over the wire.""" self.sent.append((title, body)) return True N_MGR["htmltest"] = HTMLNotification # The five-character source fits, although its HTML conversion is longer. asset = AppriseAsset(async_mode=False, payload_max_size=5) a = Apprise(asset=asset) obj = HTMLNotification(host="localhost", asset=asset) assert a.add(obj) is True result = a.notify( title="", body="**x**", body_format=NotifyFormat.MARKDOWN ) assert bool(result) is True assert len(obj.sent) == 1 _, delivered_body = obj.sent[0] assert delivered_body == "

    x

    " # Direct plugin calls still enforce the cap because no earlier step did. obj.sent.clear() assert obj.notify(title="", body="B" * 100) is True assert len(obj.sent) == 1 _, direct_body = obj.sent[0] assert len(direct_body) <= 5 # The old public keyword is ignored, so direct callers cannot use it to # bypass the cap. obj.sent.clear() assert obj.notify(title="", body="B" * 100, payload_precapped=True) is True assert len(obj.sent) == 1 _, guessed_kwarg_body = obj.sent[0] assert len(guessed_kwarg_body) <= 5 def test_apprise_payload_max_size_uses_service_asset_limit(): """API: Apply each service's asset cap before conversion. The service limit still applies when the top-level asset has no cap. """ class TitlelessNotification(NotifyBase): """Test plugin with no native title field.""" title_maxlen = 0 def __init__(self, *args, **kwargs): super().__init__(**kwargs) self.sent = [] def send(self, body, title="", notify_type=None, **kwargs): """Record what would have gone out over the wire.""" self.sent.append((title, body)) return True N_MGR["titleless"] = TitlelessNotification # The top-level asset used by Apprise() itself has the cap disabled. global_asset = AppriseAsset(async_mode=False, payload_max_size=0) a = Apprise(asset=global_asset) # Give this service a separate asset with its own cap. service_asset = AppriseAsset(async_mode=False, payload_max_size=20) obj = TitlelessNotification(host="localhost", asset=service_asset) assert a.add(obj) is True title = "T" * 100 body = "B" * 1000 with mock.patch( "apprise.apprise.convert_between", side_effect=lambda src, dst, content: content, ) as mock_convert: assert bool(a.notify(title=title, body=body)) is True # Conversion receives content trimmed to the service's own cap. for call in mock_convert.call_args_list: assert len(call.kwargs["content"]) <= 20 assert len(obj.sent) == 1 delivered_title, delivered_body = obj.sent[0] assert len(delivered_title) + len(delivered_body) < 30 def test_apprise_payload_max_size_cache_key_includes_buffer_settings(): """API: Services with different payload settings use separate caches.""" class TitlelessNotification(NotifyBase): """Test plugin with no native title field.""" title_maxlen = 0 def __init__(self, *args, **kwargs): super().__init__(**kwargs) self.sent = [] def send(self, body, title="", notify_type=None, **kwargs): """Record what would have gone out over the wire.""" self.sent.append((title, body)) return True N_MGR["titleless"] = TitlelessNotification global_asset = AppriseAsset(async_mode=False) a = Apprise(asset=global_asset) # The second service disables the short-side rule. asset1 = AppriseAsset(async_mode=False, payload_max_size=35) obj1 = TitlelessNotification(host="host1", asset=asset1) assert a.add(obj1) is True asset2 = AppriseAsset( async_mode=False, payload_max_size=35, payload_buffer_threshold=0 ) obj2 = TitlelessNotification(host="host2", asset=asset2) assert a.add(obj2) is True title = "T" * 9 body = "B" * 33 with mock.patch( "apprise.apprise.convert_between", side_effect=lambda src, dst, content: content, ): assert bool(a.notify(title=title, body=body)) is True assert len(obj1.sent) == 1 assert len(obj2.sent) == 1 # With no title field, the different allocations appear in each body. delivered1 = obj1.sent[0][1] delivered2 = obj2.sent[0][1] assert delivered1 != delivered2 def test_apprise_payload_trim_warning(caplog): """API: Log a warning only when a service cap trims the input.""" # Older pytest versions may leave logging disabled after set_level(). # Override that state only when needed, then restore it below. caplog.set_level(logging.WARNING, logger=LOGGER_NAME) manual_override = not logging.getLogger(LOGGER_NAME).isEnabledFor( logging.WARNING ) if manual_override: logging.disable(logging.NOTSET) try: class PlainNotification(NotifyBase): title_maxlen = 10 def send(self, body, title="", notify_type=None, **kwargs): return True N_MGR["plain"] = PlainNotification asset = AppriseAsset(async_mode=False, payload_max_size=5) a = Apprise(asset=asset) obj = PlainNotification(host="localhost", asset=asset) assert a.add(obj) is True caplog.clear() assert bool(a.notify(title="", body="x" * 100)) is True assert "payload_max_size" in caplog.text # A body that already fits the cap logs no warning at all. caplog.clear() assert bool(a.notify(title="", body="x")) is True assert "payload_max_size" not in caplog.text finally: if manual_override: logging.disable(logging.CRITICAL) def test_apprise_multi_format_details(): """ API: Apprise() Details reflect a plugin's real supported formats """ class SingleFormatNotification(NotifyBase): """Test plugin whose public details should stay broad.""" service_name = "Single Format Testing" protocol = "singlefmt" notify_format = NotifyFormat.HTML templates = ("{schema}://{host}",) def send(self, body, title="", notify_type=None, **kwargs): """Accept the prepared body without doing network work.""" return True class MultiFormatNotification(NotifyBase): """Test plugin whose public details expose supported formats.""" service_name = "Multi Format Testing" protocol = "multifmtdetails" # Declaration order matters: markdown is the default (index[0]) notify_format = (NotifyFormat.MARKDOWN, NotifyFormat.HTML) templates = ("{schema}://{host}",) def send(self, body, title="", notify_type=None, **kwargs): """Accept the prepared body without doing network work.""" return True # values/default remain broad for every plugin so consumers that only # know those fields keep seeing the standard Apprise ?format= choices. # The additive `supported` field carries the narrower native subset. single_details = plugins.details(SingleFormatNotification) assert single_details["args"]["format"]["values"] == NOTIFY_FORMATS assert single_details["args"]["format"]["default"] == "html" # `supported` names the subset this plugin actually renders natively: # one entry for a single-format plugin. assert single_details["args"]["format"]["supported"] == ["html"] multi_details = plugins.details(MultiFormatNotification) assert multi_details["args"]["format"]["values"] == NOTIFY_FORMATS assert multi_details["args"]["format"]["default"] == "markdown" # Declaration order preserved, not alphabetically re-sorted assert multi_details["args"]["format"]["supported"] == [ "markdown", "html", ] def test_apprise_asset(tmpdir): """ API: AppriseAsset() object """ a = AppriseAsset(theme="light") # Default theme assert a.theme == "light" # Invalid kw handling with pytest.raises(AttributeError): AppriseAsset(invalid_kw="value") a = AppriseAsset( theme="dark", image_path_mask="/{THEME}/{TYPE}-{XY}{EXTENSION}", image_url_mask="http://localhost/{THEME}/{TYPE}-{XY}{EXTENSION}", ) a.default_html_color = "#abcabc" assert a.color("invalid", tuple) == (171, 202, 188) assert a.color(NotifyType.INFO, tuple) == (58, 163, 227) assert a.color("invalid", int) == 11258556 assert a.color(NotifyType.INFO, int) == 3843043 assert a.color("invalid", None) == "#abcabc" assert a.color(NotifyType.INFO, None) == "#3AA3E3" # None is the default assert a.color(NotifyType.INFO) == "#3AA3E3" # Invalid Type with pytest.raises(ValueError): # The exception we expect since dict is not supported a.color(NotifyType.INFO, dict) # Test our ASCII mappings assert a.ascii("invalid") == "[?]" assert a.ascii(NotifyType.INFO) == "[i]" assert a.ascii(NotifyType.SUCCESS) == "[+]" assert a.ascii(NotifyType.WARNING) == "[~]" assert a.ascii(NotifyType.FAILURE) == "[!]" # Invalid Type with pytest.raises(ValueError): # The exception we expect since dict is not supported a.color(NotifyType.INFO, dict) assert ( a.image_url(NotifyType.INFO, NotifyImageSize.XY_128) == "http://localhost/dark/info-128x128.png" ) # Default image size assert ( a.image_url(NotifyType.INFO) == "http://localhost/dark/info-256x256.png" ) assert ( a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, must_exist=False) == "/dark/info-256x256.png" ) # This path doesn't exist so image_raw will fail (since we just # randompyl picked it for testing) assert a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is None assert ( a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, must_exist=True) is None ) # Create a new object (with our default settings) a = AppriseAsset() # Our default configuration can access our file assert ( a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, must_exist=True) is not None ) assert a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is not None # Create a temporary directory sub = tmpdir.mkdir("great.theme") # Write a file sub.join( f"{NotifyType.INFO.value}-{NotifyImageSize.XY_256.value}.png" ).write("the content doesn't matter for testing.") # Create an asset that will reference our file we just created a = AppriseAsset( theme="great.theme", image_path_mask=( f"{dirname(sub.strpath)}/{{THEME}}/{{TYPE}}-{{XY}}.png" ), ) # We'll be able to read file we just created assert a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is not None # We can retrieve the filename at this point even with must_exist set # to True assert ( a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, must_exist=True) is not None ) # Test case where we can't access the image file with mock.patch("builtins.open", side_effect=OSError()): assert a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is None # Our content is retrivable again assert a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is not None # Disable all image references a = AppriseAsset(image_path_mask=False, image_url_mask=False) # We always return none in these calls now assert a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is None assert a.image_url(NotifyType.INFO, NotifyImageSize.XY_256) is None assert ( a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, must_exist=False) is None ) assert ( a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, must_exist=True) is None ) # Test our default extension out a = AppriseAsset( image_path_mask="/{THEME}/{TYPE}-{XY}{EXTENSION}", image_url_mask="http://localhost/{THEME}/{TYPE}-{XY}{EXTENSION}", default_extension=".jpeg", ) assert ( a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, must_exist=False) == "/default/info-256x256.jpeg" ) assert ( a.image_url(NotifyType.INFO, NotifyImageSize.XY_256) == "http://localhost/default/info-256x256.jpeg" ) # extension support assert ( a.image_path( NotifyType.INFO, NotifyImageSize.XY_128, must_exist=False, extension=".ico", ) == "/default/info-128x128.ico" ) assert ( a.image_url(NotifyType.INFO, NotifyImageSize.XY_256, extension=".test") == "http://localhost/default/info-256x256.test" ) a = AppriseAsset(plugin_paths=("/tmp",)) assert a.plugin_paths == ("/tmp",) def test_apprise_disabled_plugins(): """ API: Apprise() Disabled Plugin States """ # Ensure there are no other drives loaded N_MGR.unload_modules(disable_native=True) assert len(N_MGR) == 0 class TestDisabled01Notification(NotifyBase): """This class is used to test a pre-disabled state.""" # Just flat out disable our service enabled = False # we'll use this as a key to make our service easier to find # in the next part of the testing service_name = "na01" def notify(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["na01"] = TestDisabled01Notification class TestDisabled02Notification(NotifyBase): """This class is used to test a post-disabled state.""" # we'll use this as a key to make our service easier to find # in the next part of the testing service_name = "na02" def __init__(self, *args, **kwargs): super().__init__(**kwargs) # enable state changes **AFTER** we initialize self.enabled = False def notify(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["na02"] = TestDisabled02Notification # Create our Apprise instance a = Apprise() result = a.details(lang="ca-en", show_disabled=True) assert isinstance(result, dict) assert "schemas" in result assert len(result["schemas"]) == 2 # our na01 is disabled right from the get-go entry = next( (x for x in result["schemas"] if x["service_name"] == "na01"), None ) assert entry is not None assert entry["enabled"] is False plugin = a.instantiate("na01://localhost") # Object is just flat out disabled... nothing is instatiated assert plugin is None # our na02 isn't however until it's initialized; as a result # it get's returned in our result set entry = next( (x for x in result["schemas"] if x["service_name"] == "na02"), None ) assert entry is not None assert entry["enabled"] is True plugin = a.instantiate("na02://localhost") # Object isn't disabled until the __init__() call. But this is still # enough to not instantiate the object: assert plugin is None # If we choose to filter our disabled, we can't unfortunately filter those # that go disabled after instantiation, but we do filter out any that are # already known to not be enabled: result = a.details(lang="ca-en", show_disabled=False) assert isinstance(result, dict) assert "schemas" in result assert len(result["schemas"]) == 1 # We'll add a good notification to our list class TesEnabled01Notification(NotifyBase): """This class is just a simple enabled one.""" # we'll use this as a key to make our service easier to find # in the next part of the testing service_name = "good" def send(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["good"] = TesEnabled01Notification # The last thing we'll simulate is a case where the plugin is just # disabled at a later time long into it's life. this is just to allow # administrators to stop the flow of their notifications for their own # given reasons. plugin = a.instantiate("good://localhost") assert isinstance(plugin, NotifyBase) # we'll toggle our state plugin.enabled = False # As a result, we can now no longer send a notification: assert bool(plugin.notify("My Message")) is False # As just a proof of how you can toggle the state back: plugin.enabled = True # our notifications will go okay now assert bool(plugin.notify("My Message")) is True # Restore our modules N_MGR.unload_modules() def test_apprise_details(): """ API: Apprise() Details """ # This is a made up class that is just used to verify class TestDetailNotification(NotifyBase): """This class is used to test various configurations supported.""" # Minimum requirements for a plugin to produce details service_name = "Detail Testing" # The default simple (insecure) protocol (used by NotifyMail) protocol = "details" # Set test_bool flag always_true = True always_false = False # Define object templates templates = ( "{schema}://{host}", "{schema}://{host}:{port}", "{schema}://{user}@{host}:{port}", "{schema}://{user}:{pass}@{host}:{port}", ) # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "notype": { # name is a minimum requirement "name": _("no type"), }, "regex_test01": { "name": _("RegexTest"), "type": "string", "regex": r"^[A-Z0-9]$", }, "regex_test02": { "name": _("RegexTest"), # Support regex options too "regex": (r"^[A-Z0-9]$", "i"), }, "regex_test03": { "name": _("RegexTest"), # Support regex option without a second option "regex": r"^[A-Z0-9]$", }, "regex_test04": { # this entry would just end up getting removed "regex": None, }, # List without delimiters (causes defaults to kick in) "mylistA": { "name": "fruit", "type": "list:string", }, # A list with a delimiter list "mylistB": { "name": "softdrinks", "type": "list:string", "delim": ["|", "-"], }, }, ) template_args = dict( NotifyBase.template_args, **{ # Test _exist_if logic "test_exists_if_01": { "name": "Always False", "type": "bool", # Provide a default "default": False, # Base the existance of this key/value entry on the lookup # of this class value at runtime. Hence: # if not NotifyObject.always_false # del this_entry # "_exists_if": "always_false", }, # Test _exist_if logic "test_exists_if_02": { "name": "Always True", "type": "bool", # Provide a default "default": False, # Base the existance of this key/value entry on the lookup # of this class value at runtime. Hence: # if not NotifyObject.always_true # del this_entry # "_exists_if": "always_true", }, # alias_of testing "test_alias_of": {"alias_of": "mylistB", "delim": ("-", " ")}, }, ) def send(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True # Store our good detail notification in our schema map N_MGR["details"] = TestDetailNotification # This is a made up class that is just used to verify class TestReq01Notification(NotifyBase): """This class is used to test various requirement configurations.""" # Set some requirements requirements: ClassVar[RequirementsSpec] = { "packages_required": [ "cryptography <= 3.4", "ultrasync", ], "packages_recommended": "django", } def send(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["req01"] = TestReq01Notification # This is a made up class that is just used to verify class TestReq02Notification(NotifyBase): """This class is used to test various requirement configurations.""" # Just not enabled at all enabled = False # Set some requirements requirements: ClassVar[RequirementsSpec] = { # None and/or [] is implied, but jsut to show that the code won't # crash if explicitly set this way: "packages_required": None, "packages_recommended": [ "cryptography <= 3.4", ], } def send(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["req02"] = TestReq02Notification # This is a made up class that is just used to verify class TestReq03Notification(NotifyBase): """This class is used to test various requirement configurations.""" # Set some requirements requirements: ClassVar[RequirementsSpec] = { # We can over-ride the default details assigned to our plugin if # specified "details": _("some specified requirement details"), # We can set a string value as well (it does not have to be a list) "packages_recommended": "cryptography <= 3.4", } def send(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["req03"] = TestReq03Notification # This is a made up class that is just used to verify class TestReq04Notification(NotifyBase): """This class is used to test a case where our requirements is fixed to a None.""" # This is the same as saying there are no requirements requirements = None def send(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["req04"] = TestReq04Notification # This is a made up class that is just used to verify class TestReq05Notification(NotifyBase): """This class is used to test a case where only packages_recommended is identified.""" requirements: ClassVar[RequirementsSpec] = { # We can set a string value as well (it does not have to be a list) "packages_recommended": "cryptography <= 3.4" } def send(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["req05"] = TestReq05Notification # Create our Apprise instance a = Apprise() # Dictionary response result = a.details() assert isinstance(result, dict) # Test different variations of our call result = a.details(lang="ca-fr") assert isinstance(result, dict) for entry in result["schemas"]: # Verify our key does not exist because we did not ask for it assert "enabled" not in entry assert "requirements" not in entry result = a.details(lang="us-en", show_requirements=True) assert isinstance(result, dict) for entry in result["schemas"]: # Verify our key does not exist because we did not ask for it assert "enabled" not in entry # Requirements are set for display assert "requirements" in entry assert "details" in entry["requirements"] assert "packages_required" in entry["requirements"] assert "packages_recommended" in entry["requirements"] assert isinstance( entry["requirements"]["details"], (str, LazyTranslation) ) assert isinstance(entry["requirements"]["packages_required"], list) assert isinstance(entry["requirements"]["packages_recommended"], list) result = a.details(lang="ca-en", show_disabled=True) assert isinstance(result, dict) for entry in result["schemas"]: # Verify that our plugin state is available to us assert "enabled" in entry assert isinstance(entry["enabled"], bool) # Verify our key does not exist because we did not ask for it assert "requirements" not in entry result = a.details( lang="ca-fr", show_requirements=True, show_disabled=True ) assert isinstance(result, dict) for entry in result["schemas"]: # Plugin States are set for display assert "enabled" in entry assert isinstance(entry["enabled"], bool) # Requirements are set for display assert "requirements" in entry assert "details" in entry["requirements"] assert "packages_required" in entry["requirements"] assert "packages_recommended" in entry["requirements"] assert isinstance( entry["requirements"]["details"], (str, LazyTranslation) ) assert isinstance(entry["requirements"]["packages_required"], list) assert isinstance(entry["requirements"]["packages_recommended"], list) def test_apprise_details_plugin_verification(): """ API: Apprise() Details Plugin Verification """ # Prepare our object a = Apprise() # Details object details = a.details() # Dictionary response assert isinstance(details, dict) # Details object with language defined: details = a.details(lang="en") # Dictionary response assert isinstance(details, dict) # Details object with unsupported language: details = a.details(lang="xx") # Dictionary response assert isinstance(details, dict) # Apprise version assert "version" in details assert details.get("version") == __version__ # Defined schemas identify each plugin assert "schemas" in details assert isinstance(details.get("schemas"), list) # We have an entry per defined plugin assert "asset" in details assert isinstance(details.get("asset"), dict) assert "app_id" in details["asset"] assert "app_desc" in details["asset"] assert "default_extension" in details["asset"] assert "theme" in details["asset"] assert "image_path_mask" in details["asset"] assert "image_url_mask" in details["asset"] assert "image_url_logo" in details["asset"] # Valid Type Regular Expression Checker # Case Sensitive and MUST match the following: is_valid_type_re = re.compile( r"((choice|list):)?(string|bool|int|float|email)" ) # match tokens found in templates so we can cross reference them back # to see if they have a matching argument template_token_re = re.compile(r"{([^}]+)}[^{]*?(?=$|{)") # Define acceptable map_to arguments that can be tied in with the # kwargs function definitions. valid_kwargs = { # General Parameters "user", "password", "port", "host", "schema", "fullpath", # NotifyBase parameters: "tz", "format", "overflow", "emojis", "retry", "wait", "optional", # URLBase parameters: "verify", "redirect", "cto", "rto", "store", } # Valid Schema Entries: valid_schema_keys = ( "name", "private", "required", "type", "values", # The subset of `values` this specific plugin actually renders # natively; only meaningfully narrower than `values` for a # multi-format plugin. See NotifyBase.resolve_format(). "supported", "min", "max", "regex", "default", "list", "delim", "prefix", "map_to", "alias_of", "group", ) for entry in details["schemas"]: # Track the map_to entries (if specified); We need to make sure that # these properly map back map_to_entries = set() # Track the alias_of entries map_to_aliases = set() # A Service Name MUST be defined assert "service_name" in entry assert isinstance(entry["service_name"], (str, LazyTranslation)) # Acquire our protocols protocols = parse_list(entry["protocols"], entry["secure_protocols"]) # At least one schema/protocol MUST be defined assert len(protocols) > 0 # our details assert "details" in entry assert isinstance(entry["details"], dict) # All schema details should include args for section in ["kwargs", "args", "tokens"]: assert section in entry["details"] assert isinstance(entry["details"][section], dict) for key, arg in entry["details"][section].items(): # Validate keys (case-sensitive) assert len([k for k in arg if k not in valid_schema_keys]) == 0 # Test our argument assert isinstance(arg, dict) if "alias_of" not in arg: # Minimum requirement of an argument assert "name" in arg assert isinstance(arg["name"], str) assert "type" in arg assert isinstance(arg["type"], str) assert is_valid_type_re.match(arg["type"]) is not None if "min" in arg: assert arg["type"].endswith("float") or arg[ "type" ].endswith("int") assert isinstance(arg["min"], (int, float)) if "max" in arg: # If a min and max was specified, at least check # to confirm the min is less then the max assert arg["min"] < arg["max"] if "max" in arg: assert arg["type"].endswith("float") or arg[ "type" ].endswith("int") assert isinstance(arg["max"], (int, float)) if "private" in arg: assert isinstance(arg["private"], bool) if "required" in arg: assert isinstance(arg["required"], bool) if "prefix" in arg: assert isinstance(arg["prefix"], str) if section == "kwargs": # The only acceptable prefix types for kwargs assert arg["prefix"] in (":", "+", "-") else: # kwargs requires that the 'prefix' is defined assert section != "kwargs" if "map_to" in arg: # must be a string assert isinstance(arg["map_to"], str) # Track our map_to object map_to_entries.add(arg["map_to"]) else: map_to_entries.add(key) # Some verification if arg["type"].startswith("choice"): # choice:bool is redundant and should be swapped to # just bool assert not arg["type"].endswith("bool") # Choices require that a values list is provided assert "values" in arg assert isinstance( arg["values"], (list, tuple, frozenset, set) ) assert len(arg["values"]) > 0 # Test default if "default" in arg: # if a default is provided on a choice object, # it better be in the list of values assert arg["default"] in arg["values"] if arg["type"].startswith("bool"): # Boolean choices are less restrictive but require a # default value assert "default" in arg assert isinstance(arg["default"], bool) if "regex" in arg: # Regex must ALWAYS be in the format (regex, option) assert isinstance(arg["regex"], (tuple, list)) assert len(arg["regex"]) == 2 assert isinstance(arg["regex"][0], str) assert arg["regex"][1] is None or isinstance( arg["regex"][1], str ) # Compile the regular expression to verify that it is # valid try: re.compile(arg["regex"][0]) except Exception as e: # Treat any compilation failure as invalid. pytest.fail( "{} is an invalid regex ({}: {})".format( arg["regex"][0], type(e).__name__, e ) ) # Regex should always start and/or end with ^/$ assert ( re.match(r"^\^.+?$", arg["regex"][0]) is not None ) assert ( re.match(r"^.+?\$$", arg["regex"][0]) is not None ) if arg["type"].startswith("list"): # Delimiters MUST be defined assert "delim" in arg assert isinstance(arg["delim"], (list, tuple)) assert len(arg["delim"]) > 0 else: # alias_of is in the object # Ensure we're not already in the tokens section # The alias_of object has no value here assert section != "tokens" # must be a string assert isinstance(arg["alias_of"], (str, list, tuple, set)) aliases = ( [arg["alias_of"]] if isinstance(arg["alias_of"], str) else arg["alias_of"] ) for alias_of in aliases: # Track our alias_of object map_to_aliases.add(alias_of) # We can't be an alias_of ourselves if key == alias_of: # This is acceptable as long as we exist in the # tokens table because that is truely what we map # back to assert key in entry["details"]["tokens"] else: # Throw the problem into an assert tag for # debugging purposes... the mapping is not # acceptable assert key != alias_of # alias_of always references back to tokens assert ( alias_of in entry["details"]["tokens"] or alias_of in entry["details"]["args"] ) # Find a list directive in our tokens t_match = ( entry["details"]["tokens"] .get(alias_of, {}) .get("type", "") .startswith("list") ) a_match = ( entry["details"]["args"] .get(alias_of, {}) .get("type", "") .startswith("list") ) if not (t_match or a_match): # Ensure the only token we have is the alias_of # hence record should look like as example): # { # 'token': { # 'alias_of': 'apitoken', # }, # } # # Or if it can represent more then one entry; in # this case, one must define a name (to define # grouping). # { # 'token': { # 'name': 'Tokens', # 'alias_of': ('apitoken', 'webtoken'), # }, # } if isinstance(arg["alias_of"], str): assert len(entry["details"][section][key]) == 1 else: # is tuple,list, or set assert len(entry["details"][section][key]) == 2 # Must have a name defined to define grouping assert "name" in entry["details"][section][key] else: # We're a list, we allow up to 2 variables # Obviously we have the alias_of entry; that's why # were at this part of the code. But we can # additionally provide a 'delim' over-ride. assert len(entry["details"][section][key]) <= 2 if len(entry["details"][section][key]) == 2: # Verify that it is in fact the 'delim' tag assert ( "delim" in entry["details"][section][key] ) # If we do have a delim value set, it must be # of a list/set/tuple type assert isinstance( entry["details"][section][key]["delim"], (tuple, set, list), ) spec = inspect.getfullargspec(N_MGR._schema_map[protocols[0]].__init__) function_args = ( (set(parse_list(spec.varkw)) - {"kwargs"}) | (set(spec.args) - {"self"}) | valid_kwargs ) # Iterate over our map_to_entries and make sure that everything # maps to a function argument for arg in map_to_entries: if arg not in function_args: # This print statement just makes the error easier to # troubleshoot raise AssertionError( "{}.__init__() expects a {}=None entry according to " "template configuration".format( N_MGR._schema_map[protocols[0]].__name__, arg ) ) # Iterate over all of the function arguments and make sure that # it maps back to a key function_args -= valid_kwargs for arg in function_args: if arg not in map_to_entries: err = N_MGR._schema_map[protocols[0]].__name__.__init__(arg) raise AssertionError( f"{err} found but not defined in the template " "configuration" ) # Iterate over our map_to_aliases and make sure they were defined in # either the as a token or arg for arg in map_to_aliases: assert arg in set(entry["details"]["args"].keys()) | set( entry["details"]["tokens"].keys() ) # Template verification assert "templates" in entry["details"] assert isinstance(entry["details"]["templates"], (set, tuple, list)) # Iterate over our templates and parse our arguments for template in entry["details"]["templates"]: # Ensure we've properly opened and closed all of our tokens assert template.count("{") == template.count("}") expected_tokens = template.count("}") args = template_token_re.findall(template) assert expected_tokens == len(args) # Build a cross reference set of our current defined objects defined_tokens = set() for key, arg in entry["details"]["tokens"].items(): defined_tokens.add(key) if "alias_of" in arg: defined_tokens.add(arg["alias_of"]) # We want to make sure all of our defined tokens have been # accounted for in at least one defined template for arg in args: assert arg in set(entry["details"]["args"].keys()) | set( entry["details"]["tokens"].keys() ) # The reverse of the above; make sure that each entry defined # in the template_tokens is accounted for in at least one of # the defined templates assert arg in defined_tokens def test_apprise_details_plugin_raw_template_tokens(): """Require an explicit type in every raw plugin template token. Sanitizing supplies a missing ``string`` default, so this checks the raw declarations first. """ # Use the same valid token types as the plugin verification test. is_valid_type_re = re.compile( r"((choice|list):)?(string|bool|int|float|email)" ) for plugin in N_MGR.plugins(): label = plugin.__name__ for section_name, raw in ( ("template_tokens", plugin.template_tokens), ("template_args", plugin.template_args), ): for key, token in raw.items(): # alias_of entries are intentionally type-free; skip them if "alias_of" in token: continue assert "type" in token, ( f"{label}.{section_name}[{key!r}] is missing a" " 'type' field. Every non-alias token must declare" " its type explicitly -- _sanitize_token silently" " fills in 'string' and the omission goes" " undetected by the post-processed details() checks." ) assert isinstance(token["type"], str) and ( is_valid_type_re.match(token["type"]) is not None ), ( f"{label}.{section_name}[{key!r}]['type'] =" f" {token['type']!r} is not a valid type string." " Expected one of: string, bool, int, float," " choice:string, list:string, etc." ) def test_apprise_details_email_token_types(): """Ensure URL email fields declare their token type as ``email``. Code that builds URLs from this metadata must leave ``@`` unescaped. Add fields only after confirming that behavior in the plugin's ``url()`` method; a field name alone is not enough. """ # (plugin class name, template_tokens key) expected_email_tokens = { ("NotifySES", "from_email"), ("NotifyPostmark", "from_email"), ("NotifyPostmark", "target_email"), ("NotifyResend", "from_addr"), ("NotifyResend", "target_email"), ("NotifyMailerSend", "from_email"), ("NotifyMailerSend", "target_email"), ("NotifyOffice365", "target_email"), ("NotifyOctopush", "api_login"), ("NotifyThreema", "target_email"), ("NotifyBrevo", "from_email"), ("NotifyBrevo", "target_email"), ("NotifySendGrid", "from_email"), ("NotifySendGrid", "target_email"), ("NotifyMailgun", "target_email"), ("NotifyOneSignal", "target_email"), ("NotifyPingram", "target_email"), ("NotifyPushBullet", "target_email"), ("NotifySendPulse", "target_email"), ("NotifySlack", "target_email"), ("NotifyTwist", "email"), ("NotifyVoipms", "email"), ("NotifyZulip", "target_user"), } plugins_by_name = {plugin.__name__: plugin for plugin in N_MGR.plugins()} found = set() for plugin_name, token_key in expected_email_tokens: assert plugin_name in plugins_by_name, ( f"{plugin_name} was not found -- has it been renamed/removed?" " Update expected_email_tokens to match." ) plugin = plugins_by_name[plugin_name] assert token_key in plugin.template_tokens, ( f"{plugin_name}.template_tokens no longer defines" f" {token_key!r} -- update expected_email_tokens to match." ) assert plugin.template_tokens[token_key]["type"] == "email", ( f"{plugin_name}.template_tokens[{token_key!r}]['type'] must" " stay 'email' -- this field's own url() preserves a literal" " '@' when rebuilding the URL, so a client keying off this" " metadata needs the same signal." ) found.add((plugin_name, token_key)) # Scan all declared email tokens so new fields must join the allowlist. for plugin in N_MGR.plugins(): for key, token in plugin.template_tokens.items(): if token.get("type") == "email": assert (plugin.__name__, key) in found, ( f"{plugin.__name__}.template_tokens[{key!r}] is tagged" " 'email' but isn't listed in expected_email_tokens --" " add it there (after confirming this field's own" " url() actually needs it, per this test's docstring)." ) def test_apprise_send_format_handlers_match_notify_format(): """ API: send_text()/send_html()/send_markdown() vs notify_format sanity A format-specific send_() method must be reachable through the plugin's notify_format declaration. """ for plugin in N_MGR.plugins(): label = plugin.__name__ # Only consider format-specific methods defined directly on this # class (not merely inherited), so a subclass that narrows # notify_format isn't blamed for a handler its parent defined. declared_handlers = { fmt for fmt in NOTIFY_FORMATS if f"send_{fmt}" in vars(plugin) } if not declared_handlers: continue # notify_format may be a single NotifyFormat or a tuple; resolve # it the same way NotifyBase._formats() does. supported = set(parse_list(plugin.notify_format, sort=False)) missing = declared_handlers - supported assert not missing, ( f"{label} defines send_{next(iter(missing))}() but " f"{next(iter(missing))!r} is not declared in its " f"notify_format {plugin.notify_format!r}; the handler can " "never be reached." ) def test_apprise_multi_format_plugins_no_scalar_comparison(): """Multi-format plugins must resolve before scalar comparisons.""" scalar_compare_re = re.compile(r"self\.notify_format\s*==") for plugin in N_MGR.plugins(): formats = parse_list(plugin.notify_format, sort=False) if len(formats) <= 1: # Single-format plugins can compare their scalar directly. continue try: source = inspect.getsource(plugin) except (OSError, TypeError): # Dynamic plugins have no source file to inspect. continue assert not scalar_compare_re.search(source), ( f"{plugin.__name__} declares a multi-format notify_format " f"{plugin.notify_format!r} but compares self.notify_format " "directly somewhere in its source; use " "self.resolve_format(body_format) instead, since a tuple " "never equals a scalar and the comparison can never be " "True." ) @mock.patch("requests.request") @mock.patch("asyncio.gather", wraps=asyncio.gather) @mock.patch( "concurrent.futures.ThreadPoolExecutor", wraps=concurrent.futures.ThreadPoolExecutor, ) def test_apprise_async_mode(mock_threadpool, mock_gather, mock_request): """ API: Apprise() async_mode tests """ mock_request.return_value.status_code = requests.codes.ok # Define some services services = [ "xml://localhost", "json://localhost", ] # Services use asynchronous mode by default. asset = AppriseAsset() assert asset.async_mode is True # Build an Apprise object with that shared asset. a = Apprise(asset=asset) # Add both test services. a.add(services=services) # Both service URLs should load. assert len(a) == 2 # Both services share the asset's asynchronous setting. for service in a: assert service.asset.async_mode is True # Reset the process-wide pool so this test observes its first creation. # mock.patch restores any pool that an earlier test created. with mock.patch("apprise.apprise._shared_executor", None): # Send both notifications asynchronously. assert bool(a.notify("async")) is True # The first send creates the shared worker pool once. assert mock_threadpool.call_count == 1 mock_threadpool.reset_mock() # Repeat the test with asynchronous mode disabled. asset = AppriseAsset(async_mode=False) assert asset.async_mode is False # Build a fresh Apprise object with the new asset. a = Apprise(asset=asset) # The new object keeps the disabled setting. assert a.asset.async_mode is False # Add both test services again. a.add(services=services) # Both service URLs should load. assert len(a) == 2 # Both services now use ordered delivery. for service in a: assert service.asset.async_mode is False # Send both notifications one at a time. assert bool(a.notify("sync")) is True # Ordered delivery does not need asyncio.gather(). assert mock_gather.call_count == 0 mock_gather.reset_mock() # Each loaded service exposes the same disabled setting. assert a[0].asset.async_mode is False assert a[1].asset.async_mode is False # Changing the shared asset through one service affects both. a[0].asset.async_mode = True assert a[0].asset.async_mode is True # The second service sees the same shared asset change. assert a[1].asset.async_mode is True # Give only the second service its own asset. a[1].asset = AppriseAsset(async_mode=False) assert a[0].asset.async_mode is True assert a[1].asset.async_mode is False # Send one service each way in the same batch. assert bool(a.notify("a mixed batch")) is True # The single async-mode service still goes through the thread # pool even though it's alone in its batch: AppriseAsset. # service_timeout defaults to 60s, so a deadline applies, and # only a thread-pool dispatch can abandon a service that runs # past its deadline. No NEW executor is constructed though -- # the one from the first dispatch above is shared and reused. assert mock_threadpool.call_count == 0 # Disable this service's own timeout budget entirely -- with no # deadline of any kind in effect, a single async-mode service has # nothing that could ever need abandoning, so the thread-pool # shortcut applies again (no executor use at all, shared or not). a[0].asset = AppriseAsset(async_mode=True, service_timeout=0) a[1].asset = AppriseAsset(async_mode=False) assert bool(a.notify("a mixed batch, no timeout")) is True assert mock_threadpool.call_count == 0 # Shut down the executor constructed inside this patched block # before it's discarded -- otherwise its worker threads would # sit idle forever with nothing left referencing them once # mock.patch restores apprise.apprise._shared_executor below. import apprise.apprise as apprise_module if apprise_module._shared_executor is not None: apprise_module._shared_executor.shutdown(wait=False) def test_notify_matrix_dynamic_importing(tmpdir): """ API: Apprise() Notify Matrix Importing """ # Make our new path valid suite = tmpdir.mkdir("apprise_notify_test_suite") suite.join("__init__.py").write("") module_name = "badnotify" # Update our path to point to our new test suite sys.path.insert(0, str(suite)) # Create a base area to work within base = suite.mkdir(module_name) base.join("__init__.py").write("") # Test no app_id base.join("NotifyBadFile1.py").write( cleandoc(""" class NotifyBadFile1: pass """) ) # No class of the same name base.join("NotifyBadFile2.py").write( cleandoc(""" class BadClassName: pass """) ) # Exception thrown base.join("NotifyBadFile3.py").write("""raise ImportError()""") # Utilizes a schema:// already occupied (as string) base.join("NotifyGoober.py").write( cleandoc(""" from apprise import NotifyBase class NotifyGoober(NotifyBase): # This class tests the fact we have a new class name, but we're # trying to over-ride items previously used # The default simple (insecure) protocol (used by NotifyMail) protocol = ('mailto', 'goober') # The default secure protocol (used by NotifyMail) secure_protocol = 'mailtos' @staticmethod def parse_url(url, *args, **kwargs): # always parseable return ConfigBase.parse_url(url, verify_host=False) """) ) # Utilizes a schema:// already occupied (as tuple) base.join("NotifyBugger.py").write( cleandoc(""" from apprise import NotifyBase class NotifyBugger(NotifyBase): # This class tests the fact we have a new class name, but we're # trying to over-ride items previously used # The default simple (insecure) protocol (used by NotifyMail), the # other isn't protocol = ('mailto', 'bugger-test' ) # The default secure protocol (used by NotifyMail), the other isn't secure_protocol = ('mailtos', ['garbage']) @staticmethod def parse_url(url, *args, **kwargs): # always parseable return ConfigBase.parse_url(url, verify_host=False) """) ) N_MGR.load_modules(path=str(base), name=module_name) def test_apprise_ipv6_host_preserved(): """ API: an IPv6 host in a URL reaches the plugin intact """ # Compressed IPv6 addresses for url, host in ( ("json://[2001:db8::1]:8080/path", "[2001:db8::1]"), ("json://[fd00::5]:8080/path", "[fd00::5]"), ("json://[::1]:8080/path", "[::1]"), ( "form://[2001:db8:85a3::8a2e:370:7334]/p", "[2001:db8:85a3::8a2e:370:7334]", ), ( "xml://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]/p", "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]", ), ): obj = Apprise.instantiate(url, suppress_exceptions=False) assert obj is not None assert obj.host == host # The generated url carries the same address, so re-loading it keeps # pointing at the same place assert host in obj.url() reloaded = Apprise.instantiate(obj.url(), suppress_exceptions=False) assert reloaded.host == host assert reloaded.url() == obj.url() def test_apprise_ipv6_host_invalid(): """ API: a malformed IPv6 host in a URL is refused """ # More than one :: is not a valid address, so there is nothing sensible # to deliver to for url in ( "json://[2001:db8::1::2]:8080/path", "json://[2001:db8::12345]/path", "json://[not:an:address]/path", ): assert Apprise.instantiate(url, suppress_exceptions=True) is None caronc-apprise-dfec414/tests/test_apprise_asset.py000066400000000000000000000353211526720371000225400ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from datetime import timezone, tzinfo import logging import sys from zoneinfo import ZoneInfo import pytest from apprise.asset import AppriseAsset from apprise.exception import AppriseImproperlyConfigured logging.disable(logging.CRITICAL) # Ensure we don't create .pyc files for these tests sys.dont_write_bytecode = True def test_timezone(): "asset: timezone() testing" asset = AppriseAsset(timezone="utc") assert isinstance(asset.tzinfo, tzinfo) # Default (uses system value) asset = AppriseAsset(timezone=None) assert isinstance(asset.tzinfo, tzinfo) # Timezone can also already be a tzinfo object asset = AppriseAsset(timezone=timezone.utc) assert isinstance(asset.tzinfo, tzinfo) asset = AppriseAsset(timezone=ZoneInfo("America/Toronto")) assert isinstance(asset.tzinfo, tzinfo) with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(timezone=object) with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(timezone="invalid") # The private field cannot bypass the validated timezone argument. with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(_tzinfo=timezone.utc) def test_language(): "asset: language() testing" # None leaves the detection of the language up to Apprise assert AppriseAsset().language is None # Both the 2 and the 5 letter forms are accepted; a region is stored # upper case and the language lower case no matter how it arrives for entry in ("en", "EN", " en "): assert AppriseAsset(language=entry).language == "en" for entry in ("en-CA", "en_CA", "EN-ca", "en_ca"): assert AppriseAsset(language=entry).language == "en_CA" # A script or any other extra part is dropped, leaving the language assert AppriseAsset(language="zh-Hans").language == "zh" assert AppriseAsset(language="zh-Hans-CN").language == "zh" # Only the first entry of an Accept-Language styled value is used asset = AppriseAsset(language="en-US,en;q=0.9,de;q=0.5") assert asset.language == "en_US" # An empty value is the same as not providing one at all assert AppriseAsset(language="").language is None assert AppriseAsset(language=" ").language is None # Anything we can not make sense of is a configuration error for entry in ("garbage", "!", 42, {}, object()): with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(language=entry) # The language is locked in once the asset is built; a new language # means a new asset with pytest.raises(AttributeError): AppriseAsset().language = "fr" # The private field cannot bypass the validated language argument. with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(_language="garbage") def test_service_timeout(): "asset: service_timeout() testing" # Default value is 60 seconds, defined directly on AppriseAsset asset = AppriseAsset() assert asset._service_timeout == 60.0 # Accepts an int and stores it as a float asset = AppriseAsset(service_timeout=10) assert asset._service_timeout == 10.0 assert isinstance(asset._service_timeout, float) # Accepts a float directly asset = AppriseAsset(service_timeout=12.5) assert asset._service_timeout == 12.5 # 0 is valid -- it disables the timeout entirely asset = AppriseAsset(service_timeout=0) assert asset._service_timeout == 0.0 # Negative values are rejected with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(service_timeout=-1) # Non-numeric types are rejected with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(service_timeout="invalid") # Booleans are rejected even though bool is technically an int subclass with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(service_timeout=True) # inf might look like a second way to spell "unbounded" (0 is the # documented one), but concurrent.futures.Future.result(timeout= # float("inf")) raises OverflowError on some platforms, silently # turning a successful notification into a reported FAILURE -- so # it's rejected outright, same as any other non-finite value. with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(service_timeout=float("inf")) with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(service_timeout=float("-inf")) # NaN fails every ordering comparison, so it would otherwise slip # past a plain "< 0" check and silently disable the timeout as an # accidental side effect of its comparison semantics. with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(service_timeout=float("nan")) # The private field cannot bypass the validated public argument. with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(_service_timeout=99.0) def test_payload_max_size(): "asset: payload_max_size() testing" # Zero disables the cap by default. asset = AppriseAsset() assert asset._payload_max_size == 0 # Accepts a positive int, turning the cap on asset = AppriseAsset(payload_max_size=500) assert asset._payload_max_size == 500 # 0 is valid -- it is also the default, and disables the cap entirely asset = AppriseAsset(payload_max_size=0) assert asset._payload_max_size == 0 # Negative values are rejected with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(payload_max_size=-1) # Non-int types are rejected -- a float character count makes no sense with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(payload_max_size=12.5) with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(payload_max_size="invalid") # Booleans are rejected even though bool is technically an int subclass with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(payload_max_size=True) # The private field cannot bypass the validated public argument. with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(_payload_max_size=250) def test_payload_buffer_threshold_and_min_buffer(): "asset: payload_buffer_threshold() / payload_min_buffer() testing" # Defaults match the documented values. asset = AppriseAsset() assert asset._payload_buffer_threshold == 10 assert asset._payload_min_buffer == 25 # Public constructor arguments configure both settings. asset = AppriseAsset(payload_buffer_threshold=3, payload_min_buffer=5) assert asset._payload_buffer_threshold == 3 assert asset._payload_min_buffer == 5 # 0 is valid for both. asset = AppriseAsset(payload_buffer_threshold=0, payload_min_buffer=0) assert asset._payload_buffer_threshold == 0 assert asset._payload_min_buffer == 0 # Negative values are rejected. with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(payload_buffer_threshold=-1) with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(payload_min_buffer=-1) # Non-int types are rejected. with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(payload_buffer_threshold=12.5) with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(payload_min_buffer="invalid") # Booleans are rejected even though bool is technically an int subclass. with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(payload_buffer_threshold=True) with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(payload_min_buffer=True) # Private fields cannot bypass the validated public arguments. with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(_payload_buffer_threshold=2) with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(_payload_min_buffer=7) def test_result_log_storage_sizes(): """Result-log storage accepts non-negative byte limits.""" asset = AppriseAsset() assert asset.result_log_memory_size == 0 assert asset.result_log_disk_size == 0 asset = AppriseAsset( result_log_memory_size=2048, result_log_disk_size=4096 ) assert asset.result_log_memory_size == 2048 assert asset.result_log_disk_size == 4096 for name in ("result_log_memory_size", "result_log_disk_size"): with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(**{name: -1}) for value in (True, 1.5, "1024"): with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(**{name: value}) with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(**{"_{}".format(name): 1}) def test_enforce_payload_max_size(): "asset: enforce_payload_max_size() testing" # Disabled (the default): nothing is ever trimmed. asset = AppriseAsset() title, body = asset.enforce_payload_max_size("T" * 100, "B" * 1000) assert title == "T" * 100 assert body == "B" * 1000 # Already within budget: left untouched. asset = AppriseAsset(payload_max_size=2000) title, body = asset.enforce_payload_max_size("T" * 100, "B" * 1000) assert title == "T" * 100 assert body == "B" * 1000 def test_enforce_payload_max_size_absent_side(): "asset: enforce_payload_max_size() never trims a missing side" asset = AppriseAsset(payload_max_size=20) # An empty title never competes with the body for room. title, body = asset.enforce_payload_max_size("", "B" * 1000) assert title == "" assert len(body) == 20 # An empty body never competes with the title for room. title, body = asset.enforce_payload_max_size("T" * 1000, "") assert len(title) == 20 assert body == "" def test_enforce_payload_max_size_short_side_guaranteed(): """asset: keep a short side whole when the other minimum fits""" # A short title stays whole; the body receives the remaining room. asset = AppriseAsset(payload_max_size=40) title, body = asset.enforce_payload_max_size("T" * 9, "B" * 1000) assert title == "T" * 9 assert len(body) == 31 # The same rule keeps a short body whole. title, body = asset.enforce_payload_max_size("T" * 40, "hi!") assert title == "T" * 37 assert body == "hi!" # A title exactly at the buffer width still qualifies. title, body = asset.enforce_payload_max_size("T" * 10, "B" * 1000) assert title == "T" * 10 assert len(body) == 30 def test_enforce_payload_max_size_guarantee_unaffordable(): """asset: use proportional sharing when a guarantee cannot fit""" # Two long sides share the cap proportionally. asset = AppriseAsset(payload_max_size=20) title, body = asset.enforce_payload_max_size("T" * 20, "B" * 18) assert len(title) + len(body) == 20 assert len(title) == 10 assert len(body) == 10 # The title is short, but the body minimum cannot fit beside it. tiny_asset = AppriseAsset(payload_max_size=11) title, body = tiny_asset.enforce_payload_max_size("T" * 10, "B" * 1000) assert len(title) + len(body) == 11 assert len(title) == 1 assert len(body) == 10 # The mirrored short-body rule also falls back when space is tight. balanced_asset = AppriseAsset(payload_max_size=15) title, body = balanced_asset.enforce_payload_max_size("T" * 15, "B" * 10) assert len(title) + len(body) == 15 assert len(title) == 9 assert len(body) == 6 def test_enforce_payload_max_size_proportional_tier(): "asset: enforce_payload_max_size() proportional split for two long sides" # Both sides are longer than the buffer, so title and body share the # cap in proportion to their original lengths. asset = AppriseAsset(payload_max_size=200) title, body = asset.enforce_payload_max_size("T" * 100, "B" * 1000) assert len(title) + len(body) == 200 # The title receives 100/1100 of the budget, rounded down. assert len(title) == 18 assert len(body) == 182 # A title-heavy split keeps most of the budget for the title. heavy_asset = AppriseAsset(payload_max_size=50) title, body = heavy_asset.enforce_payload_max_size("T" * 100, "B" * 60) assert len(title) + len(body) == 50 assert len(title) > len(body) # Keep one title character when a title longer than the buffer still # has its proportional share round down to zero. title, body = asset.enforce_payload_max_size("T" * 11, "B" * 2200) assert title == "T" assert len(body) == 199 def test_enforce_payload_max_size_configurable_tiers(): "asset: payload_buffer_threshold / payload_min_buffer are tunable" # Public constructor arguments can override both settings. asset = AppriseAsset( payload_max_size=12, payload_buffer_threshold=3, payload_min_buffer=5, ) assert asset._payload_buffer_threshold == 3 assert asset._payload_min_buffer == 5 # Both sides exceed the custom buffer threshold (3), so this falls # through to the proportional split. title, body = asset.enforce_payload_max_size("T" * 4, "B" * 20) assert len(title) == 2 assert len(body) == 10 # A title at or below the custom buffer threshold now qualifies for # the whole-and-guaranteed rule, using the custom (smaller) minimum. custom_asset = AppriseAsset( payload_max_size=12, payload_buffer_threshold=5, payload_min_buffer=3, ) title, body = custom_asset.enforce_payload_max_size("T" * 4, "B" * 20) assert title == "T" * 4 assert len(body) == 8 caronc-apprise-dfec414/tests/test_apprise_attachments.py000066400000000000000000000337401526720371000237370ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from inspect import cleandoc import json # Disable logging for a cleaner testing output import logging from os.path import dirname, getsize, join from unittest import mock import pytest import requests from apprise import Apprise, AppriseAsset, AttachmentManager from apprise.apprise_attachment import AppriseAttachment from apprise.attachment import AttachBase from apprise.common import ContentLocation from apprise.exception import AppriseImproperlyConfigured from apprise.logger import LogCapture logging.disable(logging.CRITICAL) TEST_VAR_DIR = join(dirname(__file__), "var") # Grant access to our Attachment Manager Singleton A_MGR = AttachmentManager() def test_apprise_attachment(): """ API: AppriseAttachment basic testing """ # Create ourselves an attachment object aa = AppriseAttachment() # There are no attachents loaded assert len(aa) == 0 # Object can be directly checked as a boolean; response is False # when there are no entries loaded assert not aa # An attachment object using a custom Apprise Asset object # Set a cache expiry of 5 minutes (300 seconds) aa = AppriseAttachment(asset=AppriseAsset(), cache=300) # still no attachments added assert len(aa) == 0 # Add a file by it's path path = join(TEST_VAR_DIR, "apprise-test.gif") assert aa.add(path) # There is now 1 attachment assert len(aa) == 1 # our attachment took on our cache value assert aa[0].cache == 300 # we can test the object as a boolean and get a value of True now assert aa # Add another entry already in it's AttachBase format response = AppriseAttachment.instantiate(path, cache=True) assert isinstance(response, AttachBase) assert aa.add(response, asset=AppriseAsset()) # There is now 2 attachments assert len(aa) == 2 # Cache is initialized to True assert aa[1].cache is True # Reset our object aa = AppriseAttachment() # We can add by lists as well in a variety of formats attachments = ( path, f"file://{path}?name=newfilename.gif?cache=120", AppriseAttachment.instantiate( f"file://{path}?name=anotherfilename.gif", cache=100 ), ) # Add them assert aa.add(attachments, cache=False) # There is now 3 attachments assert len(aa) == 3 # Take on our fixed cache value of False. # The last entry will have our set value of 100 assert aa[0].cache is False # Even though we set a value of 120, we take on the value of False because # it was forced on the instantiate call assert aa[1].cache is False assert aa[2].cache == 100 # We can pop the last element off of the list as well attachment = aa.pop() assert isinstance(attachment, AttachBase) # we can test of the attachment is valid using a boolean check: assert attachment assert len(aa) == 2 assert attachment.path == path assert attachment.name == "anotherfilename.gif" assert attachment.mimetype == "image/gif" # elements can also be directly indexed assert isinstance(aa[0], AttachBase) assert isinstance(aa[1], AttachBase) with pytest.raises(IndexError): aa[2] # We can iterate over attachments too: for count, a in enumerate(aa): assert isinstance(a, AttachBase) # we'll never iterate more then the number of entries in our object assert count < len(aa) # Get the file-size of our image expected_size = getsize(path) * len(aa) # verify that's what we get as a result assert aa.size() == expected_size # Attachments can also be loaded during the instantiation of the # AppriseAttachment object aa = AppriseAttachment(attachments) # There is now 3 attachments assert len(aa) == 3 # Reset our object aa.clear() assert len(aa) == 0 assert not aa assert aa.add( AppriseAttachment.instantiate( f"file://{path}?name=andanother.png&cache=Yes" ) ) assert aa.add( AppriseAttachment.instantiate( f"file://{path}?name=andanother.png&cache=No" ) ) AppriseAttachment.instantiate( f"file://{path}?name=andanother.png&cache=600" ) assert aa.add( AppriseAttachment.instantiate( f"file://{path}?name=andanother.png&cache=600" ) ) assert len(aa) == 3 assert aa[0].cache is True assert aa[1].cache is False assert aa[2].cache == 600 # Negative cache are not allowed assert not aa.add( AppriseAttachment.instantiate( f"file://{path}?name=andanother.png&cache=-600" ) ) # Invalid cache value assert not aa.add( AppriseAttachment.instantiate( f"file://{path}?name=andanother.png", cache="invalid" ) ) # No length change assert len(aa) == 3 # Reset our object aa.clear() # Garbage in produces garbage out assert aa.add(None) is False assert aa.add(object()) is False assert aa.add(42) is False # length remains unchanged assert len(aa) == 0 # We can add by lists as well in a variety of formats attachments = ( None, object(), 42, "garbage://", ) # Add our attachments assert aa.add(attachments) is False # length remains unchanged assert len(aa) == 0 # Direct construction reports attachments that could not be loaded. with pytest.raises(AppriseImproperlyConfigured): AppriseAttachment("garbage://") # Load our other attachment types aa = AppriseAttachment(location=ContentLocation.LOCAL) # Hosted type won't allow us to import files aa = AppriseAttachment(location=ContentLocation.HOSTED) assert len(aa) == 0 # Add our attachments defined a the head of this function aa.add(attachments) # Our length is still zero because we can't import files in # a hosted environment assert len(aa) == 0 # Inaccessible type prevents the adding of new stuff aa = AppriseAttachment(location=ContentLocation.INACCESSIBLE) assert len(aa) == 0 # Add our attachments defined a the head of this function aa.add(attachments) # Our length is still zero assert len(aa) == 0 with pytest.raises(AppriseImproperlyConfigured): # Invalid location specified AppriseAttachment(location="invalid") # test cases when file simply doesn't exist aa = AppriseAttachment("file://non-existant-file.png") # Our length is still 1 assert len(aa) == 1 # Our object will still return a True assert aa # However our indexed entry will not assert not aa[0] # length will return 0 assert len(aa[0]) == 0 # Total length will also return 0 assert aa.size() == 0 @mock.patch("requests.request") def test_apprise_attachment_truncate(mock_request): """ API: AppriseAttachment when truncation in place """ # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_request.return_value = response # our Apprise Object ap_obj = Apprise() # Add ourselves an object set to truncate ap_obj.add("json://localhost/?method=GET&overflow=truncate") # Create ourselves an attachment object aa = AppriseAttachment() # There are no attachents loaded assert len(aa) == 0 # Object can be directly checked as a boolean; response is False # when there are no entries loaded assert not aa # Add 2 attachments assert aa.add(join(TEST_VAR_DIR, "apprise-test.gif")) assert aa.add(join(TEST_VAR_DIR, "apprise-test.png")) assert mock_request.call_count == 0 assert ap_obj.notify(body="body", title="title", attach=aa) assert mock_request.call_count == 1 # Our first item was truncated, so only 1 attachment details = mock_request.call_args_list[0] assert details[0][0] == "GET" dataset = json.loads(details[1]["data"]) assert len(dataset["attachments"]) == 1 # Reset our object mock_request.reset_mock() # our Apprise Object ap_obj = Apprise() # Add ourselves an object set to upstream ap_obj.add("json://localhost/?method=GET&overflow=upstream") # Create ourselves an attachment object aa = AppriseAttachment() # Add 2 attachments assert aa.add(join(TEST_VAR_DIR, "apprise-test.gif")) assert aa.add(join(TEST_VAR_DIR, "apprise-test.png")) assert mock_request.call_count == 0 assert ap_obj.notify(body="body", title="title", attach=aa) assert mock_request.call_count == 1 # Our item was not truncated, so all attachments details = mock_request.call_args_list[0] assert details[0][0] == "GET" dataset = json.loads(details[1]["data"]) assert len(dataset["attachments"]) == 2 def test_apprise_attachment_instantiate(): """ API: AppriseAttachment.instantiate() """ assert ( AppriseAttachment.instantiate("file://?", suppress_exceptions=True) is None ) assert ( AppriseAttachment.instantiate("invalid://?", suppress_exceptions=True) is None ) class BadAttachType(AttachBase): def __init__(self, **kwargs): super().__init__(**kwargs) # We fail whenever we're initialized raise TypeError() # Store our bad attachment type in our schema map A_MGR["bad"] = BadAttachType with pytest.raises(TypeError): AppriseAttachment.instantiate("bad://path", suppress_exceptions=False) # Same call but exceptions suppressed assert ( AppriseAttachment.instantiate("bad://path", suppress_exceptions=True) is None ) def test_attachment_matrix_dynamic_importing(tmpdir): """ API: Apprise() Attachment Matrix Importing """ # Make our new path valid suite = tmpdir.mkdir("apprise_attach_test_suite") suite.join("__init__.py").write("") module_name = "badattach" # Create a base area to work within base = suite.mkdir(module_name) base.join("__init__.py").write("") # Test no app_id base.join("AttachBadFile1.py").write( cleandoc(""" class AttachBadFile1: pass """) ) # No class of the same name base.join("AttachBadFile2.py").write( cleandoc(""" class BadClassName: pass """) ) # Exception thrown base.join("AttachBadFile3.py").write("""raise ImportError()""") # Utilizes a schema:// already occupied (as string) base.join("AttachGoober.py").write( cleandoc(""" from apprise import AttachBase class AttachGoober(AttachBase): # This class tests the fact we have a new class name, but we're # trying to over-ride items previously used # The default simple (insecure) protocol protocol = 'http' # The default secure protocol secure_protocol = 'https' """) ) # Utilizes a schema:// already occupied (as tuple) base.join("AttachBugger.py").write( cleandoc(""" from apprise import AttachBase class AttachBugger(AttachBase): # This class tests the fact we have a new class name, but we're # trying to over-ride items previously used # The default simple (insecure) protocol protocol = ('http', 'bugger-test' ) # The default secure protocol secure_protocol = ('https', 'bugger-tests') """) ) A_MGR.load_modules(path=str(base), name=module_name) def test_attachment_manager_no_import_failures(): """Verify all native attach plugins load without TRACE import errors.""" # Remove the global test-suite log suppression so TRACE records # are visible during the scan below logging.disable(logging.NOTSET) # Force a clean reload of the native attachment plugin directory A_MGR.unload_modules() with LogCapture(level=logging.TRACE) as stream: # Trigger load_modules() via the lazy-load path len(A_MGR) # Grab the captured log content before the handler is removed content = stream.getvalue() # Restore log suppression for the remainder of the test suite logging.disable(logging.CRITICAL) # Any "import failed" line means a plugin directory was found but # contained no loadable Attach* class (e.g. a stale __pycache__ # directory left behind after a rename). failures = [ line for line in content.splitlines() if "import failed" in line ] assert not failures, ( "Attachment plugin import failures detected:\n" + "\n".join(failures) ) # Leave the manager in a clean state for subsequent tests A_MGR.unload_modules() caronc-apprise-dfec414/tests/test_apprise_cli.py000066400000000000000000002707531526720371000222020ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from importlib import reload from importlib.metadata import PackageNotFoundError from inspect import cleandoc import json # Disable logging for a cleaner testing output import logging import os from os.path import dirname, join import re import sys from typing import ClassVar from unittest import mock from click.testing import CliRunner from helpers import environ import pytest import requests from apprise import ( Apprise, AppriseAsset, AppriseResultStatus, NotificationManager, NotifyBase, cli, ) from apprise.locale import gettext_lazy as _ from apprise.plugins.base import RequirementsSpec logging.disable(logging.CRITICAL) # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() def test_apprise_cli_nux_env(tmpdir): """ CLI: Nux Environment """ class GoodNotification(NotifyBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def notify(self, **kwargs): # Pretend everything is okay (when passing --disable-async) return True async def async_notify(self, **kwargs): # Pretend everything is okay return True def url(self, *args, **kwargs): # Support url() return "good://" class BadNotification(NotifyBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) async def async_notify(self, **kwargs): # Pretend everything is okay return False def url(self, *args, **kwargs): # Support url() return "bad://" # Set up our notification types N_MGR["good"] = GoodNotification N_MGR["bad"] = BadNotification runner = CliRunner() result = runner.invoke(cli.main) # no services specified; we return 1 (non-zero) assert result.exit_code == 1 result = runner.invoke(cli.main, ["-v"]) assert result.exit_code == 1 result = runner.invoke(cli.main, ["-vv"]) assert result.exit_code == 1 result = runner.invoke(cli.main, ["-vvv"]) assert result.exit_code == 1 result = runner.invoke(cli.main, ["-vvvv"]) assert result.exit_code == 1 # Display version information and exit result = runner.invoke(cli.main, ["-V"]) assert result.exit_code == 0 result = runner.invoke( cli.main, [ "-t", "test title", "-b", "test body", "good://localhost", ], ) assert result.exit_code == 0 with mock.patch("requests.request") as mock_request: # Prepare Mock mock_request.return_value = requests.Request() mock_request.return_value.status_code = requests.codes.ok result = runner.invoke( cli.main, [ "-t", "test title", "-b", "test body\\nsNewLine", # Test using interpret escapes "-e", # Use our JSON query "json://localhost", ], ) assert result.exit_code == 0 # Test our call count assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" # Our string is now escaped correctly assert ( json.loads(details[1]["data"]).get("message", "") == "test body\nsNewLine" ) # Reset mock_request.reset_mock() result = runner.invoke( cli.main, [ "-t", "test title", "-b", "test body\\nsNewLine", # No -e switch at all (so we don't escape the above) # Use our JSON query "json://localhost", ], ) assert result.exit_code == 0 # Test our call count assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" # Our string is now escaped correctly assert ( json.loads(details[1]["data"]).get("message", "") == "test body\\nsNewLine" ) # Run in synchronous mode result = runner.invoke( cli.main, [ "-t", "test title", "-b", "test body", "good://localhost", "--disable-async", ], ) assert result.exit_code == 0 # Test Debug Mode (--debug) result = runner.invoke( cli.main, [ "-t", "test title", "-b", "test body", "good://localhost", "--debug", ], ) assert result.exit_code == 0 # Test Debug Mode (-D) result = runner.invoke( cli.main, [ "-t", "test title", "-b", "test body", "good://localhost", "-D", ], ) assert result.exit_code == 0 result = runner.invoke( cli.main, [ "-t", "test title", "good://localhost", ], input="test stdin body\n", ) assert result.exit_code == 0 # Run in synchronous mode result = runner.invoke( cli.main, [ "-t", "test title", "good://localhost", "--disable-async", ], input="test stdin body\n", ) assert result.exit_code == 0 result = runner.invoke( cli.main, [ "-t", "test title", "-b", "test body", "bad://localhost", ], ) assert result.exit_code == 1 # Run in synchronous mode result = runner.invoke( cli.main, [ "-t", "test title", "-b", "test body", "bad://localhost", "-Da", ], ) assert result.exit_code == 1 # Testing with the --dry-run flag reveals a successful response since we # don't actually execute the bad:// notification; we only display it result = runner.invoke( cli.main, [ "-t", "test title", "-b", "test body", "bad://localhost", "--dry-run", ], ) assert result.exit_code == 0 # Write a simple text based configuration file t = tmpdir.mkdir("apprise-obj").join("apprise") buf = f""" # Include ourselves include {t!s} taga,tagb=good://localhost tagc=good://nuxref.com """ t.write(buf) # This will read our configuration and not send any notices at all # because we assigned tags to all of our urls and didn't identify # a specific match below. # 'include' reference in configuration file would have included the file a # second time (since recursion default is 1). result = runner.invoke( cli.main, [ "-b", "test config", "--config", str(t), ], ) # Even when recursion take place, tags are all honored # so 2 is returned because nothing was notified assert result.exit_code == 3 # This will send out 1 notification because our tag matches # one of the entries above # translation: has taga result = runner.invoke( cli.main, [ "-b", "has taga", "--config", str(t), "--tag", "taga", ], ) assert result.exit_code == 0 # Test recursion result = runner.invoke( cli.main, [ "-t", "test title", "-b", "test body", "--config", str(t), "--tag", "tagc", # Invalid entry specified for recursion "-R", "invalid", ], ) assert result.exit_code == 2 result = runner.invoke( cli.main, [ "-t", "test title", "-b", "test body", "--config", str(t), "--tag", "tagc", # missing entry specified for recursion "--recursive-depth", ], ) assert result.exit_code == 2 result = runner.invoke( cli.main, [ "-t", "test title", "-b", "test body", "--config", str(t), "--tag", "tagc", # Disable recursion (thus inclusion will be ignored) "-R", "0", ], ) assert result.exit_code == 0 # Test recursion result = runner.invoke( cli.main, [ "-t", "test title", "-b", "test body", "--config", str(t), "--tag", "tagc", # Recurse up to 5 times "--recursion-depth", "5", ], ) assert result.exit_code == 0 # This will send out 2 notifications because by specifying 2 tag # entries, we 'or' them together: # translation: has taga or tagb or tagd result = runner.invoke( cli.main, [ "-b", "has taga OR tagc OR tagd", "--config", str(t), "--tag", "taga", "--tag", "tagc", "--tag", "tagd", ], ) assert result.exit_code == 0 # Write a simple text based configuration file t = tmpdir.mkdir("apprise-obj2").join("apprise-test2") buf = """ good://localhost/1 good://localhost/2 good://localhost/3 good://localhost/4 good://localhost/5 myTag=good://localhost/6 """ t.write(buf) # This will read our configuration and send a notification to # the first 5 entries in the list, but not the one that has # the tag associated with it result = runner.invoke( cli.main, [ "-b", "test config", "--config", str(t), ], ) assert result.exit_code == 0 # Test our notification type switch (it defaults to info) so we want to # try it as a different value. Should return without a problem result = runner.invoke( cli.main, [ "-b", "# test config", "--config", str(t), "-n", "success", ], ) assert result.exit_code == 0 # Test our notification type switch when set to something unsupported result = runner.invoke( cli.main, [ "-b", "test config", "--config", str(t), "--notification-type", "invalid", ], ) # An error code of 2 is returned if invalid input is specified on the # command line assert result.exit_code == 2 # The notification type switch is case-insensitive result = runner.invoke( cli.main, [ "-b", "test config", "--config", str(t), "--notification-type", "WARNING", ], ) assert result.exit_code == 0 # Test our formatting switch (it defaults to text) so we want to try it as # a different value. Should return without a problem result = runner.invoke( cli.main, [ "-b", "# test config", "--config", str(t), "-i", "markdown", ], ) assert result.exit_code == 0 # Test our formatting switch when set to something unsupported result = runner.invoke( cli.main, [ "-b", "test config", "--config", str(t), "--input-format", "invalid", ], ) # An error code of 2 is returned if invalid input is specified on the # command line assert result.exit_code == 2 # The formatting switch is not case sensitive result = runner.invoke( cli.main, [ "-b", "# test config", "--config", str(t), "--input-format", "HTML", ], ) assert result.exit_code == 0 # As a way of ensuring we match the first 5 entries, we can run a # --dry-run against the same result set above and verify the output result = runner.invoke( cli.main, [ "-b", "test config", "--config", str(t), "--dry-run", ], ) assert result.exit_code == 0 lines = re.split(r"[\r\n]", result.output.strip()) # 5 lines of all good:// entries matched + url id underneath assert len(lines) == 10 # Verify we match against the remaining good:// entries for i in range(0, 10, 2): assert lines[i].endswith("good://") # Tags are case-insensitive; mytag matches myTag result = runner.invoke( cli.main, [ "-b", "has mytag", "--config", str(t), "--tag", "mytag", ], ) assert result.exit_code == 0 # Same command with --dry-run; one entry matches so exit code is 0 result = runner.invoke( cli.main, ["-b", "has mytag", "--config", str(t), "--tag", "mytag", "--dry-run"], ) assert result.exit_code == 0 # A tag that does not exist in the config produces no matches; the live # run returns exit code 3 to signal "no services found". result = runner.invoke( cli.main, ["-b", "no match", "--config", str(t), "--tag", "nonexistent"], ) assert result.exit_code == 3 # --dry-run must mirror the live result: a non-matching tag also returns # exit code 3, not 0. This guards against dry-run masking "no services # found" by always reporting success. result = runner.invoke( cli.main, [ "-b", "no match", "--config", str(t), "--tag", "nonexistent", "--dry-run", ], ) assert result.exit_code == 3 # Here is a case where we get what was expected; we also attach a file result = runner.invoke( cli.main, [ "-b", "has myTag", "--config", str(t), "--attach", join(dirname(__file__), "var", "apprise-test.gif"), "--tag", "myTag", ], ) assert result.exit_code == 0 # Testing with the --dry-run flag reveals the same positive results # because there was at least one match result = runner.invoke( cli.main, [ "-b", "has myTag", "--config", str(t), "--tag", "myTag", "--dry-run", ], ) assert result.exit_code == 0 # # Test environment variables # # Write a simple text based configuration file t2 = tmpdir.mkdir("apprise-obj-env").join("apprise") buf = """ # A general one good://localhost # A failure (if we use the fail tag) fail=bad://localhost # A normal one tied to myTag myTag=good://nuxref.com """ t2.write(buf) with environ(APPRISE_URLS="good://localhost"): # This will load okay because we defined the environment # variable with a valid URL result = runner.invoke( cli.main, [ "-b", "test environment", # Test that we ignore our tag "--tag", "mytag", ], ) assert result.exit_code == 0 # Same action but without --tag result = runner.invoke( cli.main, [ "-b", "test environment", ], ) assert result.exit_code == 0 with ( mock.patch("apprise.cli.DEFAULT_CONFIG_PATHS", []), environ(APPRISE_URLS=" "), ): # An empty string is not valid and therefore not loaded so the below # fails. We override the DEFAULT_CONFIG_PATHS because we don't want to # detect ones loaded on the machine running the unit tests result = runner.invoke( cli.main, [ "-b", "test environment", ], ) assert result.exit_code == 1 with environ(APPRISE_URLS="bad://localhost"): result = runner.invoke( cli.main, [ "-b", "test environment", ], ) assert result.exit_code == 1 # An inline URL overrides the environment. # variable result = runner.invoke( cli.main, [ "-t", "test title", "-b", "test body", "good://localhost", ], ) assert result.exit_code == 0 # A Config file also over-rides the environment variable if # specified on the command line: result = runner.invoke( cli.main, [ "-b", "has myTag", "--config", str(t2), "--tag", "myTag", ], ) assert result.exit_code == 0 with environ(APPRISE_CONFIG_PATH=str(t2)): # Load the configuration from the current environment variable. result = runner.invoke( cli.main, [ "-b", "has myTag", "--tag", "myTag", ], ) assert result.exit_code == 0 with environ(APPRISE_CONFIG=str(t2)): # The deprecated name still works and emits a notice. result = runner.invoke( cli.main, [ "-b", "has myTag", "--tag", "myTag", ], ) assert result.exit_code == 0 with environ( APPRISE_CONFIG_PATH=str(t2), APPRISE_CONFIG="garbage/file/path.yaml", ): # Prefer APPRISE_CONFIG_PATH when both names are defined. result = runner.invoke( cli.main, [ "-b", "has myTag", "--tag", "myTag", ], ) assert result.exit_code == 0 with environ(APPRISE_CONFIG_PATH=str(t2) + ";/another/path"): # The current variable accepts multiple configuration paths. result = runner.invoke( cli.main, [ "-b", "has myTag", "--tag", "myTag", ], ) assert result.exit_code == 0 with ( mock.patch("apprise.cli.DEFAULT_CONFIG_PATHS", []), environ(APPRISE_CONFIG_PATH=" "), ): # We will fail to send the notification as no path was specified. # We override the DEFAULT_CONFIG_PATHS because we don't want to detect # ones loaded on the machine running the unit tests result = runner.invoke( cli.main, [ "-b", "my message", ], ) assert result.exit_code == 1 with environ(APPRISE_CONFIG_PATH="garbage/file/path.yaml"): # We will fail to send the notification as the path # specified is not loadable result = runner.invoke( cli.main, [ "-b", "my message", ], ) assert result.exit_code == 1 # A command-line configuration overrides the invalid environment path. result = runner.invoke( cli.main, [ "-b", "has myTag", "--config", str(t2), "--tag", "myTag", ], ) assert result.exit_code == 0 # Explicit URLs take priority over --config. result = runner.invoke( cli.main, [ "-b", "has myTag", "--config", str(t2), "good://localhost", "--tag", "fail", ], ) # Tags are ignored, URL specified, so it trump config assert result.exit_code == 0 # we just repeat the test as a proof that it only executes # the urls despite the fact the --config was specified result = runner.invoke( cli.main, [ "-b", "reads the url entry only", "--config", str(t2), "good://localhost", "--tag", "fail", ], ) # Tags are ignored, URL specified, so it trump config assert result.exit_code == 0 # once agian, but we call bad:// result = runner.invoke( cli.main, [ "-b", "reads the url entry only", "--config", str(t2), "bad://localhost", "--tag", "myTag", ], ) assert result.exit_code == 1 # Test Escaping: result = runner.invoke( cli.main, [ "-e", "-t", "test\ntitle", "-b", "test\nbody", "good://localhost", ], ) assert result.exit_code == 0 # Test Escaping (without title) result = runner.invoke( cli.main, [ "--interpret-escapes", "-b", "test\nbody", "good://localhost", ], ) assert result.exit_code == 0 # Test Emojis: result = runner.invoke( cli.main, [ "-j", "-t", ":smile:", "-b", ":grin:", "good://localhost", ], ) assert result.exit_code == 0 result = runner.invoke( cli.main, [ "--interpret-emojis", "-t", ":smile:", "-b", ":grin:", "good://localhost", ], ) assert result.exit_code == 0 def test_apprise_cli_modules(tmpdir): """ CLI: --plugin (-P) """ runner = CliRunner() # # Loading of modules works correctly # notify_cmod_base = tmpdir.mkdir("cli_modules") notify_cmod = notify_cmod_base.join("hook.py") notify_cmod.write( cleandoc(""" from apprise.decorators import notify @notify(on="climod") def mywrapper(body, title, notify_type, *args, **kwargs): pass """) ) result = runner.invoke( cli.main, [ "--plugin-path", str(notify_cmod), "-t", "title", "-b", "body", "climod://", ], ) assert result.exit_code == 0 # Test -P result = runner.invoke( cli.main, [ "-P", str(notify_cmod), "-t", "title", "-b", "body", "climod://", ], ) assert result.exit_code == 0 # Test double hooks notify_cmod2 = notify_cmod_base.join("hook2.py") notify_cmod2.write( cleandoc(""" from apprise.decorators import notify @notify(on="climod2") def mywrapper(body, title, notify_type, *args, **kwargs): pass """) ) result = runner.invoke( cli.main, [ "--plugin-path", str(notify_cmod), "--plugin-path", str(notify_cmod2), "-t", "title", "-b", "body", "climod://", "climod2://", ], ) assert result.exit_code == 0 with environ( APPRISE_PLUGIN_PATH=str(notify_cmod) + ";" + str(notify_cmod2) ): # Leverage our environment variables to specify the plugin path result = runner.invoke( cli.main, [ "-b", "body", "climod://", "climod2://", ], ) assert result.exit_code == 0 @pytest.mark.skipif( sys.platform == "win32", reason="Unreliable results to be determined" ) def test_apprise_cli_persistent_storage(tmpdir): """ CLI: test persistent storage """ # This is a made up class that is just used to verify class NoURLIDNotification(NotifyBase): """A no URL ID.""" # Update URL identifier url_identifier = False def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def send(self, **kwargs): # Pretend everything is okay return True def url(self, *args, **kwargs): # Support URL return "noper://" def parse_url(self, *args, **kwargs): # parse our url return {"schema": "noper"} # This is a made up class that is just used to verify class TestNotification(NotifyBase): """A Testing Script.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def send(self, **kwargs): # Test our persistent settings self.store.set("key", "value") assert self.store.get("key") == "value" # Pretend everything is okay return True def url(self, *args, **kwargs): # Support URL return "test://" def parse_url(self, *args, **kwargs): # parse our url return {"schema": "test"} # assign test:// to our notification defined above N_MGR["test"] = TestNotification N_MGR["noper"] = NoURLIDNotification # Write a simple text based configuration file config = tmpdir.join("apprise.cfg") buf = cleandoc(""" # Create a config file we can source easily test=test:// noper=noper:// # Define a second test URL that will two-urls=test:// # Create another entry that has no tag associatd with it test://?entry=2 """) config.write(buf) runner = CliRunner() # Generate notification that creates persistent data result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "storage", "list", ], ) # list our entries assert result.exit_code == 0 # our persist storage has not been created yet stdout = result.stdout.strip() # Click output can wrap based on terminal width, and storage backend # sizes are not stable across Python/OS variations. Validate semantics. assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+unused\b", stdout, re.MULTILINE, ) assert re.search(r"(?m)^\s*-\s+test://\s*$", stdout) # An invalid mode specified result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--storage-mode", "invalid", "--config", str(config), "-g", "test", "-t", "title", "-b", "body", ], ) # Bad mode specified assert result.exit_code == 2 # Invalid uid lenth specified result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--storage-mode", "flush", "--storage-uid-length", 1, "--config", str(config), "-g", "test", "-t", "title", "-b", "body", ], ) # storage uid length to small assert result.exit_code == 2 # No files written yet; just config file exists dir_content = os.listdir(str(tmpdir)) assert len(dir_content) == 1 assert "apprise.cfg" in dir_content # Generate notification that creates persistent data result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--storage-mode", "flush", "--config", str(config), "-t", "title", "-b", "body", "-g", "test", ], ) # We parsed our data accordingly assert result.exit_code == 0 dir_content = os.listdir(str(tmpdir)) assert len(dir_content) == 2 assert "apprise.cfg" in dir_content assert "ea482db7" in dir_content # Have a look at our storage listings result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "storage", "list", ], ) # list our entries assert result.exit_code == 0 stdout = result.stdout.strip() assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+active\b", stdout, re.MULTILINE, ) # keyword list is not required result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "storage", ], ) # list our entries assert result.exit_code == 0 stdout = result.stdout.strip() assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+active\b", stdout, re.MULTILINE, ) # search on something that won't match result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "storage", "list", "nomatch", ], ) # list our entries assert result.exit_code == 0 assert not result.stdout.strip() # closest match search result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "storage", "list", # Closest match will hit a result "ea", ], ) # list our entries assert result.exit_code == 0 stdout = result.stdout.strip() assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+active\b", stdout, re.MULTILINE, ) # list is the presumed option if no match result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "storage", # Closest match will hit a result "ea", ], ) # list our entries successfully again.. assert result.exit_code == 0 stdout = result.stdout.strip() assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+active\b", stdout, re.MULTILINE, ) # Search based on tag result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "storage", "list", # We can match by tags too "-g", "test", ], ) # list our entries assert result.exit_code == 0 stdout = result.stdout.strip() assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+active\b", stdout, re.MULTILINE, ) # Create a stale 8-char namespace on disk that belongs to a different # URL — it must NOT appear in the output when filtering by URL. tmpdir.mkdir("aaaaaaaa") # Filter by URL directly — no config file required; the URL is loaded # inline and its uid is used to match storage entries. result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "storage", "list", # Pass the raw URL instead of an 8-char UID prefix "test://", ], ) # Should produce exactly one entry (the url's namespace); the # unrelated stale directory 'aaaaaaaa' must not bleed into output. assert result.exit_code == 0 stdout = result.stdout.strip() assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+active\b", stdout, re.MULTILINE, ) # Confirm only one entry — stale 'aaaaaaaa' must be absent assert not re.search(r"aaaaaaaa", stdout) # URL filter alongside --config: URL filters trump all other sources, # including --config. 'a' is reset and only the URL-filter plugin # is loaded. Its storage was already created, so exactly one active # entry should appear. result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "storage", "list", "test://", ], ) assert result.exit_code == 0 stdout = result.stdout.strip() assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+active\b", stdout, re.MULTILINE, ) # Duplicate URL filter: the same URL specified twice. Both entries # are added to the fresh Apprise instance; the uids dict groups them # under one uid key, so only a single storage entry should appear. result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "storage", "list", "test://", "test://", ], ) assert result.exit_code == 0 stdout = result.stdout.strip() assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+active\b", stdout, re.MULTILINE, ) # Only one numbered entry should appear despite two URL arguments assert len(re.findall(r"^\d+\.", stdout, re.MULTILINE)) == 1 # URL filter with an unrecognised schema: a.add() silently rejects the # URL, leaving the Apprise instance empty. With no plugin uids resolved, # disk_scan is skipped entirely and the output is empty. result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "storage", "list", "nosuchschema://filter", ], ) assert result.exit_code == 0 assert not result.stdout.strip() # URL filter with --tag: URL filter fires rule #1 which clears the tag # (same as sending a notification — URLs trump all, including tags). # The test:// plugin's storage is shown; the stale dirs are absent # because disk_scan is scoped to the URL-filter plugin's uid. result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--tag", "test", "storage", "list", "test://", ], ) assert result.exit_code == 0 stdout = result.stdout.strip() assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+active\b", stdout, re.MULTILINE, ) # Stale dirs must not bleed into output when URL filter is active assert not re.search(r"aaaaaaaa", stdout) # URL filter scopes PRUNE too: only the test:// plugin's storage # directory is eligible; stale dirs like 'aaaaaaaa' are left alone. # Use --dry-run so we exercise the _had_url_filters namespace-scoping # branch without actually modifying disk (keeping ea482db7 active for # the assertions that follow). result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--storage-prune-days", 0, "--dry-run", "storage", "prune", "test://", ], ) assert result.exit_code == 0 # The stale 'aaaaaaaa' directory must still exist after the URL-scoped # prune because it was outside the test:// namespace. assert os.path.isdir(os.path.join(str(tmpdir), "aaaaaaaa")) # URL filter scopes CLEAR too: only the test:// plugin's namespace is # cleared; unrelated stale directories are untouched. # Use --dry-run so we exercise the _had_url_filters scoping path # without wiping ea482db7 contents (subsequent tests expect it active). result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--dry-run", "storage", "clear", "test://", ], ) assert result.exit_code == 0 # 'aaaaaaaa' must still be present — it was outside the cleared scope assert os.path.isdir(os.path.join(str(tmpdir), "aaaaaaaa")) # Tag-scoped PRUNE: --tag limits the prune namespace to only the uids # that matched the tag filter. The stale 'aaaaaaaa' directory is # outside that namespace and must not be removed. # Use --dry-run so the test is non-destructive and ea482db7 stays active. result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "--tag", "test", "--storage-prune-days", 0, "--dry-run", "storage", "prune", ], ) assert result.exit_code == 0 # The stale 'aaaaaaaa' directory must be untouched — it was outside the # tag-scoped prune namespace. assert os.path.isdir(os.path.join(str(tmpdir), "aaaaaaaa")) # Tag-scoped CLEAR: same principle — only the tagged plugin's namespace # is eligible for clearing; everything else is left alone. # Use --dry-run to keep ea482db7 active for subsequent assertions. result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "--tag", "test", "--dry-run", "storage", "clear", ], ) assert result.exit_code == 0 # The stale 'aaaaaaaa' directory must survive — it was not in scope assert os.path.isdir(os.path.join(str(tmpdir), "aaaaaaaa")) # When a tag or URL filter is specified but resolves to nothing, # disk_prune() must NOT be called. The tests below confirm that ea482db7 # survives each no-match check. # Tag-scoped PRUNE with a tag that matches no loaded plugin: nothing # resolves so disk_prune() must be skipped entirely. result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "--tag", "no-such-tag", "--storage-prune-days", 0, "storage", "prune", ], ) assert result.exit_code == 0 # A prune with no matches must leave existing storage intact. assert os.path.isdir(os.path.join(str(tmpdir), "ea482db7")) # Tag-scoped CLEAR with the same unmatchable tag: same guard must fire. result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "--tag", "no-such-tag", "storage", "clear", ], ) assert result.exit_code == 0 # A clear with no matches must leave existing storage intact. assert os.path.isdir(os.path.join(str(tmpdir), "ea482db7")) # URL filter that fails to load (unknown schema): _had_url_filters is # True but no uid resolves, so disk_prune() must not be called. result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--storage-prune-days", 0, "storage", "prune", "nosuchschema://", ], ) assert result.exit_code == 0 assert os.path.isdir(os.path.join(str(tmpdir), "ea482db7")) # Same guard applies to clear when the URL filter resolves nothing. result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "storage", "clear", "nosuchschema://", ], ) assert result.exit_code == 0 assert os.path.isdir(os.path.join(str(tmpdir), "ea482db7")) # Prune call but prune-days set incorrectly result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--storage-prune-days", -1, "storage", "prune", ], ) # storage prune days is invalid assert result.exit_code == 2 # Create a tmporary namespace tmpdir.mkdir("namespace") # Generates another listing result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "storage", ], ) # list our entries assert result.exit_code == 0 stdout = result.stdout.strip() assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+active\b", stdout, re.MULTILINE, ) assert re.match( r".*\s*[0-9]\.\s+namespace\s+0\.00B\s+stale.*", stdout, (re.MULTILINE | re.DOTALL), ) # Generates another listing but utilize the tag result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "--tag", "test", "storage", ], ) # list our entries assert result.exit_code == 0 stdout = result.stdout.strip() assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+active\b", stdout, re.MULTILINE, ) assert ( re.match( r".*\s*[0-9]\.\s+namespace\s+0\.00B\s+stale.*", stdout, (re.MULTILINE | re.DOTALL), ) is None ) # Clear all of our accumulated disk space result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "storage", "clear", ], ) # successful assert result.exit_code == 0 # Generate another listing result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "storage", ], ) # list our entries assert result.exit_code == 0 stdout = result.stdout.strip() # back to unused state and 0 bytes assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+unused\b", stdout, re.MULTILINE, ) # namespace is gone now assert ( re.match( r".*\s*[0-9]\.\s+namespace\s+0\.00B\s+stale.*", stdout, (re.MULTILINE | re.DOTALL), ) is None ) # Provide both tags and uid result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "storage", "ea", "-g", "test", ], ) # list our entries assert result.exit_code == 0 stdout = result.stdout.strip() # back to unused state and 0 bytes assert re.match( r"^[0-9]\.\s+[a-z0-9_-]{8}\s+0\.00B\s+unused\s+-\s+test://$", stdout, re.MULTILINE, ) # Generate notification that creates persistent data result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--storage-mode", "flush", "--config", str(config), "-t", "title", "-b", "body", "-g", "test", ], ) # We parsed our data accordingly assert result.exit_code == 0 # Have a look at our storage listings result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "storage", "list", ], ) # list our entries assert result.exit_code == 0 stdout = result.stdout.strip() assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+active\b", stdout, re.MULTILINE, ) # Prune call but prune-days set incorrectly result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "storage", "prune", ], ) # Run our prune successfully assert result.exit_code == 0 # Have a look at our storage listings (expected no change in output) result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "storage", "list", ], ) # list our entries assert result.exit_code == 0 stdout = result.stdout.strip() assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+active\b", stdout, re.MULTILINE, ) # Prune call but prune-days set incorrectly result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), # zero simulates a full clean "--storage-prune-days", 0, "storage", "prune", ], ) # Run our prune successfully assert result.exit_code == 0 # Have a look at our storage listings (expected no change in output) result = runner.invoke( cli.main, [ "--storage-path", str(tmpdir), "--config", str(config), "storage", "list", ], ) # list our entries assert result.exit_code == 0 # Note: An prune/expiry of zero gets everything except for MS Windows # during testing only. # Until this can be resolved, this is the section of the test that # caused us to disable it in MS Windows stdout = result.stdout.strip() assert re.search( r"^1\.\s+[a-z0-9_-]{8}\s+\d+(?:\.\d{2})?[KMGT]?B\s+unused\b", stdout, re.MULTILINE, ) # New Temporary namespace new_persistent_base = tmpdir.mkdir("namespace") with environ(APPRISE_STORAGE_PATH=str(new_persistent_base)): # Reload our module reload(cli) # Nothing in our directory yet dir_content = os.listdir(str(new_persistent_base)) assert len(dir_content) == 0 # Generate notification that creates persistent data # storage path is pulled out of our environment variable result = runner.invoke( cli.main, [ "--storage-mode", "flush", "--config", str(config), "-t", "title", "-b", "body", "-g", "test", ], ) # We parsed our data accordingly assert result.exit_code == 0 # Now content exists dir_content = os.listdir(str(new_persistent_base)) assert len(dir_content) == 1 # Reload our module with our environment variable gone reload(cli) # Clear loaded modules N_MGR.unload_modules() @pytest.mark.parametrize( "prune_days, uid_length, expected", [ ("", "", (30, 8)), (" ", "abc", (30, 8)), ("not-a-number", "", (30, 8)), ("45", " 12 ", (45, 12)), ], ) def test_apprise_cli_storage_env_defaults(prune_days, uid_length, expected): """ CLI: an empty or invalid storage environment variable no longer breaks loading the CLI or --help """ try: with environ( APPRISE_STORAGE_PRUNE_DAYS=prune_days, APPRISE_STORAGE_UID_LENGTH=uid_length, ): reload(cli) assert expected == ( cli.DEFAULT_STORAGE_PRUNE_DAYS, cli.DEFAULT_STORAGE_UID_LENGTH, ) result = CliRunner().invoke(cli.main, ["--help"]) assert result.exit_code == 0 finally: reload(cli) def test_apprise_cli_storage_env_invalid(): """CLI: a non-numeric storage environment variable stops the CLI.""" try: with environ(APPRISE_STORAGE_PRUNE_DAYS="abc"): reload(cli) assert cli.INVALID_ENV == [("APPRISE_STORAGE_PRUNE_DAYS", "abc")] # --help still works so the user can find the right value result = CliRunner().invoke(cli.main, ["--help"]) assert result.exit_code == 0 # Anything else stops with a message naming the variable result = CliRunner().invoke(cli.main, ["-b", "test"]) assert result.exit_code == 2 assert "APPRISE_STORAGE_PRUNE_DAYS" in result.output finally: reload(cli) def test_apprise_cli_details(tmpdir): """ CLI: --details (-l) """ runner = CliRunner() # # Testing the printout of our details # --details or -l # result = runner.invoke( cli.main, [ "--details", ], ) assert result.exit_code == 0 result = runner.invoke( cli.main, [ "-l", ], ) assert result.exit_code == 0 # Clear loaded modules N_MGR.unload_modules() # This is a made up class that is just used to verify class TestReq01Notification(NotifyBase): """This class is used to test various requirement configurations.""" # Set some requirements requirements: ClassVar[RequirementsSpec] = { "packages_required": [ "cryptography <= 3.4", "ultrasync", ], "packages_recommended": "django", } def url(self, **kwargs): # Support URL return "" def send(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["req01"] = TestReq01Notification # This is a made up class that is just used to verify class TestReq02Notification(NotifyBase): """This class is used to test various requirement configurations.""" # Just not enabled at all enabled = False # Set some requirements requirements: ClassVar[RequirementsSpec] = { # None and/or [] is implied, but jsut to show that the code won't # crash if explicitly set this way: "packages_required": None, "packages_recommended": [ "cryptography <= 3.4", ], } def url(self, **kwargs): # Support URL return "" def send(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["req02"] = TestReq02Notification # This is a made up class that is just used to verify class TestReq03Notification(NotifyBase): """This class is used to test various requirement configurations.""" # Set requirements with a custom details value. requirements: ClassVar[RequirementsSpec] = { # Allow callers to override the plugin's default details. "details": _("some specified requirement details"), # We can set a string value as well (it does not have to be a list) "packages_recommended": "cryptography <= 3.4", } def url(self, **kwargs): # Support URL return "" def send(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["req03"] = TestReq03Notification # This is a made up class that is just used to verify class TestReq04Notification(NotifyBase): """This class is used to test a case where our requirements is fixed to a None.""" # This is the same as saying there are no requirements requirements = None def url(self, **kwargs): # Support URL return "" def send(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["req04"] = TestReq04Notification # This is a made up class that is just used to verify class TestReq05Notification(NotifyBase): """This class is used to test a case where only packages_recommended is identified.""" requirements: ClassVar[RequirementsSpec] = { "packages_recommended": "cryptography <= 3.4" } def url(self, **kwargs): # Support URL return "" def send(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["req05"] = TestReq05Notification class TestDisabled01Notification(NotifyBase): """This class is used to test a pre-disabled state.""" # Just flat out disable our service enabled = False # we'll use this as a key to make our service easier to find # in the next part of the testing service_name = "na01" def url(self, **kwargs): # Support URL return "" def notify(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["na01"] = TestDisabled01Notification class TestDisabled02Notification(NotifyBase): """This class is used to test a post-disabled state.""" # we'll use this as a key to make our service easier to find # in the next part of the testing service_name = "na02" def __init__(self, *args, **kwargs): super().__init__(**kwargs) # enable state changes **AFTER** we initialize self.enabled = False def url(self, **kwargs): # Support URL return "" def notify(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["na02"] = TestDisabled02Notification # We'll add a good notification to our list class TesEnabled01Notification(NotifyBase): """This class is just a simple enabled one.""" # we'll use this as a key to make our service easier to find # in the next part of the testing service_name = "good" def url(self, **kwargs): # Support URL return "" def send(self, **kwargs): # Pretend everything is okay (so we don't break other tests) return True N_MGR["good"] = TesEnabled01Notification # Verify that we can pass through all of our different details result = runner.invoke( cli.main, [ "--details", ], ) assert result.exit_code == 0 result = runner.invoke( cli.main, [ "-l", ], ) assert result.exit_code == 0 # Clear loaded modules N_MGR.unload_modules() def test_apprise_cli_print_help(): """ CLI: --help (-h) """ runner = CliRunner() # Clear our working variables so they don't obstruct the next test # This simulates an actual call from the CLI. Unfortunately through # testing were occupying the same memory space so our singleton's # have already been populated N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() # Print help and exit result = runner.invoke(cli.main, ["--help"]) assert result.exit_code == 0 result = runner.invoke(cli.main, ["-h"]) assert result.exit_code == 0 @mock.patch("requests.request") def test_apprise_cli_plugin_loading(mock_request, tmpdir): """ CLI: --plugin-path (-P) """ # Prepare Mock mock_request.return_value = requests.Request() mock_request.return_value.status_code = requests.codes.ok runner = CliRunner() # Clear our working variables so they don't obstruct the next test # This simulates an actual call from the CLI. Unfortunately through # testing were occupying the same memory space so our singleton's # have already been populated N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() # Test a path that has no files to load in it result = runner.invoke( cli.main, [ "--plugin-path", join(str(tmpdir), "invalid_path"), "-b", "test\nbody", "json://localhost", ], ) # The valid notification still loads despite the invalid path. assert result.exit_code == 0 # Directories that don't exist passed in by the CLI aren't even scanned assert len(N_MGR._paths_previously_scanned) == 0 assert len(N_MGR._custom_module_map) == 0 # Test our current existing path that has no entries in it result = runner.invoke( cli.main, [ "--plugin-path", str(tmpdir.mkdir("empty")), "-b", "test\nbody", "json://localhost", ], ) # The valid notification still loads despite the invalid path. assert result.exit_code == 0 assert len(N_MGR._paths_previously_scanned) == 1 assert join(str(tmpdir), "empty") in N_MGR._paths_previously_scanned # However there was nothing to load assert len(N_MGR._custom_module_map) == 0 # Clear our working variables so they don't obstruct the next test # This simulates an actual call from the CLI. Unfortunately through # testing were occupying the same memory space so our singleton's # have already been populated N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() # Prepare ourselves a file to work with notify_hook_a_base = tmpdir.mkdir("random") notify_hook_a = notify_hook_a_base.join("myhook01.py") notify_hook_a.write( cleandoc(""" raise ImportError """) ) result = runner.invoke( cli.main, [ "--plugin-path", str(notify_hook_a), "-b", "test\nbody", # A custom hook: "clihook://", ], ) # It doesn't exist so it will fail # meanwhile we would have failed to load the myhook path assert result.exit_code == 1 # The valid notification still loads despite the invalid path. assert len(N_MGR._paths_previously_scanned) == 1 assert str(notify_hook_a) in N_MGR._paths_previously_scanned # However there was nothing to load assert len(N_MGR._custom_module_map) == 0 # Prepare ourselves a file to work with notify_hook_aa = notify_hook_a_base.join("myhook02.py") notify_hook_aa.write( cleandoc(""" garbage entry """) ) N_MGR.plugins() result = runner.invoke( cli.main, [ "--plugin-path", str(notify_hook_aa), "-b", "test\nbody", # A custom hook: "clihook://custom", ], ) # It doesn't exist so it will fail # meanwhile we would have failed to load the myhook path assert result.exit_code == 1 # The path is silently loaded but fails... # as a result the path stacks with the last assert len(N_MGR._paths_previously_scanned) == 2 assert str(notify_hook_a) in N_MGR._paths_previously_scanned assert str(notify_hook_aa) in N_MGR._paths_previously_scanned # However there was nothing to load assert len(N_MGR._custom_module_map) == 0 # Clear our working variables so they don't obstruct the next test # This simulates an actual call from the CLI. Unfortunately through # testing were occupying the same memory space so our singleton's # have already been populated N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() # Prepare ourselves a file to work with notify_hook_b = tmpdir.mkdir("goodmodule").join("__init__.py") notify_hook_b.write( cleandoc(""" from apprise.decorators import notify # We want to trigger on anyone who configures a call to clihook:// @notify(on="clihook") def mywrapper(body, title, notify_type, *args, **kwargs): # A simple test - print to screen print("{}: {} - {}".format(notify_type, title, body)) # No return (so a return of None) get's translated to True # Define another in the same file @notify(on="clihookA") def mywrapper(body, title, notify_type, *args, **kwargs): # A simple test - print to screen print("!! {}: {} - {}".format(notify_type, title, body)) # No return (so a return of None) get's translated to True """) ) result = runner.invoke( cli.main, [ "--plugin-path", str(tmpdir), "-b", "test body", # A custom hook: "clihook://still/valid", ], ) # We can detect the goodmodule (which has an __init__.py in it) # so we'll load okay assert result.exit_code == 0 # Let's see how things got loaded: assert len(N_MGR._paths_previously_scanned) == 2 assert str(tmpdir) in N_MGR._paths_previously_scanned # absolute path to detected module is also added assert ( join(str(tmpdir), "goodmodule", "__init__.py") in N_MGR._paths_previously_scanned ) # We also loaded our clihook properly assert len(N_MGR._custom_module_map) == 1 # We can find our new hook loaded in our schema map now... assert "clihook" in N_MGR # Capture our key for reference key = next(iter(N_MGR._custom_module_map.keys())) # We loaded 2 entries from the same file assert len(N_MGR._custom_module_map[key]["notify"]) == 2 assert "clihook" in N_MGR._custom_module_map[key]["notify"] # Converted to lower case assert "clihooka" in N_MGR._custom_module_map[key]["notify"] # Our function name assert ( N_MGR._custom_module_map[key]["notify"]["clihook"]["fn_name"] == "mywrapper" ) # What we parsed from the `on` keyword in the @notify decorator assert ( N_MGR._custom_module_map[key]["notify"]["clihook"]["url"] == "clihook://" ) # our default name Assignment. This can be-overridden on the @notify # decorator by just adding a name= to the parameter list assert N_MGR["clihook"].service_name == "Custom - clihook" # Our Base Notification object when initialized: assert ( len(N_MGR._module_map[N_MGR._custom_module_map[key]["name"]]["plugin"]) == 2 ) for plugin in N_MGR._module_map[N_MGR._custom_module_map[key]["name"]][ "plugin" ]: assert isinstance(plugin(), NotifyBase) # Clear our working variables so they don't obstruct the next test # This simulates an actual call from the CLI. Unfortunately through # testing were occupying the same memory space so our singleton's # have already been populated N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() del N_MGR["clihook"] result = runner.invoke( cli.main, [ "--plugin-path", str(notify_hook_b), "-b", "test body", # A custom hook: "clihook://", ], ) # Absolute path to __init__.py is okay assert result.exit_code == 0 # we can verify that it prepares our message assert result.stdout.strip() == "info: - test body" # Clear our working variables so they don't obstruct the next test # This simulates an actual call from the CLI. Unfortunately through # testing were occupying the same memory space so our singleton's # have already been populated N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() del N_MGR["clihook"] result = runner.invoke( cli.main, [ "--plugin-path", dirname(str(notify_hook_b)), "-b", "test body", # A custom hook: "clihook://", ], ) # Now we succeed to load our module when pointed to it only because # an __init__.py is found on the inside of it assert result.exit_code == 0 # we can verify that it prepares our message assert result.stdout.strip() == "info: - test body" # Test double paths that are the same; this ensures we only # load the plugin once result = runner.invoke( cli.main, [ "--plugin-path", dirname(str(notify_hook_b)), "--plugin-path", str(notify_hook_b), "--details", ], ) # Now we succeed to load our module when pointed to it only because # an __init__.py is found on the inside of it assert result.exit_code == 0 # Clear our working variables so they don't obstruct the next test # This simulates an actual call from the CLI. Unfortunately through # testing were occupying the same memory space so our singleton's # have already been populated N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() del N_MGR["clihook"] # Prepare ourselves a file to work with notify_hook_b = tmpdir.mkdir("complex").join("complex.py") notify_hook_b.write( cleandoc(""" from apprise.decorators import notify # Existing elements cannot be overridden. # in this case json:// @notify(on="json") def mywrapper_01(body, title, notify_type, *args, **kwargs): # Return True (same as None) return True @notify(on="willfail", name="always failing...") def mywrapper_02(body, title, notify_type, *args, **kwargs): # Simply fail return False @notify(on="clihook1", name="the original clihook entry") def mywrapper_03(body, title, notify_type, *args, **kwargs): # Return True return True # A duplicate schema cannot be loaded. @notify(on="clihook1", name="a duplicate of the clihook entry") def mywrapper_04(body, title, notify_type, *args, **kwargs): # Return True return True # A schema URL may also provide defaults for the wrapper. @notify(on="clihook2://localhost") def mywrapper_05(body, title, notify_type, *args, **kwargs): # Return True return True # This can't load because of the defined schema/on definition @notify(on="", name="an invalid schema was specified") def mywrapper_06(body, title, notify_type, *args, **kwargs): return True """) ) result = runner.invoke( cli.main, [ "--plugin-path", join(str(tmpdir), "complex"), "-b", "test body", # A custom hook that does not exist "clihook://", ], ) # Since clihook:// isn't in our complex listing, this will fail assert result.exit_code == 1 # Let's see how things got loaded assert len(N_MGR._paths_previously_scanned) == 2 # Our path we specified on the CLI... assert join(str(tmpdir), "complex") in N_MGR._paths_previously_scanned # absolute path to detected module is also added assert ( join(str(tmpdir), "complex", "complex.py") in N_MGR._paths_previously_scanned ) # We loaded our one module successfuly assert len(N_MGR._custom_module_map) == 1 # We can find our new hook loaded in our SCHEMA_MAP now... assert "willfail" in N_MGR assert "clihook1" in N_MGR assert "clihook2" in N_MGR # Capture our key for reference key = next(iter(N_MGR._custom_module_map.keys())) assert len(N_MGR._custom_module_map[key]["notify"]) == 3 assert "willfail" in N_MGR._custom_module_map[key]["notify"] assert "clihook1" in N_MGR._custom_module_map[key]["notify"] # We only load 1 instance of the clihook2, the second will fail assert "clihook2" in N_MGR._custom_module_map[key]["notify"] # We can never load previously created notifications assert "json" not in N_MGR._custom_module_map[key]["notify"] result = runner.invoke( cli.main, [ "--plugin-path", join(str(tmpdir), "complex"), "-b", "test body", # A custom notification set up for failure "willfail://", ], ) # Note that the failure of the decorator carries all the way back # to the CLI assert result.exit_code == 1 result = runner.invoke( cli.main, [ "--plugin-path", join(str(tmpdir), "complex"), "-b", "test body", # our clihook that returns true "clihook1://", # our other loaded clihook "clihook2://", ], ) # Note that the failure of the decorator carries all the way back # to the CLI assert result.exit_code == 0 result = runner.invoke( cli.main, [ "--plugin-path", join(str(tmpdir), "complex"), "--notification-type", "invalid", "-b", "test body", # our clihook that returns true "clihook1://", ], ) # Bad notification type specified assert result.exit_code == 2 result = runner.invoke( cli.main, [ "--plugin-path", join(str(tmpdir), "complex"), "-b", "-i", "warningtest body", # our clihook that returns true "clihook1://", ], ) # Bad notification type specified assert result.exit_code == 0 result = runner.invoke( cli.main, [ "--plugin-path", join(str(tmpdir), "complex"), # Print our custom details to the screen "--details", ], ) assert "willfail" in result.stdout assert "always failing..." in result.stdout assert "clihook1" in result.stdout assert "the original clihook entry" in result.stdout assert "a duplicate of the clihook entry" not in result.stdout assert "clihook2" in result.stdout assert "Custom - clihook2" in result.stdout # Note that the failure of the decorator carries all the way back # to the CLI assert result.exit_code == 0 @mock.patch("platform.system") def test_apprise_cli_windows_env(mock_system): """ CLI: Windows Environment """ # Force a windows environment mock_system.return_value = "Windows" # Reload our module reload(cli) @mock.patch("apprise.cli.NotificationManager") @mock.patch("importlib.metadata.packages_distributions", create=True) @mock.patch("importlib.metadata.version") @mock.patch("apprise.cli.logger") def test_apprise_cli_runtime_env_skip_when_not_debug( mock_logger, mock_ver, mock_dist, mock_mgr ): """ CLI: _log_runtime_env() exits immediately when not at DEBUG level. """ # Simulate a logger that is not at DEBUG level mock_logger.isEnabledFor.return_value = False cli._log_runtime_env() # Nothing should be computed or logged mock_logger.debug.assert_not_called() mock_mgr.assert_not_called() mock_dist.assert_not_called() mock_ver.assert_not_called() @mock.patch("apprise.cli.NotificationManager") @mock.patch("importlib.metadata.packages_distributions", create=True) @mock.patch("importlib.metadata.version") @mock.patch("apprise.cli.logger") def test_apprise_cli_runtime_env_logging( mock_logger, mock_ver, mock_dist, mock_mgr ): """ CLI: _log_runtime_env() summary """ # Simulate a DEBUG-level logger mock_logger.isEnabledFor.return_value = True # Plugin whose dep should appear in output class EnabledPlugin: enabled = True @staticmethod def runtime_deps(): return ("testpkg",) # Dependencies from disabled plugins must not appear. class DisabledPlugin: enabled = False @staticmethod def runtime_deps(): return ("disabled-dep",) # Plugins without runtime_deps are skipped. class NoRuntimeDepsPlugin: enabled = True mock_mgr.return_value = [ { "plugin": { EnabledPlugin, DisabledPlugin, NoRuntimeDepsPlugin, }, }, ] mock_dist.return_value = {"testpkg": ["test-package"]} mock_ver.return_value = "9.9.9" cli._log_runtime_env() # Collect positional-arg tuples from each debug() call calls = [a for a, _ in mock_logger.debug.call_args_list] # Environment summary lines must be present assert any(a[0] == "Apprise: %s" for a in calls) assert any(a[0] == "Python: %s" for a in calls) assert any(a[0] == "Platform: %s" for a in calls) assert any(a[0] == "Encoding: %s" for a in calls) # Resolved deps emitted as a single inline line assert any( a[0] == "Runtime deps: %s" and a[1] == "test-package=9.9.9" for a in calls ) # Disabled plugin dep must not be logged assert not any("disabled-dep" in str(a) for a in calls) @mock.patch("apprise.cli.NotificationManager") @mock.patch("importlib.metadata.packages_distributions", create=True) @mock.patch("importlib.metadata.version") @mock.patch("apprise.cli.logger") def test_apprise_cli_runtime_env_no_runtime_deps( mock_logger, mock_ver, mock_dist, mock_mgr ): """ CLI: _log_runtime_env() """ mock_logger.isEnabledFor.return_value = True # Plugin that declares no deps class NoDepsPlugin: enabled = True @staticmethod def runtime_deps(): return () mock_mgr.return_value = [{"plugin": {NoDepsPlugin}}] cli._log_runtime_env() # Environment header was still logged calls = [a for a, _ in mock_logger.debug.call_args_list] assert any(a[0] == "Apprise: %s" for a in calls) # Package-metadata scan must not have been attempted mock_dist.assert_not_called() mock_ver.assert_not_called() # No " pkg: ver" lines assert not any(a[0] == "Runtime deps: %s" for a in calls) @mock.patch("apprise.cli.NotificationManager") @mock.patch("importlib.metadata.packages_distributions", create=True) @mock.patch("importlib.metadata.version") @mock.patch("apprise.cli.logger") def test_apprise_cli_runtime_env_dist_map_exception( mock_logger, mock_ver, mock_dist, mock_mgr ): """ CLI: _log_runtime_env() handles packages_distributions() failures. """ mock_logger.isEnabledFor.return_value = True class DepPlugin: enabled = True @staticmethod def runtime_deps(): return ("somepkg",) mock_mgr.return_value = [{"plugin": {DepPlugin}}] mock_dist.side_effect = Exception("metadata unavailable") # Must not raise cli._log_runtime_env() # pkg_version should never be reached mock_ver.assert_not_called() # No " pkg: ver" lines calls = [a for a, _ in mock_logger.debug.call_args_list] assert not any(a[0] == "Runtime deps: %s" for a in calls) @mock.patch("apprise.cli.NotificationManager") @mock.patch("importlib.metadata.packages_distributions", create=True) @mock.patch("importlib.metadata.version") @mock.patch("apprise.cli.logger") def test_apprise_cli_runtime_env_lookup_errors( mock_logger, mock_ver, mock_dist, mock_mgr ): """ CLI: _log_runtime_env() silently skips packages exception raised. """ mock_logger.isEnabledFor.return_value = True # Plugin whose runtime_deps() raises class BrokenPlugin: enabled = True @staticmethod def runtime_deps(): raise RuntimeError("plugin error") # Plugin whose dep import name is not in the dist map class UnmappedPlugin: enabled = True @staticmethod def runtime_deps(): return ("notmapped",) # Plugin whose dep maps to a dist but the version lookup fails class VersionlessPlugin: enabled = True @staticmethod def runtime_deps(): return ("verpkg",) mock_mgr.return_value = [ { "plugin": { BrokenPlugin, UnmappedPlugin, VersionlessPlugin, }, }, ] # "notmapped" absent from map; "verpkg" present but ver raises mock_dist.return_value = {"verpkg": ["ver-package"]} mock_ver.side_effect = PackageNotFoundError("ver-package") # Must not raise cli._log_runtime_env() # None of the error paths should produce a " pkg: ver" line calls = [a for a, _ in mock_logger.debug.call_args_list] assert not any(a[0] == "Runtime deps: %s" for a in calls) @pytest.mark.skipif( sys.version_info < (3, 11), reason="packages_distributions requires Python 3.11+", ) @mock.patch("apprise.cli.NotificationManager") @mock.patch("apprise.cli.logger") def test_apprise_cli_runtime_env_no_packages_distributions( mock_logger, mock_mgr ): """ CLI: _log_runtime_env() gracefully skips dep listing when packages_distributions() is missing (ImportError path). Simulates the Python < 3.11 scenario on Python 3.11+ by temporarily removing packages_distributions from importlib.metadata. """ import importlib.metadata as _meta mock_logger.isEnabledFor.return_value = True class DepPlugin: enabled = True @staticmethod def runtime_deps(): return ("somepkg",) mock_mgr.return_value = [{"plugin": {DepPlugin}}] # Temporarily hide packages_distributions to trigger the ImportError _pd = _meta.packages_distributions del _meta.packages_distributions try: cli._log_runtime_env() finally: _meta.packages_distributions = _pd # Env summary was still logged despite the missing function calls = [a for a, _ in mock_logger.debug.call_args_list] assert any(a[0] == "Apprise: %s" for a in calls) # No dependency list is logged without packages_distributions. assert not any(a[0] == "Runtime deps: %s" for a in calls) # Remove this test when Python 3.9 support is dropped. @pytest.mark.skipif( sys.version_info >= (3, 11), reason="packages_distributions exists on Python 3.11+", ) @mock.patch("apprise.cli.NotificationManager") @mock.patch("apprise.cli.logger") def test_apprise_cli_runtime_env_py39_no_packages_distributions( mock_logger, mock_mgr ): """ CLI: _log_runtime_env() gracefully skips dep listing on Python < 3.11 where packages_distributions() is genuinely absent. """ mock_logger.isEnabledFor.return_value = True class DepPlugin: enabled = True @staticmethod def runtime_deps(): return ("somepkg",) mock_mgr.return_value = [{"plugin": {DepPlugin}}] # This Python version lacks packages_distributions natively. cli._log_runtime_env() # Env summary was still logged despite the missing function calls = [a for a, _ in mock_logger.debug.call_args_list] assert any(a[0] == "Apprise: %s" for a in calls) # No dependency list is logged without packages_distributions. assert not any(a[0] == "Runtime deps: %s" for a in calls) def test_apprise_cli_limit_option_in_help(): """ CLI: --limit (-L) appears in --help output """ runner = CliRunner() result = runner.invoke(cli.main, ["--help"]) assert result.exit_code == 0 assert "--limit" in result.output assert "-L" in result.output @mock.patch("requests.request") def test_apprise_cli_notify_runtime_stat_log(mock_request): """CLI: log runtime, counts, and overall status after notification. Patch only ``logger.debug`` because setup also uses the logger's level. """ response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_request.return_value = response runner = CliRunner() with mock.patch.object(cli.logger, "debug") as mock_debug: result = runner.invoke( cli.main, ["-t", "title", "-b", "body", "json://good"], ) assert result.exit_code == 0 calls = [a for a, _ in mock_debug.call_args_list] assert any( a[0] == ( "Finished in %.2fs. %d service(s) tried (%s): %d sent / " "%d failed / %d timed out." ) # The elapsed-seconds value is the first substitution. and a[2:] == (1, "SUCCESS", 1, 0, 0) for a in calls ) @mock.patch("apprise.cli._force_exit") @mock.patch("requests.request") def test_apprise_cli_limit_option_times_out_service( mock_request, mock_force_exit ): """CLI: ``--limit`` reports TIMEOUT without requiring a hard exit. The long service delay avoids timing races. Mock ``_force_exit`` because its real ``os._exit`` would stop the in-process test runner. """ import time def _slow_failure(*args, **kwargs): """Return a delayed HTTP failure to force the CLI timeout path.""" time.sleep(2.0) response = mock.Mock() response.status_code = requests.codes.internal_server_error response.content = b"" response.text = "" return response mock_request.side_effect = _slow_failure runner = CliRunner() result = runner.invoke( cli.main, [ "-t", "title", "-b", "body", "--limit", "0.05", "json://good?retry=3", ], ) # A lone timeout produces the TIMEOUT exit code, not generic FAILURE. # Its diagnostic uses logging and is tested separately. assert result.exit_code == AppriseResultStatus.TIMEOUT # The service finishes during the grace period, avoiding a hard exit. mock_force_exit.assert_not_called() @mock.patch("apprise.cli._force_exit") @mock.patch("requests.request") def test_apprise_cli_limit_hard_exit(mock_request, mock_force_exit): """Force exit when a call outlives the timeout grace period. A two-second delay avoids timing races. ``_force_exit()`` is mocked so the test process stays alive. """ import time def _slow_failure(*args, **kwargs): """Return a delayed HTTP failure to force the CLI timeout path.""" time.sleep(2.0) response = mock.Mock() response.status_code = requests.codes.internal_server_error response.content = b"" response.text = "" return response mock_request.side_effect = _slow_failure with mock.patch("apprise.cli.CLI_TIMEOUT_EXIT_GRACE_SECONDS", 0.05): runner = CliRunner() result = runner.invoke( cli.main, [ "-t", "title", "-b", "body", "--limit", "0.05", "json://good?retry=3", ], ) assert result.exit_code == AppriseResultStatus.TIMEOUT mock_force_exit.assert_called_once() assert mock_force_exit.call_args.args[1] == AppriseResultStatus.TIMEOUT def test_wait_for_abandoned_calls_polls_full_grace_period(): """Poll in short intervals until the full grace period expires.""" with ( mock.patch("apprise.cli.time.sleep") as mock_sleep, mock.patch( "apprise.cli.any_abandoned_calls_still_running", return_value=True, ) as mock_still_running, ): result = cli._wait_for_abandoned_calls( cli.CLI_TIMEOUT_EXIT_GRACE_SECONDS ) assert result is False assert mock_still_running.called total_slept = sum(call.args[0] for call in mock_sleep.call_args_list) assert total_slept == pytest.approx(cli.CLI_TIMEOUT_EXIT_GRACE_SECONDS) for call in mock_sleep.call_args_list: assert call.args[0] <= cli.CLI_TIMEOUT_EXIT_POLL_INTERVAL def test_wait_for_abandoned_calls_logs_masked_services(): """Log masked descriptions for unfinished calls at DEBUG once.""" with ( mock.patch("apprise.cli.time.sleep"), mock.patch( "apprise.cli.any_abandoned_calls_still_running", return_value=False, ), mock.patch( "apprise.cli.abandoned_call_descriptions", return_value=["dummy (dummy://masked@host)"], ), mock.patch("apprise.cli.logger.debug") as mock_debug, ): cli._wait_for_abandoned_calls(cli.CLI_TIMEOUT_EXIT_GRACE_SECONDS) first_call_message = mock_debug.call_args_list[0].args assert "dummy (dummy://masked@host)" in first_call_message def test_wait_for_abandoned_calls_exits_early_when_calls_finish(): """Stop polling as soon as every abandoned call finishes.""" # Two busy checks produce two short sleeps before completion. with ( mock.patch("apprise.cli.time.sleep") as mock_sleep, mock.patch( "apprise.cli.any_abandoned_calls_still_running", side_effect=[True, True, False], ) as mock_still_running, ): result = cli._wait_for_abandoned_calls( cli.CLI_TIMEOUT_EXIT_GRACE_SECONDS ) assert result is True assert mock_still_running.call_count == 3 assert mock_sleep.call_count == 2 for call in mock_sleep.call_args_list: assert call.args[0] == cli.CLI_TIMEOUT_EXIT_POLL_INTERVAL def test_wait_for_abandoned_calls_finishes_right_as_grace_period_ends(): """A final post-loop check can catch work that just finished.""" with ( mock.patch("apprise.cli.time.sleep"), mock.patch( "apprise.cli.any_abandoned_calls_still_running", # Four in-loop polls still report running. The final check # sees that the abandoned work has just finished. side_effect=[True, True, True, True, False], ) as mock_still_running, ): result = cli._wait_for_abandoned_calls(1.0) assert result is True assert mock_still_running.call_count == 5 def test_force_exit_sequence(): """Flush stores and output before forcing the process to exit. All external effects are mocked so the sequence is tested safely. """ a = Apprise() services = [mock.Mock(spec=NotifyBase) for _ in range(3)] for service in services: a.add(service) with ( mock.patch("apprise.cli.logging.shutdown") as mock_shutdown, mock.patch("apprise.cli.sys.stdout.flush") as mock_stdout_flush, mock.patch("apprise.cli.sys.stderr.flush") as mock_stderr_flush, mock.patch("apprise.cli.os._exit") as mock_os_exit, ): cli._force_exit(a, AppriseResultStatus.TIMEOUT) for service in services: service.flush_store.assert_called_once() mock_shutdown.assert_called_once() mock_stdout_flush.assert_called_once() mock_stderr_flush.assert_called_once() mock_os_exit.assert_called_once_with(AppriseResultStatus.TIMEOUT) def test_force_exit_flush_failure_does_not_skip_others(): """A failed store flush does not skip other stores or the hard exit. The broken service comes first to exercise the remaining flushes. """ a = Apprise() broken_service = mock.Mock(spec=NotifyBase) broken_service.flush_store.side_effect = RuntimeError("disk full") ok_service = mock.Mock(spec=NotifyBase) a.add(broken_service) a.add(ok_service) with ( mock.patch("apprise.cli.logging.shutdown") as mock_shutdown, mock.patch("apprise.cli.os._exit") as mock_os_exit, ): cli._force_exit(a, AppriseResultStatus.TIMEOUT) # The service after the broken one in iteration order still got # its own flush attempt. ok_service.flush_store.assert_called_once() mock_os_exit.assert_called_once_with(AppriseResultStatus.TIMEOUT) # logging.shutdown() is unrelated to the failing store and should # still run normally afterward. mock_shutdown.assert_called_once() def test_force_exit_reaches_exit_despite_logging_failure(): """A logging shutdown failure does not prevent output flushes or exit.""" a = Apprise() service = mock.Mock(spec=NotifyBase) a.add(service) with ( mock.patch( "apprise.cli.logging.shutdown", side_effect=RuntimeError("logging is broken"), ), mock.patch("apprise.cli.sys.stdout.flush") as mock_stdout_flush, mock.patch("apprise.cli.sys.stderr.flush") as mock_stderr_flush, mock.patch("apprise.cli.os._exit") as mock_os_exit, ): cli._force_exit(a, AppriseResultStatus.TIMEOUT) service.flush_store.assert_called_once() mock_stdout_flush.assert_called_once() mock_stderr_flush.assert_called_once() mock_os_exit.assert_called_once_with(AppriseResultStatus.TIMEOUT) def test_apprise_cli_limit_option_negative_value_errors(): """ CLI: --limit (-L) rejects a negative value the same way notify() and AppriseAsset(service_timeout=...) do. """ runner = CliRunner() result = runner.invoke( cli.main, ["-t", "title", "-b", "body", "--limit", "-5", "json://good"], ) assert result.exit_code != 0 def test_apprise_cli_service_limit_option_in_help(): """ CLI: --service-limit (-SL) appears in --help output """ runner = CliRunner() result = runner.invoke(cli.main, ["--help"]) assert result.exit_code == 0 assert "--service-limit" in result.output assert "-SL" in result.output def test_apprise_cli_service_limit_option_negative_value_errors(): """ CLI: --service-limit (-SL) rejects a negative value the same way AppriseAsset(service_timeout=...) does. """ runner = CliRunner() result = runner.invoke( cli.main, [ "-t", "title", "-b", "body", "--service-limit", "-5", "json://good", ], ) assert result.exit_code != 0 @mock.patch("apprise.cli.AppriseAsset") @mock.patch("requests.request") def test_apprise_cli_service_limit_option_passed_to_asset( mock_request, mock_asset_cls ): """ CLI: --service-limit (-SL), when specified, is passed into AppriseAsset(service_timeout=...) independently of --limit. """ mock_asset_cls.side_effect = AppriseAsset mock_request.return_value = requests.Request() mock_request.return_value.status_code = requests.codes.ok runner = CliRunner() result = runner.invoke( cli.main, [ "-t", "title", "-b", "body", "--limit", "30", "--service-limit", "5", "json://localhost", ], ) assert result.exit_code == 0 # --service-limit is independently set on the AppriseAsset, no # matter what --limit was also set to. assert mock_asset_cls.call_args.kwargs.get("service_timeout") == 5.0 @mock.patch("apprise.cli.AppriseAsset") @mock.patch("requests.request") def test_apprise_cli_service_limit_option_omitted( mock_request, mock_asset_cls ): """ CLI: when --service-limit is not specified, AppriseAsset() receives service_timeout=None -- its own built-in default remains in effect, even if --limit was specified. """ mock_asset_cls.side_effect = AppriseAsset mock_request.return_value = requests.Request() mock_request.return_value.status_code = requests.codes.ok runner = CliRunner() result = runner.invoke( cli.main, [ "-t", "title", "-b", "body", "--limit", "30", "json://localhost", ], ) assert result.exit_code == 0 assert mock_asset_cls.call_args.kwargs.get("service_timeout") is None caronc-apprise-dfec414/tests/test_apprise_config.py000066400000000000000000001320531526720371000226660ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import sys import time from unittest import mock import pytest from apprise import ( Apprise, AppriseAsset, AppriseConfig, ConfigFormat, ConfigurationManager, ContentIncludeMode, NotificationManager, NotifyFormat, ) from apprise.config import ConfigBase from apprise.config.file import ConfigFile from apprise.logger import LogCapture from apprise.plugins import NotifyBase logging.disable(logging.CRITICAL) # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() # Grant access to our Configuration Manager Singleton C_MGR = ConfigurationManager() def test_apprise_config(tmpdir): """ API: AppriseConfig basic testing """ # Create ourselves a config object ac = AppriseConfig() # There are no services loaded assert len(ac) == 0 # Object can be directly checked as a boolean; response is False # when there are no entries loaded assert not ac # lets try anyway assert len(ac.services()) == 0 t = tmpdir.mkdir("simple-formatting").join("apprise") t.write(""" # A comment line over top of a URL mailto://usera:pass@gmail.com # A line with mulitiple tag assignments to it taga,tagb=gnome:// # Event if there is accidental leading spaces, this configuation # is accepting of htat and will not exclude them tagc=kde:// # A very poorly structured url sns://:@/ # Just 1 token provided causes exception sns://T1JJ3T3L2/ # XML xml://localhost/?+HeaderEntry=Test&:IgnoredEntry=Ignored """) # Create ourselves a config object ac = AppriseConfig(paths=str(t)) # One configuration file should have been found assert len(ac) == 1 # Object can be directly checked as a boolean; response is True # when there is at least one entry assert ac # We should be able to read our 4 services from that assert len(ac.services()) == 4 # Get our URL back assert isinstance(ac[0].url(), str) # Test cases where our URL is invalid t = tmpdir.mkdir("strange-lines").join("apprise") t.write(""" # basicly this consists of defined tags and no url tag= """) # Create ourselves a config object ac = AppriseConfig(paths=str(t), asset=AppriseAsset()) # One configuration file should have been found assert len(ac) == 1 # No urls were set assert len(ac.services()) == 0 # Create a ConfigBase object cb = ConfigBase() # Test adding of all entries assert ac.add(configs=cb, asset=AppriseAsset(), tag="test") is True # Test adding of all entries assert ( ac.add( configs=[ "file://?", ], asset=AppriseAsset(), tag="test", ) is False ) # Test the adding of garbage assert ac.add(configs=object()) is False # Try again but enforce our format ac = AppriseConfig(paths=f"file://{t!s}?format=text") # One configuration file should have been found assert len(ac) == 1 # No urls were set assert len(ac.services()) == 0 # # Test Internatialization and the handling of unicode characters # istr = """ # Iñtërnâtiônàlization Testing windows://""" # Write our content to our file t = tmpdir.mkdir("internationalization").join("apprise") with open(str(t), "wb") as f: f.write(istr.encode("latin-1")) # Create ourselves a config object ac = AppriseConfig(paths=str(t)) # One configuration file should have been found assert len(ac) == 1 # This will fail because our default encoding is utf-8; however the file # we opened was not; it was latin-1 and could not be parsed. assert len(ac.services()) == 0 # Test iterator count = 0 for _entry in ac: count += 1 assert len(ac) == count # We can fix this though; set our encoding to latin-1 ac = AppriseConfig(paths=f"file://{t!s}?encoding=latin-1") # One configuration file should have been found assert len(ac) == 1 # Our URL should be found assert len(ac.services()) == 1 # Get our URL back assert isinstance(ac[0].url(), str) # pop an entry from our list assert isinstance(ac.pop(0), ConfigBase) # Determine we have no more configuration entries loaded assert len(ac) == 0 # # Test buffer handling (and overflow) t = tmpdir.mkdir("buffer-handling").join("apprise") buf = "gnome://" t.write(buf) # Reset our config object ac.clear() # Create ourselves a config object ac = AppriseConfig(paths=str(t)) # update our length to be the size of our actual file ac[0].max_buffer_size = len(buf) # One configuration file should have been found assert len(ac) == 1 assert len(ac.services()) == 1 # update our buffer size to be slightly smaller then what we allow ac[0].max_buffer_size = len(buf) - 1 # Content is automatically cached; so even though we adjusted the buffer # above, our results have been cached so we get a 1 response. assert len(ac.services()) == 1 def test_apprise_multi_config_entries(tmpdir): """ API: AppriseConfig basic multi-adding functionality """ # temporary file to work with t = tmpdir.mkdir("apprise-multi-add").join("apprise") buf = """ good://hostname """ t.write(buf) # temporary empty file to work with te = tmpdir.join("apprise-multi-add", "apprise-empty") te.write("") # Define our good:// url class GoodNotification(NotifyBase): def __init__(self, **kwargs): super().__init__(notify_format=NotifyFormat.HTML, **kwargs) def notify(self, **kwargs): # Pretend everything is okay return True def url(self, **kwargs): # support url() return "" # Store our good notification in our schema map N_MGR._schema_map["good"] = GoodNotification # Create ourselves a config object ac = AppriseConfig() # There are no services loaded assert len(ac) == 0 # Support adding of muilt strings and objects: assert ac.add(configs=(str(t), str(t))) is True assert ( ac.add(configs=(ConfigFile(path=str(te)), ConfigFile(path=str(t)))) is True ) # don't support the adding of invalid content assert ac.add(configs=(object(), object())) is False assert ac.add(configs=object()) is False # Try to pop an element out of range with pytest.raises(IndexError): ac.service_pop(len(ac.services())) # Pop our elements while len(ac.services()) > 0: assert isinstance(ac.service_pop(len(ac.services()) - 1), NotifyBase) def test_apprise_add_config(): """API AppriseConfig.add_config()""" content = """ # A comment line over top of a URL mailto://usera:pass@gmail.com # A line with mulitiple tag assignments to it taga,tagb=gnome:// # Event if there is accidental leading spaces, this configuation # is accepting of htat and will not exclude them tagc=kde:// # A very poorly structured url sns://:@/ # Just 1 token provided causes exception sns://T1JJ3T3L2/ """ # Create ourselves a config object ac = AppriseConfig() assert ac.add_config(content=content) is True # One configuration file should have been found assert len(ac) == 1 assert ac[0].config_format is ConfigFormat.TEXT # Object can be directly checked as a boolean; response is True # when there is at least one entry assert ac # We should be able to read our 3 services from that assert len(ac.services()) == 3 # Get our URL back assert isinstance(ac[0].url(), str) # Test invalid content assert ac.add_config(content=object()) is False assert ac.add_config(content=42) is False assert ac.add_config(content=None) is False # Still only one service loaded assert len(ac) == 1 # Test having a pre-defined asset object and tag created assert ( ac.add_config(content=content, asset=AppriseAsset(), tag="a") is True ) # Now there are 2 services loaded assert len(ac) == 2 # and 6 urls.. (as we've doubled up) assert len(ac.services()) == 6 content = """ # A YAML File urls: - mailto://usera:pass@gmail.com - gnome://: tag: taga,tagb - json://localhost: +HeaderEntry1: 'a header entry' -HeaderEntryDepricated: 'a deprecated entry' :HeaderEntryIgnored: 'an ignored header entry' - xml://localhost: +HeaderEntry1: 'a header entry' -HeaderEntryDepricated: 'a deprecated entry' :HeaderEntryIgnored: 'an ignored header entry' """ # Create ourselves a config object ac = AppriseConfig() assert ac.add_config(content=content) is True # One configuration file should have been found assert len(ac) == 1 assert ac[0].config_format is ConfigFormat.YAML # Object can be directly checked as a boolean; response is True # when there is at least one entry assert ac # We should be able to read our 4 services from that assert len(ac.services()) == 4 # Now an invalid configuration file content = "invalid" # Create ourselves a config object ac = AppriseConfig() assert ac.add_config(content=content) is False # Nothing is loaded assert len(ac.services()) == 0 def test_apprise_config_tagging(tmpdir): """ API: AppriseConfig tagging """ # temporary file to work with t = tmpdir.mkdir("tagging").join("apprise") buf = "gnome://" t.write(buf) # Create ourselves a config object ac = AppriseConfig() # Add an item associated with tag a assert ac.add(configs=str(t), asset=AppriseAsset(), tag="a") is True # Add an item associated with tag b assert ac.add(configs=str(t), asset=AppriseAsset(), tag="b") is True # Add an item associated with tag a or b assert ac.add(configs=str(t), asset=AppriseAsset(), tag="a,b") is True # Now filter: a: assert len(ac.services(tag="a")) == 2 # Now filter: a or b: assert len(ac.services(tag="a,b")) == 3 # Now filter: a and b assert len(ac.services(tag=[("a", "b")])) == 1 # all matches everything assert len(ac.services(tag="all")) == 3 # Test cases using the `always` keyword # Create ourselves a config object ac = AppriseConfig() # Add an item associated with tag a assert ac.add(configs=str(t), asset=AppriseAsset(), tag="a,always") is True # Add an item associated with tag b assert ac.add(configs=str(t), asset=AppriseAsset(), tag="b") is True # Add an item associated with tag a or b assert ac.add(configs=str(t), asset=AppriseAsset(), tag="c,d") is True # Now filter: a: assert len(ac.services(tag="a")) == 1 # Now filter: a or b: assert len(ac.services(tag="a,b")) == 2 # Now filter: e # we'll match the `always' assert len(ac.services(tag="e")) == 1 assert len(ac.services(tag="e", match_always=False)) == 0 # all matches everything assert len(ac.services(tag="all")) == 3 # Now filter: d # we'll match the `always' tag assert len(ac.services(tag="d")) == 2 assert len(ac.services(tag="d", match_always=False)) == 1 def test_apprise_config_instantiate(): """ API: AppriseConfig.instantiate() """ assert ( AppriseConfig.instantiate("file://?", suppress_exceptions=True) is None ) assert ( AppriseConfig.instantiate("invalid://?", suppress_exceptions=True) is None ) class BadConfig(ConfigBase): # always allow incusion allow_cross_includes = ContentIncludeMode.ALWAYS def __init__(self, **kwargs): super().__init__(**kwargs) # We fail whenever we're initialized raise TypeError() @staticmethod def parse_url(url, *args, **kwargs): # always parseable return ConfigBase.parse_url(url, verify_host=False) # Store our bad configuration in our schema map C_MGR["bad"] = BadConfig with pytest.raises(TypeError): AppriseConfig.instantiate("bad://path", suppress_exceptions=False) # Same call but exceptions suppressed assert ( AppriseConfig.instantiate("bad://path", suppress_exceptions=True) is None ) def test_invalid_apprise_config(tmpdir): """Parse invalid configuration includes.""" class BadConfig(ConfigBase): # always allow incusion allow_cross_includes = ContentIncludeMode.ALWAYS def __init__(self, **kwargs): super().__init__(**kwargs) # We intentionally fail whenever we're initialized raise TypeError() @staticmethod def parse_url(url, *args, **kwargs): # always parseable return ConfigBase.parse_url(url, verify_host=False) # Store our bad configuration in our schema map C_MGR["bad"] = BadConfig # temporary file to work with t = tmpdir.mkdir("apprise-bad-obj").join("invalid") buf = f""" # Include an invalid schema include invalid:// # An unparsable valid schema include https:// # A valid configuration that will throw an exception include bad:// # Include ourselves (So our recursive includes fails as well) include {t!s} """ t.write(buf) # Create ourselves a config object with caching disbled ac = AppriseConfig(recursion=2, insecure_includes=True, cache=False) # Nothing loaded yet assert len(ac) == 0 # Add our config assert ac.add(configs=str(t), asset=AppriseAsset()) is True # One configuration file assert len(ac) == 1 # All of the services were invalid and would not load assert len(ac.services()) == 0 def test_apprise_config_with_apprise_obj(tmpdir): """ API: ConfigBase - parse valid config """ # temporary file to work with t = tmpdir.mkdir("apprise-obj").join("apprise") buf = """ good://hostname localhost=good://localhost """ t.write(buf) # Define our good:// url class GoodNotification(NotifyBase): def __init__(self, **kwargs): super().__init__(notify_format=NotifyFormat.HTML, **kwargs) def notify(self, **kwargs): # Pretend everything is okay return True def url(self, **kwargs): # support url() return "" # Store our good notification in our schema map N_MGR._schema_map["good"] = GoodNotification # Create ourselves a config object ac = AppriseConfig(cache=False) # Nothing loaded yet assert len(ac) == 0 # Add an item associated with tag a assert ac.add(configs=str(t), asset=AppriseAsset(), tag="a") is True # One configuration file assert len(ac) == 1 # 2 services found in it assert len(ac.services()) == 2 # Pop one of them (at index 0) ac.service_pop(0) # Verify that it no longer listed assert len(ac.services()) == 1 # Test our ability to add Config objects to our apprise object a = Apprise() # Add our configuration object assert a.add(services=ac) is True # Detect our 1 entry (originally there were 2 but we deleted one) assert len(a) == 1 # Notify our service assert bool(a.notify(body="apprise configuration power!")) is True # Add our configuration object assert ( a.add(services=[AppriseConfig(str(t)), AppriseConfig(str(t))]) is True ) # Detect our 5 loaded entries now; 1 from first config, and another # 2x2 based on adding our list above assert len(a) == 5 # We can't add garbage assert a.add(services=object()) is False assert a.add(services=[object(), object()]) is False # Our length is unchanged assert len(a) == 5 # reference index 0 of our list ref = a[0] assert isinstance(ref, NotifyBase) # Our length is unchanged assert len(a) == 5 # pop the index ref_popped = a.pop(0) # Verify our response assert isinstance(ref_popped, NotifyBase) # Our length drops by 1 assert len(a) == 4 # Content popped is the same as one referenced by index # earlier assert ref == ref_popped # pop an index out of range with pytest.raises(IndexError): a.pop(len(a)) # Our length remains unchanged assert len(a) == 4 # Reference content out of range with pytest.raises(IndexError): a[len(a)] # reference index at the end of our list ref = a[len(a) - 1] # Verify our response assert isinstance(ref, NotifyBase) # Our length stays the same assert len(a) == 4 # We can pop from the back of the list without a problem too ref_popped = a.pop(len(a) - 1) # Verify our response assert isinstance(ref_popped, NotifyBase) # Content popped is the same as one referenced by index # earlier assert ref == ref_popped # Our length drops by 1 assert len(a) == 3 # Now we'll test adding another element to the list so that it mixes up # our response object. # Below we add 3 different types, a ConfigBase, NotifyBase, and URL assert ( a.add( services=[ ConfigFile(path=(str(t))), "good://another.host", GoodNotification(**{"host": "nuxref.com"}), ] ) is True ) # Our length increases by 4 (2 entries in the config file, + 2 others) assert len(a) == 7 # reference index at the end of our list ref = a[len(a) - 1] # Verify our response assert isinstance(ref, NotifyBase) # We can pop from the back of the list without a problem too ref_popped = a.pop(len(a) - 1) # Verify our response assert isinstance(ref_popped, NotifyBase) # Content popped is the same as one referenced by index # earlier assert ref == ref_popped # Our length drops by 1 assert len(a) == 6 # pop our list while len(a) > 0: assert isinstance(a.pop(len(a) - 1), NotifyBase) def test_recursive_config_inclusion(tmpdir): """ API: Apprise() Recursive Config Inclusion """ # To test our config classes, we make three dummy configs class ConfigCrossPostAlways(ConfigFile): """A dummy config that is set to always allow inclusion.""" service_name = "always" # protocol protocol = "always" # Always type allow_cross_includes = ContentIncludeMode.ALWAYS class ConfigCrossPostStrict(ConfigFile): """A dummy config that is set to strict inclusion.""" service_name = "strict" # protocol protocol = "strict" # Always type allow_cross_includes = ContentIncludeMode.STRICT class ConfigCrossPostNever(ConfigFile): """A dummy config that is set to never allow inclusion.""" service_name = "never" # protocol protocol = "never" # Always type allow_cross_includes = ContentIncludeMode.NEVER # store our entries C_MGR["never"] = ConfigCrossPostNever C_MGR["strict"] = ConfigCrossPostStrict C_MGR["always"] = ConfigCrossPostAlways # Make our new path valid suite = tmpdir.mkdir("apprise_config_recursion") cfg01 = suite.join("cfg01.cfg") cfg02 = suite.mkdir("dir1").join("cfg02.cfg") cfg03 = suite.mkdir("dir2").join("cfg03.cfg") cfg04 = suite.mkdir("dir3").join("cfg04.cfg") # Populate our files with valid configuration include lines cfg01.write(f""" # json entry json://localhost:8080 # absolute path inclusion to ourselves include {cfg01!s}""") cfg02.write(""" # json entry json://localhost:8080 # recursively include ourselves include cfg02.cfg""") cfg03.write(""" # xml entry xml://localhost:8080 # relative path inclusion include ../dir1/cfg02.cfg # test that we can't include invalid entries include invalid://entry # Include non includable type include memory://""") cfg04.write(f""" # xml entry xml://localhost:8080 # always include of our file include always://{cfg04!s} # never include of our file include never://{cfg04!s} # strict include of our file include strict://{cfg04!s}""") # Create ourselves a config object ac = AppriseConfig() # There are no services loaded assert len(ac) == 0 # load our configuration assert ac.add(configs=str(cfg01)) is True # verify it loaded assert len(ac) == 1 # 1 service will be loaded as there is no recursion at this point assert len(ac.services()) == 1 # Create ourselves a config object ac = AppriseConfig(recursion=1) # load our configuration assert ac.add(configs=str(cfg01)) is True # verify one configuration file loaded however since it recursively # loaded itself 1 more time, it still doesn't impact the load count: assert len(ac) == 1 # 2 services loaded now that we loaded the same file twice assert len(ac.services()) == 2 # # Now we test relative file inclusion # # Create ourselves a config object ac = AppriseConfig(recursion=10) # There are no services loaded assert len(ac) == 0 # load our configuration assert ac.add(configs=str(cfg02)) is True # verify it loaded assert len(ac) == 1 # 11 services loaded because we reloaded ourselves 10 times # after loading the first entry assert len(ac.services()) == 11 # Test our include modes (strict, always, and never) # Create ourselves a config object ac = AppriseConfig(recursion=1) # There are no services loaded assert len(ac) == 0 # load our configuration assert ac.add(configs=str(cfg04)) is True # verify it loaded assert len(ac) == 1 # 2 services loaded # 1 - from the file read (which is set at mode STRICT # 1 - from the always:// # # The never:// can ever be includeed, and the strict:// is ot of type # file:// (the one doing the include) so it is also ignored. # # By turning on the insecure_includes, we can include the strict files too assert len(ac.services()) == 2 # Create ourselves a config object ac = AppriseConfig(recursion=1, insecure_includes=True) # There are no services loaded assert len(ac) == 0 # load our configuration assert ac.add(configs=str(cfg04)) is True # verify it loaded assert len(ac) == 1 # 3 services loaded # 1 - from the file read (which is set at mode STRICT # 1 - from the always:// # 1 - from the strict:// (due to insecure_includes set) assert len(ac.services()) == 3 def test_apprise_config_file_loading(tmpdir): """ API: AppriseConfig() URL Testing """ config_path = tmpdir / "apprise.yml" # Create a temporary config file config_path.write("urls:\n - json://localhost") # Flow from README.md ap = Apprise() ap.add("xml://localhost") config = AppriseConfig() config.add(str(config_path)) ap.add(config) # Using urls() assert len(ap.urls()) == 2 def test_apprise_config_matrix_load(): """ API: AppriseConfig() matrix initialization """ import apprise class ConfigDummy(ConfigBase): """A dummy wrapper for testing the different options in the load_matrix function.""" # The default descriptive name associated with the Notification service_name = "dummy" # protocol as tuple protocol = ("uh", "oh") # secure protocol as tuple secure_protocol = ("no", "yes") class ConfigDummy2(ConfigBase): """A dummy wrapper for testing the different options in the load_matrix function.""" # The default descriptive name associated with the Notification service_name = "dummy2" # secure protocol as tuple secure_protocol = ("true", "false") class ConfigDummy3(ConfigBase): """A dummy wrapper for testing the different options in the load_matrix function.""" # The default descriptive name associated with the Notification service_name = "dummy3" # secure protocol as string secure_protocol = "true" class ConfigDummy4(ConfigBase): """A dummy wrapper for testing the different options in the load_matrix function.""" # The default descriptive name associated with the Notification service_name = "dummy4" # protocol as string protocol = "true" # Generate ourselves a fake entry apprise.config.ConfigDummy = ConfigDummy apprise.config.ConfigDummy2 = ConfigDummy2 apprise.config.ConfigDummy3 = ConfigDummy3 apprise.config.ConfigDummy4 = ConfigDummy4 def test_configmatrix_dynamic_importing(tmpdir): """ API: Apprise() Config Matrix Importing """ # Make our new path valid suite = tmpdir.mkdir("apprise_config_test_suite") suite.join("__init__.py").write("") module_name = "badconfig" # Update our path to point to our new test suite sys.path.insert(0, str(suite)) # Create a base area to work within base = suite.mkdir(module_name) base.join("__init__.py").write("") # Test no app_id base.join("ConfigBadFile1.py").write(""" class ConfigBadFile1: pass""") # No class of the same name base.join("ConfigBadFile2.py").write(""" class BadClassName: pass""") # Exception thrown base.join("ConfigBadFile3.py").write("""raise ImportError()""") # Utilizes a schema:// already occupied (as string) base.join("ConfigGoober.py").write(""" from apprise.config import ConfigBase class ConfigGoober(ConfigBase): # This class tests the fact we have a new class name, but we're # trying to over-ride items previously used # The default simple (insecure) protocol (used by ConfigHTTP) protocol = ('http', 'goober') # The default secure protocol (used by ConfigHTTP) secure_protocol = 'https' @staticmethod def parse_url(url, *args, **kwargs): # always parseable return ConfigBase.parse_url(url, verify_host=False)""") # Utilizes a schema:// already occupied (as tuple) base.join("ConfigBugger.py").write(""" from apprise.config import ConfigBase class ConfigBugger(ConfigBase): # This class tests the fact we have a new class name, but we're # trying to over-ride items previously used # The default simple (insecure) protocol (used by ConfigHTTP), the other # isn't protocol = ('http', 'bugger-test' ) # The default secure protocol (used by ConfigHTTP), the other isn't secure_protocol = ('https', ['garbage']) @staticmethod def parse_url(url, *args, **kwargs): # always parseable return ConfigBase.parse_url(url, verify_host=False)""") @mock.patch("os.path.getsize") def test_config_base_inaccessible_text_file(mock_getsize, tmpdir): """ API: ConfigBase.parse_inaccessible_text_file """ # temporary file to work with t = tmpdir.mkdir("inaccessible").join("apprise") buf = "gnome://" t.write(buf) # Set getsize return value mock_getsize.return_value = None mock_getsize.side_effect = OSError # Create ourselves a config object ac = AppriseConfig(paths=str(t)) # The following internally throws an exception but still counts # as a loaded configuration file assert len(ac) == 1 # Thus no notifications are loaded assert len(ac.services()) == 0 def test_config_base_parse_yaml_file01(tmpdir): """ API: ConfigBase.parse_yaml_file (#1) """ t = tmpdir.mkdir("empty-file").join("apprise.yml") t.write("") # Create ourselves a config object ac = AppriseConfig(paths=str(t)) # The number of configuration files that exist assert len(ac) == 1 # no notifications are loaded assert len(ac.services()) == 0 def test_config_base_parse_yaml_file02(tmpdir): """ API: ConfigBase.parse_yaml_file (#2) """ t = tmpdir.mkdir("matching-tags").join("apprise.yml") t.write("""urls: - pover://nsisxnvnqixq39t0cw54pxieyvtdd9@2jevtmstfg5a7hfxndiybasttxxfku: - tag: test1 - pover://rg8ta87qngcrkc6t4qbykxktou0uug@tqs3i88xlufexwl8t4asglt4zp5wfn: - tag: test2 - pover://jcqgnlyq2oetea4qg3iunahj8d5ijm@evalvutkhc8ipmz2lcgc70wtsm0qpb: - tag: test3""") # Create ourselves a config object ac = AppriseConfig(paths=str(t)) # The number of configuration files that exist assert len(ac) == 1 # no notifications are loaded assert len(ac.services()) == 3 # Test our ability to add Config objects to our apprise object a = Apprise() # Add our configuration object assert a.add(services=ac) is True # Detect our 3 entry as they should have loaded successfully assert len(a) == 3 # No match assert sum(1 for _ in a.find("no-match")) == 0 # Match everything assert sum(1 for _ in a.find("all")) == 3 # Match test1 entry assert sum(1 for _ in a.find("test1")) == 1 # Match test2 entry assert sum(1 for _ in a.find("test2")) == 1 # Match test3 entry assert sum(1 for _ in a.find("test3")) == 1 # Match test1 or test3 entry assert sum(1 for _ in a.find("test1, test3")) == 2 def test_config_base_parse_yaml_file03(tmpdir): """ API: ConfigBase.parse_yaml_file (#3) """ t = tmpdir.mkdir("bad-first-entry").join("apprise.yml") # The first entry is -tag and not tag # The element is therefore not picked up; This causes us to display # some warning messages to the screen complaining of this typo yet # still allowing us to load the URL since it is valid t.write("""urls: - pover://nsisxnvnqixq39t0cw54pxieyvtdd9@2jevtmstfg5a7hfxndiybasttxxfku: -tag: test1 - pover://rg8ta87qngcrkc6t4qbykxktou0uug@tqs3i88xlufexwl8t4asglt4zp5wfn: - tag: test2 - pover://jcqgnlyq2oetea4qg3iunahj8d5ijm@evalvutkhc8ipmz2lcgc70wtsm0qpb: - tag: test3""") # Create ourselves a config object ac = AppriseConfig(paths=str(t)) # The number of configuration files that exist assert len(ac) == 1 # no notifications lines processed is 3 assert len(ac.services()) == 3 # Test our ability to add Config objects to our apprise object a = Apprise() # Add our configuration object assert a.add(services=ac) is True # Detect our 3 entry as they should have loaded successfully assert len(a) == 3 # No match assert sum(1 for _ in a.find("no-match")) == 0 # Match everything assert sum(1 for _ in a.find("all")) == 3 # No match for bad entry assert sum(1 for _ in a.find("test1")) == 0 # Match test2 entry assert sum(1 for _ in a.find("test2")) == 1 # Match test3 entry assert sum(1 for _ in a.find("test3")) == 1 # Match test1 or test3 entry; (only matches test3) assert sum(1 for _ in a.find("test1, test3")) == 1 def test_config_base_parse_yaml_file04(tmpdir): """ API: ConfigBase.parse_yaml_file (#4) Test the always keyword """ t = tmpdir.mkdir("always-keyword").join("apprise.yml") t.write("""urls: - pover://nsisxnvnqixq39t0cw54pxieyvtdd9@2jevtmstfg5a7hfxndiybasttxxfku: - tag: test1,always - pover://rg8ta87qngcrkc6t4qbykxktou0uug@tqs3i88xlufexwl8t4asglt4zp5wfn: - tag: test2 - pover://jcqgnlyq2oetea4qg3iunahj8d5ijm@evalvutkhc8ipmz2lcgc70wtsm0qpb: - tag: test3""") # Create ourselves a config object ac = AppriseConfig(paths=str(t)) # The number of configuration files that exist assert len(ac) == 1 # no notifications are loaded assert len(ac.services()) == 3 # Test our ability to add Config objects to our apprise object a = Apprise() # Add our configuration object assert a.add(services=ac) is True # Detect our 3 entry as they should have loaded successfully assert len(a) == 3 # No match still matches `always` keyword assert sum(1 for _ in a.find("no-match")) == 1 # Unless we explicitly do not look for that file assert sum(1 for _ in a.find("no-match", match_always=False)) == 0 # Match everything assert sum(1 for _ in a.find("all")) == 3 # Match test1 entry (also has `always` keyword assert sum(1 for _ in a.find("test1")) == 1 assert sum(1 for _ in a.find("test1", match_always=False)) == 1 # Match test2 entry (and test1 due to always keyword) assert sum(1 for _ in a.find("test2")) == 2 assert sum(1 for _ in a.find("test2", match_always=False)) == 1 # Match test3 entry (and test1 due to always keyword) assert sum(1 for _ in a.find("test3")) == 2 assert sum(1 for _ in a.find("test3", match_always=False)) == 1 # Match test1 or test3 entry assert sum(1 for _ in a.find("test1, test3")) == 2 def test_apprise_config_template_parse(tmpdir): """ API: AppriseConfig parsing of templates """ # Create ourselves a config object ac = AppriseConfig() t = tmpdir.mkdir("template-testing").join("apprise.yml") t.write(""" tag: - company # A comment line over top of a URL urls: - mailto://user:pass@example.com: - to: user1@gmail.com cc: test@hotmail.com - to: user2@gmail.com tag: co-worker """) # Create ourselves a config object ac = AppriseConfig(paths=str(t)) # 2 emails to be sent assert len(ac.services()) == 2 # The below checks are very customized for NotifyMail but just # test that the content got passed correctly assert (False, "user1@gmail.com") in ac[0][0].targets assert "test@hotmail.com" in ac[0][0].cc assert "company" in ac[0][1].tags assert (False, "user2@gmail.com") in ac[0][1].targets assert "company" in ac[0][1].tags assert "co-worker" in ac[0][1].tags # # Specifically test _special_token_handler() # tokens = { # This maps to itself (bcc); no change here "bcc": "user@test.com", # This should get mapped to 'targets' "to": "user1@abc.com", # white space and tab is intentionally added to the end to verify we # do not play/tamper with information "targets": "user2@abc.com, user3@abc.com \t", # If the end user provides a configuration for data we simply don't use # this isn't a proble... we simply don't touch it either; we leave it # as is. "ignore": "not-used", } result = ConfigBase._special_token_handler("mailto", tokens) # to gets mapped to targets assert "to" not in result # bcc is allowed here assert "bcc" in result assert "targets" in result # Not used, but also not touched; this entry should still be in our result # set assert "ignore" in result # We'll concatinate all of our targets together assert len(result["targets"]) == 2 assert "user1@abc.com" in result["targets"] # Content is passed as is assert "user2@abc.com, user3@abc.com \t" in result["targets"] # We re-do the simmiar test above. The very key difference is the # `targets` is a list already (it's expected type) so `to` can properly be # concatinated into the list vs the above (which tries to correct the # situation) tokens = { # This maps to itself (bcc); no change here "bcc": "user@test.com", # This should get mapped to 'targets' "to": "user1@abc.com", # similar to the above test except targets is now a proper # dictionary allowing the `to` (when translated to `targets`) to get # appended to it "targets": ["user2@abc.com", "user3@abc.com"], # If the end user provides a configuration for data we simply don't use # this isn't a proble... we simply don't touch it either; we leave it # as is. "ignore": "not-used", } result = ConfigBase._special_token_handler("mailto", tokens) # to gets mapped to targets assert "to" not in result # bcc is allowed here assert "bcc" in result assert "targets" in result # Not used, but also not touched; this entry should still be in our result # set assert "ignore" in result # Now we'll see the new user added as expected (concatinated into our list) assert len(result["targets"]) == 3 assert "user1@abc.com" in result["targets"] assert "user2@abc.com" in result["targets"] assert "user3@abc.com" in result["targets"] # Test the False branch of the isinstance guard in the template_kwargs # loop -- where the kwarg key already holds a valid dict AND prefix # matches exist (so we do NOT reset the existing dict to {}). The # slack schema has template_kwargs with a ':' prefix on the 'tokens' # key, which mirrors the old msteams layout used to cover this path. tokens = { # Pre-populated dict; must be preserved, not reset "tokens": {"existing": "value"}, # ':' prefix match -- keeps matches non-empty so the loop body runs ":newkey": "newvalue", } result = ConfigBase._special_token_handler("slack", tokens) # The prefixed entry must be stripped from the top-level result assert ":newkey" not in result # The pre-existing dict must survive (not be wiped to {}) assert isinstance(result.get("tokens"), dict) assert result["tokens"].get("existing") == "value" # The prefix match must be merged in alongside the existing entry assert result["tokens"].get("newkey") == "newvalue" # Test providing a list t.write(""" # A comment line over top of a URL urls: - mailtos://user:pass@example.com: - smtp: smtp3-dev.google.gmail.com to: - John Smith - Jason Tater - user3@gmail.com - to: Henry Fisher , Jason Archie smtp_host: smtp5-dev.google.gmail.com tag: drinking-buddy # provide case where the URL includes some input too # In both of these cases, the cc and targets (to) get over-ridden # by values below - mailtos://user:pass@example.com/arnold@imdb.com/?cc=bill@micro.com/: to: - override01@gmail.com cc: - override02@gmail.com - sinch://: - spi: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa token: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb # Test a case where we expect a string, but yaml reads it in as # a number from: 10005243890 to: +1(123)555-1234 """) # Create ourselves a config object ac = AppriseConfig(paths=str(t)) # 2 emails to be sent and 1 Sinch service call assert len(ac.services()) == 4 # Verify our users got placed into the to assert len(ac[0][0].targets) == 3 assert ("John Smith", "user1@gmail.com") in ac[0][0].targets assert ("Jason Tater", "user2@gmail.com") in ac[0][0].targets assert (False, "user3@gmail.com") in ac[0][0].targets assert ac[0][0].smtp_host == "smtp3-dev.google.gmail.com" assert len(ac[0][1].targets) == 2 assert ("Henry Fisher", "user4@gmail.com") in ac[0][1].targets assert ("Jason Archie", "user5@gmail.com") in ac[0][1].targets assert "drinking-buddy" in ac[0][1].tags assert ac[0][1].smtp_host == "smtp5-dev.google.gmail.com" # Our third test tests cases where some variables are defined inline # and additional ones are defined below that share the same token space assert len(ac[0][2].targets) == 1 assert len(ac[0][2].cc) == 1 assert (False, "override01@gmail.com") in ac[0][2].targets assert "override02@gmail.com" in ac[0][2].cc # Test our Since configuration now: assert len(ac[0][3].targets) == 1 assert ac[0][3].service_plan_id == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" assert ac[0][3].source == "+10005243890" assert ac[0][3].targets[0] == "+11235551234" def test_config_base_yaml_trace_logging(tmpdir): """ ConfigBase: cover TRACE-only logging path in YAML parsing This specifically targets: if ConfigBase.logger.isEnabledFor(logging.TRACE): ConfigBase.logger.trace(...) """ t = tmpdir.mkdir("trace-logging").join("apprise.yml") t.write("""urls: - json://localhost """) # Patch logger methods to force and observe TRACE logging behaviour with ( mock.patch.object( ConfigBase.logger, "isEnabledFor", side_effect=lambda level: level == logging.TRACE, ), mock.patch.object(ConfigBase.logger, "trace") as m_trace, ): ac = AppriseConfig(paths=str(t)) assert len(ac) == 1 assert len(ac.services()) == 1 # We expect our TRACE log call to have happened at least once assert m_trace.called is True def test_config_base_clear_cache(tmpdir): """ ConfigBase: cover clear_cache() behaviour """ t = tmpdir.mkdir("clear-cache").join("apprise.yml") t.write("""urls: - json://localhost """) ac = AppriseConfig(paths=str(t)) # Trigger a load to populate cache inside the config instance assert len(ac.services()) == 1 # The underlying config object should have cached state cfg = ac[0] assert isinstance(cfg, ConfigBase) assert isinstance(getattr(cfg, "_cached_services", None), list) assert getattr(cfg, "_cached_time", None) is not None # Now clear it and confirm it resets as expected cfg.clear_cache() assert getattr(cfg, "_cached_services", None) is None assert getattr(cfg, "_cached_time", None) is None def test_config_base_parse_url_cache_variants(): """ ConfigBase.parse_url(): cover cache parsing for int and bool values """ # Integer cache value r = ConfigBase.parse_url("file:///tmp/apprise.yml?cache=60") assert isinstance(r, dict) assert r.get("cache") == 60 # Boolean cache value (non-int) r = ConfigBase.parse_url("file:///tmp/apprise.yml?cache=no") assert isinstance(r, dict) assert r.get("cache") is False r = ConfigBase.parse_url("file:///tmp/apprise.yml?cache=yes") assert isinstance(r, dict) assert r.get("cache") is True def test_config_base_parse_url_invalid_format_removed(): """ ConfigBase.parse_url(): cover invalid format handling (format removed) """ r = ConfigBase.parse_url( "file:///tmp/apprise.yml?format=definitely-not-a-format" ) assert isinstance(r, dict) # Invalid format should be dropped from results assert "format" not in r def test_config_base_expired_with_int_cache(monkeypatch): """ ConfigBase.expired(): cover int cache expiry checks Simulate time movement to ensure both non-expired and expired paths. """ cb = ConfigBase(cache=30) # Seed cache cb._cached_services = [] cb._cached_time = 1000.0 # Within cache window monkeypatch.setattr(time, "time", lambda: 1020.0) assert cb.expired() is False # Beyond cache window monkeypatch.setattr(time, "time", lambda: 1031.0) assert cb.expired() is True def test_configuration_manager_no_import_failures(): """Verify all native config plugins load without TRACE import errors.""" # Remove the global test-suite log suppression so TRACE records # are visible during the scan below logging.disable(logging.NOTSET) # Force a clean reload of the native config plugin directory C_MGR.unload_modules() with LogCapture(level=logging.TRACE) as stream: # Trigger load_modules() via the lazy-load path len(C_MGR) # Grab the captured log content before the handler is removed content = stream.getvalue() # Restore log suppression for the remainder of the test suite logging.disable(logging.CRITICAL) # Any "import failed" line means a plugin directory was found but # contained no loadable Config* class (e.g. a stale __pycache__ # directory left behind after a rename). failures = [ line for line in content.splitlines() if "import failed" in line ] assert not failures, ( "Config plugin import failures detected:\n" + "\n".join(failures) ) # Leave the manager in a clean state for subsequent tests C_MGR.unload_modules() caronc-apprise-dfec414/tests/test_apprise_emojis.py000066400000000000000000000111061526720371000227020ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import sys from apprise import emojis logging.disable(logging.CRITICAL) # Ensure we don't create .pyc files for these tests sys.dont_write_bytecode = True def test_emojis(): "emojis: apply_emojis() testing" assert emojis.apply_emojis("") == "" assert emojis.apply_emojis("no change") == "no change" assert emojis.apply_emojis(":smile:") == "😄" assert emojis.apply_emojis(":smile::smile:") == "😄😄" # Missing Delimiters assert emojis.apply_emojis(":smile") == ":smile" assert emojis.apply_emojis("smile:") == "smile:" # Bad data assert emojis.apply_emojis(None) == "" assert emojis.apply_emojis(object) == "" assert emojis.apply_emojis(True) == "" assert emojis.apply_emojis(4.0) == "" def test_emojis_alternation_aliases(): """emojis: apply_emojis() handles regex alternation keys correctly.""" # thumbsup / +1 assert emojis.apply_emojis(":thumbsup:") == "�" assert emojis.apply_emojis(":+1:") == "�" # thumbsdown / -1 assert emojis.apply_emojis(":thumbsdown:") == "👎" assert emojis.apply_emojis(":-1:") == "👎" # laughing / satisfied assert emojis.apply_emojis(":laughing:") == "😆" assert emojis.apply_emojis(":satisfied:") == "😆" # poop aliases assert emojis.apply_emojis(":poop:") == "💩" assert emojis.apply_emojis(":hankey:") == "💩" assert emojis.apply_emojis(":shit:") == "💩" # boom / collision assert emojis.apply_emojis(":boom:") == "💥" assert emojis.apply_emojis(":collision:") == "💥" # exclamation aliases assert emojis.apply_emojis(":exclamation:") == "�" assert emojis.apply_emojis(":heavy_exclamation_mark:") == "�" # knife / hocho assert emojis.apply_emojis(":knife:") == "🔪" assert emojis.apply_emojis(":hocho:") == "🔪" # memo / pencil assert emojis.apply_emojis(":memo:") == "�" assert emojis.apply_emojis(":pencil:") == "�" # uk / gb assert emojis.apply_emojis(":uk:") == "🇬🇧" assert emojis.apply_emojis(":gb:") == "🇬🇧" # aliases embedded in text assert emojis.apply_emojis("hello :thumbsup: world") == "hello � world" assert emojis.apply_emojis(":poop::boom:") == "💩💥" # optional-group patterns: ':hand:' and ':raised_hand:' both map to ✋ assert emojis.apply_emojis(":hand:") == "✋" assert emojis.apply_emojis(":raised_hand:") == "✋" # runner / running assert emojis.apply_emojis(":runner:") == "�" assert emojis.apply_emojis(":running:") == "�" def test_emojis_lookup_no_pattern_match(): """emojis: _lookup() returns the raw token when _EMOJI_PATTERN_LIST is empty (defensive fallback -- return text branch).""" # Ensure the engine is initialised first. emojis.apply_emojis(":smile:") original = emojis._EMOJI_PATTERN_LIST emojis._EMOJI_PATTERN_LIST = [] try: # With no patterns to match against, _lookup falls through to # "return text", so the matched token is passed back unchanged. result = emojis.apply_emojis(":smile:") assert result == ":smile:" finally: emojis._EMOJI_PATTERN_LIST = original caronc-apprise-dfec414/tests/test_apprise_helpers.py000066400000000000000000000106571526720371000230700ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import os import sys import helpers logging.disable(logging.CRITICAL) # Ensure we don't create .pyc files for these tests sys.dont_write_bytecode = True def test_environ_temporary_change(): """helpers: environ() testing""" # This is a helper function; but it does enough that we want to verify # our usage of it works correctly; yes... we're testing a test e_key1 = "APPRISE_TEMP1" e_key2 = "APPRISE_TEMP2" e_key3 = "APPRISE_TEMP3" e_val1 = "ABCD" e_val2 = "DEFG" e_val3 = "HIJK" os.environ[e_key1] = e_val1 os.environ[e_key2] = e_val2 os.environ[e_key3] = e_val3 # Ensure our environment variable stuck assert e_key1 in os.environ assert e_val1 in os.environ[e_key1] assert e_key2 in os.environ assert e_val2 in os.environ[e_key2] assert e_key3 in os.environ assert e_val3 in os.environ[e_key3] with helpers.environ(e_key1, e_key3): # Eliminates Environment Variable 1 and 3 assert e_key1 not in os.environ assert e_key2 in os.environ assert e_val2 in os.environ[e_key2] assert e_key3 not in os.environ # after with is over, environment is restored to normal assert e_key1 in os.environ assert e_val1 in os.environ[e_key1] assert e_key2 in os.environ assert e_val2 in os.environ[e_key2] assert e_key3 in os.environ assert e_val3 in os.environ[e_key3] d_key = "APPRISE_NOT_SET" n_key = "APPRISE_NEW_KEY" n_val = "NEW_VAL" # Verify that our temporary variables (defined above) are not pre-existing # environemnt variables as we'll be setting them below assert n_key not in os.environ assert d_key not in os.environ # makes it easier to pass in the arguments updates = { e_key1: e_val3, e_key2: e_val1, n_key: n_val, } with helpers.environ(d_key, e_key3, **updates): # Attempt to eliminate an undefined key (silently ignored) # Eliminates Environment Variable 3 # Environment Variable 1 takes on the value of Env 3 # Environment Variable 2 takes on the value of Env 1 # Set a brand new variable that previously didn't exist assert e_key1 in os.environ assert e_val3 in os.environ[e_key1] assert e_key2 in os.environ assert e_val1 in os.environ[e_key2] assert e_key3 not in os.environ # Can't delete a variable that doesn't exist; so we're in the same # state here. assert d_key not in os.environ # Our temporary variables will be found now assert n_key in os.environ assert n_val in os.environ[n_key] # after with is over, environment is restored to normal assert e_key1 in os.environ assert e_val1 in os.environ[e_key1] assert e_key2 in os.environ assert e_val2 in os.environ[e_key2] assert e_key3 in os.environ assert e_val3 in os.environ[e_key3] # Even our temporary variables are now missing assert n_key not in os.environ assert d_key not in os.environ caronc-apprise-dfec414/tests/test_apprise_jsonencoder.py000066400000000000000000000202051526720371000237250ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import base64 from datetime import datetime, timezone import json # Disable logging for a cleaner testing output import logging import sys from unittest import mock import pytest from apprise import Apprise from apprise.common import AWARE_DATE_ISO_FORMAT, NAIVE_DATE_ISO_FORMAT from apprise.locale import LazyTranslation from apprise.utils.json import AppriseJSONEncoder logging.disable(logging.CRITICAL) # Ensure we don't create .pyc files for these tests sys.dont_write_bytecode = True def test_apprise_json_encoder_datetime_naive(): "AppriseJSONEncoder: naive datetime serialization" dt = datetime(2026, 3, 11, 12, 30, 45, 123456) result = json.dumps(dt, cls=AppriseJSONEncoder) assert result == f'"{dt.strftime(NAIVE_DATE_ISO_FORMAT)}"' assert "T" in result # Naive datetimes have no timezone offset assert result.endswith('"') assert "+00:00" not in result def test_apprise_json_encoder_datetime_aware(): "AppriseJSONEncoder: aware datetime serialization" dt = datetime(2026, 3, 11, 12, 30, 45, 123456, tzinfo=timezone.utc) result = json.dumps(dt, cls=AppriseJSONEncoder) assert result == f'"{dt.strftime(AWARE_DATE_ISO_FORMAT)}"' assert "T" in result # Aware datetimes include the timezone offset assert "+0000" in result def test_apprise_json_encoder_bytes(): "AppriseJSONEncoder: bytes serialization" data = b"hello world" result = json.dumps(data, cls=AppriseJSONEncoder) expected = base64.b64encode(data).decode("utf-8") assert result == f'"{expected}"' # Empty bytes result = json.dumps(b"", cls=AppriseJSONEncoder) assert result == '""' # Binary data data = bytes(range(256)) result = json.dumps(data, cls=AppriseJSONEncoder) expected = base64.b64encode(data).decode("utf-8") assert result == f'"{expected}"' def test_apprise_json_encoder_set(): "AppriseJSONEncoder: set serialization" data = {1, 2, 3} result = json.loads(json.dumps(data, cls=AppriseJSONEncoder)) assert isinstance(result, list) assert sorted(result) == [1, 2, 3] # Empty set result = json.loads(json.dumps(set(), cls=AppriseJSONEncoder)) assert result == [] def test_apprise_json_encoder_frozenset(): "AppriseJSONEncoder: frozenset serialization" data = frozenset([4, 5, 6]) result = json.loads(json.dumps(data, cls=AppriseJSONEncoder)) assert isinstance(result, list) assert sorted(result) == [4, 5, 6] # Empty frozenset result = json.loads(json.dumps(frozenset(), cls=AppriseJSONEncoder)) assert result == [] def test_apprise_json_encoder_tuple(): "AppriseJSONEncoder: tuple serialization" data = (7, 8, 9) result = json.loads(json.dumps(data, cls=AppriseJSONEncoder)) assert isinstance(result, list) assert result == [7, 8, 9] # Empty tuple result = json.loads(json.dumps((), cls=AppriseJSONEncoder)) assert result == [] def test_apprise_json_encoder_lazy_translation(): "AppriseJSONEncoder: LazyTranslation serialization" lt = LazyTranslation(text="hello world") result = json.dumps(lt, cls=AppriseJSONEncoder) assert result == '"hello world"' # A translation with no gettext mapping returns the original text lt = LazyTranslation(text="no-translation-key-xyz") result = json.dumps(lt, cls=AppriseJSONEncoder) assert result == '"no-translation-key-xyz"' def test_apprise_json_encoder_unsupported_type(): "AppriseJSONEncoder: unsupported type raises TypeError" class CustomObj: pass with pytest.raises(TypeError): json.dumps(CustomObj(), cls=AppriseJSONEncoder) def test_apprise_json_encoder_nested(): "AppriseJSONEncoder: nested structures with mixed types" data = { "when": datetime(2026, 3, 11, 0, 0, 0, tzinfo=timezone.utc), "payload": b"binary data", "tags": {"alpha", "beta"}, "label": LazyTranslation(text="nested"), "coords": (1.0, 2.0), } result = json.loads(json.dumps(data, cls=AppriseJSONEncoder)) assert isinstance(result["when"], str) assert "T" in result["when"] assert isinstance(result["payload"], str) assert result["payload"] == base64.b64encode(b"binary data").decode( "utf-8" ) assert isinstance(result["tags"], list) assert sorted(result["tags"]) == ["alpha", "beta"] assert result["label"] == "nested" assert result["coords"] == [1.0, 2.0] def test_apprise_json_method_no_path(): "Apprise.json(): returns compact JSON string when no path is given" apobj = Apprise() # Default call — compact output (indent=None), no newlines result = apobj.json() assert isinstance(result, str) parsed = json.loads(result) assert isinstance(parsed, dict) # Compact: no newlines in the output assert "\n" not in result # show_requirements and show_disabled flags are forwarded to details() result_req = apobj.json(show_requirements=True) assert isinstance(result_req, str) assert json.loads(result_req) result_dis = apobj.json(show_disabled=True) assert isinstance(result_dis, str) assert json.loads(result_dis) def test_apprise_json_method_indent(): "Apprise.json(): indent > 0 produces pretty-printed output with newlines" apobj = Apprise() result = apobj.json(indent=4) assert isinstance(result, str) # Pretty-printed output contains newlines assert "\n" in result assert json.loads(result) def test_apprise_json_method_with_path(tmpdir): "Apprise.json(): writes JSON to file and returns True" apobj = Apprise() output = tmpdir.join("details.json") assert apobj.json(path=str(output)) is True assert output.check(file=True) content = output.read() parsed = json.loads(content) assert isinstance(parsed, dict) # Compact by default — no newlines assert "\n" not in content def test_apprise_json_method_with_path_and_indent(tmpdir): "Apprise.json(): writes pretty-printed JSON to file when indent is set" apobj = Apprise() output = tmpdir.join("details_pretty.json") assert apobj.json(path=str(output), indent=2) is True content = output.read() assert "\n" in content assert json.loads(content) def test_apprise_json_method_write_failure(tmpdir): "Apprise.json(): returns False when json.dump raises OSError" apobj = Apprise() output = tmpdir.join("fail.json") with mock.patch( "apprise.apprise.json.dump", side_effect=OSError("disk full") ): assert apobj.json(path=str(output)) is False def test_apprise_json_method_write_eoferror(tmpdir): "Apprise.json(): returns False when json.dump raises EOFError" apobj = Apprise() output = tmpdir.join("eof.json") with mock.patch("apprise.apprise.json.dump", side_effect=EOFError): assert apobj.json(path=str(output)) is False caronc-apprise-dfec414/tests/test_apprise_pickle.py000066400000000000000000000071121526720371000226650ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import pickle import sys from apprise import Apprise, AppriseAsset, AppriseLocale logging.disable(logging.CRITICAL) # Ensure we don't create .pyc files for these tests sys.dont_write_bytecode = True def test_apprise_pickle_asset(tmpdir): """pickle: AppriseAsset""" asset = AppriseAsset() serialized = pickle.dumps(asset) new_asset = pickle.loads(serialized) # iterate over some keys to verify they're still the same: keys = ( "app_id", "app_desc", "app_url", "html_notify_map", "ascii_notify_map", "default_html_color", "default_extension", "theme", "image_url_mask", "image_url_logo", "image_path_mask", "body_format", "async_mode", "interpret_escapes", "encoding", "secure_logging", "_recursion", ) for key in keys: assert getattr(asset, key) == getattr(new_asset, key) def test_apprise_pickle_locale(tmpdir): """pickle: AppriseLocale""" locale = AppriseLocale() serialized = pickle.dumps(locale) new_locale = pickle.loads(serialized) assert locale.lang == new_locale.lang # Ensure internal functions still call in new object new_locale.detect_language() def test_apprise_pickle_core(tmpdir): """pickle: Apprise""" asset = AppriseAsset(app_id="default") apobj = Apprise(asset=asset) # Create a custom asset so we can verify it gets correctly serialized xml_asset = AppriseAsset(app_id="xml") # Store our Entries apobj.add("json://localhost") apobj.add("xml://localhost", asset=xml_asset) apobj.add("form://localhost") apobj.add("mailto://user:pass@localhost", tag="email") serialized = pickle.dumps(apobj) # Unserialize our object new_apobj = pickle.loads(serialized) # Verify that it loaded our URLs back assert len(new_apobj) == 4 # Our assets were kept (note the XML altered entry) assert apobj[0].app_id == "default" assert apobj[1].app_id == "xml" assert apobj[2].app_id == "default" assert apobj[3].app_id == "default" # Our tag was kept assert "email" in apobj[3].tags caronc-apprise-dfec414/tests/test_apprise_template.py000066400000000000000000002311441526720371000232350ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Tests for template variables end to end. Where a variable may be written, and what each placement can reach, is covered separately in ``test_apprise_template_placement.py``. """ # Disable logging for a cleaner testing output from collections import UserDict from json import loads import logging import os from types import MappingProxyType from unittest import mock import pytest import requests from apprise import Apprise, AppriseAsset, AppriseConfig, NotifyTemplate from apprise.config import ConfigBase from apprise.exception import AppriseTemplateError from apprise.result import AppriseResultStatus def parse(content): """Return the services a YAML configuration produces.""" return ConfigBase.config_parse_yaml(content)[0] def load(content): """Build an Apprise object around a YAML configuration.""" apobj = Apprise() config = AppriseConfig() assert config.add_config(content, format="yaml") assert apobj.add(config) return apobj @pytest.fixture def logging_enabled(): """Enable logging while tests inspect captured records.""" previous = logging.root.manager.disable logging.disable(logging.NOTSET) yield logging.disable(previous) @pytest.fixture def sent(): """Accept every outbound request so delivery reports success.""" with mock.patch("requests.request") as request: response = mock.Mock() response.status_code = requests.codes.ok response.content = "" response.headers = {} request.return_value = response yield request def test_apprise_template_deferred_entry(): """The service cannot be built while a value is missing.""" services = parse( "template:\n - target\nurls:\n - json://localhost/?to=${TARGET}\n" ) assert len(services) == 1 assert isinstance(services[0], NotifyTemplate) assert services[0].template_names == ("target",) assert services[0].template_required == ("target",) assert services[0].url_id() is None def test_apprise_template_text_config(): """TEXT configurations keep ${...} as literal text.""" services, _ = ConfigBase.config_parse_text( "json://user:${NOT_A_VAR}@localhost" ) assert len(services) == 1 assert "$" in services[0].password @pytest.mark.parametrize( "entry", ( "json://localhost/:\n - tag: ${T}", "json://localhost/:\n - tags: ${T}", ), ) def test_apprise_template_rejects_tag_variable(entry, logging_enabled, caplog): """Singular and plural YAML tag keys stay fixed.""" with caplog.at_level(logging.ERROR): assert parse("template:\n - t\nurls:\n - {}\n".format(entry)) == [] assert "not permitted in tag/tags" in caplog.text @pytest.mark.parametrize( "url, field", ( ("apprise://localhost/key?method=json&tags=${T}", "body"), ("json://localhost/?tag=${T}", None), ("json://localhost/?tags=${T}", None), ("json://localhost/?-tag=${T}", "params"), ("json://localhost/?:tag=${T}", "body"), ("json://localhost/?+tag=${T}", "headers"), ), ) def test_apprise_template_url_tag_passthrough(url, field): """A templated tag on the URL is handed to the plugin, not routing.""" apobj = load("template:\n - t\nurls:\n - {}\n".format(url)) # The URL value never becomes an Apprise routing tag service = next(apobj.find(template={"t": "devops"})) assert not service.tags response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" response.headers = {} with ( mock.patch("requests.post", return_value=response) as post, mock.patch("requests.request", return_value=response) as request, ): assert apobj.notify(body="x", template={"t": "devops"}) # Only one of the two calls is used by each plugin call = (post.call_args or request.call_args).kwargs if field == "body": # The value reaches the request payload assert loads(call["data"])["tag"] in ("devops", ["devops"]) elif field == "params": # The value reaches the request query string assert call["params"]["tag"] == "devops" elif field == "headers": # The value reaches the request headers assert call["headers"]["tag"] == "devops" @pytest.mark.parametrize( "tags", ("tag: ${T}", "tags: ${T}", 'tags: [fixed, "${T}"]'), ) def test_apprise_template_rejects_global_tag_variable( tags, logging_enabled, caplog ): """Global routing tags must also be known before resolution.""" with caplog.at_level(logging.ERROR): services = parse( f"template:\n - t\n{tags}\nurls:\n - json://localhost/\n" ) assert services == [] assert "not permitted in tag/tags" in caplog.text def test_apprise_template_disabled_keeps_global_tag_marker(): """A tag marker is ordinary text when template support is disabled.""" services, _ = ConfigBase.config_parse_yaml( "template:\n - t\ntag: ${T}\nurls:\n - json://localhost/\n", asset=AppriseAsset(allow_templates=False), ) assert len(services) == 1 assert {str(tag) for tag in services[0].tags} == {"${t}"} def test_apprise_template_keeps_undeclared_global_tag_marker(): """Only declared variables are forbidden in routing tags.""" services = parse( "template:\n - other\ntag: ${T}\nurls:\n - json://localhost/\n" ) assert len(services) == 1 assert {str(tag) for tag in services[0].tags} == {"${t}"} def test_apprise_template_fills_marker_after_schema(): """Every marker past :// is filled in, wherever it was written.""" entry = parse( "template:\n - t\nurls:\n - json://localhost/?+${T}=1&+X=${T}\n" )[0] assert isinstance(entry, NotifyTemplate) assert entry.template_names == ("t",) # The parameter name and the value both take the supplied value service = entry.resolve({"t": "REAL"}) assert service.headers == {"REAL": "1", "X": "REAL"} def test_apprise_template_preserves_yaml_setting_name(): """A marker written as a YAML setting name is not a variable. Only values are replaced underneath a URL, so the name stays as the configuration spelled it and Apprise reads it the usual way. """ entry = parse( "template:\n - t\n" "urls:\n - json://localhost/:\n" " '+${T}': 1\n '+X': ${T}\n" )[0] service = entry.resolve({"t": "REAL"}) assert service.headers == {"${T}": "1", "X": "REAL"} def test_apprise_template_bad_entry_isolated(): """One refused entry leaves the rest of the file usable.""" services = parse( "template:\n - t\n" "urls:\n - mailto://u:p@gmail.com:${T}\n - json://other/\n" ) assert len(services) == 1 assert not isinstance(services[0], NotifyTemplate) def test_apprise_template_rejects_yaml_loop(logging_enabled, caplog): """A recursive YAML value rejects its entry without escaping an error.""" with caplog.at_level(logging.ERROR): services = parse( "template:\n - t\n" "urls:\n - json://user:${T}@localhost/:\n" " - headers: &loop [*loop]\n" " - json://localhost/\n" ) assert len(services) == 1 assert "Looping structure" in caplog.text @pytest.mark.parametrize( "url", [ "json://${T}/path", "json://host/${T}", "json://user:${T}@host/", "json://${T}:pass@host/", "json://host/?to=${T}", ], ) def test_apprise_template_url_fields(url): """Host, path, credentials and parameters all accept one.""" services = parse(f"template:\n - t\nurls:\n - {url}\n") assert len(services) == 1 assert isinstance(services[0], NotifyTemplate) def test_apprise_template_yaml_setting(): """A setting written under the URL may be templated too.""" services = parse( "template:\n - t\n" "urls:\n - json://localhost/:\n - to: ${T}\n tag: work\n" ) assert len(services) == 1 assert services[0].template_names == ("t",) assert {str(t) for t in services[0].tags} == {"work"} def test_apprise_template_yaml_url_key(): """The mapping form is supported as well as the plain form.""" services = parse( "template:\n - t\n" "urls:\n - json://localhost/${T}:\n - tag: work\n" ) assert len(services) == 1 assert isinstance(services[0], NotifyTemplate) def test_apprise_template_value_encoding(sent): """Credential punctuation reaches the parsed password field unchanged.""" apobj = load("template:\n - t\nurls:\n - json://user:${T}@localhost/\n") hostile = "a b&c=d?e/f@g" assert apobj.notify(body="x", template={"t": hostile}) service = next(apobj.find(template={"t": hostile})) # The value survives untouched assert service.password == hostile # The plugin escapes that field if it later renders a URL. rendered = service.url() assert "%26" in rendered assert "a b&c" not in rendered def test_apprise_template_undeclared_marker_literal(): """``${NAME}`` only means something when it was declared. Anything else is ordinary text, and the entry is not treated as templated at all. """ services = parse( "template:\n - t\nurls:\n - json://user:${NOT_DECLARED}@localhost/\n" ) assert len(services) == 1 # It loaded straight away; nothing is waiting on a value assert not isinstance(services[0], NotifyTemplate) assert services[0].password == "${NOT_DECLARED}" def test_apprise_template_undeclared_marker_not_pending(sent): """The URL is sent exactly as written.""" apobj = load( "template:\n - t\nurls:\n - json://user:${NOPE}@localhost/\n" ) assert apobj.template_vars() == {} assert apobj.notify(body="x").status == AppriseResultStatus.SUCCESS def test_apprise_template_success_status(sent): """A complete set of values delivers as normal.""" apobj = load( "template:\n - t\n" "urls:\n - json://localhost/?:x=${T}\n - json://other/\n" ) result = apobj.notify(body="x", template={"t": "value"}) assert result.status == AppriseResultStatus.SUCCESS assert len(list(result)) == 2 def test_apprise_template_partial_status(sent): """A skipped entry must not read as a clean success.""" apobj = load( "template:\n - t\n" "urls:\n - json://localhost/?:x=${T}\n - json://other/\n" ) result = apobj.notify(body="x") assert result.status == AppriseResultStatus.PARTIAL assert len(list(result)) == 1 def test_apprise_template_missing_all_status(sent): """Every match being held back is a failure, not a no-match.""" apobj = load("template:\n - t\nurls:\n - json://localhost/?:x=${T}\n") assert apobj.notify(body="x").status == AppriseResultStatus.FAILURE def test_apprise_template_environment_value(sent, monkeypatch): """A deployment can supply values without touching the config.""" monkeypatch.setenv("APPRISE_TEMPLATE_T", "from-env") apobj = load("template:\n - t\nurls:\n - json://user:${T}@localhost/\n") assert apobj.notify(body="x").status == AppriseResultStatus.SUCCESS assert next(apobj.find()).password == "from-env" def test_apprise_template_tag_matching(sent): """Tags are known up front, so filtering is unaffected.""" apobj = load( "template:\n - t\n" "urls:\n - json://user:${T}@localhost/:\n - tag: work\n" " - json://other/:\n - tag: home\n" ) matched = list(apobj.find(tag="work", template={"t": "x"})) assert len(matched) == 1 assert matched[0].password == "x" def test_apprise_template_pending_url_display(): """Introspection reveals which values are still wanted.""" apobj = load("template:\n - t\nurls:\n - json://host/${T}\n") assert "${T}" in apobj.urls()[0] assert "${T}" in apobj.urls(privacy=True)[0] def test_apprise_template_secret_masking(): """A variable name is safe to show; a password is not.""" apobj = load( "template:\n - t\nurls:\n - json://user:hunter2secret@host/${T}\n" ) shown = apobj.urls(privacy=True)[0] assert "${T}" in shown assert "hunter2secret" not in shown def test_apprise_template_service_reuse(sent): """Anything the service remembers survives between calls.""" entry = parse( "template:\n - t\nurls:\n - json://user:${T}@localhost/\n" )[0] first = entry.resolve({"t": "x"}) assert entry.resolve({"t": "x"}) is first assert entry.resolve({"t": "y"}) is not first # ...and an identifier only exists once it has been built assert entry.url_id() is None assert first.url_id() def test_apprise_template_cache_limit(): """A caller sending new values each time cannot grow it.""" from apprise.template import MAX_RESOLVE_CACHE entry = parse( "template:\n - t\nurls:\n - json://user:${T}@localhost/\n" )[0] for i in range(MAX_RESOLVE_CACHE + 10): entry.resolve({"t": f"value{i}"}) assert len(entry._cache) <= MAX_RESOLVE_CACHE def test_apprise_template_vars_summary(): """Callers can ask what a configuration expects.""" apobj = load( "template:\n - needed\n - other: a-default\n" "urls:\n - json://host/${NEEDED}/${OTHER}\n" ) report = apobj.template_vars() # No default means the call or environment needs to fill the name. assert report["needed"]["default"] is None # ...otherwise the default itself is reported assert report["other"]["default"] == "a-default" assert report["needed"]["services"] == 1 def test_apprise_template_missing_name_not_captured(sent, logging_enabled): """Keep missing names out of logs returned to API callers.""" captured = [] apobj = load( "template:\n - super_secret_name\n" "urls:\n - json://localhost/?:x=${SUPER_SECRET_NAME}\n" " - json://other/\n" ) result = apobj.notify( body="x", log_callback=lambda entry, service: captured.append(entry), log_level=logging.DEBUG, ) assert result.status == AppriseResultStatus.PARTIAL blob = " ".join(entry.message for entry in captured).lower() assert "super_secret_name" not in blob assert "${" not in blob # The operator is still told that something was left out assert "skipped" in blob def test_apprise_template_config_not_captured(sent, logging_enabled): """A capture must never carry the configuration body.""" captured = [] apobj = load( "template:\n - t\n" "urls:\n - json://user:hunter2secret@localhost/?:x=${T}\n" ) apobj.notify( body="x", log_callback=lambda entry, service: captured.append(entry), log_level=logging.DEBUG, ) blob = " ".join(entry.message for entry in captured) assert "hunter2secret" not in blob def test_apprise_template_log_capture_opt_out(logging_enabled): """The flag the template code relies on works on its own.""" from apprise.logger import _ServiceLogCapture, logger captured = [] with _ServiceLogCapture( service=None, log_callback=lambda entry, service: captured.append(entry), level=logging.DEBUG, ): logger.error("PUBLIC-MARKER") logger.error("PRIVATE-MARKER", extra={"apprise_capture": False}) blob = " ".join(entry.message for entry in captured) assert "PUBLIC-MARKER" in blob assert "PRIVATE-MARKER" not in blob def test_apprise_template_included_declarations(sent, tmpdir): """Each file declares what it uses; values reach them all.""" child = tmpdir.join("child.yml") child.write( "template:\n - shared\nurls:\n - json://user:${SHARED}@child-host/\n" ) parent = tmpdir.join("parent.yml") parent.write( "template:\n - shared\n" f"include:\n - {child!s}\n" "urls:\n - json://user:${SHARED}@parent-host/\n" ) apobj = Apprise() config = AppriseConfig(recursion=1) assert config.add(str(parent)) assert apobj.add(config) # One value supplied once reaches both files built = list(apobj.find(template={"shared": "one-value"})) assert len(built) == 2 assert {service.password for service in built} == {"one-value"} def test_apprise_template_shared_yaml_block(sent): """A YAML anchor used twice does not confuse the parser.""" apobj = load( "template:\n - t\n" "shared: &shared\n to: ${T}\n" "urls:\n - json://first/:\n <<: *shared\n" " - json://second/:\n <<: *shared\n" ) assert len(list(apobj.find(template={"t": "x"}))) == 2 def test_apprise_template_cli_value(tmpdir, sent): """--template supplies a value for a run.""" from click.testing import CliRunner from apprise import cli config = tmpdir.join("apprise.yml") config.write("template:\n - t\nurls:\n - json://user:${T}@localhost/\n") result = CliRunner().invoke( cli.main, ["-b", "x", f"--config={config!s}", "--template-var", "t=supplied"], ) assert result.exit_code == 0 def test_apprise_template_cli_invalid_value(tmpdir): """A --template entry has to read as NAME=VALUE.""" from click.testing import CliRunner from apprise import cli config = tmpdir.join("apprise.yml") config.write("urls:\n - json://localhost\n") result = CliRunner().invoke( cli.main, ["-b", "x", f"--config={config!s}", "-tv", "no-equals-sign"], ) assert result.exit_code == 2 assert "NAME=VALUE" in result.output def test_apprise_template_cli_dry_run(tmpdir): """The written form and the required names are both listed.""" from click.testing import CliRunner from apprise import cli config = tmpdir.join("apprise.yml") config.write( "template:\n - t\n - other: a-default\n" "urls:\n - json://host/${T}/${OTHER}\n" ) result = CliRunner().invoke( cli.main, ["-b", "x", f"--config={config!s}", "--dry-run"] ) assert "${T}" in result.output assert "t (required)" in result.output assert "other (has default)" in result.output def test_apprise_template_pending_repr(): """It stands in for a service, so it answers the same questions.""" entry = parse( "template:\n - t\n" "urls:\n - json://user:${T}@localhost/:\n" " - tag: work\n retry: 2\n" )[0] assert entry.schema == "json" assert entry.service_name == "JSON" assert entry.enabled is True assert entry.retry == 2 assert entry.optional is None assert "json" in repr(entry) assert "${T}" in str(entry) def test_apprise_template_unknown_service_metadata(): """A schema we do not carry is answered for safely.""" entry = parse( "template:\n - t\nurls:\n - json://user:${T}@localhost/\n" )[0] entry.results["schema"] = "not-a-real-schema" assert entry._plugin is None assert entry.service_name is None assert entry.enabled is True def test_apprise_template_setting_display(): """A setting is listed so it is clear a value is wanted.""" apobj = load( "template:\n - t\n" "urls:\n - mailto://user:pass@gmail.com:\n - to: ${T}\n" ) shown = apobj.urls()[0] assert "${T}" in shown # ...and it survives being escaped for the URL assert "%24%7B" not in shown def test_apprise_template_repeated_name(sent): """A variable may be written as many times as you like.""" apobj = load( "template:\n - t\n" "urls:\n - json://user:${T}@localhost/${T}/:\n - to: ${T}\n" ) service = next(apobj.find(template={"t": "same"})) assert service.password == "same" assert "/same/" in service.url() def test_apprise_template_url_size_limit(monkeypatch, logging_enabled, caplog): """A built URL is capped no matter what values arrive.""" import apprise.template as template_module entry = parse( "template:\n - t\nurls:\n - json://user:${T}@localhost/\n" )[0] monkeypatch.setattr(template_module, "MAX_RESOLVED_URL_LEN", 10) with caplog.at_level(logging.ERROR): assert entry.resolve({"t": "a-value-well-over-ten-characters"}) is None # The message says which lines of the file to go and look at assert "YAML entry #1, item #1" in caplog.text def test_apprise_template_build_failure(logging_enabled, caplog): """A value the service rejects skips the entry, not the run.""" entry = parse( "template:\n - t\nurls:\n - json://user:${T}@localhost/\n" )[0] from apprise.template import N_MGR with ( mock.patch.object( type(N_MGR), "__getitem__", side_effect=TypeError("the value was refused"), ), caplog.at_level(logging.ERROR), ): assert entry.resolve({"t": "x"}) is None assert "YAML entry #1, item #1" in caplog.text def test_apprise_template_substitution_failure(logging_enabled, caplog): """A failure while filling values in is handled quietly.""" entry = parse( "template:\n - t\nurls:\n - json://user:${T}@localhost/\n" )[0] with ( mock.patch.object( entry.placeholders, "substitute", side_effect=RuntimeError("nope") ), caplog.at_level(logging.ERROR), ): assert entry.resolve({"t": "x"}) is None assert "YAML entry #1, item #1" in caplog.text def test_apprise_template_exception_detail_is_never_captured( logging_enabled, caplog ): """A rejected value is not handed back through the result log.""" apobj = Apprise() config = AppriseConfig() config.add_config( "template:\n - key\n" "urls:\n - sendgrid://${KEY}:user@example.com/to@example.com\n", format="yaml", ) apobj.add(config) captured = [] with caplog.at_level(logging.DEBUG, logger="apprise"): apobj.notify( body="x", # Refused by the service, which names it in the error it raises template={"key": "SECRET!!!KEY"}, log_callback=lambda entry, plugin: captured.append(entry), log_level=logging.DEBUG, ) messages = [str(getattr(entry, "message", entry)) for entry in captured] # The entry was skipped, and said so without naming the value assert any("Could not load" in m for m in messages) # The service's own exception text never reaches the caller assert not [m for m in messages if "Loading Exception" in m] def test_apprise_template_location_counts_each_expansion(): """One URL block that becomes several services numbers them apart.""" services = parse( "template:\n - t\n" "urls:\n - mailto://user:pass@gmail.com:\n" " - to: one@example.com\n cc: ${T}\n" " - to: two@example.com\n cc: ${T}\n" ) # Both came from the first block under urls:, as separate items assert [s.location for s in services] == [ "YAML entry #1, item #1", "YAML entry #1, item #2", ] def test_apprise_template_adjacent_text_masking(): """Only the variable name is safe to show; the rest is not.""" entry = parse( "template:\n - t\n" "urls:\n - json://user:prefix${T}suffix@localhost/\n" )[0] shown = entry.url(privacy=True) assert "${T}" in shown assert "prefix" not in shown assert "suffix" not in shown # Without privacy the whole thing reads back as written assert "prefix${T}suffix" in entry.url() def test_apprise_template_query_display(): """A parameter waiting on a value is visible in the URL.""" entry = parse( "template:\n - t\nurls:\n - json://localhost/?:custom=${T}\n" )[0] assert "${T}" in entry.url() def test_apprise_template_parse_time_validation(logging_enabled, caplog): """Report services that must validate a field while parsing.""" with caplog.at_level(logging.ERROR): services = parse( "template:\n - t\n" "urls:\n - sendgrid://SG.abcdefghij:${T}/to@example.com\n" ) assert services == [] assert "template variable can not be used at this position" in ( caplog.text.lower() ) def test_apprise_template_valid_service_position(): """The limitation is the position, not the service.""" services = parse( "template:\n - t\n" "urls:\n - sendgrid://${T}:from@example.com/to@example.com\n" ) assert len(services) == 1 assert isinstance(services[0], NotifyTemplate) def test_apprise_template_service_coverage(): """A spot check across a few differently shaped URLs.""" for url in ( "discord://${T}/${T}", "slack://${T}/${T}/${T}", "mailtos://user:pass@example.com/a@b.com?smtp=${T}", "twilio://sid:token@${T}/+15551234567", "tgram://123456789:${T}/-1001234567890", ): services = parse(f"template:\n - t\nurls:\n - {url}\n") assert len(services) == 1, url assert isinstance(services[0], NotifyTemplate), url def test_apprise_template_many_url_variables(sent): """There is no cap on how many are used in one URL.""" names = [f"v{i}" for i in range(60)] declared = "\n".join(f" - {name}" for name in names) path = "/".join("${{{}}}".format(name.upper()) for name in names) apobj = load(f"template:\n{declared}\nurls:\n - json://host/{path}\n") values = {name: f"val{i}" for i, name in enumerate(names)} service = next(apobj.find(template=values)) for i in range(60): assert f"val{i}" in service.url() def test_apprise_template_shared_url_setting(sent): """One name can fill the URL and a setting below it.""" apobj = load( "template:\n - t\n" "urls:\n - json://user:${T}@localhost/:\n" " - to: ${T}\n tag: work\n" ) service = next(apobj.find(template={"t": "shared"})) assert service.password == "shared" def test_apprise_template_query_value_is_not_reparsed(sent): """A parsed query value is not reparsed as more query settings.""" apobj = load( "template:\n - v\nurls:\n - json://localhost/?:custom=${V}\n" ) control = load("template:\n - v\nurls:\n - json://localhost/?:c=${V}\n") evil = next( apobj.find(template={"v": "x&method=GET&to=hijacked@example.com"}) ) clean = next(control.find(template={"v": "x"})) # The marker occupied one parsed value, so its punctuation stays there. assert evil.method == clean.method == "POST" def test_apprise_template_path_punctuation(sent): """Path fields preserve punctuation selected by the config author.""" apobj = load("template:\n - v\nurls:\n - json://h/${V}/x\n") service = next(apobj.find(template={"v": "seg/ment?x=1&y=2#z"})) assert service.fullpath == "/seg/ment?x=1&y=2#z/x" def test_apprise_template_service_validates_value(sent): """Garbage is handed to the service to make sense of.""" apobj = load("template:\n - v\nurls:\n - json://${V}/\n") for value in ("ok.example.com", "evil.com/p", "not a host", "a@b"): assert next(apobj.find(template={"v": value}), None) is not None def test_apprise_template_embedded_host_is_preserved(sent): """A partial hostname keeps author-supplied URL punctuation.""" apobj = load("template:\n - v\nurls:\n - json://trusted-${V}/\n") service = next( apobj.find(template={"v": "name@evil.test:80/path?x=1#part"}) ) assert service.host == "trusted-name@evil.test:80/path?x=1#part" assert service.fullpath == "/" @pytest.mark.parametrize( "entry", [ # Written on its own "${V}://host/", # Written as a YAML key with settings under it "${V}://host/:\n - tag: work", # A marker standing in for the schema and its separator "${V}user:pass@host/", # Trailing text after the marker, as in a secure variant "${V}s://host/", # Leading text before the marker "json${V}://host/", # ...and the same the other way around "${V}json://host/", # Buried in the middle of an otherwise real schema "js${V}on://host/", # A secure variant built from both "mailto${V}s://host/", # No separator at all "${V}", # A separator that is not quite one "${V}//host/", # A later separator does not hide a variable in the schema position "${V}/path://later", # The mapping form must make the same decision "${V}/path://later:\n - tag: work", # A URL hidden in a query value cannot provide the schema boundary "${V}localhost/?url=http://bad.actor.com", # A backslash before :// does not make a variable schema acceptable r"abc${V}s\://localhost", ], ) def test_apprise_template_rejects_schema_variable( entry, logging_enabled, caplog ): """Nothing before :// can be a template variable. The schema decides which service reads the URL, and that is settled long before a value is known, so this is a configuration error rather than an unresolved notification service. """ with caplog.at_level(logging.ERROR): assert parse("template:\n - v\nurls:\n - {}\n".format(entry)) == [] assert "not permitted in URL schemas" in caplog.text def test_apprise_template_dollar_is_literal(): """A "$" before a marker is just a "$". There is no escape syntax. To keep a ${NAME} literal, simply do not declare that name. """ entry = parse("template:\n - t\nurls:\n - json://l/?+X=$${T}\n")[0] service = entry.resolve({"t": "abc"}) assert service.headers == {"X": "$abc"} def test_apprise_template_default_marker_is_literal(): """A default is used exactly as written, markers and all. One declaration never builds on another. ``key`` below is the text ``${value}``, not ``34``. Assemble values in the URL instead. """ apobj = load( "template:\n value: 34\n key: ${value}\n" "urls:\n - json://l/?+K=${KEY}&+V=${VALUE}\n" ) # K keeps the marker; V shows that 'value' really does resolve to 34 assert next(apobj.find()).headers == {"K": "${value}", "V": "34"} def test_apprise_template_supplied_marker_is_literal(): """A value handed in for one call is not looked at again either.""" apobj = load( "template:\n value: 34\n key:\n" "urls:\n - json://l/?+K=${KEY}&+V=${VALUE}\n" ) service = next(apobj.find(template={"key": "${value}"})) assert service.headers == {"K": "${value}", "V": "34"} def test_apprise_template_environment_marker_is_literal(): """An environment value is taken as written in the same way.""" with mock.patch.dict(os.environ, {"APPRISE_TEMPLATE_KEY": "${value}"}): apobj = load( "template:\n value: 34\n key:\n" "urls:\n - json://l/?+K=${KEY}&+V=${VALUE}\n" ) assert next(apobj.find()).headers == {"K": "${value}", "V": "34"} def test_apprise_template_bad_schema_isolated(sent): """One bad entry leaves the rest of the file usable.""" services = parse( "template:\n - v\nurls:\n - ${V}://host/\n - json://ok/\n" ) assert len(services) == 1 assert not isinstance(services[0], NotifyTemplate) def test_apprise_template_credential_separators(sent): """Ordinary password punctuation is kept unchanged.""" apobj = load("template:\n - v\nurls:\n - json://u:${V}@host/\n") service = next(apobj.find(template={"v": "p/a?s&s"})) assert service.password == "p/a?s&s" @pytest.mark.parametrize("credentials", ("user", "user:pass")) def test_apprise_template_combined_credentials(credentials, sent): """A user field may carry either supported credential form.""" apobj = load("template:\n - creds\nurls:\n - json://${CREDS}@host/\n") service = next(apobj.find(template={"creds": credentials})) user, separator, password = credentials.partition(":") assert service.user == user assert service.password == (password if separator else None) assert service.host == "host" def test_apprise_template_email_authority(sent): """A service may accept an email as its complete authority value.""" apobj = load( "template:\n - email\n" "urls:\n" " - ses://${EMAIL}/T1JJ3T3L2/A1BRTD4JD/" "TIiajkdnlazkcevi7FQ/us-west-2/user2@example.com\n" ) service = next(apobj.find(template={"email": "sender@example.com"})) assert service.from_addr == "sender@example.com" # The characters that separate one part of a URL from the next, and a few # everyday ones that have to survive untouched. SEPARATORS = ("/", "&", "@") VALUES = ( "plain-value", "a/b", "a&b", "a@b", "user:pass", "one,two", "a/b&c@d", "b64/secret+value==", ) @pytest.mark.parametrize("value", VALUES) def test_apprise_template_url_value_is_preserved(value, sent): """A value written into a URL fills the one spot it was written in.""" apobj = load("template:\n - v\nurls:\n - json://user:${V}@host/\n") service = next(apobj.find(template={"v": value})) # The service still talks to the host the author wrote assert service.host == "host" assert service.user == "user" # ...and the value arrives whole, however it was written assert service.password == value @pytest.mark.parametrize("value", VALUES) def test_apprise_template_setting_value_is_left_alone(value, sent): """A setting under the URL already says what its value is for.""" apobj = load( "template:\n - v\n" "urls:\n - json://user:pass@host/:\n - to: ${V}\n" ) service = next(apobj.find(template={"v": value})) assert service.host == "host" assert service.password == "pass" @pytest.mark.parametrize("value", VALUES) def test_apprise_template_query_value_adds_no_parameter(value, sent): """An "&" in a value cannot become another parameter.""" apobj = load("template:\n - v\nurls:\n - json://host/?:custom=${V}\n") control = load("template:\n - v\nurls:\n - json://host/?:custom=${V}\n") service = next(apobj.find(template={"v": value})) clean = next(control.find(template={"v": "plain"})) # Nothing the value carried changed how the service was set up assert service.method == clean.method assert service.host == clean.host == "host" @pytest.mark.parametrize( "address", ["[::1]", "::1", "2001:db8::1", "127.0.0.1", "host.example"] ) def test_apprise_template_real_addresses_survive(address, sent): """A genuine address is written through untouched.""" apobj = load("template:\n - v\nurls:\n - json://${V}/\n") assert next(apobj.find(template={"v": address})).host == address @pytest.mark.parametrize("hostile", VALUES) def test_apprise_template_email_setting_keeps_server(hostile, sent): """A named email setting cannot change the configured mail server.""" apobj = load( "template:\n - v\n" "urls:\n - mailtos://user:pass@example.com/:\n - bcc: ${V}\n" ) service = next(apobj.find(template={"v": hostile})) # The server being talked to is still the one the author wrote assert service.host == "example.com" assert service.user == "user" assert service.password == "pass" def two_configurations(): """Two separately loaded configurations, each with its own variable.""" apobj = Apprise() for name in ("alpha", "beta"): config = AppriseConfig() assert config.add_config( f"template:\n - {name}\n" f"urls:\n - json://user:${{{name.upper()}}}@{name}-host/\n", format="yaml", ) assert apobj.add(config) return apobj def test_apprise_template_values_span_configurations(sent): """One set of values covers everything that is loaded. Each entry passes over the names belonging to the other configuration rather than refusing them. """ built = list( two_configurations().find(template={"alpha": "1", "beta": "2"}) ) assert sorted(service.host for service in built) == [ "alpha-host", "beta-host", ] assert sorted(service.password for service in built) == ["1", "2"] def test_apprise_template_case_is_ignored_end_to_end(sent): """Declaration, marker, and supplied key casing may all differ.""" apobj = Apprise() config = AppriseConfig() assert config.add_config( "template:\n - MiXeD\nurls:\n - json://user:${mIxEd}@localhost/\n", format="yaml", ) assert apobj.add(config) service = next(apobj.find(template={"MIXED": "value"})) assert service.password == "value" def test_apprise_template_partial_values_span_configurations(sent): """Supplying one still delivers to the one it belongs to.""" built = list(two_configurations().find(template={"alpha": "1"})) assert [service.host for service in built] == ["alpha-host"] def test_apprise_template_unknown_name_is_ignored(sent): """A name no loaded configuration uses does not block delivery.""" built = list( two_configurations().find(template={"alpha": "1", "nowhere": "x"}) ) assert [service.host for service in built] == ["alpha-host"] def test_apprise_template_unknown_name_does_not_change_status(sent): """An unused name does not turn a successful send into a partial one.""" apobj = Apprise() assert apobj.add("json://untemplated/") result = apobj.notify(body="x", template={"nowhere": "x"}) assert result.status == AppriseResultStatus.SUCCESS def test_apprise_template_hides_unknown_name_from_capture( sent, logging_enabled ): """The names must not reach whoever asked for the notification.""" captured = [] two_configurations().notify( body="x", template={"a_secret_name": "x"}, log_callback=lambda entry, service: captured.append(entry), log_level=logging.DEBUG, ) blob = " ".join(entry.message for entry in captured).lower() assert "a_secret_name" not in blob def test_apprise_template_logs_unknown_name_locally( sent, logging_enabled, caplog ): """The local debug log names unused inputs without logging values.""" with caplog.at_level(logging.DEBUG): list( two_configurations().find( template={"NoWhErE": "do-not-log-this-value"} ) ) assert "nowhere" in caplog.text assert "supplied but not used" in caplog.text assert "do-not-log-this-value" not in caplog.text def test_apprise_template_unused_name_log_is_bounded(logging_enabled, caplog): """A large unused mapping cannot create an equally large log entry.""" values = {"name{}".format(i): "secret{}".format(i) for i in range(25)} with caplog.at_level(logging.DEBUG): list(Apprise().find(template=values)) assert "(+5 more)" in caplog.text assert "name19" in caplog.text assert "name20" not in caplog.text assert "secret" not in caplog.text def test_apprise_template_can_be_switched_off(sent): """The configuration is then read as it was before templates. Nothing is held back, nothing is read from the environment, and the markers stay exactly as they were written. """ content = "template:\n - v\nurls:\n - json://user:${V}@localhost/\n" asset = AppriseAsset(allow_templates=False) apobj = Apprise(asset=asset) config = AppriseConfig(asset=asset) assert config.add_config(content, format="yaml") assert apobj.add(config) service = next(apobj.find()) assert not isinstance(service, NotifyTemplate) assert service.password == "${V}" assert apobj.template_vars() == {} def test_apprise_template_disabled_ignores_environment(sent, monkeypatch): """A value in the environment must not quietly take effect.""" monkeypatch.setenv("APPRISE_TEMPLATE_V", "from-env") asset = AppriseAsset(allow_templates=False) apobj = Apprise(asset=asset) config = AppriseConfig(asset=asset) assert config.add_config( "template:\n - v\nurls:\n - json://user:${V}@localhost/\n", format="yaml", ) assert apobj.add(config) assert next(apobj.find()).password == "${V}" def test_apprise_template_duplicate_setting_uses_last_value(): """PyYAML's normal last-value behavior applies below root sections.""" services = parse( "urls:\n - json://localhost/:\n" " - method: GET\n method: POST\n" ) assert len(services) == 1 assert services[0].method == "POST" def test_config_yaml_duplicate_section_uses_last_value(): """Repeated root sections retain PyYAML's last-value behavior.""" services = parse("urls:\n - json://first/\nurls:\n - json://second/\n") assert len(services) == 1 assert services[0].host == "second" def test_apprise_template_late_declaration_is_recognized( logging_enabled, caplog ): """A declaration may follow the service that uses it.""" with caplog.at_level(logging.DEBUG): services = parse( "urls:\n - json://user:${LATE}@localhost/\ntemplate:\n - late\n" ) assert len(services) == 1 assert isinstance(services[0], NotifyTemplate) assert "not defined" not in caplog.text def test_apprise_template_keeps_undeclared_marker(logging_enabled, caplog): """An undeclared marker remains literal and receives a debug hint.""" with caplog.at_level(logging.DEBUG): services = parse("urls:\n - json://user:${MISSING}@localhost/\n") assert services[0].password == "${MISSING}" assert "'missing' is not defined" in caplog.text assert "kept as written" in caplog.text assert not [r for r in caplog.records if r.levelno >= logging.WARNING] def test_apprise_template_keeps_undeclared_marker_beside_declared_one( logging_enabled, caplog ): """Declaring one marker does not activate other marker-like text.""" with caplog.at_level(logging.DEBUG): services = parse( "template:\n - used\n" "urls:\n - json://user:${MISSING}@localhost/?to=${USED}\n" ) assert services[0].results["password"] == "${MISSING}" assert "'missing' is not defined" in caplog.text def test_apprise_template_reports_unused_declaration(logging_enabled, caplog): """Debug inspection warns about declarations unused by service entries.""" with caplog.at_level(logging.DEBUG): services = parse( "template:\n - unused\nurls:\n - json://localhost/\n" ) assert len(services) == 1 assert "'unused' is defined but not referenced" in caplog.text assert any( record.levelno == logging.WARNING and "'unused' is defined but not referenced" in record.message for record in caplog.records ) def test_apprise_template_yaml_value_counts_as_reference( logging_enabled, caplog ): """A marker in a YAML option value counts as a real use.""" with caplog.at_level(logging.DEBUG): services = parse( "template:\n - used\n" "urls:\n - json://localhost/:\n" " headers:\n X-Test: ${USED}\n" ) assert len(services) == 1 assert isinstance(services[0], NotifyTemplate) assert "not referenced" not in caplog.text def test_apprise_template_yaml_name_is_not_a_reference( logging_enabled, caplog ): """YAML setting names stay literal and do not count as variable use.""" with caplog.at_level(logging.DEBUG): services = parse( "template:\n - unused\n" "urls:\n - json://localhost/:\n" " '+${UNUSED}': literal\n" ) assert len(services) == 1 assert "'unused' is defined but not referenced" in caplog.text def test_apprise_template_skips_reference_scan_without_debug( logging_enabled, caplog ): """Reference discovery adds no work when debug logging is disabled.""" with ( mock.patch( "apprise.config.base.template_references", side_effect=AssertionError("unexpected template scan"), ), caplog.at_level(logging.INFO), ): services = parse( "template:\n - unused\nurls:\n - json://localhost/\n" ) assert len(services) == 1 def test_apprise_template_supports_shared_yaml_anchor(): """A YAML anchor may reuse an entry containing a template marker.""" services = parse( "template:\n - t\n" "urls:\n - &shared json://localhost/?to=${T}\n - *shared\n" ) # Both entries load and both still wait on the same value assert len(services) == 2 assert all(isinstance(s, NotifyTemplate) for s in services) def test_apprise_template_disabled_marker_is_left_alone( logging_enabled, caplog ): """Disabled templates neither inspect nor change marker-like text.""" asset = AppriseAsset(allow_templates=False) with caplog.at_level(logging.DEBUG): services, _ = ConfigBase.config_parse_yaml( "template:\n - unused\nurls:\n - json://user:${V}@localhost/\n", asset=asset, ) assert services[0].password == "${V}" assert "not defined" not in caplog.text assert "not referenced" not in caplog.text @pytest.mark.parametrize( ("label", "content"), [ ( "marker used as a query name", "template:\n - t\nurls:\n - json://localhost/?+${T}=1\n", ), ( "marker used as a value", "template:\n - t\nurls:\n - json://localhost/?+X=${T}\n", ), ( "marker used as a YAML setting name", "template:\n - t\n" "urls:\n - json://localhost/:\n '+${T}': 1\n", ), ( "marker in the credentials", "template:\n - t\nurls:\n - json://user:${T}@localhost/\n", ), ( "no template section at all", "urls:\n - json://localhost/?+X=${T}\n", ), ], ) def test_apprise_template_disabled_matches_no_section(label, content): """Disabled templates leave declared markers as literal text.""" asset = AppriseAsset(allow_templates=False) services, _ = ConfigBase.config_parse_yaml(content, asset=asset) assert len(services) == 1 # A real service, never an entry waiting on a value assert not isinstance(services[0], NotifyTemplate) def test_apprise_template_disabled_ignores_the_environment(monkeypatch): """A disabled file never reads APPRISE_TEMPLATE_ values.""" monkeypatch.setenv("APPRISE_TEMPLATE_T", "from-the-environment") content = "template:\n - t\nurls:\n - json://user:${T}@localhost/\n" services, _ = ConfigBase.config_parse_yaml( content, asset=AppriseAsset(allow_templates=False) ) assert services[0].password == "${T}" # With the feature on, the same file uses that value assert next(load(content).find()).password == "from-the-environment" def test_apprise_template_disabled_ignores_repeated_sections(): """A disabled template table follows PyYAML's last-value behavior.""" asset = AppriseAsset(allow_templates=False) services, _ = ConfigBase.config_parse_yaml( "template:\n first: one\n first: two\n" "urls:\n - json://localhost/\n" "template:\n - second\n", asset=asset, ) assert len(services) == 1 def test_apprise_template_url_handles_a_non_text_setting(): """A setting that is not text has no marker to show.""" entry = parse( "template:\n - t\n" "urls:\n - json://user:${T}@localhost/:\n" " - verify: no\n retry: 2\n" )[0] # It renders without complaint, and the marker still shows assert "${T}" in entry.url() @pytest.mark.parametrize("declaration", ("", "template:\n - t\n")) def test_config_skips_non_text_sibling_before_url(declaration): """A non-text sibling cannot be mistaken for the URL key.""" services = parse( declaration + "urls:\n - 1: ignored\n json://localhost/:\n" ) assert len(services) == 1 def test_apprise_template_url_shows_every_field_using_a_name(): """Show a shared marker in every field that uses it. A marker identifies where the caller's value goes. Leaving it out of one field would hide a setting the notification still uses. """ entry = parse( "template:\n - t\n" "urls:\n - mailto://user:${T}@gmail.com:\n" " - to: ${T}\n cc: ${T}\n" )[0] url = entry.url() assert "user:${T}@" in url assert "to=${T}" in url assert "cc=${T}" in url def test_apprise_template_url_without_a_path(): """Not every URL has a path to render.""" entry = parse("template:\n - t\nurls:\n - json://${T}\n")[0] assert "${T}" in entry.url() def test_apprise_template_invalid_declaration_refuses_file( logging_enabled, caplog ): """An unreadable template section stops the whole file.""" with caplog.at_level(logging.ERROR): services = parse( "template:\n - name\n - NAME\nurls:\n - json://localhost/\n" ) assert services == [] assert "template section" in caplog.text.lower() def test_apprise_template_unusable_placeholder_prefix(logging_enabled, caplog): """Giving up on a placeholder prefix stops the whole file.""" taken = "0" * 16 with ( mock.patch( "apprise.utils.template.secrets.token_hex", return_value=taken ), caplog.at_level(logging.ERROR), ): # The prefix it keeps choosing already appears in the file services = parse( f"template:\n - v\nurls:\n - json://a{taken}/${{V}}\n" ) assert services == [] def test_apprise_template_mapping_form_setting_name(logging_enabled, caplog): """A marker naming a YAML setting is left to the usual token check. Apprise already refuses a setting name it does not recognise, so the marker takes the same path any other unusable name would. """ with caplog.at_level(logging.WARNING): services = parse( "template:\n - t\n" "urls:\n - json://localhost/:\n - ${T}: value\n" ) assert len(services) == 1 assert "Ignoring invalid token (${T})" in caplog.text # A file that never declared the name is treated the same way with caplog.at_level(logging.WARNING): plain = parse("urls:\n - json://localhost/:\n - ${T}: value\n") assert len(plain) == 1 assert "Ignoring invalid token (${T})" in caplog.text def test_apprise_template_mapping_form_bad_position(logging_enabled, caplog): """The positional refusal also covers the mapping form.""" with caplog.at_level(logging.ERROR): services = parse( "template:\n - t\n" "urls:\n - sendgrid://SG.abcdefghij:${T}/to@example.com:\n" " - tag: work\n" ) assert services == [] assert "can not be used at this position" in caplog.text def test_apprise_template_vars_reports_a_disagreement(sent): """Two configurations may not agree on a default.""" apobj = Apprise() for default in ("first-default", "second-default"): config = AppriseConfig() assert config.add_config( f"template:\n - shared: {default}\n" "urls:\n - json://user:${SHARED}@host/\n", format="yaml", ) assert apobj.add(config) # Neither default is assumed; use a call or environment value instead. assert apobj.template_vars()["shared"]["default"] is None def test_apprise_template_status_other_outcomes(): """Only a clean run and a no-match are reworded.""" from apprise.dispatch import template_status for status in ( AppriseResultStatus.FAILURE, AppriseResultStatus.PARTIAL, AppriseResultStatus.TIMEOUT, ): assert template_status(status, ["skipped"]) == status def test_apprise_asset_allow_templates_must_be_a_flag(): """It is a yes or no answer, not a piece of text.""" from apprise.exception import AppriseImproperlyConfigured with pytest.raises(AppriseImproperlyConfigured): AppriseAsset(allow_templates="yes") def schema_for(*names): """A declared schema to check a URL's service choice against.""" from apprise.utils.template import TemplateSchema return TemplateSchema.parse(list(names)) @pytest.mark.parametrize( "section", ["template:", "template: []", "template: {}"] ) def test_apprise_template_empty_section_changes_nothing(section, sent): """A template section that declares nothing is simply empty. Nothing was declared, so every marker is ordinary text and each URL loads straight away. """ services = parse(f"{section}\nurls:\n - json://user:${{T}}@localhost/\n") assert len(services) == 1 assert not isinstance(services[0], NotifyTemplate) assert services[0].password == "${T}" def test_apprise_template_vars_counts_agreeing_configurations(sent): """Two configurations may declare the same variable the same way.""" apobj = Apprise() for host in ("first", "second"): config = AppriseConfig() assert config.add_config( "template:\n - shared: agreed\n" f"urls:\n - json://user:${{SHARED}}@{host}/\n", format="yaml", ) assert apobj.add(config) report = apobj.template_vars() # They agree, so the default stands and both entries are counted assert report["shared"]["default"] == "agreed" assert report["shared"]["services"] == 2 @pytest.mark.parametrize( "url", [ "mailtos://user:pass@example.com/${EMAIL}", "sendgrid://SG.abcdefghij:from@example.com/${EMAIL}", ], ) def test_apprise_template_email_address_value_works(url, sent): """A whole email address is a perfectly ordinary value. Some services are written as ``schema://{email}/...``, where the value really is ``user@host``. Escaping must not stand in the way of that. """ apobj = load(f"template:\n - email\nurls:\n - {url}\n") service = next(apobj.find(template={"email": "you@example.ca"})) assert "you@example.ca" in [ target[1] if isinstance(target, tuple) else target for target in service.targets ] def test_apprise_template_email_address_as_a_setting(sent): """The same address written against a setting instead.""" apobj = load( "template:\n - email\n" "urls:\n - mailtos://user:pass@example.com/:\n - to: ${EMAIL}\n" ) service = next(apobj.find(template={"email": "you@example.ca"})) assert [target[1] for target in service.targets] == ["you@example.ca"] def test_config_yaml_rejects_an_unusable_key(): """Reject a YAML key that cannot be looked up.""" # A list written as a key; there is nothing to index a mapping by assert ConfigBase.config_parse_yaml("? [a, b]\n: value\n") == ([], []) def test_config_query_tag_check_without_templates(): """With nothing templated there is no query to examine.""" from apprise.utils.yaml import templated_tag assert templated_tag({"qsd": {}}, None) is False assert templated_tag("not a mapping", None) is False def test_config_query_tag_check_skips_unusable_buckets(sent): """A query bucket that is not a mapping is passed over.""" from apprise.utils.template import TemplatePlaceholderMap, TemplateSchema from apprise.utils.yaml import templated_tag placeholders = TemplatePlaceholderMap(TemplateSchema.parse(["t"]), "") results = {"qsd": None, "qsd+": {}, "qsd-": {}, "qsd:": {}} assert templated_tag(results, placeholders) is False def test_apprise_template_rejects_invalid_supplied_name(sent): """A name that is not text is refused rather than guessed at. Nothing can be looked up by it, so the request is turned down instead of being filled in from a malformed mapping. """ apobj = load("template:\n - t\nurls:\n - json://user:${T}@host/\n") with pytest.raises(AppriseTemplateError): list(apobj.find(template={"t": "value", 7: "ignored"})) def run_cli(tmpdir, content, *args): """Run a dry run against a configuration written to disk.""" from click.testing import CliRunner from apprise import cli config = tmpdir.join("apprise.yml") config.write(content) return CliRunner().invoke( cli.main, ["-b", "x", f"--config={config!s}", "--dry-run", *args] ) def test_apprise_cli_dry_run_supplied_value(tmpdir): """A value given on the command line is filled in for the preview.""" result = run_cli( tmpdir, "template:\n - t\nurls:\n - json://user:${T}@localhost/\n", "--template-var", "t=supplied", ) # The entry resolved, so it has a real identifier rather than a notice assert "missing value(s)" not in result.output assert "- n/a -" not in result.output assert result.exit_code == AppriseResultStatus.SUCCESS def test_apprise_cli_dry_run_reports_no_identifier(tmpdir): """A service that keeps no stored data has no identifier to show.""" from apprise.plugins.custom_json import NotifyJSON with mock.patch.object(NotifyJSON, "url_id", return_value=None): result = run_cli(tmpdir, "urls:\n - json://localhost/\n") assert "- n/a -" in result.output assert result.exit_code == AppriseResultStatus.SUCCESS def test_apprise_cli_dry_run_partial(tmpdir): """Some entries are ready and some are still waiting on a value.""" result = run_cli( tmpdir, "template:\n - t\n" "urls:\n - json://user:${T}@localhost/\n - json://ready/\n", ) assert "could not be resolved" in result.output assert "1 of 2" in result.output # The same outcome a real run would report assert result.exit_code == AppriseResultStatus.PARTIAL def test_apprise_cli_dry_run_failure(tmpdir): """Every entry is waiting on a value, so nothing would be sent.""" result = run_cli( tmpdir, "template:\n - t\nurls:\n - json://user:${T}@localhost/\n" ) assert result.exit_code == AppriseResultStatus.FAILURE def test_config_yaml_loader_failure_is_reported(): """A YAML loader failure returns an empty configuration.""" with mock.patch( "yaml.load", side_effect=AttributeError("no loader"), ): assert ConfigBase.config_parse_yaml("urls:\n - json://a/\n") == ( [], [], ) def test_apprise_cli_details_lists_required_packages(): """A service that is switched off explains what it needs.""" from click.testing import CliRunner from apprise import cli entry = { "service_name": "Example", "service_url": "https://example.ca", "setup_url": None, "enabled": False, "details": {"templates": ("{schema}://example",)}, "category": "native", "attachment_support": False, "protocols": ("example",), "secure_protocols": None, "requirements": { "details": "Needs an extra package", "packages_required": ["examplelib"], "packages_recommended": [], }, } with mock.patch( "apprise.Apprise.details", return_value={"version": "1.0", "asset": {}, "schemas": [entry]}, ): result = CliRunner().invoke(cli.main, ["--details"]) assert "Python Packages Required" in result.output assert "examplelib" in result.output @pytest.mark.parametrize("supplied", ["a=b", ["a"], 7, ("a", "b")]) def test_apprise_template_rejects_a_non_mapping(supplied): """Values must arrive as name/value pairs.""" apobj = load("template:\n - t\nurls:\n - json://user:${T}@host/\n") with pytest.raises(AppriseTemplateError): list(apobj.find(template=supplied)) def test_apprise_notify_failure_for_non_mapping(): """A bad value table fails the call instead of raising.""" apobj = load("template:\n - t\nurls:\n - json://user:${T}@host/\n") result = apobj.notify(body="x", template="a=b") assert not result assert result.status == AppriseResultStatus.FAILURE def test_apprise_template_rejects_a_repeated_name(): """Two spellings of one name leave no way to tell which was meant.""" apobj = load("template:\n - t\nurls:\n - json://user:${T}@host/\n") with pytest.raises(AppriseTemplateError): list(apobj.find(template={"t": "one", "T": "two"})) def test_apprise_template_name_not_captured(logging_enabled, caplog): """An unusable name is turned down before anything is written out.""" apobj = load("template:\n - t\nurls:\n - json://user:${T}@host/\n") with caplog.at_level(logging.DEBUG), pytest.raises(AppriseTemplateError): list(apobj.find(template={"t": "value", "unused\nwrite": "x"})) assert "\nwrite" not in caplog.text def test_apprise_template_empty_environment_value(monkeypatch, sent): """An empty environment value never replaces a real one. Whitespace is not a value, and a default written in the configuration outranks the environment in any case. """ monkeypatch.setenv("APPRISE_TEMPLATE_T", " ") apobj = load( "template:\n t: fallback\nurls:\n - json://user:${T}@localhost/\n" ) service = next(apobj.find()) assert service.password == "fallback" @pytest.mark.parametrize("entry", ["bad name=x", "=x", "a\nb=x", "a-b=x"]) def test_apprise_template_cli_unusable_name(tmpdir, entry): """A --template-var name has to look like a variable name.""" from click.testing import CliRunner from apprise import cli config = tmpdir.join("apprise.yml") config.write("urls:\n - json://localhost\n") result = CliRunner().invoke( cli.main, ["-b", "x", f"--config={config!s}", "-tv", entry] ) assert result.exit_code == 2 assert "NAME=VALUE" in result.output def test_apprise_template_cli_repeated_name(tmpdir): """The same name twice leaves no way to tell which value was meant.""" from click.testing import CliRunner from apprise import cli config = tmpdir.join("apprise.yml") config.write("template:\n - t\nurls:\n - json://user:${T}@localhost/\n") result = CliRunner().invoke( cli.main, ["-b", "x", f"--config={config!s}", "-tv", "t=one", "-tv", "T=two"], ) assert result.exit_code == 2 assert "more than once" in result.output def test_apprise_template_reads_a_configuration_once(): """One find() visits each configuration source a single time.""" apobj = Apprise() config = AppriseConfig(cache=False) assert config.add_config( "template:\n - t\nurls:\n - json://localhost/?to=${T}\n", format="yaml", ) assert apobj.add(config) source = config[0] reads = [] original = source.read def counted(*args, **kwargs): reads.append(1) return original(*args, **kwargs) source.read = counted assert len(list(apobj.find(template={"t": "x"}))) == 1 assert len(reads) == 1 @pytest.mark.parametrize( "supplied", [ MappingProxyType({"t": "value"}), UserDict({"t": "value"}), ], ) def test_apprise_template_accepts_any_mapping(supplied, sent): """Any mapping is usable, not only a plain dictionary.""" apobj = load("template:\n - t\nurls:\n - json://localhost/?to=${T}\n") service = next(apobj.find(template=supplied)) assert service is not None def test_apprise_template_cli_error_hides_value(tmpdir): """A refused entry must not echo whatever followed the equals sign.""" from click.testing import CliRunner from apprise import cli config = tmpdir.join("apprise.yml") config.write("urls:\n - json://localhost\n") result = CliRunner().invoke( cli.main, ["-b", "x", f"--config={config!s}", "-tv", "bad name=super-secret"], ) assert result.exit_code == 2 assert "super-secret" not in result.output assert "bad name" in result.output def test_apprise_template_url_shows_a_list_setting(): """Keep markers in list settings, such as email recipients. Email recipients are stored as a list. A listing that only checks text fields would silently lose their markers. """ entry = parse( "template:\n - t\n" "urls:\n - mailto://user:pass@gmail.com:\n to: ${T}\n" )[0] assert "to=${T}" in entry.url() def test_apprise_template_url_lists_all_members(): """A list mixing a marker with fixed members keeps them all.""" entry = parse( "template:\n - t\n" "urls:\n - mailto://user:pass@gmail.com:\n" " to:\n - ${T}\n - fixed@example.com\n" )[0] # Members come back the way a URL supplies a list: comma separated url = entry.url() assert "${T}" in url assert "fixed%40example.com" in url def test_apprise_template_url_uses_the_argument_a_url_accepts(): """Show YAML ``smtp:`` as the URL argument ``smtp=``. The YAML value is stored as ``smtp_host`` inside the service, but a URL must use ``smtp=`` to set that same field when read back. """ entry = parse( "template:\n - t\n" "urls:\n - mailto://user:pass@gmail.com:\n smtp: ${T}\n" )[0] url = entry.url() assert "smtp=${T}" in url assert "smtp_host" not in url service = Apprise.instantiate(url.replace("${T}", "mail.example.com")) assert service.smtp_host == "mail.example.com" def test_apprise_template_url_shows_a_grouped_setting(): """A header waiting on a value is shown with its URL prefix.""" entry = parse( "template:\n - t\n" "urls:\n - json://localhost:\n '+X-Token': ${T}\n" )[0] url = entry.url() assert "${T}" in url service = Apprise.instantiate(url.replace("${T}", "abc123")) assert service.headers == {"X-Token": "abc123"} def test_apprise_template_url_skips_non_string_group_key(): """Skip grouped settings whose YAML keys are not strings. YAML parses a bare ``1:`` as a number, which cannot name a URL header. """ entry = parse( "template:\n - t\n" "urls:\n - json://localhost:\n" " headers:\n 1: fixed\n X-Token: ${T}\n" )[0] url = entry.url() assert "X-Token=${T}" in url assert "fixed" not in url def test_apprise_template_url_deduplicates_header(): """A header written in the URL is listed once, not twice.""" entry = parse( "template:\n - t\nurls:\n - json://localhost/?+X-Token=${T}\n" )[0] assert entry.url().lower().count("x-token") == 1 def test_apprise_template_url_shows_a_port_waiting_on_a_value(): """A port setting shows its marker, not the internal stand-in.""" entry = parse( "template:\n - t\n" "urls:\n - mailto://user:pass@gmail.com:\n port: ${T}\n" )[0] for url in (entry.url(), entry.url(privacy=True)): assert ":${T}" in url assert entry.placeholders.pattern.search(url) is None def test_apprise_template_url_never_escapes_a_marker(): """Markers stay literal so they can be found and replaced. A marker is not a value yet, so escaping it would leave a caller with nothing to search for. """ entry = parse( "template:\n - t\n" "urls:\n - mailto://user:${T}@${T}.example/:\n" " to: ${T}\n smtp: ${T}\n" )[0] for url in (entry.url(), entry.url(privacy=True)): assert "%24%7B" not in url assert url.count("${T}") == 4 def test_apprise_template_url_masks_around_a_marker_only(): """Privacy hides stored text but never the marker itself.""" entry = parse( "template:\n - t\n" "urls:\n - mailto://user:pass@gmail.com:\n" " smtp: secretserver.${T}\n" )[0] shown = entry.url(privacy=True) assert "${T}" in shown assert "secretserver" not in shown def test_apprise_template_url_rejects_unparsed_marker(logging_enabled, caplog): """Reject a URL when a port marker disappears during parsing. A port is parsed as a number, so a marker there may vanish along with part of the hostname. Do not load the resulting broken entry. """ entries = ( # Written on its own "urls:\n - mailto://user:pass@gmail.com:${T}\n", # Written with settings underneath it "urls:\n - mailto://user:pass@gmail.com:${T}:\n" " to: me@example.com\n", ) for entry in entries: caplog.clear() with caplog.at_level(logging.ERROR): services = parse("template:\n - t\n" + entry) assert services == [] assert "can not be used at this position" in caplog.text def test_apprise_template_url_omits_a_setting_no_url_can_set(): """Do not show ``to=`` for ``json://``, which has no such option. A setting the service cannot use changes nothing when sending. Adding it to the listed URL would suggest the value goes somewhere it does not. """ entry = parse( "template:\n - t\n" "urls:\n - json://user:pass@localhost/:\n to: ${T}\n" )[0] url = entry.url() assert "to=" not in url # The same setting on a service that does have it is still shown entry = parse( "template:\n - t\n" "urls:\n - mailto://user:pass@gmail.com:\n to: ${T}\n" )[0] assert "to=${T}" in entry.url() def test_apprise_template_url_grouped_setting_replaces_the_group(): """A header written under the URL replaces the URL's whole group. Apprise treats a group such as headers as one setting, so writing any of them under the URL drops the ones the URL carried. """ entry = parse( "template:\n - t\n" "urls:\n - json://localhost/?+X-Token=unused:\n" " '+Other': ${T}\n" )[0] url = entry.url() assert "${T}" in url assert "unused" not in url @pytest.mark.parametrize( ("placement", "entry"), [ ("host", "json://${V}/"), ("part of a host", "json://api.${V}.net/"), ("user", "json://${V}:pass@localhost/"), ("password", "json://user:${V}@localhost/"), ("path", "json://localhost/${V}/end"), ("query value", "json://localhost/?:custom=${V}"), ("header", "json://localhost/?+X-Tok=${V}"), ("setting", "mailto://u:p@gmail.com:\n smtp: ${V}\n"), ("port", "mailto://u:p@gmail.com:\n port: ${V}\n"), ( "list setting", "mailto://u:p@gmail.com:\n to:\n" " - ${V}\n - s@e.com\n", ), ( "beside other text", "mailto://u:p@gmail.com:\n smtp: a-${V}-b\n", ), ], ) def test_apprise_template_marker_is_never_escaped(placement, entry): """A marker reads back as written wherever it was put. Escaping is right for everything else a URL carries, but a marker is not a value yet. It has to stay something a reader can spot and a caller can search for and replace, in both privacy modes. """ suffix = "" if entry.endswith("\n") else "\n" services = parse("template:\n - v\nurls:\n - " + entry + suffix) entry_obj = services[0] for url in (entry_obj.url(), entry_obj.url(privacy=True)): assert "${V}" in url assert "%24%7B" not in url def test_apprise_template_undeclared_marker_stays_readable(): """A name left out of the template section is still shown as written. It is not a variable and no value is ever filled into it, but leaving it readable is what shows the author why the entry never asks for it. """ entry = parse( "template:\n - v\n" "urls:\n - json://localhost/?a=${undeclared}&b=${V}\n" )[0] url = entry.url() assert "b=${V}" in url assert "a=${undeclared}" in url # ...but only the declared name is something to supply a value for assert entry.template_names == ("v",) def test_apprise_template_url_uses_written_name(): """A setting keeps the name it was written under, not an alias. mailto's ``from`` and ``name`` both end up in ``from_addr``, yet they mean different things, so the listing must not rename one to the other. """ entry = parse( "template:\n - t\n" "urls:\n - mailtos://u:p@gmail.com:\n name: ${T}\n" )[0] url = entry.url() assert "name=${T}" in url assert "from=" not in url def test_apprise_template_url_setting_precedence(): """A YAML setting replaces a URL alias for the same field. Here, ``name:`` and ``from=`` both fill ``from_addr``. Listing only the YAML setting ensures the displayed URL behaves like the configuration. """ entry = parse( "template:\n - t\n" "urls:\n - mailtos://u:p@gmail.com?from=someone@example.com:\n" " name: ${T}\n" )[0] url = entry.url() assert "name=${T}" in url assert "someone%40example.com" not in url def test_apprise_template_url_last_written_setting_wins(): """When two names fill one field, the later one is the one listed.""" entry = parse( "template:\n - t\n" "urls:\n - mailtos://u:p@gmail.com:\n" " from: real@example.com\n name: ${T}\n" )[0] url = entry.url() assert "name=${T}" in url assert "real%40example.com" not in url def test_apprise_template_url_round_trip(): """A listed URL resolves to what the saved configuration resolves to. Settings that hold no marker are listed too, so nothing the entry needs is lost when the URL is filled in and loaded on its own. """ config = ( "template:\n - t\n" "urls:\n - mailtos://u:p@gmail.com:\n" " to: rcpt@example.com\n name: ${T}\n" ) entry = parse(config)[0] url = entry.url().replace("${T}", "Bob") # The recipient is carried over rather than dropped assert "to=rcpt%40example.com" in url apobj = load(config) saved = next(apobj.find(template={"t": "Bob"})) reloaded = Apprise() assert reloaded.add(url) listed = next(reloaded.find()) assert listed.from_addr == saved.from_addr assert listed.targets == saved.targets def test_apprise_template_flatten_reads_nested_lists(): """A list wrapped in more lists is read as one list of text.""" from apprise.template import _flatten assert _flatten(["a", ["b", ("c",)]]) == ["a", "b", "c"] # A set is ordered only once every member is known to be text assert _flatten({"b", "a"}) == ["a", "b"] def test_apprise_template_flatten_rejects_non_text(): """Anything that is not text makes the whole setting unusable.""" from apprise.template import _flatten # A number cannot hold a marker assert _flatten(["a", 5]) is None # ...and neither can one nested inside another list assert _flatten(["a", ["b", 5]]) is None # A set mixing text and numbers cannot even be ordered assert _flatten({"${T}", 5}) is None def test_apprise_template_flatten_stops_at_the_nesting_limit(): """A list that contains itself is abandoned rather than followed.""" from apprise.template import _flatten loop = ["a"] loop.append(loop) assert _flatten(loop) is None def test_apprise_template_url_omits_empty_setting(): """An empty list, or a value no URL can carry, is left out.""" entry = parse( "template:\n - t\n" "urls:\n - mailtos://u:p@gmail.com:\n" " to: []\n cc: null\n name: ${T}\n" )[0] url = entry.url() assert "name=${T}" in url assert "to=" not in url assert "cc=" not in url def test_apprise_template_url_args_without_a_usable_plugin(): """A service we cannot describe simply gets no translation.""" from apprise.template import _url_args # Nothing to describe at all assert _url_args(None) == {} # ...and a plugin whose details cannot be read is treated the same way class Broken: """Stands in for a service that cannot be described.""" with mock.patch("apprise.plugins.details", side_effect=ValueError("nope")): assert _url_args(Broken) == {} def test_apprise_template_settable_needs_a_known_service(): """A schema with no plugin behind it can carry no settings.""" entry = parse( "template:\n - t\n" "urls:\n - mailtos://u:p@gmail.com:\n name: ${T}\n" )[0] # Point the entry at a schema no plugin provides entry.results["schema"] = "not-a-real-schema" assert entry._settable("name") is False caronc-apprise-dfec414/tests/test_apprise_template_placement.py000066400000000000000000000171221526720371000252630ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Test where template values may be placed and what they can change. URL markers can change any field, including the destination. Markers in named YAML settings change only that setting, making them safer for untrusted input. The Apprise API uses the same rules at every access level. """ # Disable logging for a cleaner testing output import logging from unittest import mock import pytest import requests from apprise import Apprise, AppriseConfig logging.disable(logging.CRITICAL) def load(content): """Build an Apprise object around a YAML configuration.""" apobj = Apprise() config = AppriseConfig() assert config.add_config(content, format="yaml") assert apobj.add(config) return apobj @pytest.fixture def sent(): """Accept every outbound request so delivery reports success.""" with mock.patch("requests.request") as request: response = mock.Mock() response.status_code = requests.codes.ok response.content = "" response.headers = {} request.return_value = response yield request # A value that would reach further than one field if anything re-parsed # it. Every placement below is checked against this. REACHING_VALUE = "evil.example/hook" @pytest.mark.parametrize( ("placement", "entry"), [ ("whole host", "json://${V}/"), ("part of a host", "json://${V}.example/"), ("host beside credentials", "json://user:pass@${V}/"), ("host beside a path", "json://${V}/api/notify"), ("host beside a query value", "json://${V}/?apikey=stored"), ("host beside a header", "json://${V}/?:Authorization=Bearer+s"), ("host beside a payload field", "json://${V}/?+token=stored"), ("user", "json://${V}:pass@host/"), ("password", "json://user:${V}@host/"), ("path", "json://host/${V}"), ("query value", "json://host/?:custom=${V}"), ], ) def test_variable_in_any_url_field(placement, entry, sent): """Confirm every URL field accepts a variable, including the host.""" apobj = load("template:\n - v\nurls:\n - {}\n".format(entry)) assert next(apobj.find(template={"v": "filled-in"})) is not None def test_all_url_fields_as_variables(sent): """One URL can be made entirely of variables.""" apobj = load( "template:\n - user\n - secret\n - host\n" "urls:\n - json://${USER}:${SECRET}@${HOST}/\n" ) service = next( apobj.find( template={"user": "me", "secret": "mine", "host": "my.example"} ) ) assert service.user == "me" assert service.password == "mine" assert service.host == "my.example" def test_host_variable_with_settings(sent): """Settings written under the URL do not hold the entry back.""" apobj = load( "template:\n - host\nurls:\n - json://user:pass@${HOST}/:\n" " - headers:\n Authorization: Bearer stored\n" ) assert next(apobj.find(template={"host": "my.example"})).host == ( "my.example" ) @pytest.mark.parametrize( "source", ["supplied", "environment", "default"], ) def test_host_variable_value_sources(source, monkeypatch, sent): """Treat supplied, environment, and default host values alike.""" declaration = " host: my.example" if source == "default" else " - host" supplied = {"host": "my.example"} if source == "supplied" else None if source == "environment": monkeypatch.setenv("APPRISE_TEMPLATE_HOST", "my.example") apobj = load( "template:\n{}\nurls:\n - json://user:pass@${{HOST}}/\n".format( declaration ) ) service = next(apobj.find(template=supplied)) assert service.host == "my.example" assert service.user == "user" def test_url_variable_changes_destination(sent): """A host variable chooses where the URL and its credentials are sent.""" apobj = load("template:\n - v\nurls:\n - json://user:pass@${V}/\n") service = next(apobj.find(template={"v": REACHING_VALUE})) assert service.host == REACHING_VALUE assert service.user == "user" assert service.password == "pass" def test_setting_variable_keeps_destination(sent): """A named setting changes one option without moving the destination.""" apobj = load( "template:\n - v\n" "urls:\n - json://user:pass@fixed.example/:\n - to: ${V}\n" ) service = next(apobj.find(template={"v": REACHING_VALUE})) assert service.host == "fixed.example" assert service.user == "user" assert service.password == "pass" def test_url_and_setting_scope(sent): """Contrast unrestricted URL placement with a scoped YAML setting.""" in_url = load("template:\n - v\nurls:\n - json://user:pass@${V}/\n") in_setting = load( "template:\n - v\n" "urls:\n - json://user:pass@fixed.example/:\n - to: ${V}\n" ) moved = next(in_url.find(template={"v": REACHING_VALUE})) fixed = next(in_setting.find(template={"v": REACHING_VALUE})) assert moved.host != fixed.host assert fixed.host == "fixed.example" @pytest.mark.parametrize( "position", [ "json://localhost/:\n - tag: ${V}", "${V}://localhost/", ], ) def test_disallowed_variable_positions(position): """Do not let values choose a service or a tag. These fields are read before template values are applied. """ apobj = Apprise() config = AppriseConfig() config.add_config( "template:\n - v\nurls:\n - {}\n".format(position), format="yaml", ) apobj.add(config) assert list(apobj.find(template={"v": "anything"})) == [] def test_yaml_setting_name_stays_literal(): """Only values are replaced underneath a URL, never the names. A setting is called whatever the configuration file called it, so a marker written as one stays plain text. """ apobj = Apprise() config = AppriseConfig() config.add_config( "template:\n - v\n" "urls:\n - json://localhost/:\n" " '+${V}': 1\n '+X': ${V}\n", format="yaml", ) apobj.add(config) service = next(apobj.find(template={"v": "supplied"})) # The name kept its marker; only the value took the supplied text assert service.headers == {"${V}": "1", "X": "supplied"} caronc-apprise-dfec414/tests/test_apprise_translations.py000066400000000000000000000647621526720371000241550ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import ctypes from importlib import reload # Disable logging for a cleaner testing output import logging import os import sys from unittest import mock from helpers import environ import pytest from apprise import Apprise, AppriseAsset, locale logging.disable(logging.CRITICAL) # True when the catalogs have been compiled (tox -e compile). They are not # kept in git and CI does not build them, so add() results depend on this. CATALOG_COMPILED = os.path.isfile( os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "apprise", "i18n", "en", "LC_MESSAGES", "apprise.mo", ) ) def test_apprise_trans(): """ API: Test apprise locale object """ # Text with no translation reads the same in every language text = "A string Apprise has no translation for" lazytrans = locale.LazyTranslation(text) assert str(lazytrans) == text @pytest.mark.skipif("gettext" not in sys.modules, reason="Requires gettext") def test_apprise_trans_gettext_init(): """ API: Handle gettext """ # Toggle locale.GETTEXT_LOADED = False # Objects can still be created al = locale.AppriseLocale() with al.lang_at("en") as _: # functions still behave as normal assert _ is None # Lazy strings hand back the English source text they were given assert str(locale.gettext_lazy("Token")) == "Token" # Restore the object locale.GETTEXT_LOADED = True @pytest.mark.skipif("gettext" not in sys.modules, reason="Requires gettext") @mock.patch("gettext.translation") @mock.patch("locale.getlocale") def test_apprise_trans_gettext_translations( mock_getlocale, mock_gettext_trans ): """ API: Apprise() Gettext translations """ # Set the gettext locale returned by the mock. mock_getlocale.return_value = ("en_US", "UTF-8") mock_gettext_trans.side_effect = FileNotFoundError() # This throws internally but we handle it gracefully al = locale.AppriseLocale() with al.lang_at("en"): # functions still behave as normal pass # This throws internally but we handle it gracefully locale.AppriseLocale(language="fr") @pytest.mark.skipif(hasattr(ctypes, "windll"), reason="Unique Nux test cases") @pytest.mark.skipif("gettext" not in sys.modules, reason="Requires gettext") @mock.patch("locale.getlocale") def test_apprise_trans_gettext_lang_at(mock_getlocale): """ API: Apprise() Gettext lang_at """ # Set the gettext locale returned by the mock. mock_getlocale.return_value = ("en_CA", "UTF-8") # Clear the language variables so only the mock above is detected with environ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE", "LANG"): # This throws internally but we handle it gracefully al = locale.AppriseLocale() # We detected en_CA, so plain en needs a compiled catalog assert al.add("en", set_default=False) is CATALOG_COMPILED assert al.add("en", set_default=True) is CATALOG_COMPILED with al.lang_at("en"): # functions still behave as normal pass # This throws internally but we handle it gracefully locale.AppriseLocale(language="fr") with al.lang_at("en") as _: # functions still behave as normal assert callable(_) with al.lang_at("es") as _: # functions still behave as normal assert callable(_) with al.lang_at("fr") as _: # functions still behave as normal assert callable(_) # Test our initialization when our fallback is a language we do # not have. This is only done to test edge cases when for whatever # reason the person who set up apprise does not have the languages # installed. fallback = locale.AppriseLocale._default_language mock_getlocale.return_value = None with environ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE", "LANG"): # Our default language locale.AppriseLocale._default_language = "zz" # We will detect the zz since there were no environment variables to # help us otherwise assert locale.AppriseLocale.detect_language() is None al = locale.AppriseLocale() # No language can be set because its locale directory does not exist. assert al.lang is None with al.lang_at(None) as _: # functions still behave as normal assert callable(_) with al.lang_at("en") as _: # functions still behave as normal assert callable(_) with al.lang_at("es") as _: # functions still behave as normal assert callable(_) with al.lang_at("fr") as _: # functions still behave as normal assert callable(_) # We can still perform simple lookups; they access a dummy wrapper: assert al.gettext("test") == "test" locale.AppriseLocale._default_language = "fr" with environ( "LANGUAGE", "LC_MESSAGES", "LC_CTYPE", LC_ALL="C.UTF-8", LANG="en_CA", ): # LC_ALL is authoritative, and the C locale it names is a request for # no translation at all. LANG is not consulted, and we settle on the # language configured as our default. assert locale.AppriseLocale.detect_language() == "fr" with environ( "LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE", LANG="en_CA" ): # With no C locale in the way, LANG decides and overrides the # default. The region is kept so that an en_CA translation is # preferred over the en translation. assert locale.AppriseLocale.detect_language() == "en_CA" al = locale.AppriseLocale() assert al.lang == "en_CA" assert al.gettext("test") == "test" # Test case with set_default set to False (so we're still set to 'fr') assert al.add("zy", set_default=False) is False assert al.gettext("test") == "test" al.add("ab", set_default=True) assert al.gettext("test") == "test" assert al.add("zy", set_default=False) is False locale.AppriseLocale._default_language = fallback def test_normalize_language(): """Both the 2 and 5 letter language forms are accepted.""" # A plain language stays as it is, and case never matters for entry in ("en", "EN", " en "): assert locale.AppriseLocale.normalize_language(entry) == "en" # A region may be separated by a hyphen or an underscore for entry in ("en-CA", "en_CA", "EN-ca", "en-ca"): assert locale.AppriseLocale.normalize_language(entry) == "en_CA" # A codeset (.UTF-8) and modifier (@euro) are both dropped for entry in ( "en_CA.UTF-8", "en_CA.utf8", "en_CA.ISO8859-1", "en_CA.ANSI_X3.4-1968", "en_CA@euro", "en_CA.UTF-8@euro", ): assert locale.AppriseLocale.normalize_language(entry) == "en_CA" # A modifier is dropped even without a codeset assert locale.AppriseLocale.normalize_language("sr_RS@latin") == "sr_RS" assert locale.AppriseLocale.normalize_language("fr@euro") == "fr" # The form locale.normalize() returns for ca_ES@valencia assert ( locale.AppriseLocale.normalize_language("ca_ES.UTF-8@valencia") == "ca_ES" ) # A codeset or modifier on its own is not a language for entry in (".UTF-8", "@euro", "garbage.stuff_more"): assert locale.AppriseLocale.normalize_language(entry) is None # Anything beyond a 2 letter region is dropped, leaving the language assert locale.AppriseLocale.normalize_language("zh-Hans") == "zh" assert locale.AppriseLocale.normalize_language("zh-Hans-CN") == "zh" # Only the first entry of a preference list is honored, whether it is # comma separated like an Accept-Language header or colon separated like # a LANGUAGE= environment variable assert ( locale.AppriseLocale.normalize_language("en-US,en;q=0.9,de;q=0.5") == "en_US" ) assert locale.AppriseLocale.normalize_language("fr:de") == "fr" # A colon is a list separator, so it never introduces a region assert locale.AppriseLocale.normalize_language("en:CA") == "en" # Values we can not work with for entry in ("", "garbage", "!", "C", "C.UTF-8", None, 42): assert locale.AppriseLocale.normalize_language(entry) is None def test_is_ansii_locale(): """The C locale is recognized under each of its spellings.""" # Every spelling, with or without a codeset or modifier for entry in ( "C", "c", "POSIX", "posix", "C.UTF-8", "C.utf8", "C.ANSI_X3.4-1968", "POSIX.UTF-8", "C@euro", ): assert locale.AppriseLocale.is_ansii_locale(entry) is True # A language is not the C locale, including the ones that start with a c for entry in ("en", "ca", "cs", "ca_ES", "cat", "", None, 42): assert locale.AppriseLocale.is_ansii_locale(entry) is False def test_language_candidates(): """A region is always retried without it before we give up.""" # A plain language has nowhere else to look assert locale.AppriseLocale.language_candidates("fr") == ["fr"] # A region is preferred, but the language itself is the backup assert locale.AppriseLocale.language_candidates("pt-BR") == [ "pt_BR", "pt", ] # Nothing to search for assert locale.AppriseLocale.language_candidates("garbage") == [] @pytest.mark.skipif(sys.platform == "win32", reason="Unique Nux test cases") @mock.patch("locale.getlocale") def test_detect_language_precedence(mock_getlocale): """The environment is read in the order gettext itself reads it.""" # Never reached while an environment variable answers for us mock_getlocale.return_value = (None, None) with environ( "LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE", LANG="en_CA" ): # LANG on its own assert locale.AppriseLocale.detect_language() == "en_CA" with environ( "LANGUAGE", "LC_ALL", "LC_CTYPE", LANG="en_CA", LC_MESSAGES="fr_CA" ): # LC_MESSAGES is the category that governs translated text assert locale.AppriseLocale.detect_language() == "fr_CA" with environ( "LANGUAGE", "LC_CTYPE", LANG="en_CA", LC_MESSAGES="fr_CA", LC_ALL="de_DE", ): # LC_ALL over-rides every category assert locale.AppriseLocale.detect_language() == "de_DE" with environ( "LC_CTYPE", LANG="en_CA", LC_MESSAGES="fr_CA", LC_ALL="de_DE", LANGUAGE="es", ): # LANGUAGE wins over all of them assert locale.AppriseLocale.detect_language() == "es" with environ( "LANGUAGE", "LC_ALL", "LC_MESSAGES", LANG="en_CA", LC_CTYPE="de_DE" ): # LC_CTYPE governs character handling, not the language of text assert locale.AppriseLocale.detect_language() == "en_CA" with environ( "LC_ALL", "LC_MESSAGES", "LC_CTYPE", LANG="en_CA", LANGUAGE="fr:de" ): # A LANGUAGE list is colon separated; its first entry is used assert locale.AppriseLocale.detect_language() == "fr" with environ( "LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE", LANG="fr_FR.ISO8859-1@euro", ): # The codeset and modifier are dropped assert locale.AppriseLocale.detect_language() == "fr_FR" default = locale.AppriseLocale._default_language for value in ( "C", "POSIX", "C.UTF-8", "posix", "C.ANSI_X3.4-1968", "C@euro", ): with environ( "LC_MESSAGES", "LC_CTYPE", LC_ALL=value, LANG="fr_FR.UTF-8", LANGUAGE="de", ): # Asking for the C locale is asking for untranslated text, so # nothing below LC_ALL gets a say, LANGUAGE included assert locale.AppriseLocale.detect_language() == default with environ( "LANGUAGE", "LC_ALL", "LC_CTYPE", LANG="C", LC_MESSAGES="fr_CA" ): # A higher priority category still decides; only the locale actually # in charge of messages can silence translation assert locale.AppriseLocale.detect_language() == "fr_CA" with environ( "LC_ALL", "LC_MESSAGES", "LC_CTYPE", LANGUAGE="garbage", LANG="fr_CA" ): # A value we can make no sense of is stepped over rather than # stopping us; only the C locale does that assert locale.AppriseLocale.detect_language() == "fr_CA" @pytest.mark.skipif("gettext" not in sys.modules, reason="Requires gettext") def test_lang_at_invalid_entries(): """An unusable language falls back instead of raising.""" al = locale.AppriseLocale() # None of these can be turned into a language, yet each still hands # back a usable translation function for entry in (None, "", " ", "garbage", "!", 42): with al.lang_at(entry) as fn: assert callable(fn) # The same holds true for the public details() call obj = Apprise() assert isinstance(obj.details(lang="invalid!"), dict) @pytest.mark.skipif("gettext" not in sys.modules, reason="Requires gettext") @mock.patch("gettext.translation") def test_lazy_translation_context(mock_translation): """A lang_at() block decides how lazy strings are translated.""" # Our stand-in catalog marks anything it translates dummy = mock.Mock() dummy.gettext = lambda text: f"xx:{text}" mock_translation.return_value = dummy al = locale.AppriseLocale() entry = locale.gettext_lazy("Token") # Our own singleton is untouched outside of the block before = str(entry) with al.lang_at("fr"): assert str(entry) == "xx:Token" # The language in effect before us is restored assert str(entry) == before # Asking for a function other than gettext leaves lazy strings alone with al.lang_at("fr", mapto="ngettext"): assert str(entry) == before @pytest.mark.skipif("gettext" not in sys.modules, reason="Requires gettext") @mock.patch("gettext.translation") def test_asset_language_honored(mock_translation): """An asset language is used by the details() it returns.""" # Our stand-in catalog marks anything it translates dummy = mock.Mock() dummy.gettext = lambda text: f"xx:{text}" mock_translation.return_value = dummy obj = Apprise(asset=AppriseAsset(language="fr-CA")) # Our asset decided the language of the object we built assert obj.locale.lang == "fr_CA" # Every human readable entry came back translated names = [ token["name"] for schema in obj.details()["schemas"] for token in schema["details"]["tokens"].values() ] assert names assert all(str(name).startswith("xx:") for name in names) @pytest.mark.skipif("gettext" not in sys.modules, reason="Requires gettext") @mock.patch("locale.getlocale") def test_apprise_trans_add(mock_getlocale): """ API: Apprise() Gettext add """ # Used when no language variable is set. Windows asks the system # first, but always gets a regional answer (en_US), so the checks below # still hold. mock_getlocale.return_value = ("en_CA", "UTF-8") with environ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE", "LANG"): # We start on en_CA, so plain en needs a compiled catalog al = locale.AppriseLocale() assert al.add("en") is CATALOG_COMPILED with environ( "LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE", LANG="C.UTF-8" ): # The C locale starts on our default language, so en is loaded al = locale.AppriseLocale() assert al.lang == locale.AppriseLocale._default_language assert al.add("en") is True with environ( "LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE", LANG="en_CA.UTF-8" ): # Test English Environment al = locale.AppriseLocale() assert al.add("en") is CATALOG_COMPILED # Double add (copy of above) to access logic that prevents adding it # again assert al.add("en") is CATALOG_COMPILED # Invalid Language assert al.add("bad") is False # A value that is not a language at all never reaches gettext; there is # no catalog name that could be built from it for entry in ("C", "!", "1"): assert al.add(entry) is False # An empty value is not a failure; it selects the default language assert al.add("") is CATALOG_COMPILED @pytest.mark.skipif( not hasattr(ctypes, "windll"), reason="Unique Windows test cases" ) @pytest.mark.skipif("gettext" not in sys.modules, reason="Requires gettext") @mock.patch("locale.getlocale") def test_apprise_trans_windows_users_win(mock_getlocale): """ API: Apprise() Windows Locale Testing (Win version) """ # Set the gettext locale returned by the mock. mock_getlocale.return_value = ("fr_CA", "UTF-8") with mock.patch( "ctypes.windll.kernel32.GetUserDefaultUILanguage" ) as ui_lang: # 4105 = en_CA ui_lang.return_value = 4105 with environ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE", "LANG"): # Our default language locale.AppriseLocale._default_language = "zz" # The Windows locale supplies English. assert locale.AppriseLocale.detect_language() == "en_CA" # Environment variables take precedence over the Windows locale. with environ( "LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE", LANG="es_AR" ): assert locale.AppriseLocale.detect_language() == "es_AR" # Without environment variables, use the Windows locale. with environ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE", "LANG"): assert locale.AppriseLocale.detect_language() == "en_CA" assert ( locale.AppriseLocale.detect_language(detect_fallback=False) is None ) # 0 = IndexError ui_lang.return_value = 0 with environ("LANGUAGE", "LANG", "LC_ALL", "LC_MESSAGES", "LC_CTYPE"): # Fall back to the POSIX locale. assert locale.AppriseLocale.detect_language() == "fr_CA" @pytest.mark.skipif(hasattr(ctypes, "windll"), reason="Unique Nux test cases") @pytest.mark.skipif("gettext" not in sys.modules, reason="Requires gettext") @mock.patch("locale.getlocale") def test_apprise_trans_windows_users_nux(mock_getlocale): """ API: Apprise() Windows Locale Testing (Nux version) """ # Set the gettext locale returned by the mock. mock_getlocale.return_value = ("fr_CA", "UTF-8") # Emulate a windows environment windll = mock.Mock() ctypes.windll = windll # 4105 = en_CA windll.kernel32.GetUserDefaultUILanguage.return_value = 4105 # Store default value to not break other tests default_language = locale.AppriseLocale._default_language with environ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE", "LANG"): # Our default language locale.AppriseLocale._default_language = "zz" # The Windows locale supplies English. assert locale.AppriseLocale.detect_language() == "en_CA" # Environment variables take precedence over the Windows locale. with environ( "LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE", LANG="es_AR" ): assert locale.AppriseLocale.detect_language() == "es_AR" # Without environment variables, use the Windows locale. with environ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE", "LANG"): assert locale.AppriseLocale.detect_language() == "en_CA" assert locale.AppriseLocale.detect_language(detect_fallback=False) is None # 0 = IndexError windll.kernel32.GetUserDefaultUILanguage.return_value = 0 with environ("LANGUAGE", "LANG", "LC_ALL", "LC_MESSAGES", "LC_CTYPE"): # Fall back to the POSIX locale. assert locale.AppriseLocale.detect_language() == "fr_CA" del ctypes.windll # Restore default value locale.AppriseLocale._default_language = default_language @pytest.mark.skipif(sys.platform == "win32", reason="Unique Nux test cases") @mock.patch("locale.getlocale") def test_detect_language_using_env(mock_getlocale): """Test the reading of information from an environment variable.""" # Set the gettext locale returned by the mock. mock_getlocale.return_value = ("en_CA", "UTF-8") # The below accesses the windows fallback code and fail # then it will resort to the environment variables. with environ("LANG", "LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE"): # Language can now be detected in this case assert isinstance(locale.AppriseLocale.detect_language(), str) # Detect French language. with environ( "LANGUAGE", "LC_ALL", "LC_MESSAGES", LC_CTYPE="garbage", LANG="fr_CA" ): assert locale.AppriseLocale.detect_language() == "fr_CA" # A system reporting the C locale falls back to our default language mock_getlocale.return_value = ("C", "UTF-8") with environ("LANG", "LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_CTYPE"): assert ( locale.AppriseLocale.detect_language() == locale.AppriseLocale._default_language ) # Restore what the rest of this test expects mock_getlocale.return_value = ("en_CA", "UTF-8") # The following unsets all environment variables and sets LC_CTYPE # This was causing Python 2.7 to internally parse UTF-8 as an invalid # locale and throw an uncaught ValueError; Python v2 support has been # dropped, but just to ensure this issue does not come back, we keep # this test: with environ(*list(os.environ.keys()), LC_CTYPE="UTF-8"): assert isinstance(locale.AppriseLocale.detect_language(), str) # Test with no environment variables. with environ(*list(os.environ.keys())): assert isinstance(locale.AppriseLocale.detect_language(), str) # Handle case where getlocale() can't be detected mock_getlocale.return_value = None with environ("LC_ALL", "LC_MESSAGES", "LC_CTYPE", "LANG", "LANGUAGE"): assert locale.AppriseLocale.detect_language() is None mock_getlocale.return_value = (None, None) with environ("LC_ALL", "LC_MESSAGES", "LC_CTYPE", "LANG", "LANGUAGE"): assert locale.AppriseLocale.detect_language() is None # if detect_language and windows env fail us, then we don't # set up a default language on first load locale.AppriseLocale() @pytest.mark.skipif("gettext" not in sys.modules, reason="Requires gettext") def test_apprise_trans_gettext_missing(tmpdir): """Verify we can still operate without the gettext library.""" # Reloading a module rebinds every class it defines. Anything that # imported one of them earlier keeps the original, and the two no longer # compare as the same object, which breaks isinstance() checks and # pickling for the rest of the run. Only what Apprise hands out by # reference is restored afterwards; the modules this one imports are # left as the reload found them so they can still be mocked. restore = { name: getattr(sys.modules["apprise.locale"], name) for name in ( "ACTIVE_GETTEXT", "AppriseLocale", "LOCALE", "LazyTranslation", "Translatable", "gettext_lazy", ) } # remove gettext from our system enviroment del sys.modules["gettext"] try: # Put a failing fake gettext module ahead of the real one. gettext_dir = tmpdir.mkdir("gettext") gettext_dir.join("__init__.py").write("") gettext_dir.join("gettext.py").write("""raise ImportError()""") # Update our path to point path to head sys.path.insert(0, str(gettext_dir)) # reload our module (forcing the import error when it tries to load # gettext reload(sys.modules["apprise.locale"]) from apprise import locale assert locale.GETTEXT_LOADED is False # Now roll our changes back sys.path.pop(0) # Reload again (reverting back) reload(sys.modules["apprise.locale"]) from apprise import locale assert locale.GETTEXT_LOADED is True finally: # Hand back the classes the rest of Apprise is already holding module = sys.modules["apprise.locale"] for name, value in restore.items(): setattr(module, name, value) @mock.patch("gettext.translation") def test_apprise_locale_add_existing_language(mock_translation): """Re-add an existing language to hit fallback mapping path.""" # Return a fake gettext object with a valid gettext() method dummy = mock.Mock() dummy.gettext = lambda x: f"tr:{x}" mock_translation.return_value = dummy al = locale.AppriseLocale() assert al.add("en", set_default=True) is True assert al.add("en", set_default=False) is True @mock.patch("gettext.translation") @mock.patch("locale.getlocale") def test_apprise_trans_successful_translation( mock_getlocale, mock_translation ): """Trigger logger.trace by mocking successful gettext.translation()""" mock_getlocale.return_value = ("en_CA", "UTF-8") dummy_translation = mock.Mock() dummy_translation.gettext = lambda x: f"tr:{x}" mock_translation.return_value = dummy_translation al = locale.AppriseLocale() assert al.add("en") is True assert al.gettext("test") == "tr:test" caronc-apprise-dfec414/tests/test_apprise_utils.py000066400000000000000000004050051526720371000225610ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import tzinfo from inspect import cleandoc # Disable logging for a cleaner testing output import logging import os import re import sys import time from unittest import mock from urllib.parse import unquote from apprise import Apprise, NotificationManager, utils from apprise.tag import AppriseTag logging.disable(logging.CRITICAL) # Ensure we don't create .pyc files for these tests sys.dont_write_bytecode = True # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() def test_parse_qsd(): "utils: parse_qsd() testing" result = utils.parse.parse_qsd("a=1&b=&c&d=abcd") assert isinstance(result, dict) assert len(result) == 4 assert "qsd" in result assert "qsd+" in result assert "qsd-" in result assert "qsd:" in result assert len(result["qsd"]) == 4 assert "a" in result["qsd"] assert "b" in result["qsd"] assert "c" in result["qsd"] assert "d" in result["qsd"] assert len(result["qsd-"]) == 0 assert len(result["qsd+"]) == 0 assert len(result["qsd:"]) == 0 def test_parse_url_general(): "utils: parse_url() testing" result = utils.parse.parse_url("http://hostname") assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "http://hostname" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # GitHub Ticket 1234 - Unparseable Hostname result = utils.parse.parse_url("http://5t4m59hl-34343.euw.devtunnels.ms") assert result["schema"] == "http" assert result["host"] == "5t4m59hl-34343.euw.devtunnels.ms" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "http://5t4m59hl-34343.euw.devtunnels.ms" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url("http://hostname/") assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] == "/" assert result["path"] == "/" assert result["query"] is None assert result["url"] == "http://hostname/" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # colon after hostname without port number is no good assert utils.parse.parse_url("http://hostname:") is None # An invalid port result = utils.parse.parse_url( "http://hostname:invalid", verify_host=False, strict_port=True ) assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == "invalid" assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "http://hostname:invalid" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # However if we don't verify the host, it is okay result = utils.parse.parse_url("http://hostname:", verify_host=False) assert result["schema"] == "http" assert result["host"] == "hostname:" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "http://hostname:" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # A port of Zero is not valid with strict port checking assert utils.parse.parse_url("http://hostname:0", strict_port=True) is None # Without strict port checking however, it is okay result = utils.parse.parse_url("http://hostname:0", strict_port=False) assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == 0 assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "http://hostname:0" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # A negative port is not valid assert ( utils.parse.parse_url("http://hostname:-92", strict_port=True) is None ) result = utils.parse.parse_url( "http://hostname:-92", verify_host=False, strict_port=True ) assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == -92 assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "http://hostname:-92" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # A port that is too large is not valid assert ( utils.parse.parse_url("http://hostname:65536", strict_port=True) is None ) # This is an accetable port (the maximum) result = utils.parse.parse_url("http://hostname:65535", strict_port=True) assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == 65535 assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "http://hostname:65535" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # This is an accetable port (the maximum) result = utils.parse.parse_url("http://hostname:1", strict_port=True) assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == 1 assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "http://hostname:1" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # A port that was identfied as a string is invalid assert ( utils.parse.parse_url("http://hostname:invalid", strict_port=True) is None ) result = utils.parse.parse_url( "http://hostname:invalid", verify_host=False, strict_port=True ) assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == "invalid" assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "http://hostname:invalid" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url( "http://hostname:invalid", verify_host=False, strict_port=False ) assert result["schema"] == "http" assert result["host"] == "hostname:invalid" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "http://hostname:invalid" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url( "http://hostname:invalid?key=value&-minuskey=mvalue", verify_host=False, strict_port=False, ) assert result["schema"] == "http" assert result["host"] == "hostname:invalid" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "http://hostname:invalid" assert unquote(result["qsd"]["-minuskey"]) == "mvalue" assert unquote(result["qsd"]["key"]) == "value" assert unquote(result["qsd-"]["minuskey"]) == "mvalue" assert result["qsd+"] == {} assert result["qsd:"] == {} # Handling of floats assert ( utils.parse.parse_url("http://hostname:4.2", strict_port=True) is None ) result = utils.parse.parse_url( "http://hostname:4.2", verify_host=False, strict_port=True ) assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == "4.2" assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "http://hostname:4.2" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # A Port of zero is not acceptable for a regular hostname assert utils.parse.parse_url("http://hostname:0", strict_port=True) is None # No host verification (zero is an acceptable port when this is the case result = utils.parse.parse_url("http://hostname:0", verify_host=False) assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == 0 assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "http://hostname:0" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url( "http://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8080", verify_host=False, ) assert result["schema"] == "http" assert result["host"] == "[2001:db8:002a:3256:adfe:05c0:0003:0006]" assert result["port"] == 8080 assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert ( result["url"] == "http://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8080" ) assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url( "http://hostname:0", verify_host=False, strict_port=True ) assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == 0 assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "http://hostname:0" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url("http://hostname/?-KeY=Value") assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] == "/" assert result["path"] == "/" assert result["query"] is None assert result["url"] == "http://hostname/" assert "-key" in result["qsd"] assert unquote(result["qsd"]["-key"]) == "Value" assert "KeY" in result["qsd-"] assert unquote(result["qsd-"]["KeY"]) == "Value" assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url("http://hostname/?+KeY=Value") assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] == "/" assert result["path"] == "/" assert result["query"] is None assert result["url"] == "http://hostname/" assert "+key" in result["qsd"] assert "KeY" in result["qsd+"] assert result["qsd+"]["KeY"] == "Value" assert result["qsd-"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url("http://hostname/?:kEy=vALUE") assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] == "/" assert result["path"] == "/" assert result["query"] is None assert result["url"] == "http://hostname/" assert ":key" in result["qsd"] assert "kEy" in result["qsd:"] assert result["qsd:"]["kEy"] == "vALUE" assert result["qsd+"] == {} assert result["qsd-"] == {} result = utils.parse.parse_url( "http://hostname/?+KeY=ValueA&-kEy=ValueB&KEY=Value%20+C&:colon=y" ) assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] == "/" assert result["path"] == "/" assert result["query"] is None assert result["url"] == "http://hostname/" assert "+key" in result["qsd"] assert "-key" in result["qsd"] assert ":colon" in result["qsd"] assert result["qsd:"]["colon"] == "y" assert "key" in result["qsd"] assert "KeY" in result["qsd+"] assert result["qsd+"]["KeY"] == "ValueA" assert "kEy" in result["qsd-"] assert result["qsd-"]["kEy"] == "ValueB" assert result["qsd"]["key"] == "Value +C" assert result["qsd"]["+key"] == result["qsd+"]["KeY"] assert result["qsd"]["-key"] == result["qsd-"]["kEy"] result = utils.parse.parse_url( "http://hostname/?+KeY=ValueA&-kEy=ValueB&KEY=Value%20+C&:colon=y", plus_to_space=True, ) assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] == "/" assert result["path"] == "/" assert result["query"] is None assert result["url"] == "http://hostname/" assert "+key" in result["qsd"] assert "-key" in result["qsd"] assert ":colon" in result["qsd"] assert result["qsd:"]["colon"] == "y" assert "key" in result["qsd"] assert "KeY" in result["qsd+"] assert result["qsd+"]["KeY"] == "ValueA" assert "kEy" in result["qsd-"] assert result["qsd-"]["kEy"] == "ValueB" assert result["qsd"]["key"] == "Value C" assert result["qsd"]["+key"] == result["qsd+"]["KeY"] assert result["qsd"]["-key"] == result["qsd-"]["kEy"] result = utils.parse.parse_url("http://hostname////") assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] == "/" assert result["path"] == "/" assert result["query"] is None assert result["url"] == "http://hostname/" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url("http://hostname:40////") assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == 40 assert result["user"] is None assert result["password"] is None assert result["fullpath"] == "/" assert result["path"] == "/" assert result["query"] is None assert result["url"] == "http://hostname:40/" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url("HTTP://HoStNaMe:40/test.php") assert result["schema"] == "http" assert result["host"] == "HoStNaMe" assert result["port"] == 40 assert result["user"] is None assert result["password"] is None assert result["fullpath"] == "/test.php" assert result["path"] == "/" assert result["query"] == "test.php" assert result["url"] == "http://HoStNaMe:40/test.php" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url("HTTPS://user@hostname/test.py") assert result["schema"] == "https" assert result["host"] == "hostname" assert result["port"] is None assert result["user"] == "user" assert result["password"] is None assert result["fullpath"] == "/test.py" assert result["path"] == "/" assert result["query"] == "test.py" assert result["url"] == "https://user@hostname/test.py" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url(" HTTPS://///user@@@hostname///test.py ") assert result["schema"] == "https" assert result["host"] == "hostname" assert result["port"] is None assert result["user"] == "user" assert result["password"] is None assert result["fullpath"] == "/test.py" assert result["path"] == "/" assert result["query"] == "test.py" assert result["url"] == "https://user@hostname/test.py" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url( "HTTPS://user:password@otherHost/full///path/name/", ) assert result["schema"] == "https" assert result["host"] == "otherHost" assert result["port"] is None assert result["user"] == "user" assert result["password"] == "password" assert result["fullpath"] == "/full/path/name/" assert result["path"] == "/full/path/name/" assert result["query"] is None assert result["url"] == "https://user:password@otherHost/full/path/name/" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url( "HTTPS://hostname/a/path/ending/with/slash/?key=value", ) assert result["schema"] == "https" assert result["host"] == "hostname" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] == "/a/path/ending/with/slash/" assert result["path"] == "/a/path/ending/with/slash/" assert result["query"] is None assert result["url"] == "https://hostname/a/path/ending/with/slash/" assert result["qsd"] == {"key": "value"} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # Handle garbage assert utils.parse.parse_url(None) is None result = utils.parse.parse_url( "mailto://user:password@otherHost/lead2gold@gmail.com" + "?from=test@test.com&name=Chris%20Caron&format=text" ) assert result["schema"] == "mailto" assert result["host"] == "otherHost" assert result["port"] is None assert result["user"] == "user" assert result["password"] == "password" assert unquote(result["fullpath"]) == "/lead2gold@gmail.com" assert result["path"] == "/" assert unquote(result["query"]) == "lead2gold@gmail.com" assert ( unquote(result["url"]) == "mailto://user:password@otherHost/lead2gold@gmail.com" ) assert len(result["qsd"]) == 3 assert "name" in result["qsd"] assert unquote(result["qsd"]["name"]) == "Chris Caron" assert "from" in result["qsd"] assert unquote(result["qsd"]["from"]) == "test@test.com" assert "format" in result["qsd"] assert unquote(result["qsd"]["format"]) == "text" assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # Test Passwords with question marks ?; not supported result = utils.parse.parse_url("http://user:pass.with.?question@host") assert result is None # just hostnames result = utils.parse.parse_url("nuxref.com") assert result["schema"] == "http" assert result["host"] == "nuxref.com" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "http://nuxref.com" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # just host and path result = utils.parse.parse_url("invalid/host") assert result["schema"] == "http" assert result["host"] == "invalid" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] == "/host" assert result["path"] == "/" assert result["query"] == "host" assert result["url"] == "http://invalid/host" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # just all out invalid assert utils.parse.parse_url("?") is None assert utils.parse.parse_url("/") is None # Test some illegal strings result = utils.parse.parse_url(object, verify_host=False) assert result is None result = utils.parse.parse_url(None, verify_host=False) assert result is None # Just a schema; invalid host result = utils.parse.parse_url("test://") assert result is None # Do it again without host validation result = utils.parse.parse_url("test://", verify_host=False) assert result["schema"] == "test" # It's worth noting that the hostname is an empty string and is NEVER set # to None if it wasn't specified. assert result["host"] == "" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None assert result["url"] == "test://" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url("testhostname") assert result["schema"] == "http" assert result["host"] == "testhostname" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None # The default_schema kicks in here assert result["url"] == "http://testhostname" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url("example.com", default_schema="unknown") assert result["schema"] == "unknown" assert result["host"] == "example.com" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None # The default_schema kicks in here assert result["url"] == "unknown://example.com" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # An empty string without a hostame is still valid if verify_host is set result = utils.parse.parse_url("", verify_host=False) assert result["schema"] == "http" assert result["host"] == "" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] is None assert result["path"] is None assert result["query"] is None # The default_schema kicks in here assert result["url"] == "http://" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # A messed up URL result = utils.parse.parse_url("test://:@/", verify_host=False) assert result["schema"] == "test" assert result["host"] == "" assert result["port"] is None assert result["user"] == "" assert result["password"] == "" assert result["fullpath"] == "/" assert result["path"] == "/" assert result["query"] is None assert result["url"] == "test://:@/" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} result = utils.parse.parse_url( "crazy://:@//_/@^&/jack.json", verify_host=False ) assert result["schema"] == "crazy" assert result["host"] == "" assert result["port"] is None assert result["user"] == "" assert result["password"] == "" assert unquote(result["fullpath"]) == "/_/@^&/jack.json" assert unquote(result["path"]) == "/_/@^&/" assert result["query"] == "jack.json" assert unquote(result["url"]) == "crazy://:@/_/@^&/jack.json" assert result["qsd"] == {} assert result["qsd-"] == {} assert result["qsd+"] == {} assert result["qsd:"] == {} # Sanitizing result = utils.parse.parse_url( "hTTp://hostname/?+KeY=ValueA&-kEy=ValueB&KEY=Value%20+C&:cOlON=YeS", sanitize=False, ) assert len(result["qsd-"]) == 1 assert len(result["qsd+"]) == 1 assert len(result["qsd"]) == 4 assert len(result["qsd:"]) == 1 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] == "/" assert result["path"] == "/" assert result["query"] is None assert result["url"] == "http://hostname/" assert "+KeY" in result["qsd"] assert "-kEy" in result["qsd"] assert ":cOlON" in result["qsd"] assert result["qsd:"]["cOlON"] == "YeS" assert "key" not in result["qsd"] assert "KeY" in result["qsd+"] assert result["qsd+"]["KeY"] == "ValueA" assert "kEy" in result["qsd-"] assert result["qsd-"]["kEy"] == "ValueB" assert result["qsd"]["KEY"] == "Value +C" assert result["qsd"]["+KeY"] == result["qsd+"]["KeY"] assert result["qsd"]["-kEy"] == result["qsd-"]["kEy"] # Testing Defect 1264 - whitespaces in url result = utils.parse.parse_url( "posts://example.com/my endpoint?-token=ab cdefg" ) assert len(result["qsd-"]) == 1 assert len(result["qsd+"]) == 0 assert len(result["qsd"]) == 1 assert len(result["qsd:"]) == 0 assert result["schema"] == "posts" assert result["host"] == "example.com" assert result["port"] is None assert result["user"] is None assert result["password"] is None assert result["fullpath"] == "/my%20endpoint" assert result["path"] == "/" assert result["query"] == "my%20endpoint" assert result["url"] == "posts://example.com/my%20endpoint" assert "-token" in result["qsd"] assert result["qsd-"]["token"] == "ab cdefg" # Testing Defect 1672 - reserved characters in URL paths url = "https://cdn.jsdelivr.net/gh/Cleanuparr/Cleanuparr@main/Logo/256.png" result = utils.parse.parse_url(url) assert result["fullpath"] == "/gh/Cleanuparr/Cleanuparr@main/Logo/256.png" assert result["url"] == url # RFC 3986 path characters must survive a parse_url() round trip because # some services distinguish literal characters from encoded ones. url = "https://example.com/a:b@c!d$e&f'g(h)i*j+k=n/file.txt" result = utils.parse.parse_url(url) assert result["fullpath"] == "/a:b@c!d$e&f'g(h)i*j+k=n/file.txt" assert result["url"] == url # Keep commas and semicolons encoded because Apprise uses them as path/list # delimiters. Otherwise path data such as a Home Assistant target list # could become extra segments. Related regressions have dedicated plugin # tests. result = utils.parse.parse_url("https://example.com/a,b;c/file.txt") assert result["fullpath"] == "/a%2Cb%3Bc/file.txt" # Characters outside an RFC 3986 path segment must remain encoded when the # safe list is expanded. result = utils.parse.parse_url("https://example.com/a b/c[1]#/file.txt") assert "%20" in result["fullpath"] assert "[" not in result["fullpath"] assert "]" not in result["fullpath"] # An encoded slash belongs to its segment and must not become a # separator, while a literal slash still splits the path. result = utils.parse.parse_url( "https://example.com/api/v4/projects/my%2Frepo/file%2fname" ) assert result["fullpath"] == "/api/v4/projects/my%2Frepo/file%2Fname" assert result["query"] == "file%2Fname" def test_parse_url_markdown_content(): """ utils: parse_url() with unmatched-bracket content (GH#1693) """ # Content whose host portion carries an unmatched '[' (for example from # Markdown link syntax) makes the underlying urlparse() raise # "Invalid IPv6 URL". parse_url() documents that it returns None when # content cannot be extracted, so that ValueError must not propagate. assert ( utils.parse.parse_url("**bold** and [link](https://example.com)") is None ) assert utils.parse.parse_url("text with [unclosed bracket") is None def test_parse_url_path_no_redos(): "utils: parse_url() does not regress into slow/backtracking behavior" # Escaping uses linear-time table lookups. This guards against a future # regex-based implementation backtracking on untrusted paths. segment = "%40%3A%21%24%26%27%28%29%2A%2B%2C%3B%3D-a" * 5000 url = f"https://example.com/{segment}/file.txt?foo=bar" start = time.time() result = utils.parse.parse_url(url) elapsed = time.time() - start assert result is not None # The generous limit allows slower CI while catching nonlinear behavior. assert elapsed < 5.0 def test_parse_url_simple(): "utils: parse_url() testing" result = utils.parse.parse_url("http://hostname", simple=True) assert len(result) == 3 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["url"] == "http://hostname" result = utils.parse.parse_url("http://hostname/", simple=True) assert len(result) == 5 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["fullpath"] == "/" assert result["path"] == "/" assert result["url"] == "http://hostname/" # colon after hostname without port number is no good assert utils.parse.parse_url("http://hostname:", simple=True) is None # An invalid port result = utils.parse.parse_url( "http://hostname:invalid", verify_host=False, strict_port=True, simple=True, ) assert len(result) == 4 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == "invalid" assert result["url"] == "http://hostname:invalid" # However if we don't verify the host, it is okay result = utils.parse.parse_url( "http://hostname:", verify_host=False, simple=True ) assert len(result) == 3 assert result["schema"] == "http" assert result["host"] == "hostname:" assert result["url"] == "http://hostname:" # A port of Zero is not valid with strict port checking assert ( utils.parse.parse_url( "http://hostname:0", strict_port=True, simple=True ) is None ) # Without strict port checking however, it is okay result = utils.parse.parse_url( "http://hostname:0", strict_port=False, simple=True ) assert len(result) == 4 assert result["schema"] == "http" assert result["port"] == 0 assert result["host"] == "hostname" assert result["url"] == "http://hostname:0" # A negative port is not valid assert ( utils.parse.parse_url( "http://hostname:-92", strict_port=True, simple=True ) is None ) result = utils.parse.parse_url( "http://hostname:-92", verify_host=False, strict_port=True, simple=True ) assert len(result) == 4 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == -92 assert result["url"] == "http://hostname:-92" # A port that is too large is not valid assert ( utils.parse.parse_url( "http://hostname:65536", strict_port=True, simple=True ) is None ) # This is an accetable port (the maximum) result = utils.parse.parse_url( "http://hostname:65535", strict_port=True, simple=True ) assert len(result) == 4 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == 65535 assert result["url"] == "http://hostname:65535" # This is an accetable port (the maximum) result = utils.parse.parse_url( "http://hostname:1", strict_port=True, simple=True ) assert len(result) == 4 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == 1 assert result["url"] == "http://hostname:1" # A port that was identfied as a string is invalid assert ( utils.parse.parse_url( "http://hostname:invalid", strict_port=True, simple=True ) is None ) result = utils.parse.parse_url( "http://hostname:invalid", verify_host=False, strict_port=True, simple=True, ) assert len(result) == 4 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == "invalid" assert result["url"] == "http://hostname:invalid" result = utils.parse.parse_url( "http://hostname:invalid", verify_host=False, strict_port=False, simple=True, ) assert len(result) == 3 assert result["schema"] == "http" assert result["host"] == "hostname:invalid" assert result["url"] == "http://hostname:invalid" result = utils.parse.parse_url( "http://hostname:invalid?key=value&-minuskey=mvalue", verify_host=False, strict_port=False, simple=True, ) assert len(result) == 4 assert result["schema"] == "http" assert result["host"] == "hostname:invalid" assert result["url"] == "http://hostname:invalid" assert isinstance(result["qsd"], dict) assert len(result["qsd"]) == 2 assert unquote(result["qsd"]["-minuskey"]) == "mvalue" assert unquote(result["qsd"]["key"]) == "value" # Handling of floats assert ( utils.parse.parse_url( "http://hostname:4.2", strict_port=True, simple=True ) is None ) result = utils.parse.parse_url( "http://hostname:4.2", verify_host=False, strict_port=True, simple=True ) assert len(result) == 4 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == "4.2" assert result["url"] == "http://hostname:4.2" # A Port of zero is not acceptable for a regular hostname assert ( utils.parse.parse_url( "http://hostname:0", strict_port=True, simple=True ) is None ) # No host verification (zero is an acceptable port when this is the case result = utils.parse.parse_url( "http://hostname:0", verify_host=False, simple=True ) assert len(result) == 4 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == 0 assert result["url"] == "http://hostname:0" result = utils.parse.parse_url( "http://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8080", verify_host=False, simple=True, ) assert len(result) == 4 assert result["schema"] == "http" assert result["host"] == "[2001:db8:002a:3256:adfe:05c0:0003:0006]" assert result["port"] == 8080 assert ( result["url"] == "http://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8080" ) result = utils.parse.parse_url( "http://hostname:0", verify_host=False, strict_port=True, simple=True ) assert len(result) == 4 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == 0 assert result["url"] == "http://hostname:0" result = utils.parse.parse_url("http://hostname/?-KeY=Value", simple=True) assert len(result) == 6 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["fullpath"] == "/" assert result["path"] == "/" assert result["url"] == "http://hostname/" assert "-key" in result["qsd"] assert unquote(result["qsd"]["-key"]) == "Value" result = utils.parse.parse_url("http://hostname/?+KeY=Value", simple=True) assert len(result) == 6 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["fullpath"] == "/" assert result["path"] == "/" assert result["url"] == "http://hostname/" assert "+key" in result["qsd"] assert result["qsd"]["+key"] == "Value" result = utils.parse.parse_url("http://hostname/?:kEy=vALUE", simple=True) assert len(result) == 6 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["fullpath"] == "/" assert result["path"] == "/" assert result["url"] == "http://hostname/" assert ":key" in result["qsd"] assert result["qsd"][":key"] == "vALUE" result = utils.parse.parse_url( "http://hostname/?+KeY=ValueA&-kEy=ValueB&KEY=Value%20+C&:colon=y", simple=True, ) assert len(result) == 6 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["fullpath"] == "/" assert result["path"] == "/" assert result["url"] == "http://hostname/" assert "+key" in result["qsd"] assert "-key" in result["qsd"] assert ":colon" in result["qsd"] assert result["qsd"][":colon"] == "y" assert result["qsd"]["key"] == "Value +C" assert result["qsd"]["+key"] == "ValueA" assert result["qsd"]["-key"] == "ValueB" result = utils.parse.parse_url("http://hostname////", simple=True) assert len(result) == 5 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["fullpath"] == "/" assert result["path"] == "/" assert result["url"] == "http://hostname/" result = utils.parse.parse_url("http://hostname:40////", simple=True) assert len(result) == 6 assert result["schema"] == "http" assert result["host"] == "hostname" assert result["port"] == 40 assert result["fullpath"] == "/" assert result["path"] == "/" assert result["url"] == "http://hostname:40/" result = utils.parse.parse_url("HTTP://HoStNaMe:40/test.php", simple=True) assert len(result) == 7 assert result["schema"] == "http" assert result["host"] == "HoStNaMe" assert result["port"] == 40 assert result["fullpath"] == "/test.php" assert result["path"] == "/" assert result["query"] == "test.php" assert result["url"] == "http://HoStNaMe:40/test.php" result = utils.parse.parse_url( "HTTPS://user@hostname/test.py", simple=True ) assert len(result) == 7 assert result["schema"] == "https" assert result["host"] == "hostname" assert result["user"] == "user" assert result["fullpath"] == "/test.py" assert result["path"] == "/" assert result["query"] == "test.py" assert result["url"] == "https://user@hostname/test.py" result = utils.parse.parse_url( " HTTPS://///user@@@hostname///test.py ", simple=True ) assert len(result) == 7 assert result["schema"] == "https" assert result["host"] == "hostname" assert result["user"] == "user" assert result["fullpath"] == "/test.py" assert result["path"] == "/" assert result["query"] == "test.py" assert result["url"] == "https://user@hostname/test.py" result = utils.parse.parse_url( "HTTPS://user:password@otherHost/full///path/name/", simple=True, ) assert len(result) == 7 assert result["schema"] == "https" assert result["host"] == "otherHost" assert result["user"] == "user" assert result["password"] == "password" assert result["fullpath"] == "/full/path/name/" assert result["path"] == "/full/path/name/" assert result["url"] == "https://user:password@otherHost/full/path/name/" # Handle garbage assert utils.parse.parse_url(None) is None result = utils.parse.parse_url( "mailto://user:password@otherHost/lead2gold@gmail.com" + "?from=test@test.com&name=Chris%20Caron&format=text", simple=True, ) assert len(result) == 9 assert result["schema"] == "mailto" assert result["host"] == "otherHost" assert result["user"] == "user" assert result["password"] == "password" assert unquote(result["fullpath"]) == "/lead2gold@gmail.com" assert result["path"] == "/" assert unquote(result["query"]) == "lead2gold@gmail.com" assert ( unquote(result["url"]) == "mailto://user:password@otherHost/lead2gold@gmail.com" ) assert len(result["qsd"]) == 3 assert "name" in result["qsd"] assert unquote(result["qsd"]["name"]) == "Chris Caron" assert "from" in result["qsd"] assert unquote(result["qsd"]["from"]) == "test@test.com" assert "format" in result["qsd"] assert unquote(result["qsd"]["format"]) == "text" # Test Passwords with question marks ?; not supported result = utils.parse.parse_url( "http://user:pass.with.?question@host", simple=True ) assert result is None # just hostnames result = utils.parse.parse_url( "nuxref.com", simple=True, ) assert len(result) == 3 assert result["schema"] == "http" assert result["host"] == "nuxref.com" assert result["url"] == "http://nuxref.com" # just host and path result = utils.parse.parse_url("invalid/host", simple=True) assert len(result) == 6 assert result["schema"] == "http" assert result["host"] == "invalid" assert result["fullpath"] == "/host" assert result["path"] == "/" assert result["query"] == "host" assert result["url"] == "http://invalid/host" # just all out invalid assert utils.parse.parse_url("?", simple=True) is None assert utils.parse.parse_url("/", simple=True) is None # Test some illegal strings result = utils.parse.parse_url(object, verify_host=False, simple=True) assert result is None result = utils.parse.parse_url(None, verify_host=False, simple=True) assert result is None # Just a schema; invalid host result = utils.parse.parse_url("test://", simple=True) assert result is None # Do it again without host validation result = utils.parse.parse_url("test://", verify_host=False, simple=True) assert len(result) == 2 assert result["schema"] == "test" assert result["url"] == "test://" result = utils.parse.parse_url("testhostname", simple=True) assert len(result) == 3 assert result["schema"] == "http" assert result["host"] == "testhostname" # The default_schema kicks in here assert result["url"] == "http://testhostname" result = utils.parse.parse_url( "example.com", default_schema="unknown", simple=True ) assert len(result) == 3 assert result["schema"] == "unknown" assert result["host"] == "example.com" # The default_schema kicks in here assert result["url"] == "unknown://example.com" # An empty string without a hostame is still valid if verify_host is set result = utils.parse.parse_url("", verify_host=False, simple=True) assert len(result) == 2 assert result["schema"] == "http" # The default_schema kicks in here assert result["url"] == "http://" # A messed up URL result = utils.parse.parse_url( "test://:@/", verify_host=False, simple=True ) assert len(result) == 6 assert result["schema"] == "test" assert result["user"] == "" assert result["password"] == "" assert result["fullpath"] == "/" assert result["path"] == "/" assert result["url"] == "test://:@/" result = utils.parse.parse_url( "crazy://:@//_/@^&/jack.json", verify_host=False, simple=True ) assert len(result) == 7 assert result["schema"] == "crazy" assert result["user"] == "" assert result["password"] == "" assert unquote(result["fullpath"]) == "/_/@^&/jack.json" assert unquote(result["path"]) == "/_/@^&/" assert result["query"] == "jack.json" assert unquote(result["url"]) == "crazy://:@/_/@^&/jack.json" def test_url_assembly(): """ "utils: url_assembly() testing.""" url = "schema://user:password@hostname:port/path/?key=value" assert ( utils.parse.url_assembly( **utils.parse.parse_url(url, verify_host=False) ) == url ) # Same URL without trailing slash after path url = "schema://user:password@hostname:port/path?key=value" assert ( utils.parse.url_assembly( **utils.parse.parse_url(url, verify_host=False) ) == url ) url = "schema://user@hostname:port/path?key=value" assert ( utils.parse.url_assembly( **utils.parse.parse_url(url, verify_host=False) ) == url ) url = "schema://hostname:10/a/file.php" assert ( utils.parse.url_assembly( **utils.parse.parse_url(url, verify_host=False) ) == url ) # When spaces and special characters are introduced, the URL # is hard to mimic what was entered. Instead it is normalized url = ( "schema://hostname:10/a space/file.php?" "arg=a+space&arg2=a%20space&arg3=a space" ) assert ( utils.parse.url_assembly( **utils.parse.parse_url(url, verify_host=False) ) == "schema://hostname:10/a%20space/file.php?" "arg=a%2Bspace&arg2=a%20space&arg3=a%20space" ) # encode=True should only be used if you're passing in un-assembled # content... hence the following is likely not what is expected: assert ( utils.parse.url_assembly( **utils.parse.parse_url(url, verify_host=False), encode=True ) == "schema://hostname:10/a%2520space/file.php?" "arg=a%2Bspace&arg2=a%20space&arg3=a%20space" ) # But the following utilizes the encode=True and produces the # desired effects: content = { "host": "hostname", # Note that fullpath requires escaping in this case "fullpath": "/a space/file.php", "path": "/a space/", "query": "file.php", "schema": "schema", # our query arguments also require escaping as well "qsd": {"arg": "a+space", "arg2": "a space", "arg3": "a space"}, } assert ( utils.parse.url_assembly(**content, encode=True) == "schema://hostname/a%20space/file.php?" "arg=a%2Bspace&arg2=a%20space&arg3=a%20space" ) def test_parse_bool(): "utils: parse_bool() testing" assert utils.parse.parse_bool("Enabled", None) is True assert utils.parse.parse_bool("Disabled", None) is False assert utils.parse.parse_bool("Allow", None) is True assert utils.parse.parse_bool("Deny", None) is False assert utils.parse.parse_bool("Yes", None) is True assert utils.parse.parse_bool("YES", None) is True assert utils.parse.parse_bool("Always", None) is True assert utils.parse.parse_bool("No", None) is False assert utils.parse.parse_bool("NO", None) is False assert utils.parse.parse_bool("NEVER", None) is False assert utils.parse.parse_bool("TrUE", None) is True assert utils.parse.parse_bool("tRUe", None) is True assert utils.parse.parse_bool("FAlse", None) is False assert utils.parse.parse_bool("F", None) is False assert utils.parse.parse_bool("T", None) is True assert utils.parse.parse_bool("0", None) is False assert utils.parse.parse_bool("1", None) is True assert utils.parse.parse_bool("True", None) is True assert utils.parse.parse_bool("Yes", None) is True assert utils.parse.parse_bool(1, None) is True assert utils.parse.parse_bool(0, None) is False assert utils.parse.parse_bool(True, None) is True assert utils.parse.parse_bool(False, None) is False # only the int of 0 will return False since the function # casts this to a boolean assert utils.parse.parse_bool(2, None) is True # An empty list is still false assert utils.parse.parse_bool([], None) is False # But a list that contains something is True assert ( utils.parse.parse_bool( [ "value", ], None, ) is True ) # Use Default (which is False) assert utils.parse.parse_bool("OhYeah") is False # Adjust Default and get a different result assert utils.parse.parse_bool("OhYeah", True) is True def test_is_uuid(): """ API: is_uuid() function """ # Invalid Entries assert utils.parse.is_uuid("invalid") is False assert utils.parse.is_uuid(None) is False assert utils.parse.is_uuid(5) is False assert utils.parse.is_uuid(object) is False # A slightly invalid uuid4 entry assert utils.parse.is_uuid("591ed387-fa65-ac97-9712-b9d2a15e42a9") is False assert utils.parse.is_uuid("591ed387-fa65-Jc97-9712-b9d2a15e42a9") is False # Valid UUID4 Entries assert utils.parse.is_uuid("591ed387-fa65-4c97-9712-b9d2a15e42a9") is True assert utils.parse.is_uuid("32b0b447-fe84-4df1-8368-81925e729265") is True def test_is_hostname(): """ API: is_hostname() function """ # Valid Hostnames assert utils.parse.is_hostname("yahoo.ca") == "yahoo.ca" assert utils.parse.is_hostname("yahoo.ca.") == "yahoo.ca" assert ( utils.parse.is_hostname("valid-dashes-in-host.ca") == "valid-dashes-in-host.ca" ) assert ( utils.parse.is_hostname("valid-underscores_in_host.ca") == "valid-underscores_in_host.ca" ) # Underscores are supported by default assert ( utils.parse.is_hostname("valid_dashes_in_host.ca") == "valid_dashes_in_host.ca" ) # However they are not if specified otherwise: assert ( utils.parse.is_hostname("valid_dashes_in_host.ca", underscore=False) is False ) # Invalid Hostnames assert ( utils.parse.is_hostname("-hostname.that.starts.with.a.dash") is False ) assert utils.parse.is_hostname("invalid-characters_#^.ca") is False assert utils.parse.is_hostname(" spaces ") is False assert utils.parse.is_hostname(" ") is False assert utils.parse.is_hostname("") is False # Valid IPv4 Addresses assert utils.parse.is_hostname("127.0.0.1") == "127.0.0.1" assert utils.parse.is_hostname("0.0.0.0") == "0.0.0.0" assert utils.parse.is_hostname("255.255.255.255") == "255.255.255.255" # But not if we're not checking for this: assert utils.parse.is_hostname("127.0.0.1", ipv4=False) is False assert utils.parse.is_hostname("0.0.0.0", ipv4=False) is False assert utils.parse.is_hostname("255.255.255.255", ipv4=False) is False # Invalid IPv4 Addresses assert utils.parse.is_hostname("1.2.3") is False assert utils.parse.is_hostname("256.256.256.256") is False assert utils.parse.is_hostname("999.0.0.0") is False assert utils.parse.is_hostname("1.2.3.4.5") is False assert utils.parse.is_hostname(" 127.0.0.1 ") is False assert utils.parse.is_hostname(" ") is False assert utils.parse.is_hostname("") is False # Valid IPv6 Addresses (square brakets supported for URL construction) assert ( utils.parse.is_hostname("[2001:0db8:85a3:0000:0000:8a2e:0370:7334]") == "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]" ) assert ( utils.parse.is_hostname("2001:0db8:85a3:0000:0000:8a2e:0370:7334") == "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]" ) assert ( utils.parse.is_hostname("[2001:db8:002a:3256:adfe:05c0:0003:0006]") == "[2001:db8:002a:3256:adfe:05c0:0003:0006]" ) # localhost assert utils.parse.is_hostname("::1") == "[::1]" assert utils.parse.is_hostname("0:0:0:0:0:0:0:1") == "[0:0:0:0:0:0:0:1]" # compressed addresses assert utils.parse.is_hostname("2001:db8::1") == "[2001:db8::1]" assert utils.parse.is_hostname("[2001:db8::1]") == "[2001:db8::1]" assert utils.parse.is_hostname("fd00::5") == "[fd00::5]" assert utils.parse.is_hostname("[fd00::5]") == "[fd00::5]" # A malformed address is refused rather than partially accepted assert utils.parse.is_hostname("2001:db8::1::2") is False assert utils.parse.is_hostname("[2001:db8::1") is False # But not if we're not checking for this: assert ( utils.parse.is_hostname( "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]", ipv6=False ) is False ) assert ( utils.parse.is_hostname( "2001:0db8:85a3:0000:0000:8a2e:0370:7334", ipv6=False ) is False ) # Test hostnames with a single character hostname assert ( utils.parse.is_hostname("cloud.a.example.com", ipv4=False, ipv6=False) == "cloud.a.example.com" ) def test_is_ipaddr(): """ API: is_ipaddr() function """ # Valid IPv4 Addresses assert utils.parse.is_ipaddr("127.0.0.1") == "127.0.0.1" assert utils.parse.is_ipaddr("0.0.0.0") == "0.0.0.0" assert utils.parse.is_ipaddr("255.255.255.255") == "255.255.255.255" # Invalid IPv4 Addresses assert utils.parse.is_ipaddr("1.2.3") is False assert utils.parse.is_ipaddr("256.256.256.256") is False assert utils.parse.is_ipaddr("999.0.0.0") is False assert utils.parse.is_ipaddr("1.2.3.4.5") is False assert utils.parse.is_ipaddr(" 127.0.0.1 ") is False assert utils.parse.is_ipaddr(" ") is False assert utils.parse.is_ipaddr("") is False # Valid IPv6 Addresses (square brakets supported for URL construction) assert ( utils.parse.is_ipaddr("[2001:0db8:85a3:0000:0000:8a2e:0370:7334]") == "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]" ) assert ( utils.parse.is_ipaddr("2001:0db8:85a3:0000:0000:8a2e:0370:7334") == "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]" ) assert ( utils.parse.is_ipaddr("[2001:db8:002a:3256:adfe:05c0:0003:0006]") == "[2001:db8:002a:3256:adfe:05c0:0003:0006]" ) # localhost assert utils.parse.is_ipaddr("::1") == "[::1]" assert utils.parse.is_ipaddr("0:0:0:0:0:0:0:1") == "[0:0:0:0:0:0:0:1]" # Compressed addresses assert utils.parse.is_ipaddr("2001:db8::1") == "[2001:db8::1]" assert utils.parse.is_ipaddr("[2001:db8::1]") == "[2001:db8::1]" assert utils.parse.is_ipaddr("fd00::5") == "[fd00::5]" assert utils.parse.is_ipaddr("fe80::1") == "[fe80::1]" assert utils.parse.is_ipaddr("::") == "[::]" assert utils.parse.is_ipaddr("1::") == "[1::]" assert utils.parse.is_ipaddr("1::8") == "[1::8]" assert utils.parse.is_ipaddr("2001:db8::8a2e:370:7334") == ( "[2001:db8::8a2e:370:7334]" ) assert utils.parse.is_ipaddr("2001:db8:85a3::8a2e:370:7334") == ( "[2001:db8:85a3::8a2e:370:7334]" ) assert utils.parse.is_ipaddr("2001:db8:0:0:1::1") == ( "[2001:db8:0:0:1::1]" ) # IPv4 mapped and embedded forms assert utils.parse.is_ipaddr("::ffff:192.0.2.1") == "[::ffff:192.0.2.1]" assert utils.parse.is_ipaddr("::192.0.2.1") == "[::192.0.2.1]" assert utils.parse.is_ipaddr("2001:db8::192.0.2.1") == ( "[2001:db8::192.0.2.1]" ) # A link local address may carry a zone id assert utils.parse.is_ipaddr("fe80::1%eth0") == "[fe80::1%eth0]" assert utils.parse.is_ipaddr("[fe80::1%eth0]") == "[fe80::1%eth0]" # Invalid IPv6 Addresses; more than one :: is not allowed assert utils.parse.is_ipaddr("2001:db8::1::2") is False assert utils.parse.is_ipaddr("::1::") is False # Groups longer than four hex digits, or that are not hex at all assert utils.parse.is_ipaddr("2001:db8::12345") is False assert utils.parse.is_ipaddr("not:an:address") is False assert utils.parse.is_ipaddr("2001:db8::xyz") is False # Too many groups assert utils.parse.is_ipaddr("1:2:3:4:5:6:7:8:9") is False # Trailing and leading junk is not quietly dropped assert utils.parse.is_ipaddr("2001:db8::1junk") is False assert utils.parse.is_ipaddr("junk2001:db8::1") is False assert utils.parse.is_ipaddr("2001:db8::1 ") is False assert utils.parse.is_ipaddr(" 2001:db8::1") is False # Brackets have to come in pairs assert utils.parse.is_ipaddr("[2001:db8::1") is False assert utils.parse.is_ipaddr("2001:db8::1]") is False assert utils.parse.is_ipaddr("[::1") is False assert utils.parse.is_ipaddr("::1]") is False # An IPv6 address is not accepted when we are not looking for one assert utils.parse.is_ipaddr("2001:db8::1", ipv6=False) is False assert utils.parse.is_ipaddr("fd00::5", ipv6=False) is False # ...and likewise for IPv4 assert utils.parse.is_ipaddr("127.0.0.1", ipv4=False) is False # An IPv4 address is not an IPv6 one, brackets or not assert utils.parse.is_ipaddr("[127.0.0.1]") is False def test_parse_url_ipv6_host(): """ API: parse_url() IPv6 host handling """ # Compressed addresses result = utils.parse.parse_url("https://[2001:db8::1]/path") assert result["host"] == "[2001:db8::1]" assert result["port"] is None assert result["url"] == "https://[2001:db8::1]/path" # ...and the port is kept separate from the address result = utils.parse.parse_url("https://[2001:db8::1]:8443/path") assert result["host"] == "[2001:db8::1]" assert result["port"] == 8443 assert result["url"] == "https://[2001:db8::1]:8443/path" # An address written without brackets and without a port also works result = utils.parse.parse_url("https://fd00::5/path") assert result["host"] == "[fd00::5]" assert result["port"] is None assert result["url"] == "https://[fd00::5]/path" # A full length address is unchanged result = utils.parse.parse_url( "https://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:443/x" ) assert result["host"] == "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]" assert result["port"] == 443 # Credentials sit alongside an IPv6 host without disturbing it result = utils.parse.parse_url("https://user:pass@[fd00::5]:8443/path") assert result["host"] == "[fd00::5]" assert result["port"] == 8443 assert result["user"] == "user" assert result["password"] == "pass" # A malformed bracketed address is refused. assert utils.parse.parse_url("https://[2001:db8::1::2]/x") is None assert ( utils.parse.parse_url("https://[2001:db8::1::2]/x", verify_host=False) is None ) # An address written without brackets assert utils.parse.parse_url("https://2001:db8::1::2/x") is None result = utils.parse.parse_url( "https://2001:db8::1::2/x", verify_host=False ) assert result["host"] == "2001:db8::1::2" def test_parse_url_ipv6_round_trip(): """ API: parse_url() IPv6 addresses survive a round trip """ # ipv6 host checks for host in ( "[2001:db8::1]", "[fd00::5]", "[::1]", "[2001:db8:85a3::8a2e:370:7334]", "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]", ): url = f"https://{host}:8443/path" first = utils.parse.parse_url(url) assert first["host"] == host second = utils.parse.parse_url(first["url"]) assert second["host"] == host assert second["url"] == first["url"] def test_is_email(): """ API: is_email() function """ # Valid Emails results = utils.parse.is_email("test@gmail.com") assert results["name"] == "" assert results["email"] == "test@gmail.com" assert results["full_email"] == "test@gmail.com" assert results["domain"] == "gmail.com" assert results["user"] == "test" assert results["label"] == "" results = utils.parse.is_email("test@my-valid_host.com") assert results["name"] == "" assert results["email"] == "test@my-valid_host.com" assert results["full_email"] == "test@my-valid_host.com" assert results["domain"] == "my-valid_host.com" assert results["user"] == "test" assert results["label"] == "" results = utils.parse.is_email("tag+test@gmail.com") assert results["name"] == "" assert results["email"] == "test@gmail.com" assert results["full_email"] == "tag+test@gmail.com" assert results["domain"] == "gmail.com" assert results["user"] == "test" assert results["label"] == "tag" # Support Full Names as well results = utils.parse.is_email("Bill Gates: bgates@microsoft.com") assert results["name"] == "Bill Gates" assert results["email"] == "bgates@microsoft.com" assert results["full_email"] == "bgates@microsoft.com" assert results["domain"] == "microsoft.com" assert results["user"] == "bgates" assert results["label"] == "" results = utils.parse.is_email("Bill Gates ") assert results["name"] == "Bill Gates" assert results["email"] == "bgates@microsoft.com" assert results["full_email"] == "bgates@microsoft.com" assert results["domain"] == "microsoft.com" assert results["user"] == "bgates" assert results["label"] == "" results = utils.parse.is_email("Bill Gates: ") assert results["name"] == "Bill Gates" assert results["email"] == "bgates@microsoft.com" assert results["full_email"] == "bgates@microsoft.com" assert results["domain"] == "microsoft.com" assert results["user"] == "bgates" assert results["label"] == "" results = utils.parse.is_email("Sundar Pichai ") assert results["name"] == "Sundar Pichai" assert results["email"] == "spichai@gmail.com" assert results["full_email"] == "ceo+spichai@gmail.com" assert results["domain"] == "gmail.com" assert results["user"] == "spichai" assert results["label"] == "ceo" # Support Quotes results = utils.parse.is_email('"Chris Hemsworth" ') assert results["name"] == "Chris Hemsworth" assert results["email"] == "ch@test.com" assert results["full_email"] == "ch@test.com" assert results["domain"] == "test.com" assert results["user"] == "ch" assert results["label"] == "" # An email without name, but contains delimiters results = utils.parse.is_email(" ") assert results["name"] == "" assert results["email"] == "spichai@gmail.com" assert results["full_email"] == "spichai@gmail.com" assert results["domain"] == "gmail.com" assert results["user"] == "spichai" assert results["label"] == "" # a valid email not properly delimited with a colon or angle bracket # We do a best guess and still parse it correctly results = utils.parse.is_email("Name valid@example.com") assert results["name"] == "Name" assert results["email"] == "valid@example.com" assert results["full_email"] == "valid@example.com" assert results["domain"] == "example.com" assert results["user"] == "valid" assert results["label"] == "" # a valid email not properly delimited with a colon or angle bracket # We do a best guess and still parse it correctly results = utils.parse.is_email("Ру�лан Эра russian+russia@example.ru") assert results["name"] == "Ру�лан Эра" assert results["email"] == "russia@example.ru" assert results["full_email"] == "russian+russia@example.ru" assert results["domain"] == "example.ru" assert results["user"] == "russia" assert results["label"] == "russian" # Invalid Emails assert utils.parse.is_email("invalid.com") is False assert utils.parse.is_email(object()) is False assert utils.parse.is_email(None) is False assert utils.parse.is_email("Just A Name") is False assert utils.parse.is_email("Name ") is False # Extended valid emails # # The first + denotes our label, so this test really validates # that there is a correct split and parsing of our email results = utils.parse.is_email("a-z0-9_!#$%&*+/=?%`{|}~^.-@gmail.com") assert results["name"] == "" assert results["label"] == "a-z0-9_!#$%&*" assert results["email"] == "/=?%`{|}~^.-@gmail.com" assert results["full_email"] == "a-z0-9_!#$%&*+/=?%`{|}~^.-@gmail.com" assert results["domain"] == "gmail.com" assert results["user"] == "/=?%`{|}~^.-" # A similar test without '+' (use of a label) # The first + denotes our label, so this test really validates # that there is a correct split and parsing of our email results = utils.parse.is_email("a-z0-9_!#$%&*/=?%`{|}~^.-@gmail.com") assert results["name"] == "" assert results["label"] == "" assert results["email"] == "a-z0-9_!#$%&*/=?%`{|}~^.-@gmail.com" assert results["full_email"] == "a-z0-9_!#$%&*/=?%`{|}~^.-@gmail.com" assert results["domain"] == "gmail.com" assert results["user"] == "a-z0-9_!#$%&*/=?%`{|}~^.-" def test_is_domain_service_target(): """ API: is_domain_service_target() function """ # Invalid information assert utils.parse.is_domain_service_target(None) is False assert utils.parse.is_domain_service_target(42) is False assert utils.parse.is_domain_service_target(object) is False assert utils.parse.is_domain_service_target("") is False assert utils.parse.is_domain_service_target("+()") is False assert utils.parse.is_domain_service_target("+") is False # Valid entries result = utils.parse.is_domain_service_target("service") assert isinstance(result, dict) assert result["service"] == "service" # Default domain assert result["domain"] == "notify" assert isinstance(result["targets"], list) assert len(result["targets"]) == 0 result = utils.parse.is_domain_service_target("domain.service") assert isinstance(result, dict) assert result["service"] == "service" assert result["domain"] == "domain" assert isinstance(result["targets"], list) assert len(result["targets"]) == 0 result = utils.parse.is_domain_service_target("domain.service:target") assert isinstance(result, dict) assert result["service"] == "service" assert result["domain"] == "domain" assert isinstance(result["targets"], list) assert len(result["targets"]) == 1 assert result["targets"][0] == "target" result = utils.parse.is_domain_service_target("domain.service:t1,t2,t3") assert isinstance(result, dict) assert result["service"] == "service" assert result["domain"] == "domain" assert isinstance(result["targets"], list) assert len(result["targets"]) == 3 assert "t1" in result["targets"] assert "t2" in result["targets"] assert "t3" in result["targets"] result = utils.parse.is_domain_service_target( "service:t1,t2,t3", domain="new_default" ) assert isinstance(result, dict) assert result["service"] == "service" # Default domain assert result["domain"] == "new_default" assert isinstance(result["targets"], list) assert len(result["targets"]) == 3 assert "t1" in result["targets"] assert "t2" in result["targets"] assert "t3" in result["targets"] def test_is_call_sign_no(): """ API: is_call_sign() function """ # Invalid numbers assert utils.parse.is_call_sign(None) is False assert utils.parse.is_call_sign(42) is False assert utils.parse.is_call_sign(object) is False assert utils.parse.is_call_sign("") is False assert utils.parse.is_call_sign("1") is False assert utils.parse.is_call_sign("12") is False assert utils.parse.is_call_sign("abc") is False assert utils.parse.is_call_sign("+()") is False assert utils.parse.is_call_sign("+") is False assert utils.parse.is_call_sign(None) is False assert utils.parse.is_call_sign(42) is False # To short or 2 long assert utils.parse.is_call_sign("DF1ABCX") is False assert utils.parse.is_call_sign("DF1ABCEFG") is False assert utils.parse.is_call_sign("1ABCX") is False # 4th character is not an number assert utils.parse.is_call_sign("XXXXXX") is False # Some valid checks # 1x2 result = utils.parse.is_call_sign("A0AF") assert isinstance(result, dict) assert result["callsign"] == "A0AF" assert result["ssid"] == "" # 2x1 result = utils.parse.is_call_sign("AA0A") assert isinstance(result, dict) assert result["callsign"] == "AA0A" assert result["ssid"] == "" # 2x2 result = utils.parse.is_call_sign("AA0AF") assert isinstance(result, dict) assert result["callsign"] == "AA0AF" assert result["ssid"] == "" result = utils.parse.is_call_sign("AA0AF-23") assert isinstance(result, dict) assert result["callsign"] == "AA0AF" assert result["ssid"] == "23" # 1x3 result = utils.parse.is_call_sign("K0ACL") assert isinstance(result, dict) assert result["callsign"] == "K0ACL" assert result["ssid"] == "" # 2x3 result = utils.parse.is_call_sign("DF1ABC") assert isinstance(result, dict) assert result["callsign"] == "DF1ABC" assert result["ssid"] == "" # Get our SSID result = utils.parse.is_call_sign("DF1ABC-14") assert result["callsign"] == "DF1ABC" assert result["ssid"] == "14" def test_is_phone_no(): """ API: is_phone_no() function """ # Invalid numbers assert utils.parse.is_phone_no(None) is False assert utils.parse.is_phone_no(42) is False assert utils.parse.is_phone_no(object) is False assert utils.parse.is_phone_no("") is False assert utils.parse.is_phone_no("1") is False assert utils.parse.is_phone_no("12") is False assert utils.parse.is_phone_no("abc") is False assert utils.parse.is_phone_no("+()") is False assert utils.parse.is_phone_no("+") is False assert utils.parse.is_phone_no(None) is False assert utils.parse.is_phone_no(42) is False assert utils.parse.is_phone_no(object, min_len=0) is False assert utils.parse.is_phone_no("", min_len=1) is False assert utils.parse.is_phone_no("abc", min_len=0) is False assert utils.parse.is_phone_no("", min_len=0) is False # Ambigious, but will document it here in this test as such results = utils.parse.is_phone_no("+((()))--+", min_len=0) assert results["country"] == "" assert results["area"] == "" assert results["line"] == "" assert results["pretty"] == "" assert results["full"] == "" # Valid phone numbers assert utils.parse.is_phone_no("+(0)") is False results = utils.parse.is_phone_no("+(0)", min_len=1) assert results["country"] == "" assert results["area"] == "" assert results["line"] == "0" assert results["pretty"] == "0" assert results["full"] == "0" assert utils.parse.is_phone_no("1") is False results = utils.parse.is_phone_no("1", min_len=1) assert results["country"] == "" assert results["area"] == "" assert results["line"] == "1" assert results["pretty"] == "1" assert results["full"] == "1" assert utils.parse.is_phone_no("12") is False results = utils.parse.is_phone_no("12", min_len=2) assert results["country"] == "" assert results["area"] == "" assert results["line"] == "12" assert results["pretty"] == "12" assert results["full"] == "12" assert utils.parse.is_phone_no("911") is False results = utils.parse.is_phone_no("911", min_len=3) assert isinstance(results, dict) assert results["country"] == "" assert results["area"] == "" assert results["line"] == "911" assert results["pretty"] == "911" assert results["full"] == "911" assert utils.parse.is_phone_no("1234") is False results = utils.parse.is_phone_no("1234", min_len=4) assert isinstance(results, dict) assert results["country"] == "" assert results["area"] == "" assert results["line"] == "1234" assert results["pretty"] == "1234" assert results["full"] == "1234" assert utils.parse.is_phone_no("12345") is False results = utils.parse.is_phone_no("12345", min_len=5) assert isinstance(results, dict) assert results["country"] == "" assert results["area"] == "" assert results["line"] == "12345" assert results["pretty"] == "12345" assert results["full"] == "12345" assert utils.parse.is_phone_no("123456") is False results = utils.parse.is_phone_no("123456", min_len=6) assert isinstance(results, dict) assert results["country"] == "" assert results["area"] == "" assert results["line"] == "123456" assert results["pretty"] == "123456" assert results["full"] == "123456" # at 7 digits, the format hyphenates in the `pretty` section assert utils.parse.is_phone_no("1234567") is False results = utils.parse.is_phone_no("1234567", min_len=7) assert isinstance(results, dict) assert results["country"] == "" assert results["area"] == "" assert results["line"] == "1234567" assert results["pretty"] == "123-4567" assert results["full"] == "1234567" results = utils.parse.is_phone_no("1(800) 123-4567") assert isinstance(results, dict) assert results["country"] == "1" assert results["area"] == "800" assert results["line"] == "1234567" assert results["pretty"] == "+1 800-123-4567" assert results["full"] == "18001234567" def test_parse_call_sign(): """utils: parse_call_sign() testing""" # A simple single array entry (As str) results = utils.parse.parse_call_sign("") assert isinstance(results, list) assert len(results) == 0 # just delimeters results = utils.parse.parse_call_sign(", ,, , ,,, ") assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_call_sign(None) assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_call_sign(42) assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_call_sign( "this is not a parseable call sign at all" ) assert isinstance(results, list) assert len(results) == 9 results = utils.parse.parse_call_sign( "this is not a parseable call sign at all", store_unparseable=False ) assert isinstance(results, list) assert len(results) == 0 # Now test valid call signs results = utils.parse.parse_call_sign("0A1DEF") assert isinstance(results, list) assert len(results) == 1 assert "0A1DEF" in results results = utils.parse.parse_call_sign("0A1DEF, DF1ABC") assert isinstance(results, list) assert len(results) == 2 assert "0A1DEF" in results assert "DF1ABC" in results results = utils.parse.parse_call_sign("AA0A, A0AF-12, GARBAGE") assert isinstance(results, list) assert len(results) == 2 assert "AA0A" in results assert "A0AF-12" in results def test_parse_domain_service_targets(): """utils: parse_domain_service_targets() testing""" results = utils.parse.parse_domain_service_targets("") assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_domain_service_targets("service1 service2") assert isinstance(results, list) assert len(results) == 2 assert "service1" in results assert "service2" in results results = utils.parse.parse_domain_service_targets( "service1:target1,target2" ) assert isinstance(results, list) assert len(results) == 1 assert "service1:target1,target2" in results results = utils.parse.parse_domain_service_targets( "service1:target1,target2 service2 domain.service3" ) assert isinstance(results, list) assert len(results) == 3 assert "service1:target1,target2" in results assert "service2" in results assert "domain.service3" in results # Support a comma in the space between entries results = utils.parse.parse_domain_service_targets( "service1:target1,target2, service2 ,domain.service3, , , service4" ) assert isinstance(results, list) assert len(results) == 4 assert "service1:target1,target2" in results assert "service2" in results assert "domain.service3" in results assert "service4" in results results = utils.parse.parse_domain_service_targets( "service:target1,target2" ) assert isinstance(results, list) assert len(results) == 1 assert "service:target1,target2" in results # Handle unparseables results = utils.parse.parse_domain_service_targets( ": %invalid ^entries%", store_unparseable=False ) assert isinstance(results, list) assert len(results) == 0 # Verify many separators complete in reasonable time. t0 = time.monotonic() utils.parse.parse_domain_service_targets("ha.smtp" + " , " * 30) assert time.monotonic() - t0 < 5.0 def test_parse_phone_no(): """utils: parse_phone_no() testing""" # A simple single array entry (As str) results = utils.parse.parse_phone_no("") assert isinstance(results, list) assert len(results) == 0 # just delimeters results = utils.parse.parse_phone_no(", ,, , ,,, ") assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_phone_no(",") assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_phone_no(None) assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_phone_no(42) assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_phone_no( "this is not a parseable phoneno at all" ) assert isinstance(results, list) assert len(results) == 8 # Now we do it again with the store_unparsable flag set to False results = utils.parse.parse_phone_no( "this is not a parseable email at all", store_unparseable=False ) assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_phone_no("+", store_unparseable=False) assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_phone_no("(", store_unparseable=False) assert isinstance(results, list) assert len(results) == 0 # Number is too short results = utils.parse.parse_phone_no("0", store_unparseable=False) assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_phone_no("12", store_unparseable=False) assert isinstance(results, list) assert len(results) == 0 # Now test valid phone numbers results = utils.parse.parse_phone_no("+1 (124) 245 2345") assert isinstance(results, list) assert len(results) == 1 assert "+1 (124) 245 2345" in results results = utils.parse.parse_phone_no("911", store_unparseable=False) assert isinstance(results, list) assert len(results) == 1 assert "911" in results results = utils.parse.parse_phone_no( "911, 123-123-1234", store_unparseable=False ) assert isinstance(results, list) assert len(results) == 2 assert "911" in results assert "123-123-1234" in results # Space variations results = utils.parse.parse_phone_no(" 911 , +1 (123) 123-1234") assert isinstance(results, list) assert len(results) == 2 assert "911" in results assert "+1 (123) 123-1234" in results results = utils.parse.parse_phone_no(" 911 , + 1 ( 123 ) 123-1234") assert isinstance(results, list) assert len(results) == 2 assert "911" in results assert "+ 1 ( 123 ) 123-1234" in results # Verify trailing-whitespace runs complete in reasonable time. t0 = time.monotonic() results = utils.parse.parse_phone_no("+12345678901" + " " * 1000) assert time.monotonic() - t0 < 5.0 assert results == ["+12345678901"] def test_parse_emails(): """utils: parse_emails() testing""" # A simple single array entry (As str) results = utils.parse.parse_emails("") assert isinstance(results, list) assert len(results) == 0 # just delimeters results = utils.parse.parse_emails(", ,, , ,,, ") assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_emails(",") assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_emails(None) assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_emails(42) assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_emails("this is not a parseable email at all") assert isinstance(results, list) assert len(results) == 8 # Now we do it again with the store_unparsable flag set to False results = utils.parse.parse_emails( "this is not a parseable email at all", store_unparseable=False ) assert isinstance(results, list) assert len(results) == 0 # Now test valid emails results = utils.parse.parse_emails("user@example.com") assert isinstance(results, list) assert len(results) == 1 assert "user@example.com" in results results = utils.parse.parse_emails("a@") assert isinstance(results, list) assert len(results) == 1 assert "a@" in results results = utils.parse.parse_emails("user1@example.com user2@example.com") assert isinstance(results, list) assert len(results) == 2 assert "user1@example.com" in results assert "user2@example.com" in results # Commas and spaces found inside URLs are ignored emails = [ "user1@example.com,", "test1@example.com,,, abcd@example.com", "Chuck Norris roundhouse@kick.com", "David Spade dspade@example.com, Yours Truly yours@truly.com", ] results = utils.parse.parse_emails(", ".join(emails)) assert isinstance(results, list) assert len(results) == 6 assert "user1@example.com" in results assert "test1@example.com" in results assert "abcd@example.com" in results assert "Chuck Norris roundhouse@kick.com" in results assert "David Spade dspade@example.com" in results assert "Yours Truly yours@truly.com" in results # Test triangle bracket parsing # Commas and spaces found inside URLs are ignored emails = [ "User1 user1@example.com", "User 2 user2@example.com", "User Three ", "The Forth User: ", "5th User: user4@example.com", ] results = utils.parse.parse_emails(", ".join(emails)) assert isinstance(results, list) assert len(results) == len(emails) for email in emails: assert email in results is_email = utils.parse.is_email(email) assert is_email assert is_email.get("name") # pass the entries in as a list results = utils.parse.parse_emails(emails) assert isinstance(results, list) assert len(results) == len(emails) for email in emails: assert email in results # Pass in some unparseables results = utils.parse.parse_emails("garbage") assert isinstance(results, list) assert len(results) == 1 results = utils.parse.parse_emails("garbage", store_unparseable=False) assert isinstance(results, list) assert len(results) == 0 # Pass in garbage results = utils.parse.parse_emails(object) assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_emails(42) assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_emails([None, object, 42]) assert isinstance(results, list) assert len(results) == 0 # Verify trailing-whitespace runs complete in reasonable time. t0 = time.monotonic() results = utils.parse.parse_emails("victim@example.com" + " " * 400) assert time.monotonic() - t0 < 5.0 assert results == ["victim@example.com"] # Quoted display names containing commas are kept as a single token. results = utils.parse.parse_emails( '"John, Jason & Mike" ' ) assert results == ['"John, Jason & Mike" '] results = utils.parse.parse_emails( '"Smith, Jane" , Bob Jones ' ) assert len(results) == 2 assert '"Smith, Jane" ' in results assert "Bob Jones " in results def test_parse_urls(): """utils: parse_urls() testing""" # A simple single array entry (As str) results = utils.parse.parse_urls("") assert isinstance(results, list) assert len(results) == 0 # just delimeters results = utils.parse.parse_urls(", ,, , ,,, ") assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_urls(",") assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_urls(None) assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_urls(42) assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_urls("this is not a parseable url at all") assert isinstance(results, list) # we still end up returning this assert len(results) == 8 results = utils.parse.parse_urls( "this is not a parseable url at all", store_unparseable=False ) assert isinstance(results, list) assert len(results) == 0 # Now test valid URLs results = utils.parse.parse_urls("windows://") assert isinstance(results, list) assert len(results) == 1 assert "windows://" in results results = utils.parse.parse_urls("windows:// gnome://") assert isinstance(results, list) assert len(results) == 2 assert "windows://" in results assert "gnome://" in results # We don't want to parse out URLs that are part of another URL's arguments results = utils.parse.parse_urls("discord://host?url=https://localhost") assert isinstance(results, list) assert len(results) == 1 assert "discord://host?url=https://localhost" in results # Commas and spaces found inside URLs are ignored urls = [ ( "mailgun://noreply@sandbox.mailgun.org/apikey/" "?to=test@example.com,test2@example.com,," " abcd@example.com" ), ( "mailgun://noreply@sandbox.another.mailgun.org/apikey/" "?to=hello@example.com,,hmmm@example.com,, abcd@example.com, ," ), "windows://", ] # Since comma's and whitespace are the delimiters; they won't be # present at the end of the URL; so we just need to write a special # rstrip() as a regular exression to handle whitespace (\s) and comma # delimiter rstrip_re = re.compile(r"[\s,]+$") # Since a comma acts as a delimiter, we run a risk of a problem where the # comma exists as part of the URL and is therefore lost if it was found # at the end of it. results = utils.parse.parse_urls(", ".join(urls)) assert isinstance(results, list) assert len(results) == len(urls) for url in urls: assert rstrip_re.sub("", url) in results # However if a comma is found at the end of a single url without a new # match to hit, it is saved and not lost # The comma at the end of the password will not be lost if we're # dealing with a single entry: url = "http://hostname?password=,abcd," results = utils.parse.parse_urls(url) assert isinstance(results, list) assert len(results) == 1 assert url in results # however if we have multiple entries, commas and spaces between # URLs will be lost, however the last URL will not lose the comma urls = [ "schema1://hostname?password=,abcd,", "schema2://hostname?password=,abcd,", ] results = utils.parse.parse_urls(", ".join(urls)) assert isinstance(results, list) assert len(results) == len(urls) # No match because the comma is gone in the results entry # schema1://hostname?password=,abcd assert urls[0] not in results assert urls[0][:-1] in results # However we wouldn't have lost the comma in the second one: # schema2://hostname?password=,abcd, assert urls[1] in results # Pass the list in (as a list); results are the same results = utils.parse.parse_urls(urls) assert isinstance(results, list) assert len(results) == len(urls) # Pass in garbage results = utils.parse.parse_urls(object) assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_urls(42) assert isinstance(results, list) assert len(results) == 0 results = utils.parse.parse_urls([None, object, 42]) assert isinstance(results, list) assert len(results) == 0 # Verify trailing-whitespace runs complete in reasonable time. t0 = time.monotonic() results = utils.parse.parse_urls("https://example.com" + " " * 50000) assert time.monotonic() - t0 < 5.0 assert results == ["https://example.com"] def test_parse_urls_semicolon_delimiter(): """utils: parse_urls() accepts a semicolon between URLs""" # A semicolon is a delimiter just like the comma and whitespace for text in ( "json://a/;json://b/", "json://a/ ; json://b/", "json://a/; json://b/", "json://a/,; json://b/", "json://a/ ,; json://b/", ): assert utils.parse.parse_urls(text) == ["json://a/", "json://b/"] results = utils.parse.parse_urls("json://a/;json://b/;mailto://c/") assert results == ["json://a/", "json://b/", "mailto://c/"] # Each URL becomes its own service, the same as the comma form for text in ( "json://a/;json://b/", "mailtos://u:p@h.com;json://b/", ): asset = Apprise() assert asset.add(text) is True assert len(asset) == 2 comma = Apprise() assert comma.add(text.replace(";", ",")) is True assert len(asset) == len(comma) # A semicolon not followed by another URL is part of the URL for text in ( "json://a/?x=1;2", "json://u:p;w@h/", "json://a/;b", "json://a/?url=x;y,z", ): assert utils.parse.parse_urls(text) == [text] # Even when other URLs are present results = utils.parse.parse_urls("json://a/?x=1;2;json://u:p;w@h/") assert results == ["json://a/?x=1;2", "json://u:p;w@h/"] def test_dict_full_update(): """utils: dict_full_update() testing""" dict_1 = { "a": 1, "b": 2, "c": 3, "d": { "z": 27, "y": 26, "x": 25, }, } dict_2 = { "d": { "x": "updated", "w": 24, }, "c": "updated", "e": 5, } utils.logic.dict_full_update(dict_1, dict_2) # Dictionary 2 is untouched assert len(dict_2) == 3 assert dict_2["c"] == "updated" assert dict_2["d"]["w"] == 24 assert dict_2["d"]["x"] == "updated" assert dict_2["e"] == 5 # Dictionary 3 however has entries from Dict 2 applied # without disrupting entries that were not matched. assert len(dict_1) == 5 assert dict_1["a"] == 1 assert dict_1["b"] == 2 assert dict_1["c"] == "updated" assert dict_1["d"]["w"] == 24 assert dict_1["d"]["x"] == "updated" assert dict_1["d"]["y"] == 26 assert dict_1["d"]["z"] == 27 assert dict_1["e"] == 5 def test_parse_list(): """utils: parse_list() testing""" # A simple single array entry (As str) results = utils.parse.parse_list( ".mkv,.avi,.divx,.xvid,.mov,.wmv,.mp4,.mpg,.mpeg,.vob,.iso" ) assert results == sorted( [ ".divx", ".iso", ".mkv", ".mov", ".mpg", ".avi", ".mpeg", ".vob", ".xvid", ".wmv", ".mp4", ] ) class StrangeObject: def __str__(self): return ".avi" # Now 2 lists with lots of duplicates and other delimiters results = utils.parse.parse_list( ".mkv,.avi,.divx,.xvid,.mov,.wmv,.mp4,.mpg .mpeg,.vob,,; ;", (".mkv,.avi,.divx,.xvid,.mov ", " .wmv,.mp4;.mpg,.mpeg,"), ".vob,.iso", [ ".vob", [ ".vob", ".mkv", StrangeObject(), ], ], StrangeObject(), ) assert results == sorted( [ ".divx", ".iso", ".mkv", ".mov", ".mpg", ".avi", ".mpeg", ".vob", ".xvid", ".wmv", ".mp4", ] ) # Garbage in is removed assert utils.parse.parse_list(object(), 42, None) == [] # Now a list with extras we want to add as strings # empty entries are removed results = utils.parse.parse_list( [ ".divx", ".iso", ".mkv", ".mov", "", " ", ".avi", ".mpeg", ".vob", ".xvid", ".mp4", ], ".mov,.wmv,.mp4,.mpg", ) assert results == sorted( [ ".divx", ".wmv", ".iso", ".mkv", ".mov", ".mpg", ".avi", ".vob", ".xvid", ".mpeg", ".mp4", ] ) def test_import_module(tmpdir): """utils: import_module testing""" # Prepare ourselves a file to work with bad_file_base = tmpdir.mkdir("a") bad_file = bad_file_base.join("README.md") bad_file.write( cleandoc(""" I'm a README file, not a Python one. I can't be loaded """) ) assert utils.module.import_module(str(bad_file), "invalidfile1") is None assert ( utils.module.import_module(str(bad_file_base), "invalidfile2") is None ) def test_module_detection(tmpdir): """utils: test_module_detection() testing""" # Clear our working variables so they don't obstruct with the tests here N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() # Test case where we load invalid data N_MGR.module_detection(None) # Invalid data does not load anything assert len(N_MGR._paths_previously_scanned) == 0 assert len(N_MGR._custom_module_map) == 0 # Prepare ourselves a file to work with notify_hook_a_base = tmpdir.mkdir("a") notify_hook_a = notify_hook_a_base.join("hook.py") notify_hook_a.write( cleandoc(""" from apprise.decorators import notify @notify(on="clihook") def mywrapper(body, title, notify_type, *args, **kwargs): pass """) ) notify_ignore = notify_hook_a_base.join("README.md") notify_ignore.write( cleandoc(""" We're not a .py file, so this file gets gracefully skipped """) ) # Not previously loaded assert "clihook" not in N_MGR # load entry by string N_MGR.module_detection(str(notify_hook_a)) N_MGR.module_detection(str(notify_ignore)) N_MGR.module_detection(str(notify_hook_a_base)) assert len(N_MGR._paths_previously_scanned) == 3 assert len(N_MGR._custom_module_map) == 1 # Now loaded assert "clihook" in N_MGR # load entry by array N_MGR.module_detection([str(notify_hook_a)]) # No changes to our path assert len(N_MGR._paths_previously_scanned) == 3 assert len(N_MGR._custom_module_map) == 1 # Reset our variables for the next test N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() # Hidden files are ignored notify_hook_b_base = tmpdir.mkdir("b") notify_hook_b = notify_hook_b_base.join(".hook.py") notify_hook_b.write( cleandoc(""" from apprise.decorators import notify # this is in a hidden file so it will not load @notify(on="hidden") def mywrapper(body, title, notify_type, *args, **kwargs): pass """) ) assert "hidden" not in N_MGR N_MGR.module_detection([str(notify_hook_b)]) # Verify that it did not load assert "hidden" not in N_MGR # Path was scanned; nothing loaded assert len(N_MGR._paths_previously_scanned) == 1 assert len(N_MGR._custom_module_map) == 0 # Reset our variables for the next test N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() # modules with no hooks found are ignored notify_hook_c_base = tmpdir.mkdir("c") notify_hook_c = notify_hook_c_base.join("empty.py") notify_hook_c.write("") N_MGR.module_detection([str(notify_hook_c)]) # File was found, no custom modules assert len(N_MGR._paths_previously_scanned) == 1 assert len(N_MGR._custom_module_map) == 0 # A new path scanned N_MGR.module_detection([str(notify_hook_c_base)]) assert len(N_MGR._paths_previously_scanned) == 2 assert len(N_MGR._custom_module_map) == 0 def create_hook(tdir, cache=True, on="valid1"): """Just a temporary hook creation tool for writing a working notify hook.""" tdir.write( cleandoc(f""" from apprise.decorators import notify # this is a good hook but burried in hidden directory which won't # be accessed unless the file is pointed to via absolute path @notify(on="{on}") def mywrapper(body, title, notify_type, *args, **kwargs): pass """) ) N_MGR.module_detection([str(tdir)], cache=cache) create_hook(notify_hook_c, on="valid1") assert "valid1" not in N_MGR # Even if we correct our empty file; the fact the directory has been # scanned and failed to load (same with file), it won't be loaded # a second time. This is intentional since module_detection() get's # called for every AppriseAsset() object creation. This prevents us # from reloading conent over and over again wasting resources assert "valid1" not in N_MGR N_MGR.module_detection([str(notify_hook_c)]) assert len(N_MGR._paths_previously_scanned) == 2 assert len(N_MGR._custom_module_map) == 0 # Even by absolute path... N_MGR.module_detection([str(notify_hook_c)]) assert "valid1" not in N_MGR assert len(N_MGR._paths_previously_scanned) == 2 assert len(N_MGR._custom_module_map) == 0 # However we can bypass the cache if we really want to N_MGR.module_detection([str(notify_hook_c_base)], cache=False) assert "valid1" in N_MGR assert len(N_MGR._paths_previously_scanned) == 2 assert len(N_MGR._custom_module_map) == 1 # Bypassing it twice causes the module to load twice (not very efficient) # However we can bypass the cache if we really want to N_MGR.module_detection([str(notify_hook_c_base)], cache=False) assert "valid1" in N_MGR assert len(N_MGR._paths_previously_scanned) == 2 assert len(N_MGR._custom_module_map) == 1 # If you update the module (corrupting it in the process and reload) notify_hook_c.write( cleandoc(""" raise ValueError """) ) # Force no cache to cause the file to be replaced N_MGR.module_detection([str(notify_hook_c_base)], cache=False) # Our valid entry is no longer loaded assert "valid1" not in N_MGR # No change to scanned paths assert len(N_MGR._paths_previously_scanned) == 2 # The previously loaded module is now gone assert len(N_MGR._custom_module_map) == 0 # Reload our valid1 entry create_hook(notify_hook_c, on="valid1", cache=False) assert "valid1" in N_MGR assert len(N_MGR._paths_previously_scanned) == 2 assert len(N_MGR._custom_module_map) == 1 # Prepare an empty file notify_hook_c.write("") N_MGR.module_detection([str(notify_hook_c_base)], cache=False) # Our valid entry is no longer loaded assert "valid1" not in N_MGR assert len(N_MGR._paths_previously_scanned) == 2 assert len(N_MGR._custom_module_map) == 0 # Now reload our module again (this time rather then an exception, the # module is read back and swaps `valid1` for `valid2` create_hook(notify_hook_c, on="valid1", cache=False) assert "valid1" in N_MGR assert "valid2" not in N_MGR assert len(N_MGR._paths_previously_scanned) == 2 assert len(N_MGR._custom_module_map) == 1 create_hook(notify_hook_c, on="valid2", cache=False) assert "valid1" not in N_MGR assert "valid2" in N_MGR assert len(N_MGR._paths_previously_scanned) == 2 assert len(N_MGR._custom_module_map) == 1 # Reset our variables for the next test create_hook(notify_hook_c, on="valid1", cache=False) del N_MGR["valid1"] N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() notify_hook_d = notify_hook_c_base.join(".ignore.py") notify_hook_d.write("") notify_hook_e_base = notify_hook_c_base.mkdir(".ignore") notify_hook_e = notify_hook_e_base.join("__init__.py") notify_hook_e.write( cleandoc(""" from apprise.decorators import notify # this is a good hook but burried in hidden directory which won't # be accessed unless the file is pointed to via absolute path @notify(on="valid2") def mywrapper(body, title, notify_type, *args, **kwargs): pass """) ) # Try to load our base directory again; this time we search by the # directory; the only edge case we're testing here is it will not # even look at the .ignore.py file found since it is invalid N_MGR.module_detection([str(notify_hook_c_base)]) assert "valid1" in N_MGR assert len(N_MGR._paths_previously_scanned) == 2 assert len(N_MGR._custom_module_map) == 1 # Reset our variables for the next test del N_MGR._schema_map["valid1"] N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() # Try to load our base directory again N_MGR.module_detection([str(notify_hook_c)]) assert "valid1" in N_MGR # Hidden directories are not scanned assert "valid2" not in N_MGR assert len(N_MGR._paths_previously_scanned) == 1 assert str(notify_hook_c) in N_MGR._paths_previously_scanned assert len(N_MGR._custom_module_map) == 1 # However a direct reference to the hidden directory is okay N_MGR.module_detection([str(notify_hook_e_base)]) # We loaded our module assert "valid2" in N_MGR assert len(N_MGR._paths_previously_scanned) == 3 assert str(notify_hook_c) in N_MGR._paths_previously_scanned assert str(notify_hook_e) in N_MGR._paths_previously_scanned assert str(notify_hook_e_base) in N_MGR._paths_previously_scanned assert len(N_MGR._custom_module_map) == 2 # Reset our variables for the next test del N_MGR._schema_map["valid1"] del N_MGR._schema_map["valid2"] N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() # Load our file directly assert "valid2" not in N_MGR N_MGR.module_detection([str(notify_hook_e)]) # Now we have it loaded as expected assert "valid2" in N_MGR assert len(N_MGR._paths_previously_scanned) == 1 assert str(notify_hook_e) in N_MGR._paths_previously_scanned assert len(N_MGR._custom_module_map) == 1 # however if we try to load the base directory where the __init__.py # was already loaded from, it will not change anything N_MGR.module_detection([str(notify_hook_e_base)]) assert "valid2" in N_MGR assert len(N_MGR._paths_previously_scanned) == 2 assert str(notify_hook_e) in N_MGR._paths_previously_scanned assert str(notify_hook_e_base) in N_MGR._paths_previously_scanned assert len(N_MGR._custom_module_map) == 1 # Tidy up for the next test del N_MGR._schema_map["valid2"] N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() assert "valid1" not in N_MGR assert "valid2" not in N_MGR assert "valid3" not in N_MGR notify_hook_f_base = tmpdir.mkdir("f") notify_hook_f = notify_hook_f_base.join("invalid.py") notify_hook_f.write( cleandoc(""" from apprise.decorators import notify # A very invalid hook type... on should not be None @notify(on=None) def mywrapper(body, title, notify_type, *args, **kwargs): pass # An invalid name @notify(on='valid1', name=None) def mywrapper(body, title, notify_type, *args, **kwargs): pass # Another invalid name (so it's ignored) @notify(on='valid2', name=object) def mywrapper(body, title, notify_type, *args, **kwargs): pass # Simply put... the name has to be a string to be referenced # however this will still be loaded @notify(on='valid3', name=4) def mywrapper(body, title, notify_type, *args, **kwargs): pass """) ) N_MGR.module_detection([str(notify_hook_f)]) assert len(N_MGR._paths_previously_scanned) == 1 assert len(N_MGR._custom_module_map) == 1 assert "valid1" in N_MGR assert "valid2" in N_MGR assert "valid3" in N_MGR # Reset our variables for the next test del N_MGR._schema_map["valid1"] del N_MGR._schema_map["valid2"] del N_MGR._schema_map["valid3"] N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() # Now test the handling of just bad data entirely notify_hook_g_base = tmpdir.mkdir("g") notify_hook_g = notify_hook_g_base.join("binary.py") with open(str(notify_hook_g), "wb") as fout: fout.write(os.urandom(512)) N_MGR.module_detection([str(notify_hook_g)]) assert len(N_MGR._paths_previously_scanned) == 1 assert len(N_MGR._custom_module_map) == 0 # Reset our variables before we leave N_MGR._paths_previously_scanned.clear() N_MGR._custom_module_map.clear() def test_exclusive_match(): """utils: is_exclusive_match() testing""" # No Logic always returns True if there is also no data assert utils.logic.is_exclusive_match(data=None, logic=None) is True assert utils.logic.is_exclusive_match(data=None, logic=set()) is True assert utils.logic.is_exclusive_match(data="", logic=set()) is True assert utils.logic.is_exclusive_match(data="", logic=set()) is True # however, once data is introduced, True is no longer returned # if no logic has been specified assert utils.logic.is_exclusive_match(data="check", logic=set()) is False assert ( utils.logic.is_exclusive_match(data=["check", "checkb"], logic=set()) is False ) # String delimters are stripped out so that a list can be formed # the below is just an empty token list assert utils.logic.is_exclusive_match(data=set(), logic=",; ,") is True # garbage logic is never an exclusive match assert utils.logic.is_exclusive_match(data=set(), logic=object()) is False assert ( utils.logic.is_exclusive_match( data=set(), logic=[ object(), ], ) is False ) # # Test with logic: # data = {"abc"} # def in data assert utils.logic.is_exclusive_match(logic="def", data=data) is False # def in data assert ( utils.logic.is_exclusive_match( logic=[ "def", ], data=data, ) is False ) # def in data assert utils.logic.is_exclusive_match(logic=("def",), data=data) is False # def in data assert ( utils.logic.is_exclusive_match( logic={ "def", }, data=data, ) is False ) # abc in data assert ( utils.logic.is_exclusive_match( logic=[ "abc", ], data=data, ) is True ) # abc in data assert utils.logic.is_exclusive_match(logic=("abc",), data=data) is True # abc in data assert ( utils.logic.is_exclusive_match( logic={ "abc", }, data=data, ) is True ) # abc or def in data assert utils.logic.is_exclusive_match(logic="abc, def", data=data) is True # # Update our data set so we can do more advance checks # data = {"abc", "def", "efg", "xyz"} # match_all matches everything assert utils.logic.is_exclusive_match(logic="all", data=data) is True assert utils.logic.is_exclusive_match(logic=["all"], data=data) is True # def and abc in data assert ( utils.logic.is_exclusive_match(logic=[("abc", "def")], data=data) is True ) # cba and abc in data assert ( utils.logic.is_exclusive_match(logic=[("cba", "abc")], data=data) is False ) # www or zzz or abc and xyz assert ( utils.logic.is_exclusive_match( logic=["www", "zzz", ("abc", "xyz")], data=data ) is True ) # www or zzz or abc and xyz (strings are valid too) assert ( utils.logic.is_exclusive_match( logic=["www", "zzz", "abc, xyz"], data=data ) is True ) # www or zzz or abc and jjj assert ( utils.logic.is_exclusive_match( logic=["www", "zzz", ("abc", "jjj")], data=data ) is False ) # # Empty data set # data = set() assert utils.logic.is_exclusive_match(logic=["www"], data=data) is False assert utils.logic.is_exclusive_match(logic="all", data=data) is True # # Update our data set so we can do more advance checks # data = {"always", "entry1"} # We'll always match on the with keyword always assert utils.logic.is_exclusive_match(logic="always", data=data) is True assert utils.logic.is_exclusive_match(logic="garbage", data=data) is True # However we will not match if we turn this feature off assert ( utils.logic.is_exclusive_match( logic="garbage", data=data, match_always=False ) is False ) # Change default value from 'all' to 'match_me'. Logic matches # so we pass assert ( utils.logic.is_exclusive_match( logic="match_me", data=data, match_all="match_me" ) is True ) def test_exclusive_match_priority_tokens(): """utils: is_exclusive_match() with priority-prefixed filter tokens""" # AppriseTag.__hash__ is name-only, so a set holds at most one entry per # tag name -- matching real usage where each URL has one priority. tag_ep3 = AppriseTag("endpoint", priority=3, has_priority=True) tag_other = AppriseTag("other", priority=1, has_priority=True) data = {tag_ep3, tag_other} # Exact priority match assert ( utils.logic.is_exclusive_match(logic="3:endpoint", data=data) is True ) # Priority mismatch -- "2:endpoint" does not match priority-3 entry assert ( utils.logic.is_exclusive_match(logic="2:endpoint", data=data) is False ) # Name-only filter matches via O(1) hash path regardless of stored priority assert utils.logic.is_exclusive_match(logic="endpoint", data=data) is True # Absent tag returns False assert utils.logic.is_exclusive_match(logic="absent", data=data) is False # AND group: both tokens present with correct priorities assert ( utils.logic.is_exclusive_match( logic=[("3:endpoint", "1:other")], data=data ) is True ) # AND group: one priority mismatch fails the whole group assert ( utils.logic.is_exclusive_match( logic=[("2:endpoint", "1:other")], data=data ) is False ) # OR: failing AND group rescued by a matching single token assert ( utils.logic.is_exclusive_match( logic=[("2:endpoint", "1:other"), "3:endpoint"], data=data ) is True ) # OR: no term matches -- all fail assert ( utils.logic.is_exclusive_match( logic=[("2:endpoint", "1:other"), "99:endpoint"], data=data ) is False ) # Backward compat: plain strings in data matched by a priority-prefixed # filter token (the for-loop fallback in _token_matches_data) str_data = {"endpoint", "other"} assert ( utils.logic.is_exclusive_match(logic="7:endpoint", data=str_data) is True ) assert ( utils.logic.is_exclusive_match(logic="7:absent", data=str_data) is False ) # match_all keyword short-circuits even with priority data assert utils.logic.is_exclusive_match(logic="all", data=data) is True def test_apprise_validate_regex(): """ API: Apprise() Validate Regex tests """ assert utils.parse.validate_regex(None) is None assert utils.parse.validate_regex(object) is None assert utils.parse.validate_regex(42) is None assert utils.parse.validate_regex("") is None assert utils.parse.validate_regex(" ") is None assert utils.parse.validate_regex("abc") == "abc" # value is a keyword that is extracted (if found) assert ( utils.parse.validate_regex( "- abcd -", r"-(?P[^-]+)-", fmt="{value}" ) == "abcd" ) assert ( utils.parse.validate_regex( "- abcd -", r"-(?P[^-]+)-", strip=False, fmt="{value}" ) == " abcd " ) # String flags supported in addition to numeric assert ( utils.parse.validate_regex( "- abcd -", r"-(?P[^-]+)-", "i", fmt="{value}" ) == "abcd" ) assert ( utils.parse.validate_regex( "- abcd -", r"-(?P[^-]+)-", re.I, fmt="{value}" ) == "abcd" ) # Test multiple flag settings assert ( utils.parse.validate_regex( "- abcd -", r"-(?P[^-]+)-", "isax", fmt="{value}" ) == "abcd" ) # Invalid flags are just ignored. The below fails to match # because the default value of 'i' is over-ridden by what is # identfied below, and no flag is set at the end of the day assert ( utils.parse.validate_regex( "- abcd -", r"-(?P[ABCD]+)-", "-%2gb", fmt="{value}" ) is None ) assert ( utils.parse.validate_regex( "- abcd -", r"-(?P[ABCD]+)-", "", fmt="{value}" ) is None ) assert ( utils.parse.validate_regex( "- abcd -", r"-(?P[ABCD]+)-", None, fmt="{value}" ) is None ) def test_apply_templating(): """utils: apply_template() testing""" template = "Hello {{fname}}, How are you {{whence}}?" result = utils.templates.apply_template( template, **{"fname": "Chris", "whence": "this morning"} ) assert isinstance(result, str) assert result == "Hello Chris, How are you this morning?" # In this example 'whence' isn't provided, so it isn't swapped result = utils.templates.apply_template(template, **{"fname": "Chris"}) assert isinstance(result, str) assert result == "Hello Chris, How are you {{whence}}?" # white space won't cause any ill affects: template = "Hello {{ fname }}, How are you {{ whence}}?" result = utils.templates.apply_template( template, **{"fname": "Chris", "whence": "this morning"} ) assert isinstance(result, str) assert result == "Hello Chris, How are you this morning?" # No arguments won't cause any problems template = "Hello {{fname}}, How are you {{whence}}?" result = utils.templates.apply_template(template) assert isinstance(result, str) assert result == template # Wrong elements are simply ignored result = utils.templates.apply_template( template, **{"fname": "l2g", "whence": "this evening", "ignore": "me"} ) assert isinstance(result, str) assert result == "Hello l2g, How are you this evening?" # Empty template makes things easy result = utils.templates.apply_template( "", **{"fname": "l2g", "whence": "this evening"} ) assert isinstance(result, str) assert result == "" # Regular expressions are safely escapped and act as normal # tokens: template = "Hello {{.*}}, How are you {{[A-Z0-9]+}}?" result = utils.templates.apply_template( template, **{".*": "l2g", "[A-Z0-9]+": "this afternoon"} ) assert result == "Hello l2g, How are you this afternoon?" # JSON is handled too such as escaping quotes template = '{value: "{{ value }}"}' result = utils.templates.apply_template( template, app_mode=utils.templates.TemplateType.JSON, **{"value": '"quotes are escaped"'}, ) assert result == '{value: "\\"quotes are escaped\\""}' def test_cwe312_word(): """utils: cwe312_word() testing""" assert utils.cwe312.cwe312_word(None) is None assert utils.cwe312.cwe312_word(42) == 42 assert utils.cwe312.cwe312_word("") == "" assert utils.cwe312.cwe312_word(" ") == " " assert utils.cwe312.cwe312_word("!") == "!" assert utils.cwe312.cwe312_word("a") == "a" assert utils.cwe312.cwe312_word("ab") == "ab" assert utils.cwe312.cwe312_word("abc") == "abc" assert utils.cwe312.cwe312_word("abcd") == "abcd" assert utils.cwe312.cwe312_word("abcd", force=True) == "a...d" assert utils.cwe312.cwe312_word("abc--d") == "abc--d" assert utils.cwe312.cwe312_word("a-domain.ca") == "a...a" # Variances to still catch domain assert ( utils.cwe312.cwe312_word("a-domain.ca", advanced=False) == "a-domain.ca" ) assert ( utils.cwe312.cwe312_word("a-domain.ca", threshold=6) == "a-domain.ca" ) def test_cwe312_url(): """utils: cwe312_url() testing""" assert utils.cwe312.cwe312_url(None) is None assert utils.cwe312.cwe312_url(42) == 42 assert utils.cwe312.cwe312_url("http://") == "http://" assert utils.cwe312.cwe312_url("discord://") == "discord://" assert utils.cwe312.cwe312_url("path") == "http://path" assert utils.cwe312.cwe312_url("path/") == "http://path/" # Now test http:// private data assert ( utils.cwe312.cwe312_url("http://user:pass123@localhost") == "http://user:p...3@localhost" ) assert ( utils.cwe312.cwe312_url("http://user@localhost") == "http://user@localhost" ) assert ( utils.cwe312.cwe312_url("http://user@localhost?password=abc123") == "http://user@localhost?password=a...3" ) assert ( utils.cwe312.cwe312_url("http://user@localhost?secret=secret-.12345") == "http://user@localhost?secret=s...5" ) assert ( utils.cwe312.cwe312_url( "slack://mybot@xoxb-43598234231-3248932482278" "-BZK5Wj15B9mPh1RkShJoCZ44" "/lead2gold@gmail.com" ) == "slack://mybot@x...4/l...m" ) assert ( utils.cwe312.cwe312_url( "slack://test@B4QP3WWB4/J3QWT41JM/XIl2ffpqXkzkwMXrJdevi7W3/#random" ) == "slack://test@B...4/J...M/X...3/" ) def test_cwe312_loggable(): """utils: cwe312_loggable() testing""" # A url carrying credentials is masked assert ( utils.cwe312.cwe312_loggable("http://user:pass123@localhost") == "http://user:p...3@localhost" ) assert ( utils.cwe312.cwe312_loggable( "https://user:pass123@example.com/private_key.pem" ) == "https://user:p...3@example.com/p...m" ) # Masking can be switched off, which is what secure_logging=False does assert ( utils.cwe312.cwe312_loggable( "http://user:pass123@localhost", secure=False ) == "http://user:pass123@localhost" ) # Keep local paths readable so failures identify the affected file. for path in ( "/etc/apprise/private_key.pem", "~/keys/private_key.pem", "./subscriptions.json", "subscriptions.json", "private_key.pem", "C:\\keys\\private.pem", ): assert utils.cwe312.cwe312_loggable(path) == path assert utils.cwe312.cwe312_loggable(path, secure=False) == path # Nothing to print reads as a placeholder rather than 'None' assert utils.cwe312.cwe312_loggable(None) == "(none)" assert utils.cwe312.cwe312_loggable("") == "(none)" assert utils.cwe312.cwe312_loggable(0) == "(none)" assert utils.cwe312.cwe312_loggable(None, secure=False) == "(none)" # Anything that is not a string is turned into one rather than raising assert utils.cwe312.cwe312_loggable(42) == "42" assert utils.cwe312.cwe312_loggable(4.2) == "4.2" # Dictionaries remain available for administrator-facing diagnostics. details = {"password": "pass123"} assert utils.cwe312.cwe312_loggable(details) == str(details) assert utils.cwe312.cwe312_loggable(details, secure=False) == str(details) # Mask by default when the caller omits the setting. url = "http://user:pass123@localhost" assert utils.cwe312.cwe312_loggable(url) != url # Malformed URLs fail closed instead of returning credentials unchanged. for url in ( "http://user:pass123@[", "http://user:pass123@example.com:bad", "http://user:pass123@example.com\nforged", ): result = utils.cwe312.cwe312_loggable(url) assert "pass123" not in result assert "\n" not in result assert ( utils.cwe312.cwe312_loggable("http://user:pass123@[") == "http://..." ) assert ( utils.cwe312.cwe312_loggable("http://user:pass123@example.com:bad") == "http://..." ) def test_base64_encode_decode(): """Utils:Base64:URLEncode & Decode.""" assert utils.base64.base64_urlencode(None) is None assert utils.base64.base64_urlencode(42) is None assert utils.base64.base64_urlencode(object) is None assert utils.base64.base64_urlencode({}) is None assert utils.base64.base64_urlencode("") is None assert utils.base64.base64_urlencode("abc") is None assert utils.base64.base64_urlencode(b"") == "" assert utils.base64.base64_urlencode(b"abc") == "YWJj" assert utils.base64.base64_urldecode(None) is None assert utils.base64.base64_urldecode(42) is None assert utils.base64.base64_urldecode(object) is None assert utils.base64.base64_urldecode({}) is None assert utils.base64.base64_urldecode("abc") == b"i\xb7" assert utils.base64.base64_urldecode("") == b"" assert utils.base64.base64_urldecode("YWJj") == b"abc" def test_dict_base64_codec(tmpdir): """Test encoding/decoding of base64 content.""" original = { "int": 1, "float": 2.3, } encoded, needs_decoding = utils.base64.encode_b64_dict(original) assert encoded == {"int": "b64:MQ==", "float": "b64:Mi4z"} assert needs_decoding is True decoded = utils.base64.decode_b64_dict(encoded) assert decoded == original with mock.patch("json.dumps", side_effect=TypeError()): encoded, needs_decoding = utils.base64.encode_b64_dict(original) # we failed assert needs_decoding is False assert encoded == { "int": "1", "float": "2.3", } def test_dir_size(tmpdir): """Test dir size tool.""" # Nothing to find/see size, errors = utils.disk.dir_size(str(tmpdir)) assert size == 0 assert len(errors) == 0 # Write a file in our root directory tmpdir.join("root.psdata").write("0" * 1024 * 1024) # Prepare some more directories namespace_1 = tmpdir.mkdir("abcdefg") namespace_2 = tmpdir.mkdir("defghij") namespace_2.join("cache.psdata").write("0" * 1024 * 1024) size, errors = utils.disk.dir_size(str(tmpdir)) assert size == 1024 * 1024 * 2 assert len(errors) == 0 # Write another file namespace_1.join("cache.psdata").write("0" * 1024 * 1024) size, errors = utils.disk.dir_size(str(tmpdir)) assert size == 1024 * 1024 * 3 assert len(errors) == 0 size, errors = utils.disk.dir_size(str(namespace_1)) assert size == 1024 * 1024 assert len(errors) == 0 # Create a directory insde one of our namespaces subspace_1 = namespace_1.mkdir("zyx") size, errors = utils.disk.dir_size(str(namespace_1)) assert size == 1024 * 1024 subspace_1.join("cache.psdata").write("0" * 1024 * 1024) size, errors = utils.disk.dir_size(str(tmpdir)) assert size == 1024 * 1024 * 4 assert len(errors) == 0 # Recursion limit reduced... no change at 2 as we can go 2 # diretories deep no problem size, errors = utils.disk.dir_size(str(tmpdir), max_depth=2) assert size == 1024 * 1024 * 4 assert len(errors) == 0 size, errors = utils.disk.dir_size(str(tmpdir), max_depth=1) assert size == 1024 * 1024 * 3 # we can't get into our subspace_1 assert len(errors) == 1 assert str(subspace_1) in errors size, errors = utils.disk.dir_size(str(tmpdir), max_depth=0) assert size == 1024 * 1024 # we can't get into our namespace directories assert len(errors) == 2 assert str(namespace_1) in errors assert str(namespace_2) in errors # Let's cause problems now and test the output size, errors = utils.disk.dir_size("invalid-directory", missing_okay=True) assert size == 0 assert len(errors) == 0 size, errors = utils.disk.dir_size("invalid-directory", missing_okay=False) assert size == 0 assert len(errors) == 1 assert "invalid-directory" in errors with mock.patch("os.scandir", side_effect=OSError()): size, errors = utils.disk.dir_size(str(tmpdir), missing_okay=True) assert size == 0 assert len(errors) == 1 assert str(tmpdir) in errors with mock.patch("os.scandir") as mock_scandir: mock_entry = mock.MagicMock() mock_entry.is_file.side_effect = OSError() mock_entry.path = "/test/path" # Mock the scandir return value to yield the mock entry mock_scandir.return_value.__enter__.return_value = [mock_entry] size, errors = utils.disk.dir_size(str(tmpdir)) assert size == 0 assert len(errors) == 1 assert mock_entry.path in errors with mock.patch("os.scandir") as mock_scandir: mock_entry = mock.MagicMock() mock_entry.is_file.return_value = False mock_entry.is_dir.side_effect = OSError() mock_entry.path = "/test/path" # Mock the scandir return value to yield the mock entry mock_scandir.return_value.__enter__.return_value = [mock_entry] size, errors = utils.disk.dir_size(str(tmpdir)) assert len(errors) == 1 assert mock_entry.path in errors with mock.patch("os.scandir") as mock_scandir: mock_entry = mock.MagicMock() mock_entry.is_file.return_value = False mock_entry.is_dir.return_value = False # Mock the scandir return value to yield the mock entry mock_scandir.return_value.__enter__.return_value = [mock_entry] size, errors = utils.disk.dir_size(str(tmpdir)) assert size == 0 assert len(errors) == 0 with mock.patch("os.scandir") as mock_scandir: mock_entry = mock.MagicMock() mock_entry.is_file.side_effect = FileNotFoundError() mock_entry.path = "/test/path" # Mock the scandir return value to yield the mock entry mock_scandir.return_value.__enter__.return_value = [mock_entry] size, errors = utils.disk.dir_size(str(tmpdir)) assert size == 0 # No file isn't a problem, we're calculating disksize anyway, # one less thing to calculate assert len(errors) == 0 def test_bytes_to_str(): """Test Bytes to String representation.""" # Garbage Entry assert utils.disk.bytes_to_str(None) is None assert utils.disk.bytes_to_str("") is None assert utils.disk.bytes_to_str("GARBAGE") is None # Good Entries assert utils.disk.bytes_to_str(0) == "0.00B" assert utils.disk.bytes_to_str(1) == "1.00B" assert utils.disk.bytes_to_str(1.1) == "1.10B" assert utils.disk.bytes_to_str(1024) == "1.00KB" assert utils.disk.bytes_to_str(1024 * 1024) == "1.00MB" assert utils.disk.bytes_to_str(1024 * 1024 * 1024) == "1.00GB" assert utils.disk.bytes_to_str(1024 * 1024 * 1024 * 1024) == "1.00TB" # Support strings too assert utils.disk.bytes_to_str("0") == "0.00B" assert utils.disk.bytes_to_str("1024") == "1.00KB" def test_time_zoneinfo(): """utils: zoneinfo() testing""" # Some valid strings assert isinstance(utils.time.zoneinfo("UTC"), tzinfo) assert isinstance(utils.time.zoneinfo("z"), tzinfo) assert isinstance(utils.time.zoneinfo("gmt"), tzinfo) assert isinstance(utils.time.zoneinfo("utc"), tzinfo) assert isinstance(utils.time.zoneinfo("Toronto"), tzinfo) assert isinstance(utils.time.zoneinfo("America/Toronto"), tzinfo) assert isinstance(utils.time.zoneinfo("america/toronto"), tzinfo) # Edge Case with time also supported: tz = utils.time.zoneinfo("America/Argentina/Cordoba") isinstance(tz, tzinfo) assert isinstance(utils.time.zoneinfo("Argentina/Cordoba"), tzinfo) assert utils.time.zoneinfo("Argentina/Cordoba").key == tz.key # "America/Cordoba" has been obsoleted by IANA in facor of # "America/Argentina/Cordoba", however the IANA database # instance used is system-dependent, so these tests have # different results depending on the system running them if utils.time.zoneinfo("Cordoba") is not None: # the system has the obsolete "America/Cordoba" entry assert isinstance(utils.time.zoneinfo("Cordoba"), tzinfo) assert utils.time.zoneinfo("Cordoba").key == "America/Cordoba" else: assert utils.time.zoneinfo("Cordoba") is None # the utils helper should still resolve this abbreviated (and # lowercase) form assert ( utils.time.zoneinfo("argentina/cordoba").key == "America/Argentina/Cordoba" ) # Too ambiguous assert utils.time.zoneinfo("Argentina") is None # bad data assert utils.time.zoneinfo(object) is None assert utils.time.zoneinfo(None) is None assert utils.time.zoneinfo(1) is None assert utils.time.zoneinfo("") is None assert utils.time.zoneinfo("invalid") is None caronc-apprise-dfec414/tests/test_asyncio.py000066400000000000000000000067621526720371000213520ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import sys import pytest from apprise import Apprise, NotificationManager, NotifyBase, NotifyFormat logging.disable(logging.CRITICAL) # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() @pytest.mark.skipif( sys.version_info >= (3, 7), reason="Requires Python 3.0 to 3.6" ) def test_apprise_asyncio_runtime_error(): """ API: Apprise() AsyncIO RuntimeError handling """ class GoodNotification(NotifyBase): def __init__(self, **kwargs): super().__init__(notify_format=NotifyFormat.HTML, **kwargs) def url(self, **kwargs): # Support URL return "" def send(self, **kwargs): # Pretend everything is okay return True @staticmethod def parse_url(url, *args, **kwargs): # always parseable return NotifyBase.parse_url(url, verify_host=False) # Store our good notification in our schema map N_MGR["good"] = GoodNotification # Create ourselves an Apprise object a = Apprise() # Add a few entries for _ in range(25): a.add("good://") # Python v3.6 and lower can't handle situations gracefully when an # event_loop isn't already established(). Test that Apprise can handle # these situations import asyncio # Get our event loop try: loop = asyncio.get_event_loop() except RuntimeError: loop = None # Adjust out event loop to not point at anything asyncio.set_event_loop(None) # With the event loop inactive, we'll fail trying to get the active loop with pytest.raises(RuntimeError): asyncio.get_event_loop() try: # Below, we internally will throw a RuntimeError() since there will # be no active event_loop in place. However internally it will be smart # enough to create a new event loop and continue... assert bool(a.notify(title="title", body="body")) is True finally: # Restore our event loop (in the event the above test failed) asyncio.set_event_loop(loop) caronc-apprise-dfec414/tests/test_attach_base.py000066400000000000000000000111111526720371000221230ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock import pytest from apprise.attachment.base import AttachBase from apprise.exception import AppriseImproperlyConfigured logging.disable(logging.CRITICAL) def test_mimetype_initialization(): """ API: AttachBase() mimetype initialization """ with ( mock.patch("mimetypes.init") as mock_init, mock.patch("mimetypes.inited", True), ): AttachBase() assert mock_init.call_count == 0 with ( mock.patch("mimetypes.init") as mock_init, mock.patch("mimetypes.inited", False), ): AttachBase() assert mock_init.call_count == 1 def test_attach_base(): """ API: AttachBase() """ # an invalid mime-type with pytest.raises(AppriseImproperlyConfigured): AttachBase(**{"mimetype": "invalid"}) # a valid mime-type does not cause an exception to throw AttachBase(**{"mimetype": "image/png"}) # Create an object with no mimetype over-ride obj = AttachBase() # Get our url object str(obj) # We can not process name/path/mimetype at a Base level with pytest.raises(NotImplementedError): obj.download() # Unsupported URLs are not parsed assert AttachBase.parse_url(url="invalid://") is None # Valid URL & Valid Format results = AttachBase.parse_url(url="file://relative/path") assert isinstance(results, dict) # No mime is defined assert results.get("mimetype") is None # Valid URL & Valid Format with mime type set results = AttachBase.parse_url(url="file://relative/path?mime=image/jpeg") assert isinstance(results, dict) # mime defined assert results.get("mimetype") == "image/jpeg" # We can retrieve our url assert str(results) def test_attach_base_parse_url_name_sanitization(): """ API: AttachBase.parse_url() ?name= sanitization """ # Normal name -- returned as-is (lowercased) results = AttachBase.parse_url( url="file://path/to/file?name=thumbnail.jpg" ) assert isinstance(results, dict) assert results.get("name") == "thumbnail.jpg" # Path traversal stripped; only the basename is kept results = AttachBase.parse_url(url="file://path/to/file?name=/etc/passwd") assert isinstance(results, dict) assert results.get("name") == "passwd" # Deep traversal -- same rule applies results = AttachBase.parse_url( url="file://path/to/file?name=../../secret.jpg" ) assert isinstance(results, dict) assert results.get("name") == "secret.jpg" # Empty ?name= -- treated as not specified; key must be absent results = AttachBase.parse_url(url="file://path/to/file?name=") assert isinstance(results, dict) assert "name" not in results # Whitespace-only ?name= (URL-encoded spaces) -- treated as not specified results = AttachBase.parse_url(url="file://path/to/file?name=%20%20%20") assert isinstance(results, dict) assert "name" not in results # Directory-only path (no filename component) -- treated as not specified results = AttachBase.parse_url(url="file://path/to/file?name=/") assert isinstance(results, dict) assert "name" not in results caronc-apprise-dfec414/tests/test_attach_file.py000066400000000000000000000260131526720371000221370ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from os.path import dirname, join import re import time from unittest import mock import urllib import pytest from apprise import AppriseAttachment, exception from apprise.attachment.base import AttachBase from apprise.attachment.file import AttachFile from apprise.common import ContentLocation logging.disable(logging.CRITICAL) TEST_VAR_DIR = join(dirname(__file__), "var") def test_attach_file_parse_url(): """ API: AttachFile().parse_url() """ # bad entry assert AttachFile.parse_url("garbage://") is None # no file path specified assert AttachFile.parse_url("file://") is None def test_file_expiry(tmpdir): """ API: AttachFile Expiry """ path = join(TEST_VAR_DIR, "apprise-test.gif") image = tmpdir.mkdir("apprise_file").join("test.jpg") with open(path, "rb") as data: image.write(data) aa = AppriseAttachment.instantiate(str(image), cache=30) # Our file is now available assert aa.exists() # Our second call has the file already downloaded, but now compares # it's date against when we consider it to have expire. We're well # under 30 seconds here (our set value), so this will succeed assert aa.exists() with mock.patch("time.time", return_value=time.time() + 31): # This will force a re-download as our cache will have # expired assert aa.exists() with mock.patch("time.time", side_effect=OSError): # We will throw an exception assert aa.exists() def test_attach_mimetype(): """ API: AttachFile MimeType() """ # Simple gif test path = join(TEST_VAR_DIR, "apprise-test.gif") response = AppriseAttachment.instantiate(path) assert isinstance(response, AttachFile) assert response.path == path assert response.name == "apprise-test.gif" assert response.mimetype == "image/gif" # Force mimetype response._mimetype = None response.detected_mimetype = None assert response.mimetype == "image/gif" response._mimetype = None response.detected_mimetype = None with mock.patch("mimetypes.guess_type", side_effect=TypeError): assert response.mimetype == "application/octet-stream" def test_attach_file(): """ API: AttachFile() """ # Simple gif test path = join(TEST_VAR_DIR, "apprise-test.gif") response = AppriseAttachment.instantiate(path) assert isinstance(response, AttachFile) assert response.path == path assert response.name == "apprise-test.gif" assert response.mimetype == "image/gif" # Download is successful and has already been called by now; below pulls # results from cache assert response.download() with mock.patch("os.path.isfile", side_effect=OSError): assert response.exists() is False with mock.patch("os.path.isfile", return_value=False): assert response.exists() is False # Test that our file exists assert response.exists() is True response.cache = True # Leverage always-cached flag assert response.exists() is True # On Windows, it is: # `file://D%3A%5Ca%5Capprise%5Capprise%5Ctest%5Cvar%5Capprise-test.gif` path_in_url = urllib.parse.quote(path) assert response.url().startswith(f"file://{path_in_url}") # No mime-type and/or filename over-ride was specified, so it # won't show up in the generated URL assert re.search(r"[?&]mime=", response.url()) is None assert re.search(r"[?&]name=", response.url()) is None # Test case where location is simply set to INACCESSIBLE # Below is a bad example, but it proves the section of code properly works. # Ideally a server admin may wish to just disable all File based # attachments entirely. In this case, they simply just need to change the # global singleton at the start of their program like: # # import apprise # apprise.attachment.AttachFile.location = \ # apprise.ContentLocation.INACCESSIBLE # response = AppriseAttachment.instantiate(path) assert isinstance(response, AttachFile) response.location = ContentLocation.INACCESSIBLE assert response.path is None # Downloads just don't work period assert response.download() is False # File handling (even if image is set to maxium allowable) response = AppriseAttachment.instantiate(path) assert isinstance(response, AttachFile) with mock.patch("os.path.getsize", return_value=AttachBase.max_file_size): # It will still work assert response.path == path # File handling when size is to large response = AppriseAttachment.instantiate(path) assert isinstance(response, AttachFile) with mock.patch( "os.path.getsize", return_value=AttachBase.max_file_size + 1 ): # We can't work in this case assert response.path is None # File handling when image is not available response = AppriseAttachment.instantiate(path) assert isinstance(response, AttachFile) with mock.patch("os.path.isfile", return_value=False): # This triggers a full check and will fail the isfile() check assert response.path is None # The call to AttachBase.path automatically triggers a call to download() # but this same is done with a call to AttachBase.name as well. Above # test cases reference 'path' right after instantiation; here we reference # 'name' response = AppriseAttachment.instantiate(path) assert response.name == "apprise-test.gif" assert response.path == path assert response.mimetype == "image/gif" # No mime-type and/or filename over-ride was specified, so therefore it # won't show up in the generated URL assert re.search(r"[?&]mime=", response.url()) is None assert re.search(r"[?&]name=", response.url()) is None # continuation to cheking 'name' instead of 'path' first where our call # to download() fails response = AppriseAttachment.instantiate(path) assert isinstance(response, AttachFile) with mock.patch("os.path.isfile", return_value=False): # This triggers a full check and will fail the isfile() check assert response.name is None # The call to AttachBase.path automatically triggers a call to download() # but this same is done with a call to AttachBase.mimetype as well. Above # test cases reference 'path' right after instantiation; here we reference # 'mimetype' response = AppriseAttachment.instantiate(path) assert response.mimetype == "image/gif" assert response.name == "apprise-test.gif" assert response.path == path # No mime-type and/or filename over-ride was specified, so therefore it # won't show up in the generated URL assert re.search(r"[?&]mime=", response.url()) is None assert re.search(r"[?&]name=", response.url()) is None # continuation to cheking 'name' instead of 'path' first where our call # to download() fails response = AppriseAttachment.instantiate(path) assert isinstance(response, AttachFile) with mock.patch("os.path.isfile", return_value=False): # download() fails so we don't have a mimetpe assert response.mimetype is None assert response.name is None assert response.path is None # This triggers a full check and will fail the isfile() check # Force a mime-type and new name response = AppriseAttachment.instantiate( "file://{}?mime={}&name={}".format(path, "image/jpeg", "test.jpeg") ) assert isinstance(response, AttachFile) assert response.path == path assert response.name == "test.jpeg" assert response.mimetype == "image/jpeg" assert re.search(r"[?&]mime=image/jpeg", response.url(), re.I) assert re.search(r"[?&]name=test\.jpeg", response.url(), re.I) # Test hosted configuration and that we can't add a valid file aa = AppriseAttachment(location=ContentLocation.HOSTED) # No entries defined yet assert bool(aa) is False assert aa.sync() is False # Entry count does not impact sync if told to act that way assert aa.sync(abort_if_empty=False) is True assert aa.add(path) is False response = AppriseAttachment.instantiate(path) assert len(response) > 0 # Get file assert response.download() # Test the inability to get our file size with mock.patch("os.path.getsize", side_effect=(0, OSError)): assert len(response) == 0 # get file again assert response.download() with mock.patch("os.path.isfile", return_value=True): response.cache = True with mock.patch("os.path.getsize", side_effect=OSError): assert len(response) == 0 def test_attach_file_base64(): """ API: AttachFile() with base64 encoding """ # Simple gif test path = join(TEST_VAR_DIR, "apprise-test.gif") response = AppriseAttachment.instantiate(path) assert isinstance(response, AttachFile) assert response.name == "apprise-test.gif" assert response.mimetype == "image/gif" # now test our base64 output assert isinstance(response.base64(), str) # No encoding if we choose assert isinstance(response.base64(encoding=None), bytes) # Error cases: with ( mock.patch("os.path.isfile", return_value=False), pytest.raises(exception.AppriseFileNotFound), ): response.base64() with mock.patch( "builtins.open", new_callable=mock.mock_open, read_data="mocked file content", ) as mock_file: mock_file.side_effect = FileNotFoundError with pytest.raises(exception.AppriseFileNotFound): response.base64() mock_file.side_effect = OSError with pytest.raises(exception.AppriseDiskIOError): response.base64() caronc-apprise-dfec414/tests/test_attach_http.py000066400000000000000000000604761526720371000222120ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import errno import logging import mimetypes from os.path import dirname, getsize, join import re from typing import ClassVar from unittest import mock import pytest import requests from apprise import Apprise, AppriseAttachment, NotificationManager, exception from apprise.attachment.http import AttachHTTP from apprise.common import ContentLocation from apprise.plugins import NotifyBase logging.disable(logging.CRITICAL) TEST_VAR_DIR = join(dirname(__file__), "var") # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() # Some exception handling we'll use REQUEST_EXCEPTIONS = ( requests.ConnectionError(0, "requests.ConnectionError() not handled"), requests.RequestException(0, "requests.RequestException() not handled"), requests.HTTPError(0, "requests.HTTPError() not handled"), requests.ReadTimeout(0, "requests.ReadTimeout() not handled"), requests.TooManyRedirects(0, "requests.TooManyRedirects() not handled"), # Throw OSError exceptions too OSError("SystemError"), ) def test_attach_http_parse_url(): """ API: AttachHTTP().parse_url() """ # bad entry assert AttachHTTP.parse_url("garbage://") is None # no url specified assert AttachHTTP.parse_url("http://") is None def test_attach_http_encoded_path(): """AttachHTTP() keeps an encoded path intact through url().""" # A GitLab style file path with encoded slashes url = "https://example.com/api/v4/projects/my%2Frepo/files/a%2Fb.txt" results = AttachHTTP.parse_url(url) attach = AttachHTTP(**results) assert attach.fullpath == "/api/v4/projects/my%2Frepo/files/a%2Fb.txt" # url() does not encode the path a second time assert attach.url().startswith(url + "?") def test_attach_http_query_string_dictionary(): """ API: AttachHTTP() Query String Dictionary """ # Set verify off results = AttachHTTP.parse_url("http://localhost?verify=no&rto=9&cto=8") assert isinstance(results, dict) # Create our object obj = AttachHTTP(**results) assert isinstance(obj, AttachHTTP) # verify is disabled and therefore set assert re.search(r"[?&]verify=no", obj.url()) # Our connect timeout flag is set since it differs from the default assert re.search(r"[?&]cto=8", obj.url()) # Our read timeout flag is set since it differs from the default assert re.search(r"[?&]rto=9", obj.url()) # Now lets create a URL with a custom Query String entry # some custom qsd entries specified results = AttachHTTP.parse_url("http://localhost?dl=1&_var=test") assert isinstance(results, dict) # Create our object obj = AttachHTTP(**results) assert isinstance(obj, AttachHTTP) # verify is not in the URL as it is implied (default) assert not re.search(r"[?&]verify=yes", obj.url()) # But now test that our custom arguments have also been set assert re.search(r"[?&]dl=1", obj.url()) assert re.search(r"[?&]_var=test", obj.url()) @mock.patch("requests.request") @mock.patch("requests.get") def test_attach_http(mock_get, mock_request): """ API: AttachHTTP() object """ # Define our good:// url class GoodNotification(NotifyBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True def url(self): # Support url() function return "" # Store our good notification in our schema map N_MGR["good"] = GoodNotification # Temporary path path = join(TEST_VAR_DIR, "apprise-test.gif") class DummyResponse: """A dummy response used to manage our object.""" status_code = requests.codes.ok headers: ClassVar[dict[str, str]] = { "Content-Length": getsize(path), "Content-Type": "text/plain", } # Pointer to file ptr = None # used to return random keep-alive chunks _keepalive_chunk_ref = 0 def close(self): return def iter_content(self, chunk_size=1024): """Lazy function (generator) to read a file piece by piece. Default chunk size: 1k. """ while True: self._keepalive_chunk_ref += 1 if 16 % self._keepalive_chunk_ref == 0: # Yield a keep-alive block yield "" data = self.ptr.read(chunk_size) if not data: break yield data def raise_for_status(self): return def __enter__(self): self.ptr = open(path, "rb") return self def __exit__(self, *args, **kwargs): self.ptr.close() # Prepare Mock dummy_response = DummyResponse() mock_get.return_value = dummy_response # Test custom url get parameters results = AttachHTTP.parse_url( "http://user:pass@localhost/apprise.gif?DL=1&cache=300" ) assert isinstance(results, dict) attachment = AttachHTTP(**results) assert isinstance(attachment.url(), str) is True # Test that our extended variables are passed along assert mock_get.call_count == 0 assert attachment assert mock_get.call_count == 1 assert "params" in mock_get.call_args_list[0][1] assert "DL" in mock_get.call_args_list[0][1]["params"] # Verify that arguments that are reserved for apprise are not # passed along assert "cache" not in mock_get.call_args_list[0][1]["params"] with mock.patch("os.unlink", side_effect=OSError()): # Test invalidation with exception thrown attachment.invalidate() results = AttachHTTP.parse_url( "http://user:pass@localhost/apprise.gif?+key=value&cache=True" ) assert isinstance(results, dict) attachment = AttachHTTP(**results) assert isinstance(attachment.url(), str) is True # No mime-type and/or filename over-ride was specified, so therefore it # won't show up in the generated URL assert re.search(r"[?&]mime=", attachment.url()) is None assert re.search(r"[?&]name=", attachment.url()) is None # No Content-Disposition; so we use filename from path assert attachment.name == "apprise.gif" # Format is text/plain because of the Content-Type in the HTTP Query assert attachment.mimetype == "text/plain" # To get our desired effect, we'd have to have had to detect everything attachment.detected_mimetype = None attachment._mimetype = None # Now a call would yield a detected result that we'd agree with: assert attachment.mimetype == "image/gif" # Detect the MIME type again without a Content-Type header. # we would have had a different result; the below forces it to detect it # again: attachment.detected_mimetype = None # Now we get what we would have expected: assert attachment.mimetype == "image/gif" results = AttachHTTP.parse_url( "http://localhost:3000/noname.gif?name=usethis.jpg&mime=image/jpeg" ) assert isinstance(results, dict) attachment = AttachHTTP(**results) assert isinstance(attachment.url(), str) is True # both mime and name over-ridden assert re.search(r"[?&]mime=image/jpeg", attachment.url()) assert re.search(r"[?&]name=usethis.jpg", attachment.url()) # No Content-Disposition; so we use filename from path assert attachment.name == "usethis.jpg" assert attachment.mimetype == "image/jpeg" # Edge case; download called a second time when content already retrieved assert attachment.download() assert attachment assert len(attachment) == getsize(path) # Administrators can disable all HTTP attachments globally: # # import apprise # apprise.attachment.AttachHTTP.location = \ # apprise.ContentLocation.INACCESSIBLE attachment = AttachHTTP(**results) attachment.location = ContentLocation.INACCESSIBLE assert attachment.path is None # Downloads are blocked when HTTP attachments are inaccessible. assert attachment.download() is False # No path specified # No Content-Disposition specified # No filename (because no path) results = AttachHTTP.parse_url("http://localhost") assert isinstance(results, dict) attachment = AttachHTTP(**results) assert isinstance(attachment.url(), str) is True # No mime-type and/or filename over-ride was specified, so therefore it # won't show up in the generated URL assert re.search(r"[?&]mime=", attachment.url()) is None assert re.search(r"[?&]name=", attachment.url()) is None # Format is text/plain because of the Content-Type in the HTTP Query assert attachment.mimetype == "text/plain" # To get our desired effect, we'd have to have had to detect everything attachment.detected_mimetype = None attachment._mimetype = None # Now we are unable to detect our file without enough data to do it with assert attachment.mimetype == "application/octet-stream" # Because we could determine our mime type, we could build an extension # for our unknown filename assert ( attachment.name == f"{AttachHTTP.unknown_filename}" f"{mimetypes.guess_extension(attachment.mimetype)}" ) assert attachment assert len(attachment) == getsize(path) # Set Content-Length to a value that exceeds our maximum allowable dummy_response.headers["Content-Length"] = AttachHTTP.max_file_size + 1 results = AttachHTTP.parse_url("http://localhost/toobig.jpg") assert isinstance(results, dict) attachment = AttachHTTP(**results) # we can not download this attachment assert not attachment assert isinstance(attachment.url(), str) is True # No mime-type and/or filename over-ride was specified, so therefore it # won't show up in the generated URL assert re.search(r"[?&]mime=", attachment.url()) is None assert re.search(r"[?&]name=", attachment.url()) is None assert attachment.mimetype is None assert attachment.name is None assert len(attachment) == 0 # Handle cases where we have no Content-Length and we need to rely # on what is read as it is streamed del dummy_response.headers["Content-Length"] # No path specified # No Content-Disposition specified # No Content-Length specified # No filename (because no path) results = AttachHTTP.parse_url("http://localhost/no-length.gif") assert isinstance(results, dict) attachment = AttachHTTP(**results) assert isinstance(attachment.url(), str) is True # No mime-type and/or filename over-ride was specified, so therefore it # won't show up in the generated URL assert re.search(r"[?&]mime=", attachment.url()) is None assert re.search(r"[?&]name=", attachment.url()) is None # Format is text/plain because of the Content-Type in the HTTP Query assert attachment.mimetype == "text/plain" # Because we could determine our mime type, we could build an extension # for our unknown filename assert attachment.name == "no-length.gif" assert attachment assert len(attachment) == getsize(path) # Set our limit to be the length of our image; everything should work # without a problem max_file_size = AttachHTTP.max_file_size AttachHTTP.max_file_size = getsize(path) # Set ourselves a Content-Disposition (providing a filename) dummy_response.headers["Content-Disposition"] = ( 'attachment; filename="myimage.gif"' ) # Remove our content type so we're forced to guess it from our filename # specified in our Content-Disposition del dummy_response.headers["Content-Type"] # No path specified # No Content-Length specified # Filename in Content-Disposition (over-rides one found in path results = AttachHTTP.parse_url("http://user@localhost/ignore-filename.gif") assert isinstance(results, dict) attachment = AttachHTTP(**results) assert isinstance(attachment.url(), str) is True # No mime-type and/or filename over-ride was specified, so therefore it # won't show up in the generated URL assert re.search(r"[?&]mime=", attachment.url()) is None assert re.search(r"[?&]name=", attachment.url()) is None assert attachment.mimetype == "image/gif" # Because we could determine our mime type, we could build an extension # for our unknown filename assert attachment.name == "myimage.gif" assert attachment assert len(attachment) == getsize(path) # Similar to test above except we make our max message size just 1 byte # smaller then our gif file. This will cause us to fail to read the # attachment AttachHTTP.max_file_size = getsize(path) - 1 results = AttachHTTP.parse_url("http://localhost/toobig.jpg") assert isinstance(results, dict) attachment = AttachHTTP(**results) # we can not download this attachment assert not attachment assert isinstance(attachment.url(), str) is True # No mime-type and/or filename over-ride was specified, so therefore it # won't show up in the generated URL assert re.search(r"[?&]mime=", attachment.url()) is None assert re.search(r"[?&]name=", attachment.url()) is None assert attachment.mimetype is None assert attachment.name is None assert len(attachment) == 0 # Disable our file size limitations AttachHTTP.max_file_size = 0 results = AttachHTTP.parse_url("http://user@localhost") assert isinstance(results, dict) attachment = AttachHTTP(**results) assert isinstance(attachment.url(), str) is True # No mime-type and/or filename over-ride was specified, so therefore it # won't show up in the generated URL assert re.search(r"[?&]mime=", attachment.url()) is None assert re.search(r"[?&]name=", attachment.url()) is None assert attachment.mimetype == "image/gif" # Because we could determine our mime type, we could build an extension # for our unknown filename assert attachment.name == "myimage.gif" assert attachment assert len(attachment) == getsize(path) # Set our header up with an invalid Content-Length; we can still process # this data. It just means we track it lower when reading back content dummy_response.headers = {"Content-Length": "invalid"} results = AttachHTTP.parse_url("http://localhost/invalid-length.gif") assert isinstance(results, dict) attachment = AttachHTTP(**results) assert isinstance(attachment.url(), str) is True # No mime-type and/or filename over-ride was specified, so therefore it # won't show up in the generated URL assert re.search(r"[?&]mime=", attachment.url()) is None assert re.search(r"[?&]name=", attachment.url()) is None assert attachment.mimetype == "image/gif" # Because we could determine our mime type, we could build an extension # for our unknown filename assert attachment.name == "invalid-length.gif" assert attachment # Give ourselves nothing to work with dummy_response.headers = {} results = AttachHTTP.parse_url("http://user@localhost") assert isinstance(results, dict) attachment = AttachHTTP(**results) # we can not download this attachment assert attachment assert isinstance(attachment.url(), str) is True # No mime-type and/or filename over-ride was specified, so therefore it # won't show up in the generated URL assert re.search(r"[?&]mime=", attachment.url()) is None assert re.search(r"[?&]name=", attachment.url()) is None # Handle edge-case where detected_name is None for whatever reason attachment.detected_name = None assert attachment.mimetype == attachment.unknown_mimetype assert attachment.name.startswith(AttachHTTP.unknown_filename) assert len(attachment) == getsize(path) # Exception handling mock_get.return_value = None for exception_ in REQUEST_EXCEPTIONS: aa = AppriseAttachment.instantiate( "http://localhost/exception.gif?cache=30" ) assert isinstance(aa, AttachHTTP) mock_get.side_effect = exception_ assert not aa # Restore value AttachHTTP.max_file_size = max_file_size # Multi Message Testing mock_get.side_effect = None mock_get.return_value = DummyResponse() # Prepare our POST response (from notify call) response = requests.Request() response.status_code = requests.codes.ok response.content = "" mock_request.return_value = response mock_get.reset_mock() mock_request.reset_mock() assert mock_get.call_count == 0 apobj = Apprise() assert apobj.add("form://localhost") assert apobj.add("json://localhost") assert apobj.add("xml://localhost") assert len(apobj) == 3 assert ( bool( apobj.notify( body="one attachment split 3 times", attach="http://localhost/test.gif", ) ) is True ) # We posted 3 times assert mock_request.call_count == 3 # We only fetched once and re-used the same fetch for all posts assert mock_get.call_count == 1 mock_get.reset_mock() mock_request.reset_mock() apobj = Apprise() for n in range(10): assert apobj.add(f"json://localhost?:entry={n}&method=post") assert apobj.add(f"form://localhost?:entry={n}&method=post") assert apobj.add(f"xml://localhost?:entry={n}&method=post") assert ( bool( apobj.notify( body="one attachment split 30 times", attach="http://localhost/test.gif", ) ) is True ) # We posted 30 times assert mock_request.call_count == 30 # We only fetched once and re-used the same fetch for all posts assert mock_get.call_count == 1 # # We will test our base64 handling now # mock_get.reset_mock() mock_request.reset_mock() AttachHTTP.max_file_size = getsize(path) # Set ourselves a Content-Disposition (providing a filename) dummy_response.headers["Content-Disposition"] = ( 'attachment; filename="myimage.gif"' ) results = AttachHTTP.parse_url("http://user@localhost/filename.gif") assert isinstance(results, dict) obj = AttachHTTP(**results) # now test our base64 output assert isinstance(obj.base64(), str) # No encoding if we choose assert isinstance(obj.base64(encoding=None), bytes) # Test that a 3xx redirect is rejected when redirect=no is set; a # status-code range guard (300-399) catches what raise_for_status() misses. class RedirectResponse: """Simulates a 301 response returned when allow_redirects=False.""" status_code = requests.codes.moved_permanently headers: ClassVar[dict[str, str]] = {} def raise_for_status(self): return def __enter__(self): return self def __exit__(self, *args, **kwargs): return mock_get.return_value = RedirectResponse() results = AttachHTTP.parse_url( "http://user@localhost/filename.gif?redirect=no" ) assert isinstance(results, dict) obj_no_redir = AttachHTTP(**results) assert obj_no_redir.redirects is False # Do not stream a redirect response when redirects are disabled. assert obj_no_redir.download() is False # Restore for downstream tests mock_get.return_value = dummy_response # Error cases: with mock.patch( "builtins.open", new_callable=mock.mock_open, read_data="mocked file content", ) as mock_file: mock_file.side_effect = FileNotFoundError with pytest.raises(exception.AppriseFileNotFound): obj.base64() mock_file.side_effect = OSError with pytest.raises(exception.AppriseDiskIOError): obj.base64() class SizedResponse: """A dummy response that streams a fixed payload in small chunks.""" status_code = requests.codes.ok headers: ClassVar[dict[str, str]] = {"Content-Type": "text/plain"} # The payload streamed back to the caller payload = b"abcdefghij" def close(self): return def iter_content(self, chunk_size=1024): """Stream our payload back 2 bytes at a time.""" for index in range(0, len(self.payload), 2): yield self.payload[index : index + 2] def raise_for_status(self): return def __enter__(self): return self def __exit__(self, *args, **kwargs): return @mock.patch("requests.get") def test_attach_http_download_dir(mock_get, tmpdir): """AttachHTTP writes downloads into the requested directory.""" mock_get.return_value = SizedResponse() # Point our download at a directory of our own choosing download_dir = str(tmpdir.mkdir("attachments")) results = AttachHTTP.parse_url("http://localhost/file.txt") assert isinstance(results, dict) attachment = AttachHTTP(download_dir=download_dir, **results) assert attachment # Our content lives in the directory we asked for assert dirname(attachment.path) == download_dir assert len(attachment) == len(SizedResponse.payload) # Without a download_dir we fall back to the system temporary directory attachment = AttachHTTP(**results) assert attachment assert dirname(attachment.path) != download_dir @mock.patch("requests.get") def test_attach_http_size_limit(mock_get): """AttachHTTP never writes more than max_file_size bytes.""" mock_get.return_value = SizedResponse() # Track how large the file grew before it was thrown away written = [] invalidate = AttachHTTP.invalidate def record(self): if self._temp_file: # tell() covers buffered writes that have not reached disk yet written.append(self._temp_file.tell()) invalidate(self) results = AttachHTTP.parse_url("http://localhost/file.txt") assert isinstance(results, dict) with mock.patch.object(AttachHTTP, "invalidate", record): attachment = AttachHTTP(**results) # Allow 5 of the 10 bytes on offer attachment.max_file_size = 5 # Our download fails because the payload is over our limit assert attachment.download() is False # Stop before the chunk that would cross the limit. assert written assert all(size <= 5 for size in written) assert written[0] == 4 # A payload landing exactly on our limit is still accepted attachment = AttachHTTP(**results) attachment.max_file_size = len(SizedResponse.payload) assert attachment.download() is True assert len(attachment) == len(SizedResponse.payload) @mock.patch("apprise.attachment.http.NamedTemporaryFile") @mock.patch("requests.get") def test_attach_http_preserves_storage_error(mock_get, mock_temp_file): """Callers can inspect a download failure caused by local storage.""" mock_get.return_value = SizedResponse() storage_error = OSError(errno.ENOSPC, "disk full") mock_temp_file.side_effect = storage_error results = AttachHTTP.parse_url("http://localhost/file.txt") attachment = AttachHTTP(**results) assert attachment.download() is False assert attachment.download_error is storage_error caronc-apprise-dfec414/tests/test_attach_memory.py000066400000000000000000000220511526720371000225260ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import re import urllib import pytest from apprise import AppriseAttachment, exception from apprise.attachment.base import AttachBase from apprise.attachment.memory import AttachMemory from apprise.common import ContentLocation from apprise.exception import AppriseImproperlyConfigured logging.disable(logging.CRITICAL) def test_attach_memory_parse_url(): """ API: AttachMemory().parse_url() """ # Bad Entry assert AttachMemory.parse_url(object) is None # Our filename is detected automatically assert AttachMemory.parse_url("memory://") # pass our content in as a string mem = AttachMemory(content="string") # it loads a string type by default assert mem.mimetype == "text/plain" # Our filename is automatically generated (with .txt) assert re.match(r"^[a-z0-9-]+\.txt$", mem.name, re.I) # open our file with mem as fp: assert fp.getbuffer().nbytes == len(mem) # pass our content in as a string mem = AttachMemory( content="", name="test.html", mimetype="text/html" ) # it loads a string type by default assert mem.mimetype == "text/html" assert mem.name == "test.html" # Stub function assert mem.download() with pytest.raises(AppriseImproperlyConfigured): # garbage in, garbage out AttachMemory(content=3) # Each open call returns an independent pointer to our data. first = mem.open() second = mem.open() assert first is not second assert first.read(1) == b"<" assert second.read() == b"" # Closing one pointer does not affect another or the attachment. with first: assert first.read() == b"html/>" assert first.closed is True assert second.closed is False assert second.read() == b"" # The attachment remains readable after its prior pointers are closed. second.close() with mem.open() as pointer: assert pointer.read() == b"" assert pointer.closed is True with mem.open() as pointer: assert pointer.read() == b"" # pass our content in as a string mem = AttachMemory(content=b"binary-data", name="raw.dat") # it loads a string type by default assert mem.mimetype == "application/octet-stream" assert mem.name == "raw.dat" # pass our content in as a string mem = AttachMemory(content=b"binary-data") # it loads a string type by default assert mem.mimetype == "application/octet-stream" # Our filename is automatically generated (with .dat) assert re.match(r"^[a-z0-9-]+\.dat$", mem.name, re.I) def test_attach_memory(): """ API: AttachMemory() """ # A url we can test with fname = "testfile" url = f"memory:///ignored/path/{fname}" # Simple gif test response = AppriseAttachment.instantiate(url) assert isinstance(response, AttachMemory) # There is no path yet as we haven't written anything to our memory object # yet assert response.path is None assert bool(response) is False with response as memobj: memobj.write(b"content") # Memory object defaults assert response.name == fname assert response.path == response.name assert response.mimetype == "application/octet-stream" assert bool(response) is True # fname_in_url = urllib.parse.quote(response.name) assert response.url().startswith(f"memory://{fname_in_url}") # Mime is always part of url assert re.search(r"[?&]mime=", response.url()) is not None # Test case where location is simply set to INACCESSIBLE # Below is a bad example, but it proves the section of code properly works. # Ideally a server admin may wish to just disable all File based # attachments entirely. In this case, they simply just need to change the # global singleton at the start of their program like: # # import apprise # apprise.attachment.AttachMemory.location = \ # apprise.ContentLocation.INACCESSIBLE # response = AppriseAttachment.instantiate(url) assert isinstance(response, AttachMemory) with response as memobj: memobj.write(b"content") response.location = ContentLocation.INACCESSIBLE assert response.path is None # Downloads just don't work period assert response.download() is False # File handling (even if image is set to maxium allowable) response = AppriseAttachment.instantiate(url) assert isinstance(response, AttachMemory) with response as memobj: memobj.write(b"content") # Memory handling when size is to large response = AppriseAttachment.instantiate(url) assert isinstance(response, AttachMemory) with response as memobj: memobj.write(b"content") # Test case where we exceed our defined max_file_size in memory prev_value = AttachBase.max_file_size AttachBase.max_file_size = len(response) - 1 # We can't work in this case assert response.path is None assert response.download() is False # Restore our file_size AttachBase.max_file_size = prev_value response = AppriseAttachment.instantiate( "memory://apprise-file.gif?mime=image/gif" ) assert isinstance(response, AttachMemory) with response as memobj: memobj.write(b"content") assert response.name == "apprise-file.gif" assert response.path == response.name assert response.mimetype == "image/gif" # No mime-type and/or filename over-ride was specified, so therefore it # won't show up in the generated URL assert re.search(r"[?&]mime=", response.url()) is not None assert "image/gif" in response.url() # Force a mime-type and new name response = AppriseAttachment.instantiate( "memory://{}?mime={}&name={}".format( "ignored.gif", "image/jpeg", "test.jpeg" ) ) assert isinstance(response, AttachMemory) with response as memobj: memobj.write(b"content") assert response.name == "test.jpeg" assert response.path == response.name assert response.mimetype == "image/jpeg" # We will match on mime type now (%2F = /) assert re.search(r"[?&]mime=image/jpeg", response.url(), re.I) assert response.url().startswith("memory://test.jpeg") # Test hosted configuration and that we can't add a valid memory file aa = AppriseAttachment(location=ContentLocation.HOSTED) assert aa.add(response) is False # now test our base64 output assert isinstance(response.base64(), str) # No encoding if we choose assert isinstance(response.base64(encoding=None), bytes) response.invalidate() with pytest.raises(exception.AppriseFileNotFound): response.base64() def test_attach_memory_open_reusable(): """ API: AttachMemory().open() returns a reusable handle """ mem = AttachMemory(content=b"content") # Closing a handle returned by open() (as a `with` block does) # must not affect the attachment itself with mem.open() as fp: assert fp.read() == b"content" # The attachment is still usable afterwards assert bool(mem) is True assert len(mem) == len(b"content") assert mem.exists() is True # A second open() call still works and returns the full content with mem.open() as fp: assert fp.read() == b"content" # Each call to open() hands back an independent object assert mem.open() is not mem.open() def test_attach_memory_invalidate_closed_buffer(): """ API: AttachMemory().invalidate() on an already-closed buffer """ mem = AttachMemory(content=b"content") # Simulate our internal buffer already being closed mem._data.close() # invalidate() must not attempt to truncate a closed buffer mem.invalidate() caronc-apprise-dfec414/tests/test_compat_markdown33.py000066400000000000000000000037321526720371000232320ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Tests for Python-Markdown 3.3 (Rocky Linux 9) compatibility.""" def test_compat_markdown_escape_placeholders(): """Escape placeholders left by older Python-Markdown become valid HTML.""" from markdown.util import ETX, STX from apprise.compat.markdown33 import _UnescapePostprocessor # Python-Markdown 3.3 leaves "\&" and "\<" as numbered placeholders text = f"a {STX}38{ETX} b {STX}60{ETX} c" assert _UnescapePostprocessor().run(text) == "a & b < c" # Text without placeholders is returned as it was assert _UnescapePostprocessor().run("plain") == "plain" caronc-apprise-dfec414/tests/test_compat_py39.py000066400000000000000000000057251526720371000220520ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Tests for Python 3.9 Compatibility.""" from __future__ import annotations from unittest import mock import pytest from apprise.plugins.irc import protocol def test_compat_dataclass_keeps_slots(): """dataclass_compat() passes slots= through on Python 3.10+.""" # protocol.dataclass is a compat wrapper around # apprise.compat.py39._dataclass. with ( mock.patch("apprise.compat.py39._HAS_SLOTS", True), mock.patch("apprise.compat.py39._dataclass") as m, ): sentinel = object() m.return_value = sentinel assert protocol.dataclass(frozen=True, slots=True) is sentinel m.assert_called_once_with(frozen=True, slots=True) def test_compat_dataclass_drops_slots(): """dataclass_compat() drops slots= on Python 3.9.""" with ( mock.patch("apprise.compat.py39._HAS_SLOTS", False), mock.patch("apprise.compat.py39._dataclass") as m, ): sentinel = object() m.return_value = sentinel assert protocol.dataclass(frozen=True, slots=True) is sentinel m.assert_called_once_with(frozen=True) def test_compat_dataclass_errors_not_hidden(): """dataclass_compat() never retries or hides a TypeError.""" with ( mock.patch("apprise.compat.py39._HAS_SLOTS", True), mock.patch("apprise.compat.py39._dataclass") as m, ): m.side_effect = TypeError("boom") with pytest.raises(TypeError): protocol.dataclass(frozen=True, slots=True) # The call was made once, with slots still requested m.assert_called_once_with(frozen=True, slots=True) caronc-apprise-dfec414/tests/test_config_base.py000066400000000000000000002413351526720371000221410ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime, timezone as _tz, tzinfo from inspect import cleandoc # Disable logging for a cleaner testing output import logging import sys from typing import Any from unittest.mock import Mock import pytest from pytest_mock import MockerFixture import requests import yaml from apprise import Apprise, AppriseAsset, AppriseConfig, ConfigFormat from apprise.config import ConfigBase from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.email import NotifyEmail from apprise.utils.time import zoneinfo logging.disable(logging.CRITICAL) @pytest.fixture def requests_remote_config(mocker: MockerFixture) -> Mock: """ Patch requests.post globally. The config loader will still go through its normal HTTP logic, but all outbound GETs will receive controlled in-memory responses. """ def fake_post(url: str, *args: Any, **kwargs: Any) -> requests.Response: if url == "http://localhost:8000/get/test-001": body = cleandoc(""" json://localhost form://localhost """) elif url == "http://localhost:8000/get/test-002": body = cleandoc(""" xml://localhost """) else: pytest.fail(f"Unexpected URL fetched: {url!r}") resp = requests.Response() resp.status_code = requests.codes.ok resp.url = url resp._content = body.encode("utf-8") # type: ignore[attr-defined] resp.encoding = "utf-8" return resp # Patch the actual requests.post symbol that ConfigHTTP uses internally mock_post: Mock = mocker.patch("requests.post", side_effect=fake_post) return mock_post def test_config_base(): """ API: ConfigBase() object """ # invalid types throw exceptions with pytest.raises(AppriseImproperlyConfigured): ConfigBase(**{"format": "invalid"}) # Config format types are not the same as ConfigBase ones with pytest.raises(AppriseImproperlyConfigured): ConfigBase(**{"format": "markdown"}) cb = ConfigBase(**{"format": "yaml"}) assert isinstance(cb, ConfigBase) cb = ConfigBase(**{"format": "text"}) assert isinstance(cb, ConfigBase) # Set encoding cb = ConfigBase(encoding="utf-8", format="text") assert isinstance(cb, ConfigBase) # read is not supported in the base object; only the children assert cb.read() is None # There are no services loaded on a freshly created object assert len(cb.services()) == 0 # Unsupported URLs are not parsed assert ConfigBase.parse_url(url="invalid://") is None # Valid URL & Valid Format results = ConfigBase.parse_url( url="file://relative/path?format=yaml&encoding=latin-1" ) assert isinstance(results, dict) # These are moved into the root assert results.get("format") == "yaml" assert results.get("encoding") == "latin-1" # But they also exist in the qsd location assert isinstance(results.get("qsd"), dict) assert results["qsd"].get("encoding") == "latin-1" assert results["qsd"].get("format") == "yaml" # Valid URL & Invalid Format results = ConfigBase.parse_url( url="file://relative/path?format=invalid&encoding=latin-1" ) assert isinstance(results, dict) # Only encoding is moved into the root assert "format" not in results assert results.get("encoding") == "latin-1" # But they will always exist in the qsd location assert isinstance(results.get("qsd"), dict) assert results["qsd"].get("encoding") == "latin-1" assert results["qsd"].get("format") == "invalid" def test_config_base_detect_config_format(): """ API: ConfigBase.detect_config_format """ # Garbage Handling for garbage in (object(), None, 42): # A response is always correctly returned assert ConfigBase.detect_config_format(garbage) is None # Empty files are valid assert ConfigBase.detect_config_format("") is ConfigFormat.TEXT # Valid Text Configuration assert ( ConfigBase.detect_config_format(""" # A comment line over top of a URL mailto://userb:pass@gmail.com """) is ConfigFormat.TEXT ) # A text file that has semi-colon as comment characters # is valid too assert ( ConfigBase.detect_config_format(""" ; A comment line over top of a URL mailto://userb:pass@gmail.com """) is ConfigFormat.TEXT ) # Valid YAML Configuration assert ( ConfigBase.detect_config_format(""" # A comment line over top of a URL version: 1 """) is ConfigFormat.YAML ) # Just a whole lot of blank lines... assert ConfigBase.detect_config_format("\n\n\n") is ConfigFormat.TEXT # A tag group assignment carries tags instead of a URL, but it is still # TEXT format; detection must not stop at the first one it encounters assert ( ConfigBase.detect_config_format(""" # A group lets one name stand for several others family=me,wife,kids me=mailto://userb:pass@gmail.com """) is ConfigFormat.TEXT ) # A group assignment that carries a priority prefixed tag assert ( ConfigBase.detect_config_format(""" 1:alerts,me=ntfy://ntfy.sh/chris-phone """) is ConfigFormat.TEXT ) # A colon in the key still identifies YAML when no URL or tag list # follows it assert ( ConfigBase.detect_config_format(""" version: 1 """) is ConfigFormat.YAML ) # Invalid Config assert ConfigBase.detect_config_format("3") is None def test_config_base_config_parse(): """ API: ConfigBase.config_parse """ # Garbage Handling for garbage in (object(), None, 42): # A response is always correctly returned result = ConfigBase.config_parse(garbage) # response is a tuple... assert isinstance(result, tuple) # containing 2 items (plugins, config) assert len(result) == 2 # In the case of garbage in, we get garbage out; both lists are empty assert result == ([], []) # Valid Text Configuration result = ConfigBase.config_parse( """ # A comment line over top of a URL mailto://userb:pass@gmail.com """, asset=AppriseAsset(), ) # We expect to parse 1 entry from the above assert isinstance(result, tuple) assert len(result) == 2 # The first element is the number of notification services processed assert len(result[0]) == 1 # If we index into the item, we can check to see the tags associate # with it assert len(result[0][0].tags) == 0 # The second is the number of configuration include lines parsed assert len(result[1]) == 0 # Valid Configuration result = ConfigBase.config_parse( """ # if no version is specified then version 1 is presumed version: 1 # # Define your notification urls: # urls: - pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b - mailto://test:password@gmail.com - json://localhost: - tag: devops, admin """, asset=AppriseAsset(), ) # We expect to parse 2 entries from the above assert isinstance(result, tuple) assert len(result) == 2 assert isinstance(result[0], list) assert len(result[0]) == 3 assert len(result[0][0].tags) == 0 assert len(result[0][1].tags) == 0 assert len(result[0][2].tags) == 2 # Test case where we pass in a bad format result = ConfigBase.config_parse( """ ; A comment line over top of a URL mailto://userb:pass@gmail.com """, config_format="invalid-format", ) # This is not parseable despite the valid text assert isinstance(result, tuple) assert isinstance(result[0], list) assert len(result[0]) == 0 result, _ = ConfigBase.config_parse( """ ; A comment line over top of a URL mailto://userb:pass@gmail.com """, config_format=ConfigFormat.TEXT, ) # Parseable assert isinstance(result, list) assert len(result) == 1 def test_config_base_discord_bug_report_01(): """ API: ConfigBase.config_parse user feedback A Discord report that a tag was not correctly assigned to a URL when presented in the following format urls: - json://myhost: - tag: test userid: test """ result, config = ConfigBase.config_parse( """ urls: - json://myhost: - tag: test userid: test """, asset=AppriseAsset(), ) # We expect to parse 4 entries from the above assert isinstance(result, list) assert isinstance(config, list) assert len(result) == 1 assert len(result[0].tags) == 1 assert "test" in result[0].tags def test_config_base_config_parse_text(): """ API: ConfigBase.config_parse_text object """ # Garbage Handling for garbage in (object(), None, 42): # A response is always correctly returned result = ConfigBase.config_parse_text(garbage) # response is a tuple... assert isinstance(result, tuple) # containing 2 items (plugins, config) assert len(result) == 2 # In the case of garbage in, we get garbage out; both lists are empty assert result == ([], []) # Valid Configuration result, config = ConfigBase.config_parse_text( """ # A completely invalid token on json string (it gets ignored) # but the URL is still valid json://localhost?invalid-token=nodashes # A comment line over top of a URL mailto://userb:pass@gmail.com # Test a URL using it's native format; in this case Ryver https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG # Invalid URL as it's not associated with a plugin # or a native url https://not.a.native.url/ # A line with mulitiple tag assignments to it taga,tagb=kde:// # An include statement to Apprise API with trailing spaces: include http://localhost:8080/notify/apprise # A relative include statement (with trailing spaces) include apprise.cfg """, asset=AppriseAsset(), ) # We expect to parse 4 entries from the above assert isinstance(result, list) assert isinstance(config, list) assert len(result) == 4 assert len(result[0].tags) == 0 # Our last element will have 2 tags associated with it assert len(result[-1].tags) == 2 assert "taga" in result[-1].tags assert "tagb" in result[-1].tags assert len(config) == 2 assert "http://localhost:8080/notify/apprise" in config assert "apprise.cfg" in config # Here is a similar result set however this one has an invalid line # in it which invalidates the entire file result, config = ConfigBase.config_parse_text(""" # A comment line over top of a URL mailto://userc:pass@gmail.com # A line with mulitiple tag assignments to it taga,tagb=windows:// I am an invalid line that does not follow any of the Apprise file rules! """) # We expect to parse 0 entries from the above because the invalid line # invalidates the entire configuration file. This is for security reasons; # we don't want to point at files load content in them just because they # resemble an Apprise configuration. assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 # More invalid data result, config = ConfigBase.config_parse_text(""" # An invalid URL invalid://user:pass@gmail.com # A tag without a url taga= # A very poorly structured url sns://:@/ # Just 1 token provided sns://T1JJ3T3L2/ # Even with the above invalid entries, we can still # have valid include lines include file:///etc/apprise.cfg # An invalid include (nothing specified afterwards) include # An include of a config type we don't support include invalid:// """) # We expect to parse 0 entries from the above assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 # Test case where a comment is on it's own line with nothing else result, config = ConfigBase.config_parse_text("#") # We expect to parse 0 entries from the above assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 # Verify our tagging works when multiple tags are provided result, config = ConfigBase.config_parse_text(""" tag1, tag2, tag3=json://user:pass@localhost """) assert isinstance(result, list) assert len(result) == 1 assert len(result[0].tags) == 3 assert "tag1" in result[0].tags assert "tag2" in result[0].tags assert "tag3" in result[0].tags def test_config_base_config_tag_groups_text(): """ API: ConfigBase.config_tag_groups_text object """ # Valid Configuration result, config = ConfigBase.config_parse_text( """ # Tag assignments groupA, groupB = tagB, tagC # groupB doubles down as it takes the entries initialized above # plus the added ones defined below groupB = tagA, tagB, tagD groupC = groupA, groupB, groupC, tagE # Tag that recursively looks to more tags groupD = groupC # Assigned ourselves groupX = groupX # Set up a recursive loop groupE = groupF groupF = groupE # Set up a larger recursive loop groupG = groupH groupH = groupI groupI = groupJ groupJ = groupK groupK = groupG # Bad assignments groupM = , , , , , = , , , # int's and floats are okay 1 = 2 a = 5 # A comment line over top of a URL 4, groupB = mailto://userb:pass@gmail.com # Tag Assignments tagA,groupB=json://localhost # More Tag Assignments tagC,groupB=xml://localhost # More Tag Assignments groupD=form://localhost """, asset=AppriseAsset(), ) # We expect to parse 4 entries from the above assert isinstance(result, list) assert isinstance(config, list) assert len(result) == 4 # Our first element is our group tags assert len(result[0].tags) == 2 assert "groupb" in result[0].tags assert "4" in result[0].tags # No additional configuration is loaded assert len(config) == 0 apobj = Apprise() assert apobj.add(result) # We match against 1 entry assert len(list(apobj.find("tagA"))) == 1 assert len(list(apobj.find("tagB"))) == 0 assert len(list(apobj.find("groupA"))) == 1 assert len(list(apobj.find("groupB"))) == 3 assert len(list(apobj.find("groupC"))) == 2 assert len(list(apobj.find("groupD"))) == 3 # Invalid Assignment result, config = ConfigBase.config_parse_text(""" # Must have something to equal or it's a bad line group = # A tag Assignments that is never gotten to as the line # above is bad groupD=form://localhost """) # We expect to parse 0 entries from the above assert isinstance(result, list) assert isinstance(config, list) assert len(result) == 0 assert len(config) == 0 # Invalid Assignment result, config = ConfigBase.config_parse_text(""" # Rundant assignment group = group # Our group assignment group=windows:// """) # the redundant assignment does us no harm; but it doesn't grant us any # value either assert isinstance(result, list) assert len(result) == 1 # Our first element is our group tags assert len(result[0].tags) == 1 assert "group" in result[0].tags # There were no include entries defined assert len(config) == 0 # More invalid data result, config = ConfigBase.config_parse_text(""" # A tag without a url or group assignment taga= """) # We expect to parse 0 entries from the above assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 result, config = ConfigBase.config_parse_text(""" # A tag without a url or group assignment taga= %%INVALID """) # We expect to parse 0 entries from the above assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 def test_config_base_config_parse_text_with_url(): """ API: ConfigBase.config_parse_text object_with_url """ # Here is a similar result set however this one has an invalid line # in it which invalidates the entire file result, config = ConfigBase.config_parse_text(""" # Test a URL that has a URL as an argument json://user:pass@localhost?+arg=http://example.com?arg2=1&arg3=3 """) # No tag is parsed, but our URL successfully parses as is assert isinstance(result, list) assert len(result) == 1 assert len(result[0].tags) == 0 # Verify our URL is correctly captured assert "%2Barg=http%3A%2F%2Fexample.com%3Farg2%3D1" in result[0].url() assert "json://user:pass@localhost/" in result[0].url() # There were no include entries defined assert len(config) == 0 # Pass in our configuration again result, config = ConfigBase.config_parse_text(result[0].url()) # Verify that our results repeat themselves assert isinstance(result, list) assert len(result) == 1 assert len(result[0].tags) == 0 assert "%2Barg=http%3A%2F%2Fexample.com%3Farg2%3D1" in result[0].url() assert "json://user:pass@localhost/" in result[0].url() assert len(config) == 0 def test_config_base_config_parse_yaml(): """ API: ConfigBase.config_parse_yaml object """ # general reference used below asset = AppriseAsset() # Garbage Handling for garbage in (object(), None, "", 42): # A response is always correctly returned result = ConfigBase.config_parse_yaml(garbage) # response is a tuple... assert isinstance(result, tuple) # containing 2 items (plugins, config) assert len(result) == 2 # In the case of garbage in, we get garbage out; both lists are empty assert result == ([], []) # Invalid Version result, config = ConfigBase.config_parse_yaml("version: 2a", asset=asset) # Invalid data gets us an empty result set assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 # Invalid Syntax (throws a ScannerError) result, config = ConfigBase.config_parse_yaml( """ # if no version is specified then version 1 is presumed version: 1 urls """, asset=asset, ) # Invalid data gets us an empty result set assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 # Missing url token result, config = ConfigBase.config_parse_yaml( """ # if no version is specified then version 1 is presumed version: 1 """, asset=asset, ) # Invalid data gets us an empty result set assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 # No urls defined result, config = ConfigBase.config_parse_yaml( """ # if no version is specified then version 1 is presumed version: 1 urls: """, asset=asset, ) # Invalid data gets us an empty result set assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 # Invalid url defined result, config = ConfigBase.config_parse_yaml( """ # if no version is specified then version 1 is presumed version: 1 # Invalid URL definition; yet the answer to life at the same time urls: 43 """, asset=asset, ) # Invalid data gets us an empty result set assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 # Invalid url/schema result, config = ConfigBase.config_parse_yaml( """ # if no version is specified then version 1 is presumed version: 1 urls: - invalid:// """, asset=asset, ) # Invalid data gets us an empty result set assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 # Invalid url/schema result, config = ConfigBase.config_parse_yaml( """ # if no version is specified then version 1 is presumed version: 1 urls: - invalid://: - a: b """, asset=asset, ) # Invalid data gets us an empty result set assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 # Invalid url/schema result, config = ConfigBase.config_parse_yaml( """ # Include entry with nothing associated with it include: urls: - just some free text that isn't valid: - a garbage entry to go with it """, asset=asset, ) # Invalid data gets us an empty result set assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 # Invalid url/schema result, config = ConfigBase.config_parse_yaml( """ # if no version is specified then version 1 is presumed version: 1 urls: - not even a proper url """, asset=asset, ) # Invalid data gets us an empty result set assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 # Invalid url/schema result, config = ConfigBase.config_parse_yaml( """ urls: # a very invalid sns entry - sns://T1JJ3T3L2/ - sns://:@/: - invalid: test - sns://T1JJ3T3L2/: - invalid: test - _invalid: Token can not start with an underscore # some strangeness - - - test """, asset=asset, ) # Invalid data gets us an empty result set assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 # Valid Configuration result, config = ConfigBase.config_parse_yaml( """ # if no version is specified then version 1 is presumed version: 1 # Including by dict include: # File includes - file:///absolute/path/ - relative/path # Trailing colon shouldn't disrupt include - http://test.com: # invalid (numeric) - 4 # some strangeness - - - test # # Define your notification urls: # urls: - pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b - mailto://test:password@gmail.com - https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG - https://not.a.native.url/ # A completely invalid token on json string (it gets ignored) # but the URL is still valid - json://localhost?invalid-token=nodashes """, asset=asset, ) # We expect to parse 4 entries from the above # The Ryver one is in a native form and the 4th one is invalid assert isinstance(result, list) assert len(result) == 4 assert len(result[0].tags) == 0 # There were 3 include entries assert len(config) == 3 assert "file:///absolute/path/" in config assert "relative/path" in config assert "http://test.com" in config # Valid Configuration result, config = ConfigBase.config_parse_yaml( """ # A single line include is supported include: http://localhost:8080/notify/apprise urls: # The following generates 1 service - json://localhost: tag: my-custom-tag, my-other-tag # The following also generates 1 service - json://localhost: - tag: my-custom-tag, my-other-tag # How to stack multiple entries (this generates 2): - mailto://user:123abc@yahoo.ca: - to: test@examle.com - to: test2@examle.com # This is an illegal entry; the schema can not be changed schema: json # accidently left a colon at the end of the url; no problem # we'll accept it - mailto://oscar:pass@gmail.com: # A Ryver URL (using Native format); still accepted - https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG: # An invalid URL with colon (ignored) - https://not.a.native.url/: # A telegram entry (returns a None in parse_url()) - tgram://invalid """, asset=asset, ) # We expect to parse 6 entries from the above because the tgram:// entry # would have failed to be loaded assert isinstance(result, list) assert len(result) == 6 assert len(result[0].tags) == 2 # Our single line included assert len(config) == 1 assert "http://localhost:8080/notify/apprise" in config # Global Tags result, config = ConfigBase.config_parse_yaml( """ # Global Tags stacked as a list tag: - admin - devops urls: - json://localhost - dbus:// """, asset=asset, ) # We expect to parse 2 entries from the above assert isinstance(result, list) assert len(result) == 2 # There were no include entries defined assert len(config) == 0 # all entries will have our global tags defined in them for entry in result: assert "admin" in entry.tags assert "devops" in entry.tags # Global Tags result, config = ConfigBase.config_parse_yaml( """ # Global Tags tag: admin, devops urls: # The following tags will get added to the global set - json://localhost: - tag: string-tag, my-other-tag, text # Tags can be presented in this list format too: - dbus://: - tag: - list-tag - dbus """, asset=asset, ) # all entries will have our global tags defined in them for entry in result: assert "admin" in entry.tags assert "devops" in entry.tags # We expect to parse 2 entries from the above assert isinstance(result, list) assert len(result) == 2 # json:// has 2 globals + 3 defined assert len(result[0].tags) == 5 assert "text" in result[0].tags # json:// has 2 globals + 2 defined assert len(result[1].tags) == 4 assert "list-tag" in result[1].tags # There were no include entries defined assert len(config) == 0 # An invalid set of entries result, config = ConfigBase.config_parse_yaml( """ urls: # The following tags will get added to the global set - json://localhost: - - - entry """, asset=asset, ) # We expect to parse 0 entries from the above assert isinstance(result, list) assert len(result) == 0 # There were no include entries defined assert len(config) == 0 # An asset we'll manipulate; set some system flags asset = AppriseAsset(_uid="abc123", _recursion=1) # Global Tags result, config = ConfigBase.config_parse_yaml( """ # Test the creation of our apprise asset object asset: app_id: AppriseTest app_desc: Apprise Test Notifications app_url: http://nuxref.com async_mode: no # System flags should never get set _uid: custom_id _recursion: 100 # Support setting empty values image_url_mask: image_url_logo: image_path_mask: tmp/path # Timezone (supports tz keyword too) tz: America/Montreal # invalid entry theme: - - - entry # Now for some invalid entries invalid: entry __init__: can't be over-ridden nolists: - we don't support these entries - in the apprise object urls: - json://localhost: """, asset=asset, ) # We expect to parse 1 entries from the above assert isinstance(result, list) assert len(result) == 1 # There were no include entries defined assert len(config) == 0 assert asset.app_id == "AppriseTest" assert asset.app_desc == "Apprise Test Notifications" assert asset.app_url == "http://nuxref.com" # Verify our system flags retain only the value they were initialized to assert asset._uid == "abc123" assert asset._recursion == 1 # Boolean types stay boolean assert asset.async_mode is False # Our TimeZone assert isinstance(asset.tzinfo, tzinfo) assert asset.tzinfo.key == zoneinfo("America/Montreal").key # the theme was not updated and remains the same as it was assert asset.theme == AppriseAsset().theme # Empty string assignment assert isinstance(asset.image_url_mask, str) assert asset.image_url_mask == "" assert isinstance(asset.image_url_logo, str) assert asset.image_url_logo == "" # For on-lookers looking through this file; here is a perfectly formatted # YAML configuration file for your reference so you can see it without # all of the errors like the ones identified above result, config = ConfigBase.config_parse_yaml( """ # if no version is specified then version 1 is presumed. Thus this is a # completely optional field. It's a good idea to just add this line because it # will help with future ambiguity (if it ever occurs). version: 1 # Define an Asset object if you wish (Optional) asset: app_id: AppriseTest app_desc: Apprise Test Notifications app_url: http://nuxref.com # An invalid timezone timezone: invalid # Optionally define some global tags to associate with ALL of your # urls below. tag: admin, devops # Define your URLs (Mandatory!) urls: # Either on-line each entry like this: - json://localhost # Or add a colon to the end of the URL where you can optionally provide # over-ride entries. One of the most likely entry to be used here # is the tag entry. This gets extended to the global tag (if defined) # above - xml://localhost: - tag: customer # The more elements you specify under a URL the more times the URL will # get replicated and used. Hence this entry actually could be considered # 2 URLs being called with just the destination email address changed: - mailto://george:password@gmail.com: - to: jason@hotmail.com - to: fred@live.com # Again... to re-iterate, the above mailto:// would actually fire two (2) # separate emails each with a different destination address specified. # Be careful when defining your arguments and differentiating between # when to use the dash (-) and when not to. Each time you do, you will # cause another instance to be created. # Defining more then 1 element to a muti-set is easy, it looks like this: - mailto://jackson:abc123@hotmail.com: - to: jeff@gmail.com tag: jeff, customer - to: chris@yahoo.com tag: chris, customer """, asset=asset, ) # okay, here is how we get our total based on the above (read top-down) # +1 json:// entry # +1 xml:// entry # +2 mailto:// entry to jason@hotmail.com and fred@live.com # +2 mailto:// entry to jeff@gmail.com and chris@yahoo.com # = 6 assert len(result) == 6 # all six entries will have our global tags defined in them for entry in result: assert "admin" in entry.tags assert "devops" in entry.tags # Entries can be directly accessed as they were added # our json:// had no additional tags added; so just the global ones # So just 2; admin and devops (these were already validated above in the # for loop assert len(result[0].tags) == 2 # our xml:// object has 1 tag added (customer) assert len(result[1].tags) == 3 assert "customer" in result[1].tags # You get the idea, here is just a direct mapping to the remaining entries # in the same order they appear above assert len(result[2].tags) == 2 assert len(result[3].tags) == 2 assert len(result[4].tags) == 4 assert "customer" in result[4].tags assert "jeff" in result[4].tags assert len(result[5].tags) == 4 assert "customer" in result[5].tags assert "chris" in result[5].tags # There were no include entries defined assert len(config) == 0 # Valid Configuration (multi inline configuration entries) result, config = ConfigBase.config_parse_yaml( """ # A configuration file that contains 2 includes separated by a comma and/or # space: include: http://localhost:8080/notify/apprise, http://localhost/apprise/cfg """, asset=asset, ) # We will have loaded no results assert isinstance(result, list) assert len(result) == 0 # But our two configuration files will be present: assert len(config) == 2 assert "http://localhost:8080/notify/apprise" in config assert "http://localhost/apprise/cfg" in config # Valid Configuration (another way of specifying more then one include) result, config = ConfigBase.config_parse_yaml( """ # A configuration file that contains 4 includes on their own # lines beneath the keyword `include`: include: http://localhost:8080/notify/apprise http://localhost/apprise/cfg01 http://localhost/apprise/cfg02 http://localhost/apprise/cfg03 """, asset=asset, ) # We will have loaded no results assert isinstance(result, list) assert len(result) == 0 # But our 4 configuration files will be present: assert len(config) == 4 assert "http://localhost:8080/notify/apprise" in config assert "http://localhost/apprise/cfg01" in config assert "http://localhost/apprise/cfg02" in config assert "http://localhost/apprise/cfg03" in config # Test a configuration with an invalid schema with options result, config = ConfigBase.config_parse_yaml( """ urls: - invalid://: tag: 'invalid' :name: 'Testing2' :body: 'test body2' :title: 'test title2' """, asset=asset, ) # We will have loaded no results assert isinstance(result, list) assert len(result) == 0 # Valid Configuration (we allow comma separated entries for # each defined bullet) result, config = ConfigBase.config_parse_yaml( """ # A configuration file that contains 4 includes on their own # lines beneath the keyword `include`: include: - http://localhost:8080/notify/apprise, http://localhost/apprise/cfg01 http://localhost/apprise/cfg02 - http://localhost/apprise/cfg03 """, asset=asset, ) # We will have loaded no results assert isinstance(result, list) assert len(result) == 0 # But our 4 configuration files will be present: assert len(config) == 4 assert "http://localhost:8080/notify/apprise" in config assert "http://localhost/apprise/cfg01" in config assert "http://localhost/apprise/cfg02" in config assert "http://localhost/apprise/cfg03" in config def test_config_base_yaml_includes( requests_remote_config: Mock, ) -> None: """ API: ConfigBase.config_parse_yaml_includes Verify that HTTP include entries are fetched via requests.get and that the remote config bodies are parsed into json:// and xml:// notifiers. """ # general reference used below asset = AppriseAsset() # Initialize our apprise configuration ac = AppriseConfig(asset=asset, recursion=1) # Add our entry ac.add_config( cleandoc(""" # Include our Apprise Configuration from 2 locations include: - http://localhost:8000/get/test-001 - http://localhost:8000/get/test-002 # no further URLs defined """) ) # Force a fresh parse and get the loaded plugin services = ac.services() # the following will return assert len(services) == 3 # representation for NotifyBase subclasses. urls = {n.url() for n in services} # The *exact* URL string may include extra params depending on defaults, # so we check using containment instead of strict equality. assert any(u.startswith("json://localhost") for u in urls) assert any(u.startswith("xml://localhost") for u in urls) assert any(u.startswith("form://localhost") for u in urls) def test_yaml_vs_text_tagging(): """ API: ConfigBase YAML vs TEXT tagging """ yaml_result, _ = ConfigBase.config_parse_yaml(""" urls: - mailtos://lead2gold:yesqbrulvaelyxve@gmail.com: tag: mytag """) assert yaml_result text_result, _ = ConfigBase.config_parse_text(""" mytag=mailtos://lead2gold:yesqbrulvaelyxve@gmail.com """) assert text_result # Now we compare our results and verify they are the same assert len(yaml_result) == len(text_result) assert isinstance(yaml_result[0], NotifyEmail) assert isinstance(text_result[0], NotifyEmail) assert "mytag" in text_result[0] assert "mytag" in yaml_result[0] def test_config_base_config_tag_groups_yaml_01(): """ API: ConfigBase.config_tag_groups_yaml #1 object """ # general reference used below asset = AppriseAsset() # Valid Configuration result, config = ConfigBase.config_parse_yaml( """ # if no version is specified then version 1 is presumed version: 1 groups: - group1: tagB, tagC, tagNotAssigned - group2: - tagA - tagC - group3: - tagD: optional comment - tagA: optional comment #2 # No assignment - group4 # No assignment type 2 - group5: # Integer assignment - group6: 3 - group6: 3, 4, 5, test - group6: 3.5, tagC # Recursion - groupA: groupB - groupB: groupA # And Again... (just because) - groupA: groupB - groupB: groupA # Self assignment - groupX: groupX # Set up a larger recursive loop - groupG: groupH - groupH: groupI, groupJ - groupI: groupJ, groupG - groupJ: groupK, groupH, groupI - groupK: groupG # No tags assigned - groupK: ",, , ," - " , ": ",, , ," # Multi Assignments - groupL, groupM: tagD, tagA - 4, groupN: - tagD - tagE, TagA # Add one more tag to groupL making it different then GroupM by 1 - groupL: tagB # # Define your notification urls: # urls: - form://localhost: - tag: tagA - mailto://test:password@gmail.com: - tag: tagB - xml://localhost: - tag: tagC - json://localhost: - tag: tagD, tagA """, asset=asset, ) # We expect to parse 4 entries from the above assert isinstance(result, list) assert isinstance(config, list) assert len(result) == 4 # Our first element is our group tags assert len(result[0].tags) == 5 assert "group2" in result[0].tags assert "group3" in result[0].tags assert "groupl" in result[0].tags assert "groupm" in result[0].tags assert "taga" in result[0].tags # No additional configuration is loaded assert len(config) == 0 apobj = Apprise() assert apobj.add(result) # We match against 1 entry assert len(list(apobj.find("tagA"))) == 2 assert len(list(apobj.find("tagB"))) == 1 assert len(list(apobj.find("tagC"))) == 1 assert len(list(apobj.find("tagD"))) == 1 assert len(list(apobj.find("group1"))) == 2 assert len(list(apobj.find("group2"))) == 3 assert len(list(apobj.find("group3"))) == 2 assert len(list(apobj.find("group4"))) == 0 assert len(list(apobj.find("group5"))) == 0 # json:// -- group6 -> 4 -> TagA # xml:// -- group6 -> TagC assert len(list(apobj.find("group6"))) == 2 assert len(list(apobj.find("4"))) == 1 assert len(list(apobj.find("groupN"))) == 1 def test_config_base_config_tag_groups_yaml_02(): """ API: ConfigBase.config_tag_groups_yaml #2 object """ # general reference used below asset = AppriseAsset() # Valid Configuration result, config = ConfigBase.config_parse_yaml( """ # if no version is specified then version 1 is presumed version: 1 groups: group1: tagB, tagC, tagNotAssigned group2: - tagA - tagC group3: - tagD: optional comment - tagA: optional comment #2 # No assignment type 2 group5: # Integer assignment (since it's not a list, the last element prevails # and replaces the above); '4' does not get appended as it would in # the event this was a list instead group6: 3 group6: 3, 4, 5, test group6: 3.5, tagC # Recursion groupA: groupB groupB: groupA # And Again... (just because) groupA: groupB groupB: groupA # Self assignment groupX: groupX # Set up a larger recursive loop groupG: groupH groupH: groupI, groupJ groupI: groupJ, groupG groupJ: groupK, groupH, groupI groupK: groupG # No tags assigned groupK: ",, , ," " , ": ",, , ," # Multi Assignments groupL, groupM: tagD, tagA 4, groupN: - tagD - tagE, TagA # Add one more tag to groupL making it different then GroupM by 1 groupL: tagB # # Define your notification urls: # urls: - form://localhost: - tag: tagA - mailto://test:password@gmail.com: - tag: tagB - xml://localhost: - tag: tagC - json://localhost: - tag: tagD, tagA """, asset=asset, ) # We expect to parse 4 entries from the above assert isinstance(result, list) assert isinstance(config, list) assert len(result) == 4 # Our first element is our group tags assert len(result[0].tags) == 5 assert "group2" in result[0].tags assert "group3" in result[0].tags assert "groupl" in result[0].tags assert "groupm" in result[0].tags assert "taga" in result[0].tags # No additional configuration is loaded assert len(config) == 0 apobj = Apprise() assert apobj.add(result) # We match against 1 entry assert len(list(apobj.find("tagA"))) == 2 assert len(list(apobj.find("tagB"))) == 1 assert len(list(apobj.find("tagC"))) == 1 assert len(list(apobj.find("tagD"))) == 1 assert len(list(apobj.find("group1"))) == 2 assert len(list(apobj.find("group2"))) == 3 assert len(list(apobj.find("group3"))) == 2 assert len(list(apobj.find("group4"))) == 0 assert len(list(apobj.find("group5"))) == 0 # NOT json:// -- group6 -> 4 -> TagA (not appended because dict storage) # ^ # | # See: test_config_base_config_tag_groups_yaml_01 (above) # dict storage (as this tests for) causes last entry to # prevail; previous assignments are lost # # xml:// -- group6 -> TagC assert len(list(apobj.find("group6"))) == 1 assert len(list(apobj.find("4"))) == 1 assert len(list(apobj.find("groupN"))) == 1 assert len(list(apobj.find("groupK"))) == 0 def test_config_base_config_parse_yaml_globals(): """ API: ConfigBase.config_parse_yaml globals """ # general reference used below asset = AppriseAsset() # Invalid Syntax (throws a ScannerError) results, config = ConfigBase.config_parse_yaml( cleandoc(""" urls: - jsons://localhost1: - to: jeff@gmail.com tag: jeff, customer cto: 30 rto: 30 verify: no - jsons://localhost2?cto=30&rto=30&verify=no: - to: json@gmail.com tag: json, customer """), asset=asset, ) # Invalid data gets us an empty result set assert isinstance(results, list) # Our results loaded assert len(results) == 2 assert len(config) == 0 # Now verify that our global variables correctly initialized for entry in results: assert entry.verify_certificate is False assert entry.socket_read_timeout == 30 assert entry.socket_connect_timeout == 30 # This test fails on CentOS 8.x so it was moved into it's own function # so it could be bypassed. The ability to use lists in YAML files didn't # appear to happen until later on; it's certainly not available in v3.12 # which was what shipped with CentOS v8 at the time. @pytest.mark.skipif( int(yaml.__version__.split(".")[0]) <= 3, reason="requires pyaml v4.x or higher.", ) def test_config_base_config_parse_yaml_list(): """ API: ConfigBase.config_parse_yaml list parsing """ # general reference used below asset = AppriseAsset() # Invalid url/schema result, config = ConfigBase.config_parse_yaml( """ # no lists... just no urls: [milk, pumpkin pie, eggs, juice] # Including by list is okay include: [file:///absolute/path/, relative/path, http://test.com] """, asset=asset, ) # Invalid data gets us an empty result set assert isinstance(result, list) assert len(result) == 0 # There were 3 include entries assert len(config) == 3 assert "file:///absolute/path/" in config assert "relative/path" in config assert "http://test.com" in config def test_yaml_asset_timezone_and_asset_tokens(tmpdir): """ Covers: valid tz, reserved keys, invalid key, bool coercion, None->"", invalid type for string, and %z formatting path used later by plugins. """ cfg = tmpdir.join("asset-tz.yml") cfg.write( """ version: 1 asset: tz: " america/toronto " # case-insensitive + whitespace cleanup _private: "ignored" # reserved (starts with _) name_: "ignored" # reserved (ends with _) not_a_field: "ignored" # invalid asset key secure_logging: "yes" # string -> bool via parse_bool app_id: null # None becomes empty string app_desc: [ "list" ] # invalid type for string -> warning path urls: - json://localhost """ ) ac = AppriseConfig(paths=str(cfg)) # Force a fresh parse and get the loaded plugin services = ac.services() assert len(services) == 1 plugin = services[0] asset = plugin.asset # tz was accepted and normalised # lower() is required since Mac and Window are not case sensitive and will # See output as it was passed in and not corrected per IANA assert getattr(asset.tzinfo, "key", None).lower() == "america/toronto" # boolean coercion applied assert asset.secure_logging is True # None -> "" assert asset.app_id == "" def test_yaml_asset_language(tmpdir): """A YAML asset may set the language Apprise presents strings in.""" def language_for(content): """Load ``content`` and return the language of its asset.""" cfg = tmpdir.join("asset-lang.yml") cfg.write(content) services = AppriseConfig(paths=str(cfg)).services() assert len(services) == 1 return services[0].asset.language # Accepted, and stored in its normalized form assert ( language_for( """ version: 1 asset: language: " ES-mx " urls: - json://localhost """ ) == "es_MX" ) # lang is accepted as well assert ( language_for( """ version: 1 asset: lang: fr urls: - json://localhost """ ) == "fr" ) # language wins when both are present assert ( language_for( """ version: 1 asset: lang: fr language: de urls: - json://localhost """ ) == "de" ) # Anything we can not work with is ignored, leaving the system language for value in ('"garbage"', "null", "[ a, b ]", "42"): assert ( language_for( f""" version: 1 asset: lang: {value} urls: - json://localhost """ ) is None ) def test_yaml_asset_timezone_invalid_and_precedence(tmpdir): """ If 'timezone' is present but invalid, it takes precedence over 'tz' and MUST NOT set the asset to the 'tz' value. We assert that London was not applied. We deliberately avoid asserting the exact fallback, since environments may surface a system tz (datetime.timezone) that lacks a `.key` attribute. """ cfg = tmpdir.join("asset-tz-invalid.yml") cfg.write( """ version: 1 asset: timezone: null # invalid (will be seen as "None") tz: Europe/London # would be valid, but 'timezone' wins urls: - json://localhost """ ) base_asset = AppriseAsset(timezone="UTC") ac = AppriseConfig(paths=str(cfg)) services = ac.services(asset=base_asset) assert len(services) == 1 tzinfo = services[0].asset.tzinfo # The key assertion: 'tz' MUST NOT have been applied assert getattr(tzinfo, "key", "").lower() != "europe/london" # Sanity check that something sensible is set # Compare offsets at a fixed instant instead of object identity dt = datetime(2024, 1, 1, 12, 0, tzinfo=_tz.utc) assert tzinfo.utcoffset(dt) is not None @pytest.mark.parametrize( "garbage_yaml", [ "123", "3.1415", "true", "[UTC]", "{x: UTC}", ], ) def test_yaml_asset_tz_garbage_types_only(tmpdir, garbage_yaml): """ If only 'tz' is present and it is non-string, it is ignored. We assert it didn't become a real IANA zone (e.g., Europe/London), and that the tzinfo is usable. """ cfg = tmpdir.join("asset-tz-garbage-only.yml") cfg.write( f""" version: 1 asset: tz: {garbage_yaml} # non-string -> warning path urls: - json://localhost """ ) base_asset = AppriseAsset(timezone="UTC") ac = AppriseConfig(paths=str(cfg)) services = ac.services(asset=base_asset) assert len(services) == 1 tzinfo = services[0].asset.tzinfo # 1) Did not “accidentally� become a valid IANA from elsewhere. assert getattr(tzinfo, "key", "").lower() != "europe/london" # 2) tzinfo is usable (offset resolves at a fixed instant). dt = datetime(2024, 1, 1, 12, 0, tzinfo=_tz.utc) assert tzinfo.utcoffset(dt) is not None # also stable tzname resolution assert isinstance(tzinfo.tzname(dt), str) def test_config_base_yaml_missing(monkeypatch): """YAML config loads nothing when PyYAML is not installed.""" # A None entry in sys.modules makes import yaml raise ImportError. monkeypatch.setitem(sys.modules, "yaml", None) assert ConfigBase.config_parse_yaml("urls:\n - json://localhost\n") == ( [], [], ) # URLs and text configuration still work. assert ConfigBase.config_parse_text("json://localhost")[0] def test_config_base_yaml_tag_alias_dict(tmpdir): """ API: ConfigBase.parse_yaml_file (#5) Validate `tags` is accepted as an alias of `tag` in the dict form: - "schema://...": tags: a-tag Also implicitly verifies `tags` does not leak into plugin kwargs, because plugin instantiation would fail if an unexpected kwarg is passed through. """ t = tmpdir.mkdir("tags-alias-dict-form").join("apprise.yml") t.write("""urls: - pover://nsisxnvnqixq39t0cw54pxieyvtdd9@2jevtmstfg5a7hfxndiybasttxxfku: tags: test1 - pover://rg8ta87qngcrkc6t4qbykxktou0uug@tqs3i88xlufexwl8t4asglt4zp5wfn: tags: test2 - pover://jcqgnlyq2oetea4qg3iunahj8d5ijm@evalvutkhc8ipmz2lcgc70wtsm0qpb: tags: test3 """) ac = AppriseConfig(paths=str(t)) # The number of configuration files that exist assert len(ac) == 1 # All entries should load assert len(ac.services()) == 3 a = Apprise() assert a.add(services=ac) is True assert len(a) == 3 # Verify tag matching works assert sum(1 for _ in a.find("no-match")) == 0 assert sum(1 for _ in a.find("all")) == 3 assert sum(1 for _ in a.find("test1")) == 1 assert sum(1 for _ in a.find("test2")) == 1 assert sum(1 for _ in a.find("test3")) == 1 assert sum(1 for _ in a.find("test1, test3")) == 2 def test_config_base_yaml_tag_alias_list(tmpdir): """ API: ConfigBase.parse_yaml_file (#6) Validate `tags` is accepted as an alias of `tag` in the list-of-dicts form: - "schema://...": - tags: a-tag - tags: another-tag Expected behaviour: expands into multiple entries (one per list item), and each is tagged appropriately. """ t = tmpdir.mkdir("tags-alias-list-form").join("apprise.yml") t.write("""urls: - pover://nsisxnvnqixq39t0cw54pxieyvtdd9@2jevtmstfg5a7hfxndiybasttxxfku: - tags: test1 - tags: test2 - pover://jcqgnlyq2oetea4qg3iunahj8d5ijm@evalvutkhc8ipmz2lcgc70wtsm0qpb: - tags: test3 """) ac = AppriseConfig(paths=str(t)) # The number of configuration files that exist assert len(ac) == 1 # First URL expands to 2, second expands to 1 assert len(ac.services()) == 3 a = Apprise() assert a.add(services=ac) is True assert len(a) == 3 # Verify tag matching works across expanded entries assert sum(1 for _ in a.find("test1")) == 1 assert sum(1 for _ in a.find("test2")) == 1 assert sum(1 for _ in a.find("test3")) == 1 assert sum(1 for _ in a.find("test1, test2")) == 2 def test_config_base_yaml_tag_priority(tmpdir): """ API: ConfigBase.parse_yaml_file (#7) Validate priority: when both `tag` and `tags` are present, `tag` wins. This must remain true to preserve the original documented behaviour. """ t = tmpdir.mkdir("tag-priority-over-tags").join("apprise.yml") t.write("""urls: - pover://nsisxnvnqixq39t0cw54pxieyvtdd9@2jevtmstfg5a7hfxndiybasttxxfku: tag: primary tags: secondary """) ac = AppriseConfig(paths=str(t)) # The number of configuration files that exist assert len(ac) == 1 # Entry should load successfully assert len(ac.services()) == 1 a = Apprise() assert a.add(services=ac) is True assert len(a) == 1 # Tag priority check assert sum(1 for _ in a.find("primary")) == 1 assert sum(1 for _ in a.find("secondary")) == 0 @pytest.mark.parametrize( "url", ( "apprise://localhost/mykey?tags=devops", "opsgenie://apikey/?tags=devops", "pagertree://int_abcdefghij?tags=devops", "jira://apikey/?tags=devops", ), ) def test_config_base_yaml_plugin_tags_kept(url): """ API: ConfigBase.config_parse_yaml A plugin's own ?tags= reaches the plugin and never becomes a routing tag. """ text_result, _ = ConfigBase.config_parse_text(url) yaml_result, _ = ConfigBase.config_parse_yaml(f"urls:\n - {url}\n") assert len(text_result) == len(yaml_result) == 1 for result in (text_result[0], yaml_result[0]): # No routing tag was derived from the URL assert not result.tags # The plugin kept its own tags assert "tags=devops" in result.url() # A `tag` key in the YAML sets the routing tag and leaves the plugin's # ?tags= alone yaml_result, _ = ConfigBase.config_parse_yaml( f"urls:\n - {url}:\n tag: local\n" ) assert len(yaml_result) == 1 assert yaml_result[0].tags == {"local"} assert "tags=devops" in yaml_result[0].url() # The list form behaves the same way for every expanded entry yaml_result, _ = ConfigBase.config_parse_yaml( f"urls:\n - {url}:\n - tag: one\n - tag: two\n" ) assert len(yaml_result) == 2 assert {frozenset(r.tags) for r in yaml_result} == { frozenset({"one"}), frozenset({"two"}), } assert all("tags=devops" in r.url() for r in yaml_result) # Global tags are applied as routing tags only yaml_result, _ = ConfigBase.config_parse_yaml( f"tag: global\nurls:\n - {url}\n" ) assert len(yaml_result) == 1 assert yaml_result[0].tags == {"global"} assert "tags=devops" in yaml_result[0].url() def test_config_base_yaml_tags_key_still_alias(): """ API: ConfigBase.config_parse_yaml A `tags` key written in the YAML remains an alias of `tag`, even for a plugin that has its own `tags` argument. """ yaml_result, _ = ConfigBase.config_parse_yaml( "urls:\n - apprise://localhost/mykey:\n tags: local\n" ) assert len(yaml_result) == 1 assert yaml_result[0].tags == {"local"} assert "tags=" not in yaml_result[0].url() def test_yaml_sibling_token_overrides_basic(): """ API: ConfigBase - YAML sibling-key token overrides (basic cases). Keys at the same indentation level as the schema URL key (sibling form) must behave identically to child-indented tokens. Both of these forms must load the same plugin with the same attrs: # sibling form (same indentation) - mailtos://_: smtp: smtp.example.com from: sender@example.com # child form (one level deeper) - mailtos://_: smtp: smtp.example.com from: sender@example.com """ # --- sibling form (the historically broken form) --- result_sibling, _ = ConfigBase.config_parse_yaml(""" urls: - mailtos://_: user: sender@example.com password: secret smtp: smtp.example.com from: sender@example.com to: recipient@example.com tag: myteam """) # --- child form (always worked) --- result_child, _ = ConfigBase.config_parse_yaml(""" urls: - mailtos://_: user: sender@example.com password: secret smtp: smtp.example.com from: sender@example.com to: recipient@example.com tag: myteam """) # Both forms must load exactly one plugin instance assert len(result_sibling) == 1 assert len(result_child) == 1 # Both results must be NotifyEmail instances assert isinstance(result_sibling[0], NotifyEmail) assert isinstance(result_child[0], NotifyEmail) for obj in (result_sibling[0], result_child[0]): # SMTP server from sibling/child token assert obj.smtp_host == "smtp.example.com" # from: maps to from_addr via template_args assert obj.from_addr[1] == "sender@example.com" # to: maps to targets assert any(t[1] == "recipient@example.com" for t in obj.targets) # password: sets the credential directly assert obj.password == "secret" # tag: applies correctly assert "myteam" in obj.tags def test_yaml_sibling_token_priority_over_url(): """ API: ConfigBase - sibling tokens override URL-embedded values. Priority order (lowest to highest): 3 - URL path (user:pass@host:port) 2 - URL query string (?smtp=...) 1 - YAML sibling/child tokens (highest) """ # URL embeds one smtp server; sibling token overrides it result, _ = ConfigBase.config_parse_yaml(""" urls: - mailtos://sender@example.com?smtp=old.smtp.example.com: smtp: new.smtp.example.com to: recipient@example.com tag: priority-test """) assert len(result) == 1 assert isinstance(result[0], NotifyEmail) # Sibling token must win over the URL query-string value assert result[0].smtp_host == "new.smtp.example.com" assert "priority-test" in result[0].tags def test_yaml_child_token_priority_over_sibling(): """ API: ConfigBase - child tokens (indented) override sibling tokens. When a URL entry has BOTH a child value (indented under the key) AND sibling keys, child tokens take priority on a per-key basis. """ result, _ = ConfigBase.config_parse_yaml(""" urls: - mailtos://sender@example.com: smtp: child.smtp.example.com smtp: sibling.smtp.example.com to: recipient@example.com """) assert len(result) == 1 assert isinstance(result[0], NotifyEmail) # Child token (child.smtp.example.com) must beat sibling token assert result[0].smtp_host == "child.smtp.example.com" def test_yaml_sibling_token_with_multiple_targets(): """ API: ConfigBase - multiple targets via sibling 'to:' token. 'to:' in YAML tokens is a kwarg equivalent to ?to= in a URL. Multiple recipients must be expressed as a comma-separated string or as a YAML list under the 'to:' key -- NOT as multiple YAML entries each containing a separate 'to:' (that would create multiple plugin instances rather than one plugin with many targets). Both the comma-separated and YAML-list forms should resolve to a single plugin instance whose 'targets' list contains all recipients. """ # Comma-separated form: equivalent to ?to=user1,user2 in the URL result_csv, _ = ConfigBase.config_parse_yaml(""" urls: - mailtos://sender@example.com: smtp: smtp.example.com to: user1@example.com, user2@example.com tag: shared """) # YAML list form: same semantics, different syntax result_list, _ = ConfigBase.config_parse_yaml(""" urls: - mailtos://sender@example.com: smtp: smtp.example.com to: - user1@example.com - user2@example.com tag: shared """) # Each form must load exactly one plugin instance assert len(result_csv) == 1 assert len(result_list) == 1 for obj in (result_csv[0], result_list[0]): assert isinstance(obj, NotifyEmail) # Sibling smtp: applies to the single instance assert obj.smtp_host == "smtp.example.com" # Both recipients must appear in targets assert any(t[1] == "user1@example.com" for t in obj.targets) assert any(t[1] == "user2@example.com" for t in obj.targets) # Sibling tag: applies correctly assert "shared" in obj.tags def test_yaml_sibling_token_shared_across_list_expansion(): """ API: ConfigBase - sibling tokens propagate to every list-expansion entry. When the URL key has a YAML list as its child value (each list item becomes a separate plugin instance) AND sibling keys supply shared settings, the sibling tokens are applied as a base to every instance before per-entry overrides are merged. This is distinct from 'to: email1, email2' (multiple targets for one instance) -- here each list entry is an independent plugin instance with its own per-entry settings, all sharing the sibling tokens. """ result, _ = ConfigBase.config_parse_yaml(""" urls: - mailtos://sender@example.com: - to: manager@example.com tag: boss - to: coworker@example.com tag: team smtp: smtp.example.com """) # One plugin instance per list entry assert len(result) == 2 assert all(isinstance(r, NotifyEmail) for r in result) # Sibling smtp: must be applied to every instance assert result[0].smtp_host == "smtp.example.com" assert result[1].smtp_host == "smtp.example.com" # Per-entry tags must be respected assert "boss" in result[0].tags assert "team" in result[1].tags def test_yaml_sibling_token_per_entry_overrides_sibling(): """ API: ConfigBase - per-entry tokens override sibling tokens for same key. When a list-expansion entry defines the same key as a sibling token, the per-entry value wins for that instance only. Other instances with no per-entry value for that key still receive the sibling fallback. Scenario: - sibling smtp: smtp1.example.com (shared fallback for all entries) - entry 1: to=manager, tag=boss (no per-entry smtp -> uses sibling) - entry 2: to=coworker, smtp=smtp2, tag=team (overrides sibling smtp) Expected result: 2 instances, each with correct smtp and tags. """ result, _ = ConfigBase.config_parse_yaml(""" urls: - mailtos://sender@example.com: - to: manager@example.com tag: boss - to: coworker@example.com smtp: smtp2.example.com tag: team smtp: smtp1.example.com """) # One instance per list entry assert len(result) == 2 assert all(isinstance(r, NotifyEmail) for r in result) # Instance 0: no per-entry smtp, so sibling smtp1 is used assert any(t[1] == "manager@example.com" for t in result[0].targets) assert result[0].smtp_host == "smtp1.example.com" assert "boss" in result[0].tags # Instance 1: per-entry smtp2 overrides the sibling smtp1 assert any(t[1] == "coworker@example.com" for t in result[1].targets) assert result[1].smtp_host == "smtp2.example.com" assert "team" in result[1].tags def test_yaml_sibling_alias_does_not_overwrite_child_canonical(): """ API: ConfigBase - child alias key wins over sibling canonical key. When a sibling provides a canonical key ('password') and the child dict provides the URL_TOKEN_ALIASES shorthand for the same key ('pass'), the child value must win. Before the fix, both dicts were merged raw before _special_token_handler() ran the URL_TOKEN_ALIASES step. The combined dict contained both 'pass' and 'password', so the alias-conflict branch discarded 'pass' and kept the sibling's 'password' -- the wrong winner. The fix normalizes sibling and child token dicts separately first, so each alias is resolved in the context of its own dict only. After normalization both dicts carry 'password', and the priority merge (sibling as base, child overrides) produces the correct winner. """ result, _ = ConfigBase.config_parse_yaml(""" urls: - mailtos://sender@example.com: pass: child_pass to: recipient@example.com password: sibling_pass """) # One instance created assert len(result) == 1 assert isinstance(result[0], NotifyEmail) # Child 'pass' (alias) must win over sibling 'password' (canonical) assert result[0].password == "child_pass" def test_yaml_alias_populates_null_canonical(): """ API: ConfigBase - alias populates canonical when canonical is null. """ # canonical 'password:' is null (no value) + alias 'pass: secret' # alias must win because null == not provided result, _ = ConfigBase.config_parse_yaml(""" urls: - json://user@localhost: pass: secret password: """) assert len(result) == 1 assert result[0].password == "secret" # Canonical with a real value still wins over the alias result2, _ = ConfigBase.config_parse_yaml(""" urls: - json://user@localhost: pass: alias_val password: real_val """) assert len(result2) == 1 assert result2[0].password == "real_val" def test_yaml_sibling_token_order_independent(): """ API: ConfigBase - sibling tokens are captured regardless of YAML key order. YAML does not guarantee mapping key order. If a sibling token appears before the URL key in the mapping dict (e.g. due to editor or template quirks), it must still be captured. The old dict(it) approach only captured items *after* the URL key in the iterator; the fix uses the full url.items() dict so all non-URL-key items are included. """ # Write the YAML directly so the key order is explicit and no # yaml.dump call is required. smtp appears BEFORE the URL key, # to appears AFTER -- both must be captured as sibling tokens. content = """ urls: - smtp: smtp.example.com mailtos://sender@example.com: to: recipient@example.com """ result, _ = ConfigBase.config_parse_yaml(content) # Both pre-URL (smtp) and post-URL (to) siblings must be applied assert len(result) == 1 assert isinstance(result[0], NotifyEmail) assert result[0].smtp_host == "smtp.example.com" assert any(t[1] == "recipient@example.com" for t in result[0].targets) def test_yaml_sibling_password_token(): """ API: ConfigBase - 'password' and 'pass' keys both set plugin password. YAML tokens bypass parse_url(), so the URL-path shorthand 'pass' must be normalized to 'password' by URL_TOKEN_ALIASES inside _special_token_handler. Verify that both spellings work in sibling and child indentation forms. """ # --- 'password:' sibling form --- result_sibling, _ = ConfigBase.config_parse_yaml(""" urls: - json://testhost: user: alice password: s3cr3t tag: pw-test """) # --- 'password:' child form --- result_child, _ = ConfigBase.config_parse_yaml(""" urls: - json://testhost: user: alice password: s3cr3t tag: pw-test """) # --- 'pass:' sibling form (alias normalization) --- result_pass_sibling, _ = ConfigBase.config_parse_yaml(""" urls: - json://testhost: user: alice pass: s3cr3t tag: pw-test """) # --- 'pass:' child form (alias normalization) --- result_pass_child, _ = ConfigBase.config_parse_yaml(""" urls: - json://testhost: user: alice pass: s3cr3t tag: pw-test """) for result in ( result_sibling, result_child, result_pass_sibling, result_pass_child, ): assert len(result) == 1 for obj in ( result_sibling[0], result_child[0], result_pass_sibling[0], result_pass_child[0], ): assert obj.user == "alice" assert obj.password == "s3cr3t" assert "pw-test" in obj.tags # When both 'pass:' and 'password:' appear, 'password:' (canonical) wins result_both, _ = ConfigBase.config_parse_yaml(""" urls: - json://testhost: user: alice password: canonical pass: shorthand """) assert len(result_both) == 1 assert result_both[0].password == "canonical" def test_yaml_sibling_no_silent_drop_on_null_child(): """ API: ConfigBase - sibling tokens are never silently discarded. Previously, when the URL key had a null child value (the common case when users used same-level indentation), all sibling keys were silently ignored and the URL failed to instantiate. Confirm that the failure mode described in issue #1635 no longer occurs. """ # Mirrors the bug-report YAML (correcting 'rom' typo to 'from') result, _ = ConfigBase.config_parse_yaml(""" urls: - mailtos://_: user: sender@example.com password: secret smtp: smtp.example.com from: sender@example.com to: recipient@example.com tag: - dev - prod """) # Must load successfully (previously raised TypeError and returned []) assert len(result) == 1 assert isinstance(result[0], NotifyEmail) obj = result[0] assert obj.smtp_host == "smtp.example.com" assert obj.from_addr[1] == "sender@example.com" assert any(t[1] == "recipient@example.com" for t in obj.targets) assert "dev" in obj.tags assert "prod" in obj.tags def test_yaml_sibling_token_unknown_schema(): """ API: ConfigBase - sibling tokens with an unregistered schema. When the schema is not in N_MGR the _special_token_handler call inside the 'elif sibling_tokens' branch is skipped (the False arm of 'if schema in N_MGR'). The branch still executes fully; it just applies sibling tokens as-is. Because the schema is unknown no plugin is instantiated and the result list is empty. """ # 'xyzunknown' is not a registered Apprise plugin, so the # 'if schema in N_MGR' guard evaluates to False. result, _ = ConfigBase.config_parse_yaml(""" urls: - xyzunknown://placeholder: mytoken: value otherkey: data """) # The sibling branch ran (no exception), but no plugin loaded assert isinstance(result, list) assert len(result) == 0 def test_yaml_child_dict_schema_key_ignored(): """ API: ConfigBase - 'schema' key in a child dict token is stripped. The schema that determines which plugin is instantiated comes from the URL key itself. Allowing a child dict to override 'schema' via a token would cause the wrong plugin constructor to be called with fields parsed for a different URL type, leading to silent failures. The list-expansion branch explicitly strips 'schema' from each entry. This test verifies the single-child-dict branch does the same. """ result, _ = ConfigBase.config_parse_yaml(""" urls: - mailtos://sender@example.com: schema: json to: recipient@example.com """) # The email plugin must load; the child 'schema: json' must be ignored assert len(result) == 1 assert isinstance(result[0], NotifyEmail) def test_yaml_sibling_before_url_no_spurious_warning(): """ API: ConfigBase - sibling tokens before the URL key emit no warning. YAML mappings do not guarantee key order. A sibling token (e.g. 'smtp:') may appear before the URL key in the serialised YAML. The scan loop that finds the URL key must skip non-schema keys silently; it must NOT emit an 'Ignored entry' warning because those keys are collected later as valid sibling tokens. """ from unittest import mock as _mock with _mock.patch("apprise.config.base.ConfigBase.logger") as mock_log: result, _ = ConfigBase.config_parse_yaml(""" urls: - smtp: smtp.example.com mailtos://sender@example.com: to: recipient@example.com """) # Both siblings are applied; no spurious "Ignored entry" warning fired assert len(result) == 1 assert isinstance(result[0], NotifyEmail) assert result[0].smtp_host == "smtp.example.com" assert any(t[1] == "recipient@example.com" for t in result[0].targets) # Confirm no "Ignored entry" warning was logged for call_args in mock_log.warning.call_args_list: assert "Ignored entry" not in str(call_args) def test_yaml_token_priority_over_qsd(): """ API: ConfigBase - YAML tokens take priority over URL query-string params. The documented priority is: YAML tokens > ?key=value qsd > URL path. A second call to post_process_parse_url_results() runs after YAML tokens are merged. Without stripping qsd first, that call would re-apply qsd overrides and silently overwrite YAML-token values, breaking the documented priority. """ # The URL embeds ?pass= in the query string; the YAML sibling token # 'password:' should take priority and survive to the plugin. result, _ = ConfigBase.config_parse_yaml(""" urls: - json://user:urlpass@localhost?pass=qsdpass: password: yamlpass """) assert len(result) == 1 # YAML token must win over qsd ?pass= and URL-path password assert result[0].password == "yamlpass" def test_yaml_token_priority_over_qsd_non_credential_fields(): """ API: ConfigBase - YAML tokens take priority over qsd for non-credential fields (verify, redirect, etc.). post_process_parse_url_results() processes not only user/password but also verify, redirect, rto, cto, and port from qsd. All of these must honour the same YAML > qsd > URL-path priority rule. The fix strips ALL qsd (not just credential keys) before the second post_process call for URLBase-based plugins, so qsd can never overwrite any YAML-token value regardless of which field it sets. """ # URL has ?verify=no but the YAML sibling says verify: yes -- # YAML token must win; the plugin should have verify_certificate=True result, _ = ConfigBase.config_parse_yaml(""" urls: - json://user:pass@localhost?verify=no: verify: yes """) assert len(result) == 1 assert result[0].verify_certificate is True def test_yaml_reapply_preserves_tags_and_asset(): """The final YAML pass must not undo values assembled by ConfigBase. Both spellings of tags are normalized into one set and combined with the global tags. The asset is supplied by the caller and cannot be replaced by a same-named YAML option. """ asset = AppriseAsset() result, _ = ConfigBase.config_parse_yaml( "tag: global\n" "urls:\n" " - json://localhost/:\n" " tags: local\n" " asset: untrusted-value\n", asset=asset, ) assert len(result) == 1 assert result[0].tags == {"global", "local"} assert result[0].asset is asset def test_yaml_priority_all_urlbase_globals_via_plugin_details(): """ API: ConfigBase - YAML priority verified dynamically for every URLBase-level global arg via plugin.details(). Verify that the priority sequence (YAML > qsd > URL path) is respected for every global arg defined. """ from apprise import plugins from apprise.config.base import N_MGR from apprise.url import URLBase # Discover args via plugin.details() for a minimal URLBase plugin. # json:// has no required service credentials and is always available. details = plugins.details(N_MGR["json"]) # Type-driven conflict pairs: (qsd_str, yaml_str). # YAML should win -- expected value is derived from yaml_str. type_conflict = { "bool": ("no", "yes"), "float": ("5.0", "10.0"), "int": ("5", "10"), } # Intersect plugin.details() args with URLBase.template_args to # isolate the globals shared by every URLBase plugin. # _lookup_default is the URLBase instance attribute name for the arg. urlbase_meta = URLBase.template_args test_cases = [] for name, arg_meta in details["args"].items(): if name not in urlbase_meta: continue arg_type = arg_meta.get("type", "string") if arg_type not in type_conflict: continue attr_name = urlbase_meta[name].get("_lookup_default") if not attr_name: continue qsd_val, yaml_val = type_conflict[arg_type] test_cases.append((name, attr_name, arg_type, qsd_val, yaml_val)) # Guard: the 4 known URLBase globals must always be discovered. # If URLBase.template_args loses an entry this fires immediately. found = {t[0] for t in test_cases} assert found >= {"cto", "redirect", "rto", "verify"}, ( "Expected URLBase globals cto/redirect/rto/verify in " "plugin.details(); got: {}".format(sorted(found)) ) # Build a YAML config that sets every global in both qsd AND as a # YAML sibling token with a conflicting value so the winner is # unambiguous. qsd_str = "&".join("{}={}".format(n, qv) for n, _, _, qv, _ in test_cases) siblings = "\n ".join( "{}: {}".format(n, yv) for n, _, _, _, yv in test_cases ) yaml_cfg = ( "urls:\n - json://user:pass@localhost?{qsd}:\n {siblings}\n" ).format(qsd=qsd_str, siblings=siblings) result, _ = ConfigBase.config_parse_yaml(yaml_cfg) assert len(result) == 1 inst = result[0] # Verify YAML wins for every global. for name, attr_name, arg_type, qsd_val, yaml_val in test_cases: actual = getattr(inst, attr_name) if arg_type == "bool": expected = True # yaml_val is always "yes" elif arg_type == "float": expected = float(yaml_val) elif arg_type == "int": expected = int(yaml_val) else: expected = yaml_val assert actual == expected, ( "YAML token '{}' should override qsd " "(YAML={!r}, qsd={!r}) but got {!r} for '{}'".format( name, yaml_val, qsd_val, actual, attr_name ) ) def test_yaml_url_token_map_to(): """ConfigBase maps named URL parts used as YAML keys.""" result, _ = ConfigBase.config_parse_yaml(""" urls: - discord://1234567890/abcdefghijklmnopqrstuvwxyz/: - tag: test1 botname: App 1 avatar_url: https://localhost/app1.png - tag: test2 botname: App 2 avatar_url: https://localhost/app2.png """) assert len(result) == 2 assert result[0].user == "App 1" assert result[0].avatar_url == "https://localhost/app1.png" assert result[1].user == "App 2" assert result[1].avatar_url == "https://localhost/app2.png" # The sibling (non-indented) form works the same way result, _ = ConfigBase.config_parse_yaml(""" urls: - discord://1234567890/abcdefghijklmnopqrstuvwxyz/: botname: App 3 """) assert len(result) == 1 assert result[0].user == "App 3" # A YAML key that is neither an argument nor a URL token is left alone tokens = ConfigBase._special_token_handler( "discord", {"botname": "App 4", "not-a-token": "ignored"} ) assert tokens["user"] == "App 4" assert "botname" not in tokens assert tokens["not-a-token"] == "ignored" def test_yaml_token_alias_chains(): """ConfigBase follows YAML aliases to constructor arguments.""" # sogs `key` -> `user` (URL token) -> `public_key` (constructor arg) tokens = ConfigBase._special_token_handler("sogs", {"key": "abc"}) assert tokens == {"public_key": "abc"} # The URL token name itself resolves to the same place tokens = ConfigBase._special_token_handler("sogs", {"user": "abc"}) assert tokens == {"public_key": "abc"} # ringcentral `from_phone` (URL token) -> `source` (constructor arg) tokens = ConfigBase._special_token_handler( "ringc", {"from_phone": "+15551234567"} ) assert tokens == {"source": "+15551234567"} def test_yaml_token_multi_alias(): """ConfigBase skips shortcuts that represent several URL values.""" tokens = ConfigBase._special_token_handler( "slack", {"token": "T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ"} ) assert tokens == {} # The same key in a real configuration does not break the parse result, _ = ConfigBase.config_parse_yaml(""" urls: - slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#general: token: ignored """) assert len(result) == 1 caronc-apprise-dfec414/tests/test_config_file.py000066400000000000000000000077061526720371000221500ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from apprise import AppriseAsset from apprise.config.file import ConfigFile from apprise.plugins import NotifyBase logging.disable(logging.CRITICAL) def test_config_file(tmpdir): """ API: ConfigFile() object """ assert ConfigFile.parse_url("garbage://") is None # Test cases where our URL is invalid t = tmpdir.mkdir("testing").join("apprise") t.write("gnome://") assert ConfigFile.parse_url("file://?") is None # Create an Apprise asset we can reference asset = AppriseAsset() # Initialize our object cf = ConfigFile(path=str(t), format="text", asset=asset) # one entry added assert len(cf) == 1 assert isinstance(cf.url(), str) is True # Verify that we're using the same asset assert cf[0].asset is asset # Testing of pop cf = ConfigFile(path=str(t), format="text") ref = cf[0] assert isinstance(ref, NotifyBase) is True ref_popped = cf.pop(0) assert isinstance(ref_popped, NotifyBase) is True assert ref == ref_popped assert len(cf) == 0 # reference to calls on initial reference cf = ConfigFile(path=str(t), format="text") assert isinstance(cf.pop(0), NotifyBase) is True cf = ConfigFile(path=str(t), format="text") assert isinstance(cf[0], NotifyBase) is True # Second reference actually uses cache assert isinstance(cf[0], NotifyBase) is True cf = ConfigFile(path=str(t), format="text") # Itereator creation (nothing needed to assert here) iter(cf) # Second reference actually uses cache iter(cf) # Cache Handling; cache each request for 30 seconds results = ConfigFile.parse_url(f"file://{t!s}?cache=30") assert isinstance(results, dict) cf = ConfigFile(**results) assert isinstance(cf.url(), str) is True assert isinstance(cf.read(), str) is True def test_config_file_exceptions(tmpdir): """ API: ConfigFile() i/o exception handling """ # Test cases where our URL is invalid t = tmpdir.mkdir("testing").join("apprise") t.write("gnome://") # Initialize our object cf = ConfigFile(path=str(t), format="text") # Internal Exception would have been thrown and this would fail with mock.patch("builtins.open", side_effect=OSError): assert cf.read() is None # handle case where the file is to large for what was expected: max_buffer_size = cf.max_buffer_size cf.max_buffer_size = 1 assert cf.read() is None # Restore default value cf.max_buffer_size = max_buffer_size caronc-apprise-dfec414/tests/test_config_http.py000066400000000000000000000300001526720371000221670ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import time from typing import ClassVar from unittest import mock import pytest import requests from apprise import NotificationManager from apprise.common import ConfigFormat from apprise.config.http import ConfigHTTP from apprise.exception import AppriseImproperlyConfigured from apprise.plugins import NotifyBase logging.disable(logging.CRITICAL) # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() # Some exception handling we'll use REQUEST_EXCEPTIONS = ( requests.ConnectionError(0, "requests.ConnectionError() not handled"), requests.RequestException(0, "requests.RequestException() not handled"), requests.HTTPError(0, "requests.HTTPError() not handled"), requests.ReadTimeout(0, "requests.ReadTimeout() not handled"), requests.TooManyRedirects(0, "requests.TooManyRedirects() not handled"), ) @mock.patch("requests.post") def test_config_http(mock_post): """ API: ConfigHTTP() object """ # Define our good:// url class GoodNotification(NotifyBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True def url(self, **kwargs): # Support url() function return "" # Store our good notification in our schema map N_MGR["good"] = GoodNotification # Our default content default_content = """taga,tagb=good://server01""" class DummyResponse: """A dummy response used to manage our object.""" status_code = requests.codes.ok headers: ClassVar[dict[str, str]] = { "Content-Length": str(len(default_content)), "Content-Type": "text/plain", } text = default_content # Pointer to file ptr = None def close(self): return def raise_for_status(self): return def __enter__(self): return self def __exit__(self, *args, **kwargs): return # Prepare Mock dummy_response = DummyResponse() mock_post.return_value = dummy_response assert ConfigHTTP.parse_url("garbage://") is None results = ConfigHTTP.parse_url("http://user:pass@localhost?+key=value") assert isinstance(results, dict) ch = ConfigHTTP(**results) assert isinstance(ch.url(), str) is True assert isinstance(ch.read(), str) is True # one entry added assert len(ch) == 1 # Password-only administrator credentials preserve the empty username. results = ConfigHTTP.parse_url("http://:pass@localhost/path/") assert isinstance(results, dict) ch = ConfigHTTP(**results) assert ch.url(privacy=False).startswith("http://:pass@localhost/path/") assert isinstance(ch.read(), str) is True assert mock_post.call_args.kwargs["auth"] == ("", "pass") results = ConfigHTTP.parse_url("http://localhost:8080/path/") assert isinstance(results, dict) ch = ConfigHTTP(**results) assert isinstance(ch.url(), str) is True assert isinstance(ch.read(), str) is True # one entry added assert len(ch) == 1 # Clear all our mock counters mock_post.reset_mock() # Cache Handling; cache each request for 30 seconds results = ConfigHTTP.parse_url("http://localhost:8080/path/?cache=30") assert mock_post.call_count == 0 assert isinstance(ch.url(), str) is True assert isinstance(results, dict) ch = ConfigHTTP(**results) assert mock_post.call_count == 0 assert isinstance(ch.url(), str) is True assert mock_post.call_count == 0 assert isinstance(ch.read(), str) is True assert mock_post.call_count == 1 # Clear all our mock counters mock_post.reset_mock() # Behind the scenes we haven't actually made a fetch yet. We can consider # our content expired at this point assert ch.expired() is True # Test using boolean check; this will force a remote fetch assert ch # Now a call was made assert mock_post.call_count == 1 mock_post.reset_mock() # Our content hasn't expired yet (it's good for 30 seconds) assert ch.expired() is False assert len(ch) == 1 assert mock_post.call_count == 0 # Test using boolean check; we will re-use our cache and not # make another remote request mock_post.reset_mock() assert ch assert len(ch.services()) == 1 assert len(ch) == 1 # No remote post has been made assert mock_post.call_count == 0 with mock.patch("time.time", return_value=time.time() + 10): # even with 10 seconds elapsed, no fetch will be made assert ch.expired() is False assert ch assert len(ch.services()) == 1 assert len(ch) == 1 # No remote post has been made assert mock_post.call_count == 0 with mock.patch("time.time", return_value=time.time() + 31): # but 30+ seconds from now is considered expired assert ch.expired() is True assert ch assert len(ch.services()) == 1 assert len(ch) == 1 # Our content would have been renewed with a single new fetch assert mock_post.call_count == 1 # one entry added assert len(ch) == 1 # Invalid cache results = ConfigHTTP.parse_url("http://localhost:8080/path/?cache=False") assert isinstance(results, dict) assert isinstance(ch.url(), str) is True results = ConfigHTTP.parse_url("http://localhost:8080/path/?cache=-10") assert isinstance(results, dict) with pytest.raises(AppriseImproperlyConfigured): ch = ConfigHTTP(**results) results = ConfigHTTP.parse_url("http://user@localhost?format=text") assert isinstance(results, dict) ch = ConfigHTTP(**results) assert isinstance(ch.url(), str) is True assert isinstance(ch.read(), str) is True # one entry added assert len(ch) == 1 results = ConfigHTTP.parse_url("https://localhost") assert isinstance(results, dict) ch = ConfigHTTP(**results) assert isinstance(ch.url(), str) is True assert isinstance(ch.read(), str) is True # one entry added assert len(ch) == 1 # Testing of pop ch = ConfigHTTP(**results) ref = ch[0] assert isinstance(ref, NotifyBase) is True ref_popped = ch.pop(0) assert isinstance(ref_popped, NotifyBase) is True assert ref == ref_popped assert len(ch) == 0 # reference to calls on initial reference ch = ConfigHTTP(**results) assert isinstance(ch.pop(0), NotifyBase) is True ch = ConfigHTTP(**results) assert isinstance(ch[0], NotifyBase) is True # Second reference actually uses cache assert isinstance(ch[0], NotifyBase) is True ch = ConfigHTTP(**results) # Itereator creation (nothing needed to assert here) iter(ch) # Second reference actually uses cache iter(ch) # Test a buffer size limit reach ch.max_buffer_size = len(dummy_response.text) assert isinstance(ch.read(), str) is True # Test YAML detection yaml_supported_types = ( "text/yaml", "text/x-yaml", "application/yaml", "application/x-yaml", ) for st in yaml_supported_types: dummy_response.headers["Content-Type"] = st ch.default_config_format = None assert isinstance(ch.read(), str) is True # Set to YAML assert ch.default_config_format == ConfigFormat.YAML # Test TEXT detection text_supported_types = ("text/plain", "text/html") for st in text_supported_types: dummy_response.headers["Content-Type"] = st ch.default_config_format = None assert isinstance(ch.read(), str) is True # Set to TEXT assert ch.default_config_format == ConfigFormat.TEXT # The type is never adjusted to mime types we don't understand ukwn_supported_types = ("text/css", "application/zip") for st in ukwn_supported_types: dummy_response.headers["Content-Type"] = st ch.default_config_format = None assert isinstance(ch.read(), str) is True # Remains unchanged assert ch.default_config_format is None # When the entry is missing; we handle this too del dummy_response.headers["Content-Type"] ch.default_config_format = None assert isinstance(ch.read(), str) is True # Remains unchanged assert ch.default_config_format is None # Restore our content type object for lower tests dummy_response.headers["Content-Type"] = "text/plain" # Take a snapshot max_buffer_size = ch.max_buffer_size ch.max_buffer_size = len(dummy_response.text) - 1 assert ch.read() is None # Restore buffer size count ch.max_buffer_size = max_buffer_size # Test erroneous Content-Length # Our content is still within the limits, so we're okay dummy_response.headers["Content-Length"] = "garbage" assert isinstance(ch.read(), str) is True dummy_response.headers["Content-Length"] = "None" # Our content is still within the limits, so we're okay assert isinstance(ch.read(), str) is True # Handle cases where the content length is exactly at our limit dummy_response.text = "a" * ch.max_buffer_size # This is acceptable assert isinstance(ch.read(), str) is True # If we are over our limit though.. dummy_response.text = "b" * (ch.max_buffer_size + 1) assert ch.read() is None # Test an invalid return code dummy_response.status_code = 400 assert ch.read() is None ch.max_error_buffer_size = 0 assert ch.read() is None # Test that a 3xx redirect is rejected when redirect=no is set; a # status-code range guard (300-399) catches what raise_for_status() misses. class RedirectResponse: """Simulates a 301 response returned when allow_redirects=False.""" status_code = requests.codes.moved_permanently headers: ClassVar[dict[str, str]] = {} text = "" def raise_for_status(self): return def __enter__(self): return self def __exit__(self, *args, **kwargs): return mock_post.return_value = RedirectResponse() results = ConfigHTTP.parse_url("http://localhost/?redirect=no") assert isinstance(results, dict) ch_no_redir = ConfigHTTP(**results) assert ch_no_redir.redirects is False # read() must return None -- returning redirect HTML as config is wrong assert ch_no_redir.read() is None # Restore for downstream tests mock_post.return_value = dummy_response # Exception handling for exception in REQUEST_EXCEPTIONS: mock_post.side_effect = exception assert ch.read() is None # Restore buffer size count ch.max_buffer_size = max_buffer_size caronc-apprise-dfec414/tests/test_config_memory.py000066400000000000000000000043711526720371000225340ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from apprise.config.memory import ConfigMemory logging.disable(logging.CRITICAL) def test_config_memory(): """ API: ConfigMemory() object """ assert ConfigMemory.parse_url("garbage://") is None # Initialize our object cm = ConfigMemory(content="json://localhost", format="text") # one entry added assert len(cm) == 1 # Test general functions assert isinstance(cm.url(), str) assert isinstance(cm.read(), str) # Test situation where an auto-detect is required: cm = ConfigMemory(content="json://localhost") # one entry added assert len(cm) == 1 # Test general functions assert isinstance(cm.url(), str) assert isinstance(cm.read(), str) # Test situation where we can not detect the data assert len(ConfigMemory(content="garbage")) == 0 caronc-apprise-dfec414/tests/test_conversion.py000066400000000000000000005133501526720371000220660ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from inspect import cleandoc # Disable logging for a cleaner testing output import logging from timeit import default_timer from unittest import mock import pytest from apprise import NotifyFormat from apprise.conversion import ( BLOCKQUOTE_DEPTH_MAX, LIST_DEPTH_MAX, MAX_FRAME_DEPTH, HTMLMarkdownConverter, HTMLTagReducer, commonmark as commonmark_module, commonmark_can_close_emphasis, commonmark_can_open_emphasis, commonmark_decode_backslash_escapes, commonmark_emphasis_run, commonmark_find_backtick_run, commonmark_headings_to_bold, commonmark_index_backtick_runs, commonmark_match_emphasis, commonmark_materialize_repair, commonmark_pick_emphasis_sentinel, commonmark_render_emphasis_events, commonmark_render_emphasis_markers, commonmark_repair_chunk, commonmark_scan_autolink_dest, commonmark_scan_closer_runs, commonmark_scan_delimiter_run, commonmark_scan_paren_dest, commonmark_scan_repair_region, commonmark_sections, convert_between, html_to_markdown, markdown_to_html, split_dialect_chunk, truncate_dialect_chunk, ) from apprise.plugins.google_chat import NotifyGoogleChat from apprise.plugins.pushover import PushoverHTMLReducer from apprise.plugins.slack import NotifySlack from apprise.plugins.telegram import TelegramHTMLReducer logging.disable(logging.CRITICAL) def test_conversion_html_to_text(): """conversion: Test HTML to plain text""" def to_html(body): """A function to simply html conversion tests.""" return convert_between(NotifyFormat.HTML, NotifyFormat.TEXT, body) assert to_html("No HTML code here.") == "No HTML code here." clist = to_html("
    • Lots and lots
    • of lists.
    ") assert "Lots and lots" in clist assert "of lists." in clist assert "To be or not to be." in to_html( "
    To be or not to be.
    " ) cspace = to_html( "

    Fancy heading

    And a paragraph too.
    Plus line break.

    " ) assert "Fancy heading" in cspace assert "And a paragraph too.\nPlus line break." in cspace assert ( to_html( "" "

    Some obnoxious text here.

    " ) == "Some obnoxious text here." ) assert ( to_html("

    line 1

    line 2

    line 3

    ") == "line 1\nline 2\nline 3" ) # Case sensitivity assert ( to_html("

    line 1

    line 2

    line 3

    ") == "line 1\nline 2\nline 3" ) # double new lines (testing
    and
    ) assert ( to_html("some information

    and more information") == "some information\n\nand more information" ) # # Test bad tags # # first 2 entries are okay, but last will do as best as it can assert ( to_html("

    line 1

    line 2

    line 3>") == "line 1\nline 2\nline 3>" ) # Make sure we ignore fields that aren't important to us assert ( to_html( "" "

    line 1

    " "Another line without being enclosed" ) == "line 1\nAnother line without being enclosed" ) # Test cases when there are no new lines (we're dealing with just inline # entries); an empty entry as well assert ( to_html("test my link") == "test my link" ) #

    missing assert ( to_html( "
    line 1 bold
    " " my link" "

    3rd line" ) == "line 1 bold\nmy link\n3rd line" ) #


    on it's own assert to_html("
    ") == "---" assert to_html("
    ") == "---" # We need to handle HTML Encodings assert ( to_html(""" ignore this entry Let's handle special html encoding
    """) == "Let's handle special html encoding\n---" ) # If you give nothing, you get nothing in return assert to_html("") == "" # Special case on HR tag assert ( to_html("""

    FROM: apprise-test@mydomain.yyy

    Hi!
    How are you?
    red font link you wanted.
    """) == "FROM: apprise-test@mydomain.yyy\nHi!\n How are you?\n red font" " link you wanted." ) assert ( to_html("""

    FROM: apprise-test@mydomain.yyy


    Hi!
    How are you?
    red font link you wanted.
    """) == "FROM: apprise-test@mydomain.yyy\n---\nHi!\n How are you?\n red" " font link you wanted." ) # Special case on HR if text is sorrunded by HR tags # its created a dict element assert ( to_html("""


    FROM: apprise-test@mydomain.yyy

    Hi!
    How are you?
    red font link you wanted.
    """) == "---\nFROM: apprise-test@mydomain.yyy\n---\nHi!\n How are you?\n" " red font link you wanted." ) assert ( to_html("""


    TEST

    Hi!
    How are you?
    red font link you wanted.
    """) == "---\nTEST\n---\nHi!\n How are you?\n red font link you wanted." ) with pytest.raises(TypeError): # Invalid input assert to_html(None) with pytest.raises(TypeError): # Invalid input assert to_html(42) with pytest.raises(TypeError): # Invalid input assert to_html(object) def test_conversion_html_to_text_pre(): """conversion: Test HTML to plain text keeps
     formatting"""
    
        def to_html(body):
            """A function to simply html conversion tests."""
            return convert_between(NotifyFormat.HTML, NotifyFormat.TEXT, body)
    
        # Line breaks inside 
     are kept
        assert to_html("
    a\nb
    ") == "a\nb" # Line breaks and indentation of the lines after the first are kept trace = ( "Traceback (most recent call last):\n" ' File "x.py", line 1\n' " raise E\n" "E: boom" ) assert to_html(f"
    {trace}
    ") == trace assert to_html(f"
    {trace}
    ") == trace # Text around the block is converted as before assert ( to_html("

    intro

    x\n  y

    outro

    ") == "intro\nx\n y\noutro" ) # Tags nested in
     stay part of the preformatted region
        assert to_html("
    a\n  b
    ") == "a\n b" assert to_html("
    a\n  b\n    c\n  d
    ") == ( "a\n b\n c\n d" ) # Windows line breaks are normalized assert to_html("
    a\r\n  b\r\n    c
    ") == "a\n b\n c" # Whitespace outside of
     is still condensed
        assert to_html("

    x \n y

    a
    b \n c") == "x y\na\nb c" assert to_html("
    a\n b

    c\n d

    ") == "a\n b\nc d" # The Markdown conversion is not affected assert html_to_markdown("
    a\n  b
    ") == "```\na\n b\n```" def test_conversion_html_to_markdown(): """Test basic HTML-to-Markdown conversion.""" def to_md(body): """Wrapper to simplify html-to-markdown conversion tests.""" return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body) # Plain text with no HTML passes through unchanged assert to_md("No HTML code here.") == "No HTML code here." # Empty string in, empty string out assert to_md("") == "" # Paragraphs need a full blank line between them. assert ( to_md("

    line 1

    line 2

    line 3

    ") == "line 1\n\nline 2\n\nline 3" ) # Case sensitivity -- tag names are case-insensitive in HTML assert ( to_md("

    line 1

    line 2

    line 3

    ") == "line 1\n\nline 2\n\nline 3" ) # HTMLParser lowercases ALL tag names -- inline tags included assert to_md("bold text") == "**bold text**" assert to_md("italic") == "*italic*" # Uppercase self-closing
    tags also produce CommonMark hard breaks. assert ( to_md("line one
    line two
    line three") == "line one \nline two \nline three" ) #
    and self-closing
    both emit a hard break assert ( to_md("some information

    and more information") == "some information \n \nand more information" ) # Each heading level maps to the correct number of # characters assert to_md("

    Heading 1

    ") == "# Heading 1" assert to_md("

    Heading 2

    ") == "## Heading 2" assert to_md("

    Heading 3

    ") == "### Heading 3" assert to_md("

    Heading 4

    ") == "#### Heading 4" assert to_md("
    Heading 5
    ") == "##### Heading 5" assert to_md("
    Heading 6
    ") == "###### Heading 6" # Multiple headings and paragraphs together assert to_md( "

    Heading 1

    " "

    Heading 2

    " "

    Heading 3

    " "

    Heading 4

    " "
    Heading 5
    " "
    Heading 6
    " "

    line 1" "

    line 2" "

    line 3>" ) == ( "# Heading 1\n## Heading 2\n### Heading 3\n" "#### Heading 4\n##### Heading 5\n###### Heading 6\n\n" "line 1\n\n*line 2*\n\nline 3\\>" ) # and both produce bold markers assert to_md("bold text") == "**bold text**" assert to_md("bold text") == "**bold text**" # and both produce italic markers assert to_md("italic") == "*italic*" assert to_md("italic") == "*italic*" # Angle destinations prevent parentheses in an href from ending links. assert ( to_md( "

    line 1 bold
    " " my link" "

    3rd line" ) == "line 1 **bold**\n\n[my link]()\n\n3rd line" ) # produces Markdown link syntax assert ( to_md("test my link") == "test [my link](<#>)" ) # with nested inline markup -- the href must survive the child tags assert to_md("hello world") == ( "[**hello** world]()" ) assert to_md("label") == ( "[**label**]()" ) assert ( to_md("italic and plain") == "[*italic* and plain]()" ) # Nested -- inner href wins for its own span; outer wraps the rest assert ( to_md("text link") == "[text [link]()]()" ) # with no href attribute -- content rendered as plain text assert to_md("test no link") == "test no link" # Bare anchor (no href) -- text passes through unchanged assert to_md("jump target") == "jump target" # is inline -- it passes text through without a newline;

    is # block-level and paragraph-like, so it adds a blank line. assert to_md("
    block
    inline") == "block\n\ninline" # HTML comments are stripped entirely; surrounding text is preserved assert to_md(" text") == "text" assert to_md("abc") == "abc" # sections are gracefully ignored (content is dropped, text # outside the CDATA boundary is kept) assert to_md("text here") == "text here" assert to_md("text") == "text" # Inline wraps in backticks without a block boundary assert to_md("func()") == "`func()`" # Markdown special characters inside are NOT escaped -- backtick # delimiters already make content literal assert to_md("x*2 and #tag") == "`x*2 and #tag`" #
     produces a fenced code block
        assert to_md("
    line a\nline b
    ") == "```\nline a\nline b\n```" # is treated the same as
     (fenced block)
        assert to_md("output\nhere") == "```\noutput\nhere\n```"
    
        # Inline code followed immediately by a pre block
        assert to_md(
            "multi-line 1\nmulti-line 2more content"
            "
    multi-line 1\nmulti-line 2
    more content" ) == ( "`multi-line 1\nmulti-line 2`more content" "\n```\nmulti-line 1\nmulti-line 2\n```\nmore content" ) # Unordered lists produce "- " prefixed items result = to_md("
    • Lots and lots
    • of lists.
    ") assert "- Lots and lots" in result assert "- of lists." in result assert ( to_md("
    To be or not to be.
    ") == "> To be or not to be." ) # Standalone
    produces just "---" assert to_md("
    ") == "---" assert to_md("
    ") == "---" # " "

    Some obnoxious text here.

    " ) == "Some obnoxious text here." ) # " "

    line 1

    " "Another line without being enclosed" ) == "line 1\n\nAnother line without being enclosed" ) # '*' outside code is escaped so it does not trigger emphasis assert to_md("

    price: 5 * 3

    ") == r"price: 5 \* 3" # '#' outside code is escaped so it does not trigger a heading assert to_md("

    Tag #1

    ") == r"Tag \#1" # Backtick outside code is escaped assert to_md("

    Use `func`

    ") == r"Use \`func\`" assert ( to_md( """ ignore this entry Let's handle special html encoding
    """ ) == "Let's handle special html encoding\n\n---" ) # Missing

    is handled gracefully assert ( to_md( "

    Heading

    And a paragraph too.
    Plus line break.

    " ) == "## Heading\n\nAnd a paragraph too. \nPlus line break." ) with pytest.raises(TypeError): to_md(None) with pytest.raises(TypeError): to_md(42) with pytest.raises(TypeError): to_md(object) def test_conversion_html_to_markdown_lists(): """Test list nesting and numbering.""" def to_md(body): """Wrapper to simplify html-to-markdown conversion tests.""" return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body) # Flat unordered list assert ( to_md("
    • alpha
    • beta
    • gamma
    ") == "- alpha\n- beta\n- gamma" ) # Flat ordered list with auto-incrementing counter assert ( to_md("
    1. first
    2. second
    3. third
    ") == "1. first\n2. second\n3. third" ) #
      shifts where the auto-incrementing counter begins assert to_md('
      1. a
      2. b
      ') == "4. a\n5. b" assert to_md('
      1. a
      ') == "0. a" # CommonMark list markers cannot represent negative starting values. assert to_md('
      1. a
      2. b
      ') == "0. a\n1. b" # A non-numeric start is ignored, same as no attribute at all assert to_md('
      1. a
      ') == "1. a" #
    1. resets the counter for that item and every sibling after it assert to_md('
      1. a
      2. b
      ') == "5. a\n6. b" assert ( to_md('
      1. a
      2. b
      3. c
      ') == "1. a\n10. b\n11. c" ) assert to_md('
      1. a
      2. b
      ') == "0. a\n1. b" # A non-numeric value is ignored too -- the counter just keeps incrementing # normally, same as if the attribute weren't there assert to_md('
      1. a
      2. b
      ') == "1. a\n2. b" # Nested unordered lists (2 levels) assert ( to_md( "
      • top A
        • nested A
      • top B
      " ) == "- top A\n - nested A\n- top B" ) # Nested unordered lists (3 levels) assert ( to_md("
      • L1
        • L2
          • L3
      ") == "- L1\n - L2\n - L3" ) # Mixed nesting: ol inside ul assert ( to_md( "
        " "
      • intro
          " "
        1. step one
        2. " "
        3. step two
        4. " "
      • " "
      " ) == "- intro\n 1. step one\n 2. step two" ) # Mixed nesting: ul inside ol assert ( to_md( "
        " "
      1. first
          " "
        • sub A
        • " "
        • sub B
        • " "
      2. " "
      3. second
      4. " "
      " ) == "1. first\n - sub A\n - sub B\n2. second" ) # Malformed HTML: missing
    2. in a
        HTMLParser does not synthesize # implicit close events; each missing is simply absent, but the next. assert ( to_md("
        • item A
        • item B
        • item C
        ") == "- item A\n- item B\n- item C" ) # Without , ordered-list counters cannot advance. assert ( to_md("
        1. one
        2. two
        3. three
        ") == "1. one\n1. two\n1. three" ) # Malformed HTML: missing closing
      assert to_md("
      • item A
      • item B
      • ") == "- item A\n- item B" # Malformed HTML: missing AND missing
      assert to_md("
      • item A
      • item B") == "- item A\n- item B" # Malformed HTML: bare text inside
          (no
        • wrapper)
            is in # IGNORE_TAGS so unwrapped text is suppressed entirely assert to_md("
              bare text
            ") == "" # inside
          • : inline code preserved with backticks assert ( to_md("
            • run cmd --flag now
            ") == "- run `cmd --flag` now" ) # Markdown special characters inside are NOT escaped assert ( to_md("
            • see x*2 #tag
            ") == "- see `x*2 #tag`" ) # Fenced blocks retain content and list indentation. assert ( to_md("
            • code:
                indented\n  here
            ") == "- code:\n ```\n indented\n here\n ```" ) # Nested fenced blocks combine content indentation with list indentation. assert ( to_md( "
            • outer
              • inner:
                  x = 1
            " ) == "- outer\n - inner:\n ```\n x = 1\n ```" ) # A first-child block shares the list marker's line. # Single item with a

            first child assert to_md("

            • alpha

            ") == "- alpha" # Multiple items each with a

            first child. assert ( to_md("

            • alpha

            • beta

            ") == "- alpha\n\n- beta" ) # Multiple

            children inside one

          • assert to_md("
            • one

              two

            ") == "- one\n\n two" # Mixed: direct text for first item,

            for second. assert ( to_md("

            • direct
            • wrapped

            ") == "- direct\n- wrapped" ) # Numbered list with

            children assert ( to_md("

            1. first

            2. second

            ") == "1. first\n\n2. second" ) # link as first child of
          • -- the marker must share the line assert to_md("") == "- [link]()" # with nested markup as first (and only) child of
          • assert ( to_md("") == "- [**bold link**]()" ) # link followed by a

            sibling inside the same

          • . assert ( to_md("") == "- [link]()\n\n more" ) def test_conversion_html_to_markdown_escaping(): """Test Markdown escaping.""" def to_md(body): """Wrapper to simplify html-to-markdown conversion tests.""" return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body) # Embedded backticks widen code delimiters. assert to_md("a`b") == "``a`b``" # Content starting or ending with a backtick gets a padding space, per # CommonMark's code-span disambiguation rule assert to_md("`x") == "`` `x ``" assert to_md("x`") == "`` x` ``" # A run of 3 backticks inside
             widens the fence past 3
                assert to_md("
            boom```
            ") == "````\nboom```\n````" # Plain content with no backticks still uses the minimal delimiter assert to_md("
            x
            ") == "```\nx\n```" # Ignored containers suppress both text and Markdown markers. assert ( to_md( "ignore

            keep

            " ) == "keep" ) # A heading marker ("# ") must not leak from a suppressed container assert ( to_md("

            hidden heading

            text") == "text" ) # A list marker ("- ") must not leak from a
          • nested inside a suppressed # container, even though
          • normally re-enables storage assert ( to_md("
            • hidden item
            text") == "text" ) #

            keep

            ") == "keep" # A
             block fully inside a suppressed container emits nothing, not even
                # an empty fence
                assert to_md("

            keep

            ") == "keep" # Angle destinations preserve whitespace in link targets. assert to_md("link") == "[link]()" # Nested tags are inert inside preformatted content. assert ( to_md("
            before link after
            ") == "```\nbefore link after\n```" ) assert ( to_md("before bold after") == "`before bold after`" ) # Stray code/pre/samp close tags assert to_md("
            text") == "text" assert to_md("
            text") == "text" assert to_md("text") == "text" # Markdown/HTML injection via plain text content assert ( to_md("

            Click [here](https://evil.example.com) now

            ") == r"Click \[here\]\(https://evil.example.com\) now" ) assert ( to_md("

            An image ![x](https://evil.example.com/x.png) too

            ") == r"An image \!\[x\]\(https://evil.example.com/x.png\) too" ) # A literal backslash must itself be escaped assert to_md("

            back\\*slash

            ") == r"back\\\*slash" # '_' (CommonMark's other emphasis delimiter) and '~' (GFM/chat- dialect # strikethrough) are escaped unconditionally, the same as '*'. assert to_md("

            _literal_

            ") == r"\_literal\_" assert to_md("

            my_variable_name

            ") == r"my\_variable\_name" assert to_md("

            ~strikethrough~

            ") == r"\~strikethrough\~" # Entity-encoded HTML (decoded to literal "<"/">" text by the parser) must # not survive into the Markdown output unescaped assert ( to_md("

            previously <script>alert(1)</script>

            ") == r"previously \alert\(1\)\" ) # href cannot break out of the link destination assert to_md( '' "legit link" ) == ( "[legit link]" "()" ) # A '<' or '>' inside the href itself must be escaped, since both are # meaningful within the angle-bracket destination form. assert to_md('y') == ( r"[y](>)" ) # Strip line endings before placing a URL in an angle-bracket destination. assert ( to_md('click') == "[click]()" ) # Neutralize schemes that can execute content or expose local files. assert to_md('click me') == ( "[click me](<#>)" ) assert to_md('click') == "[click](<#>)" assert to_md('click') == "[click](<#>)" assert to_md('click') == "[click](<#>)" assert to_md('click') == "[click](<#>)" # Leading or trailing whitespace must not defeat scheme detection. assert to_md('click') == ( "[click](<#>)" ) assert to_md('click') == ( "[click](<#>)" ) assert to_md('click') == ( "[click](<#>)" ) # BiDi override/embedding characters must not defeat scheme detection. assert to_md('click') == ("[click](<#>)") assert to_md('click') == ("[click](<#>)") # U+2066 (LEFT-TO-RIGHT ISOLATE) must also be stripped. assert to_md('click') == ("[click](<#>)") # Keep legitimate app-specific schemes that are not explicitly unsafe. assert to_md('x') == ( "[x]()" ) assert to_md('x') == ( "[x]()" ) assert to_md('x') == ( "[x]()" ) assert to_md('x') == ( "[x]()" ) assert to_md('x') == ( "[x]()" ) assert to_md('x') == ( "[x]()" ) assert to_md('x') == ( "[x]()" ) assert to_md('x') == ("[x]()") assert to_md('x') == "[x](<#anchor>)" def test_conversion_html_to_markdown_line_start_escaping(): """Test block syntax escaping.""" def to_md(body): """Wrapper to simplify html-to-markdown conversion tests.""" return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body) # Escape ordered-list-shaped text only at the start of a line. assert to_md("

            1. Apples

            2. Oranges

            ") == ( "1\\. Apples\n\n2\\. Oranges" ) assert to_md("

            23. done

            ") == "23\\. done" # Match bullet markers at the start of a line. assert to_md("

            - not a bullet, just a dash

            ") == ( "\\- not a bullet, just a dash" ) assert to_md("

            + plus sign list?

            ") == "\\+ plus sign list?" # Escape repeated dashes that could become a thematic break. assert to_md("

            Some text

            ---

            more text

            ") == ( "Some text\n\n\\---\n\nmore text" ) # Escaping the first dash is enough to prevent a thematic break. rendered = convert_between( NotifyFormat.MARKDOWN, NotifyFormat.HTML, to_md("

            Some text

            ---

            more text

            "), ) assert "***

            ") == "\\*\\*\\*" assert to_md("

            ___

            ") == "\\_\\_\\_" assert to_md("

            - - -

            ") == "\\- - -" assert to_md("

            Hello world
            ===

            ") == "Hello world \n\\===" assert to_md("

            Heading
            ---

            ") == "Heading \n\\---" # None of this is ambiguous -- and so isn't escaped -- anywhere other than # a true line start assert to_md("

            well-known v1.2 it is not - really

            ") == ( "well-known v1.2 it is not - really" ) assert to_md("

            price is $5.00

            ") == "price is $5.00" assert to_md("

            a-b-c

            ") == "a-b-c" assert to_md("

            5 - 3 = 2

            ") == "5 - 3 = 2" def test_conversion_html_to_markdown_hardening(): """Test malformed stack input.""" def to_md(body): """Wrapper to simplify html-to-markdown conversion tests.""" return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body) # Stray close tags before any matching open tag #
          and
    take the early-return path in handle_endtag assert to_md("text") == "text" assert to_md("text") == "text" # finds no open
  • anywhere on the stack and is a no-op -- the # text that follows is unindented and stored normally assert to_md("
  • text") == "text" # Multiple stray close tags in a row must not crash or corrupt state assert to_md("preamble") == "preamble" # A valid list after a stray close must still render correctly assert to_md("
    • A
    • B
    ") == "- A\n- B" # A stray close tag with content on BOTH sides must be a complete no-op, # not just absorbed because nothing preceded it. assert to_md("ab") == "ab" assert to_md("ab") == "ab" assert to_md("ab") == "ab" assert to_md("a
    b") == "ab" assert to_md("ab") == "ab" assert to_md("ab") == "ab" # A stray / close still falls through cleanly when content follows. assert to_md("

    ab

    ") == "ab" # _make_frame() empty-stack guard conv = HTMLMarkdownConverter() # Force the stack empty -- this can only happen via direct attribute # manipulation, never through the public parsing API conv._stack = [] frame = conv._make_frame("div") # The fallback defaults must match the root sentinel values assert frame["tag"] == "div" assert frame["do_store"] is True assert frame["preserve_cr"] is False assert frame["list_type"] is None assert frame["depth"] == 0 assert frame["counter"] is None assert frame["list_do_store"] is True # A stale open-tag count must not corrupt the stack. conv = HTMLMarkdownConverter() conv._tag_open_counts["nonexistent"] = 1 conv._pop_to("nonexistent") # must not raise or corrupt state assert len(conv._stack) == 1 # only the root sentinel remains # ignore content found in head assert ( to_md( "" + "
      " * 50 + "
    • hidden
    • " + "
    " * 50 + "text" ) == "text" ) # deep
      nesting still resolve correctly. assert ( to_md("
        " * 50 + "
      • visible
      • " + "
      " * 50) == "- visible" ) # List indentation is capped at LIST_DEPTH_MAX --- assert LIST_DEPTH_MAX == 4 one_per_level = "
      • x" * 10 + "
      " * 10 out = to_md(one_per_level) lines = out.split("\n") assert len(lines) == 10 # Indent grows for the first LIST_DEPTH_MAX levels... assert lines[0] == "- x" assert lines[1] == " - x" assert lines[2] == " - x" assert lines[3] == " - x" # ...then holds steady for every level beyond the cap for line in lines[4:]: assert line == " - x" # Performance: same shape, large enough that the old O(N^2) n = 20000 deep = "
      • x" * n + "
      " * n start = default_timer() out = to_md(deep) elapsed = default_timer() - start assert len(out) < 20 * n # Generous bound: this must catch a real return to quadratic # behavior, not just run fast on a quiet dev machine -- shared or # emulated build infrastructure (e.g. a busy Koji builder) can be # many times slower. assert elapsed < 15.0 # Performance: many UNCLOSED
    • tags nested directly inside one another. n = 20000 html = "
    • x" * n start = default_timer() out = to_md(html) elapsed = default_timer() - start assert len(out) < 10 * n # output itself is linear, not quadratic # Generous bound -- see the note on the equivalent check above. assert elapsed < 30.0 # Performance: many open
      tags followed by many closing tags of # a *different* kind that's never actually open ("
    "). n = 20000 html = "
    " * n + "" * n start = default_timer() out = to_md(html) elapsed = default_timer() - start assert len(out) < 20 * n # Generous bound: this must catch a real return to quadratic # behavior, not just run fast on a quiet dev machine -- shared or # emulated build infrastructure (e.g. a busy Koji builder) can be # many times slower. assert elapsed < 15.0 # Blockquote depth is capped at BLOCKQUOTE_DEPTH_MAX assert BLOCKQUOTE_DEPTH_MAX == 4 # Each level is its own

    , so every line of real content is followed by a # blank (but still "> "-prefixed) separator line. one_per_level = "

    x

    " * 10 + "
    " * 10 out = to_md(one_per_level) lines = out.split("\n") assert len(lines) == 19 content_lines = lines[0::2] separator_lines = lines[1::2] assert len(content_lines) == 10 assert content_lines[0] == "> x" assert content_lines[1] == "> > x" assert content_lines[2] == "> > > x" assert content_lines[3] == "> > > > x" for line in content_lines[4:]: assert line == "> > > > x" # Each separator matches its preceding content line, minus the "x" for content, separator in zip(content_lines, separator_lines): assert separator == content[: -len("x")].rstrip() n = 20000 deep = "

    x

    " * n + "
    " * n start = default_timer() out = to_md(deep) elapsed = default_timer() - start assert len(out) < 20 * n # Generous bound: this must catch a real return to quadratic # behavior, not just run fast on a quiet dev machine -- shared or # emulated build infrastructure (e.g. a busy Koji builder) can be # many times slower. assert elapsed < 15.0 # When emphasis or anchor tags are nested past MAX_FRAME_DEPTH, _push_frame # returns False and the opening delimiter must NOT be emitted. depth = MAX_FRAME_DEPTH + 1 em_open = "" * depth em_close = "" * depth out_em = to_md(f"{em_open}text{em_close}") # The text must appear; no unmatched bare asterisk may leak through. assert "text" in out_em assert out_em.replace("*", "").strip() == "text" # Same guard for -- no unmatched "[" in the output. a_open = '' * depth a_close = "" * depth out_a = to_md(f"{a_open}text{a_close}") assert "text" in out_a # Count "[" and "](" -- every "[" must have a matching "](". assert out_a.count("[") == out_a.count("](") # At MAX_FRAME_DEPTH the
    frame is discarded and _push_frame # returns False. bq_open = "
    " * depth bq_close = "
    " * depth out_bq = to_md(f"{bq_open}text{bq_close}") assert "text" in out_bq # Every ">" line-prefix must correspond to a real nested blockquote level. first_line = out_bq.splitlines()[0] if out_bq else "" gt_count = len(first_line) - len(first_line.lstrip("> ")) assert gt_count <= BLOCKQUOTE_DEPTH_MAX * 2 # ">" + " " per level # At MAX_FRAME_DEPTH the
  • frame is discarded and _push_frame returns # False. No _ListMarker must be appended without a backing frame. li_open = "
      " + "
    • " * depth li_close = "
    • " * depth + "
    " out_li = to_md(f"{li_open}text{li_close}") assert "text" in out_li def test_conversion_headings_to_bold(): """Convert supported ATX headings for chat dialects without headings.""" # Convert the common heading forms generated from HTML. assert commonmark_headings_to_bold("# Alert") == "**Alert**" assert commonmark_headings_to_bold("###### Alert") == "**Alert**" # An optional closing sequence of "#" is stripped, not kept as text. assert commonmark_headings_to_bold("## foo ##") == "**foo**" assert commonmark_headings_to_bold("# foo #####") == "**foo**" # A hash without preceding whitespace remains part of the heading text. assert commonmark_headings_to_bold("# foo#") == "**foo#**" # Preserve up to three spaces allowed before a heading. assert commonmark_headings_to_bold(" # one space") == " **one space**" assert commonmark_headings_to_bold(" # three spaces") == ( " **three spaces**" ) # Four leading spaces make an indented code block, not a heading. four_spaces = " # four spaces" assert commonmark_headings_to_bold(four_spaces) == four_spaces # Empty headings do not produce empty bold markers. assert commonmark_headings_to_bold("###") == "" assert commonmark_headings_to_bold("### ") == "" # Heading text requires whitespace after its opening hashes. literal = "#nospace" assert commonmark_headings_to_bold(literal) == literal # Inline code spans are never touched. assert commonmark_headings_to_bold("`# inline code`") == ( "`# inline code`" ) # Backtick and tilde fences protect heading-like code. fenced_backtick = "```\n# not a heading\n```" assert commonmark_headings_to_bold(fenced_backtick) == fenced_backtick fenced_tilde = "~~~\n# not a heading either\n~~~" assert commonmark_headings_to_bold(fenced_tilde) == fenced_tilde # An unfinished fence protects the remaining text. unterminated = "```\n# still not a heading\nmore text" assert commonmark_headings_to_bold(unterminated) == unterminated # A real heading before and after a fenced block is still converted. mixed = "# Before\n```\n# inside\n```\n# After" assert commonmark_headings_to_bold(mixed) == ( "**Before**\n```\n# inside\n```\n**After**" ) # Preserve blockquote markers emitted by HTML-to-CommonMark conversion. assert commonmark_headings_to_bold("> # Alert") == "> **Alert**" assert commonmark_headings_to_bold("> > # Alert") == "> > **Alert**" assert commonmark_headings_to_bold("> > > # Alert") == ("> > > **Alert**") # Preserve unordered and ordered list markers. assert commonmark_headings_to_bold("- # Alert") == "- **Alert**" assert commonmark_headings_to_bold("* # Alert") == "* **Alert**" assert commonmark_headings_to_bold("+ # Alert") == "+ **Alert**" assert commonmark_headings_to_bold("1. # Alert") == "1. **Alert**" assert commonmark_headings_to_bold("2) # Alert") == "2) **Alert**" # Preserve combined blockquote and list markers. assert commonmark_headings_to_bold("> - # Alert") == "> - **Alert**" # Keep the blockquote marker for an empty heading. assert commonmark_headings_to_bold("> ###") == "> " # Recognize CRLF fences without protecting later headings. crlf_fence = "```\r\ncode\r\n```\r\n# Real Heading" assert commonmark_headings_to_bold(crlf_fence) == ( "```\r\ncode\r\n```\r\n**Real Heading**" ) # Invalid backtick fence labels do not protect later headings. invalid_opener = ( "Some `inline` text\n" "```code`with`backtick info string\n" "more text\n" "# Real Heading" ) assert commonmark_headings_to_bold(invalid_opener) == ( "Some `inline` text\n" "```code`with`backtick info string\n" "more text\n" "**Real Heading**" ) # Tilde fence labels may contain backticks. tilde_with_backtick = "~~~code`ok\nreal code\n~~~" assert ( commonmark_headings_to_bold(tilde_with_backtick) == tilde_with_backtick ) # Preserve heading-like text inside a quoted tilde fence. quoted_tilde_fence = "> ~~~\n> # literal code\n> ~~~" assert ( commonmark_headings_to_bold(quoted_tilde_fence) == quoted_tilde_fence ) # Repeating ``"- "`` starts a sibling list item, so these are three # separate items. The middle heading remains real Markdown and converts. three_sibling_items = "- ~~~\n- # literal code\n- ~~~~" assert commonmark_headings_to_bold(three_sibling_items) == ( "- ~~~\n- **literal code**\n- ~~~~" ) # A backtick closer wider than its opener still closes the fence, even # when both are nested inside a blockquote. quoted_wider_closer = "> ```\n> # literal code\n> ````" assert ( commonmark_headings_to_bold(quoted_wider_closer) == quoted_wider_closer ) # Nested blockquotes around a fence are recognized at every level. nested_quoted_fence = "> > ~~~\n> > # literal code\n> > ~~~" assert ( commonmark_headings_to_bold(nested_quoted_fence) == nested_quoted_fence ) # Quote markers may have up to three leading spaces. assert commonmark_headings_to_bold(" > # Alert") == " > **Alert**" indented_quoted_fence = " > ~~~\n > # literal code\n > ~~~" assert ( commonmark_headings_to_bold(indented_quoted_fence) == indented_quoted_fence ) # Four leading spaces create indented code, not a top-level fence. # The following real heading must therefore still be converted. four_space_fence_then_heading = ( " ~~~\n not a real fence\n ~~~\n# Real Heading" ) assert commonmark_headings_to_bold(four_space_fence_then_heading) == ( " ~~~\n not a real fence\n ~~~\n**Real Heading**" ) # An ordered-list marker can be wider than three characters ("123. " # is five), so its continuation lines need more than three leading # spaces too. A real heading after the fence must still convert. wide_ordered_fence = "123. ~~~\n # literal\n ~~~\n\n# real" assert commonmark_headings_to_bold(wide_ordered_fence) == ( "123. ~~~\n # literal\n ~~~\n\n**real**" ) # A list containing a quote puts the list marker first, while continuation # lines replace it with spaces. Both prefix orders belong to one container. list_then_quote_fence = "- > ~~~\n > # literal\n > ~~~\n\n# real" assert commonmark_headings_to_bold(list_then_quote_fence) == ( "- > ~~~\n > # literal\n > ~~~\n\n**real**" ) # Up to three leading spaces may belong to a top-level fence rather than # a list. Its heading-like content must remain protected. lightly_indented_fence = " ~~~\n# literal\n ~~~\n# real" assert commonmark_headings_to_bold(lightly_indented_fence) == ( " ~~~\n# literal\n ~~~\n**real**" ) # Opening and closing fences have independent indentation allowances. mismatched_indent_fence = "~~~\n# literal\n ~~~\n# real" assert commonmark_headings_to_bold(mismatched_indent_fence) == ( "~~~\n# literal\n ~~~\n**real**" ) # The same closer slack applies inside a blockquote. quoted_mismatched_indent_fence = "> ~~~\n> # literal\n> ~~~\n> # real" assert commonmark_headings_to_bold(quoted_mismatched_indent_fence) == ( "> ~~~\n> # literal\n> ~~~\n> **real**" ) # A lightly indented fence can continue a list item without repeating # its marker. An unfinished fence stops when that list item ends. unclosed_narrow_list_fence = "- intro\n ~~~\n # literal\n\n# real" assert commonmark_headings_to_bold(unclosed_narrow_list_fence) == ( "- intro\n ~~~\n # literal\n\n**real**" ) # A later top-level fence cannot close the list's unfinished fence. unclosed_narrow_list_fence_with_later_fence = ( "- intro\n ~~~\n # literal\n\n# real\n\n~~~\nunrelated\n~~~" ) assert commonmark_headings_to_bold( unclosed_narrow_list_fence_with_later_fence ) == ("- intro\n ~~~\n # literal\n\n**real**\n\n~~~\nunrelated\n~~~") # Blank lines before the fence may remain within the list item. unclosed_narrow_list_fence_with_blank = ( "- intro\n\n ~~~\n # literal\n\n# real" ) assert commonmark_headings_to_bold( unclosed_narrow_list_fence_with_blank ) == ("- intro\n\n ~~~\n # literal\n\n**real**") # Light indentation without an earlier list marker is top-level. unmarked_indent_no_list_behind_it = ( "Some intro text\n ~~~\n# literal\n ~~~\n# real" ) assert commonmark_headings_to_bold(unmarked_indent_no_list_behind_it) == ( "Some intro text\n ~~~\n# literal\n ~~~\n**real**" ) # The paragraph lazily continues the list, but one-space fence indentation # is still insufficient for the two-space item. list_ended_by_unindented_paragraph = ( "- intro\nunindented paragraph\n ~~~\n# literal\n ~~~\n# real" ) assert commonmark_headings_to_bold(list_ended_by_unindented_paragraph) == ( "- intro\nunindented paragraph\n ~~~\n# literal\n ~~~\n**real**" ) # Indented list text may appear between the marker and fence. unclosed_narrow_list_fence_with_paragraph = ( "- intro\n more text in the same item\n ~~~\n # literal\n\n# real" ) assert commonmark_headings_to_bold( unclosed_narrow_list_fence_with_paragraph ) == ( "- intro\n more text in the same item\n ~~~\n # literal\n\n**real**" ) # The HTML converter replaces a list marker with equal-width spaces on # continuation lines. Test that real output as well as synthetic input. list_only_fence_html = ( "
    • # literal\nmore
    " "

    Real Heading

    " ) list_only_fence_body = html_to_markdown(list_only_fence_html) assert list_only_fence_body == ( "- ```\n # literal\n more\n ```\n\n# Real Heading" ) converted = commonmark_headings_to_bold(list_only_fence_body) # The heading-like line inside the fence stays literal code. assert " # literal\n" in converted # A real heading placed after the fenced block still converts. assert converted.endswith("\n**Real Heading**") # Also cover a preceding paragraph and the wider numbered-list marker. text_then_fence_html = ( "
    • intro
      # literal\nmore
    " "

    Real Heading

    " ) text_then_fence_body = html_to_markdown(text_then_fence_html) assert text_then_fence_body == ( "- intro\n ```\n # literal\n more\n ```\n\n# Real Heading" ) converted = commonmark_headings_to_bold(text_then_fence_body) assert " # literal\n" in converted assert converted.endswith("\n**Real Heading**") ordered_fence_html = ( "
    1. # literal\nmore
    " "

    Real Heading

    " ) ordered_fence_body = html_to_markdown(ordered_fence_html) assert ordered_fence_body == ( "1. ```\n # literal\n more\n ```\n\n# Real Heading" ) converted = commonmark_headings_to_bold(ordered_fence_body) assert " # literal\n" in converted assert converted.endswith("\n**Real Heading**") # List continuation spacing moves before the quote marker on later lines. quoted_list_fence_html = ( "
    • intro" "
      # literal\nmore
    " "

    Real Heading

    " ) quoted_list_fence_body = html_to_markdown(quoted_list_fence_html) assert quoted_list_fence_body == ( "> - intro\n > ```\n > # literal\n > more\n > ```" "\n\n# Real Heading" ) converted = commonmark_headings_to_bold(quoted_list_fence_body) assert " > # literal\n" in converted assert converted.endswith("\n**Real Heading**") # Leading list indentation contributes to the continuation width, whether # the fence begins the item or follows its introductory text. indented_marker_then_fence = " - ~~~\n # literal\n ~~~\n# real" assert commonmark_headings_to_bold(indented_marker_then_fence) == ( " - ~~~\n # literal\n ~~~\n**real**" ) indented_marker_with_intro = " - intro\n ~~~\n # literal\n\n# real" assert commonmark_headings_to_bold(indented_marker_with_intro) == ( " - intro\n ~~~\n # literal\n\n**real**" ) # A literal "- " inside a closed fence must not create list state for the # unfinished top-level fence that follows it. marker_like_text_inside_closed_fence = ( "~~~\n- literal\n ~~~\n ~~~\n# still code" ) assert commonmark_headings_to_bold( marker_like_text_inside_closed_fence ) == ("~~~\n- literal\n ~~~\n ~~~\n# still code") # Ending an inner list restores the still-open outer list width. nested_list_falls_back_to_outer = ( "- outer\n - inner\n text\n ~~~\n # literal\n\n# real" ) assert commonmark_headings_to_bold(nested_list_falls_back_to_outer) == ( "- outer\n - inner\n text\n ~~~\n # literal\n\n**real**" ) # An unindented paragraph may lazily continue an open list item. lazy_continuation_keeps_list_open = ( "- intro\nlazy continuation\n ~~~\n # literal\n\n# real" ) assert commonmark_headings_to_bold(lazy_continuation_keeps_list_open) == ( "- intro\nlazy continuation\n ~~~\n # literal\n\n**real**" ) # A new list marker ends lazy paragraph continuation. new_marker_ends_lazy_continuation = ( "- intro\nordinary text\n- newitem\n ~~~\n # literal\n\n# real" ) assert commonmark_headings_to_bold(new_marker_ends_lazy_continuation) == ( "- intro\nordinary text\n- newitem\n ~~~\n # literal\n\n**real**" ) # A list fence closer may add only three columns of indentation. # Greater indentation leaves it open until the list ends. wildly_overindented_list_closer = ( "- intro\n ```\n code\n ```\n# real" ) assert commonmark_headings_to_bold(wildly_overindented_list_closer) == ( "- intro\n ```\n code\n ```\n**real**" ) # The same three-column closer limit applies outside lists. wildly_overindented_top_level_closer = "```\ncode\n ```\n# after" assert commonmark_headings_to_bold( wildly_overindented_top_level_closer ) == ("```\ncode\n ```\n# after") # ``-\t`` reaches column four. Two spaces cannot continue that list, so # the following unfinished fence is top-level and protects the remainder. tab_expanded_marker_width = "-\tintro\n ```\n # literal\n\n# real" assert commonmark_headings_to_bold(tab_expanded_marker_width) == ( tab_expanded_marker_width ) # A tab reaches the same column as ``-\t``, keeping the fence in the list. tab_expanded_continuation_width = "-\tintro\n\t```\n\t# literal\n\n# real" assert commonmark_headings_to_bold(tab_expanded_continuation_width) == ( "-\tintro\n\t```\n\t# literal\n\n**real**" ) # Accept quote-first continuation lines as well as Apprise's list-first # output when a fenced block is nested in both containers. quote_then_list_continuation = "- > ~~~\n> # literal\n> ~~~\n\n# real" assert commonmark_headings_to_bold(quote_then_list_continuation) == ( "- > ~~~\n> # literal\n> ~~~\n\n**real**" ) # A quote-first closer still needs the list's full indentation. quote_then_list_insufficient_width = ( "- > ~~~\n> # literal\n> ~~~\n> ~~~\n\n# real" ) assert commonmark_headings_to_bold(quote_then_list_insufficient_width) == ( "- > ~~~\n> # literal\n> ~~~\n> ~~~\n\n**real**" ) # List indentation without the required quote marker ends the container. list_width_met_but_no_quote_at_all = ( "- > ~~~\n text without quote\n> ~~~\n\n# real" ) assert commonmark_headings_to_bold(list_width_met_but_no_quote_at_all) == ( "- > ~~~\n text without quote\n> ~~~\n\n**real**" ) # A bare quote marker still satisfies its quote depth. bare_quote_marker_line_in_combo = "- > ~~~\n>\n> ~~~\n\n# real" assert commonmark_headings_to_bold(bare_quote_marker_line_in_combo) == ( "- > ~~~\n>\n> ~~~\n\n**real**" ) # A heading ends the list, making the following unfinished fence top-level. heading_ends_list_before_fence = ( "- intro\n# outside\n ~~~\n # literal\n# still code" ) assert commonmark_headings_to_bold(heading_ends_list_before_fence) == ( "- intro\n**outside**\n ~~~\n # literal\n# still code" ) # A heading inside a list marker cannot gain a lazy continuation. marker_content_is_heading = ( "- # heading\noutside\n ~~~\n # literal\n# still code" ) assert commonmark_headings_to_bold(marker_content_is_heading) == ( "- **heading**\noutside\n ~~~\n # literal\n# still code" ) # Blockquotes and thematic breaks also end lazy list continuation. blockquote_ends_list_before_fence = ( "- intro\n> outside\n ~~~\n # literal\n# still code" ) assert commonmark_headings_to_bold(blockquote_ends_list_before_fence) == ( "- intro\n> outside\n ~~~\n # literal\n# still code" ) thematic_break_ends_list_before_fence = ( "- intro\n---\n ~~~\n # literal\n# still code" ) assert commonmark_headings_to_bold( thematic_break_ends_list_before_fence ) == ("- intro\n---\n ~~~\n # literal\n# still code") # CRLF input follows the same thematic-break boundary rule. thematic_break_ends_list_crlf = ( "- intro\r\n---\r\n ~~~\r\n # literal\r\n# still code" ) assert commonmark_headings_to_bold(thematic_break_ends_list_crlf) == ( "- intro\r\n---\r\n ~~~\r\n # literal\r\n# still code" ) # A tab at column zero reaches column four, creating indented code. tab_at_column_zero_is_not_slack = "\t# literal" assert ( commonmark_headings_to_bold(tab_at_column_zero_is_not_slack) == tab_at_column_zero_is_not_slack ) tab_at_column_zero_fence_is_not_slack = "\t~~~\ncode\n\t~~~\n# real" assert commonmark_headings_to_bold( tab_at_column_zero_fence_is_not_slack ) == ("\t~~~\ncode\n\t~~~\n**real**") # A tab after a quote can remain within the three-column allowance. tab_after_quote_is_slack = ">\t# literal" assert commonmark_headings_to_bold(tab_after_quote_is_slack) == ( ">\t**literal**" ) # Tabs between nested quote markers use one column of marker padding. tab_separated_nested_quote_fence = ( ">\t>\t~~~\n>\t>\t# literal\n>\t>\t~~~\n>\t>\t# real" ) assert commonmark_headings_to_bold(tab_separated_nested_quote_fence) == ( ">\t>\t~~~\n>\t>\t# literal\n>\t>\t~~~\n>\t>\t**real**" ) # Nested quote markers also work without separating spaces. unspaced_nested_quote_fence = ">>~~~\n>>literal\n>>~~~\n>># real" assert commonmark_headings_to_bold(unspaced_nested_quote_fence) == ( ">>~~~\n>>literal\n>>~~~\n>>**real**" ) # Tab-separated quote/list content uses indentation on continuation lines. tab_separated_quote_list_fence = ( ">\t-\tintro\n>\t ~~~\n>\t # literal\n>\t ~~~\n>\t# real" ) assert commonmark_headings_to_bold(tab_separated_quote_list_fence) == ( ">\t-\tintro\n>\t ~~~\n>\t # literal\n>\t ~~~\n>\t**real**" ) # HTML blocks end lazy list paragraphs, making the next fence top-level. html_block_ends_list_before_fence = ( "- intro\n
    \nhtml\n
    \n\n ~~~\n # literal\n# still code" ) assert commonmark_headings_to_bold(html_block_ends_list_before_fence) == ( "- intro\n
    \nhtml\n
    \n\n ~~~\n # literal\n# still code" ) html_comment_ends_list_before_fence = ( "- intro\n\n ~~~\n # literal\n# still code" ) assert commonmark_headings_to_bold( html_comment_ends_list_before_fence ) == ("- intro\n\n ~~~\n # literal\n# still code") # A backtick in a backtick fence's info string makes the opener invalid. invalid_backtick_info_string_not_a_fence = ( "- intro\n ```bad`info\nlazy\n ~~~\n # literal\n# real" ) assert commonmark_headings_to_bold( invalid_backtick_info_string_not_a_fence ) == ("- intro\n ```bad`info\nlazy\n ~~~\n # literal\n**real**") # Four extra columns inside a two-column list create indented code. six_space_indent_not_a_fence = ( "- intro\n ```\nlazy\n ~~~\n # literal\n# real" ) assert commonmark_headings_to_bold(six_space_indent_not_a_fence) == ( "- intro\n ```\nlazy\n ~~~\n # literal\n**real**" ) # A blank line ends a blockquote fence but may remain in a plain list. blank_line_ends_blockquote_fence = "> ~~~\n> code\n\n> # real" assert commonmark_headings_to_bold(blank_line_ends_blockquote_fence) == ( "> ~~~\n> code\n\n> **real**" ) # A fence may use up to three extra columns inside an active list. fence_with_extra_slack_stays_in_list = ( "1. item\n ~~~\n # literal\n ~~~\n# real" ) assert commonmark_headings_to_bold( fence_with_extra_slack_stays_in_list ) == ("1. item\n ~~~\n # literal\n ~~~\n**real**") # Active-list indentation can identify a heading without another marker. assert commonmark_headings_to_bold("- item\n # nested") == ( "- item\n **nested**" ) assert commonmark_headings_to_bold("- item\n\t# nested") == ( "- item\n\t**nested**" ) # Content inside an HTML block passes through unchanged. heading_inside_html_block_untouched = "
    \n# literal\n
    " assert ( commonmark_headings_to_bold(heading_inside_html_block_untouched) == heading_inside_html_block_untouched ) # Fence-looking text inside HTML must not consume the following document. fence_look_alike_inside_html_block = "
    \n~~~\n
    \n\n# real" assert ( commonmark_headings_to_bold(fence_look_alike_inside_html_block) == "
    \n~~~\n
    \n\n**real**" ) # A lowercase CDATA look-alike is ordinary text. lowercase_cdata_is_not_html_block = ( "- intro\n " * 9 + "# real" assert commonmark_headings_to_bold(heading_past_old_depth_cap) == ( "> " * 9 + "**real**" ) # An HTML block is recognized even after an active blockquote prefix, # not just at the very start of a line. html_block_inside_blockquote = ">
    \n> # literal\n>
    " assert ( commonmark_headings_to_bold(html_block_inside_blockquote) == html_block_inside_blockquote ) # A tag-looking line inside a real fence is literal fenced content, # not an HTML block start -- it must not swallow what follows the # fence's own close. html_look_alike_inside_fence = "~~~\n\ncontent\n\nafter\n" "\n# real" ) assert commonmark_headings_to_bold(raw_tag_requires_exact_close) == ( "\nafter\n" "\n**real**" ) # A heading between two same-length backtick runs must not look like # a code span spanning across it -- block structure is resolved # before inline code-span pairing. backtick_run_does_not_cross_heading = "before ``\n# real\nafter ``" assert commonmark_headings_to_bold( backtick_run_does_not_cross_heading ) == ("before ``\n**real**\nafter ``") # Type 7: a complete tag for any other name, alone on its own line, # starts an HTML block too -- but only when not interrupting an # already-open paragraph. custom_tag_at_document_start_protects_content = ( "\n# literal\n" ) assert ( commonmark_headings_to_bold( custom_tag_at_document_start_protects_content ) == custom_tag_at_document_start_protects_content ) custom_tag_cannot_interrupt_paragraph = "some text\n\n# real" assert commonmark_headings_to_bold( custom_tag_cannot_interrupt_paragraph ) == ("some text\n\n**real**") # A space before the self-closing slash makes this an invalid tag. malformed_self_close_not_a_tag = "\n# real" assert commonmark_headings_to_bold(malformed_self_close_not_a_tag) == ( "\n**real**" ) # A ">" inside a quoted attribute does not end the tag early. quoted_attribute_value_with_gt = '\n# literal' assert ( commonmark_headings_to_bold(quoted_attribute_value_with_gt) == quoted_attribute_value_with_gt ) # Any raw-tag closer ends a type-1 block, even if its name differs. raw_tag_close_need_not_match_open = "\nafter" close = text.index("") + len("") assert commonmark_module._commonmark_html_block_spans(text) == [ (0, close + 1) ] # A comment closes at its own terminator, wherever it appears. text = "\nafter" close = text.index("-->") + len("-->") assert commonmark_module._commonmark_html_block_spans(text) == [ (0, close + 1) ] # An unterminated comment runs to the end of the text. text = "\nafter" spans = commonmark_module._commonmark_leaf_block_spans(text) assert spans == [(0, len("> \n"), "html")] # When the quote holds all the way through and no terminator ever # appears, the block runs to the end of the text. text = "> \nHi", "\nHi", ), ( '\nHi', '\nHi', ), (" ", " "), ("<3 all good", "<3 all good"), ], ) @mock.patch("requests.post") def test_plugin_twilio_call_twiml_detection(mock_post, body, twiml): """A body starting with "<" is relayed as TwiML; text is spoken.""" response = mock.Mock() response.content = b"{}" response.status_code = requests.codes.created mock_post.return_value = response aobj = Apprise() assert aobj.add( "twilio://AC{}:{}@15551233456/15559876543?method=call".format( "a" * 32, "b" * 32 ) ) assert aobj.notify(body=body) assert mock_post.call_args[1]["data"]["Twiml"] == twiml @mock.patch("requests.post") def test_plugin_twilio_sms_title_joins_body(mock_post): """An SMS still carries the title at the top of its body.""" response = mock.Mock() response.content = b"{}" response.status_code = requests.codes.created mock_post.return_value = response aobj = Apprise() assert aobj.add( "twilio://AC{}:{}@15551233456/15559876543".format("a" * 32, "b" * 32) ) assert aobj.notify(body="body", title="title") assert mock_post.call_args[1]["data"]["Body"] == "title\r\nbody" @mock.patch("requests.post") def test_plugin_twilio_call_say_injection(mock_post): """Text cannot close the Say element early.""" response = mock.Mock() response.content = b"{}" response.status_code = requests.codes.created mock_post.return_value = response aobj = Apprise() assert aobj.add( "twilio://AC{}:{}@15551233456/15559876543?method=call".format( "a" * 32, "b" * 32 ) ) expected = ( "t</Say><Hangup/>" "b</Say><Play>x</Play>" ) # Both a declared text body and undeclared content are escaped for body_format in ("text", None): assert aobj.notify( body="bx", title="t", body_format=body_format, ) assert mock_post.call_args[1]["data"]["Twiml"] == expected @pytest.mark.parametrize("overflow", ["split", "truncate"]) @mock.patch("requests.post") def test_plugin_twilio_call_twiml_not_split(mock_post, overflow): """Valid TwiML within Twilio's limit is sent whole as one call.""" response = mock.Mock() response.content = b"{}" response.status_code = requests.codes.created mock_post.return_value = response aobj = Apprise() assert aobj.add( "twilio://AC{}:{}@15551233456/15559876543?method=call" "&overflow={}".format("a" * 32, "b" * 32, overflow) ) # A 3900 character TwiML document, with a long title that is ignored twiml = "{}".format("x" * 3868) assert len(twiml) == 3900 assert aobj.notify(body=twiml, title="t" * 250, body_format="text") assert mock_post.call_count == 1 assert mock_post.call_args[1]["data"]["Twiml"] == twiml caronc-apprise-dfec414/tests/test_plugin_twist.py000066400000000000000000000452761526720371000224400ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyFormat from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.twist import NotifyTwist logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "twist://", { # Missing Email and Login "instance": None, }, ), ( "twist://:@/", { "instance": None, }, ), ( "twist://user@example.com/", { # No password "instance": None, }, ), ( "twist://user@example.com/password", { # Password acceptable as first entry in path "instance": NotifyTwist, # Expected notify() response is False because internally we would # have failed to login "notify_response": False, }, ), ( "twist://password:user1@example.com", { # password:login acceptable "instance": NotifyTwist, # Expected notify() response is False because internally we would # have failed to login "notify_response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "twist://****:user1@example.com", }, ), ( "twist://password:user2@example.com", { # password:login acceptable "instance": NotifyTwist, # Expected notify() response is False because internally we would # have logged in, but we would have failed to look up the #General # channel and workspace. "requests_response_text": { # Login expected response "id": 1234, "default_workspace": 9876, }, "notify_response": False, }, ), ( "twist://password:user2@example.com", { "instance": NotifyTwist, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "twist://password:user2@example.com", { "instance": NotifyTwist, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_twist_urls(): """NotifyTwist() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_twist_init(): """NotifyTwist() init()""" with pytest.raises(AppriseImproperlyConfigured): NotifyTwist(email="invalid", targets=None) with pytest.raises(AppriseImproperlyConfigured): NotifyTwist(email="user@domain", targets=None) # Simple object initialization result = NotifyTwist( password="abc123", email="user@domain.com", targets=None ) assert result.user == "user" assert result.host == "domain.com" assert result.password == "abc123" # Channel Instantiation by name obj = Apprise.instantiate("twist://password:user@example.com/#Channel") assert isinstance(obj, NotifyTwist) # Channel Instantiation by id (faster if you know the translation) obj = Apprise.instantiate("twist://password:user@example.com/12345") assert isinstance(obj, NotifyTwist) # Invalid Channel - (max characters is 64), the below drops it obj = Apprise.instantiate( "twist://password:user@example.com/{}".format("a" * 65) ) assert isinstance(obj, NotifyTwist) # No User detect result = NotifyTwist.parse_url("twist://example.com") assert result is None # test usage of to= result = NotifyTwist.parse_url( "twist://password:user@example.com?to=#channel" ) assert isinstance(result, dict) assert "user" in result assert result["user"] == "user" assert "host" in result assert result["host"] == "example.com" assert "password" in result assert result["password"] == "password" assert "targets" in result assert isinstance(result["targets"], list) is True assert len(result["targets"]) == 1 assert "#channel" in result["targets"] @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_twist_auth(mock_post, mock_get): """NotifyTwist() login/logout()""" # Prepare Mock mock_get.return_value = requests.Request() mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok mock_post.return_value.content = dumps( { "token": "2e82c1e4e8b0091fdaa34ff3972351821406f796", "default_workspace": 12345, } ) mock_get.return_value.content = mock_post.return_value.content # Instantiate an object obj = Apprise.instantiate("twist://password:user@example.com/#Channel") assert isinstance(obj, NotifyTwist) # not logged in yet obj.logout() assert obj.login() is True # Clear our channel listing obj.channels.clear() # No channels mean there is no internal migration/lookups required assert obj._channel_migration() is True # Workspace Success mock_post.return_value.content = dumps( [ { "name": "TesT", "id": 1, }, { "name": "tESt2", "id": 2, }, ] ) mock_get.return_value.content = mock_post.return_value.content results = obj.get_workspaces() assert len(results) == 2 assert "test" in results assert results["test"] == 1 assert "test2" in results assert results["test2"] == 2 mock_post.return_value.content = dumps( [ { "name": "ChaNNEL1", "id": 1, }, { "name": "chaNNel2", "id": 2, }, ] ) mock_get.return_value.content = mock_post.return_value.content results = obj.get_channels(wid=1) assert len(results) == 2 assert "channel1" in results assert results["channel1"] == 1 assert "channel2" in results assert results["channel2"] == 2 # Test result failure response mock_post.return_value.status_code = 403 mock_get.return_value.status_code = 403 assert obj.get_workspaces() == {} # Return things how they were mock_post.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok # Forces call to logout: del obj # # Authentication failures # mock_post.return_value.status_code = 403 mock_get.return_value.status_code = 403 # Instantiate an object obj = Apprise.instantiate("twist://password:user@example.com/#Channel") assert isinstance(obj, NotifyTwist) # Authentication failed assert obj.get_workspaces() == {} assert obj.get_channels(wid=1) == {} assert obj._channel_migration() is False assert obj.send("body", "title") is False obj = Apprise.instantiate("twist://password:user@example.com/#Channel") assert isinstance(obj, NotifyTwist) # Calling logout on an object already logged out obj.logout() @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_twist_cache(mock_post, mock_get): """NotifyTwist() Cache Handling.""" def _response(url, *args, **kwargs): # Default configuration request = mock.Mock() request.status_code = requests.codes.ok request.content = "{}" if url.endswith("/login"): # Simulate a successful login request.content = dumps( { "token": "2e82c1e4e8b0091fdaa34ff3972351821406f796", "default_workspace": 1, } ) elif url.endswith("workspaces/get"): request.content = dumps( [ { "name": "TeamA", "id": 1, }, { "name": "TeamB", "id": 2, }, ] ) elif url.endswith("channels/get"): request.content = dumps( [ { "name": "ChanA", "id": 1, }, { "name": "ChanB", "id": 2, }, ] ) return request mock_get.side_effect = _response mock_post.side_effect = _response # Instantiate an object obj = Apprise.instantiate( "twist://password:user@example.com/" "#ChanB/1:1/TeamA:ChanA/Ignore:Chan/3:1" ) assert isinstance(obj, NotifyTwist) # Will detect channels except Ignore:Chan assert obj._channel_migration() is False # Add another channel obj.channels.add("ChanB") assert obj._channel_migration() is True # Nothing more to detect the second time around assert obj._channel_migration() is True # Send a notification assert obj.send("body", "title") is True def _can_not_send_response(url, *args, **kwargs): """Simulate a case where we can't send a notification.""" # Force a failure request = mock.Mock() request.status_code = 403 request.content = "{}" return request mock_get.side_effect = _can_not_send_response mock_post.side_effect = _can_not_send_response # Send a notification and fail at it assert obj.send("body", "title") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_twist_fetch(mock_post, mock_get): """NotifyTwist() fetch() fetch() is a wrapper that handles all kinds of edge cases and even attempts to re-authenticate to the Twist server if our token happens to expire. This tests these edge cases """ # Track our iteration; by tracing within an object, we can re-reference # it within a function scope. cache = { "first_time": True, } def _reauth_response(url, *args, **kwargs): """Tests re-authentication process and then a successful retry.""" # Default configuration request = mock.Mock() request.status_code = requests.codes.ok # Simulate a successful login request.content = dumps( { "token": "2e82c1e4e8b0091fdaa34ff3972351821406f796", "default_workspace": 12345, } ) if url.endswith("threads/add") and cache["first_time"] is True: # First time iteration; act as if we failed; our second iteration # will not enter this and be successful. This is done by simply # toggling the first_time flag: cache["first_time"] = False # otherwise, we set our first-time failure settings request.status_code = 403 request.content = dumps( { "error_code": 200, "error_string": "Invalid token", } ) return request mock_get.side_effect = _reauth_response mock_post.side_effect = _reauth_response # Instantiate an object obj = Apprise.instantiate("twist://password:user@example.com/#Channel/34") assert isinstance(obj, NotifyTwist) # Simulate a re-authentication postokay, response = obj._fetch("threads/add") ########################################################################## cache = { "first_time": True, } def _reauth_exception_response(url, *args, **kwargs): """Tests exception thrown after re-authentication process.""" # Default configuration request = mock.Mock() request.status_code = requests.codes.ok # Simulate a successful login request.content = dumps( { "token": "2e82c1e4e8b0091fdaa34ff3972351821406f796", "default_workspace": 12345, } ) if url.endswith("threads/add") and cache["first_time"] is True: # First time iteration; act as if we failed; our second iteration # will not enter this and be successful. This is done by simply # toggling the first_time flag: cache["first_time"] = False # otherwise, we set our first-time failure settings request.status_code = 403 request.content = dumps( { "error_code": 200, "error_string": "Invalid token", } ) elif url.endswith("threads/add") and cache["first_time"] is False: # unparseable response throws the exception request.status_code = 200 request.content = "{" return request mock_get.side_effect = _reauth_exception_response mock_post.side_effect = _reauth_exception_response # Instantiate an object obj = Apprise.instantiate("twist://password:user@example.com/#Channel/34") assert isinstance(obj, NotifyTwist) # Simulate a re-authentication postokay, response = obj._fetch("threads/add") ########################################################################## cache = { "first_time": True, } def _reauth_failed_response(url, *args, **kwargs): """Tests re-authentication process and have it not succeed.""" # Default configuration request = mock.Mock() request.status_code = requests.codes.ok # Simulate a successful login request.content = dumps( { "token": "2e82c1e4e8b0091fdaa34ff3972351821406f796", "default_workspace": 12345, } ) if url.endswith("threads/add") and cache["first_time"] is True: # First time iteration; act as if we failed; our second iteration # will not enter this and be successful. This is done by simply # toggling the first_time flag: cache["first_time"] = False # otherwise, we set our first-time failure settings request.status_code = 403 request.content = dumps( { "error_code": 200, "error_string": "Invalid token", } ) elif url.endswith("/login") and cache["first_time"] is False: # Fail to login request.status_code = 403 request.content = "{}" return request mock_get.side_effect = _reauth_failed_response mock_post.side_effect = _reauth_failed_response # Instantiate an object obj = Apprise.instantiate("twist://password:user@example.com/#Channel/34") assert isinstance(obj, NotifyTwist) # Simulate a re-authentication postokay, response = obj._fetch("threads/add") def _unparseable_json_response(url, *args, **kwargs): # Default configuration request = mock.Mock() request.status_code = requests.codes.ok request.content = "{" return request mock_get.side_effect = _unparseable_json_response mock_post.side_effect = _unparseable_json_response # Instantiate our object obj = Apprise.instantiate("twist://password:user@example.com/#Channel/34") assert isinstance(obj, NotifyTwist) # Simulate a re-authentication postokay, response = obj._fetch("threads/add") assert postokay is True # When we can't parse the content, we still default to an empty # dictionary assert response == {} @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_twist_html_to_markdown_format(mock_post, mock_get): """NotifyTwist(): HTML body is converted to Markdown.""" def _response(url, *args, **kwargs): # Default configuration request = mock.Mock() request.status_code = requests.codes.ok if url.endswith("/login"): # Simulate a successful login request.content = dumps( { "token": "2e82c1e4e8b0091fdaa34ff3972351821406f796", "default_workspace": 1, } ) else: # All other endpoints return an empty JSON object request.content = "{}" return request mock_get.side_effect = _response mock_post.side_effect = _response # Twist has class-level notify_format = MARKDOWN, so no ?format= # is needed; incoming HTML bodies are converted before dispatch aobj = Apprise() assert aobj.add("twist://password:user@example.com/1:1") # Notify with an HTML body; the framework converts it to Markdown # before dispatching to the Twist plugin assert ( bool( aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) ) is True ) # Two POST calls are expected: login then threads/add assert mock_post.call_count == 2 # The body must arrive as Markdown in the threads/add payload data = mock_post.call_args_list[1][1]["data"] assert data["content"] == "**hello** *world*" caronc-apprise-dfec414/tests/test_plugin_twitter.py000066400000000000000000001341671526720371000227660ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime, timezone import json import logging import os from unittest.mock import Mock, patch from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.base import _delivery_tracker from apprise.plugins.twitter import NotifyTwitter # Disable logging for a cleaner testing output logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") TWITTER_SCREEN_NAME = "apprise" # Our Testing URLs apprise_url_tests = ( ################################## # NotifyTwitter ################################## ( "twitter://", { # Missing Consumer API Key "instance": AppriseImproperlyConfigured, }, ), ( "twitter://:@/", { "instance": AppriseImproperlyConfigured, }, ), ( "twitter://consumer_key", { # Missing Keys "instance": AppriseImproperlyConfigured, }, ), ( "twitter://consumer_key/consumer_secret/", { # Missing Keys "instance": AppriseImproperlyConfigured, }, ), ( "twitter://consumer_key/consumer_secret/atoken1/", { # Missing Access Secret "instance": AppriseImproperlyConfigured, }, ), ( "twitter://consumer_key/consumer_secret/atoken2/access_secret", { # No user mean's we message ourselves "instance": NotifyTwitter, # Expected notify() response False (because we won't be able # to detect our user); default mock has no v2 data key "notify_response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "x://c...y/****/a...2/****", }, ), ( ( "twitter://consumer_key/consumer_secret/atoken3/access_secret" "?cache=no" ), { # No user mean's we message ourselves "instance": NotifyTwitter, # v2 whoami format: {"data": {"id": "...", "username": "..."}} "requests_response_text": { "data": {"id": "12345", "username": "test"}, "media_id": 123, }, }, ), ( "twitter://consumer_key/consumer_secret/atoken4/access_secret", { # No user mean's we message ourselves "instance": NotifyTwitter, # v2 whoami format "requests_response_text": { "data": {"id": "12345", "username": "test"}, "media_id": 123, }, }, ), # A duplicate of the entry above, this will cause cache to be referenced ( "twitter://consumer_key/consumer_secret/atoken5/access_secret", { # No user mean's we message ourselves "instance": NotifyTwitter, # v2 whoami format "requests_response_text": { "data": {"id": "12345", "username": "test"}, "media_id": 123, }, }, ), # handle cases where the screen_name is missing from the response causing # an exception during parsing ( "twitter://consumer_key/consumer_secret2/atoken6/access_secret", { # No user mean's we message ourselves "instance": NotifyTwitter, # v1.1-format response (missing "data" key) causes _whoami to fail "requests_response_text": { "id": 12345, "media_id": 123, }, # due to a mangled response_text we'll fail "notify_response": False, }, ), ( "twitter://user@consumer_key/csecret2/atoken7/access_secret/-/%/", { # One Invalid User "instance": NotifyTwitter, # Expected notify() response False (because we won't be able # to detect our user) "notify_response": False, }, ), ( ( "twitter://user@consumer_key/csecret/atoken8/access_secret" "?cache=No&batch=No" ), { # No Cache & No Batch "instance": NotifyTwitter, # v2 user lookup format "requests_response_text": { "data": [{"id": "12345", "username": "user"}] }, }, ), ( "twitter://user@consumer_key/csecret/atoken9/access_secret", { # We're good! "instance": NotifyTwitter, # v2 user lookup format "requests_response_text": { "data": [{"id": "12345", "username": "user"}] }, }, ), ( "twitter://user@consumer_key/csecret/atoken11/access_secret", { # We're identifying the same user we already sent to "instance": NotifyTwitter, "notify_response": False, }, ), ( "tweet://ckey/csecret/atoken12/access_secret", { # A Public Tweet "instance": NotifyTwitter, }, ), ( "twitter://user@ckey/csecret/atoken13/access_secret?mode=invalid", { # An invalid mode "instance": AppriseImproperlyConfigured, }, ), ( ( "twitter://usera@consumer_key/consumer_secret/atoken14/" "access_secret/user/?to=userb" ), { # We're good! "instance": NotifyTwitter, # v2 user lookup format with multiple entries and a garbage entry # to test exception handling "requests_response_text": { "data": [ {"id": "12345", "username": "usera"}, {"id": "12346", "username": "userb"}, { # A garbage entry we can test exception handling on }, ] }, }, ), ( "twitter://ckey/csecret/atoken15/access_secret", { "instance": NotifyTwitter, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "twitter://ckey/csecret/atoken16/access_secret", { "instance": NotifyTwitter, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ( "twitter://ckey/csecret/atoken17/access_secret?mode=tweet", { "instance": NotifyTwitter, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def good_response(data): """Prepare a good response.""" response = Mock() response.content = json.dumps(data) response.status_code = requests.codes.ok return response def bad_response(data): """Prepare a bad response.""" response = Mock() response.content = json.dumps(data) response.status_code = requests.codes.internal_server_error return response @pytest.fixture def twitter_url(): ckey = "ckey" csecret = "csecret" akey = "akey" asecret = "asecret" url = f"twitter://{ckey}/{csecret}/{akey}/{asecret}" return url @pytest.fixture def good_message_response(): """Prepare a good v2 whoami response.""" # v2 /users/me format: {"data": {"id": "...", "username": "..."}} response = good_response( { "data": { "id": "9876", "username": TWITTER_SCREEN_NAME, } } ) return response @pytest.fixture def bad_message_response(): """Prepare a bad message response.""" response = bad_response( { "errors": [ { "code": 999, "message": "Something failed", } ] } ) return response @pytest.fixture def good_media_response(): """Prepare a good v2 media upload response.""" # v2 /2/media/upload format: {"data": {"id": "...", ...}} response = Mock() response.content = json.dumps( { "data": { "id": "710511363345354753", "media_key": "3_710511363345354753", "expires_after_secs": 86400, "size": 11065, } } ) response.status_code = requests.codes.ok return response @pytest.fixture def bad_media_response(): """Prepare a bad media response.""" response = bad_response( { "errors": [ { "code": 93, "message": ( "This application is not allowed to access or " "delete your direct messages." ), } ] } ) return response @pytest.fixture(autouse=True) def ensure_whoami_is_mocked(mocker, good_message_response): """ Make sure requests to https://api.twitter.com/2/users/me do not escape the test harness, for all test case functions. """ mock_get = mocker.patch("requests.get") mock_get.return_value = good_message_response def test_plugin_twitter_urls(): """NotifyTwitter() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_twitter_general(mocker): """NotifyTwitter() General Tests.""" mock_get = mocker.patch("requests.get") mock_post = mocker.patch("requests.post") ckey = "ckey" csecret = "csecret" akey = "akey" asecret = "asecret" # v2 user lookup response: {"data": [{"id": "...", "username": "..."}]} user_lookup_obj = { "data": [ { "username": TWITTER_SCREEN_NAME, "id": "9876", } ] } # Epoch time: epoch = datetime.fromtimestamp(0, timezone.utc) request = Mock() request.content = json.dumps(user_lookup_obj) request.status_code = requests.codes.ok request.headers = { "x-rate-limit-reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "x-rate-limit-remaining": 1, } # Prepare Mock mock_get.return_value = request mock_post.return_value = request # Variation Initializations obj = NotifyTwitter( ckey=ckey, csecret=csecret, akey=akey, asecret=asecret, targets=TWITTER_SCREEN_NAME, ) assert isinstance(obj, NotifyTwitter) is True assert isinstance(obj.url(), str) is True # apprise room was found assert obj.send(body="test") is True # Change our status code and try again request.status_code = 403 assert obj.send(body="test") is False assert obj.ratelimit_remaining == 1 # Return the status request.status_code = requests.codes.ok # Force a reset request.headers["x-rate-limit-remaining"] = 0 # behind the scenes, it should cause us to update our rate limit assert obj.send(body="test") is True assert obj.ratelimit_remaining == 0 # This should cause us to block request.headers["x-rate-limit-remaining"] = 10 assert obj.send(body="test") is True assert obj.ratelimit_remaining == 10 # Handle cases where we simply couldn't get this field del request.headers["x-rate-limit-remaining"] assert obj.send(body="test") is True # It remains set to the last value assert obj.ratelimit_remaining == 10 # Reset our variable back to 1 request.headers["x-rate-limit-remaining"] = 1 # Handle cases where our epoch time is wrong del request.headers["x-rate-limit-reset"] assert obj.send(body="test") is True # Return our object, but place it in the future forcing us to block request.headers["x-rate-limit-reset"] = ( datetime.now(timezone.utc) - epoch ).total_seconds() + 1 request.headers["x-rate-limit-remaining"] = 0 obj.ratelimit_remaining = 0 assert obj.send(body="test") is True # Return our object, but place it in the future forcing us to block request.headers["x-rate-limit-reset"] = ( datetime.now(timezone.utc) - epoch ).total_seconds() - 1 request.headers["x-rate-limit-remaining"] = 0 obj.ratelimit_remaining = 0 assert obj.send(body="test") is True # Return our limits to always work request.headers["x-rate-limit-reset"] = ( datetime.now(timezone.utc) - epoch ).total_seconds() request.headers["x-rate-limit-remaining"] = 1 obj.ratelimit_remaining = 1 # Alter pending targets obj.targets.append("usera") # Response still in v2 user_lookup format from above request.content = json.dumps(user_lookup_obj) assert obj.send(body="test") is True # Flush our cache forcing it is re-creating obj._user_cache = {} assert obj.send(body="test") is True # Cause content response to be None request.content = None assert obj.send(body="test") is True # Invalid JSON request.content = "{" assert obj.send(body="test") is True # Return it to a parseable string request.content = "{}" results = NotifyTwitter.parse_url( f"twitter://{ckey}/{csecret}/{akey}/{asecret}?to={TWITTER_SCREEN_NAME}" ) assert isinstance(results, dict) is True assert TWITTER_SCREEN_NAME in results["targets"] # Valid JSON response; send succeeds assert obj.send(body="test") is True # Set ourselves up to handle whoami calls # Flush out our cache obj._user_cache = {} # v2 whoami response: {"data": {"id": "...", "username": "..."}} whoami_obj = { "data": { "username": TWITTER_SCREEN_NAME, "id": "9876", } } request.content = json.dumps(whoami_obj) obj = NotifyTwitter(ckey=ckey, csecret=csecret, akey=akey, asecret=asecret) assert obj.send(body="test") is True # Alter the key forcing us to look up a new value of ourselves again obj._user_cache = {} obj._whoami_cache = None obj.ckey = "different.then.it.was" assert obj.send(body="test") is True obj._whoami_cache = None obj.ckey = "different.again" assert obj.send(body="test") is True def test_plugin_twitter_garbage_responses(mocker): """Verify garbage/invalid server responses are handled gracefully.""" mock_get = mocker.patch("requests.get") mock_post = mocker.patch("requests.post") ckey = "ckey" csecret = "csecret" akey = "akey" asecret = "asecret" # Base request mock returning HTTP 200 request = Mock() request.status_code = requests.codes.ok request.headers = {} mock_get.return_value = request mock_post.return_value = request # No targets -> _send_dm calls _whoami obj = NotifyTwitter(ckey=ckey, csecret=csecret, akey=akey, asecret=asecret) # _whoami: None content -> TypeError in loads() -> content = {} # -> no "data" key in {} -> KeyError caught -> _whoami returns {} request.content = None assert obj.send(body="test") is False # _whoami: malformed JSON -> ValueError in loads() -> content = {} # -> same path as above obj._whoami_cache = None request.content = "{" assert obj.send(body="test") is False # _whoami: valid JSON but v1.1-style response (missing "data" key) # -> KeyError in _whoami -> caught -> returns {} obj._whoami_cache = None request.content = json.dumps({"screen_name": "apprise", "id": 9876}) assert obj.send(body="test") is False # Has targets -> _send_dm calls _user_lookup obj2 = NotifyTwitter( ckey=ckey, csecret=csecret, akey=akey, asecret=asecret, targets=TWITTER_SCREEN_NAME, ) # _user_lookup: response is a list, not a dict # -> isinstance(response, dict) is False -> continue -> no results request.content = json.dumps( [{"id": "9876", "username": TWITTER_SCREEN_NAME}] ) assert obj2.send(body="test") is False # _fetch: json=False with a non-None payload -> raw body path request.content = json.dumps({}) postokay, _ = obj._fetch( "https://api.twitter.com/2/tweets", payload="raw=data", json=False, ) assert postokay is True # _fetch: HTTP 201 Created is treated as success (v2 POST endpoints) request.status_code = requests.codes.created request.content = json.dumps({"data": {"id": "1"}}) postokay, _response = obj._fetch( "https://api.twitter.com/2/tweets", payload={"text": "hi"}, ) assert postokay is True def test_plugin_twitter_edge_cases(): """NotifyTwitter() Edge Cases.""" with pytest.raises(AppriseImproperlyConfigured): NotifyTwitter(ckey=None, csecret=None, akey=None, asecret=None) with pytest.raises(AppriseImproperlyConfigured): NotifyTwitter(ckey="value", csecret=None, akey=None, asecret=None) with pytest.raises(AppriseImproperlyConfigured): NotifyTwitter(ckey="value", csecret="value", akey=None, asecret=None) with pytest.raises(AppriseImproperlyConfigured): NotifyTwitter( ckey="value", csecret="value", akey="value", asecret=None ) assert isinstance( NotifyTwitter( ckey="value", csecret="value", akey="value", asecret="value" ), NotifyTwitter, ) assert isinstance( NotifyTwitter( ckey="value", csecret="value", akey="value", asecret="value", user="l2gnux", ), NotifyTwitter, ) # Invalid Target User obj = NotifyTwitter( ckey="value", csecret="value", akey="value", asecret="value", targets="%G@rB@g3", ) assert ( bool( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) ) is False ) def test_plugin_twitter_dm_caching( mocker, twitter_url, good_message_response, good_media_response ): """Verify that the `NotifyTwitter.{_user_cache,_whoami_cache}` caches work as intended.""" # This is the request to `https://api.twitter.com/2/users/me`. # Explicitly mock it here so the calls to it can be evaluated. mock_get = mocker.patch("requests.get") mock_get.return_value = good_message_response # This test case submits two notifications, so make sure to provide two # mocked responses. mock_post = mocker.patch("requests.post") mock_post.side_effect = [good_message_response, good_message_response] # Make sure to start with empty caches. if hasattr(NotifyTwitter, "_user_cache"): NotifyTwitter._user_cache = {} if hasattr(NotifyTwitter, "_whoami_cache"): NotifyTwitter._whoami_cache = {} # Create application objects. obj = Apprise.instantiate(twitter_url) # Send the first notification. assert ( bool( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) ) is True ) # Test call counts. assert mock_get.call_count == 1 assert ( # v2 current-user endpoint replaced v1.1 verify_credentials mock_get.call_args_list[0][0][0] == "https://api.twitter.com/2/users/me" ) assert mock_post.call_count == 1 # v2 DM endpoint; user id "9876" comes from good_message_response assert ( mock_post.call_args_list[0][0][0] == "https://api.twitter.com/2/dm_conversations/with/9876/messages" ) # Reset the mocks to start counting calls from scratch. mock_get.reset_mock() mock_post.reset_mock() # Send another notification. assert ( bool( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) ) is True ) # Calls to `users/me` will get cached by `NotifyTwitter`. # So, the `GET` request to `users/me` should only have been # issued once. assert mock_get.call_count == 0 assert mock_post.call_count == 1 def test_plugin_twitter_dm_attachments_basic( mocker, twitter_url, good_message_response, good_media_response ): """ NotifyTwitter() DM Attachment Checks - Basic """ mock_get = mocker.patch("requests.get") mock_post = mocker.patch("requests.post") # Epoch time: epoch = datetime.fromtimestamp(0, timezone.utc) mock_get.return_value = good_message_response mock_post.return_value.headers = { "x-rate-limit-reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "x-rate-limit-remaining": 1, } # The first response is for uploading the attachment, # the second one for posting the actual message. mock_post.side_effect = [good_media_response, good_message_response] # Create application objects. obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our notification. tracker_token = _delivery_tracker.set(set()) try: assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is True ) finally: _delivery_tracker.reset(tracker_token) # Test call counts. assert mock_get.call_count == 1 assert ( # v2 whoami endpoint mock_get.call_args_list[0][0][0] == "https://api.twitter.com/2/users/me" ) assert mock_post.call_count == 2 assert ( # v2 media upload endpoint (available on all access tiers) mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( # v2 DM endpoint; user id "9876" comes from good_message_response mock_post.call_args_list[1][0][0] == "https://api.twitter.com/2/dm_conversations/with/9876/messages" ) def test_plugin_twitter_dm_attachments_message_fails( mocker, twitter_url, good_media_response, bad_message_response ): """Test case with a bad media response.""" mock_post = mocker.patch("requests.post") mock_post.side_effect = [good_media_response, bad_message_response] # Create application objects. obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our notification; it will fail because of the message response. assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is False ) assert mock_post.call_count == 2 assert ( # v2 media upload endpoint (available on all access tiers) mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( # v2 DM endpoint; user id "9876" comes from good_message_response mock_post.call_args_list[1][0][0] == "https://api.twitter.com/2/dm_conversations/with/9876/messages" ) def test_plugin_twitter_dm_attachments_upload_fails( mocker, twitter_url, good_message_response, bad_media_response ): """Test case where upload fails.""" mock_post = mocker.patch("requests.post") mock_post.side_effect = [bad_media_response, good_message_response] # Create application objects. obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our notification; it will fail because of the media response. assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is False ) # Test call counts. assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) def test_plugin_twitter_dm_attachments_invalid_attachment( mocker, twitter_url, good_message_response ): """Test case with an invalid attachment.""" mock_post: Mock = mocker.patch("requests.post") mock_post.side_effect = [good_media_response, good_message_response] # Create application objects. # An invalid attachment will cause a failure. obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment( os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") ) assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is False ) # Verify no post requests have been issued, because attachment is not good. assert mock_post.mock_calls == [] def test_plugin_twitter_dm_attachments_multiple( mocker, twitter_url, good_message_response, good_media_response ): """Verify multiple attachments are included with a direct message.""" mock_post = mocker.patch("requests.post") mock_post.side_effect = [ good_media_response, good_media_response, good_media_response, good_media_response, good_message_response, good_message_response, good_message_response, good_message_response, ] # 4 images are produced attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.jpeg"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), # This one is not supported, so it's ignored gracefully os.path.join(TEST_VAR_DIR, "apprise-test.mp4"), ] # Create application objects. obj = Apprise.instantiate(twitter_url) tracker_token = _delivery_tracker.set(set()) try: assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is True ) finally: _delivery_tracker.reset(tracker_token) assert mock_post.call_count == 8 # First 4 calls are v2 media uploads assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[2][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[3][0][0] == "https://api.x.com/2/media/upload" ) # Last 4 calls are DM sends via v2 endpoint; user id "9876" from # good_message_response assert ( mock_post.call_args_list[4][0][0] == "https://api.twitter.com/2/dm_conversations/with/9876/messages" ) assert ( mock_post.call_args_list[5][0][0] == "https://api.twitter.com/2/dm_conversations/with/9876/messages" ) assert ( mock_post.call_args_list[6][0][0] == "https://api.twitter.com/2/dm_conversations/with/9876/messages" ) assert ( mock_post.call_args_list[7][0][0] == "https://api.twitter.com/2/dm_conversations/with/9876/messages" ) def test_plugin_twitter_dm_attachments_multiple_oserror( mocker, twitter_url, good_message_response, good_media_response ): """Verify a file error during multi-attachment DM delivery is handled.""" # Inject an `OSError` into the middle of the operation. mock_post = mocker.patch("requests.post") mock_post.side_effect = [good_media_response, OSError()] # 2 images are produced attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), # This one is not supported, so it's ignored gracefully os.path.join(TEST_VAR_DIR, "apprise-test.mp4"), ] # Create application objects. obj = Apprise.instantiate(twitter_url) # We'll fail to send this time assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is False ) assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.x.com/2/media/upload" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_basic( mock_post, twitter_url, good_message_response, good_media_response ): """ NotifyTwitter() Tweet Attachment Checks - Basic """ mock_post.side_effect = [good_media_response, good_message_response] # Create application objects. twitter_url += "?mode=tweet" obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our notification assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is True ) # Verify API calls. assert mock_post.call_count == 2 assert ( # v2 media upload endpoint (available on all access tiers) mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( # v2 tweet endpoint mock_post.call_args_list[1][0][0] == "https://api.twitter.com/2/tweets" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_more_logging( mock_post, twitter_url, good_media_response ): """ NotifyTwitter() Tweet Attachment Checks - More logging """ # v2 tweet response: {"data": {"id": "...", "text": "..."}} good_tweet_response = good_response( { "data": { "id": "12345", "text": "body", }, } ) mock_post.side_effect = [good_media_response, good_tweet_response] # Create application objects. twitter_url += "?mode=tweet" obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our notification assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is True ) # Verify API calls. assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.twitter.com/2/tweets" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_bad_message_response( mock_post, twitter_url, good_media_response, bad_message_response ): """Verify a failed tweet response fails attachment delivery.""" mock_post.side_effect = [good_media_response, bad_message_response] # Create application objects. twitter_url += "?mode=tweet" obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Our notification will fail now since our tweet will error out. assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is False ) # Verify API calls. assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.twitter.com/2/tweets" ) @patch("requests.post") def test_plugin_twitter_unparseable_attachment_error( mock_post, twitter_url, good_media_response ): """Verify an unparseable tweet failure response is handled.""" bad_message_response = bad_response("") mock_post.side_effect = [good_media_response, bad_message_response] # Create application objects. twitter_url += "?mode=tweet" obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # The notification will fail now since the tweet will error out. # This is the same test as above, except that the error response is not # parseable. assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is False ) # Verify API calls. assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.twitter.com/2/tweets" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_upload_fails( mock_post, twitter_url, good_media_response ): """Verify a failed media upload prevents tweet delivery.""" # Prepare a bad tweet response. bad_tweet_response = bad_response({}) # Test case where upload fails. mock_post.side_effect = [good_media_response, bad_tweet_response] # Create application objects. twitter_url += "?mode=tweet" obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our notification; it will fail because of the message response. assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is False ) # Verify API calls. assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.twitter.com/2/tweets" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_invalid_attachment( mock_post, twitter_url, good_message_response, good_media_response ): """Verify an invalid tweet attachment is rejected cleanly.""" mock_post.side_effect = [good_media_response, good_message_response] # Create application objects. twitter_url += "?mode=tweet" obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment( os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") ) # An invalid attachment will cause a failure. assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is False ) # No post request as attachment is not good. assert mock_post.call_count == 0 @patch("requests.post") def test_plugin_twitter_tweet_attachments_multiple_batch( mock_post, twitter_url, good_message_response, good_media_response ): """Verify multiple tweet attachments are sent in batch mode.""" mock_post.side_effect = [ good_media_response, good_media_response, good_media_response, good_media_response, good_message_response, good_message_response, good_message_response, good_message_response, ] # instantiate our object obj = Apprise.instantiate(twitter_url + "?mode=tweet") # 4 images are produced attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.jpeg"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), # This one is not supported, so it's ignored gracefully os.path.join(TEST_VAR_DIR, "apprise-test.mp4"), ] assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is True ) assert mock_post.call_count == 7 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[2][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[3][0][0] == "https://api.x.com/2/media/upload" ) # v2 tweet endpoint assert ( mock_post.call_args_list[4][0][0] == "https://api.twitter.com/2/tweets" ) assert ( mock_post.call_args_list[5][0][0] == "https://api.twitter.com/2/tweets" ) # The 2 images are grouped together (batch mode) assert ( mock_post.call_args_list[6][0][0] == "https://api.twitter.com/2/tweets" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_multiple_nobatch( mock_post, twitter_url, good_message_response, good_media_response ): """Verify multiple tweet attachments are sent without batch mode.""" mock_post.side_effect = [ good_media_response, good_media_response, good_media_response, good_media_response, good_message_response, good_message_response, good_message_response, good_message_response, ] # instantiate our object (without a batch mode) obj = Apprise.instantiate(twitter_url + "?mode=tweet&batch=no") # 4 images are produced attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.jpeg"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), # This one is not supported, so it's ignored gracefully os.path.join(TEST_VAR_DIR, "apprise-test.mp4"), ] assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is True ) assert mock_post.call_count == 8 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[2][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[3][0][0] == "https://api.x.com/2/media/upload" ) # v2 tweet endpoint (one per image, no batching) assert ( mock_post.call_args_list[4][0][0] == "https://api.twitter.com/2/tweets" ) assert ( mock_post.call_args_list[5][0][0] == "https://api.twitter.com/2/tweets" ) assert ( mock_post.call_args_list[6][0][0] == "https://api.twitter.com/2/tweets" ) assert ( mock_post.call_args_list[7][0][0] == "https://api.twitter.com/2/tweets" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_multiple_oserror( mock_post, twitter_url, good_media_response ): """Verify file errors during multi-attachment tweets are handled.""" # We have an OSError thrown in the middle of our preparation mock_post.side_effect = [good_media_response, OSError()] # 2 images are produced attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), # This one is not supported, so it's ignored gracefully os.path.join(TEST_VAR_DIR, "apprise-test.mp4"), ] # We'll fail to send this time obj = Apprise.instantiate(twitter_url + "?mode=tweet") assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is False ) assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.x.com/2/media/upload" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_jpeg_before_gif( mock_post, twitter_url, good_media_response ): """ Verify that when PNG/JPEG attachments precede a GIF in batch mode, the accumulated PNG/JPEG batch is flushed first, then the GIF is emitted alone. This exercises the ``if batch:`` flush branch inside the non-batchable (GIF) path of _send_tweet. """ # v2 tweet response good_tweet_response = good_response( {"data": {"id": "12345", "text": "body"}} ) # 3 uploads (jpeg, png, gif) + 2 tweet sends = 5 post calls mock_post.side_effect = [ good_media_response, good_media_response, good_media_response, good_tweet_response, good_tweet_response, ] # instantiate in tweet + batch mode obj = Apprise.instantiate(twitter_url + "?mode=tweet") # jpeg and png come first, then gif -- the gif triggers a flush of # the pending jpeg/png batch before being emitted in its own tweet attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.jpeg"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), ] assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) ) is True ) # 3 media uploads + 2 tweet sends assert mock_post.call_count == 5 # First 3 are v2 media uploads assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[2][0][0] == "https://api.x.com/2/media/upload" ) # jpeg + png batched into one tweet assert ( mock_post.call_args_list[3][0][0] == "https://api.twitter.com/2/tweets" ) # gif emitted alone in its own tweet assert ( mock_post.call_args_list[4][0][0] == "https://api.twitter.com/2/tweets" ) @patch("apprise.plugins.base.time.sleep") @patch("requests.post") def test_plugin_twitter_tweet_retry( mock_post, mock_sleep, twitter_url, good_message_response, bad_message_response, good_media_response, ): """A retry only re-posts the tweet that failed.""" # Tweet 1 posts, tweet 2 fails, then the retry posts tweet 2 alone tweets = [ good_message_response, bad_message_response, good_message_response, ] def _post(url, *args, **kwargs): return ( good_media_response if url == "https://api.x.com/2/media/upload" else tweets.pop(0) ) mock_post.side_effect = _post aobj = Apprise() assert aobj.add(twitter_url + "?mode=tweet&batch=no&retry=2&wait=0") # Each gif is its own tweet attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), ] assert bool(aobj.notify(body="body", attach=attach)) is True texts = [ json.loads(call[1]["data"])["text"] for call in mock_post.call_args_list if call[0][0] == "https://api.twitter.com/2/tweets" ] # The first tweet was never repeated assert texts == ["body", "02/02", "02/02"] assert not tweets @patch("apprise.plugins.base.time.sleep") @patch("requests.post") def test_plugin_twitter_dm_retry( mock_post, mock_sleep, twitter_url, good_message_response, bad_message_response, good_media_response, ): """A retry only re-sends the direct message that failed.""" # DM 1 is sent, DM 2 fails, then the retry sends DM 2 alone messages = [ good_message_response, bad_message_response, good_message_response, ] def _post(url, *args, **kwargs): return ( good_media_response if url == "https://api.x.com/2/media/upload" else messages.pop(0) ) mock_post.side_effect = _post aobj = Apprise() assert aobj.add(twitter_url + "?mode=dm&retry=2&wait=0") attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), ] assert bool(aobj.notify(body="body", attach=attach)) is True texts = [ json.loads(call[1]["data"])["text"] for call in mock_post.call_args_list if call[0][0].endswith("/messages") ] # The first message was never repeated assert texts == ["body", "02/02", "02/02"] assert not messages caronc-apprise-dfec414/tests/test_plugin_vapid.py000066400000000000000000002047261526720371000223660ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import base64 import json # Disable logging for a cleaner testing output import logging import os import stat import sys from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, asset, exception, url from apprise.common import PersistentStoreMode from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.vapid import VAPID_API_LOOKUP, NotifyVapid from apprise.plugins.vapid.subscription import ( WebPushSubscription, WebPushSubscriptionManager, webpush_origin, ) from apprise.utils.cwe312 import cwe312_loggable from apprise.utils.pem import ApprisePEMController logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # a test UUID we can use SUBSCRIBER = "user@example.com" PLUGIN_ID = "vapid" # Windows has no POSIX file modes, so tests that check them are skipped # there. IS_WINDOWS = sys.platform == "win32" # Root ignores file permissions entirely, so a read-only file still tests # as writable. RPM builds run their test suite as root. IS_ROOT = hasattr(os, "geteuid") and os.geteuid() == 0 # Our Testing URLs apprise_url_tests = ( ( "vapid://", { "instance": AppriseImproperlyConfigured, }, ), ( "vapid://:@/", { "instance": AppriseImproperlyConfigured, }, ), ( "vapid://invalid-subscriber", { # An invalid Subscriber "instance": AppriseImproperlyConfigured, }, ), ( "vapid://user@example.com", { # bare bone requirements met, but we don't have our subscription # file or our private key (pem) "instance": NotifyVapid, # We'll fail to respond because we would not have found any # configuration to load "notify_response": False, }, ), ( "vapid://user@example.com?keyfile=invalid&subfile=invalid", { # Test passing keyfile and subfile on our path (even if invalid) "instance": NotifyVapid, # We'll fail to respond because we would not have found any # configuration to load "notify_response": False, }, ), ( "vapid://user@example.com/newuser@example.com", { # we don't have our subscription file or private key "instance": NotifyVapid, "notify_response": False, }, ), ( "vapid://user@example.ca/newuser@example.ca", { "instance": NotifyVapid, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "vapid://user@example.uk/newuser@example.uk", { "instance": NotifyVapid, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "vapid://user@example.au/newuser@example.au", { "instance": NotifyVapid, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) @pytest.fixture def patch_persistent_store_namespace(tmpdir): """Force an easy to test environment.""" with ( mock.patch.object(url.URLBase, "url_id", return_value=PLUGIN_ID), mock.patch.object( asset.AppriseAsset, "storage_mode", PersistentStoreMode.AUTO ), mock.patch.object(asset.AppriseAsset, "storage_path", str(tmpdir)), ): tmp_dir = tmpdir.mkdir(PLUGIN_ID) # Return the directory name yield str(tmp_dir) @pytest.fixture def subscription_reference(): return { "user@example.com": { "endpoint": "https://fcm.googleapis.com/fcm/send/default", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, }, "user1": { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, }, "user2": { "endpoint": "https://fcm.googleapis.com/fcm/send/def456", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, }, } @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_urls(): """ NotifyVapid() Apprise URLs - No Config """ # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_urls_with_required_assets( patch_persistent_store_namespace, subscription_reference ): """NotifyVapid() Apprise URLs With Config.""" # Determine our store pc = ApprisePEMController(path=patch_persistent_store_namespace) assert pc.keygen() is True # Write our subscriptions file to disk subscription_file = os.path.join( patch_persistent_store_namespace, WebPushSubscriptionManager.default_filename, ) with open(subscription_file, "w") as f: f.write(json.dumps(subscription_reference)) tests = ( ( "vapid://user@example.com", { # user@example.com loaded (also used as subscriber id) "instance": NotifyVapid, }, ), ( "vapid://user@example.com/newuser@example.com", { # no newuser@example.com key entry "instance": NotifyVapid, "notify_response": False, }, ), ( "vapid://user@example.com/user1?to=user2", { # We'll succesfully notify 2 users "instance": NotifyVapid, }, ), ( "vapid://user1?to=user2&from=user@example.com", { # We'll succesfully notify 2 users "instance": NotifyVapid, }, ), ( "vapid://?to=user2&from=user@example.com", { # No host provided "instance": NotifyVapid, }, ), ( "vapid://user@example.com?to=user2&from=user@example.com", { # We'll succesfully notify 2 users "instance": NotifyVapid, }, ), ( "vapid://user@example.com/user1?to=user2&ttl=15", { # test ttl "instance": NotifyVapid, }, ), ( "vapid://user@example.com/user1?to=user2&ttl=", { # test ttl "instance": NotifyVapid, }, ), ( "vapid://user@example.com/user1?to=user2&ttl=invalid", { # test ttl "instance": NotifyVapid, }, ), ( "vapid://user@example.com/user1?to=user2&ttl=-4000", { # bad ttl "instance": AppriseImproperlyConfigured, }, ), ( "vapid://user@example.com/user1?to=user2&mode=edge", { # test mode "instance": NotifyVapid, }, ), ( "vapid://user@example.com/user1?to=user2&mode=", { # test mode "instance": AppriseImproperlyConfigured, }, ), ( "vapid://user@example.com/user1?to=user2&mode=invalid", { # test mode more "instance": AppriseImproperlyConfigured, }, ), ( "vapid://user@example.com/user1", { "instance": NotifyVapid, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "vapid://user@example.com/user1", { "instance": NotifyVapid, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "vapid://user@example.com/user1", { "instance": NotifyVapid, # Throws a series of connection and transfer exceptions # when this flag is set and tests that we gracefully handle # them "test_requests_exceptions": True, }, ), ) AppriseURLTester(tests=tests).run_all() @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_subscriptions(tmpdir): """NotifyVapid() Subscriptions.""" # Temporary directory tmpdir0 = tmpdir.mkdir("tmp00") with pytest.raises(exception.AppriseInvalidData): # Integer not supported WebPushSubscription(42) with pytest.raises(exception.AppriseInvalidData): # Not the correct format WebPushSubscription("bad-content") with pytest.raises(exception.AppriseInvalidData): # Invalid JSON WebPushSubscription("{") with pytest.raises(exception.AppriseInvalidData): # Empty Dictionary WebPushSubscription({}) with pytest.raises(exception.AppriseInvalidData): WebPushSubscription( { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": "BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR=", "auth": 42, }, } ) with pytest.raises(exception.AppriseInvalidData): WebPushSubscription( { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": 42, "auth": "k9Xzm43nBGo=", }, } ) with pytest.raises(exception.AppriseInvalidData): WebPushSubscription( { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", } ) with pytest.raises(exception.AppriseInvalidData): WebPushSubscription( { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": {}, } ) with pytest.raises(exception.AppriseInvalidData): # Invalid p256dh public key provided wps = WebPushSubscription( { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": "BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR=", "auth": "k9Xzm43nBGo=", }, } ) # An empty object wps = WebPushSubscription() assert bool(wps) is False assert isinstance(wps.json(), str) assert json.loads(wps.json()) assert str(wps) == "" assert wps.auth is None assert wps.endpoint is None assert wps.p256dh is None assert wps.public_key is None # We can't write anything as there is nothing loaded assert wps.write(os.path.join(str(tmpdir0), "subscriptions.json")) is False # A valid key wps = WebPushSubscription( { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, } ) assert bool(wps) is True assert isinstance(wps.json(), str) assert json.loads(wps.json()) assert str(wps) == "abc123" assert wps.auth == "k9Xzm43nBGo=" assert wps.endpoint == "https://fcm.googleapis.com/fcm/send/abc123" assert ( wps.p256dh == "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ) assert wps.public_key is not None # Currently no files here assert os.listdir(str(tmpdir0)) == [] # Bad content assert wps.write(object) is False assert wps.write(None) is False # Can't write to a name already taken by as a directory assert wps.write(str(tmpdir0)) is False # Can't write to a name already taken by as a directory assert wps.write(os.path.join(str(tmpdir0), "subscriptions.json")) is True assert os.listdir(str(tmpdir0)) == ["subscriptions.json"] @pytest.mark.skipif( "cryptography" in sys.modules, reason="Requires that cryptography NOT be installed", ) def test_plugin_vapid_subscriptions_without_c(): """NotifyVapid() Subscriptions (no Cryptography)""" with pytest.raises(exception.AppriseInvalidData): # A valid key that can't be loaded because crytography is missing WebPushSubscription( { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, } ) @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_subscription_manager(tmpdir): """NotifyVapid() Subscription Manager.""" # Temporary directory tmpdir0 = tmpdir.mkdir("tmp00") with pytest.raises(exception.AppriseInvalidData): # An invalid object smgr = WebPushSubscriptionManager() smgr["abc"] = "invalid" with pytest.raises(exception.AppriseInvalidData): # An invalid object smgr = WebPushSubscriptionManager() smgr += "invalid" smgr = WebPushSubscriptionManager() assert bool(smgr) is False assert len(smgr) == 0 sub = { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, } assert smgr.add(sub) is True assert bool(smgr) is True assert len(smgr) == 1 # Same sub (overwrites same slot) smgr += sub assert bool(smgr) is True assert len(smgr) == 1 # This makes a copy smgr["abc"] = smgr["abc123"] assert bool(smgr) is True assert len(smgr) == 2 assert isinstance(smgr["abc123"], WebPushSubscription) # Currently no files here assert os.listdir(str(tmpdir0)) == [] # Write our content assert smgr.write(os.path.join(str(tmpdir0), "subscriptions.json")) is True assert os.listdir(str(tmpdir0)) == ["subscriptions.json"] # Reset our object smgr.clear() assert bool(smgr) is False assert len(smgr) == 0 # Load our content back assert smgr.load(os.path.join(str(tmpdir0), "subscriptions.json")) is True assert bool(smgr) is True assert len(smgr) == 2 # Write over our file using the standard Subscription format assert ( smgr["abc123"].write(os.path.join(str(tmpdir0), "subscriptions.json")) is True ) # We can still open this type as well assert smgr.load(os.path.join(str(tmpdir0), "subscriptions.json")) is True assert bool(smgr) is True assert len(smgr) == 1 smgr.clear() bad_entry = { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": "invalid", "auth": "garbage", }, } subscriptions = os.path.join(str(tmpdir0), "subscriptions.json") with open(subscriptions, "w", encoding="utf-8") as f: # A bad JSON file f.write("{") assert smgr.load(subscriptions) is False with open(subscriptions, "w", encoding="utf-8") as f: # not expected dictionary f.write("null") assert smgr.load(subscriptions) is False subscriptions = os.path.join(str(tmpdir0), "subscriptions.json") with open(subscriptions, "w", encoding="utf-8") as f: json.dump(bad_entry, f) assert smgr.load(subscriptions) is False # Create bad data bad_data = { "bad1": bad_entry, "bad2": bad_entry, "bad3": bad_entry, "bad4": bad_entry, } subscriptions = os.path.join(str(tmpdir0), "subscriptions.json") with open(subscriptions, "w", encoding="utf-8") as f: json.dump(bad_data, f) assert smgr.load(subscriptions) is False assert smgr.load("invalid-file") is False @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @mock.patch("requests.post") def test_plugin_vapid_initializations(mock_post, tmpdir): """NotifyVapid() Initializations.""" # Assign our mock object our return value okay_response = requests.Request() okay_response.status_code = requests.codes.ok okay_response.content = "" mock_post.return_value = okay_response # Temporary directory tmpdir0 = tmpdir.mkdir("tmp00") # Write our subfile smgr = WebPushSubscriptionManager() sub = { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, } subfile = os.path.join(str(tmpdir0), "subscriptions.json") assert smgr.add(sub) is True assert smgr.add(smgr["abc123"]) is True assert os.listdir(str(tmpdir0)) == [] with mock.patch("json.dump", side_effect=OSError): # We will fial to write assert smgr.write(subfile) is False assert smgr.write(subfile) is True assert os.listdir(str(tmpdir0)) == ["subscriptions.json"] assert isinstance(smgr.json(), str) asset_ = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), # Auto-gen our private/public key pair pem_autogen=True, ) # Auto-Key Generation obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], subfile=subfile, asset=asset_, ) assert isinstance(obj, NotifyVapid) # Our subscription directory + our # persistent store where our keys were generated assert len(os.listdir(str(tmpdir0))) == 2 # Second call re-references keys previously generated obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], subfile=subfile, asset=asset_, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) assert obj.send("test") is True # A second message makes no difference; what is loaded into memory is used assert obj.send("test") is True obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], subfile="/a/bad/path", asset=asset_, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) assert obj.send("test") is False # A second message makes no difference; what is loaded into memory is used assert obj.send("test") is False # Detect our keyfile cache_dir = next( x for x in os.listdir(str(tmpdir0)) if not x.endswith("subscriptions.json") ) # Test fixed assignment to our keyfile keyfile = os.path.join(str(tmpdir0), cache_dir, "private_key.pem") assert os.path.exists(keyfile) obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], keyfile=keyfile, subfile=subfile, asset=asset_, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) assert obj.send("test") is True # A second message makes no difference; what is loaded into memory is used assert obj.send("test") is True # Invalid Keyfile obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], keyfile=subfile, subfile=subfile, asset=asset_, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) assert obj.send("test") is False # A second message makes no difference; what is loaded into memory is used assert obj.send("test") is False # AutoGen Temporary directory tmpdir1 = tmpdir.mkdir("tmp01") asset2 = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir1), # Auto-gen our private/public key pair pem_autogen=True, ) assert os.listdir(str(tmpdir1)) == [] obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], keyfile=keyfile, asset=asset2, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) # We have a temporary subscription file we can use assert os.listdir(str(tmpdir1)) == ["00088ad3"] # We will have a dud configuration file, but at least it's something # to help the user with assert obj.send("test") is False # Second instance fails as well assert obj.send("test") is False # AutoGen Temporary directory tmpdir2 = tmpdir.mkdir("tmp02") asset3 = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir2), # Auto-gen our private/public key pair pem_autogen=True, ) # Test invalid keyfile assert os.path.exists(keyfile) obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], keyfile="invalid-file", subfile=subfile, asset=asset3, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) assert obj.send("test") is False # A second message makes no difference; what is loaded into memory is used assert obj.send("test") is False @pytest.mark.skipif( "cryptography" in sys.modules, reason="Requires that cryptography NOT be installed", ) def test_plugin_vapid_initializations_without_c(tmpdir): """NotifyVapid() Initializations without cryptography.""" # Temporary directory tmpdir0 = tmpdir.mkdir("tmp00") # Write our subfile smgr = WebPushSubscriptionManager() sub = { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, } subfile = os.path.join(str(tmpdir0), "subscriptions.json") assert smgr.add(sub) is False asset_ = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), # Auto-gen our private/public key pair pem_autogen=True, ) # Auto-Key Generation obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], subfile=subfile, asset=asset_, ) assert isinstance(obj, NotifyVapid) def _jwt_audience(authorization): """Returns the VAPID JWT audience from an Authorization header.""" token = authorization.split("t=", 1)[1].split(",", 1)[0] payload = token.split(".")[1] # JWT segments use base64url without padding. payload += "=" * (-len(payload) % 4) return json.loads(base64.urlsafe_b64decode(payload))["aud"] # Valid P-256 public key shared by the test subscriptions. TEST_P256DH = ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ) # Valid authentication secret shared by the test subscriptions. TEST_AUTH = "k9Xzm43nBGo=" def _mk_resp(code=requests.codes.ok): """Builds a mocked push service response carrying the code given.""" response = requests.Request() response.status_code = code response.content = "" return response def _subscription(endpoint): """Builds a single subscription dictionary for the endpoint given.""" return { "endpoint": endpoint, "keys": {"p256dh": TEST_P256DH, "auth": TEST_AUTH}, } def _write_subscriptions(path, endpoints): """Builds a subscriptions.json containing the endpoints provided. ``endpoints`` accepts: - a list of endpoints, each named after its own last path segment - a dictionary of name to endpoint pairs """ if isinstance(endpoints, dict): with open(path, "w") as f: json.dump( { name: _subscription(endpoint) for name, endpoint in endpoints.items() }, f, ) return smgr = WebPushSubscriptionManager() for endpoint in endpoints: assert smgr.add(_subscription(endpoint)) is True assert smgr.write(path) is True def _mk_notifier(tmpdir, endpoints, targets, name="vapid"): """Builds a NotifyVapid() backed by a subscriptions file on disk. Returns the notifier along with the path of the file that was written. """ tmpdir0 = tmpdir.mkdir(name) subfile = os.path.join(str(tmpdir0), "subscriptions.json") _write_subscriptions(subfile, endpoints) obj = NotifyVapid( "user@example.ca", targets=targets, subfile=subfile, asset=asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), pem_autogen=True, ), ) return obj, subfile @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @mock.patch("requests.post") def test_plugin_vapid_subscription_endpoint(mock_post, tmpdir): """NotifyVapid() delivers to the endpoint of the subscription itself.""" mock_post.return_value = _mk_resp() endpoint = "https://fcm.googleapis.com/fcm/send/abc123" obj, _ = _mk_notifier(tmpdir, [endpoint], ["abc123"], name="tmp10") assert obj.send("test") is True # Use the browser-issued endpoint instead of the mode's base URL. assert mock_post.call_args[0][0] == endpoint assert mock_post.call_args[0][0] != VAPID_API_LOOKUP[obj.mode] # Use the endpoint's origin as the JWT audience. headers = mock_post.call_args[1]["headers"] assert _jwt_audience(headers["Authorization"]) == ( "https://fcm.googleapis.com" ) @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @mock.patch("requests.post") def test_plugin_vapid_multiple_endpoints(mock_post, tmpdir): """NotifyVapid() handles subscriptions hosted by different services.""" mock_post.return_value = _mk_resp() endpoints = { "abc123": "https://fcm.googleapis.com/fcm/send/abc123", "xyz789": "https://web.push.apple.com/xyz789", } obj, _ = _mk_notifier( tmpdir, list(endpoints.values()), list(endpoints), name="tmp11" ) assert obj.send("test") is True assert mock_post.call_count == len(endpoints) # Give each service its own endpoint and JWT audience. delivered = {} for call in mock_post.call_args_list: url = call[0][0] delivered[url] = _jwt_audience(call[1]["headers"]["Authorization"]) assert set(delivered) == set(endpoints.values()) assert delivered["https://fcm.googleapis.com/fcm/send/abc123"] == ( "https://fcm.googleapis.com" ) assert delivered["https://web.push.apple.com/xyz789"] == ( "https://web.push.apple.com" ) @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @pytest.mark.parametrize( "status_code", ( requests.codes.ok, requests.codes.created, requests.codes.accepted, requests.codes.no_content, ), ) @mock.patch("requests.post") def test_plugin_vapid_success_codes(mock_post, tmpdir, status_code): """NotifyVapid() accepts its supported success responses.""" mock_post.return_value = _mk_resp(status_code) obj, _ = _mk_notifier( tmpdir, ["https://fcm.googleapis.com/fcm/send/abc123"], ["abc123"], name=f"ok{status_code}", ) # Supported services may acknowledge queued messages with 201 or 202. assert obj.send("test") is True @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @mock.patch("requests.post") def test_plugin_vapid_prunes_expired_subscriptions(mock_post, tmpdir): """A 404 or a 410 from the push service removes that subscription.""" def respond(url, *args, **kwargs): # Endpoint names select the expired response returned by this mock. return _mk_resp( requests.codes.gone if url.endswith("GONE") else ( requests.codes.not_found if url.endswith("MISSING") else requests.codes.created ) ) mock_post.side_effect = respond obj, subfile = _mk_notifier( tmpdir, { "live": "https://web.push.apple.com/LIVE", "gone": "https://web.push.apple.com/GONE", "missing": "https://web.push.apple.com/MISSING", }, ["live", "gone", "missing"], name="prune", ) # An expired endpoint is reported honestly as a failure, even though # another target was accepted. assert obj.send("test") is False # Remove only the expired subscriptions from disk. with open(subfile) as f: remaining = json.load(f) assert set(remaining) == {"live"} assert "gone" not in obj.subscriptions assert "missing" not in obj.subscriptions @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @mock.patch("requests.post") def test_plugin_vapid_prune_write_failure(mock_post, tmpdir): """A subscription file that cannot be updated does not break sending.""" mock_post.return_value = _mk_resp(requests.codes.gone) obj, subfile = _mk_notifier( tmpdir, ["https://web.push.apple.com/DEAD"], ["dead"], name="writefail", ) with mock.patch.object( WebPushSubscriptionManager, "prune", return_value=False ): assert obj.send("test") is False # Remove the expired subscription from memory despite the write failure. assert "dead" not in obj.subscriptions # The unchanged file still contains the expired subscription. with open(subfile) as f: assert "dead" in json.load(f) @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @mock.patch("requests.post") def test_plugin_vapid_expired_after_subfile_clear(mock_post, tmpdir): """An expired subscription is dropped after clearing ``subfile``.""" mock_post.return_value = _mk_resp(requests.codes.gone) obj, subfile = _mk_notifier( tmpdir, ["https://web.push.apple.com/DEAD"], ["dead"], name="nosubfile", ) # Load subscriptions, then clear the notifier's file reference assert obj.subscriptions.load(subfile) is True obj.subfile = None assert obj.send("test") is False # The expired subscription is still dropped from memory. assert "dead" not in obj.subscriptions @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_subscription_remove(): """WebPushSubscriptionManager() reports whether it removed anything.""" smgr = WebPushSubscriptionManager() # Nothing can be removed from an empty manager. assert smgr.remove("missing") is False assert smgr.add(_subscription("https://web.push.apple.com/abc123")) is True assert len(smgr) == 1 # Match subscription names without regard to case. assert smgr.remove("ABC123") is True assert len(smgr) == 0 assert smgr.remove("abc123") is False @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @pytest.mark.parametrize( ("endpoint", "origin"), ( # A standard endpoint keeps its origin. ( "https://fcm.googleapis.com/fcm/send/abc123", "https://fcm.googleapis.com", ), # Lowercase the scheme and host, and omit port 443. ( "HTTPS://FCM.GoogleAPIs.COM:443/fcm/send/abc123", "https://fcm.googleapis.com", ), # Ignore surrounding whitespace. ( " https://fcm.googleapis.com/fcm/send/abc123 ", "https://fcm.googleapis.com", ), # Keep a non-default port in the origin. ( "https://push.example.com:8443/wpush/v1/abc123", "https://push.example.com:8443", ), # Keep brackets around an IPv6 host. ("https://[2001:db8::1]/wpush/v1/abc", "https://[2001:db8::1]"), ( "https://[2001:db8::1]:8443/wpush/v1/abc", "https://[2001:db8::1]:8443", ), # Allow a self-hosted service on a private address. ("https://192.168.0.10:8443/push/abc", "https://192.168.0.10:8443"), ), ) def test_plugin_vapid_endpoint_origins(endpoint, origin): """A valid endpoint is accepted and reduced to its origin.""" assert webpush_origin(endpoint) == origin sub = WebPushSubscription(_subscription(endpoint)) assert sub.origin == origin assert sub.endpoint == endpoint.strip() @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @pytest.mark.parametrize( "endpoint", ( # Web Push requires HTTPS. "http://fcm.googleapis.com/fcm/send/abc123", "ftp://fcm.googleapis.com/fcm/send/abc123", "fcm.googleapis.com/fcm/send/abc123", # Reject embedded credentials, even empty ones. "https://user@fcm.googleapis.com/fcm/send/abc123", "https://user:pass@fcm.googleapis.com/fcm/send/abc123", "https://@fcm.googleapis.com/fcm/send/abc123", "https://:@fcm.googleapis.com/fcm/send/abc123", "https://:pass@fcm.googleapis.com/fcm/send/abc123", # Reject endpoints without a host. "https://", "https:///fcm/send/abc123", "://", "", "not a url", # Reject malformed IPv6 without raising. "https://[2001:db8::1/fcm/send/abc123", # Reject an out-of-range port without raising. "https://fcm.googleapis.com:99999/fcm/send/abc123", # Reject a host holding characters that are not legal in one. "https://fcm google apis.com/fcm/send/abc123", "https://fcm%20googleapis.com/fcm/send/abc123", "https://-fcm.googleapis.com/fcm/send/abc123", "https://.fcm.googleapis.com/fcm/send/abc123", f"https://{'a' * 254}.com/fcm/send/abc123", # Reject an IPv6 literal that is not an address at all. "https://[2001:db8::1::2]/fcm/send/abc123", "https://[not:an:address]/fcm/send/abc123", # Reject non-string values. None, 42, ["https://fcm.googleapis.com/fcm/send/abc123"], ), ) def test_plugin_vapid_bad_endpoints(endpoint): """An endpoint that cannot be used safely is rejected.""" assert webpush_origin(endpoint) is None # A subscription containing an invalid endpoint cannot be loaded. with pytest.raises(exception.AppriseInvalidData): WebPushSubscription(_subscription(endpoint)) sub = WebPushSubscription() assert sub.load(_subscription(endpoint)) is False assert sub.origin is None assert sub.endpoint is None assert bool(sub) is False # The manager also refuses the subscription. smgr = WebPushSubscriptionManager() assert smgr.add(_subscription(endpoint)) is False assert len(smgr) == 0 @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_subscription_atomic_write(tmpdir): """A failed write leaves whatever was already on disk alone.""" tmpdir0 = tmpdir.mkdir("atomic") path = os.path.join(str(tmpdir0), "subscriptions.json") smgr = WebPushSubscriptionManager() assert smgr.add(_subscription("https://web.push.apple.com/abc123")) is True assert smgr.write(path) is True with open(path) as f: original = json.load(f) assert set(original) == {"abc123"} # A partial write must not replace the existing file. smgr.clear() assert smgr.add(_subscription("https://web.push.apple.com/xyz789")) is True with mock.patch("json.dump", side_effect=OSError): assert smgr.write(path) is False with open(path) as f: assert json.load(f) == original # Do not leave temporary files behind. assert os.listdir(str(tmpdir0)) == ["subscriptions.json"] # Treat a missing destination directory as a failed write. assert smgr.write(os.path.join(str(tmpdir0), "missing", "s.json")) is False @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @pytest.mark.skipif(IS_WINDOWS, reason="Requires POSIX file modes") def test_plugin_vapid_write_keeps_permissions(tmpdir): """An existing subscription file keeps the permissions it had.""" tmpdir0 = tmpdir.mkdir("perms") path = os.path.join(str(tmpdir0), "subscriptions.json") smgr = WebPushSubscriptionManager() assert smgr.add(_subscription("https://web.push.apple.com/abc123")) is True assert smgr.write(path) is True # A brand new file is kept to ourselves; it lists where we deliver assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 # Hand the file a mode of its own and rewrite it os.chmod(path, 0o640) assert smgr.add(_subscription("https://web.push.apple.com/xyz789")) is True assert smgr.write(path) is True assert stat.S_IMODE(os.stat(path).st_mode) == 0o640 # Confirm the new entry was written. with open(path) as f: assert set(json.load(f)) == {"abc123", "xyz789"} @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_write_follows_symlink(tmpdir): """A symlinked subscription file is written through, not replaced.""" tmpdir0 = tmpdir.mkdir("symlink") target = os.path.join(str(tmpdir0), "real.json") link = os.path.join(str(tmpdir0), "subscriptions.json") smgr = WebPushSubscriptionManager() assert smgr.add(_subscription("https://web.push.apple.com/abc123")) is True assert smgr.write(target) is True os.symlink(target, link) # Write by way of the link assert smgr.add(_subscription("https://web.push.apple.com/xyz789")) is True assert smgr.write(link) is True # Our link survived and the file it points at was the one updated assert os.path.islink(link) is True assert os.path.realpath(link) == target with open(target) as f: assert set(json.load(f)) == {"abc123", "xyz789"} # No temporary files are left beside either path assert sorted(os.listdir(str(tmpdir0))) == [ "real.json", "subscriptions.json", ] @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_bad_subfile_source(tmpdir): """An unavailable or invalid subscription source is refused.""" smgr = WebPushSubscriptionManager() # A remote source that cannot be fetched. The download is mocked so the # test does not depend on anything outside this machine. with mock.patch("requests.get") as mock_get: mock_get.return_value.status_code = ( requests.codes.internal_server_error ) mock_get.return_value.headers = {} mock_get.return_value.raise_for_status.side_effect = ( requests.RequestException ) mock_get.return_value.__enter__ = lambda s: s mock_get.return_value.__exit__ = lambda s, *a, **kw: None assert smgr.load("https://example.com/subscriptions.json") is False assert len(smgr) == 0 # Reject an empty path as well. assert smgr.load("") is False assert len(smgr) == 0 # A valid local file still loads normally. tmpdir0 = tmpdir.mkdir("local") path = os.path.join(str(tmpdir0), "subscriptions.json") _write_subscriptions(path, ["https://web.push.apple.com/abc123"]) assert smgr.load(path) is True assert len(smgr) == 1 @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_subfile_not_in_url(tmpdir): """A subscription path we worked out ourselves stays out of the URL.""" tmpdir0 = tmpdir.mkdir("portable") asset_ = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), pem_autogen=True, ) obj = Apprise.instantiate("vapid://user@example.ca/abc123", asset=asset_) # The file lives in this URL's own storage directory assert obj.subfile.startswith(obj.store.path) assert obj.subfile_specified is False # The URL omits local details so it can move to another installation. assert "subfile" not in obj.url() assert str(tmpdir0) not in obj.url() @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_subfile_in_url_when_asked(tmpdir): """A subscription path given to us is kept in the URL.""" tmpdir0 = tmpdir.mkdir("explicit") subfile = os.path.join(str(tmpdir0), "subscriptions.json") _write_subscriptions(subfile, ["https://web.push.apple.com/abc123"]) obj = Apprise.instantiate( f"vapid://user@example.ca/abc123?subfile={subfile}", asset=asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), pem_autogen=True, ), ) assert obj.subfile == subfile assert obj.subfile_specified is True # It round-trips, so a path someone chose is not lost assert "subfile" in obj.url() reloaded = Apprise.instantiate(obj.url()) assert reloaded.subfile == subfile @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_url_moves_between_servers(tmpdir): """The same URL resolves to each machine's own storage directory.""" rootA = tmpdir.mkdir("serverA") rootB = tmpdir.mkdir("serverB") def _server(root): return Apprise.instantiate( "vapid://user@example.ca/abc123", asset=asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(root), pem_autogen=True, ), ) objA = _server(rootA) objB = Apprise.instantiate( objA.url(), asset=asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(rootB), pem_autogen=True, ), ) # Both land on the same storage id, since it is worked out from the URL # and not from anything local to one machine assert objA.url_id() == objB.url_id() # Each instance reads from its own storage directory. assert objA.subfile.startswith(str(rootA)) assert objB.subfile.startswith(str(rootB)) @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_prune_keeps_other_entries(tmpdir): """Pruning removes only what it was asked to.""" tmpdir0 = tmpdir.mkdir("keep") path = os.path.join(str(tmpdir0), "subscriptions.json") # Mix valid entries with a typo and an entry missing its keys. original = { "good": _subscription("https://fcm.googleapis.com/fcm/send/GOOD"), "dead": _subscription("https://fcm.googleapis.com/fcm/send/DEAD"), "httponly": _subscription("http://legacy.example.com/push/OLD"), "typo": _subscription("https://fcm.googleapis.com /fcm/send/X"), "handedit": {"endpoint": "https://fcm.googleapis.com/fcm/send/Y"}, } with open(path, "w") as f: json.dump(original, f) smgr = WebPushSubscriptionManager() assert smgr.load(path) is True # Only the two usable entries were loaded assert set(smgr.dict) == {"good", "dead"} assert smgr.writable is True assert smgr.prune(["dead"]) is True with open(path) as f: remaining = json.load(f) # The expired entry is gone and nothing else was touched, so somebody's # typo is still theirs to fix rather than quietly deleted assert set(remaining) == {"good", "httponly", "typo", "handedit"} assert remaining["typo"] == original["typo"] assert remaining["handedit"] == original["handedit"] @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_prune_reloads_file(tmpdir): """Each prune uses the latest file instead of stale loaded state.""" tmpdir0 = tmpdir.mkdir("concurrent") path = os.path.join(str(tmpdir0), "subscriptions.json") _write_subscriptions( path, { n: f"https://fcm.googleapis.com/fcm/send/{n}" for n in ("a", "b", "c", "d") }, ) # Both instances begin with the same snapshot. first = WebPushSubscriptionManager() second = WebPushSubscriptionManager() assert first.load(path) is True assert second.load(path) is True assert first.prune(["a"]) is True assert second.prune(["c"]) is True with open(path) as f: remaining = json.load(f) # The second prune reloads the first one's change before writing. assert set(remaining) == {"b", "d"} @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @pytest.mark.skipif(IS_ROOT, reason="Root can write read-only files") def test_plugin_vapid_readonly_file_respected(tmpdir): """A read-only subscription file is left alone.""" tmpdir0 = tmpdir.mkdir("readonly") path = os.path.join(str(tmpdir0), "subscriptions.json") _write_subscriptions(path, ["https://fcm.googleapis.com/fcm/send/X"]) os.chmod(path, 0o444) smgr = WebPushSubscriptionManager() assert smgr.load(path) is True assert smgr.local_file == path # We can read it, but it is not ours to change assert smgr.writable is False # Swapping a file into place only needs permission on the directory, so # prune() has to refuse on its own rather than rely on the caller assert smgr.prune(["x"]) is False with open(path) as f: assert set(json.load(f)) == {"x"} @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @pytest.mark.skipif(IS_ROOT, reason="Root can write read-only files") @mock.patch("requests.post") def test_plugin_vapid_retires_when_not_writable(mock_post, tmpdir): """An expired endpoint is tracked when its file cannot be updated.""" mock_post.return_value = _mk_resp(requests.codes.gone) tmpdir0 = tmpdir.mkdir("retire") subfile = os.path.join(str(tmpdir0), "subscriptions.json") _write_subscriptions(subfile, ["https://web.push.apple.com/DEAD"]) os.chmod(subfile, 0o444) asset_ = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), pem_autogen=True, ) obj = NotifyVapid( "user@example.ca", targets=["dead"], subfile=subfile, asset=asset_, ) assert obj.send("test") is False # The file still holds it, because it was not ours to rewrite with open(subfile) as f: assert set(json.load(f)) == {"dead"} # Remember the endpoint in persistent storage instead. assert obj.store.get(obj.vapid_retired_key) == [ "https://web.push.apple.com/DEAD" ] # A fresh instance skips it and does not report a false success. mock_post.reset_mock() obj2 = NotifyVapid( "user@example.ca", targets=["dead"], subfile=subfile, asset=asset_, ) assert obj2.send("test") is False assert mock_post.call_count == 0 @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_prune_edge_cases(tmpdir): """Pruning declines anything it cannot safely rewrite.""" tmpdir0 = tmpdir.mkdir("edges") # Nothing was loaded, so there is no file to update smgr = WebPushSubscriptionManager() assert smgr.local_file is None assert smgr.writable is False assert smgr.prune(["anything"]) is False # A single entry file carries no names, so there is nothing to take out single = os.path.join(str(tmpdir0), "single.json") with open(single, "w") as f: json.dump( _subscription("https://fcm.googleapis.com/fcm/send/abc123"), f ) smgr = WebPushSubscriptionManager() assert smgr.load(single) is True assert smgr.prune(["abc123"]) is False # A name we do not hold leaves the file exactly as it was many = os.path.join(str(tmpdir0), "many.json") _write_subscriptions(many, ["https://fcm.googleapis.com/fcm/send/abc123"]) smgr = WebPushSubscriptionManager() assert smgr.load(many) is True assert smgr.prune(["nothing-like-this"]) is True with open(many) as f: assert set(json.load(f)) == {"abc123"} # Content that is no longer valid JSON is left for its owner to fix with open(many, "w") as f: f.write("{ this is not json") assert smgr.prune(["abc123"]) is False with open(many) as f: assert f.read() == "{ this is not json" # Also reject a file that does not contain an object. with open(many, "w") as f: json.dump(["not", "an", "object"], f) assert smgr.prune(["abc123"]) is False # A write that fails part way leaves the file as it was _write_subscriptions(many, ["https://fcm.googleapis.com/fcm/send/abc123"]) assert smgr.load(many) is True with mock.patch("json.dump", side_effect=OSError): assert smgr.prune(["abc123"]) is False with open(many) as f: assert set(json.load(f)) == {"abc123"} @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_remote_subfile(tmpdir): """A remotely hosted subscription file loads as read-only.""" tmpdir0 = tmpdir.mkdir("remote") local = os.path.join(str(tmpdir0), "subscriptions.json") _write_subscriptions(local, ["https://fcm.googleapis.com/fcm/send/abc123"]) with open(local, "rb") as f: payload = f.read() smgr = WebPushSubscriptionManager() # Stand in for the download an AppriseAttachment would perform with mock.patch("requests.get") as mock_get: mock_get.return_value.status_code = requests.codes.ok mock_get.return_value.headers = {"Content-Length": len(payload)} mock_get.return_value.iter_content.return_value = iter([payload]) mock_get.return_value.raise_for_status.return_value = None mock_get.return_value.__enter__ = lambda s: s mock_get.return_value.__exit__ = lambda s, *a, **kw: None assert smgr.load("https://example.com/subscriptions.json") is True # The subscription came through assert set(smgr.dict) == {"abc123"} # With no local source file, it cannot be rewritten. assert smgr.local_file is None assert smgr.writable is False assert smgr.prune(["abc123"]) is False @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @mock.patch("requests.post") def test_plugin_vapid_retires_when_prune_fails(mock_post, tmpdir): """A pruning failure still stops the endpoint being tried again.""" mock_post.return_value = _mk_resp(requests.codes.gone) tmpdir0 = tmpdir.mkdir("prunefail") subfile = os.path.join(str(tmpdir0), "subscriptions.json") _write_subscriptions(subfile, ["https://web.push.apple.com/DEAD"]) asset_ = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), pem_autogen=True, ) obj = NotifyVapid( "user@example.ca", targets=["dead"], subfile=subfile, asset=asset_, ) # The file is writable, but rewriting it does not work out with mock.patch.object( WebPushSubscriptionManager, "prune", return_value=False ): assert obj.send("test") is False # The file still lists it, since the rewrite failed with open(subfile) as f: assert set(json.load(f)) == {"dead"} # ...so the endpoint was remembered instead and is not asked for again assert obj.store.get(obj.vapid_retired_key) == [ "https://web.push.apple.com/DEAD" ] mock_post.reset_mock() obj2 = NotifyVapid( "user@example.ca", targets=["dead"], subfile=subfile, asset=asset_, ) assert obj2.send("test") is False assert mock_post.call_count == 0 @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_load_forgets_previous_path(tmpdir): """A later load does not leave an earlier file behind to be pruned.""" tmpdir0 = tmpdir.mkdir("stale") first = os.path.join(str(tmpdir0), "first.json") _write_subscriptions(first, ["https://fcm.googleapis.com/fcm/send/abc123"]) smgr = WebPushSubscriptionManager() assert smgr.load(first) is True assert smgr.local_file == first # A load that fails must not leave the previous file remembered, # otherwise a prune would rewrite a file we are no longer using assert smgr.load(os.path.join(str(tmpdir0), "missing.json")) is False assert smgr.local_file is None assert smgr.writable is False assert smgr.prune(["abc123"]) is False # The first file is untouched with open(first) as f: assert set(json.load(f)) == {"abc123"} @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_logging_masks_secrets(tmpdir): """Endpoints and remote credentials do not reach the logs intact.""" secret = "cH1s-Is-A-SeCr3t-RegIstratIon-Id" # An endpoint token is sensitive and must not appear in logs. masked = cwe312_loggable(f"https://fcm.googleapis.com/fcm/send/{secret}") assert secret not in masked # A missing value is reported rather than blowing up assert cwe312_loggable(None) == "(none)" assert cwe312_loggable("") == "(none)" # Keep local paths readable for troubleshooting. assert ( cwe312_loggable("/etc/apprise/subscriptions.json") == "/etc/apprise/subscriptions.json" ) smgr = WebPushSubscriptionManager() # A remote subscription file can carry a password loggable = smgr.loggable_path( "https://user:pass123@example.com/subscriptions.json" ) assert "pass123" not in loggable assert smgr.loggable_path(None) == "(none)" # Somebody who turns masking off gets the value through untouched smgr_plain = WebPushSubscriptionManager( asset=asset.AppriseAsset(secure_logging=False) ) url = "https://user:pass123@example.com/subscriptions.json" assert smgr_plain.loggable_path(url) == url @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_load_read_errors(tmpdir): """Loading tells a missing file, bad JSON and a disk problem apart.""" tmpdir0 = tmpdir.mkdir("loaderrs") path = os.path.join(str(tmpdir0), "subscriptions.json") _write_subscriptions(path, ["https://fcm.googleapis.com/fcm/send/abc123"]) smgr = WebPushSubscriptionManager() # The file goes away between being resolved and being opened with mock.patch("builtins.open", side_effect=FileNotFoundError): assert smgr.load(path) is False # It is there, but it does not hold JSON with mock.patch( "json.load", side_effect=json.decoder.JSONDecodeError("bad", "", 0) ): assert smgr.load(path) is False # It is there and we are not allowed to read it with mock.patch("builtins.open", side_effect=OSError): assert smgr.load(path) is False # ...and it still loads when nothing is wrong assert smgr.load(path) is True @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_prune_read_errors(tmpdir): """Pruning tells a missing file, bad JSON and a disk problem apart.""" tmpdir0 = tmpdir.mkdir("pruneerrs") path = os.path.join(str(tmpdir0), "subscriptions.json") _write_subscriptions(path, ["https://fcm.googleapis.com/fcm/send/abc123"]) smgr = WebPushSubscriptionManager() assert smgr.load(path) is True # The file goes away before we can rewrite it with mock.patch("builtins.open", side_effect=FileNotFoundError): assert smgr.prune(["abc123"]) is False # Leave newly invalid content untouched rather than overwriting it. with mock.patch( "json.load", side_effect=json.decoder.JSONDecodeError("bad", "", 0) ): assert smgr.prune(["abc123"]) is False # We are no longer allowed to read it with mock.patch("builtins.open", side_effect=OSError): assert smgr.prune(["abc123"]) is False # The file is untouched throughout with open(path) as f: assert set(json.load(f)) == {"abc123"} @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_write_errors(tmpdir): """Writing tells a content problem apart from a disk problem.""" tmpdir0 = tmpdir.mkdir("writeerrs") path = os.path.join(str(tmpdir0), "subscriptions.json") smgr = WebPushSubscriptionManager() assert smgr.add(_subscription("https://web.push.apple.com/abc123")) is True # Content that will not turn into JSON with mock.patch("json.dump", side_effect=TypeError): assert smgr.write(path) is False # A circular reference raises ValueError rather than TypeError with mock.patch("json.dump", side_effect=ValueError): assert smgr.write(path) is False # ...and a disk problem is reported as one with mock.patch("json.dump", side_effect=OSError): assert smgr.write(path) is False # Nothing was left behind by any of those assert os.listdir(str(tmpdir0)) == [] assert smgr.write(path) is True @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_write_unreadable_mode(tmpdir): """A file whose mode cannot be read is still replaced.""" tmpdir0 = tmpdir.mkdir("modeerr") path = os.path.join(str(tmpdir0), "subscriptions.json") smgr = WebPushSubscriptionManager() assert smgr.add(_subscription("https://web.push.apple.com/abc123")) is True assert smgr.write(path) is True # We cannot stat what is already there, so our own mode is kept rather # than guessing at one assert smgr.add(_subscription("https://web.push.apple.com/xyz789")) is True with mock.patch("os.stat", side_effect=PermissionError): assert smgr.write(path) is True with open(path) as f: assert set(json.load(f)) == {"abc123", "xyz789"} @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @mock.patch("requests.post") def test_plugin_vapid_expired_is_reported(mock_post, tmpdir): """An expired subscription is reported as a failure.""" def respond(url, *args, **kwargs): # One device is gone, the other is fine return _mk_resp( requests.codes.gone if url.endswith("DEAD") else requests.codes.created ) mock_post.side_effect = respond obj, _ = _mk_notifier( tmpdir, { "live": "https://web.push.apple.com/LIVE", "dead": "https://web.push.apple.com/DEAD", }, ["live", "dead"], name="expreport", ) # The expired endpoint is a real failure; it is pruned below so no # retry will reach for it again. assert obj.send("test") is False assert "dead" not in obj.subscriptions @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @mock.patch("requests.post") def test_plugin_vapid_all_expired_still_fails(mock_post, tmpdir): """Nothing reaching anybody is a failure however we got there.""" mock_post.return_value = _mk_resp(requests.codes.gone) obj, _ = _mk_notifier( tmpdir, ["https://web.push.apple.com/DEAD"], ["dead"], name="allexp", ) # Every target expired, so the message went nowhere. assert obj.send("test") is False @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_image_url(tmpdir): """The image flag survives a round trip through the URL.""" tmpdir0 = tmpdir.mkdir("imgurl") asset_ = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), pem_autogen=True, ) # Images are included by default obj = Apprise.instantiate("vapid://user@example.ca/abc123", asset=asset_) assert obj.include_image is True assert "image=yes" in obj.url() # Turning them off is remembered rather than quietly lost when the URL # is rebuilt off = Apprise.instantiate( "vapid://user@example.ca/abc123?image=no", asset=asset_ ) assert off.include_image is False assert "image=no" in off.url() assert Apprise.instantiate(off.url()).include_image is False assert Apprise.instantiate(obj.url()).include_image is True @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_autogen(tmpdir): """Create and manage the default subscription file.""" tmpdir0 = tmpdir.mkdir("autogen") # With nothing to work with there is no file and nothing to load smgr = WebPushSubscriptionManager() assert smgr.subscription_file is None assert smgr.loaded is False assert smgr.autogen() is False assert smgr.load() is False assert smgr.loaded is True # A directory gives us a file of our own to create smgr = WebPushSubscriptionManager(str(tmpdir0)) assert smgr.subscription_file == os.path.join( str(tmpdir0), WebPushSubscriptionManager.default_filename ) assert smgr.autogen() is True # The file shows what a subscription is supposed to look like with open(smgr.subscription_file) as f: assert json.load(f) == smgr.template # We do not overwrite what is already there assert smgr.autogen() is False # The placeholder is not a working subscription, so loading it leaves us # with nothing to notify assert smgr.load() is True assert len(smgr) == 0 # A file we were pointed at belongs to our user, not to us subfile = os.path.join(str(tmpdir0), "mine.json") smgr = WebPushSubscriptionManager(str(tmpdir0), subfile=subfile) assert smgr.subscription_file == subfile assert smgr.autogen() is False assert not os.path.exists(subfile) # A file we cannot write does not cause an exception tmpdir1 = tmpdir.mkdir("autogen-fail") smgr = WebPushSubscriptionManager(str(tmpdir1)) with mock.patch("json.dump", side_effect=OSError): assert smgr.autogen() is False @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_autogen_disabled(tmpdir): """No starter file is left behind when auto-generation is turned off.""" tmpdir0 = tmpdir.mkdir("noautogen") asset_ = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), pem_autogen=False, ) obj = Apprise.instantiate("vapid://user@example.ca/abc123", asset=asset_) assert obj.subfile is not None assert not os.path.exists(obj.subfile) # There is nothing for us to notify with assert obj.send("test") is False @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_rejects_malformed_subscription(tmpdir): """Reject malformed subscription data without raising exceptions.""" smgr = WebPushSubscriptionManager() # The keys entry is not an object we can read for keys in (None, "nope", [1, 2], 42): assert ( smgr.add( {"endpoint": "https://web.push.apple.com/X", "keys": keys}, name="junk", ) is False ) # The keys are there, but they are not base64 assert ( smgr.add( { "endpoint": "https://web.push.apple.com/X", "keys": {"p256dh": "!!", "auth": "!!"}, }, name="junk", ) is False ) assert len(smgr) == 0 # Reject nesting deep enough to exhaust the JSON parser tmpdir0 = tmpdir.mkdir("junk") path = os.path.join(str(tmpdir0), "subscriptions.json") with open(path, "w") as f: f.write("[" * 200000 + "]" * 200000) assert smgr.load(path) is False # Reject the same nesting when supplied as a string assert WebPushSubscription().load("[" * 200000 + "]" * 200000) is False caronc-apprise-dfec414/tests/test_plugin_viber.py000066400000000000000000000135631526720371000223670ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps import logging from helpers import AppriseURLTester import requests from apprise import Apprise from apprise.plugins.viber import NotifyViber logging.disable(logging.CRITICAL) VIBER_GOOD_RESPONSE = dumps({"status": 0, "status_message": "ok"}) VIBER_BAD_RESPONSE = dumps( {"status": 12, "status_message": "Too many requests"} ) # Our Testing URLs apprise_url_tests = ( ("viber://", False), ("viber:///", False), ("viber://tokena", {"instance": NotifyViber, "notify_response": False}), ( "viber://?token=tokenb", {"instance": NotifyViber, "notify_response": False}, ), ( "viber://token/targetx", { "instance": NotifyViber, # Our response expected server response "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://token/t1/t2?from=Viber%20Bot", { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://t1/t2?token=token", { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://?token=token&to=t5", { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://token/t3?avatar=value", { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://token/?to=abc,def", { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://token/m12/?from={}".format( "a" * (NotifyViber.viber_sender_name_limit + 1) ), { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://token/m12/?from={}".format( "b" * (NotifyViber.viber_sender_name_limit) ), { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://?token=token&to=hij,klm", { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://?token=token&to=nop,qrs", { "instance": NotifyViber, "requests_response_text": VIBER_BAD_RESPONSE, "notify_response": False, }, ), ( "viber://?token=token&to=tuv,wxy", { "instance": NotifyViber, # Bad JSON "requests_response_text": "{", "notify_response": False, }, ), # Privacy redaction of token ( "viber://token/t10", { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, "privacy_url": "viber://****/t10", }, ), ( "viber://token/targetZ", { "instance": NotifyViber, # throw a bizarre code forcing us to fail to look it up "requests_response_text": VIBER_GOOD_RESPONSE, "response": False, "requests_response_code": 999, }, ), ( "viber://token/targetZ", { "instance": NotifyViber, # force a failure "response": False, "requests_response_text": VIBER_BAD_RESPONSE, "requests_response_code": requests.codes.internal_server_error, }, ), ( "viber://token/targetY", { "instance": NotifyViber, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "requests_response_text": VIBER_GOOD_RESPONSE, "test_requests_exceptions": True, }, ), ) def test_plugin_viber_urls(): """Verify URL parsing, privacy, and basic validation.""" AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_viber_http_error_and_exception(mocker): """Verify HTTP error and requests exception paths.""" post = mocker.patch("requests.post") post.return_value.status_code = 400 a = Apprise() assert a.add("viber://token/target2") is True assert bool(a.notify("test")) is False post.side_effect = requests.RequestException("boom") a = Apprise() assert a.add("viber://token/target2") is True assert bool(a.notify("test")) is False caronc-apprise-dfec414/tests/test_plugin_voipms.py000066400000000000000000000204111526720371000225630ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.voipms import NotifyVoipms logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "voipms://", { # No email/password specified "instance": AppriseImproperlyConfigured, }, ), ( "voipms://@:", { # Invalid url "instance": AppriseImproperlyConfigured, }, ), ( "voipms://{}/{}".format("user@example.com", "1" * 11), { # No password specified "instance": AppriseImproperlyConfigured, }, ), ( "voipms://:{}".format("password"), { # No email specified "instance": AppriseImproperlyConfigured, }, ), ( "voipms://{}:{}/{}".format("user@", "pass", "1" * 11), { # Check valid email "instance": AppriseImproperlyConfigured, }, ), ( "voipms://{password}:{email}".format( email="user@example.com", password="password" ), { # No from_phone specified "instance": AppriseImproperlyConfigured, }, ), # Invalid phone number test ( "voipms://{password}:{email}/1613".format( email="user@example.com", password="password" ), { # Invalid phone number "instance": AppriseImproperlyConfigured, }, ), # Invalid country code phone number test ( "voipms://{password}:{email}/01133122446688".format( email="user@example.com", password="password" ), { # Non North American phone number "instance": AppriseImproperlyConfigured, }, ), ( "voipms://{password}:{email}/{from_phone}/{targets}/".format( email="user@example.com", password="password", from_phone="16134448888", targets="/".join(["26134442222"]), ), { # Invalid target phone number "instance": NotifyVoipms, "response": False, "requests_response_code": 999, }, ), ( "voipms://{password}:{email}/{from_phone}".format( email="user@example.com", password="password", from_phone="16138884444", ), { "instance": NotifyVoipms, # No targets specified "response": False, "requests_response_code": 999, }, ), ( "voipms://{password}:{email}/?from={from_phone}".format( email="user@example.com", password="password", from_phone="16138884444", ), { "instance": NotifyVoipms, # No targets specified "response": False, "requests_response_code": 999, }, ), ( "voipms://{password}:{email}/{from_phone}/{targets}/".format( email="user@example.com", password="password", from_phone="16138884444", targets="/".join(["16134442222"]), ), { # Valid "instance": NotifyVoipms, "response": True, "privacy_url": "voipms://p...d:user@example.com/16...4", }, ), ( "voipms://{password}:{email}/{from_phone}/{targets}/".format( email="user@example.com", password="password", from_phone="16138884444", targets="/".join(["16134442222", "16134443333"]), ), { # Valid multiple targets "instance": NotifyVoipms, "response": True, "privacy_url": "voipms://p...d:user@example.com/16...4", }, ), ( "voipms://{password}:{email}/?from={from_phone}&to={targets}".format( email="user@example.com", password="password", from_phone="16138884444", targets="16134448888", ), { # Valid "instance": NotifyVoipms, }, ), ( "voipms://{password}:{email}/{from_phone}/{targets}/".format( email="user@example.com", password="password", from_phone="16138884444", targets="16134442222", ), { "instance": NotifyVoipms, # Throws a series of errors "test_requests_exceptions": True, }, ), ) def test_plugin_voipms(): """NotifyVoipms() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.get") def test_plugin_voipms_edge_cases(mock_get): """NotifyVoipms() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_get.return_value = response # Initialize some generic (but valid) tokens email = "user@example.com" password = "password" source = "+1 (555) 123-3456" targets = "+1 (555) 123-9876" # No email specified with pytest.raises(AppriseImproperlyConfigured): NotifyVoipms(email=None, source=source) # a error response is returned response.status_code = 400 response.content = dumps( { "code": 21211, "message": "Unable to process your request.", } ) mock_get.return_value = response # Initialize our object obj = Apprise.instantiate( f"voipms://{password}:{email}/{source}/{targets}" ) assert isinstance(obj, NotifyVoipms) # We will fail with the above error code assert bool(obj.notify("title", "body", "info")) is False @mock.patch("requests.get") def test_plugin_voipms_non_success_status(mock_get): """NotifyVoipms() Non Success Status.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_get.return_value = response # A 200 response is returned but non-success message response.status_code = 200 response.content = dumps( { "status": "invalid_credentials", "message": "Username or Password is incorrect", } ) obj = Apprise.instantiate( "voipms://{password}:{email}/{source}/{targets}".format( email="user@example.com", password="badpassword", source="16134448888", targets="16134442222", ) ) assert isinstance(obj, NotifyVoipms) # We will fail with the above error code assert bool(obj.notify("title", "body", "info")) is False response.content = "{" assert obj.send("title", "body") is False caronc-apprise-dfec414/tests/test_plugin_vonage.py000066400000000000000000000221171526720371000225320ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.vonage import NotifyVonage logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "vonage://", { # No API Key specified "instance": AppriseImproperlyConfigured, }, ), ( "vonage://:@/", { # invalid Auth key "instance": AppriseImproperlyConfigured, }, ), ( "vonage://AC{}@12345678".format("a" * 8), { # Just a key provided "instance": AppriseImproperlyConfigured, }, ), ( "vonage://AC{}:{}@{}".format("a" * 8, "b" * 16, "3" * 9), { # key and secret provided and from but invalid from no "instance": AppriseImproperlyConfigured, }, ), ( "vonage://AC{}:{}@{}/?ttl=0".format("b" * 8, "c" * 16, "3" * 11), { # Invalid ttl defined "instance": AppriseImproperlyConfigured, }, ), ( "vonage://AC{}:{}@{}".format("d" * 8, "e" * 16, "a" * 11), { # Invalid source number "instance": AppriseImproperlyConfigured, }, ), ( "vonage://AC{}:{}@{}/123/{}/abcd/".format( "f" * 8, "g" * 16, "3" * 11, "9" * 15 ), { # valid everything but target numbers "instance": NotifyVonage, # Our expected url(privacy=True) startswith() response: "privacy_url": "vonage://A...f:****@", }, ), ( "vonage://AC{}:{}@{}".format("h" * 8, "i" * 16, "5" * 11), { # using phone no with no target - we text ourselves in # this case "instance": NotifyVonage, }, ), ( "vonage://_?key=AC{}&secret={}&from={}".format( "a" * 8, "b" * 16, "5" * 11 ), { # use get args to acomplish the same thing "instance": NotifyVonage, }, ), ( "vonage://_?key=AC{}&secret={}&source={}".format( "a" * 8, "b" * 16, "5" * 11 ), { # use get args to acomplish the same thing (use source instead # of from) "instance": NotifyVonage, }, ), ( "vonage://_?key=AC{}&secret={}&from={}&to={}".format( "a" * 8, "b" * 16, "5" * 11, "7" * 13 ), { # use to= "instance": NotifyVonage, }, ), ( "vonage://AC{}:{}@{}".format("a" * 8, "b" * 16, "6" * 11), { "instance": NotifyVonage, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "vonage://AC{}:{}@{}".format("a" * 8, "b" * 16, "6" * 11), { "instance": NotifyVonage, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), # Nexmo Backwards Support ( "nexmo://", { # No API Key specified "instance": AppriseImproperlyConfigured, }, ), ( "nexmo://:@/", { # invalid Auth key "instance": AppriseImproperlyConfigured, }, ), ( "nexmo://AC{}@12345678".format("a" * 8), { # Just a key provided "instance": AppriseImproperlyConfigured, }, ), ( "nexmo://AC{}:{}@{}".format("a" * 8, "b" * 16, "3" * 9), { # key and secret provided and from but invalid from no "instance": AppriseImproperlyConfigured, }, ), ( "nexmo://AC{}:{}@{}/?ttl=0".format("b" * 8, "c" * 16, "3" * 11), { # Invalid ttl defined "instance": AppriseImproperlyConfigured, }, ), ( "nexmo://AC{}:{}@{}".format("d" * 8, "e" * 16, "a" * 11), { # Invalid source number "instance": AppriseImproperlyConfigured, }, ), ( "nexmo://AC{}:{}@{}/123/{}/abcd/".format( "f" * 8, "g" * 16, "3" * 11, "9" * 15 ), { # valid everything but target numbers "instance": NotifyVonage, # Our expected url(privacy=True) startswith() response: "privacy_url": "vonage://A...f:****@", }, ), ( "nexmo://AC{}:{}@{}".format("h" * 8, "i" * 16, "5" * 11), { # using phone no with no target - we text ourselves in # this case "instance": NotifyVonage, }, ), ( "nexmo://_?key=AC{}&secret={}&from={}".format( "a" * 8, "b" * 16, "5" * 11 ), { # use get args to acomplish the same thing "instance": NotifyVonage, }, ), ( "nexmo://_?key=AC{}&secret={}&source={}".format( "a" * 8, "b" * 16, "5" * 11 ), { # use get args to acomplish the same thing (use source instead of # from) "instance": NotifyVonage, }, ), ( "nexmo://_?key=AC{}&secret={}&from={}&to={}".format( "a" * 8, "b" * 16, "5" * 11, "7" * 13 ), { # use to= "instance": NotifyVonage, }, ), ( "nexmo://AC{}:{}@{}".format("a" * 8, "b" * 16, "6" * 11), { "instance": NotifyVonage, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "nexmo://AC{}:{}@{}".format("a" * 8, "b" * 16, "6" * 11), { "instance": NotifyVonage, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_vonage_urls(): """NotifyVonage() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_vonage_edge_cases(mock_post): """NotifyVonage() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) tokens apikey = "AC{}".format("b" * 8) secret = "{}".format("b" * 16) source = "+1 (555) 123-3456" # No apikey specified with pytest.raises(AppriseImproperlyConfigured): NotifyVonage(apikey=None, secret=secret, source=source) with pytest.raises(AppriseImproperlyConfigured): NotifyVonage(apikey=" ", secret=secret, source=source) # No secret specified with pytest.raises(AppriseImproperlyConfigured): NotifyVonage(apikey=apikey, secret=None, source=source) with pytest.raises(AppriseImproperlyConfigured): NotifyVonage(apikey=apikey, secret=" ", source=source) # a error response response.status_code = 400 response.content = dumps( { "code": 21211, "message": "The 'To' number +1234567 is not a valid phone number.", } ) mock_post.return_value = response # Initialize our object obj = NotifyVonage(apikey=apikey, secret=secret, source=source) # We will fail with the above error code assert bool(obj.notify("title", "body", "info")) is False caronc-apprise-dfec414/tests/test_plugin_webex_teams.py000066400000000000000000000624641526720371000235670ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from json import loads import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyFormat, NotifyType from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.webexteams import ( NotifyWebexTeams, WebexTeamsMode, ) logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # A valid webhook token (80 lowercase alphanumeric chars) WEBHOOK_TOKEN = "a" * 80 # A valid bot access token (longer than 160 chars, contains hyphen) # Must be > 160 chars or contain non-alphanumeric chars to bypass # the webhook-token regex (which only allows [a-z0-9], 80-160 chars). # Using 200 chars ending in '0' so the privacy URL ends in '0'. BOT_TOKEN = "Bc10" * 50 # 200 chars, ends in '0' # A valid Webex room ID (base64url-like, 50 chars) ROOM_ID = "Y2lzY29zcGFyazovL3VzL1JPTU9NLzEyMzQ1Njc4OTAxMjM0" ROOM_ID2 = "Y2lzY29zcGFyazovL3VzL1JPTU9NL2FiY2RlZjEyMzQ1Njc4" # Our Testing URLs apprise_url_tests = ( ( "wxteams://", { # Token missing "instance": AppriseImproperlyConfigured, }, ), ( "wxteams://:@/", { # We don't have strict host checking on for wxteams, so this # URL actually becomes parseable and :@ becomes a hostname. # The below errors because a second token wasn't found "instance": AppriseImproperlyConfigured, }, ), ( "wxteams://{}".format("a" * 80), { # webhook token provided - we're good "instance": NotifyWebexTeams, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxteams://a...a/", }, ), ( "wxteams://?token={}".format("a" * 80), { # token as query param - we're good "instance": NotifyWebexTeams, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxteams://a...a/", }, ), ( "webex://{}".format("a" * 140), { # token provided via 'webex' schema - we're good "instance": NotifyWebexTeams, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxteams://a...a/", }, ), # Support Native Webhook URLs ( "https://api.ciscospark.com/v1/webhooks/incoming/{}".format("a" * 80), { # token provided - we're good "instance": NotifyWebexTeams, }, ), # Support New Native Webhook URLs ( "https://webexapis.com/v1/webhooks/incoming/{}".format("a" * 100), { # token provided - we're good "instance": NotifyWebexTeams, }, ), # Support Native Webhook URLs with arguments ( "https://api.ciscospark.com/v1/webhooks/incoming/{}" "?format=text".format("a" * 80), { # token provided - we're good "instance": NotifyWebexTeams, }, ), # Bot mode: access_token + room ID ( "wxteams://{}/{}".format(BOT_TOKEN, ROOM_ID), { "instance": NotifyWebexTeams, "privacy_url": "wxteams://B...0/", }, ), # Bot mode: explicit mode=bot with no room IDs - loads but fails to send ( "wxteams://{}?mode=bot".format("a" * 80), { "instance": NotifyWebexTeams, # notify() returns False with no targets (no HTTP call needed) "notify_response": False, }, ), # Explicit mode=webhook with a valid webhook token ( "wxteams://{}?mode=webhook".format("a" * 80), { "instance": NotifyWebexTeams, "privacy_url": "wxteams://a...a/", }, ), # Invalid mode ( "wxteams://{}?mode=invalid".format("a" * 80), { "instance": AppriseImproperlyConfigured, }, ), # Webhook mode: HTTP 500 response ( "wxteams://{}".format("a" * 80), { "instance": NotifyWebexTeams, "response": False, "requests_response_code": (requests.codes.internal_server_error), }, ), # Webhook mode: unusual HTTP response code ( "wxteams://{}".format("a" * 80), { "instance": NotifyWebexTeams, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), # Webhook mode: request exception ( "wxteams://{}".format("a" * 80), { "instance": NotifyWebexTeams, # Throws a series of i/o exceptions with this flag set and # tests that we gracefully handle them "test_requests_exceptions": True, }, ), # Bot mode: HTTP 500 response ( "wxteams://{}/{}".format(BOT_TOKEN, ROOM_ID), { "instance": NotifyWebexTeams, "response": False, "requests_response_code": (requests.codes.internal_server_error), }, ), # Bot mode: unusual HTTP response code ( "wxteams://{}/{}".format(BOT_TOKEN, ROOM_ID), { "instance": NotifyWebexTeams, "response": False, "requests_response_code": 999, }, ), # Bot mode: request exception ( "wxteams://{}/{}".format(BOT_TOKEN, ROOM_ID), { "instance": NotifyWebexTeams, "test_requests_exceptions": True, }, ), ) def test_plugin_webex_teams_urls(): """NotifyWebexTeams() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_webex_teams_webhook_mode(): """NotifyWebexTeams() Webhook mode direct tests.""" # Valid webhook token token = "b" * 80 obj = NotifyWebexTeams(token=token) assert isinstance(obj, NotifyWebexTeams) assert obj.mode == WebexTeamsMode.WEBHOOK # url() round-trip url = obj.url() assert "wxteams://" in url assert "mode=webhook" in url # parse_url round-trip result = NotifyWebexTeams.parse_url(url) assert result is not None obj2 = NotifyWebexTeams(**result) assert obj2.mode == WebexTeamsMode.WEBHOOK assert obj2.token == token # privacy URL priv = obj.url(privacy=True) assert obj.token not in priv # url_identifier assert obj.url_identifier == ( NotifyWebexTeams.secure_protocol[0], token, ) # 80-char token (minimum) assert NotifyWebexTeams(token="c" * 80) # 160-char token (maximum) assert NotifyWebexTeams(token="c" * 160) # Too short for webhook -> falls to bot mode (no targets), loads but # send() returns False obj_short = NotifyWebexTeams(token="c" * 79) assert obj_short.mode == WebexTeamsMode.BOT assert obj_short.send(body="test") is False # Too long for webhook -> bot mode, same behaviour obj_long = NotifyWebexTeams(token="c" * 161) assert obj_long.mode == WebexTeamsMode.BOT assert obj_long.send(body="test") is False # No token candidate is invalid. with pytest.raises(AppriseImproperlyConfigured): NotifyWebexTeams(token=None) # An empty token is invalid. with pytest.raises(AppriseImproperlyConfigured): NotifyWebexTeams(token="") # Non-alphanumeric chars -> falls to bot mode (no targets) obj_hyph = NotifyWebexTeams(token="a-b-" * 20) assert obj_hyph.mode == WebexTeamsMode.BOT assert obj_hyph.send(body="test") is False def test_plugin_webex_teams_bot_mode(): """NotifyWebexTeams() Bot mode direct tests.""" obj = NotifyWebexTeams(access_token=BOT_TOKEN, targets=[ROOM_ID]) assert isinstance(obj, NotifyWebexTeams) assert obj.mode == WebexTeamsMode.BOT assert obj.access_token == BOT_TOKEN assert ROOM_ID in obj.targets # url() round-trip url = obj.url() assert "wxteams://" in url assert "mode=bot" in url result = NotifyWebexTeams.parse_url(url) assert result is not None obj2 = NotifyWebexTeams(**result) assert obj2.mode == WebexTeamsMode.BOT assert obj2.access_token == BOT_TOKEN assert ROOM_ID in obj2.targets # privacy URL masks the token priv = obj.url(privacy=True) assert BOT_TOKEN not in priv # url_identifier assert obj.url_identifier == ( NotifyWebexTeams.secure_protocol[0], BOT_TOKEN, ) # Multiple rooms obj3 = NotifyWebexTeams( access_token=BOT_TOKEN, targets=[ROOM_ID, ROOM_ID2] ) assert len(obj3.targets) == 2 assert len(obj3) == 2 # No room IDs -> loads fine, but send() returns False obj_no_rooms = NotifyWebexTeams(access_token=BOT_TOKEN, targets=[]) assert len(obj_no_rooms) == 1 assert isinstance(obj_no_rooms, NotifyWebexTeams) assert obj_no_rooms.send(body="test") is False # An access token is required. with pytest.raises(AppriseImproperlyConfigured): NotifyWebexTeams(access_token=None, targets=[ROOM_ID]) # Bot mode requires a token or access token. with pytest.raises(AppriseImproperlyConfigured): NotifyWebexTeams(mode="bot", targets=[ROOM_ID]) # Explicit mode=bot on a short (webhook-style) token obj4 = NotifyWebexTeams(token="a" * 80, mode="bot", targets=[ROOM_ID]) assert obj4.mode == WebexTeamsMode.BOT # The 'token' argument is used as access_token in bot mode assert obj4.access_token == "a" * 80 # Invalid mode with pytest.raises(AppriseImproperlyConfigured): NotifyWebexTeams( access_token=BOT_TOKEN, targets=[ROOM_ID], mode="invalid", ) def test_plugin_webex_teams_body_maxlen(): """NotifyWebexTeams() body_maxlen varies by mode.""" # Webhook mode: 1000 chars obj = NotifyWebexTeams(token=WEBHOOK_TOKEN) assert obj.body_maxlen == 1000 # Bot mode: 7439 chars obj = NotifyWebexTeams(access_token=BOT_TOKEN, targets=[ROOM_ID]) assert obj.body_maxlen == 7439 def test_plugin_webex_teams_mode_detection(): """NotifyWebexTeams() auto-detects mode from token format.""" # 80-char lowercase alphanumeric -> WEBHOOK obj = NotifyWebexTeams(token="a" * 80) assert obj.mode == WebexTeamsMode.WEBHOOK # 80-char uppercase alphanumeric also matches webhook regex # (regex uses 'i' flag); no targets means webhook mode obj = NotifyWebexTeams(token="A" * 80) assert obj.mode == WebexTeamsMode.WEBHOOK # Token longer than 160 chars -> fails webhook regex -> BOT obj = NotifyWebexTeams(token="a" * 200, targets=[ROOM_ID]) assert obj.mode == WebexTeamsMode.BOT # Token with hyphens (non-alphanumeric) -> fails webhook regex -> BOT obj = NotifyWebexTeams(access_token=BOT_TOKEN, targets=[ROOM_ID]) assert obj.mode == WebexTeamsMode.BOT def test_plugin_webex_teams_url_parsing(): """NotifyWebexTeams() URL parsing edge cases.""" # Webhook via ?token= param result = NotifyWebexTeams.parse_url("wxteams://?token={}".format("a" * 80)) assert result is not None obj = NotifyWebexTeams(**result) assert obj.mode == WebexTeamsMode.WEBHOOK assert obj.token == "a" * 80 # Bot mode: access_token in host, room ID in path result = NotifyWebexTeams.parse_url( "wxteams://{}/{}".format(BOT_TOKEN, ROOM_ID) ) assert result is not None obj = NotifyWebexTeams(**result) assert obj.mode == WebexTeamsMode.BOT assert ROOM_ID in obj.targets # Bot mode: multiple room IDs in path result = NotifyWebexTeams.parse_url( "wxteams://{}/{}/{}".format(BOT_TOKEN, ROOM_ID, ROOM_ID2) ) assert result is not None obj = NotifyWebexTeams(**result) assert obj.mode == WebexTeamsMode.BOT assert len(obj.targets) == 2 # Bot mode: room IDs via ?to= param result = NotifyWebexTeams.parse_url( "wxteams://{}?to={}&mode=bot".format(BOT_TOKEN, ROOM_ID) ) assert result is not None obj = NotifyWebexTeams(**result) assert obj.mode == WebexTeamsMode.BOT assert ROOM_ID in obj.targets # Native webhook URL result = NotifyWebexTeams.parse_native_url( "https://api.ciscospark.com/v1/webhooks/incoming/{}".format("a" * 80) ) assert result is not None obj = NotifyWebexTeams(**result) assert obj.mode == WebexTeamsMode.WEBHOOK # New native webhook URL result = NotifyWebexTeams.parse_native_url( "https://webexapis.com/v1/webhooks/incoming/{}".format("a" * 100) ) assert result is not None obj = NotifyWebexTeams(**result) assert obj.mode == WebexTeamsMode.WEBHOOK # parse_native_url returns None for non-matching URLs assert ( NotifyWebexTeams.parse_native_url( "https://example.com/not/a/webex/url" ) is None ) # Webhook mode forced with ?mode=webhook even with path entries result = NotifyWebexTeams.parse_url( "wxteams://{}?mode=webhook".format("a" * 80) ) assert result is not None obj = NotifyWebexTeams(**result) assert obj.mode == WebexTeamsMode.WEBHOOK @mock.patch("requests.post") def test_plugin_webex_teams_webhook_send(mock_post): """NotifyWebexTeams() Webhook send() coverage.""" # Prepare a good response response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response obj = NotifyWebexTeams(token=WEBHOOK_TOKEN) # Successful send assert obj.send(body="test message") is True assert mock_post.call_count == 1 call_url = mock_post.call_args[0][0] assert "webhooks/incoming" in call_url assert WEBHOOK_TOKEN in call_url # Verify correct Content-Type header headers = mock_post.call_args[1]["headers"] assert headers["Content-Type"] == "application/json" mock_post.reset_mock() # 204 No Content is also acceptable response.status_code = requests.codes.no_content assert obj.send(body="test") is True mock_post.reset_mock() # HTTP error response.status_code = requests.codes.internal_server_error assert obj.send(body="test") is False mock_post.reset_mock() # Unknown HTTP error code response.status_code = 999 assert obj.send(body="test") is False mock_post.reset_mock() # Connection error mock_post.side_effect = requests.RequestException() assert obj.send(body="test") is False mock_post.side_effect = None mock_post.return_value = response response.status_code = requests.codes.ok # Webhook warns about attachments (but still proceeds) mock_post.reset_mock() path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) assert obj.send(body="test", attach=attach) is True # Only one POST call: text-only webhook assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_webex_teams_bot_send(mock_post): """NotifyWebexTeams() Bot send() text-only coverage.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response obj = NotifyWebexTeams(access_token=BOT_TOKEN, targets=[ROOM_ID]) # Successful text send assert obj.send(body="hello from bot") is True assert mock_post.call_count == 1 call_url = mock_post.call_args[0][0] assert call_url == "https://webexapis.com/v1/messages" # Verify Bearer authorization headers = mock_post.call_args[1]["headers"] assert headers["Authorization"] == f"Bearer {BOT_TOKEN}" assert headers["Content-Type"] == "application/json" mock_post.reset_mock() # Two rooms -> two calls obj2 = NotifyWebexTeams( access_token=BOT_TOKEN, targets=[ROOM_ID, ROOM_ID2] ) assert obj2.send(body="broadcast") is True assert mock_post.call_count == 2 mock_post.reset_mock() # HTTP error for bot mode response.status_code = requests.codes.internal_server_error assert obj.send(body="test") is False mock_post.reset_mock() response.status_code = 999 assert obj.send(body="test") is False mock_post.reset_mock() # Connection error for bot mode mock_post.side_effect = requests.RequestException() assert obj.send(body="test") is False mock_post.side_effect = None response.status_code = requests.codes.ok mock_post.return_value = response # Two rooms: second fails -> overall failure reported mock_post.reset_mock() fail_response = mock.Mock() fail_response.status_code = requests.codes.internal_server_error fail_response.content = b"" mock_post.side_effect = [response, fail_response] obj2 = NotifyWebexTeams( access_token=BOT_TOKEN, targets=[ROOM_ID, ROOM_ID2] ) assert obj2.send(body="test") is False @mock.patch("requests.post") def test_plugin_webex_teams_bot_attachments(mock_post): """NotifyWebexTeams() Bot mode attachment coverage.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response obj = NotifyWebexTeams(access_token=BOT_TOKEN, targets=[ROOM_ID]) path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) # Successful single attachment mock_post.reset_mock() assert ( bool( obj.notify( body="see attached", title="", notify_type=NotifyType.INFO, attach=attach, ) ) is True ) # One POST call for the single attachment assert mock_post.call_count == 1 call_url = mock_post.call_args[0][0] assert call_url == "https://webexapis.com/v1/messages" # No Content-Type header set (multipart sets its own) headers = mock_post.call_args[1]["headers"] assert "Content-Type" not in headers # files kwarg was provided assert mock_post.call_args[1].get("files") is not None # Multiple attachments -> one POST per attachment mock_post.reset_mock() path2 = os.path.join(TEST_VAR_DIR, "apprise-test.png") attach2 = AppriseAttachment([path, path2]) assert obj.send(body="two files", attach=attach2) is True assert mock_post.call_count == 2 # Two rooms, one attachment -> 2 POST calls mock_post.reset_mock() obj2 = NotifyWebexTeams( access_token=BOT_TOKEN, targets=[ROOM_ID, ROOM_ID2] ) assert obj2.send(body="room broadcast", attach=attach) is True assert mock_post.call_count == 2 # Connection error on attachment POST mock_post.reset_mock() mock_post.side_effect = requests.RequestException() assert obj.send(body="test", attach=attach) is False mock_post.side_effect = None mock_post.return_value = response # OSError reading attachment mock_post.reset_mock() mock_post.side_effect = OSError() assert obj.send(body="test", attach=attach) is False mock_post.side_effect = None mock_post.return_value = response # HTTP error on attachment upload mock_post.reset_mock() bad_response = mock.Mock() bad_response.status_code = requests.codes.internal_server_error bad_response.content = b"" mock_post.return_value = bad_response assert obj.send(body="test", attach=attach) is False mock_post.return_value = response # OSError raised by attachment.open() (before the file handle is used) mock_post.reset_mock() mock_post.side_effect = None with mock.patch( "apprise.attachment.file.AttachFile.open", side_effect=OSError("open failed"), ): assert obj.send(body="test", attach=attach) is False mock_post.side_effect = None mock_post.return_value = response # Invalid/inaccessible attachment mock_post.reset_mock() invalid_path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/file.jpg") bad_attach = AppriseAttachment(invalid_path) assert ( bool( obj.notify( body="test", notify_type=NotifyType.INFO, attach=bad_attach, ) ) is False ) # No POST should have been made assert mock_post.call_count == 0 @mock.patch("requests.post") def test_plugin_webex_teams_apprise_integration(mock_post): """NotifyWebexTeams() Apprise.notify() integration.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response # Webhook mode via Apprise app = Apprise() assert app.add("wxteams://{}".format(WEBHOOK_TOKEN)) assert bool(app.notify(body="webhook test")) is True mock_post.reset_mock() # Bot mode via Apprise app2 = Apprise() assert app2.add("wxteams://{}/{}".format(BOT_TOKEN, ROOM_ID)) assert bool(app2.notify(body="bot test")) is True @mock.patch("requests.post") def test_plugin_webex_teams_html_to_markdown_format(mock_post): """NotifyWebexTeams(): HTML body is converted to Markdown.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Use a webhook URL (80-char token, webhook mode) aobj = Apprise() assert aobj.add("wxteams://{}".format("a" * 80)) # Notify with an HTML body; the framework should convert it # to Markdown before dispatching to Webex Teams assert ( bool( aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) ) is True ) assert mock_post.call_count == 1 # The body must arrive as Markdown (not plain text) in the # "markdown" key, since notify_format is MARKDOWN by default payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["markdown"] == "**hello** *world*" @mock.patch("apprise.plugins.base.time.sleep") @mock.patch("requests.post") def test_plugin_webex_teams_bot_attach_retry(mock_post, mock_sleep): """A retry re-sends only the attachment post that failed.""" good = mock.Mock() good.status_code = requests.codes.ok good.content = b"" bad = mock.Mock() bad.status_code = requests.codes.internal_server_error bad.content = b"" # Attachment 1 (with the body) posts, attachment 2 fails, then the # retry only needs to post attachment 2 mock_post.side_effect = [good, bad, good] aobj = Apprise() assert aobj.add( f"wxteams://{BOT_TOKEN}/{ROOM_ID}/?mode=bot&retry=2&wait=0" ) attach = AppriseAttachment( [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), ] ) assert bool(aobj.notify(body="body", attach=attach)) is True # The body and first file went out once; the second file was retried assert mock_post.call_count == 3 names = [call[1]["files"]["files"][0] for call in mock_post.call_args_list] assert names == [ "apprise-test.gif", "apprise-test.png", "apprise-test.png", ] # Only the first post carried the message body assert "markdown" in mock_post.call_args_list[0][1]["data"] assert "markdown" not in mock_post.call_args_list[2][1]["data"] @mock.patch("apprise.plugins.base.time.sleep") @mock.patch("requests.post") def test_plugin_webex_teams_bot_room_retry(mock_post, mock_sleep): """A retry only re-posts to the room that failed.""" good = mock.Mock() good.status_code = requests.codes.ok good.content = b"" bad = mock.Mock() bad.status_code = requests.codes.internal_server_error bad.content = b"" # The first room accepts; the second fails once and then accepts mock_post.side_effect = [good, bad, good] aobj = Apprise() assert aobj.add( f"wxteams://{BOT_TOKEN}/{ROOM_ID}/{ROOM_ID2}/?retry=2&wait=0" ) assert bool(aobj.notify(body="body")) is True rooms = [ loads(call[1]["data"])["roomId"] for call in mock_post.call_args_list ] # The first room was notified once; the second one was retried first, second = aobj[0].targets assert rooms == [first, second, second] caronc-apprise-dfec414/tests/test_plugin_wechat.py000066400000000000000000000727611526720371000225400ustar00rootroot00000000000000# # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions # are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in # the documentation and/or other materials provided with the # distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from json import dumps import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise from apprise.common import NotifyFormat from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.wechat import ( WECHAT_ERROR_CODES, WECHAT_TOKEN_ERROR_CODES, NotifyWeChat, ) logging.disable(logging.CRITICAL) # Fixed test credentials CORPID = "wwcorpid1234567890" CORPSECRET = "abcSecret9" AGENTID = "1000002" # A successful response serves double-duty: the token GET needs # access_token and expires_in; the message POST needs errcode == 0. GOOD_RESPONSE = dumps( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN12345678", "expires_in": 7200, } ) # pprint(CORPSECRET, privacy=True) -> first + "..." + last char # "abcSecret9" -> "a...9" PRIVACY_SECRET = "a...9" # Our Testing URLs apprise_url_tests = ( # ---------------------------------------------------------------- # Invalid / missing credential cases # ---------------------------------------------------------------- ( "wechat://", { # No credentials at all "instance": AppriseImproperlyConfigured, }, ), ( # Missing corpsecret and agentid "wechat://{}".format(CORPID), { "instance": AppriseImproperlyConfigured, }, ), ( # Missing agentid (non-numeric host provided as port only) "wechat://{}:{}@".format(CORPID, CORPSECRET), { "instance": AppriseImproperlyConfigured, }, ), ( # Non-numeric agentid "wechat://{}:{}@notanumber".format(CORPID, CORPSECRET), { "instance": AppriseImproperlyConfigured, }, ), # ---------------------------------------------------------------- # No targets -- plugin loads but send() returns False early # ---------------------------------------------------------------- ( "wechat://{}:{}@{}".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "notify_response": False, "privacy_url": "wechat://{}:{}@{}/".format( CORPID, PRIVACY_SECRET, AGENTID ), }, ), # ---------------------------------------------------------------- # @all broadcast -- sends to entire organisation # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/@all".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, "privacy_url": "wechat://{}:{}@{}/@all/".format( CORPID, PRIVACY_SECRET, AGENTID ), }, ), # ---------------------------------------------------------------- # Single user target (bare form -- no @ prefix) # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/johndoe".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Single user target (@ prefixed form) # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/@johndoe".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Multiple user targets # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/user1/user2/user3".format( CORPID, CORPSECRET, AGENTID ), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Department target (%23 decodes to # prefix) # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/%23100".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Tag target (+ prefix) # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/+7".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Mixed recipients (user + department + tag) # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/john/%23200/+3".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Targets supplied via ?to= query parameter # ---------------------------------------------------------------- ( "wechat://{}:{}@{}?to=@all".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Markdown format # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/@all?format=markdown".format( CORPID, CORPSECRET, AGENTID ), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # HTTP error responses -- token GET fails -> overall send fails # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/@all".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "wechat://{}:{}@{}/@all".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "response": False, "requests_response_code": 999, }, ), # ---------------------------------------------------------------- # Network exception on all requests # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/@all".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "test_requests_exceptions": True, }, ), ) def test_plugin_wechat_urls(): """Run the standard Apprise URL tester for all wechat:// entries.""" AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_wechat_init(): """Verify __init__ validation for required fields.""" # Valid construction obj = NotifyWeChat(corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID) assert isinstance(obj, NotifyWeChat) # corpid is required with ( mock.patch("apprise.plugins.wechat.validate_regex", return_value=None), mock.patch.object(NotifyWeChat, "logger"), pytest.raises(AppriseImproperlyConfigured), ): NotifyWeChat(corpid="", corpsecret=CORPSECRET, agentid=AGENTID) # corpsecret is required with pytest.raises(AppriseImproperlyConfigured): NotifyWeChat(corpid=CORPID, corpsecret="", agentid=AGENTID) # agentid must be numeric with pytest.raises(AppriseImproperlyConfigured): NotifyWeChat(corpid=CORPID, corpsecret=CORPSECRET, agentid="notnum") # agentid=None must be rejected with pytest.raises(AppriseImproperlyConfigured): NotifyWeChat(corpid=CORPID, corpsecret=CORPSECRET, agentid=None) # Invalid targets are silently dropped; valid ones are kept obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["validuser", "!!invalid!!", "#42", "+7"], ) assert "validuser" in obj.users assert "42" in obj.departments assert "7" in obj.tag_ids assert "!!invalid!!" in obj.invalid_targets # The @ prefix on user IDs is optional on input; it is stripped # by the regex before storing so the API payload stays prefix-free. # The bare keyword "all" is normalised to "@all" (WeCom API form). obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@johndoe", "@all", "all"], ) assert "johndoe" in obj.users # Both "@all" and bare "all" must normalise to "@all" assert obj.users.count("@all") == 2 def test_plugin_wechat_url_round_trip(): """Verify that url() and parse_url() form a lossless round-trip.""" obj1 = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["alice", "#10", "+2", "@all"], ) url = obj1.url() result = NotifyWeChat.parse_url(url) assert result is not None obj2 = NotifyWeChat(**result) # Connection identity must be preserved assert obj1.url_identifier == obj2.url_identifier # Target counts must match assert len(obj1) == len(obj2) def test_plugin_wechat_url(): """Verify url() output for various target combinations.""" # No targets obj = NotifyWeChat(corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID) url = obj.url() assert CORPID in url assert CORPSECRET in url assert AGENTID in url # Privacy URL masks the corpsecret privacy = obj.url(privacy=True) assert CORPSECRET not in privacy assert PRIVACY_SECRET in privacy assert CORPID in privacy # Department gets encoded as %23 obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["#5"], ) assert "%235" in obj.url() # Tag gets + prefix obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["+3"], ) assert "+3" in obj.url() # @all is kept as-is obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert "@all" in obj.url() # Regular user IDs are always emitted with @ prefix in the URL obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["alice"], ) assert "@alice" in obj.url() # Invalid targets survive the round-trip obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["!!bad!!"], ) assert obj.invalid_targets # url() must include the invalid target so it round-trips assert obj.url() != "" def test_plugin_wechat_url_identifier(): """url_identifier must differ when credentials differ.""" obj1 = NotifyWeChat(corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID) obj2 = NotifyWeChat( corpid="other_corp", corpsecret=CORPSECRET, agentid=AGENTID ) obj3 = NotifyWeChat( corpid=CORPID, corpsecret="other_secret", agentid=AGENTID ) obj4 = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid="9999999" ) assert obj1.url_identifier != obj2.url_identifier assert obj1.url_identifier != obj3.url_identifier assert obj1.url_identifier != obj4.url_identifier @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_ok(mock_post, mock_get): """Successful end-to-end send: token GET + message POST both succeed.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r # Token GET -> success + message POST -> success mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "MYTOKEN", "expires_in": 7200, } ) mock_post.return_value = _mk_resp({"errcode": 0, "errmsg": "ok"}) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is True assert mock_get.call_count == 1 assert mock_post.call_count == 1 @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_cached_token(mock_post, mock_get): """Second send reuses the cached token without a new GET request.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "CACHED_TOKEN", "expires_in": 7200, } ) mock_post.return_value = _mk_resp({"errcode": 0, "errmsg": "ok"}) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) # First send fetches the token assert obj.send(body="First") is True assert mock_get.call_count == 1 # Second send reuses the cached token assert obj.send(body="Second") is True assert mock_get.call_count == 1 # still 1 -- no second GET @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_all_targets(mock_post, mock_get): """A single send delivers to users, departments, and tags in one POST.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) mock_post.return_value = _mk_resp({"errcode": 0, "errmsg": "ok"}) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["alice", "bob", "#10", "#20", "+3", "+5"], ) assert obj.send(body="Hello") is True assert mock_post.call_count == 1 # Verify payload recipients import json call_kwargs = mock_post.call_args payload = json.loads(call_kwargs[1]["data"]) assert set(payload["touser"].split("|")) == {"alice", "bob"} assert set(payload["toparty"].split("|")) == {"10", "20"} assert set(payload["totag"].split("|")) == {"3", "5"} @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_markdown(mock_post, mock_get): """Markdown format produces msgtype=markdown in the payload.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) mock_post.return_value = _mk_resp({"errcode": 0, "errmsg": "ok"}) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], format=NotifyFormat.MARKDOWN, ) assert obj.send(body="# Hello") is True import json payload = json.loads(mock_post.call_args[1]["data"]) assert payload["msgtype"] == "markdown" assert "markdown" in payload @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_no_targets(mock_post, mock_get): """send() returns False immediately when no valid targets are present.""" obj = NotifyWeChat(corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID) # No targets -> early return False, no network calls assert obj.send(body="Hello") is False assert mock_get.call_count == 0 assert mock_post.call_count == 0 @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_token_fetch_http_error(mock_post, mock_get): """Token GET returning HTTP 500 causes send() to fail.""" r = mock.Mock() r.status_code = requests.codes.internal_server_error r.content = b"" mock_get.return_value = r obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False assert mock_get.call_count == 1 assert mock_post.call_count == 0 @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_token_fetch_api_error(mock_post, mock_get): """Token GET returning errcode != 0 causes send() to fail.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r # errcode 40001 -- invalid credential mock_get.return_value = _mk_resp( {"errcode": 40001, "errmsg": "invalid credential"} ) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False assert mock_post.call_count == 0 @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_token_fetch_no_token_field(mock_post, mock_get): """Token GET returning errcode 0 but no access_token causes failure.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r # errcode 0 but access_token is absent mock_get.return_value = _mk_resp({"errcode": 0, "errmsg": "ok"}) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_token_fetch_bad_json(mock_post, mock_get): """Token GET returning unparsable JSON is handled gracefully.""" r = mock.Mock() r.status_code = requests.codes.ok r.content = b"{bad json" mock_get.return_value = r obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_token_fetch_request_exception(mock_post, mock_get): """RequestException during token GET causes send() to fail.""" mock_get.side_effect = requests.RequestException("connection error") obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_http_error(mock_post, mock_get): """Message POST returning HTTP 500 causes send() to fail.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) mock_post.return_value = _mk_resp( {}, code=requests.codes.internal_server_error ) mock_post.return_value.content = b"" obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_api_error(mock_post, mock_get): """Message POST returning errcode != 0 causes send() to fail.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) # 81013 -- all recipients invalid mock_post.return_value = _mk_resp( {"errcode": 81013, "errmsg": "All recipients invalid"} ) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_token_expiry_evicts_cache(mock_post, mock_get): """A token-expired errcode from the POST evicts the cached token.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) # POST returns 42001 -- token expired mock_post.return_value = _mk_resp( {"errcode": 42001, "errmsg": "access_token expired"} ) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False # Verify the store no longer holds the token assert obj.store.get("access_token") is None @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_token_expiry_all_codes(mock_post, mock_get): """All WECHAT_TOKEN_ERROR_CODES cause the cached token to be evicted.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r for errcode in WECHAT_TOKEN_ERROR_CODES: mock_get.reset_mock() mock_post.reset_mock() mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) mock_post.return_value = _mk_resp( {"errcode": errcode, "errmsg": "token error"} ) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False assert obj.store.get("access_token") is None @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_bad_json(mock_post, mock_get): """Message POST returning unparsable JSON is handled gracefully.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) # POST returns unparsable body post_r = mock.Mock() post_r.status_code = requests.codes.ok post_r.content = b"{bad json" mock_post.return_value = post_r obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) # Bad JSON -> content = {} -> errcode = -1 -> failure assert obj.send(body="Hello") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_request_exception(mock_post, mock_get): """RequestException during message POST causes send() to fail.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) mock_post.side_effect = requests.RequestException("network error") obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False def test_plugin_wechat_parse_url(): """Verify parse_url() handles all supported URL forms.""" # Standard form: corpid:corpsecret@agentid/targets url = "wechat://{}:{}@{}/alice".format(CORPID, CORPSECRET, AGENTID) result = NotifyWeChat.parse_url(url) assert result is not None assert result["corpid"] == CORPID assert result["corpsecret"] == CORPSECRET assert result["agentid"] == AGENTID assert "alice" in result["targets"] # Department target (%23 decodes to #) url = "wechat://{}:{}@{}/%23999".format(CORPID, CORPSECRET, AGENTID) result = NotifyWeChat.parse_url(url) assert "#999" in result["targets"] # Tag target url = "wechat://{}:{}@{}/+42".format(CORPID, CORPSECRET, AGENTID) result = NotifyWeChat.parse_url(url) assert "+42" in result["targets"] # ?to= adds targets url = "wechat://{}:{}@{}?to=@all".format(CORPID, CORPSECRET, AGENTID) result = NotifyWeChat.parse_url(url) assert "@all" in result["targets"] # None and non-string inputs return None assert NotifyWeChat.parse_url(None) is None def test_plugin_wechat_parse_native_url(): """parse_native_url() always returns None -- no native URL form.""" assert ( NotifyWeChat.parse_native_url("https://qyapi.weixin.qq.com/any/url") is None ) def test_plugin_wechat_error_codes(): """Verify the error code constants are populated correctly.""" assert 0 in WECHAT_ERROR_CODES assert 40001 in WECHAT_TOKEN_ERROR_CODES assert 40014 in WECHAT_TOKEN_ERROR_CODES assert 42001 in WECHAT_TOKEN_ERROR_CODES @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_apprise_integration(mock_post, mock_get): """End-to-end Apprise().notify() integration test.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) mock_post.return_value = _mk_resp({"errcode": 0, "errmsg": "ok"}) app = Apprise() url = "wechat://{}:{}@{}/@all".format(CORPID, CORPSECRET, AGENTID) assert app.add(url) is True assert bool(app.notify(title="T", body="B")) is True @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_unknown_api_error(mock_post, mock_get): """An unknown errcode falls back to the errmsg field in the response.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) # Use an error code not in WECHAT_ERROR_CODES mock_post.return_value = _mk_resp( {"errcode": 99999, "errmsg": "custom error message"} ) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_token_unknown_api_error(mock_post, mock_get): """Unknown errcode from token GET falls back to errmsg field.""" r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps( {"errcode": 99999, "errmsg": "custom token error"} ).encode("utf-8") mock_get.return_value = r obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False caronc-apprise-dfec414/tests/test_plugin_wecombot.py000066400000000000000000000067121526720371000230750ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import requests from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.wecombot import NotifyWeComBot logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "wecombot://", { "instance": AppriseImproperlyConfigured, }, ), ( "wecombot://:@/", { "instance": AppriseImproperlyConfigured, }, ), ( "wecombot://botkey", { # Minimum requirements met "instance": NotifyWeComBot, }, ), ( "wecombot://?key=botkey", { # Test ?key= "instance": NotifyWeComBot, }, ), # Support Native URLs ( "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=BOTKEY", { "instance": NotifyWeComBot, }, ), ( "https://qyapi.weixin.qq.com/cgi-bin/webhook/send/?key=BOTKEY&data=123", { # another variation (more parameters don't obstruct our key) "instance": NotifyWeComBot, }, ), ( "wecombot://botkey", { "instance": NotifyWeComBot, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "wecombot://botkey", { "instance": NotifyWeComBot, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "wecombot://botkey", { "instance": NotifyWeComBot, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_wecombot_urls(): """NotifyWeComBot() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-dfec414/tests/test_plugin_whatsapp.py000066400000000000000000000443611526720371000231070ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps, loads # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyType from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.whatsapp import IS_GROUP_ID, NotifyWhatsApp logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "whatsapp://", { # Not enough details "instance": AppriseImproperlyConfigured, }, ), ( "whatsapp://:@/", { # invalid Access Token "instance": AppriseImproperlyConfigured, }, ), ( "whatsapp://{}@_".format("a" * 32), { # token provided but invalid from "instance": AppriseImproperlyConfigured, }, ), ( "whatsapp://%20:{}@12345/{}".format("e" * 32, "4" * 11), { # Invalid template "instance": AppriseImproperlyConfigured, }, ), ( "whatsapp://{}@{}".format("b" * 32, 10**9), { # token provided and from but no target no "instance": NotifyWhatsApp, # Response will fail due to no targets defined "notify_response": False, }, ), ( "whatsapp://{}:{}@{}/123/{}/abcd/".format( "a" * 32, "b" * 32, "3" * 11, "9" * 15 ), { # valid everything but target numbers "instance": NotifyWhatsApp, # Response will fail due to target not being loaded "notify_response": False, }, ), ( "whatsapp://{}@12345/{}".format("e" * 32, "4" * 11), { # simple message "instance": NotifyWhatsApp, # Our expected url(privacy=True) startswith() response: "privacy_url": "whatsapp://e...e@1...5/%2B44444444444/", }, ), ( "whatsapp://template:{}@12345/{}".format("e" * 32, "4" * 11), { # template "instance": NotifyWhatsApp, # Our expected url(privacy=True) startswith() response: "privacy_url": "whatsapp://template:e...e@1...5/%2B44444444444/", }, ), ( "whatsapp://template:{}@12345/{}?lang=fr_CA".format( "e" * 32, "4" * 11 ), { # template with language over-ride "instance": NotifyWhatsApp, # Our expected url(privacy=True) startswith() response: "privacy_url": "whatsapp://template:e...e@1...5/%2B44444444444/", }, ), ( "whatsapp://{}@12345/{}?template=template&lang=fr_CA".format( "e" * 32, "4" * 11 ), { # template specified as kwarg with language over-ride "instance": NotifyWhatsApp, # Our expected url(privacy=True) startswith() response: "privacy_url": "whatsapp://template:e...e@1...5/%2B44444444444/", }, ), ( "whatsapp://template:{}@12345/{}?lang=1234".format("e" * 32, "4" * 11), { # template with invalid language over-ride "instance": AppriseImproperlyConfigured, }, ), ( "whatsapp://template:{}@12345/{}?:1=test&:body=3&:type=2".format( "e" * 32, "4" * 11 ), { # template with kwarg assignments # {{1}} assigned test # {{2}} assigned Apprise Message type (special keyword) # {{3}} assigned Apprise Message body (special keyword) "instance": NotifyWhatsApp, # Our expected url(privacy=True) startswith() response: "privacy_url": "whatsapp://template:e...e@1...5/%2B44444444444/", }, ), ( "whatsapp://template:{}@12345/{}?:invalid=23".format( "e" * 32, "4" * 11 ), { # template with kwarg assignments # Invalid keyword specified; cna only be a digit OR `body' # or 'type' "instance": AppriseImproperlyConfigured, }, ), ( "whatsapp://template:{}@12345/{}?:body=".format("e" * 32, "4" * 11), { # template with kwarg assignments # No Body Assigment "instance": AppriseImproperlyConfigured, }, ), ( "whatsapp://template:{}@12345/{}?:1=Test&:body=1".format( "e" * 32, "4" * 11 ), { # template with kwarg assignments # Ambiguious assignment {{1}} assigned twice "instance": AppriseImproperlyConfigured, }, ), ( "whatsapp://{}:{}@123456/{}".format("a" * 32, "b" * 32, "4" * 11), { # using short-code (6 characters) "instance": NotifyWhatsApp, }, ), ( "whatsapp://_?token={}&from={}&to={}".format( "d" * 32, "5" * 11, "6" * 11 ), { # use get args to acomplish the same thing "instance": NotifyWhatsApp, }, ), ( "whatsapp://_?token={}&source={}&to={}".format( "d" * 32, "5" * 11, "6" * 11 ), { # use get args to acomplish the same thing (use source instead # of from) "instance": NotifyWhatsApp, }, ), ( "whatsapp://{}@12345/{}".format("e" * 32, "4" * 11), { "instance": NotifyWhatsApp, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "whatsapp://{}@12345/{}".format("e" * 32, "4" * 11), { "instance": NotifyWhatsApp, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), # Group target tests ( # Group ID via '#' prefix "whatsapp://{}@12345/%23120363043968066561".format("e" * 32), { "instance": NotifyWhatsApp, "privacy_url": "whatsapp://e...e@1...5/%23120363043968066561/", }, ), ( # Mix of phone number and group ID "whatsapp://{}@12345/{}/%23120363043968066561".format( "e" * 32, "4" * 11 ), { "instance": NotifyWhatsApp, }, ), ( # Group target with HTTP 500 "whatsapp://{}@12345/%23120363043968066561".format("e" * 32), { "instance": NotifyWhatsApp, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( # Group target with request exception "whatsapp://{}@12345/%23120363043968066561".format("e" * 32), { "instance": NotifyWhatsApp, "test_requests_exceptions": True, }, ), ) def test_plugin_whatsapp_urls(): """NotifyWhatsApp() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_whatsapp_url_language(): """NotifyWhatsApp() url() writes the template language as language=.""" obj = Apprise.instantiate( "whatsapp://template:{}@12345/{}?lang=fr_CA".format("e" * 32, "4" * 11) ) assert isinstance(obj, NotifyWhatsApp) # lang= is an alias, so url() writes the value back as language= url = obj.url() assert "language=fr_CA" in url assert "lang=" not in url # Reloading our URL keeps the language obj2 = Apprise.instantiate(url) assert isinstance(obj2, NotifyWhatsApp) assert obj2.language == "fr_CA" assert obj2.url() == url @mock.patch("requests.post") def test_plugin_whatsapp_auth(mock_post): """NotifyWhatsApp() Auth. - account-wide auth token - API key and its own auth token """ response = mock.Mock() response.content = "" response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) tokens token = "{}".format("b" * 32) from_phone_id = "123456787654321" target = "+1 (555) 987-6543" message_contents = "test" # Variation of initialization without API key obj = Apprise.instantiate(f"whatsapp://{token}@{from_phone_id}/{target}") assert isinstance(obj, NotifyWhatsApp) is True assert isinstance(obj.url(), str) is True # Send Notification assert obj.send(body=message_contents) is True # Validate expected call parameters assert mock_post.call_count == 1 first_call = mock_post.call_args_list[0] # URL and message parameters are the same for both calls assert first_call[0][0] == ( "https://graph.facebook.com/" f"{NotifyWhatsApp.fb_graph_version}/{from_phone_id}/messages" ) response = loads(first_call[1]["data"]) assert response["text"]["body"] == message_contents assert response["to"] == "+15559876543" assert response["recipient_type"] == "individual" @mock.patch("requests.post") def test_plugin_whatsapp_edge_cases(mock_post): """NotifyWhatsApp() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) tokens token = "b" * 32 from_phone_id = "123456787654321" targets = ("+1 (555) 123-3456",) # No token specified with pytest.raises(AppriseImproperlyConfigured): NotifyWhatsApp( token=None, from_phone_id=from_phone_id, targets=targets ) # No from_phone_id specified with pytest.raises(AppriseImproperlyConfigured): NotifyWhatsApp(token=token, from_phone_id=None, targets=targets) # a error response response.status_code = 400 response.content = dumps( { "error": { "code": 21211, "message": ( "The 'To' number +1234567 is not a valid phone number." ), }, } ) mock_post.return_value = response # Initialize our object obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=targets ) # We will fail with the above error code assert bool(obj.notify("title", "body", "info")) is False @mock.patch("requests.post") def test_plugin_whatsapp_template_notify_type_value(mock_post): response = mock.Mock() response.content = "" response.status_code = requests.codes.ok mock_post.return_value = response token = "b" * 32 from_phone_id = "12345" target = "+1 (555) 987-6543" # Map notify_type -> {{2}}, body -> {{3}} obj = Apprise.instantiate( f"whatsapp://template:{token}@{from_phone_id}/{target}?:type=2&:body=3" ) assert isinstance(obj, NotifyWhatsApp) assert ( obj.send(body="test", title="t", notify_type=NotifyType.INFO) is True ) call = mock_post.call_args_list[0] assert "NotifyType." not in call[1]["data"] payload = loads(call[1]["data"]) params = payload["template"]["components"][0]["parameters"] # Ensure values are injected, not literal strings assert params[0]["text"] == NotifyType.INFO.value assert params[1]["text"] == "test" @mock.patch("requests.post") def test_plugin_whatsapp_groups(mock_post): """NotifyWhatsApp() group target support.""" def _mk_resp(code=requests.codes.ok): r = mock.Mock() r.content = "" r.status_code = code return r mock_post.return_value = _mk_resp() token = "e" * 32 from_phone_id = "123456787654321" # A realistic-looking numeric group ID group_id = "120363043968066561" # IS_GROUP_ID only matches the numeric portion (no prefix/suffix) assert IS_GROUP_ID.match(group_id) assert not IS_GROUP_ID.match(f"#{group_id}") assert not IS_GROUP_ID.match(f"{group_id}@g.us") assert not IS_GROUP_ID.match("abc123") # __init__ target classification # '#' prefix → group obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[f"#{group_id}"], ) assert obj.targets == [f"#{group_id}"] # '@g.us' suffix (native API format) → normalised to '#' prefix obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[f"{group_id}@g.us"], ) assert obj.targets == [f"#{group_id}"] # '#' prefix + '@g.us' suffix (both present) → normalised obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[f"#{group_id}@g.us"], ) assert obj.targets == [f"#{group_id}"] # Invalid group ID (non-numeric) is dropped with a warning obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=["#badgroupid", f"#{group_id}"], ) assert obj.targets == [f"#{group_id}"] # Mixed: phone number + group ID obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=["+14155552671", f"#{group_id}"], ) assert len(obj.targets) == 2 assert "+14155552671" in obj.targets assert f"#{group_id}" in obj.targets # send() payload verification mock_post.reset_mock() obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[f"#{group_id}"], ) assert obj.send(body="hello group") is True assert mock_post.call_count == 1 # Group payload must carry recipient_type='group' and 'to'=id@g.us sent = loads(mock_post.call_args_list[0][1]["data"]) assert sent["recipient_type"] == "group" assert sent["to"] == f"{group_id}@g.us" # send() for individual phone preserves recipient_type='individual' mock_post.reset_mock() obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=["+14155552671"], ) assert obj.send(body="hello individual") is True sent = loads(mock_post.call_args_list[0][1]["data"]) assert sent["recipient_type"] == "individual" assert sent["to"] == "+14155552671" # mixed send: one phone + one group makes two POST calls mock_post.reset_mock() obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=["+14155552671", f"#{group_id}"], ) assert obj.send(body="mixed") is True assert mock_post.call_count == 2 payloads = [loads(c[1]["data"]) for c in mock_post.call_args_list] types = {p["recipient_type"] for p in payloads} assert types == {"individual", "group"} # HTTP error on group target returns False mock_post.return_value = _mk_resp(requests.codes.internal_server_error) obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[f"#{group_id}"], ) assert obj.send(body="should fail") is False # partial failure: phone succeeds, group errors → overall False mock_post.side_effect = [ _mk_resp(requests.codes.ok), _mk_resp(requests.codes.internal_server_error), ] obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=["+14155552671", f"#{group_id}"], ) assert obj.send(body="partial") is False # URL round-trip: group ID survives url() -> parse_url() mock_post.side_effect = None mock_post.return_value = _mk_resp() obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[f"#{group_id}"], ) rebuilt = NotifyWhatsApp(**NotifyWhatsApp.parse_url(obj.url())) assert rebuilt.targets == obj.targets assert rebuilt.url_identifier == obj.url_identifier # Round-trip with mixed targets obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=["+14155552671", f"#{group_id}"], ) rebuilt = NotifyWhatsApp(**NotifyWhatsApp.parse_url(obj.url())) assert sorted(rebuilt.targets) == sorted(obj.targets) # targets as a plain string (comma-separated, not a list) obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=f"#{group_id}", ) assert obj.targets == [f"#{group_id}"] # whitespace-only list entry is silently skipped obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[" ", f"#{group_id}"], ) assert obj.targets == [f"#{group_id}"] # ?to= query parameter accepts group IDs url = f"whatsapp://_?token={token}&from={from_phone_id}&to=%23{group_id}" obj = Apprise.instantiate(url) assert isinstance(obj, NotifyWhatsApp) assert f"#{group_id}" in obj.targets # privacy_url masks group targets correctly obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[f"#{group_id}"], ) priv = obj.url(privacy=True) # The token should be masked; the group ID should still be present assert token not in priv assert group_id in priv caronc-apprise-dfec414/tests/test_plugin_windows.py000066400000000000000000000325301526720371000227450ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from importlib import reload # Disable logging for a cleaner testing output import logging import sys import types from unittest import mock import pytest import apprise logging.disable(logging.CRITICAL) @pytest.fixture def mocked_win32_environment(): """Inject fake win32 modules, reload apprise, and fully restore sys.modules on teardown. Previously the setup lived inside test_plugin_windows_mocked itself, with no cleanup. That permanently abandoned all apprise.* module objects, causing order-dependent failures in every test that runs afterwards and holds a module-level `from apprise.plugins.X import` reference. A fixture with a yield guarantees cleanup even on failure. """ # Build fake win32api win32api = types.ModuleType("win32api") win32api.GetModuleHandle = mock.Mock(name="win32api.GetModuleHandle") win32api.PostQuitMessage = mock.Mock(name="win32api.PostQuitMessage") # Build fake win32con win32con = types.ModuleType("win32con") win32con.CW_USEDEFAULT = mock.Mock(name="win32con.CW_USEDEFAULT") win32con.IDI_APPLICATION = mock.Mock(name="win32con.IDI_APPLICATION") win32con.IMAGE_ICON = mock.Mock(name="win32con.IMAGE_ICON") win32con.LR_DEFAULTSIZE = 1 win32con.LR_LOADFROMFILE = 2 win32con.WM_DESTROY = mock.Mock(name="win32con.WM_DESTROY") win32con.WM_USER = 0 win32con.WS_OVERLAPPED = 1 win32con.WS_SYSMENU = 2 # Build fake win32gui win32gui = types.ModuleType("win32gui") win32gui.CreateWindow = mock.Mock(name="win32gui.CreateWindow") win32gui.DestroyWindow = mock.Mock(name="win32gui.DestroyWindow") win32gui.LoadIcon = mock.Mock(name="win32gui.LoadIcon") win32gui.LoadImage = mock.Mock(name="win32gui.LoadImage") win32gui.NIF_ICON = 1 win32gui.NIF_INFO = mock.Mock(name="win32gui.NIF_INFO") win32gui.NIF_MESSAGE = 2 win32gui.NIF_TIP = 4 win32gui.NIM_ADD = mock.Mock(name="win32gui.NIM_ADD") win32gui.NIM_DELETE = mock.Mock(name="win32gui.NIM_DELETE") win32gui.NIM_MODIFY = mock.Mock(name="win32gui.NIM_MODIFY") win32gui.RegisterClass = mock.Mock(name="win32gui.RegisterClass") win32gui.UnregisterClass = mock.Mock(name="win32gui.UnregisterClass") win32gui.Shell_NotifyIcon = mock.Mock(name="win32gui.Shell_NotifyIcon") win32gui.UpdateWindow = mock.Mock(name="win32gui.UpdateWindow") win32gui.WNDCLASS = mock.Mock(name="win32gui.WNDCLASS") # Save all apprise.* module references before wiping them. # These are the same module objects whose __dict__ entries are # pointed to by module-level imports in other test files; restoring # them keeps those references valid after the test completes. saved_apprise = { k: v for k, v in sys.modules.items() if k.startswith("apprise.") } # Inject win32 stubs so the plugin's top-level import succeeds, # then wipe apprise.* and reload so it picks them up. sys.modules["win32api"] = win32api sys.modules["win32con"] = win32con sys.modules["win32gui"] = win32gui for mod in list(saved_apprise): del sys.modules[mod] reload(apprise) # Yield win32gui -- the test uses it to configure side-effects yield win32gui # --- Teardown --- # Remove all apprise.* entries created during this test for mod in [k for k in sys.modules if k.startswith("apprise.")]: del sys.modules[mod] # Remove the injected win32 stubs for stub in ("win32api", "win32con", "win32gui"): sys.modules.pop(stub, None) # Restore the original apprise.* module objects so that any # module-level `from apprise.plugins.X import NotifyX` in other # test files continues to point at live (non-abandoned) module dicts. sys.modules.update(saved_apprise) # sys.modules.update() above only fixes the dict *keys* -- Python's # import machinery separately sets each submodule as an ATTRIBUTE on # its parent package too (e.g. sys.modules["apprise"].apprise), and # that binding is untouched by the update. Left stale, any later # `import apprise.apprise as x` anywhere else in the test session -- # resolved via getattr(sys.modules["apprise"], "apprise"), not a # sys.modules lookup -- would silently get the module object this # fixture created and already discarded above, rather than the real, # restored one. for name, mod in saved_apprise.items(): parent_name, _, attr = name.rpartition(".") parent = sys.modules.get(parent_name) if parent is not None: setattr(parent, attr, mod) @pytest.mark.skipif( ( "win32api" in sys.modules or "win32con" in sys.modules or "win32gui" in sys.modules ), reason="Requires non-windows platform", ) def test_plugin_windows_mocked(mocked_win32_environment): """NotifyWindows() General Checks (via non-Windows platform)""" # Retrieve the win32gui mock that the fixture set up win32gui = mocked_win32_environment # Create our instance obj = apprise.Apprise.instantiate("windows://", suppress_exceptions=False) obj.duration = 0 # Test URL functionality assert isinstance(obj.url(), str) # Verify that a URL ID can not be generated assert obj.url_id() is None # Check that it found our mocked environments assert obj.enabled is True # _on_destroy check obj._on_destroy(0, "", 0, 0) # test notifications assert ( bool( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) ) is True ) obj = apprise.Apprise.instantiate( "windows://_/?image=True", suppress_exceptions=False ) obj.duration = 0 assert isinstance(obj.url(), str) assert ( bool( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) ) is True ) obj = apprise.Apprise.instantiate( "windows://_/?image=False", suppress_exceptions=False ) obj.duration = 0 assert isinstance(obj.url(), str) assert ( bool( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) ) is True ) obj = apprise.Apprise.instantiate( "windows://_/?duration=1", suppress_exceptions=False ) assert isinstance(obj.url(), str) # loads okay assert obj.duration == 1 assert ( bool( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) ) is True ) obj = apprise.Apprise.instantiate( "windows://_/?duration=invalid", suppress_exceptions=False ) # Falls back to default assert obj.duration == obj.default_popup_duration_sec obj = apprise.Apprise.instantiate( "windows://_/?duration=-1", suppress_exceptions=False ) # Falls back to default assert obj.duration == obj.default_popup_duration_sec obj = apprise.Apprise.instantiate( "windows://_/?duration=0", suppress_exceptions=False ) # Falls back to default assert obj.duration == obj.default_popup_duration_sec # To avoid slowdowns (for testing), turn it to zero for now obj.duration = 0 # Test our loading of our icon exception; it will still allow the # notification to be sent win32gui.LoadImage.side_effect = AttributeError assert ( bool( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) ) is True ) # Undo our change win32gui.LoadImage.side_effect = None # Test our global exception handling win32gui.UpdateWindow.side_effect = AttributeError assert ( bool( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) ) is False ) # Undo our change win32gui.UpdateWindow.side_effect = None # Toggle our testing for when we can't send notifications because the # package has been made unavailable to us obj.enabled = False assert ( bool( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) ) is False ) @pytest.mark.skipif( "win32api" not in sys.modules and "win32con" not in sys.modules and "win32gui" not in sys.modules, reason="Requires win32api, win32con, and win32gui", ) @mock.patch("win32gui.UpdateWindow") @mock.patch("win32gui.Shell_NotifyIcon") @mock.patch("win32gui.LoadImage") def test_plugin_windows_native( mock_loadimage, mock_notify, mock_update_window ): """NotifyWindows() General Checks (via Windows platform)""" # Create our instance obj = apprise.Apprise.instantiate("windows://", suppress_exceptions=False) obj.duration = 0 # Test URL functionality assert isinstance(obj.url(), str) # Check that it found our mocked environments assert obj.enabled is True # _on_destroy check obj._on_destroy(0, "", 0, 0) # test notifications assert ( bool( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) ) is True ) obj = apprise.Apprise.instantiate( "windows://_/?image=True", suppress_exceptions=False ) obj.duration = 0 assert isinstance(obj.url(), str) assert ( bool( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) ) is True ) obj = apprise.Apprise.instantiate( "windows://_/?image=False", suppress_exceptions=False ) obj.duration = 0 assert isinstance(obj.url(), str) assert ( bool( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) ) is True ) obj = apprise.Apprise.instantiate( "windows://_/?duration=1", suppress_exceptions=False ) assert isinstance(obj.url(), str) assert ( bool( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) ) is True ) # loads okay assert obj.duration == 1 obj = apprise.Apprise.instantiate( "windows://_/?duration=invalid", suppress_exceptions=False ) # Falls back to default assert obj.duration == obj.default_popup_duration_sec obj = apprise.Apprise.instantiate( "windows://_/?duration=-1", suppress_exceptions=False ) # Falls back to default assert obj.duration == obj.default_popup_duration_sec obj = apprise.Apprise.instantiate( "windows://_/?duration=0", suppress_exceptions=False ) # Falls back to default assert obj.duration == obj.default_popup_duration_sec # To avoid slowdowns (for testing), turn it to zero for now obj = apprise.Apprise.instantiate("windows://", suppress_exceptions=False) obj.duration = 0 # Test our loading of our icon exception; it will still allow the # notification to be sent mock_loadimage.side_effect = AttributeError assert ( bool( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) ) is True ) # Undo our change mock_loadimage.side_effect = None # Test our global exception handling mock_update_window.side_effect = AttributeError assert ( bool( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) ) is False ) # Undo our change mock_update_window.side_effect = None # Toggle our testing for when we can't send notifications because the # package has been made unavailable to us obj.enabled = False assert ( bool( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) ) is False ) caronc-apprise-dfec414/tests/test_plugin_workflows.py000066400000000000000000001144311526720371000233110ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from inspect import cleandoc import json # Disable logging for a cleaner testing output import logging import sys from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseConfig, NotifyFormat, NotifyType from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.workflows import NotifyWorkflows logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ################################## # NotifyWorkflows ################################## ( "workflow://", { # invalid host details (parsing fails very early) "instance": None, }, ), ( "workflow://:@/", { # invalid host details (parsing fails very early) "instance": None, }, ), ( "workflow://host/workflow", { # workflow provided only, no signature "instance": AppriseImproperlyConfigured, }, ), ( "workflow://host:443/^(/signature", { # invalid workflow provided "instance": AppriseImproperlyConfigured, }, ), ( "workflow://host:443/workflow1a/signature/?image=no", { # All tokens provided - we're good # Tests case without image defined "instance": NotifyWorkflows, }, ), ( "workflows://host:443/workflow1b/signature/", { # support workflows (s added to end) "instance": NotifyWorkflows, }, ), ( "workflows://host:443/signature/?id=workflow1c", { # id= to store workflow id "instance": NotifyWorkflows, }, ), ( "workflows://host:443/signature/?workflow=workflow1d&wrap=yes", { # workflow= to store workflow id "instance": NotifyWorkflows, }, ), ( "workflows://host:443/signature/?workflow=workflow1d&wrap=no", { # workflow= to store workflow id "instance": NotifyWorkflows, }, ), ( "workflows://host:443/workflow1e/signature/?api-version=2024-01-01", { # support api-version which is extracted from webhook "instance": NotifyWorkflows, # Our expected url(privacy=True) startswith() response "privacy_url": "workflow://host:443/w...e/s...e/", }, ), ( "workflows://host:443/workflow1b/signature/?ver=2016-06-01", { # Support ver= (api-version alias) "instance": NotifyWorkflows, }, ), ( "workflows://host:443/?id=workflow1b&signature=signature", { # Support signature= (sig= alias) "instance": NotifyWorkflows, # Our expected url(privacy=True) startswith() response "privacy_url": "workflow://host:443/w...b/s...e/", }, ), ( "workflows://host:443/workflow1e/signature/?powerautomate=yes", { # support power_automate flag "instance": NotifyWorkflows, }, ), ( "workflows://host:443/workflow1e/signature/?pa=yes&ver=1995-01-01", { # support power_automate flag with ver flag "instance": NotifyWorkflows, }, ), ( "workflows://host:443/workflow1e/signature/?pa=yes", { # support power_automate flag "instance": NotifyWorkflows, }, ), # Support native URLs ( ( "https://server.azure.com:443/workflows/643e69f83c8944/" "triggers/manual/paths/invoke?" "api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&" "sv=1.0&sig=KODuebWbDGYFr0z0eu" ), { # All tokens provided - we're good "instance": NotifyWorkflows, # Our expected url(privacy=True) startswith() response "privacy_url": "workflow://server.azure.com:443/6...4/K...u/", }, ), ( ( "https://server.azure.com:443/" "powerautomate/automations/direct/" "workflows/643e69f83c8944/" "triggers/manual/paths/invoke?" "api-version=2022-03-01-preview&sp=%2Ftriggers%2Fmanual%2Frun&" "sv=1.0&sig=KODuebWbDGYFr0z0eu" ), { # Power-Automate alternative URL - All tokens provided - we're good "instance": NotifyWorkflows, }, ), ( "workflow://host:443/workflow2/signature/", { "instance": NotifyWorkflows, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "workflow://host:443/workflow3/signature/", { "instance": NotifyWorkflows, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "workflow://host:443/workflow4/signature/", { "instance": NotifyWorkflows, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_workflows_urls(): """NotifyWorkflows() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @pytest.fixture def workflows_url(): return "workflow://host:443/workflow/signature" @pytest.fixture def request_mock(mocker): """Prepare requests mock.""" mock_post = mocker.patch("requests.post") mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok return mock_post @pytest.fixture def simple_template(tmpdir): template = tmpdir.join("simple.json") template.write( cleandoc(""" { "type": "message", "attachments": [{ "contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": null, "content": { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.4", "msteams": { "width": "full" }, "body": [ { "type": "TextBlock", "text": "**Test**", "style": "heading" } ] } }] } """) ) return template def test_plugin_workflows_simple_test( request_mock, workflows_url, ): """ NotifyWorkflows() simple testing """ # Instantiate our URL obj = Apprise.instantiate(workflows_url) assert isinstance(obj, NotifyWorkflows) assert ( bool( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) ) is True ) assert request_mock.called is True assert request_mock.call_args_list[0][0][0].startswith( "https://host:443/workflows/workflow/triggers/manual/paths/invoke" ) payload = json.loads(request_mock.call_args_list[0][1]["data"]) assert "NotifyType." not in request_mock.call_args_list[0][1]["data"] assert payload == { "type": "message", "attachments": [ { "contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": None, "content": { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.4", "body": [ { "type": "Image", "url": "https://github.com/caronc/apprise/raw/" "master/apprise/assets/themes/default/" "apprise-info-32x32.png", "height": "32px", "altText": NotifyType.INFO.value, }, { "type": "TextBlock", # Verify our Title is set "text": "title", "style": "heading", "weight": "Bolder", "size": "Large", "id": "title", }, { "type": "TextBlock", # Verify our Body is set "text": "body", "style": "default", "wrap": True, "id": "body", }, ], "msteams": {"width": "full"}, }, }, ], } request_mock.reset_mock() # Instantiate our URL obj = Apprise.instantiate(f"{workflows_url}?pa=yes") assert isinstance(obj, NotifyWorkflows) assert ( bool( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) ) is True ) assert request_mock.called is True assert request_mock.call_args_list[0][0][0].startswith( "https://host:443/powerautomate/automations/direct/" "workflows/workflow/triggers/manual/paths/invoke" ) payload = json.loads(request_mock.call_args_list[0][1]["data"]) assert "NotifyType." not in request_mock.call_args_list[0][1]["data"] assert payload == { "type": "message", "attachments": [ { "contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": None, "content": { "$schema": "http://adaptivecards.io/schemas/" "adaptive-card.json", "type": "AdaptiveCard", "version": "1.4", "body": [ { "type": "Image", "url": "https://github.com/caronc/apprise/raw/" "master/apprise/assets/themes/default/" "apprise-info-32x32.png", "height": "32px", "altText": "info", }, { "type": "TextBlock", # Verify our Title is set "text": "title", "style": "heading", "weight": "Bolder", "size": "Large", "id": "title", }, { "type": "TextBlock", # Verify our Body is set "text": "body", "style": "default", "wrap": True, "id": "body", }, ], "msteams": { "width": "full", }, }, }, ], } def test_plugin_workflows_templating_basic_success( request_mock, workflows_url, tmpdir ): """ NotifyWorkflows() Templating - success. Test cases where URL and JSON is valid. """ template = tmpdir.join("simple.json") template.write( cleandoc(""" { "type": "message", "attachments": [{ "contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": null, "content": { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.4", "body": [ {"type": "TextBlock", "id": "app_id", "text": "{{app_id}}"}, {"type": "TextBlock", "id": "color", "text": "{{app_color}}"}, {"type": "TextBlock", "id": "title", "text": "{{app_title}}"}, {"type": "TextBlock", "id": "body", "text": "{{app_body}}"} ] } }] } """) ) # Instantiate our URL obj = Apprise.instantiate( "{url}/?template={template}&{kwargs}".format( url=workflows_url, template=str(template), kwargs=":key1=token&:key2=token", ) ) assert isinstance(obj, NotifyWorkflows) assert ( bool( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) ) is True ) assert request_mock.called is True assert request_mock.call_args_list[0][0][0].startswith( "https://host:443/workflows/workflow/triggers/manual/paths/invoke" ) # Our Posted JSON Object posted_json = json.loads(request_mock.call_args_list[0][1]["data"]) assert "NotifyType." not in request_mock.call_args_list[0][1]["data"] # Verify the Adaptive Card envelope assert posted_json["type"] == "message" content = posted_json["attachments"][0]["content"] # Index body blocks by their id for easy lookup blocks = {b["id"]: b["text"] for b in content["body"] if "id" in b} assert blocks["app_id"] == "Apprise" assert blocks["color"] == "#3AA3E3" assert blocks["title"] == "title" assert blocks["body"] == "body" def test_plugin_workflows_templating_invalid_json( request_mock, workflows_url, tmpdir ): """ NotifyWorkflows() Templating - invalid JSON. """ template = tmpdir.join("invalid.json") template.write("}") # Instantiate our URL obj = Apprise.instantiate( "{url}/?template={template}&{kwargs}".format( url=workflows_url, template=str(template), kwargs=":key1=token&:key2=token", ) ) assert isinstance(obj, NotifyWorkflows) # We will fail to preform our notifcation because the JSON is bad assert ( bool( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) ) is False ) def test_plugin_workflows_templating_load_json_failure( request_mock, workflows_url, tmpdir ): """ NotifyWorkflows() Templating - template loading failure. Test a case where we can not access the file. """ template = tmpdir.join("empty.json") template.write("") obj = Apprise.instantiate(f"{workflows_url}/?template={template!s}") with mock.patch("json.loads", side_effect=OSError): # we fail, but this time it's because we couldn't # access the cached file contents for reading assert ( bool( obj.notify( body="body", title="title", notify_type=NotifyType.INFO ) ) is False ) def test_plugin_workflows_templating_target_success( request_mock, workflows_url, tmpdir ): """ NotifyWorkflows() Templating - success with target. A more complicated example; uses a custom token. """ template = tmpdir.join("more_complicated_example.json") template.write( cleandoc(""" { "type": "message", "attachments": [{ "contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": null, "content": { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.4", "body": [ {"type": "TextBlock", "id": "desc", "text": "{{app_desc}}"}, {"type": "TextBlock", "id": "color", "text": "{{app_color}}"}, {"type": "TextBlock", "id": "title", "text": "{{app_title}}"}, {"type": "TextBlock", "id": "body", "text": "{{app_body}}"} ], "actions": [ { "type": "Action.OpenUrl", "title": "Open", "url": "{{ target }}" } ] } }] } """) ) # Instantiate our URL obj = Apprise.instantiate( "{url}/?template={template}&{kwargs}".format( url=workflows_url, template=str(template), kwargs=":key1=token&:key2=token&:target=http://localhost", ) ) assert isinstance(obj, NotifyWorkflows) assert ( bool( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) ) is True ) assert request_mock.called is True assert request_mock.call_args_list[0][0][0].startswith( "https://host:443/workflows/workflow/triggers/manual/paths/invoke" ) # Our Posted JSON Object posted_json = json.loads(request_mock.call_args_list[0][1]["data"]) assert "NotifyType." not in request_mock.call_args_list[0][1]["data"] # Verify the Adaptive Card envelope assert posted_json["type"] == "message" content = posted_json["attachments"][0]["content"] # Index body blocks by their id for easy lookup blocks = {b["id"]: b["text"] for b in content["body"] if "id" in b} assert blocks["desc"] == "Apprise Notifications" assert blocks["color"] == "#3AA3E3" assert blocks["title"] == "title" assert blocks["body"] == "body" # The custom :target token must be substituted into the action URL assert content["actions"][0]["url"] == "http://localhost" def test_plugin_workflows_templating_invalid_type( request_mock, workflows_url, tmpdir ): """NotifyWorkflows() Templating - root 'type' != 'message' is rejected.""" template = tmpdir.join("bad_type.json") template.write( cleandoc(""" { "type": "wrongtype", "attachments": [{ "contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": null, "content": {} }] } """) ) obj = Apprise.instantiate(f"{workflows_url}/?template={template!s}") assert isinstance(obj, NotifyWorkflows) # Validation rejects incorrect 'type' value assert ( bool( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) ) is False ) assert request_mock.called is False def test_plugin_workflows_templating_missing_attachments( request_mock, workflows_url, tmpdir ): """NotifyWorkflows() Templating - absent or empty attachments fails.""" template = tmpdir.join("no_attachments.json") # Missing 'attachments' key entirely template.write('{"type": "message"}') obj = Apprise.instantiate(f"{workflows_url}/?template={template!s}") assert isinstance(obj, NotifyWorkflows) assert ( bool( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) ) is False ) assert request_mock.called is False # Empty list is also rejected template.write('{"type": "message", "attachments": []}') obj2 = Apprise.instantiate(f"{workflows_url}/?template={template!s}") assert isinstance(obj2, NotifyWorkflows) assert ( bool( obj2.notify( body="body", title="title", notify_type=NotifyType.INFO ) ) is False ) assert request_mock.called is False def test_plugin_workflows_templating_invalid_contenttype( request_mock, workflows_url, tmpdir ): """NotifyWorkflows() Templating - attachment missing contentType fails.""" template = tmpdir.join("no_contenttype.json") template.write('{"type": "message", "attachments": [{"content": {}}]}') obj = Apprise.instantiate(f"{workflows_url}/?template={template!s}") assert isinstance(obj, NotifyWorkflows) # An attachment without a 'contentType' string is rejected assert ( bool( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) ) is False ) assert request_mock.called is False @pytest.mark.skipif( sys.version_info < (3, 10), reason="Python <3.10: see test_plugin_workflows_template_add_failure_py39", ) def test_plugin_workflows_template_add_failure(): """NotifyWorkflows() rejects an attachment it cannot add.""" # Simulate add() silently failing (len stays 0); no HTTP call needed with mock.patch("apprise.plugins.workflows.AppriseAttachment") as mock_cls: inst = mock.MagicMock() inst.__len__ = mock.Mock(return_value=0) mock_cls.return_value = inst with pytest.raises(AppriseImproperlyConfigured): NotifyWorkflows( workflow="T00000000001", signature="AbCdEfGhIjKlMnOpQrStUvWx", template="file:///some/template.json", ) # --- Python v3.9 Support --- # On Python 3.9, mock.patch() resolves a dotted target ("a.b.c.Name") by # walking getattr(pkg, "sub") chains rather than going straight to # sys.modules (that was fixed in Python 3.10+). # Delete this test once Python 3.9 reaches end-of-life. @pytest.mark.skipif( sys.version_info >= (3, 10), reason="Python 3.10+ mock.patch resolves via sys.modules directly", ) def test_plugin_workflows_template_add_failure_py39_compat(): """The attachment rejection remains compatible with Python 3.9.""" import importlib # Resolve the live module so patch.object and the class share the same # module dict regardless of any sys.modules manipulation by earlier tests. wf_mod = importlib.import_module("apprise.plugins.workflows") _NotifyWorkflows = wf_mod.NotifyWorkflows # Simulate add() silently failing (len stays 0); no HTTP call needed with mock.patch.object(wf_mod, "AppriseAttachment") as mock_cls: inst = mock.MagicMock() inst.__len__ = mock.Mock(return_value=0) mock_cls.return_value = inst with pytest.raises(AppriseImproperlyConfigured): _NotifyWorkflows( workflow="T00000000001", signature="AbCdEfGhIjKlMnOpQrStUvWx", template="file:///some/template.json", ) # --- End Python v3.9 Compat --- def test_plugin_workflows_templating_content_not_dict( request_mock, workflows_url, tmpdir ): """NotifyWorkflows() Templating - template that parses to a JSON array is rejected cleanly.""" # Valid JSON but a list rather than an object; no HTTP call is made template = tmpdir.join("array.json") template.write('[{"type": "message"}]') obj = Apprise.instantiate(f"{workflows_url}/?template={template!s}") assert isinstance(obj, NotifyWorkflows) assert ( bool( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) ) is False ) assert request_mock.called is False def test_plugin_workflows_templating_attachment_not_dict( request_mock, workflows_url, tmpdir ): """NotifyWorkflows() Templating - non-dict in attachments rejected.""" # attachments list contains a string rather than a dict; no HTTP call made template = tmpdir.join("bad_attach.json") template.write('{"type": "message", "attachments": ["not-a-dict"]}') obj = Apprise.instantiate(f"{workflows_url}/?template={template!s}") assert isinstance(obj, NotifyWorkflows) assert ( bool( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) ) is False ) assert request_mock.called is False def test_plugin_workflows_templating_none_token_value( request_mock, workflows_url, tmpdir ): """NotifyWorkflows() Templating - None token value (e.g. app_image_url) is coerced to empty string before JSON-escaping.""" # Template references app_image_url which will be None when # include_image=False; old code produced corrupted JSON ("ul" from None) template = tmpdir.join("img_ref.json") template.write( '{"type": "message", "attachments": [{' '"contentType": "application/vnd.microsoft.card.adaptive",' '"content": {"body": [{"type": "TextBlock",' '"text": "{{app_body}} img={{app_image_url}}"}]}}]}' ) # image=no forces app_image_url to None, exercising the None->"" # coercion in safe_tokens obj = Apprise.instantiate( f"{workflows_url}/?image=no&template={template!s}" ) assert isinstance(obj, NotifyWorkflows) # Must succeed -- None coerced to "" keeps the JSON valid assert ( bool(obj.notify(body="hello", title="t", notify_type=NotifyType.INFO)) is True ) assert request_mock.called is True posted_data = json.loads(request_mock.call_args_list[0][1]["data"]) body_text = posted_data["attachments"][0]["content"]["body"][0]["text"] # app_image_url should expand to "" not "ul" assert "img=" in body_text assert "ul" not in body_text def test_workflows_yaml_config_missing_template_filename( request_mock, workflows_url, simple_template, tmpdir ): """ NotifyWorkflows() YAML Configuration Entries - Missing template reference. """ config = tmpdir.join("workflow01.yml") config.write( cleandoc(f""" urls: - {workflows_url}: - tag: 'workflow' template: {simple_template!s}.missing :name: 'Template.Missing' :body: 'test body' :title: 'test title' """) ) # Config still loads okay cfg = AppriseConfig() cfg.add(str(config)) assert len(cfg) == 1 assert len(cfg[0]) == 1 obj = cfg[0][0] assert isinstance(obj, NotifyWorkflows) # However we can't send notification since the template couldn't be loaded assert ( bool( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) ) is False ) assert request_mock.called is False def test_plugin_workflows_edge_cases(): """NotifyWorkflows() Edge Cases.""" # Initializes the plugin with an invalid token with pytest.raises(AppriseImproperlyConfigured): NotifyWorkflows(workflow="@", signature="@") with pytest.raises(AppriseImproperlyConfigured): NotifyWorkflows(workflow="", signature="abcd") with pytest.raises(AppriseImproperlyConfigured): NotifyWorkflows(workflow=None, signature="abcd") # Whitespace also acts as an invalid token value with pytest.raises(AppriseImproperlyConfigured): NotifyWorkflows(workflow=" ", signature="abcd") with pytest.raises(AppriseImproperlyConfigured): NotifyWorkflows(workflow="abcd", signature=None) # Whitespace also acts as an invalid token value with pytest.raises(AppriseImproperlyConfigured): NotifyWorkflows(workflow="abcd", signature=" ") # test case where invalid tokens are specified with pytest.raises(AppriseImproperlyConfigured): NotifyWorkflows( workflow="workflow", signature="signature", tokens="not-a-dict" ) # test case where no tokens are specified obj = NotifyWorkflows(workflow="workflow", signature="signature") assert isinstance(obj, NotifyWorkflows) def test_plugin_workflows_azure_webhooks(request_mock): """NotifyWorkflows() Azure Webhooks.""" url = ( "https://prod-15.uksouth.logic.azure.com:443" "/workflows/3XXX5/triggers/manual/paths/invoke" "?api-version=2016-06-01&" "sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=iXXXU" ) # # Initialize # obj = Apprise.instantiate(url) assert isinstance(obj, NotifyWorkflows) assert obj.workflow == "3XXX5" assert obj.signature == "iXXXU" assert obj.api_version == "2016-06-01" @pytest.mark.parametrize("routing_id", (123456789, 987654321)) def test_plugin_workflows_power_automate_cu_webhooks(request_mock, routing_id): """NotifyWorkflows() preserves Power Automate CU routing IDs.""" url = ( "https://default.environment.api.powerplatform.com:443" f"/powerautomate/automations/direct/cu/{routing_id}" "/workflows/3XXX5/triggers/manual/paths/invoke" "?api-version=2022-03-01-preview&" "sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=iXXXU" ) obj = Apprise.instantiate(url) assert isinstance(obj, NotifyWorkflows) assert obj.notify(body="body", title="title") is True assert request_mock.call_args.args[0] == ( "https://default.environment.api.powerplatform.com:443" f"/powerautomate/automations/direct/cu/{routing_id}" "/workflows/3XXX5/triggers/manual/paths/invoke" ) # Our routing ID is tracked and advertised in the Apprise URL assert obj.routing_id == str(routing_id) assert f"route={routing_id}" in obj.url() @pytest.mark.parametrize("key", ("route", "routeid")) def test_plugin_workflows_routing_id_parse_url(key): """NotifyWorkflows() parses the routing ID from an Apprise URL.""" results = NotifyWorkflows.parse_url( f"workflow://host/3XXX5/iXXXU/?pa=yes&{key}=123456789" ) assert isinstance(results, dict) assert results["routing_id"] == "123456789" obj = Apprise.instantiate( f"workflow://host/3XXX5/iXXXU/?pa=yes&{key}=123456789" ) assert isinstance(obj, NotifyWorkflows) assert obj.routing_id == "123456789" # No routing ID provided; nothing is set and nothing is advertised obj = Apprise.instantiate("workflow://host/3XXX5/iXXXU/?pa=yes") assert isinstance(obj, NotifyWorkflows) assert obj.routing_id is None assert "route=" not in obj.url() def test_plugin_workflows_routing_id_invalid(): """NotifyWorkflows() rejects a non-numeric routing ID.""" with pytest.raises(AppriseImproperlyConfigured): NotifyWorkflows( workflow="3XXX5", signature="iXXXU", routing_id="1/../evil" ) # Confirmed unusable (and therefore never reflected) via a full URL too assert ( Apprise.instantiate( "workflow://host/3XXX5/iXXXU/?pa=yes&route=1/../evil" ) is None ) def test_plugin_workflows_routing_id_round_trip(request_mock): """NotifyWorkflows() round-trips the routing ID through url().""" obj = Apprise.instantiate( "https://default.environment.api.powerplatform.com:443" "/powerautomate/automations/direct/cu/123456789" "/workflows/3XXX5/triggers/manual/paths/invoke" "?api-version=2022-03-01-preview&" "sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=iXXXU" ) assert isinstance(obj, NotifyWorkflows) assert obj.routing_id == "123456789" # Re-instantiate ourselves from our own generated URL obj_copy = Apprise.instantiate(obj.url()) assert isinstance(obj_copy, NotifyWorkflows) assert obj_copy.routing_id == obj.routing_id assert obj_copy.url() == obj.url() # The routed endpoint survives the round trip assert obj_copy.notify(body="body", title="title") is True assert request_mock.call_args.args[0] == ( "https://default.environment.api.powerplatform.com:443" "/powerautomate/automations/direct/cu/123456789" "/workflows/3XXX5/triggers/manual/paths/invoke" ) @mock.patch("requests.post") def test_plugin_workflows_html_to_markdown_format(mock_post): """NotifyWorkflows(): HTML body is converted to Markdown.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Workflows' notify_format is MARKDOWN by default aobj = Apprise() assert aobj.add("workflow://hostname/workflow1/signature1/?image=no") # Notify with an HTML body; the framework should convert it # to Markdown before dispatching to Workflows assert ( bool( aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) ) is True ) assert mock_post.call_count == 1 # The body must arrive as Markdown inside the adaptive card body block, # not as stripped plain text payload = json.loads(mock_post.call_args_list[0][1]["data"]) body_blocks = payload["attachments"][0]["content"]["body"] body_text = next(b["text"] for b in body_blocks if b.get("id") == "body") assert body_text == "**hello** *world*" @mock.patch("requests.post") def test_plugin_workflows_mention_entities(mock_post): """Teams mention tags populate unique ``msteams.entities`` entries.""" # Prepare a successful mocked HTTP response. mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok aobj = Apprise() assert aobj.add("workflow://hostname/workflow1/signature1/?image=no") # Keep Teams metadata minimal when the body contains no mentions. assert bool(aobj.notify(body="Hello world", title="T")) is True payload = json.loads(mock_post.call_args_list[0][1]["data"]) msteams = payload["attachments"][0]["content"]["msteams"] assert msteams == {"width": "full"} mock_post.reset_mock() # Add one entity for a single mention. assert ( bool( aobj.notify( body="Hello foo@example.com!", title="T", ) ) is True ) payload = json.loads(mock_post.call_args_list[0][1]["data"]) msteams = payload["attachments"][0]["content"]["msteams"] assert msteams == { "width": "full", "entities": [ { "type": "mention", "text": "foo@example.com", "mentioned": { "id": "foo@example.com", "name": "foo@example.com", }, } ], } mock_post.reset_mock() # Preserve source order for distinct mentions. assert ( bool( aobj.notify( body=( "Hi alice@example.com and" " bob@example.com!" ), title="T", ) ) is True ) payload = json.loads(mock_post.call_args_list[0][1]["data"]) entities = payload["attachments"][0]["content"]["msteams"]["entities"] assert len(entities) == 2 assert entities[0]["mentioned"]["id"] == "alice@example.com" assert entities[1]["mentioned"]["id"] == "bob@example.com" mock_post.reset_mock() # Deduplicate repeated mention IDs. assert ( bool( aobj.notify( body=( "dup@example.com and" " dup@example.com again" ), title="T", ) ) is True ) payload = json.loads(mock_post.call_args_list[0][1]["data"]) entities = payload["attachments"][0]["content"]["msteams"]["entities"] assert len(entities) == 1 assert entities[0]["mentioned"]["id"] == "dup@example.com" mock_post.reset_mock() # Recover the valid inner tag from a nested opening sequence. assert ( bool( aobj.notify( body="Hi user@example.com!", title="T", ) ) is True ) payload = json.loads(mock_post.call_args_list[0][1]["data"]) entities = payload["attachments"][0]["content"]["msteams"]["entities"] assert len(entities) == 1 assert entities[0]["mentioned"]["id"] == "user@example.com" mock_post.reset_mock() # Recover an inner mention when text invalidates the outer tag. assert ( bool( aobj.notify( body="garbageinner@example.com", title="T", ) ) is True ) payload = json.loads(mock_post.call_args_list[0][1]["data"]) entities = payload["attachments"][0]["content"]["msteams"]["entities"] assert len(entities) == 1 assert entities[0]["mentioned"]["id"] == "inner@example.com" mock_post.reset_mock() # Ignore empty, whitespace-only, and unclosed mention tags. for bad_body in ("", " ", "unclosed"): assert bool(aobj.notify(body=bad_body, title="T")) is True payload = json.loads(mock_post.call_args_list[-1][1]["data"]) msteams = payload["attachments"][0]["content"]["msteams"] assert msteams == {"width": "full"}, ( f"expected no entities for {bad_body!r}" ) mock_post.reset_mock() # Preserve mixed-case entity text for Teams' verbatim body match. assert ( bool( aobj.notify( body="Hi user@example.com!", title="T", ) ) is True ) payload = json.loads(mock_post.call_args_list[0][1]["data"]) entity = payload["attachments"][0]["content"]["msteams"]["entities"][0] assert entity["text"] == "user@example.com" assert entity["mentioned"]["id"] == "user@example.com" mock_post.reset_mock() # Preserve tag padding while trimming the ID used for user resolution. assert ( bool( aobj.notify( body="Hi padded@example.com !", title="T", ) ) is True ) payload = json.loads(mock_post.call_args_list[0][1]["data"]) entity = payload["attachments"][0]["content"]["msteams"]["entities"][0] assert entity["text"] == " padded@example.com " assert entity["mentioned"]["id"] == "padded@example.com" caronc-apprise-dfec414/tests/test_plugin_wpush.py000066400000000000000000000401341526720371000224200ustar00rootroot00000000000000# # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from json import dumps, loads import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.wpush import ( WPUSH_CHANNEL_DEFAULT, NotifyWPush, WPushChannel, ) logging.disable(logging.CRITICAL) # WPUSH + exactly 27 more alphanumeric characters (32 total) GOOD_KEY = "WPUSH" + ("a" * 27) GOOD_RESPONSE = dumps( {"code": 0, "message": "success", "data": "1126950958891274240"} ) BAD_RESPONSE = dumps({"code": 401, "message": "API Key error"}) apprise_url_tests = ( ( "wpush://", {"instance": AppriseImproperlyConfigured}, ), ( "wpush://short", {"instance": AppriseImproperlyConfigured}, ), ( "wpush://{}".format(GOOD_KEY), { "instance": NotifyWPush, "privacy_url": "wpush://****/", "requests_response_text": GOOD_RESPONSE, }, ), ( "wpush://?apikey={}".format(GOOD_KEY), { "instance": NotifyWPush, "privacy_url": "wpush://****/", "requests_response_text": GOOD_RESPONSE, }, ), ( "wpush://{}?channel=feishu".format(GOOD_KEY), { "instance": NotifyWPush, "requests_response_text": GOOD_RESPONSE, }, ), ( "wpush://{}?channel=feishu,dingtalk,qqbot".format(GOOD_KEY), { "instance": NotifyWPush, "requests_response_text": GOOD_RESPONSE, }, ), ( "wpush://{}/topic1/topic2".format(GOOD_KEY), { "instance": NotifyWPush, "requests_response_text": GOOD_RESPONSE, }, ), ( "wpush://{}?to=topic1,topic2".format(GOOD_KEY), { "instance": NotifyWPush, "requests_response_text": GOOD_RESPONSE, }, ), ( "wpush://{}?topic_code=mytopic".format(GOOD_KEY), { "instance": NotifyWPush, "requests_response_text": GOOD_RESPONSE, }, ), ( "wpush://{}?topic=mytopic".format(GOOD_KEY), { "instance": NotifyWPush, "requests_response_text": GOOD_RESPONSE, }, ), ( "wpush://{}?channel=qqbot&group=123456".format(GOOD_KEY), { "instance": NotifyWPush, "requests_response_text": GOOD_RESPONSE, }, ), ( "wpush://{}?channel=qqbot&option=123456".format(GOOD_KEY), { "instance": NotifyWPush, "requests_response_text": GOOD_RESPONSE, }, ), ( "wpush://{}?url=https://example.com/".format(GOOD_KEY), { "instance": NotifyWPush, "requests_response_text": GOOD_RESPONSE, }, ), ( "wpush://{}?channel=nope".format(GOOD_KEY), {"instance": AppriseImproperlyConfigured}, ), ( "wpush://{}?group=123456&topic_code=mytopic".format(GOOD_KEY), {"instance": AppriseImproperlyConfigured}, ), ( "https://api.wpush.cn/api/v1/send?apikey={}".format(GOOD_KEY), { "instance": NotifyWPush, "requests_response_text": GOOD_RESPONSE, }, ), ( "wpush://{}".format(GOOD_KEY), { "instance": NotifyWPush, "response": False, "requests_response_text": BAD_RESPONSE, }, ), ( "wpush://{}".format(GOOD_KEY), { "instance": NotifyWPush, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "wpush://{}".format(GOOD_KEY), { "instance": NotifyWPush, "test_requests_exceptions": True, }, ), ) def test_plugin_wpush_urls(): """NotifyWPush() Apprise URL test suite.""" AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_wpush_init(): """NotifyWPush() object initialization.""" obj = NotifyWPush(apikey=GOOD_KEY) assert obj.apikey == GOOD_KEY assert obj.channels == [WPUSH_CHANNEL_DEFAULT] assert obj.topics == [] assert obj.group is None assert obj.click_url is None assert len(obj) == 1 obj = NotifyWPush(apikey=GOOD_KEY, channel="mail") assert obj.channels == [WPushChannel.MAIL] # Channels are stored once in alphabetical order obj = NotifyWPush(apikey=GOOD_KEY, channel="feishu,dingtalk,qqbot") assert obj.channels == [ WPushChannel.DINGTALK, WPushChannel.FEISHU, WPushChannel.QQBOT, ] obj = NotifyWPush(apikey=GOOD_KEY, targets=["topic1", "topic2"]) assert obj.topics == ["topic1", "topic2"] assert len(obj) == 2 obj = NotifyWPush(apikey=GOOD_KEY, channel="qqbot", group="123456") assert obj.group == "123456" # Channel matching ignores case and duplicates obj = NotifyWPush(apikey=GOOD_KEY, channel="Feishu,dingtalk,feishu") assert obj.channels == [WPushChannel.DINGTALK, WPushChannel.FEISHU] obj = NotifyWPush(apikey=GOOD_KEY, click_url="https://example.com/") assert obj.click_url == "https://example.com/" with pytest.raises(AppriseImproperlyConfigured): NotifyWPush(apikey=GOOD_KEY, channel="nope") with pytest.raises(AppriseImproperlyConfigured): NotifyWPush(apikey=GOOD_KEY, channel="feishu,nope") with pytest.raises(AppriseImproperlyConfigured): NotifyWPush(apikey="bad") with pytest.raises(AppriseImproperlyConfigured): # Group and topic targets cannot be combined NotifyWPush(apikey=GOOD_KEY, group="123456", targets=["topic1"]) def test_plugin_wpush_url_round_trip(): """NotifyWPush() url() round trip through parse_url().""" obj = NotifyWPush( apikey=GOOD_KEY, channel="feishu,dingtalk", targets=["topic1", "topic2"], click_url="https://example.com/", ) results = NotifyWPush.parse_url(obj.url()) obj2 = NotifyWPush(**results) assert obj.url_identifier == obj2.url_identifier assert obj.topics == obj2.topics # Channels are stored once in alphabetical order assert obj2.channels == [WPushChannel.DINGTALK, WPushChannel.FEISHU] assert obj2.click_url == "https://example.com/" # Round trip a QQ Robot group without a topic obj3 = NotifyWPush(apikey=GOOD_KEY, channel="qqbot", group="123456") results3 = NotifyWPush.parse_url(obj3.url()) obj4 = NotifyWPush(**results3) assert obj4.group == "123456" assert obj4.channels == [WPushChannel.QQBOT] @mock.patch("requests.post") def test_plugin_wpush_send_ok(mock_post): """Send once when no topic is set.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = GOOD_RESPONSE.encode("utf-8") mock_post.return_value = response obj = NotifyWPush(apikey=GOOD_KEY) assert obj.send(body="hello", title="title") is True assert mock_post.call_count == 1 assert mock_post.call_args[0][0] == "https://api.wpush.cn/api/v1/send" assert mock_post.call_args[1]["headers"]["X-API-Key"] == GOOD_KEY assert "X-Idempotency-Key" in mock_post.call_args[1]["headers"] payload = loads(mock_post.call_args[1]["data"]) assert payload["apikey"] == GOOD_KEY assert payload["title"] == "title" assert payload["content"] == "hello" assert payload["channel"] == WPUSH_CHANNEL_DEFAULT assert "topic_code" not in payload assert "option" not in payload assert "url" not in payload mock_post.reset_mock() assert obj.send(body="only body") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["title"] == "only body" @mock.patch("requests.post") def test_plugin_wpush_send_multi_channel(mock_post): """Send to several channels in one request.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = GOOD_RESPONSE.encode("utf-8") mock_post.return_value = response # The payload keeps the stored alphabetical order obj = NotifyWPush(apikey=GOOD_KEY, channel="feishu,dingtalk,qqbot") assert obj.send(body="msg", title="t") is True assert mock_post.call_count == 1 payload = loads(mock_post.call_args[1]["data"]) assert payload["channel"] == "dingtalk,feishu,qqbot" @mock.patch("requests.post") def test_plugin_wpush_send_topic_and_channel(mock_post): """Send to one topic through a selected channel.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = GOOD_RESPONSE.encode("utf-8") mock_post.return_value = response obj = NotifyWPush(apikey=GOOD_KEY, channel="feishu", targets="abc123") assert obj.send(body="msg", title="t") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["channel"] == "feishu" assert payload["topic_code"] == "abc123" @mock.patch("requests.post") def test_plugin_wpush_send_qqbot_group(mock_post): """NotifyWPush() includes API option whenever group/option is set.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = GOOD_RESPONSE.encode("utf-8") mock_post.return_value = response # Send the group code for QQ Robot obj = NotifyWPush(apikey=GOOD_KEY, channel="qqbot", group="123456") assert obj.send(body="msg") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["option"] == "123456" # Other supported channels use option to select a bound instance mock_post.reset_mock() obj = NotifyWPush(apikey=GOOD_KEY, channel="mail", group="123456") assert obj.send(body="msg") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["option"] == "123456" @mock.patch("requests.post") def test_plugin_wpush_send_click_url(mock_post): """Include an optional click-through URL.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = GOOD_RESPONSE.encode("utf-8") mock_post.return_value = response obj = NotifyWPush(apikey=GOOD_KEY, click_url="https://example.com/") assert obj.send(body="msg") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["url"] == "https://example.com/" @mock.patch("requests.post") def test_plugin_wpush_send_multiple_topics(mock_post): """NotifyWPush() sends once per topic and reports partial failure.""" ok_response = mock.Mock() ok_response.status_code = requests.codes.ok ok_response.content = GOOD_RESPONSE.encode("utf-8") bad_response = mock.Mock() bad_response.status_code = requests.codes.ok bad_response.content = BAD_RESPONSE.encode("utf-8") # Capture each key while the shared headers dictionary changes seen_keys = [] responses = [ok_response, bad_response] def _side_effect(*args, **kwargs): seen_keys.append(kwargs["headers"]["X-Idempotency-Key"]) return responses[len(seen_keys) - 1] mock_post.side_effect = _side_effect obj = NotifyWPush(apikey=GOOD_KEY, targets=["topic1", "topic2"]) assert obj.send(body="msg") is False assert mock_post.call_count == 2 # Each request must use a different key assert seen_keys[0] != seen_keys[1] @mock.patch("requests.post") def test_plugin_wpush_send_api_error(mock_post): """Handle a WPUSH error returned with HTTP 200.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = BAD_RESPONSE.encode("utf-8") mock_post.return_value = response obj = NotifyWPush(apikey=GOOD_KEY) assert obj.send(body="msg") is False @mock.patch("requests.post") def test_plugin_wpush_bad_json(mock_post): """Handle a response that is not JSON.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"not-json" mock_post.return_value = response obj = NotifyWPush(apikey=GOOD_KEY) assert obj.send(body="msg") is False @mock.patch("requests.post") def test_plugin_wpush_apprise_integration(mock_post): """Send through Apprise.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = GOOD_RESPONSE.encode("utf-8") mock_post.return_value = response aobj = Apprise() assert aobj.add("wpush://{}".format(GOOD_KEY)) assert bool(aobj.notify(title="T", body="B")) is True assert mock_post.call_count == 1 def test_plugin_wpush_instance_options(): """Send instance codes through the option field.""" from json import loads for channel in ("feishu", "dingtalk", "webhook", "wechat_work"): obj = Apprise.instantiate( "wpush://{}?channel={}&option=ops".format(GOOD_KEY, channel) ) assert isinstance(obj, NotifyWPush) assert obj.group == "ops" with mock.patch("requests.post") as mock_post: mock_post.return_value = mock.Mock( status_code=200, content=GOOD_RESPONSE.encode("utf-8"), ) assert obj.notify(title="t", body="b") is True assert mock_post.called payload = loads(mock_post.call_args[1]["data"].decode("utf-8")) assert payload.get("option") == "ops" assert payload.get("channel") == channel def test_plugin_wpush_parse_native_url(): """Parse supported and invalid native URLs.""" # Parse the API Key result = NotifyWPush.parse_native_url( "https://api.wpush.cn/api/v1/send?apikey={}".format(GOOD_KEY) ) assert result is not None obj = NotifyWPush(**result) assert obj.apikey == GOOD_KEY # Preserve the channel parameter result = NotifyWPush.parse_native_url( "https://api.wpush.cn/api/v1/send?apikey={}&channel=mail".format( GOOD_KEY ) ) assert result is not None obj = NotifyWPush(**result) assert obj.channels == [WPushChannel.MAIL] # Reject a URL without an API Key assert ( NotifyWPush.parse_native_url( "https://api.wpush.cn/api/v1/send?channel=mail" ) is None ) # Reject other domains assert ( NotifyWPush.parse_native_url( "https://other.example.com/api/v1/send?apikey={}".format(GOOD_KEY) ) is None ) # Reject a URL without query parameters assert ( NotifyWPush.parse_native_url("https://api.wpush.cn/api/v1/send") is None ) def test_plugin_wpush_rejects_boolean_code(): """Reject Boolean codes even though False equals zero in Python.""" obj = Apprise.instantiate("wpush://{}".format(GOOD_KEY)) with mock.patch("requests.post") as mock_post: mock_post.return_value = mock.Mock( status_code=200, content=dumps({"code": False, "message": "nope"}).encode("utf-8"), ) assert obj.notify(title="t", body="b") is False caronc-apprise-dfec414/tests/test_plugin_wxpusher.py000066400000000000000000000306001526720371000231340ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps, loads # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.wxpusher import NotifyWxPusher logging.disable(logging.CRITICAL) WXPUSHER_GOOD_RESPONSE = dumps({"code": 1000}) WXPUSHER_BAD_RESPONSE = dumps({"code": 99}) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "wxpusher://", { # No token specified "instance": AppriseImproperlyConfigured, }, ), ( "wxpusher://:@/", { # invalid url "instance": AppriseImproperlyConfigured, }, ), ( "wxpusher://invalid", { # invalid app token "instance": AppriseImproperlyConfigured, }, ), ( "wxpusher://AT_appid/123/", { # invalid 'to' phone number "instance": NotifyWxPusher, # Notify will fail because it couldn't send to anyone "response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxpusher://****/123/", # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/%20/%20/", { # invalid 'to' phone number "instance": NotifyWxPusher, # Notify will fail because it couldn't send to anyone "response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxpusher://****/", # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/123/", { # one phone number will notify ourselves "instance": NotifyWxPusher, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://123?token=AT_abc1234", { # pass our token in as an argument and our host actually becomes a # target "instance": NotifyWxPusher, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://?token=AT_abc1234", { # slightly different then test above; a token is defined, but # there are no targets "instance": NotifyWxPusher, # Notify will fail because it couldn't send to anyone "response": False, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://?token=AT_abc1234&to=UID_abc", { # all kwargs to load url with "instance": NotifyWxPusher, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/UID_abcd/", { # a valid contact "instance": NotifyWxPusher, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxpusher://****/UID_abcd", # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/@/#/,/", { # Test case where we provide bad data "instance": NotifyWxPusher, # Our failed response "requests_response_text": WXPUSHER_GOOD_RESPONSE, # as a result, we expect a failed notification "response": False, }, ), ( "wxpusher://AT_appid/123/", { # Test case where we get a bad response "instance": NotifyWxPusher, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxpusher://****/123", # Our failed response "requests_response_text": WXPUSHER_BAD_RESPONSE, # as a result, we expect a failed notification "response": False, }, ), ( "wxpusher://AT_appid/UID_345/", { # Test case where we get a bad response "instance": NotifyWxPusher, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxpusher://****/UID_345", # Our failed response "requests_response_text": None, # as a result, we expect a failed notification "response": False, }, ), ( "wxpusher://AT_appid/UID_345/", { # Test case where we get a bad response "instance": NotifyWxPusher, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxpusher://****/UID_345", # Our failed response (bad json) "requests_response_text": "{", # as a result, we expect a failed notification "response": False, }, ), ( "wxpusher://AT_appid/?to={},{}".format("2" * 11, "3" * 11), { # use get args to acomplish the same thing "instance": NotifyWxPusher, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/?to={},{},{}".format("2" * 11, "3" * 11, "5" * 3), { # 2 good targets and one invalid one "instance": NotifyWxPusher, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/{}/{}/".format("2" * 11, "3" * 11), { # If we have from= specified, then all elements take on the # to= value "instance": NotifyWxPusher, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/{}".format("3" * 11), { # use get args to acomplish the same thing (use source instead # of from) "instance": NotifyWxPusher, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/{}".format("4" * 11), { "instance": NotifyWxPusher, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/{}".format("4" * 11), { "instance": NotifyWxPusher, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_wxpusher_urls(): """NotifyWxPusher() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_wxpusher_edge_cases(mock_post): """NotifyWxPusher() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok response.content = WXPUSHER_GOOD_RESPONSE # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) tokens target = "UID_abcd" body = "test body" title = "My Title" aobj = Apprise() assert aobj.add(f"wxpusher://AT_appid/{target}") assert len(aobj) == 1 assert aobj.notify(title=title, body=body) assert mock_post.call_count == 1 details = mock_post.call_args_list[0] assert details[0][0] == "https://wxpusher.zjiecode.com/api/send/message" payload = loads(details[1]["data"]) assert payload == { "appToken": "AT_appid", "content": "test body", "summary": "My Title", "contentType": 1, "topicIds": [], "uids": ["UID_abcd"], "url": None, } # Reset our mock object mock_post.reset_mock() @mock.patch("requests.post") def test_plugin_wxpusher_result_set(mock_post): """NotifyWxPusher() Result Sets.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok response.content = WXPUSHER_GOOD_RESPONSE # Prepare Mock mock_post.return_value = response body = "test body" title = "My Title" aobj = Apprise() aobj.add("wxpusher://AT_appid/123/abc/UID_456") # One bad entry and 2 good assert len(aobj[0]) == 1 assert aobj.notify(title=title, body=body) # 2 posts made assert mock_post.call_count == 1 details = mock_post.call_args_list[0] assert details[0][0] == "https://wxpusher.zjiecode.com/api/send/message" payload = loads(details[1]["data"]) assert payload == { "appToken": "AT_appid", "content": "test body", "summary": "My Title", "contentType": 1, "topicIds": [123], "uids": ["UID_456"], "url": None, } # Validate our information is also placed back into the assembled URL assert "/123" in aobj[0].url() assert "/UID_456" in aobj[0].url() assert "/abc" in aobj[0].url() mock_post.reset_mock() aobj = Apprise() aobj.add("wxpusher://AT_appid//UID_123/UID_abc/123456789") assert len(aobj[0]) == 1 assert aobj.notify(title=title, body=body) # If batch is off then there is a post per entry assert mock_post.call_count == 1 details = mock_post.call_args_list[0] assert details[0][0] == "https://wxpusher.zjiecode.com/api/send/message" payload = loads(details[1]["data"]) assert payload == { "appToken": "AT_appid", "content": "test body", "summary": "My Title", "contentType": 1, "topicIds": [123456789], "uids": ["UID_123", "UID_abc"], "url": None, } assert "/123456789" in aobj[0].url() assert "/UID_123" in aobj[0].url() assert "/UID_abc" in aobj[0].url() @mock.patch("requests.post") def test_notify_wxpusher_plugin_result_list(mock_post): """NotifyWxPusher() Result List Response.""" okay_response = requests.Request() okay_response.status_code = requests.codes.ok # We want to test the case where the `result` set returned is a list # Invalid JSON response okay_response.content = "{" # Assign our mock object our return value mock_post.return_value = okay_response obj = Apprise.instantiate("wxpusher://AT_apptoken/UID_abcd/") assert isinstance(obj, NotifyWxPusher) # We should now fail assert bool(obj.notify("test")) is False caronc-apprise-dfec414/tests/test_plugin_xbmc_kodi.py000066400000000000000000000170201526720371000232070ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import requests from apprise import NotifyType from apprise.plugins.kodi import NotifyXBMC logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "kodi://", { "instance": None, }, ), ( "kodis://", { "instance": None, }, ), ( "kodi://localhost", { "instance": NotifyXBMC, }, ), ( "kodi://192.168.4.1", { # Support IPv4 Addresses "instance": NotifyXBMC, }, ), ( "kodi://[2001:db8:002a:3256:adfe:05c0:0003:0006]", { # Support IPv6 Addresses "instance": NotifyXBMC, # Privacy URL "privacy_url": "kodi://[2001:db8:002a:3256:adfe:05c0:0003:0006]", }, ), ( "kodi://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8282", { # Support IPv6 Addresses with port "instance": NotifyXBMC, # Privacy URL "privacy_url": ( "kodi://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8282" ), }, ), ( "kodi://user:pass@localhost", { "instance": NotifyXBMC, # Our expected url(privacy=True) startswith() response: "privacy_url": "kodi://user:****@localhost", }, ), ( "kodi://localhost:8080", { "instance": NotifyXBMC, }, ), ( "kodi://user:pass@localhost:8080", { "instance": NotifyXBMC, }, ), ( "kodis://localhost", { "instance": NotifyXBMC, }, ), ( "kodis://user:pass@localhost", { "instance": NotifyXBMC, }, ), ( "kodis://localhost:8080/path/", { "instance": NotifyXBMC, }, ), ( "kodis://user:password@localhost:8080", { "instance": NotifyXBMC, # Our expected url(privacy=True) startswith() response: "privacy_url": "kodis://user:****@localhost:8080", }, ), ( "kodi://localhost", { "instance": NotifyXBMC, # Experement with different notification types "notify_type": NotifyType.WARNING, }, ), ( "kodi://localhost", { "instance": NotifyXBMC, # Experement with different notification types "notify_type": NotifyType.FAILURE, }, ), ( "kodis://localhost:443", { "instance": NotifyXBMC, # don't include an image by default "include_image": False, }, ), ( "kodi://:@/", { "instance": None, }, ), ( "kodi://user:pass@localhost:8081", { "instance": NotifyXBMC, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "kodi://user:pass@localhost:8082", { "instance": NotifyXBMC, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "kodi://user:pass@localhost:8083", { "instance": NotifyXBMC, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), # # XMBC (Legacy Platform) Shares this same KODI Plugin # ( "xbmc://", { "instance": None, }, ), ( "xbmc://localhost", { "instance": NotifyXBMC, }, ), ( "xbmc://localhost?duration=14", { "instance": NotifyXBMC, }, ), ( "xbmc://localhost?duration=invalid", { "instance": NotifyXBMC, }, ), ( "xbmc://localhost?duration=-1", { "instance": NotifyXBMC, }, ), ( "xbmc://user:pass@localhost", { "instance": NotifyXBMC, }, ), ( "xbmc://localhost:8080", { "instance": NotifyXBMC, }, ), ( "xbmc://user:pass@localhost:8080", { "instance": NotifyXBMC, }, ), ( "xbmc://user@localhost", { "instance": NotifyXBMC, # don't include an image by default "include_image": False, }, ), ( "xbmc://localhost", { "instance": NotifyXBMC, # Experement with different notification types "notify_type": NotifyType.WARNING, }, ), ( "xbmc://localhost", { "instance": NotifyXBMC, # Experement with different notification types "notify_type": NotifyType.FAILURE, }, ), ( "xbmc://:@/", { "instance": None, }, ), ( "xbmc://user:pass@localhost:8081", { "instance": NotifyXBMC, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "xbmc://user:pass@localhost:8082", { "instance": NotifyXBMC, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "xbmc://user:pass@localhost:8083", { "instance": NotifyXBMC, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_xbmc_kodi_urls(): """NotifyXBMC() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-dfec414/tests/test_plugin_xmpp.py000066400000000000000000005112001526720371000222330ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """ Unit Tests for the XMPP plugin. """ from __future__ import annotations import asyncio from collections.abc import Iterator import contextlib import gc import logging import ssl import threading import time from types import SimpleNamespace from typing import Any, Optional import pytest from apprise import LOGGER_NAME, Apprise, NotifyType from apprise.exception import AppriseException, AppriseImproperlyConfigured from apprise.plugins.xmpp import adapter as xmpp_adapter, base as xmpp_base from apprise.plugins.xmpp.base import NotifyXMPP def test_plugin_xmpp_channel_binding_exception() -> None: """Channel-binding failures use the common Apprise exception.""" exc = xmpp_adapter.XMPPChannelBindingError() assert isinstance(exc, AppriseException) assert str(exc) == "SASL SCRAM-PLUS channel-binding authentication failed." def run_on_loop(loop: asyncio.AbstractEventLoop, coro: Any) -> Any: """Run a coroutine on a specific event loop.""" asyncio.set_event_loop(loop) return loop.run_until_complete(coro) # --------------------------------------------------------------------------- # Slixmpp Availability Handling # --------------------------------------------------------------------------- try: # Enforce slixmpp >= Minimum Supported Version SLIXMPP_AVAILABLE = xmpp_adapter.SlixmppAdapter.supported_version() except Exception: SLIXMPP_AVAILABLE = False # Seconds to sleep before forcing the timeout branch in _FakeDoneEvent.wait(). # This gives the worker thread enough time to progress to a known checkpoint # (e.g. event loop created, client instantiated) without relying on a longer # wall-clock timeout that would slow tests down. WORKER_THREAD_STARTUP_DELAY: float = 0.02 # --------------------------------------------------------------------------- # Fake Slixmpp Client # --------------------------------------------------------------------------- class FakeClientXMPP: def __init__(self, jid: str, password: str) -> None: self.jid = jid self.password = password # Loop is assigned by adapter via `client.loop = loop`, but we default # to the current event loop for safety in tests. self.loop: Optional[asyncio.AbstractEventLoop] = None self.handlers: dict[str, Any] = {} self.auto_reconnect = True # Slixmpp >= 1.16.0: adapter waits on this Future self.disconnected: Optional[asyncio.Future[bool]] = None # Slixmpp toggles used by adapter self.enable_plaintext = True self.enable_starttls = True self.enable_direct_tls = False self.ssl_context = None # Track plugins registered by the adapter (keepalive path) self.registered_plugins: dict[str, Any] = {} def add_event_handler(self, name: str, handler: Any) -> None: self.handlers[name] = handler def send_presence(self) -> None: return None async def get_roster(self) -> None: return None def send_message(self, **kwargs: Any) -> None: return None def register_plugin(self, name: str, config: Optional[Any] = None) -> None: self.registered_plugins[name] = config return None def disconnect(self) -> None: # Complete the disconnected Future if it is not already done. if self.disconnected is not None and not self.disconnected.done(): self.disconnected.set_result(True) def connect(self, **kwargs: Any) -> asyncio.Future[bool]: """ Slixmpp >= 1.16.0 connect() returns a Future. Our adapter awaits it. This fake schedules session_start on the assigned loop so the adapter's loop.run_until_complete() drives it, and ensures disconnected resolves. """ loop = self.loop or asyncio.get_running_loop() # Ensure disconnected future exists on the correct loop if self.disconnected is None: self.disconnected = loop.create_future() fut: asyncio.Future[bool] = loop.create_future() fut.set_result(True) handler = self.handlers.get("session_start") if handler: def _fire_session_start() -> None: rv = handler() if asyncio.iscoroutine(rv): task = loop.create_task(rv) # Ensure we always disconnect after session_start task.add_done_callback(lambda _: self.disconnect()) else: # sync handler, just disconnect self.disconnect() # Schedule for when the adapter starts running the loop loop.call_soon(_fire_session_start) return fut # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def join_xmpp_threads() -> Iterator[None]: """Wait for any XMPP worker thread a test started to finish.""" # Threads already running belong to someone else. before = set(threading.enumerate()) yield # A worker left running would call into the next test's patches. for thread in set(threading.enumerate()) - before: if thread.name.startswith("apprise-xmpp"): thread.join(timeout=10.0) def install_fake_slixmpp(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(xmpp_adapter, "asyncio", asyncio, raising=False) monkeypatch.setattr( xmpp_adapter, "slixmpp", SimpleNamespace(ClientXMPP=FakeClientXMPP), raising=False, ) monkeypatch.setattr( xmpp_adapter, "SLIXMPP_SUPPORT_AVAILABLE", True, raising=False ) monkeypatch.setattr( xmpp_adapter.SlixmppAdapter, "_enabled", True, raising=False, ) def _patch_threading( monkeypatch: pytest.MonkeyPatch, *, done_event_cls: type ) -> None: """Patch adapter threading to use a custom done-event class.""" import threading as _real_threading class _ThreadingProxy: Event = done_event_cls Thread = _real_threading.Thread def __getattr__(self, name: str) -> Any: return getattr(_real_threading, name) monkeypatch.setattr( xmpp_adapter, "threading", _ThreadingProxy(), raising=True ) # --------------------------------------------------------------------------- # NotifyXMPP Tests # --------------------------------------------------------------------------- def _make_fake_done_event_cls( signal_evt: Optional[threading.Event] = None, ) -> type: """Return a deterministic threading.Event replacement for timeout tests. The adapter under test uses ``threading.Event()`` for *done*, then calls ``done.wait(timeout=...)`` and later ``done.set()`` from the worker thread. These tests want ``wait()`` to return ``False`` deterministically (forcing the timeout branch), while still giving the worker thread a chance to reach a known checkpoint first (e.g. event loop created, client created). Capturing *signal_evt* in a closure rather than as a class-level attribute ensures that each test's event class is fully independent — parallel test runs cannot interfere with one another through shared mutable class state. Parameters ---------- signal_evt: Optional ``threading.Event`` that the worker thread sets when it reaches a stable checkpoint. If provided, ``wait()`` pauses until that event is set before forcing the timeout branch. """ class _FakeDoneEvent: def __init__(self) -> None: self._set = False self._wait_calls = 0 def is_set(self) -> bool: return self._set def set(self) -> None: self._set = True def clear(self) -> None: self._set = False def wait(self, timeout: Optional[float] = None) -> bool: self._wait_calls += 1 if self._wait_calls == 1: if signal_evt is not None: # The specific timeout value isn't important; we just # don't want this to hang if the checkpoint is never # reached. signal_evt.wait(timeout=1.0) # Give the worker thread a brief chance to run before we # force the timeout condition in the code under test. time.sleep(WORKER_THREAD_STARTUP_DELAY) return False return self._set return _FakeDoneEvent @pytest.mark.skipif(SLIXMPP_AVAILABLE, reason="Requires slixmpp NOT installed") def test_slixmpp_unavailable() -> None: obj = NotifyXMPP(host="example.com", user="me@example.com", password="x") assert obj.send("will fail") is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_invalid_jid() -> None: with pytest.raises(AppriseImproperlyConfigured): NotifyXMPP(host="example.com", user="bad jid", password="x") @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_targets_filtered() -> None: n = NotifyXMPP( host="example.com", user="me@example.com", password="x", targets=["ok@example.com", " user1 user2 ", "", "also@example.net"], ) # parse_list() tokenises on whitespace, so "bad jid" becomes two targets. # Empty values are dropped before they reach the validation loop, so no # warning is guaranteed for the empty string. assert sorted(n.targets) == [ ("chat", "also@example.net"), ("chat", "ok@example.com"), ("chat", "user1@example.com"), ("chat", "user2@example.com"), ] @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_len() -> None: n1 = NotifyXMPP(host="example.com", user="me@example.com", password="x") assert len(n1) == 1 n2 = NotifyXMPP( host="example.com", user="me@example.com", password="x", targets=["a@example.com", "b@example.com"], ) # still just one upstream message assert len(n2) == 1 @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_url_privacy() -> None: n = NotifyXMPP( host="example.com", user="me@example.com", password="secret", targets=["a@example.com"], verify=False, ) u = n.url(privacy=True) assert "secret" not in u assert "verify=" in u @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_parse_url() -> None: r = NotifyXMPP.parse_url( "xmpps://me:pass@example.com/a@example.com?verify=no" ) assert r["verify"] is False assert r["targets"] == ["a@example.com"] @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_parse_url_to() -> None: r = NotifyXMPP.parse_url( "xmpp://me:pass@example.com?to=a@example.com,b@example.net" ) assert sorted(r["targets"]) == ["a@example.com", "b@example.net"] @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_invalid_targets_logged( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """Invalid XMPP targets are dropped with a warning.""" # Some test modules disable logging globally at import time # This ensures we're correctly configured for this test. logging.disable(logging.NOTSET) caplog.set_level(logging.WARNING, logger=LOGGER_NAME) caplog.set_level(logging.WARNING, logger=xmpp_base.__name__) try: # parse_list() tokenises on whitespace, so it is difficult to # naturally feed a whitespace-containing value through. Patch # parse_list() to return a value that will fail IS_JID validation. monkeypatch.setattr( xmpp_base, "parse_list", lambda _v: ["bad jid", "ok@example.com"], raising=True, ) n = NotifyXMPP( host="example.com", user="me@example.com", password="x", targets=["ignored"], ) assert n.targets == [("chat", "ok@example.com")] assert "Dropped invalid XMPP target" in caplog.text finally: logging.disable(logging.CRITICAL) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_url_identifier_is_accessible() -> None: """url_identifier property should be accessible and stable.""" n = NotifyXMPP( host="example.com", user="me@example.com", password="secret" ) schema, host, xmpp_host, user, password, port = n.url_identifier assert schema == "xmpp" assert host == "example.com" assert xmpp_host == "" assert user == "me@example.com" assert password == "secret" assert port is None @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_server_override_parse_url() -> None: """?xmpp= is extracted into xmpp_host by parse_url().""" r = NotifyXMPP.parse_url( "xmpps://user@example.com/joe?xmpp=xmpp.example.com" ) assert r is not None assert r["xmpp_host"] == "xmpp.example.com" # host stays as the JID domain assert r["host"] == "example.com" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_server_override_constructor_and_url() -> None: """xmpp_host is stored and round-trips through url().""" n = NotifyXMPP( host="example.com", user="user", password="pass", xmpp_host="xmpp.example.com", ) # Stored correctly assert n.xmpp_host == "xmpp.example.com" # JIDs are built from host, not xmpp_host assert n.jid == "user@example.com" # url() emits xmpp= when it differs from host u = n.url(privacy=False) assert "xmpp=xmpp.example.com" in u assert "example.com" in u # url() omits xmpp= when xmpp_host matches host (redundant) n2 = NotifyXMPP( host="example.com", user="user", password="pass", xmpp_host="example.com", ) u2 = n2.url(privacy=False) assert "xmpp=" not in u2 # url() omits xmpp= when xmpp_host is empty n3 = NotifyXMPP( host="example.com", user="user", password="pass", ) u3 = n3.url(privacy=False) assert "xmpp=" not in u3 @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_server_override_target_jids_use_host() -> None: """Target JIDs are built from host, not xmpp_host.""" n = NotifyXMPP( host="example.com", user="user", password="pass", targets=["joe"], xmpp_host="xmpp.example.com", ) # Target 'joe' gets host domain appended, not xmpp_host assert n.targets == [("chat", "joe@example.com")] @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_server_override_url_identifier() -> None: """url_identifier carries xmpp_host so deduplication is xmpp_host-aware.""" n = NotifyXMPP( host="example.com", user="user", password="pass", xmpp_host="xmpp.example.com", ) _schema, host, xmpp_host, _user, _password, _port = n.url_identifier assert host == "example.com" assert xmpp_host == "xmpp.example.com" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_server_override_config_host( monkeypatch: pytest.MonkeyPatch, ) -> None: """send() passes xmpp_host (not host) to XMPPConfig when set.""" captured: dict = {} class _Adapter: def __init__(self, config: Any, **kwargs: Any) -> None: captured["config_host"] = config.host def process(self) -> bool: return True monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _Adapter, raising=True) apobj = Apprise() apobj.add("xmpps://user@example.com/joe?xmpp=xmpp.example.com") apobj.notify("hello") assert captured["config_host"] == "xmpp.example.com" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_server_override_config_host_defaults_to_host( monkeypatch: pytest.MonkeyPatch, ) -> None: """send() passes host to XMPPConfig when xmpp_host is not set.""" captured: dict = {} class _Adapter: def __init__(self, config: Any, **kwargs: Any) -> None: captured["config_host"] = config.host def process(self) -> bool: return True monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _Adapter, raising=True) apobj = Apprise() apobj.add("xmpps://user@example.com/joe") apobj.notify("hello") assert captured["config_host"] == "example.com" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_url_handling() -> None: """url() must not append a path when there are no targets.""" apobj = Apprise() assert apobj.add("xmpp://me:secret@example.com?verify=no") is True assert len(apobj) == 1 plugin = apobj[0] u = plugin.url(privacy=False) assert "example.com/?" in u assert "mode=none" in u apobj = Apprise() assert apobj.add("xmpps://me:secret@example.com?mode=tls") is True assert len(apobj) == 1 plugin = apobj[0] u = plugin.url(privacy=False) assert "mode=tls" in u # invalid Mode apobj = Apprise() assert apobj.add("xmpps://me:secret@example.com?mode=invalid") is False # Ambiguous secure=true (xmpps://) and mode = none apobj = Apprise() assert apobj.add("xmpps://me:secret@example.com?mode=none") is True assert len(apobj) == 1 plugin = apobj[0] u = plugin.url(privacy=False) # starttls (upgraded from none - most secure path) assert "mode=starttls" in u # Ambiguous secure=False (xmpp://) and mode != none apobj = Apprise() assert apobj.add("xmpp://me:secret@example.com?mode=tls") is True assert len(apobj) == 1 plugin = apobj[0] u = plugin.url(privacy=False) # most secure path prevails assert "mode=tls" in u apobj = Apprise() assert ( apobj.add("xmpp://me:secret@example.com?subject=yes&roster=yes") is True ) assert len(apobj) == 1 plugin = apobj[0] u = plugin.url(privacy=False) # most secure path prevails assert "mode=none" in u assert "roster=yes" in u assert "subject=yes" in u @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_parse_url_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: """parse_url() returns None when NotifyBase.parse_url() fails.""" monkeypatch.setattr( xmpp_base.NotifyBase, "parse_url", lambda *_args, **_kwargs: None, raising=True, ) assert NotifyXMPP.parse_url("xmpp://bad") is None @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_finalize_loop_when_already_closed_does_not_close_again() -> None: loop = asyncio.new_event_loop() loop.close() # If _finalize_loop tried to close again, this would still be harmless, # but this test specifically forces the "if not loop.is_closed()" branch # to be False for coverage. xmpp_adapter.SlixmppAdapter._finalize_loop(loop) assert loop.is_closed() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_apprise_notify_invokes_adapter( monkeypatch: pytest.MonkeyPatch, ) -> None: """Exercise the Apprise.notify() workflow while mocking network I/O.""" captured: dict[str, Any] = {} class _Adapter: def __init__( self, config: xmpp_adapter.XMPPConfig, targets: list[str], subject: str, body: str, timeout: float = 0.0, roster: bool = False, before_message: Any = None, logger: Optional[logging.Logger] = None, **kwargs: Any, ) -> None: captured["targets"] = targets captured["body"] = body captured["subject"] = subject captured["roster"] = roster def process(self) -> bool: return True monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _Adapter, raising=True) apobj = Apprise() apobj.add("xmpp://me:secret@example.com/a@example.com") assert ( bool( apobj.notify("hello", title="subject", notify_type=NotifyType.INFO) ) is True ) assert captured["targets"] == [("chat", "a@example.com")] assert captured["subject"] == "" assert "subject\r\nhello" in captured["body"] apobj = Apprise() apobj.add("xmpp://me:secret@example.com/a@example.com?subject=yes") assert ( bool( apobj.notify("hello", title="subject", notify_type=NotifyType.INFO) ) is True ) assert captured["targets"] == [("chat", "a@example.com")] assert captured["subject"] == "subject" assert "hello" in captured["body"] # --------------------------------------------------------------------------- # Adapter Tests # --------------------------------------------------------------------------- @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_ssl_context() -> None: ctx = xmpp_adapter.SlixmppAdapter._ssl_context(True) assert ctx.verify_mode != ssl.CERT_NONE ctx2 = xmpp_adapter.SlixmppAdapter._ssl_context(False) assert ctx2.verify_mode == ssl.CERT_NONE assert ctx2.check_hostname is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_cancel_pending_empty(monkeypatch: pytest.MonkeyPatch) -> None: loop = asyncio.new_event_loop() try: monkeypatch.setattr( asyncio, "all_tasks", lambda _: set(), raising=True ) xmpp_adapter.SlixmppAdapter._finalize_loop(loop) finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_cancel_pending_tasks(monkeypatch: pytest.MonkeyPatch) -> None: loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) async def _noop() -> None: await asyncio.sleep(0) t1 = loop.create_task(_noop()) t2 = loop.create_task(_noop()) monkeypatch.setattr( asyncio, "all_tasks", lambda _: {t1, t2}, raising=True ) xmpp_adapter.SlixmppAdapter._finalize_loop(loop) assert t1.cancelled() assert t2.cancelled() finally: # Avoid asyncio.get_event_loop() here to prevent DeprecationWarning on # newer Python versions. Setting to None is sufficient for isolation. with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_disabled(monkeypatch: pytest.MonkeyPatch) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.TLS, verify_certificate=True, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", timeout=1, ) monkeypatch.setattr(a, "_enabled", False, raising=True) assert a.process() is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_fail( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: install_fake_slixmpp(monkeypatch) caplog.set_level(logging.WARNING) def bad_connect(self: FakeClientXMPP, **kw: Any) -> asyncio.Future[bool]: fut: asyncio.Future[bool] = self.loop.create_future() fut.set_result(False) return fut monkeypatch.setattr(FakeClientXMPP, "connect", bad_connect, raising=True) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=True, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1, ) assert a.process() is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_success(monkeypatch: pytest.MonkeyPatch) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.TLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1.0, ) assert a.process() is True # Test with roster=True a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1.0, roster=True, ) assert a.process() is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_default_target(monkeypatch: pytest.MonkeyPatch) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", timeout=1.0, ) assert a.process() is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_exception( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: install_fake_slixmpp(monkeypatch) caplog.set_level(logging.DEBUG) def explode(self: FakeClientXMPP, **kw: Any) -> bool: raise RuntimeError("boom") monkeypatch.setattr(FakeClientXMPP, "connect", explode, raising=True) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.TLS, verify_certificate=True, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1, ) assert a.process() is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_before_message(monkeypatch: pytest.MonkeyPatch) -> None: """Ensure before_message callback is invoked for each target.""" install_fake_slixmpp(monkeypatch) called: list[int] = [] def before_message() -> None: called.append(1) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com"), ("chat", "b@example.com")], subject="s", body="b", timeout=1, before_message=before_message, ) assert a.process() is True assert len(called) == 2 @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_failed_auth_disconnect( monkeypatch: pytest.MonkeyPatch, ) -> None: """Authentication failure path must still call disconnect().""" install_fake_slixmpp(monkeypatch) # Force the connect() path to trigger the failed_auth handler. def connect_failed_auth( self: FakeClientXMPP, **kw: Any ) -> asyncio.Future[bool]: loop = self.loop or asyncio.get_running_loop() if self.disconnected is None: self.disconnected = loop.create_future() fut: asyncio.Future[bool] = loop.create_future() fut.set_result(True) handler = self.handlers.get("failed_auth") if handler: loop.call_soon(handler) loop.call_soon(self.disconnect) return fut monkeypatch.setattr( FakeClientXMPP, "connect", connect_failed_auth, raising=True ) disconnected = {"called": False} def disconnect(self: FakeClientXMPP) -> None: disconnected["called"] = True if not self.disconnected.done(): self.disconnected.set_result(True) monkeypatch.setattr(FakeClientXMPP, "disconnect", disconnect, raising=True) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.TLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1, ) assert a.process() is False assert disconnected["called"] is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_normalize_jid() -> None: """Tests normalize_jid()""" assert NotifyXMPP.normalize_jid("user", "example.ca") == ( "user@example.ca", False, ) assert NotifyXMPP.normalize_jid("user/resource", "example.ca") == ( "user@example.ca/resource", False, ) assert NotifyXMPP.normalize_jid( "user/resource/extra/crap", "example.ca" ) == ("user@example.ca/resource", False) assert NotifyXMPP.normalize_jid("user@example.com/r1", "example.ca") == ( "user@example.com/r1", False, ) assert NotifyXMPP.normalize_jid( "#room@conference.example.ca", "example.ca" ) == ("room@conference.example.ca", True) with pytest.raises(AppriseImproperlyConfigured): # Bad entry NotifyXMPP.normalize_jid("", "example.ca") @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_bridge_logging_safe_lock( monkeypatch: pytest.MonkeyPatch, ) -> None: """tests bridge logging on multiple attempts""" # Reset module globals monkeypatch.setattr(xmpp_adapter, "_LOG_BRIDGED", False, raising=True) # Acquire the lock so the worker blocks inside bridge_slixmpp_logging() xmpp_adapter._LOG_BRIDGE_LOCK.acquire() done = {"ok": False} def worker() -> None: xmpp_adapter.bridge_slixmpp_logging() done["ok"] = True t = threading.Thread(target=worker, daemon=True) t.start() # Allow enough time for the worker to reach the lock, even on busy hosts. time.sleep(0.1) # Now flip the flag while the lock is still held; when released, worker # will acquire the lock and hit the *inner* early-return. monkeypatch.setattr(xmpp_adapter, "_LOG_BRIDGED", True, raising=True) xmpp_adapter._LOG_BRIDGE_LOCK.release() t.join(timeout=1.0) assert done["ok"] is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_multi_logging_bridge_handling( monkeypatch: pytest.MonkeyPatch, ) -> None: """Handles multiple logging attempts made by multiple xmpp instances""" # Reset module globals monkeypatch.setattr(xmpp_adapter, "_LOG_BRIDGED", False, raising=True) apprise_logger = logging.getLogger("apprise") slix_logger = logging.getLogger("slixmpp") # Preserve existing handlers old_apprise = list(apprise_logger.handlers) old_slix = list(slix_logger.handlers) try: handler = logging.NullHandler() # Arrange for slix to already have the handler apprise_logger.handlers = [handler] slix_logger.handlers = [handler] xmpp_adapter.bridge_slixmpp_logging() # Handler should not be duplicated assert slix_logger.handlers.count(handler) == 1 finally: apprise_logger.handlers = old_apprise slix_logger.handlers = old_slix @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_failure_cleanup(monkeypatch: pytest.MonkeyPatch) -> None: """Tests edge cases for failures that require cleanup""" install_fake_slixmpp(monkeypatch) # Ensure adapter is enabled monkeypatch.setattr( xmpp_adapter.SlixmppAdapter, "_enabled", True, raising=True ) # Make new_event_loop fail before shared['loop'] is assigned def raise_new_event_loop() -> Any: raise RuntimeError("poof") monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", raise_new_event_loop, raising=True, ) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, ) # Should return False assert a.process() is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_timeout_cleanup_disconnect_exception_suppressed( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover timeout cleanup: loop_obj != None""" install_fake_slixmpp(monkeypatch) # Ensure adapter is enabled monkeypatch.setattr( xmpp_adapter.SlixmppAdapter, "_enabled", True, raising=True ) client_created = threading.Event() # Let us know when _Client() has been constructed (super().__init__ call) orig_init = FakeClientXMPP.__init__ def init_signal( self: FakeClientXMPP, jid: str, password: str, *args: Any, **kwargs: Any, ) -> None: orig_init(self, jid, password, *args, **kwargs) client_created.set() monkeypatch.setattr(FakeClientXMPP, "__init__", init_signal, raising=True) # Block connect so runner cannot complete before the timeout branch. def connect_blocks( self: FakeClientXMPP, **kw: Any ) -> asyncio.Future[bool]: fut: asyncio.Future[bool] = self.loop.create_future() # Never resolve. return fut monkeypatch.setattr( FakeClientXMPP, "connect", connect_blocks, raising=True ) # Make done.wait deterministic without touching real threading.Event used # by Thread internals _patch_threading( monkeypatch, done_event_cls=_make_fake_done_event_cls(client_created), ) # Capture and control loop.call_soon_threadsafe behaviour import asyncio as _real_asyncio orig_new_event_loop = _real_asyncio.new_event_loop calls: list[str] = [] def new_event_loop_wrapped() -> _real_asyncio.AbstractEventLoop: loop = orig_new_event_loop() orig_csts = loop.call_soon_threadsafe def csts(cb: Any, *a: Any) -> Any: # Identify which callback is being scheduled name = getattr(cb, "__name__", repr(cb)) calls.append(name) # Raise only for disconnect callback to hit try/except if name == "disconnect": raise RuntimeError("boom") return orig_csts(cb, *a) monkeypatch.setattr(loop, "call_soon_threadsafe", csts, raising=True) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.TLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, ) assert a.process() is False # We should have attempted both disconnect and loop.stop scheduling assert "disconnect" in calls assert "stop" in calls @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_timeout_cleanup_no_client_stop_exception_suppressed( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover timeout cleanup: loop_obj != None""" install_fake_slixmpp(monkeypatch) monkeypatch.setattr( xmpp_adapter.SlixmppAdapter, "_enabled", True, raising=True ) loop_created = threading.Event() allow_client_create = threading.Event() # Block FakeClientXMPP.__init__ so shared['client'] never gets set before # timeout orig_init = FakeClientXMPP.__init__ def init_block( self: FakeClientXMPP, jid: str, password: str, *args: Any, **kwargs: Any, ) -> None: # Wait until main thread has already taken the timeout path allow_client_create.wait(timeout=1.0) orig_init(self, jid, password, *args, **kwargs) monkeypatch.setattr(FakeClientXMPP, "__init__", init_block, raising=True) # Deterministic done.wait: wait until loop exists, then force timeout _patch_threading( monkeypatch, done_event_cls=_make_fake_done_event_cls(loop_created), ) # Wrap new_event_loop so we can (a) signal loop exists and (b) make stop # raise import asyncio as _real_asyncio orig_new_event_loop = _real_asyncio.new_event_loop calls: list[str] = [] def new_event_loop_wrapped() -> _real_asyncio.AbstractEventLoop: loop = orig_new_event_loop() loop_created.set() def csts(cb: Any, *a: Any) -> Any: name = getattr(cb, "__name__", repr(cb)) calls.append(name) if name == "stop": raise RuntimeError("boom-stop") return None monkeypatch.setattr(loop, "call_soon_threadsafe", csts, raising=True) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.NONE, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, ) try: assert a.process() is False # client_obj is None, so no disconnect scheduling attempt should occur assert "disconnect" not in calls # stop scheduling attempted and exception suppressed assert "stop" in calls finally: # Unblock the runner thread so it does not linger allow_client_create.set() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_timeout_cleanup_loop_none_skips_disconnect_and_stop( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """Cover timeout clean-up branch where shared['loop'] is still None.""" install_fake_slixmpp(monkeypatch) # Some test modules disable logging globally at import time (see # "Disable logging for a cleaner testing output" elsewhere in the # suite). caplog.set_level() only relies on newer pytest versions to # un-suppress that global disable -- older pytest releases (as # packaged for some RPM builds) do not, leaving caplog.text empty # regardless of the level requested. Force logging on explicitly so # this assertion holds under any pytest version or collection order. logging.disable(logging.NOTSET) caplog.set_level(logging.WARNING, logger="apprise.xmpp") # Ensure adapter is enabled monkeypatch.setattr( xmpp_adapter.SlixmppAdapter, "_enabled", True, raising=True ) # Gate used to block runner before it can assign shared["loop"]. gate = threading.Event() # Raised by new_event_loop_blocking as soon as the worker enters it -- # before any loop object is created and before shared["loop"] is set. # This lets done.wait() synchronize on this checkpoint so we have a # deterministic guarantee that shared["loop"] is still None when the # main-thread timeout cleanup runs. worker_entered_gate = threading.Event() # Patch asyncio.new_event_loop to block until we allow it, so # shared["loop"] remains None when the timeout clean-up executes. import asyncio as _real_asyncio orig_new_event_loop = _real_asyncio.new_event_loop def new_event_loop_blocking() -> _real_asyncio.AbstractEventLoop: # Signal the main thread: we are inside new_event_loop_blocking and # shared["loop"] has not been set yet. worker_entered_gate.set() gate.wait(timeout=2.0) return orig_new_event_loop() monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_blocking, raising=True, ) # Make done.wait deterministic: wait until the worker has entered # new_event_loop_blocking (guaranteeing shared["loop"] is still None) # before forcing the timeout branch. _patch_threading( monkeypatch, done_event_cls=_make_fake_done_event_cls(worker_entered_gate), ) # Track whether any loop clean-up scheduling occurs. # It must NOT when loop_obj is None. called = {"disconnect": 0, "stop": 0} # Block connect so runner cannot complete before the timeout branch. def connect_blocks( self: FakeClientXMPP, **kw: Any ) -> asyncio.Future[bool]: fut: asyncio.Future[bool] = self.loop.create_future() # Never resolve. return fut monkeypatch.setattr( FakeClientXMPP, "connect", connect_blocks, raising=True ) # As an extra guard, if loop.call_soon_threadsafe were somehow reached, # we'd see it via this patch. Since loop_obj is None, it must not. def fake_disconnect(self: FakeClientXMPP) -> None: called["disconnect"] += 1 monkeypatch.setattr( FakeClientXMPP, "disconnect", fake_disconnect, raising=True ) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.TLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, ) try: # done.wait blocks until the worker signals worker_entered_gate # (guaranteeing shared["loop"] is None), then returns False # immediately, so the timeout clean-up sees loop_obj=None. assert a.process() is False assert "XMPP send timed out" in caplog.text # No disconnect should have been scheduled because loop_obj was None. assert called["disconnect"] == 0 finally: # Let the runner proceed and exit gate.set() logging.disable(logging.CRITICAL) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_unsupported_secure_mode( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """The process runner handles an unsupported secure mode.""" install_fake_slixmpp(monkeypatch) # See test_xmpp_invalid_targets_logged for why this is needed: older # pytest releases don't un-suppress a prior logging.disable(CRITICAL) # left by another test module's import-time call. logging.disable(logging.NOTSET) caplog.set_level(logging.WARNING, logger="apprise.xmpp") try: config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure="invalid", verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, ) assert a.process() is False assert "XMPP send failed" in caplog.text finally: logging.disable(logging.CRITICAL) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_connect_timeout( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """Cover connect timeout branch (asyncio.TimeoutError).""" install_fake_slixmpp(monkeypatch) # See test_xmpp_invalid_targets_logged for why this is needed: older # pytest releases don't un-suppress a prior logging.disable(CRITICAL) # left by another test module's import-time call. logging.disable(logging.NOTSET) caplog.set_level(logging.WARNING, logger="apprise.xmpp") try: # Return a Future that never resolves. connect_future: dict[str, Any] = {"fut": None} def connect_never( self: FakeClientXMPP, **kw: Any ) -> asyncio.Future[bool]: fut: asyncio.Future[bool] = self.loop.create_future() connect_future["fut"] = fut return fut monkeypatch.setattr( FakeClientXMPP, "connect", connect_never, raising=True ) # Force wait_for() to raise TimeoutError immediately for this # connect future. real_wait_for = xmpp_adapter.asyncio.wait_for async def wait_for_patched( aw: Any, timeout: Optional[float] = None ) -> Any: if aw is connect_future["fut"]: raise asyncio.TimeoutError() return await real_wait_for(aw, timeout=timeout) monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", wait_for_patched, raising=True ) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, ) assert a.process() is False assert "XMPP connect timed out" in caplog.text finally: logging.disable(logging.CRITICAL) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_session_timeout( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """Cover session timeout branch when waiting on client.disconnected.""" install_fake_slixmpp(monkeypatch) # See test_xmpp_invalid_targets_logged for why this is needed: older # pytest releases don't un-suppress a prior logging.disable(CRITICAL) # left by another test module's import-time call. logging.disable(logging.NOTSET) caplog.set_level(logging.WARNING, logger="apprise.xmpp") try: # Ensure disconnect does not complete the disconnected Future. def disconnect_no_complete(self: FakeClientXMPP) -> None: return None monkeypatch.setattr( FakeClientXMPP, "disconnect", disconnect_no_complete, raising=True ) # Capture the disconnected future once connect() creates it. It # does not exist yet at __init__ time (see FakeClientXMPP.__init__), # so it must be captured here instead. real_connect = FakeClientXMPP.connect disconnected_future: dict[str, Any] = {"fut": None} def connect_capture(self: FakeClientXMPP, **kwargs: Any) -> Any: fut = real_connect(self, **kwargs) disconnected_future["fut"] = self.disconnected return fut monkeypatch.setattr( FakeClientXMPP, "connect", connect_capture, raising=True ) # Force wait_for() to raise TimeoutError immediately, but only for # the disconnected future -- matching by identity rather than call # order keeps this deterministic even if session_start's own # concurrently-scheduled task (MUC join, etc.) happens to call # wait_for() of its own accord first. real_wait_for = xmpp_adapter.asyncio.wait_for def wait_for_patched(aw: Any, timeout: Optional[float] = None) -> Any: if aw is disconnected_future["fut"]: # If aw is a coroutine, close it to avoid warnings with contextlib.suppress(Exception): if hasattr(aw, "close"): aw.close() raise asyncio.TimeoutError() return real_wait_for(aw, timeout=timeout) monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", wait_for_patched, raising=True ) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, ) assert a.process() is False assert "XMPP session timed out" in caplog.text finally: logging.disable(logging.CRITICAL) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_slixmpp_versioning(monkeypatch: pytest.MonkeyPatch) -> None: """ Simple checks to verify versioning works correctly """ # Garbage in... garbage out assert xmpp_adapter.SlixmppAdapter.supported_version("garbage") is False # These versions are too old assert xmpp_adapter.SlixmppAdapter.supported_version("1.0.0") is False assert xmpp_adapter.SlixmppAdapter.supported_version("1.10.0") is False assert xmpp_adapter.SlixmppAdapter.supported_version("1.14.1") is False assert xmpp_adapter.SlixmppAdapter.supported_version("1.15.0") is False assert xmpp_adapter.SlixmppAdapter.supported_version("1.15.1") is False # Good version checks assert xmpp_adapter.SlixmppAdapter.supported_version("1.16.0") is True assert xmpp_adapter.SlixmppAdapter.supported_version("1.16") is True assert xmpp_adapter.SlixmppAdapter.supported_version("1.16.1") is True assert xmpp_adapter.SlixmppAdapter.supported_version("1.16.100") is True assert xmpp_adapter.SlixmppAdapter.supported_version("1.17.0") is True # This version is too old assert xmpp_adapter.SlixmppAdapter.supported_version("1") is False # This version is good assert xmpp_adapter.SlixmppAdapter.supported_version("3") is True assert xmpp_adapter.SlixmppAdapter.supported_version("3.2") is True monkeypatch.setattr( xmpp_adapter, "SLIXMPP_SUPPORT_AVAILABLE", False, raising=False ) monkeypatch.setattr( xmpp_adapter.SlixmppAdapter, "_enabled", False, raising=False, ) assert xmpp_adapter.SlixmppAdapter.supported_version("3.2") is False # --------------------------------------------------------------------------- # Keepalive Tests # --------------------------------------------------------------------------- @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_keepalive_url_and_parse() -> None: n = NotifyXMPP( host="example.com", user="me@example.com", password="x", targets=["a@example.com"], keepalive=True, ) u = n.url(privacy=False) assert "keepalive=yes" in u r = NotifyXMPP.parse_url( "xmpp://me:pass@example.com/a@example.com?keepalive=yes" ) assert r["keepalive"] is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_keepalive_reuses_adapter_instance( monkeypatch: pytest.MonkeyPatch, ) -> None: created = {"count": 0} calls = {"send_message": 0, "process": 0} class _Adapter: def __init__(self, *args: Any, **kwargs: Any) -> None: created["count"] += 1 self.keepalive = kwargs.get("keepalive", False) def process(self) -> bool: calls["process"] += 1 return True def send_message(self, **kwargs: Any) -> bool: calls["send_message"] += 1 return True def close(self) -> None: return None monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _Adapter, raising=True) # keepalive=yes should create adapter once, then reuse it n = NotifyXMPP( host="example.com", user="me@example.com", password="x", targets=["a@example.com"], keepalive=True, ) assert n.send("body1", title="t1") is True assert n.send("body2", title="t2") is True assert created["count"] == 1 assert calls["send_message"] == 2 assert calls["process"] == 0 # keepalive=no should not reuse adapter, it uses process() one-shot created["count"] = 0 calls["send_message"] = 0 calls["process"] = 0 n = NotifyXMPP( host="example.com", user="me@example.com", password="x", targets=["a@example.com"], keepalive=False, ) assert n.send("body", title="t") is True assert created["count"] == 1 assert calls["process"] == 1 assert calls["send_message"] == 0 @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_message_keepalive_false_calls_process( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.NONE, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, keepalive=False, ) called = {"process": 0} def process() -> bool: called["process"] += 1 return True monkeypatch.setattr(a, "process", process, raising=True) assert a.send_message(targets=["x"], subject="y", body="z") is True assert called["process"] == 1 assert a.targets == ["x"] assert a.subject == "y" assert a.body == "z" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_ensure_keepalive_worker_edge_cases( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) # keepalive False => False a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=False, ) assert a._ensure_keepalive_worker() is False # keepalive True but closing => False a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=True, ) a._closing = True assert a._ensure_keepalive_worker() is False # keepalive True but disabled => False a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=True, ) monkeypatch.setattr(a, "_enabled", False, raising=True) assert a._ensure_keepalive_worker() is False # thread already alive => True a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=True, ) class _AliveThread: def is_alive(self) -> bool: return True a._thread = _AliveThread() assert a._ensure_keepalive_worker() is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_ensure_keepalive_worker_wait_timeout( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: install_fake_slixmpp(monkeypatch) # See test_xmpp_invalid_targets_logged for why this is needed: older # pytest releases don't un-suppress a prior logging.disable(CRITICAL) # left by another test module's import-time call. logging.disable(logging.NOTSET) caplog.set_level(logging.WARNING, logger="apprise.xmpp") try: config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", timeout=0.1, keepalive=True, ) # Prevent starting a real loop thread monkeypatch.setattr(a, "_keepalive_runner", lambda: None, raising=True) # Force wait() to fail monkeypatch.setattr( a._loop_ready, "wait", lambda timeout=None: False, raising=True ) assert a._ensure_keepalive_worker() is False assert "keepalive worker failed to start" in caplog.text finally: logging.disable(logging.CRITICAL) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_early_returns( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=True ) assert asyncio.run(a._connect_if_required()) is False loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop assert run_on_loop(loop, a._connect_if_required()) is False a._client = SimpleNamespace() assert run_on_loop(loop, a._connect_if_required()) is False a._connect_lock = asyncio.Lock() assert run_on_loop(loop, a._connect_if_required()) is False a._session_started = asyncio.Event() assert run_on_loop(loop, a._connect_if_required()) is False finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_already_connected( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() a._session_started.set() called = {"connect": 0} class _Client: def connect(self, **kwargs: Any) -> Any: called["connect"] += 1 fut = loop.create_future() fut.set_result(True) return fut a._client = _Client() assert run_on_loop(loop, a._connect_if_required()) is True assert called["connect"] == 0 finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_connect_timeout( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", timeout=5.0, keepalive=True, ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() fut = loop.create_future() class _Client: def connect(self, **kwargs: Any) -> Any: return fut a._client = _Client() real_wait_for = xmpp_adapter.asyncio.wait_for def wait_for_patched(aw: Any, timeout: Optional[float] = None) -> Any: if aw is fut: raise asyncio.TimeoutError() return real_wait_for(aw, timeout=timeout) monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", wait_for_patched, raising=True ) assert run_on_loop(loop, a._connect_if_required()) is False finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_connect_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() class _Client: def connect(self, **kwargs: Any) -> Any: raise RuntimeError("boom") a._client = _Client() assert run_on_loop(loop, a._connect_if_required()) is False finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_session_start_timeout( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", timeout=5.0, keepalive=True, ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() fut = loop.create_future() fut.set_result(True) class _Client: def connect(self, **kwargs: Any) -> Any: return fut a._client = _Client() # First wait_for succeeds (connect), second one raises # (session start wait) real_wait_for = xmpp_adapter.asyncio.wait_for calls = {"n": 0} async def wait_for_patched( aw: Any, timeout: Optional[float] = None ) -> Any: calls["n"] += 1 if calls["n"] == 2: # Prevent "coroutine Event.wait was never awaited" with contextlib.suppress(Exception): if hasattr(aw, "close"): aw.close() raise asyncio.TimeoutError() return await real_wait_for(aw, timeout=timeout) monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", wait_for_patched, raising=True ) assert run_on_loop(loop, a._connect_if_required()) is False finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_keepalive_async_default_target_and_send_error( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() a._session_started.set() sent: list[str] = [] class _Client: def send_message(self, **kwargs: Any) -> None: sent.append(kwargs.get("mto")) a._client = _Client() async def ok_connect() -> bool: return True monkeypatch.setattr( a, "_connect_if_required", ok_connect, raising=True ) assert ( run_on_loop( loop, a._send_keepalive_async(targets=[], subject="s", body="b"), ) is True ) assert sent == ["me@example.com"] # Now force send_message to raise and ensure session_started clears def boom_send_message(self: _Client, **kwargs: Any) -> None: raise RuntimeError("boom-send") monkeypatch.setattr( _Client, "send_message", boom_send_message, raising=True ) assert a._session_started.is_set() is True assert ( run_on_loop( loop, a._send_keepalive_async(targets=[], subject="s", body="b"), ) is False ) assert a._session_started.is_set() is False finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_message_keepalive_timeout_and_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", timeout=5.0, keepalive=True, ) # Ensure worker exists without spinning a real thread monkeypatch.setattr( a, "_ensure_keepalive_worker", lambda: True, raising=True ) calls: list[str] = [] class _Loop: def call_soon_threadsafe(self, cb: Any, *args: Any) -> None: calls.append(getattr(cb, "__name__", "cb")) cb(*args) a._loop = _Loop() cleared = {"n": 0} class _Evt: def clear(self) -> None: cleared["n"] += 1 a._session_started = _Evt() # Timeout path class _FutureTimeout: def result(self, timeout: Optional[float] = None) -> Any: raise xmpp_adapter.FuturesTimeoutError() def run_coroutine_threadsafe_timeout(coro: Any, loop: Any) -> Any: # Prevent "never awaited" warnings with contextlib.suppress(Exception): coro.close() return _FutureTimeout() monkeypatch.setattr( xmpp_adapter.asyncio, "run_coroutine_threadsafe", run_coroutine_threadsafe_timeout, raising=True, ) assert a.send_message(targets=[], subject="s", body="b") is False assert cleared["n"] == 1 # Exception path class _FutureError: def result(self, timeout: Optional[float] = None) -> Any: raise RuntimeError("boom") monkeypatch.setattr( xmpp_adapter.asyncio, "run_coroutine_threadsafe", run_coroutine_threadsafe_timeout, raising=True, ) assert a.send_message(targets=[], subject="s", body="b") is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_del_suppresses_close_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover SlixmppAdapter.__del__() exception suppression """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) def boom_close() -> None: raise RuntimeError("boom-close") monkeypatch.setattr(a, "close", boom_close, raising=True) # Ensure __del__ runs and does not raise del a gc.collect() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_close_shutdown_paths_and_state_reset( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover close(): _shutdown() branches and loop.call_soon_threadsafe exception suppression, plus state reset """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) class _Client: def disconnect(self) -> None: raise RuntimeError("disconnect boom") class _Loop: def __init__(self) -> None: self.called: list[str] = [] def stop(self) -> None: self.called.append("stop") raise RuntimeError("stop boom") def call_soon_threadsafe(self, cb: Any, *args: Any) -> None: self.called.append("call_soon_threadsafe") # Execute immediately so _shutdown() runs in this test cb(*args) class _Thread: def join(self, timeout: Optional[float] = None) -> None: return None # Populate internals so close() takes the full path loop = _Loop() a._loop = loop a._client = _Client() a._thread = _Thread() # Also set asyncio primitives to ensure they are nulled out a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() a.close() # State must be cleared assert a._thread is None assert a._loop is None assert a._client is None assert a._connect_lock is None assert a._session_started is None # Ensure call_soon_threadsafe ran (and stop attempted inside _shutdown) assert "call_soon_threadsafe" in loop.called assert "stop" in loop.called @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_close_call_soon_threadsafe_raises_still_resets( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover close(): loop.call_soon_threadsafe(_shutdown) raising and being suppressed, while still joining and clearing state. """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) class _Loop: def call_soon_threadsafe(self, cb: Any, *args: Any) -> None: raise RuntimeError("csts boom") def stop(self) -> None: return None class _Thread: def join(self, timeout: Optional[float] = None) -> None: return None a._loop = _Loop() a._client = SimpleNamespace(disconnect=lambda: None) a._thread = _Thread() a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() a.close() assert a._thread is None assert a._loop is None assert a._client is None assert a._connect_lock is None assert a._session_started is None @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_ensure_keepalive_worker_returns_true_at_end( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Hit _ensure_keepalive_worker() bottom return True """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", timeout=5.0, keepalive=True, ) # Make the runner set loop_ready quickly and exit, using a real thread. def runner() -> None: a._loop_ready.set() return None monkeypatch.setattr(a, "_keepalive_runner", runner, raising=True) assert a._ensure_keepalive_worker() is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_runner_executes_and_register_plugin_suppressed( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Execute _keepalive_runner() including: - secure mode config - register_plugin() suppression - loop.run_forever() exit - finally: loop.stop/close """ install_fake_slixmpp(monkeypatch) # Force register_plugin to raise so the suppress(Exception) is exercised def boom_register_plugin( self: FakeClientXMPP, name: str, config: Optional[Any] = None ) -> None: raise RuntimeError("boom-plugin") monkeypatch.setattr( FakeClientXMPP, "register_plugin", boom_register_plugin, raising=True ) # Wrap new_event_loop so we can make run_forever return immediately, and # make stop/close callable. real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() # Exit immediately instead of blocking forever monkeypatch.setattr(loop, "run_forever", lambda: None, raising=True) # Ensure stop/close are callable (real loop has them already) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) # Run synchronously. Since run_forever() is patched to return, this # should exit quickly. a._keepalive_runner() # Worker should have published these before entering run_forever() assert a._loop is not None assert a._client is not None assert a._connect_lock is not None assert a._session_started is not None @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_hits_bottom_return_true( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover _connect_if_required() success path reaching the final return True, not the early already-connected return. """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", timeout=5.0, keepalive=True, ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() fut = loop.create_future() fut.set_result(True) class _Client: def connect(self, **kwargs: Any) -> Any: return fut a._client = _Client() # Patch wait_for so the second wait_for (session_started.wait()) sets # the event just in time, forcing the bottom return True. real_wait_for = xmpp_adapter.asyncio.wait_for calls = {"n": 0} async def wait_for_patched( aw: Any, timeout: Optional[float] = None ) -> Any: calls["n"] += 1 if calls["n"] == 2: assert a._session_started is not None a._session_started.set() return await real_wait_for(aw, timeout=timeout) monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", wait_for_patched, raising=True ) assert run_on_loop(loop, a._connect_if_required()) is True finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_keepalive_async_connect_fail_branch( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover _send_keepalive_async(): if not ok: return False """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) # Must not be None, or it returns False earlier a._client = SimpleNamespace(send_message=lambda **kw: None) async def fail_connect() -> bool: return False monkeypatch.setattr(a, "_connect_if_required", fail_connect, raising=True) assert ( asyncio.run( a._send_keepalive_async(targets=["x"], subject="s", body="b") ) is False ) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_keepalive_async_exception_when_session_started_none( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover _send_keepalive_async() exception path where _session_started is None so clear() is skipped """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) async def ok_connect() -> bool: return True monkeypatch.setattr(a, "_connect_if_required", ok_connect, raising=True) class _Client: def send_message(self, **kwargs: Any) -> None: raise RuntimeError("boom-send") a._client = _Client() # Key detail: explicitly set to None to take the "skip clear()" branch a._session_started = None assert ( asyncio.run( a._send_keepalive_async(targets=["x"], subject="s", body="b") ) is False ) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_close_shutdown_client_none_branch( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover close(): _shutdown() path where client is None. """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) class _Loop: def __init__(self) -> None: self.stopped = False self.called = False def stop(self) -> None: self.stopped = True def call_soon_threadsafe(self, cb: Any, *args: Any) -> None: self.called = True cb(*args) class _Thread: def join(self, timeout: Optional[float] = None) -> None: return None loop = _Loop() a._loop = loop a._thread = _Thread() # Key: ensure client is None so if client is not None is False a._client = None a.close() assert loop.called is True assert loop.stopped is True assert a._loop is None assert a._thread is None assert a._client is None @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_close_thread_alive_returns_without_clearing_state( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover close() branch where worker thread is still alive.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) class _Client: def disconnect(self) -> None: return None class _Loop: def __init__(self) -> None: self.called: list[str] = [] def stop(self) -> None: self.called.append("stop") def call_soon_threadsafe(self, cb: Any, *args: Any) -> None: self.called.append("call_soon_threadsafe") cb(*args) class _Thread: def join(self, timeout: Optional[float] = None) -> None: return None def is_alive(self) -> bool: return True loop = _Loop() thread = _Thread() a._loop = loop a._client = _Client() a._thread = thread a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() a.close() assert a._loop is loop assert a._thread is thread assert loop.called[0] == "call_soon_threadsafe" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_runner_returns_if_closing_before_publish( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _keepalive_runner() state-lock early return when closing.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) def _closing_register_plugin( self: FakeClientXMPP, name: str, config: Optional[Any] = None ) -> None: a._closing = True return None monkeypatch.setattr( FakeClientXMPP, "register_plugin", _closing_register_plugin, raising=True, ) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def _new_event_loop() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() monkeypatch.setattr( loop, "run_forever", lambda: (_ for _ in ()).throw(AssertionError("run_forever")), raising=True, ) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", _new_event_loop, raising=True, ) a._loop_ready.clear() a._keepalive_runner() assert a._loop_ready.is_set() is False assert a._loop is None assert a._client is None @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_runner_finally_clears_state_when_closing( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _keepalive_runner() finally state-clear when closing.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop created: list[asyncio.AbstractEventLoop] = [] def _new_event_loop() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() created.append(loop) def _run_forever() -> None: a._closing = True return None monkeypatch.setattr(loop, "run_forever", _run_forever, raising=True) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", _new_event_loop, raising=True, ) a._loop_ready.clear() a._keepalive_runner() assert a._loop_ready.is_set() is True assert a._loop is None assert a._client is None assert a._connect_lock is None assert a._session_started is None assert created assert created[0].is_closed() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_session_start_signals_on_error( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover keepalive _Client._session_start() exception handling.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="ex.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True, roster=True, timeout=5.0, ) loop = asyncio.new_event_loop() # Detach from policy initially asyncio.set_event_loop(None) try: def run_forever_hook() -> None: client = a._client started = a._session_started monkeypatch.setattr( client, "send_presence", lambda: (_ for _ in ()).throw(RuntimeError("fail")), raising=True, ) client._on_session_start() async def wait_for_event(): while not started.is_set(): await asyncio.sleep(0.01) # Assign to variable so we can explicitly close it if needed coro = wait_for_event() try: wf = asyncio.wait_for(coro, timeout=1.0) try: loop.run_until_complete(wf) finally: with contextlib.suppress(Exception): wf.close() finally: # Prevent "coroutine was never awaited" warnings with contextlib.suppress(Exception): coro.close() assert started.is_set() is True return None monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", lambda: loop, ) monkeypatch.setattr(loop, "run_forever", run_forever_hook) a._keepalive_runner() finally: if not loop.is_closed(): # Cancel all tasks, including those from internal asyncio # machinery for task in asyncio.all_tasks(loop): task.cancel() with contextlib.suppress(Exception): loop.run_until_complete(asyncio.sleep(0)) loop.close() # Clean up global policies asyncio.set_event_loop(None) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_session_start_roster_timeout_closes_awaitable( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover roster timeout close path in _session_start().""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) called: list[object] = [] orig_close = xmpp_adapter._close_awaitable def _spy_close(obj: object) -> None: called.append(obj) orig_close(obj) monkeypatch.setattr( xmpp_adapter, "_close_awaitable", _spy_close, raising=True ) async def _raise_timeout(*args: object, **kwargs: object) -> None: raise asyncio.TimeoutError() monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", _raise_timeout, raising=True ) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: evt = asyncio.Event() client = client_cls( jid="user@example.com", password="pass", oneshot=False, want_roster=True, roster_timeout=1.0, session_started_evt=evt, ) loop.run_until_complete(client._session_start()) assert len(called) == 1 finally: asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_session_start_keepalive_sets_ready_event( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover non-oneshot path in _session_start().""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: evt = asyncio.Event() client = client_cls( jid="user@example.com", password="pass", oneshot=False, targets=["a", "b"], subject="s", body="b", before_message=lambda: None, want_roster=False, roster_timeout=0.0, session_started_evt=evt, ) monkeypatch.setattr( client, "send_message", lambda **k: (_ for _ in ()).throw(AssertionError()), raising=True, ) monkeypatch.setattr( client, "disconnect", lambda: (_ for _ in ()).throw(AssertionError()), raising=True, ) loop.run_until_complete(client._session_start()) assert evt.is_set() is True finally: asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_session_start_oneshot_sends_and_disconnects( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover oneshot path in _session_start() and finally disconnect.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) sent: list[dict[str, Any]] = [] before: list[int] = [] disconnected: list[bool] = [] def _before() -> None: before.append(1) def _send_message(**kwargs: Any) -> None: sent.append(kwargs) def _disconnect() -> None: disconnected.append(True) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: client = client_cls( jid="user@example.com", password="pass", oneshot=True, targets=[("chat", "a@example.com"), ("chat", "b@example.com")], subject="sub", body="body", before_message=_before, want_roster=False, roster_timeout=0.0, session_started_evt=None, ) monkeypatch.setattr( client, "send_message", _send_message, raising=True ) monkeypatch.setattr(client, "disconnect", _disconnect, raising=True) loop.run_until_complete(client._session_start()) assert len(before) == 2 assert len(sent) == 2 assert all(msg.get("mtype") == "chat" for msg in sent) assert disconnected == [True] finally: asyncio.set_event_loop(None) loop.close() def test_adapter_keepalive_runner_failed_handlers( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover keepalive _Client._failed_auth() and _disconnected(). """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True, roster=False, ) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() def run_forever_hook() -> None: client = a._client started = a._session_started assert client is not None assert started is not None disconnected = {"called": 0} def disconnect_spy() -> None: disconnected["called"] += 1 monkeypatch.setattr( client, "disconnect", disconnect_spy, raising=True ) # Mark started True, then failed_auth must clear + disconnect started.set() client._failed_auth() assert started.is_set() is False assert disconnected["called"] == 1 # Mark started True again, disconnected must clear, no disconnect # call started.set() client._disconnected() assert started.is_set() is False assert disconnected["called"] == 1 return None monkeypatch.setattr( loop, "run_forever", run_forever_hook, raising=True ) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) a._keepalive_runner() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_keepalive_runner_rejects_secure_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: """The keepalive runner handles an unsupported secure mode.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure="definitely-not-supported", verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) # Ensure clean initial state a._loop_ready.clear() # Patch run_forever to be safe if it ever reaches it (it should not) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() monkeypatch.setattr(loop, "run_forever", lambda: None, raising=True) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) a._keepalive_runner() # Since secure mode invalid, it should not publish loop/client state assert a._loop_ready.is_set() is False assert a._loop is None assert a._client is None assert a._connect_lock is None assert a._session_started is None @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_session_start_try_finally_and_roster( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover _session_start() try/finally and roster handling: - try/finally always sets _session_started_evt - roster path is taken and roster errors are suppressed - send_presence exception still leads to finally:set() """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True, roster=True, timeout=5.0, ) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() def run_forever_hook() -> None: client = a._client evt = a._session_started assert client is not None assert evt is not None # Case 1: roster enabled, get_roster raises but suppressed def send_presence_ok() -> None: return None async def get_roster_boom() -> None: raise RuntimeError("boom-roster") monkeypatch.setattr( client, "send_presence", send_presence_ok, raising=True ) monkeypatch.setattr( client, "get_roster", get_roster_boom, raising=True ) evt.clear() client._on_session_start() # Drive the loop without creating extra coroutine objects for _ in range(10): fut = loop.create_future() loop.call_soon(fut.set_result, None) loop.run_until_complete(fut) if evt.is_set(): break xmpp_adapter.SlixmppAdapter._finalize_loop(loop) assert evt.is_set() is True # Case 2: send_presence raises, finally must still set def send_presence_boom() -> None: raise RuntimeError("boom-presence") monkeypatch.setattr( client, "send_presence", send_presence_boom, raising=True ) evt.clear() client._on_session_start() # Drive the loop without creating extra coroutine objects for _ in range(10): fut = loop.create_future() loop.call_soon(fut.set_result, None) loop.run_until_complete(fut) if evt.is_set(): break xmpp_adapter.SlixmppAdapter._finalize_loop(loop) assert evt.is_set() is True return None monkeypatch.setattr( loop, "run_forever", run_forever_hook, raising=True ) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) a._keepalive_runner() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_runner_plaintext_mode_skips_ssl_context( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover adapter.py - ensuring enable_plaintext=True, so 'if not client.enable_plaintext' is False and ssl_context is not set. """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.NONE, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True, timeout=5.0, ) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() def run_forever_hook() -> None: # At this point the client has been created and configured. assert a._client is not None # In plaintext mode we expect ssl_context not to be forced by # adapter. assert getattr(a._client, "enable_plaintext", None) is True assert getattr(a._client, "ssl_context", None) is None return None monkeypatch.setattr( loop, "run_forever", run_forever_hook, raising=True ) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) a._keepalive_runner() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_runner_finally_loop_none( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover adapter.py when loop is None (new_event_loop fails), so both 'if loop is not None' conditions are False. """ install_fake_slixmpp(monkeypatch) monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", lambda: (_ for _ in ()).throw(RuntimeError("boom")), raising=True, ) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) # Should not raise, finally must handle loop=None a._keepalive_runner() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_runner_suppresses_cleanup_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: """Ensure event-loop cleanup errors do not escape the runner.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="ex.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True, roster=True, timeout=5.0, ) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() # Force finally path with a raised exception from run_forever def run_forever_boom() -> None: raise RuntimeError("boom-forever") monkeypatch.setattr( loop, "run_forever", run_forever_boom, raising=True ) # Raise after real cleanup so the event loop does not leak resources. real_stop = loop.stop real_close = loop.close def stop_boom() -> None: real_stop() raise RuntimeError("boom-stop") def close_boom() -> None: real_close() raise RuntimeError("boom-close") monkeypatch.setattr(loop, "stop", stop_boom, raising=True) monkeypatch.setattr(loop, "close", close_boom, raising=True) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) # Should not raise, stop/close exceptions must be suppressed a._keepalive_runner() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_keepalive_async_returns_false_when_client_none( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover adapter.py _client is None -> return False.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True, ) a._client = None loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) result = loop.run_until_complete( a._send_keepalive_async(["t"], "s", "b") ) assert result is False finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) # Safe shutdown of asyncgens if not loop.is_closed(): with contextlib.suppress(Exception): ag_coro = loop.shutdown_asyncgens() try: loop.run_until_complete(ag_coro) except Exception: # Reuse the helper from the adapter module if available, # or do a manual close if hasattr(ag_coro, "close"): ag_coro.close() # Ensure close() is called even if the block above had issues with contextlib.suppress(Exception): loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_error_suppresses_close_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", timeout=5.0, keepalive=True, ) monkeypatch.setattr( a, "_ensure_keepalive_worker", lambda: True, raising=True ) class _Loop: def call_soon_threadsafe(self, cb: Any, *args: Any) -> None: cb(*args) a._loop = _Loop() class _BadCoro: def close(self) -> None: raise RuntimeError("close-failed") monkeypatch.setattr( a, "_send_keepalive_async", lambda **kwargs: _BadCoro(), raising=True ) def run_coroutine_threadsafe_raises(coro: Any, loop: Any) -> Any: raise RuntimeError("schedule-failed") monkeypatch.setattr( xmpp_adapter.asyncio, "run_coroutine_threadsafe", run_coroutine_threadsafe_raises, raising=True, ) assert a.send_message(targets=[], subject="s", body="b") is False def test_adapter_keepalive_failure_keeps_default_params( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.NONE, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "orig@example.com")], subject="orig-subj", body="orig-body", keepalive=False, ) called = {"process": 0} def process() -> bool: called["process"] += 1 return True monkeypatch.setattr(a, "process", process, raising=True) assert a.send_message() is True assert called["process"] == 1 assert a.targets == [("chat", "orig@example.com")] assert a.subject == "orig-subj" assert a.body == "orig-body" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_message_keepalive_worker_failure_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", keepalive=True, ) monkeypatch.setattr( a, "_ensure_keepalive_worker", lambda: False, raising=True ) assert a.send_message() is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_timeout_without_session_event( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: install_fake_slixmpp(monkeypatch) # See test_xmpp_invalid_targets_logged for why this is needed: older # pytest releases don't un-suppress a prior logging.disable(CRITICAL) # left by another test module's import-time call. logging.disable(logging.NOTSET) caplog.set_level(logging.WARNING, logger="apprise.xmpp") try: config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, keepalive=True, ) monkeypatch.setattr( a, "_ensure_keepalive_worker", lambda: True, raising=True ) class _Loop: def call_soon_threadsafe(self, cb: Any, *args: Any) -> None: raise AssertionError( "should not be called when _session_started is None" ) a._loop = _Loop() a._session_started = None # key for branch coverage class _FutureTimeout: def result(self, timeout: Optional[float] = None) -> Any: raise xmpp_adapter.FuturesTimeoutError() def run_coroutine_threadsafe_timeout(coro: Any, loop: Any) -> Any: # Close coroutine to avoid RuntimeWarning with contextlib.suppress(Exception): coro.close() return _FutureTimeout() monkeypatch.setattr( xmpp_adapter.asyncio, "run_coroutine_threadsafe", run_coroutine_threadsafe_timeout, raising=True, ) assert a.send_message() is False assert "XMPP keepalive send timed out" in caplog.text finally: logging.disable(logging.CRITICAL) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_session_start_keepalive_evt_none_no_set( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _session_start() when session event is None.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) client = client_cls( jid="user@example.com", password="pass", oneshot=False, targets=[], subject="s", body="b", before_message=None, want_roster=False, roster_timeout=0.0, session_started_evt=None, ) loop.run_until_complete(client._session_start()) finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_general_function( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: install_fake_slixmpp(monkeypatch) def _close(): raise Exception("boom") arg = {"close": _close} # does nothing xmpp_adapter._close_awaitable(None) xmpp_adapter._close_awaitable(arg) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_on_session_start_create_task_failure_closes_coro( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover loop.create_task() failure that closes the awaitable.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) client = client_cls( jid="user@example.com", password="pass", oneshot=False, targets=[], subject="s", body="b", before_message=None, want_roster=False, roster_timeout=0.0, session_started_evt=asyncio.Event(), ) class _FakeCoro: def __init__(self) -> None: self.closed = False def close(self) -> None: self.closed = True fake_coro = _FakeCoro() def _session_start(*args: object, **kwargs: object) -> _FakeCoro: return fake_coro monkeypatch.setattr( client, "_session_start", _session_start, raising=True ) class _FakeLoop: def is_running(self) -> bool: return True def create_task(self, coro: object) -> object: raise RuntimeError("boom") client.loop = _FakeLoop() # type: ignore[assignment] client._on_session_start() assert fake_coro.closed is True finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_disconnected_evt_none_no_clear( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _disconnected() branch where session event is None.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) client = client_cls( jid="user@example.com", password="pass", oneshot=False, targets=[], subject="s", body="b", before_message=None, want_roster=False, roster_timeout=0.0, session_started_evt=None, ) client._disconnected() finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_message_keepalive_loop_none_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover send_message(): loop is None -> return False.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("chat", "t@example.com")], subject="s", body="b", keepalive=True, ) monkeypatch.setattr( a, "_ensure_keepalive_worker", lambda: True, raising=True ) a._loop = None assert a.send_message() is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_message_keepalive_exception_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover send_message(): exception path returns False.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("chat", "t@example.com")], subject="s", body="b", timeout=5.0, keepalive=True, ) monkeypatch.setattr( a, "_ensure_keepalive_worker", lambda: True, raising=True ) class _Loop: pass a._loop = _Loop() def run_coroutine_threadsafe_boom(coro: Any, loop: Any) -> Any: with contextlib.suppress(Exception): coro.close() raise RuntimeError("boom") monkeypatch.setattr( xmpp_adapter.asyncio, "run_coroutine_threadsafe", run_coroutine_threadsafe_boom, raising=True, ) assert a.send_message() is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_keepalive_async_auth_failed_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _send_keepalive_async(): auth_failed -> return False.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True, ) class _Client: _auth_failed = True def send_message(self, **kwargs: Any) -> None: raise AssertionError("send must not occur") a._client = _Client() async def ok_connect() -> bool: return True monkeypatch.setattr(a, "_connect_if_required", ok_connect, raising=True) assert ( asyncio.run(a._send_keepalive_async(["t@example.com"], "s", "b")) is False ) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_auth_failed_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _connect_if_required(): auth_failed -> return False.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() class _Client: _auth_failed = True a._client = _Client() assert run_on_loop(loop, a._connect_if_required()) is False finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_connect_ok_false_disconnects( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """Cover _connect_if_required(): connect_ok False path.""" install_fake_slixmpp(monkeypatch) # See test_xmpp_invalid_targets_logged for why this is needed: older # pytest releases don't un-suppress a prior logging.disable(CRITICAL) # left by another test module's import-time call. logging.disable(logging.NOTSET) caplog.set_level(logging.WARNING, logger="apprise.xmpp") cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() disconnected = {"n": 0} class _Client: def connect(self, **kwargs: Any) -> Any: fut = loop.create_future() fut.set_result(False) return fut def disconnect(self) -> None: disconnected["n"] += 1 a._client = _Client() assert run_on_loop(loop, a._connect_if_required()) is False assert disconnected["n"] == 1 assert "XMPP connect failed" in caplog.text finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() logging.disable(logging.CRITICAL) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_session_wait_exception_closes( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _connect_if_required(): session_wait Exception path.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() fut = loop.create_future() fut.set_result(True) class _Client: def connect(self, **kwargs: Any) -> Any: return fut a._client = _Client() called: list[object] = [] real_close = xmpp_adapter._close_awaitable def close_spy(obj: object) -> None: called.append(obj) real_close(obj) monkeypatch.setattr( xmpp_adapter, "_close_awaitable", close_spy, raising=True ) real_wait_for = xmpp_adapter.asyncio.wait_for calls = {"n": 0} async def wait_for_patched( aw: Any, timeout: Optional[float] = None ) -> Any: calls["n"] += 1 if calls["n"] == 2: raise RuntimeError("boom") return await real_wait_for(aw, timeout=timeout) monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", wait_for_patched, raising=True ) assert run_on_loop(loop, a._connect_if_required()) is False assert len(called) == 1 finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_on_session_start_add_done_callback_executes( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover adapter.py _on_session_start() keepalive path where we create a task and attach a done callback that calls t.exception() when not cancelled. This test avoids patching asyncio.Task (immutable on newer Pythons) by using a fake loop and fake task object. """ install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) evt = asyncio.Event() client = client_cls( jid="me@example.com", password="x", oneshot=False, targets=[], subject="s", body="b", before_message=None, want_roster=False, roster_timeout=0.0, session_started_evt=evt, ) class _FakeTask: def __init__(self) -> None: self.exception_called = False self.callback_called = False def add_done_callback(self, cb: Any) -> None: self.callback_called = True cb(self) def cancelled(self) -> bool: return False def exception(self) -> None: self.exception_called = True return None class _FakeLoop: def __init__(self) -> None: self.task = _FakeTask() self.create_task_called = False def is_running(self) -> bool: return True def create_task(self, coro: Any) -> _FakeTask: self.create_task_called = True return self.task fake_loop = _FakeLoop() client.loop = fake_loop # type: ignore[assignment] class _FakeCoro: def close(self) -> None: return None def _session_start(*args: object, **kwargs: object) -> _FakeCoro: return _FakeCoro() monkeypatch.setattr( client, "_session_start", _session_start, raising=True ) client._on_session_start() assert fake_loop.create_task_called is True assert fake_loop.task.callback_called is True assert fake_loop.task.exception_called is True finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) with contextlib.suppress(Exception): loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_on_session_start_done_callback_cancelled_returns( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _log_task() early return when task is cancelled.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) evt = asyncio.Event() client = client_cls( jid="me@example.com", password="x", oneshot=False, targets=[], subject="s", body="b", before_message=None, want_roster=False, roster_timeout=0.0, session_started_evt=evt, ) errors: list[str] = [] class _Logger: def error(self, msg: str, *args: Any) -> None: errors.append(msg % args if args else msg) client.logger = _Logger() # type: ignore[assignment] class _FakeTask: def __init__(self) -> None: self.exception_called = False def add_done_callback(self, cb: Any) -> None: cb(self) def cancelled(self) -> bool: return True def exception(self) -> None: self.exception_called = True return RuntimeError("should-not-be-read") class _FakeLoop: def is_running(self) -> bool: return True def create_task(self, coro: Any) -> _FakeTask: return _FakeTask() client.loop = _FakeLoop() # type: ignore[assignment] class _FakeCoro: def close(self) -> None: return None monkeypatch.setattr( client, "_session_start", lambda *_a, **_k: _FakeCoro(), raising=True, ) client._on_session_start() assert errors == [] finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) with contextlib.suppress(Exception): loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_on_session_start_done_callback_logs_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _log_task() error log when task.exception() is not None.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) evt = asyncio.Event() client = client_cls( jid="me@example.com", password="x", oneshot=False, targets=[], subject="s", body="b", before_message=None, want_roster=False, roster_timeout=0.0, session_started_evt=evt, ) errors: list[str] = [] class _Logger: def error(self, msg: str, *args: Any) -> None: errors.append(msg % args if args else msg) client.logger = _Logger() # type: ignore[assignment] class _FakeTask: def add_done_callback(self, cb: Any) -> None: cb(self) def cancelled(self) -> bool: return False def exception(self) -> Exception: return RuntimeError("boom-task") class _FakeLoop: def is_running(self) -> bool: return True def create_task(self, coro: Any) -> _FakeTask: return _FakeTask() client.loop = _FakeLoop() # type: ignore[assignment] class _FakeCoro: def close(self) -> None: return None monkeypatch.setattr( client, "_session_start", lambda *_a, **_k: _FakeCoro(), raising=True, ) client._on_session_start() assert len(errors) == 1 assert "XMPP task failed" in errors[0] finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) with contextlib.suppress(Exception): loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_muc_url_parsing_and_reconstruction() -> None: """MUC targets parse from '#room@conf.host' and round-trip via url().""" n = NotifyXMPP( host="example.com", user="me", password="secret", targets=["#room@conference.example.com", "user@example.com"], ) # MUC target becomes groupchat, regular becomes chat assert ("groupchat", "room@conference.example.com") in n.targets assert ("chat", "user@example.com") in n.targets assert n.want_muc is True # url() encodes MUC '#' as %23 to avoid URL fragment misparse u = n.url() assert "%23room%40conference.example.com" in u # Verify round-trip: parse_url -> NotifyXMPP reconstructs same targets results = NotifyXMPP.parse_url(u) assert results is not None n2 = NotifyXMPP(**results) assert ("groupchat", "room@conference.example.com") in n2.targets assert ("chat", "user@example.com") in n2.targets assert n2.want_muc is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_name_parameter() -> None: """name= is stored, emitted in url(), and round-trips via parse_url().""" # Valid name: stored as-is and emitted in the URL n = NotifyXMPP( host="example.com", user="me", password="secret", name="my_bot", ) assert n.name == "my_bot" u = n.url() assert "name=my_bot" in u # Round-trip through parse_url results = NotifyXMPP.parse_url(u) assert results is not None assert results.get("name") == "my_bot" n2 = NotifyXMPP(**results) assert n2.name == "my_bot" # Invalid name falls back to user (then app_id if no user) n3 = NotifyXMPP( host="example.com", user="me", password="secret", name="bad name!", ) assert n3.name == n3.user # No name provided -> defaults to user (then app_id if no user); # not emitted in URL n4 = NotifyXMPP( host="example.com", user="me", password="secret", ) assert n4.name == n4.user assert "name=" not in n4.url() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_muc_only_no_regular_targets() -> None: """NotifyXMPP with only MUC targets sets want_muc=True.""" n = NotifyXMPP( host="example.com", user="me", password="secret", targets=["#general@conference.example.com"], ) assert n.want_muc is True assert len(n.targets) == 1 assert n.targets[0] == ("groupchat", "general@conference.example.com") @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_session_start_oneshot_muc_join( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover join_muc() in _session_start() for oneshot groupchat target.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) sent: list[dict[str, Any]] = [] join_calls: list[tuple[str, str]] = [] async def fake_join_muc(room: str, nick: str) -> None: join_calls.append((room, nick)) class FakeMucPlugin: def join_muc(self, room: str, nick: str, **kw: Any) -> Any: return fake_join_muc(room, nick) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: client = client_cls( jid="me@example.com", password="pass", oneshot=True, targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", want_muc=True, want_roster=False, roster_timeout=0.0, session_started_evt=None, ) # Inject fake xep_0045 plugin and boundjid (not on FakeClientXMPP) class _BoundJID: user = "me" monkeypatch.setattr(client, "boundjid", _BoundJID(), raising=False) monkeypatch.setattr( client, "plugin", {"xep_0045": FakeMucPlugin()}, raising=False ) monkeypatch.setattr( client, "send_message", lambda **kw: sent.append(kw), raising=True ) monkeypatch.setattr(client, "disconnect", lambda: None, raising=True) loop.run_until_complete(client._session_start()) assert len(join_calls) == 1 assert join_calls[0][0] == "room@conference.example.com" assert join_calls[0][1] == "me" assert len(sent) == 1 assert sent[0]["mtype"] == "groupchat" finally: asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_session_start_oneshot_muc_join_error( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover join_muc() error path: exception is caught, send still happens.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) sent: list[dict[str, Any]] = [] async def join_muc_fail(room: str, nick: str) -> None: raise asyncio.TimeoutError() # simulate timeout class FakeMucPlugin: def join_muc(self, room: str, nick: str, **kw: Any) -> Any: return join_muc_fail(room, nick) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: client = client_cls( jid="me@example.com", password="pass", oneshot=True, targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", want_muc=True, want_roster=False, roster_timeout=0.0, session_started_evt=None, ) class _BoundJID: user = "me" monkeypatch.setattr(client, "boundjid", _BoundJID(), raising=False) monkeypatch.setattr( client, "plugin", {"xep_0045": FakeMucPlugin()}, raising=False ) monkeypatch.setattr( client, "send_message", lambda **kw: sent.append(kw), raising=True ) monkeypatch.setattr(client, "disconnect", lambda: None, raising=True) loop.run_until_complete(client._session_start()) # Message is still sent even when MUC join errors assert len(sent) == 1 assert sent[0]["mtype"] == "groupchat" finally: asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_send_keepalive_async_muc_join( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover join_muc() in _send_keepalive_async() for groupchat target.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", keepalive=True, want_muc=True, ) join_calls: list[tuple[str, str]] = [] sent: list[dict[str, Any]] = [] async def fake_join_muc(room: str, nick: str) -> None: join_calls.append((room, nick)) class FakeBoundJID: user = "me" class FakeMucPlugin: def join_muc(self, room: str, nick: str, **kw: Any) -> Any: return fake_join_muc(room, nick) class _Client: _auth_failed = False boundjid = FakeBoundJID() plugin = {"xep_0045": FakeMucPlugin()} def send_message(self, **kwargs: Any) -> None: sent.append(kwargs) a._client = _Client() async def ok_connect() -> bool: return True monkeypatch.setattr(a, "_connect_if_required", ok_connect, raising=True) result = asyncio.run( a._send_keepalive_async( targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", ) ) assert result is True assert len(join_calls) == 1 assert join_calls[0][0] == "room@conference.example.com" # boundjid.user == "me" -> nick must be taken from boundjid, not nickname assert join_calls[0][1] == "me" assert len(sent) == 1 assert sent[0]["mtype"] == "groupchat" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_send_keepalive_async_muc_join_nick_fallback( monkeypatch: pytest.MonkeyPatch, ) -> None: """When boundjid.user is empty, nick falls back to adapter nickname.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", keepalive=True, want_muc=True, default_nickname="mybot", ) join_calls: list[tuple[str, str]] = [] sent: list[dict[str, Any]] = [] async def fake_join_muc(room: str, nick: str) -> None: join_calls.append((room, nick)) class FakeBoundJID: user = "" # empty -> should fall back to nickname class FakeMucPlugin: def join_muc(self, room: str, nick: str, **kw: Any) -> Any: return fake_join_muc(room, nick) class _Client: _auth_failed = False boundjid = FakeBoundJID() plugin = {"xep_0045": FakeMucPlugin()} def send_message(self, **kwargs: Any) -> None: sent.append(kwargs) a._client = _Client() async def ok_connect() -> bool: return True monkeypatch.setattr(a, "_connect_if_required", ok_connect, raising=True) result = asyncio.run( a._send_keepalive_async( targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", ) ) assert result is True assert len(join_calls) == 1 assert join_calls[0][0] == "room@conference.example.com" # boundjid.user is empty -> nick must fall back to adapter's nickname assert join_calls[0][1] == "mybot" assert len(sent) == 1 assert sent[0]["mtype"] == "groupchat" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_runner_registers_xep_0045_when_want_muc( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _keepalive_runner(): register_plugin('xep_0045') for want_muc.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", keepalive=True, want_muc=True, ) registered: list[str] = [] real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() def run_forever_hook() -> None: client = a._client assert client is not None # Capture what was registered and then stop registered.extend(client.registered_plugins.keys()) monkeypatch.setattr( loop, "run_forever", run_forever_hook, raising=True ) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) a._keepalive_runner() assert "xep_0045" in registered @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_send_keepalive_async_muc_join_error( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover join_muc() exception path in _send_keepalive_async().""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", keepalive=True, want_muc=True, ) sent: list[dict[str, Any]] = [] async def join_muc_fail(room: str, nick: str) -> None: raise asyncio.TimeoutError() # simulate timeout -> exception path class FakeBoundJID: user = "me" class FakeMucPlugin: def join_muc(self, room: str, nick: str, **kw: Any) -> Any: return join_muc_fail(room, nick) class _Client: _auth_failed = False boundjid = FakeBoundJID() plugin = {"xep_0045": FakeMucPlugin()} def send_message(self, **kwargs: Any) -> None: sent.append(kwargs) a._client = _Client() async def ok_connect() -> bool: return True monkeypatch.setattr(a, "_connect_if_required", ok_connect, raising=True) result = asyncio.run( a._send_keepalive_async( targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", ) ) # Exception from join_muc is caught; message is still sent assert result is True assert len(sent) == 1 assert sent[0]["mtype"] == "groupchat" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_client_logger_presence_regression(monkeypatch): """ Regression test: Verify that the internal _Client class automatically receives a 'logger' attribute so _log_task does not AttributeError. """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="ex.com", port=5222 ) # Test standard adapter initialization adapter = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b" ) # Use the same factory the adapter uses to create a client client = xmpp_adapter._build_client( jid="me@example.com", password="x", oneshot=True, logger=adapter.logger ) # This was the point of failure: self.logger must exist assert hasattr(client, "logger") assert client.logger == adapter.logger @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_log_task_crash_regression(monkeypatch): """ Regression test: Verify that _log_task can handle an exception without crashing on a missing logger attribute. """ install_fake_slixmpp(monkeypatch) # Create a client using the production factory client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) client = client_cls( jid="user@example.com", password="pass", oneshot=False, # Provide Logger logger=logging.getLogger("apprise"), ) # Simulate a task failure class MockTask: def cancelled(self): return False def exception(self): return RuntimeError("Simulated XMPP Failure") # Manually trigger the callback logic client.handlers.get("session_start") # Triggers _log_task client._on_session_start() # --------------------------------------------------------------------------- # SCRAM-PLUS / Channel Binding Tests # --------------------------------------------------------------------------- @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_default_true() -> None: """scramplus defaults to True (SCRAM-PLUS enabled).""" n = NotifyXMPP(host="example.com", user="me@example.com", password="x") assert n.scramplus is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_explicit_false() -> None: """scramplus=False is stored and reflected in url().""" n = NotifyXMPP( host="example.com", user="me@example.com", password="x", scramplus=False, ) assert n.scramplus is False u = n.url() assert "scramplus=no" in u @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_url_round_trip() -> None: """scramplus round-trips through url() and parse_url().""" n = NotifyXMPP( host="example.com", user="me@example.com", password="secret", scramplus=False, ) u = n.url() r = NotifyXMPP.parse_url(u) assert r is not None assert r["scramplus"] is False n2 = NotifyXMPP(**r) assert n2.scramplus is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_parse_url_no() -> None: """?scramplus=no is parsed and stored correctly.""" r = NotifyXMPP.parse_url( "xmpps://me:pass@example.com/a@example.com?scramplus=no" ) assert r is not None assert r["scramplus"] is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_parse_url_yes() -> None: """?scramplus=yes is parsed and stored correctly.""" r = NotifyXMPP.parse_url( "xmpps://me:pass@example.com/a@example.com?scramplus=yes" ) assert r is not None assert r["scramplus"] is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_passes_to_config( monkeypatch: pytest.MonkeyPatch, ) -> None: """scramplus=False propagates use_channel_binding=False to XMPPConfig.""" captured: dict = {} class _Adapter: def __init__(self, config: Any, **kwargs: Any) -> None: captured["use_channel_binding"] = config.use_channel_binding def process(self) -> bool: return True monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _Adapter, raising=True) apobj = Apprise() apobj.add("xmpps://user:pass@example.com/joe?scramplus=no") apobj.notify("hello") assert captured["use_channel_binding"] is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_true_passes_to_config( monkeypatch: pytest.MonkeyPatch, ) -> None: """Default scramplus=True propagates use_channel_binding=True.""" captured: dict = {} class _Adapter: def __init__(self, config: Any, **kwargs: Any) -> None: captured["use_channel_binding"] = config.use_channel_binding def process(self) -> bool: return True monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _Adapter, raising=True) apobj = Apprise() apobj.add("xmpps://user:pass@example.com/joe") apobj.notify("hello") assert captured["use_channel_binding"] is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_disable_channel_binding_filters_plus_mechs() -> None: """_disable_channel_binding patches _send_auth to strip -PLUS mechs.""" from types import SimpleNamespace captured: list[set] = [] plugin = SimpleNamespace( mech_list={"SCRAM-SHA-256-PLUS", "SCRAM-SHA-256", "SCRAM-SHA-1"}, ) def orig_send_auth() -> None: captured.append(set(plugin.mech_list)) plugin._send_auth = orig_send_auth class _Client: def __getitem__(self, key: str) -> Any: if key == "feature_mechanisms": return plugin raise KeyError(key) xmpp_adapter._disable_channel_binding(_Client()) # Trigger the patched _send_auth plugin._send_auth() assert len(captured) == 1 assert "SCRAM-SHA-256-PLUS" not in captured[0] assert "SCRAM-SHA-256" in captured[0] assert "SCRAM-SHA-1" in captured[0] @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_disable_channel_binding_safe_when_no_plugin() -> None: """_disable_channel_binding no-ops when feature_mechanisms is absent.""" class _BadClient: def __getitem__(self, key: str) -> Any: raise KeyError(key) # Must not raise xmpp_adapter._disable_channel_binding(_BadClient()) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_disabled_process( monkeypatch: pytest.MonkeyPatch, ) -> None: """process() calls _disable_channel_binding when binding disabled.""" install_fake_slixmpp(monkeypatch) patched: list[bool] = [] orig_disable = xmpp_adapter._disable_channel_binding def spy_disable(client: Any) -> None: patched.append(True) orig_disable(client) monkeypatch.setattr( xmpp_adapter, "_disable_channel_binding", spy_disable, raising=True ) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, use_channel_binding=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1.0, ) assert a.process() is True assert len(patched) == 1 @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_enabled_no_patch( monkeypatch: pytest.MonkeyPatch, ) -> None: """process() skips _disable_channel_binding when binding enabled.""" install_fake_slixmpp(monkeypatch) patched: list[bool] = [] orig_disable = xmpp_adapter._disable_channel_binding def spy_disable(client: Any) -> None: patched.append(True) orig_disable(client) monkeypatch.setattr( xmpp_adapter, "_disable_channel_binding", spy_disable, raising=True ) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, use_channel_binding=True, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1.0, ) assert a.process() is True # No patching when SCRAM-PLUS is enabled assert len(patched) == 0 @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_disabled_keepalive_runner( monkeypatch: pytest.MonkeyPatch, ) -> None: """_keepalive_runner calls _disable_channel_binding when disabled.""" install_fake_slixmpp(monkeypatch) patched: list[bool] = [] orig_disable = xmpp_adapter._disable_channel_binding def spy_disable(client: Any) -> None: patched.append(True) orig_disable(client) monkeypatch.setattr( xmpp_adapter, "_disable_channel_binding", spy_disable, raising=True ) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, use_channel_binding=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1.0, keepalive=True, ) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() def run_forever_hook() -> None: pass monkeypatch.setattr( loop, "run_forever", run_forever_hook, raising=True ) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) a._keepalive_runner() assert len(patched) == 1 @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_failed_auth_stanza_sets_channel_binding_flag( monkeypatch: pytest.MonkeyPatch, ) -> None: """_failed_auth sets _channel_binding_failed when phrase detected.""" install_fake_slixmpp(monkeypatch) # Clear the subclass cache so we pick up the patched FakeClientXMPP. xmpp_adapter._CLIENT_SUBCLASS_CACHE.clear() # Build a _Client instance directly using the fake base class. _ClientCls = xmpp_adapter._get_client_subclass(FakeClientXMPP) client = _ClientCls( jid="me@example.com", password="x", oneshot=True, logger=logging.getLogger("test"), ) # Verify initial state. assert client._auth_failed is False assert client._channel_binding_failed is False # Simulate a failed_auth stanza containing the channel binding phrase. fake_stanza = ( "" "Invalid channel binding" ) client._failed_auth(fake_stanza) assert client._auth_failed is True assert client._channel_binding_failed is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_failed_auth_plain_does_not_set_channel_binding_flag( monkeypatch: pytest.MonkeyPatch, ) -> None: """_failed_auth skips _channel_binding_failed for plain auth failures.""" install_fake_slixmpp(monkeypatch) xmpp_adapter._CLIENT_SUBCLASS_CACHE.clear() _ClientCls = xmpp_adapter._get_client_subclass(FakeClientXMPP) client = _ClientCls( jid="me@example.com", password="x", oneshot=True, logger=logging.getLogger("test"), ) # Stanza does not mention channel binding. fake_stanza = "" client._failed_auth(fake_stanza) assert client._auth_failed is True assert client._channel_binding_failed is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_raises_channel_binding_error( monkeypatch: pytest.MonkeyPatch, ) -> None: """process() raises XMPPChannelBindingError when channel binding fails.""" install_fake_slixmpp(monkeypatch) xmpp_adapter._CLIENT_SUBCLASS_CACHE.clear() # Patch FakeClientXMPP.connect to fire failed_auth with a channel # binding stanza so the adapter detects the failure. orig_connect = FakeClientXMPP.connect def connect_cb_fail(self, **kwargs: Any) -> asyncio.Future[bool]: loop = self.loop or asyncio.get_running_loop() if self.disconnected is None: self.disconnected = loop.create_future() fut: asyncio.Future[bool] = loop.create_future() fut.set_result(True) def _fire_failed_auth() -> None: # Simulate slixmpp firing failed_auth with a channel binding # stanza before setting up session_start. failed_handler = self.handlers.get("failed_auth") if failed_handler: fake_stanza = ( "" "Invalid channel binding" ) failed_handler(fake_stanza) # Complete disconnect so the adapter loop unblocks. if self.disconnected is not None and not self.disconnected.done(): self.disconnected.set_result(True) loop.call_soon(_fire_failed_auth) return fut monkeypatch.setattr(FakeClientXMPP, "connect", connect_cb_fail) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("chat", "joe@example.com")], subject="s", body="b", timeout=5.0, ) with pytest.raises(xmpp_adapter.XMPPChannelBindingError): a.process() monkeypatch.setattr(FakeClientXMPP, "connect", orig_connect) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_send_catches_channel_binding_error_and_logs( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """base.py send() catches XMPPChannelBindingError and logs the hint.""" class _RaisingAdapter: def __init__(self, **kwargs: Any) -> None: pass def process(self) -> bool: raise xmpp_adapter.XMPPChannelBindingError() monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _RaisingAdapter) monkeypatch.setattr( xmpp_base, "SLIXMPP_SUPPORT_AVAILABLE", True, raising=False ) monkeypatch.setattr(xmpp_base.NotifyXMPP, "enabled", True, raising=False) n = NotifyXMPP(host="example.com", user="me", password="x") # See test_xmpp_invalid_targets_logged for why this is needed: older # pytest releases don't un-suppress a prior logging.disable(CRITICAL) # left by another test module's import-time call. logging.disable(logging.NOTSET) try: with caplog.at_level(logging.WARNING, logger="apprise"): result = n.send(body="hi") assert result is False assert "scramplus=no" in caplog.text finally: logging.disable(logging.CRITICAL) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_send_channel_binding_error_scramplus_already_disabled( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """No ?scramplus=no hint when scramplus is already False.""" class _RaisingAdapter: def __init__(self, **kwargs: Any) -> None: pass def process(self) -> bool: raise xmpp_adapter.XMPPChannelBindingError() monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _RaisingAdapter) monkeypatch.setattr( xmpp_base, "SLIXMPP_SUPPORT_AVAILABLE", True, raising=False ) monkeypatch.setattr(xmpp_base.NotifyXMPP, "enabled", True, raising=False) # scramplus explicitly disabled -- the hint must NOT repeat the advice n = NotifyXMPP( host="example.com", user="me", password="x", scramplus=False ) # See test_xmpp_invalid_targets_logged for why this is needed: older # pytest releases don't un-suppress a prior logging.disable(CRITICAL) # left by another test module's import-time call. logging.disable(logging.NOTSET) try: with caplog.at_level(logging.WARNING, logger="apprise"): result = n.send(body="hi") assert result is False # The "Add ?scramplus=no" suggestion must not appear when already # set assert "Add ?scramplus=no" not in caplog.text # A compatibility-oriented message must appear instead assert "compatibility" in caplog.text finally: logging.disable(logging.CRITICAL) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_connect_if_required_channel_binding_early_raises( monkeypatch: pytest.MonkeyPatch, ) -> None: """_connect_if_required() raises early when _channel_binding_failed.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop # Client with channel binding already flagged before connect. a._client = SimpleNamespace( _channel_binding_failed=True, _auth_failed=False ) a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() with pytest.raises(xmpp_adapter.XMPPChannelBindingError): run_on_loop(loop, a._connect_if_required()) finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_connect_if_required_channel_binding_bottom_raises( monkeypatch: pytest.MonkeyPatch, ) -> None: """_connect_if_required() raises XMPPChannelBindingError check.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() fut = loop.create_future() fut.set_result(True) class _Client: def __init__(self) -> None: self._channel_binding_failed = False self._auth_failed = False def connect(self, **kwargs: Any) -> Any: return fut a._client = _Client() # Patch wait_for to set channel-binding flag after session wait. real_wait_for = xmpp_adapter.asyncio.wait_for calls: dict[str, int] = {"n": 0} async def wait_for_patched( aw: Any, timeout: Optional[float] = None ) -> Any: calls["n"] += 1 if calls["n"] == 2: assert a._session_started is not None a._session_started.set() assert isinstance(a._client, _Client) a._client._channel_binding_failed = True return await real_wait_for(aw, timeout=timeout) monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", wait_for_patched, raising=True ) with pytest.raises(xmpp_adapter.XMPPChannelBindingError): run_on_loop(loop, a._connect_if_required()) finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_send_message_keepalive_reraises_channel_binding_error( monkeypatch: pytest.MonkeyPatch, ) -> None: """send_message() keepalive path re-raises XMPPChannelBindingError.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("chat", "t@example.com")], subject="s", body="b", timeout=5.0, keepalive=True, ) monkeypatch.setattr( a, "_ensure_keepalive_worker", lambda: True, raising=True ) class _Loop: pass a._loop = _Loop() def run_coroutine_threadsafe_cb_fail(coro: Any, loop: Any) -> Any: # Close the coroutine to avoid RuntimeWarning about it not being run. with contextlib.suppress(Exception): coro.close() class _Fut: def result(self, timeout: Optional[float] = None) -> bool: raise xmpp_adapter.XMPPChannelBindingError() return _Fut() monkeypatch.setattr( xmpp_adapter.asyncio, "run_coroutine_threadsafe", run_coroutine_threadsafe_cb_fail, raising=True, ) with pytest.raises(xmpp_adapter.XMPPChannelBindingError): a.send_message() caronc-apprise-dfec414/tests/test_plugin_youlmk.py000066400000000000000000000431721526720371000225770ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import loads # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAsset, NotifyType from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.youlmk import NotifyYouLMK, youlmk_priority logging.disable(logging.CRITICAL) # The handoff's placeholder key and a token of the same shape; neither is real TOKEN = "ylk_" + "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" KEY = "k_7hq2nk3f9qd8w4" # Our Testing URLs apprise_url_tests = ( ( "youlmk://", { # No token specified "instance": AppriseImproperlyConfigured, }, ), ( "youlmk://invalid", { # Neither a ylk_ token nor a k_ key "instance": AppriseImproperlyConfigured, }, ), ( "youlmk://ylk_tooshort", { # The token's shape is ylk_ and 32 characters "instance": AppriseImproperlyConfigured, }, ), ( f"youlmk://{TOKEN}", { # A valid bearer token "instance": NotifyYouLMK, # Our expected url(privacy=True) startswith() response: "privacy_url": "youlmk://y...6/", }, ), ( f"youlmk://{KEY}", { # A valid URL key "instance": NotifyYouLMK, "privacy_url": "youlmk://k...4/", }, ), ( f"youlmk://?token={TOKEN}", { # The token provided as a query argument "instance": NotifyYouLMK, "privacy_url": "youlmk://y...6/", }, ), ( f"youlmk://{TOKEN}?priority=critical", { # A forced priority "instance": NotifyYouLMK, }, ), ( f"youlmk://{TOKEN}?priority=bogus", { # An unknown priority "instance": AppriseImproperlyConfigured, }, ), ( f"youlmk://{TOKEN}?failure=critical&info=low", { # Per-type priorities "instance": NotifyYouLMK, }, ), ( f"youlmk://{TOKEN}?warning=bogus", { # An unknown per-type priority "instance": AppriseImproperlyConfigured, }, ), ( f"youlmk://{TOKEN}?url=https://example.com/runs/1&group=deploys", { # A link and a group "instance": NotifyYouLMK, }, ), ( f"youlmk://{TOKEN}?url=https://example.com&url_label=View%20run", { # A link with its own wording "instance": NotifyYouLMK, }, ), ( f"youlmk://{TOKEN}?image=yes", { # The notification-type image rides along "instance": NotifyYouLMK, }, ), ( f"https://youlmk.com/k/{KEY}", { # The key door pasted in as-is "instance": NotifyYouLMK, "privacy_url": "youlmk://k...4/", }, ), ( f"youlmk://{TOKEN}", { "instance": NotifyYouLMK, # Throw an exception on the send "test_requests_exceptions": True, }, ), ( f"youlmk://{TOKEN}", { "instance": NotifyYouLMK, # An unknown key "response": False, "requests_response_code": 401, }, ), ( f"youlmk://{TOKEN}", { "instance": NotifyYouLMK, # The trial's notifications are used "response": False, "requests_response_code": 402, }, ), ( f"youlmk://{TOKEN}", { "instance": NotifyYouLMK, # A code the map does not name "response": False, "requests_response_code": 999, }, ), ) def test_plugin_youlmk_urls(): """NotifyYouLMK() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_youlmk_edge_cases(): """NotifyYouLMK() Edge Cases.""" # No token with pytest.raises(AppriseImproperlyConfigured): NotifyYouLMK(token=None) # An empty token with pytest.raises(AppriseImproperlyConfigured): NotifyYouLMK(token=" ") # The wrong shape with pytest.raises(AppriseImproperlyConfigured): NotifyYouLMK(token="ylk_" + "x" * 31) # A bad forced priority with pytest.raises(AppriseImproperlyConfigured): NotifyYouLMK(token=TOKEN, priority="bogus") # A bad per-type priority with pytest.raises(AppriseImproperlyConfigured): NotifyYouLMK(token=TOKEN, failure="bogus") # Both shapes are accepted, and the case of the token is kept obj = NotifyYouLMK(token=TOKEN.upper()) assert obj.token == TOKEN.upper() assert obj.is_token is True obj = NotifyYouLMK(token=KEY) assert obj.is_token is False @mock.patch("requests.post") def test_plugin_youlmk_send_token(mock_post): """NotifyYouLMK() Send with the bearer token.""" response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response obj = Apprise.instantiate(f"youlmk://{TOKEN}") assert isinstance(obj, NotifyYouLMK) assert obj.notify(title="backup finished", body="4.2 GB") is True assert mock_post.call_count == 1 # The bearer door, with the token in the header assert mock_post.call_args[0][0] == "https://youlmk.com/v1/notify" headers = mock_post.call_args[1]["headers"] assert headers["Authorization"] == f"Bearer {TOKEN}" assert headers["Content-Type"] == "application/json; charset=utf-8" payload = loads(mock_post.call_args[1]["data"]) assert payload["title"] == "backup finished" assert payload["body"] == "4.2 GB" # INFO maps to normal by default assert payload["priority"] == "normal" assert "url" not in payload assert "group" not in payload @mock.patch("requests.post") def test_plugin_youlmk_send_key(mock_post): """NotifyYouLMK() Send with the URL key.""" response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response obj = Apprise.instantiate(f"youlmk://{KEY}") assert isinstance(obj, NotifyYouLMK) assert obj.notify(title="deployed", body="v2.4.1") is True assert mock_post.call_count == 1 # The key door, with the key in the address and no bearer header assert mock_post.call_args[0][0] == f"https://youlmk.com/k/{KEY}" assert "Authorization" not in mock_post.call_args[1]["headers"] payload = loads(mock_post.call_args[1]["data"]) assert payload["title"] == "deployed" assert payload["priority"] == "normal" @mock.patch("requests.post") def test_plugin_youlmk_notify_type_mapping(mock_post): """NotifyYouLMK() Priority derived from the notification type.""" response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response obj = Apprise.instantiate(f"youlmk://{TOKEN}") expected = { NotifyType.INFO: "normal", NotifyType.SUCCESS: "normal", NotifyType.WARNING: "high", NotifyType.FAILURE: "high", } for notify_type, priority in expected.items(): mock_post.reset_mock() assert obj.notify(title="t", body="b", notify_type=notify_type) is True payload = loads(mock_post.call_args[1]["data"]) assert payload["priority"] == priority @mock.patch("requests.post") def test_plugin_youlmk_priority_override(mock_post): """NotifyYouLMK() A forced priority wins over the type.""" response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response obj = Apprise.instantiate(f"youlmk://{TOKEN}?priority=crit") assert obj.priority == "critical" assert obj.notify(title="t", body="b", notify_type=NotifyType.INFO) is True payload = loads(mock_post.call_args[1]["data"]) assert payload["priority"] == "critical" @mock.patch("requests.post") def test_plugin_youlmk_per_type_priorities(mock_post): """NotifyYouLMK() Per-type priorities from the URL.""" response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response obj = Apprise.instantiate( f"youlmk://{TOKEN}?info=low&success=l&warning=n&failure=critical" ) assert obj.priority is None assert obj.priority_map[NotifyType.INFO] == "low" assert obj.priority_map[NotifyType.SUCCESS] == "low" assert obj.priority_map[NotifyType.WARNING] == "normal" assert obj.priority_map[NotifyType.FAILURE] == "critical" assert ( obj.notify(title="t", body="b", notify_type=NotifyType.FAILURE) is True ) payload = loads(mock_post.call_args[1]["data"]) assert payload["priority"] == "critical" # Every changed mapping is carried by the URL url = obj.url() assert "info=low" in url assert "success=low" in url assert "warning=normal" in url assert "failure=critical" in url @mock.patch("requests.post") def test_plugin_youlmk_link_and_group(mock_post): """NotifyYouLMK() A link and a group travel with the payload.""" response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response obj = Apprise.instantiate( f"youlmk://{TOKEN}?url=https://example.com/runs/1&group=deploys" ) assert obj.link == "https://example.com/runs/1" assert obj.group == "deploys" assert obj.notify(title="deployed", body="v2.4.1") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["url"] == "https://example.com/runs/1" assert payload["group"] == "deploys" # And come back out of the URL url = obj.url() assert "url=https%3A%2F%2Fexample.com%2Fruns%2F1" in url assert "group=deploys" in url @mock.patch("requests.post") def test_plugin_youlmk_empty_title(mock_post): """NotifyYouLMK() A title is always sent.""" response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response # The application description stands in for a missing title asset = AppriseAsset(app_desc="My App") obj = Apprise.instantiate(f"youlmk://{TOKEN}", asset=asset) assert obj.notify(body="b", title="") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["title"] == "My App" # And the application id when there is no description either mock_post.reset_mock() asset = AppriseAsset(app_desc="", app_id="apprise") obj = Apprise.instantiate(f"youlmk://{TOKEN}", asset=asset) assert obj.notify(body="b", title="") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["title"] == "apprise" @mock.patch("requests.post") def test_plugin_youlmk_failures(mock_post): """NotifyYouLMK() The door's own codes read as failures.""" for code in (400, 401, 402, 410, 413, 422, 429, 503): response = mock.Mock() response.status_code = code response.content = b'{"error":"code"}' mock_post.return_value = response obj = Apprise.instantiate(f"youlmk://{TOKEN}") assert obj.notify(title="t", body="b") is False # A connection error mock_post.side_effect = requests.RequestException("down") obj = Apprise.instantiate(f"youlmk://{TOKEN}") assert obj.notify(title="t", body="b") is False @mock.patch("requests.post") def test_plugin_youlmk_url_roundtrip(mock_post): """NotifyYouLMK() A URL survives a round trip.""" response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response source = f"youlmk://{TOKEN}?priority=high&failure=critical&group=g" obj = Apprise.instantiate(source) url = obj.url() assert url.startswith(f"youlmk://{TOKEN}/?") assert "priority=high" in url assert "failure=critical" in url assert "group=g" in url again = Apprise.instantiate(url) assert isinstance(again, NotifyYouLMK) assert again.token == TOKEN assert again.priority == "high" assert again.priority_map[NotifyType.FAILURE] == "critical" assert again.group == "g" assert again.url_identifier == obj.url_identifier # Two credentials are two identifiers other = Apprise.instantiate(f"youlmk://{KEY}") assert other.url_identifier != obj.url_identifier def test_plugin_youlmk_parse_url(): """NotifyYouLMK() parse_url().""" # An unparseable (non-string) URL returns None assert NotifyYouLMK.parse_url(None) is None results = NotifyYouLMK.parse_url(f"youlmk://{TOKEN}") assert results["token"] == TOKEN assert "priority" not in results assert "link" not in results results = NotifyYouLMK.parse_url( f"youlmk://ignored?token={TOKEN}&priority=low&info=high" "&url=https://example.com&group=g" ) assert results["token"] == TOKEN assert results["priority"] == "low" assert results["info"] == "high" assert results["link"] == "https://example.com" assert results["group"] == "g" @mock.patch("requests.post") def test_plugin_youlmk_url_label(mock_post): """NotifyYouLMK() The primary button carries its own wording.""" response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response obj = Apprise.instantiate( f"youlmk://{TOKEN}?url=https://example.com&url_label=View%20run" ) assert obj.url_label == "View run" assert obj.notify(title="t", body="b") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["url_label"] == "View run" # And it comes back out of the URL assert "url_label=View%20run" in obj.url() # Anything over the payload's limit is trimmed obj = NotifyYouLMK( token=TOKEN, link="https://example.com", url_label="x" * 40 ) assert obj.url_label == "x" * 24 # A label with no address to sit on is left out of the payload mock_post.reset_mock() obj = NotifyYouLMK(token=TOKEN, url_label="View run") assert obj.notify(title="t", body="b") is True payload = loads(mock_post.call_args[1]["data"]) assert "url" not in payload assert "url_label" not in payload @mock.patch("requests.post") def test_plugin_youlmk_image(mock_post): """NotifyYouLMK() The notification-type image is opt-in.""" response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response # Off by default obj = Apprise.instantiate(f"youlmk://{TOKEN}") assert obj.include_image is False assert obj.notify(title="t", body="b") is True assert "image" not in loads(mock_post.call_args[1]["data"]) assert "image=no" in obj.url() # On when asked for mock_post.reset_mock() obj = Apprise.instantiate(f"youlmk://{TOKEN}?image=yes") assert obj.include_image is True assert obj.notify(title="t", body="b") is True assert loads(mock_post.call_args[1]["data"])["image"].startswith("http") assert "image=yes" in obj.url() # An asset with no imagery leaves the field out mock_post.reset_mock() asset = AppriseAsset(image_url_mask=None, image_path_mask=None) obj = Apprise.instantiate(f"youlmk://{TOKEN}?image=yes", asset=asset) assert obj.notify(title="t", body="b") is True assert "image" not in loads(mock_post.call_args[1]["data"]) def test_plugin_youlmk_parse_native_url(): """NotifyYouLMK() parse_native_url().""" # The key door pasted straight from the source's Key screen results = NotifyYouLMK.parse_native_url(f"https://youlmk.com/k/{KEY}") assert results["token"] == KEY # A www. prefix, a trailing slash and query arguments all travel results = NotifyYouLMK.parse_native_url( f"https://www.youlmk.com/k/{KEY}/?priority=low" ) assert results["token"] == KEY assert results["priority"] == "low" # The bearer door is not a key door assert ( NotifyYouLMK.parse_native_url("https://youlmk.com/v1/notify") is None ) def test_plugin_youlmk_priority_resolution(): """youlmk_priority() resolves short forms and refuses the rest.""" assert youlmk_priority("low") == "low" assert youlmk_priority("l") == "low" assert youlmk_priority("NORMAL") == "normal" assert youlmk_priority("hi") == "high" assert youlmk_priority("crit") == "critical" assert youlmk_priority("") is None assert youlmk_priority(" ") is None assert youlmk_priority("bogus") is None caronc-apprise-dfec414/tests/test_plugin_zalo.py000066400000000000000000000243421526720371000222220ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import json import logging from unittest import mock from urllib.parse import urlparse from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyFormat from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.zalo import NotifyZalo logging.disable(logging.CRITICAL) # A realistic Bot Token TOKEN = "123456789:abcdefghijKLMNOPqrst" # A successful Zalo response OK_RESPONSE = json.dumps( {"ok": True, "result": {"message_id": "82599fa32f", "date": 1749632637}} ).encode("utf-8") # Our Testing URLs apprise_url_tests = ( ( "zalo://", { # No token specified "instance": TypeError, }, ), ( "zalo://123456789", { # Only half of a token "instance": AppriseImproperlyConfigured, }, ), ( "zalo://abc:def/chat1", { # The bot id must be numeric "instance": TypeError, }, ), ( f"zalo://{TOKEN}", { # No Chat IDs; we load but have nothing to notify "instance": NotifyZalo, "notify_response": False, }, ), ( f"zalo://{TOKEN}/chat1", { "instance": NotifyZalo, "requests_response_text": OK_RESPONSE, # Our expected url(privacy=True) startswith() response "privacy_url": "zalo://1...t/chat1", }, ), ( "zalo://123456789%3Aabcdefghij/chat1/chat.2/", { # An encoded colon is accepted too "instance": NotifyZalo, }, ), ( "zalo://123456789/abcdefghij/chat1/", { # A slash in place of the colon is accepted too "instance": NotifyZalo, }, ), ( f"zalo://chat1/?token={TOKEN}&to=chat2,chat3", { # Token and Chat IDs given as arguments "instance": NotifyZalo, "privacy_url": "zalo://1...t/chat1/chat2/chat3", }, ), ( f"zalo://?token={TOKEN}&to=chat1", { # No host at all "instance": NotifyZalo, }, ), ( f"zalo://{TOKEN}/chat1/invalid!id", { # One bad Chat ID is dropped; the good one still works "instance": NotifyZalo, }, ), ( f"zalo://{TOKEN}/invalid!id", { # Nothing valid to notify "instance": NotifyZalo, "notify_response": False, }, ), ( f"zalo://{TOKEN}/chat1", { # Zalo refused the message "instance": NotifyZalo, "requests_response_text": { "ok": False, "error_code": 400, "description": "Bad Request", }, "response": False, }, ), ( f"zalo://{TOKEN}/chat1", { "instance": NotifyZalo, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( f"zalo://{TOKEN}/chat1", { "instance": NotifyZalo, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( f"zalo://{TOKEN}/chat1", { "instance": NotifyZalo, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def _mk_resp(content=OK_RESPONSE, code=requests.codes.ok): """Build a mock Zalo response.""" r = mock.Mock() r.status_code = code r.content = content return r def test_plugin_zalo_urls(): """NotifyZalo() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_zalo_init(): """NotifyZalo() validates its token and Chat IDs.""" # Bad tokens are refused for token in (None, "", "123456789", "abc:def", "123:bad token"): with pytest.raises(AppriseImproperlyConfigured): NotifyZalo(token=token, targets="chat1") # Good and bad Chat IDs are kept apart obj = NotifyZalo(token=TOKEN, targets=["chat1", "a.b-c_d", "bad!id"]) assert obj.targets == ["a.b-c_d", "chat1"] assert obj.invalid_targets == ["bad!id"] assert len(obj) == 2 # No targets still counts as one notification assert len(NotifyZalo(token=TOKEN)) == 1 def test_plugin_zalo_round_trip(): """NotifyZalo() url() reloads into the same object.""" obj = NotifyZalo( token=TOKEN, targets=["chat1", "bad!id"], format=NotifyFormat.TEXT ) url = obj.url() assert url.startswith(f"zalo://{TOKEN}/chat1/bad%21id?") assert "format=text" in url result = NotifyZalo.parse_url(url) assert result["token"] == TOKEN obj2 = NotifyZalo(**result) assert obj2.url_identifier == obj.url_identifier assert obj2.targets == obj.targets assert obj2.invalid_targets == obj.invalid_targets assert obj2.url() == url # Different targets are still the same bot assert ( NotifyZalo(token=TOKEN, targets="other").url_identifier == obj.url_identifier ) # Nothing to parse assert NotifyZalo.parse_url(None) is None @mock.patch("requests.post") def test_plugin_zalo_payload(mock_post): """NotifyZalo() posts one message to each Chat ID.""" mock_post.return_value = _mk_resp() obj = NotifyZalo(token=TOKEN, targets=["chat1", "chat2"]) assert obj.notify(body="body", title="title") assert mock_post.call_count == 2 # The Bot Token is part of the API path url = mock_post.call_args_list[0][0][0] assert urlparse(url).hostname == "bot-api.zaloplatforms.com" assert urlparse(url).path == f"/bot{TOKEN}/sendMessage" # Each Chat ID gets its own copy first = json.loads(mock_post.call_args_list[0][1]["data"]) second = json.loads(mock_post.call_args_list[1][1]["data"]) assert first["chat_id"] == "chat1" assert second["chat_id"] == "chat2" # Undeclared content takes the Markdown path, title on its own line assert first["text"] == "title\r\nbody" assert first["parse_mode"] == "markdown" @mock.patch("requests.post") def test_plugin_zalo_partial_failure(mock_post): """NotifyZalo() reports a failure when one Chat ID is refused.""" mock_post.side_effect = ( _mk_resp(), _mk_resp(b"", requests.codes.unauthorized), ) obj = NotifyZalo(token=TOKEN, targets=["chat1", "chat2"]) assert not obj.notify(body="body") assert mock_post.call_count == 2 # An empty body with a 200 is still a success mock_post.side_effect = None mock_post.return_value = _mk_resp(None) assert obj.notify(body="body") @mock.patch("requests.post") def test_plugin_zalo_formats(mock_post): """NotifyZalo() asks Zalo to style Markdown and leaves text alone.""" mock_post.return_value = _mk_resp() obj = NotifyZalo(token=TOKEN, targets="chat1") # Markdown keeps its markup and turns on parse_mode assert obj.notify( body="**bold** _it_ \\*", title="T & ", body_format=NotifyFormat.MARKDOWN, ) payload = json.loads(mock_post.call_args[1]["data"]) assert payload["parse_mode"] == "markdown" assert payload["text"].endswith("**bold** _it_ \\*") assert payload["text"].startswith("# T") # HTML is converted to Markdown assert obj.notify( body="bold it", body_format=NotifyFormat.HTML ) payload = json.loads(mock_post.call_args[1]["data"]) assert payload["parse_mode"] == "markdown" assert payload["text"] == "**bold** *it*" # Plain text reaches Zalo exactly as it was typed assert obj.notify( body="5 * 3 = a_b # x", body_format=NotifyFormat.TEXT ) payload = json.loads(mock_post.call_args[1]["data"]) assert payload["text"] == "5 * 3 = a_b # x" assert "parse_mode" not in payload # Content with no declared format is not touched assert obj.notify(body="**raw** \\*") payload = json.loads(mock_post.call_args[1]["data"]) assert payload["text"] == "**raw** \\*" assert payload["parse_mode"] == "markdown" # ?format=text sends passthrough content as plain text obj = NotifyZalo(token=TOKEN, targets="chat1", format="text") assert obj.notify(body="**raw**") payload = json.loads(mock_post.call_args[1]["data"]) assert payload["text"] == "**raw**" assert "parse_mode" not in payload @mock.patch("requests.post") def test_plugin_zalo_apprise_integration(mock_post): """Apprise() loads and notifies a zalo:// URL.""" mock_post.return_value = _mk_resp() apobj = Apprise() assert apobj.add(f"zalo://{TOKEN}/chat1") assert apobj.notify(body="hello") assert mock_post.call_count == 1 caronc-apprise-dfec414/tests/test_plugin_zoom.py000066400000000000000000000333421526720371000222410ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyType from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.zoom import ( ZOOM_MODE_DEFAULT, NotifyZoom, ZoomMode, ) logging.disable(logging.CRITICAL) # Reusable test credentials WEBHOOK_ID = "abcHook123" TOKEN = "xyzVerify456" # Our Testing URLs apprise_url_tests = ( # Missing everything ( "zoom://", { "instance": AppriseImproperlyConfigured, }, ), # Empty user/host segment ( "zoom://:@/", { "instance": AppriseImproperlyConfigured, }, ), # Missing token (webhook_id present but no path segment) ( "zoom://{}".format(WEBHOOK_ID), { "instance": AppriseImproperlyConfigured, }, ), # Valid full-mode (default) ( "zoom://{}/{}".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "privacy_url": "zoom://a...3/x...6/", }, ), # Explicit mode=full ( "zoom://{}/{}?mode=full".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "privacy_url": "zoom://a...3/x...6/", }, ), # Explicit mode=simple ( "zoom://{}/{}?mode=simple".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "privacy_url": "zoom://a...3/x...6/", }, ), # Invalid mode ( "zoom://{}/{}?mode=invalid".format(WEBHOOK_ID, TOKEN), { "instance": AppriseImproperlyConfigured, }, ), # Native URL with ?token= query param ( "https://inbots.zoom.us/incoming/hook/{}?token={}".format( WEBHOOK_ID, TOKEN ), { "instance": NotifyZoom, }, ), # Full mode: HTTP 500 ( "zoom://{}/{}".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "response": False, "requests_response_code": (requests.codes.internal_server_error), }, ), # Full mode: unusual status code ( "zoom://{}/{}".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "response": False, "requests_response_code": 999, }, ), # Full mode: request exception ( "zoom://{}/{}".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "test_requests_exceptions": True, }, ), # Simple mode: HTTP 500 ( "zoom://{}/{}?mode=simple".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "response": False, "requests_response_code": (requests.codes.internal_server_error), }, ), # Simple mode: unusual status code ( "zoom://{}/{}?mode=simple".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "response": False, "requests_response_code": 999, }, ), # Simple mode: request exception ( "zoom://{}/{}?mode=simple".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "test_requests_exceptions": True, }, ), ) def test_plugin_zoom_urls(): """NotifyZoom() Apprise URLs.""" AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_zoom_init(): """Test NotifyZoom initialization and validation.""" # Missing webhook_id with pytest.raises(AppriseImproperlyConfigured): NotifyZoom(webhook_id=None, token=TOKEN) # Empty webhook_id with pytest.raises(AppriseImproperlyConfigured): NotifyZoom(webhook_id="", token=TOKEN) # Webhook ID with invalid characters (slash) with pytest.raises(AppriseImproperlyConfigured): NotifyZoom(webhook_id="bad/id", token=TOKEN) # Missing token with pytest.raises(AppriseImproperlyConfigured): NotifyZoom(webhook_id=WEBHOOK_ID, token=None) # Empty token with pytest.raises(AppriseImproperlyConfigured): NotifyZoom(webhook_id=WEBHOOK_ID, token="") # Invalid mode with pytest.raises(AppriseImproperlyConfigured): NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="bogus") # Empty string mode is also invalid (must not silently resolve to simple) with pytest.raises(AppriseImproperlyConfigured): NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="") # Whitespace-only mode is also invalid with pytest.raises(AppriseImproperlyConfigured): NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode=" ") # Valid: default mode obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN) assert obj.mode == ZOOM_MODE_DEFAULT # Valid: explicit full mode obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="full") assert obj.mode == ZoomMode.FULL # Valid: explicit simple mode obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="simple") assert obj.mode == ZoomMode.SIMPLE # Partial prefix matching: "sim" -> "simple" obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="sim") assert obj.mode == ZoomMode.SIMPLE # Partial prefix matching: "f" -> "full" obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="f") assert obj.mode == ZoomMode.FULL def test_plugin_zoom_url_round_trip(): """Verify that url() -> parse_url() -> __init__ is lossless.""" # Full mode round-trip obj1 = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="full") result = NotifyZoom.parse_url(obj1.url()) obj2 = NotifyZoom(**result) assert obj1.url_identifier == obj2.url_identifier assert obj1.mode == obj2.mode # Simple mode round-trip obj1 = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="simple") result = NotifyZoom.parse_url(obj1.url()) obj2 = NotifyZoom(**result) assert obj1.url_identifier == obj2.url_identifier assert obj1.mode == obj2.mode def test_plugin_zoom_url_privacy(): """Verify url(privacy=True) masks webhook_id and token.""" obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN) priv = obj.url(privacy=True) assert WEBHOOK_ID not in priv assert TOKEN not in priv # Schema is still present assert priv.startswith("zoom://") def test_plugin_zoom_url_identifier(): """Verify url_identifier contains webhook_id, token, and mode.""" obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN) uid = obj.url_identifier assert "zoom" in uid assert WEBHOOK_ID in uid assert TOKEN in uid # Two instances with different tokens are distinct obj2 = NotifyZoom(webhook_id=WEBHOOK_ID, token="other") assert obj.url_identifier != obj2.url_identifier # Same credentials but different modes produce different identifiers obj_full = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="full") obj_simple = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="simple") assert obj_full.url_identifier != obj_simple.url_identifier # mode is the 4th element (index 3) of the identifier tuple assert obj_full.url_identifier[3] == ZoomMode.FULL assert obj_simple.url_identifier[3] == ZoomMode.SIMPLE @mock.patch("requests.post") def test_plugin_zoom_full_send(mock_post): """Test _send_full() success and failure paths.""" def _mk_resp(code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = b"" return r mock_post.return_value = _mk_resp() # Success: with title obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN) assert obj.notify( body="body text", title="My Title", notify_type=NotifyType.INFO, ) assert mock_post.call_count == 1 # Verify ?format=full in URL call_url = mock_post.call_args[0][0] assert "format=full" in call_url mock_post.reset_mock() # Success: without title (no head section) assert obj.notify( body="body only", title="", notify_type=NotifyType.INFO, ) assert mock_post.call_count == 1 mock_post.reset_mock() # HTTP 204 is also a success mock_post.return_value = _mk_resp(requests.codes.no_content) assert obj.notify(body="test", notify_type=NotifyType.INFO) mock_post.reset_mock() # HTTP 500 -> failure mock_post.return_value = _mk_resp(requests.codes.internal_server_error) assert not obj.notify(body="test", notify_type=NotifyType.INFO) mock_post.reset_mock() # Unknown status code -> failure mock_post.return_value = _mk_resp(999) assert not obj.notify(body="test", notify_type=NotifyType.INFO) mock_post.reset_mock() # RequestException -> failure mock_post.side_effect = requests.RequestException("boom") assert not obj.notify(body="test", notify_type=NotifyType.INFO) @mock.patch("requests.post") def test_plugin_zoom_simple_send(mock_post): """Test _send_simple() success and failure paths.""" def _mk_resp(code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = b"" return r mock_post.return_value = _mk_resp() # Create a simple-mode instance obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="simple") # Success: with title (title prepended to body) assert obj.notify( body="msg body", title="Alert", notify_type=NotifyType.INFO, ) assert mock_post.call_count == 1 # Verify no ?format= in simple mode URL call_url = mock_post.call_args[0][0] assert "format" not in call_url mock_post.reset_mock() # Success: without title assert obj.notify( body="no title", title="", notify_type=NotifyType.INFO, ) mock_post.reset_mock() # HTTP 204 is also a success mock_post.return_value = _mk_resp(requests.codes.no_content) assert obj.notify(body="test", notify_type=NotifyType.INFO) mock_post.reset_mock() # HTTP 500 -> failure mock_post.return_value = _mk_resp(requests.codes.internal_server_error) assert not obj.notify(body="test", notify_type=NotifyType.INFO) mock_post.reset_mock() # Unknown status code -> failure mock_post.return_value = _mk_resp(999) assert not obj.notify(body="test", notify_type=NotifyType.INFO) mock_post.reset_mock() # RequestException -> failure mock_post.side_effect = requests.RequestException("err") assert not obj.notify(body="test", notify_type=NotifyType.INFO) def test_plugin_zoom_parse_url(): """Exercise parse_url() edge cases.""" # Basic: webhook_id in host, token in path result = NotifyZoom.parse_url("zoom://myhookid/mytoken/") assert result["webhook_id"] == "myhookid" assert result["token"] == "mytoken" # ?token= override result = NotifyZoom.parse_url("zoom://myhookid/?token=override123") assert result["webhook_id"] == "myhookid" assert result["token"] == "override123" # ?mode= parameter result = NotifyZoom.parse_url("zoom://myhookid/mytoken/?mode=simple") assert result["mode"] == "simple" # A URL that fails base parsing returns None result = NotifyZoom.parse_url(None) assert result is None def test_plugin_zoom_parse_native_url(): """Exercise parse_native_url().""" # Native URL with ?token= appended result = NotifyZoom.parse_native_url( "https://inbots.zoom.us/incoming/hook/{}?token={}".format( WEBHOOK_ID, TOKEN ) ) assert result is not None assert result["webhook_id"] == WEBHOOK_ID assert result["token"] == TOKEN # Native URL without token -> parse succeeds but # token will be None/missing (instantiation would fail) result = NotifyZoom.parse_native_url( "https://inbots.zoom.us/incoming/hook/{}".format(WEBHOOK_ID) ) assert result is not None assert result["webhook_id"] == WEBHOOK_ID # Non-matching URL -> None assert ( NotifyZoom.parse_native_url("https://example.com/not/a/zoom/url") is None ) @mock.patch("requests.post") def test_plugin_zoom_apprise_integration(mock_post): """Test end-to-end via Apprise.add() and Apprise.notify().""" r = mock.Mock() r.status_code = requests.codes.ok r.content = b"" mock_post.return_value = r app = Apprise() assert app.add("zoom://{}/{}".format(WEBHOOK_ID, TOKEN)) assert app.notify(title="Hello", body="World") assert mock_post.call_count == 1 caronc-apprise-dfec414/tests/test_plugin_zulip.py000066400000000000000000000127311526720371000224170ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import pytest import requests from apprise.exception import AppriseImproperlyConfigured from apprise.plugins.zulip import NotifyZulip logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "zulip://", { "instance": AppriseImproperlyConfigured, }, ), ( "zulip://:@/", { "instance": AppriseImproperlyConfigured, }, ), ( "zulip://apprise", { # Just org provided (no token or botname) "instance": AppriseImproperlyConfigured, }, ), ( "zulip://botname@apprise", { # Just org and botname provided (no token) "instance": AppriseImproperlyConfigured, }, ), # invalid token ( "zulip://botname@apprise/{}".format("a" * 24), { "instance": AppriseImproperlyConfigured, }, ), # invalid botname ( "zulip://....@apprise/{}".format("a" * 32), { "instance": AppriseImproperlyConfigured, }, ), # Valid everything - botname with a dash ( "zulip://bot-name@apprise/{}".format("a" * 32), { "instance": NotifyZulip, "privacy_url": "zulip://bot-name@apprise/a...a/", }, ), # Valid everything - no target so default is used ( "zulip://botname@apprise/{}".format("a" * 32), { "instance": NotifyZulip, # Our expected url(privacy=True) startswith() response: "privacy_url": "zulip://botname@apprise/a...a/", }, ), # Valid everything - organization as hostname ( "zulip://botname@apprise.zulipchat.com/{}".format("a" * 32), { "instance": NotifyZulip, }, ), # Valid everything - 2 streams specified ( "zulip://botname@apprise/{}/channel1/channel2".format("a" * 32), { "instance": NotifyZulip, }, ), # Valid everything - 2 streams specified (using to=) ( "zulip://botname@apprise/{}/?to=channel1/channel2".format("a" * 32), { "instance": NotifyZulip, }, ), # Test token= ( "zulip://botname@apprise/?token={}&to=channel1".format("a" * 32), { "instance": NotifyZulip, }, ), # Valid everything - 2 emails specified ( "zulip://botname@apprise/{}/user@example.com/user2@example.com".format( "a" * 32 ), { "instance": NotifyZulip, }, ), ( "zulip://botname@apprise/{}".format("a" * 32), { "instance": NotifyZulip, # don't include an image by default "include_image": False, }, ), ( "zulip://botname@apprise/{}".format("a" * 32), { "instance": NotifyZulip, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "zulip://botname@apprise/{}".format("a" * 32), { "instance": NotifyZulip, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "zulip://botname@apprise/{}".format("a" * 32), { "instance": NotifyZulip, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_zulip_urls(): """NotifyZulip() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_zulip_edge_cases(): """NotifyZulip() Edge Cases.""" # must be 32 characters long token = "a" * 32 # Invalid organization with pytest.raises(AppriseImproperlyConfigured): NotifyZulip(botname="test", organization="#", token=token) caronc-apprise-dfec414/tests/test_result.py000066400000000000000000000724671526720371000212300ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from datetime import datetime, timezone from io import StringIO from json import loads import logging from apprise.common import AWARE_DATE_ISO_FORMAT from apprise.logger import NotifyLogEntry from apprise.result import ( AppriseResult, AppriseResultStatus, NotifyAttempt, NotifyResult, ) logging.disable(logging.CRITICAL) def _json_data(value): """Decode one result object's bounded JSON iterator for assertions.""" return loads("".join(value.iter_json())) def test_notify_log_entry(): """NotifyLogEntry: attributes, asdict(), json(), __str__/__repr__.""" entry = NotifyLogEntry(level="WARNING", message="HTTP 403 Forbidden") assert entry.level == "WARNING" assert entry.message == "HTTP 403 Forbidden" assert entry.time.tzinfo is not None assert entry.asdict() == { "level": "WARNING", "message": "HTTP 403 Forbidden", "time": entry.time.strftime(AWARE_DATE_ISO_FORMAT), } assert loads(entry.json()) == entry.asdict() # __str__ mirrors Python logging's own default format: # "%(asctime)s - %(levelname)s - %(message)s". expected_asctime = "{},{:03d}".format( entry.time.strftime("%Y-%m-%d %H:%M:%S"), entry.time.microsecond // 1000, ) assert str(entry) == (f"{expected_asctime} - WARNING - HTTP 403 Forbidden") assert "WARNING" in repr(entry) assert "HTTP 403 Forbidden" in repr(entry) def test_notify_log_entry_time_defaults_to_now(): """NotifyLogEntry.time defaults to now() when constructed directly (e.g. in tests) without an explicit time= from a real LogRecord.""" before = datetime.now(timezone.utc) entry = NotifyLogEntry(level="WARNING", message="test") after = datetime.now(timezone.utc) assert before <= entry.time <= after def test_notify_log_entry_explicit_time(): """NotifyLogEntry accepts an explicit time=, as _ServiceLogCapture.emit() does from the underlying LogRecord's own timestamp.""" when = datetime(2026, 1, 1, tzinfo=timezone.utc) entry = NotifyLogEntry(level="ERROR", message="test", time=when) assert entry.time == when def test_notify_attempt_defaults(): """NotifyAttempt: default construction requires only status.""" attempt = NotifyAttempt(status=AppriseResultStatus.SUCCESS) assert attempt.status == AppriseResultStatus.SUCCESS assert attempt.elapsed == 0.0 assert attempt.logs == [] assert bool(attempt) is True def test_notify_attempt_bool_matches_status(): """NotifyAttempt.__bool__ is True only for status=SUCCESS -- raw, never adjusted for optional=.""" success = NotifyAttempt(status=AppriseResultStatus.SUCCESS) failure = NotifyAttempt(status=AppriseResultStatus.FAILURE) timeout = NotifyAttempt(status=AppriseResultStatus.TIMEOUT) assert bool(success) is True assert bool(failure) is False assert bool(timeout) is False def test_notify_attempt_start_end_time_match_elapsed(): """start_time/end_time are always populated and their difference is exactly elapsed -- by construction, not by measurement, so there is no separate clock to drift out of sync.""" before = datetime.now(timezone.utc) attempt = NotifyAttempt(status=AppriseResultStatus.SUCCESS, elapsed=3.5) after = datetime.now(timezone.utc) assert attempt.start_time.tzinfo is not None assert attempt.end_time.tzinfo is not None assert (attempt.end_time - attempt.start_time).total_seconds() == 3.5 assert before <= attempt.end_time <= after parsed = datetime.strptime( _json_data(attempt)["start_time"], AWARE_DATE_ISO_FORMAT ) assert parsed == attempt.start_time def test_notify_attempt_iterates_logs(): """NotifyAttempt iterates directly over its own captured log entries.""" logs = [ NotifyLogEntry(level="WARNING", message="rate limited"), NotifyLogEntry(level="ERROR", message="invalid token"), ] attempt = NotifyAttempt( status=AppriseResultStatus.FAILURE, elapsed=0.2, logs=logs ) assert list(attempt) == logs messages = [str(entry) for entry in attempt] assert " - WARNING - rate limited" in messages[0] assert " - ERROR - invalid token" in messages[1] def test_notify_attempt_iter_json_and_write_json(): """Attempt JSON streams every field and can write to a text stream.""" logs = [NotifyLogEntry(level="WARNING", message="HTTP 429")] attempt = NotifyAttempt( status=AppriseResultStatus.FAILURE, elapsed=0.5, logs=logs ) expected = { "status": "FAILURE", "elapsed": 0.5, "start_time": attempt.start_time.strftime(AWARE_DATE_ISO_FORMAT), "end_time": attempt.end_time.strftime(AWARE_DATE_ISO_FORMAT), "logs": [logs[0].asdict()], } output = "".join(attempt.iter_json()) assert loads(output) == expected assert ", " not in output assert ": " not in output # write_json consumes the same bounded chunks without joining them first. stream = StringIO() attempt.write_json(stream) assert stream.getvalue() == output def test_notify_attempt_json_reads_disk_logs_lazily(): """Creating and starting JSON output does not preload disk-backed logs.""" from apprise.logger import _LogCaptureBudget, _NotifyLogStore budget = _LogCaptureBudget(memory_size=0, disk_size=4096) logs = _NotifyLogStore(budget) logs.append(NotifyLogEntry(level="WARNING", message="from disk")) reads = 0 original_read = budget.read def tracked_read(offset): """Count actual disk reads while preserving normal behavior.""" nonlocal reads reads += 1 return original_read(offset) budget.read = tracked_read attempt = NotifyAttempt(AppriseResultStatus.FAILURE, logs=logs) chunks = attempt.iter_json() # Fixed fields are emitted before iteration reaches the disk log array. first_chunk = next(chunks) assert first_chunk.startswith('{"status":') assert reads == 0 output = first_chunk + "".join(chunks) assert loads(output)["logs"][0]["message"] == "from disk" assert reads == 1 logs.close() def test_notify_attempt_repr(): """NotifyAttempt.__repr__ is a compact, informative one-liner.""" attempt = NotifyAttempt(status=AppriseResultStatus.TIMEOUT, elapsed=1.2) text = repr(attempt) assert "TIMEOUT" in text assert "1.2" in text def test_notify_result_defaults(): """NotifyResult: default construction requires only name/url, and with no attempts given reports FAILURE (nothing ever succeeded).""" result = NotifyResult(name="Slack", url="slack://t/ch") assert result.name == "Slack" assert result.url == "slack://t/ch" assert result.url_id is None assert result.tag == () assert result.status == AppriseResultStatus.FAILURE assert bool(result) is False assert result.optional is False assert result.weight == 1 assert result.max_attempts == 1 assert result.attempts == () assert len(result) == 0 assert result.elapsed == 0.0 assert list(result.logs()) == [] def test_notify_result_status_reflects_any_success(): """.status is SUCCESS if any attempt succeeded, regardless of earlier failed attempts.""" result = NotifyResult( name="Slack", url="slack://t/ch", max_attempts=3, attempts=[ NotifyAttempt(status=AppriseResultStatus.FAILURE), NotifyAttempt(status=AppriseResultStatus.SUCCESS), ], ) assert result.status == AppriseResultStatus.SUCCESS assert bool(result) is True assert len(result) == 2 def test_notify_result_status_pure_timeout_no_failure(): """.status is TIMEOUT when Apprise gave up waiting without ever seeing a confirmed failure from this service -- e.g. the deadline had already passed before any attempt could even start, or the one attempt made was abandoned mid-flight rather than completing.""" result = NotifyResult( name="Slack", url="slack://t/ch", attempts=[NotifyAttempt(status=AppriseResultStatus.TIMEOUT)], ) assert result.status == AppriseResultStatus.TIMEOUT assert bool(result) is False def test_notify_result_status_failure_trumps_timeout(): """.status is FAILURE, not TIMEOUT, when at least one attempt confirmed a real failure before Apprise later gave up waiting on a further retry -- a confirmed failure is more informative than "ran out of time" and takes priority over it.""" result = NotifyResult( name="Slack", url="slack://t/ch", attempts=[ NotifyAttempt(status=AppriseResultStatus.FAILURE), NotifyAttempt(status=AppriseResultStatus.TIMEOUT), ], ) assert result.status == AppriseResultStatus.FAILURE assert bool(result) is False def test_notify_result_status_failure_trumps_timeout_multiple_retries(): """The same FAILURE-over-TIMEOUT priority holds regardless of how many failed retries preceded the eventual TIMEOUT.""" result = NotifyResult( name="Slack", url="slack://t/ch", attempts=[ NotifyAttempt(status=AppriseResultStatus.FAILURE), NotifyAttempt(status=AppriseResultStatus.FAILURE), NotifyAttempt(status=AppriseResultStatus.FAILURE), NotifyAttempt(status=AppriseResultStatus.TIMEOUT), ], ) assert result.status == AppriseResultStatus.FAILURE def test_notify_result_optional_absorbs_failure_trumping_timeout(): """optional=True still absorbs a FAILURE-trumps-TIMEOUT outcome into SUCCESS, same as it would a plain FAILURE or a plain TIMEOUT.""" result = NotifyResult( name="Slack", url="slack://t/ch", optional=True, attempts=[ NotifyAttempt(status=AppriseResultStatus.FAILURE), NotifyAttempt(status=AppriseResultStatus.TIMEOUT), ], ) assert result.status == AppriseResultStatus.SUCCESS def test_notify_result_status_failure_when_no_timeout_no_success(): """.status is FAILURE when every attempt failed outright (no TIMEOUT, no SUCCESS, no optional).""" result = NotifyResult( name="Slack", url="slack://t/ch", attempts=[NotifyAttempt(status=AppriseResultStatus.FAILURE)], ) assert result.status == AppriseResultStatus.FAILURE def test_notify_result_optional_absorbs_failure(): """optional=True remaps a failed outcome's reflective .status to SUCCESS, even though the underlying NotifyAttempt stays raw.""" result = NotifyResult( name="Slack", url="slack://t/ch", optional=True, attempts=[NotifyAttempt(status=AppriseResultStatus.FAILURE)], ) assert result.status == AppriseResultStatus.SUCCESS assert bool(result) is True # The raw attempt itself is untouched by optional=. assert next(iter(result)).status == AppriseResultStatus.FAILURE def test_notify_result_optional_absorbs_timeout(): """optional=True also absorbs a TIMEOUT outcome the same way it absorbs a plain FAILURE.""" result = NotifyResult( name="Slack", url="slack://t/ch", optional=True, attempts=[NotifyAttempt(status=AppriseResultStatus.TIMEOUT)], ) assert result.status == AppriseResultStatus.SUCCESS assert next(iter(result)).status == AppriseResultStatus.TIMEOUT def test_notify_result_optional_does_not_mask_success(): """optional=True on an already-successful result is a no-op -- .status is still simply SUCCESS.""" result = NotifyResult( name="Slack", url="slack://t/ch", optional=True, attempts=[NotifyAttempt(status=AppriseResultStatus.SUCCESS)], ) assert result.status == AppriseResultStatus.SUCCESS def test_notify_result_weight(): """.weight defaults to 1 and stores whatever value is given (e.g. a plugin's own target count via len(service)).""" default = NotifyResult(name="Slack", url="slack://t/ch") fanned_out = NotifyResult(name="Twilio", url="twilio://x", weight=50) assert default.weight == 1 assert fanned_out.weight == 50 def test_notify_result_max_attempts_ratio(): """max_attempts is the configured ceiling; len(result)/.attempts show how many were actually used -- e.g. 1/3 means the first try succeeded.""" result = NotifyResult( name="Telegram", url="tgram://bot/chat", url_id="abc123", max_attempts=3, attempts=[NotifyAttempt(status=AppriseResultStatus.SUCCESS)], ) assert result.url_id == "abc123" assert result.max_attempts == 3 assert len(result) == 1 assert len(result.attempts) == 1 def test_notify_result_start_end_time_span_all_attempts(): """start_time is the first attempt's start, end_time is the last attempt's end, and elapsed is the difference between them -- not the sum of each attempt's own elapsed.""" first = NotifyAttempt(status=AppriseResultStatus.FAILURE, elapsed=1.0) second = NotifyAttempt(status=AppriseResultStatus.SUCCESS, elapsed=1.0) result = NotifyResult( name="Slack", url="slack://t/ch", attempts=[first, second] ) assert result.start_time == first.start_time assert result.end_time == second.end_time assert result.elapsed == ( (second.end_time - first.start_time).total_seconds() ) def test_notify_result_start_end_time_default_when_no_attempts(): """With no attempts at all, start_time/end_time still coincide -- never left unset.""" result = NotifyResult(name="Slack", url="slack://t/ch") assert result.start_time == result.end_time def test_notify_result_iterates_attempts(): """NotifyResult iterates over its NotifyAttempt entries, not log lines directly -- use .logs() for a flattened log view.""" a1 = NotifyAttempt(status=AppriseResultStatus.FAILURE) a2 = NotifyAttempt(status=AppriseResultStatus.SUCCESS) result = NotifyResult( name="Telegram", url="tgram://bot/chat", attempts=[a1, a2] ) assert list(result) == [a1, a2] assert result.attempts == (a1, a2) def test_notify_result_logs_flattens_every_attempt_in_order(): """NotifyResult.logs() yields every log entry across every attempt, in call order.""" logs1 = [NotifyLogEntry(level="WARNING", message="rate limited")] logs2 = [NotifyLogEntry(level="ERROR", message="invalid token")] result = NotifyResult( name="Telegram", url="tgram://bot/chat", attempts=[ NotifyAttempt(status=AppriseResultStatus.FAILURE, logs=logs1), NotifyAttempt(status=AppriseResultStatus.SUCCESS, logs=logs2), ], ) assert list(result.logs()) == logs1 + logs2 def test_notify_result_optional_and_tag(): """NotifyResult carries the tag tuple and optional flag through.""" result = NotifyResult( name="Discord", url="discord://w/t", tag=("devops", "alerts"), optional=True, attempts=[NotifyAttempt(status=AppriseResultStatus.FAILURE)], ) assert result.tag == ("devops", "alerts") assert result.optional is True # Absorbed by optional=True. assert bool(result) is True def test_notify_result_iter_json_and_write_json(): """Service JSON streams every field and its nested attempts.""" logs = [NotifyLogEntry(level="WARNING", message="HTTP 429")] attempt = NotifyAttempt( status=AppriseResultStatus.FAILURE, elapsed=0.5, logs=logs ) result = NotifyResult( name="Telegram", url="tgram://bot/chat", url_id="abc123", tag=("alerts",), optional=False, weight=3, max_attempts=3, attempts=[attempt], ) expected = { "name": "Telegram", "url": "tgram://bot/chat", "url_id": "abc123", "tag": ["alerts"], "status": "FAILURE", "optional": False, "weight": 3, "max_attempts": 3, "elapsed": result.elapsed, "start_time": result.start_time.strftime(AWARE_DATE_ISO_FORMAT), "end_time": result.end_time.strftime(AWARE_DATE_ISO_FORMAT), "attempts": [_json_data(attempt)], } output = "".join(result.iter_json()) assert loads(output) == expected assert ", " not in output assert ": " not in output stream = StringIO() result.write_json(stream) assert stream.getvalue() == output def test_notify_result_repr(): """NotifyResult.__repr__ is a compact, informative one-liner.""" result = NotifyResult( name="Slack", url="slack://t/ch", attempts=[NotifyAttempt(status=AppriseResultStatus.SUCCESS)], ) text = repr(result) assert "Slack" in text assert "slack://t/ch" in text assert "SUCCESS" in text def test_apprise_result_defaults(): """AppriseResult: default construction is an empty NOMATCH result.""" result = AppriseResult() assert result.status == AppriseResultStatus.NOMATCH assert len(result) == 0 assert list(result) == [] assert result.results == () assert result.success_count == 0 assert result.failed_count == 0 assert result.timeout_count == 0 assert result.elapsed == 0.0 assert bool(result) is False assert list(result.call_logs()) == [] def test_apprise_result_elapsed_custom(): """AppriseResult.elapsed stores the caller-supplied wall-clock time.""" result = AppriseResult(status=AppriseResultStatus.SUCCESS, elapsed=2.5) assert result.elapsed == 2.5 def test_apprise_result_start_end_time_match_elapsed(): """Same start_time/end_time-derived-from-elapsed guarantee as NotifyResult, at the whole-call level.""" result = AppriseResult(status=AppriseResultStatus.SUCCESS, elapsed=4.0) assert result.start_time.tzinfo is not None assert result.end_time.tzinfo is not None assert (result.end_time - result.start_time).total_seconds() == 4.0 parsed = datetime.strptime( _json_data(result)["end_time"], AWARE_DATE_ISO_FORMAT ) assert parsed == result.end_time def test_apprise_result_bool_success(): """AppriseResult.__bool__ is True only for SUCCESS.""" result = AppriseResult(status=AppriseResultStatus.SUCCESS) assert bool(result) is True def test_apprise_result_bool_failure(): """AppriseResult.__bool__ is False for FAILURE.""" result = AppriseResult(status=AppriseResultStatus.FAILURE) assert bool(result) is False def test_apprise_result_bool_nomatch(): """AppriseResult.__bool__ is False for NOMATCH.""" result = AppriseResult(status=AppriseResultStatus.NOMATCH) assert bool(result) is False def test_apprise_result_bool_timeout(): """AppriseResult.__bool__ is False for TIMEOUT.""" result = AppriseResult(status=AppriseResultStatus.TIMEOUT) assert bool(result) is False def test_apprise_result_status_exit_code_alignment(): """AppriseResultStatus values equal the CLI's historical exit codes.""" assert int(AppriseResultStatus.SUCCESS) == 0 assert int(AppriseResultStatus.FAILURE) == 1 assert int(AppriseResultStatus.NOMATCH) == 3 assert int(AppriseResultStatus.PARTIAL) == 4 def test_apprise_result_timeout_status_value(): """TIMEOUT is the highest-numbered status value (5).""" assert int(AppriseResultStatus.TIMEOUT) == 5 def _notify_result(name, url, status): """Test helper: build a one-attempt NotifyResult with the given reflective status (SUCCESS/FAILURE/TIMEOUT), for tests that only care about the aggregate outcome, not attempt-level detail.""" return NotifyResult(name=name, url=url, attempts=[NotifyAttempt(status)]) def test_apprise_result_len_and_iter(): """AppriseResult.__len__/__iter__ walk the collected NotifyResults.""" r1 = _notify_result("Slack", "slack://t/ch", AppriseResultStatus.SUCCESS) r2 = _notify_result( "Discord", "discord://w/t", AppriseResultStatus.FAILURE ) result = AppriseResult( status=AppriseResultStatus.FAILURE, results=[r1, r2] ) assert len(result) == 2 assert list(result) == [r1, r2] assert result.results == (r1, r2) def test_apprise_result_success_and_failed_counts(): """success_count/failed_count are derived from each NotifyResult's reflective .status, and timeout_count narrows failed_count down to just TIMEOUT entries.""" results = [ _notify_result("Slack", "slack://t/ch", AppriseResultStatus.SUCCESS), _notify_result( "Discord", "discord://w/t", AppriseResultStatus.FAILURE ), _notify_result("Telegram", "tgram://b/c", AppriseResultStatus.SUCCESS), _notify_result("Twilio", "twilio://x", AppriseResultStatus.TIMEOUT), ] result = AppriseResult(status=AppriseResultStatus.FAILURE, results=results) assert result.success_count == 2 assert result.failed_count == 2 assert result.timeout_count == 1 def test_apprise_result_logs_merges_and_sorts_across_services(): """AppriseResult.logs() combines every service's log entries into one chronological timeline -- not simply service by service, since services dispatched concurrently can log in any relative order to each other regardless of which NotifyResult they end up attached to.""" early = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) middle = datetime(2026, 1, 1, 12, 0, 1, tzinfo=timezone.utc) late = datetime(2026, 1, 1, 12, 0, 2, tzinfo=timezone.utc) # Discord's own NotifyResult is built first, but its one log entry # actually happened AFTER Slack's -- e.g. two services notified # concurrently, with Discord simply finishing (and being appended # to the batch) first. discord = NotifyResult( name="Discord", url="discord://w/t", attempts=[ NotifyAttempt( status=AppriseResultStatus.SUCCESS, logs=[ NotifyLogEntry( level="WARNING", message="from discord", time=late ) ], ) ], ) slack = NotifyResult( name="Slack", url="slack://t/ch", attempts=[ NotifyAttempt( status=AppriseResultStatus.SUCCESS, logs=[ NotifyLogEntry( level="WARNING", message="from slack", time=early ), NotifyLogEntry( level="ERROR", message="from slack, later", time=middle ), ], ) ], ) result = AppriseResult( status=AppriseResultStatus.SUCCESS, results=[discord, slack] ) messages = [entry.message for entry in result.logs()] assert messages == ["from slack", "from slack, later", "from discord"] def test_apprise_result_call_logs(): """Call logs expose a read-only snapshot of orchestration entries.""" t = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) entries = [ NotifyLogEntry( level="WARNING", message="no services to notify", time=t ) ] result = AppriseResult( status=AppriseResultStatus.NOMATCH, call_logs=entries ) assert list(result.call_logs()) == entries # Mutating the input list afterwards must not affect the snapshot. entries.append( NotifyLogEntry(level="WARNING", message="late addition", time=t) ) assert len(list(result.call_logs())) == 1 def test_result_logs_merge_call_logs(): """Combined logs merge call and service entries by time.""" early = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) middle = datetime(2026, 1, 1, 12, 0, 1, tzinfo=timezone.utc) late = datetime(2026, 1, 1, 12, 0, 2, tzinfo=timezone.utc) slack = NotifyResult( name="Slack", url="slack://t/ch", attempts=[ NotifyAttempt( status=AppriseResultStatus.SUCCESS, logs=[ NotifyLogEntry( level="WARNING", message="from slack", time=middle ) ], ) ], ) call_logs = [ NotifyLogEntry( level="TRACE", message="starting attempt 1/1", time=early ), NotifyLogEntry(level="TRACE", message="attempt finished", time=late), ] result = AppriseResult( status=AppriseResultStatus.SUCCESS, results=[slack], call_logs=call_logs, ) messages = [entry.message for entry in result.logs()] assert messages == [ "starting attempt 1/1", "from slack", "attempt finished", ] def test_apprise_result_iter_json_and_write_json(): """Overall JSON streams summary fields and every service result.""" results = [ _notify_result("Slack", "slack://t/ch", AppriseResultStatus.SUCCESS), _notify_result( "Discord", "discord://w/t", AppriseResultStatus.FAILURE ), ] result = AppriseResult( status=AppriseResultStatus.FAILURE, results=results, elapsed=0.75 ) expected = { "status": "FAILURE", "success_count": 1, "failed_count": 1, "elapsed": 0.75, "start_time": result.start_time.strftime(AWARE_DATE_ISO_FORMAT), "end_time": result.end_time.strftime(AWARE_DATE_ISO_FORMAT), "results": [_json_data(item) for item in results], "call_logs": [], } output = "".join(result.iter_json()) assert loads(output) == expected assert ", " not in output assert ": " not in output stream = StringIO() result.write_json(stream) assert stream.getvalue() == output def test_result_json_includes_call_logs(): """Bounded result JSON includes call-level logs.""" t = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) entries = [NotifyLogEntry(level="WARNING", message="no services", time=t)] result = AppriseResult( status=AppriseResultStatus.NOMATCH, call_logs=entries ) assert _json_data(result)["call_logs"] == [e.asdict() for e in entries] def test_apprise_result_repr(): """AppriseResult.__repr__ reports status and entry count.""" result = AppriseResult( status=AppriseResultStatus.SUCCESS, results=[ _notify_result( "Slack", "slack://t/ch", AppriseResultStatus.SUCCESS ) ], ) text = repr(result) assert "SUCCESS" in text assert "1" in text def test_apprise_result_results_is_read_only_view(): """.results is a snapshot tuple; mutating the input list afterwards does not affect it.""" source = [ _notify_result("Slack", "slack://t/ch", AppriseResultStatus.SUCCESS) ] result = AppriseResult(status=AppriseResultStatus.SUCCESS, results=source) source.append( _notify_result("Discord", "discord://w/t", AppriseResultStatus.FAILURE) ) assert len(result) == 1 assert len(result.results) == 1 def test_apprise_result_closes_disk_backed_logs(): """Closing a result releases temporary files held by its log stores.""" from apprise.logger import _LogCaptureBudget, _NotifyLogStore call_logs = _NotifyLogStore( _LogCaptureBudget(memory_size=0, disk_size=4096) ) attempt_logs = _NotifyLogStore( _LogCaptureBudget(memory_size=0, disk_size=4096) ) call_logs.append(NotifyLogEntry("WARNING", "call")) attempt_logs.append(NotifyLogEntry("WARNING", "attempt")) service = NotifyResult( name="Slack", url="slack://t/ch", attempts=[ NotifyAttempt( status=AppriseResultStatus.SUCCESS, logs=attempt_logs ) ], ) with AppriseResult( status=AppriseResultStatus.SUCCESS, results=[service], call_logs=call_logs, ) as result: assert [entry.message for entry in result.logs()] == [ "call", "attempt", ] assert call_logs._budget._disk is None assert attempt_logs._budget._disk is None # Closing ordinary list-backed results is also a safe no-op. AppriseResult( results=[ NotifyResult( name="Slack", url="slack://t/ch", attempts=[NotifyAttempt(AppriseResultStatus.SUCCESS)], ) ] ).close() caronc-apprise-dfec414/tests/test_retry_wait.py000066400000000000000000005631221526720371000220740ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import asyncio from contextlib import contextmanager import logging import threading import time from unittest import mock import pytest from apprise import LOGGER_NAME, Apprise, AppriseAsset, AppriseResultStatus from apprise.common import ( APPRISE_MAX_SERVICE_RETRY, APPRISE_MAX_SERVICE_WAIT, MATCH_ALL_TAG, ) from apprise.config.base import ConfigBase from apprise.exception import AppriseImproperlyConfigured from apprise.manager_plugins import NotificationManager from apprise.plugins import NotifyBase from apprise.tag import AppriseTag logging.disable(logging.CRITICAL) N_MGR = NotificationManager() @contextmanager def apprise_logs_enabled(level=logging.TRACE): """Re-enable apprise logging so a test can inspect captured entries.""" # This module silences logging outright, which would keep every record # away from the captures a result is built from. apprise_logger = logging.getLogger(LOGGER_NAME) restore_level = apprise_logger.level apprise_logger.setLevel(level) logging.disable(logging.NOTSET) try: yield finally: # Put the module back the way the rest of the file expects it. logging.disable(logging.CRITICAL) apprise_logger.setLevel(restore_level) def wait_until(condition, timeout=5.0): """Check ``condition`` until it is true or ``timeout`` seconds pass.""" # Poll because background work finishes asynchronously. deadline = time.monotonic() + timeout while not condition() and time.monotonic() < deadline: time.sleep(0.02) class _TestNotify(NotifyBase): """Test notification plugin -- controlled pass/fail via side_effect.""" app_id = "TestApp" app_desc = "Test" notify_url = "test://" title_maxlen = 250 body_maxlen = 32768 # Override so NotifyBase doesn't reject the URL def url(self, privacy=False, *args, **kwargs): return "test://localhost" def send(self, **kwargs): return True @staticmethod def parse_url(url): results = NotifyBase.parse_url(url, verify_host=False) return results class TestWaitValidation: """Tests for the wait= parameter accepted by NotifyBase.""" def _make(self, **kwargs): return _TestNotify(host="localhost", **kwargs) @pytest.mark.parametrize( "value,expected", [ (0, 0.0), (0.0, 0.0), (0.5, 0.5), (1, 1.0), (1.0, 1.0), ("0.5", 0.5), ("1", 1.0), ("1.4", 1.4), ("20", 20.0), (APPRISE_MAX_SERVICE_WAIT, APPRISE_MAX_SERVICE_WAIT), ], ) def test_valid_wait(self, value, expected): nb = self._make(wait=value) assert nb.wait == pytest.approx(expected) def test_wait_clamped_to_max(self): # Values above the cap are clamped, not rejected nb = self._make(wait=APPRISE_MAX_SERVICE_WAIT + 9999) assert nb.wait == pytest.approx(APPRISE_MAX_SERVICE_WAIT) @pytest.mark.parametrize( "bad_value", [ "-3", "-0.1", "1.3.3.", "abc", "inf", "-inf", "nan", None, # None means "use asset default" -- tested separately ], ) def test_invalid_wait_uses_asset_default(self, bad_value): if bad_value is None: # Not passing wait= at all should use asset default asset = AppriseAsset(default_service_wait=1.25) nb = _TestNotify(host="localhost", asset=asset) assert nb.wait == pytest.approx(1.25) return asset = AppriseAsset(default_service_wait=0.75) nb = self._make(wait=bad_value, asset=asset) # Falls back to asset default on invalid input assert nb.wait == pytest.approx(0.75) def test_negative_wait_falls_back(self): asset = AppriseAsset(default_service_wait=2.0) nb = self._make(wait=-1.0, asset=asset) assert nb.wait == pytest.approx(2.0) def test_asset_default_zero(self): asset = AppriseAsset(default_service_wait=0.0) nb = _TestNotify(host="localhost", asset=asset) assert nb.wait == pytest.approx(0.0) def test_asset_default_clamped_to_max(self): asset = AppriseAsset(default_service_wait=APPRISE_MAX_SERVICE_WAIT + 1) nb = _TestNotify(host="localhost", asset=asset) assert nb.wait == pytest.approx(APPRISE_MAX_SERVICE_WAIT) class TestRetryValidation: """Sanity checks for the retry= parameter.""" def _make(self, **kwargs): return _TestNotify(host="localhost", **kwargs) @pytest.mark.parametrize( "value,expected", [ (0, 0), (1, 1), ("3", 3), (APPRISE_MAX_SERVICE_RETRY, APPRISE_MAX_SERVICE_RETRY), ], ) def test_valid_retry(self, value, expected): nb = self._make(retry=value) assert nb.retry == expected def test_retry_clamped_to_max(self): nb = self._make(retry=APPRISE_MAX_SERVICE_RETRY + 999) assert nb.retry == APPRISE_MAX_SERVICE_RETRY @pytest.mark.parametrize("bad", ["-1", "abc", "1.3.3."]) def test_invalid_retry_uses_asset_default(self, bad): asset = AppriseAsset(default_service_retry=2) nb = self._make(retry=bad, asset=asset) assert nb.retry == 2 class TestUrlRoundTrip: def test_wait_in_url_parameters(self): nb = _TestNotify(host="localhost", wait=1.5, retry=2) params = nb.url_parameters() assert "wait" in params assert params["wait"] == "1.5" assert "retry" in params assert params["retry"] == "2" def test_wait_zero_always_in_url_parameters(self): """retry= and wait= are always present, even at their zero defaults.""" nb = _TestNotify(host="localhost", wait=0.0, retry=0) params = nb.url_parameters() assert "wait" in params assert params["wait"] == "0.0" assert "retry" in params assert params["retry"] == "0" def test_parse_url_extracts_wait(self): results = _TestNotify.parse_url("test://localhost?wait=2.5&retry=3") assert results is not None assert results.get("wait") == "2.5" assert results.get("retry") == "3" def test_parse_url_no_wait_key_absent(self): results = _TestNotify.parse_url("test://localhost") assert results is not None assert "wait" not in results class _FailThenSucceedNotify(NotifyBase): """Fails the first N calls, then succeeds.""" app_id = "FailThenSucceed" app_desc = "Test" notify_url = "failpass://" title_maxlen = 250 body_maxlen = 32768 def __init__(self, fail_times=1, **kwargs): super().__init__(**kwargs) self._fail_times = fail_times self._calls = 0 def url(self, *args, **kwargs): return "failpass://localhost" def send(self, **kwargs): self._calls += 1 return not self._calls <= self._fail_times async def async_notify(self, **kwargs): return self.send(**kwargs) @staticmethod def parse_url(url): return NotifyBase.parse_url(url, verify_host=False) class TestSequentialSleep: """time.sleep is called between retries in sequential dispatch.""" def test_sleep_called_on_retry(self): """A failed attempt waits once before its successful retry.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) service = _FailThenSucceedNotify( host="localhost", asset=asset, retry=2, wait=0.3, ) a.add(service) with mock.patch("apprise.apprise.time.sleep") as mock_sleep: result = a.notify(body="test") assert bool(result) is True # Failed once -> sleep once before retry mock_sleep.assert_called_once_with(pytest.approx(0.3)) finally: N_MGR.unload_modules() def test_sleep_not_called_when_wait_zero(self): """A zero wait setting retries without calling sleep.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) service = _FailThenSucceedNotify( host="localhost", asset=asset, retry=2, wait=0.0, ) a.add(service) with mock.patch("apprise.apprise.time.sleep") as mock_sleep: result = a.notify(body="test") assert bool(result) is True mock_sleep.assert_not_called() finally: N_MGR.unload_modules() def test_sleep_not_called_when_no_retry(self): """A service without retries never sleeps after failure.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) # retry=0 means only one attempt; with wait=1.0 sleep should # never fire since there are no retries service = _FailThenSucceedNotify( host="localhost", asset=asset, retry=0, wait=1.0, fail_times=0, # always succeeds ) a.add(service) with mock.patch("apprise.apprise.time.sleep") as mock_sleep: result = a.notify(body="test") assert bool(result) is True mock_sleep.assert_not_called() finally: N_MGR.unload_modules() def test_sleep_called_multiple_times(self): """Fails twice, succeeds on third attempt -> sleep called twice.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) service = _FailThenSucceedNotify( host="localhost", asset=asset, retry=3, wait=0.5, fail_times=2, ) a.add(service) with mock.patch("apprise.apprise.time.sleep") as mock_sleep: result = a.notify(body="test") assert bool(result) is True assert mock_sleep.call_count == 2 for call in mock_sleep.call_args_list: assert call == mock.call(pytest.approx(0.5)) finally: N_MGR.unload_modules() class TestThreadPoolSleep: """time.sleep is called between retries in threadpool dispatch.""" def test_sleep_called_in_threadpool(self): """A thread-pool retry honors the configured wait interval.""" N_MGR["failpass"] = _FailThenSucceedNotify try: # async_mode=True -> uses threadpool when > 1 service; # but with a single service it falls through to sequential. # Use two services so we actually hit the threadpool path. asset = AppriseAsset(async_mode=True) # Two separate service objects so a threadpool is used s1 = _FailThenSucceedNotify( host="localhost", asset=asset, retry=2, wait=0.2, fail_times=1, ) s2 = _FailThenSucceedNotify( host="localhost2", asset=asset, retry=0, wait=0.0, fail_times=0, ) a = Apprise(asset=asset) a.add(s1) a.add(s2) with mock.patch("apprise.apprise.time.sleep") as mock_sleep: result = a.notify(body="test") assert bool(result) is True # s1 fails once -> sleep once mock_sleep.assert_called_once_with(pytest.approx(0.2)) finally: N_MGR.unload_modules() def test_sleep_not_called_in_threadpool_when_wait_zero(self): """A zero thread-pool wait retries without calling sleep.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=True) s1 = _FailThenSucceedNotify( host="localhost", asset=asset, retry=2, wait=0.0, fail_times=1, ) s2 = _FailThenSucceedNotify( host="localhost2", asset=asset, retry=0, wait=0.0, fail_times=0, ) a = Apprise(asset=asset) a.add(s1) a.add(s2) with mock.patch("apprise.apprise.time.sleep") as mock_sleep: result = a.notify(body="test") assert bool(result) is True mock_sleep.assert_not_called() finally: N_MGR.unload_modules() class TestAsyncioSleep: """asyncio.sleep is called between retries in async dispatch.""" def test_asyncio_sleep_called(self): """An asynchronous retry honors the configured wait interval.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset() service = _FailThenSucceedNotify( host="localhost", asset=asset, retry=2, wait=0.4, fail_times=1, ) a = Apprise(asset=asset) a.add(service) async def run(): """Dispatch while capturing the asynchronous retry sleep.""" with mock.patch("apprise.apprise.asyncio.sleep") as mock_sleep: mock_sleep.return_value = None result = await a.async_notify(body="test") return result, mock_sleep result, mock_sleep = asyncio.run(run()) assert bool(result) is True mock_sleep.assert_called_once_with(pytest.approx(0.4)) finally: N_MGR.unload_modules() def test_asyncio_sleep_not_called_when_wait_zero(self): """A zero asynchronous wait retries without calling sleep.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset() service = _FailThenSucceedNotify( host="localhost", asset=asset, retry=2, wait=0.0, fail_times=1, ) a = Apprise(asset=asset) a.add(service) async def run(): """Dispatch while checking that asyncio.sleep is unused.""" with mock.patch("apprise.apprise.asyncio.sleep") as mock_sleep: mock_sleep.return_value = None result = await a.async_notify(body="test") return result, mock_sleep result, mock_sleep = asyncio.run(run()) assert bool(result) is True mock_sleep.assert_not_called() finally: N_MGR.unload_modules() class TestAssetDefault: def test_asset_wait_default_used(self): asset = AppriseAsset(default_service_wait=1.5) nb = _TestNotify(host="localhost", asset=asset) assert nb.wait == pytest.approx(1.5) def test_explicit_wait_overrides_asset(self): asset = AppriseAsset(default_service_wait=1.5) nb = _TestNotify(host="localhost", asset=asset, wait=0.2) assert nb.wait == pytest.approx(0.2) def test_asset_retry_default_used(self): asset = AppriseAsset(default_service_retry=3) nb = _TestNotify(host="localhost", asset=asset) assert nb.retry == 3 def test_explicit_retry_overrides_asset(self): asset = AppriseAsset(default_service_retry=3) nb = _TestNotify(host="localhost", asset=asset, retry=1) assert nb.retry == 1 class _RaisingNotify(NotifyBase): """Plugin that raises a bare Exception from notify().""" app_id = "RaisingApp" app_desc = "Test" notify_url = "raise://" title_maxlen = 250 body_maxlen = 32768 def url(self, *args, **kwargs): """Return the stable URL for this raising test plugin.""" return "raise://localhost" def send(self, **kwargs): """Raise a delivery error for synchronous exception tests.""" raise RuntimeError("plugin exploded") async def async_notify(self, **kwargs): """Raise a delivery error for asynchronous exception tests.""" raise RuntimeError("plugin exploded async") @staticmethod def parse_url(url): """Parse the synthetic URL without requiring a real host.""" return NotifyBase.parse_url(url, verify_host=False) class _BadFormatNotify(NotifyBase): """Plugin that raises while its content is prepared. The failure lands before any service is dispatched. ``error`` picks what resolve_format() raises, so a test can show the guard is not limited to TypeError. """ app_id = "BadFormatApp" app_desc = "Test" service_name = "BadFormat" notify_url = "badformat://" title_maxlen = 250 body_maxlen = 32768 def __init__(self, error=None, **kwargs): """Initialize with the exception preparation should raise.""" super().__init__(**kwargs) self._error = error or TypeError("bad format resolution") def resolve_format(self, body_format=None): """Fail the way a buggy plugin would, before anything is sent.""" raise self._error def url(self, *args, **kwargs): """Return the stable URL for this misbehaving test plugin.""" return "badformat://localhost" def send(self, **kwargs): """Never reached; preparation fails before delivery starts.""" return True @staticmethod def parse_url(url): """Parse the synthetic URL without requiring a real host.""" return NotifyBase.parse_url(url, verify_host=False) class _SlowNotify(NotifyBase): """Plugin whose send() blocks for a configurable delay before reporting success -- used to exercise AppriseAsset._service_timeout and notify(timeout=...) enforcement with real wall-clock timing.""" app_id = "SlowApp" app_desc = "Test" notify_url = "slow://" title_maxlen = 250 body_maxlen = 32768 def __init__(self, delay=0.0, **kwargs): """Initialize the test plugin with its artificial delay.""" super().__init__(**kwargs) self._delay = delay self.calls = 0 def url(self, *args, **kwargs): """Return a stable URL containing the test host.""" return "slow://{}".format(self.host) def send(self, **kwargs): """Simulate blocking delivery before reporting success.""" self.calls += 1 time.sleep(self._delay) return True async def async_notify(self, **kwargs): """Simulate asynchronous delivery before reporting success.""" self.calls += 1 await asyncio.sleep(self._delay) return True @staticmethod def parse_url(url): """Parse the synthetic URL without requiring a real host.""" return NotifyBase.parse_url(url, verify_host=False) class TestAppriseTag: """Full branch coverage for AppriseTag.""" def test_parse_returns_same_instance(self): """parse() short-circuits when the input is already an AppriseTag.""" t = AppriseTag("alerts", priority=2) # Must return the exact same object, not a copy assert AppriseTag.parse(t) is t def test_parse_fallback_for_unrecognised_input(self): """parse() falls back to a raw-name tag when the regex has no match.""" # Input starting with '@' does not match _RE_TAG; the fallback branch # stores the whole input as the name. t = AppriseTag.parse("@invalid") assert str(t) == "@invalid" assert t.priority == 0 assert t.retry is None # --- __repr__ --- def test_repr_plain(self): """repr() with default priority and no retry is minimal.""" assert repr(AppriseTag("simple")) == "AppriseTag('simple')" def test_repr_with_priority(self): """repr() includes priority= when the priority is non-zero.""" r = repr(AppriseTag("test", priority=3)) assert "priority=3" in r def test_repr_with_retry(self): """repr() includes retry= when a retry count is set.""" r = repr(AppriseTag("test", retry=5)) assert "retry=5" in r def test_repr_with_priority_and_retry(self): """repr() includes both fields when both are non-default.""" r = repr(AppriseTag("test", priority=2, retry=3)) assert "priority=2" in r assert "retry=3" in r # --- __eq__ --- def test_eq_two_apprisetags_same_name(self): """Two AppriseTag objects are equal when their names match, regardless of priority or retry.""" assert AppriseTag("abc") == AppriseTag("abc", priority=5) assert AppriseTag("ABC") == AppriseTag("abc") def test_eq_with_string(self): """An AppriseTag equals a plain str by name.""" assert AppriseTag("hello") == "hello" assert AppriseTag("HELLO") == "hello" def test_eq_not_equal_different_name(self): assert AppriseTag("abc") != AppriseTag("xyz") assert AppriseTag("abc") != "xyz" def test_eq_returns_notimplemented_for_other_types(self): """Comparing to a non-str/AppriseTag returns NotImplemented""" result = AppriseTag("abc").__eq__(42) assert result is NotImplemented # --- __lt__ --- def test_lt_two_apprisetags(self): """__lt__ sorts two AppriseTag objects by tag name.""" assert AppriseTag("abc") < AppriseTag("def") assert not (AppriseTag("def") < AppriseTag("abc")) def test_lt_with_string(self): """AppriseTag can be ordered against a plain string.""" assert AppriseTag("abc") < "def" assert not (AppriseTag("zzz") < "aaa") def test_lt_returns_notimplemented_for_other_types(self): """__lt__ with non-str/AppriseTag returns NotImplemented""" result = AppriseTag("abc").__lt__(42) assert result is NotImplemented # --- __bool__ --- def test_bool_empty_tag_is_false(self): """An AppriseTag with an empty name is falsy""" assert not AppriseTag("") assert bool(AppriseTag("")) is False def test_bool_nonempty_tag_is_true(self): assert bool(AppriseTag("something")) is True # --- set / dict integration --- def test_apprisetag_in_set_with_plain_string(self): """AppriseTag in a set is found by plain string membership test.""" s = {AppriseTag("endpoint")} assert "endpoint" in s def test_plain_string_in_set_with_apprisetag(self): """A plain string is found in a set containing AppriseTag.""" s = {"endpoint"} assert AppriseTag("endpoint") in s class TestStaticHelpers: """Cover priority helpers not reached by integration tests.""" def test_set_filter_tokens_are_ordered(self): """A set filter yields the same token order every run. Sorting prevents Python's changing set order from selecting a different retry value in another process. """ tokens = Apprise._parse_filter_tokens({"b:2", "a:1", "c:3"}) assert [str(t) for t in tokens] == ["a", "b", "c"] # The same filter written as a list keeps the caller's own order. tokens = Apprise._parse_filter_tokens(["b:2", "a:1", "c:3"]) assert [str(t) for t in tokens] == ["b", "a", "c"] def test_set_filter_chains_are_ordered(self): """A set filter puts a service in the same chain every run. A service with several matching tags must not move between chains when the process restarts. """ class _MultiTagged: """Service carrying both tags the filter names.""" tags = {"alpha", "beta"} chains = Apprise._build_tag_chains( [(_MultiTagged(), {})], {"beta", "alpha"} ) assert sorted(chains) == ["alpha"] def test_service_priority_for_tag_name_absent(self): """Returns 0 when no tag in service.tags matches tag_name.""" service = mock.Mock() service.tags = {AppriseTag("alerts", priority=3, has_priority=True)} assert Apprise._service_priority_for_tag_name(service, "backup") == 0 def test_match_service_retry_plain_string_tag(self): """Plain string in service.tags matched by a priority-prefixed token. Covers the string fallback when a matching service tag is not an AppriseTag instance. """ service = mock.Mock() service.tags = {"alerts"} # plain string, not an AppriseTag # "3:alerts:2" carries priority=3 + retry=2; plain "alerts" matches retry_tokens = Apprise._parse_retry_filter_tokens("3:alerts:2") result = Apprise._match_service_retry(service, retry_tokens) assert result == 2 def test_match_service_retry_plain_string_tag_no_match(self): """Plain string in service.tags that does not match returns None.""" service = mock.Mock() service.tags = {"other"} # plain string -- does not match "alerts" retry_tokens = Apprise._parse_retry_filter_tokens("3:alerts:2") result = Apprise._match_service_retry(service, retry_tokens) assert result is None def test_inject_per_service_retries_mixed_matches(self): """Only matching services receive the retry override.""" matching = mock.Mock() matching.tags = {"alerts"} other = mock.Mock() other.tags = {"other"} all_calls = [(matching, {}), (other, {})] result = Apprise._inject_per_service_retries(all_calls, "3:alerts:2") matching_kwargs = next(k for s, k in result if s is matching) other_kwargs = next(k for s, k in result if s is other) assert matching_kwargs["_retry_override"] == 2 assert "_retry_override" not in other_kwargs def test_inject_per_service_retries_no_retry_tokens(self): """A tag filter with no retry suffix leaves every call unchanged.""" service = mock.Mock() service.tags = {"alerts"} all_calls = [(service, {})] result = Apprise._inject_per_service_retries(all_calls, "alerts") assert result == all_calls def test_resolve_retry_count_clamps_override(self): """Clamp tag retry overrides to the same limit as plugin URLs.""" from apprise.common import APPRISE_MAX_SERVICE_RETRY from apprise.dispatch import resolve_retry_count service = mock.Mock() service.retry = 0 # A huge override is clamped down to the documented ceiling. kwargs = {"_retry_override": 1000000000} assert ( resolve_retry_count(service, kwargs) == APPRISE_MAX_SERVICE_RETRY ) # The override key is always consumed, even when clamped. assert "_retry_override" not in kwargs # A negative override is clamped up to zero, not left negative. assert resolve_retry_count(service, {"_retry_override": -5}) == 0 # A within-range override passes through unchanged. assert resolve_retry_count(service, {"_retry_override": 3}) == 3 # No override at all falls back to the plugin's own (already # clamped) retry attribute. service.retry = 4 assert resolve_retry_count(service, {}) == 4 # Invalid overrides fall back to the service's retry value. service.retry = 2 assert resolve_retry_count(service, {"_retry_override": "x"}) == 2 def test_configured_max_attempts_preserves_override(self): """Read timeout metadata without consuming the worker's override.""" from apprise.dispatch import configured_max_attempts service = mock.Mock() service.retry = 1 # The timeout result and worker must see the same retry limit. kwargs = {"_retry_override": 3} assert configured_max_attempts(service, kwargs) == 4 assert kwargs == {"_retry_override": 3} def test_configured_max_attempts_handles_bad_metadata(self): """Leave invalid plugin metadata for the worker safety net.""" from apprise.dispatch import configured_max_attempts class BrokenRetry: @property def retry(self): # A plugin property may fail before its worker starts. raise RuntimeError("bad retry metadata") assert configured_max_attempts(BrokenRetry(), {}) == 1 def test_retry_override_end_to_end_is_bounded(self): """Prevent a huge tag retry suffix from creating an unbounded loop.""" from apprise.common import APPRISE_MAX_SERVICE_RETRY N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False, service_timeout=0) # fail_times higher than the clamp so every attempt fails and # the loop runs out its full (clamped) retry budget. service = _FailThenSucceedNotify( host="x", asset=asset, fail_times=10**9 ) service.tags = {"alerts"} a = Apprise(asset=asset) a.add(service) result = a.notify(body="test", tag="alerts:1000000000") assert bool(result) is False assert service._calls == APPRISE_MAX_SERVICE_RETRY + 1 assert ( result.results[0].max_attempts == APPRISE_MAX_SERVICE_RETRY + 1 ) finally: N_MGR.unload_modules() def test_apprise_tag_parse_rejects_absurd_digit_runs(self): """Ignore numeric tag fields that exceed Python's conversion limit.""" huge_digits = "9" * 5000 # An absurd retry suffix falls back to retry=None instead of # raising ValueError out of parse(). parsed = AppriseTag.parse(f"alerts:{huge_digits}") assert str(parsed) == "alerts" assert parsed.retry is None # An unusable priority is absent and must not select flat dispatch. parsed = AppriseTag.parse(f"{huge_digits}:alerts") assert str(parsed) == "alerts" assert parsed.priority == 0 assert parsed.has_priority is False assert ( Apprise._filter_has_explicit_priority([f"{huge_digits}:alerts"]) is False ) # A normal, reasonably-sized value is unaffected by the guard. parsed = AppriseTag.parse("3:alerts:5") assert parsed.priority == 3 assert parsed.retry == 5 assert parsed.has_priority is True assert Apprise._filter_has_explicit_priority(["3:alerts:5"]) is True def test_service_metadata_survives_bad_tag_str(self): """Fall back safely when a custom tag cannot convert to text.""" from apprise.dispatch import service_metadata class _BadTag: def __str__(self): raise RuntimeError("boom") service = mock.Mock() service.service_name = "Test" service.url.return_value = "test://" service.url_id.return_value = "abc" service.__len__ = mock.Mock(return_value=1) service.tags = {_BadTag()} name, url, url_id, tag, weight = service_metadata(service) assert name == "Test" assert url == "test://" assert url_id == "abc" assert tag == () assert weight == 1 def test_notify_succeeds_despite_bad_tag_str(self): """Keep a successful result when custom tag conversion fails.""" class _BadTag: def __str__(self): raise RuntimeError("boom") class _AlwaysSucceed(NotifyBase): def send(self, body, title="", notify_type=None, **kwargs): return True service = _AlwaysSucceed(host="x", asset=AppriseAsset()) service.tags = {_BadTag()} a = Apprise() a.add(service) result = a.notify(body="test") assert bool(result) is True assert result.results[0].tag == () def test_has_priority_nested_list(self): """A priority prefix is found inside a nested list.""" assert Apprise._filter_has_explicit_priority([["2:alerts"]]) def test_has_priority_list_of_strings(self): """A priority prefix is found in a plain-string list.""" assert Apprise._filter_has_explicit_priority(["2:alerts"]) def test_has_priority_false_no_prefix(self): """Filters without a prefix do not carry explicit priority.""" assert not Apprise._filter_has_explicit_priority("alerts") assert not Apprise._filter_has_explicit_priority(["alerts"]) def test_has_priority_false_for_match_all(self): """Match-all filters do not carry an explicit priority.""" assert not Apprise._filter_has_explicit_priority(MATCH_ALL_TAG) assert not Apprise._filter_has_explicit_priority(None) # _notify_parallel_threadpool / _notify_parallel_asyncio zero-length ----- def test_threadpool_zero_services_returns_true(self): """Empty service list returns (True, []) immediately.""" ok, results = Apprise._notify_parallel_threadpool() assert ok is True assert results == [] def test_asyncio_zero_services_returns_true(self): """Empty service list returns (True, []) immediately.""" ok, results = asyncio.run(Apprise._notify_parallel_asyncio()) assert ok is True assert results == [] class TestDispatchIntegration: """End-to-end notify() tests covering the new priority/retry dispatch.""" def _make_service(self, host, priority, asset, fail_times=0): """Create a _FailThenSucceedNotify with an AppriseTag in its tags.""" s = _FailThenSucceedNotify( host=host, asset=asset, fail_times=fail_times ) # Assign a fresh instance-level set rather than mutating the # class-level tags set. URLBase.tags is a class attribute (set()); # .add() would mutate it and share state across all instances. # Assignment shadows the class attribute and gives each service its # own set with the correct priority stored in the AppriseTag object. s.tags = {AppriseTag("alerts", priority=priority, has_priority=True)} return s def test_filter_retry_override_via_tag_suffix(self): """tag suffix ':N' overrides per-service retry for this call only.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # Service's own retry=0; tag suffix of 2 should allow 3 attempts service = _FailThenSucceedNotify( host="localhost", asset=asset, retry=0, wait=0.0, fail_times=2, ) # Tag the service so the filter "failpass:2" (tag name "failpass", # retry override 2) can match it via is_exclusive_match. service.tags = {"failpass"} a = Apprise(asset=asset) a.add(service) # "failpass:2" -> retry override of 2 (3 total attempts) result = a.notify(body="test", tag="failpass:2") assert bool(result) is True assert service._calls == 3 finally: N_MGR.unload_modules() def test_exclusive_priority_dispatch_skips_other_priorities(self): """An explicit priority dispatches only matching-priority services.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s1 = self._make_service("host1", priority=1, asset=asset) s2 = self._make_service("host2", priority=2, asset=asset) a = Apprise(asset=asset) a.add(s1) a.add(s2) # "2:alerts" -> only priority-2 service fires result = a.notify(body="test", tag="2:alerts") assert bool(result) is True assert s1._calls == 0 # priority-1, not contacted assert s2._calls == 1 # priority-2, contacted once finally: N_MGR.unload_modules() def test_escalation_stops_after_priority_success(self): """If priority-1 group all succeed, priority-2 group is not run.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s1 = self._make_service("host1", priority=1, asset=asset) s2 = self._make_service("host2", priority=2, asset=asset) a = Apprise(asset=asset) a.add(s1) a.add(s2) result = a.notify(body="test", tag="alerts") assert bool(result) is True assert s1._calls == 1 # priority 1 succeeded assert s2._calls == 0 # never escalated to finally: N_MGR.unload_modules() def test_escalation_triggers_on_priority_group_failure(self): """Priority-1 group failure escalates to priority-2 group.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # priority-1 always fails s1 = self._make_service( "host1", priority=1, asset=asset, fail_times=999 ) # priority-2 always succeeds s2 = self._make_service("host2", priority=2, asset=asset) a = Apprise(asset=asset) a.add(s1) a.add(s2) result = a.notify(body="test", tag="alerts") assert bool(result) is True assert s1._calls == 1 assert s2._calls == 1 finally: N_MGR.unload_modules() def test_async_notify_filter_retry_and_explicit_priority(self): """async_notify() supports escalation and exclusive priority paths.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset() s1 = self._make_service("host1", priority=1, asset=asset) s2 = self._make_service("host2", priority=2, asset=asset) a = Apprise(asset=asset) a.add(s1) a.add(s2) async def run_exclusive(): """Dispatch only the explicitly selected priority.""" return await a.async_notify(body="test", tag="2:alerts") async def run_escalation(): """Dispatch the first successful escalation group.""" return await a.async_notify(body="test", tag="alerts") # exclusive: only priority-2 fires r = asyncio.run(run_exclusive()) assert bool(r) is True assert s1._calls == 0 assert s2._calls == 1 # escalation: priority-1 succeeds, priority-2 skipped s1._calls = 0 s2._calls = 0 r = asyncio.run(run_escalation()) assert bool(r) is True assert s1._calls == 1 assert s2._calls == 0 finally: N_MGR.unload_modules() def test_plain_and_zero_prefix_are_equivalent(self): """'family' and '0:family' are identical at priority 0. A filter of '0:family' must match services tagged with plain 'family', and both forms must land in the same escalation group.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # s1: tagged with plain "family" (priority=0, has_priority=False) s1 = _FailThenSucceedNotify( host="host1", asset=asset, fail_times=0 ) s1.tags = {AppriseTag.parse("family")} # s2: tagged with explicit "0:family" (priority=0, # has_priority=True) s2 = _FailThenSucceedNotify( host="host2", asset=asset, fail_times=0 ) s2.tags = {AppriseTag.parse("0:family")} # s3: lower-urgency fallback -- should never fire when p-0 succeeds s3 = _FailThenSucceedNotify( host="host3", asset=asset, fail_times=0 ) s3.tags = {AppriseTag.parse("1:family")} a = Apprise(asset=asset) a.add(s1) a.add(s2) a.add(s3) # Exclusive "0:family": plain "family" and "0:family" services # are both priority 0 and must both fire; priority-1 service is # skipped. result = a.notify(body="test", tag="0:family") assert bool(result) is True assert s1._calls == 1 # plain "family" matched by "0:family" assert s2._calls == 1 # explicit "0:family" matched assert s3._calls == 0 # priority 1, not priority 0 -- skipped # Escalation "family": s1 and s2 are both in the priority-0 # group. Both succeed, so s3 (priority 1) is never escalated to. s1._calls = s2._calls = s3._calls = 0 result = a.notify(body="test", tag="family") assert bool(result) is True assert s1._calls == 1 # priority-0 group assert s2._calls == 1 # same priority-0 group assert s3._calls == 0 # not escalated to finally: N_MGR.unload_modules() class TestRetryWarningMessage: """Retry warnings show total attempts in sync and async dispatch.""" def test_sync_attempt_denominator_counts_total_attempts(self, caplog): """Sequential dispatch: one retry means two total attempts.""" N_MGR["failpass"] = _FailThenSucceedNotify try: # Older pytest releases (e.g. the one shipped with EL9) do # not un-disable logging on our behalf when # caplog.set_level() is called, so a prior module-level # logging.disable(logging.CRITICAL) call would otherwise # silently swallow this warning. Clear it explicitly. logging.disable(logging.NOTSET) caplog.set_level(logging.WARNING, logger=LOGGER_NAME) asset = AppriseAsset(async_mode=False) # Fail once, then succeed on the only retry. service = _FailThenSucceedNotify( host="localhost", asset=asset, retry=1, wait=0.0, fail_times=1, ) a = Apprise(asset=asset) a.add(service) result = a.notify(body="test") assert bool(result) is True assert service._calls == 2 # The denominator must be retry + 1 (2), not retry (1). assert "Attempt 1/2" in caplog.text assert "Attempt 1/1" not in caplog.text finally: logging.disable(logging.CRITICAL) N_MGR.unload_modules() def test_async_attempt_denominator_counts_total_attempts(self, caplog): """Asyncio dispatch: one retry means two total attempts.""" N_MGR["failpass"] = _FailThenSucceedNotify try: # See the sync test above for why this reset is needed. logging.disable(logging.NOTSET) caplog.set_level(logging.WARNING, logger=LOGGER_NAME) asset = AppriseAsset(async_mode=True) service = _FailThenSucceedNotify( host="localhost", asset=asset, retry=1, wait=0.0, fail_times=1, ) a = Apprise(asset=asset) a.add(service) result = asyncio.run(a.async_notify(body="test")) assert bool(result) is True assert service._calls == 2 # The denominator must be retry + 1 (2), not retry (1). assert "Attempt 1/2" in caplog.text assert "Attempt 1/1" not in caplog.text finally: logging.disable(logging.CRITICAL) N_MGR.unload_modules() class TestDispatchSafetyNets: """Dispatch crashes are reported as failures, never raised at callers.""" class _ExplodingAsset(AppriseAsset): """Asset whose mode lookup fails inside batch dispatch.""" @property def async_mode(self): """Raise the injected failure when dispatch reads mode.""" raise RuntimeError("injected async_mode access") def _tagged_service(self): """Build one tagged service whose dispatch will raise.""" # Reading async_mode later will trigger the test failure. service = _FailThenSucceedNotify( host="solo", asset=self._ExplodingAsset(), fail_times=0 ) service.tags = {"alerts"} # Return the public object used by both sync and async tests. a = Apprise() a.add(service) return a def test_flat_dispatch_exception(self): """A crash during flat dispatch becomes a failed result. An explicit priority skips the escalation chain, so this batch has no later group to fall back on. """ N_MGR["failpass"] = _FailThenSucceedNotify try: # An exact priority uses flat dispatch instead of escalation. result = self._tagged_service().notify(body="test", tag="0:alerts") # The caller receives a normal failed result, not an exception. assert bool(result) is False finally: N_MGR.unload_modules() def test_async_flat_dispatch_exception(self): """The asynchronous flat dispatch contains a crash the same way.""" N_MGR["failpass"] = _FailThenSucceedNotify try: # The asynchronous public API must provide the same safe result. result = asyncio.run( self._tagged_service().async_notify( body="test", tag="0:alerts" ) ) # The caller receives a normal failed result, not an exception. assert bool(result) is False finally: N_MGR.unload_modules() def test_async_base_exception_result(self): """A BaseException from one service still produces its result. asyncio.gather() hands back BaseException subclasses, and CancelledError is the one seen in practice. These are not Exception subclasses, so they must not reach tuple unpacking. """ N_MGR["failpass"] = _FailThenSucceedNotify try: class _Fatal(BaseException): """Stands in for CancelledError, which is not Exception.""" class _FatalNotify(_FailThenSucceedNotify): """Plugin whose asynchronous send raises a BaseException.""" async def async_notify(self, **kwargs): """Fail in a way ordinary handlers do not catch.""" raise _Fatal("plugin cancelled") # Use asynchronous mode so gather() receives the raised object. service = _FatalNotify( host="solo", asset=AppriseAsset(async_mode=True), fail_times=0, ) # Exercise the batch helper directly to inspect its result list. ok, results = asyncio.run( Apprise._notify_parallel_asyncio((service, {"body": "test"})) ) # The crash is recorded rather than losing the service entirely. assert ok is False assert len(results) == 1 finally: N_MGR.unload_modules() def test_bad_metadata_fallbacks(self): """Result building tolerates a plugin whose metadata raises. These helpers exist to describe an already-misbehaving plugin, so they must not raise a second time while doing it. """ from apprise.dispatch import ( RetryRunner, finalize_service_result, resolve_retry_count, service_crashed, service_metadata, timeout_result, ) from apprise.result import NotifyAttempt class _Hostile: """Stand-in whose every plugin-supplied detail raises.""" @property def service_name(self): """Raise instead of naming the service.""" raise RuntimeError("no name") @property def optional(self): """Raise instead of reporting optional status.""" raise RuntimeError("no optional") @property def retry(self): """Raise instead of reporting a retry count.""" raise RuntimeError("no retry") @property def wait(self): """Raise instead of reporting a retry wait.""" raise RuntimeError("no wait") @property def tags(self): """Raise instead of listing tags.""" raise RuntimeError("no tags") def url(self, **kwargs): """Raise instead of returning a URL.""" raise RuntimeError("no url") def url_id(self): """Raise instead of returning a URL identifier.""" raise RuntimeError("no url_id") def __len__(self): """Raise instead of reporting a target count.""" raise RuntimeError("no length") # Every result helper below receives the same broken plugin. service = _Hostile() failed = [NotifyAttempt(status=AppriseResultStatus.FAILURE)] # The metadata helper supplies its documented safe values. assert service_metadata(service) == ( "Unknown", "unknown://", None, (), 1, ) # Error results remain required failures when optional cannot be read. assert bool(service_crashed(service, ValueError("boom"))) is False # The timeout and completed-attempt paths fall back the same way. assert timeout_result(service, 1.0, 1).optional is False ok, result = finalize_service_result(service, 0, failed) assert ok is False assert result.optional is False # A broken retry property must not throw away a valid override. assert resolve_retry_count(service, {"_retry_override": 3}) == 3 assert resolve_retry_count(service, {}) == 0 assert resolve_retry_count(service, {"_retry_override": "x"}) == 0 class _BadRetryValue: """Plugin whose retry is readable but not a usable number.""" retry = "bad" # A non-numeric retry is treated as none rather than reaching the # comparison that clamps it. bad = _BadRetryValue() assert resolve_retry_count(bad, {}) == 0 assert resolve_retry_count(bad, {"_retry_override": "x"}) == 0 assert resolve_retry_count(bad, {"_retry_override": 2}) == 2 class _InfiniteRetry: """Plugin whose retry has no whole-number form.""" retry = float("inf") # Infinity and NaN have no integer form, so they clamp to none too. infinite = _InfiniteRetry() assert resolve_retry_count(infinite, {}) == 0 assert ( resolve_retry_count(infinite, {"_retry_override": float("inf")}) == 0 ) assert ( resolve_retry_count(infinite, {"_retry_override": float("nan")}) == 0 ) # The runner reads the name once, so its log lines stay usable. assert RetryRunner(service, {}, None).name == "Unknown" class TestExceptionHandling: """Verify that plugin exceptions are caught and retried, not propagated.""" def test_sequential_exception_treated_as_failure(self): """A plugin raising Exception is caught; delivery is marked failed.""" N_MGR["raise"] = _RaisingNotify try: asset = AppriseAsset(async_mode=False) service = _RaisingNotify(host="localhost", asset=asset, retry=0) a = Apprise(asset=asset) a.add(service) result = a.notify(body="test") assert bool(result) is False finally: N_MGR.unload_modules() def test_sequential_exception_retried(self): """Exception on first attempt is retried; later success counts.""" N_MGR["failpass"] = _FailThenSucceedNotify N_MGR["raise"] = _RaisingNotify try: asset = AppriseAsset(async_mode=False) # Use a plugin that raises on the first attempt then succeeds service = _FailThenSucceedNotify( host="localhost", asset=asset, retry=1, wait=0.0, fail_times=1 ) # Patch notify to raise on the first call then succeed call_count = [0] def raising_then_ok(**kwargs): call_count[0] += 1 if call_count[0] == 1: raise RuntimeError("transient error") return True service.send = raising_then_ok a = Apprise(asset=asset) a.add(service) result = a.notify(body="test") assert bool(result) is True assert call_count[0] == 2 finally: N_MGR.unload_modules() def test_threadpool_future_exception_caught(self): """An exception escaping a thread future is caught gracefully.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=True) # Need 2 services to trigger the thread pool path s1 = _FailThenSucceedNotify( host="host1", asset=asset, fail_times=0 ) s2 = _FailThenSucceedNotify( host="host2", asset=asset, fail_times=0 ) a = Apprise(asset=asset) a.add(s1) a.add(s2) # Inject one failed future so the outer safety net is exercised. import concurrent.futures as cf real_executor_cls = cf.ThreadPoolExecutor class _PatchedExecutor: """Delegate to a real executor while injecting one failure.""" def __init__(self, *args, **kwargs): """Create the wrapped executor with the same arguments.""" self._real = real_executor_cls(*args, **kwargs) def __enter__(self): """Return this wrapper when entering the context.""" return self def __exit__(self, *exc): """Forward context cleanup to the wrapped executor.""" return self._real.__exit__(*exc) def submit( self, context_run, fn, service, kwargs, call_deadline ): """Return a failed future for ``s1`` and submit others.""" if service is s1: fut = cf.Future() fut.set_exception(RuntimeError("future boom")) return fut return self._real.submit( context_run, fn, service, kwargs, call_deadline ) def shutdown(self, *args, **kwargs): """Forward shutdown behavior to the wrapped executor.""" return self._real.shutdown(*args, **kwargs) # Reset the shared executor so the patched class is used here. import apprise.apprise as apprise_module with ( mock.patch("apprise.apprise._shared_executor", None), mock.patch( "apprise.apprise.cf.ThreadPoolExecutor", _PatchedExecutor ), ): result = a.notify(body="test") # Clean up the real executor wrapped by _PatchedExecutor. apprise_module._shared_executor._real.shutdown(wait=True) # One future raised -> overall result is False assert bool(result) is False finally: N_MGR.unload_modules() def test_sequential_future_exception_caught(self): """An exception escaping a sequential dispatch's thread future is caught by the outer safety net, same as the thread-pool path.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False, service_timeout=0.05) service = _SlowNotify(host="x", asset=asset, delay=0.0) a = Apprise(asset=asset) a.add(service) import concurrent.futures as cf real_executor_cls = cf.ThreadPoolExecutor class _PatchedExecutor: """Delegate to a real executor while always failing.""" def __init__(self, *args, **kwargs): """Create the wrapped executor with the same args.""" self._real = real_executor_cls(*args, **kwargs) def __enter__(self): """Return this wrapper when entering the context.""" return self def __exit__(self, *exc): """Forward context cleanup to the wrapped executor.""" return self._real.__exit__(*exc) def submit( self, context_run, fn, service, kwargs, call_deadline ): """Return a future that raises instead of running.""" fut = cf.Future() fut.set_exception(RuntimeError("future boom")) return fut def shutdown(self, *args, **kwargs): """Forward shutdown behavior to the wrapped executor.""" return self._real.shutdown(*args, **kwargs) # Reset the shared executor so the patched class is used here. import apprise.apprise as apprise_module with ( mock.patch("apprise.apprise._shared_executor", None), mock.patch( "apprise.apprise.cf.ThreadPoolExecutor", _PatchedExecutor ), ): result = a.notify(body="test") # Clean up the real executor wrapped by _PatchedExecutor. apprise_module._shared_executor._real.shutdown(wait=True) assert bool(result) is False finally: N_MGR.unload_modules() def test_asyncio_exception_in_do_call_treated_as_failure(self): """An exception inside do_call marks the service as failed.""" N_MGR["raise"] = _RaisingNotify try: asset = AppriseAsset() service = _RaisingNotify(host="localhost", asset=asset, retry=0) a = Apprise(asset=asset) a.add(service) async def run(): """Execute async notification with the patched gather.""" return await a.async_notify(body="test") result = asyncio.run(run()) assert bool(result) is False finally: N_MGR.unload_modules() def test_asyncio_gather_unhandled_exception(self): """An Exception escaping the outer gather() round is caught as a safety net and treated as a batch failure.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset() service = _FailThenSucceedNotify( host="localhost", asset=asset, fail_times=0 ) a = Apprise(asset=asset) a.add(service) # Mock asyncio.gather to return an escaped exception object. # Close any unawaited coroutines passed to the mock to avoid # "coroutine was never awaited" RuntimeWarnings. async def fake_gather(*args, **kw): """Return an escaped error while closing input coroutines.""" for arg in args: if asyncio.iscoroutine(arg): arg.close() return [RuntimeError("escaped")] async def run(): """Dispatch while gather returns an escaped exception.""" with mock.patch("apprise.apprise.asyncio.gather", fake_gather): return await a.async_notify(body="test") result = asyncio.run(run()) assert bool(result) is False finally: N_MGR.unload_modules() def test_asyncio_gather_type_error(self): """A TypeError in gather results is caught and returns False.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset() service = _FailThenSucceedNotify( host="localhost", asset=asset, fail_times=0 ) a = Apprise(asset=asset) a.add(service) async def fake_gather(*args, **kw): """Return a validation error as a gathered result.""" for arg in args: if asyncio.iscoroutine(arg): arg.close() return [TypeError("validation")] async def run(): """Dispatch while gather returns a validation error.""" with mock.patch("apprise.apprise.asyncio.gather", fake_gather): return await a.async_notify(body="test") result = asyncio.run(run()) assert bool(result) is False finally: N_MGR.unload_modules() def test_sequential_raise_does_not_block_other_services(self): """A raising service is marked failed but every other service in the same sequential batch is still attempted and reported.""" N_MGR["raise"] = _RaisingNotify N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) bad = _RaisingNotify(host="bad", asset=asset) good1 = _FailThenSucceedNotify( host="good1", asset=asset, fail_times=0 ) good2 = _FailThenSucceedNotify( host="good2", asset=asset, fail_times=0 ) a = Apprise(asset=asset) a.add(good1) a.add(bad) a.add(good2) result = a.notify(body="test") assert bool(result) is False assert len(result) == 3 # Order matches add() order: good1, bad, good2. results = list(result) assert bool(results[0]) is True assert bool(results[1]) is False assert bool(results[2]) is True # good2 was still attempted despite bad raising before it assert good2._calls == 1 finally: N_MGR.unload_modules() def test_threadpool_raise_does_not_block_other_services(self): """Same guarantee as above, but across the thread-pool dispatch path (async_mode=True, >1 service).""" N_MGR["raise"] = _RaisingNotify N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=True) bad = _RaisingNotify(host="bad", asset=asset) good1 = _FailThenSucceedNotify( host="good1", asset=asset, fail_times=0 ) good2 = _FailThenSucceedNotify( host="good2", asset=asset, fail_times=0 ) a = Apprise(asset=asset) a.add(good1) a.add(bad) a.add(good2) result = a.notify(body="test") assert bool(result) is False assert len(result) == 3 # Threadpool dispatch preserves original submission order. results = list(result) assert bool(results[0]) is True assert bool(results[1]) is False assert bool(results[2]) is True assert good2._calls == 1 finally: N_MGR.unload_modules() def test_asyncio_raise_does_not_block_other_services(self): """Same guarantee as above, but across the asyncio dispatch path.""" N_MGR["raise"] = _RaisingNotify N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=True) bad = _RaisingNotify(host="bad", asset=asset) good1 = _FailThenSucceedNotify( host="good1", asset=asset, fail_times=0 ) good2 = _FailThenSucceedNotify( host="good2", asset=asset, fail_times=0 ) a = Apprise(asset=asset) a.add(good1) a.add(bad) a.add(good2) result = asyncio.run(a.async_notify(body="test")) assert bool(result) is False assert len(result) == 3 # asyncio dispatch preserves original submission order too. results = list(result) assert bool(results[0]) is True assert bool(results[1]) is False assert bool(results[2]) is True assert good2._calls == 1 finally: N_MGR.unload_modules() def test_notify_preparation_typeerror(self): """A TypeError raised while preparing content explains itself.""" N_MGR["badformat"] = _BadFormatNotify try: asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) a.add(_BadFormatNotify(host="localhost", asset=asset)) with apprise_logs_enabled(): result = a.notify(body="test") finally: N_MGR.unload_modules() assert result.status == AppriseResultStatus.FAILURE # The service that broke reports itself rather than vanishing. assert [(r.name, r.status) for r in result.results] == [ ("BadFormat", AppriseResultStatus.FAILURE) ] # The reason has to reach the caller; a silent FAILURE here used # to leave nothing at all behind to diagnose. assert any( "raised an exception" in e.message for e in result.call_logs() ) def test_async_notify_preparation_typeerror(self): """The asynchronous path explains a preparation TypeError too.""" N_MGR["badformat"] = _BadFormatNotify try: asset = AppriseAsset(async_mode=True) a = Apprise(asset=asset) a.add(_BadFormatNotify(host="localhost", asset=asset)) with apprise_logs_enabled(): result = asyncio.run(a.async_notify(body="test")) finally: N_MGR.unload_modules() assert result.status == AppriseResultStatus.FAILURE assert [(r.name, r.status) for r in result.results] == [ ("BadFormat", AppriseResultStatus.FAILURE) ] assert any( "raised an exception" in e.message for e in result.call_logs() ) def test_async_notify_unknown_argument(self): """async_notify() names the argument it could not accept.""" N_MGR["test"] = _TestNotify try: asset = AppriseAsset(async_mode=True) a = Apprise(asset=asset) a.add(_TestNotify(host="localhost", asset=asset)) with apprise_logs_enabled(): result = asyncio.run(a.async_notify(body="test", bogus=1)) finally: N_MGR.unload_modules() assert result.status == AppriseResultStatus.FAILURE assert len(result.results) == 0 assert any("bogus" in e.message for e in result.call_logs()) def test_notify_preparation_crash(self): """A plugin crashing during preparation fails the call, not the caller.""" N_MGR["badformat"] = _BadFormatNotify try: asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) a.add( _BadFormatNotify( host="localhost", asset=asset, error=RuntimeError("plugin blew up"), ) ) with apprise_logs_enabled(): result = a.notify(body="test", log_level=logging.DEBUG) finally: N_MGR.unload_modules() # Anything raised while preparing content belongs in the result, # not thrown at whoever called notify(). assert result.status == AppriseResultStatus.FAILURE assert [(r.name, r.status) for r in result.results] == [ ("BadFormat", AppriseResultStatus.FAILURE) ] # DEBUG carries the original text behind the summary warning. assert any("plugin blew up" in e.message for e in result.call_logs()) def test_async_notify_preparation_crash(self): """The asynchronous path contains a preparation crash too.""" N_MGR["badformat"] = _BadFormatNotify try: asset = AppriseAsset(async_mode=True) a = Apprise(asset=asset) a.add( _BadFormatNotify( host="localhost", asset=asset, error=RuntimeError("plugin blew up"), ) ) with apprise_logs_enabled(): result = asyncio.run( a.async_notify(body="test", log_level=logging.DEBUG) ) finally: N_MGR.unload_modules() assert result.status == AppriseResultStatus.FAILURE assert [(r.name, r.status) for r in result.results] == [ ("BadFormat", AppriseResultStatus.FAILURE) ] assert any("plugin blew up" in e.message for e in result.call_logs()) def test_delivery_typeerror_reported(self): """A TypeError raised while delivering is reported like any crash.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False) service = _SlowNotify(host="x", asset=asset, delay=0.0) def _raise_type_error(**kwargs): """Fail the way a plugin with a signature bug would.""" raise TypeError("delivery-time type error") service.send = _raise_type_error a = Apprise(asset=asset) a.add(service) with apprise_logs_enabled(): result = a.notify(body="test") finally: N_MGR.unload_modules() assert result.status == AppriseResultStatus.FAILURE # A failed result with no logs at all cannot be diagnosed. messages = [e.message for r in result.results for e in r.logs()] assert any("raised an exception" in m for m in messages) def test_async_delivery_typeerror_reported(self): """Native async delivery reports a TypeError the same way.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=True) service = _SlowNotify(host="x", asset=asset, delay=0.0) async def _raise_type_error(**kwargs): """Fail the way a plugin with a signature bug would.""" raise TypeError("delivery-time type error") service.async_notify = _raise_type_error a = Apprise(asset=asset) a.add(service) with apprise_logs_enabled(): result = asyncio.run(a.async_notify(body="test")) finally: N_MGR.unload_modules() assert result.status == AppriseResultStatus.FAILURE messages = [e.message for r in result.results for e in r.logs()] assert any("raised an exception" in m for m in messages) def test_preparation_crash_isolated(self): """One plugin failing preparation does not silence the others.""" N_MGR["badformat"] = _BadFormatNotify N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False) healthy = _SlowNotify(host="a", asset=asset, delay=0.0) a = Apprise(asset=asset) a.add(healthy) a.add( _BadFormatNotify( host="localhost", asset=asset, error=RuntimeError("plugin blew up"), ) ) result = a.notify(body="test") finally: N_MGR.unload_modules() # The healthy service was still contacted. assert healthy.calls == 1 # Mixed outcome: one real delivery, one service that never ran. assert result.status == AppriseResultStatus.PARTIAL statuses = {r.name: r.status for r in result.results} assert statuses["BadFormat"] == AppriseResultStatus.FAILURE assert len(result.results) == 2 def test_async_preparation_crash_isolated(self): """The asynchronous path isolates a preparation crash too.""" N_MGR["badformat"] = _BadFormatNotify N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=True) healthy = _SlowNotify(host="a", asset=asset, delay=0.0) a = Apprise(asset=asset) a.add(healthy) a.add( _BadFormatNotify( host="localhost", asset=asset, error=RuntimeError("plugin blew up"), ) ) result = asyncio.run(a.async_notify(body="test")) finally: N_MGR.unload_modules() assert healthy.calls == 1 assert result.status == AppriseResultStatus.PARTIAL assert len(result.results) == 2 def test_flat_dispatch_preparation_crash(self): """An explicit priority filter isolates a crash the same way.""" N_MGR["badformat"] = _BadFormatNotify N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False) healthy = _SlowNotify(host="a", asset=asset, delay=0.0, tag="ops") a = Apprise(asset=asset) a.add(healthy) a.add( _BadFormatNotify( host="localhost", asset=asset, tag="ops", error=RuntimeError("plugin blew up"), ) ) # A priority prefix sends one flat batch, skipping escalation. result = a.notify(body="test", tag="0:ops") finally: N_MGR.unload_modules() assert healthy.calls == 1 # The batch itself succeeded, but a broken service still counts. assert result.status == AppriseResultStatus.PARTIAL assert len(result.results) == 2 def test_every_service_broken(self): """A call where nothing could be prepared is a failure, not a no-match.""" N_MGR["badformat"] = _BadFormatNotify try: asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) for _ in range(2): a.add( _BadFormatNotify( host="localhost", asset=asset, error=RuntimeError("plugin blew up"), ) ) result = a.notify(body="test") finally: N_MGR.unload_modules() # Nothing was dispatched, but services did match, so NOMATCH would # be misleading here. assert result.status == AppriseResultStatus.FAILURE assert len(result.results) == 2 def test_bad_content_still_stops_everything(self): """Content Apprise cannot use is the caller's error, not a service's.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False) healthy = _SlowNotify(host="a", asset=asset, delay=0.0) a = Apprise(asset=asset) a.add(healthy) # A list cannot be escaped; that is a mistake in the call, so # no service is blamed and none is contacted. result = a.notify( body=["not", "a", "string"], interpret_escapes=True ) finally: N_MGR.unload_modules() assert result.status == AppriseResultStatus.FAILURE assert len(result.results) == 0 assert healthy.calls == 0 def test_unreached_fallback_not_blamed(self): """A broken fallback escalation never reaches is never reported.""" N_MGR["badformat"] = _BadFormatNotify N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False) primary = _SlowNotify( host="a", asset=asset, delay=0.0, tag="1:ops" ) a = Apprise(asset=asset) a.add(primary) # Only ever tried if the priority-1 group fails. a.add( _BadFormatNotify( host="localhost", asset=asset, tag="9:ops", error=RuntimeError("plugin blew up"), ) ) result = a.notify(body="test", tag="ops") finally: N_MGR.unload_modules() assert primary.calls == 1 # The chain stopped at the group that worked, so the broken # fallback is neither dispatched nor counted against the call. assert result.status == AppriseResultStatus.SUCCESS assert len(result.results) == 1 assert "BadFormat" not in [r.name for r in result.results] def test_optional_service_preparation_crash(self): """A broken optional service stays optional.""" N_MGR["badformat"] = _BadFormatNotify N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False) required = _SlowNotify(host="a", asset=asset, delay=0.0) a = Apprise(asset=asset) a.add(required) a.add( _BadFormatNotify( host="localhost", asset=asset, optional=True, error=RuntimeError("plugin blew up"), ) ) result = a.notify(body="test") # Alone, it must not drag the call down either. b = Apprise(asset=asset) b.add( _BadFormatNotify( host="localhost", asset=asset, optional=True, error=RuntimeError("plugin blew up"), ) ) alone = b.notify(body="test") finally: N_MGR.unload_modules() assert required.calls == 1 # An optional service that fails is allowed to, so the overall # status must agree with the entry's own SUCCESS. assert result.status == AppriseResultStatus.SUCCESS assert all(bool(r) for r in result.results) assert alone.status == AppriseResultStatus.SUCCESS def test_preparation_crash_keeps_position(self): """A broken service stays where it was added, not first.""" N_MGR["badformat"] = _BadFormatNotify N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) a.add(_SlowNotify(host="first", asset=asset, delay=0.0)) a.add( _BadFormatNotify( host="localhost", asset=asset, error=RuntimeError("plugin blew up"), ) ) a.add(_SlowNotify(host="third", asset=asset, delay=0.0)) result = a.notify(body="test") finally: N_MGR.unload_modules() # Results follow dispatch order, so the broken one is in the # middle where it was added. assert [r.status for r in result.results] == [ AppriseResultStatus.SUCCESS, AppriseResultStatus.FAILURE, AppriseResultStatus.SUCCESS, ] class TestConfigTagRetry: """Config parsers must reject a retry suffix in service tag definitions. A tag like 'alerts:3' is only valid at call-time (notify() filter / CLI). Putting it in a config file creates ambiguity with the URL ?retry= param and the YAML retry: key, so both parsers must warn and skip the entry. """ def test_text_rejects_retry_suffix(self): """'alerts:3=json://...' is rejected; no service loaded.""" services, _ = ConfigBase.config_parse_text("alerts:3=json://localhost") assert services == [] def test_text_rejects_priority_and_retry_in_tag(self): """'1:alerts:3=json://...' is rejected (priority + retry suffix).""" services, _ = ConfigBase.config_parse_text( "1:alerts:3=json://localhost" ) assert services == [] def test_text_accepts_priority_tag_without_retry(self): """'1:alerts=json://...' loads normally (priority prefix, no retry).""" services, _ = ConfigBase.config_parse_text("1:alerts=json://localhost") assert len(services) == 1 def test_text_accepts_plain_tag(self): """'alerts=json://...' loads normally (no prefix, no retry).""" services, _ = ConfigBase.config_parse_text("alerts=json://localhost") assert len(services) == 1 def test_yaml_rejects_retry_suffix(self): """YAML 'tag: alerts:3' is rejected; no service loaded.""" services, _ = ConfigBase.config_parse_yaml( "version: 1\nurls:\n - json://localhost:\n tag: alerts:3\n" ) assert services == [] def test_yaml_rejects_priority_and_retry_in_tag(self): """YAML 'tag: 1:alerts:3' is rejected (priority + retry suffix).""" services, _ = ConfigBase.config_parse_yaml( "version: 1\nurls:\n - json://localhost:\n tag: 1:alerts:3\n" ) assert services == [] def test_yaml_accepts_priority_tag_without_retry(self): """YAML 'tag: 1:alerts' loads normally (priority prefix, no retry).""" services, _ = ConfigBase.config_parse_yaml( "version: 1\nurls:\n - json://localhost:\n tag: 1:alerts\n" ) assert len(services) == 1 def test_yaml_accepts_plain_tag(self): """YAML 'tag: alerts' loads normally (no prefix, no retry).""" services, _ = ConfigBase.config_parse_yaml( "version: 1\nurls:\n - json://localhost:\n tag: alerts\n" ) assert len(services) == 1 def test_text_valid_entry_after_rejected_one_still_loads(self): """A valid entry that follows a rejected one is still loaded.""" services, _ = ConfigBase.config_parse_text( "alerts:3=json://localhost\nalerts=json://localhost\n" ) assert len(services) == 1 def test_yaml_valid_entry_after_rejected_one_still_loads(self): """A valid entry that follows a rejected one is still loaded.""" services, _ = ConfigBase.config_parse_yaml( "version: 1\n" "urls:\n" " - json://localhost:\n" " tag: alerts:3\n" " - json://localhost:\n" " tag: alerts\n" ) assert len(services) == 1 class TestMultiTagDispatch: """Exercise independent escalation chains in multi-tag OR filters. Each service receives the retry count from the filter token it matched. """ def _make_tagged(self, tag_str, asset, fail_times=0): """Create a _FailThenSucceedNotify tagged with a single AppriseTag.""" s = _FailThenSucceedNotify( host=tag_str, asset=asset, fail_times=fail_times ) # Assign instance-level set to avoid class-level mutation. s.tags = {AppriseTag.parse(tag_str)} return s def _make_priority_tagged(self, tag_str, asset, fail_times=0): """Create a service whose tag is an explicit-priority AppriseTag.""" ft = AppriseTag.parse(tag_str) s = _FailThenSucceedNotify( host=str(ft), asset=asset, fail_times=fail_times ) s.tags = {ft} return s # ------------------------------------------------------------------ # Per-service retry via space-separated string # ------------------------------------------------------------------ def test_per_service_retry_space_separated_string(self): """'devops:3 management:2' applies retry=3 to devops services and retry=2 to management services independently.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # devops service: fails 3 times; needs retry=3 to succeed s_devops = _FailThenSucceedNotify( host="devops", asset=asset, fail_times=3 ) s_devops.tags = {"devops"} # management service: fails 2 times; needs retry=2 to succeed s_mgmt = _FailThenSucceedNotify( host="mgmt", asset=asset, fail_times=2 ) s_mgmt.tags = {"management"} a = Apprise(asset=asset) a.add(s_devops) a.add(s_mgmt) result = a.notify(body="test", tag="devops:3 management:2") assert bool(result) is True # devops: 1 initial + 3 retries = 4 total calls assert s_devops._calls == 4 # management: 1 initial + 2 retries = 3 total calls assert s_mgmt._calls == 3 finally: N_MGR.unload_modules() def test_per_service_retry_list_of_tokens(self): """['devops:3', 'management:2'] gives the same per-service retry result as a space-separated string.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s_devops = _FailThenSucceedNotify( host="devops", asset=asset, fail_times=3 ) s_devops.tags = {"devops"} s_mgmt = _FailThenSucceedNotify( host="mgmt", asset=asset, fail_times=2 ) s_mgmt.tags = {"management"} a = Apprise(asset=asset) a.add(s_devops) a.add(s_mgmt) result = a.notify(body="test", tag=["devops:3", "management:2"]) assert bool(result) is True assert s_devops._calls == 4 assert s_mgmt._calls == 3 finally: N_MGR.unload_modules() # ------------------------------------------------------------------ # Independent escalation chains # ------------------------------------------------------------------ def test_independent_chains_do_not_skip(self): """When devops chain succeeds at priority-1, management chain still dispatches independently -- its priority-1 failure escalates to its priority-2 service.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # devops chain: priority-1 succeeds; priority-2 must not fire s_devops_p1 = _FailThenSucceedNotify( host="d1", asset=asset, fail_times=0 ) s_devops_p1.tags = { AppriseTag("devops", priority=1, has_priority=True) } s_devops_p2 = _FailThenSucceedNotify( host="d2", asset=asset, fail_times=0 ) s_devops_p2.tags = { AppriseTag("devops", priority=2, has_priority=True) } # management chain: priority-1 always fails; must escalate to # priority-2 s_mgmt_p1 = _FailThenSucceedNotify( host="m1", asset=asset, fail_times=999 ) s_mgmt_p1.tags = { AppriseTag("management", priority=1, has_priority=True) } s_mgmt_p2 = _FailThenSucceedNotify( host="m2", asset=asset, fail_times=0 ) s_mgmt_p2.tags = { AppriseTag("management", priority=2, has_priority=True) } a = Apprise(asset=asset) a.add(s_devops_p1) a.add(s_devops_p2) a.add(s_mgmt_p1) a.add(s_mgmt_p2) result = a.notify(body="test", tag="devops management") assert bool(result) is True assert s_devops_p1._calls == 1 # devops chain: p1 succeeded assert s_devops_p2._calls == 0 # never escalated to assert s_mgmt_p1._calls == 1 # management chain: p1 failed assert s_mgmt_p2._calls == 1 # escalated to p2, succeeded finally: N_MGR.unload_modules() def test_both_chains_must_succeed_for_true(self): """notify() returns False when one chain exhausts all priority groups without any group fully succeeding.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # devops chain: always succeeds s_devops = _FailThenSucceedNotify( host="d", asset=asset, fail_times=0 ) s_devops.tags = {"devops"} # management chain: always fails s_mgmt = _FailThenSucceedNotify( host="m", asset=asset, fail_times=999 ) s_mgmt.tags = {"management"} a = Apprise(asset=asset) a.add(s_devops) a.add(s_mgmt) result = a.notify(body="test", tag="devops management") assert bool(result) is False assert s_devops._calls == 1 # its chain succeeded assert s_mgmt._calls == 1 # its chain failed finally: N_MGR.unload_modules() # ------------------------------------------------------------------ # Explicit-priority list with per-service retry (flat dispatch) # ------------------------------------------------------------------ def test_explicit_priority_list_per_service_retry(self): """['1:tag:2', '2:tag:0'] flat-dispatches both services; each gets its own retry: the priority-1 service gets retry=2 and the priority-2 service gets retry=0 (one attempt only).""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # priority-1 service: fails twice; needs retry=2 to succeed s1 = _FailThenSucceedNotify(host="h1", asset=asset, fail_times=2) s1.tags = {AppriseTag("tag", priority=1, has_priority=True)} # priority-2 service: always succeeds; retry=0 means one attempt s2 = _FailThenSucceedNotify(host="h2", asset=asset, fail_times=0) s2.tags = {AppriseTag("tag", priority=2, has_priority=True)} a = Apprise(asset=asset) a.add(s1) a.add(s2) result = a.notify(body="test", tag=["1:tag:2", "2:tag:0"]) assert bool(result) is True # s1: 1 initial + 2 retries = 3 total assert s1._calls == 3 # s2: retry=0 -> exactly 1 attempt assert s2._calls == 1 finally: N_MGR.unload_modules() # ------------------------------------------------------------------ # CLI OR format (list of single-element lists) # ------------------------------------------------------------------ def test_cli_or_format_independent_chains(self): """CLI --tag 'devops:3' --tag 'management:2' generates [['devops:3'], ['management:2']] -- single-element inner lists that must be treated as independent OR chains, not AND groups.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s_devops = _FailThenSucceedNotify( host="devops", asset=asset, fail_times=3 ) s_devops.tags = {"devops"} s_mgmt = _FailThenSucceedNotify( host="mgmt", asset=asset, fail_times=2 ) s_mgmt.tags = {"management"} a = Apprise(asset=asset) a.add(s_devops) a.add(s_mgmt) # Simulate CLI: --tag "devops:3" --tag "management:2" cli_tag = [["devops:3"], ["management:2"]] result = a.notify(body="test", tag=cli_tag) assert bool(result) is True assert s_devops._calls == 4 # 1 initial + 3 retries assert s_mgmt._calls == 3 # 1 initial + 2 retries finally: N_MGR.unload_modules() def test_cli_and_format_shared_chain(self): """CLI --tag 'devops, management' generates [['devops', 'management']] -- a multi-element inner list that is an AND condition and uses a single shared escalation chain.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # Service must carry BOTH tags to match s_both = _FailThenSucceedNotify( host="both", asset=asset, fail_times=0 ) s_both.tags = {"devops", "management"} # Service with only "devops" -- must NOT match AND filter s_devonly = _FailThenSucceedNotify( host="devonly", asset=asset, fail_times=0 ) s_devonly.tags = {"devops"} a = Apprise(asset=asset) a.add(s_both) a.add(s_devonly) # Simulate CLI: --tag "devops, management" (AND condition) cli_tag = [["devops", "management"]] result = a.notify(body="test", tag=cli_tag) assert bool(result) is True assert s_both._calls == 1 # has both tags -- matched assert s_devonly._calls == 0 # missing "management" -- not matched finally: N_MGR.unload_modules() # ------------------------------------------------------------------ # Edge cases: explicit priority + AND, 3-chain OR, chain exhaustion # ------------------------------------------------------------------ def test_and_filter_explicit_priority_no_match_returns_none(self): """AND filter with an explicit priority that no service satisfies returns AppriseResultStatus.NOMATCH (no services dispatched at all).""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # Service is tagged 'alerts' at priority 2 s = _FailThenSucceedNotify(host="s", asset=asset, fail_times=0) s.tags = {AppriseTag("alerts", priority=2, has_priority=True)} a = Apprise(asset=asset) a.add(s) # Request 'alerts' at priority 1 AND 'backup' -- service has # 'alerts' but at priority 2, not 1; nothing matches. result = a.notify(body="test", tag=[["1:alerts", "backup"]]) assert result.status == AppriseResultStatus.NOMATCH assert len(result) == 0 assert s._calls == 0 finally: N_MGR.unload_modules() def test_three_or_chains_all_succeed(self): """Three independent OR chains each succeed at their first priority group; notify() returns True and each service is called once.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s_a = _FailThenSucceedNotify(host="a", asset=asset, fail_times=0) s_a.tags = {"alpha"} s_b = _FailThenSucceedNotify(host="b", asset=asset, fail_times=0) s_b.tags = {"beta"} s_c = _FailThenSucceedNotify(host="c", asset=asset, fail_times=0) s_c.tags = {"gamma"} a = Apprise(asset=asset) a.add(s_a) a.add(s_b) a.add(s_c) result = a.notify(body="test", tag=["alpha", "beta", "gamma"]) assert bool(result) is True assert s_a._calls == 1 assert s_b._calls == 1 assert s_c._calls == 1 finally: N_MGR.unload_modules() def test_three_or_chains_one_fails_overall_false(self): """Three OR chains: two succeed, one exhausts all priority groups. notify() returns False even though the other chains succeeded.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s_a = _FailThenSucceedNotify(host="a", asset=asset, fail_times=0) s_a.tags = {"alpha"} # beta always fails (fail_times > any possible retry) s_b = _FailThenSucceedNotify(host="b", asset=asset, fail_times=99) s_b.tags = {"beta"} s_c = _FailThenSucceedNotify(host="c", asset=asset, fail_times=0) s_c.tags = {"gamma"} a = Apprise(asset=asset) a.add(s_a) a.add(s_b) a.add(s_c) result = a.notify(body="test", tag=["alpha", "beta", "gamma"]) assert bool(result) is False assert s_a._calls == 1 # alpha succeeded assert s_c._calls == 1 # gamma succeeded finally: N_MGR.unload_modules() def test_chain_escalation_to_second_priority_group(self): """A chain whose priority-0 group fails escalates to the priority-1 group; the second group succeeds so notify() returns True.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # priority-0 service: always fails (no retry configured) s_p0 = _FailThenSucceedNotify( host="p0", asset=asset, fail_times=99 ) s_p0.tags = {AppriseTag("alerts", priority=0, has_priority=False)} # priority-1 service: succeeds on first attempt s_p1 = _FailThenSucceedNotify(host="p1", asset=asset, fail_times=0) s_p1.tags = {AppriseTag("alerts", priority=1, has_priority=True)} a = Apprise(asset=asset) a.add(s_p0) a.add(s_p1) result = a.notify(body="test", tag="alerts") assert bool(result) is True # priority-0 group was attempted once and failed assert s_p0._calls == 1 # priority-1 group was then tried as fallback assert s_p1._calls == 1 finally: N_MGR.unload_modules() def test_all_priority_groups_exhausted_returns_false(self): """When every priority group in a chain fails, notify() returns False and all groups were attempted in order.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # priority-0 service: always fails s_p0 = _FailThenSucceedNotify( host="p0", asset=asset, fail_times=99 ) s_p0.tags = {AppriseTag("alerts", priority=0, has_priority=False)} # priority-1 service: also always fails s_p1 = _FailThenSucceedNotify( host="p1", asset=asset, fail_times=99 ) s_p1.tags = {AppriseTag("alerts", priority=1, has_priority=True)} a = Apprise(asset=asset) a.add(s_p0) a.add(s_p1) result = a.notify(body="test", tag="alerts") assert bool(result) is False # Both groups were attempted assert s_p0._calls == 1 assert s_p1._calls == 1 finally: N_MGR.unload_modules() def test_chain_dispatch_future_exception_treated_as_failure(self): """Treat an exception from a concurrent tag chain as failure. Multiple chains force the executor path. An asset that raises during dispatch reaches its fallback without cross-thread method patching. """ N_MGR["failpass"] = _FailThenSucceedNotify try: # _ExplodingAsset.async_mode raises RuntimeError. _run_batch # reads s.asset.async_mode to split seq/par, so a batch with # one of these services raises and the future captures it. class _ExplodingAsset(AppriseAsset): """Asset whose mode lookup fails inside chain dispatch.""" @property def async_mode(self): """Raise the injected failure when dispatch reads mode.""" raise RuntimeError("injected async_mode access") # Two services with distinct tags -> two independent chains so # the ThreadPoolExecutor path (len(active) > 1) is taken. s_a = _FailThenSucceedNotify( host="a", asset=_ExplodingAsset(), fail_times=0 ) s_a.tags = {"alpha"} s_b = _FailThenSucceedNotify( host="b", asset=_ExplodingAsset(), fail_times=0 ) s_b.tags = {"beta"} a = Apprise() a.add(s_a) a.add(s_b) result = a.notify(body="test", tag=["alpha", "beta"]) # Both chains raised instead of returning True -- overall False. assert bool(result) is False finally: N_MGR.unload_modules() def test_single_chain_exception(self): """Treat a direct-dispatch chain exception as a failure.""" N_MGR["failpass"] = _FailThenSucceedNotify try: class _ExplodingAsset(AppriseAsset): """Asset whose mode lookup fails inside chain dispatch.""" @property def async_mode(self): """Raise the injected failure when dispatch reads mode.""" raise RuntimeError("injected async_mode access") # One untagged chain dispatches without the coordinator pool. service = _FailThenSucceedNotify( host="solo", asset=_ExplodingAsset(), fail_times=0 ) a = Apprise() a.add(service) result = a.notify(body="test") # The chain raised instead of returning True -- overall False. assert bool(result) is False finally: N_MGR.unload_modules() def test_async_chain_base_exception(self): """Treat a BaseException returned by gather() as a chain failure.""" N_MGR["failpass"] = _FailThenSucceedNotify try: class _FatalDispatch(BaseException): """Stands in for CancelledError, which is not Exception.""" class _CancellingAsset(AppriseAsset): """Asset whose mode lookup fails inside chain dispatch.""" @property def async_mode(self): """Raise the injected failure when dispatch reads mode.""" raise _FatalDispatch("injected chain cancellation") service = _FailThenSucceedNotify( host="solo", asset=_CancellingAsset(), fail_times=0 ) a = Apprise() a.add(service) result = asyncio.run(a.async_notify(body="test")) # The chain escalated instead of the result being unpacked. assert bool(result) is False finally: N_MGR.unload_modules() class TestAbortOnChainFailure: """Exercise optional early exit when one tag chain is exhausted. By default all chains finish. Enabling the setting stops later escalation rounds after a chain fails. """ def _make_tagged(self, tag_str, asset, fail_times=0): s = _FailThenSucceedNotify( host=tag_str, asset=asset, fail_times=fail_times ) s.tags = {tag_str} return s def test_default_false_all_chains_complete(self): """With abort_on_chain_failure=False (default), all chains run to completion even if one chain has already failed.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # alpha always succeeds s_a = self._make_tagged("alpha", asset, fail_times=0) # beta always fails (exhausts its only group immediately) s_b = self._make_tagged("beta", asset, fail_times=99) # gamma always succeeds s_c = self._make_tagged("gamma", asset, fail_times=0) a = Apprise(asset=asset) a.add(s_a) a.add(s_b) a.add(s_c) result = a.notify(body="test", tag=["alpha", "beta", "gamma"]) assert bool(result) is False # alpha and gamma were still dispatched despite beta failing assert s_a._calls == 1 assert s_b._calls == 1 assert s_c._calls == 1 finally: N_MGR.unload_modules() def test_abort_on_chain_failure_true_stops_early(self): """With abort_on_chain_failure=True, once beta exhausts all priority groups without success, further escalation rounds for other chains are skipped.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False, abort_on_chain_failure=True) # alpha: two priority levels; priority-0 always fails s_a_p0 = _FailThenSucceedNotify( host="a0", asset=asset, fail_times=99 ) s_a_p0.tags = {AppriseTag("alpha", priority=0, has_priority=False)} s_a_p1 = _FailThenSucceedNotify( host="a1", asset=asset, fail_times=0 ) s_a_p1.tags = {AppriseTag("alpha", priority=1, has_priority=True)} # beta: one priority level; always fails s_b = _FailThenSucceedNotify(host="b", asset=asset, fail_times=99) s_b.tags = {"beta"} a = Apprise(asset=asset) a.add(s_a_p0) a.add(s_a_p1) a.add(s_b) result = a.notify(body="test", tag=["alpha", "beta"]) assert bool(result) is False # beta failed in round 1 and was exhausted -- abort triggered. # alpha's p1 (escalation) should NOT have been attempted. assert s_b._calls == 1 assert s_a_p0._calls == 1 assert s_a_p1._calls == 0 # skipped due to early abort finally: N_MGR.unload_modules() def test_abort_on_chain_failure_true_async(self): """abort_on_chain_failure=True triggers the same early-abort in async_notify()""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=True, abort_on_chain_failure=True) # alpha has two priority levels; priority-0 always fails s_a_p0 = _FailThenSucceedNotify( host="a0", asset=asset, fail_times=99 ) s_a_p0.tags = {AppriseTag("alpha", priority=0, has_priority=False)} s_a_p1 = _FailThenSucceedNotify( host="a1", asset=asset, fail_times=0 ) s_a_p1.tags = {AppriseTag("alpha", priority=1, has_priority=True)} # beta has one priority level; always fails s_b = _FailThenSucceedNotify(host="b", asset=asset, fail_times=99) s_b.tags = {"beta"} a = Apprise(asset=asset) a.add(s_a_p0) a.add(s_a_p1) a.add(s_b) result = asyncio.run( a.async_notify(body="test", tag=["alpha", "beta"]) ) assert bool(result) is False # beta was exhausted in round 1 -- abort fires before alpha's p1 assert s_b._calls == 1 assert s_a_p0._calls == 1 assert s_a_p1._calls == 0 finally: N_MGR.unload_modules() class TestOptionalService: """optional=True silently absorbs per-service delivery failures. The overall notify()/async_notify() result evaluates truthy even when an optional service cannot be reached, so callers are not penalised for "nice to have" endpoints (e.g. home screens, debug logging). """ def test_optional_default_false(self): """optional defaults to False on a freshly constructed plugin.""" nb = _TestNotify(host="localhost") assert nb.optional is False def test_optional_url_includes_flag(self): """url_parameters() always emits optional=yes or optional=no.""" nb = _TestNotify(host="localhost") params = nb.url_parameters() assert "optional" in params assert params["optional"] == "no" nb_opt = _TestNotify(host="localhost", optional=True) params_opt = nb_opt.url_parameters() assert params_opt["optional"] == "yes" def test_optional_parse_url_yes(self): """parse_url propagates optional=yes into the results dict.""" results = _TestNotify.parse_url("test://localhost?optional=yes") assert results is not None assert results.get("optional") is True def test_optional_parse_url_no(self): """parse_url propagates optional=no into the results dict.""" results = _TestNotify.parse_url("test://localhost?optional=no") assert results is not None assert results.get("optional") is False def test_optional_kwarg_sets_attribute(self): """Passing optional=True/False to __init__ sets .optional.""" nb_yes = _TestNotify(host="localhost", optional=True) assert nb_yes.optional is True nb_no = _TestNotify(host="localhost", optional=False) assert nb_no.optional is False def test_optional_sequential_failure_absorbed(self): """Sequential dispatch: failed optional service -> overall True.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s = _FailThenSucceedNotify(host="s1", asset=asset, fail_times=99) s.optional = True a = Apprise(asset=asset) a.add(s) result = a.notify(body="test") assert bool(result) is True assert s._calls == 1 finally: N_MGR.unload_modules() def test_optional_threadpool_failure_absorbed(self): """Thread-pool dispatch: failed optional services -> overall True. Two async_mode=True services ensure _notify_parallel_threadpool uses the actual thread pool (n_calls==1 falls back to sequential). """ N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=True) s1 = _FailThenSucceedNotify(host="s1", asset=asset, fail_times=99) s1.optional = True s2 = _FailThenSucceedNotify(host="s2", asset=asset, fail_times=99) s2.optional = True a = Apprise(asset=asset) a.add(s1) a.add(s2) result = a.notify(body="test") assert bool(result) is True finally: N_MGR.unload_modules() def test_optional_asyncio_failure_absorbed(self): """Asyncio dispatch: failed optional service -> overall True.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=True) s = _FailThenSucceedNotify(host="s1", asset=asset, fail_times=99) s.optional = True a = Apprise(asset=asset) a.add(s) result = asyncio.run(a.async_notify(body="test")) assert bool(result) is True finally: N_MGR.unload_modules() def test_optional_all_fail_all_optional_returns_true(self): """When every service is optional and every service fails -> True.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) for i in range(3): s = _FailThenSucceedNotify( host=f"s{i}", asset=asset, fail_times=99 ) s.optional = True a.add(s) result = a.notify(body="test") assert bool(result) is True finally: N_MGR.unload_modules() def test_optional_mixed_required_fails_returns_false(self): """Required service failure taints result even with optional peers.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s_opt = _FailThenSucceedNotify( host="opt", asset=asset, fail_times=99 ) s_opt.optional = True s_req = _FailThenSucceedNotify( host="req", asset=asset, fail_times=99 ) # s_req.optional is False (default) a = Apprise(asset=asset) a.add(s_opt) a.add(s_req) result = a.notify(body="test") assert bool(result) is False finally: N_MGR.unload_modules() def test_optional_success_unaffected(self): """An optional service that succeeds is still counted as True.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s = _FailThenSucceedNotify(host="s1", asset=asset, fail_times=0) s.optional = True a = Apprise(asset=asset) a.add(s) result = a.notify(body="test") assert bool(result) is True assert s._calls == 1 finally: N_MGR.unload_modules() class TestPartialStatus: """AppriseResultStatus.PARTIAL: a batch where at least one service genuinely delivered and at least one did not, in the same call.""" def test_mixed_success_and_failure_is_partial(self): """One service succeeds, one fails outright -> PARTIAL, not FAILURE.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s_ok = _FailThenSucceedNotify(host="ok", asset=asset, fail_times=0) s_fail = _FailThenSucceedNotify( host="fail", asset=asset, fail_times=99 ) a = Apprise(asset=asset) a.add(s_ok) a.add(s_fail) result = a.notify(body="test") assert result.status == AppriseResultStatus.PARTIAL # PARTIAL is not the truthy SUCCESS value. assert bool(result) is False finally: N_MGR.unload_modules() def test_all_succeed_is_success_not_partial(self): """Every service succeeding stays SUCCESS -- PARTIAL requires at least one non-delivery in the same batch.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s1 = _FailThenSucceedNotify(host="s1", asset=asset, fail_times=0) s2 = _FailThenSucceedNotify(host="s2", asset=asset, fail_times=0) a = Apprise(asset=asset) a.add(s1) a.add(s2) result = a.notify(body="test") assert result.status == AppriseResultStatus.SUCCESS finally: N_MGR.unload_modules() def test_all_fail_is_failure_not_partial(self): """Every service failing outright stays FAILURE -- there is no genuine success in the batch to make it PARTIAL.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s1 = _FailThenSucceedNotify(host="s1", asset=asset, fail_times=99) s2 = _FailThenSucceedNotify(host="s2", asset=asset, fail_times=99) a = Apprise(asset=asset) a.add(s1) a.add(s2) result = a.notify(body="test") assert result.status == AppriseResultStatus.FAILURE finally: N_MGR.unload_modules() def test_optional_absorbed_failure_does_not_count_as_partial(self): """A forgiven optional=yes failure does not count as a 'genuine' success -- an optional service that never actually delivered, paired with a required service that also failed, is still a clean FAILURE, not PARTIAL.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s_opt = _FailThenSucceedNotify( host="opt", asset=asset, fail_times=99 ) s_opt.optional = True s_req = _FailThenSucceedNotify( host="req", asset=asset, fail_times=99 ) a = Apprise(asset=asset) a.add(s_opt) a.add(s_req) result = a.notify(body="test") assert result.status == AppriseResultStatus.FAILURE finally: N_MGR.unload_modules() def test_success_plus_timeout_is_partial(self): """Report PARTIAL when one parallel service succeeds and one times out. Two asynchronous services force bounded parallel dispatch, which can stop waiting for an in-flight call; sequential dispatch cannot. """ N_MGR["failpass"] = _FailThenSucceedNotify N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=True, service_timeout=0.05) s_ok = _FailThenSucceedNotify(host="ok", asset=asset, fail_times=0) s_slow = _SlowNotify(host="slow", asset=asset, delay=0.3) a = Apprise(asset=asset) a.add(s_ok) a.add(s_slow) result = a.notify(body="test") assert result.status == AppriseResultStatus.PARTIAL finally: N_MGR.unload_modules() def test_async_notify_mixed_outcome_is_partial(self): """The asyncio dispatch path reports PARTIAL the same way as the synchronous paths.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=True) s_ok = _FailThenSucceedNotify(host="ok", asset=asset, fail_times=0) s_fail = _FailThenSucceedNotify( host="fail", asset=asset, fail_times=99 ) a = Apprise(asset=asset) a.add(s_ok) a.add(s_fail) result = asyncio.run(a.async_notify(body="test")) assert result.status == AppriseResultStatus.PARTIAL finally: N_MGR.unload_modules() class TestCreateNotifyCalls: """Cover Apprise._create_notify_calls() which splits services into sequential and parallel buckets based on their asset.async_mode flag.""" def test_splits_sequential_and_parallel(self): """Sequential and parallel services are split into separate lists.""" N_MGR["failpass"] = _FailThenSucceedNotify try: # With async_mode=False all loaded services go to the sequential # bucket; the parallel bucket remains empty. asset_seq = AppriseAsset(async_mode=False) s_seq = _FailThenSucceedNotify( host="s1", asset=asset_seq, fail_times=0 ) a = Apprise(asset=asset_seq) a.add(s_seq) seq, par = a._create_notify_calls(body="test") assert len(seq) == 1 assert len(par) == 0 # With async_mode=True the same service is placed in the parallel # bucket instead. asset_par = AppriseAsset(async_mode=True) s_par = _FailThenSucceedNotify( host="s2", asset=asset_par, fail_times=0 ) b = Apprise(asset=asset_par) b.add(s_par) seq2, par2 = b._create_notify_calls(body="test") assert len(seq2) == 0 assert len(par2) == 1 finally: N_MGR.unload_modules() class TestAsyncioSafetyNet: """Cover async safety nets for exceptions that escape do_call.""" def test_exception_outside_try_block_triggers_safety_net(self): """An escaped property failure is reported beside a good service.""" class _RaisingLoggerService: """Service whose logger fails before delivery starts.""" service_name = "raiser" asset = AppriseAsset(async_mode=True) optional = False @property def logger(self): """Raise while the attempt's log capture is being built. Result-building reads are guarded, so this uses one that is not: the capture reads the service's own logger before any per-attempt handling exists to catch it. """ # This escapes do_call and is captured by asyncio.gather. raise RuntimeError("injected: property raised outside try") async def async_notify(self, **kwargs): """Represent delivery that must not be reached in this test.""" return True # pragma: no cover -- never reached class _GoodService: """Minimal well-behaved service that succeeds immediately.""" service_name = "good" asset = AppriseAsset(async_mode=True) optional = False tags = set() def url(self, privacy=False): """Return the stable URL used to identify this service.""" return "good://localhost" def url_id(self): """Return a stable identifier for result assertions.""" return "good-id" def __len__(self): """Report the single target represented by this service.""" return 1 async def async_notify(self, **kwargs): """Report immediate successful asynchronous delivery.""" return True raiser = _RaisingLoggerService() good = _GoodService() # Exercise both the escaped-exception and successful-result branches. ok, results = asyncio.run( Apprise._notify_parallel_asyncio((raiser, {}), (good, {})) ) assert ok is False assert len(results) == 2 class TestServiceTimeout: """AppriseAsset._service_timeout / notify(timeout=...) enforcement. Real sleeps are intentional here: these tests protect threaded/async deadline behaviour. Margins stay generous so slower CI hosts fail only for real regressions, not ordinary timing jitter. """ def test_sequential_between_attempts_timeout(self): """A real failure before timeout remains a FAILURE.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False, service_timeout=0.5) # First attempt costs 0.1s and fails, leaving ~0.4s of the # 0.5s budget. retry=5 with wait=5.0 would normally sleep # five seconds between attempts, but that must be capped to # the remaining budget, and no second attempt may start. service = _SlowNotify( host="x", asset=asset, delay=0.0, retry=5, wait=5.0 ) service.send = lambda **kw: (time.sleep(0.1), False)[1] a = Apprise(asset=asset) a.add(service) t0 = time.monotonic() result = a.notify(body="test") wall = time.monotonic() - t0 finally: N_MGR.unload_modules() r0 = next(iter(result)) # The worker and outer wait share a deadline, so accept either attempt # sequence when slower hosts change which one finishes first. assert len(r0.attempts) in (1, 2) assert r0.attempts[-1].status == AppriseResultStatus.TIMEOUT assert r0.max_attempts == 6 if len(r0.attempts) == 2: assert r0.attempts[0].status == AppriseResultStatus.FAILURE # A real failure takes priority over the synthetic timeout. assert r0.status == AppriseResultStatus.FAILURE else: # The outer wait finished first, leaving only its timeout. assert r0.status == AppriseResultStatus.TIMEOUT assert result.status == r0.status # The retry sleep uses the remaining budget instead of the full 5s. assert wall < 3.0 def test_threadpool_between_attempts_timeout_and_sleep_cap(self): """Thread-pool retry sleep is capped by the worker deadline.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=True, service_timeout=2.0) # The failed first attempt leaves enough budget for the worker to # cap wait=5.0 itself before the outer bounded wait races it. slow = _SlowNotify( host="slow", asset=asset, delay=0.0, retry=3, wait=5.0 ) slow.send = lambda **kw: (time.sleep(0.3), False)[1] fast = _SlowNotify(host="fast", asset=asset, delay=0.0) a = Apprise(asset=asset) a.add(slow) a.add(fast) t0 = time.monotonic() result = a.notify(body="test") wall = time.monotonic() - t0 finally: N_MGR.unload_modules() by_host = {r.url: r for r in result} slow_result = by_host["slow://slow"] # The worker and outer wait share a deadline, so accept either attempt # sequence when slower hosts change which one finishes first. assert len(slow_result.attempts) in (1, 2) assert slow_result.attempts[-1].status == AppriseResultStatus.TIMEOUT assert slow_result.max_attempts == 4 if len(slow_result.attempts) == 2: assert ( slow_result.attempts[0].status == AppriseResultStatus.FAILURE ) # A real failure takes priority over the synthetic timeout. assert slow_result.status == AppriseResultStatus.FAILURE else: # The outer wait finished first, leaving only its timeout. assert slow_result.status == AppriseResultStatus.TIMEOUT assert by_host["slow://fast"].status == AppriseResultStatus.SUCCESS # The retry sleep uses the remaining budget instead of the full 5s. assert wall < 5.0 def test_asyncio_between_attempts_timeout_and_sleep_cap(self): """Same guarantee as the thread-pool test above, for the asyncio do_call() coroutine's own between-attempts deadline check.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=True, service_timeout=2.0) slow = _SlowNotify( host="slow", asset=asset, delay=0.0, retry=3, wait=5.0 ) async def _fail_slowly(**kw): """Delay briefly, then fail so retry waiting consumes time.""" await asyncio.sleep(0.3) return False slow.async_notify = _fail_slowly fast = _SlowNotify(host="fast", asset=asset, delay=0.0) a = Apprise(asset=asset) a.add(slow) a.add(fast) t0 = time.monotonic() result = asyncio.run(a.async_notify(body="test")) wall = time.monotonic() - t0 finally: N_MGR.unload_modules() by_host = {r.url: r for r in result} slow_result = by_host["slow://slow"] # As above, accept either attempt sequence at the shared deadline. assert len(slow_result.attempts) in (1, 2) assert slow_result.attempts[-1].status == AppriseResultStatus.TIMEOUT assert slow_result.max_attempts == 4 if len(slow_result.attempts) == 2: assert ( slow_result.attempts[0].status == AppriseResultStatus.FAILURE ) # A real failure takes priority over the synthetic timeout. assert slow_result.status == AppriseResultStatus.FAILURE else: # The outer wait finished first, leaving only its timeout. assert slow_result.status == AppriseResultStatus.TIMEOUT assert by_host["slow://fast"].status == AppriseResultStatus.SUCCESS assert wall < 5.0 def test_sequential_service_timeout_disabled(self): """service_timeout=0 disables the cap -- a slow service still completes normally.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False, service_timeout=0) service = _SlowNotify(host="x", asset=asset, delay=0.05) a = Apprise(asset=asset) a.add(service) result = a.notify(body="test") finally: N_MGR.unload_modules() r0 = next(iter(result)) assert r0.status == AppriseResultStatus.SUCCESS assert service.calls == 1 def test_sequential_timeout_disabled_retry_wait(self): """With service_timeout=0 (no deadline at all), the retry/wait loop keeps its normal uncapped wait behaviour.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False, service_timeout=0) service = _SlowNotify( host="x", asset=asset, delay=0.0, retry=1, wait=0.01 ) call_count = {"n": 0} def _fail_then_succeed(**kw): """Fail once, then succeed to exercise an uncapped retry.""" call_count["n"] += 1 return call_count["n"] >= 2 service.send = _fail_then_succeed a = Apprise(asset=asset) a.add(service) result = a.notify(body="test") finally: N_MGR.unload_modules() r0 = next(iter(result)) assert r0.status == AppriseResultStatus.SUCCESS assert len(r0.attempts) == 2 def test_threadpool_outer_bound_abandons_slow_service(self): """A service stuck past its deadline is abandoned by the outer bounded wait; the batch returns promptly and the other service is unaffected.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=True, service_timeout=0.5) slow = _SlowNotify(host="slow", asset=asset, delay=2.0) fast = _SlowNotify(host="fast", asset=asset, delay=0.0) a = Apprise(asset=asset) a.add(slow) a.add(fast) t0 = time.monotonic() result = a.notify(body="test") wall = time.monotonic() - t0 finally: N_MGR.unload_modules() # The whole call returned promptly -- nowhere near the slow # service's 2.0s delay -- even though that thread is still # running in the background because Python cannot stop the worker. assert wall < 3.0 by_host = {r.url: r for r in result} assert by_host["slow://slow"].status == (AppriseResultStatus.TIMEOUT) assert by_host["slow://fast"].status == (AppriseResultStatus.SUCCESS) def test_asyncio_outer_bound_abandons_slow_service(self): """Same guarantee as the thread-pool test, for async_notify().""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=True, service_timeout=0.5) slow = _SlowNotify(host="slow", asset=asset, delay=2.0) fast = _SlowNotify(host="fast", asset=asset, delay=0.0) a = Apprise(asset=asset) a.add(slow) a.add(fast) t0 = time.monotonic() result = asyncio.run(a.async_notify(body="test")) wall = time.monotonic() - t0 finally: N_MGR.unload_modules() assert wall < 3.0 by_host = {r.url: r for r in result} assert by_host["slow://slow"].status == (AppriseResultStatus.TIMEOUT) assert by_host["slow://fast"].status == (AppriseResultStatus.SUCCESS) def test_threadpool_timeout_disabled_retry_wait(self): """With service_timeout=0, the thread-pool worker's retry/wait loop keeps its normal uncapped wait behaviour.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=True, service_timeout=0) call_count = {"n": 0} def _fail_then_succeed(**kw): """Fail once, then succeed in the thread-pool retry loop.""" call_count["n"] += 1 return call_count["n"] >= 2 flaky = _SlowNotify( host="flaky", asset=asset, delay=0.0, retry=1, wait=0.01 ) flaky.send = _fail_then_succeed other = _SlowNotify(host="other", asset=asset, delay=0.0) a = Apprise(asset=asset) a.add(flaky) a.add(other) result = a.notify(body="test") finally: N_MGR.unload_modules() by_host = {r.url: r for r in result} assert by_host["slow://flaky"].status == AppriseResultStatus.SUCCESS assert len(by_host["slow://flaky"].attempts) == 2 assert by_host["slow://other"].status == AppriseResultStatus.SUCCESS def test_asyncio_timeout_disabled_retry_wait(self): """Same guarantee as the thread-pool test above, for the asyncio do_call() coroutine's retry/wait loop.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=True, service_timeout=0) call_count = {"n": 0} async def _fail_then_succeed(**kw): """Fail once, then succeed in the asyncio retry loop.""" call_count["n"] += 1 return call_count["n"] >= 2 flaky = _SlowNotify( host="flaky", asset=asset, delay=0.0, retry=1, wait=0.01 ) flaky.async_notify = _fail_then_succeed other = _SlowNotify(host="other", asset=asset, delay=0.0) a = Apprise(asset=asset) a.add(flaky) a.add(other) result = asyncio.run(a.async_notify(body="test")) finally: N_MGR.unload_modules() by_host = {r.url: r for r in result} assert by_host["slow://flaky"].status == AppriseResultStatus.SUCCESS assert len(by_host["slow://flaky"].attempts) == 2 assert by_host["slow://other"].status == AppriseResultStatus.SUCCESS def test_notify_timeout_overrides_longer_asset_default(self): """notify(timeout=...) can cut a call short even when the asset default would have allowed more time.""" N_MGR["slow"] = _SlowNotify try: # Asset default is generous (10s); the call-level override # of 0.5s must win since it is the sooner of the two. asset = AppriseAsset(async_mode=True, service_timeout=10) slow1 = _SlowNotify(host="one", asset=asset, delay=2.0) slow2 = _SlowNotify(host="two", asset=asset, delay=2.0) a = Apprise(asset=asset) a.add(slow1) a.add(slow2) t0 = time.monotonic() result = a.notify(body="test", timeout=0.5) wall = time.monotonic() - t0 finally: N_MGR.unload_modules() assert wall < 3.0 assert all(r.status == AppriseResultStatus.TIMEOUT for r in result) def test_notify_timeout_keeps_stricter_asset_default(self): """The reverse also holds: a short asset default still applies even when notify(timeout=...) allows much more time.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=True, service_timeout=0.5) slow1 = _SlowNotify(host="one", asset=asset, delay=2.0) slow2 = _SlowNotify(host="two", asset=asset, delay=2.0) a = Apprise(asset=asset) a.add(slow1) a.add(slow2) t0 = time.monotonic() result = a.notify(body="test", timeout=60) wall = time.monotonic() - t0 finally: N_MGR.unload_modules() assert wall < 3.0 assert all(r.status == AppriseResultStatus.TIMEOUT for r in result) def test_notify_negative_timeout_raises(self): """notify(timeout=-1) is a caller error, not a silent no-op.""" a = Apprise() a.add("json://localhost") with pytest.raises(ValueError): a.notify(body="test", timeout=-1) def test_async_notify_negative_timeout_raises(self): """async_notify(timeout=-1) is a caller error, not a silent no-op.""" a = Apprise() a.add("json://localhost") with pytest.raises(ValueError): asyncio.run(a.async_notify(body="test", timeout=-1)) def test_notify_infinite_timeout_raises(self): """notify(timeout=inf) is rejected; 0 is the unbounded spelling.""" a = Apprise() a.add("json://localhost") with pytest.raises(ValueError): a.notify(body="test", timeout=float("inf")) def test_notify_nan_timeout_raises(self): """notify(timeout=nan) is rejected instead of disabling the timeout.""" a = Apprise() a.add("json://localhost") with pytest.raises(ValueError): a.notify(body="test", timeout=float("nan")) def test_async_notify_infinite_timeout_raises(self): """async_notify(timeout=inf) is rejected for the same reason as notify(timeout=inf).""" a = Apprise() a.add("json://localhost") with pytest.raises(ValueError): asyncio.run(a.async_notify(body="test", timeout=float("inf"))) def test_notify_rejects_non_numeric_timeout(self): """notify() rejects a non-numeric timeout.""" a = Apprise() a.add("json://localhost") with pytest.raises(AppriseImproperlyConfigured): a.notify(body="test", timeout="abc") def test_notify_rejects_bool_timeout(self): """A bool timeout is rejected even though bool is an int subclass.""" a = Apprise() a.add("json://localhost") with pytest.raises(AppriseImproperlyConfigured): a.notify(body="test", timeout=True) def test_async_notify_rejects_non_numeric_timeout(self): """async_notify() rejects a non-numeric timeout.""" a = Apprise() a.add("json://localhost") with pytest.raises(AppriseImproperlyConfigured): asyncio.run(a.async_notify(body="test", timeout="abc")) def test_notify_timeout_defaults_to_zero(self): """timeout defaults to 0 (no call-level override) -- only AppriseAsset._service_timeout controls the default behaviour.""" import inspect signature = inspect.signature(Apprise.notify) assert signature.parameters["timeout"].default == 0 def test_timeout_logs_include_error_entry(self): """A TIMEOUT attempt includes a matching ERROR log entry. Each attempt takes one second, so the 1.5-second deadline expires during the retry wait or the second attempt. The grace period is widened so a slow test host cannot stop the wait before the worker reports its own attempts. """ N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False, service_timeout=1.5) service = _SlowNotify(host="x", asset=asset, retry=2) service.send = lambda **kw: (time.sleep(1.0), False)[1] a = Apprise(asset=asset) a.add(service) # Wait long enough for the worker to return its attempts. with mock.patch("apprise.apprise._ABANDON_GRACE_SECONDS", 5.0): result = a.notify(body="test") finally: N_MGR.unload_modules() r0 = next(iter(result)) # The earlier real failures still outrank the later timeout. assert r0.status == AppriseResultStatus.FAILURE assert r0.attempts[-1].status == AppriseResultStatus.TIMEOUT error_logs = [entry for entry in r0.logs() if entry.level == "ERROR"] assert len(error_logs) == 1 assert "did not finish" in error_logs[0].message assert r0.name in error_logs[0].message def test_timeout_result_json_includes_start_end_time(self): """A TIMEOUT NotifyResult still has start_time/end_time populated.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=True, service_timeout=0.5) slow = _SlowNotify(host="slow", asset=asset, delay=3.0) fast = _SlowNotify(host="fast", asset=asset, delay=0.0) a = Apprise(asset=asset) a.add(slow) a.add(fast) result = a.notify(body="test") finally: N_MGR.unload_modules() by_host = {r.url: r for r in result} r0 = by_host["slow://slow"] assert r0.status == AppriseResultStatus.TIMEOUT assert r0.start_time is not None assert r0.end_time is not None # Datetime arithmetic can differ from float elapsed by tiny rounding. assert (r0.end_time - r0.start_time).total_seconds() == pytest.approx( r0.elapsed, abs=1e-5 ) def test_weight_reflects_service_len(self): """NotifyResult.weight mirrors len(service) (the plugin's own target count).""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False) service = _SlowNotify(host="x", asset=asset, delay=0.0) a = Apprise(asset=asset) a.add(service) result = a.notify(body="test") finally: N_MGR.unload_modules() # _SlowNotify has no targets/list -- NotifyBase.__len__ always # reports at least 1. assert next(iter(result)).weight == len(service) == 1 def test_log_capture_records_warning_message(self): """A service WARNING is captured in NotifyResult.logs().""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False) service = _SlowNotify(host="x", asset=asset, delay=0.0) def _fail_with_warning(**kw): """Emit a warning before reporting a delivery failure.""" service.logger.warning("HTTP 429 Too Many Requests") return False service.send = _fail_with_warning a = Apprise(asset=asset) a.add(service) logging.disable(logging.NOTSET) try: result = a.notify(body="test") finally: logging.disable(logging.CRITICAL) finally: N_MGR.unload_modules() r0 = next(iter(result)) assert r0.status == AppriseResultStatus.FAILURE logs = list(r0.logs()) assert len(logs) == 1 assert logs[0].level == "WARNING" assert "429" in logs[0].message assert "429" in str(logs[0]) def test_log_capture_preserves_preexisting_instance_logger(self): """_ServiceLogCapture does not replace a service's logger.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False) service = _SlowNotify(host="x", asset=asset, delay=0.0) custom_logger = logging.getLogger("apprise.test.custom") service.logger = custom_logger a = Apprise(asset=asset) a.add(service) a.notify(body="test") finally: N_MGR.unload_modules() assert "logger" in service.__dict__ assert service.__dict__["logger"] is custom_logger def test_notify_log_callback_default_from_apprise_instance(self): """A log_callback given to Apprise() applies to every notify() call made with that instance, firing live with (entry, service) for each captured entry.""" N_MGR["slow"] = _SlowNotify received = [] def _cb(entry, service): """Collect the instance-level callback's service/message pair.""" received.append((service.service_name, entry.message)) try: asset = AppriseAsset(async_mode=False) service = _SlowNotify(host="x", asset=asset, delay=0.0) def _fail_with_warning(**kw): """Emit a warning and report a failed delivery attempt.""" service.logger.warning("HTTP 429 Too Many Requests") return False service.send = _fail_with_warning a = Apprise(asset=asset, log_callback=_cb) a.add(service) logging.disable(logging.NOTSET) try: a.notify(body="test") finally: logging.disable(logging.CRITICAL) finally: N_MGR.unload_modules() assert received == [ (service.service_name, "HTTP 429 Too Many Requests") ] def test_notify_log_callback_call_override(self): """log_callback passed directly to notify() takes priority over the Apprise instance's own default for that one call only.""" N_MGR["slow"] = _SlowNotify default_received = [] override_received = [] def _default_cb(entry, service): """Collect entries delivered to the instance default callback.""" default_received.append(entry.message) def _override_cb(entry, service): """Collect entries delivered to the per-call override callback.""" override_received.append(entry.message) try: asset = AppriseAsset(async_mode=False) service = _SlowNotify(host="x", asset=asset, delay=0.0) def _fail_with_warning(**kw): """Emit a warning and report a failed delivery attempt.""" service.logger.warning("overridden call") return False service.send = _fail_with_warning a = Apprise(asset=asset, log_callback=_default_cb) a.add(service) logging.disable(logging.NOTSET) try: a.notify(body="test", log_callback=_override_cb) finally: logging.disable(logging.CRITICAL) finally: N_MGR.unload_modules() assert default_received == [] assert override_received == ["overridden call"] def test_notify_log_level_default_is_warning(self): """Without an explicit log_level, captured entries still stop at WARNING -- INFO chatter from the service is not recorded.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False) service = _SlowNotify(host="x", asset=asset, delay=0.0) def _info_then_warn(**kw): """Log at both INFO and WARNING, then report failure.""" service.logger.info("delivery attempt starting") service.logger.warning("HTTP 429 Too Many Requests") return False service.send = _info_then_warn a = Apprise(asset=asset) a.add(service) logging.disable(logging.NOTSET) try: result = a.notify(body="test") finally: logging.disable(logging.CRITICAL) finally: N_MGR.unload_modules() service_result = next(iter(result)) messages = [e.message for e in service_result.logs()] assert messages == ["HTTP 429 Too Many Requests"] def test_notify_callback_defaults_to_info(self): """A callback raises the default capture level to INFO.""" N_MGR["slow"] = _SlowNotify received = [] def _cb(entry, service): """Collect every entry the callback actually receives.""" received.append(entry.message) try: asset = AppriseAsset(async_mode=False) service = _SlowNotify(host="x", asset=asset, delay=0.0) def _info_then_warn(**kw): """Log at both INFO and WARNING, then report failure.""" service.logger.info("delivery attempt starting") service.logger.warning("HTTP 429 Too Many Requests") return False service.send = _info_then_warn a = Apprise(asset=asset, log_callback=_cb) a.add(service) apprise_logger = logging.getLogger("apprise") restore_level = apprise_logger.level apprise_logger.setLevel(logging.INFO) logging.disable(logging.NOTSET) try: result = a.notify(body="test") finally: logging.disable(logging.CRITICAL) apprise_logger.setLevel(restore_level) finally: N_MGR.unload_modules() service_result = next(iter(result)) messages = [e.message for e in service_result.logs()] assert messages == [ "delivery attempt starting", "HTTP 429 Too Many Requests", ] assert received == [ "delivery attempt starting", "HTTP 429 Too Many Requests", ] def test_notify_explicit_log_level_wins(self): """An explicit log level overrides the callback default.""" N_MGR["slow"] = _SlowNotify received = [] def _cb(entry, service): """Collect every entry the callback actually receives.""" received.append(entry.message) try: asset = AppriseAsset(async_mode=False) service = _SlowNotify(host="x", asset=asset, delay=0.0) def _info_then_warn(**kw): """Log at both INFO and WARNING, then report failure.""" service.logger.info("delivery attempt starting") service.logger.warning("HTTP 429 Too Many Requests") return False service.send = _info_then_warn a = Apprise(asset=asset, log_callback=_cb) a.add(service) logging.disable(logging.NOTSET) try: result = a.notify(body="test", log_level=logging.WARNING) finally: logging.disable(logging.CRITICAL) finally: N_MGR.unload_modules() service_result = next(iter(result)) messages = [e.message for e in service_result.logs()] assert messages == ["HTTP 429 Too Many Requests"] assert received == ["HTTP 429 Too Many Requests"] def test_notify_log_level_call_override(self): """log_level passed directly to notify() captures finer entries for that one call, overriding the Apprise instance's default.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False) service = _SlowNotify(host="x", asset=asset, delay=0.0) def _info_then_warn(**kw): """Log at both INFO and WARNING, then report failure.""" service.logger.info("delivery attempt starting") service.logger.warning("HTTP 429 Too Many Requests") return False service.send = _info_then_warn a = Apprise(asset=asset) a.add(service) # log_level only widens what the capture handler keeps; the # 'apprise' logger's own effective level must independently # permit INFO records through before any handler sees them. apprise_logger = logging.getLogger("apprise") restore_level = apprise_logger.level apprise_logger.setLevel(logging.INFO) logging.disable(logging.NOTSET) try: result = a.notify(body="test", log_level=logging.INFO) finally: logging.disable(logging.CRITICAL) apprise_logger.setLevel(restore_level) finally: N_MGR.unload_modules() service_result = next(iter(result)) messages = [e.message for e in service_result.logs()] assert messages == [ "delivery attempt starting", "HTTP 429 Too Many Requests", ] def test_notify_log_level_default_from_apprise_instance(self): """A log_level given to Apprise() applies to every notify() call made with that instance, the same way log_callback does.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False) service = _SlowNotify(host="x", asset=asset, delay=0.0) def _info_then_warn(**kw): """Log at both INFO and WARNING, then report failure.""" service.logger.info("delivery attempt starting") service.logger.warning("HTTP 429 Too Many Requests") return False service.send = _info_then_warn a = Apprise(asset=asset, log_level=logging.INFO) a.add(service) apprise_logger = logging.getLogger("apprise") restore_level = apprise_logger.level apprise_logger.setLevel(logging.INFO) logging.disable(logging.NOTSET) try: result = a.notify(body="test") finally: logging.disable(logging.CRITICAL) apprise_logger.setLevel(restore_level) finally: N_MGR.unload_modules() service_result = next(iter(result)) messages = [e.message for e in service_result.logs()] assert messages == [ "delivery attempt starting", "HTTP 429 Too Many Requests", ] def test_async_notify_log_level(self): """log_level works for a plugin's native async_notify() path.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=True) service = _SlowNotify(host="x", asset=asset, delay=0.0) async def _info_then_warn(**kw): """Log at both INFO and WARNING, then report failure.""" service.logger.info("async attempt starting") service.logger.warning("async 429") return False service.async_notify = _info_then_warn a = Apprise(asset=asset) a.add(service) apprise_logger = logging.getLogger("apprise") restore_level = apprise_logger.level apprise_logger.setLevel(logging.INFO) logging.disable(logging.NOTSET) try: result = asyncio.run( a.async_notify(body="test", log_level=logging.INFO) ) finally: logging.disable(logging.CRITICAL) apprise_logger.setLevel(restore_level) finally: N_MGR.unload_modules() service_result = next(iter(result)) messages = [e.message for e in service_result.logs()] assert messages == ["async attempt starting", "async 429"] def test_async_notify_callback_defaults_to_info(self): """Async callbacks also default to INFO capture.""" N_MGR["slow"] = _SlowNotify received = [] def _cb(entry, service): """Collect service entries and ignore orchestration logs.""" if service is not None: received.append(entry.message) try: asset = AppriseAsset(async_mode=True) service = _SlowNotify(host="x", asset=asset, delay=0.0) async def _info_then_warn(**kw): """Log at both INFO and WARNING, then report failure.""" service.logger.info("async attempt starting") service.logger.warning("async 429") return False service.async_notify = _info_then_warn a = Apprise(asset=asset, log_callback=_cb) a.add(service) apprise_logger = logging.getLogger("apprise") restore_level = apprise_logger.level apprise_logger.setLevel(logging.INFO) logging.disable(logging.NOTSET) try: result = asyncio.run(a.async_notify(body="test")) finally: logging.disable(logging.CRITICAL) apprise_logger.setLevel(restore_level) finally: N_MGR.unload_modules() service_result = next(iter(result)) messages = [e.message for e in service_result.logs()] assert messages == ["async attempt starting", "async 429"] assert received == ["async attempt starting", "async 429"] def test_notify_call_logs_no_services(self): """Call logs include the warning when no services are loaded.""" a = Apprise() logging.disable(logging.NOTSET) try: result = a.notify(body="test") finally: logging.disable(logging.CRITICAL) assert result.status == AppriseResultStatus.NOMATCH assert any( "no service" in e.message.lower() for e in result.call_logs() ) # Merged into the combined timeline too. assert any("no service" in e.message.lower() for e in result.logs()) def test_notify_empty_content_returns_failure(self): """Invalid empty content returns a failure result without dispatch.""" a = Apprise() a.add(_TestNotify(host="localhost")) result = a.notify(body="") assert result.status == AppriseResultStatus.FAILURE assert len(result) == 0 def test_async_notify_validation_results(self): """Async validation distinguishes invalid content from no services.""" a = Apprise() a.add(_TestNotify(host="localhost")) invalid = asyncio.run(a.async_notify(body="")) no_services = asyncio.run(Apprise().async_notify(body="test")) assert invalid.status == AppriseResultStatus.FAILURE assert no_services.status == AppriseResultStatus.NOMATCH def test_notify_call_logs_retry_trace(self): """Call logs include retry details at TRACE level.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False) service = _SlowNotify(host="x", asset=asset, delay=0.0, retry=1) def _always_fail(**kw): """Report failure on every attempt.""" return False service.send = _always_fail a = Apprise(asset=asset) a.add(service) apprise_logger = logging.getLogger("apprise") restore_level = apprise_logger.level apprise_logger.setLevel(logging.TRACE) logging.disable(logging.NOTSET) try: result = a.notify(body="test", log_level=logging.TRACE) finally: logging.disable(logging.CRITICAL) apprise_logger.setLevel(restore_level) finally: N_MGR.unload_modules() call_messages = [e.message for e in result.call_logs()] assert any("Starting attempt" in m for m in call_messages) assert any("trying again" in m for m in call_messages) def test_async_notify_call_logs(self): """Async notifications also collect call-level logs.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=True) service = _SlowNotify(host="x", asset=asset, delay=0.0) async def _fail(**kw): """Report failure without logging anything itself.""" return False service.async_notify = _fail a = Apprise(asset=asset) a.add(service) apprise_logger = logging.getLogger("apprise") restore_level = apprise_logger.level apprise_logger.setLevel(logging.TRACE) logging.disable(logging.NOTSET) try: result = asyncio.run( a.async_notify(body="test", log_level=logging.TRACE) ) finally: logging.disable(logging.CRITICAL) apprise_logger.setLevel(restore_level) finally: N_MGR.unload_modules() call_messages = [e.message for e in result.call_logs()] assert any("Starting attempt" in m for m in call_messages) def test_async_notify_log_callback(self): """log_callback works for a plugin's native async_notify().""" N_MGR["slow"] = _SlowNotify received = [] def _cb(entry, service): """Collect callback entries produced by async_notify().""" received.append((service.service_name, entry.message)) try: asset = AppriseAsset(async_mode=True) service = _SlowNotify(host="x", asset=asset, delay=0.0) async def _fail_with_warning(**kw): """Emit a warning from the native async_notify override.""" service.logger.warning("async 429") return False service.async_notify = _fail_with_warning a = Apprise(asset=asset, log_callback=_cb) a.add(service) logging.disable(logging.NOTSET) try: asyncio.run(a.async_notify(body="test")) finally: logging.disable(logging.CRITICAL) finally: N_MGR.unload_modules() assert received == [(service.service_name, "async 429")] def test_notify_log_callback_thread_attribution(self): """Concurrent log callbacks are attributed to the right service.""" N_MGR["slow"] = _SlowNotify received = [] lock = threading.Lock() def _cb(entry, service): """Append callback data under a lock.""" with lock: received.append((service.host, entry.message)) def _make_send(service): """Build a send() replacement bound to one service instance.""" def _send(**kw): """Emit a host-specific warning from the bound service.""" service.logger.warning("warning from %s", service.host) return True return _send try: asset = AppriseAsset(async_mode=True) a = Apprise(asset=asset, log_callback=_cb) services = [] for i in range(6): service = _SlowNotify(host=f"host{i}", asset=asset, delay=0.02) service.send = _make_send(service) services.append(service) a.add(service) logging.disable(logging.NOTSET) try: a.notify(body="test") finally: logging.disable(logging.CRITICAL) finally: N_MGR.unload_modules() assert sorted(received) == sorted( (s.host, f"warning from {s.host}") for s in services ) def test_timeout_result_defensive_on_malformed_service(self): """Timeout metadata falls back when service helpers are missing.""" class _MalformedSlowService: """A service missing url()/url_id()/__len__ that never finishes in time, exercising timeout_result's defensive except paths. """ service_name = "malformed" asset = AppriseAsset(async_mode=True, service_timeout=0.1) optional = False tags: set = set() retry = 0 wait = 0.0 async def async_notify(self, **kwargs): """Remain active long enough to exceed the test deadline.""" await asyncio.sleep(1.0) return True # pragma: no cover -- never reached in time good = _SlowNotify( host="good", asset=AppriseAsset(async_mode=True), delay=0.0 ) malformed = _MalformedSlowService() ok, results = asyncio.run( Apprise._notify_parallel_asyncio( (malformed, {}), (good, {}), ) ) assert ok is False by_url = {r.url: r for r in results} # No url()/url_id()/__len__ on the malformed service -- defensive # fallbacks kick in rather than raising. assert by_url["unknown://"].status == AppriseResultStatus.TIMEOUT assert by_url["unknown://"].url_id is None assert by_url["unknown://"].weight == 1 assert by_url["slow://good"].status == AppriseResultStatus.SUCCESS def test_notify_result_tag_is_sorted(self): """NotifyResult.tag is sorted for stable output.""" N_MGR["slow"] = _SlowNotify try: service = _SlowNotify( host="x", asset=AppriseAsset(async_mode=False), delay=0.0, tag=["zeta", "alpha", "middle", "beta"], ) a = Apprise() a.add(service) result = a.notify(body="test") finally: N_MGR.unload_modules() r0 = next(iter(result)) assert r0.tag == ("alpha", "beta", "middle", "zeta") def test_threadpool_queued_futures_cancelled(self): """A service still queued behind other work in a size-limited thread pool must not execute later after timeout.""" N_MGR["slow"] = _SlowNotify # More services than the default ThreadPoolExecutor can run at once, # so some work is guaranteed to still be queued at the deadline. n_services = 40 started: list[int] = [] finished: list[int] = [] lock = threading.Lock() def _slow_send(idx): """Build a send() replacement that records start and finish.""" def _send(**kwargs): """Simulate slow work so queued futures can be cancelled.""" with lock: started.append(idx) time.sleep(0.3) with lock: finished.append(idx) return True return _send try: asset = AppriseAsset(async_mode=True, service_timeout=0.05) a = Apprise(asset=asset) services = [] for i in range(n_services): service = _SlowNotify(host=f"host{i}", asset=asset, delay=0.0) service.send = _slow_send(i) services.append(service) a.add(service) result = a.notify(body="test") assert result.status == AppriseResultStatus.TIMEOUT with lock: started_at_return = len(started) # Give uncancelled queued work enough time to reveal itself. time.sleep(1.0) with lock: started_later = len(started) finished_later = len(finished) finally: N_MGR.unload_modules() # Some work stayed queued because the pool is size-limited. assert 0 < started_at_return < n_services # And none of the ones that hadn't started yet ever did. assert started_later == started_at_return assert finished_later == started_at_return def test_sequential_queued_future_cancelled(self): """A sequential service whose future never got a chance to start (still queued behind other work in a size-limited pool) is cancelled outright at the deadline -- nothing is left running, so it is never tracked as abandoned.""" N_MGR["slow"] = _SlowNotify try: import concurrent.futures as cf import apprise.apprise as apprise_module release = threading.Event() with mock.patch( "apprise.apprise._shared_executor", cf.ThreadPoolExecutor(max_workers=1), ): executor = apprise_module._shared_executor # Occupy the pool's only worker so the service's own # future stays queued until its deadline has passed. blocker = executor.submit(release.wait, 2.0) asset = AppriseAsset(async_mode=False, service_timeout=0.05) service = _SlowNotify(host="x", asset=asset, delay=0.0) a = Apprise(asset=asset) a.add(service) result = a.notify(body="test") release.set() blocker.result(timeout=2.0) executor.shutdown(wait=True) finally: N_MGR.unload_modules() assert result.status == AppriseResultStatus.TIMEOUT # Still queued when abandoned -- it never actually got to run. assert service.calls == 0 def test_threadpool_shared_across_notify_calls(self): """Separate notify() calls reuse the shared executor.""" N_MGR["slow"] = _SlowNotify try: import apprise.apprise as apprise_module # Force this test to observe executor construction. with mock.patch("apprise.apprise._shared_executor", None): executors_seen = set() for i in range(3): asset = AppriseAsset(async_mode=True, service_timeout=0.05) a = Apprise(asset=asset) service = _SlowNotify( host=f"host{i}", asset=asset, delay=1.0 ) a.add(service) result = a.notify(body="test") assert result.status == AppriseResultStatus.TIMEOUT executors_seen.add(id(apprise_module._shared_executor)) # All three notify() calls reused the same executor. assert len(executors_seen) == 1 # Clean up the temporary executor before mock.patch restores. apprise_module._shared_executor.shutdown(wait=False) finally: N_MGR.unload_modules() def test_abandoned_calls_reflect_running_state(self): """Tracked abandoned calls clear once their background work ends.""" import apprise.apprise as apprise_module N_MGR["slow"] = _SlowNotify try: # A call still queued when the timeout hits is cancelled rather # than abandoned, so give a busy machine plenty of time to start # it, and keep it running well past the timeout. asset = AppriseAsset(async_mode=True, service_timeout=0.5) a = Apprise(asset=asset) service = _SlowNotify(host="host", asset=asset, delay=2.0) a.add(service) result = a.notify(body="test") assert result.status == AppriseResultStatus.TIMEOUT # The 2s send() is still running in the background -- # notify() only gave up waiting on it, it did not stop it. assert apprise_module.any_abandoned_calls_still_running() is True # Give it a generous window to actually finish. wait_until( lambda: not apprise_module.any_abandoned_calls_still_running() ) assert apprise_module.any_abandoned_calls_still_running() is False finally: N_MGR.unload_modules() def test_abandoned_call_descriptions_clear(self): """Abandoned-call descriptions clear once the service finishes.""" import apprise.apprise as apprise_module N_MGR["slow"] = _SlowNotify try: # A call still queued when the timeout hits is cancelled rather # than abandoned, so give a busy machine plenty of time to start # it, and keep it running well past the timeout. asset = AppriseAsset(async_mode=True, service_timeout=0.5) a = Apprise(asset=asset) service = _SlowNotify( host="descriptionhost", asset=asset, delay=2.0 ) a.add(service) result = a.notify(body="test") assert result.status == AppriseResultStatus.TIMEOUT descriptions = apprise_module.abandoned_call_descriptions() assert len(descriptions) == 1 assert "slow://descriptionhost" in descriptions[0] # Once the service finishes, its abandoned-call description clears. wait_until( lambda: not apprise_module.abandoned_call_descriptions() ) assert apprise_module.abandoned_call_descriptions() == [] finally: N_MGR.unload_modules() def test_abandoned_calls_false_when_only_queued(self): """Cancelled queued work does not count as still running.""" import apprise.apprise as apprise_module N_MGR["slow"] = _SlowNotify n_services = 40 try: asset = AppriseAsset(async_mode=True, service_timeout=0.05) a = Apprise(asset=asset) for i in range(n_services): service = _SlowNotify(host=f"host{i}", asset=asset, delay=0.3) a.add(service) result = a.notify(body="test") assert result.status == AppriseResultStatus.TIMEOUT # Queued futures are cancelled before they ever touch the tracked # abandoned-call list; any running work should settle quickly. wait_until( lambda: not apprise_module.any_abandoned_calls_still_running() ) assert apprise_module.any_abandoned_calls_still_running() is False finally: N_MGR.unload_modules() def test_timeout_logged_once(self): """A service timeout is reported once, not once per capture.""" N_MGR["slow"] = _SlowNotify try: asset = AppriseAsset(async_mode=False) # The delay is well past the limit so the wait always wins. service = _SlowNotify(host="x", asset=asset, delay=2.0) a = Apprise(asset=asset) a.add(service) with apprise_logs_enabled(): result = a.notify(body="test", timeout=0.25) finally: N_MGR.unload_modules() assert result.status == AppriseResultStatus.TIMEOUT timeouts = [ e.message for e in result.logs() if "did not finish within" in e.message ] assert len(timeouts) == 1 # The entry belongs to the service attempt. Capturing the logged # copy as well would list the same timeout twice above. assert not [ e for e in result.call_logs() if "did not finish within" in e.message ] def test_timeout_reaches_log_callback(self): """A live callback still sees the timeout the result stores once.""" N_MGR["slow"] = _SlowNotify received = [] try: asset = AppriseAsset(async_mode=False) # The delay is well past the limit so the wait always wins. service = _SlowNotify(host="x", asset=asset, delay=2.0) a = Apprise(asset=asset) a.add(service) with apprise_logs_enabled(): result = a.notify( body="test", timeout=0.25, log_callback=lambda entry, svc: received.append( entry.message ), ) finally: N_MGR.unload_modules() assert result.status == AppriseResultStatus.TIMEOUT # Delivered live exactly once, and kept exactly once. Suppressing # the stored copy must not cost the callback its notice. stored = [ e for e in result.logs() if "did not finish within" in e.message ] delivered = [m for m in received if "did not finish within" in m] assert len(stored) == 1 assert len(delivered) == 1 class _SlowFailNotify(NotifyBase): """Fails after a configurable delay so deadlines can pass mid-attempt.""" app_id = "SlowFailApp" app_desc = "Test" notify_url = "slowfail://" title_maxlen = 250 body_maxlen = 32768 def __init__(self, delay=0.0, **kwargs): """Initialize the test plugin with its artificial delay.""" super().__init__(**kwargs) self._delay = delay self.calls = 0 def url(self, *args, **kwargs): """Return a stable URL containing the test host.""" return "slowfail://{}".format(self.host) def send(self, **kwargs): """Block for the configured delay, then report failure.""" self.calls += 1 time.sleep(self._delay) return False async def async_notify(self, **kwargs): """Block for the configured delay, then report failure.""" self.calls += 1 await asyncio.sleep(self._delay) return False @staticmethod def parse_url(url): """Parse the synthetic URL without requiring a real host.""" return NotifyBase.parse_url(url, verify_host=False) class TestDeadlineExpiresDuringAttempt: """Deadline-expired retries should stop without sleeping first.""" def test_sequential_abandons_service_that_blocks_past_deadline(self): """A blocking sync service is abandoned once its deadline passes.""" N_MGR["slowfail"] = _SlowFailNotify try: asset = AppriseAsset(async_mode=False, service_timeout=0.05) service = _SlowFailNotify( host="x", asset=asset, retry=1, wait=1.0, delay=1.0 ) a = Apprise(asset=asset) a.add(service) t0 = time.monotonic() result = a.notify(body="test") wall = time.monotonic() - t0 # The result returns nowhere near the full blocking send() # delay -- generous slack here for loaded CI runners, since # only the *relative* gap to the 1.0s delay matters. assert wall < 0.6 assert result.status == AppriseResultStatus.TIMEOUT # The first attempt started, but no retry was launched. assert service.calls == 1 finally: N_MGR.unload_modules() def test_asyncio_skips_wait_when_deadline_already_passed(self): """Async retry skips its wait once the deadline has passed.""" N_MGR["slowfail"] = _SlowFailNotify try: asset = AppriseAsset(async_mode=True, service_timeout=0.05) service = _SlowFailNotify( host="x", asset=asset, retry=1, wait=1.0, delay=0.08 ) a = Apprise(asset=asset) a.add(service) # Keep the delay just above service_timeout so do_call() reaches # its retry check before the outer abandon window takes over. result = asyncio.run(a.async_notify(body="test")) assert result.status == AppriseResultStatus.FAILURE assert service.calls == 1 finally: N_MGR.unload_modules() class _SlowThreadedNotify(NotifyBase): """Test the default async wrapper around a synchronous send.""" app_id = "SlowThreadedApp" app_desc = "Test" notify_url = "slowthreaded://" title_maxlen = 250 body_maxlen = 32768 def __init__(self, delay=0.0, **kwargs): """Initialize the test plugin with its artificial delay.""" super().__init__(**kwargs) self._delay = delay self.calls = 0 def url(self, *args, **kwargs): """Return a stable URL containing the test host.""" return "slowthreaded://{}".format(self.host) def send(self, **kwargs): """Block the calling (executor) thread for the configured delay.""" self.calls += 1 time.sleep(self._delay) return True @staticmethod def parse_url(url): """Parse the synthetic URL without requiring a real host.""" return NotifyBase.parse_url(url, verify_host=False) class TestAsyncAbandonedCallTracking: """Track timed-out sends while their worker threads are still running.""" def test_abandoned_async_call_is_tracked_until_it_finishes(self): """A timed-out send stays tracked until its worker finishes.""" import apprise.apprise as apprise_module N_MGR["slowthreaded"] = _SlowThreadedNotify try: # A call still queued when the timeout hits is cancelled rather # than abandoned, so give a busy machine plenty of time to start # it, and keep it running well past the timeout. asset = AppriseAsset(async_mode=True, service_timeout=0.5) service = _SlowThreadedNotify(host="x", asset=asset, delay=2.0) a = Apprise(asset=asset) a.add(service) result = asyncio.run(a.async_notify(body="test")) assert result.status == AppriseResultStatus.TIMEOUT # The sleeping worker remains visible after the timeout. assert apprise_module.any_abandoned_calls_still_running() is True descriptions = apprise_module.abandoned_call_descriptions() assert any("x" in d or "slowthreaded" in d for d in descriptions) # Once the real delay elapses, it drops out of the list. wait_until( lambda: not apprise_module.any_abandoned_calls_still_running() ) assert apprise_module.any_abandoned_calls_still_running() is False assert service.calls == 1 finally: N_MGR.unload_modules() def test_queued_call_not_yet_running_is_not_tracked(self): """A cancelled send that never started is not tracked.""" import concurrent.futures as cf import apprise.apprise as apprise_module N_MGR["slowthreaded"] = _SlowThreadedNotify busy_executor = cf.ThreadPoolExecutor(max_workers=1) block_event = threading.Event() filler = busy_executor.submit(block_event.wait, 5) try: with mock.patch( "apprise.apprise.get_shared_executor", return_value=busy_executor, ): asset = AppriseAsset(async_mode=True, service_timeout=0.05) service = _SlowThreadedNotify(host="x", asset=asset) a = Apprise(asset=asset) a.add(service) result = asyncio.run(a.async_notify(body="test")) assert result.status == AppriseResultStatus.TIMEOUT # The queued call never started, so it is not abandoned work. assert apprise_module.any_abandoned_calls_still_running() is False assert service.calls == 0 finally: block_event.set() filler.result(timeout=5) busy_executor.shutdown(wait=True) N_MGR.unload_modules() class TestSharedExecutorRace: """get_shared_executor() must reuse an executor created during locking.""" def test_inner_check_skips_creation_if_already_set(self): """The inner lock check returns the executor another thread created.""" import apprise.apprise as apprise_module sentinel = mock.Mock(name="already-created-executor") class _RaceLock: """Simulate another thread creating the executor first.""" def __enter__(self): apprise_module._shared_executor = sentinel return self def __exit__(self, *exc): return False with ( mock.patch("apprise.apprise._shared_executor", None), mock.patch("apprise.apprise._shared_executor_lock", _RaceLock()), mock.patch("apprise.apprise.cf.ThreadPoolExecutor") as mock_pool, ): result = apprise_module.get_shared_executor() assert result is sentinel mock_pool.assert_not_called() def test_coordinator_inner_check_skips_creation_if_already_set(self): """Same race-safety guarantee for the coordinator pool.""" import apprise.apprise as apprise_module sentinel = mock.Mock(name="already-created-coordinator-executor") class _RaceLock: """Simulate another thread creating the executor first.""" def __enter__(self): apprise_module._coordinator_executor = sentinel return self def __exit__(self, *exc): return False with ( mock.patch("apprise.apprise._coordinator_executor", None), mock.patch( "apprise.apprise._coordinator_executor_lock", _RaceLock() ), mock.patch("apprise.apprise.cf.ThreadPoolExecutor") as mock_pool, ): result = apprise_module.get_coordinator_executor() assert result is sentinel mock_pool.assert_not_called() class TestCoordinatorLeafPoolSeparation: """Keep coordinators separate from the service calls they await. Single-worker pools make starvation failures deterministic. """ def test_multiple_chains_do_not_starve_their_own_leaf_calls(self): """Multiple tag chains succeed with one worker in each pool.""" import concurrent.futures as cf import apprise.apprise as apprise_module N_MGR["failpass"] = _FailThenSucceedNotify try: # A finite deadline turns starvation into a test failure. asset = AppriseAsset(async_mode=False, service_timeout=5) s_devops = _FailThenSucceedNotify( host="devops", asset=asset, fail_times=0 ) s_devops.tags = {"devops"} s_mgmt = _FailThenSucceedNotify( host="mgmt", asset=asset, fail_times=0 ) s_mgmt.tags = {"management"} a = Apprise(asset=asset) a.add(s_devops) a.add(s_mgmt) with ( mock.patch( "apprise.apprise._shared_executor", cf.ThreadPoolExecutor(max_workers=1), ), mock.patch( "apprise.apprise._coordinator_executor", cf.ThreadPoolExecutor(max_workers=1), ), ): # Distinct OR tags exercise the multiple-chain path. result = a.notify(body="test", tag="devops management") assert bool(result) is True assert s_devops._calls == 1 assert s_mgmt._calls == 1 finally: N_MGR.unload_modules() apprise_module._shared_executor = None apprise_module._coordinator_executor = None def test_async_sequential_batch_does_not_starve_itself(self): """Async dispatch can still run its blocking service batch.""" import concurrent.futures as cf import apprise.apprise as apprise_module # A synchronous service exercises the coordinator-backed blocking path. asset = AppriseAsset(async_mode=False, service_timeout=5) service = _FailThenSucceedNotify( host="solo", asset=asset, fail_times=0 ) async def run(): return await Apprise._split_and_dispatch_async( [(service, {"body": "test"})] ) with ( mock.patch( "apprise.apprise._shared_executor", cf.ThreadPoolExecutor(max_workers=1), ), mock.patch( "apprise.apprise._coordinator_executor", cf.ThreadPoolExecutor(max_workers=1), ), ): ok, _results = asyncio.run(run()) assert ok is True assert service._calls == 1 apprise_module._shared_executor = None apprise_module._coordinator_executor = None caronc-apprise-dfec414/tests/test_secure_logging.py000066400000000000000000000174301526720371000226730ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Verify that logging masks credentials in file-like URL inputs. See CWE-312 for the related cleartext-storage weakness. """ import logging import os from unittest import mock from urllib.parse import urlparse import pytest import requests from apprise import ( LOGGER_NAME, AppriseAsset, AppriseAttachment, AppriseConfig, utils, ) from apprise.attachment import AttachBase from apprise.common import ContentLocation from apprise.utils.pgp import ApprisePGPController # A password that must never turn up in a log line SECRET = "pass123" # URLs shaped like supported key and attachment inputs. SECRET_KEY_URL = f"https://user:{SECRET}@example.com/private_key.pem" SECRET_ATTACH_URL = f"https://user:{SECRET}@example.com/image.png" @pytest.fixture def logs(caplog): """Capture Apprise logs, undoing logging disabled by other test modules.""" disabled = logging.root.manager.disable logging.disable(logging.NOTSET) caplog.set_level(1, logger=LOGGER_NAME) yield caplog logging.disable(disabled) @pytest.fixture def unreachable(): """Makes any attempt to fetch a remote file fail.""" with mock.patch( "requests.get", side_effect=requests.RequestException("nope") ): yield def test_pem_private_keyfile_credentials(logs, unreachable, tmpdir): """A PEM private key url does not put its password in the log.""" pem = utils.pem.ApprisePEMController(path=str(tmpdir)) assert pem.load_private_key(SECRET_KEY_URL) is False # The failure is reported without the password. assert "Could not access PEM Private Key" in logs.text assert SECRET not in logs.text # Parse logged URLs before checking which host was retained. urls_in_logs = [token for token in logs.text.split() if "://" in token] assert any( urlparse(token).hostname == "example.com" for token in urls_in_logs ) # The masked form is what was written. assert "user:p...3@example.com" in logs.text def test_pem_public_keyfile_credentials(logs, unreachable, tmpdir): """A PEM public key url does not put its password in the log.""" pem = utils.pem.ApprisePEMController(path=str(tmpdir)) assert pem.load_public_key(SECRET_KEY_URL) is False assert "Could not access PEM Public Key" in logs.text assert SECRET not in logs.text def test_attachment_url_credentials(logs, unreachable): """Adding a remote attachment does not log its password.""" attach = AppriseAttachment() assert attach.add(SECRET_ATTACH_URL) is True assert "Loading attachment" in logs.text assert SECRET not in logs.text def test_attachment_disabled_credentials(logs): """Refusing an attachment does not log its password either.""" attach = AppriseAttachment(location=ContentLocation.INACCESSIBLE) assert attach.add(SECRET_ATTACH_URL) is False assert "Attachments are disabled" in logs.text assert SECRET not in logs.text def test_attachment_parse_failure_credentials(logs): """Mask credentials when an attachment URL cannot be parsed.""" url = f"https://user:{SECRET}@example.com:bad/image.png" assert AppriseAttachment().add(url) is False assert "Unparseable URL" in logs.text assert SECRET not in logs.text def test_attachment_load_failure_credentials(logs): """Mask credentials when attachment construction raises an exception.""" class BadAttach(AttachBase): def __init__(self, **kwargs): super().__init__(**kwargs) raise TypeError() manager = AppriseAttachment.instantiate.__globals__["A_MGR"] manager["securebad"] = BadAttach try: url = f"securebad://user:{SECRET}@localhost/file" assert AppriseAttachment.instantiate(url) is None finally: del manager["securebad"] assert "Could not load URL" in logs.text assert SECRET not in logs.text def test_secure_logging_disabled(logs, unreachable, tmpdir): """Disabling secure logging exposes the full value for debugging.""" asset = AppriseAsset(secure_logging=False) pem = utils.pem.ApprisePEMController(path=str(tmpdir), asset=asset) assert pem.load_private_key(SECRET_KEY_URL) is False # With masking off, the full URL and password appear. assert SECRET in logs.text assert SECRET_KEY_URL in logs.text def test_pem_keyfile_read_errors(logs, tmpdir): """Keep a failed local key path readable so users can identify it.""" keyfile = os.path.join(str(tmpdir), "private_key.pem") with open(keyfile, "w") as f: f.write("not a real key") pem = utils.pem.ApprisePEMController(path=str(tmpdir)) # Simulate a file disappearing before it is opened. with mock.patch("builtins.open", side_effect=FileNotFoundError): assert pem.load_private_key(keyfile) is False assert "PEM Private Key file not found" in logs.text assert keyfile in logs.text # Simulate a file that cannot be read. logs.clear() with mock.patch("builtins.open", side_effect=OSError): assert pem.load_private_key(keyfile) is False assert "Error accessing PEM Private Key file" in logs.text assert keyfile in logs.text # Public keys handle the same failures. logs.clear() with mock.patch("builtins.open", side_effect=FileNotFoundError): assert pem.load_public_key(keyfile) is False assert "PEM Public Key file not found" in logs.text assert keyfile in logs.text logs.clear() with mock.patch("builtins.open", side_effect=OSError): assert pem.load_public_key(keyfile) is False assert "Error accessing PEM Public Key file" in logs.text assert keyfile in logs.text @pytest.mark.parametrize("method", ("private_key", "public_key")) def test_pgp_key_credentials(logs, tmpdir, method): """Mask credentials in PGP key read failures.""" pgp = ApprisePGPController(path=str(tmpdir)) keyfile_method = f"{method}file" error = OSError("disk error") with ( mock.patch.object(pgp, keyfile_method, return_value=SECRET_KEY_URL), mock.patch("builtins.open", side_effect=error), ): assert getattr(pgp, method)() is None assert "disk error" in logs.text assert SECRET not in logs.text def test_config_parse_failure_credentials(logs): """Mask credentials when a configuration URL cannot be parsed.""" url = f"https://user:{SECRET}@example.com:bad/config.yml" assert AppriseConfig().add(url) is False assert "Unparseable URL" in logs.text assert SECRET not in logs.text caronc-apprise-dfec414/tests/test_template.py000066400000000000000000000601021526720371000215040ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Tests for the template variable helpers.""" import time from unittest import mock import pytest from apprise.exception import AppriseTemplateError from apprise.utils.template import ( MAX_TEMPLATE_VALUE_LEN, TemplatePlaceholderMap, TemplateSchema, normalize_name, resolve_values, validate_value, ) def schema_of(*entries): """Build a schema from the entries given.""" return TemplateSchema.parse(list(entries)) def test_declaration_accepts_a_list(): """A list may mix bare names with names that carry a default.""" schema = schema_of("target", {"smtp_host": "smtp.example.com"}) assert sorted(schema.names) == ["smtp_host", "target"] assert schema.required == ("target",) assert schema.variables["smtp_host"].default == "smtp.example.com" def test_declaration_accepts_a_mapping(): """A mapping means the same thing as the list form. - a value present is the default - no value at all makes the variable mandatory """ schema = TemplateSchema.parse( { "smtp_host": "smtp.example.com", "target": None, } ) assert sorted(schema.names) == ["smtp_host", "target"] assert schema.required == ("target",) def test_template_empty_default_is_required(): """``- name:`` reads the same as a bare ``- name``.""" assert schema_of({"target": None}).required == ("target",) def test_template_name_case_insensitive(): """TARGET, Target and target all name the same variable.""" schema = schema_of("TaRgEt") assert schema.names == ("target",) assert "TARGET" in schema assert normalize_name("TARGET") == "target" def test_template_rejects_duplicate_name(): """The same name twice is refused, even in a different case.""" with pytest.raises(AppriseTemplateError): schema_of("target", "TARGET") @pytest.mark.parametrize( "name", ["", "a b", "has-dash", "x" * 33, "with.dot", "sym$"], ) def test_template_rejects_invalid_name(name): """Only letters, digits and underscores are allowed.""" with pytest.raises(AppriseTemplateError): schema_of(name) def test_template_many_declarations(): """There is no cap on how many variables are declared.""" schema = schema_of(*[f"v{i}" for i in range(500)]) assert len(schema.names) == 500 @pytest.mark.parametrize("entries", ["a string", 42, None]) def test_template_rejects_invalid_section(entries): """The section has to be a list or a mapping.""" with pytest.raises(AppriseTemplateError): TemplateSchema.parse(entries) def test_template_replaces_declared_names_only(): """An undeclared ${...} is left exactly as it was written. This is what keeps a password holding ``${`` intact. """ placeholders = TemplatePlaceholderMap(schema_of("target"), "") text = "json://user:${NOT_DECLARED}@host/${TARGET}" encoded = placeholders.encode(text) assert "${NOT_DECLARED}" in encoded assert "${TARGET}" not in encoded @pytest.mark.parametrize( ("text", "expected"), [ # A "$" in front is ordinary text kept beside the value ("$${A}", "$real"), # ...as is any other text either side of the marker ("x-$${A}-y", "x-$real-y"), # Two of them are simply two ("$$${A}", "$$real"), ], ) def test_template_dollar_is_literal(text, expected): """There is no escape syntax; a "$" is just a "$".""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") encoded = placeholders.encode(text) assert placeholders.used(encoded) == {"a"} assert placeholders.substitute(encoded, {"a": "real"}) == expected def test_template_marker_value_is_not_expanded(): """A value is dropped in as it is, markers and all. Only the original text is scanned, so a value that happens to contain ${NAME} is never looked at a second time. """ placeholders = TemplatePlaceholderMap(schema_of("a", "b"), "") encoded = placeholders.encode("${A}") # The value for 'a' mentions 'b', and stays exactly that text result = placeholders.substitute(encoded, {"a": "${B}", "b": "34"}) assert result == "${B}" def test_template_default_marker_is_literal(): """One declaration never builds on another.""" schema = TemplateSchema.parse([{"value": "34"}, {"key": "${value}"}]) assert schema.variables["key"].default == "${value}" assert resolve_values(schema, None, {}) == { "value": "34", "key": "${value}", } @pytest.mark.parametrize( "secret", ["p${a}ss", "$${x}", "${}", "${toolong" + "g" * 40 + "}", "$", "${"], ids=["inline", "double-dollar", "empty", "over-long", "bare", "unclosed"], ) def test_template_preserves_literal_markers(secret): """Nothing is declared, so nothing may be replaced.""" placeholders = TemplatePlaceholderMap(TemplateSchema(), "") assert placeholders.encode(secret) == secret def test_template_replaces_repeated_name(): """Writing one more than once fills the same value in each time.""" placeholders = TemplatePlaceholderMap(schema_of("target"), "") encoded = placeholders.encode("${TARGET}/a/${TARGET}?x=${TARGET}") result = placeholders.substitute(encoded, {"target": "VALUE"}) assert result == "VALUE/a/VALUE?x=VALUE" def test_template_single_pass_substitution(): """A value that looks like a placeholder is left as data.""" placeholders = TemplatePlaceholderMap(schema_of("a", "b"), "") encoded = placeholders.encode("${A}-${B}") # Feed one variable the text of the other's placeholder other = placeholders.encode("${B}") result = placeholders.substitute(encoded, {"a": other, "b": "second"}) assert result == f"{other}-second" def test_template_display_restores_names(): """A placeholder reads as ${NAME} again for display.""" placeholders = TemplatePlaceholderMap(schema_of("target"), "") encoded = placeholders.encode("json://host/${TARGET}/${TARGET}") assert placeholders.display(encoded) == "json://host/${TARGET}/${TARGET}" def test_template_used_names(): """Only declared names count.""" placeholders = TemplatePlaceholderMap(schema_of("a", "b"), "") encoded = placeholders.encode("${A} ${UNDECLARED}") assert placeholders.used(encoded) == {"a"} def test_template_preserves_setting_names(): """A variable fills in a value; it never names a setting.""" placeholders = TemplatePlaceholderMap(schema_of("target"), "") encoded = placeholders.encode_obj({"${TARGET}": "${TARGET}"}) key, value = next(iter(encoded.items())) assert key == "${TARGET}" assert value != "${TARGET}" def test_template_fills_mapping_name(): """A placeholder sitting in a name is filled in like any other text.""" placeholders = TemplatePlaceholderMap(schema_of("target"), "") # A parsed URL keeps its query names under "qsd", not at the top entry = {"qsd": {placeholders.encode("${TARGET}"): 1}} assert placeholders.used(entry) == {"target"} assert placeholders.substitute(entry, {"target": "real"}) == { "qsd": {"real": 1} } def test_template_rejects_loop(): """A branch pointing back at itself is not followed.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") loop = {} loop["self"] = loop with pytest.raises(AppriseTemplateError): placeholders.encode_obj(loop) def test_template_avoids_placeholder_collision(): """A placeholder cannot collide with the configuration.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") assert f"a{placeholders.nonce}" not in "some configuration body" def test_template_call_value_precedence(): """The caller beats the environment and the default.""" schema = schema_of({"target": "from-config"}) env = {"APPRISE_TEMPLATE_TARGET": "from-env"} values = resolve_values(schema, {"target": "from-call"}, env) assert values == {"target": "from-call"} def test_template_config_default_beats_the_environment(): """A default in the configuration wins over the server's own value. The author picked that default for this configuration in particular, so it is used ahead of anything the deployment set. """ schema = schema_of({"target": "from-config"}) env = {"APPRISE_TEMPLATE_TARGET": "from-env"} assert resolve_values(schema, None, env) == {"target": "from-config"} def test_template_environment_fills_a_name_with_no_default(): """The environment is what a required name falls back to.""" schema = schema_of("target") env = {"APPRISE_TEMPLATE_TARGET": "from-env"} assert resolve_values(schema, None, env) == {"target": "from-env"} def test_template_blank_value_reads_as_nothing_supplied(): """Whitespace is not a value, so the next source is used.""" schema = schema_of({"target": "from-config"}) for blank in ("", " ", "\t"): assert resolve_values(schema, {"target": blank}) == { "target": "from-config" } def test_template_value_is_trimmed(): """Surrounding whitespace never reaches the URL.""" schema = schema_of("target") assert resolve_values(schema, {"target": " spaced "}) == { "target": "spaced" } def test_template_blank_environment_value_is_ignored(): """A blank environment value leaves the name unresolved.""" schema = schema_of("target") with pytest.raises(AppriseTemplateError): resolve_values(schema, None, {"APPRISE_TEMPLATE_TARGET": " "}) def test_template_default_fallback(): """Nothing supplied anywhere falls back to the config.""" schema = schema_of({"target": "from-config"}) assert resolve_values(schema, None, {}) == {"target": "from-config"} def test_template_required_value(): """A mandatory variable with no value is an error.""" with pytest.raises(AppriseTemplateError) as exc: resolve_values(schema_of("target"), None, {}) assert exc.value.variable == "target" def test_template_supplied_name_case_insensitive(): """Callers may use any casing they like.""" schema = schema_of("target") assert resolve_values(schema, {"TaRgEt": "x"}, {}) == {"target": "x"} def test_template_converts_number_to_text(): """Convert a numeric input to text.""" schema = schema_of("target") assert resolve_values(schema, {"target": 42}, {}) == {"target": "42"} def test_template_ignores_unrelated_name(): """Each configuration ignores names that it does not use.""" schema = schema_of({"target": "a-default"}) values = resolve_values(schema, {"target": "x", "elsewhere": "y"}, {}) assert values == {"target": "x"} def test_template_checks_the_name_even_when_unused(): """Garbage is still refused, whichever configuration it was for.""" with pytest.raises(AppriseTemplateError): resolve_values(schema_of({"target": "a"}), {"not a name": "x"}, {}) def test_template_resolves_requested_names_only(): """An entry does not pay for variables it never uses.""" schema = schema_of("needed", {"other": "default"}) values = resolve_values(schema, {"needed": "x"}, {}, names={"needed"}) assert values == {"needed": "x"} @pytest.mark.parametrize( "value", ["line\nbreak", "carriage\rreturn", "null\x00byte", "bell\x07"], ) def test_template_rejects_control_character(value): """These could break out of a log line or a header.""" with pytest.raises(AppriseTemplateError): validate_value("target", value) def test_template_value_length_limit(): """Cap values so a caller cannot flood the parser.""" with pytest.raises(AppriseTemplateError): validate_value("target", "x" * (MAX_TEMPLATE_VALUE_LEN + 1)) @pytest.mark.parametrize("value", [["a"], {"a": 1}, object()]) def test_template_rejects_complex_value(value): """Only simple values may be supplied.""" with pytest.raises(AppriseTemplateError): validate_value("target", value) @pytest.mark.parametrize( ("value", "expected"), [(42, "42"), (1.5, "1.5"), (True, "yes"), (False, "no")], ) def test_template_converts_scalar_value(value, expected): """Numbers and yes/no values are converted rather than refused.""" assert validate_value("target", value) == expected @pytest.mark.parametrize( "payload", [ # Short ids keep the node name readable. Without them the payload # itself becomes the test id, and a single reported failure prints # hundreds of kilobytes, which truncates the CI log it belongs to. pytest.param("${" * 50000, id="open-markers"), pytest.param("$" * 200000, id="bare-dollars"), pytest.param("${A" * 20000, id="partial-names"), pytest.param("${" * 10000 + "}" * 10000, id="unbalanced"), pytest.param("${A}" * 100000, id="many-real-markers"), pytest.param("${" + "A" * 100000 + "}", id="over-long-name"), pytest.param("${" * 1000 + "A" + "}" * 1000, id="wrapped-braces"), pytest.param("json://h/?" + "x=${A}&" * 50000, id="long-url-tail"), pytest.param( ("a" + "0" * 16 + "t" + "9" * 5000 + "z") * 20, id="placeholder-lookalikes", ), ], ) def test_template_regex_performance(payload): """Bound every scan so input cannot make one hang. Each pattern uses bounded quantifiers with nothing that can backtrack, so an unhelpful configuration costs time in proportion to its size and no more. """ placeholders = TemplatePlaceholderMap(schema_of("a"), "") for scan in ( placeholders.encode, placeholders.encode_url, placeholders.pattern.findall, ): start = time.monotonic() scan(payload) assert time.monotonic() - start < 2 def test_template_rejects_invalid_declaration(): """A list entry has to be a name or a name with a default.""" with pytest.raises(AppriseTemplateError): TemplateSchema.parse([["not", "valid"]]) def test_template_variable_repr(): """Useful when looking at one in a debugger.""" variable = schema_of("target").variables["target"] assert "target" in repr(variable) assert variable.required is True def test_template_placeholder_collision_retry(): """A prefix that clashes with the configuration is re-rolled.""" taken = "0" * 16 free = "1" * 16 with mock.patch( "apprise.utils.template.secrets.token_hex", side_effect=[taken, free], ): placeholders = TemplatePlaceholderMap( schema_of("a"), f"a{taken} appears here" ) assert placeholders.nonce == free def test_template_placeholder_retry_limit(): """We never fall back to a prefix we know is unsafe.""" taken = "0" * 16 with ( mock.patch( "apprise.utils.template.secrets.token_hex", return_value=taken ), pytest.raises(AppriseTemplateError), ): TemplatePlaceholderMap(schema_of("a"), f"a{taken}") def test_template_shared_branch(): """A YAML anchor used twice gives the same result both times.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") shared = {"to": "${A}"} encoded = placeholders.encode_obj({"first": shared, "second": shared}) assert encoded["first"] == encoded["second"] assert encoded["first"] is encoded["second"] def test_template_nested_setting_name(): """A placeholder in a name is found however deeply it sits.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") nested = [{placeholders.encode("${A}"): 1}] assert placeholders.used(nested) == {"a"} assert placeholders.used([{"fine": 1}]) == set() @pytest.mark.parametrize("value", [42, None, True]) def test_template_preserves_non_text_config(value): """Numbers and flags are left exactly as they are.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") assert placeholders.encode(value) == value assert placeholders.display(value) == value assert placeholders.substitute(value, {}) == value def test_template_rejects_non_text_name(): """Names have to be text before they can be looked up.""" with pytest.raises(AppriseTemplateError): resolve_values(schema_of("a"), {42: "value"}, {}) def test_template_shared_setting_branch(): """The same mapping seen twice is not walked twice.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") shared = {"fine": 1} assert placeholders.used([shared, shared]) == set() def test_template_setting_loop(): """A loop is refused rather than followed forever.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") loop = {"fine": 1} loop["self"] = [loop] with pytest.raises(AppriseTemplateError): placeholders.used(loop) def test_template_missing_host(): """Not every URL has a host to check.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") parsed = {"host": None, "password": placeholders.encode("${A}")} result = placeholders.substitute(parsed, {"a": "value"}) assert result["password"] == "value" def test_template_preserves_address_separators(): """URL fields keep the punctuation deliberately supplied to them.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") parsed = {"fullpath": placeholders.encode("/${A}/x")} result = placeholders.substitute(parsed, {"a": "seg/ment?x=1&y=2#z"}) assert result["fullpath"] == "/seg/ment?x=1&y=2#z/x" def test_template_defers_service_validation(): """Whatever else a value holds is passed straight through.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") parsed = {"host": placeholders.encode("${A}")} for value in ("evil.com", "not a host at all", "??", "a@b"): # None of these raise; the service is left to make sense of it assert placeholders.substitute(parsed, {"a": value})["host"] def test_template_keeps_credentials_unchanged(): """Parsed credential fields safely accept ordinary delimiters.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") parsed = {"password": placeholders.encode("${A}")} result = placeholders.substitute(parsed, {"a": "user:pass@host/a&b"}) assert result["password"] == "user:pass@host/a&b" def test_template_preserves_an_embedded_host_value(): """A partial hostname keeps all author-supplied punctuation.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") parsed = {"host": placeholders.encode("trusted-${A}")} result = placeholders.substitute( parsed, {"a": "name@evil.test:80/path?x=1#part"} ) assert result["host"] == "trusted-name@evil.test:80/path?x=1#part" def test_template_keeps_host_beside_fixed_credentials(): """A host value does not replace separately parsed credentials.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") parsed = { "user": "fixed", "password": None, "host": placeholders.encode("${A}"), } result = placeholders.substitute(parsed, {"a": "name@evil.test:80"}) assert result["user"] == "fixed" assert result["host"] == "name@evil.test:80" def test_template_allows_a_complete_email_host(): """A whole authority field may intentionally carry an email value.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") parsed = { "user": None, "password": None, "host": placeholders.encode("${A}"), } result = placeholders.substitute(parsed, {"a": "user@example.com"}) assert result["user"] == "user" assert result["host"] == "example.com" def test_template_allows_full_authority_credentials(): """A whole authority may carry user:pass@host.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") parsed = { "user": None, "password": None, "host": placeholders.encode("${A}"), } result = placeholders.substitute(parsed, {"a": "user:pass@example.com"}) assert result["user"] == "user" assert result["password"] == "pass" assert result["host"] == "example.com" @pytest.mark.parametrize("authority", ("@example.com", "user@")) def test_template_contains_a_malformed_authority(authority): """An incomplete authority stays in the field as supplied.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") parsed = { "user": None, "password": None, "host": placeholders.encode("${A}"), } result = placeholders.substitute(parsed, {"a": authority}) assert result["user"] is None assert result["host"] == authority def test_template_allows_combined_credentials(): """A whole user field may carry user:pass credentials.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") parsed = { "user": placeholders.encode("${A}"), "password": None, "host": "example.com", } result = placeholders.substitute(parsed, {"a": "user:pass"}) assert result["user"] == "user" assert result["password"] == "pass" def test_template_leaves_a_setting_value_alone(): """A setting written under the URL already says what it is for.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") parsed = {"bcc": placeholders.encode("${A}")} result = placeholders.substitute(parsed, {"a": "b64/secret@host&x"}) assert result["bcc"] == "b64/secret@host&x" def test_template_ignores_non_text_setting_keys(): """YAML allows a number as a key; there is nothing to look at.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") entry = {1: "x", 2.5: "y"} assert placeholders.used(entry) == set() assert placeholders.substitute(entry, {}) == {1: "x", 2.5: "y"} def test_template_fills_name_beside_text(): """A name mixing a marker with fixed text keeps the fixed part.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") entry = {"qsd": {"x-" + placeholders.encode("${A}"): 1}} assert placeholders.substitute(entry, {"a": "real"}) == { "qsd": {"x-real": 1} } def test_template_preserves_yaml_names(): """A marker written as a YAML setting name stays plain text.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") encoded = placeholders.encode_obj({"${A}": "${A}"}) # The name is untouched while the value became a placeholder assert list(encoded) == ["${A}"] assert placeholders.used(encoded) == {"a"} @pytest.mark.parametrize( ("url", "expected_names"), [ # Everything after :// is fair game ("json://${A}/path?x=${A}", {"a"}), # ...and nothing before it is ("${A}://host/", set()), # A URL with no separator at all has nothing to read ("${A}user:pass@host/", set()), ], ) def test_template_encode_url_preserves_schema(url, expected_names): """Only the text past :// is ever replaced.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") assert placeholders.used(placeholders.encode_url(url)) == expected_names def test_template_encode_url_passes_non_text_through(): """There is no URL to read inside a number.""" placeholders = TemplatePlaceholderMap(schema_of("a"), "") assert placeholders.encode_url(42) == 42 caronc-apprise-dfec414/tests/test_url_response_lookup.py000066400000000000000000000043271526720371000240110ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from apprise.url import HTML_LOOKUP, URLBase def test_response_code_lookup_mask(): """A plugin's response mask never changes the shared lookup table.""" # Remember what the shared table says before any mask is used original = HTML_LOOKUP.get(401) # The mask wins for the code it defines mask = {401: "Plugin specific text.", 999: "Custom code."} assert URLBase.http_response_code_lookup(401, mask) == ( "Plugin specific text." ) assert URLBase.http_response_code_lookup(999, mask) == "Custom code." # Codes the mask does not define still use the shared table assert URLBase.http_response_code_lookup(404, mask) == HTML_LOOKUP[404] # Later lookups without the mask are unaffected assert URLBase.http_response_code_lookup(401) == original assert URLBase.http_response_code_lookup(999) == "" caronc-apprise-dfec414/tests/test_utils_format.py000066400000000000000000000562511526720371000224130ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from timeit import default_timer from apprise import NotifyFormat from apprise.utils.format import ( html_adjust, html_tag_adjust, markdown_adjust, smart_split, ) def test_smart_split_prefers_newlines_over_spaces_and_punctuation(): """ Newlines should win even if there are spaces and punctuation before the limit. """ text = "line1\nline2 line3. line4" # Long enough to include the newline and some of the next line limit = 12 chunks = smart_split(text, limit, body_format=NotifyFormat.TEXT) # First chunk should end immediately after the newline assert chunks[0] == "line1\n" # Nothing lost assert "".join(chunks) == text def test_smart_split_leading_whitespace(): """A boundary in the whitespace that opens a window is skipped.""" # The first window ends between the CR and LF of a line break, so the # second window opens with the LF text = "a" * 9 + "\r\n" + "b" * 15 chunks = smart_split(text, 10, body_format=NotifyFormat.TEXT) assert chunks == ["a" * 9 + "\r", "\n" + "b" * 9, "b" * 6] assert "".join(chunks) == text # A word that fills the window exactly, followed by a space text = "a" * 10 + " " + "b" * 15 chunks = smart_split(text, 10, body_format=NotifyFormat.TEXT) assert chunks == ["a" * 10, " " + "b" * 9, "b" * 6] assert "".join(chunks) == text def test_smart_split_prefers_spaces_over_hard_split(): """ When there are no newlines, split on the last space/tab before falling back to a hard character-limit split. """ text = "word1 word2 word3" # Force a split between word2 and word3 limit = 12 # "word1 word2 " is 12 characters chunks = smart_split(text, limit, body_format=NotifyFormat.TEXT) assert chunks == ["word1 word2 ", "word3"] assert "".join(chunks) == text def test_smart_split_can_split_after_punctuation_plus_whitespace(): """ Exercise the punctuation+whitespace pattern. In practice this collapses to the same split point as the last space, but we verify the behaviour. """ text = "Hello world. Again" # Force the split around ". " # "Hello world. " is 13 characters limit = 13 chunks = smart_split(text, limit, body_format=NotifyFormat.TEXT) # First chunk should end at the space after the period assert chunks[0] == "Hello world. " assert chunks[1] == "Again" assert "".join(chunks) == text def test_smart_split_avoids_splitting_inside_html_entity() -> None: """ In HTML mode we must not end a chunk in the middle of '&...;'. We do NOT assert exact chunk values. Instead we assert: - TEXT mode can split inside the entity. - HTML mode never has a chunk that contains '&' without a matching ';' after it in the same chunk. """ text = "1234 5678" limit = 8 # without adjustment, we would cut inside ' ' # Plain text mode: allowed to split anywhere chunks_text = smart_split(text, limit, body_format=NotifyFormat.TEXT) assert "".join(chunks_text) == text # Sanity: in TEXT mode we *do* split inside the entity assert any( "&" in chunk and ";" not in chunk[chunk.find("&") :] for chunk in chunks_text ) # HTML mode: entity-aware chunks_html = smart_split(text, limit, body_format=NotifyFormat.HTML) assert "".join(chunks_html) == text # If a chunk contains '&', it must also contain the terminating ';' # for that entity within the same chunk. for chunk in chunks_html: idx = chunk.find("&") if idx == -1: continue semi = chunk.find(";", idx + 1) assert semi != -1, f"Chunk ends inside HTML entity: {chunk!r}" def test_smart_split_avoids_splitting_inside_markdown_link() -> None: """ In MARKDOWN mode, do not split inside [text](url). We only require that the full [link](...) lies in a single chunk and that any '[' appearing in a chunk has a matching ')' in that same chunk. """ link = "[link](https://example.com)" text = "AAAA" + link limit = len(link) chunks = smart_split(text, limit, body_format=NotifyFormat.MARKDOWN) assert "".join(chunks) == text # Entire link must be contained in one chunk assert any(link in chunk for chunk in chunks) # If a chunk has '[', it must also contain its closing ')' for chunk in chunks: idx = chunk.find("[") if idx == -1: continue semi = chunk.find(")", idx + 1) assert semi != -1, f"Markdown link was split inside chunk: {chunk!r}" def test_smart_split_avoids_splitting_inside_markdown_image() -> None: """ In MARKDOWN mode, do not split inside the [alt](url) of an image. The implementation currently splits "AAAA![alt](...)" as: - "AAAA!" - "[alt](...)" which is acceptable, as the [alt](url) part is kept intact. """ image = "![alt](https://example.com/image.png)" text = "AAAA" + image limit = len(image) chunks = smart_split(text, limit, body_format=NotifyFormat.MARKDOWN) assert "".join(chunks) == text inner = "[alt](https://example.com/image.png)" # The [alt](...) portion must appear fully within a single chunk assert any(inner in chunk for chunk in chunks) # As with links, any '[' in a chunk must have its matching ')' within # the same chunk so we never split inside the [alt](url) part. for chunk in chunks: idx = chunk.find("[") if idx == -1: continue semi = chunk.find(")", idx + 1) assert semi != -1, f"Markdown image was split inside chunk: {chunk!r}" def test_smart_split_empty_and_none_input() -> None: """ Empty / None input should be returned as a single-element list unchanged. """ assert smart_split("", 10, body_format=NotifyFormat.TEXT) == [""] assert smart_split("", 0, body_format=NotifyFormat.TEXT) == [""] assert smart_split("content", 0, body_format=NotifyFormat.TEXT) == [""] # None short-circuits before len() is called assert smart_split(None, 10, body_format=NotifyFormat.TEXT) == [""] def test_smart_split_html_entity_exact_boundary() -> None: """ Splitting exactly at an HTML entity boundary should not shift the split point (no need to "fix up" a perfectly aligned boundary). """ text = "AAAA BBBB" limit = len("AAAA ") # split exactly after the entity chunks = smart_split(text, limit, body_format=NotifyFormat.HTML) # We expect the entity to remain whole in the first chunk assert chunks == ["AAAA ", "BBBB"] assert "".join(chunks) == text def test_smart_split_markdown_link_exact_boundary() -> None: """ Splitting exactly after a Markdown link should not cause any adjustment. """ link = "[link](https://example.com)" tail = " TAIL" text = link + tail limit = len(link) # split immediately after ')' chunks = smart_split(text, limit, body_format=NotifyFormat.MARKDOWN) # First chunk is exactly the link, second is the remainder assert chunks[0] == link assert "".join(chunks) == text # Sanity: the link itself is not split across chunks assert any(link in chunk for chunk in chunks) def test_smart_split_whitespace_priority_with_tabs_and_newlines() -> None: """ Exercise newline vs space/tab priority with a mix of whitespace. """ text = "word1\tword2\nword3" # Case 1: window ends just before the newline, so only tab is visible. limit_without_newline = text.index("\n") # position of '\n' chunks_no_nl = smart_split( text, limit_without_newline, body_format=NotifyFormat.TEXT ) # First chunk should end after the tab, since that is the last space/tab assert chunks_no_nl[0] == "word1\t" assert "".join(chunks_no_nl) == text # Case 2: window includes the newline; newline should win over tab. limit_with_newline = text.index("\n") + 1 chunks_with_nl = smart_split( text, limit_with_newline, body_format=NotifyFormat.TEXT ) # First chunk should now end after the newline assert chunks_with_nl[0] == "word1\tword2\n" assert "".join(chunks_with_nl) == text def test_smart_split_very_short_limit() -> None: """ Very small limits should still split deterministically without loss. """ text = "ABC" chunks = smart_split(text, 1, body_format=NotifyFormat.TEXT) # One character per chunk assert chunks == ["A", "B", "C"] assert "".join(chunks) == text def test_smart_split_very_long_limit() -> None: """ Very large limits (>= len(text)) should return a single chunk. """ text = "A short message for testing" chunks = smart_split(text, 10_000, body_format=NotifyFormat.TEXT) assert chunks == [text] assert "".join(chunks) == text def test_html_adjust_guard_paths_and_no_entity() -> None: """ Cover the early-return guard in html_adjust and the path where there is no '&' at all in the search window. """ text = "abcdef" # split_at <= window_start -> early-return unchanged assert html_adjust(text, window_start=2, split_at=2) == 2 # split_at beyond the end of the text -> early-return unchanged assert ( html_adjust(text, window_start=0, split_at=len(text) + 5) == len(text) + 5 ) # No '&' in window, nothing to adjust assert html_adjust(text, window_start=0, split_at=3) == 3 def test_html_adjust_inside_and_at_boundary_of_entity() -> None: """ Exercise the path where html_adjust moves the split back to '&' when the split falls inside an entity, and the path where the split is exactly at the entity boundary and should not move. """ text = "1234 5678" # indexes: 0..3 '1234', 4 '&', 5 'n', 6 'b', 7 's', 8 'p', 9 ';', 10 '5'... # Split inside ' ' (at index 8) -> move back to '&' (index 4) assert html_adjust(text, window_start=0, split_at=8) == 4 # Split exactly after the ';' (index 10) -> already outside entity assert html_adjust(text, window_start=0, split_at=10) == 10 def test_markdown_adjust_guard_and_no_construct() -> None: """ Cover the guard in markdown_adjust and the case where there is no '[' or '!' in the window. """ text = "plain text" # split_at <= window_start -> early-return unchanged assert markdown_adjust(text, window_start=4, split_at=4) == 4 # split_at past the end -> early-return unchanged assert ( markdown_adjust(text, window_start=0, split_at=len(text) + 3) == len(text) + 3 ) # No markdown constructs -> nothing to adjust assert markdown_adjust(text, window_start=0, split_at=5) == 5 def test_markdown_adjust_inside_construct_moves_to_start() -> None: """ Exercise the positive path in markdown_adjust where the split lands inside a [text](url) construct and the function moves the split back to the start of the construct. The forward scan is capped at split_at + (split_at - window_start) + 1 to prevent O(n^2) work on adversarial input (see test_markdown_adjust_dos_cap). This means split_at must be at least half the link length so the cap reaches the closing ')'. Realistic splits from smart_split() always satisfy this because the following applies for any link that fits in a single chunk: split_at - window_start >= limit >= link_length // 2 """ link = "[link](https://example.com)" # Choose a split point in the latter half of the link so the symmetric # forward-scan cap (split_at + window_size) reaches the closing ')'. split_at = len(link) // 2 # index 13, inside "ps://example.com" adjusted = markdown_adjust(link, window_start=0, split_at=split_at) # Should move back to the '[' at index 0 assert adjusted == 0 def test_markdown_adjust_inside_angle_bracket_construct() -> None: """Exercise the angle-bracket path in markdown_adjust. When a split point lands ANYWHERE inside a '' construct (as used by Slack mrkdwn and Google Chat), the function must move it back to the opening '<'. This covers three sub-cases: (a) Split in the label portion (after the pipe). (b) Split exactly on the pipe character. (c) Split in the URL portion (before the pipe) -- previously the pipe search stopped at split_at so no pipe was found; the fix extends the search to forward_end so this case is now also protected. """ # A Slack/Google Chat anchor -- '<' at 0, '|' at 21, '>' at 31. text = "" pipe_pos = text.index("|") # == 20 # (a) Split a few characters after the pipe, inside the label portion. split_at = pipe_pos + 3 adjusted = markdown_adjust(text, window_start=0, split_at=split_at) # Should move back to the '<' at index 0. assert adjusted == 0 # Split right at the closing '>' -- still inside the construct. split_at2 = text.index(">") adjusted2 = markdown_adjust(text, window_start=0, split_at=split_at2) # Also moves back to '<'. assert adjusted2 == 0 # (b) Split exactly ON the '|' separator. split_on_pipe = pipe_pos assert markdown_adjust(text, 0, split_on_pipe) == 0 # (c) Split inside the URL portion (before '|'). # The pipe search now extends to forward_end, not just to split_at. # forward_end = min(32, 17 + 17 + 1) = 32, which covers '>' at 31. # Both pipe (20) and '>' (31) are within range; the construct is detected # and the split moves to '<' at 0. split_in_url = pipe_pos - 3 # index 17, inside "example.com" assert markdown_adjust(text, 0, split_in_url) == 0 # Same scenario with a preamble before '<' so the move is actually # useful -- '<' is no longer at window_start so smart_split would # keep the adjusted position (not reset it). # "<" at 4, "|" at 24, ">" at 35 in the prefixed string. prefixed = "pre " + text split_in_url2 = 20 # index 20, inside "example.com", before '|' at 24 # forward_end = min(36, 20+20+1) = 36 > 35 ('>'). assert markdown_adjust(prefixed, 0, split_in_url2) == 4 # '<' position # When split_at is so small that forward_end doesn't reach the pipe, # the construct is undetectable and the split falls through unchanged. # forward_end = min(32, 5 + 5 + 1) = 11 < pipe_pos(20), so no pipe found. # Even if we detected the '<' at 0, moving the split there would equal # window_start so smart_split() would reset it anyway -- no net change. split_too_small = 5 assert markdown_adjust(text, 0, split_too_small) == split_too_small # '<' and '|' are both found, but split_at is past the closing '>' # so the condition angle_start_idx < split_at <= angle_end_idx is # False -- the function returns the split point unchanged. split_past_end = len(text) assert markdown_adjust(text, 0, split_past_end) == split_past_end def test_markdown_adjust_dos_cap() -> None: """markdown_adjust forward scan is capped to O(window_size). An adversarial body of repeated unterminated '[' openers must not cause O(n^2) work. We verify the cap by checking that a large body produces the same split position as a small one (the cap does not change the output for well-formed constructs within the window). """ from timeit import default_timer # 1 MB of repeated '[broken ' with no closing ')'. body = "[broken " * 128_000 limit = 80 start = default_timer() chunks = smart_split(body, limit, body_format=NotifyFormat.MARKDOWN) elapsed = default_timer() - start # Must complete well under 1 second (the DoS took ~14 s before the cap). assert elapsed < 1.0 # All chunks must be non-empty and re-join to the original. assert all(chunks) assert "".join(chunks) == body def test_smart_split_markdown_guard_split_at_start_is_reset() -> None: """Cover smart_split guard 'if split_at <= start: split_at = orig_split'. We force markdown_adjust to move the split back to the window start (link_start_idx == start == 0), then verify smart_split resets to the original split so progress is still made and chunks join back to the original text. The link "[link](url)" is 11 chars. With limit=7 the first hard split lands at index 7 (inside the URL). The forward-scan cap is 7 + (7 - 0) + 1 = 15, which is > 10 (position of ")"), so markdown_adjust finds the closer and moves split to link_start_idx=0. Since 0 == start, smart_split resets to orig_split=7 and emits the next chunk from there. """ text = "[link](url)" # 12 chars; ")" at index 11 limit = 7 # hard split at 7 (inside url); cap = 15 > 11 so ")" is found chunks = smart_split(text, limit, body_format=NotifyFormat.MARKDOWN) # We should never get stuck; all chunks must be non-empty. assert len(chunks) >= 2 assert all(chunks) # Re-joining all chunks must restore the original text. assert "".join(chunks) == text def test_markdown_adjust_image_bang_opener_path() -> None: """Cover the '!' opener path in markdown_adjust.""" # Bang found, but no '[' after it, so the fallback path is taken. text = "AAAAAA![b](c)" assert markdown_adjust(text, window_start=0, split_at=7) == 6 # Stray '!' not followed by '[' -- bang found but condition is False; # no link construct detected, split point returned unchanged. text2 = "hello!world" assert markdown_adjust(text2, window_start=0, split_at=6) == 6 def test_smart_split_uses_punctuation_branch_on_rare_whitespace() -> None: """ When punctuation is followed by rare whitespace (vertical tab / form feed) and there are no spaces/tabs/newlines, we should use the punctuation + whitespace split branch. """ vt = "\x0b" # vertical tab text = f"Hello.{vt}World" # Window includes 'Hello.' and the VT limit = len("Hello.") + 1 chunks = smart_split(text, limit, body_format=NotifyFormat.TEXT) assert "".join(chunks) == text # We expect the first chunk to end after the rare whitespace assert chunks[0] == f"Hello.{vt}" assert chunks[1] == "World" def test_smart_split_keeps_markdown_escape() -> None: """A hard split never strands an escaping backslash.""" # The hard split would land between "\" and ">"; it moves back one. text = "x" * 19 + "\\>quoted" chunks = smart_split(text, 20, body_format=NotifyFormat.MARKDOWN) assert chunks == ["x" * 19, "\\>quoted"] # An escaped backslash ("\\") is a complete pair, so it may end a chunk. text = "x" * 18 + "\\\\" + "y" * 5 chunks = smart_split(text, 20, body_format=NotifyFormat.MARKDOWN) assert chunks == ["x" * 18 + "\\\\", "y" * 5] # Three backslashes leave the last one unpaired, so it moves on. text = "x" * 17 + "\\\\\\" + ">yy" chunks = smart_split(text, 20, body_format=NotifyFormat.MARKDOWN) assert chunks == ["x" * 17 + "\\\\", "\\>yy"] # When the pair cannot fit at all, splitting still makes progress. chunks = smart_split("\\>\\>", 1, body_format=NotifyFormat.MARKDOWN) assert "".join(chunks) == "\\>\\>" # Plain text has no escapes, so the hard split is left alone. text = "x" * 19 + "\\>quoted" chunks = smart_split(text, 20, body_format=NotifyFormat.TEXT) assert chunks[0] == "x" * 19 + "\\" def test_smart_split_never_cuts_inside_html_tag() -> None: """A split landing inside an HTML tag moves before its "<".""" text = "x" * 10 + ' link' chunks = smart_split(text, 25, body_format=NotifyFormat.HTML) assert "".join(chunks) == text assert chunks[0] == "x" * 10 + " " assert chunks[1].startswith('') # A closed tag before the split is left alone. assert html_tag_adjust("x yy", 0, 9) == 9 # A tag opening the chunk ends the chunk right after its ">". assert html_tag_adjust('x', 0, 10, 16) == 15 # When that tag does not fit, the split is left where it was. assert html_tag_adjust('x', 0, 10, 12) == 10 assert html_tag_adjust(' None: """A ">" inside a quoted attribute value does not end the tag.""" for tag in ('', ""): text = "hello " + tag + "link world" start = text.index(tag) end = start + len(tag) for limit in range(len(tag), len(text) + 1): chunks = smart_split(text, limit, body_format=NotifyFormat.HTML) assert "".join(chunks) == text # No piece may end inside the tag pos = 0 for chunk in chunks: pos += len(chunk) assert not start < pos < end # A split inside the tag moves before it, even past a quoted ">" assert html_tag_adjust('ab x', 0, 16) == 3 # A tag opening the chunk ends after its real ">" assert html_tag_adjust('x', 0, 12) == 17 # An unclosed quote or tag never ends assert html_tag_adjust(' None: """Unbalanced attribute quotes keep HTML splitting linear.""" for body in (' # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import os from unittest import mock import pytest from apprise import AppriseAsset, PersistentStoreMode, utils from apprise.utils.pem import PEM_SUPPORT # Disable logging for a cleaner testing output logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") @pytest.mark.skipif(not PEM_SUPPORT, reason="Requires cryptography") def test_utils_pem_general(tmpdir): """Utils:PEM.""" # string to manipulate/work with unencrypted_str = "message" tmpdir0 = tmpdir.mkdir("tmp00") # Currently no files here assert os.listdir(str(tmpdir0)) == [] asset = AppriseAsset( storage_mode=PersistentStoreMode.MEMORY, storage_path=str(tmpdir0), pem_autogen=False, ) # Create a PEM Controller pem_c = utils.pem.ApprisePEMController(path=None, asset=asset) # Nothing to lookup assert pem_c.public_keyfile() is None assert pem_c.public_key() is None assert pem_c.x962_str == "" assert pem_c.decrypt(b"data") is None assert pem_c.encrypt(unencrypted_str) is None # Keys can not be generated in memory mode assert pem_c.keygen() is False assert pem_c.sign(b"data") is None asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), pem_autogen=False, ) # No new files assert os.listdir(str(tmpdir0)) == [] # Our asset is now write mode, so we will be able to generate a key pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) # Nothing to lookup assert pem_c.public_keyfile() is None assert pem_c.public_key() is None assert pem_c.x962_str == "" assert pem_c.encrypt(unencrypted_str) is None # Generate our keys assert bool(pem_c) is False assert pem_c.keygen() is True assert bool(pem_c) is True # We have 2 new key files generated pub_keyfile = os.path.join(str(tmpdir0), "public_key.pem") prv_keyfile = os.path.join(str(tmpdir0), "private_key.pem") assert os.path.isfile(pub_keyfile) assert os.path.isfile(prv_keyfile) assert pem_c.public_keyfile() is not None assert pem_c.decrypt("garbage") is None assert pem_c.public_key() is not None # Keys used later on as ref pubkey_ref = pem_c.public_key() prvkey_ref = pem_c.private_key() assert isinstance(pem_c.x962_str, str) assert len(pem_c.x962_str) > 20 content = pem_c.encrypt(unencrypted_str) assert pem_c.decrypt( pem_c.encrypt(unencrypted_str.encode("utf-8")) ) == pem_c.decrypt(pem_c.encrypt(unencrypted_str)) assert pem_c.decrypt( pem_c.encrypt(unencrypted_str, public_key=pem_c.public_key()) ) == pem_c.decrypt(pem_c.encrypt(unencrypted_str)) assert pem_c.decrypt(content) == unencrypted_str assert isinstance(content, str) assert pem_c.decrypt(content) == unencrypted_str # support str as well assert pem_c.decrypt(content) == unencrypted_str assert pem_c.decrypt(content.encode("utf-8")) == unencrypted_str # Sign test assert isinstance(pem_c.sign(content.encode("utf-8")), bytes) # Web Push handling webpush_content = pem_c.encrypt_webpush( unencrypted_str, public_key=pem_c.public_key(), auth_secret=b"secret" ) assert isinstance(webpush_content, bytes) webpush_content = pem_c.encrypt_webpush( unencrypted_str.encode("utf-8"), public_key=pem_c.public_key(), auth_secret=b"secret", ) assert isinstance(webpush_content, bytes) # Non Bytes (garbage basically) with pytest.raises(TypeError): assert pem_c.decrypt(None) is None with pytest.raises(TypeError): assert pem_c.decrypt(5) is None with pytest.raises(TypeError): assert pem_c.decrypt(False) is None with pytest.raises(TypeError): assert pem_c.decrypt(object) is None # Test our initialization pem_c = utils.pem.ApprisePEMController( path=None, prv_keyfile="invalid", asset=asset ) assert pem_c.private_keyfile() is False assert pem_c.public_keyfile() is None assert pem_c.prv_keyfile is False assert pem_c.pub_keyfile is None assert pem_c.private_key() is None assert pem_c.public_key() is None assert pem_c.decrypt(content) is None pem_c = utils.pem.ApprisePEMController( path=None, pub_keyfile="invalid", asset=asset ) assert pem_c.private_keyfile() is None assert pem_c.public_keyfile() is False assert pem_c.prv_keyfile is None assert pem_c.pub_keyfile is False assert pem_c.private_key() is None assert pem_c.public_key() is None assert pem_c.decrypt(content) is None pem_c = utils.pem.ApprisePEMController( path=None, prv_keyfile=prv_keyfile, asset=asset ) assert pem_c.private_keyfile() == prv_keyfile assert pem_c.public_keyfile() is None assert pem_c.private_key() is not None assert pem_c.prv_keyfile == prv_keyfile assert pem_c.pub_keyfile is None assert pem_c.public_key() is not None assert pem_c.decrypt(content) == unencrypted_str pem_c = utils.pem.ApprisePEMController( path=None, pub_keyfile=pub_keyfile, asset=asset ) assert pem_c.private_keyfile() is None assert pem_c.public_keyfile() == pub_keyfile assert pem_c.prv_keyfile is None assert pem_c.pub_keyfile == pub_keyfile assert pem_c.private_key() is None assert pem_c.public_key() is not None assert pem_c.decrypt(content) is None # Test our path references pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) assert pem_c.load_private_key(path=None) is True assert pem_c.private_keyfile() == prv_keyfile assert pem_c.prv_keyfile is None assert pem_c.pub_keyfile is None assert pem_c.decrypt(content) == unencrypted_str # Generate a new key referencing another location pem_c = utils.pem.ApprisePEMController( name="keygen-tests", path=str(tmpdir0), asset=asset ) # generate ourselves some keys assert pem_c.keygen() is True keygen_prv_file = pem_c.prv_keyfile keygen_pub_file = pem_c.pub_keyfile # Remove 1 (but not both) os.unlink(keygen_pub_file) pem_c = utils.pem.ApprisePEMController( name="keygen-tests", path=str(tmpdir0), asset=asset ) # Private key was found, so this does not work assert pem_c.keygen() is False os.unlink(keygen_prv_file) pem_c = utils.pem.ApprisePEMController( name="keygen-tests", path=str(tmpdir0), asset=asset ) # It works now assert pem_c.keygen() is True # Tests public_key generation failure only with mock.patch("builtins.open", side_effect=OSError()): assert pem_c.keygen(force=True) is False with mock.patch("os.unlink", side_effect=OSError()): assert pem_c.keygen(force=True) is False with mock.patch("os.unlink", return_value=True): assert pem_c.keygen(force=True) is False # Tests private key generation side_effect = [mock.mock_open(read_data="file contents").return_value] + [ OSError() for _ in range(10) ] with mock.patch("builtins.open", side_effect=side_effect): assert pem_c.keygen(force=True) is False with ( mock.patch("builtins.open", side_effect=side_effect), mock.patch("os.unlink", side_effect=OSError()), ): assert pem_c.keygen(force=True) is False with ( mock.patch("builtins.open", side_effect=side_effect), mock.patch("os.unlink", return_value=True), ): assert pem_c.keygen(force=True) is False # Generate a new key referencing another location pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) # We can't re-generate keys if ones already exist assert pem_c.keygen() is False # the keygen is the big difference here assert pem_c.keygen(name="test") is True # under the hood, a key is not regenerated (as one already exists) assert pem_c.keygen(name="test") is False # Generate it a second time by force assert pem_c.keygen(name="test", force=True) is True assert pem_c.private_keyfile() == os.path.join( str(tmpdir0), "test-private_key.pem" ) assert pem_c.public_keyfile() == os.path.join( str(tmpdir0), "test-public_key.pem" ) assert pem_c.private_key() is not None assert pem_c.public_key() is not None assert pem_c.prv_keyfile == os.path.join( str(tmpdir0), "test-private_key.pem" ) assert pem_c.pub_keyfile == os.path.join( str(tmpdir0), "test-public_key.pem" ) # 'content' was generated using a different key and can not be # decrypted assert pem_c.decrypt(content) is None # Test Decryption files pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) # Calling decrypt triggers underlining code to auto-load assert pem_c.decrypt(content) == unencrypted_str # Using a private key by path assert ( pem_c.decrypt(content, private_key=pem_c.private_key()) == unencrypted_str ) # Test different edge cases of load_private_key() pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) assert pem_c.load_private_key() is True pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) assert pem_c.load_private_key(path=prv_keyfile) is True pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) with mock.patch("builtins.open", side_effect=TypeError()): assert pem_c.load_private_key(path=prv_keyfile) is False with mock.patch("builtins.open", side_effect=OSError()): assert pem_c.load_private_key(path=prv_keyfile) is False with mock.patch("builtins.open", side_effect=FileNotFoundError()): assert pem_c.load_private_key(path=prv_keyfile) is False # Test different edge cases of load_public_key() pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) assert pem_c.load_public_key() is True pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) assert pem_c.load_public_key(path=pub_keyfile) is True pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) with mock.patch("builtins.open", side_effect=TypeError()): assert pem_c.load_public_key(path=pub_keyfile) is False with mock.patch("builtins.open", side_effect=OSError()): assert pem_c.load_public_key(path=pub_keyfile) is False with mock.patch("builtins.open", side_effect=FileNotFoundError()): assert pem_c.load_public_key(path=pub_keyfile) is False pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) assert pem_c.public_keyfile("test1", "test2") == pub_keyfile assert pem_c.private_keyfile("test1", "test2") == prv_keyfile pem_c = utils.pem.ApprisePEMController( path=str(tmpdir0), name="pub1", asset=asset ) assert pem_c.public_key(autogen=True) pem_c = utils.pem.ApprisePEMController( path=str(tmpdir0), name="pub2", asset=asset ) assert pem_c.private_key(autogen=True) # # Auto key generation turned on # asset = AppriseAsset( storage_mode=PersistentStoreMode.MEMORY, storage_path=str(tmpdir0), pem_autogen=True, ) pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) assert pem_c.load_public_key(path=pub_keyfile) is True pem_c = utils.pem.ApprisePEMController(path=None, asset=asset) assert pem_c.load_public_key(path=pub_keyfile) is True tmpdir1 = tmpdir.mkdir("tmp01") # Currently no files here assert os.listdir(str(tmpdir1)) == [] asset = AppriseAsset( storage_mode=PersistentStoreMode.MEMORY, storage_path=str(tmpdir1), pem_autogen=False, ) # Auto-Gen is turned off, so weare not successful here pem_c = utils.pem.ApprisePEMController(path=None, asset=asset) assert pem_c.public_key() is None assert pem_c.private_key() is None pem_c = utils.pem.ApprisePEMController(path=str(tmpdir1), asset=asset) assert pem_c.public_key() is None assert pem_c.private_key() is None asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir1), pem_autogen=True, ) pem_c = utils.pem.ApprisePEMController(path=str(tmpdir1), asset=asset) # Generate ourselves a private key assert pem_c.public_key() is not None assert pem_c.private_key() is not None pub_keyfile = os.path.join(str(tmpdir1), "public_key.pem") prv_keyfile = os.path.join(str(tmpdir1), "private_key.pem") assert os.path.isfile(pub_keyfile) assert os.path.isfile(prv_keyfile) with open(pub_keyfile, "w") as f: f.write("garbage") pem_c = utils.pem.ApprisePEMController(path=str(tmpdir1), asset=asset) # we can still load our data as the public key is generated # from the private assert pem_c.public_key() is not None assert pem_c.private_key() is not None tmpdir2 = tmpdir.mkdir("tmp02") pem_c = utils.pem.ApprisePEMController(path=str(tmpdir2), asset=asset) pub_keyfile = os.path.join(str(tmpdir2), "public_key.pem") prv_keyfile = os.path.join(str(tmpdir2), "private_key.pem") assert not os.path.isfile(pub_keyfile) assert not os.path.isfile(prv_keyfile) # # Public Key Edge Case Tests # with ( mock.patch.object( pem_c, "public_keyfile", side_effect=[None, pub_keyfile] ) as mock_keyfile, mock.patch.object( pem_c, "keygen", side_effect=lambda *_, **__: ( setattr(pem_c, "_ApprisePEMController__public_key", pubkey_ref) or True ), ) as mock_keygen, mock.patch.object(pem_c, "load_public_key", return_value=True), ): result = pem_c.public_key() assert result is pubkey_ref assert mock_keyfile.call_count == 2 mock_keygen.assert_called_once() # - First call: None -> triggers keygen # - Second call (recursive): None -> causes fallback public_keyfile_side_effect = [None, None] with ( mock.patch.object( pem_c, "public_keyfile", side_effect=public_keyfile_side_effect ) as mock_keyfile, mock.patch.object(pem_c, "keygen", return_value=True) as mock_keygen, mock.patch.object( pem_c, "load_public_key", return_value=False ) as mock_load, ): # Ensure no key is preset initially pem_c._ApprisePEMController__public_key = None result = pem_c.public_key() assert result is None # Once in outer call, once in recursive assert mock_keyfile.call_count == 2 mock_keygen.assert_called_once() mock_load.assert_not_called() # # Private Key Edge Case Tests # with ( mock.patch.object( pem_c, "private_keyfile", side_effect=[None, prv_keyfile] ) as mock_keyfile, mock.patch.object( pem_c, "keygen", side_effect=lambda *_, **__: ( setattr( pem_c, "_ApprisePEMController__private_key", prvkey_ref ) or True ), ) as mock_keygen, mock.patch.object(pem_c, "load_private_key", return_value=True), ): result = pem_c.private_key() assert result is prvkey_ref assert mock_keyfile.call_count == 2 mock_keygen.assert_called_once() # - First call: None -> triggers keygen # - Second call (recursive): None -> causes fallback private_keyfile_side_effect = [None, None] with ( mock.patch.object( pem_c, "private_keyfile", side_effect=private_keyfile_side_effect ) as mock_keyfile, mock.patch.object(pem_c, "keygen", return_value=True) as mock_keygen, mock.patch.object( pem_c, "load_private_key", return_value=False ) as mock_load, ): # Ensure no key is preset initially pem_c._ApprisePEMController__private_key = None result = pem_c.private_key() assert result is None # Once in outer call, once in recursive assert mock_keyfile.call_count == 2 mock_keygen.assert_called_once() mock_load.assert_not_called() @pytest.mark.skipif( PEM_SUPPORT, reason="Requires that cryptography NOT be installed", ) def test_utils_pem_without_cryptography(tmpdir): """Verify PEM operations fail cleanly without cryptography.""" tmpdir0 = tmpdir.mkdir("tmp00") # Currently no files here assert os.listdir(str(tmpdir0)) == [] asset = AppriseAsset( storage_mode=PersistentStoreMode.MEMORY, storage_path=str(tmpdir0), pem_autogen=False, ) # Create a PEM Controller pem_c = utils.pem.ApprisePEMController(path=None, asset=asset) # PEM operations require the cryptography library with pytest.raises(utils.pem.ApprisePEMException): pem_c.public_keyfile() with pytest.raises(utils.pem.ApprisePEMException): pem_c.public_key() with pytest.raises(utils.pem.ApprisePEMException): _ = pem_c.x962_str with pytest.raises(utils.pem.ApprisePEMException): pem_c.encrypt("message") with pytest.raises(utils.pem.ApprisePEMException): pem_c.keygen() asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), pem_autogen=False, ) # No new files assert os.listdir(str(tmpdir0)) == [] # Our asset is now write mode, so we will be able to generate a key pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) # Nothing to lookup with pytest.raises(utils.pem.ApprisePEMException): pem_c.private_keyfile() with pytest.raises(utils.pem.ApprisePEMException): pem_c.private_key() with pytest.raises(utils.pem.ApprisePEMException): _ = pem_c.x962_str with pytest.raises(utils.pem.ApprisePEMException): pem_c.encrypt("message") # Keys can not be generated in memory mode with pytest.raises(utils.pem.ApprisePEMException): pem_c.keygen() # No files loaded assert os.listdir(str(tmpdir0)) == [] with pytest.raises(utils.pem.ApprisePEMException): pem_c.public_keyfile() with pytest.raises(utils.pem.ApprisePEMException): pem_c.public_key() with pytest.raises(utils.pem.ApprisePEMException): _ = pem_c.x962_str with pytest.raises(utils.pem.ApprisePEMException): pem_c.encrypt("message") with pytest.raises(utils.pem.ApprisePEMException): pem_c.decrypt("abcd==") caronc-apprise-dfec414/tests/test_utils_pgp.py000066400000000000000000001636221526720371000217120ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import base64 import logging import os import shutil import sys from unittest import mock import pytest from apprise import AppriseAsset from apprise.utils import pgp as pgp_module from apprise.utils.pgp import ApprisePGPController, _ensure_imghdr_shim logging.disable(logging.CRITICAL) # Path to the pre-generated key fixtures used by signing/autocrypt tests _VAR_DIR = os.path.join(os.path.dirname(__file__), "var", "pgp") _VALID_PRV_ASC = os.path.join(_VAR_DIR, "valid-prv.asc") _VALID_PUB_ASC = os.path.join(_VAR_DIR, "valid-pub.asc") # imghdr shim def test_imghdr_shim_when_present(): """_ensure_imghdr_shim() leaves sys.modules intact when imghdr imports.""" # Guarantee imghdr is importable for this test real_imghdr = sys.modules.get("imghdr") try: import imghdr as _real # If we got here, imghdr is available on this Python version _ensure_imghdr_shim() # sys.modules entry must not have been replaced by our shim assert sys.modules.get("imghdr") is _real except ImportError: # Python 3.13 and newer removed imghdr. pytest.skip("imghdr not present on this Python version") finally: # Restore whatever was there before if real_imghdr is None: sys.modules.pop("imghdr", None) else: sys.modules["imghdr"] = real_imghdr def test_imghdr_shim_when_absent(): """_ensure_imghdr_shim() installs a working shim when imghdr is missing.""" # Remove any real imghdr from sys.modules and make import fail saved = sys.modules.pop("imghdr", None) try: # Setting the key to None causes 'import imghdr' to raise ImportError sys.modules["imghdr"] = None _ensure_imghdr_shim() shim = sys.modules.get("imghdr") assert shim is not None assert callable(shim.what) # The shim must return None (safe fallback for all inputs) assert shim.what() is None assert shim.what(file=None, h=b"\xff\xd8\xff") is None assert shim.what(None, h=b"some data") is None finally: # Restore original state if saved is None: sys.modules.pop("imghdr", None) else: sys.modules["imghdr"] = saved # ApprisePGPController WKD integration def test_pgp_controller_wkd_none_by_default(tmpdir): """ApprisePGPController has wkd=None unless explicitly provided.""" ctrl = ApprisePGPController(path=str(tmpdir)) assert ctrl.wkd is None def test_pgp_controller_accepts_wkd(tmpdir): """ApprisePGPController stores the WKD controller it is given.""" mock_wkd = mock.Mock() ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) assert ctrl.wkd is mock_wkd def test_fetch_wkd_key_no_controller(tmpdir): """_fetch_wkd_key() returns None when no WKD controller is set.""" ctrl = ApprisePGPController(path=str(tmpdir)) result = ctrl._fetch_wkd_key("user@example.com") assert result is None def test_fetch_wkd_key_empty_emails(tmpdir): """_fetch_wkd_key() returns None when no email candidates exist.""" mock_wkd = mock.Mock() ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) # No self.email set and no positional emails result = ctrl._fetch_wkd_key() assert result is None mock_wkd.fetch.assert_not_called() def test_fetch_wkd_key_skips_none_in_candidate_list(tmpdir): """_fetch_wkd_key() skips None/empty entries in the email candidates.""" mock_wkd = mock.Mock() mock_wkd.fetch.return_value = None ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) # None is a valid positional argument; it must be skipped, not crash result = ctrl._fetch_wkd_key(None, "user@example.com") assert result is None # fetch must only be called for the valid address, not for None mock_wkd.fetch.assert_called_once_with("user@example.com") def test_fetch_wkd_key_wkd_returns_none(tmpdir): """_fetch_wkd_key() returns None when WKD fetch yields nothing.""" mock_wkd = mock.Mock() mock_wkd.fetch.return_value = None ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) result = ctrl._fetch_wkd_key("user@example.com") assert result is None @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_fetch_wkd_key_success(tmpdir): """_fetch_wkd_key() parses and caches a key returned by WKD.""" import pgpy # Generate a fresh key pair to use as our fake WKD payload key = pgpy.PGPKey.new( pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 2048 ) uid = pgpy.PGPUID.new("Test", email="user@example.com") key.add_uid( uid, usage={ pgpy.constants.KeyFlags.Sign, pgpy.constants.KeyFlags.EncryptCommunications, }, hashes=[pgpy.constants.HashAlgorithm.SHA256], ciphers=[pgpy.constants.SymmetricKeyAlgorithm.AES256], compression=[pgpy.constants.CompressionAlgorithm.ZLIB], ) pub_bytes = str(key.pubkey).encode() mock_wkd = mock.Mock() mock_wkd.fetch.return_value = pub_bytes ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) result = ctrl._fetch_wkd_key("user@example.com") assert result is not None assert isinstance(result, pgpy.PGPKey) mock_wkd.fetch.assert_called_once_with("user@example.com") # The second call must use the parsed-key cache. mock_wkd.fetch.reset_mock() result2 = ctrl._fetch_wkd_key("user@example.com") assert result2 is not None mock_wkd.fetch.assert_not_called() @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_fetch_wkd_key_expired_cache_refetches(tmpdir): """_fetch_wkd_key() drops an expired parsed-key cache entry and re-fetches from WKD rather than returning the stale key.""" from datetime import datetime, timedelta, timezone import hashlib import pgpy # Generate a key to serve as the fresh WKD result key = pgpy.PGPKey.new( pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 2048 ) uid = pgpy.PGPUID.new("Fresh", email="user@example.com") key.add_uid( uid, usage={ pgpy.constants.KeyFlags.Sign, pgpy.constants.KeyFlags.EncryptCommunications, }, hashes=[pgpy.constants.HashAlgorithm.SHA256], ciphers=[pgpy.constants.SymmetricKeyAlgorithm.AES256], compression=[pgpy.constants.CompressionAlgorithm.ZLIB], ) pub_bytes = str(key.pubkey).encode() mock_wkd = mock.Mock() mock_wkd.fetch.return_value = pub_bytes ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) # Seed the parsed-key cache with an already-expired entry cache_key = hashlib.sha1(b"wkd:user@example.com").hexdigest() # Use the private name-mangled attribute ctrl._ApprisePGPController__key_lookup[cache_key] = { "public_key": None, "expires": datetime.now(timezone.utc) - timedelta(seconds=1), } # The expired entry is discarded; WKD is fetched and result returned result = ctrl._fetch_wkd_key("user@example.com") assert result is not None mock_wkd.fetch.assert_called_once_with("user@example.com") @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_fetch_wkd_key_prefers_recipient_over_sender(tmpdir): """_fetch_wkd_key() tries recipient emails before self.email so that the recipient's public key (not the sender's) is used for encryption.""" import pgpy # Generate a key to represent the recipient's WKD result key = pgpy.PGPKey.new( pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 2048 ) uid = pgpy.PGPUID.new("Recipient", email="recipient@example.com") key.add_uid( uid, usage={ pgpy.constants.KeyFlags.Sign, pgpy.constants.KeyFlags.EncryptCommunications, }, hashes=[pgpy.constants.HashAlgorithm.SHA256], ciphers=[pgpy.constants.SymmetricKeyAlgorithm.AES256], compression=[pgpy.constants.CompressionAlgorithm.ZLIB], ) pub_bytes = str(key.pubkey).encode() # WKD returns a key only for the recipient address def fake_fetch(email): if email == "recipient@example.com": return pub_bytes return None mock_wkd = mock.Mock(side_effect=fake_fetch) mock_wkd.fetch.side_effect = fake_fetch ctrl = ApprisePGPController( path=str(tmpdir), email="sender@example.com", wkd=mock_wkd, ) # The recipient's email is passed as a positional argument result = ctrl._fetch_wkd_key("recipient@example.com") assert result is not None # sender@example.com must NOT have been tried before the recipient hit calls = [call.args[0] for call in mock_wkd.fetch.call_args_list] assert calls[0] == "recipient@example.com" assert "sender@example.com" not in calls @pytest.mark.skipif( pgp_module.PGP_SUPPORT, reason="Tests behavior when pgpy is absent" ) def test_fetch_wkd_key_pgpy_not_installed(tmpdir): """_fetch_wkd_key() skips silently when pgpy raises NameError.""" mock_wkd = mock.Mock() mock_wkd.fetch.return_value = b"fake-key-bytes" ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) # In a minimal env pgpy is not installed, so pgpy.PGPKey.from_blob # raises NameError naturally, so no mock is needed. result = ctrl._fetch_wkd_key("user@example.com") assert result is None @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_fetch_wkd_key_corrupt_data(tmpdir): """_fetch_wkd_key() skips and tries next candidate on parse failure.""" mock_wkd = mock.Mock() mock_wkd.fetch.return_value = b"this-is-not-a-pgp-key" ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) # Patch the canonical pgpy module directly (not the pgp module's copy) # so the mock works whether pgpy was imported by name or attribute. with mock.patch( "pgpy.PGPKey.from_blob", side_effect=Exception("bad key"), ): result = ctrl._fetch_wkd_key("user@example.com") assert result is None def test_fetch_wkd_key_uses_self_email_as_candidate(tmpdir): """_fetch_wkd_key() includes self.email in the candidate list.""" mock_wkd = mock.Mock() mock_wkd.fetch.return_value = None ctrl = ApprisePGPController( path=str(tmpdir), email="sender@example.com", wkd=mock_wkd ) ctrl._fetch_wkd_key() # self.email must have been tried mock_wkd.fetch.assert_called_with("sender@example.com") @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_public_key_falls_through_to_wkd(tmpdir): """public_key() uses WKD when no local key file exists.""" import pgpy # Generate a minimal key for the mock WKD response key = pgpy.PGPKey.new( pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 2048 ) uid = pgpy.PGPUID.new("WKD User", email="wkd@example.com") key.add_uid( uid, usage={ pgpy.constants.KeyFlags.Sign, pgpy.constants.KeyFlags.EncryptCommunications, }, hashes=[pgpy.constants.HashAlgorithm.SHA256], ciphers=[pgpy.constants.SymmetricKeyAlgorithm.AES256], compression=[pgpy.constants.CompressionAlgorithm.ZLIB], ) pub_bytes = str(key.pubkey).encode() mock_wkd = mock.Mock() mock_wkd.fetch.return_value = pub_bytes ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) # No local key file exists, so WKD must be tried result = ctrl.public_key("wkd@example.com", autogen=False) assert result is not None mock_wkd.fetch.assert_called() @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_public_key_wkd_fails_falls_through_to_autogen(tmpdir): """public_key() falls through to autogen when WKD also returns None.""" mock_wkd = mock.Mock() mock_wkd.fetch.return_value = None ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) # No local key file exists; WKD fails; autogen=False => None returned result = ctrl.public_key("nobody@example.com", autogen=False) assert result is None mock_wkd.fetch.assert_called() @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_public_key_malformed_local_file_returns_none(tmpdir): """public_key() returns None gracefully when the local .asc file is not valid PGP data (pgpy raises during parse).""" # Write a file with non-PGP content under a name that public_keyfile() # will discover automatically bad_key_path = tmpdir.join("pub.asc") bad_key_path.write("this is not a pgp key\n") ctrl = ApprisePGPController(path=str(tmpdir)) # No autogen so the only candidate is the malformed file above result = ctrl.public_key(autogen=False) assert result is None @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") @pytest.mark.parametrize("use_keyfile", (False, True)) def test_keygen_checks_eligibility_before_rsa(tmpdir, use_keyfile): """Disabled key generation does not perform expensive RSA work.""" ctrl = ApprisePGPController( path=str(tmpdir) if use_keyfile else None, pub_keyfile=_VALID_PUB_ASC if use_keyfile else None, email="test@example.com", ) with mock.patch.object(pgp_module.pgpy.PGPKey, "new") as mock_new: assert ctrl.keygen() is False mock_new.assert_not_called() # autocrypt_header() @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_no_sender_key(tmpdir): """Autocrypt is omitted when no sender key is available.""" asset = AppriseAsset(pgp_autogen=False) ctrl = ApprisePGPController( path=str(tmpdir), email="test@example.com", asset=asset ) assert ctrl.autocrypt_header() is None @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") @pytest.mark.parametrize("missing_email", (None, "")) def test_autocrypt_header_none_without_email(tmpdir, missing_email): """Autocrypt requires an address even when a sender key exists.""" ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC, email=missing_email, ) # Only the address is missing. assert ctrl.private_key() is not None assert ctrl.autocrypt_header() is None def _fingerprint_of(path): """Loads a key fixture from disk and returns its fingerprint string.""" import pgpy with open(path) as key_file: key, _ = pgpy.PGPKey.from_blob(key_file.read()) return str(key.fingerprint) def _keydata_fingerprint(header): """Decodes an Autocrypt header's keydata= and returns its fingerprint.""" import pgpy keydata = header.split("keydata=", 1)[1] key, _ = pgpy.PGPKey.from_blob(base64.b64decode(keydata)) return str(key.fingerprint) @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_valid_key(tmpdir): """Autocrypt advertises the public half of a generated sender key.""" ctrl = ApprisePGPController(path=str(tmpdir), email="test@example.com") assert ctrl.keygen() is True header = ctrl.autocrypt_header() assert header is not None assert header.startswith( "addr=test@example.com; prefer-encrypt=mutual; keydata=" ) # Compare fingerprints so an unrelated but valid key cannot pass. assert _keydata_fingerprint(header) == str(ctrl.private_key().fingerprint) @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_ignores_recipient_pgppub(tmpdir): """Autocrypt uses our signing key instead of recipient pgppub=.""" # Generate the sender key before configuring a recipient override. keygen_ctrl = ApprisePGPController( path=str(tmpdir), email="test@example.com" ) assert keygen_ctrl.keygen() is True own_fingerprint = str(keygen_ctrl.private_key().fingerprint) # The fixture represents an unrelated recipient's key assert own_fingerprint != _fingerprint_of(_VALID_PUB_ASC) # The sender key remains discoverable beside the recipient override. ctrl = ApprisePGPController( path=str(tmpdir), pub_keyfile=_VALID_PUB_ASC, email="test@example.com", ) header = ctrl.autocrypt_header() assert header is not None assert _keydata_fingerprint(header) == own_fingerprint @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_ignores_recipient_only_key(tmpdir): """Autocrypt omits a recipient pgppub= when no sender key exists.""" asset = AppriseAsset(pgp_autogen=False) ctrl = ApprisePGPController( path=str(tmpdir), pub_keyfile=_VALID_PUB_ASC, email="test@example.com", asset=asset, ) assert ctrl.autocrypt_header() is None @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") @pytest.mark.parametrize( "filename", ( # Sender-specific names still require a matching private key. "sender@example.com-pub.asc", "sender-pub.asc", "pgp-public.asc", "pgp-pub.asc", "public.asc", "pub.asc", ), ) def test_autocrypt_header_ignores_public_only_local_files(tmpdir, filename): """Autocrypt ignores local public keys without a matching private key.""" shutil.copy(_VALID_PUB_ASC, str(tmpdir.join(filename))) asset = AppriseAsset(pgp_autogen=False) ctrl = ApprisePGPController( path=str(tmpdir), email="sender@example.com", asset=asset ) assert ctrl.autocrypt_header() is None @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_autogen_generates_own_key(tmpdir): """Autocrypt may generate and advertise a sender-owned key pair.""" asset = AppriseAsset(pgp_autogen=True) ctrl = ApprisePGPController( path=str(tmpdir), email="sender@example.com", asset=asset ) assert ctrl.private_key() is None header = ctrl.autocrypt_header() assert header is not None assert header.startswith("addr=sender@example.com;") # A private key must now exist, and it must be the one advertised private_key = ctrl.private_key() assert private_key is not None assert _keydata_fingerprint(header) == str(private_key.fingerprint) @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_respects_autogen_disabled(tmpdir): """Autocrypt respects disabled key generation.""" asset = AppriseAsset(pgp_autogen=False) ctrl = ApprisePGPController( path=str(tmpdir), email="sender@example.com", asset=asset ) assert ctrl.autocrypt_header() is None assert ctrl.private_key() is None @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_enforces_size_limit(tmpdir): """Autocrypt rejects headers over its 10 KiB limit.""" ctrl = ApprisePGPController(path=str(tmpdir), email="test@example.com") assert ctrl.keygen() is True # Sanity: the real key comfortably fits within the real limit assert ctrl.autocrypt_header() is not None # Force the limit far below anything a real key could satisfy ctrl.max_autocrypt_header_size = 10 assert ctrl.autocrypt_header() is None @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_rejects_key_without_encryption_subkey(tmpdir): """Autocrypt rejects a private key without an encryption subkey.""" ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC, email="test@example.com", ) private_key = ctrl.private_key() assert private_key is not None assert not ApprisePGPController._has_encryption_subkey(private_key) assert ctrl.autocrypt_header() is None @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_accepts_key_with_encryption_subkey(tmpdir): """_has_encryption_subkey() recognizes a properly structured key.""" ctrl = ApprisePGPController(path=str(tmpdir), email="test@example.com") assert ctrl.keygen() is True private_key = ctrl.private_key() assert ApprisePGPController._has_encryption_subkey(private_key) @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_rejects_key_with_extra_userid(tmpdir): """Autocrypt rejects an otherwise valid key with multiple UIDs.""" import pgpy from pgpy.constants import ( CompressionAlgorithm, HashAlgorithm, KeyFlags, PubKeyAlgorithm, SymmetricKeyAlgorithm, ) primary = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) for name, email in ( ("Test", "test@example.com"), ("Test (work)", "test@work.example.com"), ): primary.add_uid( pgpy.PGPUID.new(name, email=email), usage={KeyFlags.Sign, KeyFlags.Certify}, hashes=[HashAlgorithm.SHA256], ciphers=[SymmetricKeyAlgorithm.AES256], compression=[CompressionAlgorithm.ZLIB], ) subkey = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) primary.add_subkey( subkey, usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage}, ) prv_path = str(tmpdir.join("multi-uid-prv.asc")) with open(prv_path, "w") as f: f.write(str(primary)) ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile=prv_path, email="test@example.com" ) private_key = ctrl.private_key() assert private_key is not None assert not ApprisePGPController._has_encryption_subkey(private_key) assert ctrl.autocrypt_header() is None def _mock_uid_with_one_selfsig(): """A mock UID with exactly the one self-signature a compliant key is expected to have, so tests can focus on the subkey side.""" uid = mock.Mock() selfsig = mock.Mock() uid.selfsig = selfsig uid._signatures = [selfsig] return uid def test_has_encryption_subkey_skips_subkey_without_selfsig(): """A lone subkey with no self-signature at all is not usable.""" no_selfsig_subkey = mock.Mock() no_selfsig_subkey.revocation_signatures = [] no_selfsig_subkey.self_signatures = iter(()) key = mock.Mock() key.userattributes = [] key.revocation_signatures = [] key.userids = [_mock_uid_with_one_selfsig()] key.subkeys = {"a": no_selfsig_subkey} assert ApprisePGPController._has_encryption_subkey(key) is False @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_has_encryption_subkey_skips_subkey_with_wrong_flags(): """A signed subkey without encryption usage is not usable.""" wrong_flags_subkey = mock.Mock() wrong_flags_subkey.revocation_signatures = [] wrong_flags_subkey.self_signatures = iter( [mock.Mock(key_flags={pgp_module.pgpy.constants.KeyFlags.Sign})] ) key = mock.Mock() key.userattributes = [] key.revocation_signatures = [] key.userids = [_mock_uid_with_one_selfsig()] key.subkeys = {"a": wrong_flags_subkey} assert ApprisePGPController._has_encryption_subkey(key) is False @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_has_encryption_subkey_rejects_multiple_userids(): """Autocrypt rejects keys with multiple identities.""" good_subkey = mock.Mock() good_subkey.self_signatures = iter( [ mock.Mock( key_flags={ pgp_module.pgpy.constants.KeyFlags.EncryptCommunications } ) ] ) key = mock.Mock() key.userattributes = [] key.revocation_signatures = [] key.userids = ["user@example.com", "user@work.example.com"] key.subkeys = {"a": good_subkey} assert ApprisePGPController._has_encryption_subkey(key) is False @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_has_encryption_subkey_rejects_multiple_subkeys(): """Autocrypt Level 1 requires exactly one subkey.""" def make_subkey(): subkey = mock.Mock() subkey.self_signatures = iter( [ mock.Mock( key_flags={ pgp_module.pgpy.constants.KeyFlags.EncryptCommunications } ) ] ) return subkey key = mock.Mock() key.userattributes = [] key.revocation_signatures = [] key.userids = ["user@example.com"] key.subkeys = {"a": make_subkey(), "b": make_subkey()} assert ApprisePGPController._has_encryption_subkey(key) is False @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_has_encryption_subkey_rejects_extra_uid_selfsig(): """Autocrypt rejects a UID with an extra self-signature.""" uid = mock.Mock() uid._signatures = [mock.Mock(), mock.Mock()] subkey = mock.Mock() subkey.self_signatures = iter( [ mock.Mock( key_flags={ pgp_module.pgpy.constants.KeyFlags.EncryptCommunications } ) ] ) key = mock.Mock() key.userattributes = [] key.revocation_signatures = [] key.userids = [uid] key.subkeys = {"a": subkey} assert ApprisePGPController._has_encryption_subkey(key) is False @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_has_encryption_subkey_rejects_extra_subkey_binding_sig(): """Autocrypt rejects a subkey with an extra binding signature.""" uid = mock.Mock() uid._signatures = [mock.Mock()] subkey = mock.Mock() subkey.revocation_signatures = [] subkey.self_signatures = iter( [ mock.Mock( key_flags={ pgp_module.pgpy.constants.KeyFlags.EncryptCommunications } ), mock.Mock( key_flags={ pgp_module.pgpy.constants.KeyFlags.EncryptCommunications } ), ] ) key = mock.Mock() key.userattributes = [] key.revocation_signatures = [] key.userids = [uid] key.subkeys = {"a": subkey} assert ApprisePGPController._has_encryption_subkey(key) is False @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_has_encryption_subkey_rejects_uid_without_selfsig(): """Reject a UID whose only signature is from a third party.""" uid = mock.Mock() uid.selfsig = None uid._signatures = [mock.Mock()] subkey = mock.Mock() subkey.self_signatures = iter( [ mock.Mock( key_flags={ pgp_module.pgpy.constants.KeyFlags.EncryptCommunications } ) ] ) key = mock.Mock() key.userattributes = [] key.revocation_signatures = [] key.userids = [uid] key.subkeys = {"a": subkey} assert ApprisePGPController._has_encryption_subkey(key) is False def test_has_encryption_subkey_rejects_missing_signature_list(): """Reject the key if PGPy's private signature collection is absent.""" uid = mock.Mock(spec=["selfsig"]) uid.selfsig = mock.Mock() key = mock.Mock() key.userattributes = [] key.revocation_signatures = [] key.userids = [uid] key.subkeys = {"a": mock.Mock()} assert ApprisePGPController._has_encryption_subkey(key) is False @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_rejects_third_party_only_uid(tmpdir): """Reject a serialized key with only a third-party UID signature.""" import pgpy from pgpy.constants import ( CompressionAlgorithm, HashAlgorithm, KeyFlags, PubKeyAlgorithm, SymmetricKeyAlgorithm, ) victim = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) uid = pgpy.PGPUID.new("Victim", email="victim@example.com") victim.add_uid( uid, usage={KeyFlags.Sign, KeyFlags.Certify}, hashes=[HashAlgorithm.SHA256], ciphers=[SymmetricKeyAlgorithm.AES256], compression=[CompressionAlgorithm.ZLIB], ) subkey = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) victim.add_subkey( subkey, usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage}, ) # A third party certifies the UID instead of the primary itself thirdparty = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) tp_uid = pgpy.PGPUID.new("ThirdParty", email="tp@example.com") thirdparty.add_uid( tp_uid, usage={KeyFlags.Sign, KeyFlags.Certify}, hashes=[HashAlgorithm.SHA256], ciphers=[SymmetricKeyAlgorithm.AES256], compression=[CompressionAlgorithm.ZLIB], ) cert_sig = thirdparty.certify(uid) # Replace the genuine self-signature with the third-party one, so # the UID's only signature is not from its own primary key uid._signatures.clear() uid._signatures.append(cert_sig) prv_path = str(tmpdir.join("thirdparty-only-prv.asc")) with open(prv_path, "w") as f: f.write(str(victim)) ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile=prv_path, email="victim@example.com" ) private_key = ctrl.private_key() assert private_key is not None assert private_key.userids[0].selfsig is None assert not ApprisePGPController._has_encryption_subkey(private_key) assert ctrl.autocrypt_header() is None @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_rejects_key_with_extra_uid_signature(tmpdir): """Autocrypt rejects an extra UID signature after serialization.""" import pgpy from pgpy.constants import ( CompressionAlgorithm, HashAlgorithm, KeyFlags, PubKeyAlgorithm, SymmetricKeyAlgorithm, ) primary = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) uid = pgpy.PGPUID.new("Test", email="test@example.com") primary.add_uid( uid, usage={KeyFlags.Sign, KeyFlags.Certify}, hashes=[HashAlgorithm.SHA256], ciphers=[SymmetricKeyAlgorithm.AES256], compression=[CompressionAlgorithm.ZLIB], ) subkey = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) primary.add_subkey( subkey, usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage}, ) # Add another genuine self-certification to the same UID. extra_sig = primary.certify(uid, expires=None) uid._signatures.insert(0, extra_sig) prv_path = str(tmpdir.join("extra-sig-prv.asc")) with open(prv_path, "w") as f: f.write(str(primary)) ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile=prv_path, email="test@example.com" ) private_key = ctrl.private_key() assert private_key is not None assert len(private_key.userids[0]._signatures) == 2 assert not ApprisePGPController._has_encryption_subkey(private_key) assert ctrl.autocrypt_header() is None def test_has_encryption_subkey_rejects_user_attribute(): """Reject user attributes because they add packets to the key.""" key = mock.Mock() key.userattributes = [mock.Mock()] assert ApprisePGPController._has_encryption_subkey(key) is False @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_rejects_photo_attribute(tmpdir): """Reject a serialized key containing a photo attribute.""" import pgpy from pgpy.constants import ( CompressionAlgorithm, HashAlgorithm, KeyFlags, PubKeyAlgorithm, SymmetricKeyAlgorithm, ) primary = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) uid = pgpy.PGPUID.new("Test", email="test@example.com") primary.add_uid( uid, usage={KeyFlags.Sign, KeyFlags.Certify}, hashes=[HashAlgorithm.SHA256], ciphers=[SymmetricKeyAlgorithm.AES256], compression=[CompressionAlgorithm.ZLIB], ) subkey = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) primary.add_subkey( subkey, usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage}, ) # A minimal (fake, but structurally-tagged) JPEG photo attribute photo_bytes = bytearray(b"\xff\xd8\xff\xe0" + b"\x00" * 100 + b"\xff\xd9") photo_uid = pgpy.PGPUID.new(photo_bytes) primary.add_uid(photo_uid) prv_path = str(tmpdir.join("photo-prv.asc")) with open(prv_path, "w") as f: f.write(str(primary)) ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile=prv_path, email="test@example.com" ) private_key = ctrl.private_key() assert private_key is not None assert len(private_key.userattributes) == 1 assert not ApprisePGPController._has_encryption_subkey(private_key) assert ctrl.autocrypt_header() is None def test_has_encryption_subkey_rejects_revoked_primary_key(): """Reject a revoked primary key despite its valid UID and subkey.""" key = mock.Mock() key.userattributes = [] key.revocation_signatures = [mock.Mock()] assert ApprisePGPController._has_encryption_subkey(key) is False def test_has_encryption_subkey_rejects_revoked_subkey(): """Reject an encryption subkey carrying a revocation signature.""" subkey = mock.Mock() subkey.revocation_signatures = [mock.Mock()] key = mock.Mock() key.userattributes = [] key.revocation_signatures = [] key.userids = [_mock_uid_with_one_selfsig()] key.subkeys = {"a": subkey} assert ApprisePGPController._has_encryption_subkey(key) is False @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_rejects_revoked_primary_key(tmpdir): """Reject a serialized key with a revoked primary key.""" import pgpy from pgpy.constants import ( CompressionAlgorithm, HashAlgorithm, KeyFlags, PubKeyAlgorithm, SymmetricKeyAlgorithm, ) primary = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) uid = pgpy.PGPUID.new("Test", email="test@example.com") primary.add_uid( uid, usage={KeyFlags.Sign, KeyFlags.Certify}, hashes=[HashAlgorithm.SHA256], ciphers=[SymmetricKeyAlgorithm.AES256], compression=[CompressionAlgorithm.ZLIB], ) subkey = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) primary.add_subkey( subkey, usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage}, ) # Revoke the primary key itself revoke_sig = primary.revoke(primary) primary |= revoke_sig prv_path = str(tmpdir.join("revoked-primary-prv.asc")) with open(prv_path, "w") as f: f.write(str(primary)) ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile=prv_path, email="test@example.com" ) private_key = ctrl.private_key() assert private_key is not None assert len(list(private_key.revocation_signatures)) == 1 assert not ApprisePGPController._has_encryption_subkey(private_key) assert ctrl.autocrypt_header() is None @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_rejects_revoked_subkey(tmpdir): """Reject a serialized key with a revoked encryption subkey.""" import pgpy from pgpy.constants import ( CompressionAlgorithm, HashAlgorithm, KeyFlags, PubKeyAlgorithm, SymmetricKeyAlgorithm, ) primary = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) uid = pgpy.PGPUID.new("Test", email="test@example.com") primary.add_uid( uid, usage={KeyFlags.Sign, KeyFlags.Certify}, hashes=[HashAlgorithm.SHA256], ciphers=[SymmetricKeyAlgorithm.AES256], compression=[CompressionAlgorithm.ZLIB], ) subkey = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) primary.add_subkey( subkey, usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage}, ) # Revoke only the subkey, not the primary revoke_sig = primary.revoke(subkey) subkey |= revoke_sig prv_path = str(tmpdir.join("revoked-subkey-prv.asc")) with open(prv_path, "w") as f: f.write(str(primary)) ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile=prv_path, email="test@example.com" ) private_key = ctrl.private_key() assert private_key is not None assert not list(private_key.revocation_signatures) # primary is fine reparsed_subkey = next(iter(private_key.subkeys.values())) assert len(list(reparsed_subkey.revocation_signatures)) == 1 assert not ApprisePGPController._has_encryption_subkey(private_key) assert ctrl.autocrypt_header() is None @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_preserves_exported_fingerprint(tmpdir): """The advertised key retains the exported key's fingerprint.""" import pgpy ctrl = ApprisePGPController(path=str(tmpdir), email="test@example.com") assert ctrl.keygen() is True private_key = ctrl.private_key() exported = bytes(private_key.pubkey) reparsed, _ = pgpy.PGPKey.from_blob(exported) header = ctrl.autocrypt_header() assert header is not None # The advertised key must retain the exported primary-key identity. assert _keydata_fingerprint(header) == str(reparsed.fingerprint) @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_accepts_nonexportable_signature(tmpdir): """Validate the exported form of a key. PGPy drops the extra non-exportable signature, leaving one valid UID signature in the advertised key. """ import pgpy from pgpy.constants import ( CompressionAlgorithm, HashAlgorithm, KeyFlags, PubKeyAlgorithm, SymmetricKeyAlgorithm, ) primary = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) uid = pgpy.PGPUID.new("Test", email="test@example.com") primary.add_uid( uid, usage={KeyFlags.Sign, KeyFlags.Certify}, hashes=[HashAlgorithm.SHA256], ciphers=[SymmetricKeyAlgorithm.AES256], compression=[CompressionAlgorithm.ZLIB], ) subkey = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) primary.add_subkey( subkey, usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage}, ) # A second, non-exportable certification over the same UID extra_sig = primary.certify(uid, exportable=False) uid._signatures.insert(0, extra_sig) # The live in-memory object has two signatures and fails validation assert len(uid._signatures) == 2 assert not ApprisePGPController._has_encryption_subkey(primary) # PGPy omits the extra signature from the exported key. exported = bytes(primary.pubkey) reparsed, _ = pgpy.PGPKey.from_blob(exported) assert len(reparsed.userids[0]._signatures) == 1 assert ApprisePGPController._has_encryption_subkey(reparsed) prv_path = str(tmpdir.join("nonexportable-extra-prv.asc")) with open(prv_path, "w") as f: f.write(str(primary)) ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile=prv_path, email="test@example.com" ) # The header validates the exported key rather than the live object. header = ctrl.autocrypt_header() assert header is not None assert _keydata_fingerprint(header) == str(reparsed.fingerprint) @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_handles_reparse_failure(tmpdir): """Omit the header when the exported key cannot be parsed.""" ctrl = ApprisePGPController(path=str(tmpdir), email="test@example.com") assert ctrl.keygen() is True # Warm the cache so the mock affects only the exported-key reparse. assert ctrl.private_key() is not None with mock.patch.object( pgp_module.pgpy.PGPKey, "from_blob", side_effect=ValueError("corrupt export"), ): assert ctrl.autocrypt_header() is None @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_autocrypt_header_folds_long_keydata(tmpdir): """Folded keydata stays within line limits and remains decodable.""" ctrl = ApprisePGPController(path=str(tmpdir), email="test@example.com") assert ctrl.keygen() is True header = ctrl.autocrypt_header() assert header is not None # Keep every physical header line within RFC 5322's hard limit. rendered = f"Autocrypt: {header}" for line in rendered.split("\r\n"): assert len(line) <= 998 # Folding must have actually happened for a real 2048-bit RSA key assert "\r\n " in header # addr= and prefer-encrypt= must stay intact and unfolded assert header.startswith( "addr=test@example.com; prefer-encrypt=mutual; keydata=" ) # Folding must not alter the advertised key. assert _keydata_fingerprint(header) == str(ctrl.private_key().fingerprint) # prune() WKD delegation def test_prune_delegates_to_wkd(tmpdir): """prune() calls prune() on the WKD controller when one is set.""" mock_wkd = mock.Mock() ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) ctrl.prune() mock_wkd.prune.assert_called_once() def test_prune_without_wkd(tmpdir): """prune() runs without error when no WKD controller is set.""" ctrl = ApprisePGPController(path=str(tmpdir)) ctrl.prune() # must not raise # PGP_SUPPORT consistency def test_pgp_support_flag_present(): """PGP_SUPPORT is a boolean exported from the pgp module.""" assert isinstance(pgp_module.PGP_SUPPORT, bool) # private_keyfile() path resolution def test_private_keyfile_no_path(): """private_keyfile() returns None when neither path nor prv_keyfile set.""" ctrl = ApprisePGPController(path=None) assert ctrl.private_keyfile() is None def test_private_keyfile_explicit_valid(tmpdir): """private_keyfile() returns a path for a valid explicit prv_keyfile.""" ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) result = ctrl.private_keyfile() # Should be an existing file path assert result and os.path.isfile(result) def test_private_keyfile_explicit_invalid(tmpdir): """private_keyfile() returns False when an explicit file is unreachable.""" ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile="/nonexistent/path/key.asc", ) # The attachment itself is invalid, so the property returns False result = ctrl.private_keyfile() assert result is False def test_private_keyfile_auto_discover_by_email(tmpdir): """private_keyfile() finds an auto-generated key named by email prefix.""" # Place a fake private key named after the email prefix key_path = str(tmpdir.join("user-prv.asc")) with open(key_path, "w") as f: f.write("dummy") ctrl = ApprisePGPController(path=str(tmpdir), email="user@example.com") result = ctrl.private_keyfile() assert result == key_path def test_private_keyfile_fallback_generic_name(tmpdir): """private_keyfile() falls back to pgp-prv.asc when no email-named key.""" key_path = str(tmpdir.join("pgp-prv.asc")) with open(key_path, "w") as f: f.write("dummy") ctrl = ApprisePGPController(path=str(tmpdir)) result = ctrl.private_keyfile() assert result == key_path def test_private_keyfile_not_found(tmpdir): """private_keyfile() returns None when no private key exists on disk.""" ctrl = ApprisePGPController(path=str(tmpdir)) assert ctrl.private_keyfile() is None def test_prv_keyfile_property_none_when_not_set(tmpdir): """prv_keyfile property returns None when no explicit key was provided.""" ctrl = ApprisePGPController(path=str(tmpdir)) assert ctrl.prv_keyfile is None def test_prv_keyfile_property_path_when_valid(tmpdir): """prv_keyfile property returns the resolved path for a valid key.""" ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) # Property accesses the attachment; it may return None before loading # because the attachment is resolved lazily. It must not be False. assert ctrl.prv_keyfile is not False def test_prv_keyfile_property_false_when_invalid(tmpdir): """prv_keyfile property returns False for an unreachable explicit file.""" ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile="/no/such/file.asc", ) assert ctrl.prv_keyfile is False # Loading private PGP keys @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_private_key_loads_valid_fixture(tmpdir): """private_key() returns a PGPKey object from a valid private key file.""" import pgpy ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) key = ctrl.private_key() assert key is not None assert isinstance(key, pgpy.PGPKey) @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_private_key_cached_on_second_call(tmpdir): """private_key() returns the same object on repeated calls (cached).""" ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) first = ctrl.private_key() second = ctrl.private_key() assert first is not None assert first is second def test_private_key_returns_none_when_not_found(tmpdir): """private_key() returns None when no private key file exists.""" ctrl = ApprisePGPController(path=str(tmpdir)) assert ctrl.private_key() is None @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_private_key_expired_cache_reloads(tmpdir): """private_key() drops an expired cache entry and reloads from disk.""" from datetime import datetime, timedelta, timezone import hashlib ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) # Load the key once to prime the cache first = ctrl.private_key() assert first is not None # Artificially expire the cached entry by back-dating its expiry time cache_key = hashlib.sha1( ("prv:" + os.path.abspath(_VALID_PRV_ASC)).encode("utf-8") ).hexdigest() ctrl._ApprisePGPController__key_lookup[cache_key]["expires"] = ( datetime.now(timezone.utc) - timedelta(seconds=1) ) # Second call must detect the expiry and re-load from disk second = ctrl.private_key() assert second is not None def test_private_key_returns_none_when_pgpy_missing(tmpdir): """private_key() returns None gracefully when PGPy is not installed.""" import contextlib ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) with contextlib.ExitStack() as stack: # Force PGP_SUPPORT=True so the key-loading branch is reached stack.enter_context(mock.patch.object(pgp_module, "PGP_SUPPORT", True)) # Supply any readable content for the open() call stack.enter_context( mock.patch("builtins.open", mock.mock_open(read_data="data")) ) if "pgpy" in sys.modules: # When pgpy IS installed, simulate its absence by making # from_blob raise NameError (same as if the name were undefined) stack.enter_context( mock.patch("pgpy.PGPKey.from_blob", side_effect=NameError) ) # Without pgpy installed the NameError arises naturally at the # 'pgpy.PGPKey.from_blob(...)' call in private_key(); either way # private_key() must swallow the error and return None result = ctrl.private_key() assert result is None def test_private_key_returns_none_when_keyfile_inaccessible(tmpdir): """private_key() returns None when private_keyfile() returns False.""" # Provide an invalid path so the attachment reports the file as unreachable ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile="/no/such/file.asc" ) # private_keyfile() will return False; private_key() must not raise result = ctrl.private_key() assert result is None def test_private_key_returns_none_on_io_error(tmpdir): """private_key() returns None when the file cannot be read.""" ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) with mock.patch("builtins.open", side_effect=OSError("disk error")): result = ctrl.private_key() assert result is None def test_private_key_returns_none_on_malformed_key(tmpdir): """private_key() returns None when the file contains non-PGP data.""" bad_key = str(tmpdir.join("bad-prv.asc")) with open(bad_key, "w") as f: f.write("this is not a pgp key") ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=bad_key) result = ctrl.private_key() assert result is None def test_private_key_returns_none_when_file_disappears(tmpdir): """private_key() handles a key file that vanishes between stat and open.""" ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) with mock.patch("builtins.open", side_effect=FileNotFoundError("gone")): result = ctrl.private_key() assert result is None @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_private_key_rejects_passphrase_protected(tmpdir): """private_key() returns None for a passphrase-protected key.""" import pgpy # Generate a fresh key and protect it with a passphrase key = pgpy.PGPKey.new( pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 2048 ) uid = pgpy.PGPUID.new("Locked", email="locked@example.com") key.add_uid( uid, usage={pgpy.constants.KeyFlags.Sign}, hashes=[pgpy.constants.HashAlgorithm.SHA256], ciphers=[pgpy.constants.SymmetricKeyAlgorithm.AES256], compression=[pgpy.constants.CompressionAlgorithm.ZLIB], ) key.protect( "s3cr3t", pgpy.constants.SymmetricKeyAlgorithm.AES256, pgpy.constants.HashAlgorithm.SHA256, ) locked_path = str(tmpdir.join("locked-prv.asc")) with open(locked_path, "w") as f: f.write(str(key)) ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=locked_path) result = ctrl.private_key() assert result is None @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_private_key_rejects_public_only_file(tmpdir): """private_key() rejects public-only files supplied by pgpprv=.""" ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile=_VALID_PUB_ASC, email="test@example.com" ) result = ctrl.private_key() assert result is None # autocrypt_header() must not advertise it either assert ctrl.autocrypt_header() is None # Detached PGP signatures @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_sign_returns_signature_and_micalg(tmpdir): """sign() returns a (sig_str, micalg) tuple for valid message + key.""" ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) result = ctrl.sign("Hello, World!") assert result is not None sig_str, micalg = result # The signature must be a non-empty armored block assert "BEGIN PGP SIGNATURE" in sig_str # The micalg must start with 'pgp-' assert micalg.startswith("pgp-") def test_sign_returns_none_when_no_private_key(tmpdir): """sign() returns None when no private key is available.""" ctrl = ApprisePGPController(path=str(tmpdir)) assert ctrl.sign("test message") is None @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_sign_returns_none_on_pgp_error(tmpdir): """sign() returns None when pgpy raises PGPError during signing.""" import pgpy ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) with mock.patch.object( ctrl.private_key().__class__, "sign", side_effect=pgpy.errors.PGPError("fail"), ): result = ctrl.sign("test") assert result is None def test_sign_returns_none_when_pgpy_missing(tmpdir): """sign() returns None gracefully when PGPy is not installed.""" ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) # Patch private_key() to return a mock that raises NameError on sign() mock_key = mock.Mock() mock_key.sign.side_effect = NameError("pgpy not installed") with mock.patch.object(ctrl, "private_key", return_value=mock_key): result = ctrl.sign("test") assert result is None # Public-key candidate order def test_pub_key_candidates_no_email(tmpdir): """_pub_key_candidates() returns the four generic fallbacks when no email is known and no extra addresses are supplied.""" ctrl = ApprisePGPController(path=str(tmpdir)) # No self.email and no caller-supplied email candidates = ctrl._pub_key_candidates() # Only the four generic names should appear assert candidates == [ "pgp-public.asc", "pgp-pub.asc", "public.asc", "pub.asc", ] def test_pub_key_candidates_with_email(tmpdir): """_pub_key_candidates() prepends email-specific names ahead of generics, highest priority first (full-email before localpart).""" ctrl = ApprisePGPController(path=str(tmpdir), email="Chris@Example.com") candidates = ctrl._pub_key_candidates() # Full lowercase email is highest priority (index 0) assert candidates[0] == "chris@example.com-pub.asc" # Localpart shorthand is next assert candidates[1] == "chris-pub.asc" # Generic fallbacks follow assert "pgp-pub.asc" in candidates def test_pub_key_candidates_extra_email(tmpdir): """Recipient lookups never fall back to the sender's key.""" ctrl = ApprisePGPController(path=str(tmpdir), email="sender@example.com") # Simulate a lookup for a recipient address candidates = ctrl._pub_key_candidates("recipient@example.com") assert candidates[0] == "recipient@example.com-pub.asc" assert candidates[1] == "recipient-pub.asc" # Sender entries must not appear in a recipient lookup. assert "sender@example.com-pub.asc" not in candidates assert "sender-pub.asc" not in candidates # Private-key candidate order def test_prv_key_candidates_no_email(tmpdir): """_prv_key_candidates() returns the four generic fallbacks when no sender email is configured.""" ctrl = ApprisePGPController(path=str(tmpdir)) candidates = ctrl._prv_key_candidates() assert candidates == [ "pgp-private.asc", "pgp-prv.asc", "private.asc", "prv.asc", ] def test_prv_key_candidates_with_email(tmpdir): """_prv_key_candidates() prepends email-specific names ahead of generics, highest priority first (full-email before localpart).""" ctrl = ApprisePGPController(path=str(tmpdir), email="Sender@Nuxref.com") candidates = ctrl._prv_key_candidates() # Full lowercase email is highest priority assert candidates[0] == "sender@nuxref.com-prv.asc" # Localpart shorthand is next assert candidates[1] == "sender-prv.asc" # Generic fallbacks follow assert "pgp-prv.asc" in candidates # Missing public-key diagnostics def test_public_key_no_key_emits_path_debug(tmpdir): """public_key() emits a DEBUG message listing relative cache paths when no public key is found and autogen is disabled. The message is debug- level so the caller's warning remains the only user-visible line.""" ctrl = ApprisePGPController(path=str(tmpdir), email="user@example.com") logging.disable(logging.NOTSET) try: with mock.patch.object(pgp_module, "PGP_SUPPORT", True): result = ctrl.public_key("user@example.com", autogen=False) finally: logging.disable(logging.CRITICAL) # Key not found; result must be None assert result is None def test_public_key_no_key_no_path_emits_generic_debug(): """public_key() emits a generic 'No PGP public key found' debug message in memory-only mode (no path to search, so no filenames to list).""" ctrl = ApprisePGPController(path=None) logging.disable(logging.NOTSET) try: with mock.patch.object(pgp_module, "PGP_SUPPORT", True): result = ctrl.public_key("user@example.com", autogen=False) finally: logging.disable(logging.CRITICAL) assert result is None # Missing private-key diagnostics def test_private_key_not_found_emits_path_debug(tmpdir): """private_key() emits a DEBUG message listing searched relative paths when no private key file exists on disk. The message is debug-level so the caller's warning remains the only user-visible line.""" ctrl = ApprisePGPController(path=str(tmpdir), email="sender@example.com") logging.disable(logging.NOTSET) try: result = ctrl.private_key() finally: logging.disable(logging.CRITICAL) assert result is None def test_private_key_not_found_no_path_emits_generic_debug(): """private_key() emits a generic 'No PGP private key found' debug message in memory-only mode (no storage path, nothing to search).""" ctrl = ApprisePGPController(path=None) logging.disable(logging.NOTSET) try: result = ctrl.private_key() finally: logging.disable(logging.CRITICAL) assert result is None caronc-apprise-dfec414/tests/test_utils_sanitize.py000066400000000000000000000172411526720371000227450ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Unit tests for :mod:`apprise.utils.sanitize`.""" from __future__ import annotations from hashlib import sha256 import logging import sys import pytest from apprise.utils.sanitize import SanitizeOptions, sanitize_payload # Disable logging for a cleaner testing output logging.disable(logging.CRITICAL) sys.dont_write_bytecode = True class _ReprOnly: """Helper type that only supports repr(), used to cover fallback paths.""" def __repr__(self) -> str: return "" def test_sanitize_payload_passthrough_primitives() -> None: """Primitives should pass through with minimal transformation.""" assert sanitize_payload(None) is None assert sanitize_payload(True) is True assert sanitize_payload(False) is False assert sanitize_payload(123) == 123 assert sanitize_payload(3.14) == pytest.approx(3.14) def test_sanitize_payload_small_string_passthrough() -> None: """Small strings are not altered, preserving debugging usefulness.""" s = "hello world" assert sanitize_payload(s) == s def test_sanitize_payload_large_string_is_summarized() -> None: """Strings beyond max_str_len are summarized with head/tail previews.""" opts = SanitizeOptions(max_str_len=64, preview=8) s = "x" * 200 out = sanitize_payload(s, options=opts) assert isinstance(out, str) assert out.startswith(" None: """Bytes keys become readable string markers rather than raw bytes.""" opts = SanitizeOptions(hash_sample_size=16) bkey = b"abc" payload = { bkey: "value1", "k": "v", } out = sanitize_payload(payload, options=opts) assert isinstance(out, dict) expected_b_digest = sha256(bkey[: opts.hash_sample_size]).hexdigest()[:12] bkey_s = f"" assert bkey_s in out assert out[bkey_s] == "value1" assert out["k"] == "v" def test_sanitize_payload_sequence_types() -> None: """Lists, tuples, sets, and frozensets are walked safely.""" payload = [1, "x" * 200, b"xyz"] opts = SanitizeOptions(max_str_len=64, preview=8) out = sanitize_payload(payload, options=opts) assert isinstance(out, list) assert out[0] == 1 assert isinstance(out[1], str) and out[1].startswith(" None: """Self-referential objects should not trigger infinite recursion.""" payload: dict[str, object] = {} payload["self"] = payload out = sanitize_payload(payload) assert isinstance(out, dict) assert out["self"] == "" def test_sanitize_payload_max_depth_truncation() -> None: """Depth limits protect against overly deep nested structures.""" opts = SanitizeOptions(max_depth=2) payload = {"a": {"b": {"c": "d"}}} out = sanitize_payload(payload, options=opts) assert isinstance(out, dict) assert isinstance(out["a"], dict) assert isinstance(out["a"]["b"], dict) assert out["a"]["b"]["c"] == "" def test_sanitize_payload_max_items_truncation_in_list_branch() -> None: """Item limits protect against very large sequences.""" opts = SanitizeOptions(max_items=2) payload = [1, 2, 3, 4] out = sanitize_payload(payload, options=opts) assert isinstance(out, list) assert out[-1] == "" def test_sanitize_payload_global_item_limit_guard_message() -> None: """The global max_items guard stops work consistently.""" opts = SanitizeOptions(max_items=1) payload = {"a": "x", "b": "y"} out = sanitize_payload(payload, options=opts) assert isinstance(out, dict) assert any( v == "" or v == "..." for v in out.values() ) def test_sanitize_payload_falls_back_to_repr_for_unknown_objects() -> None: """Unknown objects are converted with repr() for logging.""" assert sanitize_payload(_ReprOnly()) == "" def test_sanitize_payload_blob_key_enables_blob_mode_and_summarizes() -> None: """Blob-like keys enable blob_mode and trigger .""" opts = SanitizeOptions( aggressive_blob_keys=True, # ensure normal summarization would NOT trigger max_str_len=999999, preview=8, ) payload = { # small string, but blob_mode forces summary "base64_attachments": "ABCDEF", } out = sanitize_payload(payload, options=opts) assert isinstance(out, dict) assert "base64_attachments" in out assert isinstance(out["base64_attachments"], str) assert out["base64_attachments"].startswith(" None: """ Verify non-string, non-bytes dictionary keys are preserved unchanged. This exercises the fallback path in _summarize_key(), ensuring unexpected key types (such as integers) are passed through safely during sanitisation. """ payload = { # int key triggers the "return k" branch 42: "value", # keep a normal key too "k": "v", } out = sanitize_payload(payload) assert isinstance(out, dict) assert 42 in out assert out[42] == "value" assert out["k"] == "v" caronc-apprise-dfec414/tests/test_utils_socket.py000066400000000000000000001024571526720371000224130ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import ssl from unittest import mock import pytest from apprise.exception import AppriseException, AppriseInvalidData from apprise.utils.socket import AppriseSocketError, SocketTransport # Disable logging for a cleaner testing output logging.disable(logging.CRITICAL) class _DummyFile: def __init__(self) -> None: self.flushed = 0 self.closed = 0 def flush(self) -> None: self.flushed += 1 def close(self) -> None: self.closed += 1 class _DummySocket: """ Minimal fake socket object, supports the subset used by SocketTransport. """ def __init__(self) -> None: self.blocking = True self.timeout = None self.closed = False self.shutdown_called = 0 self._recv_side_effect = None self._send_side_effect = None def setsockopt(self, *_args, **_kwargs) -> None: return None def bind(self, *_args, **_kwargs) -> None: return None def fileno(self) -> int: return 1 def settimeout(self, value): self.timeout = value def setblocking(self, value): self.blocking = bool(value) def connect(self, *_args, **_kwargs) -> None: return None def shutdown(self, *_args, **_kwargs) -> None: self.shutdown_called += 1 def close(self) -> None: self.closed = True def getsockname(self): return ("127.0.0.1", 12345) def getpeername(self): return ("203.0.113.10", 6667) def makefile(self, *_args, **_kwargs): return _DummyFile() def recv(self, *_args, **_kwargs): if self._recv_side_effect is not None: raise self._recv_side_effect return b"data" def send(self, *_args, **_kwargs): if self._send_side_effect is not None: raise self._send_side_effect return 4 def test_utils_socket_timeout_coerce(): """SocketTransport() Timeout Coercion.""" # None => None, None s = SocketTransport("example.com", 1, timeout=None) assert s._connect_timeout is None assert s._read_timeout is None # float => both s = SocketTransport("example.com", 1, timeout=2.5) assert s._connect_timeout == pytest.approx(2.5) assert s._read_timeout == pytest.approx(2.5) # tuple => (connect, read) s = SocketTransport("example.com", 1, timeout=(1.0, 3.0)) assert s._connect_timeout == pytest.approx(1.0) assert s._read_timeout == pytest.approx(3.0) # tuple with None is allowed s = SocketTransport("example.com", 1, timeout=(None, 3.0)) assert s._connect_timeout is None assert s._read_timeout == pytest.approx(3.0) # invalid types with pytest.raises(AppriseInvalidData): SocketTransport("example.com", 1, timeout="bad") with pytest.raises(AppriseInvalidData): SocketTransport("example.com", 1, timeout=(1.0, 2.0, 3.0)) with pytest.raises(AppriseInvalidData): SocketTransport("example.com", 1, timeout=-1.0) def test_utils_socket_properties_and_close_paths(): """SocketTransport() Properties And Close Paths.""" s = SocketTransport("example.com", 1) assert s.connected is False assert s.is_tls is False # Seed with dummy socket + wrappers s._sock = _DummySocket() s._rfile = _DummyFile() s._wfile = _DummyFile() s._is_tls = True s.local_addr = ("1.1.1.1", 1) s.remote_addr = ("2.2.2.2", 2) s.close() assert s._sock is None assert s._rfile is None assert s._wfile is None assert s._is_tls is False assert s.local_addr is None assert s.remote_addr is None def test_utils_socket_read_write_no_socket(): """SocketTransport() Can Read/Write - No Socket.""" s = SocketTransport("example.com", 1) assert s.can_read() is None assert s.can_write() is None def test_utils_socket_can_read_write_select_error(): """SocketTransport() Can Read/Write - select() Error.""" s = SocketTransport("example.com", 1) s._sock = _DummySocket() with mock.patch("select.select", side_effect=OSError()): assert s.can_read() is None assert s._sock is None s._sock = _DummySocket() with mock.patch("select.select", side_effect=OSError()): assert s.can_write() is None assert s._sock is None def test_utils_socket_can_read_write_socket_close(): """SocketTransport() Can Read/Write - Socket Close.""" s = SocketTransport("example.com", 1) s._sock = _DummySocket() # Exceptional socket list returned triggers close() with mock.patch("select.select", return_value=([], [], [s._sock])): assert s.can_read() is None assert s._sock is None s._sock = _DummySocket() with mock.patch("select.select", return_value=([], [], [s._sock])): assert s.can_write() is None assert s._sock is None def test_utils_socket_server_hostname_for_tls(): """SocketTransport() Server Hostname For TLS.""" s = SocketTransport("irc.example.com", 1, verify=True) assert s._server_hostname_for_tls() == "irc.example.com" s = SocketTransport("irc.example.com", 1, verify=False) assert s._server_hostname_for_tls() == "irc.example.com" def test_utils_socket_server_hostname_for_tls_ip_reverse(): """SocketTransport() Server Hostname For TLS IP Reverse DNS""" s = SocketTransport("203.0.113.10", 1, verify=True) with mock.patch( "socket.gethostbyaddr", return_value=("irc.example.com.", [], []) ): assert s._server_hostname_for_tls() == "irc.example.com" with mock.patch("socket.gethostbyaddr", side_effect=OSError()): assert s._server_hostname_for_tls() == "203.0.113.10" # verify=False => no lookup s = SocketTransport("203.0.113.10", 1, verify=False) with mock.patch("socket.gethostbyaddr") as m: assert s._server_hostname_for_tls() == "203.0.113.10" m.assert_not_called() def test_utils_socket_build_ssl_context(): """SocketTransport() Build SSL Context.""" class _DummySSLContext: def __init__(self, *, raise_on_minimum: bool) -> None: self.raise_on_minimum = raise_on_minimum self.options = 0 self.check_hostname = None self.verify_mode = None self._minimum_version = None @property def minimum_version(self): return self._minimum_version @minimum_version.setter def minimum_version(self, value): if self.raise_on_minimum: raise RuntimeError("minimum_version not supported") self._minimum_version = value # verify=True path with minimum_version support s = SocketTransport("example.com", 1, verify=True) ctx = _DummySSLContext(raise_on_minimum=False) with ( mock.patch("certifi.where", return_value="/tmp/ca.pem"), mock.patch("ssl.create_default_context", return_value=ctx) as m, ): result = s._build_ssl_context() assert result is ctx m.assert_called_once() assert ctx.check_hostname is True assert ctx.verify_mode == ssl.CERT_REQUIRED # TLS 1.2+ preferred path assert ctx.minimum_version == ssl.TLSVersion.TLSv1_2 # Compression disabled when supported if hasattr(ssl, "OP_NO_COMPRESSION"): assert (ctx.options & ssl.OP_NO_COMPRESSION) != 0 # verify=False path still enforces TLS 1.2+ s = SocketTransport("example.com", 1, verify=False) ctx = _DummySSLContext(raise_on_minimum=False) with mock.patch("ssl.create_default_context", return_value=ctx): result = s._build_ssl_context() assert result is ctx assert ctx.check_hostname is False assert ctx.verify_mode == ssl.CERT_NONE assert ctx.minimum_version == ssl.TLSVersion.TLSv1_2 # Fallback path: minimum_version setter fails, options are used instead s = SocketTransport("example.com", 1, verify=True) ctx = _DummySSLContext(raise_on_minimum=True) with ( mock.patch("certifi.where", return_value="/tmp/ca.pem"), mock.patch("ssl.create_default_context", return_value=ctx), ): result = s._build_ssl_context() assert result is ctx # minimum_version was not set assert ctx.minimum_version is None # Fallback disables older protocols when options are present if hasattr(ssl, "OP_NO_TLSv1"): assert (ctx.options & ssl.OP_NO_TLSv1) != 0 if hasattr(ssl, "OP_NO_TLSv1_1"): assert (ctx.options & ssl.OP_NO_TLSv1_1) != 0 def test_utils_socket_start_tls_no_socket_raises(): """SocketTransport() Start TLS No Socket Raises.""" s = SocketTransport("example.com", 1, secure=True) with pytest.raises(AppriseSocketError): s.start_tls() def test_utils_socket_start_tls_already_tls_noop(): """SocketTransport() Start TLS Already TLS Noop.""" s = SocketTransport("example.com", 1, secure=True) s._sock = _DummySocket() s._is_tls = True s.start_tls() assert s._sock is not None assert s._is_tls is True def test_utils_socket_tls_ssl_init_errors(): """SocketTransport() Start TLS/SSL Initialization Errors.""" s = SocketTransport("example.com", 1, secure=True) s._sock = _DummySocket() with mock.patch.object(s, "_build_ssl_context") as mctx: ctx = mock.Mock() ctx.wrap_socket.side_effect = ssl.SSLError("boom") mctx.return_value = ctx with pytest.raises(AppriseSocketError): s.start_tls() assert s._sock is None s._sock = _DummySocket() with mock.patch.object(s, "_build_ssl_context") as mctx: ctx = mock.Mock() ctx.wrap_socket.side_effect = OSError("boom") mctx.return_value = ctx with pytest.raises(AppriseSocketError): s.start_tls() assert s._sock is None def test_utils_socket_tls_tests(): """SocketTransport() TLS Coverage""" s = SocketTransport("example.com", 1, secure=True) base_sock = _DummySocket() s._sock = base_sock tls_sock = _DummySocket() ctx = mock.Mock() ctx.wrap_socket.return_value = tls_sock with ( mock.patch.object(s, "_build_ssl_context", return_value=ctx), mock.patch.object( s, "_server_hostname_for_tls", return_value="example.com" ), ): s.start_tls() assert s._sock is tls_sock assert s.is_tls is True assert s.local_addr == ("127.0.0.1", 12345) assert s.remote_addr == ("203.0.113.10", 6667) assert s._rfile is not None assert s._wfile is not None def test_utils_socket_connect(): """SocketTransport() Connect() Coverage.""" s = SocketTransport("example.com", 6667, secure=False, timeout=(1.0, 2.0)) dummy = _DummySocket() with mock.patch("socket.socket", return_value=dummy): s.connect() assert s.connected is True assert s.is_tls is False assert s.local_addr == ("127.0.0.1", 12345) assert s.remote_addr == ("203.0.113.10", 6667) # Failure path: connect raises s = SocketTransport("example.com", 6667, secure=False, timeout=(1.0, 2.0)) dummy = _DummySocket() dummy.connect = mock.Mock(side_effect=OSError("fail")) with mock.patch("socket.socket", return_value=dummy): with pytest.raises(AppriseSocketError): s.connect() assert s.connected is False def test_utils_socket_read_nonblocking(): """SocketTransport() Read Nonblocking.""" s = SocketTransport("example.com", 1) sock = _DummySocket() s._sock = sock # Non-blocking success assert s.read(blocking=False) == b"data" # Non-blocking no data sock._recv_side_effect = BlockingIOError() assert s.read(blocking=False) == b"" # Non-blocking OSError -> close + raise sock._recv_side_effect = OSError("boom") with pytest.raises(AppriseSocketError): s.read(blocking=False) assert s._sock is None def test_utils_socket_read_blocking_timeout(): """SocketTransport() Read Blocking Timeout.""" s = SocketTransport("example.com", 1, timeout=(1.0, 0.1)) sock = _DummySocket() s._sock = sock # Timeout path: can_read returns False => b"" with mock.patch.object(s, "can_read", return_value=False): assert s.read(blocking=True) == b"" # Indefinite path: wait_timeout=None, loop until can_read True s = SocketTransport("example.com", 1, timeout=None) sock = _DummySocket() s._sock = sock side_effect = [False, False, True] with mock.patch.object(s, "can_read", side_effect=side_effect): assert s.read(blocking=True) == b"data" def test_utils_socket_read_blocking_edge_cases() -> None: """SocketTransport() Read Blocking Edge Cases""" s = SocketTransport("example.com", 1, timeout=None) bad_sock = _DummySocket() good_sock = _DummySocket() good_sock.recv = mock.Mock(return_value=b"data") s._sock = bad_sock s._had_io = True def _close_side_effect() -> None: # emulate closing the socket if s._sock is not None: s._sock = None s._rfile = None s._wfile = None s._is_tls = False s.local_addr = None s.remote_addr = None def _connect_side_effect() -> None: s._sock = good_sock s._refresh_wrappers() with ( mock.patch.object(s, "close", side_effect=_close_side_effect), mock.patch.object(s, "connect", side_effect=_connect_side_effect), mock.patch.object(s, "can_read", return_value=None), ): # can_read(None) triggers reconnect branch, then recv() on new socket assert s.read(blocking=True, retries=1) == b"data" def test_utils_socket_write_with_no_socket(): """SocketTransport() write() - No Socket.""" s = SocketTransport("example.com", 1) with pytest.raises(AppriseSocketError): s.write(b"hi") s._sock = _DummySocket() with pytest.raises(AppriseInvalidData): s.write("hi") # type: ignore[arg-type] def test_utils_socket_write_flush(): """SocketTransport() write() - Flush.""" s = SocketTransport("example.com", 1, timeout=(1.0, 0.2)) sock = _DummySocket() s._sock = sock s._wfile = _DummyFile() # Normal send, flush=True with mock.patch.object(s, "can_write", return_value=True): assert s.write(b"test", flush=True) == 4 assert s._wfile.flushed >= 1 # send returning 0 triggers connection lost sock = _DummySocket() sock.send = mock.Mock(return_value=0) s._sock = sock with pytest.raises(AppriseSocketError): s.write(b"test", timeout=0.1) # Timeout path: can_write returns False sock = _DummySocket() s._sock = sock with ( mock.patch.object(s, "can_write", return_value=False), pytest.raises(AppriseSocketError), ): s.write(b"test", timeout=0.1) # OSError triggers close sock = _DummySocket() sock._send_side_effect = OSError("boom") s._sock = sock with pytest.raises(AppriseSocketError): s.write(b"test", timeout=0.1) assert s._sock is None def test_utils_socket_read_timeouts(): """SocketTransport() Read Timeouts""" # Negative connect timeout with pytest.raises(AppriseInvalidData): SocketTransport("example.com", 1, timeout=(-0.1, 1.0)) # Negative read timeout with pytest.raises(AppriseInvalidData): SocketTransport("example.com", 1, timeout=(1.0, -0.1)) # Force the read_t is None branch s = SocketTransport("example.com", 1, timeout=(1.0, None)) assert s._connect_timeout == pytest.approx(1.0) assert s._read_timeout is None def test_utils_socket_exceptions(): """SocketTransport() Exception Testing.""" assert issubclass(AppriseSocketError, AppriseException) def test_utils_socket_close_exceptions(): """SocketTransport() Close Exceptions.""" class _BadShutdownSocket(_DummySocket): def shutdown(self, *_args, **_kwargs) -> None: raise Exception("shutdown fail") class _BadFile(_DummyFile): def flush(self) -> None: raise Exception("flush fail") def close(self) -> None: raise Exception("close fail") s = SocketTransport("example.com", 1) s._sock = _BadShutdownSocket() s._wfile = _BadFile() s._rfile = _BadFile() # Should not raise, despite flush/close/shutdown throwing s.close() assert s._sock is None assert s._wfile is None assert s._rfile is None def test_utils_socket_refresh(): """SocketTransport() Refresh.""" s = SocketTransport("example.com", 1) s._sock = None s._rfile = _DummyFile() s._wfile = _DummyFile() s._refresh_wrappers() assert s._rfile is None assert s._wfile is None def test_utils_socket_can_read_returns_bool(): """SocketTransport() Can Read Returns Bool.""" s = SocketTransport("example.com", 1) s._sock = _DummySocket() with mock.patch("select.select", return_value=([s._sock], [], [])): assert s.can_read(0.01) is True with mock.patch("select.select", return_value=([], [], [])): assert s.can_read(0.01) is False def test_utils_socket_connect_bind(): """SocketTransport() bind() tests""" s = SocketTransport( "example.com", 6667, bind_addr="127.0.0.1", bind_port=0 ) dummy = _DummySocket() dummy.bind = mock.Mock() with mock.patch("socket.socket", return_value=dummy): s.connect() dummy.bind.assert_called_once() def test_utils_socket_connect_settimeout_handling(): """SocketTransport() Connect settimeout() handling.""" s = SocketTransport("example.com", 6667, timeout=None) dummy = _DummySocket() dummy.settimeout = mock.Mock() with mock.patch("socket.socket", return_value=dummy): s.connect() # called once with None after connect() (sock.settimeout(None)) # but NOT called with a float connect timeout prior to connect. assert dummy.settimeout.call_args_list == [mock.call(None)] def test_utils_socket_secure_connect(): """SocketTransport() Secure connect().""" s = SocketTransport("example.com", 6667, secure=True) dummy = _DummySocket() with ( mock.patch("socket.socket", return_value=dummy), mock.patch.object(s, "start_tls", autospec=True) as m, ): s.connect() m.assert_called_once() def test_utils_socket_connect_exceptions(): """SocketTransport() Connect Exceptions.""" class _BadCloseSocket(_DummySocket): def connect(self, *_args, **_kwargs) -> None: raise OSError("connect fail") def close(self) -> None: raise Exception("close fail") s = SocketTransport("example.com", 6667, secure=False) dummy = _BadCloseSocket() with ( mock.patch("socket.socket", return_value=dummy), pytest.raises(AppriseSocketError), ): s.connect() # transport should not keep the socket assert s.connected is False def test_utils_socket_write_deadline(): """SocketTransport() Write Deadline.""" s = SocketTransport("example.com", 1, timeout=(1.0, 0.2)) sock = _DummySocket() s._sock = sock with mock.patch.object(s, "can_write", return_value=True) as m_can_write: written = s.write(b"test", timeout=0.1, flush=False) assert written == 4 assert m_can_write.called def test_utils_socket_write_timeout(): """SocketTransport() write() timeout.""" s = SocketTransport("example.com", 1, timeout=(1.0, 0.2)) s._sock = _DummySocket() # First monotonic call sets deadline, second call makes remaining <= 0 with mock.patch("time.monotonic", side_effect=[1000.0, 1000.5]): with pytest.raises(AppriseSocketError) as e: s.write(b"test", timeout=0.1, flush=False) assert "Timed out during write" in str(e.value) s = SocketTransport("example.com", 1, timeout=None) sock = _DummySocket() s._sock = sock s._wfile = _DummyFile() # If deadline is None, can_write should never be called with mock.patch.object(s, "can_write") as m_can_write: result = s.write(b"test", flush=True, timeout=None) assert result == 4 m_can_write.assert_not_called() def test_utils_socket_write_flush_edge_cases(): """SocketTransport() write() flush() edge cases.""" s = SocketTransport("example.com", 1, timeout=(1.0, 0.2)) s._sock = _DummySocket() s._wfile = _DummyFile() with mock.patch.object(s._wfile, "flush") as m: with mock.patch.object(s, "can_write", return_value=True): written = s.write(b"test", flush=False, timeout=0.1) assert written == 4 m.assert_not_called() s = SocketTransport("example.com", 1, timeout=(1.0, 0.2)) s._sock = _DummySocket() s._wfile = None # Should succeed and simply skip flush with mock.patch.object(s, "can_write", return_value=True): assert s.write(b"test", flush=True, timeout=0.1) == 4 def test_utils_socket_recv_error(): """SocketTransport() recv() errors Covers blocking read() OSError handling after readiness check. """ s = SocketTransport("example.com", 1, timeout=(1.0, 0.5)) sock = _DummySocket() sock._recv_side_effect = OSError("recv failed") s._sock = sock with ( mock.patch.object(s, "can_read", return_value=True), pytest.raises(AppriseSocketError) as e, ): s.read(blocking=True) assert "recv failed" in str(e.value) assert s._sock is None def test_utils_socket_write_empty_payload_does_not_set_had_io() -> None: """SocketTransport() Write Empty Payload Does Not Set Had Io. Covers the branch where total_sent == 0, so _had_io is not updated. """ s = SocketTransport("example.com", 1, timeout=None) s._sock = _DummySocket() s._wfile = _DummyFile() # Empty payload performs no send() calls sent = s.write(b"", flush=False, timeout=None) assert sent == 0 assert s._had_io is False def test_utils_socket_write_handling() -> None: """SocketTransport() Write handling""" s = SocketTransport("example.com", 1, timeout=None) s._sock = _DummySocket() # Mark prior I/O so reconnect is considered eligible in real logic, but we # patch _attempt_reconnect to force the continue path deterministically. s._had_io = True # Force the send to fail with an AppriseSocketError s._sock.send = mock.Mock(side_effect=AppriseSocketError("boom")) # retries=0 => attempts == 1; we force _attempt_reconnect to return True, # which triggers `continue` and immediately exhausts the loop. with ( mock.patch.object(s, "_attempt_reconnect", return_value=True), pytest.raises(AppriseSocketError) as e, ): s.write(b"test", timeout=None, retries=0) assert "Socket write failed" in str(e.value) def test_utils_socket_write_reconnect_continue_path_is_reached() -> None: """SocketTransport() Write Reconnect Continue Path Is Reached. Covers the `continue` path in write() when _attempt_reconnect returns True. Trigger AppriseSocketError (not OSError) so we do not reset _had_io via. close() before reconnect eligibility is checked. """ s = SocketTransport("example.com", 1, timeout=None) bad_sock = _DummySocket() # send returning 0 triggers "Connection lost during write" bad_sock.send = mock.Mock(return_value=0) good_sock = _DummySocket() good_sock.send = mock.Mock(return_value=4) s._sock = bad_sock s._had_io = True def _connect_side_effect() -> None: s._sock = good_sock s._refresh_wrappers() with mock.patch.object(s, "connect", side_effect=_connect_side_effect): assert s.write(b"test", timeout=None, retries=1, flush=False) == 4 def test_utils_socket_read_reconnect_continue_path_is_reached() -> None: """SocketTransport() Read Reconnect Continue Path Is Reached. Covers the `continue` path in read() when _attempt_reconnect returns True. Trigger AppriseSocketError (not OSError) so we do not reset _had_io via. close() before reconnect eligibility is checked. """ s = SocketTransport("example.com", 1, timeout=None) bad_sock = _DummySocket() # recv returning b"" triggers "Connection lost during read" bad_sock.recv = mock.Mock(return_value=b"") good_sock = _DummySocket() good_sock.recv = mock.Mock(return_value=b"data") s._sock = bad_sock s._had_io = True def _connect_side_effect() -> None: s._sock = good_sock s._refresh_wrappers() with mock.patch.object(s, "connect", side_effect=_connect_side_effect): assert s.read(blocking=False, retries=1) == b"data" def test_utils_socket_attempt_reconnect_retries_zero_returns_false() -> None: """SocketTransport() Attempt Reconnect Retries Zero Returns False. Covers _attempt_reconnect() early return when retries <= 0. """ s = SocketTransport("example.com", 1, timeout=None) s._had_io = True assert ( s._attempt_reconnect( retries=0, action="read", exc=Exception("boom"), ) is False ) def test_utils_socket_read_blocking_connection() -> None: """SocketTransport() Read Blocking Connection.""" s = SocketTransport("example.com", 1, timeout=(1.0, 0.5)) sock = _DummySocket() sock.recv = mock.Mock(return_value=b"") s._sock = sock # Mark prior I/O, but retries=0 means reconnect is not allowed anyway s._had_io = True with ( mock.patch.object(s, "can_read", return_value=True), pytest.raises(AppriseSocketError) as e, ): s.read(blocking=True, retries=0) assert "Connection lost during read" in str(e.value) def test_utils_socket_read_blocking() -> None: """SocketTransport() Read Blocking""" s = SocketTransport("example.com", 1, timeout=(1.0, 0.5)) bad_sock = _DummySocket() bad_sock.recv = mock.Mock(return_value=b"") good_sock = _DummySocket() good_sock.recv = mock.Mock(return_value=b"data") s._sock = bad_sock s._had_io = True def _connect_side_effect() -> None: s._sock = good_sock s._refresh_wrappers() with ( mock.patch.object(s, "can_read", return_value=True), mock.patch.object(s, "connect", side_effect=_connect_side_effect), ): assert s.read(blocking=True, retries=1) == b"data" s = SocketTransport("example.com", 1, timeout=(1.0, 0.5)) sock = _DummySocket() s._sock = sock with mock.patch.object(s, "can_read", return_value=True): assert s.read(blocking=True) == b"data" s = SocketTransport("example.com", 1, timeout=None) s._sock = _DummySocket() with mock.patch.object(s, "can_read", return_value=None): with pytest.raises(AppriseSocketError) as e: s.read(blocking=True) assert "Socket closed" in str(e.value) s = SocketTransport("example.com", 1, timeout=(1.0, 0.5)) s._sock = _DummySocket() with mock.patch.object(s, "can_read", return_value=False): assert s.read(blocking=True) == b"" def test_utils_socket_read_edge_cases(): """SocketTransport() read() edge case tests.""" s = SocketTransport("example.com", 1) s._sock = None assert s.read() == b"" s = SocketTransport("example.com", 1, timeout=None) sock = _DummySocket() sock.recv = mock.Mock(return_value=b"") # triggers AppriseSocketError path s._sock = sock s._had_io = True with ( mock.patch.object(s, "_attempt_reconnect", return_value=True), pytest.raises(AppriseSocketError) as e, ): # retries=0 => attempts starts at 1, decremented to 0 in-loop s.read(blocking=False, retries=0) assert "Socket read failed" in str(e.value) def test_utils_socket_attempt_reconnect_no_io() -> None: """SocketTransport() _attempt_reconnect() returns False""" s = SocketTransport("example.com", 1, timeout=None) # Explicitly not eligible for reconnect s._had_io = False with ( mock.patch.object(s, "close") as m_close, mock.patch.object(s, "connect") as m_connect, ): assert ( s._attempt_reconnect( retries=1, action="read", exc=Exception("boom"), ) is False ) # Should not attempt any reconnect work m_close.assert_not_called() m_connect.assert_not_called() def test_utils_socket_attempt_reconnect_connect_exception() -> None: """SocketTransport() _attempt_reconnect() handles exception.""" s = SocketTransport("example.com", 1, timeout=None) # Eligible for reconnect s._had_io = True with ( mock.patch.object(s, "close") as m_close, mock.patch.object( s, "connect", side_effect=Exception("nope") ) as m_connect, ): assert ( s._attempt_reconnect( retries=1, action="write", exc=Exception("boom"), ) is False ) m_close.assert_called_once() m_connect.assert_called_once() def test_utils_socket_ssl_read_blocking() -> None: """SocketTransport() read() blocking""" s = SocketTransport("example.com", 1, timeout=None) sock = _DummySocket() # First recv indicates TLS wants more reads, second returns data sock.recv = mock.Mock(side_effect=[ssl.SSLWantReadError(), b"data"]) s._sock = sock # Indefinite blocking, so we must reach recv() and then loop on WANT_READ with mock.patch.object(s, "can_read", return_value=True): assert s.read(blocking=True, timeout=None) == b"data" # Coverage for read blocking, WANT_WRITE s = SocketTransport("example.com", 1, timeout=(1.0, 0.5)) sock = _DummySocket() # Trigger the WANT_WRITE/BlockingIOError handling inside the recv loop sock.recv = mock.Mock(side_effect=ssl.SSLWantWriteError()) s._sock = sock # 1) initial readiness check returns True (enter recv loop) # 2) inner loop calls can_read(min(...)) and gets False, returning b"" with mock.patch.object(s, "can_read", side_effect=[True, False]): assert s.read(blocking=True, timeout=0.5) == b"" s = SocketTransport("example.com", 1, timeout=(1.0, 0.5)) sock = _DummySocket() # WANT_WRITE first, then a successful read sock.recv = mock.Mock(side_effect=[ssl.SSLWantWriteError(), b"data"]) s._sock = sock # can_read used twice: # - readiness check (True) # - busy-loop avoidance check (True) with mock.patch.object(s, "can_read", side_effect=[True, True]): assert s.read(blocking=True, timeout=0.5) == b"data" s = SocketTransport("example.com", 1, timeout=None) bad_sock = _DummySocket() # In blocking read, b"" triggers "Connection lost during read" bad_sock.recv = mock.Mock(return_value=b"") good_sock = _DummySocket() # This targets the post-reconnect immediate recv path and forces b"" there good_sock.recv = mock.Mock(return_value=b"") s._sock = bad_sock s._had_io = True def _connect_side_effect() -> None: s._sock = good_sock s._refresh_wrappers() # Ensure we enter the blocking path and do the initial recv that fails, # then reconnect, then attempt immediate recv on the new socket. with ( mock.patch.object(s, "can_read", return_value=True), mock.patch.object(s, "connect", side_effect=_connect_side_effect), ): with pytest.raises(AppriseSocketError) as e: s.read(blocking=True, retries=1) assert "Connection lost during read" in str(e.value) s = SocketTransport("example.com", 1, timeout=None) bad_sock = _DummySocket() bad_sock.recv = mock.Mock(return_value=b"") s._sock = bad_sock s._had_io = True good_sock = _DummySocket() # - First call is the post-reconnect immediate recv: it raises # BlockingIOError # - Second call occurs in the next loop iteration after can_read says True good_sock.recv = mock.Mock(side_effect=[BlockingIOError(), b"data"]) def _connect_side_effect() -> None: s._sock = good_sock s._refresh_wrappers() # can_read must return True so we get to recv() on the next iteration with ( mock.patch.object(s, "can_read", return_value=True), mock.patch.object(s, "connect", side_effect=_connect_side_effect), ): assert s.read(blocking=True, retries=1) == b"data" caronc-apprise-dfec414/tests/test_utils_wkd.py000066400000000000000000000671701526720371000217120ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from datetime import datetime, timedelta, timezone import logging from unittest import mock from urllib.parse import urlparse import pytest import requests from apprise.utils.wkd import AppriseWKDController, AppriseWKDException logging.disable(logging.CRITICAL) # --------------------------------------------------------------------------- # AppriseWKDException # --------------------------------------------------------------------------- def test_wkd_exception_default_error_code(): """AppriseWKDException uses error_code 610 by default.""" exc = AppriseWKDException("something went wrong") assert exc.error_code == 610 # --------------------------------------------------------------------------- # zb32_encode # --------------------------------------------------------------------------- def test_zb32_encode_all_zeros(): """20 zero bytes encode to 32 'y' characters (index 0 of the alphabet).""" result = AppriseWKDController.zb32_encode(b"\x00" * 20) assert result == "y" * 32 def test_zb32_encode_all_ones(): """20 0xFF bytes encode to 32 '9' characters (index 31 of the alphabet).""" result = AppriseWKDController.zb32_encode(b"\xff" * 20) assert result == "9" * 32 def test_zb32_encode_sha1_length(): """SHA-1 digest (20 bytes) always produces exactly 32 z-base32 chars.""" import hashlib digest = hashlib.sha1(b"test@example.com").digest() result = AppriseWKDController.zb32_encode(digest) assert len(result) == 32 # All characters must belong to the z-base32 alphabet assert all(c in AppriseWKDController._ZB32 for c in result) def test_zb32_encode_empty(): """Empty input produces an empty string.""" assert AppriseWKDController.zb32_encode(b"") == "" def test_zb32_encode_single_byte(): """Single byte (5 bits + 3 leftover bits) produces 2 characters.""" # 0x00 = 00000 000 -> 'y' then 0x00 << 2 = 00000 -> 'y' result = AppriseWKDController.zb32_encode(b"\x00") assert len(result) == 2 assert result[0] == "y" def test_zb32_encode_known_vector(): """Verify z-base32 encoding against independently computed single-byte reference values. These expected strings are derived from first principles: each 5-bit group maps directly to _ZB32[value], and leftover bits are left-aligned. They do NOT call zb32_encode() to generate the expected string, so a regression in the alphabet or bit-shifting logic will cause at least one assertion to fail. 0xf8 = 11111000: group1 (bits 0-4): 11111 = 31 -> '9' leftover (bits 5-7) << 2: 00000 = 0 -> 'y' 0xa8 = 10101000: group1 (bits 0-4): 10101 = 21 -> 'i' leftover (bits 5-7) << 2: 00000 = 0 -> 'y' 0x08 = 00001000: group1 (bits 0-4): 00001 = 1 -> 'b' leftover (bits 5-7) << 2: 00000 = 0 -> 'y' """ assert AppriseWKDController.zb32_encode(b"\xf8") == "9y" assert AppriseWKDController.zb32_encode(b"\xa8") == "iy" assert AppriseWKDController.zb32_encode(b"\x08") == "by" # --------------------------------------------------------------------------- # wkd_urls # --------------------------------------------------------------------------- def test_wkd_urls_structure(): """wkd_urls() returns properly formed subdomain and direct URLs.""" sub, direct = AppriseWKDController.wkd_urls("user@example.com") assert sub.startswith("https://openpgpkey.example.com/") assert "/.well-known/openpgpkey/example.com/hu/" in sub assert "?l=user" in sub assert direct.startswith("https://example.com/") assert "/.well-known/openpgpkey/hu/" in direct assert "?l=user" in direct def test_wkd_urls_case_normalised(): """wkd_urls() produces the same hash and domain for differently-cased inputs, but preserves the original local-part case in ?l= (RFC 9080). The hash component uses the lower-cased local part (spec requirement), the domain is always lower-cased (URL canonicalisation), and the ?l= query parameter carries the unchanged original local part -- so two addresses that differ only in local-part capitalisation will share the same hash/path but differ in their ?l= value. """ sub_upper, dir_upper = AppriseWKDController.wkd_urls("User@Example.COM") sub_lower, dir_lower = AppriseWKDController.wkd_urls("user@example.com") # Hash and domain parts must be identical assert sub_upper.split("?")[0] == sub_lower.split("?")[0] assert dir_upper.split("?")[0] == dir_lower.split("?")[0] # ?l= must carry the original (unchanged) local part assert "?l=User" in sub_upper assert "?l=user" in sub_lower # Domain is always lower-cased regardless of input assert urlparse(sub_lower).hostname == "openpgpkey.example.com" def test_wkd_urls_l_param_preserves_original_case(): """?l= carries the unchanged local-part per RFC 9080. The spec example: Joe.Doe@Example.ORG -> ?l=Joe.Doe (original case, not lowercased) The hash is computed from the lowercased local part; only ?l= keeps the original capitalisation. """ sub, direct = AppriseWKDController.wkd_urls("Joe.Doe@Example.ORG") assert "?l=Joe.Doe" in sub assert "?l=Joe.Doe" in direct # Domain part is always lowercased in the URL host assert urlparse(sub).hostname == "openpgpkey.example.org" assert urlparse(direct).hostname == "example.org" def test_wkd_urls_local_part_encoded(): """wkd_urls() percent-encodes special characters in the local part.""" sub, direct = AppriseWKDController.wkd_urls("first+last@example.com") assert "first%2Blast" in sub assert "first%2Blast" in direct def test_wkd_urls_no_at_sign(): """wkd_urls() returns (None, None) for an address without '@'.""" sub, direct = AppriseWKDController.wkd_urls("notanemail") assert sub is None assert direct is None def test_wkd_urls_empty_string(): """wkd_urls() returns (None, None) for an empty string.""" sub, direct = AppriseWKDController.wkd_urls("") assert sub is None assert direct is None def test_wkd_urls_empty_local(): """wkd_urls() returns (None, None) when the local part is empty.""" sub, direct = AppriseWKDController.wkd_urls("@example.com") assert sub is None assert direct is None def test_wkd_urls_empty_domain(): """wkd_urls() returns (None, None) when the domain is empty.""" sub, direct = AppriseWKDController.wkd_urls("user@") assert sub is None assert direct is None def test_wkd_urls_none_input(): """wkd_urls() returns (None, None) for None input.""" sub, direct = AppriseWKDController.wkd_urls(None) assert sub is None assert direct is None def test_wkd_urls_non_string_input(): """wkd_urls() returns (None, None) for non-string truthy inputs. Without the isinstance guard, ``"@" not in 123`` raises TypeError before the try/except block can handle it. """ for bad in (123, 12.5, object(), ["user@example.com"]): sub, direct = AppriseWKDController.wkd_urls(bad) assert sub is None assert direct is None def test_wkd_urls_ipv4_domain_rejected(): """wkd_urls() returns (None, None) for emails with an IPv4 domain. WKD is defined for DNS hostnames only (RFC 9080). Allowing IP literals as the domain creates an SSRF vector because WKD discovery turns recipient domains into outbound HTTPS requests. """ for addr in ( "user@127.0.0.1", "user@192.168.1.1", "user@10.0.0.1", ): sub, direct = AppriseWKDController.wkd_urls(addr) assert sub is None, f"Expected None for IP domain: {addr}" assert direct is None, f"Expected None for IP domain: {addr}" def test_wkd_urls_lower_raises_attribute_error(): """wkd_urls() returns (None, None) when email.split() raises. Covers the defensive except branch for string-like objects that pass the '@' membership check but fail during splitting. (After the RFC 9080 case-preservation fix, split() is called before lower(), so breaking split() is what exercises this path.) """ class BrokenStr(str): def split(self, *args, **kwargs): raise AttributeError("broken split") sub, direct = AppriseWKDController.wkd_urls(BrokenStr("user@example.com")) assert sub is None assert direct is None def test_wkd_urls_domain_injection_rejected(): """wkd_urls() returns (None, None) for emails whose domain contains characters that would rewrite the URL host. user@legit.com@attacker.test splits to domain='legit.com@attacker.test' which, when embedded in an HTTPS URL, sends the request to attacker.test with legit.com as URL credentials. The is_hostname() guard rejects it. """ for bad_email in ( "user@example.com@attacker.test", "user@example.com/path", "user@example.com:8080", "user@example.com space", ): sub, direct = AppriseWKDController.wkd_urls(bad_email) assert sub is None, f"Expected None for domain injection: {bad_email}" assert direct is None, ( f"Expected None for domain injection: {bad_email}" ) # --------------------------------------------------------------------------- # AppriseWKDController.fetch -- happy path # --------------------------------------------------------------------------- def _make_resp(status_code, chunks=None, url=None): """Build a context-manager-compatible requests.Response mock. ``chunks`` is a list of byte strings that iter_content() will yield. Pass an empty list for an empty body. Leave as None when the status code is non-200 and the body will never be consumed. ``url`` sets r.url (the final URL after any redirects). When omitted the attribute is a MagicMock, which passes the HTTPS check because MagicMock().lower().startswith() is truthy. """ resp = mock.MagicMock(status_code=status_code) resp.__enter__ = mock.Mock(return_value=resp) resp.__exit__ = mock.Mock(return_value=False) # Default to an empty headers dict so r.headers.get("Content-Length") # returns None; tests that need a specific header override this resp.headers = {} if url is not None: resp.url = url if chunks is not None: resp.iter_content.return_value = iter(chunks) return resp @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_fetch_subdomain_success(mock_get): """fetch() returns key bytes when the subdomain URL responds with 200.""" # 0x99 is a valid old-format OpenPGP packet header byte (bit 7 set) key_bytes = b"\x99fake-openpgp-key-data" # Subdomain call succeeds; iter_content yields the key in one chunk mock_get.return_value = _make_resp(requests.codes.ok, chunks=[key_bytes]) ctrl = AppriseWKDController() result = ctrl.fetch("user@example.com") assert result == key_bytes assert mock_get.call_count == 1 @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_fetch_direct_fallback(mock_get): """fetch() tries the direct URL when the subdomain URL fails.""" key_bytes = b"\x99another-fake-key" # Subdomain -> 404 (body never consumed), direct -> 200 with key bytes mock_get.side_effect = [ _make_resp(404), _make_resp(requests.codes.ok, chunks=[key_bytes]), ] ctrl = AppriseWKDController() result = ctrl.fetch("user@example.com") assert result == key_bytes assert mock_get.call_count == 2 @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_fetch_both_fail(mock_get): """fetch() returns None when both WKD URLs fail.""" mock_get.return_value = _make_resp(404) ctrl = AppriseWKDController() result = ctrl.fetch("user@example.com") assert result is None assert mock_get.call_count == 2 # --------------------------------------------------------------------------- # fetch -- cache behaviour # --------------------------------------------------------------------------- @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_fetch_caches_result(mock_get): """A successful fetch is cached; subsequent calls skip the network.""" key_bytes = b"\x99cached-key" mock_get.return_value = _make_resp(requests.codes.ok, chunks=[key_bytes]) ctrl = AppriseWKDController() result1 = ctrl.fetch("user@example.com") result2 = ctrl.fetch("user@example.com") assert result1 == key_bytes assert result2 == key_bytes # Network only hit once assert mock_get.call_count == 1 @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_fetch_cache_case_insensitive(mock_get): """Cache lookup normalises the email address.""" key_bytes = b"\x99normalised-key" mock_get.return_value = _make_resp(requests.codes.ok, chunks=[key_bytes]) ctrl = AppriseWKDController() ctrl.fetch("User@Example.COM") result = ctrl.fetch("user@example.com") assert result == key_bytes assert mock_get.call_count == 1 @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_fetch_expired_cache_refetches(mock_get): """An expired cache entry triggers a new network request.""" key_bytes = b"\x99refreshed-key" mock_get.return_value = _make_resp(requests.codes.ok, chunks=[key_bytes]) ctrl = AppriseWKDController() # Seed the cache with an already-expired entry ctrl._cache["user@example.com"] = { "data": b"stale-key", "expires": datetime.now(timezone.utc) - timedelta(seconds=1), } result = ctrl.fetch("user@example.com") assert result == key_bytes assert mock_get.call_count == 1 # --------------------------------------------------------------------------- # fetch -- invalid input # --------------------------------------------------------------------------- def test_fetch_no_at_sign(): """fetch() returns None for an address without '@'.""" ctrl = AppriseWKDController() assert ctrl.fetch("notanemail") is None def test_fetch_empty_string(): """fetch() returns None for an empty string.""" ctrl = AppriseWKDController() assert ctrl.fetch("") is None def test_fetch_none(): """fetch() returns None when called with None.""" ctrl = AppriseWKDController() assert ctrl.fetch(None) is None def test_fetch_non_string_input(): """fetch() returns None for non-string truthy inputs. Without the isinstance guard, ``"@" not in 123`` raises TypeError before the method can return None for invalid input. """ ctrl = AppriseWKDController() for bad in (123, 12.5, object(), ["user@example.com"]): assert ctrl.fetch(bad) is None def test_fetch_at_only(): """fetch() returns None for '@' (empty local and empty domain). '@' passes the '@' membership check but wkd_urls() returns (None, None) because both local and domain parts are empty -- covers the guard at the second validation point inside fetch(). """ ctrl = AppriseWKDController() assert ctrl.fetch("@") is None # --------------------------------------------------------------------------- # _get -- response validation # --------------------------------------------------------------------------- @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_get_non_200_returns_none(mock_get): """_get() returns None for any non-200 HTTP status.""" for code in (301, 400, 403, 404, 500): mock_get.return_value = _make_resp(code) ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") is None @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_get_empty_body_returns_none(mock_get): """_get() returns None when the response body is empty.""" mock_get.return_value = _make_resp(requests.codes.ok, chunks=[]) ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") is None @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_get_empty_chunk_is_skipped(mock_get): """_get() skips empty bytestrings yielded by iter_content(). Some HTTP adapters yield b"" sentinel chunks between real chunks; the in-loop ``if not chunk: continue`` guard must skip them so they do not corrupt the accumulated content or trigger a false empty-body result. """ # Interleave an empty sentinel before a valid PGP binary chunk key_bytes = b"\x99" + b"\x00" * 8 mock_get.return_value = _make_resp( requests.codes.ok, chunks=[b"", key_bytes] ) ctrl = AppriseWKDController() result = ctrl._get("https://example.com/key") assert result == key_bytes @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_get_oversized_via_content_length_returns_none(mock_get): """_get() rejects early when Content-Length already exceeds the limit. iter_content() must not be called at all -- we detect the oversize from the header before reading any bytes. """ ctrl = AppriseWKDController() resp = _make_resp(requests.codes.ok) resp.headers = {"Content-Length": str(ctrl.max_response_size + 1)} mock_get.return_value = resp assert ctrl._get("https://example.com/key") is None resp.iter_content.assert_not_called() @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_get_valid_content_length_within_limit_proceeds(mock_get): """_get() proceeds to read chunks when Content-Length is within the limit. This exercises the branch where content_length is not None and the parsed value does not exceed max_response_size. """ key_bytes = b"\x99valid-key" resp = _make_resp(requests.codes.ok, chunks=[key_bytes]) resp.headers = {"Content-Length": str(len(key_bytes))} mock_get.return_value = resp ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") == key_bytes @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_get_malformed_content_length_proceeds(mock_get): """_get() ignores a malformed Content-Length and reads chunks normally.""" key_bytes = b"\x99valid-key" resp = _make_resp(requests.codes.ok, chunks=[key_bytes]) resp.headers = {"Content-Length": "not-a-number"} mock_get.return_value = resp ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") == key_bytes @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_get_oversized_body_returns_none(mock_get): """_get() returns None when accumulated chunks exceed max_response_size.""" ctrl = AppriseWKDController() # Single chunk one byte over the limit triggers the in-loop guard oversized = b"\x99" + b"x" * ctrl.max_response_size mock_get.return_value = _make_resp(requests.codes.ok, chunks=[oversized]) assert ctrl._get("https://example.com/key") is None @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_get_non_pgp_body_returns_none(mock_get): """_get() returns None when the response body is not PGP data. A subdomain endpoint that is a parked domain or CDN may return HTTP 200 with HTML content. Without this guard, fetch() would cache the HTML bytes and never try the direct-method fallback URL. """ for non_pgp in ( b"Not a key", b"", b"{}", b"Not PGP data", ): mock_get.return_value = _make_resp(requests.codes.ok, chunks=[non_pgp]) ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") is None, ( f"Expected None for non-PGP content: {non_pgp[:20]}" ) @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_get_binary_pgp_packet_accepted(mock_get): """_get() accepts a response whose first byte has bit 7 set (OpenPGP binary packet format).""" # 0x99 is the old-format public-key packet header pgp_binary = b"\x99\x01\xd6" + b"\x00" * 100 mock_get.return_value = _make_resp(requests.codes.ok, chunks=[pgp_binary]) ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") == pgp_binary @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_get_ascii_armoured_key_accepted(mock_get): """_get() accepts ASCII-armoured PGP key material (starts with '-----').""" armoured = b"-----BEGIN PGP PUBLIC KEY BLOCK-----\n..." mock_get.return_value = _make_resp(requests.codes.ok, chunks=[armoured]) ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") == armoured @mock.patch( "apprise.utils.http.HTTPPolicySession.get", side_effect=requests.RequestException("timeout"), ) def test_get_request_exception_returns_none(mock_get): """_get() returns None when requests raises any RequestException.""" ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") is None @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_get_stream_oserror_returns_none(mock_get): """_get() returns None when iter_content() raises OSError mid-stream. Covers disk-full, connection-reset, and other IO-level failures that surface after the HTTP headers have been received successfully. """ resp = _make_resp(requests.codes.ok) resp.iter_content.side_effect = OSError("connection reset by peer") mock_get.return_value = resp ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") is None @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_get_stream_generic_exception_returns_none(mock_get): """_get() returns None when iter_content() raises an unexpected exception. Covers urllib3 IncompleteRead, read-timeout mid-stream, and any other exception not otherwise categorised. """ resp = _make_resp(requests.codes.ok) resp.iter_content.side_effect = Exception("unexpected streaming error") mock_get.return_value = resp ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") is None @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_get_http_redirect_rejected(mock_get): """_get() returns None when a redirect lands on a non-HTTPS URL. A redirect from HTTPS to HTTP would expose key material to interception and break the WKD trust model. """ resp = _make_resp( requests.codes.ok, chunks=[b"\x99key"], url="http://attacker.example.com/hu/abc", ) mock_get.return_value = resp ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") is None @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_get_success_returns_bytes(mock_get): """_get() returns the response bytes on HTTP 200 with content.""" payload = b"\x99\xaa\xbb\xcc" mock_get.return_value = _make_resp(requests.codes.ok, chunks=[payload]) ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") == payload # --------------------------------------------------------------------------- # prune # --------------------------------------------------------------------------- def test_prune_removes_expired_entries(): """prune() discards entries whose expiry has passed.""" ctrl = AppriseWKDController() now = datetime.now(timezone.utc) ctrl._cache["expired@example.com"] = { "data": b"old", "expires": now - timedelta(seconds=1), } ctrl._cache["fresh@example.com"] = { "data": b"new", "expires": now + timedelta(seconds=3600), } ctrl.prune() assert "expired@example.com" not in ctrl._cache assert "fresh@example.com" in ctrl._cache def test_prune_empty_cache(): """prune() does not raise when the cache is empty.""" ctrl = AppriseWKDController() ctrl.prune() # must not raise assert ctrl._cache == {} # --------------------------------------------------------------------------- # Constructor defaults # --------------------------------------------------------------------------- def test_defaults(): """AppriseWKDController initialises with expected default values.""" ctrl = AppriseWKDController() assert ctrl.verify_certificate is True assert ctrl.request_timeout == (4, 4) assert ctrl.allow_redirects is True assert ctrl._cache == {} def test_custom_timeout_and_verify(): """Constructor stores custom timeout and verify_certificate values.""" ctrl = AppriseWKDController( verify_certificate=False, request_timeout=(2, 10) ) assert ctrl.verify_certificate is False assert ctrl.request_timeout == (2, 10) def test_custom_allow_redirects(): """Constructor stores a custom allow_redirects value.""" ctrl = AppriseWKDController(allow_redirects=False) assert ctrl.allow_redirects is False def test_close_releases_session_once(): """Explicit cleanup closes and clears the owned HTTP session once.""" ctrl = AppriseWKDController() session = mock.Mock() ctrl.http_session = session ctrl.close() ctrl.close() session.close.assert_called_once_with() assert ctrl.http_session is None def test_close_clears_session_when_close_raises(): """A failed explicit close still leaves later cleanup harmless.""" ctrl = AppriseWKDController() session = mock.Mock() session.close.side_effect = RuntimeError("close failed") ctrl.http_session = session with pytest.raises(RuntimeError, match="close failed"): ctrl.close() assert ctrl.http_session is None def test_context_manager_closes_session(): """The context-manager lifecycle closes its session on exit.""" ctrl = AppriseWKDController() session = mock.Mock() ctrl.http_session = session with ctrl as entered: assert entered is ctrl session.close.assert_called_once_with() assert ctrl.http_session is None def test_destructor_suppresses_close_failure(): """Fallback cleanup cannot leak a session close exception.""" ctrl = object.__new__(AppriseWKDController) ctrl.http_session = mock.Mock() ctrl.http_session.close.side_effect = RuntimeError("close failed") ctrl.__del__() assert ctrl.http_session is None @mock.patch("apprise.utils.http.HTTPPolicySession.get") def test_get_passes_verify_and_timeout(mock_get): """_get() forwards verify_certificate, request_timeout, allow_redirects, and stream=True to requests.""" mock_get.return_value = _make_resp(requests.codes.ok, chunks=[b"\x99key"]) ctrl = AppriseWKDController( verify_certificate=False, request_timeout=(1, 5), allow_redirects=False ) ctrl._get("https://example.com/key") _, kwargs = mock_get.call_args assert kwargs["verify"] is False assert kwargs["timeout"] == (1, 5) assert kwargs["allow_redirects"] is False assert kwargs["stream"] is True caronc-apprise-dfec414/tests/test_utils_xml.py000066400000000000000000000156001526720371000217140ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import sys from apprise.utils.xml import flatten_xml_response KEEP_MAP = { "RequestId": "request_id", "MessageId": "message_id", } def test_flatten_xml_response_extracts_mapped_tags(): """Tags listed in keep_map are pulled out under their mapped key.""" response = flatten_xml_response( """ abc-123 req-456 """, KEEP_MAP, ) assert response["type"] == "PublishResponse" assert response["message_id"] == "abc-123" assert response["request_id"] == "req-456" def test_flatten_xml_response_repeated_tag_keeps_last_value(): """Repeated mapped tags retain the last value in document order.""" response = flatten_xml_response( "" "first" "last" "", KEEP_MAP, ) assert response["request_id"] == "last" def test_flatten_xml_response_strips_root_namespace(): """A namespace on the root tag doesn't stop tags from being matched.""" response = flatten_xml_response( '' "req-456" "", KEEP_MAP, ) assert response["type"] == "PublishResponse" assert response["request_id"] == "req-456" def test_flatten_xml_response_empty_known_tag_is_blank_not_a_crash(): """A self-closed or empty known tag yields "" instead of raising. has no .text at all (it's None), and the old implementation called .strip() on that None directly. """ response = flatten_xml_response( "", {"Message": "error_message"}, ) assert response["type"] == "ErrorResponse" assert response["error_message"] == "" def test_flatten_xml_response_whitespace_only_tag_is_blank(): """A tag containing only whitespace is trimmed down to an empty string, same as an empty tag.""" response = flatten_xml_response( " ", {"Message": "error_message"}, ) assert response["error_message"] == "" def test_flatten_xml_response_survives_deeply_nested_xml(): """A response nested far past Python's default recursion limit still parses instead of raising a RecursionError. The old implementation walked the tree with a recursive helper, so one recursive call was made per level of nesting. """ depth = sys.getrecursionlimit() + 1000 xml = ("" * depth) + "leaf-value" + ("" * depth) response = flatten_xml_response(xml, {"a": "value"}) assert response["type"] == "a" assert response["value"] == "leaf-value" def test_flatten_xml_response_invalid_xml_returns_defaults(): """Malformed XML falls back to the defaults instead of raising.""" response = flatten_xml_response( "", KEEP_MAP, defaults={"request_id": None} ) assert response["type"] is None assert response["request_id"] is None def test_flatten_xml_response_non_string_input_returns_defaults(): """A non-string response (e.g. None, after a connection failure) falls back to the defaults instead of raising.""" response = flatten_xml_response( None, KEEP_MAP, defaults={"request_id": None} ) assert response["type"] is None assert response["request_id"] is None def test_flatten_xml_response_defaults_not_mutated(): """The defaults dictionary passed in is never modified in place.""" defaults = {"request_id": None} response = flatten_xml_response( "req-456", KEEP_MAP, defaults=defaults, ) assert defaults == {"request_id": None} assert response["request_id"] == "req-456" def test_flatten_xml_response_unmapped_tags_are_ignored(): """Tags that aren't listed in keep_map are left out of the result.""" response = flatten_xml_response( "ignored", KEEP_MAP, ) assert "Extra" not in response assert "extra" not in response def test_flatten_xml_response_rejects_a_dtd(): """A response carrying a DTD is refused before entities expand.""" response = flatten_xml_response( """ ]> &e;""", KEEP_MAP, defaults={"message_id": None}, ) assert response["type"] is None assert response["message_id"] is None def test_flatten_xml_response_truncated_xml_returns_defaults(): """A response that ends early contributes nothing, not a partial read.""" response = flatten_xml_response( "req-456", KEEP_MAP, defaults={"request_id": None}, ) assert response["type"] is None assert response["request_id"] is None caronc-apprise-dfec414/tests/test_utils_yaml.py000066400000000000000000000110261526720371000220540ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Tests for the small YAML/template inspection boundary.""" import pytest from apprise.utils.parse import QSD_FULL_MODE_KEYS from apprise.utils.template import TemplatePlaceholderMap, TemplateSchema from apprise.utils.yaml import ( dropped_template, template_in_schema, template_references, templated_tag, ) def placeholders(*names): """Create the same placeholder map used while parsing YAML URLs.""" return TemplatePlaceholderMap(TemplateSchema.parse(list(names)), "") def test_yaml_template_references_walks_values_and_url_keys_once(): """Scan supported containers without treating names as values.""" loop = [] loop.append(loop) shared = ("${TUPLE}",) value = [ None, 7, "${PLAIN} and malformed ${NO-DASH}", { "json://${HOST}/": { "+${LITERAL_KEY}": "${VALUE}", "nested": shared, }, "ordinary": shared, }, {"${SET}"}, loop, ] assert template_references(value) == { "plain", "host", "value", "tuple", "set", } @pytest.mark.parametrize( ("url", "declared", "expected"), [ (None, {"t"}, None), ("json://host/${T}", {"t"}, None), ("${OTHER}://host/", {"t"}, None), ("${T}://host/", {"t"}, "t"), ("json${T}://host/", ("t",), "t"), ("${T}user:pass@host/", {"t"}, "t"), ("${T}/path://later", {"t"}, "t"), ("${T}localhost/?url=http://bad.actor.com", {"t"}, "t"), (r"abc${T}s\://localhost", {"t"}, "t"), ("json://host/${T}://later", {"t"}, None), ], ) def test_yaml_template_schema_boundary(url, declared, expected): """Only declared markers at or before the schema boundary are refused.""" assert template_in_schema(url, declared) == expected def test_yaml_dropped_template_reports_stable_missing_name(): """Parser loss is detected without considering the saved source URL.""" mapping = placeholders("a", "b") url = mapping.encode_url("json://user:${B}@host/${A}") assert dropped_template(url, {}, None) is None assert dropped_template(url, {"path": url}, mapping) is None assert ( dropped_template( url, { "url": url, "password": mapping.encode("${B}"), }, mapping, ) == "a" ) @pytest.mark.parametrize("bucket", ("qsd", *QSD_FULL_MODE_KEYS)) def test_yaml_templated_tag_ignores_url_values(bucket): """A tag in the URL query belongs to the plugin and is allowed.""" mapping = placeholders("t") assert not templated_tag( {bucket: {"tag": mapping.encode("${T}")}}, mapping ) def test_yaml_templated_tag_handles_encoded_and_global_values(): """Per-service placeholders and raw global tags are both detected.""" mapping = placeholders("t") assert templated_tag({"tag": {mapping.encode("${T}")}}, mapping) assert templated_tag({"tag": {"${T}"}}, mapping) assert not templated_tag({"qsd": None, "tag": {"${OTHER}"}}, mapping) assert not templated_tag([], mapping) assert not templated_tag({"tag": set()}, None) caronc-apprise-dfec414/tests/var/000077500000000000000000000000001526720371000170515ustar00rootroot00000000000000caronc-apprise-dfec414/tests/var/01_test_example.html000066400000000000000000000036401526720371000227340ustar00rootroot00000000000000 Bootstrap 101 Template

    My Title

    Heading 1

    • Bullet 1
    • Bullet 2
    • Bullet 3
    1. Bullet 1
    2. Bullet 2
    3. Bullet 3

    Heading 2

    A div entry

    A div entry

    print('hello')

    Heading 3

    Heading 4

    Heading 5
    Heading 6

    A set of text
    Another line after the set of text


    More text