pax_global_header00006660000000000000000000000064150017110560014506gustar00rootroot0000000000000052 comment=2d36bac4ba988c7d1b9b04016c0cabfc9aa4c59b tremotesf-2.8.2/000077500000000000000000000000001500171105600135275ustar00rootroot00000000000000tremotesf-2.8.2/.cirrus.yml000066400000000000000000000025541500171105600156450ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 only_if: $CIRRUS_BRANCH == 'master' || $CIRRUS_BASE_BRANCH == 'master' || $CIRRUS_BASE_BRANCH =~ 'feature/.*' build_freebsd_task: freebsd_instance: matrix: - image_family: freebsd-13-5 - image_family: freebsd-14-2 packages_cache: folder: /var/cache/pkg install_dependencies_script: | set -e -o pipefail sudo pkg update -f sudo pkg install -y cmake ninja pkgconf libpsl libfmt qt6-base qt6-tools kf6-kwidgetsaddons kf6-kwindowsystem gettext-tools cxxopts if [ "$(freebsd-version -u | cut -d . -f 1)" -eq 14 ]; then sudo pkg install -y cpp-httplib fi cmake_build_script: | set -e -o pipefail echo 'Configuring CMake' if [ "$(freebsd-version -u | cut -d . -f 1)" -eq 14 ]; then httplib=system else httplib=none fi # Not enabling ASAN since it causes weird issues with tests that use openssl cmake -S . --preset base -D TREMOTESF_QT6=ON -D TREMOTESF_WITH_HTTPLIB="$httplib" -D TREMOTESF_ASAN=OFF echo 'Building Debug' cmake --build --preset base-debug -v echo 'Testing Debug' ASAN_OPTIONS=detect_leaks=0 ctest --preset base-debug echo 'Building Release' cmake --build --preset base-release -v echo 'Testing Release' ASAN_OPTIONS=detect_leaks=0 ctest --preset base-release tremotesf-2.8.2/.clang-format000066400000000000000000000055031500171105600161050ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 --- AccessModifierOffset: -4 AlignAfterOpenBracket: BlockIndent AlignArrayOfStructures: None AlignConsecutiveAssignments: false AlignConsecutiveBitFields: None AlignConsecutiveDeclarations: false AlignConsecutiveMacros: true AlignEscapedNewlines: Left AlignOperands: Align AlignTrailingComments: false AllowAllArgumentsOnNextLine: false AllowAllParametersOfDeclarationOnNextLine: true AllowShortBlocksOnASingleLine: Empty AllowShortCaseLabelsOnASingleLine: false AllowShortEnumsOnASingleLine: true AllowShortFunctionsOnASingleLine: All AllowShortIfStatementsOnASingleLine: WithoutElse AllowShortLambdasOnASingleLine: All AllowShortLoopsOnASingleLine: true AlwaysBreakAfterReturnType: None AlwaysBreakBeforeMultilineStrings: false AlwaysBreakTemplateDeclarations: Yes AttributeMacros: ['ALWAYS_INLINE', 'FORMAT_CONST'] BinPackArguments: false BinPackParameters: false BitFieldColonSpacing: Both BreakBeforeBinaryOperators: None BreakBeforeBraces: Attach BreakBeforeConceptDeclarations: Always BreakBeforeTernaryOperators: true BreakConstructorInitializers: BeforeColon BreakInheritanceList: BeforeColon BreakStringLiterals: true ColumnLimit: 120 CompactNamespaces: false ConstructorInitializerAllOnOneLineOrOnePerLine: true Cpp11BracedListStyle: true DeriveLineEnding: false DerivePointerAlignment: false EmptyLineAfterAccessModifier: Never EmptyLineBeforeAccessModifier: LogicalBlock FixNamespaceComments: false IndentAccessModifiers: false IndentCaseLabels: false IndentExternBlock: Indent IndentGotoLabels: true IndentPPDirectives: AfterHash IndentRequiresClause: true IndentWidth: 4 IndentWrappedFunctionNames: false InsertBraces: false InsertTrailingCommas: None KeepEmptyLinesAtTheStartOfBlocks: false LambdaBodyIndentation: Signature Language: Cpp NamespaceIndentation: All PackConstructorInitializers: NextLine PointerAlignment: Left QualifierAlignment: Leave ReferenceAlignment: Left ReflowComments: false #RemoveSemicolon: false RequiresClausePosition: OwnLine SeparateDefinitionBlocks: Leave SortIncludes: false SortUsingDeclarations: true SpaceAfterCStyleCast: true SpaceAfterLogicalNot: false SpaceAfterTemplateKeyword: false SpaceBeforeAssignmentOperators: true SpaceBeforeCaseColon: false SpaceBeforeCpp11BracedList: false SpaceBeforeCtorInitializerColon: true SpaceBeforeInheritanceColon: true SpaceBeforeParens: ControlStatements SpaceBeforeRangeBasedForLoopColon: true SpaceBeforeSquareBrackets: false SpaceInEmptyBlock: false SpaceInEmptyParentheses: false SpacesInAngles: false SpacesInCStyleCastParentheses: false SpacesInConditionalStatement: false SpacesInParentheses: false SpacesInSquareBrackets: false Standard: c++20 StatementAttributeLikeMacros: ['emit'] StatementMacros: ['Q_OBJECT', 'Q_GADGET', 'Q_ENUM', 'Q_ENUM_NS', 'Q_PROPERTY'] UseCRLF: false UseTab: Never ... tremotesf-2.8.2/.clang-tidy000066400000000000000000000027021500171105600155640ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 Checks: > -*, android-*, bugprone-*, -bugprone-suspicious-include, -bugprone-implicit-widening-of-multiplication-result, clang-analyzer-*, concurrency-*, cppcoreguidelines-*, -cppcoreguidelines-avoid-magic-numbers, -cppcoreguidelines-avoid-c-arrays, -cppcoreguidelines-owning-memory, -cppcoreguidelines-pro-type-static-cast-downcast, -cppcoreguidelines-non-private-member-variables-in-classes, -cppcoreguidelines-avoid-do-while -cppcoreguidelines-avoid-non-const-global-variables, -cppcoreguidelines-avoid-const-or-ref-data-members, -cppcoreguidelines-macro-usage, -cppcoreguidelines-misleading-capture-default-by-value, misc-*, -misc-include-cleaner, -misc-no-recursion, -misc-non-private-member-variables-in-classes, modernize-*, -modernize-avoid-c-arrays, -modernize-use-trailing-return-type, -modernize-use-nodiscard, -modernize-use-emplace, performance-*, -performance-enum-size, portability-*, readability-*, -readability-braces-around-statements, -readability-identifier-length, -readability-implicit-bool-conversion, -readability-magic-numbers, -readability-named-parameter, -readability-qualified-auto, -readability-convert-member-functions-to-static, -readability-redundant-inline-specifier, -readability-redundant-member-init CheckOptions: cppcoreguidelines-avoid-do-while.IgnoreMacros: true tremotesf-2.8.2/.github/000077500000000000000000000000001500171105600150675ustar00rootroot00000000000000tremotesf-2.8.2/.github/workflows/000077500000000000000000000000001500171105600171245ustar00rootroot00000000000000tremotesf-2.8.2/.github/workflows/build-macos.yml000066400000000000000000000054111500171105600220470ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 name: Build Tremotesf for MacOS on: workflow_call: inputs: release-tag: description: Release tag type: string required: false default: '' jobs: build-macos: strategy: fail-fast: false matrix: architecture: ['arm64', 'x86_64'] runs-on: macos-latest steps: - name: Add GCC problem matcher uses: ammaraskar/gcc-problem-matcher@master - name: Checkout uses: actions/checkout@v4 with: submodules: 'recursive' - name: Use latest Xcode uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable - name: Set up vcpkg uses: equeim/action-setup-vcpkg@v6 with: vcpkg-root: ${{ github.workspace }}/vcpkg save-cache: ${{ github.event_name != 'pull_request' }} cache-key-tag: ${{ matrix.architecture }} - name: Install host dependencies run: | brew install autoconf automake libtool ninja - name: Build Tremotesf for ${{ matrix.architecture }} architecture id: build uses: equeim/action-cmake-build@v10 with: cmake-arguments: --preset macos-${{ matrix.architecture }}-vcpkg -D TREMOTESF_ASAN=${{ inputs.release-tag == '' && 'ON' || 'OFF' }} package: true env: ASAN_OPTIONS: detect_leaks=0 - name: Archive packages if: inputs.release-tag == '' uses: actions/upload-artifact@v4 with: name: macos-${{ matrix.architecture }}-packages retention-days: ${{ github.event_name == 'push' && 7 || 3 }} path: | ${{ steps.build.outputs.build-directory }}/packaging/macos/*.dmg - name: Upload packages to release if: inputs.release-tag != '' run: | packages=(${{ steps.build.outputs.build-directory }}/packaging/macos/*.dmg) echo "Uploading packages ${packages[@]}" gh release upload '${{ inputs.release-tag }}' "${packages[@]}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Archive build logs uses: actions/upload-artifact@v4 if: always() with: name: macos-${{ matrix.architecture }}-build-logs retention-days: ${{ github.event_name == 'push' && 7 || 3 }} path: | ${{ steps.build.outputs.build-directory }}/CMakeCache.txt ${{ steps.build.outputs.build-directory }}/compile_commands.json ${{ steps.build.outputs.build-directory }}/vcpkg-manifest-install.log ${{ steps.build.outputs.build-directory }}/CMakeFiles/CMakeConfigureLog.yaml ${{ env.VCPKG_ROOT }}/buildtrees/*/*.log tremotesf-2.8.2/.github/workflows/build-windows-msvc.yml000066400000000000000000000065671500171105600234220ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 name: Build Tremotesf for Windows on: workflow_call: inputs: toolchain: description: Either 'msvc' or 'msvc-clang' type: string required: false default: 'msvc' release-tag: description: Release tag type: string required: false default: '' jobs: build-windows-msvc: strategy: fail-fast: false matrix: arch: - msvc-arch: amd64 cmake-preset-arch: x86_64 - msvc-arch: amd64_arm64 cmake-preset-arch: arm64 runs-on: windows-latest steps: - name: Checkout uses: actions/checkout@v4 with: submodules: 'recursive' - name: Set up MSVC environment uses: equeim/action-setup-msvc-environment@v1 with: arch: ${{ matrix.arch.msvc-arch }} - name: Upgrade LLVM if: inputs.toolchain == 'msvc-clang' run: | choco upgrade llvm - name: Set up vcpkg uses: equeim/action-setup-vcpkg@v6 with: vcpkg-root: ${{ github.workspace }}\vcpkg binary-cache-path: ${{ github.workspace }}\vcpkg_binary_cache save-cache: ${{ github.event_name != 'pull_request' && inputs.toolchain == 'msvc' }} cache-key-tag: ${{ matrix.arch.cmake-preset-arch }} - name: Build Tremotesf with ${{ inputs.toolchain }} toolchain for ${{ matrix.arch.cmake-preset-arch }} architecture id: build uses: equeim/action-cmake-build@v10 with: cmake-arguments: --preset windows-${{ matrix.arch.cmake-preset-arch }}-${{ inputs.toolchain }}-vcpkg -D VCPKG_INSTALLED_DIR=${{ github.workspace }}/vcpkg_installed -D TREMOTESF_ASAN=${{ inputs.release-tag == '' && inputs.toolchain == 'msvc' && 'ON' || 'OFF' }} package: true test: ${{ matrix.arch.cmake-preset-arch == 'x86_64' }} - name: Archive packages if: inputs.release-tag == '' uses: actions/upload-artifact@v4 with: name: 'windows-${{ matrix.arch.cmake-preset-arch }}-${{ inputs.toolchain }}-packages' retention-days: ${{ github.event_name == 'push' && 7 || 3 }} path: | ${{ steps.build.outputs.build-directory }}\packaging\windows\*.zip ${{ steps.build.outputs.build-directory }}\packaging\windows\*.msi - name: Upload packages to release if: inputs.release-tag != '' run: | $packages = Get-ChildItem ${{ steps.build.outputs.build-directory }}\packaging\windows\*.zip, ${{ steps.build.outputs.build-directory }}\packaging\windows\*.msi echo "Uploading packages $packages" gh release upload '${{ inputs.release-tag }}' $packages env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Archive build logs uses: actions/upload-artifact@v4 if: always() with: name: 'windows-${{ matrix.arch.cmake-preset-arch }}-${{ inputs.toolchain }}-build-logs' retention-days: ${{ github.event_name == 'push' && 7 || 3 }} path: | ${{ steps.build.outputs.build-directory }}\CMakeCache.txt ${{ steps.build.outputs.build-directory }}\compile_commands.json ${{ steps.build.outputs.build-directory }}\vcpkg-manifest-install.log ${{ steps.build.outputs.build-directory }}\CMakeFiles\CMakeConfigureLog.yaml ${{ env.VCPKG_ROOT }}\buildtrees\*\*.log tremotesf-2.8.2/.github/workflows/main.yml000066400000000000000000000202111500171105600205670ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 name: CI on: push: branches: [master] pull_request: branches: - master - 'feature/**' schedule: - cron: '0 0 * * 0' jobs: build-rpm: strategy: fail-fast: false matrix: container-image: ['fedora:41', 'fedora:42', 'opensuse/tumbleweed:latest'] compiler: ['gcc', 'clang'] arch: ['x86_64', 'arm64'] exclude: - container-image: 'opensuse/tumbleweed:latest' compiler: clang # Bug in Clang 20, possibly https://github.com/llvm/llvm-project/issues/104525 - container-image: 'fedora:42' compiler: clang runs-on: ubuntu-24.04${{ matrix.arch == 'arm64' && '-arm' || '' }} container: ${{ startsWith(matrix.container-image, 'fedora') && 'registry.fedoraproject.org' || 'registry.opensuse.org' }}/${{ matrix.container-image }} steps: - name: Add GCC problem matcher uses: ammaraskar/gcc-problem-matcher@master - name: Set packages install command for dnf if: startsWith(matrix.container-image, 'fedora') run: | cmd='dnf -y --setopt=install_weak_deps=False install' echo "INSTALL_PACKAGES=$cmd" >> "$GITHUB_ENV" echo "INSTALL_LOCAL_PACKAGES=$cmd" >> "$GITHUB_ENV" - name: Set packages install command for zypper if: startsWith(matrix.container-image, 'opensuse') run: | cmd='zypper --non-interactive in --no-recommends --details' echo "INSTALL_PACKAGES=$cmd" >> "$GITHUB_ENV" echo "INSTALL_LOCAL_PACKAGES=$cmd --allow-unsigned-rpm" >> "$GITHUB_ENV" - name: Install Git and rpm-build run: ${{env.INSTALL_PACKAGES}} git rpm-build - name: Checkout uses: actions/checkout@v4 with: submodules: 'recursive' - name: Set Fedora compiler RPM macro id: fedora-compiler if: startsWith(matrix.container-image, 'fedora') run: | echo "rpm-macro=--define 'toolchain ${{ matrix.compiler }}'" >> "$GITHUB_OUTPUT" - name: Install build dependencies run: | readarray -t dependencies < <(rpmspec ${{steps.fedora-compiler.outputs.rpm-macro}} --define 'with_asan 1' -q --srpm --qf '[%{REQUIRES}\n]' packaging/rpm/tremotesf.spec) ${{env.INSTALL_PACKAGES}} "${dependencies[@]}" - name: Make source archive run: | # Git complains if we don't do that git config --global --add safe.directory "$GITHUB_WORKSPACE" sourcedir="$(rpmbuild --eval '%_sourcedir')" mkdir -p "$sourcedir" .github/workflows/make-source-archive.py --output-directory "$sourcedir" zstd - name: Build RPM run: | rpmbuild ${{steps.fedora-compiler.outputs.rpm-macro}} -bb --with asan packaging/rpm/tremotesf.spec - name: Install RPM run: | ${{env.INSTALL_LOCAL_PACKAGES}} "$(rpm --eval='%_rpmdir')"/*/*.rpm build-deb: strategy: fail-fast: false matrix: container-image: ['debian:12', 'ubuntu:24.04', 'ubuntu:25.04'] arch: ['x86_64', 'arm64'] runs-on: ubuntu-24.04${{ matrix.arch == 'arm64' && '-arm' || '' }} container: docker.io/library/${{ matrix.container-image }} steps: - name: Add GCC problem matcher uses: ammaraskar/gcc-problem-matcher@master - name: Install dependencies needed to make source archive run: | apt-get update && DEBIAN_FRONTEND=noninteractive apt-get --no-install-recommends --assume-yes install ca-certificates git python3 cmake - name: Check out sources uses: actions/checkout@v4 with: submodules: 'recursive' - name: Make source archive run: | # Git complains if we don't do that git config --global --add safe.directory "$GITHUB_WORKSPACE" .github/workflows/make-source-archive.py --output-directory .. --debian gzip - name: Check out Debian sources uses: actions/checkout@v4 with: repository: equeim/tremotesf-debian path: tremotesf-debian - name: Remove everything except debian/ directory run: | mv tremotesf-debian/debian debian rm -rf tremotesf-debian - name: Install build dependencies run: | apt-get update && DEBIAN_FRONTEND=noninteractive apt-get --no-install-recommends --assume-yes build-dep . - name: Build DEB run: | DEB_BUILD_OPTIONS=sanitize=+address ASAN_OPTIONS=detect_leaks=0 dpkg-buildpackage - name: Install DEB run: | DEBIAN_FRONTEND=noninteractive apt-get --no-install-recommends --assume-yes install ../*.deb build-flatpak: strategy: fail-fast: false matrix: arch: ['x86_64', 'arm64'] runs-on: ubuntu-24.04${{ matrix.arch == 'arm64' && '-arm' || '' }} steps: - name: Add GCC problem matcher uses: ammaraskar/gcc-problem-matcher@master - name: Set up Flatpak run: | sudo add-apt-repository -y ppa:flatpak/stable sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get --assume-yes install flatpak flatpak-builder flatpak remote-add --user --if-not-exists flathub 'https://flathub.org/repo/flathub.flatpakrepo' - name: Checkout uses: actions/checkout@v4 with: submodules: 'recursive' - name: Build Tremotesf run: | flatpak-builder --user --install-deps-from=flathub build-dir org.equeim.Tremotesf.json - name: Check that generation of release manifest works run: | readonly archive="$(.github/workflows/make-source-archive.py zstd)" .github/workflows/make-flatpak-manifest-for-release.py test "$archive" echo "Release Flatpak manifest:" cat org.equeim.Tremotesf.json build-windows-msvc: strategy: fail-fast: false matrix: #toolchain: ['msvc', 'msvc-clang'] # Enable clang after fmt in vcpkg is updated to 11.1 toolchain: ['msvc'] uses: ./.github/workflows/build-windows-msvc.yml with: toolchain: ${{ matrix.toolchain }} build-windows-mingw: runs-on: windows-latest steps: - name: Checkout Tremotesf uses: actions/checkout@v4 with: submodules: 'recursive' - name: Set up MSYS2 uses: msys2/setup-msys2@v2 with: msystem: 'CLANG64' update: 'true' install: | mingw-w64-clang-x86_64-clang mingw-w64-clang-x86_64-cmake mingw-w64-clang-x86_64-cppwinrt mingw-w64-clang-x86_64-fmt mingw-w64-clang-x86_64-openssl mingw-w64-clang-x86_64-pkgconf mingw-w64-clang-x86_64-qt6-base mingw-w64-clang-x86_64-qt6-svg mingw-w64-clang-x86_64-qt6-tools mingw-w64-clang-x86_64-qt6-translations mingw-w64-clang-x86_64-cxxopts mingw-w64-clang-x86_64-kwidgetsaddons - name: Generate C++/WinRT headers shell: msys2 {0} run: | cppwinrt -input sdk -output /clang64/include - name: Add GCC problem matcher uses: ammaraskar/gcc-problem-matcher@master - name: Build with LLVM MinGW toolchain shell: msys2 {0} run: | set -e -o pipefail echo '::group::Configuring CMake' # ASAN + LTO causes linker crash cmake -S . --preset base -D TREMOTESF_QT6=ON -D TREMOTESF_WITH_HTTPLIB=bundled -D TREMOTESF_ASAN=ON -D CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE=OFF echo '::endgroup' echo '::group::Building Debug' cmake --build --preset base-debug -v echo '::endgroup' echo '::group::Testing Debug' ctest --preset base-debug echo '::endgroup' echo '::group::Building Release' cmake --build --preset base-release -v echo '::endgroup' echo '::group::Testing Release' ctest --preset base-release echo '::endgroup' build-macos: uses: ./.github/workflows/build-macos.yml reuse-lint: runs-on: ubuntu-latest steps: - name: Check out uses: actions/checkout@v4 - name: Check REUSE compliance run: | sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get --no-install-recommends --assume-yes install pipx pipx run reuse lint tremotesf-2.8.2/.github/workflows/make-flatpak-manifest-for-release.py000077500000000000000000000023561500171105600260520ustar00rootroot00000000000000#!/usr/bin/python3 # SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 import argparse import json from hashlib import sha256 from pathlib import PurePath MANIFEST_FILENAME = "org.equeim.Tremotesf.json" def main(): parser = argparse.ArgumentParser() parser.add_argument("tag", help="Git tag") parser.add_argument("archive", help="Release archive filename") args = parser.parse_args() with open(MANIFEST_FILENAME, "r", encoding="utf-8") as f: manifest = json.load(f) tremotesf_module = next(module for module in manifest["modules"] if module["name"] == "tremotesf") del tremotesf_module["build-options"]["env"]["ASAN_OPTIONS"] tremotesf_module["config-opts"].remove("-DTREMOTESF_ASAN=ON") with open(args.archive, "rb") as f: archive_sha256 = sha256(f.read()).hexdigest() tremotesf_module["sources"] = [ {"type": "archive", "url": f"https://github.com/equeim/tremotesf2/releases/download/{args.tag}/{PurePath(args.archive).name}", "sha256": archive_sha256}] with open(MANIFEST_FILENAME, "w", encoding="utf-8") as f: json.dump(manifest, f, indent=4) if __name__ == "__main__": main() tremotesf-2.8.2/.github/workflows/make-source-archive.py000077500000000000000000000102071500171105600233330ustar00rootroot00000000000000#!/usr/bin/python3 # SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 import argparse import gzip import json import logging import shutil import subprocess import sys import tarfile from enum import Enum from pathlib import Path, PurePath from tempfile import TemporaryDirectory def get_project_version() -> str: cmakelists = "CMakeLists.txt" if not Path(cmakelists).exists(): raise RuntimeError(f"{cmakelists} doesn't exist") process = subprocess.run(["cmake", "--trace-format=json-v1", "--trace-expand", "-P", cmakelists], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) for line in process.stderr.splitlines(): trace = json.loads(line) if "cmd" in trace and trace["cmd"].lower() == "project": args = trace["args"] found_version = False for arg in args: if found_version: return arg if arg.lower() == "version": found_version = True raise RuntimeError("Failed to find version in CMake trace output") def make_tar_archive(tempdir: PurePath, debian: bool) -> PurePath: version = get_project_version() root_directory = f"tremotesf-{version}" if debian: archive_filename = f"tremotesf_{version}.orig.tar" else: archive_filename = f"tremotesf-{version}.tar" archive_path = tempdir / archive_filename logging.info(f"Making tar archive {archive_path}") files = subprocess.run(["git", "ls-files", "--recurse-submodules", "-z"], check=True, stdout=subprocess.PIPE, text=True).stdout.split("\0") # There is an empty string in the end for some reason files = [f for f in files if f] logging.info(f"Archiving {len(files)} files") with tarfile.open(archive_path, mode="x") as tar: for file in files: tar.add(file, arcname=str(PurePath(root_directory, file)), recursive=False) return archive_path def compress_gzip(output_directory: PurePath, tar_path: PurePath) -> PurePath: gzip_path = output_directory / tar_path.with_suffix(".tar.gz").name logging.info(f"Compressing {tar_path} to {gzip_path}") with open(tar_path, mode="rb") as tar_file, gzip.open(gzip_path, mode="xb") as gzip_file: shutil.copyfileobj(tar_file, gzip_file) return gzip_path def compress_zstd(output_directory: PurePath, tar_path: PurePath) -> PurePath: zstd_path = output_directory / tar_path.with_suffix(".tar.zst").name logging.info(f"Compressing {tar_path} to {zstd_path}") subprocess.run(["zstd", str(tar_path), "-o", str(zstd_path)], check=True, stdout=sys.stderr) return zstd_path class CompressionType(Enum): GZIP = "gzip" ZSTD = "zstd" def main(): logging.basicConfig(level=logging.INFO, stream=sys.stderr) parser = argparse.ArgumentParser() parser.add_argument("compression_types", nargs="+", choices=[member.value for member in list(CompressionType)], help="Compression type") parser.add_argument("--output-directory", help="Directory where to place archives. Defaults to current directory") parser.add_argument("--debian", action="store_true", help="Make Debian upstream tarball") args = parser.parse_args() compression_types = [CompressionType(arg) for arg in args.compression_types] if args.output_directory: output_directory = PurePath(args.output_directory) else: output_directory = Path.cwd() with TemporaryDirectory() as tempdir: tar = make_tar_archive(PurePath(tempdir), args.debian) for compression_type in compression_types: match compression_type: case CompressionType.GZIP: print(compress_gzip(output_directory, tar)) case CompressionType.ZSTD: print(compress_zstd(output_directory, tar)) if __name__ == "__main__": main() tremotesf-2.8.2/.github/workflows/release.yml000066400000000000000000000050241500171105600212700ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 name: Release on: release: types: [published] jobs: upload-source-archives: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: submodules: 'recursive' - name: Upload source archives to release run: | readarray -t archives < <(.github/workflows/make-source-archive.py gzip zstd) echo "Uploading source archives ${archives[@]}" gh release upload '${{ github.event.release.tag_name }}' "${archives[@]}" .github/workflows/make-flatpak-manifest-for-release.py '${{ github.event.release.tag_name }}' "${archives[-1]}" echo "Release Flatpak manifest:" cat org.equeim.Tremotesf.json gh release upload '${{ github.event.release.tag_name }}' org.equeim.Tremotesf.json env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} upload-debian-source-package: runs-on: ubuntu-latest steps: - name: Check out sources uses: actions/checkout@v4 with: submodules: 'recursive' - name: Make source archive run: | .github/workflows/make-source-archive.py --output-directory .. --debian gzip - name: Check out Debian sources uses: actions/checkout@v4 with: repository: equeim/tremotesf-debian ref: ${{ github.event.release.tag_name }} path: tremotesf-debian - name: Remove everything except debian/ directory run: | mv tremotesf-debian/debian debian rm -rf tremotesf-debian - name: Make source package run: | dpkg-buildpackage --build=source --no-pre-clean - name: Upload source package to release run: | shopt -s failglob cd .. files=(tremotesf_*.debian.* tremotesf_*.dsc tremotesf_*.orig.*) archive_filename="$(basename tremotesf_*.dsc .dsc)-debian-source.tar.gz" echo "Archiving ${files[@]} to $archive_filename" tar --create --gzip --file "$archive_filename" "${files[@]}" echo "Uploading artifact $archive_filename" gh release upload --repo '${{ github.repository }}' '${{ github.event.release.tag_name }}' "$archive_filename" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} build-windows-msvc: uses: ./.github/workflows/build-windows-msvc.yml with: release-tag: ${{ github.event.release.tag_name }} build-macos: uses: ./.github/workflows/build-macos.yml with: release-tag: ${{ github.event.release.tag_name }} tremotesf-2.8.2/.gitignore000066400000000000000000000004321500171105600155160ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 /.cache /.kdev4 /*.kdev4 /.vs /.vscode /build /compile_commands.json /installroot /out /RPMS /vcpkg-installed /*.list /*.user /*.user.* /.idea /cmake-build* /.flatpak-builder .DS_Store tremotesf-2.8.2/.gitmodules000066400000000000000000000003221500171105600157010ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 [submodule "src/3rdparty/cpp-httplib"] path = src/3rdparty/cpp-httplib url = https://github.com/yhirose/cpp-httplib.git tremotesf-2.8.2/.tx/000077500000000000000000000000001500171105600142405ustar00rootroot00000000000000tremotesf-2.8.2/.tx/config000066400000000000000000000007171500171105600154350ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 [main] host = https://www.transifex.com [o:equeim:p:tremotesf:r:desktop] file_filter = data/po/.po source_file = data/po/messages.po source_lang = en type = PO trans.en_US = data/po/en.po [o:equeim:p:tremotesf:r:main] file_filter = translations/.ts source_file = translations/source.ts source_lang = en type = QT trans.en_US = translations/en.ts tremotesf-2.8.2/CHANGELOG.md000066400000000000000000000502371500171105600153470ustar00rootroot00000000000000# Changelog ## [2.8.2] - 2025-04-16 ### Fixed - Crash when failing to parse server response as JSON ## [2.8.1] - 2025-04-12 ### Fixed - Not working file dialogs when installed through Flatpak ## [2.8.0] - 2025-04-09 ### Added - Option to show torrent properties in a panel in the main window instead of dialog - Ability to set labels on torrents and filter torrent list by labels - Option to display relative time - Option to display only names of download directories in sidebar and torrents list ### Changed - Options dialog is rearranged to use multiple tabs - Message that's shown when trying to add torrent while disconnected from server is now displayed in a dialog instead of main window ### Fixed - Delayed loading of peers for active torrents - Window activation from clicking on notification ## [2.7.5] - 2025-01-14 ### Added - Windows on ARM64 support ### Changed - Windows builds now use system TLS library (schannel) instead of OpenSSL - Various hardening GCC and Clang compiler options are applied: - `-fhardened` with GCC >= 14 - `-ftrivial-auto-var-init=pattern`, `-fstack-protector-strong` - `-fstack-clash-protection` on Linux and FreeBSD - `-fcf-protection=full` on x86_64 - `-mbranch-protection=standard` on ARM64 - `-D_FORTIFY_SOURCE=3` - `-D_GLIBCXX_ASSERTIONS` with libstdc++ - `-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST` with libc++ >= 18 ### Fixed - Failures to add torrents when "Delete .torrent file" option is enabled - Compilation errors with fmt 11.1 - Debug logs being enabled in release builds in some cases ## [2.7.4] - 2024-12-06 ### Fixed - Tray icon disappearing in some X11 environments - Wrong translation being loaded on Windows ## [2.7.3] - 2024-11-20 ### Fixed - Black screen issues when closing fullscreen window on macOS - File dialog being shown twice in some Linux environments - Crash with GCC 12 - Torrent's details in list not being updated for most recently added torrent ## [2.7.2] - 2024-09-15 ### Fixed - Opening download directory of a torrent with some file managers ## [2.7.1] - 2024-09-13 ### Added - Dialog is shown when fatal error occurs on Windows - TREMOTESF_ASAN CMake option to build with AddressSanitizer (off by default) ### Fixed - Performance regression on Windows (and potential performance improvements on other platforms) - Crash on Windows - Issues with mounted directories mapping ## [2.7.0] - 2024-08-31 ### Added - Merging trackers when adding existing torrent - Add Torrent Link dialogs allows multiple links - "None" proxy option to bypass system proxy ### Changed - Removed Debian 11 and Ubuntu 22.04 support - minimum baseline now corresponds to Debian 12 - Minimum CMake version is 3.25 - Minimum fmt version is 9.1 - Minimum KF5 version is 5.103 - Minimum libpsl version is 0.21.2 - Minimum cxxopts version is 3.1.1 - Minimum gettext version is 0.21 - Removed dependency on Qt Concurrent module - Breeze is used as a fallback icon theme and should be installed as a runtime dependency - Clarified runtime dependency on Qt's SVG image format plugin - Notification portal is used for notifications in Flatpak - Added workaround for Transmission not showing an error for torrent when all trackers have failed - Networking and some other async code is rewritten using C++ coroutines. Hopefully nothing is broken :) ### Fixed - Mapping of mounted directories working incorrectly in some cases ## [2.6.3] - 2024-04-22 ### Fixed - Qt 6.7 compatibility ## [2.6.2] - 2024-04-01 ### Fixed - Application being closed when opening file picker in Qt 6 builds ## [2.6.1] - 2024-03-17 ### Added - Added TREMOTESF_WITH_HTTPLIB CMake option to control how cpp-httplib test dependency is searched. Possible values: - auto: CMake find_package call, otherwise pkg-config, otherwise bundled copy is used. - system: CMake find_package call, otherwise pkg-config, otherwise fatal error. - bundled: bundled copy is used. - none: cpp-httplib is not used at all and tests that require it are disabled. ### Changed - Qt 6 is now used by default instead of Qt 5. You can override it with TREMOTESF_QT6=OFF CMake option - Flatpak build uses Qt 6 - openSUSE build uses Qt 6 ### Fixed - Clarified dependency on kwayland-integration - Sorting of directories and trackers in side panel - Menu items that should disabled on first start not being disabled - Selecting of current server via status bar context menu being broken in some cases - Debug logs being printed when they are disabled ## [2.6.0] - 2024-01-08 ### Added - macOS support - Option to open torrent's file or download directory on double click - Option to not activate main window when adding torrents (except on macOS where application is always activated) - Option to not show "Add Torrent" dialog when adding torrents - Right click on status bar opens menu to quickly connect to different server - Support of xdg-activation protocol on Wayland (kwayland-integration is required as a runtime dependency) ### Changed - "Open" and "Show in file manager" actions now show error dialog if file/directory does not exist, instead if being inaccessible - "Show in file manager" actions has been renamed to "Open download directory" - Progress bar's text is now drawn according to Qt style (though Breeze style still draws text next to the progress bar, not inside of it) ### Fixed - Initial state of "Lock toolbar" menu action - Progress bar being drawn in incorrect column in torrent's files list ## [2.5.0] - 2023-10-15 ### Added - Qt 6 support (with unreleased KF6 libraries) - Option to move torrent file to trash instead of deleting it. It is enabled by default, and fallbacks to deletion if move failed for any reason. ### Changed - Windows builds use Qt 6 - Progress bar columns in torrent/file lists are now displayed with percent text - Default columns and sort order in torrents list are changed according to my personal taste: default sorting is now by added date, from new to old - When search field is focused via shortcut its contents are now selected - When Tremotesf is launched for the first time it now doesn't place itself in the middle of the screen, letting OS decide ### Removed - Windows 8.1 and Windows 10 versions prior to 1809 (October 2018 Update) are not supported anymore ### Fixed - "Open" action on torrent's root file directory - Saving of settings and window state when on logout/reboot/shutdown on Windows - Unnecessary RPC requests when torrent's limits are edited ## [2.4.0] - 2023-05-30 ### Added - "Add torrent" dialog now has checkbox to remove torrent file when torrent is added ### Changed - "Remember last download directory" is replaced with "Remember parameters of last added torrent", which also remembers priority, started/paused state and "Delete .torrent file" checkbox of last added torrent - "Remember last torrent open directory" setting is renamed to "Remember location of last opened torrent file" - When "Remember last torrent open directory" is unchecked user's home directory is always used - When authentication is enabled, `Authorization` header will be sent in advance instead of waiting for 401 response from server (thanks @otaconix) ### Fixed - Errors when opening certain torrent files - Incorrect error message being displayed when there is no configured servers ## [2.3.0] - 2023-04-30 ### Changed - Tremotesf now requires compiler with some C++20 support (concepts, ranges and comparison operators) ### Fixed - Crash on launch with some Qt styles - Mounted directories feature not working on Windows with UNC paths (those starting with \\\\) - Incorrect error message when adding torrent that already exists with some Transmission versions ## [2.2.0] - 2023-03-28 ### Added - Torrent priority selection in torrent's context menu ### Changed - Torrent status icons are redrawn to be more contrasting ### Fixed - Torrent status icons being pixelated on high DPI displays - Torrent priority column being empty - Zero number of leechers - No icon in task switcher in KDE Plasma Wayland ## [2.1.0] - 2023-03-12 ### Added - Trackers list shows number of seeders and leechers for trackers, not just total number peers - New dependencies: 1. libpsl 2. cxxopts 3. cpp-httplib 0.11 or newer (for tests only, optional) ### Changed - "Seeders" and "leechers" now refer to total number of seeders and leechers reported by trackers, while number of peers that we are currently downloading from / uploading to is displayed separately - Tracker's error is displayed in separate column - Torrents that have an error but are still being downloaded/uploaded are displayed under both "Status" filters simultaneously - TREMOTESF_BUILD_TESTS CMake option is replaced by standard BUILD_TESTING option ### Fixed - Fixed issues with lists or torrents/trackers not being updated sometimes - Main window placeholder when there is no torrents in list is now displayed correctly ## [2.0.0] - 2022-11-05 ### Added - Dark theme support on Windows (dark window title bar on Windows 10 is supported only on 1809 or newer and it can be buggy because Windows 10 doesn't support this officially) - System accent color on Windows is used in app UI (can be disabled in settings) - Windows builds now write logs to `C:\Users\User\AppData\Local\tremotesf\tremotesf` directory - Opening of torrents (local files or HTTP(S) links) and magnet links is now supported via drag & drop or Ctrl+V on main window - Option to automatically fill link from clipboard when adding torrent link (disabled by default) - Last download directory is now remembered when adding torrents (can be disabled in settings) (thanks Alex Bell) - Directory of last opened torrent is now remembered (can be disabled in settings) - When adding torrents via opening file/link, drag & drop or clipboard while disconnected from server, Tremotesf will now show message explaining that they will be added after connection to server (instead of doing this sliently, confusing users) - Status message is shown over empty torrent list when not connected to server - Command line option to enable debug logs ### Changed - Raised minimum version of Qt to 5.15 - Raised minimum version of CMake to 3.16 (3.21 on Windows) - Windows build now uses Fusion style since default style imitating Win32 controls is non-themeable - Windows installer now associates Tremotesf with torrent files and magnet links ### Removed - Sailfish OS support - org.equeim.Tremotesf D-Bus interface is removed - Removed special handling for various file managers when using "Show in file manager" action Now Tremotesf uncoditionally tries to use org.freedesktop.FileManager1 D-Bus interface, and if it fails then QDesktopServices::openUrl() is used ### Fixed - IPv6 address can be now used to connect to server - Windows paths are always displayed with native directory separators ## [1.11.3] - 2022-03-18 ### Fixed - RPM spec file ## [1.11.2] - 2022-03-16 ### Fixed - Display of torrents list updates ## [1.11.1] - 2022-02-28 ### Added - MSI installer for Windows ### Fixed - Renaming files when adding torrent - Display of torrents count in status filters - Sorting of torrents by ETA ### Removed - Donation links ## [1.11.0] - 2022-02-13 ### Added - Automatic reconnection to server (thanks to LuK1337) - PEM certificate can be loaded from file - Links in torrents' comments are now clickable - Torrent list filters (except search) are now saved when app is restarted - Torrent properties screen now shows list of web seeders - Most menu items that didn't have icons now have them (thank to Buck Melanoma) - Hovering cursor over status bar when connection to server failed show more detailed error description - Vcpkg integration when building for Windows ### Changed - Further reduced memory usage when opening torrent files - All app resources are now bundled inside executable - Windows builds use MSVC toolchain by default ### Fixed - Sailfish OS support (this will be the last release that supports Saifish OS) - Console window output encoding on Windows (it now also uses UTF-8 on Windows 10) ## [1.10.0] - 2021-09-27 ### Added - Ability to shut down remote Transmission instance - Renaming of torrent via its context menu ### Changed - Reduced memory usage when opening torrent files ## [1.9.1] - 2021-05-10 ### Changed - Disabled MIME type checking for torrent files (it doesn't work for some files) - Tremotesf now won't open torrent files that are bigger than 50 MiB ### Fixed - Segfault or error when adding torrent files ## [1.9.0] - 2021-05-04 ### Added - It is now possible to specify whole certificate chain for self-signed certificate ### Changed - Ctrl+F now focuses search field in main window - C++17 compiler is now required ### Fixed - Fixed torrent list artifacts when using GTK2 style plugin for Qt ## [1.8.0] - 2020-09-06 ### Added - Tremotesf now implements org.freedesktop.Application D-Bus interface on relevant platforms - Desktop: added support of startup notifications on X11 - Desktop: added dependencies on Qt X11 Extras and KWindowSystem on Unix-like platforms - Desktop: added menu item to copy torrent's magnet link ### Changed - org.equeim.Tremotesf D-Bus interface is deprecated - Desktop: notifications on Unix-like platforms are now clickable - Desktop: when application window is hidden to tray icon, open dialogs are now hidden too - Desktop: minor UI improvements - Updated translations ### Fixed - Fixed support of mounted remote directories ## [1.7.1] - 2020-06-27 ### Changed - Updated translations - Enabled LTO for release build on Windows ### Fixed - Fixed adding torrents with Qt 5.15 ## [1.7.0] - 2020-06-05 ### Added - Added support of configuring per-server HTTP/SOCKS5 proxies - Added support of renaming torrent's files when adding it - Added ability to add multiple trackers at a time ### Changed - Minimum CMake version raised to 3.10 - C++ standard version raised to C++14 (Sailfish OS GCC 4.9 toolchain is still supported) ### Fixed - Fixed passing command line arguments and opening files with commas - Sailfish OS: fixed opening app from notifications - Sailfish OS: fixed reconnecting to server when its connection settings are changed ## [1.6.4] - 2020-01-11 ### Fixed - Fixed compilation for Windows - Fix RPM validation errors/warnings for Sailfish OS ## [1.6.3] - 2020-01-05 ### Fixed - Fix installing of new translations ## [1.6.2] - 2020-01-05 ### Changed - Improved UNIX/Windows signals handling - Command line options that don't start GUI (such as '-v' or '-h' or passing files to already running Tremotesf instance) now don't require X/Wayland session - Tremotesf now shows localized numbers instead of Latin-1 ones (in most places) - Updated translations ### Fixed - Fixed compilation with Qt 5.14 ## [1.6.1] - 2019-07-16 ### Changed - Minor performance improvements ### Fixed - Fixed notifications not being configurable in KDE Plasma and GNOME ## [1.6.0] - 2019-01-26 ### Added - Desktop: restoring of torrent properties dialog's geometry - Desktop: Tremotesf now remebmers used download directories and shows them in combo box when adding torrent / changing location ### Changed - Desktop: toolBarVisible and toolBarArea configuration keys are now deprecated, mainWindowState is used instead ### Fixed - Desktop: fixed restoring state of detached toolbar - Sailfish OS: fixed peers' upload speed label ## [1.5.6] - 2018-12-08 ### Changed - Desktop: Improved HiDPI support ### Fixed - Desktop: fixed notification icon ## [1.5.5] - 2018-09-26 ### Fixed - Yandex.Money donate link ## [1.5.4] - 2018-09-10 ### Changed - Tremotesf binary now doesn't link to QtConcurrent library (but still requires its headers at build time) - Desktop: improved AppStream metadata ## [1.5.3] - 2018-09-03 ### Added - Multiseat support (it is now possible to run multiple Tremotesf instances on different login sessions) - Added .appdata.xml file ### Changed - D-Bus is now used for IPC on Unix - Minimum CMake version lowered to 3.0 (note that Qt >= 5.11 requires CMake 3.1) - Desktop: .desktop file and icons are renamed according to Desktop Entry Specification ### Fixed - Fixed crashes when disconnecting from server - Added window title to Server Stats dialog - "Open" and "Show In File Manager" menu items are disabled when files don't exist on filesystem - Desktop: handle cases when xdg-mime executable doesn't exist - Desktop: fallback to org.freedesktop.FileManager1 when showing files in file manager ## [1.5.2] - 2018-08-18 ### Fixed - Fixed crash when disconnecting from server ## [1.5.1] - 2018-08-14 ### Added - Universal RPM spec file for SailfishOS/Fedora/Mageia/openSUSE ### Changed - Updated Spanish translation ### Fixed - Fixed openSUSE OBS build by adding subcategory to .desktop file ## [1.5.0] - 2018-08-13 ### Added - Server stats dialog - Ability to set mounted directories for servers - Ability to open torrents' files - Notifications on added/finished torrents since last connection to server - Ability to reannounce torrents - Ability to set torrent's location ### Changed - CMake build system #### Sailfish OS - File selection dialog now shows current directory ### Fixed - Fixed segfault when disconnecting from server - Fixed segfault when closing properties dialog - Impoved support of self-signed certificates - Active network requests are now aborted when manually disconnecting from server ## [1.4.0] - 2018-05-09 ### Added - Donation links via PayPal and Yandex.Money - Filter torrents by download directory - Show available free space when adding torrent - Dutch, Flemish and Spanish translations ### Fixed - Checking for Qt version in .pro file - Translations installation - Sailfish OS command line arguments ## [1.3.2] - 2017-03-05 ### Changed - Disable debug output in release builds ### Fixed - Installation of translation files when build directory is project root directory ## [1.3.1] - 2017-03-02 ### Changed - Don't create a new thread for every async task, instead use QtConcurrent::run ### Fixed #### Desktop - 256x256 icon ## [1.3.0] - 2017-02-28 ### Changed - More correct handling of self-signed certificates (you may need to update server's configuration) - Translation are now managed on Transifex. ## [1.2.2] - 2017-02-25 ### Fixed - Windows icons ## [1.2.1] - 2017-02-24 ### Added - Show authentication error ### Changed - Show license in HTML format ### Fixed #### Desktop - Project URL in About dialog ## [1.2.0] - 2017-02-13 ### Added - Torrent properties are now reloaded after reconnection - Rename torrent's files #### Desktop - Command line option to start minimized in notification area ### Changed - Performance improvements for torrents with large number of files - Accounts are renamed to Servers (config file name also changed) ### Removed - Ability to select another file in Add Torrent File dialog (it was causing unnecessary code complication) #### Desktop - Settings options to start minimized in notification area. Use command line option instead ### Fixed - Tracker status is now more correct - Set correct torrent priority when adding torrent ## [1.1.0] - 2016-09-25 ### Added #### Sailfish OS - Peers page now shows clients of peers. ### Changed - Update data immediately after getting response from server when performing some actions on torrents (adding, starting/pausing, removing, checking, moving in queue). ### Fixed - Update torrent name when it is changed on server (e.g. after retrieving torrent metadata if torrent was added by magnet link). #### Desktop - Don't hide main window on startup if tray icon is disabled. - Fix showing temporary window if main window is hidden on startup. ## [1.0.0] - 2016-09-17 ### Added - Desktop user interface for GNU/Linux and Windows. #### Sailfish OS - You can now browse torrent's content when adding torrent file. - Filter torrents by trackers. - Search in torrents list. - Multi-selection everywhere. - Notifications on adding torrents and when disconnecting from server (all notification can be disabled in settings). - Option for disabling connection on startup. - Cover action for connecting/disconnecting. - Tremotesf can now open torrent files and links from console or file manager (Sailfish OS version can open only one torrent at a time). - Several new server settings options. ### Changed - Entire project is rewrited from scratch. - A lot of performance improvements. - New configuration file format. Accounts now stored in separate file (with automatic migration from previous version). - Switch to QMake build system #### Sailfish OS - New and more compact user interface. ### Fixed - A lot of bugs. tremotesf-2.8.2/CMakeLists.txt000066400000000000000000000026101500171105600162660ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 cmake_minimum_required(VERSION 3.25) cmake_policy(VERSION ${CMAKE_MINIMUM_REQUIRED_VERSION}...3.31) set(languages CXX) if (APPLE) include(cmake/MacOSInitialSetup.cmake) list(APPEND languages OBJCXX) endif () project(tremotesf VERSION 2.8.2 LANGUAGES ${languages}) option(TREMOTESF_QT6 "Build with Qt 6" ON) option(TREMOTESF_ASAN "Build with AddressSanitizer" OFF) set(TREMOTESF_WITH_HTTPLIB "auto" CACHE STRING "Where to find cpp-httplib dependency for unit tests. Possible values are: auto, system, bundled, none") if (NOT TREMOTESF_WITH_HTTPLIB MATCHES "^(auto|system|bundled|none)$") message(FATAL_ERROR "Invalid TREMOTESF_WITH_HTTPLIB value ${TREMOTESF_WITH_HTTPLIB}. Possible values are: auto, system, bundled, none") endif() include(CTest) include(GNUInstallDirs) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) include(cmake/CommonOptions.cmake) find_package(Qt${TREMOTESF_QT_VERSION_MAJOR} ${TREMOTESF_MINIMUM_QT_VERSION} REQUIRED COMPONENTS Core) set(QRC_FILES "") if (APPLE) set(TREMOTESF_MACOS_BUNDLE_NAME "Tremotesf") set(TREMOTESF_EXTERNAL_RESOURCES_PATH "${TREMOTESF_MACOS_BUNDLE_NAME}.app/Contents/Resources") elseif (WIN32) set(TREMOTESF_EXTERNAL_RESOURCES_PATH ".") endif () add_subdirectory("data") add_subdirectory("translations") add_subdirectory("src") add_subdirectory("packaging") tremotesf-2.8.2/CMakePresets.json000066400000000000000000000236671500171105600167660ustar00rootroot00000000000000{ "version": 3, "configurePresets": [ { "name": "base", "generator": "Ninja Multi-Config", "binaryDir": "${sourceDir}/out/build/${presetName}", "installDir": "${sourceDir}/out/install/${presetName}", "warnings": { "dev": true, "deprecated": true }, "cacheVariables": { "CMAKE_CONFIGURATION_TYPES": "Debug;Release;RelWithDebInfo", "CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE": "ON" } }, { "name": "base-vcpkg", "inherits": "base", "hidden": true, "cacheVariables": { "TREMOTESF_QT6": "ON", "TREMOTESF_WITH_HTTPLIB": "system", "VCPKG_INSTALLED_DIR": "${sourceDir}/vcpkg-installed", "VCPKG_INSTALL_OPTIONS": "--disable-metrics;--clean-packages-after-build", "VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/vcpkg-overlay-triplets" }, "toolchainFile": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" }, { "name": "base-windows-msvc-vcpkg", "inherits": "base-vcpkg", "hidden": true, "condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Windows" }, "cacheVariables": { "CMAKE_CXX_COMPILER": "cl.exe" }, "vendor": { "microsoft.com/VisualStudioSettings/CMake/1.0": { "hostOS": [ "Windows" ] } } }, { "name": "windows-arm64-msvc-vcpkg", "inherits": "base-windows-msvc-vcpkg", "architecture": { "value": "arm64", "strategy": "external" }, "cacheVariables": { "VCPKG_TARGET_TRIPLET": "arm64-windows-static", "VCPKG_HOST_TRIPLET": "x64-windows-release" } }, { "name": "windows-x86_64-msvc-vcpkg", "inherits": "base-windows-msvc-vcpkg", "architecture": { "value": "x64", "strategy": "external" }, "cacheVariables": { "VCPKG_TARGET_TRIPLET": "x64-windows-static", "VCPKG_HOST_TRIPLET": "x64-windows-static" } }, { "name": "windows-arm64-msvc-clang-vcpkg", "inherits": "windows-arm64-msvc-vcpkg", "cacheVariables": { "CMAKE_CXX_COMPILER": "clang-cl.exe" }, "environment": { "CXXFLAGS": "/clang:--target=arm64-pc-windows-msvc $penv{CXXFLAGS}", "CFLAGS": "/clang:--target=arm64-pc-windows-msvc $penv{CXXFLAGS}" } }, { "name": "windows-x86_64-msvc-clang-vcpkg", "inherits": "windows-x86_64-msvc-vcpkg", "cacheVariables": { "CMAKE_CXX_COMPILER": "clang-cl.exe" } }, { "name": "base-macos-vcpkg", "inherits": "base-vcpkg", "hidden": true, "condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Darwin" } }, { "name": "macos-arm64-vcpkg", "inherits": "base-macos-vcpkg", "cacheVariables": { "VCPKG_TARGET_TRIPLET": "arm64-osx-release", "CMAKE_OSX_ARCHITECTURES": "arm64" } }, { "name": "macos-x86_64-vcpkg", "inherits": "base-macos-vcpkg", "cacheVariables": { "VCPKG_TARGET_TRIPLET": "x64-osx-release", "CMAKE_OSX_ARCHITECTURES": "x86_64" } } ], "buildPresets": [ { "name": "base-debug", "configurePreset": "base", "configuration": "Debug" }, { "name": "base-release", "configurePreset": "base", "configuration": "Release" }, { "name": "base-relwithdebinfo", "configurePreset": "base", "configuration": "RelWithDebInfo" }, { "name": "windows-arm64-msvc-vcpkg-debug", "configurePreset": "windows-arm64-msvc-vcpkg", "configuration": "Debug" }, { "name": "windows-arm64-msvc-vcpkg-release", "configurePreset": "windows-arm64-msvc-vcpkg", "configuration": "Release" }, { "name": "windows-arm64-msvc-vcpkg-relwithdebinfo", "configurePreset": "windows-arm64-msvc-vcpkg", "configuration": "RelWithDebInfo" }, { "name": "windows-x86_64-msvc-vcpkg-debug", "configurePreset": "windows-x86_64-msvc-vcpkg", "configuration": "Debug" }, { "name": "windows-x86_64-msvc-vcpkg-release", "configurePreset": "windows-x86_64-msvc-vcpkg", "configuration": "Release" }, { "name": "windows-x86_64-msvc-vcpkg-relwithdebinfo", "configurePreset": "windows-x86_64-msvc-vcpkg", "configuration": "RelWithDebInfo" }, { "name": "windows-arm64-msvc-clang-vcpkg-debug", "configurePreset": "windows-arm64-msvc-clang-vcpkg", "configuration": "Debug" }, { "name": "windows-arm64-msvc-clang-vcpkg-release", "configurePreset": "windows-arm64-msvc-clang-vcpkg", "configuration": "Release" }, { "name": "windows-arm64-msvc-clang-vcpkg-relwithdebinfo", "configurePreset": "windows-arm64-msvc-clang-vcpkg", "configuration": "RelWithDebInfo" }, { "name": "windows-x86_64-msvc-clang-vcpkg-debug", "configurePreset": "windows-x86_64-msvc-clang-vcpkg", "configuration": "Debug" }, { "name": "windows-x86_64-msvc-clang-vcpkg-release", "configurePreset": "windows-x86_64-msvc-clang-vcpkg", "configuration": "Release" }, { "name": "windows-x86_64-msvc-clang-vcpkg-relwithdebinfo", "configurePreset": "windows-x86_64-msvc-clang-vcpkg", "configuration": "RelWithDebInfo" }, { "name": "macos-arm64-vcpkg-debug", "configurePreset": "macos-arm64-vcpkg", "configuration": "Debug" }, { "name": "macos-arm64-vcpkg-release", "configurePreset": "macos-arm64-vcpkg", "configuration": "Release" }, { "name": "macos-arm64-vcpkg-relwithdebinfo", "configurePreset": "macos-arm64-vcpkg", "configuration": "RelWithDebInfo" }, { "name": "macos-x86_64-vcpkg-debug", "configurePreset": "macos-x86_64-vcpkg", "configuration": "Debug" }, { "name": "macos-x86_64-vcpkg-release", "configurePreset": "macos-x86_64-vcpkg", "configuration": "Release" }, { "name": "macos-x86_64-vcpkg-relwithdebinfo", "configurePreset": "macos-x86_64-vcpkg", "configuration": "RelWithDebInfo" } ], "testPresets": [ { "name": "base", "hidden": true, "output": { "outputOnFailure": true } }, { "name": "base-debug", "inherits": "base", "configurePreset": "base", "configuration": "Debug" }, { "name": "base-release", "inherits": "base", "configurePreset": "base", "configuration": "Release" }, { "name": "base-relwithdebinfo", "inherits": "base", "configurePreset": "base", "configuration": "RelWithDebInfo" }, { "name": "windows-arm64-msvc-vcpkg-debug", "inherits": "base", "configurePreset": "windows-arm64-msvc-vcpkg", "configuration": "Debug" }, { "name": "windows-arm64-msvc-vcpkg-release", "inherits": "base", "configurePreset": "windows-arm64-msvc-vcpkg", "configuration": "Release" }, { "name": "windows-arm64-msvc-vcpkg-relwithdebinfo", "inherits": "base", "configurePreset": "windows-arm64-msvc-vcpkg", "configuration": "RelWithDebInfo" }, { "name": "windows-x86_64-msvc-vcpkg-debug", "inherits": "base", "configurePreset": "windows-x86_64-msvc-vcpkg", "configuration": "Debug" }, { "name": "windows-x86_64-msvc-vcpkg-release", "inherits": "base", "configurePreset": "windows-x86_64-msvc-vcpkg", "configuration": "Release" }, { "name": "windows-x86_64-msvc-vcpkg-relwithdebinfo", "inherits": "base", "configurePreset": "windows-x86_64-msvc-vcpkg", "configuration": "RelWithDebInfo" }, { "name": "windows-arm64-msvc-clang-vcpkg-debug", "inherits": "base", "configurePreset": "windows-arm64-msvc-clang-vcpkg", "configuration": "Debug" }, { "name": "windows-arm64-msvc-clang-vcpkg-release", "inherits": "base", "configurePreset": "windows-arm64-msvc-clang-vcpkg", "configuration": "Release" }, { "name": "windows-arm64-msvc-clang-vcpkg-relwithdebinfo", "inherits": "base", "configurePreset": "windows-arm64-msvc-clang-vcpkg", "configuration": "RelWithDebInfo" }, { "name": "windows-x86_64-msvc-clang-vcpkg-debug", "inherits": "base", "configurePreset": "windows-x86_64-msvc-clang-vcpkg", "configuration": "Debug" }, { "name": "windows-x86_64-msvc-clang-vcpkg-release", "inherits": "base", "configurePreset": "windows-x86_64-msvc-clang-vcpkg", "configuration": "Release" }, { "name": "windows-x86_64-msvc-clang-vcpkg-relwithdebinfo", "inherits": "base", "configurePreset": "windows-x86_64-msvc-clang-vcpkg", "configuration": "RelWithDebInfo" }, { "name": "macos-arm64-vcpkg-debug", "inherits": "base", "configurePreset": "macos-arm64-vcpkg", "configuration": "Debug" }, { "name": "macos-arm64-vcpkg-release", "inherits": "base", "configurePreset": "macos-arm64-vcpkg", "configuration": "Release" }, { "name": "macos-arm64-vcpkg-relwithdebinfo", "inherits": "base", "configurePreset": "macos-arm64-vcpkg", "configuration": "RelWithDebInfo" }, { "name": "macos-x86_64-vcpkg-debug", "inherits": "base", "configurePreset": "macos-x86_64-vcpkg", "configuration": "Debug" }, { "name": "macos-x86_64-vcpkg-release", "inherits": "base", "configurePreset": "macos-x86_64-vcpkg", "configuration": "Release" }, { "name": "macos-x86_64-vcpkg-relwithdebinfo", "inherits": "base", "configurePreset": "macos-x86_64-vcpkg", "configuration": "RelWithDebInfo" } ] } tremotesf-2.8.2/LICENSE000077700000000000000000000000001500171105600211402LICENSES/GPL-3.0-or-later.txtustar00rootroot00000000000000tremotesf-2.8.2/LICENSES/000077500000000000000000000000001500171105600147345ustar00rootroot00000000000000tremotesf-2.8.2/LICENSES/CC-BY-ND-4.0.txt000066400000000000000000000406761500171105600171050ustar00rootroot00000000000000Creative Commons Attribution-NoDerivatives 4.0 International Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. Using Creative Commons Public Licenses Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors. Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public. Creative Commons Attribution-NoDerivatives 4.0 International Public License By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NoDerivatives 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. Section 1 – Definitions. a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. b. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. c. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. d. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. e. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. f. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. g. Licensor means the individual(s) or entity(ies) granting rights under this Public License. h. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. i. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. j. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. Section 2 – Scope. a. License grant. 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: A. reproduce and Share the Licensed Material, in whole or in part; and B. produce and reproduce, but not Share, Adapted Material. 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. 3. Term. The term of this Public License is specified in Section 6(a). 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. 5. Downstream recipients. A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. B. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). b. Other rights. 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. 2. Patent and trademark rights are not licensed under this Public License. 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. Section 3 – License Conditions. Your exercise of the Licensed Rights is expressly made subject to the following conditions. a. Attribution. 1. If You Share the Licensed Material, You must: A. retain the following if it is supplied by the Licensor with the Licensed Material: i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); ii. a copyright notice; iii. a notice that refers to this Public License; iv. a notice that refers to the disclaimer of warranties; v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. 2. For the avoidance of doubt, You do not have permission under this Public License to Share Adapted Material. 3. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. 4. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. Section 4 – Sui Generis Database Rights. Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database, provided You do not Share Adapted Material; b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. Section 5 – Disclaimer of Warranties and Limitation of Liability. a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You. b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You. c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. Section 6 – Term and Termination. a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or 2. upon express reinstatement by the Licensor. c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. Section 7 – Other Terms and Conditions. a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. Section 8 – Interpretation. a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. Creative Commons may be contacted at creativecommons.org. tremotesf-2.8.2/LICENSES/CC0-1.0.txt000066400000000000000000000156101500171105600163410ustar00rootroot00000000000000Creative Commons Legal Code CC0 1.0 Universal CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER. Statement of Purpose The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. 1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; ii. moral rights retained by the original author(s) and/or performer(s); iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; v. rights protecting the extraction, dissemination, use and reuse of data in a Work; vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. 2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. 3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. 4. Limitations and Disclaimers. a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. tremotesf-2.8.2/LICENSES/GPL-2.0-or-later.txt000066400000000000000000000416711500171105600201500ustar00rootroot00000000000000GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc. 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. one line to give the program's name and an idea of what it does. Copyright (C) yyyy name of author This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. signature of Ty Coon, 1 April 1989 Ty Coon, President of Vice tremotesf-2.8.2/LICENSES/GPL-3.0-or-later.txt000066400000000000000000001035561500171105600201520ustar00rootroot00000000000000GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright © 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. “This License” refers to version 3 of the GNU General Public License. “Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. “The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations. To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work. A “covered work” means either the unmodified Program or a work based on the Program. To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work. A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”. c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. “Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. “Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”. A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”. You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . tremotesf-2.8.2/LICENSES/LGPL-2.0-or-later.txt000066400000000000000000000604551500171105600202650ustar00rootroot00000000000000GNU LIBRARY GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1991 Free Software Foundation, Inc. 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. [This is the first released version of the library GPL. It is numbered 2 because it goes with version 2 of the ordinary GPL.] Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This license, the Library General Public License, applies to some specially designated Free Software Foundation software, and to any other libraries whose authors decide to use it. You can use it for your libraries, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library, or if you modify it. For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link a program with the library, you must provide complete object files to the recipients so that they can relink them with the library, after making changes to the library and recompiling it. And you must show them these terms so they know their rights. Our method of protecting your rights has two steps: (1) copyright the library, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the library. Also, for each distributor's protection, we want to make certain that everyone understands that there is no warranty for this free library. If the library is modified by someone else and passed on, we want its recipients to know that what they have is not the original version, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that companies distributing free software will individually obtain patent licenses, thus in effect transforming the program into proprietary software. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. Most GNU software, including some libraries, is covered by the ordinary GNU General Public License, which was designed for utility programs. This license, the GNU Library General Public License, applies to certain designated libraries. This license is quite different from the ordinary one; be sure to read it in full, and don't assume that anything in it is the same as in the ordinary license. The reason we have a separate public license for some libraries is that they blur the distinction we usually make between modifying or adding to a program and simply using it. Linking a program with a library, without changing the library, is in some sense simply using the library, and is analogous to running a utility program or application program. However, in a textual and legal sense, the linked executable is a combined work, a derivative of the original library, and the ordinary General Public License treats it as such. Because of this blurred distinction, using the ordinary General Public License for libraries did not effectively promote software sharing, because most developers did not use the libraries. We concluded that weaker conditions might promote sharing better. However, unrestricted linking of non-free programs would deprive the users of those programs of all benefit from the free status of the libraries themselves. This Library General Public License is intended to permit developers of non-free programs to use free libraries, while preserving your freedom as a user of such programs to change the free libraries that are incorporated in them. (We have not seen how to achieve this as regards changes in header files, but we have achieved it as regards changes in the actual functions of the Library.) The hope is that this will lead to faster development of free libraries. The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, while the latter only works together with the library. Note that it is possible for a library to be covered by the ordinary General Public License rather than by this special one. GNU LIBRARY GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License Agreement applies to any software library which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Library General Public License (also called "this License"). Each licensee is addressed as "you". A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) "Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. 1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) The modified work must itself be a software library. b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. (For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. This option is useful when you wish to copy part of the code of the Library into a program that is not a library. 4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. 5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. 6. As an exception to the Sections above, you may also compile or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) b) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. c) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. d) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. 7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. 10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 13. The Free Software Foundation may publish revised and/or new versions of the Library General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. 14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Libraries If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. one line to give the library's name and an idea of what it does. Copyright (C) year name of author This library is free software; you can redistribute it and/or modify it under the terms of the GNU Library General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public License for more details. You should have received a copy of the GNU Library General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA. Also add information on how to contact you by electronic and paper mail. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the library `Frob' (a library for tweaking knobs) written by James Random Hacker. signature of Ty Coon, 1 April 1990 Ty Coon, President of Vice That's all there is to it! tremotesf-2.8.2/LICENSES/LGPL-2.1-or-later.txt000066400000000000000000000625571500171105600202730ustar00rootroot00000000000000GNU LESSER GENERAL PUBLIC LICENSE Version 2.1, February 1999 Copyright (C) 1991, 1999 Free Software Foundation, Inc. 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. [This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) "Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. 1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) The modified work must itself be a software library. b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. (For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. This option is useful when you wish to copy part of the code of the Library into a program that is not a library. 4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. 5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. 6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. 7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. 10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. 11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. 14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Libraries If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. one line to give the library's name and an idea of what it does. Copyright (C) year name of author This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the library `Frob' (a library for tweaking knobs) written by James Random Hacker. signature of Ty Coon, 1 April 1990 Ty Coon, President of Vice That's all there is to it! tremotesf-2.8.2/LICENSES/LGPL-3.0-only.txt000066400000000000000000001221621500171105600175140ustar00rootroot00000000000000GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright © 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. “This License” refers to version 3 of the GNU General Public License. “Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. “The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations. To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work. A “covered work” means either the unmodified Program or a work based on the Program. To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work. A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”. c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. “Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. “Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”. A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”. You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . tremotesf-2.8.2/LICENSES/MIT.txt000066400000000000000000000020661500171105600161320ustar00rootroot00000000000000MIT License Copyright (c) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. tremotesf-2.8.2/README.md000066400000000000000000000153211500171105600150100ustar00rootroot00000000000000# Tremotesf Remote GUI for transmission-daemon. Supports GNU/Linux and Windows. Table of Contents ================= * [Tremotesf](#tremotesf) * [Table of Contents](#table-of-contents) * [Installation](#installation) * [Dependencies](#dependencies) * [Building](#building) * [GNU/Linux](#gnulinux) * [FreeBSD](#freebsd) * [Windows](#windows) * [macOS](#macos) * [Translations](#translations) * [Screenshots](#screenshots) ## Installation ### Dependencies - C++ compiler with partial C++20 support. Minimum tested versions of GCC and Clang toolchains: 1. GCC 12 2. Clang 17 with libstdc++ 13 3. Clang 17 with libc++ 17 - CMake 3.25 or newer - Qt 6.6 or newer or 5.15 (Core, Network, Gui, Widgets modules) - fmt 9.1 or newer - KWidgetsAddons 5.103 or newer - libpsl 0.21.2 or newer - cxxopts 3.1.1 or newer - Qt Test module (for tests only) - OpenSSL 3.0.0 or newer (for tests only) - cpp-httplib 0.11.0 or newer (for tests only, optional) On GNU/Linux and BSD, also: - Gettext 0.21 or newer - Qt D-Bus module - KWindowSystem (with Qt5/KF5 kwayland-integration is also needed as runtime dependency) - Qt's SVG image format plugin as a runtime dependency (usually located somewhere at /usr/lib64/qt6/plugins/imageformats/libqsvg.so) On Windows, also: - Windows 11 SDK is needed to build - Minimum supported OS version to run Tremotesf is Windows 10 1809 (October 2018 Update) On macOS, also: - Latest Xcode and macOS SDK versions supported by Qt (see [here](https://doc.qt.io/qt-6/macos.html)) - Minimum supported OS version to run Tremotesf is macOS 12 ### Building ```sh cmake -S /path/to/sources -B /path/to/build/directory --preset base-multi cmake --build /path/to/build/directory --config Debug cmake --install /path/to/build/directory --config Debug --prefix /path/to/install/directory ``` This example uses base-multi preset in CMakePresets.json and Ninja Multi-Config generator. You can invoke CMake in a different way if you want. CMake configuration options: `TREMOTESF_QT6` - boolean, determines whether Qt 6 or Qt 5 will be used. `TREMOTESF_WITH_HTTPLIB` - determines how cpp-httplib test dependency is searched. Possible values: - auto: CMake find_package call, otherwise pkg-config, otherwise bundled copy is used. - system: CMake find_package call, otherwise pkg-config, otherwise fatal error. - bundled: bundled copy is used. - none: cpp-httplib is not used at all and tests that require it are disabled. ### GNU/Linux - Flatpak - [Flathub](https://flathub.org/apps/details/org.equeim.Tremotesf) - Arch Linux - [AUR](https://aur.archlinux.org/packages/tremotesf) - Debian - [Official repository](https://packages.debian.org/sid/tremotesf), or [my own OBS repository](https://build.opensuse.org/package/show/home:equeim:tremotesf/Tremotesf) ```sh debian_version="$(source /etc/os-release && echo "$VERSION_ID")" wget -qO - "https://download.opensuse.org/repositories/home:/equeim:/tremotesf/Debian_${debian_version}/Release.key" | sudo tee /etc/apt/trusted.gpg.d/tremotesf.asc sudo add-apt-repository "deb http://download.opensuse.org/repositories/home:/equeim:/tremotesf/Debian_${debian_version}/ /" sudo apt update sudo apt install tremotesf ``` - Fedora - [Copr](https://copr.fedorainfracloud.org/coprs/equeim/tremotesf) ```sh sudo dnf copr enable equeim/tremotesf sudo dnf install tremotesf ``` - Gentoo - [equeim-overlay](https://github.com/equeim/equeim-overlay) - openSUSE Tumbleweed - [OBS](https://build.opensuse.org/package/show/home:equeim:tremotesf/Tremotesf) ```sh sudo zypper ar https://download.opensuse.org/repositories/home:/equeim:/tremotesf/openSUSE_Tumbleweed/home:equeim:tremotesf.repo sudo zypper in tremotesf ``` - Ubuntu - [OBS](https://build.opensuse.org/package/show/home:equeim:tremotesf/Tremotesf) ```sh ubuntu_version="$(source /etc/os-release && echo "$VERSION_ID")" wget -qO - "https://download.opensuse.org/repositories/home:/equeim:/tremotesf/xUbuntu_${ubuntu_version}/Release.key" | sudo tee /etc/apt/trusted.gpg.d/tremotesf.asc sudo add-apt-repository "deb http://download.opensuse.org/repositories/home:/equeim:/tremotesf/xUbuntu_${ubuntu_version}/ /" sudo apt update sudo apt install tremotesf ``` ### FreeBSD Tremotesf is [available in FreeBSD ports](https://www.freshports.org/net-p2p/tremotesf/). ### Windows Windows builds are available at [releases](https://github.com/equeim/tremotesf2/releases) page. Minimum supported OS version to run Tremotesf is Windows 10 1809 (October 2018 Update). Build instructions for MSVC toolchain with vcpkg: 1. Install Visual Studio with 'Desktop development with C++' workload 2. Install latest version of CMake (from cmake.org or Visual Studio installer) 3. Install and setup [vcpkg](https://github.com/microsoft/vcpkg#quick-start-windows), and make sure that you have 15 GB of free space on disk where vcpkg is located 4. Set VCPKG_ROOT environment variable to the location of vcpkg installation When building from Visual Studio GUI, make sure to select 'Windows Debug' or 'Windows Release' configure preset. Otherwise: Launch x64 Command Prompt for Visual Studio, execute: ```pwsh cmake -S path\to\sources -B path\to\build\directory --preset # Initial compilation of dependencies will take a while cmake --build path\to\build\directory cmake --install path\to\build\directory --prefix path\to\install\directory # Next command creates ZIP archive and MSI installer cmake --build path\to\build\directory --target package ``` ### macOS macOS builds are available at [releases](https://github.com/equeim/tremotesf2/releases) page. Minimum supported OS version to run Tremotesf is macOS 12. Build instructions with vcpkg: 1. Install Xcode 2. Install CMake 3. Install and setup [vcpkg](https://github.com/microsoft/vcpkg#quick-start-windows), and make sure that you have 15 GB of free space on disk where vcpkg is located 4. Set VCPKG_ROOT environment variable to the location of vcpkg installation 5. Launch terminal, execute: ```sh cmake -S path/to/sources -B path/to/build/directory --preset # Initial compilation of dependencies will take a while cmake --build path/to/build/directory cmake --install path/to/build/directory --prefix path/to/install/directory # Next command creates DMG image cmake --build path/to/build/directory --target package ``` ## Translations Translations are managed on [Transifex](https://www.transifex.com/equeim/tremotesf). ## Screenshots ![](https://github.com/equeim/tremotesf-screenshots/raw/master/desktop-1.png) ![](https://github.com/equeim/tremotesf-screenshots/raw/master/desktop-2.png) ![](https://github.com/equeim/tremotesf-screenshots/raw/master/desktop-3.png) ![](https://github.com/equeim/tremotesf-screenshots/raw/master/desktop-4.png) tremotesf-2.8.2/REUSE.toml000066400000000000000000000023341500171105600153110ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 version = 1 SPDX-PackageName = "Tremotesf" SPDX-PackageSupplier = "Alexey Rochev " SPDX-PackageDownloadLocation = "https://github.com/equeim/tremotesf2" [[annotations]] path = ["CHANGELOG.md", "README.md", "**.json", "debian/**", "src/rpc/test-data/**.pem", "src/test-torrents/**.torrent"] precedence = "aggregate" SPDX-FileCopyrightText = "2015-2024 Alexey Rochev" SPDX-License-Identifier = "CC0-1.0" [[annotations]] path = ["translations/**.ts", "data/po/**.po"] precedence = "aggregate" SPDX-FileCopyrightText = ["2015-2024 Alexey Rochev", "2018 Carlos Gonzalez"] SPDX-License-Identifier = "GPL-3.0-or-later" [[annotations]] path = "data/icons/status/**.svg" precedence = "aggregate" SPDX-FileCopyrightText = ["2009 Mateusz Tobola", "2023 Alexey Rochev"] SPDX-License-Identifier = "GPL-2.0-or-later" [[annotations]] path = ["data/icons/hicolor/**/apps/org.equeim.Tremotesf.png", "data/icons/hicolor/scalable/apps/org.equeim.Tremotesf.svg", "data/icons/macos/tremotesf.icns", "src/tremotesf.ico"] precedence = "aggregate" SPDX-FileCopyrightText = "Transmission authors and contributors" SPDX-License-Identifier = "GPL-3.0-or-later" tremotesf-2.8.2/clang-format-all.sh000077500000000000000000000004141500171105600172050ustar00rootroot00000000000000#!/bin/bash # SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 exec find src -path src/3rdparty -prune -or \( \( -name '*.cpp' -or -name '*.h' \) -and -not -name 'recoloringsvgiconengine.*' \) -exec clang-format --verbose -i {} + tremotesf-2.8.2/cmake/000077500000000000000000000000001500171105600146075ustar00rootroot00000000000000tremotesf-2.8.2/cmake/CommonOptions.cmake000066400000000000000000000302511500171105600204160ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 if (NOT DEFINED TREMOTESF_QT6) message(FATAL_ERROR "TREMOTESF_QT6 is not defined") endif() if (TREMOTESF_QT6) set(TREMOTESF_QT_VERSION_MAJOR 6) set(TREMOTESF_MINIMUM_QT_VERSION 6.6.0) else() set(TREMOTESF_QT_VERSION_MAJOR 5) set(TREMOTESF_MINIMUM_QT_VERSION 5.15.0) endif() if (UNIX AND NOT APPLE) set(TREMOTESF_UNIX_FREEDESKTOP ON) else() set(TREMOTESF_UNIX_FREEDESKTOP OFF) endif() # FYI: # if (MSVC) -> MSVC or clang-cl # if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") -> MSVC only if (MSVC AND (NOT DEFINED CMAKE_MSVC_RUNTIME_LIBRARY)) if (VCPKG_TARGET_TRIPLET MATCHES "^[a-zA-Z0-9]+-windows-static$") set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") else() set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") endif() endif() macro(prepend_clang_prefix_for_clang_cl_options compile_options_var) list(TRANSFORM "${compile_options_var}" PREPEND "/clang:") endmacro() macro(apply_warning_options common_compile_options_var) if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") list( APPEND "${common_compile_options_var}" /diagnostics:caret /W4 /w44062 /w44165 /w44242 /w44254 /w44263 /w44264 /w44265 /w44287 /w44296 /w44355 /w44365 /w44388 /w44577 /we4774 /we4777 /w44800 /w44826 /we4905 /we4906 /w45204 ) else() set( gcc_style_warnings -Wall -Wextra -Wpedantic -Wcast-align -Woverloaded-virtual -Wconversion -Wsign-conversion -Wdouble-promotion -Wformat=2 -Werror=format -Werror=non-virtual-dtor -Werror=return-type ) if (CMAKE_CXX_COMPILER_ID MATCHES "Clang") list(APPEND gcc_style_warnings -Wlogical-op-parentheses -Wno-gnu-zero-variadic-macro-arguments) if (MSVC) prepend_clang_prefix_for_clang_cl_options(gcc_style_warnings) endif() else() list(APPEND gcc_style_warnings -Wlogical-op) endif() list(APPEND "${common_compile_options_var}" ${gcc_style_warnings}) endif() endmacro() macro(check_if_stdlib_is_glibcxx) include(CheckCXXSymbolExists) set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) check_cxx_symbol_exists("__GLIBCXX__" "version" TREMOTESF_STDLIB_IS_GLIBCXX) endmacro() macro(check_if_stdlib_is_libcpp) include(CheckCXXSymbolExists) set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) check_cxx_symbol_exists("_LIBCPP_VERSION" "version" TREMOTESF_STDLIB_IS_LIBCPP) endmacro() macro(check_if_libcpp_18_or_newer) include(CheckCXXSourceCompiles) set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) # Can't use CMAKE_CXX_COMPILER_VERSION here because it may not correspond to the actual version of libc++ (e.g. with Apple Clang) check_cxx_source_compiles([=[ #include static_assert(_LIBCPP_VERSION >= 180000); ]=] TREMOTESF_LIBCPP_IS_18_OR_NEWER) endmacro() macro(apply_hardening_options common_compile_options_var common_compile_definitions_var) if (NOT CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") # Not Microsofts's cl.exe, but allowing LLVM's clang-cl.exe on Windows include(CheckCXXCompilerFlag) set(hardened_flag_to_check "-fhardened") if (CMAKE_CXX_COMPILER_ID MATCHES "Clang" AND MSVC) prepend_clang_prefix_for_clang_cl_options(hardened_flag_to_check) endif() check_cxx_compiler_flag("${hardened_flag_to_check}" TREMOTESF_COMPILER_SUPPORTS_FHARDENED) unset(gcc_style_hardened_options) if (TREMOTESF_COMPILER_SUPPORTS_FHARDENED) # -fhardened sets -ftrivial-auto-var-init=zero, override it with pattern list(APPEND gcc_style_hardened_options -fhardened -Wno-hardened -ftrivial-auto-var-init=pattern) else() list(APPEND gcc_style_hardened_options -ftrivial-auto-var-init=pattern -fstack-protector-strong) if (CMAKE_SYSTEM_NAME MATCHES "Linux|FreeBSD") list(APPEND gcc_style_hardened_options -fstack-clash-protection) endif() if ((VCPKG_TARGET_TRIPLET MATCHES "^x64.*") OR (NOT DEFINED VCPKG_TARGET_TRIPLET AND CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64")) list(APPEND gcc_style_hardened_options -fcf-protection=full) endif() endif() # -mbranch-protection is not enabled through -fhardened # It also causes crashes on macOS if (NOT APPLE AND ((VCPKG_TARGET_TRIPLET MATCHES "^arm64.*") OR (NOT DEFINED VCPKG_TARGET_TRIPLET AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm64|ARM64|aarch64"))) list(APPEND gcc_style_hardened_options -mbranch-protection=standard) endif() if (CMAKE_CXX_COMPILER_ID MATCHES "Clang" AND MSVC) prepend_clang_prefix_for_clang_cl_options(gcc_style_hardened_options) endif() list(APPEND "${common_compile_options_var}" ${gcc_style_hardened_options}) endif() if (NOT WIN32) # Not Microsoft's libc so exclude Windows completely # Undefine _FORTIFY_SOURCE unconditionally to make sure it's enabled only when ASAN is disabled and build type is not Debug # Enable it through compile_options instead of compile_definitions so that the order of flags is correct # ASAN can be enabled via CXXFLAGS circumventing TREMOTESF_ASAN option, so check for that too list(APPEND "${common_compile_options_var}" -U_FORTIFY_SOURCE) if (NOT (TREMOTESF_ASAN OR CMAKE_CXX_FLAGS MATCHES "-fsanitize=address")) list(APPEND "${common_compile_options_var}" $<$>:-D_FORTIFY_SOURCE=3>) endif() endif() if (NOT MSVC) # Not Microsoft's STL, but allowing libstdc++ and libc++ on Windows # Macros for libstdc++/libc++ hardening if (CMAKE_CXX_COMPILER_ID MATCHES "Clang") check_if_stdlib_is_glibcxx() if (TREMOTESF_STDLIB_IS_GLIBCXX) list(APPEND "${common_compile_definitions_var}" _GLIBCXX_ASSERTIONS) else() check_if_stdlib_is_libcpp() if (TREMOTESF_STDLIB_IS_LIBCPP) check_if_libcpp_18_or_newer() if (TREMOTESF_LIBCPP_IS_18_OR_NEWER) list(APPEND "${common_compile_definitions_var}" _LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST) endif() else() message(WARNING "Unknown C++ standard library implementation") endif() endif() else() # Assuming libstdc++ list(APPEND "${common_compile_definitions_var}" _GLIBCXX_ASSERTIONS) endif() endif() endmacro() macro(apply_asan_options common_compile_options_var common_compile_definitions_var common_link_options_var) if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") if (VCPKG_TARGET_TRIPLET MATCHES "^arm64.*") message(WARNING "Ignoring TREMOTESF_ASAN=ON for ARM64, it is not supported") else() list(APPEND "${common_compile_options_var}" /fsanitize=address) list(APPEND "${common_compile_definitions_var}" _DISABLE_VECTOR_ANNOTATION _DISABLE_STRING_ANNOTATION) endif() else() if (MSVC AND CMAKE_CXX_COMPILER_ID STREQUAL "Clang") message(WARNING "Ignoring TREMOTESF_ASAN=ON with clang-cl, it doesn't work out of the box") else() list(APPEND "${common_compile_options_var}" -fsanitize=address) list(APPEND "${common_link_options_var}" -fsanitize=address) endif() endif() endmacro() # Needed for std::views::join macro(apply_old_libcpp_workaround common_compile_options_var) if (CMAKE_CXX_COMPILER_ID MATCHES "Clang" AND NOT MSVC) check_if_stdlib_is_libcpp() if (TREMOTESF_STDLIB_IS_LIBCPP) check_if_libcpp_18_or_newer() if (NOT TREMOTESF_LIBCPP_IS_18_OR_NEWER) list(APPEND "${common_compile_options_var}" -fexperimental-library) endif() endif() endif() endmacro() function(append_qt_disable_deprecated_macro common_compile_definitions_var) string(REPLACE "." ";" min_qt_version_components "${TREMOTESF_MINIMUM_QT_VERSION}") list(GET min_qt_version_components 0 major) list(GET min_qt_version_components 1 minor) list(GET min_qt_version_components 2 patch) math(EXPR macro_value "(${major}<<16)|(${minor}<<8)|(${patch})" OUTPUT_FORMAT HEXADECIMAL) if (TREMOTESF_QT6) list(APPEND "${common_compile_definitions_var}" "QT_DISABLE_DEPRECATED_UP_TO=${macro_value}") else() list(APPEND "${common_compile_definitions_var}" "QT_DISABLE_DEPRECATED_BEFORE=${macro_value}") endif() return(PROPAGATE "${common_compile_definitions_var}") endfunction() function(set_common_options_on_targets) unset(common_compile_options) unset(common_compile_definitions) unset(common_link_options) if (MSVC) set( common_compile_options /utf-8 /permissive- /volatile:iso /Zc:__cplusplus /Zc:inline ) if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") list( APPEND common_compile_options /Zc:enumTypes /Zc:externConstexpr /Zc:lambda /Zc:preprocessor /Zc:throwingNew ) endif() endif() apply_warning_options(common_compile_options) apply_hardening_options(common_compile_options common_compile_definitions) if (TREMOTESF_ASAN) apply_asan_options(common_compile_options common_compile_definitions common_link_options) endif() apply_old_libcpp_workaround(common_compile_options) list( APPEND common_compile_definitions QT_MESSAGELOGCONTEXT # Needed to silence a warning when using QList/QVector with ranges QT_STRICT_QLIST_ITERATORS ) # QT_DISABLE_DEPRECATED_BEFORE can cause linker errors with static Qt get_target_property(qt_library_type Qt::Core TYPE) if (NOT (qt_library_type STREQUAL STATIC_LIBRARY)) append_qt_disable_deprecated_macro(common_compile_definitions) endif() if (WIN32) include("${CMAKE_CURRENT_FUNCTION_LIST_DIR}/WindowsMinimumVersion.cmake") # Minimum supported version, 0x0A00 = Windows 10 list(APPEND common_compile_definitions "WINVER=${TREMOTESF_WINDOWS_WINVER_MACRO}" "_WIN32_WINNT=${TREMOTESF_WINDOWS_WINVER_MACRO}") # Disable implicit ANSI codepage counterparts to Win32 functions dealing with strings list(APPEND common_compile_definitions UNICODE) # Slim down , can be undefined locally list(APPEND common_compile_definitions WIN32_LEAN_AND_MEAN) # C++/WinRT macros list(APPEND common_compile_definitions WINRT_LEAN_AND_MEAN WINRT_NO_MODULE_LOCK _SILENCE_CLANG_COROUTINE_MESSAGE) endif() if (TREMOTESF_UNIX_FREEDESKTOP) list(APPEND common_compile_definitions TREMOTESF_UNIX_FREEDESKTOP) endif() set(common_public_compile_features cxx_std_20) set(common_target_properties CXX_EXTENSIONS OFF CXX_SCAN_FOR_MODULES OFF) get_directory_property(targets BUILDSYSTEM_TARGETS) foreach (target ${targets}) get_target_property(type ${target} TYPE) if ((type STREQUAL EXECUTABLE) OR (type STREQUAL SHARED_LIBRARY) OR (type STREQUAL STATIC_LIBRARY) OR (type STREQUAL OBJECT_LIBRARY)) target_compile_options(${target} PRIVATE ${common_compile_options}) target_compile_definitions(${target} PRIVATE ${common_compile_definitions}) target_compile_features(${target} PUBLIC ${common_public_compile_features}) target_link_options(${target} PRIVATE ${common_link_options}) set_target_properties(${target} PROPERTIES ${common_target_properties}) endif() endforeach() endfunction() tremotesf-2.8.2/cmake/FindCppHttplib.cmake000066400000000000000000000036301500171105600204650ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 # Using macros instead of functions since find_package can set variable that we need to propagate to parent scope macro(find_system_httplib) # httplib breaks backwards compatibility on each minor version (i.e. second component) # Because of that we can't pass minimum version to find_package message(STATUS "Trying cpp-httplib as a CMake package") find_package(httplib) if (httplib_FOUND) message(STATUS "Found cpp-httplib ${httplib_VERSION} as a CMake package") else () message(STATUS "Did not find cpp-httplib as a CMake package") message(STATUS "Trying cpp-httplib using pkg-config") pkg_check_modules(httplib IMPORTED_TARGET "cpp-httplib >= 0.11") unset(module) if (httplib_FOUND) message(STATUS "Found cpp-httplib ${httplib_VERSION} using pkg-config") else () message(STATUS "Did not find cpp-httplib using pkg-config") endif () endif () endmacro() macro(include_bundled_httplib) if (NOT WIN32) set(HTTPLIB_REQUIRE_OPENSSL ON) endif() add_subdirectory(3rdparty/cpp-httplib EXCLUDE_FROM_ALL) endmacro() macro(find_cpp_httplib) if (TREMOTESF_WITH_HTTPLIB STREQUAL "auto") find_system_httplib() if (NOT httplib_FOUND) message(WARNING "Using bundled cpp-httplib") set(TREMOTESF_WITH_HTTPLIB "bundled" CACHE STRING "" FORCE) include_bundled_httplib() endif() elseif (TREMOTESF_WITH_HTTPLIB STREQUAL "system") find_system_httplib() elseif (TREMOTESF_WITH_HTTPLIB STREQUAL "bundled") message(STATUS "Using bundled cpp-httplib") include_bundled_httplib() else() # We shouldn't actually get here message(FATAL_ERROR "Invalid TREMOTESF_WITH_HTTPLIB value ${TREMOTESF_WITH_HTTPLIB}") endif() endmacro() tremotesf-2.8.2/cmake/MacOSDeploymentTarget.cmake000066400000000000000000000002061500171105600217610ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 set(TREMOTESF_MACOS_DEPLOYMENT_TARGET "12.0") tremotesf-2.8.2/cmake/MacOSInitialSetup.cmake000066400000000000000000000016771500171105600211210ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 include("${CMAKE_CURRENT_LIST_DIR}/MacOSDeploymentTarget.cmake") message(STATUS "Setting CMAKE_OSX_DEPLOYMENT_TARGET to ${TREMOTESF_MACOS_DEPLOYMENT_TARGET}") set(CMAKE_OSX_DEPLOYMENT_TARGET "${TREMOTESF_MACOS_DEPLOYMENT_TARGET}") if ((NOT DEFINED VCPKG_HOST_TRIPLET) AND (VCPKG_TARGET_TRIPLET MATCHES "^[a-zA-Z0-9]+-osx.*$")) message(STATUS "Automatically selecting host triplet on macOS") cmake_host_system_information(RESULT platform QUERY OS_PLATFORM) message(STATUS "Host platform is ${platform}") if (platform STREQUAL "arm64") set(VCPKG_HOST_TRIPLET "arm64-osx-release") elseif (platform STREQUAL "x86_64") set(VCPKG_HOST_TRIPLET "x64-osx-release") else () message(FATAL_ERROR "Unsupported host platform '${platform}'") endif () message(STATUS "Setting VCPKG_HOST_TRIPLET to ${VCPKG_HOST_TRIPLET}") endif() tremotesf-2.8.2/cmake/WindowsMinimumVersion.cmake000066400000000000000000000003421500171105600221440ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 # Windows 10 set(TREMOTESF_WINDOWS_WINVER_MACRO "0x0A00") set(TREMOTESF_WINDOWS_SUPPORTED_OS_ID "{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}") tremotesf-2.8.2/data/000077500000000000000000000000001500171105600144405ustar00rootroot00000000000000tremotesf-2.8.2/data/CMakeLists.txt000066400000000000000000000105471500171105600172070ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 list( APPEND QRC_FILES "${CMAKE_CURRENT_SOURCE_DIR}/icons/status/status.qrc" ) if (TREMOTESF_UNIX_FREEDESKTOP) find_package(Gettext 0.21 REQUIRED) install(DIRECTORY "icons/hicolor" DESTINATION "${CMAKE_INSTALL_DATADIR}/icons" PATTERN ".DS_Store" EXCLUDE) set(po_dir "${CMAKE_CURRENT_SOURCE_DIR}/po") set(desktop_file_template "${CMAKE_CURRENT_SOURCE_DIR}/org.equeim.Tremotesf.desktop.in") set(desktop_file_path "${CMAKE_CURRENT_BINARY_DIR}/org.equeim.Tremotesf.desktop") add_custom_command( OUTPUT "${desktop_file_path}" COMMAND "${GETTEXT_MSGFMT_EXECUTABLE}" ARGS --desktop -d "${po_dir}" --template "${desktop_file_template}" -o "${desktop_file_path}" DEPENDS "${desktop_file_template}" VERBATIM ) add_custom_target(desktop_file ALL DEPENDS "${desktop_file_path}") install(FILES "${desktop_file_path}" DESTINATION "${CMAKE_INSTALL_DATADIR}/applications") set(appdata_template "${CMAKE_CURRENT_SOURCE_DIR}/org.equeim.Tremotesf.appdata.xml.in") set(appdata_path "${CMAKE_CURRENT_BINARY_DIR}/org.equeim.Tremotesf.appdata.xml") add_custom_command( OUTPUT "${appdata_path}" COMMAND "${GETTEXT_MSGFMT_EXECUTABLE}" ARGS --xml -d "${po_dir}" --template "${appdata_template}" -o "${appdata_path}" DEPENDS "${appdata_template}" VERBATIM ) add_custom_target(appdata ALL DEPENDS "${appdata_path}") install(FILES "${appdata_path}" DESTINATION "${CMAKE_INSTALL_DATADIR}/metainfo") elseif (WIN32 OR APPLE) message(STATUS "Building for Windows or macOS, deploying icon themes") set(TREMOTESF_BUNDLED_ICON_THEME "breeze") set(TREMOTESF_BUNDLED_ICON_THEME ${TREMOTESF_BUNDLED_ICON_THEME} PARENT_SCOPE) install(DIRECTORY "icons/hicolor" DESTINATION "${TREMOTESF_EXTERNAL_RESOURCES_PATH}/icons" PATTERN ".DS_Store" EXCLUDE) set(hicolor_download_path "${CMAKE_CURRENT_BINARY_DIR}/hicolor/index.theme") if (NOT EXISTS "${hicolor_download_path}") set(hicolor_url "https://gitlab.freedesktop.org/xdg/default-icon-theme/-/raw/v0.18/index.theme?ref_type=tags&inline=false") message(STATUS "Downloading ${hicolor_url} to ${hicolor_download_path}") file( DOWNLOAD "${hicolor_url}" "${hicolor_download_path}" SHOW_PROGRESS TLS_VERIFY ON EXPECTED_HASH SHA256=a02db5e1b203d981701481bbef8b6fae1c576baf34927eecbc0feae0d2cb9bc5 STATUS download_status ) list(GET download_status 0 download_status) if (NOT download_status EQUAL 0) message(FATAL_ERROR "Download failed") endif() endif() install(FILES "${hicolor_download_path}" DESTINATION "${TREMOTESF_EXTERNAL_RESOURCES_PATH}/icons/hicolor") set(breeze_download_path "${CMAKE_CURRENT_BINARY_DIR}/breeze.tar.xz") set(breeze_version "6.12.0") string(REGEX REPLACE "^(.*)\\..*" "\\1" breeze_version_without_patch "${breeze_version}") if (NOT EXISTS "${breeze_download_path}") set(breeze_url "https://download.kde.org/stable/frameworks/${breeze_version_without_patch}/breeze-icons-${breeze_version}.tar.xz") message(STATUS "Downloading ${breeze_url} to ${breeze_download_path}") file( DOWNLOAD "${breeze_url}" "${breeze_download_path}" SHOW_PROGRESS TLS_VERIFY ON EXPECTED_HASH SHA256=1af979a67c0539f27a8fcbff973c91245584bfb260dd64c206bc691575cbb668 STATUS download_status ) list(GET download_status 0 download_status) if (NOT download_status EQUAL 0) message(FATAL_ERROR "Download failed") endif() message(STATUS "Extracting ${breeze_download_path} to ${CMAKE_CURRENT_BINARY_DIR}") file(ARCHIVE_EXTRACT INPUT "${breeze_download_path}" DESTINATION "${CMAKE_CURRENT_BINARY_DIR}") endif() set(breeze_extracted_path "${CMAKE_CURRENT_BINARY_DIR}/breeze-icons-${breeze_version}") install(CODE "set(breeze_extracted_path [=[${breeze_extracted_path}]=])") install(CODE "set(TREMOTESF_EXTERNAL_RESOURCES_PATH [=[${TREMOTESF_EXTERNAL_RESOURCES_PATH}]=])") install(SCRIPT InstallBreezeIcons.cmake) endif() if (APPLE) install(FILES icons/macos/tremotesf.icns DESTINATION "${TREMOTESF_EXTERNAL_RESOURCES_PATH}") endif () set(QRC_FILES ${QRC_FILES} PARENT_SCOPE) tremotesf-2.8.2/data/InstallBreezeIcons.cmake000066400000000000000000000104151500171105600212020ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 cmake_policy(SET CMP0011 NEW) cmake_policy(SET CMP0009 NEW) cmake_policy(SET CMP0057 NEW) cmake_policy(SET CMP0177 NEW) set(breeze_icons_destination "${CMAKE_INSTALL_PREFIX}/${TREMOTESF_EXTERNAL_RESOURCES_PATH}/icons/breeze") file(INSTALL "${breeze_extracted_path}/icons/index.theme.in" DESTINATION "${breeze_icons_destination}") file(RENAME "${breeze_icons_destination}/index.theme.in" "${breeze_icons_destination}/index.theme") file(READ "${breeze_extracted_path}/commonthemeinfo.theme.in" commonthemeinfo) file(APPEND "${breeze_icons_destination}/index.theme" "${commonthemeinfo}") # Keep in sync with QIcon::fromTheme() calls in source code set(bundled_icon_files application-exit.svg applications-utilities.svg configure.svg dialog-cancel.svg dialog-error.svg document-open.svg document-preview.svg document-properties.svg download.svg edit-copy.svg edit-delete.svg edit-delete.svg edit-rename.svg edit-select-all.svg edit-select-invert.svg folder-download.svg go-bottom.svg go-down.svg go-jump.svg go-top.svg go-up.svg help-about.svg insert-link.svg list-add.svg list-remove.svg mark-location.svg media-playback-pause.svg media-playback-start.svg network-connect.svg network-disconnect.svg network-server.svg network-server.svg preferences-system.svg preferences-system-network.svg preferences-desktop.svg preferences-desktop-notification.svg preferences-system-time.svg system-shutdown.svg tag.svg view-refresh.svg view-refresh.svg view-statistics.svg window-close.svg ) # Replacement for file(REAL_PATH) that doesn't work on Windows function(resolve_symlink_recursively path output_variable) while (IS_SYMLINK "${path}") message("Resolving symlink ${path}") cmake_path(GET path PARENT_PATH parent) file(READ_SYMLINK "${path}" target) cmake_path(ABSOLUTE_PATH target BASE_DIRECTORY "${parent}" OUTPUT_VARIABLE path) message("Resolved to ${path}") endwhile() set("${output_variable}" "${path}" PARENT_SCOPE) endfunction() set(files_to_install "") file(GLOB_RECURSE all_icon_files LIST_DIRECTORIES OFF "${breeze_extracted_path}/icons/**/*.svg") foreach (icon_path IN LISTS all_icon_files) cmake_path(GET icon_path FILENAME icon_filename) if (icon_filename IN_LIST bundled_icon_files) cmake_path(GET icon_path PARENT_PATH icon_dir) cmake_path(RELATIVE_PATH icon_dir BASE_DIRECTORY "${breeze_extracted_path}/icons" OUTPUT_VARIABLE relative_icon_dir) set(destination "${breeze_icons_destination}/${relative_icon_dir}") if (IS_SYMLINK "${icon_path}") if (WIN32 OR CMAKE_HOST_WIN32) # Copy original file with linked name resolve_symlink_recursively("${icon_path}" original_icon_path) file(INSTALL "${original_icon_path}" DESTINATION "${destination}" RENAME "${icon_filename}") else() # Copy the whole symlink chain file(INSTALL "${icon_path}" DESTINATION "${destination}" FOLLOW_SYMLINK_CHAIN) endif() else() file(INSTALL "${icon_path}" DESTINATION "${destination}") endif() endif() endforeach() file(GLOB size_dirs LIST_DIRECTORIES ON "${breeze_extracted_path}/icons/*/*") foreach (size_dir IN LISTS size_dirs) if (IS_SYMLINK "${size_dir}") cmake_path(GET size_dir PARENT_PATH category_dir) cmake_path(GET category_dir FILENAME category) set(destination "${breeze_icons_destination}/${category}") file(READ_SYMLINK "${size_dir}" original_size) set(installed_original_size_dir "${destination}/${original_size}") if (EXISTS "${installed_original_size_dir}") if (WIN32 OR CMAKE_HOST_WIN32) # Copy original directory with linked name cmake_path(GET size_dir FILENAME size) file(INSTALL "${installed_original_size_dir}" DESTINATION "${destination}" RENAME "${size}") else() # Copy symlink file(INSTALL "${size_dir}" DESTINATION "${destination}") endif() endif() endif() endforeach() tremotesf-2.8.2/data/icons/000077500000000000000000000000001500171105600155535ustar00rootroot00000000000000tremotesf-2.8.2/data/icons/hicolor/000077500000000000000000000000001500171105600172125ustar00rootroot00000000000000tremotesf-2.8.2/data/icons/hicolor/16x16/000077500000000000000000000000001500171105600177775ustar00rootroot00000000000000tremotesf-2.8.2/data/icons/hicolor/16x16/apps/000077500000000000000000000000001500171105600207425ustar00rootroot00000000000000tremotesf-2.8.2/data/icons/hicolor/16x16/apps/org.equeim.Tremotesf.png000066400000000000000000000013261500171105600254740ustar00rootroot00000000000000PNG  IHDRaIDATxKaƯ=o ƌQ27o´ FI$PR@DoAp/>ϳݝ(}k9羮\`8{ؠj16S쇁P@޷HD!pwf_Y)H| >bHrY7<Hz^gsĻ"IBnr 9%W `d$]yC|B<|@<hA@jȼǹv=r`yo8db?=}[ՔHE&D2"eOUL&E"2%zH[cjxl}侽x@0 7LҺ,ˆ*Tq :<74}hzL (uY U!jL*hm=NKl5P,455P(@7tzj!EP%16in> |q)^/lB7hs`ZZZxt:da+gʒ):QˉsrݲNժ@XDTBCCm;`C4qTEzն-!) Պ|^^2A-A4Aj8!v&4O?iAb[IENDB`tremotesf-2.8.2/data/icons/hicolor/22x22/000077500000000000000000000000001500171105600177715ustar00rootroot00000000000000tremotesf-2.8.2/data/icons/hicolor/22x22/apps/000077500000000000000000000000001500171105600207345ustar00rootroot00000000000000tremotesf-2.8.2/data/icons/hicolor/22x22/apps/org.equeim.Tremotesf.png000066400000000000000000000022431500171105600254650ustar00rootroot00000000000000PNG  IHDRĴl;jIDATxڥ_lKQǿ_ok56&62AHHH ^x A  *lt$"abm{{^s6pO~z~{r1k֬ {E!-fժ>q OrX^k §=lW}f*Ec'DiqΟgMcQyhxv0gq9LR wA>ʙL jJlߵ'{ mn`-W`o [63.,,\r|Bg^NX1Ϣpx\CG:(#FotOvə 0#Fš: d2  ?<88隙 QW[z1ڥ !Oq<| !+rQ8.Mڬ|7u/K.+Zʏ#?/xZ.\DMM U@\九[,HO4 &MBAAHڊѨ2S T#·o|q(<ĊEF9-qc#:p]ܧY~Ԅ9jDEF>U+hrqI4qe^aajo^4?6ZZ J|^]Di8GF ƨUI:t@wws^}W.ß9x]PS]=O]LÑ&$I%^$:X yŸ#s>=yW^"^[E,r4dQPQ6yyyx-mnmil֎#gd,`(**MK$`l o&nSAS Xd zp{=Ұb_l6Wlve8w4/[$ &Hvs(߸fjolز2X-V0ɵ¡8&g]2)4Uh_n-gaUMs ={uuu4J>ͭ5 D$`gX%'T`pn޼"G(/^#b  SΌN#@'e6Af76?ms|.cz| FA( > 8>V ~'O Fl#ި*cPYU5\xR/觤~ >Qyn``?4@c>D" v[x:.7\N_=CH7S GZy8I#?> G}8H2%i?A{u?| Y4jBO >ŀ|xCYIENDB`tremotesf-2.8.2/data/icons/hicolor/256x256/000077500000000000000000000000001500171105600201535ustar00rootroot00000000000000tremotesf-2.8.2/data/icons/hicolor/256x256/apps/000077500000000000000000000000001500171105600211165ustar00rootroot00000000000000tremotesf-2.8.2/data/icons/hicolor/256x256/apps/org.equeim.Tremotesf.png000066400000000000000000000651561500171105600256630ustar00rootroot00000000000000PNG  IHDR\rfj5IDATxر AEQ&&5`S ZXƂ<8 7- ş?@X3e;3KRH ` }ׇ~<ޗgzn;{bQEW9z'L2Iyd4"DK@@#W@Pg/?BGAI F jLtw*ˋٰX{9sת' u41 ƃ?X]áM,4ʲ)[e̬,%8?-G_y°?xpG5{wߗMYn߼V魲eW2i46O`E6nY{6pnL!7KɲsGp}k vKlMy*Wa V:L`ΞfzʡО  8cvIo M`se29@;,2T/Y{_x Vo,Z(\Y٫keJc+L6A0 sؕ~A<_xT љXgɉֈa0w+; ’Nqz=^}_&p2yM4o>4Y>&BQ =K< sGduq!dͰ̣!H)a9Cm6Ce~gZ61{=J;Г  9k0' 2t3Wc{@[ǝ``<̱+Wk̏U7;DG"/[ l 9S.L[Y9K*nQ 8XS*8F*ڂDDssXZ^Fi â@$S<^Uz#+8ky9M@ǫ9̸,jpql{›owO~ޘvC~Ikc4 'Ec*JΊhk.FHLcK]ߺy($8y\Z[Cw"|/1'$wdN1a4#e( ;y7^|sb3_ʲOa/]h!y%g*rЉG+(D @٘Q]Ǭ;*4@[#_^FB̀gKC+0V3 bkH8Yz&9a&#(~L!qYcjY~~s2y1,F3{VyC (oׅm\`"OvzLHS3b'@}""U$9~뻇={2n>zdb<3!2şY͠Fr@4(~0‰a2w-8;{ OnjeG,{XD, *B8j+L+(T主E@HEA6*hULA B 8{؛?0jp }֚;̀L#H9 t"v!F@1'uGP\-ᩬ٭*+1,d2m SF++[D]A݋"*KѧbgE<=&e/aܺ響;]2K>Z)( N(6'<{$&1YGwMB;A& 28AK4{Xz}76˗/K_ȃ}拗ݽ3Tů~`p-P< AA Z#c#PZSѫ䐊p)}"wgw}V ]>CG;HމP>6ID:9ϛ?ػɎ}tΌ_0clBqD2HQHBB!QE ,EO(P<,D@M3q1{oU{tTNkĺ{fS}N:UEP94y*9^Cݿx*N|c|g;q{C ,*uo7y΂9-Q*D kx8tps7/\N}?|{IvAɿ'kE㾲md~&?(u$a60z9Vq1MXoe5~V3kЖ,YV67O@$zƉ {^*enAҌ,ኂ,jǾ^g~_??%{=[ny1?7/φ'/ciPW +DR;?Q\ CD]-Y˙pMMY3 Y}ܢJ~B3jk5Aӄ{0 )aCmd [~Xpr0: ^^\ >?qד`Uu{c|0]̠a#" Ins#P`zhQ=vA7ZpDwb-M rA@6Ml+߃E I}Mk@L8Z0:u0"Gcag# 3 kXRNtF5Í (L uqn] &G[r"Kf8D3'FgU!{Uv~s=Lp/e$F idRRQ-K9X$; CGγ\Ƚ!:@S z,*[:)Ц3.b [@α(UQY'al " 00T(dQ':,X_[ð`# `00k HKP55 kAu.Xͤ׀q[[@΅gq@ 8> BǺp9Fc=^_[Oӱ>0_<1! ϿWs\Bx269& ⤠1τZTYRdF)yM$:zo@}$kWW/$4s9;zECsgam}{08'aC*sN(KzQ"?m?:{8)O?<|GP*o#KT6kL앀ɠItVZ a,J66gY g(ƠOSz<E^yµ잫~Uw2z9ϙɚ@teCt杇'N 8{y~#0/ݿ Пr`4a1`HjDc5@Xu sZ R 0T` U.0&X6sUU?U$;Dn-+5 y^> ⬡xyԢXUT @K7`5\WQ.S@T<0XFyhs8Z[X^~ms Q~XnR ӤٰG -AǕW^e>Cp,//j=C_Y<~ z {MRN,\GLhmOvۮ⦟~5{5k;y{Iv^Zo G]6kc\K;#6娸.\ըO>5<=@}O%%طoϟ:t_׎|_= ?40+r E_:o~Ϝ9|JYBZ5rEKͬ+vL9XO cd<(P> >兴*1(ezh҄SH }O~Р5<V sz@2Frd2riFdw~ɕ<EI\h"؜u}K,X pn&C] 4el2Nctpb[O2 J/9rwe͊g5-{ a.4"e<Ƽ(ƊsDBz٦>fM E%|<ٚ'u|~KRN-3YE(eBUM cCc0P'zCP?{;y຦۹< >ˎy]($1}#6fq-sYbD4hed9nk,5py=cOR[QB 0숸2%ץt-7=N{7<io7?{o^o7d`-qsRZ..;CD\m CK/P5Nr "` `"ʹ9Nv |BA/.e %S\7m:9>}ʹOXY͸ܑ@Ѻy}9@UJyse,]b73LʰC6fU nccVAcnMzT@YroU҇RWm^{w,/o~eef/3wq_~,o^n_~4pq|e`1R&7 9hэy: ^C2i# @B]ZQU#Tqݲ"-`z6)"O`7ޅK@iMY!y o9UlyQ 3dYCx=O [֓g9 2 L H*q+O`FZ5{M`A%EB.C{x:0m$$H2&5j.kkֲPxB0?]P Bq00Y0e$HQhKm؎Nw5{0dZ_   ! >c!:SL(fn03h$z=ceQ(/@ -GX` ϥ k  Ix^X t h{I)0a*"8 I cPP%׋@g~-.HR`7A[ fV@Bd'^@"c՗e$Xȥ(L#%%H:*Ti>] Eǒ UB@'y: BЄKH0]Pg_#;EB"BH e @H (d &%$H46pŅ7AQt,y d""K ?`d3A 񁸴Mg-0$Q Ź-^21- ! @wNWm4BЌ@]w :G 0W=$ 0,ӂ- íz8? rR$T2'x$tt02,c(2H0uTbyn` =6H4Wz#PO)-Ā!Ix-EB"<p"DA $L6mae`׉@&"` A , tHO 4_! V C ^E & _n"! * s{(Bk]pH 5=60<]CS7al$`eC go0]70s@.1/mHwW.}deQ`*j\z W3YM{k%)JнY7:(?Z /Ո]w]Y[2{s627dԥlU <]%8ZМ Jf@"&<lz [hfss ?ן9yP W7 ѭdp<Ѓau׭QLM 5R"lY^\ASzNaEvkd},N/UXmݜ>oW$_=bL|Vyi8 ȹ%Րc>eFc1, C@N6W#gC]~ 6 ' :N<3F&D B8? < `W;[wxG`M42|h..kXNNZ&A\1NYYC(3s >BQC,X=jkڭZ!Bm]=lw&Pve~vʖI:T%$!h+:¨DMFivlzPeUUnPTPnAlGQpOBBR 9{zsoߪ[wݷCiDZ(u~t*w򺸘;Lmɦ 4m3cX4E:{?0KdtlTh4@L5r^G#<8Ml6++V!]]Τ$Rkq750ɉ;oq{ٖpT*m{\6|ډ9 *io<1}}}r'<HFN߱cG۔1 >{~H,БH,XFC\^M B> Νo;o+{}ٲvZRd.f MLv5g5PC[wԬ|!D\ʕao,Z|02*4aTO6|r|}-M'/éc{S Vw ˯~+_prǃaNf|s xr3M_]z~V̆}?Nb=h"ӖyA܁ Qm8/Xڹ2Y@Z!ǂy0͛#.Hz{{1ts= $GXpXx<$U mƍ¸OTO]CIA1p F*8N$ ? THJ G򒗼BvP=S. 6fshՁu'岬Z [sA }dV7qvfw"-Y,fOh,R7u8'''X,ʦMƉ~X}?lV ? #?hd)Du]n=C l +fb60nQ}G~saT#`ȹz@H:s%Ϋd3 6' Y }x֙%ԔThD"E|NC._8|r,(\N&@#|XPse3i` GxxxD/dÆHE|>߿_z5.S+!`Ne:(>I؆ WB`Hz& )g^ޏEַ3 aCb6.1DL2".]7 C|ܖ7oV@y xU/|^? #'!(f>0*PPyȐҊK<<˷~~2+tS1W%*%+Z?h9o[n"!ZB O"N俯{P6s=5PuE JY%ա't?'",P䊹 <2f ؈xv}ttK!yauW߬F4`̀Y+ݱTπεGxg7Y>O=\{ϯxf{JAVMs!C8:JUakUW`&zް]6>(gcqj-ݥqI3=*YpT `漑x0 o{>`1!ubc^Q7 "`dGZ*@B!23AtȂVעو##fW ˆ}w]!om֭Vh$WnԧK#Cn@s%5=dU!$C{qjr_[`WՆĦ, Q(H&;z:6PR Wh[/X(yQ/p-.iqܓ~ojAD nǟeos 6TF ꑀZtĤp?<,P=p~C%a0@uORQr̉- ? Ȧ' wxdtd@VGb9sƽלw>~rٯ' zn.AZed_Y= X$c\yw.w}2s(M9= sT3/NUa*؈"В! dו'Z#¹Pr)EgT0s.p7Wy/xDC TJ`@x0U ?Q䂈(70 giJQNpD@ hu% !$~j[*<\ȈUڮ_~$bp)Ifs+pμ7uF eD /:Ibxkp|tI}M-&Z;tגb;Ï9 hr-S>GcCtU{ )iDI?!7#?DgWGRm Ži@ 0@űY+;sUj t-@`uűԏР1,HN.ve &tJU.! Lf R"c;@\v64pUˀgI(NVI5qeJ$@=xT]TCq:2\t :DnQٖlJa,$^EE t5LϛB"<$Q YlHxvai pl/`c]gBɽ{U1 bH8j-мP |[ grխ]V =G>;@ >"b8>a+gcOM LOlQ?xxq:'w`+2jMMLB,a"\ςxÙ&'E|"oD :O[U!|6̘Z@5EOw  㸂'P %8: ԰D@+D=&94K\:b (4Xh58gjpgDh0KY܁؊u=.?gw px~M5:`+p9\)V5TZՇ ԃt5a3z cgBVX0 B=r(L&l< 1sr}n촦 qDŽHDC#BqNW!MKTdsc0)wX&*@d~v%-_RTM@؍u"1#&ls*o+50dynh +DUE@mN  jC^@)@>bWGZS+H\FFwQmf>NYI>}$ F@mZ6g] :0!&'r00::Oė0FSm$=%O<$SSE,')`xI;p@ hITؚ;Rq\|6q 46>o6I`߫rc6Ԙ8 KN^jƼ >;2!\3 "#Ab]8s$q.V(b`i=ʶfPXv, \dp 5@wʒ`kAvK BÓG%~d8RH]N c*1eds}pb LYJD17Qe  €xq#*A¸w$ՆtR VRq/f#؈tH& Q5``d!Ph*6L:ՇIi-A;Y hl۰nJ$R<U; Pa]T*Ϫ[  TD)q3D:<7cl.ȿG|թRLhRT)T!D {~_m(G\3BZ{,цu'ʡqD(Iٵ[PbѤ}Ƹ}-]ݝK|[;@*2kp}ϏCP7bK'mzo yl)" 5U|r>2Ǧ 9#C2{r A71N/ڠf't {h7KFJi@Kj@>~uvvC}{?RK2)>י uP$T2 vǛ8q_#3[ippuxxbHu 6ku@d8܇bTLE=8kZH138cĞ uq>w +u.D?Z6uuu '<*U\scɒ OSsOOϗ7͎@YC`f9kl줢.nan,HigG˛Veۛmh* btdD'cAAHqYugζ-MeJt8K~&1 H4zs|@F#q !JpǷ^|8(_vZCB9u1̴ F#Lb1fӟV'#>y_8\iERٙ]ߊ1G!Jʞ>r+8`-dr<::k׳*H&~vb!YAlCH}_5/5<*陟4M) 33Aa_/g,>D.mkc+5 C"CP*X,1(>,O:BqbPK0ȥs<T x@ ey=nE^,m] ! JlF +~zu^~ʗa¹xwXa|Ro=a0pΦĠT,K}c!t1fqW@ llX^ UGDE> 9}Od[nǶ>=ن5{XɉiXNh7[$0ʾ/n/Wqy?H8cB qƾ&3z*H[{YĠV $`l:}<)I6 SwC1:w"%FGGX=f7 u 9,uI!wۿ-=Cp 1Dm߱CG~\vĕxtoF_m?zrG H;c,3e©(NJmBbPKnvErYi-ʀϢTXjH755CDk(ޫ= ȊO뮹NVXH1J>CD,,6{e}j 2 eK===Z6K9GThB?T#4F'`+hVp $8L֍xX1_-:kAe"tCX`?)ÜʨԂO JŮ`&T*H?to7֋mo184`,tteb=BYgF֭['\wlݺU&0@^!]tM5'ǿiv+}IN0 qLٞ$Mf9mfѧR,p0~r!'+ɭ%7Ҭar7mTlJgR9|;h| u+c5o}r\*l."Q?*C*4riߍ ۔IÐ\q'1+Kgϣ@  1F3,nLt@ G×*7}&Y۷Vq-@e!&cfi d! wöFNrcPKHӫܢʈDjXġb`7-Qmdtbl{{q7~R^W M]WM4;͟رJ!`@ HSfe)BI.Al_\ZȮ"3+ 8PD 6p e]&'t\H3Z ރ@](]]hN-:\?-Oβ|ZkB|} :9.`} Ǐp9o'5+ B`$ s9009NW#ywV-zO Gzkr"1j 9\~T_F $=Gհ/1e0C#?ɂ&պF+$X(߱ctkp@+䪫\}Epxg &LdppHJg{(>kBS^%u~NT"v`A'q1:j*+'9OS,6D"l-lY@:zи]`:_yAl|OijFۄ՞8>@ʃQ=8/X ](Xt{w~&$\. a-I8?Xҍ0Yn@PX%@ꫝJ)J:'"? <}vzpUoz4n\5Rd`MCx Bݞ؇Gm?\-Խр퍀ypzQ㾕xYqN.>hDTm.y?^ڳ_ /Pz_4>=:mCm }f*C c .@bP8R$yQ]5sѦ[ԫwB;mq4Uhe][N";d[]P>F! ϒ5 6Jj0gpQ) y.l%y$%/njlOŵ ?kWaeҋ&;eժUp:Koˆ ̽Jwb]AsٖH JPB<àFe|ȅ-޲l/T:xΟ<L$<@h BGu|;o<}'s\p?P/y]Z% 7*U zώ kkJ1D2@u9\uhKpCªH8^~7 Yy v|{ɋ_|*Ͻid.gyrN![lOݥNX rG"s go"-"eA³l41%`xn=OxpX*64Ԓd{pm%9E{Oʰ:k'[:CCfѬjCdlr_^U]SNȫ_}!gӀ"[0@7hG?i4菒i5@gu b`RwZ JX3P Z6Y_A$ƍ>{HH^{qW&GP O,q)L1Sgtt:Ġ IezJ0?W>R}m@Uhf!֨Mя~DկK_R>eTNR޿DUs=a(rLB}:R,Z*@}`K^."X ^Jšp+8 y'e|||͕Fo?44#k ( V4Ӄ`m}AH Q`udcjPB  a:sGg򗿔 ]a$?=wFdt~9u$@xQ!ݨ^-ӛ GўnѨԲ8] @B -f5Pd^k[Urw͛ I F\ yvTh8~a>SY~_$plP HUUJTt-Ġ`ȃ\U*S.ә42*a0@r ݲe WWm(@b*ln1̆*R/~*"m9d^'8g|T|#{%LX(]?>jP M>iȐ מb /#N?)*oaL9Lc! t` /3#Wߣ|ͳz`@(Qp5eLgh֏pK (^BK6)I s఑2$]za7dv!wo:d P(2!ZjWp P˙%JRanrVPeWV֑Gۗ*~|.O˗9Q:mC9$YEy1 +IZ=*gbP}q*K@iݧߐd{h,WV9sfRXզqcOpyŝ9xHY-t5~UM(/y@~G"N.K:DX:5FJ@ЈⰒE>:~ʀU&d0By@6'm 0D?|6Qb(H=JOg8tCo ^'1*#H ֎Gk;򣐔I>>Hc8` RG=m 8jO _Fx !_꜆`+u8V^SAB`Ҥ):k 0W\\C<4p)W"`~j^xCIׅq/F4PL@mlPX5Qt6dE[/ |E.CiEY\ўx+Zkٝ-]>8p$ \!|N_q<1pu \o3ӏ4[B۹|ŅA_һ<Fkǿ/~V~TwŪH%QT!'W` wqC_H3!g  tҎSD/{O99ڃuk/=ᙔ .Kd%-`0 sAuiȥJH@e,*ݩJD+0skID.v'C P$\08Dm|z+P9ʌ P?lZa!0TjJ"O,"Wqw5 k.Ȥ>ȇ˝pȺ`CQFK1IIE!)~0N_x.$H=$$ G%,0אѤx}b}[H@"/pfQ&[`m!n(0 I`X.< x\v,DW'p4Ų#0LhXk=`Yȃ 0` U~<aoH3jH0YN)˔݅rHԓ<)O = YxQ܇M 9jӊr$**/Xo;{~fmⷄ2Osn F3z)('`M(ny$xqۄS{ )'݄;>n)3T1Q'VF:(+砐Z1L+Jk ȿ (r7ʐM{{`u\=3z di!A\UZkjUF1<@fęړ5_A`CkCmXOnʴ\i{Yq25ȶ0A6 &Kv^;_rDO$KA!P8|t]DbX:FVsQ pu&Q+?_IJ c~S+lҔ:8v,ؾR~ PXo%++jpylܚ&l$奠[Jjڤ)jԕy+IEY4ҵW=/ޯ{н9dnp iVz2r Eąqi<+.?J&[ i`{{4 vlZ_ǝ۸xgݰ=֜qF7a0;S-m?˭WrhVF2;q#lG-G:/LtZ?n4]ZIGrF]d1(e<23! ḓZ#r{[NOeKf&؏m2MXVZWe+ɂyk ,Pc[d҂̸s.\?3V&&?U&j(0ѬАpf(*Q8aR~Wr,\{2? i\vӛĵfͼ=,*F/Mo|4N@=m=2H)|UH-75#*+RM'`h^ ߠ;|<Oʸ@>oKbbucʞ$LϤwk C閯Ry4Iq(=В݀*SrRZeXPxν:`NaZ?h(S/H0lJm:cf>NM 9oKmX_hz;+ 3ԍٞi. ٍG>c#gM+oqQw_HĦֶ)Q3Jmb^fqJ^I)B:c)% O5&J`@.o] N(Zѯ-Q:;޲t0zt,H^7vAS .'Wk௵t8+>~S%h_eY)cAeYw;u@ v:B @L*)k IENDB`tremotesf-2.8.2/data/icons/hicolor/32x32/000077500000000000000000000000001500171105600177735ustar00rootroot00000000000000tremotesf-2.8.2/data/icons/hicolor/32x32/apps/000077500000000000000000000000001500171105600207365ustar00rootroot00000000000000tremotesf-2.8.2/data/icons/hicolor/32x32/apps/org.equeim.Tremotesf.png000066400000000000000000000035351500171105600254740ustar00rootroot00000000000000PNG  IHDR szz$IDATxڵVQ~URZP{{#_b~ "1glg9'm(A}=yymѽ{ƒ.Tzڿ*0.>TK xm{tWpmY{t}?&ؼf|g$ttbzb Ţy@sY YqM)d`ˆҥnJE+ݺIPzzاᐬml^Wβ>Jt`)ʯDuiu)_0cD`\P{TP^ˀW^"&+LB %|U$^ bl4@8 z+*]jttT L>P^=dyKFF 9S*x,'O P(v{yh`W@}!aȡ(d۶9ZpUvN:(Vn޼y>1%H!ּL2(U?ӉYfc޼;w` >1~x|C0Dnք|LЙJknL&t1zkeQsHڿ3zf6CڵeL$p8PZ/KZ4.-[({RP=|\tނp( we74]A%%Y1ԮS[< O&S<"Ox1uz3R'y<| PZDUJDĉWZP &mI~ܺMyPaتUNe ] ,-O͟гOz и%ˑRP wW8x0:#xBx>kBbJϔ9.+ɹO]iG$I%p%M658مޔRx5-k5tSN~uhBLeB3j^ر]/_J=[n yNL5pBу@2pڕߕXQtJ]Э[W?/F |7x7o~'OҲK,[ ʗIYД:2_b*gAk 抔&Q`+VXQFbiz**b} :z}9-U[E[K4;) YB֬YS=sǍûwSi.8~ V" 7#gQUT 2mE $oQ ,7̙`3f4to,ќ]4*bfX$^wޑ@6(4iRFHTߣѨtp:k mD Q^PV, ܾ,gLlt&%O$ 4f{\RjQ knNZɭqՙ+N۶ihը&*kL )$4WTgzq~(oKB֕3]~T6fWJt9+ a)isy\F\4ve +W/ Я$ab o/S.fN#9F. z4"4+4(A!@']}Wrr`/Xƒ9v,;KX٘WP&9IENDB`tremotesf-2.8.2/data/icons/hicolor/48x48/000077500000000000000000000000001500171105600200115ustar00rootroot00000000000000tremotesf-2.8.2/data/icons/hicolor/48x48/apps/000077500000000000000000000000001500171105600207545ustar00rootroot00000000000000tremotesf-2.8.2/data/icons/hicolor/48x48/apps/org.equeim.Tremotesf.png000066400000000000000000000057261500171105600255160ustar00rootroot00000000000000PNG  IHDR00W IDATx^Xil~fvֻ]}IL|$I! mQB!EB**3= jSHhJhZBA$ji;8!v8>Z{wg>zwm j}|7y_}_x6_dpmꅽ=Efb[qt{l߼WU  `(Q;۷g|gTW[5m\fC0=q%իVnTq,mi†(,iB 3>[zEE~in 9+S@uFV+,T/QEQ599pvkZu 0qeV/5;؈WtQ]x z M n;N$|Nκgaxr\Cav*gONy}Р^a dCG44@ƍKFSH)D#LONbddxqJvc>tpx `h?KsyyS#ᐺӝq b, Dٳ^q=C" },S&B# 7LT#CD(1`!e))_Lqd$Pj;MMa4lT92fLMM!"mm )MҔ~\Ynss7hַ\Vj\5L˂R.騩=h``VlBʞ)݆cWׯC ϟ?/ɗ":WTȽlt4IA,ZU5ͻ##OHK6 Jne[R\ ]G#{$T!FO>y6䜾>G.QRZ֏;@Cvn)).ANN É1?I }v,dX,aTSS4NnBiI).]oٙԹY3VӜ|5(J,PYY*{$PQQpxTf H%%%|{+,*]n[+MuKAA+==>_^Fp٨VFUAA\QAt01>1Ӵ$fC5ay>_Fp rUcephƝdWpX|EnnA8}WׁKCfwϺ0 ;T{7*{|y*YalaMmV\+ljQֈѱ&J&#FKGs0F k-t-8`ӊ,(VAӤ a`h86^á3Ն͠SY(/+td PR[<-+ BY09憆FPYT^ 2 \p9.Wcދc 5k40pJ)ZqqC Ae6޶_N$^b L3mf¹p|rSX`Y]Id-YwJK`k8cqF/%$OdvBr9\ٌfC:>pA2,Jb!ohѪjPYϜB}%]TX$sBM8ML0AeF]E6TCV(gȡA2*MMM@drqiQ8x.(b\6a QT\5% ,RxKK 9bwDW¤E-o P]Pz"qhKGaA~kA~\s}?{3s^B5> ؏{˖-KJVdMBD&|M֯_FY\p+Kbb^KAL;_ڸ+ho?|l/q_OhJ R- vō QP d:AWs6WSzpˬrl$+Ag~d&jIAbP.d$Z?%ު/qK?⢱rTRʄRs(Rq&(Lre)LSqM image/svg+xml tremotesf-2.8.2/data/icons/macos/000077500000000000000000000000001500171105600166555ustar00rootroot00000000000000tremotesf-2.8.2/data/icons/macos/generate_icns.sh000077500000000000000000000025221500171105600220230ustar00rootroot00000000000000#!/bin/bash # SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 set -eo pipefail readonly SCRIPT_DIR="$(realpath "$(dirname "$0")")" readonly ICONSET="${SCRIPT_DIR}/tremotesf.iconset" readonly HICOLOR_ICONS_DIR="${SCRIPT_DIR}/../hicolor" readonly HICOLOR_ICON_NAME="org.equeim.Tremotesf" readonly HICOLOR_ICON_PNG="${HICOLOR_ICON_NAME}.png" readonly HICOLOR_ICON_SVG="${HICOLOR_ICONS_DIR}/scalable/apps/${HICOLOR_ICON_NAME}.svg" rm -rf "${ICONSET}" mkdir -p "${ICONSET}" cp "${HICOLOR_ICONS_DIR}/16x16/apps/${HICOLOR_ICON_PNG}" "${ICONSET}/icon_16x16.png" cp "${HICOLOR_ICONS_DIR}/32x32/apps/${HICOLOR_ICON_PNG}" "${ICONSET}/icon_16x16@2x.png" cp "${HICOLOR_ICONS_DIR}/32x32/apps/${HICOLOR_ICON_PNG}" "${ICONSET}/icon_32x32.png" inkscape -w 64 -h 64 "$HICOLOR_ICON_SVG" -o "${ICONSET}/icon_32x32@2x.png" inkscape -w 128 -h 128 "$HICOLOR_ICON_SVG" -o "${ICONSET}/icon_128x128.png" inkscape -w 256 -h 256 "$HICOLOR_ICON_SVG" -o "${ICONSET}/icon_128x128@2x.png" inkscape -w 256 -h 256 "$HICOLOR_ICON_SVG" -o "${ICONSET}/icon_256x256.png" inkscape -w 512 -h 512 "$HICOLOR_ICON_SVG" -o "${ICONSET}/icon_256x256@2x.png" inkscape -w 512 -h 512 "$HICOLOR_ICON_SVG" -o "${ICONSET}/icon_512x512.png" inkscape -w 1024 -h 1024 "$HICOLOR_ICON_SVG" -o "${ICONSET}/icon_512x512@2x.png" iconutil -c icns "$ICONSET" rm -rf "${ICONSET}" tremotesf-2.8.2/data/icons/macos/tremotesf.icns000066400000000000000000012163551500171105600215600ustar00rootroot00000000000000icnsic12PNG  IHDR@@iqsRGBDeXIfMM*i@@FQB|IDATx[il\u>͛7bneEu%7q 4Scha (E4]ҢMEhH[1&(4u@⨱ıȲM)v" Y8f7o6F)V-|󶻜ݳsm^& <-/-ߞzʹ[9'"z?}۔mxKE!5 񻁑`0/kNն="]fg#J7/4MηE}:2P677YD)__n,/ ׼D ۾ܱr˞G!mn otpPF0W1*K3^O,^F^M޻ȭ^^XO,.>B^nUY[odd27A 0ӾpK6YpbCj.gPu(=Kʖ|\[Ie@(d(bm e^ _4J~ Q}#[@KbE]Z߅sƉcNӴBeq, 1e2T[=@c◿TWU1Sz >`ۍ68+V-Z0HWI/D[Y؁oG"^o,%0aɐ⏣9@K)5ʵK ӶKOڗ8T*J+qVaggs9YUf̛i:1ϓڹA YQr<e2Y=,м^ c=zA#j666TB"3LA,oζH$pw=𕅕nM___6L@̞ ܐ|VY&$T]0'Led&X,rر iMLFWQ!#ZruY0=5q$lD4N.2zULCLr^"f(,n,%*R}`$UBxhP)b}T@fXaHC0,8ku QvbT66bTQ6ҌΗ, *|n.'ۨ+Mac,/'z`ph..\6@EVF<t@' IUlG^{WD|P!\4JO `4IWdgj^^/Ǽ9z )A+sŝzƯE*_{x`tH 븎,.>Y@d`X6<_b#G#c# +"rG`뫎pA01X0 y3U 03` v Vd>W ,R~AA9PɆ#Ah.PG$聚NEj>0Co,Ѱ1v#&)Ak!P7{ hÐTj]% n.O3N 0|^OS&x,! 6ޕVa;tƒX1sTe2g(9ÖUV).' ,A-+<]k@}cKDضrSz*@22ĒMA+U⣙\ paek^D_OUa F?k&m]aGDc1L‰P,D[q6:Xyr=>;ر1l`SnřUl5 \`\(a4å r"?mgʆ=Vj m-fd`>̟ǼeXōsL`Cȑ#GUcHvCg*r3o+WdnzΎtm-7YeN mHDAP7+43_;*.ds^%l!(crY%r"C/.CS~n"s0-klol?mOaeW mD~K,B&&'H#bݻirRk=+W04+ox$'']֗~~k8Ӭ#W 4[pGcUs p3?$LK8T_L?T߬04%l6i$%?>lC'#Kjy?6V*>/y̾;mx///[4KPj@e0 艵Q/=TJ~&Aytƀ=[O4aU`k׮rD! $zC[l~, ]7_^I}U(~1x. 9Wz Zȋ\[f8c1z0BҲX$IENDB`ic07+PNG  IHDR>asRGBDeXIfMM*iHw*IDATx} \y^ű \)LȈUS9(rQTR%*KUMَ⤘bTZ\JHQxS I .~^ŵ?0޼W㸮@Zω5`*U5FW9qbWr߸78**]p_דrJ\o} 2[+,~Q Ilj *>ԐT;0K_c[{X9pH$j81#_?+AK(y*unnŭ(몊?vR-߂i>1CCWDZ[_uV544RɔFcİ14B^3i55==pUn*HhZP8ǁ7eRJKۦS.:h^"M"YmVnz:TkK: 5;; ^5P{BRe 3o+u ~lk/M%?rOcOc7iDBo٢ZU[GV ~98ΡQݽw1n+U䧏nY]OW3OC01fSa<:w_vMO$Mw߭uhk,/!2VU$rd}XPlVEkN*bQR)5OWwa>WG_yEU…<F?2|#|do} ~&=tDb%! j(ߤeTvʁڳGmٶM$M%KPF!daݦ6ww+RexCNVK1G+}N%q|fx*FY4W_ӧMЯM1q*۫>OzY2ћ!zr0]|g8D2?sPڋBAΜ/wnؠ@;f, cLt*_ 0CǺ  tJӲLOVDÇUKJ@CӔy,𻿠 dP4`f pryǬ5O,S BFhJݼGyt3+$9G2ňb A4JlGɹtzFYE:jrռ&QAS `(QB{&uN\HPc~njjqLϬB+ OSH;Dj׏PܸQea &)~S X]x< h6p`S0ӣ{ ࠹ @7>_aC1RgMuv*lx?z8F|n)A#7~2?^&CBi o_,|I Jk,&!on؍ҢlbD@}Vvo<{-lKw .SVSc1{:$Xo, 30MJƭ1p6 Pċ:<@a2"`PTQPgɒp|{>/ ̥Сi5~a5ZycaG cD\=Ck\(?.9k!B +&ÿ'BU-i`jm}+>m|ng;>MbhfV/Wi#"=Y /@h?/PJ]+;?> }tz dw6]]C;g_3nRL&/OÕDYq+++Fvc*5A6^njq={C)e\_4X-X[v^z(KoKg^iPY ^cdӯ|8{V/F[.e'c1x`3-& V.+?VkN^>˴5Ff`m@r 64^|rLwlkjOL5P] +\ߌ+ ~VCXx1Y.MXoسX^-yN|R"ч\.7q`mxGn{yͿr\ƚZ!*K7 nՖvET>K?/sqѬis[۽ؙOn]1I  X"rlAF&٢;Zʼnn5knf+BP(9шh$FpGT&UY$G{hk׾5-BlEt$[aMm޼Ean0%ef%^CZ7o ^*r "ĸsd xyJdPS7'tuu͛66ԑ?>[|_CdGlY[U@vZd%cZR-.F7@MEr=P C5ooP"+0Qx?0/z lWVBh۬C#~!XEhx"f( D{fffU n qcZ2MEW-~mAGdpI \C7㨚u̬f՛aYV;B#4:#ẻ [KL:#Bx*I"j]fO2e<.],s vwNU#n$a{fGގbr?%I,@ :|pp^ri) 2uGۄ {{B儙..Q:;;U i:=m$EV6R06\a}S8 !BN-COO . i1҈ck EFBn/w4%mm+b"yZi7l 2˛ި^yUщ2 Ovz%jP P($cG<Ւʁã GGw.ՅO-"ʟnV*  k@|qlIQ/-`;)&`%sspPJGbDkw$o,8yl H b1Nnn̖r R G E@RV(*Mcl6]X)l#E`{ b   .5 .@ГT*挡ŇH x ܀b,`) P=}3G :OMnk[;"Q$6ˮ+ 5PnaNfUѺ{=( ʒ#I*o]!@f !A]i1*/?H9;ޜ ]z xX.LAQa Liwz냏?y|\WLFavq`8p4W[-'5y m$ RywHLYJ)G}#mWD9V> }J =!:bq5>9Y f0@R \|o+Дv (<6ЃP5&Ybb^(ɉ9kIF2`,BU&9˯$$M1u1/7ѺVhsr;zn*f3xU[{` (-U+C%)NlS&ggH< XcZ,Osfp̘g-X=2i[xVPg"/p^/̩nߗv^]N+T\Q 04I+N8m @KX)b@CGgaMR Φ`jz{8Ȧ|7Q\:m 4LUL`uwP$M")X $~)Yz4TJҝ[:,7<$ʽ FA-q+VD (Q 'rʖ_P4lx'@^a^ rc`9g?S`z8d"WKB&Y-Q.Sx6PW0877j-=~Yq *\\@[$N|c4Ь!KhW~4~|,E^A9r-cA2P P6 3аr'ЫAG*LA"x3V~x3}_GA`DmV;;:8~G0lu_VDFIDL!^5V[uEL]:M.(beyJfҹ9E"X 7kGn,dBf @:#i> J6t/~_ %x@l A)R \-% 68,ĮӃC PZ(k# 1? ?z.O@~_>BŀUm)ۊE$$v@,`TMAw ڨSiMApb%c'@=0َO."x&`ֲ)teݝ`dNV)OWRfs6߈NIjOYZB/| #l,grrJmؠWu˚ZA^kbec( b^8|K&rVd 4 (1 Ȗ[c vL`KgJٜŔ$k@ >YAxVfHR@?ؿ40~EA]lx.t$ "z]A8HLB.`2_@ըa/V!)_ R46HM7C VJc=AV;n~{N5ʟO aQ3 KLg ʓ+yBIJ@LP-}hwvu`0 LK;&)+TD3*Lfw0$5Lޣ) ǰc7mP@SշϙPTmiCy=ZRgwFv9꒺, Ә ./4kl\U=~8;p Z2D* $OYAVJp` UpV8J\v{G"$pЅ[>8%Sy}p? "9CѤh(j! ;MuɻN*`)Mav L9.F NP6[ǚ Pؙiegag SK0x4SI't^&$#'>|X|'C @ ;8r *:;:.BKeV֊YJmdqZf94eIlnYǗ+*8ZF[ХjnsAXPA:08?tLP/w73r{Gm`ZR">!9gO p7yA|t;bZeP3 d~`-5òBp:q kX7C݁"1h\O< gSw.Lme2FHr-YJ_C50>=L%~Ry"i9O!Bl~~zh%pnH1*ZGԟ*.Bf(qsÞ=j Eϖ^kg96S ؅ \XgvVM4QH6>3T^Loizk/;tr{sh'>4OyG'tr=zdR`c(ft("XNBkoT-FerhP[- bH(\!=5M HOonF5M ĉ&\9D fUd&b\v?IcdTOAxkU]فwZNOųN׌*{u۷O]z3$=ק4bhsafHh\sGnN]5yB@,_,,x{ԭH!o(Z9]L % s3 @~ww}]aZuT#;}KG'!&ox>H0D3Q jP&X<ʼfQDYFP?l/`); 5P6A7xշl*Φ[ 46nTw~-[Ve P Z$4{J~SŧnEn̾jFg/XVJ~8~EK|죷>P֯ds }{o(Y_[nZ҂W۠E#&>{&>)@ lbMh%).D+uFچ ^zI:|Hfaaz"a^<؛o[U9otj@A K$`fz=?.U7@}BM ʦrU@`(XHIkW]՟Ν+o ۿk5A63. 6E ȑ-b+aMTkPp44T*1x%riãWɦ _.4K .&Ci S2ƱA`T61WYB56KF m,H8,.؈2:IO});ay7x]?7\jh9 †; {'=QoJ/ZU2;]w],붸naU(.@uVexXm߱C{8lJ}[B`!ȸe=PEԓR 7LoG*zr O$=}XqE 18Jбh~6|Í8"触\ ߂c0:x]Qg j8/V¸(D DG_q.;9{ s DPF 1p\mуD,F_+[oEhvr"ɆW\1 cݚ+S{ABhF[o1yzMDIg3oϥ ###1^  @Ep i+Ђsj[݂ӂg,V$*iGn%AsS7qeC Ud XD`!>W'JA.N𨒛##Ӱkpw1 }},߀I|ץAF8{3M<b0ES3g1{)\зέxvvhWh# tO w*siC7p $ssԪࠅH>qi=^4?\b8R- l~Y+GGԎFVs' f̕)K/FTCTI5V BP9XSHRp9w!l})Dƺ5`3̙>Lf̙d&iUTW,`psϱ2R @>3~`e ^8F`IV " 0)cՌC:, '"{W^Z]@s>@]ŠvogFc]. mFklRRxnvl]\X'9v8t+4Eu{zc ugZ։j#d/=ʭjbfY??75֖@qBgzDא%E שנ)J!?F 5|F/(1$U )t̍3nK3 I%|e]```@ebC~ k7MOF,7;|a`RAUw^b"PF un TUNNÃ{Q,ށAv;KLZk\u1Y靛&s I b!IbY0IP pxQk@9 C\ˆv wQ=n`Cl^yE~s?X7p388\&WV14ֶʹ[L"ZL *gfg';z⟢¿DosB_44]ycйمt胓0fX,,U@Sѥ6iu"ɮ1!@T__wۭXt*A]yh=1 ;gǧ Y &""׉B‘\n;a8+AE`(Zgd4՚;L5%l-g xqѰV;1@2?sb1FfUzv'WAEU%@x| 1enq>]ȁ8X bz3#:@b! ,=#AhYg?1!IENDB`ic13ePNG  IHDR\rfsRGBDeXIfMM*igI@IDATxyey׮^}o$ؖdQ%QDs#ؙ3gÉs$dƱ3q29؉(q(9:兒h˔%JEE쵺z{\ܷT|ฮ+` @jgV;u@B~``c !;'H@ ` $`7~R H@O` !IH01I $  v0?z$} @Bvp'UO0$Hn 2 O&rGRVݙn&rE5uZu_׺gGg0$tO H?r/~A9??-зM_O?#Ou7$` ![^~ӟ\zu,q kTFN"!`_4Cs_Փ\rx,?Y h!?sOݞ^^-Z#,wq24s+ݑ:wv q:}#`?>|& |zkqs9:)D&GS2,Nl6'JV\.cER oLl=~o巫k78{ _\wɌVhb܇_k*'Ox7x㈡ȂPtpX="1o P ?CO{xCQN"˰/gӏ>pa!o VܜGGD Nry5Ifffŋl{@dn>r?@_ݟwݿ:E-pe!~%|ۢEZ2stM:@6w d=@V/`_I*؝;K{Mˏ_g5+=Ovr`VQJTw99.k25c3S&N.{_-!/>iV*(0u׉nGݻEfd3fVqW88gSơ>%@LU+I&Lݬp4ɡno!AUWN(%6xX>sF,ˢ$O. !8A(xĪw1H[1Ʃo+ nt!z=oWʥseE_/.5 "V rÇEmoŵ51s/}IV]0"\8@V١]ĉ}HL:$F2\,F6:jvJg#J,ke#@ۆnoZF?YЎwu cr~8@pC*:s䈸#Sco;F= _edd'_&mԾ Ub;/o|3ߚ?29P,'ѵ"؊34(56[)H+B(ڤn&1D[ĆlDjs&SO1]3{6҇CǙ7qz=&&ou|.-589rB{Y}zp%Ǟ59ѹTrEۓi ,NShdj(BS+09_' t v0iB vj:K/( _.R|(V&hmù8 S4p+Ppz!R =uJ̃Acm`zD%\n1n-ɟgaM(f m`zQ6ޖ~& d9RS5C#cca5^?Ev  eW^脵7ͯYl{,GVbgDj; D"-\z_"I#tNiB@o;e`5˘5ֱԄRjՎE.uFC]z3(~6܈&ǐtf@hM1ӯ5v#q6?e7J8(#R$V1ۻMA܀s`cm~[O!Qe Y;]Mqf ZRXѡ]JkJW"d,Ko@l7WXIlp_mȒzio/` <P0@r;lr_;,\(@d"0M,:ͷ!։VafmmnڮK9Y@HvxH1e O8,#)o59,Yk¿" TM #GNζJj\ܒ[ƠQK̿HfK\DAnG g(ڸ͍(g`:ռ8(VpgrL' 8XNqnkLئO'ە{p;1!&lBP\"<f`wm(={?>{Lr{  . Vmek`u!A@n2egM 'Īq}\_8.:5 .ϋ]8%ni:KؒkV_;$\.dHcL$|fV1_6~{6~PC'&"WqZ *=xTJD\?%Ym+C~&clF.?+۔Ė1,r_^F wx tԺ . DM R5dM%\f en*>D>@/<UOO5^E-D|K3d6\gHK-8\ģ_`G' 2X$θG )& T}"1IMluܿ߁[8rC)3 AkhXQ^R`_G,<*aP9ܨ`8NټN8i }r_=w+_pVۯ{[:?pHM4?#rjm~mqj2 "Fq>?β?[=܌%=L~~+W>!/LOѻvY4c\ v`>fL7Y֫Zmu:ȵ\P/<:oǃg9a9oge>{TCeogē}(Ϲ׶CE\34?%#2~k6vބmەOOPǒ`_雒 ۆxۂ __w >P{=<_:=شÃk&5}5:`}OmiZ =8; 0{w )G~hlc׎w n; B %|t4]!^Eff7`FͿ ~u @7 zn?Bp0趚"lʡ_޳ښ#}!10QNuSu&ӟ' T2#okK$t|;`#-X)[9 ;E/jtk1~o3c ٦ͼ)I` !=A{3ܩYpI H@,">iįMMX6a7`%W=X.זbskN1㼽9$ _2$` tGH@߶lm,ͯ]MwIt  ;sPX4s y% tŜILЮ="]8 VB"=IpMH{ 5*96h6,$^17 `cm~[O7lR28aa7l{.!.͗*2 k"-&%Q viTxkX66`>@B16SȪӻ`3ԷiзML0Mv, ip6& Fq:QIx0Na[p1Ig&e#/Vխ' h{  Dm*$$M TA1 xf na !tǔ~ʼSpUNH@c4צ2&DM mʔcd;ŖnD`ߦ%`mE>-Ӆ4.+|GQL?5|k W*'xti))@Tj QC!)&3 }OmC-p'.CV+\Rߒ(bc VWDac^ik 3=_OKܫ'O9!(Έ]3brrR\Bl#ܪ6dN M'%2͵EiK[S%R `}]\(VWV8t >SO=OVZ^b?%R =q Nm[:5h(P!%XF́dK[ԐLd#ɈV!6./̓C(۲}߼?Fn 7?_?eLW)q1==Gvm_d|? P۵\2t=RccrsAW ?%A+G88x@LOb]glgsVWsX!5@tj᳙uNl.+/G;MKOOMGMK]v7MW9a N6)RKWVEXĚ1nXͯL:-Y$3Nlmbb}LnN*9"h({KKb޽⭷t+ O{T|gfnOf d"{5%ɠ M)8>|L dvӯ]u5rA #* |^ cWfRؕ!X\ZĤ"[z\#~V%ٛ9K_2kkbiii{~Sw T滽v橖gŬAUE"0"e̴-J*ҿ` $銸D)x2ڪXX7wDA ؙV푁jX6`dTP[1QgY@_0R]jjrJ8eB3ܘ#32:j;$ @fbjzʆB[@? ja F5ON{* ׏f[}ܺbs-tPN >tjW% 8b|l\,\^劮-EW_F#9lpЫf2W\zJxl31%܄4B0%ag֯ƹ7bƋ-:i(Q)H5),QB%5߂v`Z눚9DTS=4 W@$-q:vv׶ԧ @2Ϯ]pĜaڕe1=1!C< B\1~&@|wA.+&ŌM&{UMZnsV:vZK<@zg??~ I ~TUm3lگrD -YC}y.8ͦJ$ ,+<{,dA=H|C9PH7PI^>Cҕ*",ެtRݣ\z'.I6B+y5O,Q+xu7p\oŔ%'rEQ18#LC2t8*IWdypTz_rG\GYp)H_nX^^ƚ~|sfvFybrj"dچ=~LOo%s 7h%Iw]o~SrLB~D~L8 OPQP`GU%L1jq{jd x742Ut+X;vLmKm̱Nk^y*[Ulݻĩ{xۤV9PXD"h4'ȊqX QoblRnE4Tz,g|~qG g׿b8%-k8b_?jpܣjsT<4jk^D/{@ ~80Wcߦкxj>ƾȭQlw뢾`@;\Hf @VpWȬ,` NK(`_ ':*#g!c%ŪYF+{:(jbBؗ6#vHQ𜒼־cU@@9pgl3&/o8~☕0ԃZVklO Dlk8@㴃`Kw]HqjqwN8n "fe)[Jp T&ýZs *Շsf@X>/Vז8ଯR,{OSL_5[q'$8w[}E\ \r ^NU;J<&W)1;>0Z8`҈Q܎ZX6PL>;;!"NkqM &TlXЖ:RSbNr*E0`>wٽg@uJ$BZ$#qZ+rܦ* ?]Y,2{GLӄB| blx3Kixn`PnZ(5Sc@=Yκ}sī"W@'9ȁ+(xQ MsrgP!Zp v.UrDw-ƯT7>b,ܴWr"'N @ Sxw5d&p 0iPqA),紅0HA Fxǭ@H#ږN@}c@"7 ,dfTi&pg gbe/\,`jzwѓ|RXȥ "@֘p&R^+H\p@W2ܺ1 D XlYLNE߷lRoAW5w*8\/dTyu@G/\]}+ԭMڹ6fo;٤a7+$1C5~9/\%0 }Y[\kf8  8 @G0Ϥf --I7j̠Ξ=T8AɁ|ɏKB{"n~qKB0SX!LZlrBKpS/Ȩu'z/;vG)ԀyLA3; KYrDJ$h4m .8͐2 E2rzgȁFՌuDIQYk[ J]t4;@-;T=YTxg{8t~{ Z痝r0/rlŨnnWզ n.w0ifs x-ЩHAq{eKܣR@; ⠚=/q.SMDV+t/)igΞSv@V yYc@i͎jƕ?$( ~ж^Xjة/#Ir'&~: *F?yKH3VȷۮX mL TB #aQPnmWح$6)G%M,q `ͧ * D9@M q'4Td+B&@ %]HH8唶6`wO|砧P$Tz٬q;yŰܨ8Z~[2^M]8p.A33S=S0*b "3L(s{B@THAU{V$he/,@IrS/A = 온7gkLJ'TKBlK(EO{Z78ۛfU<6\-m;($¾g^ Bl'>z5i /@JNcs{(=ڨ%PQ&l #ct46ʘTt56p#4$5Fr BqJ?l+lpzW LلE<].:z6].-Il99 &z48{d\9GB}:$1RWt*JZaqoKzn0($eBo+@?(e!Yy=ت~=ШMz]Q QHC\j?Dn˴VMFZ k`iOĴ ]\UIK "/`mOD9w[)$PB9jH y=Z RWbh3kF9}F$Z r/{g:|?&AL8dҸ P5U#5)gi1) 0b0(L-aM :W[PFDV wg#rj*_P p]33pzie5+p :e H:hQЀ>W{g̭;s0\y K՝Ɂ/k/׬JFJD!#f+am( ؖKeTlJ pߦ˂4AS /HB!{gnNM*4`StaH =KRm5/`vl4J}4ΐ#J[c֭@6 |߉ 7CZm4v~7<;OfrI|JP33ЭhyQH^zem% LI2p!XQ3\0DG5V9͓FL1!(ly+љz.)P;xHmH4E$\^h[:MڼrdFS ۵yBkD~,jzZv h, "i8ӏ B2 Z^;$\YvLȥW@}RiYEZM~Z`=%LNibeޤM9)7#m86@a' zǣ=L 6/BL71i&PhHɓl~d`E #zN܆I2 ϛի^d!gr K8;+B6 hzeyDTAx ]'͆"WtGfVZP0 udG T$Ao?r^V#(v=)ozjeA#Pr-'#ԺZgl9iY@-;q jM! ֗2;yv |=ܭ9G$VBArCnb׮nfk8C[Ez(}p=%ԚDPhwp}Tig-@va~Z ߩn\`-އ楩4JUT¡UL9B@Hl W~1|]=Ջa!%zolSr'`r@-l&Lh{ B#g4RLW "<̑-MZ 0IMe8@=W5a 5hJ! ~q@ u/p:0J{!aLjѨ~7L> b7IS%PRQ$ bxzx9LnR]= TP[w&F93x[]_I`j4Ԁv~Q cjvhKmOe)!!L;d,,%mȔ6]IzRN?'#a^{EEm~f~\?Xf-@z}ғhs8GN(l @iyf %64MCks`qt&ci9CbQK&r ٣.e- }a"BB`ka*3T^("ARS8 <,Wu* Փ?&X6hmql $iϻ9E5lj @,Lљ. lu6"$guJW*SWrMmEW""hhrue u΄ P^$LL'0PT"OO2Xdح@4+r.oyEPax[duiKi;fǓ-?QBB ^ a`i."mSyS[JI~8|08lfSH&=>sFnv"˦ %dftSĢJ(@2 "t0 Z=,$]FM[x0GFǭJ93]`  c gt-c_Eag m~'V~UBB 9&gW}6\-=~ ۷Oו,dX|ޓ- R7t%8NޠOoq֎@9N; 7iS Q"Y]]/rVA 0 5i#9RFWsȶP<@Ie,3<%(IP'i! )Z蹕2IAMC@m \i@RҢD ҃e7þ@}L\e)c]!S)ȐDMXP@b5a0kɩ e"b9aH@FQ`T ~׵װQo.I\oB1q{4@7֣?[jfz6˨fnFRjP"E0d<.̖`oJ+eNV"[#I126":)`ȁW%j5,YLIsĀ!l/#(0:.m2z,nZz*OZ)K6z0YJMv^Mջ`aoZ>`@cD 61t Z!,"^d OXdUluQR6[VMNxnن\9rR;ĵN-VcG&Dee_8C<˺q/|s:V{Bp K )&" 'm/c7H(ER=`Ŵ),u/;vLv|gnפ(+S܏@eL[,Uh'؆%WZn@+Zq$ĉN-\ycQF]r dp"NiAq 8`i*ĴIm8Ev~hO%Au OdM@K k3owyc*jMGe";ʄKMWPz un2\Nv_N 0n+6Xnc%?m~{BP9 <_4~lh[2Þ$~wYV@ũֲb 1١{ _DRK!lWDe߰!o#c4Y^(R"׷2fgpKeCx.Ni1cr|2Ϧ/dnٻNj?ZAͰh%0_Ykmr"ɿͪD zI`Qkc #2fur]D_/'?rK0,vωgz[Y7*IUmK#^5T2/jP"0RQ)V=v6051 ڪgu@;ո rbl"B =N@ *`u`Wy;! 0tl C],JZ@ @=BD[Ua#`x@':9iJen]u?/?,ѽl BUMή׀ <-YԀ''&V F]ohRFO<[5Y6A8klh{5RsRǀ}5\ouV:$q&hRvܰ ouM tZ Rۼ\_<U! \bꈊ$ ,g#Rluzȴ%?m!E98B @AeY3'+SYJ-Yyjoǰ_5l{lrՕz`@ȱi#&-r\9ohHЎvhVpG7&/Ø~u4-^Ru%Ub?:?.=m+WakF[:꣔.Lxٳ7|OW.e-l[dE;%@>.j:Y6RkX B5c]SC)ϋGCc2pCa_59ۛ8xtm=Xb)`=~80[mg7yogo^A7". JӀSK@~,ϼb|c:K-xuP; SlEkY C [āZZw59P/<Ĩ%סMY:>(4)}q#BxVM\+Ưwq.2 `"oxiC =P֥yٝbqG:UyA6-@M]J,u"_1fg%9ཆ4}Qot>|XagJA%IDATK\7Jy'*Ơ!~C~J> {yi) u> rr;[o~' =Xٽ ;8g` 2:P$Xk;\s D05 Ϡ]x#h@W7=Xg_//KՌU7o{ĥRӍw=87t(cǎ{O^*/$hn \x,A)K5.͈{7[ksЕºK'A)wnG(5a[3QULҭR5 J޽ć>x7 c ϕ^:s =I7|˚vY<ȵ;hrJ"5xSZs &XY/>ӛ `WWK\qLE4LdƷ a;Z6{TxI[. ťKB16W,cB=%{c=)02>Y9b/ fIbg9s&{ɓc` 2\,@dZekpx@5(tu+kfP RhloUfqG" 5i"?-,?⯾.% ? 5ܴsfe( +Exr14%/j3'~mE:(馛ũ{NZM KlϚ90Qpu{x*b'JUssO5r̕6klkYnPuͷC@6&vcG ^3u @BK߬/X~ʋBl l6nC|ȻLfEMSqFgoTHω z¾8s'=&q;>B!s%@#$/4<^_W7ngyD}-:{^|{H0 T>G5YяxP8;3t%밍x* 0z$[ ~L2.Ye2 ,TmϠ=U3edx5]%StmE4qR Sj'āij>+^zؔ3+͈z1 %y eG?y ,/nփ}ꔸ[DfB e/A$D`Yrg*&+6Ϋ-&]gPo? %`_CD3K"hy|f&z]b~!9*u"#…KE, 8:: 0TfqIM7^]Ԭ7(>)Q{QA?ӽeRe,{"f:\@ T.N BkHtv4:0͞4Jw o0֮?rV˻wnpSハ;Kov=FS0H+)kd8(~aP/QKp}{.ѽ/BQ+0 9ܵT>BF9pƐ[ m]GÛ`l_-rEwvطgKǝYp -}?k9~8q ZVns>Omji&.FFJw\zeK[=tLj!,?N>J@ܢ?;\=}lBo\{q80}[|YGڽ{x̃x9co޸(Z\jy]P߽G˜ڜm hIuLí@o,&&pшŜw5b[bnѫ+T%A@aW]˓pJzdϦ@~w2R \xZd. /⚫]hrˍ8d}.A$dz fX3\OQdEy[ lW \^l܋f=}l4tr@|x~Z؉n<)\.W Rq[+XY8ÇTT'v.  3@v/,Lq2ie ,W#P :r3_o5:Ǐ3=7xHOkPĉl5⾨v} n //AH9$ B]]SZM*"X!RFF/f8|*~]Rg{ibbL3or A ;ۈKnP+p:˞A4khCSr7)c9F >Hl:H4M@| hЍ@t Ds Ffs~Q\4;i,I&1~=rrx@ mC&Xsߦ K!S,䅙uL-,5~ёǰeH"t*#+Չߪr8t`C.C<{Vm\ZVoӸ4+CpZ9)M 6$uTj|n2|OuԲb?| Ṅ+_xe ϴESqɶ^>GIⓍBv}믿!>6 ;E ,s)S8ơ%f:N燳Z ! 9pO_1uY6ӫW \\q{]ǺNn:;o n~/0 X^u>Ňib ,'Ta;$`x8}ܸ2 }{uܿf#Qٮ<*Qw.78\~m7ow'&U$Nϧ k'*u /%q~Vd 6%յ>+ʋ@̒*p]jZjPm+;v>-ΝGtd٩N|ຽٿv;t:v(Q]hߍrz'![ԪFM~L`S0ɐymm' 8:9d U&n/鏽}r௾3O؏<*9ݺ[$.\qMx~Sțy LMAI兡jkm)ܜA\"ct\Xl:N´dy.lK@lT Qgx׬9o׉89o?8SwZW(غ;w[F@ k5_N@ڮBǷ;NR~oN'U,h~Wv;PGj DXoI"^x}H2,ᱎI(}mbE%O=c6m+P= vDb G#\n(]n*"j|eM: :!=p'q&ߑC"s>uxV3NrﵼK\ Ś˂ Ju` .~ߝXū@ê_Fm׏I?^C?ђ|xlɚ8:z{{P\ f'aOT2 ۥ1[鉉crw@X@qM"Pq( a V|kQ"(3[Žg\mQj8+یC7i=6H^K1xǟ$P@UĄ7œYT 2aƸBoO_%9 OסB8&l' ;TXG9t@e=j~CUBbsYD uf6haý~CR}f<#xӠl7SC]t_'3S3Znj@{^?Da,P!OLyl=>GЯo-Esp<]we{R=x[ k+)@9|,7\7+dZ*\vO.\8}x LLL1/JSs?W%xHI3OL] fb/e0hWG?0V66/ JԒ& ~uHZabK b"AьNib.ù xc_@qo( xPB!$?_w7[m^|3>rbO0 ,AⳟlAFX^~V M+Wo8q4amD`K_{׻%R){R4B& R_ 5Q~p8~Zڧ>%^3V0_xAЦ4ql4ˁ2#kҫ)|@\.Cz-,84Ӕ{SB@%`s?sLf5nldRt[-x+`ysIqGk>)yLOv8xs@66F778pl4N=pm7QwBovt\JoqJپv6:<+Z1R8(sXxDW_BDdSV`2a֧Nv,.g왡`D*w:5@8mbaa ps_XHeV]S9wgFrnF@_ܞf$&@/1ٷf_ƕcF_U;VMdu$"vx;:R@Iel@Z&5XQ]wEf5s-;C+P^e QXA i)FFF{~ō7ިf t\RTo0Q1ayV s~foZͯV˹j5emsc؟[wKKafe>uPz5[LDzsl>IrM5 _MC+OP*Z.'O\ivV P}vFԦ<ڮC XKe׭v7>P_K%@N=:7:f3'J+yl& LW,|IDj;&q壏S NCCy jR*gWV*> y: @+iԀ" ;gάDz8n6*\͔3tIev!T6;j'0L @ WZpn8S̎R\S^i hqjٹvZ*B&=)dVS48t%TNš?n4Ove쫚~fĝ``;bR^5p7]Mhr2%g(SXˌ˥RR8/JS{Ϝ dX>Δ|.a%S2_(įɲ' 4d*.RM;TtKeR+CCc΋/΁xe@4'3g?6tzSMU\b!e&|* 14ފ!ae}#^Bqɞl%$mͮ4-fU0Nut*]ܮ#Rp);вtģH5egSL]Q)B(S,S:en!5%I*ۻ/⯮ /D"ұǰ=ќ7 H0Vt믿p jFm [莛wR\ũ^[;,߯exRA!VjJ@%r%JdS0DQ /^7]kk~?p]m$BI0) yB,8G(8]afw}qq6e;!{T`la@p`+תe2)#+_NgԈ=1Goٯ%@ <-aܥ:$AjiVL;79 a&fq:bXYYX~TX•Z -,\?[\GWHd *'OftD@H&Ş%9 ~d+%1 ;ua{@R͵EMCbO0@  q9B(#r:%YO!TH-D.W<3<[]0o!c3gzSKL~x~ T$ l}DZ͔l,ɮ 7``1йN֭ &&DB;@/0讷hw9J(4tMvW2`1`r,_ɶ*1vm#` @bk nq&)V폁u~;.͔0@g05 -*^5@z`!l\ OL@+R/귆R$r/{`"žXW+1\_ 1菍Wޒm\3h ڕCŽbZ^Y(>0Vnǭf~;@b_kYj'[;F*n5B 8=( L 6`O00,Z;h#gTs䫪<h.Mĝ``atsZ!t\^X~?AEq5k9D9Ȋs`KsUfuj|9X_}(+eŞu,lfb|Bs0!6%~Njr۷ E_B0g-*ry>MPЁ :12rZF9@оi:w_ b<+K \SkmR}=rW ǘ`0im2g8SByWq@{opƎ md>K>t99m>?_IIL^`-i Z Sb6MV2#1iWg}ƲVj} 7 oyY.et:NL,J$8.+  gA._WgKK]DvN0I ՟ťEq<.E_c6oySl7_d0Iv+=W*$Xp\nJl^jgUV3:RkosD)dl|k%ЄR>YZ)^7@`7 M01w̹nQ*bj!Qk[+^}eNTFf%_͕*Je!wC.ũfacVKy#g.o d_9l'v(^^'JWvj3dMW gF%[]/ʅPUYDOS(q[nxA*R&3Me"]ʸ<.5perBe13ScGwLr*n*$Rƿ9}8+ٌ+*[H-Rt:6\80<\|zy"M'MjDnRԩbnϧkP&T2 V!_ y-ͼgdx .81 v*0f1闟ru-,%9hNs lpJx~`d6JzX:-$*^}U~*ta. uĉ4PϤRPg\Í@:Te7@.eP>{ߑ xN" lk @HFk\[ژ[aJ? s7#g'.uBOd6Sk=\k_+bR#Fh j5;Y.|.3:Q] '^ r)' ̷Cnazz@VJĚ``1PKJUj}q&?Bsh?dfN?*JZqq,ov'^W) "%du$\)ݵ܆s" W@6NWS`NyS~om*PhXi?A Y X`p:5EOݞ^^-Z#,wq24s+ݑ:wv q:}#`?>|& |zkqs9:)D&GS2,Nl6'JV\.cER oLl=~o巫k78{ _\wɌVhb܇_k*'Ox7x㈡ȂPtpX="1o P ?CO{xCQN"˰/gӏ>pa!o VܜGGD Nry5Ifffŋl{@dn>r?@_ݟwݿ:E-pe!~%|ۢEZ2stM:@6w d=@V/`_I*؝;K{Mˏ_g5+=Ovr`VQJTw99.k25c3S&N.{_-!/>iV*(0u׉nGݻEfd3fVqW88gSơ>%@LU+I&Lݬp4ɡno!AUWN(%6xX>sF,ˢ$O. !8A(xĪw1H[1Ʃo+ nt!z=oWʥseE_/.5 "V rÇEmoŵ51s/}IV]0"\8@V١]ĉ}HL:$F2\,F6:jvJg#J,ke#@ۆnoZF?YЎwu cr~8@pC*:s䈸#Sco;F= _edd'_&mԾ Ub;/o|3ߚ?29P,'ѵ"؊34(56[)H+B(ڤn&1D[ĆlDjs&SO1]3{6҇CǙ7qz=&&ou|.-589rB{Y}zp%Ǟ59ѹTrEۓi ,NShdj(BS+09_' t v0iB vj:K/( _.R|(V&hmù8 S4p+Ppz!R =uJ̃Acm`zD%\n1n-ɟgaM(f m`zQ6ޖ~& d9RS5C#cca5^?Ev  eW^脵7ͯYl{,GVbgDj; D"-\z_"I#tNiB@o;e`5˘5ֱԄRjՎE.uFC]z3(~6܈&ǐtf@hM1ӯ5v#q6?e7J8(#R$V1ۻMA܀s`cm~[O!Qe Y;]Mqf ZRXѡ]JkJW"d,Ko@l7WXIlp_mȒzio/` <P0@r;lr_;,\(@d"0M,:ͷ!։VafmmnڮK9Y@HvxH1e O8,#)o59,Yk¿" TM #GNζJj\ܒ[ƠQK̿HfK\DAnG g(ڸ͍(g`:ռ8(VpgrL' 8XNqnkLئO'ە{p;1!&lBP\"<f`wm(={?>{Lr{  . Vmek`u!A@n2egM 'Īq}\_8.:5 .ϋ]8%ni:KؒkV_;$\.dHcL$|fV1_6~{6~PC'&"WqZ *=xTJD\?%Ym+C~&clF.?+۔Ė1,r_^F wx tԺ . DM R5dM%\f en*>D>@/<UOO5^E-D|K3d6\gHK-8\ģ_`G' 2X$θG )& T}"1IMluܿ߁[8rC)3 AkhXQ^R`_G,<*aP9ܨ`8NټN8i }r_=w+_pVۯ{[:?pHM4?#rjm~mqj2 "Fq>?β?[=܌%=L~~+W>!/LOѻvY4c\ v`>fL7Y֫Zmu:ȵ\P/<:oǃg9a9oge>{TCeogē}(Ϲ׶CE\34?%#2~k6vބmەOOPǒ`_雒 ۆxۂ __w >P{=<_:=شÃk&5}5:`}OmiZ =8; 0{w )G~hlc׎w n; B %|t4]!^Eff7`FͿ ~u @7 zn?Bp0趚"lʡ_޳ښ#}!10QNuSu&ӟ' T2#okK$t|;`#-X)[9 ;E/jtk1~o3c ٦ͼ)I` !=A{3ܩYpI H@,">iįMMX6a7`%W=X.זbskN1㼽9$ _2$` tGH@߶lm,ͯ]MwIt  ;sPX4s y% tŜILЮ="]8 VB"=IpMH{ 5*96h6,$^17 `cm~[O7lR28aa7l{.!.͗*2 k"-&%Q viTxkX66`>@B16SȪӻ`3ԷiзML0Mv, ip6& Fq:QIx0Na[p1Ig&e#/Vխ' h{  Dm*$$M TA1 xf na !tǔ~ʼSpUNH@c4צ2&DM mʔcd;ŖnD`ߦ%`mE>-Ӆ4.+|GQL?5|k W*'xti))@Tj QC!)&3 }OmC-p'.CV+\Rߒ(bc VWDac^ik 3=_OKܫ'O9!(Έ]3brrR\Bl#ܪ6dN M'%2͵EiK[S%R `}]\(VWV8t >SO=OVZ^b?%R =q Nm[:5h(P!%XF́dK[ԐLd#ɈV!6./̓C(۲}߼?Fn 7?_?eLW)q1==Gvm_d|? P۵\2t=RccrsAW ?%A+G88x@LOb]glgsVWsX!5@tj᳙uNl.+/G;MKOOMGMK]v7MW9a N6)RKWVEXĚ1nXͯL:-Y$3Nlmbb}LnN*9"h({KKb޽⭷t+ O{T|gfnOf d"{5%ɠ M)8>|L dvӯ]u5rA #* |^ cWfRؕ!X\ZĤ"[z\#~V%ٛ9K_2kkbiii{~Sw T滽v橖gŬAUE"0"e̴-J*ҿ` $銸D)x2ڪXX7wDA ؙV푁jX6`dTP[1QgY@_0R]jjrJ8eB3ܘ#32:j;$ @fbjzʆB[@? ja F5ON{* ׏f[}ܺbs-tPN >tjW% 8b|l\,\^劮-EW_F#9lpЫf2W\zJxl31%܄4B0%ag֯ƹ7bƋ-:i(Q)H5),QB%5߂v`Z눚9DTS=4 W@$-q:vv׶ԧ @2Ϯ]pĜaڕe1=1!C< B\1~&@|wA.+&ŌM&{UMZnsV:vZK<@zg??~ I ~TUm3lگrD -YC}y.8ͦJ$ ,+<{,dA=H|C9PH7PI^>Cҕ*",ެtRݣ\z'.I6B+y5O,Q+xu7p\oŔ%'rEQ18#LC2t8*IWdypTz_rG\GYp)H_nX^^ƚ~|sfvFybrj"dچ=~LOo%s 7h%Iw]o~SrLB~D~L8 OPQP`GU%L1jq{jd x742Ut+X;vLmKm̱Nk^y*[Ulݻĩ{xۤV9PXD"h4'ȊqX QoblRnE4Tz,g|~qG g׿b8%-k8b_?jpܣjsT<4jk^D/{@ ~80Wcߦкxj>ƾȭQlw뢾`@;\Hf @VpWȬ,` NK(`_ ':*#g!c%ŪYF+{:(jbBؗ6#vHQ𜒼־cU@@9pgl3&/o8~☕0ԃZVklO Dlk8@㴃`Kw]HqjqwN8n "fe)[Jp T&ýZs *Շsf@X>/Vז8ଯR,{OSL_5[q'$8w[}E\ \r ^NU;J<&W)1;>0Z8`҈Q܎ZX6PL>;;!"NkqM &TlXЖ:RSbNr*E0`>wٽg@uJ$BZ$#qZ+rܦ* ?]Y,2{GLӄB| blx3Kixn`PnZ(5Sc@=Yκ}sī"W@'9ȁ+(xQ MsrgP!Zp v.UrDw-ƯT7>b,ܴWr"'N @ Sxw5d&p 0iPqA),紅0HA Fxǭ@H#ږN@}c@"7 ,dfTi&pg gbe/\,`jzwѓ|RXȥ "@֘p&R^+H\p@W2ܺ1 D XlYLNE߷lRoAW5w*8\/dTyu@G/\]}+ԭMڹ6fo;٤a7+$1C5~9/\%0 }Y[\kf8  8 @G0Ϥf --I7j̠Ξ=T8AɁ|ɏKB{"n~qKB0SX!LZlrBKpS/Ȩu'z/;vG)ԀyLA3; KYrDJ$h4m .8͐2 E2rzgȁFՌuDIQYk[ J]t4;@-;T=YTxg{8t~{ Z痝r0/rlŨnnWզ n.w0ifs x-ЩHAq{eKܣR@; ⠚=/q.SMDV+t/)igΞSv@V yYc@i͎jƕ?$( ~ж^Xjة/#Ir'&~: *F?yKH3VȷۮX mL TB #aQPnmWح$6)G%M,q `ͧ * D9@M q'4Td+B&@ %]HH8唶6`wO|砧P$Tz٬q;yŰܨ8Z~[2^M]8p.A33S=S0*b "3L(s{B@THAU{V$he/,@IrS/A = 온7gkLJ'TKBlK(EO{Z78ۛfU<6\-m;($¾g^ Bl'>z5i /@JNcs{(=ڨ%PQ&l #ct46ʘTt56p#4$5Fr BqJ?l+lpzW LلE<].:z6].-Il99 &z48{d\9GB}:$1RWt*JZaqoKzn0($eBo+@?(e!Yy=ت~=ШMz]Q QHC\j?Dn˴VMFZ k`iOĴ ]\UIK "/`mOD9w[)$PB9jH y=Z RWbh3kF9}F$Z r/{g:|?&AL8dҸ P5U#5)gi1) 0b0(L-aM :W[PFDV wg#rj*_P p]33pzie5+p :e H:hQЀ>W{g̭;s0\y K՝Ɂ/k/׬JFJD!#f+am( ؖKeTlJ pߦ˂4AS /HB!{gnNM*4`StaH =KRm5/`vl4J}4ΐ#J[c֭@6 |߉ 7CZm4v~7<;OfrI|JP33ЭhyQH^zem% LI2p!XQ3\0DG5V9͓FL1!(ly+љz.)P;xHmH4E$\^h[:MڼrdFS ۵yBkD~,jzZv h, "i8ӏ B2 Z^;$\YvLȥW@}RiYEZM~Z`=%LNibeޤM9)7#m86@a' zǣ=L 6/BL71i&PhHɓl~d`E #zN܆I2 ϛի^d!gr K8;+B6 hzeyDTAx ]'͆"WtGfVZP0 udG T$Ao?r^V#(v=)ozjeA#Pr-'#ԺZgl9iY@-;q jM! ֗2;yv |=ܭ9G$VBArCnb׮nfk8C[Ez(}p=%ԚDPhwp}Tig-@va~Z ߩn\`-އ楩4JUT¡UL9B@Hl W~1|]=Ջa!%zolSr'`r@-l&Lh{ B#g4RLW "<̑-MZ 0IMe8@=W5a 5hJ! ~q@ u/p:0J{!aLjѨ~7L> b7IS%PRQ$ bxzx9LnR]= TP[w&F93x[]_I`j4Ԁv~Q cjvhKmOe)!!L;d,,%mȔ6]IzRN?'#a^{EEm~f~\?Xf-@z}ғhs8GN(l @iyf %64MCks`qt&ci9CbQK&r ٣.e- }a"BB`ka*3T^("ARS8 <,Wu* Փ?&X6hmql $iϻ9E5lj @,Lљ. lu6"$guJW*SWrMmEW""hhrue u΄ P^$LL'0PT"OO2Xdح@4+r.oyEPax[duiKi;fǓ-?QBB ^ a`i."mSyS[JI~8|08lfSH&=>sFnv"˦ %dftSĢJ(@2 "t0 Z=,$]FM[x0GFǭJ93]`  c gt-c_Eag m~'V~UBB 9&gW}6\-=~ ۷Oו,dX|ޓ- R7t%8NޠOoq֎@9N; 7iS Q"Y]]/rVA 0 5i#9RFWsȶP<@Ie,3<%(IP'i! )Z蹕2IAMC@m \i@RҢD ҃e7þ@}L\e)c]!S)ȐDMXP@b5a0kɩ e"b9aH@FQ`T ~׵װQo.I\oB1q{4@7֣?[jfz6˨fnFRjP"E0d<.̖`oJ+eNV"[#I126":)`ȁW%j5,YLIsĀ!l/#(0:.m2z,nZz*OZ)K6z0YJMv^Mջ`aoZ>`@cD 61t Z!,"^d OXdUluQR6[VMNxnن\9rR;ĵN-VcG&Dee_8C<˺q/|s:V{Bp K )&" 'm/c7H(ER=`Ŵ),u/;vLv|gnפ(+S܏@eL[,Uh'؆%WZn@+Zq$ĉN-\ycQF]r dp"NiAq 8`i*ĴIm8Ev~hO%Au OdM@K k3owyc*jMGe";ʄKMWPz un2\Nv_N 0n+6Xnc%?m~{BP9 <_4~lh[2Þ$~wYV@ũֲb 1١{ _DRK!lWDe߰!o#c4Y^(R"׷2fgpKeCx.Ni1cr|2Ϧ/dnٻNj?ZAͰh%0_Ykmr"ɿͪD zI`Qkc #2fur]D_/'?rK0,vωgz[Y7*IUmK#^5T2/jP"0RQ)V=v6051 ڪgu@;ո rbl"B =N@ *`u`Wy;! 0tl C],JZ@ @=BD[Ua#`x@':9iJen]u?/?,ѽl BUMή׀ <-YԀ''&V F]ohRFO<[5Y6A8klh{5RsRǀ}5\ouV:$q&hRvܰ ouM tZ Rۼ\_<U! \bꈊ$ ,g#Rluzȴ%?m!E98B @AeY3'+SYJ-Yyjoǰ_5l{lrՕz`@ȱi#&-r\9ohHЎvhVpG7&/Ø~u4-^Ru%Ub?:?.=m+WakF[:꣔.Lxٳ7|OW.e-l[dE;%@>.j:Y6RkX B5c]SC)ϋGCc2pCa_59ۛ8xtm=Xb)`=~80[mg7yogo^A7". JӀSK@~,ϼb|c:K-xuP; SlEkY C [āZZw59P/<Ĩ%סMY:>(4)}q#BxVM\+Ưwq.2 `"oxiC =P֥yٝbqG:UyA6-@M]J,u"_1fg%9ཆ4}Qot>|XagJA%IDATK\7Jy'*Ơ!~C~J> {yi) u> rr;[o~' =Xٽ ;8g` 2:P$Xk;\s D05 Ϡ]x#h@W7=Xg_//KՌU7o{ĥRӍw=87t(cǎ{O^*/$hn \x,A)K5.͈{7[ksЕºK'A)wnG(5a[3QULҭR5 J޽ć>x7 c ϕ^:s =I7|˚vY<ȵ;hrJ"5xSZs &XY/>ӛ `WWK\qLE4LdƷ a;Z6{TxI[. ťKB16W,cB=%{c=)02>Y9b/ fIbg9s&{ɓc` 2\,@dZekpx@5(tu+kfP RhloUfqG" 5i"?-,?⯾.% ? 5ܴsfe( +Exr14%/j3'~mE:(馛ũ{NZM KlϚ90Qpu{x*b'JUssO5r̕6klkYnPuͷC@6&vcG ^3u @BK߬/X~ʋBl l6nC|ȻLfEMSqFgoTHω z¾8s'=&q;>B!s%@#$/4<^_W7ngyD}-:{^|{H0 T>G5YяxP8;3t%밍x* 0z$[ ~L2.Ye2 ,TmϠ=U3edx5]%StmE4qR Sj'āij>+^zؔ3+͈z1 %y eG?y ,/nփ}ꔸ[DfB e/A$D`Yrg*&+6Ϋ-&]gPo? %`_CD3K"hy|f&z]b~!9*u"#…KE, 8:: 0TfqIM7^]Ԭ7(>)Q{QA?ӽeRe,{"f:\@ T.N BkHtv4:0͞4Jw o0֮?rV˻wnpSハ;Kov=FS0H+)kd8(~aP/QKp}{.ѽ/BQ+0 9ܵT>BF9pƐ[ m]GÛ`l_-rEwvطgKǝYp -}?k9~8q ZVns>Omji&.FFJw\zeK[=tLj!,?N>J@ܢ?;\=}lBo\{q80}[|YGڽ{x̃x9co޸(Z\jy]P߽G˜ڜm hIuLí@o,&&pшŜw5b[bnѫ+T%A@aW]˓pJzdϦ@~w2R \xZd. /⚫]hrˍ8d}.A$dz fX3\OQdEy[ lW \^l܋f=}l4tr@|x~Z؉n<)\.W Rq[+XY8ÇTT'v.  3@v/,Lq2ie ,W#P :r3_o5:Ǐ3=7xHOkPĉl5⾨v} n //AH9$ B]]SZM*"X!RFF/f8|*~]Rg{ibbL3or A ;ۈKnP+p:˞A4khCSr7)c9F >Hl:H4M@| hЍ@t Ds Ffs~Q\4;i,I&1~=rrx@ mC&Xsߦ K!S,䅙uL-,5~ёǰeH"t*#+Չߪr8t`C.C<{Vm\ZVoӸ4+CpZ9)M 6$uTj|n2|OuԲb?| Ṅ+_xe ϴESqɶ^>GIⓍBv}믿!>6 ;E ,s)S8ơ%f:N燳Z ! 9pO_1uY6ӫW \\q{]ǺNn:;o n~/0 X^u>Ňib ,'Ta;$`x8}ܸ2 }{uܿf#Qٮ<*Qw.78\~m7ow'&U$Nϧ k'*u /%q~Vd 6%յ>+ʋ@̒*p]jZjPm+;v>-ΝGtd٩N|ຽٿv;t:v(Q]hߍrz'![ԪFM~L`S0ɐymm' 8:9d U&n/鏽}r௾3O؏<*9ݺ[$.\qMx~Sțy LMAI兡jkm)ܜA\"ct\Xl:N´dy.lK@lT Qgx׬9o׉89o?8SwZW(غ;w[F@ k5_N@ڮBǷ;NR~oN'U,h~Wv;PGj DXoI"^x}H2,ᱎI(}mbE%O=c6m+P= vDb G#\n(]n*"j|eM: :!=p'q&ߑC"s>uxV3NrﵼK\ Ś˂ Ju` .~ߝXū@ê_Fm׏I?^C?ђ|xlɚ8:z{{P\ f'aOT2 ۥ1[鉉crw@X@qM"Pq( a V|kQ"(3[Žg\mQj8+یC7i=6H^K1xǟ$P@UĄ7œYT 2aƸBoO_%9 OסB8&l' ;TXG9t@e=j~CUBbsYD uf6haý~CR}f<#xӠl7SC]t_'3S3Znj@{^?Da,P!OLyl=>GЯo-Esp<]we{R=x[ k+)@9|,7\7+dZ*\vO.\8}x LLL1/JSs?W%xHI3OL] fb/e0hWG?0V66/ JԒ& ~uHZabK b"AьNib.ù xc_@qo( xPB!$?_w7[m^|3>rbO0 ,AⳟlAFX^~V M+Wo8q4amD`K_{׻%R){R4B& R_ 5Q~p8~Zڧ>%^3V0_xAЦ4ql4ˁ2#kҫ)|@\.Cz-,84Ӕ{SB@%`s?sLf5nldRt[-x+`ysIqGk>)yLOv8xs@66F778pl4N=pm7QwBovt\JoqJپv6:<+Z1R8(sXxDW_BDdSV`2a֧Nv,.g왡`D*w:5@8mbaa ps_XHeV]S9wgFrnF@_ܞf$&@/1ٷf_ƕcF_U;VMdu$"vx;:R@Iel@Z&5XQ]wEf5s-;C+P^e QXA i)FFF{~ō7ިf t\RTo0Q1ayV s~foZͯV˹j5emsc؟[wKKafe>uPz5[LDzsl>IrM5 _MC+OP*Z.'O\ivV P}vFԦ<ڮC XKe׭v7>P_K%@N=:7:f3'J+yl& LW,|IDj;&q壏S NCCy jR*gWV*> y: @+iԀ" ;gάDz8n6*\͔3tIev!T6;j'0L @ WZpn8S̎R\S^i hqjٹvZ*B&=)dVS48t%TNš?n4Ove쫚~fĝ``;bR^5p7]Mhr2%g(SXˌ˥RR8/JS{Ϝ dX>Δ|.a%S2_(įɲ' 4d*.RM;TtKeR+CCc΋/΁xe@4'3g?6tzSMU\b!e&|* 14ފ!ae}#^Bqɞl%$mͮ4-fU0Nut*]ܮ#Rp);вtģH5egSL]Q)B(S,S:en!5%I*ۻ/⯮ /D"ұǰ=ќ7 H0Vt믿p jFm [莛wR\ũ^[;,߯exRA!VjJ@%r%JdS0DQ /^7]kk~?p]m$BI0) yB,8G(8]afw}qq6e;!{T`la@p`+תe2)#+_NgԈ=1Goٯ%@ <-aܥ:$AjiVL;79 a&fq:bXYYX~TX•Z -,\?[\GWHd *'OftD@H&Ş%9 ~d+%1 ;ua{@R͵EMCbO0@  q9B(#r:%YO!TH-D.W<3<[]0o!c3gzSKL~x~ T$ l}DZ͔l,ɮ 7``1йN֭ &&DB;@/0讷hw9J(4tMvW2`1`r,_ɶ*1vm#` @bk nq&)V폁u~;.͔0@g05 -*^5@z`!l\ OL@+R/귆R$r/{`"žXW+1\_ 1菍Wޒm\3h ڕCŽbZ^Y(>0Vnǭf~;@b_kYj'[;F*n5B 8=( L 6`O00,Z;h#gTs䫪<h.Mĝ``atsZ!t\^X~?AEq5k9D9Ȋs`KsUfuj|9X_}(+eŞu,lfb|Bs0!6%~Njr۷ E_B0g-*ry>MPЁ :12rZF9@оi:w_ b<+K \SkmR}=rW ǘ`0im2g8SByWq@{opƎ md>K>t99m>?_IIL^`-i Z Sb6MV2#1iWg}ƲVj} 7 oyY.et:NL,J$8.+  gA._WgKK]DvN0I ՟ťEq<.E_c6oySl7_d0Iv+=W*$Xp\nJl^jgUV3:RkosD)dl|k%ЄR>YZ)^7@`7 M01w̹nQ*bj!Qk[+^}eNTFf%_͕*Je!wC.ũfacVKy#g.o d_9l'v(^^'JWvj3dMW gF%[]/ʅPUYDOS(q[nxA*R&3Me"]ʸ<.5perBe13ScGwLr*n*$Rƿ9}8+ٌ+*[H-Rt:6\80<\|zy"M'MjDnRԩbnϧkP&T2 V!_ y-ͼgdx .81 v*0f1闟ru-,%9hNs lpJx~`d6JzX:-$*^}U~*ta. uĉ4PϤRPg\Í@:Te7@.eP>{ߑ xN" lk @HFk\[ژ[aJ? s7#g'.uBOd6Sk=\k_+bR#Fh j5;Y.|.3:Q] '^ r)' ̷Cnazz@VJĚ``1PKJUj}q&?Bsh?dfN?*JZqq,ov'^W) "%du$\)ݵ܆s" W@6NWS`NyS~om*PhXi?A Y X`p:5Exݕ−ɲw ^][kk[\`WOOlXRqM6{w6EVRMn0//0^MHk&$fHCiGEoC>fx>9jkt|9442z {sjbY`u}260.04 9lldXllM㊉˵ba_nn_`d\SSn\VrN<{<EWVQo5445_QMm&$hMHjHEpHCgyC?klu}?::8| }uld[bw8:646: 9lldXllMƮ}}b_^ll^'_bZSSlZUoN>{>ESUQm7W\QMj&$dMHgGDmHDdvD@gir{@<<9x xph`W^sz9<868j u| IaPԯim(*!v?Т͛;߳c}"v8gyE~'+VcWD0TA@A`Wu  D@A@CDF*  3  !aKA@QA@Qưѥʂ (  c(cReA@A@yA@1D@1lt  < " 6TYA@@A@A` ` ],  π  00.UA@Dg@A@CDF*  3  !aKA@QA@Qưѥʂ (  c(cReA@A@yA@1D@1lt  < " 6TYA@@A@A` ` ],  π  0Laʂ 裏V&&-ߧ@GH750B'*Pg0lNo^8 >B+% wm4 G%N~o~pVBQD!E]zrew0=ߠOx;(@x @Qxȝ 5~g~ae#RǻVȐ3)?C^_xd78 2f`?bJIN @J~A A{DF&'koO3L< '`B. s?Sw#pJTUD$f p Y`E5M&!RV9Dq_[7)06j_}!#nUq(RFVzyiIvmBz6TRdl{%E`{{KI)0M_Y+ϫyuU* )Aj_% S/^Jt ^^L['"͒bffF=)OVjzǗ䔚߰гvvv"`:sdhU@ }GZᅭSďzߥ)oC=WP}=> pot9vfD:Y2񧕂\LͨiSAF_P_YQUQ8Pu~ۦ?0Z @98v/r=LӛFE~K2|Р4OLLi j~~^mmmݴCl \_DGW7=M@_Q_[۴p̂WP.q{4&):*VEw?KCgqi۟lV&g~Ffh%lC qH U[ o'iaiz/eꛈR0`_ EzqxPҋs|q8$"*NJ~Xffff_Qf[vj.(dZTa_YRW, T )̊2 3<,?J2x FG9]ҳ3szM>+,a)kҰ秕?*sd7!(Dʆ=&҇A-~qj6b@rشxW;E[~ $-wHBB_/GҊ~U{bYi<aW+ãUUZ75=5lD#&Ǣ-uEc4ꀿQp_k7#sd納+x.}xڝgih@}3Mw GFSWs䄦F &hY$w<!D ~ @튞 <‘T=#*#r VbE!Vd٣6ڽvMݫWյSԕo|C8Zdky YB~CBG@qz?BGv@ݷ}Z.5G.diB v?44x#v{e 3'nw.^DWqԥ2c(~@AX vIIۣ&}k'O jZm]!0Mr?A귆aی+~|sٞE59ӯ-Ky;gi榤zw iJ=?S0`NnSh׹xΠqt a >]E{ZP  Σ@?߱.u+_Q7joVA9h#|'}w'2%zmdx.YC2â?g?[{˴y=^T0ŗ@)11'je6%, k&gvYBB V [ x_6qg>òg"AŧiJ%oC? Epcd4Kރw[_J5{ N]@w18+i{ Qç-#Lϊy(p(A`ji0p7~l9 hR7<GcQWXW/aEB셀(^pӗi`Xw+^:u𙆟429o?5o9=#EWGH7A(XAuⓟ?κ41RKJ8R*qAP%T_0DUw ٯqp( 3=cNTX|Vbs}?hq)4'Dsj&G +@:D^u~G_b;˿;kn%%D1KR[JsN.,oWK8g>Bz!Kf]] {uOr;!adm 2z՗~>-GHʗLXO>5Q2utaħ !VV >! ab)b| @ pNtڿ~K2yPq]BBP"IVx3pCԴ|1fB Qh/-Ȼk(^UA Τ*mG /%?A8ma%/Q_W h \8Aɟ,p_fH:c.]ܳ1g̓XNGk˩Br(:WqЂɧ*`}^YQ/oQF}@ӪWEA,(%eA:-8ɤ6'-n/C#Um%~=GDб9zBᲃrzTIOeOUw=Ч_dENa *Mpmя}:K~B3*0-IO|B$-UNݶenn Qx8R@Kjѣp?Pښ DhC],pwp/m꣦,n)~[ЉWn=1DKӯU5[&@^6@ww Fcb[,jO>7QU:B>WG;5xKe sBO $$5}`_NiQK׾˥V7W8<rtODlr  ~4ɁW^.*%hZ_c(Uy# 7de@1YO=[.O6>C6V_K˔CNCӞ(H5`:Q}bMݤdPp蜌zH]Т+|p|A' @[ >wcCyաqAá󔳫\yt$LtJe5(n0zS z`4$è@A` `)n) )uВQLz|Բ?}xvbrPX<Ϥa)EsrW խ# [r˥A*߆=,?REc / #HB@ɡ Z N@ȥ Kѷ KW-]>{ LBKbɕK!@+@[L52I( @_ǍAq>؋i—+/5e5Nc\F b3(p^>VI@@ z.UH:_$%W(+Ӻ"7N?Dݍ 2- ^w>}K.1t G+OpCtCMt Q, u7^s횚eIQYYQ]¢GG[ئܷNJ,<{\]cq\yt(D,F#11Wd|s}9U %" @`Ƭ*J=С]$XmkKZzֲwΝS x0xYI1ՄZ=|ٔBB@L~DQ}"]f`: SSjiИ'%B` QLđE  I\H _ejKE3IJ߈rS\e '2S;MG"yXK8A*PV4k9j^p?.ݠT+/X44b }xTW~ܣMR <]_f}g~o "=w 2uwyu=nzA+ $u0ъ/{zXt\:*JJѣⅨ<0gv$;KVO?y )MHءsd_SZI/tg;dU+輂W;Лb߅˗ծI\g~ܴ?Dh%$lыqm3db;@iy;AnqBLeF]늩$'3uxIBB@5ԊǸ_~ K>%Wf~^u<ݯ`D1sHkn%~'z\Gǀ|[wg4$r⤊Nvq"wNB0A2lM*6,ofŠ]0FZ+p$j3ǝ>"`AF]aD9Gq#w*uHNz_N+t(]mS{N{"RkԴE;bfF>!0DawoԿ jgir>o S SYdEj/hA- C(CހI8?DS M7O.Nnkx G8:h8t}h#s,,>*Ǘ -2-< `pڢ+Дb_fuJ#5(G),y&N]^Bpl2P6cڱ 2U2]_5^tt ٚGHY`! @bgu40\qej. CL MS @y<, smz$LN]<$]C@p\@9hy92shʑH@" 8 qhe# SA"RkJ]ݠ՞#3f%#(#ҐRC;s ayY^ @/P2+OaN2soOAD<* a'!(YKA@w wAθa/\Xu8lrH(A@O[ @i4( 3yLܺyB&䂀 PpAá.Q.!D@YD(A@A wAs[PRϓ2ss@A`<`80dvL1 A[@ # nmƂCQ7K$FQFC>ǥ$QAOdzLe)CA@00` ">sO@!ǕS#N[JMqUDf!A@r @x 7AL7 X0 P6b(Q'  bFaTҍ3 G@1\; `ѱK!,eAcH0" H# n^# nj˕y5H,@g~[S:˜ޑ$f23X[UA`h`hJM8*jΥ$82shN" 0:0:m)5B8h8tT}VAXd:E@N@'pG@.]'we.' 0 phg nVhzŰ~wS?U.^Qߥm7U,[ΰya׍WׯG}Bu@UxCyt?*u0|CW^t_y+k0xJE~,% BF?!H x J+aj~ &MWR^W-MNO<ᛕ va:4l;D4l8u McYn̷.^{_2@@ @Z_.PmX)f Re2[ !% Me_?~vye@OA<Gsd TK09DRQ~Y Ga6bh:wƗSsf cD[f+Ƕ!R֋2s?J-ux"Y>A>i3qb?ř6*B.,lA`OU꟣5dk1‹Ѕ{xZXXdدU%/QאΎKU x8* Q Q,DiFVVYWD(8ʻi>b|*o^m7xt@m T;n_ z^:šVUǻU^\S,-.Q6,}ݑGr3|Oyb'8!2<&S ̩X GQvfVϩ f2vX2a<ލ;#@fi=^ۄfZ9XF_m,XÏ%F6?9rToKs.J>B m@r[7]&_E=9)8a  ~f9c%"?|Lz!s  Sr;Li8ǜm~5Z/at~9S9/0烗KzO.#7 4սz1Y'pbR]ֻ!+OdY C74C{Kyۡ;73;Z~j J@~: JUf퍝$}+iom1tzYu=V<3YYd| j7B`K4E⹾~U{oQ:yBil`TGN=EBQ<[u?B^:߯G.7ww;N yW@D}ի^!lÈ{2fpFfx? Wiq&)A@t0EISn[ON:ϩG*X2)Q<Z|&%҉4ۖ0y@щ>^yxȮerW g/%;c =g?Oe M QRp8onwRd,y, "p-Ӝ>ui^f!H}bz!m Qx_>E7ȱov#c6P}/< K/_[()Z0 Xt/uF Qp-,L'R'^8J-/ho#kwب= Vmm @hSw x*`qkm+y~;jY^Q)rD 33ŒNɛߠ,4ή^S߯+I@p:?q'@h<xW]:O`CynlS{UԿ96 afzvBmh+ū^L}]e?x[pnQ ahNg5Ȣ͍MA q+Ln)әcC,\=VbBӳ3j^uݫaK3)̠GvU^zPZJ,\VW/y9Qu[og+nwoO]tӢ='t]!Kt.>M&-\e[\t1Sꂶi7dUze:8ۢ"@8P9rVw!I NЎeH @qbB{Y9cfT 0"@!K'O?^X QUZ" {V4b`>rg\_[io#xU|pX+$h/:긼2jy9u+0-;.^H.x>|o&y,34sBTh-YyGy&?sIrb `M1O<έmfZNR{ȯ0 oA`L OnMQP2X)==(`!VbK@~:HÖӧΈɿ % 0`?Iր1eb+qT+&S5,@6}鮙z_A@mG>}v \.Nڿ )pC=&fY2QMKnqM /Mn(*9/k`WA)c@߶1X&ɏ35dlTG 4@g_u a¿v7\+t9)gV*[J(M;^7e 9}d3xoN@~ 8}:Ehj0HiGd9_rvw9(TT@cJ.e!` QSo/L#,'hN@7VN~|vT6ԗe7&8Ξ9n&U: Œcگk+(T?0P?;<_wH}+l${"G`PN,f8=;vSwM7VX,\9<{{7TLp@7Ю-LH` z}VIӴ`Se\}ȍE/~Q=.8J68ܠX !0==񬧫|`ޛN mh*hoP0TUS( @Rc(FXZ!:BqULnbfcO s: \HMvy6i1WB9,//^tIW`; 7 ,VFg2j[p4Բq6nh96%;`sMwI!l>G=Ovf YûRcAW'@d@V=˚?too_\1ں,+4lxsU=i~Jk'&*g'@=d'㑐]`k `N m!$'=4Ϫ}=x ǹU(_rI~__@\8Q--vo+`xΫEC׾Ζ2}x#D53ՔU5Rx~zeYWWpI`jeT|Wʽ|-)o΋c ^Qg*{\xY.W uie€> }L>3jg 9+y$=x+HU'&Ix+#_fxf ^pzuG3EYw?)Ŏt8{V; @e7VP\v x}k,X,hMD ؁Ѝ}rƘVW(> gիϺǾ[e&>oʕx7pufPXwH#!E޷]Z$D}"}N2 }Mj'5XuVEB ,YWI?VNB G`FA(~b/Ǩ\>Ɍ*ݪ,Go:Ȭ1C}: Q?b2:w҈P '@HMOؙaZ.;-e|R ȑ#goKJ/8W+1"|Ga^C,8~P;ij+t5ӫu}((MDny)VWe]tbB}5Kia}EI\)ϣ$|h+m2Ҙc_YY*gfLaT`(8*͸ANt#% @F8` ?ȏEEH[oMwbUKڎONw `sݽGʕevb܉ó}Cw+1ţ֘Ҭv=hj*2{OUf6E;sC|pVԙ'4kt /TK<Ɨ .Z!YG,x[SQ, ӏZut M$~zmX\ZNJN;{"sA%;NZ@IDATkZwo˺ n Nz^Rlr'bD^Q,XV-dxœ677j!ie p]Zۉ:G1c|ЩlvvvK`pwUqMA=u!D \(: {<`atFޓ'i`X;A]K&왳NR }#iZJ[@':s7tu rZZxڒii 0ީxRjaa$u'Ou"p{`{?NwA&u m$VL"DQdBp`c Kxz 3B w}v'  P,IAo}qḭK\;5CK'NKZٞf% ! ڜ i QhrS,` -t|L?zk1]FEQHpBKDLjY;1؆8=ȄjEÊD(I s=P k œku:}Ed= gP7  E.z(;d$[N?Yw;mVkss(>|-]3$5~mΨvqv@@Yl\'0 Y_|NLe?7?Ǣ% +Jߋi5r*잓2CS(K۵(zYt0BR>Xi\ 80IKDԩ3%rV\N\ڥ;xPv9'i7A;*, d')QLTd\s 0_G[۶r̓Oh0FL6BGEvW64@}t(-8T* `Ў.Y`4$k4}ߎy)8m=8 9T_6s:4+M 'N/W3+s'N,{ G5ǂjVΙYD0BsM4`#7Qy40iDAu)Q ԛbj/ƧTzIOMBᰔ f5kF,HӥKC`褯 C~_9;W4? LNĎ`j הE!^ZDJᯰ]X\t05MCk`Z*%gD , Y3gdNhmw  S,LpE\ QY<}֢jcU뽪`Ks$?:qEX̿ V [t!G$hL1r@r]A69MSSSu_򗳉MjX2-{;T})`2aƒeL<!63D/|@ Iϲ9܏Dݚ& "ds#7&2e`_"⅋lh+ݜ{@ (A`..,LF!CɑFS]9E!Zs/{;u@[ܔs.бygdWc@>%[ұ;Sk[91nٳ9!, VLBD0a+(Y˽Ns<~B”i%pxXT=P̣! @UݿCE8IŴ)V~߫5 m'ƪ|A4$D`fu (7DY MHV7Z!__*57;#6Pi@eIU5"  xN)N&@m g7baVl,L"ضҐf)d&w=o"qK5Zstq_U`M+fi  }3 Ei'@vfgHDȀBPnwuB />ɑyXEvu( JS|臥Q~$/}ySZrbj&EI:.!n& @'a_pQ 5۱uQX .ߑJj?uA7p Al_RX d `_8iنpEfZ]\_4ʟ%Ӵ<;6*њnbm埤E菙n`\<(X-j 2'f^myr3Kp|K0`"Y/z H_썱-vAaN$d$Mf\JOһXE"ڢ._QK$k'g- Ai<X8U{>\q*QۥDT {FZYWO+!t\ {nf`h <9 W@"\ k0`70f>}F5Y^^*ɢ1]g:qLw] IwYY'THWfk~E=&D$^ߦ"`~ʇto @McrqjO Mҏ6qZ7Qd[ BҡqEi`9{zJ]#,}XngкG &V3DH" @ ZJ m&XDNBd8^S,.ƌu{{2ͦW3SCQl_FzqE9He*$H^I8mIfX9fSfC L8 ˜,̀9ido( Tڼ[̮(w4c˴@i,:t?Nhi0@5bL f+kd(AQb(KZ$2 0?{Ƽj>[h>}kdʞ9@N"* @\@{@(O/،YkXk]y (!#u;Hu ٯ~ ."$O%gΞ3g V@xL/<GdQҀ* tEc3dX]%ں欼]h2bejTB늕?&jTUUL˿9v8A۴ԙ ]3+{\FLbR.]nay_N"g@}8ć\rE4 {E^37uW0CTJ~31Smuhz^5*ѪKʓFL}-=W?w` ]nV}Ndb)0i_ҚJVcǎDQ7]s22=ϕ]Y }w1="))t20s;h `>ii wV[w"#B6JuS$/"麤8 OY@h3Mŗ4k:R,c"@F,aVZAp,/ (X (ᬄȋbnyN/+&'@4DZbsE$m%(3X퐩:L%sg@!sk`e0nsSd2nO*V4\(0Fa=2rYH׋( Y/YY{qrc㏟]Lx֓euD @\1 W.FxLQ!v2:F^[ S1=e/īn-gS7*JvjKg7,)0PD0De)x6!# iᑿcn%s-ɴ2:4ܘDjx3 nآcgg9:<̀ˑiEhA?b/h[bhqbz_/X ׽#9]Zj&s8˥E`t4P۔^/kY@zVtU _j2v(ıl5S+3 *7w?/44=PIA@MI[um#O;g; 6SlYR| -( )/@sfr>gA_-UE:h]%8t.>:]\)+q^Bz- v-~` 7LN=tOVjzə5DYX >k֝Af5{L4KTqHneJwliVV)8[5Sԙ# ccqSlHEGI+ {KFtz @VuR6h,.[8912uS2hեu)8l @ @q@]y'wqޔm:?X_dΥK\4lZ8qD6b긋p׎Oi,Yh],@/ci3OH_w{̈WOn0 "Υ[;߿ $p4#Uf|Tr+㪽\i^TB;8 Y 8EN:h*1AVZC}*(ECWQ @M0bX\X5 &IE)7tyJŋ)HHq @?"Rd@] pXu;zD۷GĞÙ?c|S$Pnaj4j&s8|- uS` 9ˇ ԧ!iV;J(dC) ̣=k ϝq$5DSeNɒqQ_tIaU3k bzƗr*dn) H 15+2=`otu,/DqIf,d+ 6 uNMV:m*xug^x5JzVSB0&SL8g#aF1KM+k^%4YD mit8:-2DĴEy :Å%Z*fEȳ3"6bg.%_@"΀p\kkt§Y?y& 9en\)6PD2NQ1yǘX.T,8i~n&sc-[-5jzA (][SM$@qM \(s?|~ׇ!G.JŢHRv9N hD` J3XV%+48R)iiM(sߢHKԊO_R"XST"LwgWQ.w4MIILS嶔=H׾EJErs[x#mL$Q9 "F|$/-*KKŋIjuS ̡p}* Ru_߼b| "Z-7q4JrP8 4Ynv` [VqNާsHg;&K-NKdtS]5xd |:h ) d  #Ō4 [  Fkt(PƞPq;gS} dc"ү[1tYj QP5KͭPCzt ~ .S{{wPg)3 ۾pHNM'}Q_b.㹴LZ4t}1S:\4[Һg1d*5f1DrǞr9 @z`gw d@7,?0'Ksj'L4$uD} 7 5cm`(ɞ%y,Iqk>xW,zj5{ONڞ5q(m0NQ,l}3|Tr} cJy[sW 9BF ND)rQ.8?Yro:d"-V%&S0@I|rr,!TDT(bȺF uҏ%3SP?k*pm)T R ̂{ 8[@t  Xh~Z6Qn۾@ UWU $2G$" 3f"#3ӷmNL9M깢Q$-'{WErpIH״%:DS&t_E5vKs._}7ȃ36U9jD< Y4Ca L%t+ M0v4@|:_lmyXRIrL43`yﳅQqK)^DV) ymA,QWϮ:.#Z,-g5+*;Oَ!w+->uŹV .n g`Ybj8|! Q!#L :t `ss9'kYY:繥DA|8>FhQo¥b4 m_pwXD;E{ `)`0[2bp33 @48SWtu;k·Uta @[]*t`(|_$t\`E(KD ;.xo☭w,ĽYJJ/Ldޡ˗.]Yb7 hw?+JaP{HTNTj ۷\䅳[l/:/8;C|L&6_d"=r,)P.:y͎֙šڽ[qSX~}\ˈ<cЃ{`Ld:8$n3f%OQJȒ/6blو pQB@ZnGAsj=X?,`yYL1E{,)"KΙ ɋ-&a+*bF 3D'6+esJx,>W^IX,9m)~If<6 dQq Ae1'ev]>tX[͸fklMAU7J"FsuꕫjC޴ Ԝ/imv3V)*[x!SL,blWNSؑK37)-eh/5`x ʚ``Ք#YGZ N%7-9Il>4/6* h:j fM,J]%%`{gǔf%ˎ\5AAI(8b-c[@N1FV> V$PDzTg@~ڜ+R9.,A(3#lp }t^8IP ؏vHImj&ҚI'YJDh+WXyenUҜΉ9ƅLϨǎk #ws*'N$O-[IW|Vw`h[+Z 9_nU\H.L]$3Nl_ܙ[S馸Dyi5;;6ȼ 9zfV@ c >hxoAᰐǩL`ԠrlSAU.!ƧbR7ŜܩnJBY) -ZWVi+;Z\s|hO >69A\}TwL'T[|dB6AU92ᕋ),[ܺgNȝE87 8NR۞ZiZ8Tf>!N#0a20A#I t~Tv5A29ÚrDfVE^TLBGTLꦈ'-So fZ]=g47!9sxD{Nl"K.M%DE>t|&j㟯wVh1p:5%穢NVxKŤn\r&I"Mue">4`6st\ ̗Sf-|2-;.@$ps~9=u%@ Z6o'Z9CBfYLR\ [Q)(1t3%0vCNp>S?fLѹµk$MX]D1>me2V)>R,֨Mrv~p),e>rs8LtcT`=46W`!:I\@VV$ COI 2=1Ι-iс#op([tS1cSp3n{iP ]/+ bЏ0|%uvqKkɔY;C1^-dx&Çu">)˅eU?`is]1אG"`} pYS^Z5;'J;Ls4!_2CN$ӓł:4 pHDMe[<)u7GKǬ R@0})-W }3-D00\mҐ cP ;5. _%E4` nEɞҔ%ngnd&/Ln;Yo㳖&D4rSY8QUev] pur?ܥ]JOhڀޤ$u/\D44QW|c'GSD ܖSo -" 2cjaqQ][; nPpvSFG}sm`6,$ZF2ŊDGi_j:O670ngncs&:e (SEbe5}`Z+9 R4|k_W]wX-̘Ѱ4gPe\BMθtnKi"ROV@,"] ^:ׇqy X)a=3 Zjb߯\a{ajw3%a%Ҙ|,xY9 JЖh>e63NۦczcH /""ٜ=9EiAcaLݒ_F=URg* q;weh.8qJ=Vf_(+5Tffϱ/?E7{/<)*9a*&uSće6Y-33[ST6)6i(l;%H6nB@VRЇ>^E,=V b EӤ8%"7 jNE|{ڽlpedm%0ߚs/ޛIrb'ާ{g`0$DJ$+R" d9dq#|}!GZ![Pk),a}+$EXm;LOoWeNVVeUrse"Zw 9e w{`vyt=";,FO}[\d+$m-m^e:7@-holldȆ&}SrH1!Sǻ鈠Tg$ ;_CӒOOEȽ H]AVn>Y\y&z;rhXR)WwCٞ+*5:l&-X%`vB^WaYFRs)z9衋?U/بTF HJ¥v .#] ஑@ !Qoj1cob8+zio5;8 2o{aEjSvޭ2ҶnƓ@Y7!qA k1rIP&8o>v|-WٲqUXb%^쀶hLs)z:Z@IDAT9: Ό"=Փ%yRyWPĻ{ga 9q S!`%*2k]![UbX&21fHs;[~ } ?Ob,̷5Y{2jqۈB۶V8']d^[!,Mk3]dF@pq+$"Y;)Nib@=HXt{@f5ZUhR#8 1j7{qDiKT*a GR6O}>Cζ ]!v#Jk^vOB[XRb˨/CsϢ*lPfJi@%O%:q=,gY'(UJpj4 H50dmu:'λ۸\Us^VC'Ss I= G0 A r 1?2DBs<0s04w.߮n*nնGqfuZTT,+R3[ߑ@pEHѷ1W#vxW00,L!sg!zUi7[U~d@v^ΝZޤ8ںhUḙ:ں:q+uUlccÇh4z%(UW+?D+472: \xor(d{-G w1<)פk;FObq[onytUCF81;=d)@R 0m!I!E v(g% P[It;sϳ߹]HwtE{g~ k5_ $ 順ΰTb͛7}]Cl[؞Jd2T(H T`\1SH@Wa R%L )%S"vSh4bN xpOaUbK_FvV <[,ƞP1tJg8"I @Dtvc}eWIg4+oU!ZpQSX@@;GvL0F:$%s9o `_ Ў@L} P`0Ƹi85_&r9LT%ҕ,|ll& %ޞp #5AQ($>d.M%o߷sp+fn5ROhϹZH1l)b0n+>ڮt * [.D9{,Rp ~_[Ԡ,,)`:u@IH ؇0"l)Hͪ4Z!@k> s?'8Ll&zox32 "[oao:n.a )Q6N/#k2OTrNcB\R)Zobwd9<ޚS*L=hQڶAnݺFϔtFz[8v{@Ϛ5WabW E|qâF?xPY(і uT(6 -F nukLɨbb@ls`V2zjwT~M; xvWlܹ(WJ"Sx&ha*uLe03 ܫm+O"{FjU"TG -h<ʧMlU)Z/mHq4G Sp; g t&y:5X3`n@z]6UaO)N>NmoKݪ_аߩZTć.aiׯB4bӋW R$̵+W{g|(Pai_OP%pvF%S,SKb zU[ >1}'\J_ .3UZLM$}*Dc?S;]`ga&Y]sD;2C>zؿM4oo۹10eC$M@ s>5$֖k2C$##A}U3P맀QV i{ϙ("7%]QzMߖeFLu8mN{+9:gL>, \鯮Jd(rVa;ᷖΤŹg;+M{ {q F,-.(i 8Y|Ymhufq"MD_T >`W$;v @i0Pf$yHÇ}_oG}> ȓ9쥃:yЕ-rSs5;.s@.}9CЅsgRuYos;ԩS⡇JCǩދQh!;`ssS٣0t+IgE@Sa9Q:[ s>c ſ G,4|8*]2n_#Z јO#A_xL;zTKR.f=o?RگjBG `>-x%탘1;q~|}ăD = ~IJ"`p[Q7@~ΙᇱVkSe aks$u3?H}`ҘMp)t;ٳW8%lfeD~7/8>>Kut"AŋE(yfݠbw#d$ qc/$nܸ[o'gLs^}Pc! mߨ{K_8s&hs>m) ïz6w|juyLuK]9e APd[~4,l+M S[~#y5Uxˇ* n}^VOW}3[uT^kқ{OB5[3d)GGA9;O£S< vT* z78;GQqZ)|W}3bu+5 ΜO9rT'/l~&}tS*AtN_}0=vE SmƿF1x5!W{3lgtCЍqn ,nViHOO[*G2 Ј睏7=K2 @fT:"fsƷ'|,~ӟ(EѰo@OD>엇5qo{gk !zɉ+ЇyD:1I3b4_ÝK,S@Xؔ)z➛Qg4ug`'^?2th%' `Dca t\:ıXKY㷲5T:'N_e{* tU!c˧ ]nf쓌;–r=ѩNMq;x]j>{> w.qM֌7Tf(oC31 ۘc)~72AٽNa3r^Q-|Fjr|+_G?ҁPt-oޓ6;.d ~u<' ie~n޼wI) ~V~{s<("0Wdfy1f]٫n߯%@4--TZbJ#Rn,+i`;@[2t߰4'@%%@e20Mxrψqؑiqx'ntjbG=6w3aA:N#,/@q1mĿW{6#, 0\p1(.*nȣi[ɠB 싾?!Tr'ByK\áXdl~u?q˷#L9~(7ω{N܂{D ORsxM;ttTnB|,.׮]?܄) o%h%7V}|R>v~XJ@וy{( 6?6v- Iҷ!%ٵkOĕ+@@"I{bB:o7~ KgL΀fO@Jtbmx~?$jG@oT%Ua(%(Wˈ"J5L$ >,ᮿ˿=.0 ?{+ pݭ K}ޅ.|b9-)lI&ű=8~x+W{{Z,AJN@Bz*%h14hPW czo.O k'=C5P+׿dh,; tީ\Wenv~z0DK@@GS0y{Q SO} ;j-=RYS%q;8y⸠ߠVO< kş(wDNHj biOgy^tX9%mȲPvE3oN/q]XbT5-X*w 9zL:O2T}o 8&SPa1˱;NMMn`'A#x|L=0Έ] ܫ}Nc΋˰x"y/Eh,w?7} FWVBau4 ra$_cAewn 6rkx_=OkϺ;3R^4D}2Q)a:QE1 @4J 4?pMR#^T~'@~yy?@ wV~~% c2Zm"O* >A;- O\?Ips'8(!犟οXT3!GsfB꘵9(~i^â)`>R4 jPlp'?  Ŗ!w"H)yI0 8jy/c=ҁrׯPύU0ZA#`5`fň|'i3wM9˧O™[7藫ȳ/p~4AO*veu{7m/qb77@ NSTr;2Xsssիmy/FRhFJ;x}, TCiGJ$\c^c)!>^{D{. j~}G+}#T;׽'Qv UO>}[yP#GC׉>w<(z)]n"K#w^вou^m+nO0Jf'a b 8H`p"n=!/J_И@PV4wŭ+1GG!U>{ߙ{qb4OjDC^d&(RbY8-ELiP36BE HTg᭎ձbL>NԴp˄F%%1*i@/r=vF@h5Z$6'~J{ZUiXw5yu-(xz!*EjO_մ)2UwE='IS/}<R#vaaGKh@)P;-yh}=7^Z8GჰXozFg}?}B>t RIWIΩS?)]Ҫ@K G*=*f&6Trc`*բ-qWu}V\_5F?3"B 3ҠST|zΞPT" ;=jR'ᨋ:X*[2,p˯8u +Kb8ra"mFh.AssTd˗OFg/߼(6Dv3b7-*Qz/=!Ikfv |\v-rH۷6*pJ0S B+NBa;3{&l|YGПkt=n~<'@z >Co[|k_z"9_꓈ZxL>loɻ_nÑ?'JO>-Hnh;T`;-O@* ie@GAk*Bfhў>B7 2 0;(( :m/_) ~`( xw`yW{Z+cG=Q>= 4C{ch*>8XfM+dicܾu;{-a%-n5fmFQ<^#J 2Z ǥ F_/EX$ ?ey8.8wXt(z`!U mE v{KqL[z *`[Q=GqQ Cq p!u*ѻJBN; @h~/~@oG44#o U$h;S_-M}5đlo=b4%Iđ-CQP?,D+&ؚQ^<>Ӎ($ +/ÝW'JXv/!m;si7Jg~O)=8CF`OP놌ױ)D zPkʁGtْ_{%ns~ 8/_qČ"a?it` xT&r 'F>,fl,F%t $ 8JC8HUg[ Z?FB/7ғp'LmQ,mm+>/-=p[$%T9s@֊g}9$FV,ݽ$֕Vȅ0 elMO EOyǪQ  EwreJ~Gψc jzQէĹ?;?KGY*+xzp!Px S@(*00@ StF{T]n:hruBG_߁PGC"}o,b:g9%NA49rL՗L(y;={G.`T(7+mÑ87zN7 2V8T=FXQX@#,1t']M j^CK#mw}G?1>}eqq^|_c(Apx*KBE /uy-)mT NxL >4x4N@-1@U,V~ݻW2z*'=SM7ca 8 0!3]ugsP bH~˨>!rrԼ j'@1ŏ>"}╟Ɓ=/^{SܸqkKfO;>!;sJ$zyXcU{m433-uYT-Bs <o~ >Q |nj{SF:'@ɓ@Ν3'g.dQTj/2ߙG"}0`==at"@=]:PdD{JO`)C0.fGr|=A8>9ND`Ğ?}點&n$cG֯Ç'wǴ/'.<<0d&.w ueFSYec7F#[Þ\NB$pFz#19{cp { H5Q`|QV]|橇u!-_V*C <!DP7 0>kt:H зdÒxw~ςIXhiq CQS%w(`=)NxەO>[yAx( c |{eW/[}~2U7 V6* qC ;eݡCKK}MRZTS-r f}\Tb` IN?TXWY͓ܓ]1, *K51* `B7B`zzZs DIo~{}hҺktωꝤlq<$xJ'ROe~}c/P|H5y_##ӑ( J L$P.JJ\~S t"|Tv=w8'~beey{ svN:0Obp@Еw4j wzV%-p=96(̶% ozaB2M'~tX;5>DJ= ]yx:NÁ };57#/>TiN7߇Exꉧłb .цҞ=J @Rr &;NK;ŲVꚒS/,hd6i%P?>m; TL2QlP1L'&.^ Gi8?B"xSچ.pq_T"0T ~*_jw^yM?oşa@2aDUlWxROE8yz E9>ے@5m (О=Żِ~argvQgX3~lIasdg~:^<.։@7PyiO[ wYKKK3IS޿͎7qL âa)lp=ԙ= W [BQ: =u]B=vqq=QEu@lLUU۫gϝ{@OF@Z+=o P_.7)cT`}F-F ̂ޠsRNT7ox|o}]\*>uZP-Pv(#ҟjJ&_n&V.E(6GuG/G<]1SNDH=X g=Ux G#І 6 /˭1NēO=-tF`+(Ĉoy[eP +|֭[*ͦ/'*wZf0`r-x-n4m+_8 epsS!yJ98s>|`6@}AΝJmY|`c}l? j@A[_X6[tpcϙ>E=xߓZ9rdziMy[+ZD}o-*~,K5a!zDIN*7[Zt7Dj5]Kb ~76#k_rEYJ/Q]E`E쳿'|)'3_|4q㷴?;aq &5E,CҎfve={NE+)w +:Zro(NHOC:Qa╟o|㛑m\BGo"bieJFW.]$TX~x(mR^й*oAvtF`Gn<&/8yԩC\Ƅ_9e&%-P_mKCcD$y|@̧+Г[ʣMV]27跿m⺣lM( ,/1MSS. E#Nߣ-r  DUhF J[oVyK~])W (X{|0)` $H)hȇ5m23i犟lxfq\44!7B 9MПTO5j@//C=CF@#0GA|cS;R^*Xf.Qa( n5B5m/S+g{;7? #T5L`ɵpha3e/Ŀ7c$3F`Tߋ/(D=Fе'O .TIXMBK-'H@u/islڇu9R[g.gH~rlP^7SEff՜gOi 0 13xWbx5oC?,~[GU_h4ہ(G?}Q!ʷ @B! % oF7/5{rn=Y iu0_X^ NSA>@ S' f6gT6f)AŸ-d)!KM&>rY"<y6܃sΉOm(S~ϋ?+:9B }0|aQt:S*51z>+Ci1_o(;,TuF) QvPPIUl[no[Y{Ӫo՗C" īW*cqn۷ߴ5#9pm讉e0[LuWþ=\&RnMJB<(B9(:X}Q#PBJTBq>,a30O/$.ef._grFfsSF4@|y! bHZ e 'YwkNC/iVSDT3Sdb"Tgr ۛg<$1jZG/ xF?o-j*.}qI x9F@#-0U6BrQƦTu8U2 z)S_HՍBne(M p|`;)\Nn%l8)']*UJfTJJD9QLVfr0R&6D2b8xgmYG. mLh4A^XK::CLdTd0 '+$"ZL/$HNJٍl ]B@.@/Uܖ0#r2|" db&AU3mz)Nbk+%IDAT KQrܹ)^&pԢ@eH-3-nƝ0~^/67770'KU3 OQJ\MLC˭fVF90tKN܇_@Wcy> dO~%%[h4;o~[3r~!zSn DwkkknV/ȱj Y1&ͤ+6ME1/*Qnt$AgxmJ ?}Q+թ))ojR*D*\LdT3$A.x3pAm{oE~5NA䉓-#w믙i5k"PNRMldV2IhN7gA[~K*Xsrd￁}2De`AkT#XJH_5D53 ˮ&F}Leq8Aú; :ySnW# .3ԭJmLMSO-MLL_X5fB=f^1蝝(mҿP#BPd?=?Ο~Y~ 9'Op\i&bPV zqJ&a|?D wh/Mbf~󯙹W$Vݔ?S"7;lO 7a6  V][X7n\ҀK.m Uh4wYd9y8}N~bZKm'#X&E2+j`( 0Ҧ*@~&=oϤkVd# =A\$lI-A8b }O:LI#aV&5yƬ'HD`iVq]a'7Fn"Nk<5F`4(Y>J DǓ͉{]mw,a&h'#!PR- aH eۨԤal6ͲNص*\mȷmCtcKפB`,) J?CR&OG'?ӰrXWj4_mIi P[v?)G #E?24̪]?V'3 XcMilC\|p'th4e r;Y5aJs[6nC@mkЄkl (#: 9 ?(jb,Hbְ2 nߦ$@$lhȚr@¶O$b)C@)NF@#pw %V8s J+\⯒8R2` v,tagƤ=i5N;͡ݻ<|=5'cBbK l#WKB 0`O) ӆ@Bص` @uQF@#0B8^i# H9@Ə+1 o+{.nt@n g0 9 *Lc.& ]AǑ6@ňHh *:F05apI(B =ïIh g&% ?L~tF@#fbΊvGwAu:3MBm]Jy96 XZ4ON֍H=Jnz=7)f胘$5F N9SNMy{o侌,Cg͓me_%OΨ q?q6."bv[0,& +%X][ ,^` Cs_F@#@2AJR[>^%r^iDrD& -F tO꽁Q*΅wn\8_ <ܴe @4d$ Eb+qU-7)+=O 1j\S@ߋCp>rKWz>F{tKwQCRrQLp+ac0uF@#00T}`{ЈB!@{4:P0:8嶔{ W,Pe;yBwcƘN ;˩pJy[tuF`!PURJ#\)g΂ؓCf'@H'@ތN&rSI7 8_\6r^MqAO9iHHuF`DX]SN/l<%@q4tڰJ{~7d`CHc.lܑ s{17Kp+ (qEr[rAe|8]7n{YW#,zOuR4BuN2[Tcxxp$\,8U{7@UzR/.IhF:h7)8j+^ŹMmv"Wo-c*IkAo^%@òӑ-D{ #{jS7 =.!DĭeiH ӇDtFO%[؀wfữBXeOwŭbL_mr @/S GK8R6b3 5^00h4-+y?VwX368ɨJoڙ5F`@HhZ2mi3c8g-}tgF`D bNDZo `2 fv꣩ǥhT{U p8#lcIԣB zݚF@#lr(EX3*8w}j4 }<ƙ6.n%{ܶڥ4-dTֻ|Րƅt\''pe c^2Wke} WFͮ_Zy ۸)AH_4tLfhnH@+u@ k^މ(urG z }FtF@#h|\zYJ|;K0nsQXw+vsgslit{84 {PaGKo:4'6+rA N H0 \` u`޺|qU6mH%X\zU[̻\t dF@#lr/vcQִA.c!xm޲>[W77HHǚ8V6=Qrf(KGmm wAcM/`92Քb&Q)5⒄B}pP"rRP ˫o&nv*/u[6؉X`R$e7 Q<Kd$sC-tF@#X}}pFJaJMpԂdKn?\J)-yQQXNdg=>3!*u0 >K/J3pT*T"bɜ,}h8D1XqV,J#4:1HrmKX59T@*CNްAeuF@#=k˕ܦdݲˠYXUNX XRΨYfŒ ]pmNMI;|-Hh ֡k A dplkU7 fڒT@ *"uU9GP m ޾F@#/mݹrc_oS#JAH:ht_ҵZ5c1_ҽdϖ .}5 /H˗/KBX%&Mѭ!`pnej N$H ^` ە)g6yt/F`.~]Nd7tմY4 4ʘtop%+HzE:S u;L"`rUNXPPXjUT03W?fRL@e/.Ý;@vx":i4F@ugm s5]Ii$L@&};DzpŭH*w:pWSi"ztzh*i }GLDS{'SH:2[w^|m-;qh4U`?t_on`6?R޿e :Fz]oMށQo9GIzo= *!929˒('S<& l *@+amW o츓hI77jXs(n?Ah4cCnl.[.#)b9tJ^K㘄?Aնdt;.|SL΢t\nK/lfZ.aNf2&tP  0u h2TM߽)r.#_*ϠeH忺_T.Nkːi4EV*W/|IcHIw(o%\[uЮtʖ m\a\'ݻK:Hzտ;[ts 90.R*&s) R#5;[/Ջ8TX'zɄ@R@͌)}`~ m4M`_;2NB +v;?3ғMh4c@RwwHqtN ]Ej]XinXsZL);UOX5ldJe T&d{eq{w97T:ϛ LB.(mn&YHV#ŲHj2gfZ5ɽ 0P2 pJX[Tp{ƱQ373sp~n;tPvf0V4F` -=P]Jz}k}HČR WA_N@ ɵLՠPJ۵<$ igj-=1Q(-n^Ś+qhp+e q⢉H|f4mSɥt:Q(jHV̤iVU3edZMrE2u f~ HBcUP wvbxLH,-=0={2H3Q6I԰uF@# P)J6 +m]S "- DfZqipʆ))+@%ZkL=Wfk[p_+M׳ kiiqq7H2C gK\+$DTJN&UD.+U@ JR`( !#y+DXkNV׿D"7?;}:Lv]F@#02V``^-W7!D!T<$-CLy?֡ȟjF]5ʟܵBHa՟_?A3z&a5Wgav24 l Oٟ;8f. X__OLw+,%/92O&z5Vt0L[1`3@5<Bs΁W#h4 =1z0'*mP~S \ס_Aevo` ~5533S_Y C6,Hغkl>Gԡ}aeҊXB̄jRL)RP<\BLtL}I : 200nN#hF Е昨76}4>@@@ndҝ/4 5T-I?717зJR(m#5ٗ'6g'Ce,ZprU8yARYi)QMσf,N9QO -uWUK&C20lc/}4m0 #F@#~! L~J I$&cdٷqfc_vAVɺw!ԔEsx?'^{D;) 8xA}ry,jL2J%~*QD@^ j+.~l}Z`.Ic0)նKj4FED9vb22-ЇKs?CO$$IC_.0׫Z=N׹'/BZSSK6t6&4 tVLXK``8NLB99A ( w)`gNF@#BaTbQ#@.X6 'A3}ğ~bI]?z @OϖH8`&.'eTJ&,{Y0D=pL8R2 ^!L;e``T씑Ĺ .'c10iFAh4-\/sĿs Ɉge?~$Ic\KUP)]TtrWt.?;2@vH;Wh2F@#ܽ{gOBsWl!|\D4e>rq?1 48;s~1p<@2Xs; F\lcrE)=J(Z0%Cɋ8 8hN>\_i϶[Fj4݅@A> Ζg^$ <>KĪgrrRQߡG*2yJDIENDB`ic09PNG  IHDRxsRGBDeXIfMM*i @IDATx eIU&{w22kd.@A&>j u| IaPԯim(*!v?Т͛;߳c}"v8gyE~'+VcWD0TA@A`Wu  D@A@CDF*  3  !aKA@QA@Qưѥʂ (  c(cReA@A@yA@1D@1lt  < " 6TYA@@A@A` ` ],  π  00.UA@Dg@A@CDF*  3  !aKA@QA@Qưѥʂ (  c(cReA@A@yA@1D@1lt  < " 6TYA@@A@A` ` ],  π  0Laʂ 裏V&&-ߧ@GH750B'*Pg0lNo^8 >B+% wm4 G%N~o~pVBQD!E]zrew0=ߠOx;(@x @Qxȝ 5~g~ae#RǻVȐ3)?C^_xd78 2f`?bJIN @J~A A{DF&'koO3L< '`B. s?Sw#pJTUD$f p Y`E5M&!RV9Dq_[7)06j_}!#nUq(RFVzyiIvmBz6TRdl{%E`{{KI)0M_Y+ϫyuU* )Aj_% S/^Jt ^^L['"͒bffF=)OVjzǗ䔚߰гvvv"`:sdhU@ }GZᅭSďzߥ)oC=WP}=> pot9vfD:Y2񧕂\LͨiSAF_P_YQUQ8Pu~ۦ?0Z @98v/r=LӛFE~K2|Р4OLLi j~~^mmmݴCl \_DGW7=M@_Q_[۴p̂WP.q{4&):*VEw?KCgqi۟lV&g~Ffh%lC qH U[ o'iaiz/eꛈR0`_ EzqxPҋs|q8$"*NJ~Xffff_Qf[vj.(dZTa_YRW, T )̊2 3<,?J2x FG9]ҳ3szM>+,a)kҰ秕?*sd7!(Dʆ=&҇A-~qj6b@rشxW;E[~ $-wHBB_/GҊ~U{bYi<aW+ãUUZ75=5lD#&Ǣ-uEc4ꀿQp_k7#sd納+x.}xڝgih@}3Mw GFSWs䄦F &hY$w<!D ~ @튞 <‘T=#*#r VbE!Vd٣6ڽvMݫWյSԕo|C8Zdky YB~CBG@qz?BGv@ݷ}Z.5G.diB v?44x#v{e 3'nw.^DWqԥ2c(~@AX vIIۣ&}k'O jZm]!0Mr?A귆aی+~|sٞE59ӯ-Ky;gi榤zw iJ=?S0`NnSh׹xΠqt a >]E{ZP  Σ@?߱.u+_Q7joVA9h#|'}w'2%zmdx.YC2â?g?[{˴y=^T0ŗ@)11'je6%, k&gvYBB V [ x_6qg>òg"AŧiJ%oC? Epcd4Kރw[_J5{ N]@w18+i{ Qç-#Lϊy(p(A`ji0p7~l9 hR7<GcQWXW/aEB셀(^pӗi`Xw+^:u𙆟429o?5o9=#EWGH7A(XAuⓟ?κ41RKJ8R*qAP%T_0DUw ٯqp( 3=cNTX|Vbs}?hq)4'Dsj&G +@:D^u~G_b;˿;kn%%D1KR[JsN.,oWK8g>Bz!Kf]] {uOr;!adm 2z՗~>-GHʗLXO>5Q2utaħ !VV >! ab)b| @ pNtڿ~K2yPq]BBP"IVx3pCԴ|1fB Qh/-Ȼk(^UA Τ*mG /%?A8ma%/Q_W h \8Aɟ,p_fH:c.]ܳ1g̓XNGk˩Br(:WqЂɧ*`}^YQ/oQF}@ӪWEA,(%eA:-8ɤ6'-n/C#Um%~=GDб9zBᲃrzTIOeOUw=Ч_dENa *Mpmя}:K~B3*0-IO|B$-UNݶenn Qx8R@Kjѣp?Pښ DhC],pwp/m꣦,n)~[ЉWn=1DKӯU5[&@^6@ww Fcb[,jO>7QU:B>WG;5xKe sBO $$5}`_NiQK׾˥V7W8<rtODlr  ~4ɁW^.*%hZ_c(Uy# 7de@1YO=[.O6>C6V_K˔CNCӞ(H5`:Q}bMݤdPp蜌zH]Т+|p|A' @[ >wcCyաqAá󔳫\yt$LtJe5(n0zS z`4$è@A` `)n) )uВQLz|Բ?}xvbrPX<Ϥa)EsrW խ# [r˥A*߆=,?REc / #HB@ɡ Z N@ȥ Kѷ KW-]>{ LBKbɕK!@+@[L52I( @_ǍAq>؋i—+/5e5Nc\F b3(p^>VI@@ z.UH:_$%W(+Ӻ"7N?Dݍ 2- ^w>}K.1t G+OpCtCMt Q, u7^s횚eIQYYQ]¢GG[ئܷNJ,<{\]cq\yt(D,F#11Wd|s}9U %" @`Ƭ*J=С]$XmkKZzֲwΝS x0xYI1ՄZ=|ٔBB@L~DQ}"]f`: SSjiИ'%B` QLđE  I\H _ejKE3IJ߈rS\e '2S;MG"yXK8A*PV4k9j^p?.ݠT+/X44b }xTW~ܣMR <]_f}g~o "=w 2uwyu=nzA+ $u0ъ/{zXt\:*JJѣⅨ<0gv$;KVO?y )MHءsd_SZI/tg;dU+輂W;Лb߅˗ծI\g~ܴ?Dh%$lыqm3db;@iy;AnqBLeF]늩$'3uxIBB@5ԊǸ_~ K>%Wf~^u<ݯ`D1sHkn%~'z\Gǀ|[wg4$r⤊Nvq"wNB0A2lM*6,ofŠ]0FZ+p$j3ǝ>"`AF]aD9Gq#w*uHNz_N+t(]mS{N{"RkԴE;bfF>!0DawoԿ jgir>o S SYdEj/hA- C(CހI8?DS M7O.Nnkx G8:h8t}h#s,,>*Ǘ -2-< `pڢ+Дb_fuJ#5(G),y&N]^Bpl2P6cڱ 2U2]_5^tt ٚGHY`! @bgu40\qej. CL MS @y<, smz$LN]<$]C@p\@9hy92shʑH@" 8 qhe# SA"RkJ]ݠ՞#3f%#(#ҐRC;s ayY^ @/P2+OaN2soOAD<* a'!(YKA@w wAθa/\Xu8lrH(A@O[ @i4( 3yLܺyB&䂀 PpAá.Q.!D@YD(A@A wAs[PRϓ2ss@A`<`80dvL1 A[@ # nmƂCQ7K$FQFC>ǥ$QAOdzLe)CA@00` ">sO@!ǕS#N[JMqUDf!A@r @x 7AL7 X0 P6b(Q'  bFaTҍ3 G@1\; `ѱK!,eAcH0" H# n^# nj˕y5H,@g~[S:˜ޑ$f23X[UA`h`hJM8*jΥ$82shN" 0:0:m)5B8h8tT}VAXd:E@N@'pG@.]'we.' 0 phg nVhzŰ~wS?U.^Qߥm7U,[ΰya׍WׯG}Bu@UxCyt?*u0|CW^t_y+k0xJE~,% BF?!H x J+aj~ &MWR^W-MNO<ᛕ va:4l;D4l8u McYn̷.^{_2@@ @Z_.PmX)f Re2[ !% Me_?~vye@OA<Gsd TK09DRQ~Y Ga6bh:wƗSsf cD[f+Ƕ!R֋2s?J-ux"Y>A>i3qb?ř6*B.,lA`OU꟣5dk1‹Ѕ{xZXXdدU%/QאΎKU x8* Q Q,DiFVVYWD(8ʻi>b|*o^m7xt@m T;n_ z^:šVUǻU^\S,-.Q6,}ݑGr3|Oyb'8!2<&S ̩X GQvfVϩ f2vX2a<ލ;#@fi=^ۄfZ9XF_m,XÏ%F6?9rToKs.J>B m@r[7]&_E=9)8a  ~f9c%"?|Lz!s  Sr;Li8ǜm~5Z/at~9S9/0烗KzO.#7 4սz1Y'pbR]ֻ!+OdY C74C{Kyۡ;73;Z~j J@~: JUf퍝$}+iom1tzYu=V<3YYd| j7B`K4E⹾~U{oQ:yBil`TGN=EBQ<[u?B^:߯G.7ww;N yW@D}ի^!lÈ{2fpFfx? Wiq&)A@t0EISn[ON:ϩG*X2)Q<Z|&%҉4ۖ0y@щ>^yxȮerW g/%;c =g?Oe M QRp8onwRd,y, "p-Ӝ>ui^f!H}bz!m Qx_>E7ȱov#c6P}/< K/_[()Z0 Xt/uF Qp-,L'R'^8J-/ho#kwب= Vmm @hSw x*`qkm+y~;jY^Q)rD 33ŒNɛߠ,4ή^S߯+I@p:?q'@h<xW]:O`CynlS{UԿ96 afzvBmh+ū^L}]e?x[pnQ ahNg5Ȣ͍MA q+Ln)әcC,\=VbBӳ3j^uݫaK3)̠GvU^zPZJ,\VW/y9Qu[og+nwoO]tӢ='t]!Kt.>M&-\e[\t1Sꂶi7dUze:8ۢ"@8P9rVw!I NЎeH @qbB{Y9cfT 0"@!K'O?^X QUZ" {V4b`>rg\_[io#xU|pX+$h/:긼2jy9u+0-;.^H.x>|o&y,34sBTh-YyGy&?sIrb `M1O<έmfZNR{ȯ0 oA`L OnMQP2X)==(`!VbK@~:HÖӧΈɿ % 0`?Iր1eb+qT+&S5,@6}鮙z_A@mG>}v \.Nڿ )pC=&fY2QMKnqM /Mn(*9/k`WA)c@߶1X&ɏ35dlTG 4@g_u a¿v7\+t9)gV*[J(M;^7e 9}d3xoN@~ 8}:Ehj0HiGd9_rvw9(TT@cJ.e!` QSo/L#,'hN@7VN~|vT6ԗe7&8Ξ9n&U: Œcگk+(T?0P?;<_wH}+l${"G`PN,f8=;vSwM7VX,\9<{{7TLp@7Ю-LH` z}VIӴ`Se\}ȍE/~Q=.8J68ܠX !0==񬧫|`ޛN mh*hoP0TUS( @Rc(FXZ!:BqULnbfcO s: \HMvy6i1WB9,//^tIW`; 7 ,VFg2j[p4Բq6nh96%;`sMwI!l>G=Ovf YûRcAW'@d@V=˚?too_\1ں,+4lxsU=i~Jk'&*g'@=d'㑐]`k `N m!$'=4Ϫ}=x ǹU(_rI~__@\8Q--vo+`xΫEC׾Ζ2}x#D53ՔU5Rx~zeYWWpI`jeT|Wʽ|-)o΋c ^Qg*{\xY.W uie€> }L>3jg 9+y$=x+HU'&Ix+#_fxf ^pzuG3EYw?)Ŏt8{V; @e7VP\v x}k,X,hMD ؁Ѝ}rƘVW(> gիϺǾ[e&>oʕx7pufPXwH#!E޷]Z$D}"}N2 }Mj'5XuVEB ,YWI?VNB G`FA(~b/Ǩ\>Ɍ*ݪ,Go:Ȭ1C}: Q?b2:w҈P '@HMOؙaZ.;-e|R ȑ#goKJ/8W+1"|Ga^C,8~P;ij+t5ӫu}((MDny)VWe]tbB}5Kia}EI\)ϣ$|h+m2Ҙc_YY*gfLaT`(8*͸ANt#% @F8` ?ȏEEH[oMwbUKڎONw `sݽGʕevb܉ó}Cw+1ţ֘Ҭv=hj*2{OUf6E;sC|pVԙ'4kt /TK<Ɨ .Z!YG,x[SQ, ӏZut M$~zmX\ZNJN;{"sA%;NZ@IDATkZwo˺ n Nz^Rlr'bD^Q,XV-dxœ677j!ie p]Zۉ:G1c|ЩlvvvK`pwUqMA=u!D \(: {<`atFޓ'i`X;A]K&왳NR }#iZJ[@':s7tu rZZxڒii 0ީxRjaa$u'Ou"p{`{?NwA&u m$VL"DQdBp`c Kxz 3B w}v'  P,IAo}qḭK\;5CK'NKZٞf% ! ڜ i QhrS,` -t|L?zk1]FEQHpBKDLjY;1؆8=ȄjEÊD(I s=P k œku:}Ed= gP7  E.z(;d$[N?Yw;mVkss(>|-]3$5~mΨvqv@@Yl\'0 Y_|NLe?7?Ǣ% +Jߋi5r*잓2CS(K۵(zYt0BR>Xi\ 80IKDԩ3%rV\N\ڥ;xPv9'i7A;*, d')QLTd\s 0_G[۶r̓Oh0FL6BGEvW64@}t(-8T* `Ў.Y`4$k4}ߎy)8m=8 9T_6s:4+M 'N/W3+s'N,{ G5ǂjVΙYD0BsM4`#7Qy40iDAu)Q ԛbj/ƧTzIOMBᰔ f5kF,HӥKC`褯 C~_9;W4? LNĎ`j הE!^ZDJᯰ]X\t05MCk`Z*%gD , Y3gdNhmw  S,LpE\ QY<}֢jcU뽪`Ks$?:qEX̿ V [t!G$hL1r@r]A69MSSSu_򗳉MjX2-{;T})`2aƒeL<!63D/|@ Iϲ9܏Dݚ& "ds#7&2e`_"⅋lh+ݜ{@ (A`..,LF!CɑFS]9E!Zs/{;u@[ܔs.бygdWc@>%[ұ;Sk[91nٳ9!, VLBD0a+(Y˽Ns<~B”i%pxXT=P̣! @UݿCE8IŴ)V~߫5 m'ƪ|A4$D`fu (7DY MHV7Z!__*57;#6Pi@eIU5"  xN)N&@m g7baVl,L"ضҐf)d&w=o"qK5Zstq_U`M+fi  }3 Ei'@vfgHDȀBPnwuB />ɑyXEvu( JS|臥Q~$/}ySZrbj&EI:.!n& @'a_pQ 5۱uQX .ߑJj?uA7p Al_RX d `_8iنpEfZ]\_4ʟ%Ӵ<;6*њnbm埤E菙n`\<(X-j 2'f^myr3Kp|K0`"Y/z H_썱-vAaN$d$Mf\JOһXE"ڢ._QK$k'g- Ai<X8U{>\q*QۥDT {FZYWO+!t\ {nf`h <9 W@"\ k0`70f>}F5Y^^*ɢ1]g:qLw] IwYY'THWfk~E=&D$^ߦ"`~ʇto @McrqjO Mҏ6qZ7Qd[ BҡqEi`9{zJ]#,}XngкG &V3DH" @ ZJ m&XDNBd8^S,.ƌu{{2ͦW3SCQl_FzqE9He*$H^I8mIfX9fSfC L8 ˜,̀9ido( Tڼ[̮(w4c˴@i,:t?Nhi0@5bL f+kd(AQb(KZ$2 0?{Ƽj>[h>}kdʞ9@N"* @\@{@(O/،YkXk]y (!#u;Hu ٯ~ ."$O%gΞ3g V@xL/<GdQҀ* tEc3dX]%ں欼]h2bejTB늕?&jTUUL˿9v8A۴ԙ ]3+{\FLbR.]nay_N"g@}8ć\rE4 {E^37uW0CTJ~31Smuhz^5*ѪKʓFL}-=W?w` ]nV}Ndb)0i_ҚJVcǎDQ7]s22=ϕ]Y }w1="))t20s;h `>ii wV[w"#B6JuS$/"麤8 OY@h3Mŗ4k:R,c"@F,aVZAp,/ (X (ᬄȋbnyN/+&'@4DZbsE$m%(3X퐩:L%sg@!sk`e0nsSd2nO*V4\(0Fa=2rYH׋( Y/YY{qrc㏟]Lx֓euD @\1 W.FxLQ!v2:F^[ S1=e/īn-gS7*JvjKg7,)0PD0De)x6!# iᑿcn%s-ɴ2:4ܘDjx3 nآcgg9:<̀ˑiEhA?b/h[bhqbz_/X ׽#9]Zj&s8˥E`t4P۔^/kY@zVtU _j2v(ıl5S+3 *7w?/44=PIA@MI[um#O;g; 6SlYR| -( )/@sfr>gA_-UE:h]%8t.>:]\)+q^Bz- v-~` 7LN=tOVjzə5DYX >k֝Af5{L4KTqHneJwliVV)8[5Sԙ# ccqSlHEGI+ {KFtz @VuR6h,.[8912uS2hեu)8l @ @q@]y'wqޔm:?X_dΥK\4lZ8qD6b긋p׎Oi,Yh],@/ci3OH_w{̈WOn0 "Υ[;߿ $p4#Uf|Tr+㪽\i^TB;8 Y 8EN:h*1AVZC}*(ECWQ @M0bX\X5 &IE)7tyJŋ)HHq @?"Rd@] pXu;zD۷GĞÙ?c|S$Pnaj4j&s8|- uS` 9ˇ ԧ!iV;J(dC) ̣=k ϝq$5DSeNɒqQ_tIaU3k bzƗr*dn) H 15+2=`otu,/DqIf,d+ 6 uNMV:m*xug^x5JzVSB0&SL8g#aF1KM+k^%4YD mit8:-2DĴEy :Å%Z*fEȳ3"6bg.%_@"΀p\kkt§Y?y& 9en\)6PD2NQ1yǘX.T,8i~n&sc-[-5jzA (][SM$@qM \(s?|~ׇ!G.JŢHRv9N hD` J3XV%+48R)iiM(sߢHKԊO_R"XST"LwgWQ.w4MIILS嶔=H׾EJErs[x#mL$Q9 "F|$/-*KKŋIjuS ̡p}* Ru_߼b| "Z-7q4JrP8 4Ynv` [VqNާsHg;&K-NKdtS]5xd |:h ) d  #Ō4 [  Fkt(PƞPq;gS} dc"ү[1tYj QP5KͭPCzt ~ .S{{wPg)3 ۾pHNM'}Q_b.㹴LZ4t}1S:\4[Һg1d*5f1DrǞr9 @z`gw d@7,?0'Ksj'L4$uD} 7 5cm`(ɞ%y,Iqk>xW,zj5{ONڞ5q(m0NQ,l}3|Tr} cJy[sW 9BF ND)rQ.8?Yro:d"-V%&S0@I|rr,!TDT(bȺF uҏ%3SP?k*pm)T R ̂{ 8[@t  Xh~Z6Qn۾@ UWU $2G$" 3f"#3ӷmNL9M깢Q$-'{WErpIH״%:DS&t_E5vKs._}7ȃ36U9jD< Y4Ca L%t+ M0v4@|:_lmyXRIrL43`yﳅQqK)^DV) ymA,QWϮ:.#Z,-g5+*;Oَ!w+->uŹV .n g`Ybj8|! Q!#L :t `ss9'kYY:繥DA|8>FhQo¥b4 m_pwXD;E{ `)`0[2bp33 @48SWtu;k·Uta @[]*t`(|_$t\`E(KD ;.xo☭w,ĽYJJ/Ldޡ˗.]Yb7 hw?+JaP{HTNTj ۷\䅳[l/:/8;C|L&6_d"=r,)P.:y͎֙šڽ[qSX~}\ˈ<cЃ{`Ld:8$n3f%OQJȒ/6blو pQB@ZnGAsj=X?,`yYL1E{,)"KΙ ɋ-&a+*bF 3D'6+esJx,>W^IX,9m)~If<6 dQq Ae1'ev]>tX[͸fklMAU7J"FsuꕫjC޴ Ԝ/imv3V)*[x!SL,blWNSؑK37)-eh/5`x ʚ``Ք#YGZ N%7-9Il>4/6* h:j fM,J]%%`{gǔf%ˎ\5AAI(8b-c[@N1FV> V$PDzTg@~ڜ+R9.,A(3#lp }t^8IP ؏vHImj&ҚI'YJDh+WXyenUҜΉ9ƅLϨǎk #ws*'N$O-[IW|Vw`h[+Z 9_nU\H.L]$3Nl_ܙ[S馸Dyi5;;6ȼ 9zfV@ c >hxoAᰐǩL`ԠrlSAU.!ƧbR7ŜܩnJBY) -ZWVi+;Z\s|hO >69A\}TwL'T[|dB6AU92ᕋ),[ܺgNȝE87 8NR۞ZiZ8Tf>!N#0a20A#I t~Tv5A29ÚrDfVE^TLBGTLꦈ'-So fZ]=g47!9sxD{Nl"K.M%DE>t|&j㟯wVh1p:5%穢NVxKŤn\r&I"Mue">4`6st\ ̗Sf-|2-;.@$ps~9=u%@ Z6o'Z9CBfYLR\ [Q)(1t3%0vCNp>S?fLѹµk$MX]D1>me2V)>R,֨Mrv~p),e>rs8LtcT`=46W`!:I\@VV$ COI 2=1Ι-iс#op([tS1cSp3n{iP ]/+ bЏ0|%uvqKkɔY;C1^-dx&Çu">)˅eU?`is]1אG"`} pYS^Z5;'J;Ls4!_2CN$ӓł:4 pHDMe[<)u7GKǬ R@0})-W }3-D00\mҐ cP ;5. _%E4` nEɞҔ%ngnd&/Ln;Yo㳖&D4rSY8QUev] pur?ܥ]JOhڀޤ$u/\D44QW|c'GSD ܖSo -" 2cjaqQ][; nPpvSFG}sm`6,$ZF2ŊDGi_j:O670ngncs&:e (SEbe5}`Z+9 R4|k_W]wX-̘Ѱ4gPe\BMθtnKi"ROV@,"] ^:ׇqy X)a=3 Zjb߯\a{ajw3%a%Ҙ|,xY9 JЖh>e63NۦczcH /""ٜ=9EiAcaLݒ_F=URg* q;weh.8qJ=Vf_(+5Tffϱ/?E7{/<)*9a*&uSće6Y-33[ST6)6i(l;%H6nB@VRЇ>^E,=V b EӤ8%"7 jNE|{ڽlpedm%0ߚs/ޛIrb'ާ{g`0$DJ$+R" d9dq#|}!GZ![Pk),a}+$EXm;LOoWeNVVeUrse"Zw 9e w{`vyt=";,FO}[\d+$m-m^e:7@-holldȆ&}SrH1!Sǻ鈠Tg$ ;_CӒOOEȽ H]AVn>Y\y&z;rhXR)WwCٞ+*5:l&-X%`vB^WaYFRs)z9衋?U/بTF HJ¥v .#] ஑@ !Qoj1cob8+zio5;8 2o{aEjSvޭ2ҶnƓ@Y7!qA k1rIP&8o>v|-WٲqUXb%^쀶hLs)z:Z@IDAT9: Ό"=Փ%yRyWPĻ{ga 9q S!`%*2k]![UbX&21fHs;[~ } ?Ob,̷5Y{2jqۈB۶V8']d^[!,Mk3]dF@pq+$"Y;)Nib@=HXt{@f5ZUhR#8 1j7{qDiKT*a GR6O}>Cζ ]!v#Jk^vOB[XRb˨/CsϢ*lPfJi@%O%:q=,gY'(UJpj4 H50dmu:'λ۸\Us^VC'Ss I= G0 A r 1?2DBs<0s04w.߮n*nնGqfuZTT,+R3[ߑ@pEHѷ1W#vxW00,L!sg!zUi7[U~d@v^ΝZޤ8ںhUḙ:ں:q+uUlccÇh4z%(UW+?D+472: \xor(d{-G w1<)פk;FObq[onytUCF81;=d)@R 0m!I!E v(g% P[It;sϳ߹]HwtE{g~ k5_ $ 順ΰTb͛7}]Cl[؞Jd2T(H T`\1SH@Wa R%L )%S"vSh4bN xpOaUbK_FvV <[,ƞP1tJg8"I @Dtvc}eWIg4+oU!ZpQSX@@;GvL0F:$%s9o `_ Ў@L} P`0Ƹi85_&r9LT%ҕ,|ll& %ޞp #5AQ($>d.M%o߷sp+fn5ROhϹZH1l)b0n+>ڮt * [.D9{,Rp ~_[Ԡ,,)`:u@IH ؇0"l)Hͪ4Z!@k> s?'8Ll&zox32 "[oao:n.a )Q6N/#k2OTrNcB\R)Zobwd9<ޚS*L=hQڶAnݺFϔtFz[8v{@Ϛ5WabW E|qâF?xPY(і uT(6 -F nukLɨbb@ls`V2zjwT~M; xvWlܹ(WJ"Sx&ha*uLe03 ܫm+O"{FjU"TG -h<ʧMlU)Z/mHq4G Sp; g t&y:5X3`n@z]6UaO)N>NmoKݪ_аߩZTć.aiׯB4bӋW R$̵+W{g|(Pai_OP%pvF%S,SKb zU[ >1}'\J_ .3UZLM$}*Dc?S;]`ga&Y]sD;2C>zؿM4oo۹10eC$M@ s>5$֖k2C$##A}U3P맀QV i{ϙ("7%]QzMߖeFLu8mN{+9:gL>, \鯮Jd(rVa;ᷖΤŹg;+M{ {q F,-.(i 8Y|Ymhufq"MD_T >`W$;v @i0Pf$yHÇ}_oG}> ȓ9쥃:yЕ-rSs5;.s@.}9CЅsgRuYos;ԩS⡇JCǩދQh!;`ssS٣0t+IgE@Sa9Q:[ s>c ſ G,4|8*]2n_#Z јO#A_xL;zTKR.f=o?RگjBG `>-x%탘1;q~|}ăD = ~IJ"`p[Q7@~ΙᇱVkSe aks$u3?H}`ҘMp)t;ٳW8%lfeD~7/8>>Kut"AŋE(yfݠbw#d$ qc/$nܸ[o'gLs^}Pc! mߨ{K_8s&hs>m) ïz6w|juyLuK]9e APd[~4,l+M S[~#y5Uxˇ* n}^VOW}3[uT^kқ{OB5[3d)GGA9;O£S< vT* z78;GQqZ)|W}3bu+5 ΜO9rT'/l~&}tS*AtN_}0=vE SmƿF1x5!W{3lgtCЍqn ,nViHOO[*G2 Ј睏7=K2 @fT:"fsƷ'|,~ӟ(EѰo@OD>엇5qo{gk !zɉ+ЇyD:1I3b4_ÝK,S@Xؔ)z➛Qg4ug`'^?2th%' `Dca t\:ıXKY㷲5T:'N_e{* tU!c˧ ]nf쓌;–r=ѩNMq;x]j>{> w.qM֌7Tf(oC31 ۘc)~72AٽNa3r^Q-|Fjr|+_G?ҁPt-oޓ6;.d ~u<' ie~n޼wI) ~V~{s<("0Wdfy1f]٫n߯%@4--TZbJ#Rn,+i`;@[2t߰4'@%%@e20Mxrψqؑiqx'ntjbG=6w3aA:N#,/@q1mĿW{6#, 0\p1(.*nȣi[ɠB 싾?!Tr'ByK\áXdl~u?q˷#L9~(7ω{N܂{D ORsxM;ttTnB|,.׮]?܄) o%h%7V}|R>v~XJ@וy{( 6?6v- Iҷ!%ٵkOĕ+@@"I{bB:o7~ KgL΀fO@Jtbmx~?$jG@oT%Ua(%(Wˈ"J5L$ >,ᮿ˿=.0 ?{+ pݭ K}ޅ.|b9-)lI&ű=8~x+W{{Z,AJN@Bz*%h14hPW czo.O k'=C5P+׿dh,; tީ\Wenv~z0DK@@GS0y{Q SO} ;j-=RYS%q;8y⸠ߠVO< kş(wDNHj biOgy^tX9%mȲPvE3oN/q]XbT5-X*w 9zL:O2T}o 8&SPa1˱;NMMn`'A#x|L=0Έ] ܫ}Nc΋˰x"y/Eh,w?7} FWVBau4 ra$_cAewn 6rkx_=OkϺ;3R^4D}2Q)a:QE1 @4J 4?pMR#^T~'@~yy?@ wV~~% c2Zm"O* >A;- O\?Ips'8(!犟οXT3!GsfB꘵9(~i^â)`>R4 jPlp'?  Ŗ!w"H)yI0 8jy/c=ҁrׯPύU0ZA#`5`fň|'i3wM9˧O™[7藫ȳ/p~4AO*veu{7m/qb77@ NSTr;2Xsssիmy/FRhFJ;x}, TCiGJ$\c^c)!>^{D{. j~}G+}#T;׽'Qv UO>}[yP#GC׉>w<(z)]n"K#w^вou^m+nO0Jf'a b 8H`p"n=!/J_И@PV4wŭ+1GG!U>{ߙ{qb4OjDC^d&(RbY8-ELiP36BE HTg᭎ձbL>NԴp˄F%%1*i@/r=vF@h5Z$6'~J{ZUiXw5yu-(xz!*EjO_մ)2UwE='IS/}<R#vaaGKh@)P;-yh}=7^Z8GჰXozFg}?}B>t RIWIΩS?)]Ҫ@K G*=*f&6Trc`*բ-qWu}V\_5F?3"B 3ҠST|zΞPT" ;=jR'ᨋ:X*[2,p˯8u +Kb8ra"mFh.AssTd˗OFg/߼(6Dv3b7-*Qz/=!Ikfv |\v-rH۷6*pJ0S B+NBa;3{&l|YGПkt=n~<'@z >Co[|k_z"9_꓈ZxL>loɻ_nÑ?'JO>-Hnh;T`;-O@* ie@GAk*Bfhў>B7 2 0;(( :m/_) ~`( xw`yW{Z+cG=Q>= 4C{ch*>8XfM+dicܾu;{-a%-n5fmFQ<^#J 2Z ǥ F_/EX$ ?ey8.8wXt(z`!U mE v{KqL[z *`[Q=GqQ Cq p!u*ѻJBN; @h~/~@oG44#o U$h;S_-M}5đlo=b4%Iđ-CQP?,D+&ؚQ^<>Ӎ($ +/ÝW'JXv/!m;si7Jg~O)=8CF`OP놌ױ)D zPkʁGtْ_{%ns~ 8/_qČ"a?it` xT&r 'F>,fl,F%t $ 8JC8HUg[ Z?FB/7ғp'LmQ,mm+>/-=p[$%T9s@֊g}9$FV,ݽ$֕Vȅ0 elMO EOyǪQ  EwreJ~Gψc jzQէĹ?;?KGY*+xzp!Px S@(*00@ StF{T]n:hruBG_߁PGC"}o,b:g9%NA49rL՗L(y;={G.`T(7+mÑ87zN7 2V8T=FXQX@#,1t']M j^CK#mw}G?1>}eqq^|_c(Apx*KBE /uy-)mT NxL >4x4N@-1@U,V~ݻW2z*'=SM7ca 8 0!3]ugsP bH~˨>!rrԼ j'@1ŏ>"}╟Ɓ=/^{SܸqkKfO;>!;sJ$zyXcU{m433-uYT-Bs <o~ >Q |nj{SF:'@ɓ@Ν3'g.dQTj/2ߙG"}0`==at"@=]:PdD{JO`)C0.fGr|=A8>9ND`Ğ?}點&n$cG֯Ç'wǴ/'.<<0d&.w ueFSYec7F#[Þ\NB$pFz#19{cp { H5Q`|QV]|橇u!-_V*C <!DP7 0>kt:H зdÒxw~ςIXhiq CQS%w(`=)NxەO>[yAx( c |{eW/[}~2U7 V6* qC ;eݡCKK}MRZTS-r f}\Tb` IN?TXWY͓ܓ]1, *K51* `B7B`zzZs DIo~{}hҺktωꝤlq<$xJ'ROe~}c/P|H5y_##ӑ( J L$P.JJ\~S t"|Tv=w8'~beey{ svN:0Obp@Еw4j wzV%-p=96(̶% ozaB2M'~tX;5>DJ= ]yx:NÁ };57#/>TiN7߇Exꉧłb .цҞ=J @Rr &;NK;ŲVꚒS/,hd6i%P?>m; TL2QlP1L'&.^ Gi8?B"xSچ.pq_T"0T ~*_jw^yM?oşa@2aDUlWxROE8yz E9>ے@5m (О=Żِ~argvQgX3~lIasdg~:^<.։@7PyiO[ wYKKK3IS޿͎7qL âa)lp=ԙ= W [BQ: =u]B=vqq=QEu@lLUU۫gϝ{@OF@Z+=o P_.7)cT`}F-F ̂ޠsRNT7ox|o}]\*>uZP-Pv(#ҟjJ&_n&V.E(6GuG/G<]1SNDH=X g=Ux G#І 6 /˭1NēO=-tF`+(Ĉoy[eP +|֭[*ͦ/'*wZf0`r-x-n4m+_8 epsS!yJ98s>|`6@}AΝJmY|`c}l? j@A[_X6[tpcϙ>E=xߓZ9rdziMy[+ZD}o-*~,K5a!zDIN*7[Zt7Dj5]Kb ~76#k_rEYJ/Q]E`E쳿'|)'3_|4q㷴?;aq &5E,CҎfve={NE+)w +:Zro(NHOC:Qa╟o|㛑m\BGo"bieJFW.]$TX~x(mR^й*oAvtF`Gn<&/8yԩC\Ƅ_9e&%-P_mKCcD$y|@̧+Г[ʣMV]27跿m⺣lM( ,/1MSS. E#Nߣ-r  DUhF J[oVyK~])W (X{|0)` $H)hȇ5m23i犟lxfq\44!7B 9MПTO5j@//C=CF@#0GA|cS;R^*Xf.Qa( n5B5m/S+g{;7? #T5L`ɵpha3e/Ŀ7c$3F`Tߋ/(D=Fе'O .TIXMBK-'H@u/islڇu9R[g.gH~rlP^7SEff՜gOi 0 13xWbx5oC?,~[GU_h4ہ(G?}Q!ʷ @B! % oF7/5{rn=Y iu0_X^ NSA>@ S' f6gT6f)AŸ-d)!KM&>rY"<y6܃sΉOm(S~ϋ?+:9B }0|aQt:S*51z>+Ci1_o(;,TuF) QvPPIUl[no[Y{Ӫo՗C" īW*cqn۷ߴ5#9pm讉e0[LuWþ=\&RnMJB<(B9(:X}Q#PBJTBq>,a30O/$.ef._grFfsSF4@|y! bHZ e 'YwkNC/iVSDT3Sdb"Tgr ۛg<$1jZG/ xF?o-j*.}qI x9F@#-0U6BrQƦTu8U2 z)S_HՍBne(M p|`;)\Nn%l8)']*UJfTJJD9QLVfr0R&6D2b8xgmYG. mLh4A^XK::CLdTd0 '+$"ZL/$HNJٍl ]B@.@/Uܖ0#r2|" db&AU3mz)Nbk+%IDAT KQrܹ)^&pԢ@eH-3-nƝ0~^/67770'KU3 OQJ\MLC˭fVF90tKN܇_@Wcy> dO~%%[h4;o~[3r~!zSn DwkkknV/ȱj Y1&ͤ+6ME1/*Qnt$AgxmJ ?}Q+թ))ojR*D*\LdT3$A.x3pAm{oE~5NA䉓-#w믙i5k"PNRMldV2IhN7gA[~K*Xsrd￁}2De`AkT#XJH_5D53 ˮ&F}Leq8Aú; :ySnW# .3ԭJmLMSO-MLL_X5fB=f^1蝝(mҿP#BPd?=?Ο~Y~ 9'Op\i&bPV zqJ&a|?D wh/Mbf~󯙹W$Vݔ?S"7;lO 7a6  V][X7n\ҀK.m Uh4wYd9y8}N~bZKm'#X&E2+j`( 0Ҧ*@~&=oϤkVd# =A\$lI-A8b }O:LI#aV&5yƬ'HD`iVq]a'7Fn"Nk<5F`4(Y>J DǓ͉{]mw,a&h'#!PR- aH eۨԤal6ͲNص*\mȷmCtcKפB`,) J?CR&OG'?ӰrXWj4_mIi P[v?)G #E?24̪]?V'3 XcMilC\|p'th4e r;Y5aJs[6nC@mkЄkl (#: 9 ?(jb,Hbְ2 nߦ$@$lhȚr@¶O$b)C@)NF@#pw %V8s J+\⯒8R2` v,tagƤ=i5N;͡ݻ<|=5'cBbK l#WKB 0`O) ӆ@Bص` @uQF@#0B8^i# H9@Ə+1 o+{.nt@n g0 9 *Lc.& ]AǑ6@ňHh *:F05apI(B =ïIh g&% ?L~tF@#fbΊvGwAu:3MBm]Jy96 XZ4ON֍H=Jnz=7)f胘$5F N9SNMy{o侌,Cg͓me_%OΨ q?q6."bv[0,& +%X][ ,^` Cs_F@#@2AJR[>^%r^iDrD& -F tO꽁Q*΅wn\8_ <ܴe @4d$ Eb+qU-7)+=O 1j\S@ߋCp>rKWz>F{tKwQCRrQLp+ac0uF@#00T}`{ЈB!@{4:P0:8嶔{ W,Pe;yBwcƘN ;˩pJy[tuF`!PURJ#\)g΂ؓCf'@H'@ތN&rSI7 8_\6r^MqAO9iHHuF`DX]SN/l<%@q4tڰJ{~7d`CHc.lܑ s{17Kp+ (qEr[rAe|8]7n{YW#,zOuR4BuN2[Tcxxp$\,8U{7@UzR/.IhF:h7)8j+^ŹMmv"Wo-c*IkAo^%@òӑ-D{ #{jS7 =.!DĭeiH ӇDtFO%[؀wfữBXeOwŭbL_mr @/S GK8R6b3 5^00h4-+y?VwX368ɨJoڙ5F`@HhZ2mi3c8g-}tgF`D bNDZo `2 fv꣩ǥhT{U p8#lcIԣB zݚF@#lr(EX3*8w}j4 }<ƙ6.n%{ܶڥ4-dTֻ|Րƅt\''pe c^2Wke} WFͮ_Zy ۸)AH_4tLfhnH@+u@ k^މ(urG z }FtF@#h|\zYJ|;K0nsQXw+vsgslit{84 {PaGKo:4'6+rA N H0 \` u`޺|qU6mH%X\zU[̻\t dF@#lr/vcQִA.c!xm޲>[W77HHǚ8V6=Qrf(KGmm wAcM/`92Քb&Q)5⒄B}pP"rRP ˫o&nv*/u[6؉X`R$e7 Q<Kd$sC-tF@#X}}pFJaJMpԂdKn?\J)-yQQXNdg=>3!*u0 >K/J3pT*T"bɜ,}h8D1XqV,J#4:1HrmKX59T@*CNްAeuF@#=k˕ܦdݲˠYXUNX XRΨYfŒ ]pmNMI;|-Hh ֡k A dplkU7 fڒT@ *"uU9GP m ޾F@#/mݹrc_oS#JAH:ht_ҵZ5c1_ҽdϖ .}5 /H˗/KBX%&Mѭ!`pnej N$H ^` ە)g6yt/F`.~]Nd7tմY4 4ʘtop%+HzE:S u;L"`rUNXPPXjUT03W?fRL@e/.Ý;@vx":i4F@ugm s5]Ii$L@&};DzpŭH*w:pWSi"ztzh*i }GLDS{'SH:2[w^|m-;qh4U`?t_on`6?R޿e :Fz]oMށQo9GIzo= *!929˒('S<& l *@+amW o츓hI77jXs(n?Ah4cCnl.[.#)b9tJ^K㘄?Aնdt;.|SL΢t\nK/lfZ.aNf2&tP  0u h2TM߽)r.#_*ϠeH忺_T.Nkːi4EV*W/|IcHIw(o%\[uЮtʖ m\a\'ݻK:Hzտ;[ts 90.R*&s) R#5;[/Ջ8TX'zɄ@R@͌)}`~ m4M`_;2NB +v;?3ғMh4c@RwwHqtN ]Ej]XinXsZL);UOX5ldJe T&d{eq{w97T:ϛ LB.(mn&YHV#ŲHj2gfZ5ɽ 0P2 pJX[Tp{ƱQ373sp~n;tPvf0V4F` -=P]Jz}k}HČR WA_N@ ɵLՠPJ۵<$ igj-=1Q(-n^Ś+qhp+e q⢉H|f4mSɥt:Q(jHV̤iVU3edZMrE2u f~ HBcUP wvbxLH,-=0={2H3Q6I԰uF@# P)J6 +m]S "- DfZqipʆ))+@%ZkL=Wfk[p_+M׳ kiiqq7H2C gK\+$DTJN&UD.+U@ JR`( !#y+DXkNV׿D"7?;}:Lv]F@#02V``^-W7!D!T<$-CLy?֡ȟjF]5ʟܵBHa՟_?A3z&a5Wgav24 l Oٟ;8f. X__OLw+,%/92O&z5Vt0L[1`3@5<Bs΁W#h4 =1z0'*mP~S \ס_Aevo` ~5533S_Y C6,Hغkl>Gԡ}aeҊXB̄jRL)RP<\BLtL}I : 200nN#hF Е昨76}4>@@@ndҝ/4 5T-I?717зJR(m#5ٗ'6g'Ce,ZprU8yARYi)QMσf,N9QO -uWUK&C20lc/}4m0 #F@#~! L~J I$&cdٷqfc_vAVɺw!ԔEsx?'^{D;) 8xA}ry,jL2J%~*QD@^ j+.~l}Z`.Ic0)նKj4FED9vb22-ЇKs?CO$$IC_.0׫Z=N׹'/BZSSK6t6&4 tVLXK``8NLB99A ( w)`gNF@#BaTbQ#@.X6 'A3}ğ~bI]?z @OϖH8`&.'eTJ&,{Y0D=pL8R2 ^!L;e``T씑Ĺ .'c10iFAh4-\/sĿs Ɉge?~$Ic\KUP)]TtrWt.?;2@vH;Wh2F@#ܽ{gOBsWl!|\D4e>rq?1 48;s~1p<@2Xs; F\lcrE)=J(Z0%Cɋ8 8hN>\_i϶[Fj4݅@A> Ζg^$ <>KĪgrrRQߡG*2yJDIENDB`ic05 ARGBʄΊ ێoeȄH|<vP$`Ӏ!YH  Ǚ QS #'#وxԈ􈺀קƂ픇ǀѓ򈟀x̰͈]UUUj}yw~^VYY}J4101:T{U UiecKfV` TcُabU U`ppfwT TՖzHKnU R[叠#|{*݁yU Ssّ!^ \۫~\U R+?>= <$‹mR S.XW*܋T Tč9a 2"푟UM\Ĉa (HJA W[Pl"-32 aɞdQQ˃x  <|QSMΞϟTTTU)UU}žPuUTnoqrstvwxz{|~}umh{sT?`c> >MUM> ݊ dnph[pnb .[ZWQGWZZ.897BJE^a= ьN9Kڀ4tpcbX`!{p. nccEgE{ygEnHGpʶ@x⣑+c~JL_?;@ Dq,F&@x[߸RzߑB ~b LJ]znK?u)&AŬkJ D@^F(]VnϓjYK.iR > E PZ  K yHXKk+euK~Be^ Д@ La;FGǏH?N 2Svrϔ\{kY@<C-G@e ~7r[g,+>sћVk2#W@q떒! YcGEKE wJQS4  ' 7pQrs #- _k_(= @Q(5I9@ ycrk4Vʍ?tQz$ @WU~V `%7ő@V3' ;'N]sW^  9@*",74>'_+QyAR$ @t#XhWv,#  D7 @ D@ tTKߐ%7_wW5k*! _Y [o>$IK"n7pD Q {,gbRZmͻFN x d@ o@n rY yulG{DŽ@t648e)\\N}j*2ONLQW’'}#Ю/W@+:k~߭ h[H{#/wBr^c% P8R {hsC?UOY^d=ν2(0SР۸a[fUǍ~ JeI Ecc ˷,Y= 엿$x}YN%(8 &Ȼr}I!$ϖ9#eLJ rj'-io*֭[ݺu22Rz{{ǀr2 c6]v:Ou|&lt◿tGm}Ӯ߾lQB hGz!;;/{ ]j @R)R~i?ƾ<{WS"f~xNor3Ow}}}mCkϕd.PSSnrrb=G/y{`%X{?XGB/2' ܱ A2"`(!&I!`%(Z!{ICf1τ@P*?6Ϝoc׀A@`[֢z ŠtSO.h'1SNK1Jo-/[yЃ@.֓L! }ucuD.%<|E* x\fB @%EesEHr<&~zYg)g6t"qK/nBp;x@P7\eˉ|= މOYr1 |[We}Q{JL %X{[W%I2!@No|e?m HIz{{A큡mԗΓ8Sw;['Zz`=or 9%+X/!`BjFz=Ivxϐ3DDcB*?06orry.- eC2@tk7@@|fw=U7W`RF=r,t].F1) z!W);gJq.e.L І@Au4Ԥgr]RVzW`m 'V}^ l_ˇf.< w'şp4cH ~[%}\١==#@յ˿-bk[~ʯa-(Xl]_@sZKM:xȪwࡥm}_ F zSuo;KCıUfB D!:e6&i"uf'A-KW}I\"]mPK:< Kf\.л,5 K@5~ ߿Wz\ǭںX `SZcQTϹNIuY#*g0$ .ƿ9y jFze)ZȡcO>a{ˏʙaY2] U~VGHcX@l7 $3_`'ҳtcc2xmʶ%Ƚ++--B О{HWW%ćւG[ K#PXZ f- .s$;+$mF&= x^)*K`afTyoVz=K8QWJvh26}r=r{-c&RD8Sē4(// {r&YR/s2ڿFade*ccc o9Y  Y q^-=5oo[`k֢Wi?EoSBQHaXwS>\.wU$gd`h d2藟w#Crd<|zs%!;'/Zs5H5Sk@}) ul$ ]u`[|%oX`/xzWc侠`N_.λ: E2+\/w81!p랒 #Ic 2_&/`@ 9_wЙZ`rz]cz?IK%V -co p8 )<[w¹k4X6znFT\k%o zX2ն,okb7YJWJp2X52>gGoI?re'9@{x&3L遬{eQX^מȄ@;ߕwkKrF\qel! ,w05 䩙()62!Ў4 P?׿e4Y.kdj.p(.W8m( P,ŪOJ([ddYYF޼^e[jq34w1ր׆>=ޫo<#`">'9뿓\o4P^[c:XlK@ =.7HWnz|]LL:tM8&t8od2ϒ * tW.YI곻EWUVnO<1In &m]ZϡAI:hrL`|ݤgr{8SyUqUV8 3 ʭTaZ,Y^6(PDV~ڛGr ۽^wWrSw䗿Lu)?dOs+x_*& / ?ϕBG].q$gVuV⤓z;6浛~i䷣Ȳ^4oM6HLS3go7}I1==\#u_V$Ӽ@$z0`u ]JmpӦd)^ SS]ww=$gX9a8vC"@ +fE@tK^&/v"9õS܆v],F^_:&g}A^kgI󀩱Hzeh7}uu:=ot_kPjUģﻉ}=pI`.=>H~*Ak$]E$MwEvnc@'}oqMN6 ͈n*NUJ3ͥR+d[Mb9?91)]2ؠ_cjU LgH~,qÔRzfZ%#qεJ ZyqNJ~|2 <|s^ș(U&E9& ?'ˏ䕇{5g_whkй3Yvl_;??R>&X@/ Xk,v>jzz$)Fjs#NR n+@ks߇i^g՗H3 2ӖE2. FÄ@1)E;!yĺS{:r)nSVv:k{mk_MZ%p65 xK|Vhw<%e/t [yf;$Ѐ@}/]@,5$H'O8vn$O},#%&r*@ G 7+{32@sMVԨ~j~h~ .`F+f}K>~ (l6 I\gC B 3@iWޞ(q(@#.I].if zk/ؕO&׆@Pl{wIї/3| &):S(HwWUt u[G ܏?!7y, |U?JemC$T9#tKZk]󐇸33U'8l~lHLO&ˏ6.@*RovAmz`{YЯ1Ig @aG}oZ ԫM2vi#^t#NQg41 B]GCdom$h >oK3u@f vgtLx"EMHo;1W7M&1< K~vEQ\oR?KoJwΝnm80n7N mT/:3Oo\`T$@:.[tG?= '!?U^*3 *"kտf\0=n\GNg@=uz핦Kw)atd;Twяv-k䶀?˶ C9ly^MΒ ՙhYCWjmHZ']@%=nr.cA/:,X%s%G]I/%_$eeүRɝ'ws]nXxrGJ哏F%yvMG?IOrCrݿ!ZxlM}WٽU$j{Ħf,"P=4=,kiCҺqIoՙ"o|U&.L'A iݶ(oz>{ӆH|i-Oәr]>"`"@DRxCϓn ı[v"b (vt2 t$'ʵ:Z0]'tf)ƶ!SIKx/$Z@ z<>zSVI@o-u1+?j @$RXm=8*siHGx^韒*@dNX܇wX Lʀ{u^ ԱJ+Vmr[eʰh]@V,E9Gں ,_&X7.CU#4 XB_q,`t4}Mz72~W="HD #hRܧW=Gɠ74}o%_T;E▋K[,}'?/Ezf?pH@OM'8$7_Nڨ(]ƿmI @ !p"o8t(92g!+mܘ|G}!= h_e~ ;la/og?O`Bu&'9 ~+ v8MCG,u)O3Fj %Irs5/ 42M@h5Nyg/O(sںK"PCʚ_;rn!hXTi [iYyL??i@cK@CʜO7rm h>1@NW2]|ucm5x@1`%% o2,\e| 5+`;al j]cER(S+ZQb UKhA2[X',_ ?kO">4a],Wr\-Z_S&1@7 Ppuz_V|k>&zP%͐&@ bTFy}Tgȁuk:KYaeruŞN"zdCQ&)GɱDn;7|@('XQjrdA1A,,yn2SB2@8HV8-0 9qG 2X)d)=@1,=ҩ?ւ@g> >쬔|z)?ug/.[,@qc+^_%+b#X= HQeez)fUy2S^sѠ0(]A>WrU]dR 9@ϖDZ @:#HiL(@ Zs>DT!eIDӒErd.JΛdE N7}fŋ| tx32ƷLS+CXL ZrG~@S(W0[\G/.XܜwhW@u~&˔S~I1&F~٬ӳ-1!O?[Rθ@r(d Y ? $@rle}@K J2x dY@ַj@kQ@S>&tial !ul N:􋞥dI@ >,)BP@{NzRǠa봌|#Y Z!O |5ș^LG9_7{OjF*@ edmG}}̿LKOW''_ǬK XdN#=b=e'0H3Six |S}HSw?ɿO3i^yYٺ!/. D2S܍gd$Ʌ3b1S߹#Y Z!O }G[OzF;寓zt>I7oj~7-|{9X @Cz4dEJR/Ն{neuOtP ̻^z~ lՐzDnymc.}%G>Yj{͚$0-mi@xhZwcۖ {9Rr+W&mbd>Q>#EYLkƳ:\kY䯖FiϬ%sU51@ 9  , uE. hrd+3r|'~52.^Zrd幎\A`gȎ)!9)lj,% \v/@~_XE7Oltgӧ=Ot,@cBHK@N|SwLK`D;_we%j^?%;sƪ_讀9CQd3ZaaiO}2yILV@z/F2?3gy)AvK2/q+2&@ cBv <E_]S/;ڽg۰ql3l x $)2)h6det1S>-:= i|"B⇤~t9c)U б I MiC-en1SrMtwcں$  f{h5x@ %9wtOQ9^jH@G||8m-@P랔brt[!+ŕ6ET'Tet"k ЄWHQ`\uD "c)}>,I#=gEoוj5bv; *|zxU'q@:NK7;+"@`z_䟖J)LSX@D #)ɹK1 \3/A1vBV@`MeB OͲk~~E9Ә~iu `!@B4RǕwFUmWYדzg'7e\ L oe^ \uz3Es*'hguұJArbάȇ|'peE1i_<-?2z Vot@f hDOb,LIn +A ,=3k νk^6 f|fq(z}^)5[$ ד XtYu &H`.(V Q@.s:3!XK?_K\L=KǛ $%ni*Ȕ"<0zdu =pM@hz\MF=v6-#)N9k)U!`&@% 7]}:Β_V4F X?.HIirB `@`B`O[h>P GXX}@kun6ŕJr?$'wPKs K(zt]2pnZhsU徾d^=T" mxkY'l^FA,V=")@c%W9I~28-bIZ]xE;);%M-#Ag3jմ p|y  lJͦ|V2`mcmk_r*g+[͗ /7y #M^ǻ%$ٙ|QzX=tC@2@`Xr$h26ɄfzWg:=|)ƖqгL P@j {-Yz?uy\r\u1 &r-@ G hQd"wrY\HE݋=7jQ@Xsكv׹X^]lKGi+SQ&)ǜq|߭QD|R^7R|F+ 0*^P%k78_N u& La xkHseR '.q#"3zro,=~t.7/8wo " lՐN^wES`-}y'iud$}\WOsk /N^PȘ=2V!dǧ@zPlf[e2--ȉTl_@~rG>}z";Q;2;Vkj@@Ƅ@|$-NϼHЇ6PG@Om@IDAT"!zv_w5J;~yc^_\ !w Hj&|S&ɝi^vp()0kW~5Q hP y'iYW6Ϗ X5fZ1wf8(Kv5'eX^ (ZR2.WgKVi AÅ3m곭;k ^T z Mp^b&Tv 'Ug\\fK.#Վ LV@a;kϺ9Po#vcxM >?M_gBxezD>=ՙ{-ܐB}S!_k7S"p=OB B-S:.O?,Vt%A}k@o]6>WBMG(6hU!^k>[ۖj벋=oe^KG7%&%MNB@L! \ǿמ!!?6>&z& n̿y`#@; ,. ~ߒc࿧rV@8 `/$憵#@h<;e 8|/Ս!@7X3==VnG㿛&@ѳG$/䲀[dJ9LJ h;rK-$ , {Mνq<^R6RBt+=^-_3зʬ#2! S4o&y7g%k[zzZGR+d|Ko7sm -E@s>㙏:OD /ƻ Oz3!@M@MG HoKJ 4yc \!-Ɵ  O>6*\dO٫r1 T/ Hɒ2"A@c3&m8@RBӷ:yd@ r(MgYΊPbl\HW@PO2 5U6+@ uGλ$p}We՟(z.[%V "r9U(X $ d=)u+;OvH;dL^[$F Wm54-2V|Sg@(J'.vQ)~%@f{|EI0׹gʎ29|&2! !p@>.ps_=j,4R5:xE?_G:^) x{2 @@`%ib"D"tS+|HEQΝ-xd%> Qr>XQI{1b&@E? ߖUfvJXy)_fvıVu2dwxDGdfB@,p|I8?KIτi HK 0O@t;`sOex<&3B@OXO4 29Ki__UzG~? B@!B]`wuU(:<3|S{3! ގz/,/_e4d Y:/K>uߚ[ht{xD%;Շo[>  N9\|kԹ\GSB6 tKEq|P֭tN&|.#;sd'Hy~k@\^9k!'"{$ܚOM0!@䩶+fo9 yY]QFTʟ*;SMy@򝐁 /ig$]R#A׿K/Ɲuq|8&k І6X$K^kw8.r)z鲓?YdGEРIu+ P8ml5YgcYgjc^Ѽ)OQdK#99#s(B\( \@sAr)s:Yo`aYy}e^=8,L  G@/>9yMkxϿW5q,&@9l @`v:5`2`pNgB`)I[^'> _No7f  EH*z!)    }    A)$   @@@: LrĆ|YKG@(@ B@ ĆͿ$`o Z_uPg1 @'\ЉE@@@ 'rRQd@@@Ntg@@@ȉc䤢& ߚez%-NjG U@Qkr!,"`\$AoN@Lr @W4K@-˦   G@~ꊜ"   l˦   G1SW@P 6OӲLϰI T%A.j@. Xg^X2ZA< D]KB(?   @f    зʏ   cQ@`ouzGȾ9D"`8F@2%@LUA@@@?   @ SAf@dUcmH֔@(nR2@@@Q@@@ p @q떒!,&`wK&Lo|PgnMVV=G@@I!6eEusI"ǘ.l^ a      )6   @XªoJ   cZZzDy􂮜&Ǹ /# к֭X@P:i`ߘ5 ]@k!"`NH%ìQgl  0@h5Ny@@@ dSh@@@V@@@ H)4 \myxY&z4(bu^B@Lł EЦes:8[Ç6`YVB@%]@@@2$@ CAV@@@%@,"   !    /%K 0xoRg@&@ k5B~@Rl{hb0dI^@p랒#  $@ ʦ    )9   @@PeST@Yr8>`3 Z@!4liZ5sȯcrSv@+.$@@@2,@ ÕC@@@ `%I:   dX12\9d @U\mYguzr3@(@ B@ XhS_u9NϿk@@#a@fڑ%n >|}W@L1:   B@.L"   Йtǧ@LNr_iU u@:@'z|@@@ 'E6@lb˓izE-Djq!B tQ]g   %@ -iփ   @tU#   c%z@2&`<w\Z_uٵN3# С8 @O1o@/@uL @ Xu:_o:HӾ䤈 cQ    ݺ!g    p % !Kuz[ؿ3k@(=\ @@@Yz) )LNMd,Uh3]uzK@=]@@@*@@@tX? @,'tև6J=PN@<  ϵG@@@hQ-B |nP_qGFȇ|DlaЄW@hSKcq@@@(@<yFPtuzǸJ@R@ܬ @@@;kE@@@ U.H!dA3uzY0Z|kL $@ ڦ \|}(RS\\Jц(v=iVE;Z'F<ǮXž²uj_hYg5o~`%ۧ=ȹ@\å{cux^}O2g  dU(Uz|VOe5 S \tE''KES@xjN*_EJLJ6P$kʂ@.'ΗH|#{K 8&@c<ˑKSh*  x玏k3+2gI^@bUIy}꤈  @oع".% .(]*}2rksU,dN6UƬӳWalxH) ! l ;/qS\\n8"eCasK)A)&@@2' L7<9Yܑ!A?=yc}k!eN6ţYC:S`hAm, ;2,gWOa" tIR;?Kgx ;xZAN  !2ܧv^Um|EȼWQ3z˷K SR# tY ;vn{$S0.&$b)pWʙKg[kz{{]"T:~ey4X*.wlYlݟ$=r/0882>N9yN!!+Z R$>Nj6_)755%D^Jn#ӹȼO>uI{Yy3u\rn@~ii_z2t91[z<1r븵s6mjH,@J]kـ4,ԴpnttMLL,n-;v^Իk׮]n"Щ@N$ YlBҐ_b Xd/ _` XXFFV[m2[Mj=z*9i 00׾,o|O'ne5(g50%g[ hO17669ꦥwAj酷v[%y#K,)@%X -,rl=CnUnhhh"׳}qм @ԬOtvnPznrbMNM&F)e+yI## IˣRIOz~{&#ly ]A[%.O1Gڰ_r[j_xIwHr @45Xi ]M;R/ٿqk(I%qzBKJyoz= UwwNc]aӺC+"Y=St׭o׳OXEZmee^ r[4[w張}^&380zyvMz"fdH2=? 7[<_$c~ i `!@B4:^9#;Jd듈:aAG5@6Z;j;ﴰ&Azj2xwԭZmkzrʤ' 8hwaQwϾdg ;я~}~@ к7%6Girqe1YtժUnu CLAV{UПVHKL 0Y?FE/iQn^1ذ68l\O/v[a#`޽ȥ3ő9Oq=$3JjW@r o79=QnϤןOڵ,ş\AVL{Ӗ(rŶV6붤uz"|{β)aр* YRT^90/g5422Sz%p%\xG>H}6qY`,n(PY S\4ADfI'-h`3-o*ZƳ,V2@ A3k/SEre4 Ŧr6l y[dٮg.Ԏk=7d ^;.q즟Vi˿N~ҳͬJgx^/ZƓqƹ$XnnRAh2Y Ɩ@&2)e z\Suzf]ݨ10gf{;+mW@شRNlٲEnOOƝ;/:X͜B R. yfB&Q:ғ,Kӳ {Oq8/E' 肶oQylMGRiQ */ִ(gW }^w:t wS@%j X;o4ꙏ"ТdGv/{܉[Ntw?-ә#zf:c-.@֭XC=yq\d.uX=_.Iv@ [z2 @26*$@O44X^">^ƎKv\#aDٶ'Gޏ^X [؏OOi3P@Y8r_<.|{v-E%S$`<׭[֬uQ].<OȀ@PY2G#p@_sa9~iɑ%Px`15ЦTk[G: 0^h+۾#Ӛ=GGG¬]Q9˘uT;_]d& !С=: ~4Ohmȯ6}`W4k$g!cCmM6lpCr(S6NU&^}=$L }]_*En.\:v-TZq< j:ؙp?RL!~׳<[g,kCIq*5˟McÑСMoy&{'~wCY ?꬀|Zĥ@֮]';!miRqSSn2xcr@6;k9Mk%㱱1 #vuCJ 豗}}Gζm_,Vr0դ l>w$k {4(]h$)!о@GW'Bꓤ[|[onwr欍~=;xDѱQ799I@  5Ũ'8XСCnL=ٸTc,=6mNn}Ql99vA<i 4vSx׳k+7AO8FF?6>0œJ^X   G&=.Lom/60@[~Rv _54&0l# mf`+V~z6/$=11.g9?Ⱥ@U>ӳWHIY uF#g9ltژ.Yz1<9I7yE- }FK2ɩpk85<4\nfOLN%܎4@@#8HnSu+a&3We%錄x \4M%V<~GJe%#itV "sX"ÇrIj'y\%EKic5/9y7::Fꚉ#'Mzי7z/Z&HZ*@VXm_4Vȭ[И'H:٘@z9W/-/)qEi`f]zYB@% 4|8rn ЖXvou5W?حrUX>r$m|,  U=;*dS6֭/ycdτ#@ NrmBM^o͚v̪9/ѳL  ɡm W\z) |oQN+@]1oMglokV;nzYMM@_wLL P@ciY͚"!G,{U;R&!044-~W>*1A(LhO`֭oߨ;55`t?f3&@;6v֝]S,0==Г0'kCriu^Eei?L4 ;.UuO6ueJnk4 ,)(Mȧ|9$_]!`xtȈKpW\qz= Q@k-y=Vl#G6F@@GGG(abzڵk`xӁ{i Xby=V-OU*դ?g   x@ȔS.˥ VUWZubUZRvڥەi`xha3$םX-7vUWu$RFCi$K4h&M?0h"a#6lx<=C )&YUoUmw9/oF̈8q3KY~73ĉsRL&8?|2#0#0E 0 LF\*0TtY,_fЊsF6C(tXYY&Ix<~d:2"ow2>3#{iNړ&kB$c}cf ^k,_ehhҒcjFhW&2(WfsKF.#\`dtfp130+1_aJp677*:uMw{a qI.>Zz];3AI#/,L`F`F h5$?w\7 /P ^LΗRgT1J"0ߥt bHU( ,F`F`j M`Jl2TuQ0Ώjx 5LܲěW~WؒEZ0,B 10&-a A9[,v| oݽ+0F*?2U鬂à&qF]4M?q<"N" ^'X][3jgZǡ7@/v-A]&5o6h1 !ں]oP.^]_$'77=q{ܮ#R> V~sa3$2Z@ECXEݞ8i@jg@OIA(E5[Mv&~}׽}2-qrr"_:/}Kk˃T*D YmBNVFL[8V-Ntq`zY../`8zp YAJzd-!s-$ &/(mVz,Kxntn8SPW1驋FWAL!&`Z>c{*llѽ'\V^^^ku>0#a^YJ5S^䂋8n6`ȁi!'|yb&_Zl H Wa߂F]-A x {\<{\ ֓`FX"p򿾾&66B`<\q)f! sa$4pa!fAg14Ɲ{&%[,-JR,zAgOO>8=9w80#W/Ⓩ?:AdHWa{\67S_tD_J֢ ғ%җܡU+j/v`"gϞ30#0#+g~k{4H\\\n3&.Na #nXD h2-xP`\քg .dnoޭdJ=,k_@g7r1\fp10nc$}7+tV:R#v @%x=\'T&jӦ # 0 16A=K6}`F` Y'1&َ:X ;4=#ҐyaX/8=ja6a5G5Ç -bU:Y F`F`AÃ#V[; :}0kUDžD=e1l t}A l-r/.D%T9Xm57*^^P޾d޿ n 7L18L N`:#0#Pp˰'#Ȅ}NħM{mO8~^qP6Dhݡ@وZ<<OlCXjGՕycn0#0!}5c\]xfmSa0@t҆.ʤ>a ``?d+fãs-710#0<jc)?LZ-kDN51@E[` "P;A 1U`A.#0#CXr}},Akg7buR@qL6TpCDޘiBjz1p4Ѫ&h~ {j_<,#0#P лG%T`'KD\ZQnc1 C6ݱ%/S4!`.SX1#0#0Y"Z\;'M0^rR-3 @"{ N؃-HUMa\F`F( 8:<8 c7[ڷ~ewcq`e ߰"?0/} Wrvz&9eeF` #HK:__ٶкM**ULH6TJ,k [?XxJ$oB*ˋKӊHb2#0"pr|R1Kƣqp+LA-?.z'Xw(^S%E =0U),e)葱#vwwvb.`)vYt) %NWmO*//xJ20C/ >x8;{Rɩ$x-m7P,,ŕDWߨɘ3s{`!SƵVjET5D+b>sbŝw/$66apC0>77Ʌe YPk7!Vцbj8`xwAĹ X:'@F?i>xI`ȡR$LLI2!;zL/`8(rBm"ٙ@"ζ_q{ 6nE<F`Jv:Z"^y/BgG {CSiuPHꫢq@ݞ$nX[[; I\>X3&l %&"# >΍l%Aiy1(`$HVq=ﲻ+~x<<<̏P"0+rF`*VwKzӏ`;Ht:¶x_s'ަF:lIKf=&} ;]frlX'}Ro]Rg-wtx_-#kݟyGy9}s&\&F`Y6/ωgO~xVV#*-\I^/J+tѧ6XvdP`xk_{uhSv\HE #&F*S^22\y7>m2t2?,^Z90#Doݻ{G#o5=]pɉx bpqEn` p& kIc}?@:dؤ)mx`ueUllñ66qUUfێ mA 6q[qkf}$f7fKpTIa6ʸ:bE/a + E TTz{!yw?-^{Ua{'s7{]' Ű2 4Poׇmh!@L&TECOPVB1@IDAT>'KpσK+q|>UbKnT S…(8/~N3̳x1HG .@Mkl V~rAh`I#<7yJ|vr[5w=+_b \ؐ[Pn ps݃~Ž/3$A7` #P7~??OwW,G/&+,K!܏i+ Zpj]ubb:wm_KYl˨[pzZ6:@ Co0UDఄGUđe/ggOː1ꐲ[8L1@9G뀭g+o}[n" :I6/!A?M@a){CW+Z5+8Cdpa W`ݝ8>=-ϻܕ1DzmpPZweKIծMckE)csk6_{{{""eJI%dY0+FJU7?=1ϟ=ѥ nA%m6.)M6,imoMi6c PC_ϋ'O{?GLA 3#&;C]QZb GH|HNN-$]JƆ r Ƶ `D JB>+O 'Ad?K1Naz~yyt]Ͼ'ME@)8-*1{dwF?/|uK .Ƈ Xǯ |i1`bܴ((c4b(з~\4~w~K<#ðR@:_bʏ)o('Vg;wtSI?28ҏ`&25u`;Dƈo,nhDQE6xZ{[7y?ɃDʀayFZ6jJ^Pї/i8(|l6Чm4`ZNL蠰P\ 2=Mߦ`z#JAPWoHkls3ӝ/ǐ:4_̍FSck(8?oWv}1PqH Ka cZ܎yIiLނ%&Z.*ϟ_P=f~s4|.@8=~z/.^0@\5}PK-hS!fhkZ1s*9:ј N`]a/x$hXN@gx40"p(yDW˽L p eE[_\~ONӄXQ޸<21 }o;yJO).lXަu29YЍ#&-"ʅP?0I\]^q)3@F;ꝸ_]]yp/?KN HU&J>\46,O_k ߲ EýSŅU7\n_uoh6E+8qBCW/ogdೋAL2O*n U1ζ!6 jXC @'Boy@ .!-6,aS H߲IQ~e@`KXwQ>a潥.eE#]_.&eL+˥8k\C#41_s`(v 1 `#/,qd i XK\8%dIxgaN_c27gH8苀vpF"$akY#PJ`4%=Xvvwe ŽAeD P Ʋ Q{f'Cɵ/}Kk+kmU\1,CX{XN@x220J3dN]m. {;NT:DCHHd@ Fݟ^a @\!.@ 2c2:]1orS@)?~dնOk$`mlHi!`?bÕIK{"ږbN]^z%/`~X0!d]aZDX 04ޮ=A%*Ap]x cȂA]>.h3@ Z~{D@.E ?TtdEnwtWcc"HGn #P:US[^p!L*/ lgN;N}mEtƀ0BKix/JX' ȄsXGQ6dQʜpp3F@}υX4좩,> K]^paA E-ӄCГڻA yP[6n)NK\]aP|P& \Vq㾭=xd(ǻp8:3sz=q3ҥ\L0E p4\@t]h6'̄_p&1I ٽ@Go@|L Lj`u6Zd"Mcf>] q؂qIte* e9xT:1F,777bmma*899%YFE(40 |p*̛}+yE3Y0o6 \EFEVu[rV٣ cO?~la&ނۇh\P l(jsԬ̦lyRo!{Vl;Va@oW*ϲ3{ F0ScIm"FA)Bu@,I\Ω1]&rlXަҵf0c / EhN:;b f,2Ƚ˺.@q`]Ek?f.Ԙ.=6,SozזTri˲wx8.FMpw0GY=m؇@\#D-X@Q(8o6T-><=$| ۖш6*m^y,[]k\^^Oϝa@Qgto@z>9(@?  cr'sM**a%z?0>؋Z-€O9!"@KXm䥠UǑ?xmw &Փ_46~׋듉R~&퉊h1 UG}``O$FammUF->eG9Bt$+/eᛌ@8i~1E T%6|UUŵiQ ,\ b#POl'+-uׯj=ASA۔ ]qƣ EF[MMj$&5jFlH/#F-ft/6dqs3{l{YCPsp*9#\ 났ЏL ǑMݢP6L-&@.V(hI rd ;v) OfgϞ #P0K{\@{,(ؤL_UoYD, 8@ ￿ X} 8yo0Ixw팲.Rɰ@lxv/b,HxZj{%6W]mz7,|HB ID+7 /z1D [ Q&m7!BǕe"pyy%%/~ܮ͍ rac0ZRx&]VWGzk.yG2#wQy^nh‡u?J4(nز@y~, =IK:xL8vBgzFá@K}™Q]|lX>Ӑ`?F @K~e,dJ{X9!q4q8}HEsXAcI6񤒙6uPw\3^LD+ Q@ң4Rn[N6""MVWʌ@U@ Ǐy_tUG`3؃ GP n<)ueaA)@2\| lrm4kl@Y3 ҡL^,l(C;A #=77F~^D\Y"ρ9,6{Rn5 I\n;`+DcHDSڸF *ΗP ] Rո e oY7C0]Tl9R? #@ {֫Wagk_=⚷? Z|K_Z+Z3*5'?Vqb @(bGZ 5ۿZH*iCJo-/Ld>YL0g{5;]XԉuRj?sgdh4ZI*<<nmS)1s'- `c pێ+m6|`@e 0{NoSc.Pria 7XҐe{<` uzMHGT*]INکJ@ӱD@kѨ@mБ| :@qR# tdsry@1`@- J3߇7|$j?e 1ED'^:)Ѩ'uD=r<"&:8'60n/YtG83 ""p0cHW'R-Ո-X@-bR j!`?bÕg(rCPL[eI"b'U H6NQUi('81%ϸsJT o+ n!} DSf7+@3G/P A=`lXv˙\Re@uB OOR͉L{8-qX|AxXo?O(tHˌ`AT5YϗT#PvwH҃@0h6W{D{-̀@0m6 L}[2-&,p+-`1WGnP"c-r ХB2εPY,y< 9v"T-zP0s|׻K<8t06KjN)uF ӛlUg/|{onOri[ic`x.:u58I -}zz5^];ԤTag+.a{T8x׳*hJF/<}&vwܬ%kH+778a+ kR! al~_WׁnLZDgdՒ/X۲W;r>t-4`t`k Ttʇ>m--IL"m'ZLVS:wռ ^NtlHTO;-Eгвl8{i;" i1[}13W-te_-/JxnJ{)4tƷXnwi 25F$4skyWNh|0<Ң LVE5YIQ8c/=M!-jI{B ޶@0}[ ߟ꟱:췿&1K8l#3&{@t4R(7 bL(M7/wPݢiy?fdk@Bf&f#8*0 &h`xx07aʅL8B$xyj24M] 8`U"%D\l]/q/`,-`bpxt6G#}OEprc2===09P25*j6kf~bW #PuvDL^h0lA ,&C.lЂi+-h5*L18Hmmm.!`dPrMbt=CCZ$tNV49 R*].v{ 4du1 .@_`h'qk0NCna|PN+@{<ͨ1B` vt2Xh~feA]4GL"8L%~1AN mV+͑ϴo`nnGZK=0UG|'j Wq|Ԍ](`"@ Ls`b'651z/4^?)P:Mky}4"fr˾Iux6|<4: !䀦r,Q#=,rG8^Hy8;r'&sk #Puv u WJ#/bX˪6nB{. #D JBM'pϘm!nH_.]H )+ q?5$͛:"GIug9ٺ<*\<%̼r?O({{jT-b@&GY[ɩ)]`ZlX%Qm2-v%nkјy8G#[N>W$Z}IZ@?΋ȧIG*Imks\2aAe=F gǿ(7gY.#pQz,n7/[/8.zC?@mc&{D _ !} @x*zھ1\lr=8t0opr#c3?6MN $o*tT:z_=>>/BА2C`+ 㪥C^vW/8p44ksLn6ֶB'|#!R:㒄2t| / V\dƆ\ܘ(#ƫl@̥S|CDy `) -(qɳx4MM>cA2# Pd bMQ!(`}e`@NxTqᄆ3A&@Z(Ӓҗ>FY4Did4jx{ȩYp Xp^x +n E ՟@{ަK3C1l˸D)} [JDžXD=7Vrn=/M(g滀BLpJe0>r7,?2*Ifк^$cVlH/U pQzgiuei-P ƭ2F)=ȁ|%i%\jNm}o} D@|."w3EC -l2H˧g0TU 8\bƎa2V=<mt0xʸ?JbpaL}HZ2{ݥu_:K EL<2x6;I^U,x栤;V@e~o[ƌ@8ae()rkC[ɔ6$q=Yĩf"60j-ek=xVØwb Ϟ= W[j7QRP"Uw$^-CA:ER9}6X!,{Pdq5q>#{oooSf +(2n< b+VakH2I:E:L( TCJU2ZCsgN[F,Oً|pS|.@1jc.M6,HG:P>-B:{HQln)<\0)pCjU(CZUǐ\o{V >M(ic0P>v1KP- Ϻ &g: z4!uei=Wā);kiV \&Z;Di[iM{յU({JR!J%_*,ƪ߉US '񣞤4 $I5hPLZ{VSj B_(X|"DNj_N"_Rz S&4(`*@4P,:?B$} Q@Y.WV2xr-4Ik[y>I^S:$_*,!Zk{DI ?ԋ+& $ 擛&luzjUϪynD.t|r&`n9_a5kYgBtg|N\$L!+ J j`d=#n7.T&/9ݾ[ې:NC߫cOAUY+pT!K JPj|UPӳ'˩)B=akUA&1z1LSg߯ӠK}3P` JG&|Ln{r|"^y< #P 0Nj}mi&b[L~zԼ1q{ez:p"{݇еI Ed%CaO`k E*0sbSȬ|zϞ”TUΐ%mJC JP|U0ǏgpNjc:`!zwV|J~)V܎iWցȰyh'JM23ԙ¸~_S D7*+fJqv%7%)( -T50PaaӨxdB cvcĪyB9;4*di(A)Z߉U<}RCDgR)V0eP5^$eAQ6T5/`bZVے"TB E[FeNW 4dѲ Kip&> `^D؆t]uh:/edc2~a'!gbE`< N12'i^FVVʂ[0듋 O5joDf1—Y,rilJ(馊Py$Qd (4dP9=R:7NprsB%U$ Mj ;|Z))̇i?-x5(,ZZveAr01`~Y1``0Ȫj|yq @cԖ51۶^Ndށ- c{m<li1̨<{ nk|2{oy[LjNjЀA!Cˮ55l0@)(U h FkQN dI#b;ضH I<~<j<`s5 k*g#1\͍6JEрtHmu0oJUV`@Ҡې<#T/F+F#ߦOlE[[{,4xuGFdj VT֩UkxL̿ru8Q`r'ה5Y̻AiY`U+v>Etm#L 6sкe]Z@./o|jw`""eݚnm= C7n.< +lq4ܨF89u (4DW@#Zmz)M8~!à2F'Y>< z!ΤB1/Q- Df(MWu`@❷_Frggg߀@e1\/T_W_iV,ںTL69Jj8 rRY2n%hMB>~K? &$u${キYm[?^˥`P>D.]Th-Ԉ4TC<-Lɂ)0<ڷ@=:I5=}9(7g@94^~df0^F ?"Uq = (h(!ƽ4] d!_xE :K[^X[VWo^ĒP3DWv4BjvGhH0i)B4I:-Ո4PC4I[يjNm\m;GV e o'$U_ ^ݵ@ *pb;<:: Ur#*`+)^yRf 0 @d.ޅŌKJ=nL_bh?ZNmǣ%:<QVXj!$R+fFN* ԜºG:xֲod.Q1dKbG15tBDho>ڂ>}THwn!* fX-0R@-E/[8 % D ԙPD#\ڭ|x4jQ@d < iWRD!$2EZPIE'QD< 5"M-P7p,RaI8YuU'i#tE[va@~FE<Іtt|,^ cwqa+ލ.PȼmkXLx@KY̽Sl)_/`~^קF-QJsDyDpDT;~$?P#4_5Ӛ??~'0a&ciL 7U{AJ)= 䲡CӶ1mTV+ IxzTlH/V bmڱR_,BmveeE[#p _Bݤ01 \$O[m&.,yA}he}}M4 [T_|XOz*$Y%Tʸ K*QrlnHrwF)B3~@rq.2lu?YHO%$d92VLRMN$Ӷ xς@$3(F",mƘg]-[m !$`8X@fKRRP[:%_*!#f6ܠ@ǡNQ@71`@2K{ K?x M^cv MMm#-G轤9;: Me[#  J HD/TRIOz"ξb?ml9)/\[-H(Sw*ž-&:HHGd-p>f9]'OЍ".R .*}׼JAl }wċOAo-5!JUJC(ƪ߉Uӂu6`&ZlFbYP{·v/`V]%A2Zi[C^BCk tE0H 1eg +KIK ="YdkD&HRNW#A0H$A &;>("?>mP? L"Mnr1Q(9~Z#iG|p)hHH ,y-g!TVCHhA@)`u5Ǚreٛ'GPy@ Die-br֨akTpL Y:JPJ^ݽdb¨E xctVVm$ LDfy/Fgkˋkީ/Dy*xc1]r'09gCZOG">t`I@bs;4#'x?rKȨ 'Lʥ&,I7,-RtAϟgBRTH#.F CR)#eO![Υΐ=5vpmaJ/ w)K2a)ǐ8×RU<Oo9hg˺4c[:dFcܬK Ϭ~?v{[\R$4bCB(.OԘ" FG#6 ?F5..z`k{+-s,;krz["_?V0<|N*D?-sG$﷖2Y} *`D fVJ>R7rL2]ЀBSEjwoב@KC'6=jt `8|߀]n/kO>s&H_'WL?{T)`1M-P7TC8 4d°Gj nS 1>Uf>;nr p$ ރ^uV i.ak[Ǘ|FA.2`MzH@%M)O&@""pNlmg >>9 SBYBc_IZ*nӌH3S,(l.xOPR 35'#r@=/K 8cLHUVWWKd@dhV߇L(K{>G򽖩IY2>3u/ VYʉʔjBAΉO3tVPo@z2f 43Z9 } dd闻gS>9{*^|Q?="2l1T1S 5"M-P7pRZC)E.0B @DWh؇ul`aDglVϋ@}>U B,rd ^E_99 `7H X hH5I6+y iT:Wj *ȷjRR b:x`&tPsܡ-7))[Id"@aR^{lNjP6^>8%`%Ioay b Lȥ+n@(=KD[MfX@U(g 2ȷ&NJ4m_TCrYaIUJ>k@dN)E4ڸ76KX|3ސPYYrId_BHXʢnfJ~Zcc]t:m({Ih/-d-&u} @!0 Z1m#d+ 80t ҩ K%0¼E s6䖈HDQɓ0L~ R›Ǯۜz6A62ڶt TyKZrQOB~NLqřVUHbPH*gWI-s]K5"-P5Ύ%5"Z䤔fpj>[`̯?8] Б--ri` @R 2 tks2۫B$0¼E"o&+nDŻdrT}h<( ;f*3Q{lweb3V+yR(ETOTbH"8 vXrJeR2`&r()?$8b/ tUYJY[e`-oD#G!'JA''HlwS 'T|$3"-4rV+YqCv@DY"*pGQXcr:Qp HI/xjͥсFxm}GXy!` Dρ*D" Tti< URp[y犗 V) f.JMq6ӬOmtgipAm$%7۷P(U)vcɬ3" -ѡk b)Mb(F u2 %V3Ʉ6n&ϷRˋD{F;]TN ~RltQOB2;YC|UU08T-hnyfZ-=0beP  _Lh*[Wݩ@Xxwp)/cu;{BV \s?JV:UC(@vNIt^J)wjG UBRy v ^HMY4@*]5X)5PD@%J$፴Wtoo'B  U';MHjA)څ'1\6,30OަxJ.*I+}r?|c Dc y{վoݠXQ{}4"-<,ɚG K^#I/Q)4 `"h0tp"Mb(=!b}@;p(^AB P`V&9*TbH: IxM-`&yq`@Լ}${T-X؂rzXaoWJEva/ 8a? (>Y+̅Hna.yDɉ!n?F~f&i<_1NVPXT ~c =:탨)&( c/@@Y7hq#A& =e4e(V w ID:a%lg,7E"04Ӹp~~N;|A'+7l(w8d 4H܂u0ؖR8[!ڌlc B~1##IPH}*i7v_NP>+̅Hoa&)( Sof2*9+;|)߇ A#!Sλ%W5%7};! eng_L+NR~^V.lXnPsowh2L`ok5?V.lTKJ PH*H$ >iM[)h1٤'"*aHRrItی3CoFD ZhUIEAUmEcV#PijȕɭfW-=Zj v i]zwwwiD g.b)I2ǰX) \u p72!@VOk_+lE]==wZoEo}(䷲lyɆ%M=Pא닙Mٌ֡"ꄊGP ԓu/⭮.JN҄֌rI[74jW> -L*jhb[szzv&>cN-D lTJ MG@KM{k"EsK* fh; =n2Q $ reD :s[FXѨYri˴t[ ؖ(zP[=f5BE#Z"Q39te&EGT9&"#$@aCZDĶ''v+A߼2@@ÙޙaSj6˩qL]PG!`N9r.'#.!B7pA>-ʬdU VV dHVI;m.@]m Zu SFnuD#PL"ᑺUr _u0TP{R7@UywyF5[3>pUWq}XtQyȪ}lh\#m !f={'Yξ⪗޵tڜ@K SvAݝ/@4uj g .cuԂaLR8o4*]> *7*!D2X#HJF Qwꋙ±񮯈:jrR X=ϥNA$=t؏%?* zX0m 0!F9-T@?@sT -g HG@ lowٓL Lo 6e|[8w=FY o|SenDgn |K߶}g)_ V)V.UzRӻ`V$r,R 4b_]X־Պ>BԶFn"Aegf @'VrP?E*ntw Bt:d( m}ppBd+}7Yz0)8'7;"-;|A%59y~z ,|mnz<ҳDHĻtzAewhC(,7Iًfҍ/[@mavtpBiPD4(ZG0*Y*-/L$JP*n*jpԨ+ϑ6"RVC#*G3i$b*c|*MW Sy.NS(6 PiFS٬hӳTm 3[̐߅d V<Q2uCU3$8qwwGAc鄻]!7l8OјÔFR\u~4_ l c;)JgQ_0ƾ jcBy" 5@BFvW]7f(qnH@tF9@-bH4U(jCᖀ9T1:ŻL=&Ow_#7\__H5T޸f81=?**UZ`y"Y`%_euUÞX_7hEN!HEevvH|G(D/1(~jv7S~ެNWe9X``0ex`/^P~0xcCrY)s[V:ƬT32-й[$VY*JUvw3` pKEC] n_m}Ҩ7`eU*YT~z˖^EGʔqp(?TW}K ۡ !iC]א+/cB)£XũlP㕤PtY0WG;UAJEYlŶP@X18^}%6IV \IZg. pAZ iӢ(L++ͪwAPPݩG Q~_|8Nҩ2Z. pk:s!YrB~Zk v(G*!Bpn uD1l)Ƞ'ߚђ+cG'w2M &jZ& Y¥/6m@:;Bܬ6$-d|D%B e9>I}Y06nw8UpQzP.9M1nAtRyMi<FhkoN:Z-J4jȧ++L̕4oM͕BkQV ԏ&:Czz 9K??t7W8QG.08tN۶Ot}]5^B? ~+ͅHna.y mDTLV܀-I^x~kS4, FS@ ˋ=EP%=/pmT T*r-toSR}*oJ >/-Zew\SSsfZ+*n(*ntiS}mgyɫ(h ] qsSˆ- S9w Ğ\^h[3,SGTޱ2ŞW(yx{}3"-.YutyyI* xYÑuBn:{8je@5TJC-ox~k? K&ܶ4,E4",,kU2.ʼ.jjLkWP.lB#y/"+4Ӷ:HMRjR¬6WbScca#m8S^IdRSΣGyGy8jc\(rB0ļEvޣvq+~;J/A mI8ywT_K(`Qz@[[p?M J4_/` {}t)/ WYRлK%se_8%se=%TKSk-i5Wp}|xW qoVdJ}gUX(Ii["mڭ=rB39A2TYT52)T4 )[)h.Hr s!LZ >GW/,=S u69z{J$}8w |4`i: b%r:IfɟasM>u[Je]VOSs N4/sGP4y`./身2Pt/Rk/6D)g._ @znm}o-ɟLof2Ilb(++hAUĹhT4 [9)]q:jTX}B `C2!kLy| vRs;[*Eϫ+bNHx'\j+i\{{n2\ y4yhvuƌJM9\@ U%Bǭ^oBTR`GN`u{`@].D 0WWig–kUJq1@G)M(#!=-NʓgdܷLl 'ӿ6:zhresrz*SʒT1bTPOD5M"cPHeVHxQ虝rrEnJ`s G^*@Z nEfʢ+-),nW!4$`3cqU ώi#Bs1HQ} bDr|F[*dik1֪_stG8h5eHc%YcI'#)P4)^VIZ-Gň`k\3٫ȫqzlP  3y\ ux s=9S-@Hxn\WPu|v_؁8R5ԩ׸%\&ъc@E:JL'&*0jJ+K+Ur@I1ݵ")ŗx&V4 di)++#xzih\GA|[F9XGGJd*]>7zTpuL :Sݫk2e~=b&V" U_{uT)1 $n'OLQ\"pHB^+9=/aFPtOFck݉n.E0JB4UJ*& \ "f[a=Lg* <$g 0Ń۩0WWW>}R$~lQb/`SC.g}WRLX_lEfW'T$tE7N'XBlv`h׸k 7_sOVΥt5 4ɯh3@FlT0PڟgRF =3l4LO\Q*3, N҆2=Ҹ~yy%oΖ|t%6{%l⮨UU@ժkFAOD5y"cPoѬջy6'i f9MF3eXYq p8ༀ ^X lXO_GĿ: չqE٦ǝ3 IȤJIv~LPrptD}TYJ^$#n7oIMJRUM{`8 āQPye"' 3k3m I#/w  VU]EW=׽45dz~*@X,EE ڳ z} _hޛ .%2сP-nGi9;@-d1Lj?QɩE2Sa)1fqPG9I5G5>NJ3[Lϕ9$Aie+zv ΋UgM$9Aϣ*뾏H 8HjfwgԊ$h4KfMؒ 9<R t:"22NyFd{[uF.k]. g,wF+v=C@ɺ"9#VZnETg3F@Q0蓹f)`;aČTZM@x)rҌ|*JNPK۬pepc.8S2ꄃ7:I|tCh(21(I>TH֖I 'D}D 1BСbY_Jԡxv0PY~v~vIZx i>?#܁P6S>r؝"ANN".~jDk+]ɴat@cɡ5ZNNL;򵩋PSn(HE 8Q0M腹 (g/3<˕Nwm2'jONH@H%'Xü:X(p;ќW*㕞9QPs~~3Ⱦ0v|_Fy錶Yv>o^Kwe{<`>5:E\:bgB>5Pϑ5XX\A GN H1+:G)`8r.*ǛTsфZI"*=suG*I'nI(6 "ޭ@8Lpp-lD~$t)6:jO<ɀ`7PVRTuD w]ʸ並Ð@|'7 Ga"obxVIRp:*[:`Y;G3J)'[۫ 8RU c9fPK 6ΌOo4h^WWQQs2:%:V^IX_[Xx(A%~}ԡk0 EH{ X6E*>JZ9[W$ ~:bs)xmm\pRx/x֣uBOM_]-v K*(iX"D5alLw`!F6 Rfye)d3+ è2r=FɰRf\}B?O"}ظAS 0 laiapOuQx͗.m>XocIEHd<Y17`k'I pl<1I,Y>sZf#{ c˦\crz&858S+i~ k9Q/FGm*M:V״Hl>$oToWplp r-pBl_/xKK P~UPViX/ цՈ:èuFY$ l(Hx_Tf  *wfRfgG&L[N1B1lz=>kklaaߜXz P9h]`$@(Y-tF>e\`)`)JZ9)GtzrVsy~CrjP܁5df+J*l):Q|z%ݿkNqȅF*!\I@d:eG}bUXdyz 7P̸p83YԊ* +.db۸Sj!92x>FZZA ۉj'n-hF_ pUF(f$U*akk|C#ڄb'Un(:::>bsn}GfUDS~\-5V&uxJB=x99=}_v'l!9Hq!QM\D9&n+-B kF*< ANc z  H)e! ^wb@hB)\q_H Y[p&z WA9 B3A0TQxlg'P&4ՠ8S4Cmw<1*r"%o6[pkB3i(Yk|ɢOI,/[u꓏VDj@g,ez` >fSBNDC T%^KrK9XsCbrpw'1ǡL'`ᕇ_VcgC3 Y$;q`;8`ZݧXL$,&Aiv9`%g2.Tp((e!D9NZZU03m/0i79Cw]ks6x\ė@Zn%9|rȺ^d;SrI=ކPXSd/QI Tl: ȕZ*5s`) j|}HYp$tBh#ժ56 ]GnQ_WR 8@I;,Xha@3j; Hk^ZZ^ZLeܚ)O i K bF#-k5fXa?Lɠ(mpT!X#5zO1z"ӲA°s̋Ga%L3JPؑ$P.Cb˒Z ysH`_+x[>^aLىRV۷xs!~X? y# v: XO2hsb SDJ^ W ܚ܂F+EE.|aiA4RrEշ("$­-#!Ҍ=0_f588lszA׳>XoޢG1F|{@>%SP8sVE VR'56 x9w֠@n %,ha J"A'mۋI}7*r*|k0vv<Č,k  O>K v59"z9j8aRqt+s$\bJ?ܶтu;Wt(RCuB)c -5PHͯgON`O ‰$YC\8[*.'6H_$׻Z^a\ȗ"& hJQ_XQgg|E@գPaqLΨdAuXo\qCcv횞ơd-" 6#0D> m|)MP=j2IufRzg1Σ+2R*-"WNzY5ň+d #@H88~V[q7r-`L`UǸGo6L#%8h3Xf4sY Oܚ^"[[0 v .R܂ƌ9LWS潗kߤi'=gjhcr #+|F0{$ٴHʏ@#w n4QXŜHW<E[,/[GV>/]\\bi4j uo@Ypqv qOU0X'@d~ }-0iD8pEGd N$ rk JZ܂,-9qgabw%|wftbr"ӈgX֤;S`g~ɖhS>3]''O69+Ms/BExdBE[ JU/ԄHE&[ <;5Rڜkwfִ.g $\^=漣."yHON#y,J%7GdD [kP H}ƾGxf]p$MZ>85>ݼqaa!tY*= s%@d5笱u&HF8Nc$ V^c*ҹTZGI(ʩA£V D%@Xoy'Y@F~p'd?a$+ J݄#W2:-DABYb`e`#$ȸ_I)dzR} pܚ9P#[?b)ۨw&Sy>zJhώ$gVdå&ôP #)H@mL2M{:TS(X*8⊌G  ֩2X~|\Ո~ju0CuSSP p]tK)Z"6E0.{$-@IDATlHk I#)}$WXDV"`-_ܤ1dl>Xm^;R b,>&JQ+6PְNav O3_uv>4Q>i9PJEAB%\8:"[__˅S,}-" j 4)p^•ZaU0)1w 1؀L*NLN >TtmeihfR0N0>L?-0DGyXD1 )7i"{^aB_tߤ|y8t|/:h BPD cD6 ֟NGT"\*p ADiH lnᡠݬqzf;D]$r\"%EJ]Y(MQP~~~._(q1.8{aeR0'B"ԁ~ 9E 0`(Z. KVM*B>RI%Dh A]QCl]Y̴`Y? (qr-+8Y9XRl7͓?Ns+s+4-@*q&wNv~Mμ΀#_3ɢR&"sXZ7%#ݩIrlKR W._aJy/U.e8j=?*dpxYϨlNv&dR 2B&G*^ _M$כ1He! "_agr뢐Gs {V;*֡=@4u03=cC,!<42k`r_gQJ8!Mg(\|Hizm$j̻ 59NvR2٣Hʳ7?/ mPG8EijcJ_z]ޗ5>tf真Hv䇵~He~%n$^̗pw!@AޘM Ѐ!K'B 並 ̋6֢R{TEG``)l$}"K”##hiD,&B")`W'V.^t'Ճ(';okf4YY?#FT1 Ռt>_tjbGf^UIQ.?Gѱ/ЩX PK]ZokOUxdk %~a_9Fz@8!%]4 5A=t/nRd.2Rg WB.R̤W\ k(:%Vajgt1(7އ";Fp 1$+wTr2_>*EOO!uZZ^4N듈 U- "1^#*NvQq3`Zmk(# y ϱZώ'JM!%@@[YYfss"ٟUqw-><3Qļz5m}Irë#J9ɀ ,-dP(f$*A Y8 [(Q.Qi[_K 6 qi#We ѯ^)Fc:KPE( R!ѯd  '5z,Ia).aL’ڨrDp b%L4~CH*xt *ݯ3p8Q vRöx{Y֑k ea-M>.ޟ>|*#7^?.kh,g ϮDf&2!9 B2rId8*c ; eS g k2"-kW8<ۧU0zKc/. STcjDjEN,XZ,m}$΅*)Z%5Z21 krDNx͝m0 Fʤj_*>v@)cX5n}t^:8ͳ33lԠGp&L!)jQbˆѴo py_w='`yRжQ Pzz<>j2bӀg< {E"AQ"F rՂ(v*C-yp,)2c5 up T-i걧/NL̦t M0Y| 5yqY hl MN| il q+΅wW{$9dgد6|]6fǯrl .~:[^f~Tz|Y?T YW%"^Y9]Qic/Z)kfQ<[Qމ<K@@D3ČFZOV#cd0:(UT<$Vr2ކ0#G8~SӠ`_k8|bY 8@I:T! Hu/ӷnۿ-0'] ^ƐajTsI&a.TENC G: ߉m K{@^e sB 7 )a@7So/G89~N3I.(Q*:,#lY)Q-hFOr<9*QgF$<1B)ldxFA1!JC#DG'O7 -M S2T+5j uM‡qff !M2!jrB`dH[?p61Y/#<-Bz(K/,SZ& 9j<&~$|A$Tr ()Mpɪq U[P3h*:ap~ON0~}rDEc!=$)@]9~O-DVV>F 8*=왠ldjmj:"q3oY0$켓GeYeí9({긵ea +Z 5  !`> !N.HN0Fe#Pl1QkſUNx (ځRTRmB(=ndOATůOI"L nKl{pд__Ld[ ºcll/d,ʿܚ܂HLZ-6E%T+lfO^|P^p - :lhHEBI"S0v:$,})LT2PbVu**EJȤR$sKH_[x_& y_?Yx *7a51|RIz)$;IH6?:IG8/"0MIt9E{[+52|A ~f T;tm'~Ԩ_S]rJ)/T+p0Q ] F2hձ@CN}n^SSѲbQD)10 HJ+6$$C[㘴>t'Wx,GSCKrC2 fNa IGpJ VcvJ2*@# Rb"<5g> * J:oR(;67;͍ 7%%\|pܱFb;815` BQ; x0l)V263 @EJHMA@G )& Y8c=77͞QK24L*^ :[T +fߨZhbjBG,(=s DʧR$ Y}W_+$rk ߡ$ZKKޗdU#5; : 0!qu q>/Z~59j4U v &$8[Pe) :J2 *(Y.&;mH"JcuЗXq@7>Fa y-[sZqg mflPMGYs:P3;Iy~%)2_oI>usxubO ЎO[Zߡ*{ xR, 8@e32M81@x ̧%SU--׊Hr' (emsqڕ5O p- b\\S-IcDe!Ӡ V(&aw15{H%_3`q0VfF E9a rmR$80?欓.D?ʄkj~J%c+3_K!>;~pJNOO Z-K[HHDOJB)8iY#y#)/eTQ~AOLl^pMNLW٣]8%@_0x.Enw#?ҹ06VKۆ0_i8Tkr+Nv/LV{doQ4?B &(TFJ~:XC$ª SI3+}=ş@ ;ttq&FEŲT*l)(i!W3gQZ +Ba <&Vgj! G(:l7֖ MDvB~Jz9s3krNvR2&u<@@ ژ48y>BRUޒpGNU7A5Q ? @T(tPNq*Nx%:ly:y8@#Pȃ+kQuˋs%dm'񅞷N<,NxmRg\p䊑%ҼW|w(ٖwx6`YTQVqs9@LD횆H@oGSZ3ڄ0455 u{nÜ2FhVV N4rnT{%q %Hq!i9R@Qxg{{ Q  lٙCH ؆&seD>uM?D@G/7'$QΆA(5}}EB΅R*9eQq3䈊GfFcҬuZ@h VhyGTcZLXHW $7-@ PHTH:_G}>It \QFze͝s+IRT9 $Q ^sC9)WҨ&Wcp`khM[Z'lIh@| I˒n? $杆!G C3D ] } n?=U 9Eb6+2?du)z֋ 6BԖ !:Qq3X䈊j''lP' NQ"CZQ-y]QSB/H0'*.&&? bobPR“$5'KU"O9lrb^YtpS QMbW;^XMyn76F%1J7||(yącV2eBEM:VXt Dc< dB&Y-,{8@L":Zc(P ,9C(Dhg#U*]6[ L(P6Paj/@P.!˘N d$0弬'RprAMi~-Il)*20f,*/j % /4.zLss "'+(fŽ}_$5k>IśAC>&ƫsf͍CVE6,0:\*$J߉cp꽭A*Asss-/N1SY@h;^˘N,Xy[ s̭!WqJ}Ѐs!`dKE/Ţ M*84lLNXX\`g? 1vɊUK}*Aݲ@rH˾-(*"\"ơrPښwgKUl$A]?]Gn9gi ҐѕwY\mó0**@ 8hqb9=[ xHOʭ CJR."5 !<& "L3 咓@Y%Q_pcg Tf$D-xP$u!۾Pd[]Ͱ "!Dņ1ҒuMQ^hA5ml<[ѰjGG2dHK'Tt \JΎ+܊9pЍm fO%KZ,.yY'sj H\}D%)HU=&fom Ĥ 7L7--.6u"TσsCŷ#d[!ho(`QǞ"NJŞ͉\ 63h9l Q&Ldm"7pX~%)ZI$A ʴ>x$?pjRB A_XK5XېZ d2;)RPƿVm "Rp7><]\Nz੓`.B DS {Nj.qKŋ$qiNAe 2Kj>50I;O LiQFEn6̐ Df0$S)CGR*ATV zB Smw YSM)k \7gY$O`a.`L:Ӭʮ)+& %N齍#++|^fdI6œ4O($ {5f AR&EŽ4L,CITЍvq~ vK%*#:9jxHI6YM(K%sP"o.o􋱶=~աy9ymYb(@g }4Fp 1j} [wSvg&[)]:d遶Mj, 8|5f ABH&yߓ8?S;mx:]; S guZ(YHrPQ \IŢ%/ @kIHv _z@m)1 yD軧@SV8RZ 8< $QOOMpaJڈǺz@-JaZńKr).)ZQ1BF͑ʫ}at@b2Ik$`Sͦ~xs|G^$ Z"B0X 5rkA\ܒ$ڰ Ҟ'~%M򒬆by h'͟_>dU+$MUYT!<hKˆScH+_l$)ߝB06?d䭌YZ\*f(擓 ch3:<8`K#Y#_ƸJP;M|7$eF$A6%!%nH% jN?dc$(xmSUh+@M0w t)T!'8`D^^F\j:Ky0~6YNUPIʆHqiH'_Q}Y|GwvU`R |>ass6MYLo$px]fat$ {cyYFA9p!+A=*J騴ah"m! 유!*~,jLa@ Ѻg|дY`O7[?Ie@H2:b %^Jq36B/ ĄKC(WOtDLtqmv& I8ܯ֞,:AǻکnD^Xc.Ѧk 'r|Q֠DSaKؠ١5#-c []Lp_F#@CS7h!QD5`lv)3[p8=3ͦPny<+aWB8k;0U؈ʆPqiL1R_weh:=z)\'?7)Ŭ$!|n7 h  r{IJ><:b{;d_Oi#+rFPD^Mu kipD[F񽾶~a Ԋ[Nu|fsE>0ĸXE[X[lع%/`hYf)U5:ː.rګ3 4?ZNv^x ! P;/cx3ʄ=zJ/́͌GIu$Ё(ͣ&;8<\Rr0z5dFCzPů kXx0Ʈhܥ/*^=X:I`/}gq~w?*'kTV.Z1)6@ {8C;4J%1pezqqI P\jiR,THLDJ-\ 8w"zn/p=Կx:!"wr㓝9;`TYXI6s?qa\Q uoI5tfvN4STFQ3NKx~411 ~TlTYHa׌fJ=TF$/9?p͢ tN.c8PdBG6Yؐj0S*#AL--"Cu%V3!ۥJJ%MT6Z.I^/vaaFC49`,v{]GkOd=; 㳜U; b;-H: \USlYSSjb8K}P6&y#D3kTX'cY+oGJ6ٷkm"a2&[!+NV D =ƣv^#8c4:O)ElVa:ICxل; MuSzpG’Ƥ[7~@9j>Ȱ1 4|} ǒͲG -RgDה:X×S _BzaEv@BAhGt\2SLm~E9sȧ■Jv_ !ѵ) 4X/CUX]9 t3}ʴm{4vɎ<:w̭(5JU'IR$r$IV[pQ1BWe ?ٱu )E?2N02]M(Uh h" e ^dOZ>VWVa—У z:$ЩKv$&^Tݾmp q r \H世G::/:=ƪn#Z62NQ$QIAT"a"Ha] KP ۉe=PsK\)J92;~O^^pEK`lCPlX& ( 1T$%{@꿦qⴅGԓAn ܵp4e :ʄ'li =kEIU)J3&͓YFkU꣥&8T!=;kٹYV6'LՊ H(@9@a'CLZL+ ]T0Z 0CD*X݉,~'8Rw3@.0b%`/L |;P^0"rߧ&Wa<'=zLQ7p,8<? '@$(Hh"JHbWX W7%zKG~,[YzV..emZdq|Ntowƞ&۰gvY?\S_oyL@ESAOф_R\#ƨ1g7@eb$BI}삳ϲ{pHT'0gP_}PN/ 6g\D+_뷌pʞ6,Dy**d^^_ *S]m9 ONW:"4 C ծCax2&Gf؄YUnePH*7!D+\dGӭ-ol܃w^@< J^Q6M.9 teQ-(xi2ߗS27}G͓b͞ǡoxk@lhJwŠŒ]S!@dy76"`5`'R̸W&mp҈JZ7 NzZ "r% UjƉ1YD9NQ ,.O]'cow7U&X NmDrNÔ, x @;r@GjQÔx:n"1 4Hlo/,f ~" 3qFx"x lv0#` 8u0fqc( :‘( *kyr_", <$Rהi;Zl)"ưR ׽= M("pI`T%ϰgʯZѐL  ʹ_6 "#3vt Gʞ, O$H\"B׫6"`,.ڱԇ~q~<Ó59&ث b濤Ɋ@pB(J\ȚYq֯1hd *K:yHų; ,Ri:t:P)钓I7 & y3,a8=e+KQᘇ6 9R\*rG86 Zc(2u=Yz PVSwdY$7%y4 z&3/J=bW.C@ A?i:of'a-3%-ϒTlLIU#ȟV6.#xIqI`$,>&@ @d t6̋ :Ʃ :;/n0*#!k(b n[x m1*fTz.` jID@IDAT}7)0#S4lLmU77:TfLrcόϯ./C&:f 7(uO@ F 7R'@'ZԪT|'s t ٩@*PU*.͂m:e(Ș4M*n?RBZۿ۵ضہ`Bڭ`eNFb}dpjE }~U$1c\[#"0O>`o 5455Ş}Yv]WcC}ZAJ+Iz <% hyND!iCWoTS ~0 RE5y_?#<.K`qqKkL:?4/&T\*&tcb<:j`Qd1cZry:Svp9@:D@GMc׸Q $:Xb75S,8JquDܼu=tg?an&0z +PB̼@$n&-~1c8)O39˷k$P+'p̦,ЁkWt6%@1^xSO,)Ccn$q5R&ts} `eվ?'9h娵j} J8 K'>)Q `$<{Du@N@__L2=ǀҦO8g~\-JDP*#FD%! 4\ZZ+!uJ(@ -<#$y 4$ً/P M17~'sNL;:flH~2a(u’yiˡ6S 0gjԀ F=0G\Px l|^|=替`[DS`$ 5c4xžۗX$4'OvJ̦5v%p+/2pn t GJqI6&0_,RMa@C *MQIXߣ$e*Z4w#&HZWWW(vt^DGDF_I&nQdVW^}݃/*o5VLcpFFY"@e3.* u':|^YYa;V2xƥѐ]~]LplbWVi8W:tڠ#@rيLD;۱؏0KIY:s;$G;|zAIw&3#TB~nK2>TسZPS/}+_ep$~Lkj/2IBxIrA#8c/@^?_9S$?SApu!?s*}O>mԓZDtFoE1GHC/wt$WJ>G ="`/DsFY-lGiYg2eOYZHc9l!#@e47{'?);`!Ore$G<3˼1#~h4 <ܽ@$077Z'r )J}2/_?V. kAL B/3 _ GlcpX#hMTѹL'V,XC?N`Iw n($|n|-6!z-Gy\@5^˖3,5եcK L~Dُۏ>HaJ74z!;vJRWIV}s(˛FХrH`q5eЪʣ,M`qbvݎnA  @+ ['6R9lv0J) t~rdVqodCzfe2ND5ȋgLc,}b,R0Ɓ>]ؕe 璓E虻WrG.s|< GϞyKVzӶ^+OdľA9ssL6|@maz:BP"v ִWG^͌Lb-i`u- ]d45hB8(>I̘D'nc,|,f4 _e.%;@\J nᢨ֜Zp\Kȼ/g7Lzt$YrH&82m+Mc;Z[]c?V)qkNm).gzŵ@#ģ'2 ,(6,iw(bn _ÕQNP^D0D4 xf+_% 8ʈsN`b!yN--Y[s&xLV9-u+MѬ3ﱟ'4P*14Q 4ą*:WI`d%Ѕ_}izȲ\:8^x= bqcT닀2AQz\i>EhL8Rc@V`?ܼLԳ%1pOJ0.J,˧t SᏔNFLL4X -E=H3Sі , |n7Sh@񊙒jv~:v.S^G:)Ky婙)ڸr'Qc/TG8s>,翐Sg̋ L Hh >QpBA]S@!N@'ΡAr o AWnԖ+B&jbf&<3؍H*%D,'v#3eB:.̙i_ޝ{p,G?ďHd vw!bYE,wg(ʜ\aq*\q}կy:pn V|P/"a4}R_O P+ \RK)J=u+w)l&" ?TVE:QO=q^Brb7"8\-7oo]~_oS:R? n;;OcMbQǭ&;_[lr)T5W6/)WEbX?{fRj/eJ8ߺy$2 ?湳&,C8$05 vP)Y[|mzr--8($hp 8e6oɑN/}yp0vynn^,)blc"ੲ 8BISc"9Nto}l'/v|nf1Fkt1$0?g;~ |i7Zyt{M*'9 -< SU)Є+[Ce M?ysρj2H.Y۰RE$1IyTGdf96-أ'llX`zGp EN(W*`>T&R肩9uvj5.BAP{W͛I^1qy[^Zb%;ߗBI vII@ʵgCZ}{l9F{˯|ylnF5DEL6<HT=꒥Vu~.S]jF:H64talXGVKN.][o7EB|wu6X>)B-(upě'YSZW\e_~vR0+kXEˇKqxq뢏يǙ>Vm 0ȱ,p/@2יOE'EA?B͘ h1cj1$Z3>ň+/ѳ>(~sII @X^Ksǁn߾n޸^s 쥗^fw K5~0BE $FPU~@< sP1 jC(<T˝@Ubw z``& p0nTcczB~P9o~~ӟ@o][7o paPLFhv(IvVLcx`TS⡀۟ {YK FLV(2&D#97$0("Ѕ?j!3cGht M ,A%n>bء M@sD8]Mb#њ&ڠ,R~ϱo X$9)3K/~}2c+zcNI`X C8e+_߻|Mt@%MU*@ B"h(Eg} -SJn>ԅv :jwkymlXN@,Pkz62hZp4#=q1ϳo|~[Ȋ? ]ɢK^߼OnRjDž D$0, z4{w&({`_5h&)3N cc_5惕/0"(& #*ln^ʠ< w1/% )(:2 ,` AXY3f?xN9HPe %{KqbNwEݻصk3_]*IenoCݘ]zJ8b @0 x2A^zpL:lkaUxsk%X# ۨq7ˆ &a #HJ_ۀ`JhiInmG^?*@ۉ-uӂg_.bPi'2 25{߱>Lry 4+|P~y  W\by[pZ  lnDk>VaA_EiQINM#Xdaka~s J}$":GE>0 @AXEU#s I )Nz'.JI 7|?[h'OLٳ<ͮ^J$І 8CBR>WI`$Gt++K쳟s;w٫OXj)U̢Z3a@EBql9:FExAfqLGr?YNvI% zud`:݉H:$[660+口lK.k+n޺xl>K쫯0\J0^x,yP:]x䶳0^) ettrHCP X.m+L(:d jf~hbH=/@EZսM'`8F@pcܖRM[" pp bwA(%M/JOfeI uX_e;lBy(ok$3fзln6`;twt0$B ?_ exN.ekܴ`r5ҵG ΰ?ow-g=j XW\fk ׿~}SmXц8x-I`% sݼyݿwa/g}.CI^rb7NK.2iaf !l-fX)f Zm*Uh+~>88Pm*SHAEq4DYPTYEt ZQ[MOOnMli&)hk5㷮,d,v(u0{v: H(.f/^~]q0; I@fu٧1tԧ!uylz(+IdB,7v8WI5GJp8-u9ѱ.nqNBb$JYptDV]SPwxN`A {Uv⽟hF <[LV\@j:xb FOحw؏~I$klػ{!ܽWp>Vrk܄s_s$nw.8wL$R xM{ ).7Ȫ4ᑆnBBH0ʄǂg! G(Iu,H),d>~mhGv'"p $nMv֘_lGvv>lOfճVlH@6 $#<2"23#8/<=@[beԔci 1&k6K/.\x.^|x1f(J̺AzT&H]A ʹaz?ֻCO"IYSp`%*g VsA0]5 JE-/f)BfU(gL_݋oggq]_;7MkZtW=2vc}VlU)@cgnRޞnމtϘ8BJ*'5@ 98EK2G}t80T-8SpxZ $9!F]= 0BD&ƈve< Ŭ+Fq+rAeDn P}Sxi6 }}`9]t~ūkZxؾm3 tʅH A j( -a 3Ⴖ*r)8X Җ+ɪ5[бd\(-) e''KQd@9NNݬ|ɱrv{7fIK :|`T#iCX2q#Ɍe6QN[  o*ps`zl^SX.r)A L1KcN);wF0u֭޾5}PM:#@Im&Y#Νȑ uV  WAoR,z{S[2~H;8\ϴ1zL @`V7l^YQWW q)IC68e9+cc2p?^IH:^ac{?Y :4^@U 2_*οWHmU 0do3tZ"+g@꾥SZ(Z0F)0?9V^Y"C)T@uXpW5k;68z%{?13x:=k7Ooڠ3N+ )u00=J#~JlDC*^^ݾ řLoaf]1,tt@1x^">{cuV:swtޞnz{G7>Xp+ڄ ߂{vZ93ر R@Ww7!uvvp''&Y^{3lhVآfhVH3 +lNd b+%Iq;"><"8=s\Loq`, KXAxYVⰌSѸ mc%X72BN"|k !y3Þz[TK"6p溮Ym1P.]P/sM\Ta(W:0l1T:K0"E{߾466FgϞ;wnxQyC oMXC i&0[kPCp;z!R  7<|XWP𷐕j\# w}gz|b0Ayb0IQhnsdQoZtq#Tokm?{ ͧC?L"qr s{?~BⅾAA p2ũuL尽XDWN6X('] ,u͉@HyġEFzZ$[$ezfx8"0<S?϶J^9KS!c֭Q.8:  ҉y  0{^"9<K+$Lhtd.wf,B5`LM*-h͡7zHM4@ IXL,2 qr@;bCA %H2C0m_'&H$O"Μ=C{ohpGnuNXXHNA_ϫ{vmz }СCv?M*$2di++`b戌FC+>z~ x㰕ޞnzwлm x>ޱ+=G?OD_˓V^161&Ry~1^wS2R@_ٗ_~vٷtO9׊ [Z30g.T>N瞧+W.o 3 /6юЀw/]{Kפ!Clכ7Ѯ[ a.mtov6;㖾 4o 4^%}rO%QG{;}c:K۱޹ MeAk@CbQSx"@Q2Q \<hWC-k (';wn߾*:;;S賛H*`gT7n/ڶu0{:QJeڰ a4 Jf7}oo ^@J/lz5pΠ#9q|tK"QEqoͱꚞ{r) a UA ȋӟ>C׮~hvv֌`R荏mp~ *"nd. 01) 8lp Y]NKe>2d d,+q] o.*`߈/.yzwQcyj FNv{Mc#t/+47?_#;лЉ'iÆNz 4 4q^Aĭz]v2& J)ꇀ-3k昣i)HqcCЂ/;UGOlF^9CSvBpy0{W>O> WBcz+#Ud sI2>J{BK kM`+`T !!Vqr8iZ.&yH!qU~x Y*o`U> VK~Eo&]Lbl(aLSӄV7g|dj y?{v#GRAo0Tu|{82'`IjT3hX$WLLY &fhvs)Bg.6z?*#E Sq{wQn#elO /ϽoɩI»0 y( WٸxdaGG78`9UkM(-z+nZ</jiQK=Ҕa~znlM#25u_ĮGBǝ0jViL ltڿo7=ĉ+ я4E_?2 em˖#˹{`/YkN5'Ϯ˺\`iQ)XZ*Iω,u}Z3Sv⏅`bB *M3UYjuS/3NG7>WV֥]T&oc¿uMO&tAڿLU aC`Âli)f"_>d4M b e&(%[(`% Jz#鸥|@"#E*G3?stez7(NW.R (;m{Z&짃PGgIea~ܹ9J-'Ejt}Yhq3(7"Om! pVqrvWER8raQ X2З[@ڷo?37Y};\lmc*K?L&Sz r d]4&nQ˴VB>i) @GGq:+4;6~P uX߽t|`1luqkc ꦣG2VӢE T~ۇ歛U^/ t]Κem5E1TV-ne1]E6O0[iJƔ#l4YѽƙK~q!͂*@9ZnΟ=]vmk{}';L|A^LL,*>}ڻ{'v5;&x=:xV  7L>-i°@{&%+ҪDu#̡gX?Ds 9UBj)ùO@IDATKE*r P1yЖsgir_BTFׯ#Ě[> lƺc2mb۶mt ?F"S64;7Ky*Njg9\-38@'4m@J0;sB4T/Gw|շ -m6Ѻ^x._y?TvWuO6 GvhDo&edd="\V*^L&9F}}xKb\!SH8qkK<R(LH 9&,b_+yk 9A=O|p^NC Vwnߪfo~5BGZ[[hߞ4qC;80H'N}- $д`W嚢udj0'x3m>8~B:AX7k=L·q[8FO|g Iw2p{IW>~OwlBwn崴jgchÖ GbXj;ТOF\'߯krrMF8R@@/n6ۧ`) ݈{pl/e:'?)ݸq^y5o_>l4F#CteujE5m=`8̡J*ʱ=BVtmlA@d433)@ی.Wg0Iw-$[5tr4ẑ1rL9IMǺNMO&8"PaoAW^Z=yQŗ6 1FmCQ_o反ѩS_ma"@BԢ$*F <,eT Fj\aM;n?Yq #w)sHRH9A~$)z'hl|r MN=bax.>䦕$C[9l?z/:y ) !9 a(I?m! }F, ,~kՙXW{Ib ,6n/.\x.^|7Tv9R~߼p*SoD{w}V:ڵkw|Җ Ќ p8lxzىuZ*n+! @ u5^bC@~_C;!IdcmZܬu+pk@=ѽᆱgw=8G?z .k7BeXY]CmmFgXJ]{cGGΊ7EA@h xT*@wleB<uHub0yw~/r3K BSkN NO_GPDο|%t"[V ޛo_b#jE^oO7>o0Eɓ߳g$a&k5m@=ߏ]/XӄH׊ySG F&-U8~bTBlChBO8L>%%C1q8_}ƪEO{tOWKU`\޷g'mٴ˧vЉ'UJɢKr(kazvjjRaG2n? o5:,\`ꅉz̑;h8~-LS h Ɣޞ†HB@yy}b5֏ u[zF=ґnRA@a9 d*~ؑc+%j/T;)|*?+-QW?L7 *0!Ѭ) !}o)4A?ݸq^}2S6mt+]K=8n֯ #`+]ÇQoeK$Ey}y! yx 0iKҌ͓0e@M=#-[78Bۛa=uKz{4"#zs۾mS]軻{8#EA@f`2KZǓZu+F^6J"bl2P!`o7+})"fJ ~j"_x{'h|fNx&'V=gn@1S$srvQͮ-T6Ok'BeGB{hl|9C6Ǐӧ7o;^ʍ*Da/##S `;;:dլ5hh Z*k! -Tv\ڨ+8 ̝QtmS8zWjR @#/?|>UaxF+6кAzY!hǶDJu}uc}D\O2 wN%f:\U)` I=9\ͩ"? =XV=e! @ox!47ccKϫЀ06Lh# |hzzd`0R*(yOXN9ĤѰtK!MR^QWf֣}=sŭm45eۖaѬ/Mu_Gt6HEtL}R~AHpmFgϞjpG.H׮//Ct>` X?!+aP?A  x'W 8>.!d´)uD0o\;Vr 0fI.Ea.VrdK0?u P{֍ /X³ܨa=|pNQgguѩGO^? zꩧE_yA@0LRa6vG#Q+ʖ_|Q\,[BZr1"ȝt c __N|N"Khcht޷߆jXOPÞ*q;<+BtF!/&hؒrzkkrD.JWZQ7X !CX|I ԕRjmI ID MVҥt[ɘϿ\-dHo~:t0U;f'@% (WyӋ\YYxZhҢX! @ L^ UR)6g`.`U4Wiä$g&Gq-[07YsvXxccctaŝT"A0D7wwSZп6NʍFQ8+ƬtICCpOωNHŮN Egy˅xu_ʚ'O X‰q~v<5Li dC4:YߨCbߒSر @ٳ7m^}>Ff[k+~ڵ i$C: 0ȩe-_ǂ,06 -kG{=xhP u/b#i6]5Ǖˆ'uLMN-w_5tRQX@}g}JΝFRvA@{:QJw  z{{4ո2ZI`)uB ܌Auͺz3>Hk D #RC<ccK/{&_SD_sO_,4pi7]Bax0V]Vx~0cz @=f~kiX/viIh yb8cA@*B H?A۶mS$WQFN8EFF_A@XDSt t>0Bcwy Pͼv1ffpD1T*\\&8:p`+%Rt 4/֍ |޽$JFf?J{[bwߤ}A@C@֧ ZoxXCoRGlN1rA٨EMJSq<~\2YjS\/c. @2ңLi|39;{wz}vt(A@ [!~Dnn+: ^1 yn78MR-*Sb#-I#pHA}}ss˜25݊p/66o%`Ǘ_V!TNjNb矛)A@u|.^|}d~gֹm6:v!͓A@}g|mmZkz1|LdssfIkRcPc"r=gR!RHQ[,xLNNw}g-A`%b(͛6ә3~_YO'O~;nwH* Pi@k[{ z_^YEe 6w5)U!wY"tiC 9UH3D&8ި\NP'7wX} И=O'O2bNO>}zٟߘSBz-|W֦ʯ F8`ixGuUbX"|[_x,m>VV<>]i.@@J}Ӌ/D#+=LXr TOOkt'A@1zi$CkF4??o| vVƫ(mxƦK)׽LI@OJ']|бpx ҰJDgy._zݻGaQ2X* @_N:q)j8SC#A U~Qz--FA k @Wgp*EѲ}=LB fgFhZ~lʼnƴ!1H:!V)X ;ګb 5C@A- @"pe+ i#7]]60D*T;lBzR e< '?sjBG(EF`6T>^44#ZTelTA@A@ _Э'RwwWޝGkfݜBi?.^jsi[ 7`\*1D]x|K*`{*ӞkY 12˿±  4oaiD"x'3Bloŋv|`emY S$ZRY|isCZ=q>ŝ]Xp^[?:jO*%`8G.A@A@X ,p?~[6^۩W-tGuz{:(xX*>[_WPo-.sOx\Vg}9C҆4bA:,[d}Lt܈HA@A@XElKa}~ggE=._Y_.Z9!dA *D_?uevٓ,AzKt!jٺ voA"UA@A xYL&ώKT I2b/0ә7kz!o?ܱ~aiuu 1v?y24P'7үޝs6nP?4jٺekI^Sw֬Ǧ(rA@A@x]y {HMu1k!W`MLН hf}kAldpL/XQj#+++Qgffa$RbDR'L8RN-HX}5(`pCCm%?B3.]29A@A@\֡MVCOMtD64 >ХSA* /Wa?xߣP N5i('FD1YJ񟘘@񟚚igvv653KNӖ$h[?]*9PqJ+\x+xwTZUA@A@ݻ_Un[ZѶ{xEm)*I7o~V޽X!I[&q9˗PRO=͛.Wq|K3Y8n,vA1z #@ }bxhLо_Q}SP%#0<ƒ\b?WXe?{D/S+l+}3If``ڶm;] d+ y:ct'h||SڗA@A@hf`p1jP5N嫷TtW]D.!}gʼj7Hp;@iz,:ccct1:p ״Ҙ  *>3in~^W e3YYm9Pf q GpBV<`wsW499Y5_Y)N&Dm[&0ͺ3n6qaOtHLw[)(׊Rm>,c{},5y_+r__/*lhw\>ĢuN!f@|P3d2A'O@H?? o^ںe+mڼ J* e QfșI!6 4mqz_[⌗y݂e$6"' }x\&A xNtF?v9yV崒+@yj*(?tWfw sP*Q)e!ro3N2V7q /@* @bX4'PP/:.X+ r{J.s w r|9c 04ka ¹Zo\^!޾u [7BCCC6v~_. ԘX{5*1n7* ܬ%S 9J}A@AF1; @ggӽ{poN{rQ=0dE"]Qw..% ;?x9#G% 萌Yk~FVyxp+Ͽoq_%uwV}Xqb>_WnI9 Ҥ#T ]+V)1 S )  D`=΍oΕ+U(1`0YXڒM#kۖ`5 >72 8Mg'ad(0@~P <&Д'Xx]\+qbbB}cxL)|LBg3\uytRnI2 `\Q7y]^rNtJ6`|kH>՟󏧉ILxhtP=??})L$gC@:ƫP}[ٻFI%"[R.B +ҩ{WR-.2'l`ppB)i +Xho xy7 a'Xq+Xl6;0:%sjDWL7!t hŠ't4hd؃"` (uB~F6`S܂?&ĶmԳF ?T`gw"M$"TPsv.e#m\9X.N?_"5]Skm2^A@A@ew}яBj"@%$d´XDKM*tdCYCkd0@G[)B#궯'[ /m]sFYSH/'Q/c0şRyr)qcs8OtoͰ!~Oﻺ3/K%S,J>4a,twEX^yܚ5$ @#?)6HDRtX088eu+7NiJdr,CХ2LSŋ p~@<O_p܆K3,Y`s oXR.bۿ&I?&7?sP&bhNU?3es_; <-k]q{4d+  tr9WG`+Fxx: g@8tb XPgoVH02,1!Ā 1,* K+G~X%9)e#VVg9lH<ly1miiE  41c<4><4aLVPP_u3<=#ǭt#@3~{ S[Y󼼽hθx` ݳ4#@'[H,0-*`51 8[e J>`pǪ:.梹H2'(` WSfu[M{a_j:z1ڼe+z,}aOA@AB"Yt!U߇YZ_X .W}-Jlbꝩ{Og<1c+yclȪX A"r_2j!3)W$ z|XP "(uӰۆ5T`ᇗ`?c?+9;ew4+p9I$2j忔x\\č1 h b )%%lrj"fgF/K.]Z< `(uA@A@#==t)Z30 >6N=Ŵ>Dǰښr*9יW (:-a#@aݥHY$})7o<*/4QҰeC)\7g0C+  HDoEZg)C'y]_1|qrQZ-`ѦMo[50?ýY5R)`&PL0ܓH 7cA.nCqĉ,K ͡䀛NuES]WyPlo <W0AbbrG?RJ`?J>JzA )Ab /(f? Csp n;%z-c =M?7\(PɍlQ5ƜZ$@suIRe; {>3O/P\ ԗ{A@A@ v_u 0YGt׳ Lzz -_BYhL;~vam'$39RE>V&w:OMP?+@:=댞`8J-بb(Wy 1` *pO0q/UƁ-L+)_>QJ0W OHCh9.7i[7oѝ;w@4YMA@#qqӈ7[},)XV#L|P+wtt흄bly-=̤gQAͳdtT")9qvb#@nS2Hc 1W,o; +$TYI ^"(9ĺUo([5s|?g&a"`#$q|MZ<Ҁ@IDAT~EM;-NVЍ7OZoqbeJaF샇蛻wҽ{ÇV]&K55 ~1}r)|㣣ڌReycaZ3?cP3+b桉nϊޟͲ2$0.a`FV"}~yigtF8heGbco 05m (ONXFp9,9[4A7eL Ε!Xn|B?fgw_ϳ*W6'Q0 \*?e֤?FeL?l ڍjL:-[aibr&'ĄL1ϱe^5z]?Jp+W*?X]*na1;q,/PL;z3Lt薰i֗tg%A@U- 72AokkS&Eb[3{6YVU8 γa/Hf~GauWĒNv~ދW Py0mAv ?~ore୰.͢*Jǫ8 '\f_y,_eȁٟ! onϳiŴW9v)gY^~5C   L%i(5DCCCVႀ r?C aB@]r X2Yxa#VZx&s|<1B\Nǡ&AƁ(H܌ 0vfd7(yg+|m9[   7çd? ePgNY֫@Ʋ9ٙ ghn³砇 u, @ d'oޤ+j0 POѲ l%fiɯp/_l$|0Hs?&c<PO-Q4c<`%IÄXĸ')7`P"  Mz7<n@d75uC)Wxg|= Hr(3s"nV @z0)fY[)Y׊V7.!30'2lgPiI4L1pJ{l8 E?_}|  @s" zD;gQFVT~mq~ٽP/ "+;Lm /?O t@?#cn:}O8Kz^y |O5fi@yОfTi?zi86Ő+$ñj?0E~'83M<~&:jv*'N<{o$`s"KA@A@BM2[Z[UjQֹYFs9\XpOȞ@Ȅ8ߟB\!lK )"% }u?X6Wtڟm",6 GNNS=pric#*pmӠP02B —N(y;$A@A@AτGmUX$kcexfY|.^XEF/|Fx;* =YH4! Py8 N@&DV-,4 ?~[}7sl5' V8Q?,J>Nb }Ͱ@N Cd+[A@A@A@62یX(/<! q@7sN8i}!%`NNR21U|;{gC'Tk|/i?Q?8>S+ -Ah+O6 g#fZ'狈56 p/rX\BĨOc$8 k2}mA"@A@A@BR AL<L^DhR3i&rlN@cfTvG<藿qtPuqd7L,(VXLT"iv;nCoKɁ79R|?b 4x-Y6yca_=D"?$n5VbQo`:'A@A@A 4cFw((3H݅ʪ{WGqq[qw6:yNSӬNny00s>bBbūa o8OR:K P63Db_]++&~\K*= < X}B쿲Xё|1a r>VA@A@…w~2!H/|b|.͟D^ c[YOSJOJcEK1`%ߋ+Ǣ2_ (/6LZT~}XT??{L,.#^nIp Š.sP^?inec&uS   @pY1?CQ2kG-sY,f ,gqC [p}sX'8C^|Hl1`<]+V6\ vg3ݼJE=3O~?1!3^)bf|Q.x̭}my   @%@w0?,4[#=emP@VV_]? 8[:X>&-}_|u_CJ{\w|iD$?li&pډӕP4ZW!KA@A@)ZLs455I^ӶM5 tS2W (:f缰3FV&\lv3hW^(<$GKtؐn,{ÇUlbG<\q9Єr]9"lIL(X(,2c \/Q,CŞjoP07}zj}wp-Vϱ)  A`zz~5G!{liU:@i/{qa5^_{}Pqv,u㬟F;O^F"7/L((,&H\:^k#jj]0)Neߡ&$ bR!Dj2-MS[/[]]wjV AQVlmmVw[zAh ֓@1杹4:'UA@A@hrZZ[h1z!ݾs 1b6d Xpg.?\NlxmyWwgt-:dd[yxaWڒ Ӷ5jZ@\8DCg z 2VRR5Io?v[/nanS-VXoFEH&jcijڢA@A@(@gg'mNl?8aXp,{5(y#\n{ Ʒs/f`Wd?`| p\GI>́ z"OB@jT&„+cm@ ^HT &C{G7P+sTǪ&A@A@J"L7a?>ZP%I1ޢ/ dX"qX>TPTV7`1 S ^AEp=ȯ6n050=M20yu DGGym]FA@A@hO_:w~2({XXRIv!"(X߀EdD \} pA"M JW^l@j lfJo/ 348KQ"LA@A@CAÐ!j@w0?!s\嘯$P\o4Ktnr xK[|gHkZǸ( g <3?NoIxƸ /.2x, ojkJeCRLAJ{UxWgV2IcJA@A@hӄ%ZkJǨdzeVقi!3x xO8DXdyfq>.fz(w^1?4u?ğV,;YGDݭ{=3Ƌy@+<#! c 0 de 1C  x<wOwr*DȬȪ̬u'N8S=}^9_X[}\LQ;~!iW-bQtya?/b[XԐs׸{-J;oHn]ǽ؝B:_@#A+ŰaB8..`D@D@D@D@@yȾݿ2ڼW,)|Ÿ6z7+ jbe+Z u"paO=Q:Mb\Yࢭ_|.<~ow.-Ȗ2^!E}6j䴷:.r+o){fϛ_t0-9e˞cyKN/`mtn6co]5r#onJ򾮛RަH{@5l &=[0ƛ$R1tc$J{^^kFd/z_hl M|h,ac,A|+?^(S5qtqo9ST[>j:Zm"qAqLp91pZt$N:EIoVnZ\n iq+b^yw Ap|rQ_F:O>q}; (ϝ1B@yE0֭ͭL9vWl_/0)!v.w:3lM&2x>{̹>mA)" " " "P(p<~h^eyoxoa3oJELNVnG+R>J'|}{{a)jXD@D@D@D`5y?*zru-Zeyu lvvabcLnhq+d#Ǡ4Q_{Uɩ6YfG9oʮ\鳧ob ?z;ّ7Mp3D@D@D@DgOjI~s ^ʮ*ZjIǸtЖ.s6,[½My~Cgw ZJkR~q.Vky" " " " " "  ,# >󭜨~ѬuUD@D@D@D@D@D`2lB3LJyr0RQ"2lǸ+4q|U7 " " " " " " " @lަ.2l7~ V帮{UAD@D@D@D@D@DdطOƅyH0A"2lX+ex-_uD@D@D@D@D@DdاFҮ2lwX+D+_bv<h*]@^mN2]!  zwgvը+$^!z %(E?ahfΎ E@D@D@D@D(coz&kJfe°x^DԼMpK%M~%@q,s[Aa:q9羇 E@D@D@D@D@D@M@_n[JikIRQx[.2@)h2K1EvssT@^/ (4(@ɘ cRcX `4ݬi jTD@D@D@D@nj?dwR ^ $P~NYݻwE@D@D@D@D@n@,yaҮ- MZE?Ɵp8(i{V +Nu " " " " "P=m\F7qG۝ `8.4}D@D@D@D@D@J%i o\L[p, 'E?m.rz^MO۫!^޽{sTE: S4J:p:|=gS5+" " " " "PO#@ݤڻ!ŏx3GÑu{{CB ڞmlڗG@N[>1e{0DQ4}^u,ƃ7unZ퉀@f9+Y[5 p!bd~h44~k#y!щl-w2!xLDn˫cK M0F-:F_ӳRS{=8N3-GIrH"  }2%3\[n.^L MD@D@D@D@D`y aV]2з'_/QJ_yC6"pF26.7z\F挀 3<(\%8i2@8??/=8S槜zBwuvKc (o O:H2I`- (%U6Mx=_J߻?2D@D@D@D@D@^-=^n (csxRFs2<[1yA)Fo%8}$.3xcKKNE2<6e(e͝G{Mpbyܦϸ}vetMD@D@D@D@D`}p~r~ϬB8]Fp{r8Ox9n'/OJO}^tނ@+dXD@D@D@D@!DԧKwg#L_kЮ `5Eb ߼Φ׆Ñ3ܹsG2am[MŸ߹Y $y{/ei@򞧲 9k~oot 7Pã+in*IshwB dD@D@D@D@n/N՟̵Ų04e<1Or 5T^71\/n8N&SK0~>h;Nh;6hΝ< 9]ho77?uѰ}!K??*D@D@D@D@D@niSm\|W {Y9<etmm+Z y)np n-e@7zr[܅-8Gc6WCrGG+w$h+ |RZsp'~~>1M90$@d `nl^9J*}(B3nVQlb Zp|z7\^0W4" " " " "  sngggV]^Daײ,d qIw_<_gk׫N7\>I&Ve~5V_14`-," " " " @0ʯ6@oՎmpvX6ッ&d=ۼÏh=Y*.1^@˟١Иe&" " " " "z\G^gDcY۠;ຍR5TY۽<݃npn"wYgSvr__u2.gHqwzp@m" " " " " <_x/je$x<^v'6xս$Z^_n@wl);&7syʋ㳟Fu"X_gO.2//~iU" " " " " {!<9= évJaq|_yVQEt0lL4X&ToLf2J']U͜ MD@D@D@D@D8h-OC:u ~e{[onMMRoU?w:epYy^WJK!/~<ŏm_$ϚJri#b~^=<<xhlzrhp'+/a^D@D@D@D@D` P8'Ywʓ,a}Yj_Bd\{t1v|tp2 z/k|vY ɵlڵZ3_/66,|iҏkcC+* ;[_N Ǐ^|9Vu NS3/B@u `it#'/AenGÁZT\nMMfl\7хVٽseK~!{S+lxo,0c@r[iX69h70Óýc~殼6O>hixy 9FOO8guz-^Z{\ !Y7kk{1,oyFôl. 5_٣[jx؏2y9(888HL`2g'Tit=~SQ?-٥Q3s$ xKl: " " " " "Pmg&wY!&)<,VH/_F.h*eT3!NFo;o0pSzW؏oy I3@, @BׁIM+G@`WI=>y f/O 2P D2T,ʧfCLmNjƂ#?yء mg'q,P`4 RHϢv:M8`n<9k^y׮*T^n׷p4mz'I vp4 q U1VY~a{C8ɕK| `O4&{D@D@D@D@Du Aw{]#_l8AGyyq~p8HL*k,0^&ץ&j Q}:Lg]VԈ6Im;IeU?Ή+~1oq? xwV,L] FF`q4͖sQX7E>zrMGmR%{HU|jv鋯|B8 :4j4 `oN<߳%WGAr鐰1 fCŁ]m&KA h:XJ:q"y r8!M 8hqG?]w!Cg7m:sp.<'Kc8ɱ%9;;Nֶw*(@{{4"6pP?C/_ņ k@T+´oj3̴i9.>@BMgsfcN[xqwlAI-՚]?#AZ3?z3j8^cGP2`S8w 7Q{3>i,3Ki 1zq|޼/Pb^o~Z?s:Z=7?pqv:;6%TM!@m<² emV^myp|Uۻn=$GَJZ} FyRU[Co2;oaBy-0\_Og!c< ?-YV>_mo Uɏ尿" mw .c5\cH!9.\֤I.1y 9Ȉ3[w1jc5Vz?1rwrQ~݃}l :r-P?𼀻.~i#o $1. &jL}|%2~|CVg& D`= ۲EQ͇O\BXJsoS6B\r!؉2\6RsnSQC.q pޜGsCE`{?8&"W@LˏeBPl_bBtg"Gqtu{gr$NDZx0(hQ0l?{q a9)0xk1$j^BO$1PQhb)! 7 3G'^fWF){/1 Xj,<h $9!\끗$ 5U~~[կ+Wlj?3=FZ֓DhAI!hQSK¢ԆdCL,~FgAWTą#jexP3-Bz8 5 М'efu+J2RKC@O%9O8>~{ѿ\KbvUUD@D@D@D@D@$0~ѿi ΗtP? bOTjSF5~<}{Qg=ĩYn50>=ޮEʱbQr K=$& z!0tPqI@儓`2\9gjh =|+gdjk\'" " " " " "J L/~ɋ2qh?=Z1{xfS2ޏqM76ԂY ͝C'ϒVC|GY {V[Gf>Y d0"⦱%^7젣 HzY1fEh,Z lka V8~7_uMD@D@D@D@D@h>~?HEZ,-TeSOa^#[)#? nyZѺ܆s]U+{ |/&yqxgI2'&Bu_ 0^fPaaJyt& @~,'-x/^V7|ִ$ %dKp uf7K701 0\;f}]O`ixOXN9}\{vVHv 8f?Zk 7 0i2@.j2pP q*nreְD01,֟|^0[ԷWV>;Xꈀ@?>DgO5'LdZjkU62F@mzp;Hʲ8Ǐ޶CmٿgsUrRU"_10A;1at MaN5R 2je<;C\Z`[`s'7?z# wC /O'~u5aoS?UC?+ctƟ3 FF&ax-#< )={pkuǦ 0#߷B % 3=5^zXMdQ0qIG6*ZXg1Y%=e{\!`RZTCыsNj-N5dlJ{\7P mZ?fKӳ&flR?z(NMIm¶Z PJVb r8@8c V ̬$`ZJM)h@/kh-+9jgٲl&3?eN&dϺf{ZܳJn0,!p޴jF#C#@0&! tH$`bQ[Ihdf 1Npp&|Y#;j~0`ۜ܁5po1 {l;(ͮHD@D@D@D@D@G g翄>3RߘJ;3A_Kj.f{sv~׍g7NDrB8G.Q;ƣ|[|i-7tǴǜ0I QMs%/ĐԢJ8++T6!`*M8б]0B\K,X1Xϙ9<>?zcQ_C" " " " " "*(_v}Q9=C4OMO I}Iyn'd5H0,ZnnZzSڐpjEzfg v+cXXf&U#bca^XXh=|.SqEV0,?O 9ffDu#%XZxMz<~NѦ^VWE@D@D@D@D@28: G?'鳣߶.hDFQC%q@1-M&7qh8a=0IAQT1 "@3>@d@P n7 Q>80ay_GQ3 ''R8F}7c+ ЏE0~kȖ/|^??O_xs5˩ؠE@D@D@D@D@*L899`xwhws^0Ŋ~ g) e?L9٘c·̝IkVC0:`r.g_䓛/ugEq`M=ۿ/8P zGe߈L̉'@D 5 +`Gn FxTaS?z9ee?HB ΝgN=pgvɓï;{?4 _/,cT@ŏ z2ã^v;Ob&3Y B$^jffӧ1ۿ)G?SNK(SYkZp 2zz'//[M_PSdUH|m0="i˄x n0{{͉5-h^P۩ESa͋0AA@p#1 pp`]fH@, Ѐ$~~#P9^s +=s l~i޸'[7׽AkI͛՝" " " " " % #jxTC ֺ kII;'{Ak31X=woI;#&s%]$I?hAfb X\~lB@4vN$]'b> =CE zKbShCw@&Tăk#Er ʓT c\O 3y9X5LRjPHk~*zZf^&?kbnlg]L/ҥL#2vfc?0K-ǬYFN>@&ߪطڡ҂-`\t]+` #B@8z. ]%zs#V=xs$ X4$׮ϼjTL406uQ48zcnŬ0`t3Ÿnя%o._8cޒWvѾݳlA7O+T`W^/v2Nל#C|I>I<,ێטDNH `e1^c ˁiKU]6W<## 2XNc@@ 3O-hI@h/ ۙҶNPSב@6HҐ04Q 9{eE?!y~133?.;!5!xZC3=Gl̿uqϊ.WkoIM5;@hf_LFt>[n{! w e\=1ϼH 3X @C@cʌI(`uW"z>Cnܞ`{N t:|a+mE YѱĀ8\rDF> 3M3O#IY_F;8LL69&j?vd\弡jh_ aWqo&\4Cc0%9|d02tuFSC@X44~OV1 FxjH g[$Qa3SsOkF~{linL][lߛLb#M($?;D?,7݌?g?!Rmokſ'e\wcwJ]`@O=xt:o"B tÖo [ac^ x þ׬GC&(9Lp}jAZrld L{X@VfFd&3穱F1M3g$~֟׌˿7FVHſ]%s6?&O |I0X?&r{0dp1Dhq70HB7!"?5p@v^,[4Tse?a?IDEk6_"\ʯYA] ݱa2O7liyn -b82O }F0ܡY&F G-3+ 5X<6aL:H@R1n03 ?pc.D] ~g8t'O.ϒՐ`g?۶qKfYžgxdfE~r܊mbBDq8N;NjP.W'2qkٽq~y.7} zѝ``a@IzVsoQlN;WR{kui(Ζ:\ +" " " " "p֣ ڹ}&yz^b?9g]Sq<9P:9Gi? XǾ1i"l\ZQR5x)+=\ JUV/~{-S#2yh’. 7sذ!14`xd~(JVpțчn֓k/" " " " "z#l>N (L܊~OgDf^{_73*f%{l,nZyF?(E ;g a\CW ߊd|Ԣ1LkO<f{uA' G2]Yvc~sm&w>u8d֟dÇ㯳p*/ugHvgj_2As<[F$4! 1a:'Mڌ bxˬ1Yϳ9X65<7k0X,׹E v"Vߔy7"cPl-OXz?+sb۾z?`F>c k$4YSvAfq)rz poi/" " " " " >[ߊ|1$Ĉw{7LX@ i|?XW쌿) ^<'ͶH% uIENDB`ic11PNG  IHDR szzsRGBDeXIfMM*i  bIDATX WO}{e/`XckLMj5HI4J:CPѱ\_R_T u['՚H@\ k^]v3k%A3ߙs~snsІS_ Q_|NhkqC1m{mgZW@ lڕkGǍ&E14` Ձ?SϮ -(>ϞunXsD8ğ865 N+&CΝCwmOV]Xاccx+Ios=+hm ;g )tz.Ng5 /C uݹ3@k˼y d"T%d6]בinjxaL4},Y}w.sz<73ӌ8? 0 V)v'ȱ24W*G$y^),M`FH೉N'T6Sn!V\<RT ?*zja#u6=4M?>Ed=XںҀuuud:s;y|"T4[Eϴ 4c{ ~3k^ml0٬ +gmVN-fպ0t[*XX\ еbI竁z|`ss099urų{aKp\UBcBCݻoq _SYr/u~|_\j^zLf󳤽VbQ\~{!ϲd~Sͦnxw#Z$T*xf[4Cc___'Q\zH $rN$@F=MmA5|q qkvwuut:Z}!'N % z{!1oDUUSQu֊xމ%xSSBS$u=#\=D5CdB| 2ZZZf67F*,Mv~~^ &3:2qS{c%moߏ(Rg°tg@KK+n|d!#k!( gf:X@A.>Ϗ^%\,,#A͉YWWOPx) 9;T|6nGG]TZ|Mˏx%ΖoGğ z?VWZ( WQ[߇~f:7 9..Oiw{=Y_:yRccc8TMt4"\pݪNF5ȍ$Ý>2&&ncttRm#PI_XXb}B… 0<<\ ?FBU8(V+++OQƛSMvҥz SxZ1! OnO1;;n.+lkmt"9ŒHGU>P4Xkkk4mh/̏:[ 1wZG7qe:Y2]&FѢᝢ)V&hEe#UvPh&1is7oGW׮]A{{,d͞`M@/Փaڐk[Vh 7_@[>"'%s!?=jV/5|p#̊/W$y+A)it$txԩFsnI)Pf6?w IoTf4zء%zYC37,kC*>mDEaH1âFߑ&5›O`rEuvWgye׽;e++ R2o3# M%;MI77J TWW )$~:A:/'\F%h@(+)O)>y3Jbats6"HNMNK%'|:+WՒT8?"e?, gabP_eː&{=_P IENDB`info>bplist00 X$versionY$archiverT$topX$objects_NSKeyedArchiver Troot U$null WNS.keysZNS.objectsV$classTname_assetcatalog-referenceTicon  !"Z$classnameX$classes\NSDictionary!#XNSObject$)27ILQS[ahp{$tremotesf-2.8.2/data/icons/status/000077500000000000000000000000001500171105600170765ustar00rootroot00000000000000tremotesf-2.8.2/data/icons/status/active.svg000066400000000000000000000020741500171105600210750ustar00rootroot00000000000000 tremotesf-2.8.2/data/icons/status/checking.svg000066400000000000000000000032201500171105600213670ustar00rootroot00000000000000 tremotesf-2.8.2/data/icons/status/downloading.svg000066400000000000000000000012671500171105600221320ustar00rootroot00000000000000 tremotesf-2.8.2/data/icons/status/errored.svg000066400000000000000000000012451500171105600212630ustar00rootroot00000000000000 tremotesf-2.8.2/data/icons/status/paused.svg000066400000000000000000000012501500171105600210760ustar00rootroot00000000000000 tremotesf-2.8.2/data/icons/status/queued.svg000066400000000000000000000014231500171105600211070ustar00rootroot00000000000000 tremotesf-2.8.2/data/icons/status/seeding.svg000066400000000000000000000012671500171105600212430ustar00rootroot00000000000000 tremotesf-2.8.2/data/icons/status/stalled-downloading.svg000066400000000000000000000012571500171105600235570ustar00rootroot00000000000000 tremotesf-2.8.2/data/icons/status/stalled-seeding.svg000066400000000000000000000012571500171105600226700ustar00rootroot00000000000000 tremotesf-2.8.2/data/icons/status/status.qrc000066400000000000000000000007241500171105600211330ustar00rootroot00000000000000 active.svg checking.svg downloading.svg errored.svg paused.svg queued.svg seeding.svg stalled-downloading.svg stalled-seeding.svg tremotesf-2.8.2/data/org.equeim.Tremotesf.appdata.xml.in000066400000000000000000000315501500171105600232260ustar00rootroot00000000000000 org.equeim.Tremotesf CC0-1.0 GPL-3.0+ Tremotesf Remote GUI for Transmission BitTorrent client

Tremotesf is a remote GUI for Transmission BitTorrent client. You can fully control your transmission-daemon instance, manage torrents, set Transmission settings, view server statistics.

You can add several servers profiles and switch between them (only one can be connected at a time). Tremotesf has support of HTTPS, including self-signed certificates and client certificate authentication. You can set mounted directories for servers and quickly open torrents' files and choose download directory from file dialog.

org.equeim.Tremotesf.desktop tremotesf https://github.com/equeim/tremotesf-screenshots/raw/master/desktop-1.png https://github.com/equeim/tremotesf-screenshots/raw/master/desktop-2.png https://github.com/equeim/tremotesf-screenshots/raw/master/desktop-3.png https://github.com/equeim/tremotesf-screenshots/raw/master/desktop-4.png https://github.com/equeim/tremotesf2 https://github.com/equeim/tremotesf2/issues https://www.transifex.com/equeim/tremotesf Alexey Rochev

Fixed

  • Crash when failing to parse server response as JSON

Fixed

  • Not working file dialogs when installed through Flatpak

Added

  • Option to show torrent properties in a panel in the main window instead of dialog
  • Ability to set labels on torrents and filter torrent list by labels
  • Option to display relative time
  • Option to display only names of download directories in sidebar and torrents list

Changed

  • Options dialog is rearranged to use multiple tabs
  • Message that's shown when trying to add torrent while disconnected from server is now displayed in a dialog instead of main window

Fixed

  • Delayed loading of peers for active torrents
  • Window activation from clicking on notification

Fixed

  • Failures to add torrents when "Delete .torrent file" option is enabled

Fixed

  • Tray icon disappearing in some X11 environments
  • Wrong translation being loaded on Windows

Fixed

  • Black screen issues when closing fullscreen window on macOS
  • File dialog being shown twice in some Linux environments
  • Crash with GCC 12
  • Torrent's details in list not being updated for most recently added torrent

Fixed

  • Opening torrent's download directory with some file managers

Fixed

  • Performance issue with torrents/files lists
  • Issues with mounted directories mapping

Added

  • Merging trackers when adding existing torrent
  • Add Torrent Link dialogs allows multiple links
  • "None" proxy option to bypass system proxy

Changed

  • Breeze is used as a fallback icon theme
  • Notification portal is used for notifications in Flatpak
  • Added workaround for Transmission not showing an error for torrent when all trackers have failed

Fixed

  • Mapping of mounted directories working incorrectly in some cases

Fixed

  • Qt 6.7 compatibility

Fixed

  • Application being closed when opening file picker in Qt 6 builds

Changed

  • Qt 6 is now used by default

Fixed

  • Sorting of directories and trackers in side panel
  • Menu items that should disabled on first start not being disabled
  • Selecting of current server via status bar context menu being broken in some cases
  • Debug logs being printed when they are disabled

Added

  • Option to open torrent's file or download directory on double click
  • Option to not activate main window when adding torrents (except on macOS where application is always activated)
  • Option to not show "Add Torrent" dialog when adding torrents
  • Right click on status bar opens menu to quickly connect to different server
  • Support of xdg-activation protocol on Wayland

Changed

  • "Open" and "Show in file manager" actions now show error dialog if file/directory does not exist, instead if being inaccessible
  • "Show in file manager" actions has been renamed to "Open download directory"
  • Progress bar's text is now drawn according to Qt style (though Breeze style still draws text next to the progress bar, not inside of it)

Fixed

  • Initial state of "Lock toolbar" menu action
  • Progress bar being drawn in incorrect column in torrent's files list

Added

  • Option to move torrent file to trash instead of deleting it (enabled by default)

Changed

  • Progress bar columns in torrent/file lists are now displayed with percent text
  • Default columns and sort order in torrents list are changed. Default sorting is now by added date, from new to old
  • When search field is focused via shortcut its contents are now selected
  • When Tremotesf is launched for the first time it now doesn't place itself in the middle of the screen, letting OS decide

Fixed

  • "Open" action on torrent's root file directory
  • Unnecessary RPC requests when torrent's limits are edited

Added

  • "Add torrent" dialog now has checkbox to remove torrent file when torrent is added

Changed

  • "Remember last download directory" is replaced with "Remember parameters of last added torrent", which also remembers priority, started/paused state and "Delete .torrent file" checkbox of last added torrent
  • "Remember last torrent open directory" setting is renamed to "Remember location of last opened torrent file"
  • When "Remember last torrent open directory" is unchecked user's home directory is always used
  • When authentication is enabled, `Authorization` header will be sent in advance instead of waiting for 401 response from server (thanks @otaconix)

Fixed

  • Errors when opening certain torrent files
  • Incorrect error message being displayed when there is no configured servers

Fixed

  • Crash on launch with some Qt styles
  • Mounted directories feature not working on Windows with UNC paths
  • Incorrect error message when adding torrent that already exists with some Transmission versions

Added

  • Torrent priority selection in torrent's context menu

Changed

  • Torrent status icons are redrawn to be more contrasting

Fixed

  • Torrent status icons being pixelated on high DPI displays
  • Torrent priority column being empty
  • Zero number of leechers
  • No icon in task switcher in KDE Plasma Wayland

Added

  • Trackers list shows number of seeders and leechers for trackers, not just total number peers

Changed

  • "Seeders" and "leechers" now refer to total number of seeders and leechers reported by trackers, while number of peers that we are currently downloading from / uploading to is displayed separately
  • Tracker's error is displayed in separate column
  • Torrents that have an error but are still being downloaded/uploaded are displayed under both "Status" filters simultaneously

Fixed

  • Fixed issues with lists or torrents/trackers not being updated sometimes
  • Main window placeholder when there is no torrents in list is now displayed correctly
tremotesf-2.8.2/data/org.equeim.Tremotesf.desktop.in000066400000000000000000000006311500171105600224620ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 [Desktop Entry] Type=Application Name=Tremotesf Comment=Remote GUI for Transmission BitTorrent client Categories=Network;P2P; Icon=org.equeim.Tremotesf Exec=tremotesf %U StartupNotify=true StartupWMClass=tremotesf X-GNOME-SingleWindow=true SingleMainWindow=true MimeType=application/x-bittorrent;x-scheme-handler/magnet; tremotesf-2.8.2/data/po/000077500000000000000000000000001500171105600150565ustar00rootroot00000000000000tremotesf-2.8.2/data/po/LINGUAS000066400000000000000000000000631500171105600161020ustar00rootroot00000000000000de en es_ES fr hu_HU it_IT nl nl_BE pl ru tr zh_CN tremotesf-2.8.2/data/po/LINGUAS.license000066400000000000000000000001221500171105600175170ustar00rootroot00000000000000SPDX-FileCopyrightText: 2015-2024 Alexey Rochev SPDX-License-Identifier: CC0-1.0 tremotesf-2.8.2/data/po/de.po000066400000000000000000000044731500171105600160160ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR Alexey Rochev # This file is distributed under the same license as the tremotesf package. # FIRST AUTHOR , YEAR. # # Translators: # Steve, 2024 # #, fuzzy msgid "" msgstr "" "Project-Id-Version: tremotesf\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-08-31 19:28+0300\n" "PO-Revision-Date: 2018-08-31 13:48+0000\n" "Last-Translator: Steve, 2024\n" "Language-Team: German (https://app.transifex.com/equeim/teams/72280/de/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language: de\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: tmp.desktop.in:4 ../org.equeim.Tremotesf.appdata.xml.in:7 msgid "Tremotesf" msgstr "Tremotesf" #: tmp.desktop.in:5 ../org.equeim.Tremotesf.appdata.xml.in:8 msgid "Remote GUI for Transmission BitTorrent client" msgstr "Remote-GUI für den Transmission BitTorrent-Client" #: ../org.equeim.Tremotesf.appdata.xml.in:11 msgid "" "Tremotesf is a remote GUI for Transmission BitTorrent client. You can fully " "control your transmission-daemon instance, manage torrents, set Transmission" " settings, view server statistics." msgstr "" "Tremotesf ist eine Remote-GUI für den Transmission BitTorrent-Client. Sie " "können Ihre Transmission-Daemon-Instanz vollständig steuern, Torrents " "verwalten, Übertragungseinstellungen festlegen und Serverstatistiken " "anzeigen." #: ../org.equeim.Tremotesf.appdata.xml.in:14 msgid "" "You can add several servers profiles and switch between them (only one can " "be connected at a time). Tremotesf has support of HTTPS, including self-" "signed certificates and client certificate authentication. You can set " "mounted directories for servers and quickly open torrents' files and choose " "download directory from file dialog." msgstr "" "Sie können mehrere Serverprofile hinzufügen und zwischen ihnen wechseln (es " "kann immer nur ein Server verbunden sein). Tremotesf unterstützt HTTPS, " "einschließlich selbstsignierter Zertifikate und Client-" "Zertifikatauthentifizierung. Sie können bereitgestellte Verzeichnisse für " "Server festlegen, Torrent-Dateien schnell öffnen und im Dateidialog ein " "Download-Verzeichnis auswählen." #: ../org.equeim.Tremotesf.appdata.xml.in:41 msgid "Alexey Rochev" msgstr "Alexey Rochev" tremotesf-2.8.2/data/po/en.po000066400000000000000000000044331500171105600160240ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR Alexey Rochev # This file is distributed under the same license as the tremotesf package. # FIRST AUTHOR , YEAR. # # Translators: # Alexey Rochev , 2018 # #, fuzzy msgid "" msgstr "" "Project-Id-Version: tremotesf\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-08-31 19:28+0300\n" "PO-Revision-Date: 2018-08-31 13:48+0000\n" "Last-Translator: Alexey Rochev , 2018\n" "Language-Team: English (United States) (https://app.transifex.com/equeim/teams/72280/en_US/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language: en_US\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: tmp.desktop.in:4 ../org.equeim.Tremotesf.appdata.xml.in:7 msgid "Tremotesf" msgstr "Tremotesf" #: tmp.desktop.in:5 ../org.equeim.Tremotesf.appdata.xml.in:8 msgid "Remote GUI for Transmission BitTorrent client" msgstr "Remote GUI for Transmission BitTorrent client" #: ../org.equeim.Tremotesf.appdata.xml.in:11 msgid "" "Tremotesf is a remote GUI for Transmission BitTorrent client. You can fully " "control your transmission-daemon instance, manage torrents, set Transmission" " settings, view server statistics." msgstr "" "Tremotesf is a remote GUI for Transmission BitTorrent client. You can fully " "control your transmission-daemon instance, manage torrents, set Transmission" " settings, view server statistics." #: ../org.equeim.Tremotesf.appdata.xml.in:14 msgid "" "You can add several servers profiles and switch between them (only one can " "be connected at a time). Tremotesf has support of HTTPS, including self-" "signed certificates and client certificate authentication. You can set " "mounted directories for servers and quickly open torrents' files and choose " "download directory from file dialog." msgstr "" "You can add several servers profiles and switch between them (only one can " "be connected at a time). Tremotesf has support of HTTPS, including self-" "signed certificates and client certificate authentication. You can set " "mounted directories for servers and quickly open torrents' files and choose " "download directory from file dialog." #: ../org.equeim.Tremotesf.appdata.xml.in:41 msgid "Alexey Rochev" msgstr "Alexey Rochev" tremotesf-2.8.2/data/po/es_ES.po000066400000000000000000000047451500171105600164260ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR Alexey Rochev # This file is distributed under the same license as the tremotesf package. # FIRST AUTHOR , YEAR. # # Translators: # Carmen Fernández B., 2023 # #, fuzzy msgid "" msgstr "" "Project-Id-Version: tremotesf\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-08-31 19:28+0300\n" "PO-Revision-Date: 2018-08-31 13:48+0000\n" "Last-Translator: Carmen Fernández B., 2023\n" "Language-Team: Spanish (Spain) (https://app.transifex.com/equeim/teams/72280/es_ES/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language: es_ES\n" "Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n" #: tmp.desktop.in:4 ../org.equeim.Tremotesf.appdata.xml.in:7 msgid "Tremotesf" msgstr "Tremotesf" #: tmp.desktop.in:5 ../org.equeim.Tremotesf.appdata.xml.in:8 msgid "Remote GUI for Transmission BitTorrent client" msgstr "" "Interfaz gráfica de usuario remota para el cliente de Transmission " "BitTorrent" #: ../org.equeim.Tremotesf.appdata.xml.in:11 msgid "" "Tremotesf is a remote GUI for Transmission BitTorrent client. You can fully " "control your transmission-daemon instance, manage torrents, set Transmission" " settings, view server statistics." msgstr "" "Tremotesf es una interfaz gráfica de usuario remota para el cliente de " "Transmission BitTorrent. Puede controlar completamente tu instancia de " "transmission-daemon, administrar torrents, establecer la configuración de " "Transmission, ver las estadísticas del servidor." #: ../org.equeim.Tremotesf.appdata.xml.in:14 msgid "" "You can add several servers profiles and switch between them (only one can " "be connected at a time). Tremotesf has support of HTTPS, including self-" "signed certificates and client certificate authentication. You can set " "mounted directories for servers and quickly open torrents' files and choose " "download directory from file dialog." msgstr "" "Puedes añadir varios perfiles de servidores y cambiar entre ellos (sólo es " "posible conectarse a uno a la vez). Tremotesf es compatible con HTTPS, " "incluidos los certificados autofirmados y la autenticación de certificados " "de clientes. Puedes configurar directorios montados para servidores y abrir " "rápidamente archivos torrents y seleccionar el directorio de descarga desde " "el cuadro de diálogo." #: ../org.equeim.Tremotesf.appdata.xml.in:41 msgid "Alexey Rochev" msgstr "Alexey Rochev" tremotesf-2.8.2/data/po/fr.po000066400000000000000000000050361500171105600160310ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR Alexey Rochev # This file is distributed under the same license as the tremotesf package. # FIRST AUTHOR , YEAR. # # Translators: # Nicolas Roudninski, 2019 # Jonathan Blanc, 2022 # #, fuzzy msgid "" msgstr "" "Project-Id-Version: tremotesf\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-08-31 19:28+0300\n" "PO-Revision-Date: 2018-08-31 13:48+0000\n" "Last-Translator: Jonathan Blanc, 2022\n" "Language-Team: French (https://app.transifex.com/equeim/teams/72280/fr/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language: fr\n" "Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n" #: tmp.desktop.in:4 ../org.equeim.Tremotesf.appdata.xml.in:7 msgid "Tremotesf" msgstr "Tremotesf" #: tmp.desktop.in:5 ../org.equeim.Tremotesf.appdata.xml.in:8 msgid "Remote GUI for Transmission BitTorrent client" msgstr "Interface graphique distante pour le client BitTorrent Transmission" #: ../org.equeim.Tremotesf.appdata.xml.in:11 msgid "" "Tremotesf is a remote GUI for Transmission BitTorrent client. You can fully " "control your transmission-daemon instance, manage torrents, set Transmission" " settings, view server statistics." msgstr "" "Tremotesf est une interface graphique distante pour le client BitTorrent " "Transmission. Vous pouvez contrôler pleinement votre instance de " "transmission-daemon, gérer les torrents, définir les paramètres de " "Transmission, voir les statistiques du serveur." #: ../org.equeim.Tremotesf.appdata.xml.in:14 msgid "" "You can add several servers profiles and switch between them (only one can " "be connected at a time). Tremotesf has support of HTTPS, including self-" "signed certificates and client certificate authentication. You can set " "mounted directories for servers and quickly open torrents' files and choose " "download directory from file dialog." msgstr "" "Vous pouvez ajouter plusieurs profils de serveur et basculer entre eux (un " "seul peut être connecté à la fois). Tremotesf prend en charge le protocole " "HTTPS, notamment les certificats auto-signés et l'authentification par " "certificat client. Pour chaque serveur, vous pouvez définir des répertoires " "montés afin d'ouvrir rapidement les fichiers des torrents et choisir le " "répertoire de téléchargement à partir de la boite de dialogue lors de " "l'ajout du torrent." #: ../org.equeim.Tremotesf.appdata.xml.in:41 msgid "Alexey Rochev" msgstr "Alexey Rochev" tremotesf-2.8.2/data/po/hu_HU.po000066400000000000000000000046501500171105600164330ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR Alexey Rochev # This file is distributed under the same license as the tremotesf package. # FIRST AUTHOR , YEAR. # # Translators: # György Viktor , 2025 # #, fuzzy msgid "" msgstr "" "Project-Id-Version: tremotesf\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-08-31 19:28+0300\n" "PO-Revision-Date: 2018-08-31 13:48+0000\n" "Last-Translator: György Viktor , 2025\n" "Language-Team: Hungarian (Hungary) (https://app.transifex.com/equeim/teams/72280/hu_HU/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language: hu_HU\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: tmp.desktop.in:4 ../org.equeim.Tremotesf.appdata.xml.in:7 msgid "Tremotesf" msgstr "Tremotesf" #: tmp.desktop.in:5 ../org.equeim.Tremotesf.appdata.xml.in:8 msgid "Remote GUI for Transmission BitTorrent client" msgstr "Távoli GUI a Transmission BitTorrent klienshez" #: ../org.equeim.Tremotesf.appdata.xml.in:11 msgid "" "Tremotesf is a remote GUI for Transmission BitTorrent client. You can fully " "control your transmission-daemon instance, manage torrents, set Transmission" " settings, view server statistics." msgstr "" "A Tremotesf egy távoli grafikus felhasználói felület a Transmission " "BitTorrent klienshez. Teljes mértékben vezérelheti a transmission démon " "példányát, kezelheti a torrenteket, beállíthatja a Transmission-t, " "megtekintheti a szerver statisztikáit." #: ../org.equeim.Tremotesf.appdata.xml.in:14 msgid "" "You can add several servers profiles and switch between them (only one can " "be connected at a time). Tremotesf has support of HTTPS, including self-" "signed certificates and client certificate authentication. You can set " "mounted directories for servers and quickly open torrents' files and choose " "download directory from file dialog." msgstr "" "Több szerverprofilt is hozzáadhat, és válthat közöttük (egyszerre csak egy " "csatlakozhat). A Tremotesf támogatja a HTTPS-t, beleértve az önaláírt " "tanúsítványokat és az ügyféltanúsítvány-hitelesítést. Beállíthat csatolt " "könyvtárakat a szerverekhez, és gyorsan megnyithatja a torrent fájlokat, és " "kiválaszthatja a letöltési könyvtárat a fájl párbeszédablakban." #: ../org.equeim.Tremotesf.appdata.xml.in:41 msgid "Alexey Rochev" msgstr "Alexey Rochev" tremotesf-2.8.2/data/po/it_IT.po000066400000000000000000000044731500171105600164360ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR Alexey Rochev # This file is distributed under the same license as the tremotesf package. # FIRST AUTHOR , YEAR. # # Translators: # Ioma Taani, 2019 # #, fuzzy msgid "" msgstr "" "Project-Id-Version: tremotesf\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-08-31 19:28+0300\n" "PO-Revision-Date: 2018-08-31 13:48+0000\n" "Last-Translator: Ioma Taani, 2019\n" "Language-Team: Italian (Italy) (https://app.transifex.com/equeim/teams/72280/it_IT/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language: it_IT\n" "Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n" #: tmp.desktop.in:4 ../org.equeim.Tremotesf.appdata.xml.in:7 msgid "Tremotesf" msgstr "Tremotesf" #: tmp.desktop.in:5 ../org.equeim.Tremotesf.appdata.xml.in:8 msgid "Remote GUI for Transmission BitTorrent client" msgstr "Interfaccia remota per il client BitTorrent Transmission" #: ../org.equeim.Tremotesf.appdata.xml.in:11 msgid "" "Tremotesf is a remote GUI for Transmission BitTorrent client. You can fully " "control your transmission-daemon instance, manage torrents, set Transmission" " settings, view server statistics." msgstr "" "Tremotesf è un'interfaccia remota per il client BitTorrent Transmission. " "Puoi gestire la tua istanza di transmission-daemon, gestire i torrent, " "gestire le impostazioni e vedere le statistiche del server." #: ../org.equeim.Tremotesf.appdata.xml.in:14 msgid "" "You can add several servers profiles and switch between them (only one can " "be connected at a time). Tremotesf has support of HTTPS, including self-" "signed certificates and client certificate authentication. You can set " "mounted directories for servers and quickly open torrents' files and choose " "download directory from file dialog." msgstr "" "Puoi aggiungere diversi server e passare da uno all'altro (può esserci solo " "una connessione alla volta). Tremotesf supporta il protocollo HTTPS, inclusi" " i certificati firmati autonomamente e l'autenticazione da certificato " "client. Puoi impostare le cartelle montate per server ed aprire velocemente " "i torrent e scegliere la cartella di download." #: ../org.equeim.Tremotesf.appdata.xml.in:41 msgid "Alexey Rochev" msgstr "Alexey Rochev" tremotesf-2.8.2/data/po/messages.po000066400000000000000000000030061500171105600172240ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR Alexey Rochev # This file is distributed under the same license as the tremotesf package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: tremotesf\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-08-31 19:28+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" #: tmp.desktop.in:4 ../org.equeim.Tremotesf.appdata.xml.in:7 msgid "Tremotesf" msgstr "" #: tmp.desktop.in:5 ../org.equeim.Tremotesf.appdata.xml.in:8 msgid "Remote GUI for Transmission BitTorrent client" msgstr "" #: ../org.equeim.Tremotesf.appdata.xml.in:11 msgid "" "Tremotesf is a remote GUI for Transmission BitTorrent client. You can fully " "control your transmission-daemon instance, manage torrents, set Transmission " "settings, view server statistics." msgstr "" #: ../org.equeim.Tremotesf.appdata.xml.in:14 msgid "" "You can add several servers profiles and switch between them (only one can " "be connected at a time). Tremotesf has support of HTTPS, including self-" "signed certificates and client certificate authentication. You can set " "mounted directories for servers and quickly open torrents' files and choose " "download directory from file dialog." msgstr "" #: ../org.equeim.Tremotesf.appdata.xml.in:41 msgid "Alexey Rochev" msgstr "" tremotesf-2.8.2/data/po/nl.po000066400000000000000000000045011500171105600160270ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR Alexey Rochev # This file is distributed under the same license as the tremotesf package. # FIRST AUTHOR , YEAR. # # Translators: # Nathan Follens, 2018 # Alexey Rochev , 2018 # #, fuzzy msgid "" msgstr "" "Project-Id-Version: tremotesf\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-08-31 19:28+0300\n" "PO-Revision-Date: 2018-08-31 13:48+0000\n" "Last-Translator: Alexey Rochev , 2018\n" "Language-Team: Dutch (https://app.transifex.com/equeim/teams/72280/nl/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language: nl\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: tmp.desktop.in:4 ../org.equeim.Tremotesf.appdata.xml.in:7 msgid "Tremotesf" msgstr "Tremotesf" #: tmp.desktop.in:5 ../org.equeim.Tremotesf.appdata.xml.in:8 msgid "Remote GUI for Transmission BitTorrent client" msgstr "Externe GUI voor Transmission" #: ../org.equeim.Tremotesf.appdata.xml.in:11 msgid "" "Tremotesf is a remote GUI for Transmission BitTorrent client. You can fully " "control your transmission-daemon instance, manage torrents, set Transmission" " settings, view server statistics." msgstr "" "Tremotesf is een externe GUI voor Transmission. je kan je instantie van " "transmission-daemon volledig besturen, torrents beheren, Transmission-" "instellingen configureren en serverstatistieken bekijken." #: ../org.equeim.Tremotesf.appdata.xml.in:14 msgid "" "You can add several servers profiles and switch between them (only one can " "be connected at a time). Tremotesf has support of HTTPS, including self-" "signed certificates and client certificate authentication. You can set " "mounted directories for servers and quickly open torrents' files and choose " "download directory from file dialog." msgstr "" "Je kan verschillende serverprofielen toevoegen en ertussen wisselen (slechts" " één profiel kan gelijktijdig zijn verbonden). Tremotesf ondersteunt HTTPS, " "inclusief zelfondertekende certificaten en cliëntcertificaatauthenticatie. " "Je kan aangekoppelde mappen voor servers instellen en snel de bestanden van " "torrents openen, en een downloadmap kiezen via de bestandsdialoog." #: ../org.equeim.Tremotesf.appdata.xml.in:41 msgid "Alexey Rochev" msgstr "Alexey Rochev" tremotesf-2.8.2/data/po/nl_BE.po000066400000000000000000000045251500171105600164030ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR Alexey Rochev # This file is distributed under the same license as the tremotesf package. # FIRST AUTHOR , YEAR. # # Translators: # Nathan Follens, 2018 # Alexey Rochev , 2018 # #, fuzzy msgid "" msgstr "" "Project-Id-Version: tremotesf\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-08-31 19:28+0300\n" "PO-Revision-Date: 2018-08-31 13:48+0000\n" "Last-Translator: Alexey Rochev , 2018\n" "Language-Team: Dutch (Belgium) (https://app.transifex.com/equeim/teams/72280/nl_BE/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language: nl_BE\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: tmp.desktop.in:4 ../org.equeim.Tremotesf.appdata.xml.in:7 msgid "Tremotesf" msgstr "Tremotesf" #: tmp.desktop.in:5 ../org.equeim.Tremotesf.appdata.xml.in:8 msgid "Remote GUI for Transmission BitTorrent client" msgstr "Externen GUI voor Transmission" #: ../org.equeim.Tremotesf.appdata.xml.in:11 msgid "" "Tremotesf is a remote GUI for Transmission BitTorrent client. You can fully " "control your transmission-daemon instance, manage torrents, set Transmission" " settings, view server statistics." msgstr "" "Tremotesf is nen externen GUI voor Transmission. Ge kunt uw instantie van " "transmission-daemon volledig besturen, torrents beheren, Transmission-" "instellingen configureren en serverstatistieken bekijken." #: ../org.equeim.Tremotesf.appdata.xml.in:14 msgid "" "You can add several servers profiles and switch between them (only one can " "be connected at a time). Tremotesf has support of HTTPS, including self-" "signed certificates and client certificate authentication. You can set " "mounted directories for servers and quickly open torrents' files and choose " "download directory from file dialog." msgstr "" "Ge kunt verschillende serverprofielen toevoegen en dertussen wisselen " "(slechts één profiel kan tegelijk verbonden zijn). Tremotesf ondersteunt " "HTTPS, inclusief zelfondertekende certificaten en " "cliëntcertificaatauthenticatie. Ge kunt aangekoppelde mappen voor servers " "instellen en rap de bestanden van torrents openen, en een downloadmap kiezen" " via de bestandsdialoog." #: ../org.equeim.Tremotesf.appdata.xml.in:41 msgid "Alexey Rochev" msgstr "Alexey Rochev" tremotesf-2.8.2/data/po/pl.po000066400000000000000000000050021500171105600160260ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR Alexey Rochev # This file is distributed under the same license as the tremotesf package. # FIRST AUTHOR , YEAR. # # Translators: # Waldemar Stoczkowski, 2020 # Oliwier ., 2024 # #, fuzzy msgid "" msgstr "" "Project-Id-Version: tremotesf\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-08-31 19:28+0300\n" "PO-Revision-Date: 2018-08-31 13:48+0000\n" "Last-Translator: Oliwier ., 2024\n" "Language-Team: Polish (https://app.transifex.com/equeim/teams/72280/pl/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language: pl\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" #: tmp.desktop.in:4 ../org.equeim.Tremotesf.appdata.xml.in:7 msgid "Tremotesf" msgstr "Tremotesf" #: tmp.desktop.in:5 ../org.equeim.Tremotesf.appdata.xml.in:8 msgid "Remote GUI for Transmission BitTorrent client" msgstr "Zdalne GUI dla klienta BitTorrent Transmission" #: ../org.equeim.Tremotesf.appdata.xml.in:11 msgid "" "Tremotesf is a remote GUI for Transmission BitTorrent client. You can fully " "control your transmission-daemon instance, manage torrents, set Transmission" " settings, view server statistics." msgstr "" "Tremotesf jest zdalnym GUI dla klienta BitTorrent Transmission. Za jego " "pomocą możesz w pełni kontrolować swoją instancję transmission-daemon, " "zarządzać torrentami, konfigurować ustawienia Transmission i wyświetlać " "statystyki serwera." #: ../org.equeim.Tremotesf.appdata.xml.in:14 msgid "" "You can add several servers profiles and switch between them (only one can " "be connected at a time). Tremotesf has support of HTTPS, including self-" "signed certificates and client certificate authentication. You can set " "mounted directories for servers and quickly open torrents' files and choose " "download directory from file dialog." msgstr "" "Możesz dodać kilka profili serwerów i przełączać się między nimi (tylko " "jeden może być podłączony jednocześnie). Tremotesf obsługuje protokół HTTPS," " w tym certyfikaty z podpisem własnym i uwierzytelnianie certyfikatów " "klienta. Możesz ustawić zamontowane katalogi dla serwerów i szybko otwierać " "pliki torrentów oraz wybrać katalog pobierania z okna dialogowego plików." #: ../org.equeim.Tremotesf.appdata.xml.in:41 msgid "Alexey Rochev" msgstr "Alexey Rochev" tremotesf-2.8.2/data/po/ru.po000066400000000000000000000060251500171105600160470ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR Alexey Rochev # This file is distributed under the same license as the tremotesf package. # FIRST AUTHOR , YEAR. # # Translators: # Alexey Rochev , 2018 # #, fuzzy msgid "" msgstr "" "Project-Id-Version: tremotesf\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-08-31 19:28+0300\n" "PO-Revision-Date: 2018-08-31 13:48+0000\n" "Last-Translator: Alexey Rochev , 2018\n" "Language-Team: Russian (https://app.transifex.com/equeim/teams/72280/ru/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language: ru\n" "Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n" #: tmp.desktop.in:4 ../org.equeim.Tremotesf.appdata.xml.in:7 msgid "Tremotesf" msgstr "Tremotesf" #: tmp.desktop.in:5 ../org.equeim.Tremotesf.appdata.xml.in:8 msgid "Remote GUI for Transmission BitTorrent client" msgstr "Клиент удаленного управления для BitTorrent-клиента Transmission" #: ../org.equeim.Tremotesf.appdata.xml.in:11 msgid "" "Tremotesf is a remote GUI for Transmission BitTorrent client. You can fully " "control your transmission-daemon instance, manage torrents, set Transmission" " settings, view server statistics." msgstr "" "Tremotesf - это клиент удаленного управления для BitTorrent-клиента " "Transmission. Вы можете полностью контролировать ваш transmission-daemon. " "управлять торрентами, изменять настройки Transmission, просматривать " "статистику сервера." #: ../org.equeim.Tremotesf.appdata.xml.in:14 msgid "" "You can add several servers profiles and switch between them (only one can " "be connected at a time). Tremotesf has support of HTTPS, including self-" "signed certificates and client certificate authentication. You can set " "mounted directories for servers and quickly open torrents' files and choose " "download directory from file dialog." msgstr "" "Вы можете создать несколько профилей сервероы и переключаться между ними " "(одновременно может быть подключен только один). Tremotesf поддерживает " "работу через HTTPS, в том числе самоподписанные сертификаты и авторизацию с " "помощью клиентского сертификата. Вы можете указать подключенные каталоги для" " сервера и быстро открывать файлы торрента или выбирать каталог загрузки с " "помощью диалога выбора файлов." #: ../org.equeim.Tremotesf.appdata.xml.in:41 msgid "Alexey Rochev" msgstr "Alexey Rochev" tremotesf-2.8.2/data/po/tr.po000066400000000000000000000046211500171105600160460ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR Alexey Rochev # This file is distributed under the same license as the tremotesf package. # FIRST AUTHOR , YEAR. # # Translators: # Mehmet BEKGÖZ, 2023 # #, fuzzy msgid "" msgstr "" "Project-Id-Version: tremotesf\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-08-31 19:28+0300\n" "PO-Revision-Date: 2018-08-31 13:48+0000\n" "Last-Translator: Mehmet BEKGÖZ, 2023\n" "Language-Team: Turkish (https://app.transifex.com/equeim/teams/72280/tr/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language: tr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" #: tmp.desktop.in:4 ../org.equeim.Tremotesf.appdata.xml.in:7 msgid "Tremotesf" msgstr "Tremotesf" #: tmp.desktop.in:5 ../org.equeim.Tremotesf.appdata.xml.in:8 msgid "Remote GUI for Transmission BitTorrent client" msgstr "Transmission BitTorrent istemcisi için uzaktan uygulama" #: ../org.equeim.Tremotesf.appdata.xml.in:11 msgid "" "Tremotesf is a remote GUI for Transmission BitTorrent client. You can fully " "control your transmission-daemon instance, manage torrents, set Transmission" " settings, view server statistics." msgstr "" "Tremotesf, Transmission BitTorrent istemcisi için uzaktan bir GUI'dir. " "Transmission-daemon örneğinizi tamamen kontrol edebilir, torrentleri " "yönetebilir, transmission ayarlarını yapabilir, sunucu istatistiklerini " "görüntüleyebilirsiniz." #: ../org.equeim.Tremotesf.appdata.xml.in:14 msgid "" "You can add several servers profiles and switch between them (only one can " "be connected at a time). Tremotesf has support of HTTPS, including self-" "signed certificates and client certificate authentication. You can set " "mounted directories for servers and quickly open torrents' files and choose " "download directory from file dialog." msgstr "" "Birkaç sunucu profili ekleyebilir ve bunlar arasında geçiş yapabilirsiniz " "(aynı anda yalnızca bir tanesi bağlanabilir). Tremotesf, kendinden imzalı " "sertifikalar ve istemci sertifikası kimlik doğrulaması dahil olmak üzere " "HTTPS desteğine sahiptir. Sunucular için monte edilmiş dizinler " "ayarlayabilir ve torrent dosyalarını hızlı bir şekilde açabilir ve dosya " "iletişim kutusundan indirme dizinini seçebilirsiniz." #: ../org.equeim.Tremotesf.appdata.xml.in:41 msgid "Alexey Rochev" msgstr "Alexey Rochev" tremotesf-2.8.2/data/po/update.sh000066400000000000000000000005641500171105600167010ustar00rootroot00000000000000#!/bin/sh # SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 sed "/Icon=/d" ../org.equeim.Tremotesf.desktop.in > tmp.desktop.in xgettext --copyright-holder="Alexey Rochev " --package-name="tremotesf" -p ./ tmp.desktop.in ../org.equeim.Tremotesf.appdata.xml.in rm tmp.desktop.in sed -i "s/CHARSET/UTF-8/" messages.po tremotesf-2.8.2/data/po/zh_CN.po000066400000000000000000000042431500171105600164220ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR Alexey Rochev # This file is distributed under the same license as the tremotesf package. # FIRST AUTHOR , YEAR. # # Translators: # dumperk , 2019 # #, fuzzy msgid "" msgstr "" "Project-Id-Version: tremotesf\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-08-31 19:28+0300\n" "PO-Revision-Date: 2018-08-31 13:48+0000\n" "Last-Translator: dumperk , 2019\n" "Language-Team: Chinese (China) (https://app.transifex.com/equeim/teams/72280/zh_CN/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language: zh_CN\n" "Plural-Forms: nplurals=1; plural=0;\n" #: tmp.desktop.in:4 ../org.equeim.Tremotesf.appdata.xml.in:7 msgid "Tremotesf" msgstr "Tremotesf" #: tmp.desktop.in:5 ../org.equeim.Tremotesf.appdata.xml.in:8 msgid "Remote GUI for Transmission BitTorrent client" msgstr "Transmission远程GUI客户端" #: ../org.equeim.Tremotesf.appdata.xml.in:11 msgid "" "Tremotesf is a remote GUI for Transmission BitTorrent client. You can fully " "control your transmission-daemon instance, manage torrents, set Transmission" " settings, view server statistics." msgstr "" "Tremotesf是一款Transmission远程GUI客户端,你可以完全的控制你的transmission-" "daemon实例,管理种子、更改transmission的设置、查看服务器统计数据。" #: ../org.equeim.Tremotesf.appdata.xml.in:14 msgid "" "You can add several servers profiles and switch between them (only one can " "be connected at a time). Tremotesf has support of HTTPS, including self-" "signed certificates and client certificate authentication. You can set " "mounted directories for servers and quickly open torrents' files and choose " "download directory from file dialog." msgstr "" "你可以添加多个服务器配置并切换它们(同时只能连接一个)。Tremotesf支持HTTPS,包含自签名证书和客户端证书认证。为服务器设置目录映射从而快速的打开种子文件,添加种子时可以选择下载目录。" #: ../org.equeim.Tremotesf.appdata.xml.in:41 msgid "Alexey Rochev" msgstr "Alexey Rochev" tremotesf-2.8.2/org.equeim.Tremotesf.json000066400000000000000000000047271500171105600204560ustar00rootroot00000000000000{ "app-id": "org.equeim.Tremotesf", "runtime": "org.kde.Platform", "runtime-version": "6.8", "sdk": "org.kde.Sdk", "command": "tremotesf", "finish-args": [ "--socket=fallback-x11", "--socket=wayland", "--share=ipc", "--share=network", "--device=dri", "--filesystem=host:ro", "--talk-name=org.kde.StatusNotifierWatcher", "--talk-name=org.freedesktop.FileManager1" ], "cleanup": [ "/include", "/lib/cmake", "/lib/pkgconfig" ], "modules": [ { "name": "cxxopts", "buildsystem": "cmake-ninja", "builddir": true, "config-opts": [ "-DCMAKE_BUILD_TYPE=RelWithDebInfo", "-DCXXOPTS_BUILD_EXAMPLES=OFF", "-DCXXOPTS_BUILD_TESTS=OFF" ], "sources": [ { "type": "archive", "url": "https://github.com/jarro2783/cxxopts/archive/refs/tags/v3.2.1.tar.gz", "sha256": "841f49f2e045b9c6365997c2a8fbf76e6f215042dda4511a5bb04bc5ebc7f88a" } ] }, { "name": "cpp-httplib", "buildsystem": "cmake-ninja", "builddir": true, "config-opts": [ "-DCMAKE_BUILD_TYPE=RelWithDebInfo", "-DHTTPLIB_REQUIRE_OPENSSL=ON" ], "sources": [ { "type": "archive", "url": "https://github.com/yhirose/cpp-httplib/archive/refs/tags/v0.20.0.tar.gz", "sha256": "18064587e0cc6a0d5d56d619f4cbbcaba47aa5d84d86013abbd45d95c6653866" } ] }, { "name": "tremotesf", "buildsystem": "cmake-ninja", "builddir": true, "build-options": { "env": { "CTEST_OUTPUT_ON_FAILURE": "1", "ASAN_OPTIONS": "detect_leaks=0" } }, "config-opts": [ "-DCMAKE_BUILD_TYPE=RelWithDebInfo", "-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON", "-DTREMOTESF_WITH_HTTPLIB=system", "-DTREMOTESF_ASAN=ON" ], "run-tests": true, "sources": [ { "type": "dir", "path": "." } ] } ] } tremotesf-2.8.2/packaging/000077500000000000000000000000001500171105600154535ustar00rootroot00000000000000tremotesf-2.8.2/packaging/CMakeLists.txt000066400000000000000000000002701500171105600202120ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 if (WIN32) add_subdirectory("windows") elseif (APPLE) add_subdirectory("macos") endif() tremotesf-2.8.2/packaging/CPackCommon.cmake000066400000000000000000000031271500171105600206120ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 set(CPACK_THREADS 0) set(CPACK_PACKAGE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}") if (WIN32) set(os "Windows") elseif (APPLE) set(os "macOS") else () message(FATAL_ERROR "Unsupported target platform ${CMAKE_SYSTEM_NAME}") endif () if (DEFINED VCPKG_TARGET_TRIPLET) if (VCPKG_TARGET_TRIPLET MATCHES "^x64.*$") set(arch "x86_64") elseif (VCPKG_TARGET_TRIPLET MATCHES "^arm64.*$") set(arch "arm64") else () message(FATAL_ERROR "Unsupported VCPKG_TARGET_TRIPLET ${VCPKG_TARGET_TRIPLET}") endif () elseif (DEFINED CMAKE_OSX_ARCHITECTURES) if (CMAKE_OSX_ARCHITECTURES STREQUAL "x86_64") set(arch "x86_64") elseif (CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") set(arch "arm64") else () message(FATAL_ERROR "Unsupported CMAKE_OSX_ARCHITECTURES ${CMAKE_OSX_ARCHITECTURES}") endif () else () string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" target_arch) if ((target_arch STREQUAL "amd64") OR (target_arch STREQUAL "x86_64")) set(arch "x86_64") elseif (target_arch STREQUAL "arm64") set(arch "arm64") else () message(FATAL_ERROR "Unsupported target architecture ${target_arch}") endif () endif () get_property(is_multi_config GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) if (is_multi_config) set(CPACK_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}-${os}-${arch}-\${CPACK_BUILD_CONFIG}") else () set(CPACK_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}-${os}-${arch}-${CMAKE_BUILD_TYPE}") endif () tremotesf-2.8.2/packaging/macos/000077500000000000000000000000001500171105600165555ustar00rootroot00000000000000tremotesf-2.8.2/packaging/macos/!!! ATTENTION !!!.txt000066400000000000000000000004671500171105600213000ustar00rootroot00000000000000After installing this app to Applications you need to run following command in the terminal: xattr -dr com.apple.quarantine /Applications/Tremotesf.app The reason why this command is needed is that this app is unsigned and macOS will prevent it from running. This command tells macOS to allow this app to run.tremotesf-2.8.2/packaging/macos/!!! ATTENTION !!!.txt.license000066400000000000000000000001211500171105600227040ustar00rootroot00000000000000SPDX-FileCopyrightText: 2015-2024 Alexey Rochev SPDX-License-Identifier: CC0-1.0tremotesf-2.8.2/packaging/macos/CMakeLists.txt000066400000000000000000000012021500171105600213100ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 configure_file("Info.plist.in" "${CMAKE_CURRENT_BINARY_DIR}/Info.plist") install(FILES "${CMAKE_CURRENT_BINARY_DIR}/Info.plist" DESTINATION "${TREMOTESF_MACOS_BUNDLE_NAME}.app/Contents") install(FILES "!!! ATTENTION !!!.txt" DESTINATION ".") include("../CPackCommon.cmake") set(CPACK_GENERATOR DragNDrop) get_property(is_multi_config GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) if (is_multi_config) set(CPACK_PROJECT_CONFIG_FILE "${CMAKE_CURRENT_SOURCE_DIR}/StripExecutable.cmake") else() include(StripExecutable.cmake) endif() include(CPack) tremotesf-2.8.2/packaging/macos/Info.plist.in000066400000000000000000000067661500171105600211510ustar00rootroot00000000000000 CFBundleDevelopmentRegion English CFBundleExecutable tremotesf CFBundleIconFile tremotesf CFBundleIdentifier org.equeim.Tremotesf CFBundleInfoDictionaryVersion 6.0 CFBundleName ${TREMOTESF_MACOS_BUNDLE_NAME} CFBundlePackageType APPL CFBundleShortVersionString ${PROJECT_VERSION} CFBundleVersion ${PROJECT_VERSION} CSResourcesFileMapped NSHumanReadableCopyright 2015-2024 Alexey Rochev LSMinimumSystemVersion ${TREMOTESF_MACOS_DEPLOYMENT_TARGET} CFBundleDocumentTypes CFBundleTypeExtensions torrent CFBundleTypeIconFile tremotesf CFBundleTypeName BitTorrent Document CFBundleTypeRole Viewer LSHandlerRank Owner LSItemContentTypes org.bittorrent.torrent NSExportableTypes org.bittorrent.torrent UTExportedTypeDeclarations UTTypeConformsTo public.data public.item com.bittorrent.torrent UTTypeDescription BitTorrent Document UTTypeIconFile tremotesf UTTypeIdentifier org.bittorrent.torrent UTTypeReferenceURL https://www.bittorrent.org/beps/bep_0000.html UTTypeTagSpecification com.apple.ostype TORR public.filename-extension torrent public.mime-type application/x-bittorrent CFBundleURLTypes CFBundleTypeRole Viewer CFBundleURLSchemes magnet CFBundleURLName BitTorrent Magnet URL tremotesf-2.8.2/packaging/macos/StripExecutable.cmake000066400000000000000000000010401500171105600226550ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 if (DEFINED CPACK_BUILD_CONFIG) string(TOLOWER "${CPACK_BUILD_CONFIG}" config) elseif (DEFINED CMAKE_BUILD_TYPE) string(TOLOWER "${CMAKE_BUILD_TYPE}" config) else () message(FATAL_ERROR "Unknown build type") endif () if (config STREQUAL "release") message(STATUS "Stripping executable when packaging") set(CPACK_STRIP_FILES ON) else() message(STATUS "Not stripping executable when packaging") set(CPACK_STRIP_FILES OFF) endif() tremotesf-2.8.2/packaging/rpm/000077500000000000000000000000001500171105600162515ustar00rootroot00000000000000tremotesf-2.8.2/packaging/rpm/tremotesf.spec000066400000000000000000000142401500171105600211360ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 %bcond asan 0 %global app_id org.equeim.Tremotesf %if %{defined suse_version} || 0%{?fedora} >= 40 %global qt_version 6 %else %global qt_version 5 %endif Name: tremotesf Version: 2.8.2 Release: 1%{!?suse_version:%{?dist}} Summary: Remote GUI for transmission-daemon %if %{defined suse_version} Group: Productivity/Networking/Other License: GPL-3.0-or-later %else License: GPLv3+ %endif URL: https://github.com/equeim/tremotesf2 Source0: https://github.com/equeim/tremotesf2/releases/download/%{version}/%{name}-%{version}.tar.zst Requires: hicolor-icon-theme BuildRequires: cmake BuildRequires: desktop-file-utils BuildRequires: gettext BuildRequires: make BuildRequires: zstd BuildRequires: cmake(Qt%{qt_version}) BuildRequires: cmake(Qt%{qt_version}Core) BuildRequires: cmake(Qt%{qt_version}DBus) BuildRequires: cmake(Qt%{qt_version}LinguistTools) BuildRequires: cmake(Qt%{qt_version}Network) BuildRequires: cmake(Qt%{qt_version}Test) BuildRequires: cmake(Qt%{qt_version}Widgets) BuildRequires: cmake(fmt) BuildRequires: cmake(KF%{qt_version}WidgetsAddons) BuildRequires: cmake(KF%{qt_version}WindowSystem) BuildRequires: cmake(cxxopts) BuildRequires: pkgconfig(libpsl) BuildRequires: openssl-devel %if %{defined fedora} BuildRequires: cmake(httplib) BuildRequires: libappstream-glib %if "%{toolchain}" == "clang" BuildRequires: clang %if %{with asan} BuildRequires: compiler-rt %endif %else BuildRequires: gcc-c++ %if %{with asan} BuildRequires: libasan %endif %endif Requires: qt%{qt_version}-qtsvg Requires: breeze-icon-theme %if %{qt_version} == 5 Requires: kwayland-integration %endif %global tremotesf_with_httplib system %endif %if %{defined suse_version} BuildRequires: pkgconfig(cpp-httplib) BuildRequires: appstream-glib # OBS complains about not owned directories if hicolor-icon-theme isn't installed at build time BuildRequires: hicolor-icon-theme Requires: libQt6Svg6 Requires: kf6-breeze-icons %global _metainfodir %{_datadir}/metainfo %global tremotesf_with_httplib system %endif %if %{defined mageia} BuildRequires: appstream-util Requires: qtsvg5 Requires: breeze-icons %if %{qt_version} == 5 Requires: kwayland-integration %endif %global tremotesf_with_httplib bundled %endif %description Remote GUI for Transmission BitTorrent client. %prep %autosetup %build %cmake -D TREMOTESF_QT6=%[%{qt_version} == 6 ? "ON" : "OFF"] -D TREMOTESF_WITH_HTTPLIB=%{tremotesf_with_httplib} -D TREMOTESF_ASAN=%{with asan} %cmake_build %check export ASAN_OPTIONS=detect_leaks=0 %ctest %install %cmake_install appstream-util validate-relax --nonet %{buildroot}/%{_metainfodir}/%{app_id}.appdata.xml desktop-file-validate %{buildroot}/%{_datadir}/applications/%{app_id}.desktop %files %{_bindir}/%{name} %{_datadir}/icons/hicolor/*/apps/%{app_id}.* %{_datadir}/applications/%{app_id}.desktop %{_metainfodir}/%{app_id}.appdata.xml %changelog * Wed Apr 16 2025 Alexey Rochev - 2.8.2-1 - tremotesf-2.8.2 * Sat Apr 12 2025 Alexey Rochev - 2.8.1-1 - tremotesf-2.8.1 * Wed Apr 09 2025 Alexey Rochev - 2.8.0-1 - tremotesf-2.8.0 * Tue Jan 14 2025 Alexey Rochev - 2.7.5-1 - tremotesf-2.7.5 * Fri Dec 06 2024 Alexey Rochev - 2.7.4-1 - tremotesf-2.7.4 * Wed Nov 20 2024 Alexey Rochev - 2.7.3-1 - tremotesf-2.7.3 * Sun Sep 15 2024 Alexey Rochev - 2.7.2-1 - tremotesf-2.7.2 * Fri Sep 13 2024 Alexey Rochev - 2.7.1-1 - tremotesf-2.7.1 * Sat Aug 31 2024 Alexey Rochev - 2.7.0-1 - tremotesf-2.7.0 * Mon Apr 22 2024 Alexey Rochev - 2.6.3-1 - tremotesf-2.6.3 * Mon Apr 01 2024 Alexey Rochev - 2.6.2-1 - tremotesf-2.6.2 * Sun Mar 17 2024 Alexey Rochev - 2.6.1-1 - tremotesf-2.6.1 * Mon Jan 08 2024 Alexey Rochev - 2.6.0-1 - tremotesf-2.6.0 * Sun Oct 15 2023 Alexey Rochev - 2.5.0-1 - tremotesf-2.5.0 * Tue May 30 2023 Alexey Rochev - 2.4.0-1 - tremotesf-2.4.0 * Sun Apr 30 2023 Alexey Rochev - 2.3.0-1 - tremotesf-2.3.0 * Tue Mar 28 2023 Alexey Rochev - 2.2.0-1 - tremotesf-2.2.0 * Sun Mar 12 2023 Alexey Rochev - 2.1.0-1 - tremotesf-2.1.0 * Sat Nov 05 2022 Alexey Rochev - 2.0.0-1 - tremotesf-2.0.0 * Fri Mar 18 2022 Alexey Rochev - 1.11.3-1 - tremotesf-1.11.3 * Wed Mar 16 2022 Alexey Rochev - 1.11.2-1 - tremotesf-1.11.2 * Mon Feb 28 2022 Alexey Rochev - 1.11.1-1 - tremotesf-1.11.1 * Sun Feb 13 2022 Alexey Rochev - 1.11.0-1 - tremotesf-1.11.0 * Mon Sep 27 2021 Alexey Rochev - 1.10.0-1 - tremotesf-1.10.0 * Mon May 10 2021 Alexey Rochev - 1.9.1-1 - tremotesf-1.9.1 * Tue May 04 2021 Alexey Rochev - 1.9.0-1 - tremotesf-1.9.0 * Sun Sep 06 2020 Alexey Rochev - 1.8.0-1 - tremotesf-1.8.0 * Sat Jun 27 2020 Alexey Rochev - 1.7.1-1 - tremotesf-1.7.1 * Fri Jun 05 2020 Alexey Rochev - 1.7.0-1 - tremotesf-1.7.0 * Sat Jan 11 2020 Alexey Rochev - 1.6.4-1 - tremotesf-1.6.4 * Sun Jan 05 2020 Alexey Rochev - 1.6.3-1 - tremotesf-1.6.3 * Sun Jan 05 2020 Alexey Rochev - 1.6.2-1 - tremotesf-1.6.2 * Tue Jul 16 2019 Alexey Rochev - 1.6.1-1 - tremotesf-1.6.1 * Sat Jan 26 2019 Alexey Rochev - 1.6.0-1 - tremotesf-1.6.0 * Sat Dec 08 2018 Alexey Rochev - 1.5.6-1 - tremotesf-1.5.6 * Wed Sep 26 2018 Alexey Rochev - 1.5.5-1 - tremotesf-1.5.5 * Mon Sep 10 2018 Alexey Rochev - 1.5.4-1 - tremotesf-1.5.4 * Mon Sep 03 2018 Alexey Rochev - 1.5.3-1 - tremotesf-1.5.3 * Sat Aug 18 2018 Alexey Rochev - 1.5.2-1 - tremotesf-1.5.2 * Tue Aug 14 2018 Alexey Rochev - 1.5.1-1 - tremotesf-1.5.1 * Mon Aug 13 2018 Alexey Rochev - 1.5.0-1 - tremotesf-1.5.0 tremotesf-2.8.2/packaging/windows/000077500000000000000000000000001500171105600171455ustar00rootroot00000000000000tremotesf-2.8.2/packaging/windows/CMakeLists.txt000066400000000000000000000012671500171105600217130ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 include("../CPackCommon.cmake") set(CPACK_GENERATOR ZIP WIX) set(CPACK_PACKAGE_NAME "Tremotesf") set(CPACK_PACKAGE_VENDOR "Tremotesf") set(CPACK_PACKAGE_EXECUTABLES "tremotesf;Tremotesf") set(CPACK_PACKAGE_INSTALL_DIRECTORY "Tremotesf") set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_CURRENT_SOURCE_DIR}/license.rtf") set(CPACK_WIX_UPGRADE_GUID "67e0511b-d9de-4b3c-a604-0dd47522d451") set(CPACK_WIX_PRODUCT_ICON "${TREMOTESF_WINDOWS_ICON}") set(CPACK_WIX_EXTRA_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/registry.wxs") set(CPACK_WIX_PATCH_FILE "${CMAKE_CURRENT_SOURCE_DIR}/registry_feature_patch.wxs") include(CPack) tremotesf-2.8.2/packaging/windows/license.rtf000066400000000000000000001103201500171105600213010ustar00rootroot00000000000000{\rtf1\ansi\deff0\nouicompat{\fonttbl{\f0\fswiss Helvetica;}{\f1\fswiss\fcharset0 Helvetica;}{\f2\fnil Consolas;}} {\colortbl ;\red0\green0\blue255;} {\*\generator Riched20 10.0.22621}\viewkind4\uc1 \pard\sa180\b\f0\fs22\lang9 GNU GENERAL PUBLIC LICENSE\par \b0\fs20 Version 3, 29 June 2007\par Copyright \f1\'a9 2007 Free Software Foundation, Inc. <{{\field{\*\fldinst{HYPERLINK "http://fsf.org/"}}{\fldrslt{http://fsf.org/\ul0\cf0}}}}\f1\fs20 >\par \f0 Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.\par \b\fs22 Preamble\par \b0\fs20 The GNU General Public License is a free, copyleft license for software and other kinds of works.\par The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.\par When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.\par To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.\par For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.\par Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.\par For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.\par Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.\par Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.\par The precise terms and conditions for copying, distribution and modification follow.\par \b\fs22 TERMS AND CONDITIONS\par \fs20 0. Definitions.\par \b0\ldblquote This License\rdblquote refers to version 3 of the GNU General Public License.\par \ldblquote Copyright\rdblquote also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.\par \ldblquote The Program\rdblquote refers to any copyrightable work licensed under this License. Each licensee is addressed as \ldblquote you\rdblquote . \ldblquote Licensees\rdblquote and \ldblquote recipients\rdblquote may be individuals or organizations.\par To \ldblquote modify\rdblquote a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a \ldblquote modified version\rdblquote of the earlier work or a work \ldblquote based on\rdblquote the earlier work.\par A \ldblquote covered work\rdblquote means either the unmodified Program or a work based on the Program.\par To \ldblquote propagate\rdblquote a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.\par To \ldblquote convey\rdblquote a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.\par An interactive user interface displays \ldblquote Appropriate Legal Notices\rdblquote to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.\par \b 1. Source Code.\par \b0 The \ldblquote source code\rdblquote for a work means the preferred form of the work for making modifications to it. \ldblquote Object code\rdblquote means any non-source form of a work.\par A \ldblquote Standard Interface\rdblquote means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.\par The \ldblquote System Libraries\rdblquote of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A \ldblquote Major Component\rdblquote , in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.\par The \ldblquote Corresponding Source\rdblquote for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.\par The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.\par The Corresponding Source for a work in source code form is that same work.\par \b 2. Basic Permissions.\par \b0 All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.\par You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.\par Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.\par \b 3. Protecting Users' Legal Rights From Anti-Circumvention Law.\par \b0 No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.\par When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.\par \b 4. Conveying Verbatim Copies.\par \b0 You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.\par You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.\par \b 5. Conveying Modified Source Versions.\par \b0 You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:\par \pard\fi-360\li360\tx360\bullet\tab a) The work must carry prominent notices stating that you modified it, and giving a relevant date.\par \bullet\tab b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to \ldblquote keep intact all notices\rdblquote .\par \bullet\tab c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.\par \pard\fi-360\li360\sa180\tx360\bullet\tab d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.\par \pard\sa180 A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an \ldblquote aggregate\rdblquote if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.\par \b 6. Conveying Non-Source Forms.\par \b0 You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:\par \pard\fi-360\li360\tx360\bullet\tab a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.\par \bullet\tab b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.\par \bullet\tab c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.\par \bullet\tab d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.\par \pard\fi-360\li360\sa180\tx360\bullet\tab e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.\par \pard\sa180 A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.\par A \ldblquote User Product\rdblquote is either (1) a \ldblquote consumer product\rdblquote , which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, \ldblquote normally used\rdblquote refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.\par \ldblquote Installation Information\rdblquote for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.\par If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).\par The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.\par Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.\par \b 7. Additional Terms.\par \b0\ldblquote Additional permissions\rdblquote are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.\par When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.\par Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:\par \pard\fi-360\li360\tx360\bullet\tab a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or\par \bullet\tab b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or\par \bullet\tab c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or\par \bullet\tab d) Limiting the use for publicity purposes of names of licensors or authors of the material; or\par \bullet\tab e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or\par \pard\fi-360\li360\sa180\tx360\bullet\tab f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.\par \pard\sa180 All other non-permissive additional terms are considered \ldblquote further restrictions\rdblquote within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.\par If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.\par Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.\par \b 8. Termination.\par \b0 You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).\par However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.\par Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.\par Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.\par \b 9. Acceptance Not Required for Having Copies.\par \b0 You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.\par \b 10. Automatic Licensing of Downstream Recipients.\par \b0 Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.\par An \ldblquote entity transaction\rdblquote is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.\par You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.\par \b 11. Patents.\par \b0 A \ldblquote contributor\rdblquote is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's \ldblquote contributor version\rdblquote .\par A contributor's \ldblquote essential patent claims\rdblquote are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, \ldblquote control\rdblquote includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.\par Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.\par In the following three paragraphs, a \ldblquote patent license\rdblquote is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To \ldblquote grant\rdblquote such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.\par If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. \ldblquote Knowingly relying\rdblquote means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.\par If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.\par A patent license is \ldblquote discriminatory\rdblquote if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.\par Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.\par \b 12. No Surrender of Others' Freedom.\par \b0 If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.\par \b 13. Use with the GNU Affero General Public License.\par \b0 Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.\par \b 14. Revised Versions of this License.\par \b0 The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.\par Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License \ldblquote or any later version\rdblquote applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.\par If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.\par Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.\par \b 15. Disclaimer of Warranty.\par \b0 THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \ldblquote AS IS\rdblquote WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.\par \b 16. Limitation of Liability.\par \b0 IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.\par \b 17. Interpretation of Sections 15 and 16.\par \b0 If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.\par END OF TERMS AND CONDITIONS\par \b\fs22 How to Apply These Terms to Your New Programs\par \b0\fs20 If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.\par To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the \ldblquote copyright\rdblquote line and a pointer to where the full notice is found.\par \f2 \line Copyright (C) \line\line This program is free software: you can redistribute it and/or modify\line it under the terms of the GNU General Public License as published by\line the Free Software Foundation, either version 3 of the License, or\line (at your option) any later version.\line\line This program is distributed in the hope that it will be useful,\line but WITHOUT ANY WARRANTY; without even the implied warranty of\line MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\line GNU General Public License for more details.\line\line You should have received a copy of the GNU General Public License\line along with this program. If not, see <{{\field{\*\fldinst{HYPERLINK "http://www.gnu.org/licenses/"}}{\fldrslt{http://www.gnu.org/licenses/\ul0\cf0}}}}\f2\fs20 >.\par \f0 Also add information on how to contact you by electronic and paper mail.\par If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:\par \f2 Copyright (C) \line This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\line This is free software, and you are welcome to redistribute it\line under certain conditions; type `show c' for details.\par \f0 The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an \ldblquote about box\rdblquote .\par You should also get your employer (if you work as a programmer) or school, if any, to sign a \ldblquote copyright disclaimer\rdblquote for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <{{\field{\*\fldinst{HYPERLINK "http://www.gnu.org/licenses/"}}{\fldrslt{http://www.gnu.org/licenses/\ul0\cf0}}}}\f0\fs20 >.\par The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <{{\field{\*\fldinst{HYPERLINK "http://www.gnu.org/philosophy/why-not-lgpl.html"}}{\fldrslt{http://www.gnu.org/philosophy/why-not-lgpl.html\ul0\cf0}}}}\f0\fs20 >.\par } tremotesf-2.8.2/packaging/windows/license.rtf.license000066400000000000000000000001641500171105600227260ustar00rootroot00000000000000SPDX-FileCopyrightText: 2007 Free Software Foundation, Inc. SPDX-License-Identifier: CC-BY-ND-4.0 tremotesf-2.8.2/packaging/windows/registry.wxs000066400000000000000000000104601500171105600215610ustar00rootroot00000000000000 tremotesf-2.8.2/packaging/windows/registry_feature_patch.wxs000066400000000000000000000006211500171105600244510ustar00rootroot00000000000000 tremotesf-2.8.2/src/000077500000000000000000000000001500171105600143165ustar00rootroot00000000000000tremotesf-2.8.2/src/CMakeLists.txt000066400000000000000000000406511500171105600170640ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 include(CTest) set(CMAKE_INCLUDE_CURRENT_DIR ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) find_package(Threads REQUIRED) find_package(fmt 9.1 REQUIRED) find_package(cxxopts 3.1.1 REQUIRED) find_package(Qt${TREMOTESF_QT_VERSION_MAJOR} ${TREMOTESF_MINIMUM_QT_VERSION} REQUIRED COMPONENTS Core Network Widgets) if (TREMOTESF_QT6) set(minimum_kf_version 5.245) else() set(minimum_kf_version 5.103) endif() find_package(KF${TREMOTESF_QT_VERSION_MAJOR}WidgetsAddons ${minimum_kf_version} REQUIRED) find_package(PkgConfig REQUIRED) if (WIN32 OR APPLE) set(registrable_domain_qt ON) else () set(registrable_domain_qt OFF) pkg_check_modules(libpsl REQUIRED IMPORTED_TARGET "libpsl >= 0.21.2") endif () list(APPEND QRC_FILES resources.qrc) add_library(tremotesf_objects OBJECT) target_sources(tremotesf_objects PRIVATE bencodeparser.cpp desktoputils.cpp filemanagerlauncher.cpp fileutils.cpp formatutils.cpp magnetlinkparser.cpp settings.cpp torrentfileparser.cpp coroutines/coroutines.cpp coroutines/scope.cpp coroutines/waitall.cpp log/demangle.cpp log/formatters.cpp log/log.cpp rpc/addressutils.cpp rpc/mounteddirectoriesutils.cpp rpc/pathutils.cpp rpc/peer.cpp rpc/requestrouter.cpp rpc/rpc.cpp rpc/servers.cpp rpc/serversettings.cpp rpc/serverstats.cpp rpc/torrent.cpp rpc/torrentfile.cpp rpc/tracker.cpp startup/commandlineparser.cpp ui/itemmodels/baseproxymodel.cpp ui/itemmodels/basetorrentfilesmodel.cpp ui/itemmodels/stringlistmodel.cpp ui/itemmodels/torrentfilesmodelentry.cpp ui/itemmodels/torrentfilesproxymodel.cpp ui/notificationscontroller.cpp ui/savewindowstatedispatcher.cpp ui/screens/aboutdialog.cpp ui/screens/addtorrent/addtorrentdialog.cpp ui/screens/addtorrent/addtorrenthelpers.cpp ui/screens/addtorrent/droppedtorrents.cpp ui/screens/addtorrent/localtorrentfilesmodel.cpp ui/screens/connectionsettings/connectionsettingsdialog.cpp ui/screens/connectionsettings/servereditdialog.cpp ui/screens/connectionsettings/serversmodel.cpp ui/screens/mainwindow/alltrackersmodel.cpp ui/screens/mainwindow/basetorrentsfilterssettingsmodel.cpp ui/screens/mainwindow/downloaddirectoriesmodel.cpp ui/screens/mainwindow/editlabelsdialog.cpp ui/screens/mainwindow/labelsmodel.cpp ui/screens/mainwindow/mainwindow.cpp ui/screens/mainwindow/mainwindowsidebar.cpp ui/screens/mainwindow/mainwindowstatusbar.cpp ui/screens/mainwindow/mainwindowviewmodel.cpp ui/screens/mainwindow/statusfiltersmodel.cpp ui/screens/mainwindow/torrentsmodel.cpp ui/screens/mainwindow/torrentsproxymodel.cpp ui/screens/mainwindow/torrentsview.cpp ui/screens/serversettings/serversettingsdialog.cpp ui/screens/serverstatsdialog.cpp ui/screens/settingsdialog.cpp ui/screens/torrentproperties/peersmodel.cpp ui/screens/torrentproperties/torrentfilesmodel.cpp ui/screens/torrentproperties/torrentpropertiesdialog.cpp ui/screens/torrentproperties/torrentpropertieswidget.cpp ui/screens/torrentproperties/trackersmodel.cpp ui/screens/torrentproperties/trackersviewwidget.cpp ui/stylehelpers.cpp ui/widgets/basetreeview.cpp ui/widgets/commondelegate.cpp ui/widgets/editlabelswidget.cpp ui/widgets/listplaceholder.cpp ui/widgets/remotedirectoryselectionwidget.cpp ui/widgets/textinputdialog.cpp ui/widgets/torrentfilesview.cpp ui/widgets/torrentremotedirectoryselectionwidget.cpp ${QRC_FILES} PRIVATE FILE_SET HEADERS FILES bencodeparser.h desktoputils.h filemanagerlauncher.h fileutils.h formatutils.h itemlistupdater.h literals.h magnetlinkparser.h pragmamacros.h settings.h stdutils.h target_os.h macoshelpers.h torrentfileparser.h unixhelpers.h windowshelpers.h coroutines/coroutinefwd.h coroutines/coroutines.h coroutines/hostinfo.h coroutines/network.h coroutines/qobjectsignal.h coroutines/scope.h coroutines/threadpool.h coroutines/timer.h coroutines/waitall.h ipc/ipcclient.h ipc/ipcserver.h log/demangle.h log/formatters.h log/log.h rpc/addressutils.h rpc/jsonutils.h rpc/mounteddirectoriesutils.h rpc/pathutils.h rpc/peer.h rpc/requestrouter.h rpc/rpc.h rpc/servers.h rpc/serversettings.h rpc/serverstats.h rpc/torrent.h rpc/torrentfile.h rpc/tracker.h startup/commandlineparser.h startup/signalhandler.h ui/iconthemesetup.h ui/itemmodels/baseproxymodel.h ui/itemmodels/basetorrentfilesmodel.h ui/itemmodels/modelutils.h ui/itemmodels/stringlistmodel.h ui/itemmodels/torrentfilesmodelentry.h ui/itemmodels/torrentfilesproxymodel.h ui/notificationscontroller.h ui/savewindowstatedispatcher.h ui/screens/aboutdialog.h ui/screens/addtorrent/addtorrentdialog.h ui/screens/addtorrent/addtorrenthelpers.h ui/screens/addtorrent/droppedtorrents.h ui/screens/addtorrent/localtorrentfilesmodel.h ui/screens/connectionsettings/connectionsettingsdialog.h ui/screens/connectionsettings/servereditdialog.h ui/screens/connectionsettings/serversmodel.h ui/screens/mainwindow/alltrackersmodel.h ui/screens/mainwindow/basetorrentsfilterssettingsmodel.h ui/screens/mainwindow/downloaddirectoriesmodel.h ui/screens/mainwindow/editlabelsdialog.h ui/screens/mainwindow/labelsmodel.h ui/screens/mainwindow/mainwindow.h ui/screens/mainwindow/mainwindowsidebar.h ui/screens/mainwindow/mainwindowstatusbar.h ui/screens/mainwindow/mainwindowviewmodel.h ui/screens/mainwindow/statusfiltersmodel.h ui/screens/mainwindow/torrentsmodel.h ui/screens/mainwindow/torrentsproxymodel.h ui/screens/mainwindow/torrentsview.h ui/screens/serversettings/serversettingsdialog.h ui/screens/serverstatsdialog.h ui/screens/settingsdialog.h ui/screens/torrentproperties/peersmodel.h ui/screens/torrentproperties/torrentfilesmodel.h ui/screens/torrentproperties/torrentpropertiesdialog.h ui/screens/torrentproperties/torrentpropertieswidget.h ui/screens/torrentproperties/trackersmodel.h ui/screens/torrentproperties/trackersviewwidget.h ui/stylehelpers.h ui/systemcolorsprovider.h ui/widgets/basetreeview.h ui/widgets/commondelegate.h ui/widgets/editlabelswidget.h ui/widgets/listplaceholder.h ui/widgets/remotedirectoryselectionwidget.h ui/widgets/textinputdialog.h ui/widgets/torrentfilesview.h ui/widgets/torrentremotedirectoryselectionwidget.h ) target_link_libraries(tremotesf_objects PUBLIC cxxopts::cxxopts Threads::Threads fmt::fmt Qt::Core Qt::Network Qt::Widgets KF${TREMOTESF_QT_VERSION_MAJOR}::WidgetsAddons) # We need these as CMake variables for configure_file() call below set(TREMOTESF_APP_NAME "Tremotesf") set(TREMOTESF_EXECUTABLE_NAME "tremotesf") target_compile_definitions( tremotesf_objects PUBLIC TREMOTESF_EXECUTABLE_NAME="${TREMOTESF_EXECUTABLE_NAME}" TREMOTESF_APP_ID="org.equeim.Tremotesf" TREMOTESF_APP_NAME="${TREMOTESF_APP_NAME}" TREMOTESF_VERSION="${PROJECT_VERSION}" FMT_VERSION_MAJOR=${fmt_VERSION_MAJOR} ) if (registrable_domain_qt) target_compile_definitions(tremotesf_objects PUBLIC TREMOTESF_REGISTRABLE_DOMAIN_QT) else () target_link_libraries(tremotesf_objects PUBLIC PkgConfig::libpsl) endif () if (UNIX) target_sources( tremotesf_objects PRIVATE unixhelpers.cpp startup/signalhandler_unix.cpp ) if (TREMOTESF_UNIX_FREEDESKTOP) find_package(Qt${TREMOTESF_QT_VERSION_MAJOR} ${TREMOTESF_MINIMUM_QT_VERSION} REQUIRED COMPONENTS DBus) find_package(KF${TREMOTESF_QT_VERSION_MAJOR}WindowSystem ${minimum_kf_version} REQUIRED) target_link_libraries(tremotesf_objects PUBLIC Qt::DBus KF${TREMOTESF_QT_VERSION_MAJOR}::WindowSystem) qt_add_dbus_adaptor( dbus_generated ipc/org.freedesktop.Application.xml ipc/ipcserver_dbus_service.h tremotesf::IpcDbusService tremotesf_dbus_generated/ipc/org.freedesktop.Application.adaptor OrgFreedesktopApplicationAdaptor ) qt_add_dbus_interface( dbus_generated ipc/org.freedesktop.Application.xml tremotesf_dbus_generated/ipc/org.freedesktop.Application ) qt_add_dbus_interface( dbus_generated org.freedesktop.portal.Notification.xml tremotesf_dbus_generated/org.freedesktop.portal.Notification ) qt_add_dbus_interface( dbus_generated org.freedesktop.Notifications.xml tremotesf_dbus_generated/org.freedesktop.Notifications ) qt_add_dbus_interface( dbus_generated org.freedesktop.FileManager1.xml tremotesf_dbus_generated/org.freedesktop.FileManager1 ) target_sources( tremotesf_objects PRIVATE filemanagerlauncher_freedesktop.cpp ipc/ipcclient_dbus.cpp ipc/ipcserver_dbus.cpp ipc/ipcserver_dbus_service.cpp ui/iconthemesetup_freedesktop.cpp ui/notificationscontroller_freedesktop.cpp ${dbus_generated} PRIVATE FILE_SET HEADERS FILES coroutines/dbus.h ipc/ipcserver_dbus.h ipc/ipcserver_dbus_service.h ) else () target_sources( tremotesf_objects PRIVATE ipc/ipcclient_socket.cpp ipc/ipcserver_socket.cpp ui/notificationscontroller_fallback.cpp PRIVATE FILE_SET HEADERS FILES ipc/ipcserver_socket.h ) if (APPLE) target_sources( tremotesf_objects PRIVATE macoshelpers.mm filemanagerlauncher_macos.mm ipc/fileopeneventhandler.cpp PRIVATE FILE_SET HEADERS FILES ipc/fileopeneventhandler.h ) else () target_sources( tremotesf_objects PRIVATE filemanagerlauncher_fallback.cpp ) endif () endif () elseif (WIN32) find_library(Dwmapi Dwmapi REQUIRED) find_library(RuntimeObject RuntimeObject REQUIRED) target_link_libraries(tremotesf_objects PUBLIC Qt::Svg "${Dwmapi}" "${RuntimeObject}") target_sources( tremotesf_objects PRIVATE filemanagerlauncher_windows.cpp ipc/ipcclient_socket.cpp ipc/ipcserver_socket.cpp startup/signalhandler_windows.cpp startup/windowsfatalerrorhandlers.cpp ui/darkthemeapplier_windows.cpp ui/notificationscontroller_fallback.cpp ui/systemcolorsprovider_windows.cpp windowshelpers.cpp PRIVATE FILE_SET HEADERS FILES ipc/ipcserver_socket.h startup/windowsfatalerrorhandlers.h ui/darkthemeapplier_windows.h ) set(TREMOTESF_WINDOWS_ICON "${CMAKE_CURRENT_SOURCE_DIR}/tremotesf.ico" PARENT_SCOPE) else () message(FATAL_ERROR "Unsupported target platform ${CMAKE_SYSTEM_NAME}") endif () add_executable(tremotesf startup/main.cpp) target_link_libraries(tremotesf PRIVATE tremotesf_objects) # Executable configuration on Windows if (WIN32) include("${CMAKE_SOURCE_DIR}/cmake/WindowsMinimumVersion.cmake") configure_file(tremotesf.rc.in tremotesf.rc @ONLY) configure_file(tremotesf.manifest.in tremotesf.manifest @ONLY) target_sources( tremotesf PRIVATE startup/main_windows.cpp startup/windowsmessagehandler.cpp "${CMAKE_CURRENT_BINARY_DIR}/tremotesf.rc" PRIVATE FILE_SET HEADERS FILES startup/main_windows.h startup/windowsmessagehandler.h ) if (MSVC) target_sources(tremotesf PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/tremotesf.manifest") else () target_sources(tremotesf PRIVATE tremotesf.manifest.rc) endif () if (MSVC) # For C++23's std::stacktrace set_source_files_properties(startup/windowsfatalerrorhandlers.cpp PROPERTIES COMPILE_OPTIONS "/std:c++latest") target_link_options(tremotesf PRIVATE "/PDBALTPATH:%_PDB%") endif() set_target_properties(tremotesf PROPERTIES WIN32_EXECUTABLE $>) endif () # Executable installation if (WIN32) set(bindir ".") elseif (APPLE) set(bindir "${TREMOTESF_MACOS_BUNDLE_NAME}.app/Contents/MacOS") else () set(bindir "${CMAKE_INSTALL_BINDIR}") endif () install(TARGETS tremotesf DESTINATION "${bindir}") if (WIN32 AND MSVC) install(FILES $ DESTINATION "${bindir}" OPTIONAL) endif () # Icons and Qt translations bundling if (WIN32 OR APPLE) target_sources( tremotesf PRIVATE startup/recoloringsvgiconengineplugin.cpp ui/iconthemesetup_bundled.cpp ui/recoloringsvgiconengine.cpp PRIVATE FILE_SET HEADERS FILES startup/recoloringsvgiconengineplugin.h ui/recoloringsvgiconengine.h ) target_compile_definitions( tremotesf PUBLIC TREMOTESF_BUNDLED_ICON_THEME="${TREMOTESF_BUNDLED_ICON_THEME}" TREMOTESF_USE_BUNDLED_QT_TRANSLATIONS QT_STATICPLUGIN ) find_package(Qt${TREMOTESF_QT_VERSION_MAJOR} ${TREMOTESF_MINIMUM_QT_VERSION} REQUIRED COMPONENTS Svg) target_link_libraries(tremotesf PUBLIC Qt::GuiPrivate Qt::Svg) set(exclude_plugins bearer iconengines imageformats) if (WIN32) list(APPEND exclude_plugins styles) endif () qt_import_plugins(tremotesf EXCLUDE_BY_TYPE ${exclude_plugins}) endif () if (BUILD_TESTING) find_package(Qt${TREMOTESF_QT_VERSION_MAJOR} ${TREMOTESF_MINIMUM_QT_VERSION} REQUIRED COMPONENTS Test) add_executable(itemlistupdater_test itemlistupdater_test.cpp) add_test(NAME itemlistupdater_test COMMAND itemlistupdater_test) target_link_libraries(itemlistupdater_test PRIVATE tremotesf_objects Qt::Test) add_executable(log_test log/log_test.cpp) add_test(NAME log_test COMMAND log_test) target_link_libraries(log_test PRIVATE tremotesf_objects Qt::Test) add_executable(demangle_test log/demangle_test.cpp) add_test(NAME demangle_test COMMAND demangle_test) target_link_libraries(demangle_test PRIVATE tremotesf_objects Qt::Test) if (NOT TREMOTESF_WITH_HTTPLIB STREQUAL "none") include("${CMAKE_SOURCE_DIR}/cmake/FindCppHttplib.cmake") find_cpp_httplib() add_executable(requestrouter_test rpc/requestrouter_test.cpp) target_compile_definitions(requestrouter_test PRIVATE TEST_DATA_PATH="${CMAKE_CURRENT_SOURCE_DIR}/rpc/test-data") add_test(NAME requestrouter_test COMMAND requestrouter_test) target_link_libraries(requestrouter_test PRIVATE tremotesf_objects Qt::Test) if (TARGET PkgConfig::httplib) target_link_libraries(requestrouter_test PRIVATE PkgConfig::httplib) else () target_link_libraries(requestrouter_test PRIVATE httplib::httplib) endif () endif() add_executable(pathutils_test rpc/pathutils_test.cpp) add_test(NAME pathutils_test COMMAND pathutils_test) target_link_libraries(pathutils_test PRIVATE tremotesf_objects Qt::Test) add_executable(tracker_test rpc/tracker_test.cpp) add_test(NAME tracker_test COMMAND tracker_test) target_link_libraries(tracker_test PRIVATE tremotesf_objects Qt::Test) add_executable(servers_test rpc/servers_test.cpp) add_test(NAME servers_test COMMAND servers_test) target_link_libraries(servers_test PRIVATE tremotesf_objects Qt::Test) add_executable(coroutines_test coroutines/coroutines_test.cpp) add_test(NAME coroutines_test COMMAND coroutines_test) target_link_libraries(coroutines_test PRIVATE tremotesf_objects Qt::Test) add_executable(magnetlinkparser_test magnetlinkparser_test.cpp) add_test(NAME magnetlinkparser_test COMMAND magnetlinkparser_test) target_link_libraries(magnetlinkparser_test PRIVATE tremotesf_objects Qt::Test) add_executable(torrentfileparser_test torrentfileparser_test.cpp) add_test(NAME torrentfileparser_test COMMAND torrentfileparser_test WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/test-torrents") target_link_libraries(torrentfileparser_test PRIVATE tremotesf_objects Qt::Test) endif () set_common_options_on_targets() tremotesf-2.8.2/src/authors.html000066400000000000000000000007351500171105600166760ustar00rootroot00000000000000

Alexey Rochev <equeim@gmail.com>
%1

LuK1337 <priv.luk@gmail.com>
%2

Buck Melanoma
%2

Alex Bell
%2

otaconix
%2

tremotesf-2.8.2/src/bencodeparser.cpp000066400000000000000000000230311500171105600176350ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "bencodeparser.h" #include #include #include #include #include #include #include #include "fileutils.h" namespace tremotesf::bencode { namespace { constexpr char integerPrefix = 'i'; constexpr char listPrefix = 'l'; constexpr char dictionaryPrefix = 'd'; constexpr char terminator = 'e'; constexpr char byteArraySeparator = ':'; // digits10 + 1 is maximum number of character needed to hold integer, +1 for minus sign, +1 for terminator character constexpr int integerBufferSize = std::numeric_limits::digits10 + 3; } template Expected Value::takeValue() && { return std::visit( [](auto&& value) -> Expected { using T = std::decay_t; if constexpr (std::same_as) { return std::forward(value); } else if constexpr (std::same_as && std::same_as) { auto string = QString::fromStdString(value); value.clear(); return string; } else { throw Error( Error::Type::Parsing, fmt::format("Value is not of {} type", getValueTypeName()) ); } }, std::move(mValue) ); } template Integer Value::takeValue() &&; template ByteArray Value::takeValue() &&; template List Value::takeValue() &&; template Dictionary Value::takeValue() &&; template QString Value::takeValue() &&; namespace { class Parser { public: explicit Parser(QFile& file, ReadRootDictionaryValueCallback onReadRootDictionaryValue) : mFile{file}, mOnReadRootDictionaryValue(std::move(onReadRootDictionaryValue)) {}; Value parse() { return parseValue(true); } private: Value parseValue(bool root = false) { const char byte = peekByte(); if (byte == integerPrefix) { return parseInteger(); } if (byte == listPrefix) { return parseList(); } if (byte == dictionaryPrefix) { return parseDictionary(root); } return parseByteArray(); } Dictionary parseDictionary(bool root) { return parseContainer([&](Dictionary& dict) { ByteArray key(parseByteArray()); const auto valuePos = mFile.pos(); Value value(parseValue()); if (root) { mOnReadRootDictionaryValue(key, valuePos, mFile.pos() - valuePos); } dict.emplace(std::move(key), std::move(value)); }); } List parseList() { return parseContainer([&](List& list) { list.push_back(parseValue()); }); } template ParseNextElement> Container parseContainer(ParseNextElement parseNextElement) { const auto containerPos = mFile.pos(); try { skipByte(); Container container{}; while (true) { if (peekByte() == terminator) { skipByte(); return container; } parseNextElement(container); } } catch (const Error& e) { std::throw_with_nested(Error( e.type(), fmt::format("Failed to parse {} at position {}", getValueTypeName(), containerPos) )); } } ByteArray parseByteArray() { const auto byteArrayPos = mFile.pos(); try { Integer size{}; try { size = readIntegerUntilTerminator(byteArraySeparator); } catch (const Error& e) { std::throw_with_nested(Error(e.type(), "Failed to parse byte array size")); } if (size < 0) { throw Error(Error::Type::Parsing, fmt::format("Incorrect byte array size {}", size)); } ByteArray byteArray(static_cast(size), 0); if (size != 0) { try { readBytes(mFile, byteArray); } catch (const QFileError&) { std::throw_with_nested(Error( Error::Type::Reading, fmt::format("Failed to read byte array data with size {}", size) )); } } return byteArray; } catch (const Error& e) { std::throw_with_nested( Error(e.type(), fmt::format("Failed to parse byte array at position {}", byteArrayPos)) ); } } Integer parseInteger() { const auto integerPos = mFile.pos(); try { skipByte(); return readIntegerUntilTerminator(terminator); } catch (const Error& e) { std::throw_with_nested( Error(e.type(), fmt::format("Failed to parse integer at position {}", integerPos)) ); } } Integer readIntegerUntilTerminator(char integerTerminator) { std::span peeked{}; try { peeked = peekBytes(mFile, mIntegerBuffer); } catch (const QFileError&) { std::throw_with_nested(Error(Error::Type::Reading, "Failed to read integer buffer")); } Integer integer{}; const auto result = std::from_chars(std::to_address(peeked.begin()), std::to_address(peeked.end()), integer); if (result.ec != std::errc{}) { throw Error( Error::Type::Parsing, fmt::format( "std::from_chars() failed with: {} (error code {} ({:#x}))", std::make_error_condition(result.ec).message(), static_cast>(result.ec), static_cast>>(result.ec) ) ); } if (result.ptr == std::to_address(peeked.end())) { throw Error( Error::Type::Parsing, fmt::format( "Didn't find integer terminator \"{}\", file is possibly truncated", integerTerminator ) ); } if (*result.ptr != integerTerminator) { throw Error( Error::Type::Parsing, fmt::format( R"(Integer terminator doesn't match: expected "{}", actual "{}")", integerTerminator, *result.ptr ) ); } const auto terminatorIndex = result.ptr - peeked.data(); skip(terminatorIndex + 1); return integer; } char peekByte() { std::array buffer{}; try { [[maybe_unused]] const auto peeked = peekBytes(mFile, buffer); } catch (const QFileError&) { std::throw_with_nested(Error(Error::Type::Reading, "Failed to peek byte")); } return buffer[0]; } void skipByte() { skip(1); } void skip(qint64 size) { try { skipBytes(mFile, size); } catch (const QFileError&) { std::throw_with_nested(Error(Error::Type::Reading, fmt::format("Failed to skip {} bytes", size))); } } QFile& mFile; ReadRootDictionaryValueCallback mOnReadRootDictionaryValue; std::array mIntegerBuffer{}; }; } Value parse(const QString& filePath, ReadRootDictionaryValueCallback onReadRootDictionaryValue) { QFile file(filePath); try { openFile(file, QIODevice::ReadOnly); } catch (const QFileError&) { std::throw_with_nested(Error(Error::Type::Reading, "Failed to open file")); } return parse(file, std::move(onReadRootDictionaryValue)); } Value parse(QFile& device, ReadRootDictionaryValueCallback onReadRootDictionaryValue) { return Parser(device, std::move(onReadRootDictionaryValue)).parse(); } } tremotesf-2.8.2/src/bencodeparser.h000066400000000000000000000076121500171105600173110ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_BENCODEPARSER_H #define TREMOTESF_BENCODEPARSER_H #include #include #include #include #include #include #include #include #include class QFile; namespace tremotesf::bencode { class Value; using Integer = int64_t; using ByteArray = std::string; using List = std::list; struct DictionaryComparator { using is_transparent = void; inline bool operator()(const ByteArray& key1, const ByteArray& key2) const { return key1 < key2; } inline bool operator()(std::string_view key1, const ByteArray& key2) const { return key1 < key2; } inline bool operator()(const ByteArray& key1, std::string_view key2) const { return key1 < key2; } }; using Dictionary = std::map; template concept ValueType = std::same_as || std::same_as || std::same_as || std::same_as || std::same_as; template inline constexpr const char* getValueTypeName() { if constexpr (std::same_as) { return "Integer"; } else if constexpr (std::same_as) { return "ByteArray"; } else if constexpr (std::same_as) { return "List"; } else if constexpr (std::same_as) { return "Dictionary"; } else if constexpr (std::same_as) { return "String"; } } class Value { public: Value() = default; inline Value(Integer value) : mValue{value} {} inline Value(ByteArray&& value) : mValue{std::move(value)} {} inline Value(List&& value) : mValue{std::move(value)} {} inline Value(Dictionary&& value) : mValue{std::move(value)} {} inline Integer takeInteger() &&; inline ByteArray takeByteArray() &&; inline QString takeString() &&; inline List takeList() &&; inline Dictionary takeDictionary() &&; template Expected takeValue() &&; private: using Variant = std::variant; Variant mValue{}; }; extern template Integer Value::takeValue() &&; extern template ByteArray Value::takeValue() &&; extern template List Value::takeValue() &&; extern template Dictionary Value::takeValue() &&; extern template QString Value::takeValue() &&; inline Integer Value::takeInteger() && { return std::move(*this).takeValue(); } inline ByteArray Value::takeByteArray() && { return std::move(*this).takeValue(); } inline QString Value::takeString() && { return std::move(*this).takeValue(); } inline List Value::takeList() && { return std::move(*this).takeValue(); } inline Dictionary Value::takeDictionary() && { return std::move(*this).takeValue(); } class Error : public std::runtime_error { public: enum class Type { Reading, Parsing }; inline explicit Error(Type type, const char* what) : std::runtime_error(what), mType(type) {} inline explicit Error(Type type, const std::string& what) : std::runtime_error(what), mType(type) {} inline Type type() const { return mType; } private: Type mType; }; using ReadRootDictionaryValueCallback = std::function; Value parse(const QString& filePath, ReadRootDictionaryValueCallback onReadRootDictionaryValue); Value parse(QFile& device, ReadRootDictionaryValueCallback onReadRootDictionaryValue); } #endif // TREMOTESF_BENCODEPARSER_H tremotesf-2.8.2/src/coroutines/000077500000000000000000000000001500171105600165105ustar00rootroot00000000000000tremotesf-2.8.2/src/coroutines/coroutinefwd.h000066400000000000000000000007551500171105600214000ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_COROUTINEFWD_H #define TREMOTESF_COROUTINEFWD_H #include namespace tremotesf { namespace impl { template concept CoroutineReturnValue = std::same_as || std::movable; class StandaloneCoroutine; } template class Coroutine; } #endif // TREMOTESF_COROUTINEFWD_H tremotesf-2.8.2/src/coroutines/coroutines.cpp000066400000000000000000000067141500171105600214160ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "coroutines.h" #include "log/log.h" namespace tremotesf::impl { void CoroutinePromiseBase::setParentCoroutineHandle(std::coroutine_handle<> parentCoroutineHandle) { mParentCoroutineHandle = parentCoroutineHandle; } void CoroutinePromiseBase::interruptChildAwaiter() { std::visit( [&](auto& callback) { using Type = std::decay_t; if constexpr (std::same_as) { mOwningStandaloneCoroutine->completeCancellation(); } else if constexpr (std::same_as>) { callback(); } else if constexpr (std::same_as) { } }, mChildAwaiterInterruptionCallback ); } bool CoroutinePromiseBase::onStartedAwaiting(JustCompleteCancellation) { if (mOwningStandaloneCoroutine->completeCancellation()) { return false; } mChildAwaiterInterruptionCallback = JustCompleteCancellation{}; return true; } bool CoroutinePromiseBase::onStartedAwaiting(std::function&& interruptionCallback) { if (mOwningStandaloneCoroutine->completeCancellation()) { return false; } mChildAwaiterInterruptionCallback = std::move(interruptionCallback); return true; } void CoroutinePromiseBase::abortNoParent(std::coroutine_handle<> handle) { fatal().log("No parent coroutine when completing coroutine {}", handle.address()); Q_UNREACHABLE(); } void CoroutinePromise::invokeCompletionCallbackForStandaloneCoroutine() { // Completion callback will destroy Coroutine<> object, but coroutine itself will be destroyed later by compiler's injected machinery // because CoroutinePromiseFinalSuspendAwaiter::await_ready will return false // Pass true for coroutineWillBeDestroyedAutomatically parameter here so that Coroutine<>'s destructor won't destroy coroutine resulting in double free mOwningStandaloneCoroutine->invokeCompletionCallback(std::move(mUnhandledException), true); } void StandaloneCoroutine::cancel() { if (mCancellationState != CancellationState::NotCancelled) { return; } mCancellationState = CancellationState::Cancelling; mCoroutine.mHandle.promise().interruptChildAwaiter(); } bool StandaloneCoroutine::completeCancellation() { switch (mCancellationState) { case CancellationState::NotCancelled: return false; case CancellationState::Cancelling: mCancellationState = CancellationState::Cancelled; invokeCompletionCallback({}, false); return true; case CancellationState::Cancelled: return true; } return false; } void StandaloneCoroutine::invokeCompletionCallback( std::exception_ptr&& unhandledException, bool coroutineWillBeDestroyedAutomatically ) { if (coroutineWillBeDestroyedAutomatically) { mCoroutine.mHandle = nullptr; } mCompletionCallback(std::move(unhandledException)); } void StandaloneCoroutine::setCompletionCallback(std::function&& callback) { mCompletionCallback = std::move(callback); } } tremotesf-2.8.2/src/coroutines/coroutines.h000066400000000000000000000266271500171105600210700ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_COROUTINES_H #define TREMOTESF_COROUTINES_H #include #include #include #include #include #include #if __has_include() # include #else # include #endif #include "coroutinefwd.h" namespace tremotesf { namespace impl { template class CoroutinePromise; template class CoroutineAwaiter; } template class [[nodiscard]] Coroutine { public: using promise_type = impl::CoroutinePromise; inline explicit Coroutine(std::coroutine_handle> handle) : mHandle(std::move(handle)) {} inline Coroutine(Coroutine&& other) noexcept : mHandle(std::exchange(other.mHandle, nullptr)) {} inline Coroutine& operator=(Coroutine&& other) noexcept { mHandle = std::exchange(other.mHandle, nullptr); return *this; } Q_DISABLE_COPY(Coroutine) inline ~Coroutine() { if (mHandle) { mHandle.destroy(); } } inline impl::CoroutineAwaiter operator co_await(); inline void* address() const { return mHandle.address(); } private: std::coroutine_handle> mHandle; friend class impl::StandaloneCoroutine; }; namespace impl { class CoroutinePromiseBase { public: inline ~CoroutinePromiseBase() = default; Q_DISABLE_COPY_MOVE(CoroutinePromiseBase) // promise object contract begin inline std::suspend_always initial_suspend() { return {}; } inline void unhandled_exception() { mUnhandledException = std::current_exception(); } // promise object contract end void interruptChildAwaiter(); struct JustCompleteCancellation {}; bool onStartedAwaiting(JustCompleteCancellation); bool onStartedAwaiting(std::function&& interruptionCallback); inline void onAboutToResume() { mChildAwaiterInterruptionCallback = std::monostate{}; } inline StandaloneCoroutine* owningStandaloneCoroutine() const { return mOwningStandaloneCoroutine; } inline void setOwningStandaloneCoroutine(StandaloneCoroutine* root) { mOwningStandaloneCoroutine = root; } void setParentCoroutineHandle(std::coroutine_handle<> parentCoroutineHandle); void rethrowException() { if (mUnhandledException) { std::rethrow_exception(mUnhandledException); } } protected: inline CoroutinePromiseBase() = default; [[noreturn]] static void abortNoParent(std::coroutine_handle<> handle); std::coroutine_handle<> mParentCoroutineHandle{}; std::variant> mChildAwaiterInterruptionCallback{}; std::exception_ptr mUnhandledException{}; StandaloneCoroutine* mOwningStandaloneCoroutine{}; }; class CoroutinePromiseFinalSuspendAwaiter final { public: explicit CoroutinePromiseFinalSuspendAwaiter(std::coroutine_handle<> parentCoroutine) : mParentCoroutine(std::move(parentCoroutine)) {} // If there is no parent coroutine then await_ready returns false which causes our coroutine to be destroyed // Otherwise control is transferred to parent coroutine, which destroys CoroutineAwaiter and therefore our coroutine inline bool await_ready() noexcept { return !mParentCoroutine; } std::coroutine_handle<> await_suspend(std::coroutine_handle<>) noexcept { return mParentCoroutine; } inline void await_resume() noexcept {} private: std::coroutine_handle<> mParentCoroutine; }; template class CoroutinePromise final : public CoroutinePromiseBase { public: inline CoroutinePromise() : mCoroutineHandle(std::coroutine_handle>::from_promise(*this)) {} // promise object contract begin inline Coroutine get_return_object() { return Coroutine(mCoroutineHandle); } inline void return_value(const T& valueToReturn) { mValue = valueToReturn; } inline void return_value(T&& valueToReturn) { mValue = std::move(valueToReturn); } inline CoroutinePromiseFinalSuspendAwaiter final_suspend() noexcept { if (mParentCoroutineHandle) { return CoroutinePromiseFinalSuspendAwaiter(mParentCoroutineHandle); } abortNoParent(mCoroutineHandle); } // promise object contract end inline T takeValueOrRethrowException() { rethrowException(); // NOLINTNEXTLINE(bugprone-unchecked-optional-access) T value = std::move(mValue).value(); mValue.reset(); return value; } private: std::coroutine_handle> mCoroutineHandle; std::optional mValue{}; }; template<> class CoroutinePromise final : public CoroutinePromiseBase { public: // promise object contract begin inline Coroutine get_return_object() { return Coroutine(std::coroutine_handle>::from_promise(*this)); } inline void return_void() {} inline CoroutinePromiseFinalSuspendAwaiter final_suspend() noexcept { if (mParentCoroutineHandle) { return CoroutinePromiseFinalSuspendAwaiter(mParentCoroutineHandle); } invokeCompletionCallbackForStandaloneCoroutine(); return CoroutinePromiseFinalSuspendAwaiter(nullptr); } // promise object contract end void invokeCompletionCallbackForStandaloneCoroutine(); inline void takeValueOrRethrowException() { rethrowException(); } }; template class CoroutineAwaiter final { public: inline explicit CoroutineAwaiter(std::coroutine_handle> handle) : mHandle(handle) {} inline ~CoroutineAwaiter() = default; Q_DISABLE_COPY_MOVE(CoroutineAwaiter) inline bool await_ready() { return false; } template inline std::coroutine_handle<> await_suspend(std::coroutine_handle parentCoroutineHandle) { if constexpr (std::derived_from) { mParentCoroutinePromise = &parentCoroutineHandle.promise(); if (!mParentCoroutinePromise->onStartedAwaiting([this] { mHandle.promise().interruptChildAwaiter(); })) { return std::noop_coroutine(); } mHandle.promise().setOwningStandaloneCoroutine(mParentCoroutinePromise->owningStandaloneCoroutine() ); } mHandle.promise().setParentCoroutineHandle(parentCoroutineHandle); return mHandle; } inline T await_resume() { if (mParentCoroutinePromise) { mParentCoroutinePromise->onAboutToResume(); } return mHandle.promise().takeValueOrRethrowException(); } private: std::coroutine_handle> mHandle; CoroutinePromiseBase* mParentCoroutinePromise{}; }; template requires(!std::same_as) [[nodiscard]] bool startAwaiting(std::coroutine_handle handle) { if constexpr (std::derived_from) { return handle.promise().onStartedAwaiting(CoroutinePromiseBase::JustCompleteCancellation{}); } else { return true; } } inline void resume(std::coroutine_handle<> handle, CoroutinePromiseBase* promise) { if (promise) { promise->onAboutToResume(); } handle.resume(); } template requires(!std::same_as) void resume(std::coroutine_handle handle) { CoroutinePromiseBase* promise{}; if constexpr (std::derived_from) { promise = &handle.promise(); } resume(handle, promise); } class StandaloneCoroutine { public: inline explicit StandaloneCoroutine(Coroutine&& coroutine) : mCoroutine(std::move(coroutine)) { mCoroutine.mHandle.promise().setOwningStandaloneCoroutine(this); } inline ~StandaloneCoroutine() = default; Q_DISABLE_COPY_MOVE(StandaloneCoroutine) inline void* address() const { return mCoroutine.address(); } inline void start() { mCoroutine.mHandle.resume(); } inline StandaloneCoroutine* rootCoroutine() const { return mRootCoroutine; } inline void setRootCoroutine(StandaloneCoroutine* coroutine) { mRootCoroutine = coroutine; } void cancel(); bool completeCancellation(); void invokeCompletionCallback( std::exception_ptr&& unhandledException, bool coroutineWillBeDestroyedAutomatically ); void setCompletionCallback(std::function&& callback); private: Coroutine mCoroutine; StandaloneCoroutine* mRootCoroutine{}; std::function mCompletionCallback{}; enum class CancellationState : char { NotCancelled, Cancelling, Cancelled }; CancellationState mCancellationState{CancellationState::NotCancelled}; }; class [[nodiscard]] CancellationAwaiter final { public: inline bool await_ready() { return false; } template Promise> inline void await_suspend(std::coroutine_handle handle) { if (startAwaiting(handle)) { handle.promise().owningStandaloneCoroutine()->rootCoroutine()->cancel(); } } inline void await_resume() {} }; } template impl::CoroutineAwaiter Coroutine::operator co_await() { return impl::CoroutineAwaiter(mHandle); } } #define cancelCoroutine() \ do { \ co_await tremotesf::impl::CancellationAwaiter{}; \ qFatal("CancellationAwaiter resumed"); /* CancellationAwaiter must never resume */ \ } while (false); #endif // TREMOTESF_COROUTINES_H tremotesf-2.8.2/src/coroutines/coroutines_test.cpp000066400000000000000000000220641500171105600224510ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include #include "coroutines/coroutines.h" #include "coroutines/scope.h" #include "coroutines/timer.h" #include "coroutines/waitall.h" using namespace std::chrono_literals; using std::chrono::duration_cast; using std::chrono::milliseconds; // NOLINTBEGIN(cppcoreguidelines-avoid-reference-coroutine-parameters) namespace tremotesf { class CoroutinesTest final : public QObject { Q_OBJECT private slots: void checkImmediateCoroutine() { CoroutineScope scope{}; bool executed{}; scope.launch(immediateCoroutine(executed)); QCOMPARE(scope.coroutinesCount(), 0); QVERIFY(executed); } void checkImmediateCoroutineCancellationFromInside() { CoroutineScope scope{}; bool executed{}; scope.launch(immediateCoroutineCancellingFromInside(executed)); QCOMPARE(scope.coroutinesCount(), 0); QVERIFY(!executed); } void checkImmediateCoroutineWithNested() { CoroutineScope scope{}; int value{}; scope.launch(immediateCoroutineWithNested(value)); QCOMPARE(scope.coroutinesCount(), 0); QCOMPARE(value, testReturnValue); } void checkImmediateCoroutineWithNestedCancellationFromInside() { CoroutineScope scope{}; int value{}; scope.launch(immediateCoroutineWithNestedCancellingFromInside(value)); QCOMPARE(scope.coroutinesCount(), 0); QCOMPARE(value, 0); } void checkSuspendingCoroutine() { CoroutineScope scope{}; bool executed{}; scope.launch(suspendingCoroutine(executed)); QCOMPARE(scope.coroutinesCount(), 1); waitTestTime(); QCOMPARE(scope.coroutinesCount(), 0); QVERIFY(executed); } void checkSuspendingCoroutineCancellation() { CoroutineScope scope{}; bool executed{}; scope.launch(suspendingCoroutine(executed)); QCOMPARE(scope.coroutinesCount(), 1); scope.cancelAll(); QCOMPARE(scope.coroutinesCount(), 0); QVERIFY(!executed); } void checkSuspendingCoroutineCancellationFromInside() { CoroutineScope scope{}; bool executed{}; scope.launch(suspendingCoroutine(executed)); QCOMPARE(scope.coroutinesCount(), 1); scope.cancelAll(); waitTestTime(); QCOMPARE(scope.coroutinesCount(), 0); QVERIFY(!executed); } void checkSuspendingWithNested() { CoroutineScope scope{}; int value{}; scope.launch(suspendingCoroutineWithNested(value)); QCOMPARE(scope.coroutinesCount(), 1); waitTestTime(); QCOMPARE(scope.coroutinesCount(), 0); QCOMPARE(value, testReturnValue); } void checkSuspendingWithNestedCancellation() { CoroutineScope scope{}; int value{}; scope.launch(suspendingCoroutineWithNested(value)); QCOMPARE(scope.coroutinesCount(), 1); scope.cancelAll(); QCOMPARE(scope.coroutinesCount(), 0); QCOMPARE(value, 0); } void checkWaitAllCoroutineImmediate() { CoroutineScope scope{}; WaitAllCoroutineSideEffects sideEffects{}; scope.launch(waitAllCoroutineImmediate(sideEffects)); QCOMPARE(scope.coroutinesCount(), 0); QVERIFY(sideEffects.executed1); QVERIFY(sideEffects.executed2); QVERIFY(sideEffects.executedAll); } void checkWaitAllCoroutineImmediateCancellationFromInside() { CoroutineScope scope{}; WaitAllCoroutineSideEffects sideEffects{}; scope.launch(waitAllCoroutineImmediateCancellingFromInside(sideEffects)); QCOMPARE(scope.coroutinesCount(), 0); QVERIFY(sideEffects.executed1); QVERIFY(!sideEffects.executed2); QVERIFY(!sideEffects.executedAll); } void checkWaitAllCoroutineSuspending() { CoroutineScope scope{}; WaitAllCoroutineSideEffects sideEffects{}; scope.launch(waitAllCoroutineSuspending(sideEffects)); QCOMPARE(scope.coroutinesCount(), 1); waitTestTime(); QCOMPARE(scope.coroutinesCount(), 0); QVERIFY(sideEffects.executed1); QVERIFY(sideEffects.executed2); QVERIFY(sideEffects.executedAll); } void checkWaitAllCoroutineSuspendingCancellation() { CoroutineScope scope{}; WaitAllCoroutineSideEffects sideEffects{}; scope.launch(waitAllCoroutineSuspending(sideEffects)); QCOMPARE(scope.coroutinesCount(), 1); scope.cancelAll(); QCOMPARE(scope.coroutinesCount(), 0); QVERIFY(!sideEffects.executed1); QVERIFY(!sideEffects.executed2); QVERIFY(!sideEffects.executedAll); } void checkWaitAllCoroutineSuspendingCancellationFromInside() { CoroutineScope scope{}; WaitAllCoroutineSideEffects sideEffects{}; scope.launch(waitAllCoroutineSuspendingCancellingFromInside(sideEffects)); QCOMPARE(scope.coroutinesCount(), 1); waitTestTime(); QCOMPARE(scope.coroutinesCount(), 0); QVERIFY(!sideEffects.executed1); QVERIFY(!sideEffects.executed2); QVERIFY(!sideEffects.executedAll); } private: static constexpr int testReturnValue = 42; static constexpr auto testWaitTime = 40ms; static constexpr auto testWaitTimeReduced = 20ms; void waitTestTime() { // Default QTimer guarantees 5% accuracy QTest::qWait(duration_cast(testWaitTime * 1.05).count() + 1); QCoreApplication::processEvents(); } Coroutine<> immediateCoroutine(bool& executed) { executed = true; co_return; } Coroutine<> immediateCoroutineCancellingFromInside(bool& executed) { cancelCoroutine(); executed = true; co_return; } Coroutine immediateCoroutineReturningValue() { co_return testReturnValue; } Coroutine<> immediateCoroutineWithNested(int& value) { value = co_await immediateCoroutineReturningValue(); } Coroutine immediateCoroutineReturningValueCancellingFromInside() { cancelCoroutine(); co_return testReturnValue; } Coroutine<> immediateCoroutineWithNestedCancellingFromInside(int& value) { value = co_await immediateCoroutineReturningValueCancellingFromInside(); } Coroutine<> suspendingCoroutine(bool& executed) { co_await waitFor(testWaitTime); executed = true; } Coroutine suspendingReturningValue() { co_await waitFor(testWaitTime); co_return testReturnValue; } Coroutine<> suspendingCoroutineWithNested(int& value) { value = co_await suspendingReturningValue(); } Coroutine<> suspendingCoroutineCancellingFromInside(bool& executed) { co_await waitFor(testWaitTimeReduced); cancelCoroutine(); executed = true; } struct WaitAllCoroutineSideEffects { bool executed1; bool executed2; bool executedAll; }; Coroutine<> waitAllCoroutineImmediate(WaitAllCoroutineSideEffects& sideEffects) { co_await waitAll(immediateCoroutine(sideEffects.executed1), immediateCoroutine(sideEffects.executed2)); sideEffects.executedAll = true; } Coroutine<> waitAllCoroutineImmediateCancellingFromInside(WaitAllCoroutineSideEffects& sideEffects) { co_await waitAll( immediateCoroutine(sideEffects.executed1), immediateCoroutineCancellingFromInside(sideEffects.executed2) ); sideEffects.executedAll = true; } Coroutine<> waitAllCoroutineSuspending(WaitAllCoroutineSideEffects& sideEffects) { co_await waitAll(suspendingCoroutine(sideEffects.executed1), suspendingCoroutine(sideEffects.executed2)); sideEffects.executedAll = true; } Coroutine<> waitAllCoroutineSuspendingCancellingFromInside(WaitAllCoroutineSideEffects& sideEffects) { co_await waitAll( suspendingCoroutine(sideEffects.executed1), suspendingCoroutineCancellingFromInside(sideEffects.executed2) ); sideEffects.executedAll = true; } }; } // NOLINTEND(cppcoreguidelines-avoid-reference-coroutine-parameters) QTEST_GUILESS_MAIN(tremotesf::CoroutinesTest) #include "coroutines_test.moc" tremotesf-2.8.2/src/coroutines/dbus.h000066400000000000000000000025021500171105600176150ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_COROUTINES_DBUS_H #define TREMOTESF_COROUTINES_DBUS_H #include #include "qobjectsignal.h" class QDBusPendingCallWatcher; namespace tremotesf { namespace impl { template class DbusReplyAwaitable final : public SignalAwaitable { public: inline explicit DbusReplyAwaitable(const QDBusPendingReply& reply) : SignalAwaitable(nullptr, &QDBusPendingCallWatcher::finished), mReply(reply) { mSender = &mWatcher; } inline ~DbusReplyAwaitable() = default; Q_DISABLE_COPY_MOVE(DbusReplyAwaitable) inline bool await_ready() { return mReply.isFinished(); } inline QDBusPendingReply await_resume() { return mReply; }; private: QDBusPendingReply mReply; QDBusPendingCallWatcher mWatcher{mReply}; }; } template inline impl::DbusReplyAwaitable operator co_await(const QDBusPendingReply& reply) { return impl::DbusReplyAwaitable(reply); } } #endif // TREMOTESF_COROUTINES_DBUS_H tremotesf-2.8.2/src/coroutines/hostinfo.h000066400000000000000000000032331500171105600205130ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_COROUTINES_HOSTINFO_H #define TREMOTESF_COROUTINES_HOSTINFO_H #include #include #include "coroutines.h" namespace tremotesf { namespace impl { class HostInfoAwaitable final { public: inline explicit HostInfoAwaitable(QString name) : mName(std::move(name)) {} inline ~HostInfoAwaitable() { if (mLookupId) { QHostInfo::abortHostLookup(*mLookupId); } }; Q_DISABLE_COPY_MOVE(HostInfoAwaitable) inline bool await_ready() { return false; } template inline void await_suspend(std::coroutine_handle handle) { if (!startAwaiting(handle)) { return; } mLookupId = QHostInfo::lookupHost(mName, &mReceiver, [this, handle](QHostInfo hostInfo) { mLookupId = std::nullopt; mResult = std::move(hostInfo); resume(handle); }); } // NOLINTNEXTLINE(bugprone-unchecked-optional-access) inline QHostInfo await_resume() { return std::move(mResult).value(); }; private: QString mName; QObject mReceiver{}; std::optional mLookupId{}; std::optional mResult{}; }; } inline impl::HostInfoAwaitable lookupHost(QString name) { return impl::HostInfoAwaitable(std::move(name)); } } #endif // TREMOTESF_COROUTINES_HOSTINFO_H tremotesf-2.8.2/src/coroutines/network.h000066400000000000000000000017351500171105600203600ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_COROUTINES_NETWORK_H #define TREMOTESF_COROUTINES_NETWORK_H #include #include "qobjectsignal.h" namespace tremotesf { namespace impl { class NetworkReplyAwaitable final : public SignalAwaitable { public: inline explicit NetworkReplyAwaitable(QNetworkReply* reply) : SignalAwaitable(reply, &QNetworkReply::finished) {} inline ~NetworkReplyAwaitable() = default; Q_DISABLE_COPY_MOVE(NetworkReplyAwaitable) inline bool await_ready() { return mSender->isFinished(); } private: QObject mReceiver{}; }; } inline impl::NetworkReplyAwaitable operator co_await(QNetworkReply& reply) { return impl::NetworkReplyAwaitable(&reply); } } #endif // TREMOTESF_COROUTINES_NETWORK_H tremotesf-2.8.2/src/coroutines/qobjectsignal.h000066400000000000000000000030231500171105600215040ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_COROUTINES_QOBJECTSIGNAL_H #define TREMOTESF_COROUTINES_QOBJECTSIGNAL_H #include #include "coroutines.h" namespace tremotesf { namespace impl { template class SignalAwaitable { public: inline explicit SignalAwaitable(const Object* sender, PointerToMemberFunction signal) : mSender(sender), mSignal(signal) {} inline ~SignalAwaitable() = default; Q_DISABLE_COPY_MOVE(SignalAwaitable) inline bool await_ready() { return false; } template inline void await_suspend(std::coroutine_handle handle) { if (startAwaiting(handle)) { QObject::connect(mSender, mSignal, &mReceiver, [handle] { resume(handle); }); } } inline void await_resume() {}; protected: const Object* mSender; PointerToMemberFunction mSignal; QObject mReceiver{}; }; } template Object, typename PointerToMemberFunction> requires(std::is_member_function_pointer_v) inline Coroutine<> waitForSignal(const Object* object, PointerToMemberFunction signal) { co_await impl::SignalAwaitable(object, signal); } } #endif // TREMOTESF_COROUTINES_QOBJECTSIGNAL_H tremotesf-2.8.2/src/coroutines/scope.cpp000066400000000000000000000043461500171105600203340ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "coroutines/scope.h" #include "coroutines/coroutines.h" #include "log/log.h" namespace tremotesf { namespace { void handleException(const std::exception_ptr& unhandledException) { if (!unhandledException) return; warning().log("Unhandled exception in coroutine"); // Make sure we terminate immediately try { std::rethrow_exception(unhandledException); } catch (...) { std::terminate(); } } } CoroutineScope::~CoroutineScope() { if (mCoroutines.empty()) return; for (auto* coroutine : mCoroutines) { // NOLINTNEXTLINE(bugprone-unhandled-exception-at-new) coroutine->setCompletionCallback([coroutine](const std::exception_ptr& unhandledException) { handleException(unhandledException); delete coroutine; }); coroutine->cancel(); } } void CoroutineScope::launch(Coroutine<> coroutine) { auto* root = new impl::StandaloneCoroutine(std::move(coroutine)); mCoroutines.push_back(root); root->setRootCoroutine(root); root->setCompletionCallback([this, root](const std::exception_ptr& unhandledException) { onCoroutineCompleted(root, unhandledException); }); root->start(); } void CoroutineScope::cancelAll() { // Copy vector to handle the case when coroutine completes immediately and is erased from vector while we are iterating for (auto* coroutine : std::vector(mCoroutines)) { coroutine->cancel(); } } void CoroutineScope::onCoroutineCompleted( impl::StandaloneCoroutine* coroutine, const std::exception_ptr& unhandledException ) { handleException(unhandledException); const auto found = std::ranges::find(mCoroutines, coroutine); if (found == mCoroutines.end()) { fatal().log("Did not find completed coroutine {} in CoroutineScope", coroutine->address()); Q_UNREACHABLE(); } mCoroutines.erase(found); delete coroutine; } } tremotesf-2.8.2/src/coroutines/scope.h000066400000000000000000000016271500171105600200000ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_COROUTINES_SCOPE_H #define TREMOTESF_COROUTINES_SCOPE_H #if __has_include() # include #else # include #endif #include #include #include "coroutinefwd.h" namespace tremotesf { class CoroutineScope { public: CoroutineScope() = default; ~CoroutineScope(); Q_DISABLE_COPY_MOVE(CoroutineScope) void launch(Coroutine<> coroutine); void cancelAll(); inline size_t coroutinesCount() const { return mCoroutines.size(); } private: void onCoroutineCompleted(impl::StandaloneCoroutine* coroutine, const std::exception_ptr& unhandledException); std::vector mCoroutines{}; }; } #endif // TREMOTESF_COROUTINES_SCOPE_H tremotesf-2.8.2/src/coroutines/threadpool.h000066400000000000000000000124671500171105600210340ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_COROUTINES_THREADPOOL_H #define TREMOTESF_COROUTINES_THREADPOOL_H #include #include #include #include #include "coroutines.h" #ifdef Q_OS_WIN # include "startup/windowsfatalerrorhandlers.h" #endif namespace tremotesf { namespace impl { template> class ThreadPoolAwaitable final { public: inline explicit ThreadPoolAwaitable(QThreadPool* threadPool, Function&& function) : mThreadPool(threadPool), mFunction(std::move(function)) {} inline ~ThreadPoolAwaitable() { if (mSharedData) { mSharedData->cancelled = true; } } Q_DISABLE_COPY_MOVE(ThreadPoolAwaitable) inline bool await_ready() { return false; } template inline void await_suspend(std::coroutine_handle handle) { if (!startAwaiting(handle)) { return; } mSharedData = std::make_shared(); mSharedData->coroutineHandle = handle; if constexpr (std::derived_from) { mSharedData->coroutinePromise = &handle.promise(); } mThreadPool->start(new Runnable(std::move(mFunction), mSharedData)); } T await_resume() { if (mSharedData->unhandledException) { std::rethrow_exception(mSharedData->unhandledException); } if constexpr (!std::is_void_v) { // NOLINTNEXTLINE(bugprone-unchecked-optional-access) return std::move(mSharedData->result).value(); } } private: struct Empty {}; using ResultValue = std::conditional_t, Empty, T>; struct SharedData { QObject receiver{}; std::atomic_bool cancelled{}; std::optional result{}; std::exception_ptr unhandledException{}; std::coroutine_handle<> coroutineHandle{}; CoroutinePromiseBase* coroutinePromise{}; }; class Runnable : public QRunnable { public: explicit Runnable(Function&& function, const std::shared_ptr& sharedData) : mFunction(std::move(function)), mSharedData(sharedData) {} inline ~Runnable() override = default; Q_DISABLE_COPY_MOVE(Runnable) void run() override { if (mSharedData->cancelled) { return; } #ifdef Q_OS_WIN windowsSetUpFatalErrorHandlersInThread(); #endif try { if constexpr (std::is_void_v) { mFunction(); } else { mSharedData->result = mFunction(); } } catch (...) { mSharedData->unhandledException = std::current_exception(); } if (mSharedData->cancelled) { return; } QMetaObject::invokeMethod(&(mSharedData->receiver), [sharedData = mSharedData] { if (!sharedData->cancelled) { resume(sharedData->coroutineHandle, sharedData->coroutinePromise); } }); } private: Function mFunction; std::shared_ptr mSharedData; }; QThreadPool* mThreadPool; Function mFunction; std::shared_ptr mSharedData{}; }; } template inline auto runOnThreadPool(QThreadPool* threadPool, Function&& function) { return impl::ThreadPoolAwaitable(threadPool, std::forward(function)); } template inline auto runOnThreadPool(Function&& function) { return runOnThreadPool(QThreadPool::globalInstance(), std::forward(function)); } template Function> requires(sizeof...(Args) != 0) inline auto runOnThreadPool(QThreadPool* threadPool, Function&& function, Args&&... args) { return impl::ThreadPoolAwaitable( threadPool, [function = std::forward(function), ... args = std::forward(args)]() mutable { return function(std::forward(args)...); } ); } template Function> requires(sizeof...(Args) != 0) inline auto runOnThreadPool(Function&& function, Args&&... args) { return runOnThreadPool( QThreadPool::globalInstance(), std::forward(function), std::forward(args)... ); } } #endif // TREMOTESF_COROUTINES_THREADPOOL_H tremotesf-2.8.2/src/coroutines/timer.h000066400000000000000000000023021500171105600177760ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_COROUTINES_TIMER_H #define TREMOTESF_COROUTINES_TIMER_H #include #include #include "coroutines.h" namespace tremotesf { namespace impl { class TimerAwaitable final { public: inline explicit TimerAwaitable(std::chrono::milliseconds duration) : mDuration(duration) {} inline ~TimerAwaitable() = default; Q_DISABLE_COPY_MOVE(TimerAwaitable) inline bool await_ready() { return mDuration.count() == 0; } template inline void await_suspend(std::coroutine_handle handle) { if (startAwaiting(handle)) { QTimer::singleShot(mDuration, &mReceiver, [handle] { resume(handle); }); } } inline void await_resume() {}; private: std::chrono::milliseconds mDuration; QObject mReceiver{}; }; } inline impl::TimerAwaitable waitFor(std::chrono::milliseconds duration) { return impl::TimerAwaitable(duration); } } #endif // TREMOTESF_COROUTINES_TIMER_H tremotesf-2.8.2/src/coroutines/waitall.cpp000066400000000000000000000062461500171105600206610ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "coroutines/waitall.h" #include "log/log.h" namespace tremotesf::impl { namespace { template inline std::vector getPointers(std::list& list) { std::vector pointers{}; pointers.reserve(list.size()); for (auto& item : list) { pointers.push_back(&item); } return pointers; } } void MultipleCoroutinesAwaiter::await_resume() { if (mUnhandledException) { std::rethrow_exception(mUnhandledException); } } void MultipleCoroutinesAwaiter::awaitSuspendImpl() { if (mParentCoroutinePromise && !mParentCoroutinePromise->onStartedAwaiting([this] { cancelAll(); })) { return; } // Copy pointers to handle the case when coroutine completes immediately and is erased from list while we are iterating for (auto* coroutine : getPointers(mCoroutines)) { if (mParentCoroutinePromise) { coroutine->setRootCoroutine(mParentCoroutinePromise->owningStandaloneCoroutine()->rootCoroutine()); } coroutine->setCompletionCallback([this, coroutine](std::exception_ptr unhandledException) { onCoroutineCompleted(coroutine, std::move(unhandledException)); }); coroutine->start(); } } void MultipleCoroutinesAwaiter::onCoroutineCompleted( StandaloneCoroutine* coroutine, std::exception_ptr unhandledException ) { if (unhandledException && !mUnhandledException) { mUnhandledException = std::move(unhandledException); } const auto found = std::ranges::find_if(mCoroutines, [coroutine](auto& c) { return &c == coroutine; }); if (found == mCoroutines.end()) { fatal().log("Did not find completed coroutine {} in MultipleCoroutinesAwaiter", coroutine->address()); Q_UNREACHABLE(); } mCoroutines.erase(found); if (mCancellingCoroutines) { return; } if (mCoroutines.empty()) { onAllCoroutinesCompleted(); } else if ((mUnhandledException || mCancelAfterFirst) && !mCancelledCoroutinesAndWaitingForCompletionCallbacks) { cancelAll(); } } void MultipleCoroutinesAwaiter::onAllCoroutinesCompleted() { if (mParentCoroutinePromise && mParentCoroutinePromise->owningStandaloneCoroutine()->completeCancellation()) { return; } resume(mParentCoroutineHandle, mParentCoroutinePromise); } void MultipleCoroutinesAwaiter::cancelAll() { mCancellingCoroutines = true; // Copy pointers to handle the case when coroutine is cancelled immediately and is erased from list while we are iterating for (auto* coroutine : getPointers(mCoroutines)) { coroutine->cancel(); } mCancellingCoroutines = false; mCancelledCoroutinesAndWaitingForCompletionCallbacks = true; if (mCoroutines.empty()) { onAllCoroutinesCompleted(); } } } tremotesf-2.8.2/src/coroutines/waitall.h000066400000000000000000000063511500171105600203230ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_COROUTINES_WAITALL_H #define TREMOTESF_COROUTINES_WAITALL_H #include #include "coroutines.h" namespace tremotesf { namespace impl { class MultipleCoroutinesAwaiter final { public: inline explicit MultipleCoroutinesAwaiter(std::vector>&& coroutines, bool cancelAfterFirst) : mCancelAfterFirst(cancelAfterFirst) { for (auto&& coroutine : std::move(coroutines)) { mCoroutines.emplace_back(std::move(coroutine)); } } template inline explicit MultipleCoroutinesAwaiter(bool cancelAfterFirst, Coroutines&&... coroutines) : mCancelAfterFirst(cancelAfterFirst) { (mCoroutines.emplace_back(std::forward(coroutines)), ...); } inline ~MultipleCoroutinesAwaiter() = default; Q_DISABLE_COPY_MOVE(MultipleCoroutinesAwaiter) inline bool await_ready() { return mCoroutines.empty(); } template inline void await_suspend(std::coroutine_handle parentCoroutineHandle) { mParentCoroutineHandle = parentCoroutineHandle; if constexpr (std::derived_from) { mParentCoroutinePromise = &parentCoroutineHandle.promise(); } awaitSuspendImpl(); } void await_resume(); private: void awaitSuspendImpl(); void onCoroutineCompleted(impl::StandaloneCoroutine* coroutine, std::exception_ptr unhandledException); void onAllCoroutinesCompleted(); void cancelAll(); std::list mCoroutines{}; bool mCancelAfterFirst; std::coroutine_handle<> mParentCoroutineHandle{}; CoroutinePromiseBase* mParentCoroutinePromise{}; // NOLINTNEXTLINE(bugprone-throw-keyword-missing) std::exception_ptr mUnhandledException{}; bool mCancellingCoroutines{}; bool mCancelledCoroutinesAndWaitingForCompletionCallbacks{}; }; } inline impl::MultipleCoroutinesAwaiter waitAll(std::vector>&& coroutines) { return impl::MultipleCoroutinesAwaiter(std::move(coroutines), false); } template>... Coroutines> requires(sizeof...(Coroutines) != 0) inline impl::MultipleCoroutinesAwaiter waitAll(Coroutines&&... coroutines) { return impl::MultipleCoroutinesAwaiter(false, std::forward(coroutines)...); } inline impl::MultipleCoroutinesAwaiter waitAny(std::vector>&& coroutines) { return impl::MultipleCoroutinesAwaiter(std::move(coroutines), true); } template>... Coroutines> requires(sizeof...(Coroutines) != 0) inline impl::MultipleCoroutinesAwaiter waitAny(Coroutines&&... coroutines) { return impl::MultipleCoroutinesAwaiter(true, std::forward(coroutines)...); } } #endif // TREMOTESF_COROUTINES_WAITALL_H tremotesf-2.8.2/src/desktoputils.cpp000066400000000000000000000116131500171105600175560ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "desktoputils.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "literals.h" #include "log/log.h" SPECIALIZE_FORMATTER_FOR_QDEBUG(QUrl) namespace tremotesf::desktoputils { const QIcon& statusIcon(StatusIcon icon) { switch (icon) { case ActiveIcon: { static const QIcon qicon(":/active.svg"_l1); return qicon; } case CheckingIcon: { static const QIcon qicon(":/checking.svg"_l1); return qicon; } case DownloadingIcon: { static const QIcon qicon(":/downloading.svg"_l1); return qicon; } case ErroredIcon: { static const QIcon qicon(":/errored.svg"_l1); return qicon; } case PausedIcon: { static const QIcon qicon(":/paused.svg"_l1); return qicon; } case QueuedIcon: { static const QIcon qicon(":/queued.svg"_l1); return qicon; } case SeedingIcon: { static const QIcon qicon(":/seeding.svg"_l1); return qicon; } case StalledDownloadingIcon: { static const QIcon qicon(":/stalled-downloading.svg"_l1); return qicon; } case StalledSeedingIcon: { static QIcon qicon(":/stalled-seeding.svg"_l1); return qicon; } } throw std::logic_error( fmt::format("Unknown StatusIcon value {}", static_cast>(icon)) ); } const QIcon& standardFileIcon() { static const auto icon = qApp->style()->standardIcon(QStyle::SP_FileIcon); return icon; } const QIcon& standardDirIcon() { static const auto icon = qApp->style()->standardIcon(QStyle::SP_DirIcon); return icon; } void openFile(const QString& filePath, QWidget* parent) { const auto showDialogOnError = [&](std::optional error) { auto dialog = new QMessageBox( QMessageBox::Warning, //: Dialog title qApp->translate("tremotesf", "Error"), //: File opening error, %1 is a file path qApp->translate("tremotesf", "Error opening %1").arg(QDir::toNativeSeparators(filePath)), QMessageBox::Close, parent ); if (error.has_value()) { dialog->setText(dialog->text() % "\n\n"_l1 % *error); } dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->show(); }; if (!QFile::exists(filePath)) { warning().log("Can't open file {}, it does not exist", filePath); showDialogOnError(qApp->translate("tremotesf", "This file/directory does not exist")); return; } const auto url = QUrl::fromLocalFile(filePath); info().log("Executing QDesktopServices::openUrl() for {}", url); if (!QDesktopServices::openUrl(url)) { warning().log("QDesktopServices::openUrl() failed for {}", url); showDialogOnError({}); } } namespace { QRegularExpression urlRegex() { constexpr auto protocol = "(?:(?:[a-z]+:)?//)"_l1; constexpr auto host = R"((?:(?:[a-z\x{00a1}-\x{ffff0}-9][-_]*)*[a-z\x{00a1}-\x{ffff0}-9]+))"; constexpr auto domain = R"((?:\.(?:[a-z\x{00a1}-\x{ffff0}-9]-*)*[a-z\x{00a1}-\x{ffff0}-9]+)*)"; constexpr auto tld = R"((?:\.(?:[a-z\x{00a1}-\x{ffff}]{2,}))\.?)"; constexpr auto port = R"((?::\d{2,5})?)"; constexpr auto path = R"((?:[/?#][^\s"\)']*)?)"; const auto regex = QString("(?:"_l1 % protocol % R"(|www\.)(?:)" % host % domain % tld % ")"_l1 % port % path); return QRegularExpression(regex, QRegularExpression::CaseInsensitiveOption); } } void findLinksAndAddAnchors(QTextDocument* document) { auto baseFormat = QTextCharFormat(); baseFormat.setAnchor(true); baseFormat.setFontUnderline(true); baseFormat.setForeground(qApp->palette().link()); const auto regex = desktoputils::urlRegex(); auto cursor = QTextCursor(); while (true) { cursor = document->find(regex, cursor); if (cursor.isNull()) { break; } auto format = baseFormat; format.setAnchorHref(cursor.selection().toPlainText()); cursor.setCharFormat(format); } } } tremotesf-2.8.2/src/desktoputils.h000066400000000000000000000015251500171105600172240ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_DESKTOPUTILS_H #define TREMOTESF_DESKTOPUTILS_H class QIcon; class QString; class QTextDocument; class QWidget; namespace tremotesf::desktoputils { constexpr int defaultDbusTimeout = 2000; // 2 seconds enum StatusIcon { ActiveIcon, CheckingIcon, DownloadingIcon, ErroredIcon, PausedIcon, QueuedIcon, SeedingIcon, StalledDownloadingIcon, StalledSeedingIcon }; const QIcon& statusIcon(StatusIcon icon); const QIcon& standardFileIcon(); const QIcon& standardDirIcon(); void openFile(const QString& filePath, QWidget* parent = nullptr); void findLinksAndAddAnchors(QTextDocument* document); } #endif // TREMOTESF_DESKTOPUTILS_H tremotesf-2.8.2/src/filemanagerlauncher.cpp000066400000000000000000000103751500171105600210240ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "filemanagerlauncher.h" #include #include #include #include #include #include #include #include #include "log/log.h" #include "literals.h" SPECIALIZE_FORMATTER_FOR_QDEBUG(QUrl) namespace tremotesf { namespace impl { void FileManagerLauncher::launchFileManagerAndSelectFiles(const std::vector& files, QWidget* parentWidget) { std::vector filesToSelect{}; std::vector nonExistentDirectories{}; for (const QString& filePath : files) { QString dirPath = QFileInfo(filePath).path(); if (std::ranges::find(nonExistentDirectories, dirPath) != nonExistentDirectories.end()) { continue; } if (!QFileInfo::exists(dirPath)) { warning().log("FileManagerLauncher: directory {} does not exist", dirPath); nonExistentDirectories.push_back(dirPath); continue; } const auto found = std::ranges::find(filesToSelect, dirPath, &FilesInDirectory::directory); auto& dirFiles = [&]() -> std::vector& { if (found != filesToSelect.end()) { return found->files; } filesToSelect.push_back({.directory = std::move(dirPath), .files = {}}); return filesToSelect.back().files; }(); dirFiles.push_back(filePath); } if (!nonExistentDirectories.empty()) { const auto error = qApp->translate("tremotesf", "This directory does not exist"); for (const auto& dirPath : nonExistentDirectories) { showErrorDialog(dirPath, error, parentWidget); } } if (!filesToSelect.empty()) { launchFileManagerAndSelectFiles(std::move(filesToSelect), parentWidget); } else { emit done(); } } void FileManagerLauncher::launchFileManagerAndSelectFiles( // NOLINTNEXTLINE(performance-unnecessary-value-param) std::vector filesToSelect, QWidget* parentWidget ) { for (const auto& [directory, _] : filesToSelect) { fallbackForDirectory(directory, parentWidget); } emit done(); } void FileManagerLauncher::fallbackForDirectory(const QString& dirPath, QWidget* parentWidget) { const auto url = QUrl::fromLocalFile(dirPath); info().log("FileManagerLauncher: executing QDesktopServices::openUrl() for {}", url); if (!QDesktopServices::openUrl(url)) { warning().log("FileManagerLauncher: QDesktopServices::openUrl() failed for {}", url); showErrorDialog(dirPath, {}, parentWidget); } } void FileManagerLauncher::showErrorDialog( const QString& dirPath, const std::optional& error, QWidget* parentWidget ) { auto dialog = new QMessageBox( QMessageBox::Warning, //: Dialog title qApp->translate("tremotesf", "Error"), //: Directory opening error, %1 is a file path qApp->translate("tremotesf", "Error opening %1").arg(QDir::toNativeSeparators(dirPath)), QMessageBox::Close, parentWidget ); if (error.has_value()) { dialog->setText(dialog->text() % "\n\n"_l1 % *error); } dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->show(); } } void launchFileManagerAndSelectFiles(const std::vector& files, QWidget* parentWidget) { auto launcher = impl::FileManagerLauncher::createInstance(); QObject::connect(launcher, &impl::FileManagerLauncher::done, launcher, &impl::FileManagerLauncher::deleteLater); launcher->launchFileManagerAndSelectFiles(files, parentWidget); } } tremotesf-2.8.2/src/filemanagerlauncher.h000066400000000000000000000024141500171105600204640ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_FILEMANAGERLAUNCHER_H #define TREMOTESF_FILEMANAGERLAUNCHER_H #include #include #include #include class QWidget; namespace tremotesf { namespace impl { class FileManagerLauncher : public QObject { Q_OBJECT public: static FileManagerLauncher* createInstance(); void launchFileManagerAndSelectFiles(const std::vector& files, QWidget* parentWidget); protected: FileManagerLauncher() = default; struct FilesInDirectory { QString directory{}; std::vector files{}; }; virtual void launchFileManagerAndSelectFiles(std::vector filesToSelect, QWidget* parentWidget); void fallbackForDirectory(const QString& dirPath, QWidget* parentWidget); void showErrorDialog(const QString& dirPath, const std::optional& error, QWidget* parentWidget); signals: void done(); }; } void launchFileManagerAndSelectFiles(const std::vector& files, QWidget* parentWidget); } #endif tremotesf-2.8.2/src/filemanagerlauncher_fallback.cpp000066400000000000000000000004111500171105600226310ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "filemanagerlauncher.h" namespace tremotesf::impl { FileManagerLauncher* FileManagerLauncher::createInstance() { return new FileManagerLauncher(); } } tremotesf-2.8.2/src/filemanagerlauncher_freedesktop.cpp000066400000000000000000000060571500171105600234210ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "filemanagerlauncher.h" #include #include #include #include #include #include "coroutines/dbus.h" #include "coroutines/scope.h" #include "log/log.h" #include "tremotesf_dbus_generated/org.freedesktop.FileManager1.h" #include "desktoputils.h" #include "literals.h" #include "stdutils.h" SPECIALIZE_FORMATTER_FOR_QDEBUG(QDBusError) using namespace std::views; namespace tremotesf { namespace { class FreedesktopFileManagerLauncher final : public impl::FileManagerLauncher { Q_OBJECT public: FreedesktopFileManagerLauncher() = default; protected: void launchFileManagerAndSelectFiles(std::vector filesToSelect, QWidget* parentWidget) override { mCoroutineScope.launch(launchFileManagerAndSelectFilesImpl(std::move(filesToSelect), parentWidget)); } private: Coroutine<> launchFileManagerAndSelectFilesImpl( std::vector filesToSelect, QPointer parentWidget ) { OrgFreedesktopFileManager1Interface interface( "org.freedesktop.FileManager1"_l1, "/org/freedesktop/FileManager1"_l1, QDBusConnection::sessionBus() ); interface.setTimeout(desktoputils::defaultDbusTimeout); const auto uris = toContainer( filesToSelect | transform(&FilesInDirectory::files) | join | transform([](const QString& path) { return QUrl::fromLocalFile(path).toString(QUrl::FullyEncoded); }) ); info().log( "FreedesktopFileManagerLauncher: executing org.freedesktop.FileManager1.ShowItems() D-Bus call " "with: uris = {}", uris ); const auto reply = co_await interface.ShowItems(uris, {}); if (!reply.isError()) { info().log( "FreedesktopFileManagerLauncher: executed org.freedesktop.FileManager1.ShowItems() D-Bus call" ); } else { warning().log( "FreedesktopFileManagerLauncher: org.freedesktop.FileManager1.ShowItems() D-Bus call failed: " "{}", reply.error() ); for (const auto& [dirPath, dirFiles] : filesToSelect) { fallbackForDirectory(dirPath, parentWidget.data()); } } } CoroutineScope mCoroutineScope{}; }; } namespace impl { FileManagerLauncher* FileManagerLauncher::createInstance() { return new FreedesktopFileManagerLauncher(); } } } #include "filemanagerlauncher_freedesktop.moc" tremotesf-2.8.2/src/filemanagerlauncher_macos.mm000066400000000000000000000044201500171105600220270ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "filemanagerlauncher.h" #include #include #include #include "log/log.h" namespace tremotesf { namespace { class MacosFileManagerLauncher final : public impl::FileManagerLauncher { Q_OBJECT public: MacosFileManagerLauncher() = default; protected: void launchFileManagerAndSelectFiles( std::vector filesToSelect, QWidget* parentWidget ) override { auto* const workspace = [NSWorkspace sharedWorkspace]; NSMutableArray* const fileUrls = [NSMutableArray arrayWithCapacity:filesToSelect.size()]; std::vector fallbackDirectories{}; for (const auto& [dirPath, files] : filesToSelect) { for (const auto& filePath : files) { // activateFileViewerSelectingURLs won't work is file does not exist if (QFileInfo::exists(filePath)) { auto* const url = [NSURL fileURLWithPath:filePath.toNSString()]; [fileUrls addObject:url]; } else { if (std::find(fallbackDirectories.begin(), fallbackDirectories.end(), dirPath) == fallbackDirectories.end()) { fallbackDirectories.push_back(dirPath); } } } } if (const auto count = [fileUrls count]; count != 0) { info().log("Executing NSWorkspace.activateFileViewerSelectingURLs for {} files", count); [workspace activateFileViewerSelectingURLs:fileUrls]; } for (const auto& dirPath : fallbackDirectories) { fallbackForDirectory(dirPath, parentWidget); } emit done(); } }; } namespace impl { FileManagerLauncher* FileManagerLauncher::createInstance() { return new MacosFileManagerLauncher(); } } } #include "filemanagerlauncher_macos.moc" tremotesf-2.8.2/src/filemanagerlauncher_windows.cpp000066400000000000000000000105251500171105600225730ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "filemanagerlauncher.h" #include #include #include #include #include #include #include #include #include #include #include "log/log.h" using namespace winrt::Windows::Storage; using namespace winrt::Windows::System; namespace tremotesf { namespace { class WindowsFileManagerLauncher final : public impl::FileManagerLauncher { Q_OBJECT public: ~WindowsFileManagerLauncher() override { if (mCoroutine) { mCoroutine.Cancel(); } } protected: void launchFileManagerAndSelectFiles( std::vector filesToSelect, QWidget* parentWidget ) override { mCoroutine = selectFiles(std::move(filesToSelect), parentWidget); } private: winrt::Windows::Foundation::IAsyncAction selectFiles(std::vector filesToSelect, QPointer parentWidget) { (co_await winrt::get_cancellation_token()).enable_propagation(); for (auto&& [dirPath, dirFiles] : filesToSelect) { co_await selectFilesInFolder(std::move(dirPath), std::move(dirFiles), parentWidget); } emit done(); } winrt::Windows::Foundation::IAsyncAction selectFilesInFolder(QString dirPath, std::vector dirFiles, QPointer parentWidget) { auto options = FolderLauncherOptions(); for (const QString& filePath : dirFiles) { const auto nativeFilePath = winrt::hstring(QDir::toNativeSeparators(filePath).toStdWString()); if (QFileInfo(filePath).isDir()) { try { options.ItemsToSelect().Append(co_await StorageFolder::GetFolderFromPathAsync(nativeFilePath )); } catch (const winrt::hresult_canceled&) { throw; } catch (const winrt::hresult_error& e) { warning().logWithException( e, "WindowsFileManagerLauncher: failed to create StorageFolder from {}", nativeFilePath ); } } else { try { options.ItemsToSelect().Append(co_await StorageFile::GetFileFromPathAsync(nativeFilePath)); } catch (const winrt::hresult_canceled&) { throw; } catch (const winrt::hresult_error& e) { warning().logWithException( e, "WindowsFileManagerLauncher: failed to create StorageFile from {}", nativeFilePath ); } } } auto nativeDirPath = winrt::hstring(QDir::toNativeSeparators(dirPath).toStdWString()); info().log( "WindowsFileManagerLauncher: opening folder {} and selecting {} items", nativeDirPath, options.ItemsToSelect().Size() ); try { co_await Launcher::LaunchFolderPathAsync(nativeDirPath, options); } catch (const winrt::hresult_canceled&) { throw; } catch (const winrt::hresult_error& e) { warning().logWithException(e, "WindowsFileManagerLauncher: failed to select files"); fallbackForDirectory(dirPath, parentWidget); } } winrt::Windows::Foundation::IAsyncAction mCoroutine{}; }; } namespace impl { FileManagerLauncher* FileManagerLauncher::createInstance() { return new WindowsFileManagerLauncher(); } } } #include "filemanagerlauncher_windows.moc" tremotesf-2.8.2/src/fileutils.cpp000066400000000000000000000276451500171105600170400ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "fileutils.h" #include #include #include #include #include #include #include #include #include "literals.h" #include "macoshelpers.h" #include "target_os.h" #include "log/log.h" namespace fmt { template<> struct formatter : tremotesf::SimpleFormatter { format_context::iterator format(QFile::FileError e, format_context& ctx) const { const std::string_view string = [e] { using namespace std::string_view_literals; switch (e) { case QFileDevice::NoError: return "NoError"sv; case QFileDevice::ReadError: return "ReadError"sv; case QFileDevice::WriteError: return "WriteError"sv; case QFileDevice::FatalError: return "FatalError"sv; case QFileDevice::ResourceError: return "ResourceError"sv; case QFileDevice::OpenError: return "OpenError"sv; case QFileDevice::AbortError: return "AbortError"sv; case QFileDevice::TimeOutError: return "TimeOutError"sv; case QFileDevice::UnspecifiedError: return "UnspecifiedError"sv; case QFileDevice::RemoveError: return "RemoveError"sv; case QFileDevice::RenameError: return "RenameError"sv; case QFileDevice::PositionError: return "PositionError"sv; case QFileDevice::ResizeError: return "ResizeError"sv; case QFileDevice::PermissionsError: return "PermissionsError"sv; case QFileDevice::CopyError: return "CopyError"sv; } return std::string_view{}; }(); if (string.empty()) { return fmt::format_to( ctx.out(), "QFileDevice::FileError::", static_cast>(e) ); } return fmt::format_to(ctx.out(), "QFileDevice::FileError::{}", string); } }; } namespace tremotesf { namespace { std::string fileDescription(const QFile& file) { if (const QString fileName = file.fileName(); !fileName.isEmpty()) { return fmt::format(R"(file "{}")", fileName); } return fmt::format("file with handle={}", file.handle()); } std::string errorDescription(const QFile& file) { return fmt::format("{} ({})", file.errorString(), file.error()); } enum class ReadErrorType { FileError, UnexpectedEndOfFile }; void throwReadError(const QFile& file, ReadErrorType type) { switch (type) { case ReadErrorType::UnexpectedEndOfFile: throw QFileError(fmt::format("Failed to read from {}: unexpected end of file", fileDescription(file))); case ReadErrorType::FileError: throw QFileError( fmt::format("Failed to read from {}: {}", fileDescription(file), errorDescription(file)) ); } throw std::logic_error("Unknown ReadErrorType value"); } struct ReadWholeBuffer {}; struct ReadUntilEndOfFile { qint64 bytesRead{}; }; using ReadResult = std::variant; [[nodiscard]] ReadResult readWholeBufferOrUntilEndOfFile(QFile& file, std::span buffer) { if (buffer.empty()) { // If buffer's size is 0 then file.read() will return 0 which we will confuse with EOF condition // Just return early, there is nothing for us to do return ReadWholeBuffer{}; } std::span emptyBufferRemainder = buffer; while (true) { const qint64 bytesRead = file.read(emptyBufferRemainder.data(), static_cast(emptyBufferRemainder.size())); if (bytesRead == -1) { // Error, throw throwReadError(file, ReadErrorType::FileError); } if (bytesRead == 0) { // End of file, return const auto filledBufferSize = static_cast(buffer.size() - emptyBufferRemainder.size()); return ReadUntilEndOfFile{.bytesRead = filledBufferSize}; } if (bytesRead == static_cast(emptyBufferRemainder.size())) { // Read whole buffer, return return ReadWholeBuffer{}; } // Read part of buffer, continue emptyBufferRemainder = emptyBufferRemainder.subspan(static_cast(bytesRead)); } } } void openFile(QFile& file, QIODevice::OpenMode mode) { if (!file.open(mode)) { throw QFileError(fmt::format("Failed to open {}: {}", fileDescription(file), errorDescription(file))); } } void openFileFromFd(QFile& file, int fd, QIODevice::OpenMode mode) { if (!file.open(fd, mode)) { throw QFileError(fmt::format("Failed to open file from handle={}: {}", fd, errorDescription(file))); } } void readBytes(QFile& file, std::span buffer) { const auto result = readWholeBufferOrUntilEndOfFile(file, buffer); if (std::holds_alternative(result)) { throwReadError(file, ReadErrorType::UnexpectedEndOfFile); } } void skipBytes(QFile& file, qint64 bytes) { if (bytes < 0) { throw std::invalid_argument(fmt::format("Argument bytes has invalid value {}, can't be negative", bytes)); } if (bytes == 0) { // Nothing to do return; } auto remainingBytes = bytes; while (remainingBytes > 0) { const auto bytesSkipped = file.skip(remainingBytes); if (bytesSkipped == -1) { // Error, throw throwReadError(file, ReadErrorType::FileError); } if (bytesSkipped == 0) { // End of file, throw throwReadError(file, ReadErrorType::UnexpectedEndOfFile); } remainingBytes -= bytesSkipped; } } std::span peekBytes(QFile& file, std::span buffer) { if (buffer.empty()) { return buffer; } const auto peeked = file.peek(buffer.data(), static_cast(buffer.size())); if (peeked == -1) { throwReadError(file, ReadErrorType::FileError); } if (peeked == 0) { throwReadError(file, ReadErrorType::UnexpectedEndOfFile); } return buffer.subspan(0, static_cast(peeked)); } void writeBytes(QFile& file, std::span data) { std::span remainingData = data; while (true) { const qint64 bytesWritten = file.write(remainingData.data(), static_cast(remainingData.size())); if (bytesWritten == -1) { // Error, throw throw QFileError(fmt::format("Failed to write to {}: {}", fileDescription(file), errorDescription(file)) ); } if (bytesWritten == static_cast(remainingData.size())) { // Written whole buffer, return break; } // Written part of buffer, continue remainingData = remainingData.subspan(static_cast(bytesWritten)); } } QByteArray readFile(const QString& path) { QFile file(path); openFile(file, QIODevice::ReadOnly); auto data = file.readAll(); if (file.error() != QFileDevice::NoError) { throwReadError(file, ReadErrorType::FileError); } return data; } namespace { void deleteFileImpl(QFile& file) { info().log("Deleting {}", fileDescription(file)); if (file.remove()) { info().log("Succesfully deleted file"); } else { throw QFileError(fmt::format("Failed to delete {}: {}", fileDescription(file), errorDescription(file))); } } } void deleteFile(const QString& filePath) { QFile file(filePath); deleteFileImpl(file); } void moveFileToTrashOrDelete(const QString& filePath) { QFile file(filePath); info().log("Moving {} to trash", fileDescription(file)); if (file.moveToTrash()) { if (const auto newPath = file.fileName(); !newPath.isEmpty()) { info().log("Successfully moved file to trash, new path is {}", newPath); } else { info().log("Successfully moved file to trash"); } } else { warning().log("Failed to move {} to trash: {}", fileDescription(file), errorDescription(file)); deleteFileImpl(file); } } QString resolveExternalBundledResourcesPath(QLatin1String path) { const QString root = [&] { if constexpr (targetOs == TargetOs::UnixMacOS) { return bundleResourcesPath(); } else { return QCoreApplication::applicationDirPath(); } }(); return root % '/' % path; } namespace impl { QString readFileAsBase64String(QFile& file) { QString string{}; string.reserve(static_cast(((4 * file.size() / 3) + 3) & ~3)); static constexpr qint64 bufferSize = 1024 * 1024 - 1; // 1 MiB minus 1 byte (dividable by 3) QByteArray buffer(bufferSize, '\0'); while (true) { const auto result = readWholeBufferOrUntilEndOfFile(file, buffer); if (std::holds_alternative(result)) { string.append(QLatin1String(buffer.toBase64())); continue; } if (const auto readUntilEndOfFile = std::get_if(&result); readUntilEndOfFile) { buffer.resize(static_cast(readUntilEndOfFile->bytesRead)); string.append(QLatin1String(buffer.toBase64())); break; } } return string; } namespace { constexpr auto sessionIdFileLocation = [] { if constexpr (targetOs == TargetOs::Windows) { return QStandardPaths::GenericDataLocation; } else { return QStandardPaths::TempLocation; } }(); constexpr QLatin1String sessionIdFilePrefix = [] { if constexpr (targetOs == TargetOs::Windows) { return "Transmission/tr_session_id_"_l1; } else { return "tr_session_id_"_l1; } }(); } bool isTransmissionSessionIdFileExists(const QByteArray& sessionId) { const auto file = QStandardPaths::locate(sessionIdFileLocation, sessionIdFilePrefix % sessionId); if (!file.isEmpty()) { info().log( "isSessionIdFileExists: found transmission-daemon session id file {}", QDir::toNativeSeparators(file) ); return true; } info().log("isSessionIdFileExists: did not find transmission-daemon session id file"); return false; } } } tremotesf-2.8.2/src/fileutils.h000066400000000000000000000024771500171105600165010ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_FILEUTILS_H #define TREMOTESF_FILEUTILS_H #include #include #include class QFile; namespace tremotesf { class QFileError : public std::runtime_error { public: explicit QFileError(const std::string& what) : std::runtime_error(what) {} explicit QFileError(const char* what) : std::runtime_error(what) {} }; void openFile(QFile& file, QIODevice::OpenMode mode); void openFileFromFd(QFile& file, int fd, QIODevice::OpenMode mode); void readBytes(QFile& file, std::span buffer); void skipBytes(QFile& file, qint64 bytes); [[nodiscard]] std::span peekBytes(QFile& file, std::span buffer); void writeBytes(QFile& file, std::span data); [[nodiscard]] QByteArray readFile(const QString& path); void deleteFile(const QString &filePath); void moveFileToTrashOrDelete(const QString &filePath); [[maybe_unused]] QString resolveExternalBundledResourcesPath(QLatin1String path); namespace impl { [[nodiscard]] QString readFileAsBase64String(QFile& file); [[nodiscard]] bool isTransmissionSessionIdFileExists(const QByteArray& sessionId); } } #endif // TREMOTESF_FILEUTILS_H tremotesf-2.8.2/src/formatutils.cpp000066400000000000000000000250341500171105600173770ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later /* For formatRelativeDateTime() SPDX-FileCopyrightText: 2013 Alex Merry SPDX-FileCopyrightText: 2013 John Layt SPDX-FileCopyrightText: 2010 Michael Leupold SPDX-FileCopyrightText: 2009 Michael Pyne SPDX-FileCopyrightText: 2008 Albert Astals Cid SPDX-License-Identifier: LGPL-2.0-or-later */ #include "formatutils.h" #include #include #include #include #include #include #include "settings.h" namespace tremotesf::formatutils { namespace { enum class StringType { Size, Speed }; struct ByteUnitStrings { QString (*size)(); QString (*speed)(); QString string(StringType type) const { switch (type) { case StringType::Size: return size(); case StringType::Speed: return speed(); } throw std::logic_error("Unknown StringType value"); } }; // Should be kept in sync with `enum ByteUnit` constexpr auto byteUnits = std::array{ ByteUnitStrings{//: Size suffix in bytes [] { return qApp->translate("tremotesf", "%L1 B"); }, //: Download speed suffix in bytes per second [] { return qApp->translate("tremotesf", "%L1 B/s"); } }, //: IEC 80000 binary prefixes, i.e. KiB = 1024 bytes ByteUnitStrings{//: Size suffix in kibibytes [] { return qApp->translate("tremotesf", "%L1 KiB"); }, //: Download speed suffix in kibibytes per second [] { return qApp->translate("tremotesf", "%L1 KiB/s"); } }, //: IEC 80000 binary prefixes, i.e. KiB = 1024 bytes ByteUnitStrings{//: Size suffix in mebibytes [] { return qApp->translate("tremotesf", "%L1 MiB"); }, //: Download speed suffix in mebibytes per second [] { return qApp->translate("tremotesf", "%L1 MiB/s"); } }, //: IEC 80000 binary prefixes, i.e. KiB = 1024 bytes ByteUnitStrings{//: Size suffix in gibibytes [] { return qApp->translate("tremotesf", "%L1 GiB"); }, //: Download speed suffix in gibibytes per second [] { return qApp->translate("tremotesf", "%L1 GiB/s"); } }, //: IEC 80000 binary prefixes, i.e. KiB = 1024 bytes ByteUnitStrings{//: Size suffix in tebibytes [] { return qApp->translate("tremotesf", "%L1 TiB"); }, //: Download speed suffix in tebibytes per second [] { return qApp->translate("tremotesf", "%L1 TiB/s"); } }, //: IEC 80000 binary prefixes, i.e. KiB = 1024 bytes ByteUnitStrings{//: Size suffix in pebibytes [] { return qApp->translate("tremotesf", "%L1 PiB"); }, //: Download speed suffix in pebibytes per second [] { return qApp->translate("tremotesf", "%L1 PiB/s"); } }, //: IEC 80000 binary prefixes, i.e. KiB = 1024 bytes ByteUnitStrings{//: Size suffix in exbibytes [] { return qApp->translate("tremotesf", "%L1 EiB"); }, //: Download speed suffix in exbibytes per second [] { return qApp->translate("tremotesf", "%L1 EiB/s"); } }, //: IEC 80000 binary prefixes, i.e. KiB = 1024 bytes ByteUnitStrings{//: Size suffix in zebibytes [] { return qApp->translate("tremotesf", "%L1 ZiB"); }, //: Download speed suffix in zebibytes per second [] { return qApp->translate("tremotesf", "%L1 ZiB/s"); } }, //: IEC 80000 binary prefixes, i.e. KiB = 1024 bytes ByteUnitStrings{//: Size suffix in yobibytes [] { return qApp->translate("tremotesf", "%L1 YiB"); }, //: Download speed suffix in yobibytes per second [] { return qApp->translate("tremotesf", "%L1 YiB/s"); } }, }; constexpr size_t maxByteUnit = byteUnits.size() - 1; QString formatBytes(qint64 bytes, StringType stringType) { size_t unit = 0; auto bytes_floating = static_cast(bytes); while (bytes_floating >= 1024.0 && unit < maxByteUnit) { bytes_floating /= 1024.0; ++unit; } if (unit == 0) { return byteUnits[0].string(stringType).arg(bytes_floating); } // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-constant-array-index) return byteUnits[unit].string(stringType).arg(bytes_floating, 0, 'f', 1); } } QString formatByteSize(long long size) { return formatBytes(size, StringType::Size); } QString formatByteSpeed(long long speed) { return formatBytes(speed, StringType::Speed); } QString formatSpeedLimit(int limit) { //: Download speed suffix in kibibytes per second return qApp->translate("tremotesf", "%L1 KiB/s").arg(limit); } QString formatProgress(double progress) { if (qFuzzyCompare(progress, 1.0)) { //: Progress in percents. %L1 must remain unchanged, % after it is a percent character return qApp->translate("tremotesf", "%L1%").arg(100); } //: Progress in percents. %L1 must remain unchanged, % after it is a percent character return qApp->translate("tremotesf", "%L1%").arg(std::trunc(progress * 1000.0) / 10.0, 0, 'f', 1); } QString formatRatio(double ratio) { if (ratio < 0) { return {}; } int precision = 2; if (ratio >= 100) { precision = 0; } else if (ratio >= 10) { precision = 1; } return QLocale().toString(ratio, 'f', precision); } QString formatRatio(long long downloaded, long long uploaded) { if (downloaded == 0) { return formatRatio(0); } return formatRatio(static_cast(uploaded) / static_cast(downloaded)); } QString formatEta(int seconds) { if (seconds < 0) { return "\u221E"; } const int days = seconds / 86400; seconds %= 86400; const int hours = seconds / 3600; seconds %= 3600; const int minutes = seconds / 60; seconds %= 60; if (days > 0) { //: Remaining time string. %L1 is days, %L2 is hours, e.g. "2 d 5 h" return qApp->translate("tremotesf", "%L1 d %L2 h").arg(days).arg(hours); } if (hours > 0) { //: Remaining time string. %L1 is hours, %L2 is minutes, e.g. "2 h 5 m" return qApp->translate("tremotesf", "%L1 h %L2 m").arg(hours).arg(minutes); } if (minutes > 0) { //: Remaining time string. %L1 is minutes, %L2 is seconds, e.g. "2 m 5 s" return qApp->translate("tremotesf", "%L1 m %L2 s").arg(minutes).arg(seconds); } //: Remaining time string. %L1 is seconds, "10 s" return qApp->translate("tremotesf", "%L1 s").arg(seconds); } namespace { // Adapted from KCoreAddons' KFormat QString formatRelativeDate(const QDate& date, QLocale::FormatType format, const QLocale& locale) { if (!date.isValid()) { return {}; } const qint64 daysTo = QDate::currentDate().daysTo(date); if (daysTo > 0 || daysTo < -2) { return locale.toString(date, format); } switch (daysTo) { case 0: return qApp->translate("tremotesf", "Today"); case -1: return qApp->translate("tremotesf", "Yesterday"); case -2: return qApp->translate("tremotesf", "Two days ago"); } Q_UNREACHABLE(); } QString addTimeToDate(const QString& dateString, QTime time, QLocale::FormatType format, const QLocale& locale) { //: Relative date & time return qApp->translate("tremotesf", "%1 at %2") .arg( dateString, locale.toString( time, format == QLocale::FormatType::LongFormat ? QLocale::FormatType::ShortFormat : format ) ); } QString formatRelativeDateTime(const QDateTime& dateTime, QLocale::FormatType format, const QLocale& locale) { const QDateTime now = QDateTime::currentDateTime(); const auto secsToNow = dateTime.secsTo(now); constexpr int secsInAHour = 60 * 60; if (secsToNow >= 0 && secsToNow < secsInAHour) { const auto minutesToNow = static_cast(secsToNow / 60); if (minutesToNow <= 1) { //: Relative time return qApp->translate("tremotesf", "Just now"); } else { //: @item:intext %1 is a whole number //~ singular %n minute ago //~ plural %n minutes ago return qApp->translate("tremotesf", "%n minute(s) ago", nullptr, minutesToNow); } } return addTimeToDate(formatRelativeDate(dateTime.date(), format, locale), dateTime.time(), format, locale); } } QString formatDateTime(const QDateTime& dateTime, QLocale::FormatType format, bool displayRelativeTime) { if (!dateTime.isValid()) { return {}; } const QLocale locale{}; if (displayRelativeTime) { return formatRelativeDateTime(dateTime, format, locale); } return addTimeToDate(locale.toString(dateTime.date(), format), dateTime.time(), format, locale); } QString formatDateTime(const QDateTime& dateTime, QLocale::FormatType format) { return formatDateTime(dateTime, format, Settings::instance()->get_displayRelativeTime()); } } tremotesf-2.8.2/src/formatutils.h000066400000000000000000000014471500171105600170460ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_FORMATUTILS_H #define TREMOTESF_FORMATUTILS_H #include #include class QDateTime; namespace tremotesf::formatutils { QString formatByteSize(long long size); QString formatByteSpeed(long long speed); QString formatSpeedLimit(int limit); QString formatProgress(double progress); QString formatRatio(double ratio); QString formatRatio(long long downloaded, long long uploaded); QString formatEta(int seconds); QString formatDateTime(const QDateTime& dateTime, QLocale::FormatType format, bool displayRelativeTime); QString formatDateTime(const QDateTime& dateTime, QLocale::FormatType format); } #endif // TREMOTESF_FORMATUTILS_H tremotesf-2.8.2/src/ipc/000077500000000000000000000000001500171105600150715ustar00rootroot00000000000000tremotesf-2.8.2/src/ipc/fileopeneventhandler.cpp000066400000000000000000000043161500171105600220020ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "fileopeneventhandler.h" #include #include #include "log/log.h" namespace tremotesf { FileOpenEventHandler::FileOpenEventHandler(QObject* parent) : QObject(parent) { QCoreApplication::instance()->installEventFilter(this); } FileOpenEventHandler::~FileOpenEventHandler() { QCoreApplication::instance()->removeEventFilter(this); } bool FileOpenEventHandler::eventFilter(QObject* watched, QEvent* event) { // In case of multiple files / urls, Qt send separate QFileOpenEvent per file in a loop // Call processPendingEvents() in the next event loop iteration to process all events at once if (event->type() == QEvent::FileOpen) { auto* const e = static_cast(event); if (!e->file().isEmpty() || e->url().isValid()) { info().log("Received QEvent::FileOpen"); auto pendingEvent = [&] { if (!e->file().isEmpty()) { info().log("file = {}", e->file()); return PendingEvent{.fileOrUrl = e->file(), .isFile = true}; } auto url = e->url().toString(); info().log("url = {}", url); return PendingEvent{.fileOrUrl = std::move(url), .isFile = false}; }(); if (mPendingEvents.empty()) { QMetaObject::invokeMethod(this, &FileOpenEventHandler::processPendingEvents, Qt::QueuedConnection); } mPendingEvents.push_back(std::move(pendingEvent)); } } return QObject::eventFilter(watched, event); } void FileOpenEventHandler::processPendingEvents() { QStringList files{}; QStringList urls{}; for (auto& event : mPendingEvents) { if (event.isFile) { files.push_back(std::move(event.fileOrUrl)); } else { urls.push_back(std::move(event.fileOrUrl)); } } mPendingEvents.clear(); emit filesOpeningRequested(files, urls); } } tremotesf-2.8.2/src/ipc/fileopeneventhandler.h000066400000000000000000000016131500171105600214440ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_FILEOPENEVENTHANDLER_H #define TREMOTESF_FILEOPENEVENTHANDLER_H #include #include namespace tremotesf { class FileOpenEventHandler : public QObject { Q_OBJECT public: explicit FileOpenEventHandler(QObject* parent = nullptr); ~FileOpenEventHandler() override; Q_DISABLE_COPY_MOVE(FileOpenEventHandler) bool eventFilter(QObject* watched, QEvent* event) override; private: void processPendingEvents(); struct PendingEvent { QString fileOrUrl{}; bool isFile{}; }; std::vector mPendingEvents{}; signals: void filesOpeningRequested(const QStringList& files, const QStringList& urls); }; } #endif // TREMOTESF_FILEOPENEVENTHANDLER_H tremotesf-2.8.2/src/ipc/ipcclient.h000066400000000000000000000012331500171105600172130ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_IPCCLIENT_H #define TREMOTESF_IPCCLIENT_H #include #include namespace tremotesf { class IpcClient { public: static std::unique_ptr createInstance(); virtual ~IpcClient() = default; Q_DISABLE_COPY_MOVE(IpcClient) virtual bool isConnected() const = 0; virtual void activateWindow() = 0; virtual void addTorrents(const QStringList& files, const QStringList& urls) = 0; protected: IpcClient() = default; }; } #endif // TREMOTESF_IPCCLIENT_H tremotesf-2.8.2/src/ipc/ipcclient_dbus.cpp000066400000000000000000000061521500171105600205700ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "ipcclient.h" //#include #include #include #include #include "log/log.h" #include "tremotesf_dbus_generated/ipc/org.freedesktop.Application.h" #include "ipcserver_dbus.h" #include "ipcserver_dbus_service.h" #include "unixhelpers.h" namespace tremotesf { namespace { constexpr auto desktopStartupIdEnvVariable = "DESKTOP_STARTUP_ID"; inline bool waitForReply(QDBusPendingReply<> pending) { pending.waitForFinished(); const auto reply(pending.reply()); if (reply.type() != QDBusMessage::ReplyMessage) { warning().log("D-Bus method call failed: {}", reply.errorMessage()); return false; } return true; } } class IpcClientDbus final : public IpcClient { public: IpcClientDbus() = default; [[nodiscard]] bool isConnected() const override { return mInterface.isValid(); } void activateWindow() override { info().log("Requesting window activation"); if (mInterface.isValid()) { waitForReply(mInterface.Activate(getPlatformData())); } } void addTorrents(const QStringList& files, const QStringList& urls) override { info().log("Requesting torrents adding"); if (mInterface.isValid()) { QStringList uris; uris.reserve(files.size() + urls.size()); for (const QString& filePath : files) { uris.push_back(QUrl::fromLocalFile(filePath).toString()); } uris.append(urls); waitForReply(mInterface.Open(uris, getPlatformData())); } } private: static inline QVariantMap getPlatformData() { QVariantMap data{}; if (qEnvironmentVariableIsSet(desktopStartupIdEnvVariable)) { const auto startupId = qgetenv(desktopStartupIdEnvVariable); info().log("{} is '{}'", desktopStartupIdEnvVariable, startupId); data.insert(IpcDbusService::desktopStartupIdField, startupId); } else { info().log("{} is not set", desktopStartupIdEnvVariable); } if (qEnvironmentVariableIsSet(xdgActivationTokenEnvVariable)) { const auto activationToken = qgetenv(xdgActivationTokenEnvVariable); info().log("{} is '{}'", xdgActivationTokenEnvVariable, activationToken); data.insert(IpcDbusService::xdgActivationTokenField, qgetenv(xdgActivationTokenEnvVariable)); } else { info().log("{} is not set", xdgActivationTokenEnvVariable); } return data; } OrgFreedesktopApplicationInterface mInterface{ IpcServerDbus::serviceName, IpcServerDbus::objectPath, QDBusConnection::sessionBus() }; }; std::unique_ptr IpcClient::createInstance() { return std::make_unique(); } } tremotesf-2.8.2/src/ipc/ipcclient_socket.cpp000066400000000000000000000036611500171105600211250ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "ipcclient.h" #include #include #include #include #include "log/log.h" #include "ipcserver_socket.h" namespace tremotesf { class IpcClientSocket final : public IpcClient { public: IpcClientSocket() { mSocket.connectToServer(IpcServerSocket::socketName()); mSocket.waitForConnected(); } bool isConnected() const override { return mSocket.state() == QLocalSocket::ConnectedState; } void activateWindow() override { info().log("Requesting window activation"); sendMessage(IpcServerSocket::activateWindowMessage); } void addTorrents(const QStringList& files, const QStringList& urls) override { info().log("Requesting torrents adding"); sendMessage(IpcServerSocket::createAddTorrentsMessage(files, urls)); } private: void sendMessage(const QByteArray& message) { const qint64 written = mSocket.write(message); if (written != message.size()) { warning().log("Failed to write to socket ({} bytes written): {}", written, mSocket.errorString()); } waitForBytesWritten(); } void sendMessage(char message) { const bool written = mSocket.putChar(message); if (!written) { warning().log("Failed to write to socket: {}", mSocket.errorString()); } waitForBytesWritten(); } void waitForBytesWritten() { if (!mSocket.waitForBytesWritten()) { warning().log("Timed out when waiting for bytes written"); } } QLocalSocket mSocket; }; std::unique_ptr IpcClient::createInstance() { return std::make_unique(); } } tremotesf-2.8.2/src/ipc/ipcserver.h000066400000000000000000000014131500171105600172430ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_IPCSERVER_H #define TREMOTESF_IPCSERVER_H #include #include namespace tremotesf { class IpcServer : public QObject { Q_OBJECT public: static IpcServer* createInstance(QObject* parent = nullptr); protected: inline explicit IpcServer(QObject* parent = nullptr) : QObject(parent) {}; signals: void windowActivationRequested(const std::optional& windowActivationToken); void torrentsAddingRequested( const QStringList& files, const QStringList& urls, const std::optional& windowActivationToken ); }; } #endif // TREMOTESF_IPCSERVER_H tremotesf-2.8.2/src/ipc/ipcserver_dbus.cpp000066400000000000000000000003711500171105600206150ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "ipcserver_dbus.h" namespace tremotesf { IpcServer* IpcServer::createInstance(QObject* parent) { return new IpcServerDbus(parent); } } tremotesf-2.8.2/src/ipc/ipcserver_dbus.h000066400000000000000000000014131500171105600202600ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_IPCSERVER_DBUS_H #define TREMOTESF_IPCSERVER_DBUS_H #include "ipcserver.h" #include "ipcserver_dbus_service.h" #include "literals.h" namespace tremotesf { class IpcServerDbus final : public IpcServer { Q_OBJECT public: static constexpr auto serviceName = "org.equeim.Tremotesf"_l1; static constexpr auto objectPath = "/org/equeim/Tremotesf"_l1; static constexpr auto interfaceName = "org.freedesktop.Application"_l1; inline explicit IpcServerDbus(QObject* parent = nullptr) : IpcServer(parent) {}; private: IpcDbusService mDbusService{this, this}; }; } #endif // TREMOTESF_IPCSERVER_DBUS_H tremotesf-2.8.2/src/ipc/ipcserver_dbus_service.cpp000066400000000000000000000067421500171105600223450ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "ipcserver_dbus_service.h" #include #include #include "log/log.h" #include "tremotesf_dbus_generated/ipc/org.freedesktop.Application.adaptor.h" #include "ipcserver_dbus.h" SPECIALIZE_FORMATTER_FOR_QDEBUG(QDBusError) SPECIALIZE_FORMATTER_FOR_QDEBUG(QStringList) SPECIALIZE_FORMATTER_FOR_QDEBUG(QVariantMap) SPECIALIZE_FORMATTER_FOR_QDEBUG(QVariantList) namespace tremotesf { namespace { std::optional platformDataToWindowActivationToken(const QVariantMap& platformData) { QLatin1String key{}; switch (KWindowSystem::platform()) { case KWindowSystem::Platform::X11: { key = IpcDbusService::desktopStartupIdField; break; } case KWindowSystem::Platform::Wayland: { key = IpcDbusService::xdgActivationTokenField; break; } default: warning().log("Unknown windowing system"); return {}; } auto token = platformData.value(key).toByteArray(); if (token.isEmpty()) { return {}; } return token; } } IpcDbusService::IpcDbusService(IpcServerDbus* ipcServer, QObject* parent) : QObject(parent), mIpcServer(ipcServer) { new OrgFreedesktopApplicationAdaptor(this); auto connection(QDBusConnection::sessionBus()); if (connection.registerService(IpcServerDbus::serviceName)) { info().log("Registered D-Bus service"); if (connection.registerObject(IpcServerDbus::objectPath, this)) { info().log("Registered D-Bus object"); } else { warning().log("Failed to register D-Bus object: {}", connection.lastError()); } } else { warning().log("Failed to register D-Bus service", connection.lastError()); } } /* * org.freedesktop.Application methods */ void IpcDbusService::Activate(const QVariantMap& platform_data) { info().log("IpcDbusService: window activation requested, platform_data = {}", platform_data); emit mIpcServer->windowActivationRequested(platformDataToWindowActivationToken(platform_data)); } void IpcDbusService::Open(const QStringList& uris, const QVariantMap& platform_data) { info().log("IpcDbusService: torrents adding requested, uris = {}, platform_data = {}", uris, platform_data); QStringList files; QStringList urls; for (const QUrl& url : QUrl::fromStringList(uris)) { if (url.isValid()) { if (url.isLocalFile()) { files.push_back(url.toLocalFile()); } else { urls.push_back(url.toString()); } } } emit mIpcServer->torrentsAddingRequested(files, urls, platformDataToWindowActivationToken(platform_data)); } void IpcDbusService::ActivateAction( const QString& action_name, const QVariantList& parameter, const QVariantMap& platform_data ) { info().log( "IpcDbusService: action activated, action_name = {}, parameter = {}, platform_data = {}", action_name, parameter, platform_data ); emit mIpcServer->windowActivationRequested(platformDataToWindowActivationToken(platform_data)); } } tremotesf-2.8.2/src/ipc/ipcserver_dbus_service.h000066400000000000000000000021441500171105600220020ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_IPCSERVER_DBUS_SERVICE_H #define TREMOTESF_IPCSERVER_DBUS_SERVICE_H #include #include "literals.h" class OrgFreedesktopApplicationAdaptor; namespace tremotesf { class IpcServerDbus; class IpcDbusService final : public QObject { Q_OBJECT public: static constexpr auto desktopStartupIdField = "desktop-startup-id"_l1; static constexpr auto xdgActivationTokenField = "activation-token"_l1; IpcDbusService(IpcServerDbus* ipcServer, QObject* parent = nullptr); private: /* * org.freedesktop.Application methods */ friend OrgFreedesktopApplicationAdaptor; void Activate(const QVariantMap& platform_data); void Open(const QStringList& uris, const QVariantMap& platform_data); void ActivateAction(const QString& action_name, const QVariantList& parameter, const QVariantMap& platform_data); IpcServerDbus* mIpcServer; }; } #endif // TREMOTESF_IPCSERVER_DBUS_SERVICE_H tremotesf-2.8.2/src/ipc/ipcserver_socket.cpp000066400000000000000000000123501500171105600211500ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "ipcserver_socket.h" #include #include #include #include #include #include #include "fileopeneventhandler.h" #include "literals.h" #include "log/log.h" #ifdef Q_OS_WIN # include # include "windowshelpers.h" #endif #ifdef Q_OS_MACOS # include "ipc/fileopeneventhandler.h" #endif SPECIALIZE_FORMATTER_FOR_QDEBUG(QCborValue) namespace fmt { template<> struct formatter : tremotesf::SimpleFormatter { fmt::format_context::iterator format(QCborParserError error, fmt::format_context& ctx) const { return fmt::format_to( ctx.out(), "{} (error code {})", error.errorString(), static_cast(error.error) ); } }; } namespace tremotesf { namespace { constexpr QStringView keyFiles = u"files"; constexpr QStringView keyUrls = u"urls"; QStringList toStringList(const QCborValue& value) { QStringList strings{}; if (!value.isArray()) return strings; const auto array = value.toArray(); strings.reserve(static_cast(array.size())); for (const auto& v : array) { if (v.isString()) { strings.push_back(v.toString()); } } return strings; } } IpcServerSocket::IpcServerSocket(QObject* parent) : IpcServer(parent) { auto* const server = new QLocalServer(this); const QString name(socketName()); if (!server->listen(name)) { if (server->serverError() == QAbstractSocket::AddressInUseError) { // We already tried to connect to it, removing warning().log("Removing dead socket"); if (QLocalServer::removeServer(name)) { if (!server->listen(name)) { warning().log("Failed to create socket: {}", server->errorString()); } } else { warning().log("Failed to remove socket: {}", server->errorString()); } } else { warning().log("Failed to create socket: {}", server->errorString()); } } if (server->isListening()) { listenToConnections(server); } #ifdef Q_OS_MACOS const auto* const handler = new FileOpenEventHandler(this); QObject::connect( handler, &FileOpenEventHandler::filesOpeningRequested, this, [this](const QStringList& files, const QStringList& urls) { emit torrentsAddingRequested(files, urls, {}); } ); #endif } QString IpcServerSocket::socketName() { QString name("tremotesf"_l1); #ifdef Q_OS_WIN try { DWORD sessionId{}; checkWin32Bool(ProcessIdToSessionId(GetCurrentProcessId(), &sessionId), "ProcessIdToSessionId"); name += '-'; name += QString::number(sessionId); } catch (const std::system_error& e) { warning().logWithException(e, "IpcServerSocket: failed to append session id to socket name"); } #endif return name; } IpcServer* IpcServer::createInstance(QObject* parent) { return new IpcServerSocket(parent); } QByteArray IpcServerSocket::createAddTorrentsMessage(const QStringList& files, const QStringList& urls) { return QCborMap{{keyFiles, QCborArray::fromStringList(files)}, {keyUrls, QCborArray::fromStringList(urls)}} .toCborValue() .toCbor(); } void IpcServerSocket::listenToConnections(QLocalServer* server) { QObject::connect(server, &QLocalServer::newConnection, this, [this, server]() { QLocalSocket* socket = server->nextPendingConnection(); QObject::connect(socket, &QLocalSocket::disconnected, socket, &QLocalSocket::deleteLater); QTimer::singleShot(30000, socket, &QLocalSocket::disconnectFromServer); QObject::connect(socket, &QLocalSocket::readyRead, this, [this, socket]() { const QByteArray message(socket->readAll()); if (message.size() == 1 && message.front() == activateWindowMessage) { info().log("IpcServerSocket: window activation requested"); emit windowActivationRequested({}); } else { QCborParserError error{}; const auto cbor = QCborValue::fromCbor(message, &error); if (error.error != QCborError::NoError) { warning().log("IpcServerSocket: failed to parse CBOR message: {}", error); return; } info().log("Arguments received: {}", cbor); const auto map = cbor.toMap(); const auto files = toStringList(map[keyFiles]); const auto urls = toStringList(map[keyUrls]); emit torrentsAddingRequested(files, urls, {}); } }); }); } } tremotesf-2.8.2/src/ipc/ipcserver_socket.h000066400000000000000000000013031500171105600206110ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_IPCSERVER_SOCKET_H #define TREMOTESF_IPCSERVER_SOCKET_H #include "ipcserver.h" class QLocalServer; namespace tremotesf { class IpcServerSocket final : public IpcServer { Q_OBJECT public: static QString socketName(); explicit IpcServerSocket(QObject* parent = nullptr); static constexpr char activateWindowMessage = '\0'; static QByteArray createAddTorrentsMessage(const QStringList& files, const QStringList& urls); private: void listenToConnections(QLocalServer* server); }; } #endif // TREMOTESF_IPCSERVER_SOCKET_H tremotesf-2.8.2/src/ipc/org.freedesktop.Application.xml000066400000000000000000000020421500171105600231540ustar00rootroot00000000000000 tremotesf-2.8.2/src/itemlistupdater.h000066400000000000000000000153411500171105600177120ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_ITEMLISTUPDATER_H #define TREMOTESF_ITEMLISTUPDATER_H #include #include #include #include #include #include #include #if __has_include() # include #else # include #endif namespace tremotesf { class ItemBatchProcessor { public: inline explicit ItemBatchProcessor(std::function&& action) : mAction(std::move(action)) {} void nextIndex(size_t index) { if (!firstIndex.has_value()) { reset(index); return; } if (!lastIndex.has_value()) throw std::logic_error("lastIndex is empty"); if (index == *lastIndex) { lastIndex = index + 1; } else { commit(); reset(index); } } std::optional commitIfNeeded() { if (firstIndex) { return commit(); } return std::nullopt; } std::optional firstIndex = std::nullopt; std::optional lastIndex = std::nullopt; private: void reset(size_t index) { firstIndex = index; lastIndex = index + 1; } size_t commit() { if (!firstIndex.has_value()) throw std::logic_error("firstIndex is empty"); if (!lastIndex.has_value()) throw std::logic_error("lastIndex is empty"); mAction(*firstIndex, *lastIndex); const size_t size = *lastIndex - *firstIndex; firstIndex = std::nullopt; lastIndex = std::nullopt; return size; } std::function mAction; }; template> class ItemListUpdater { using NewItem = std::ranges::range_value_t; public: ItemListUpdater() = default; virtual ~ItemListUpdater() = default; Q_DISABLE_COPY_MOVE(ItemListUpdater) void update(std::vector& items, NewItemsRange newItems) { if (!items.empty()) { auto removedBatchProcessor = ItemBatchProcessor([&](size_t first, size_t last) { onAboutToRemoveItems(first, last); items.erase( items.begin() + static_cast(first), items.begin() + static_cast(last) ); onRemovedItems(first, last); }); auto changedBatchProcessor = ItemBatchProcessor([&](size_t first, size_t last) { onChangedItems(first, last); }); for (size_t i = 0, max = items.size(); i < max; ++i) { Item* item = &items[i]; auto found = findNewItemForItem(newItems, *item); if (found == newItems.end()) { changedBatchProcessor.commitIfNeeded(); removedBatchProcessor.nextIndex(i); } else { if (auto size = removedBatchProcessor.commitIfNeeded(); size) { i -= *size; max -= *size; item = &items[i]; } if (updateItem(*item, std::forward(*found))) { changedBatchProcessor.nextIndex(i); } else { changedBatchProcessor.commitIfNeeded(); } newItems.erase(found); } } removedBatchProcessor.commitIfNeeded(); changedBatchProcessor.commitIfNeeded(); } if (!newItems.empty()) { const size_t count = newItems.size(); onAboutToAddItems(count); items.reserve(items.size() + count); for (auto&& newItem : newItems) { items.push_back(createItemFromNewItem(std::forward(newItem))); } onAddedItems(count); } } protected: /** * @brief Find NewItem for corresponing Item with the same identity * @param container container of NewItems * @param item Item that should be found in container * @return iterator to the NewItem with the same identity as Item, or end iterator * Default implementation simply checks for equality of items or throws logic_error if they are not comparable */ inline virtual std::ranges::iterator_t findNewItemForItem(NewItemsRange& newItems, const Item& item) { if constexpr (std::equality_comparable_with) { return std::ranges::find(newItems, item); } else { throw std::logic_error("findNewItemForItem() must be implemented"); } }; virtual void onAboutToRemoveItems(size_t first, size_t last) = 0; virtual void onRemovedItems(size_t first, size_t last) = 0; /** * @brief Update Item from the NewItem with the same identity * @param item Item that will be updated * @param newItem NewItem, guaranteed to have the same identity as the Item * @return true if Item was changed, otherwise false * Default implementation simply returns false * (with default implementation of findNewItemForItem() Item and NewItem will be always equal) */ // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) inline virtual bool updateItem([[maybe_unused]] Item& item, [[maybe_unused]] NewItem&& newItem) { return false; }; virtual void onChangedItems(size_t first, size_t last) = 0; /** * @brief Create Item from NewItem * @return new Item instance * Default implementation performs implicit conversion from NewItem to Item, * or throws logic_error if types are not convertible */ inline virtual Item createItemFromNewItem(NewItem&& newItem) { if constexpr (std::convertible_to) { return std::move(newItem); } else { throw std::logic_error("createItemFromNewItem() must be implemented"); } }; virtual void onAboutToAddItems(size_t count) = 0; virtual void onAddedItems(size_t count) = 0; }; } #endif // TREMOTESF_ITEMLISTUPDATER_H tremotesf-2.8.2/src/itemlistupdater_test.cpp000066400000000000000000000275101500171105600213050ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include #include #include "log/log.h" #include "itemlistupdater.h" #include "stdutils.h" struct Item { int id; QString data; bool operator==(const Item& other) const = default; bool operator<(const Item& other) const { return id < other.id; } bool operator<=(const Item& other) const { return id <= other.id; } bool operator>(const Item& other) const { return id > other.id; } bool operator>=(const Item& other) const { return id > other.id; } }; namespace fmt { template<> struct formatter : tremotesf::SimpleFormatter { format_context::iterator format(const Item& item, format_context& ctx) const { return fmt::format_to(ctx.out(), "Item(id={}, data={})", item.id, item.data); } }; } class AbortTest : public std::exception {}; #define QVERIFY_THROW(statement) \ do { \ if (!QTest::qVerify(static_cast(statement), #statement, "", __FILE__, __LINE__)) throw AbortTest(); \ } while (false) #define QCOMPARE_THROW(actual, expected) \ do { \ if (!QTest::qCompare(actual, expected, #actual, #expected, __FILE__, __LINE__)) throw AbortTest(); \ } while (false) namespace tremotesf { class Updater final : public tremotesf::ItemListUpdater { public: std::vector> aboutToRemoveIndexRanges; std::vector> removedIndexRanges; std::vector> changedIndexRanges; std::optional aboutToAddCount; std::optional addedCount; protected: typename std::vector::iterator findNewItemForItem(std::vector& container, const Item& item) override { return std::ranges::find(container, item.id, &Item::id); }; void onAboutToRemoveItems(size_t first, size_t last) override { aboutToRemoveIndexRanges.emplace_back(first, last); } void onRemovedItems(size_t first, size_t last) override { removedIndexRanges.emplace_back(first, last); } // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) bool updateItem(Item& item, Item&& newItem) override { if (item != newItem) { item = newItem; return true; } return false; } void onChangedItems(size_t first, size_t last) override { changedIndexRanges.emplace_back(first, last); } Item createItemFromNewItem(Item&& newItem) override { return std::move(newItem); } void onAboutToAddItems(size_t count) override { if (aboutToAddCount) { QFAIL("onAboutToAddItems() must be called only once"); } aboutToAddCount = count; } void onAddedItems(size_t count) override { if (addedCount) { QFAIL("onAddedItems() must be called only once"); } addedCount = count; } }; class ItemListUpdaterTest final : public QObject { Q_OBJECT private slots: void emptyListDidNotChange() { checkUpdate({}, {}); } void singleItemListDidNotChange() { checkUpdate({{42, "666"}}, {{42, "666"}}); } void multipleItemsListDidNotChange() { checkUpdate({{42, "666"}, {1, "Foo"}, {11, "Bar"}}, {{42, "666"}, {1, "Foo"}, {11, "Bar"}}); } void addedSingleItemToEmptyList() { checkUpdate({}, {{42, "666"}}); } void addedMultipleItemsToEmptyList() { checkUpdate({}, {{42, "666"}, {1, "Foo"}}); } void addedSingleItemToNonEmptyList() { checkUpdate({{42, "666"}}, {Item{42, "666"}, {1, "Foo"}}); } void addedMultipleItemsToNonEmptyList() { checkUpdate({{42, "666"}}, {{42, "666"}, {1, "Foo"}, {11, "Bar"}}); } void removedItemFromSingleItemList() { checkUpdate({{42, "666"}}, {}); } void removedLastItemFromMultipleItemsList() { checkUpdate({{42, "666"}, {666, ""}}, {{42, "666"}}); } void removedFirstItemFromMultipleItemsList() { checkUpdate({{42, "666"}, {666, ""}}, {{666, ""}}); } void removedMiddleItemFromMultipleItemsList() { checkUpdate({{42, "666"}, {666, ""}, {1, ""}}, {{42, "666"}, {1, ""}}); } void removedTwoConsecutiveItemsFromEnd() { checkUpdate({{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}}, {{42, "666"}, {666, ""}}); } void removedTwoConsecutiveItemsFromBeginning() { checkUpdate({{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}}, {{1, ""}, {18, "Nope"}}); } void removedTwoConsecutiveItemsFromMiddle() { checkUpdate({{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}}, {{42, "666"}, {18, "Nope"}}); } void removedTwoSeparateItems1() { checkUpdate( {{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}, {77, "Foo"}}, {{42, "666"}, {666, ""}, {18, "Nope"}} ); } void removedTwoSeparateItems2() { checkUpdate( {{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}, {77, "Foo"}}, {{666, ""}, {18, "Nope"}, {77, "Foo"}} ); } void removedTwoSeparateItems3() { checkUpdate( {{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}, {77, "Foo"}}, {{666, ""}, {1, ""}, {18, "Nope"}} ); } void changedSingleItemList() { checkUpdate({{42, "666"}}, {{42, "42"}}); } void changedFirstItemInMultipleItemList() { checkUpdate({{42, "666"}, {666, ""}, {1, ""}}, {{42, "42"}, {666, ""}, {1, ""}}); } void changedMiddleItemInMultipleItemList() { checkUpdate({{42, "666"}, {666, ""}, {1, ""}}, {{42, "666"}, {666, "666"}, {1, ""}}); } void changedLastItemInMultipleItemList() { checkUpdate({{42, "666"}, {666, ""}, {1, ""}}, {{42, "666"}, {666, ""}, {1, "nnn"}}); } void changedTwoConsecutiveItemsFromBeginning() { checkUpdate( {{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}, {77, "Foo"}}, {{42, "AAAA"}, {666, "ok"}, {1, ""}, {18, "Nope"}, {77, "Foo"}} ); } void changedTwoConsecutiveItemsFromMiddle() { checkUpdate( {{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}, {77, "Foo"}}, {{42, "666"}, {666, ""}, {1, "uhh"}, {18, ""}, {77, "Foo"}} ); } void changedTwoConsecutiveItemsFromEnd() { checkUpdate( {{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}, {77, "Foo"}}, {{42, "666"}, {666, ""}, {1, ""}, {18, "arr"}, {77, "qr"}} ); } void changedTwoSeparateItems1() { checkUpdate( {{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}, {77, "Foo"}}, {{42, "667"}, {666, ""}, {1, ""}, {18, "Nope"}, {77, "Fooo"}} ); } void changedTwoSeparateItems2() { checkUpdate( {{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}, {77, "Foo"}}, {{42, "666"}, {666, "sure"}, {1, ""}, {18, "aoa"}, {77, "Foo"}} ); } void changedTwoSeparateItems3() { checkUpdate( {{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}, {77, "Foo"}}, {{42, "666"}, {666, ""}, {1, "uwu"}, {18, "Nope"}, {77, "www"}} ); } void removedAndAdded() { checkUpdate( {{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}, {77, "Foo"}}, {{666, ""}, {1, ""}, {18, "Nope"}, {33, "fffu"}, {-2, "ew"}} ); } void changedAndAdded() { checkUpdate( {{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}, {77, "Foo"}}, {{42, "666"}, {666, "yeeee"}, {1, "what"}, {18, "Nope"}, {77, "now"}, {999, "big"}} ); } void removedChangedAndAdded() { checkUpdate( {{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}, {77, "Foo"}}, {{42, "sad"}, {1, "small"}, {33, "fffu"}, {-2, "ew"}} ); } void removedChangedAndAdded2() { checkUpdate( {{42, "666"}, {666, ""}, {1, ""}, {18, "Nope"}, {77, "Foo"}}, {{42, "666"}, {666, "333"}, {18, "Nope"}, {77, "Foo"}} ); } private: void checkUpdate(const std::vector& oldList, std::vector newList) { checkThatItemsAreUnique(oldList); checkThatItemsAreUnique(newList); info().log("Checking update from {}", oldList); std::ranges::sort(newList); do { info().log(" - to {}", newList); try { checkUpdateInner(oldList, newList); } catch (const AbortTest&) { break; } } while (std::ranges::next_permutation(newList).found); } void checkUpdateInner(const std::vector& oldList, const std::vector& newList) { auto directlyUpdatedList = oldList; Updater updater; updater.update(directlyUpdatedList, std::vector(newList)); QVERIFY_THROW(updater.aboutToRemoveIndexRanges == updater.removedIndexRanges); QVERIFY_THROW(updater.aboutToAddCount == updater.addedCount); QCOMPARE_THROW(directlyUpdatedList.size(), newList.size()); checkThatItemsAreUnique(directlyUpdatedList); QCOMPARE_THROW( std::set(directlyUpdatedList.begin(), directlyUpdatedList.end()), std::set(newList.begin(), newList.end()) ); auto indirectlyUpdatedList = oldList; for (const auto& [first, last] : updater.removedIndexRanges) { indirectlyUpdatedList.erase( indirectlyUpdatedList.begin() + static_cast(first), indirectlyUpdatedList.begin() + static_cast(last) ); } for (const auto& [first, last] : updater.changedIndexRanges) { std::ranges::copy( slice(directlyUpdatedList, first, last), indirectlyUpdatedList.begin() + static_cast(first) ); std::ranges::copy( slice(directlyUpdatedList, first, last), indirectlyUpdatedList.begin() + static_cast(first) ); } if (updater.addedCount) { indirectlyUpdatedList.reserve(indirectlyUpdatedList.size() + *updater.addedCount); std::ranges::copy( std::views::drop( directlyUpdatedList, static_cast(directlyUpdatedList.size() - *updater.addedCount) ), std::back_insert_iterator(indirectlyUpdatedList) ); } QCOMPARE_THROW(indirectlyUpdatedList, directlyUpdatedList); } void checkThatItemsAreUnique(const std::vector& list) { const auto set = std::set(list.begin(), list.end()); QCOMPARE_THROW(set.size(), list.size()); } }; } QTEST_GUILESS_MAIN(tremotesf::ItemListUpdaterTest) #include "itemlistupdater_test.moc" tremotesf-2.8.2/src/license.html000066400000000000000000001113301500171105600166250ustar00rootroot00000000000000 GNU General Public License v3.0 - GNU Project - Free Software Foundation (FSF)

GNU GENERAL PUBLIC LICENSE

Version 3, 29 June 2007

Copyright © 2007 Free Software Foundation, Inc. <http://fsf.org/>

Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.

Preamble

The GNU General Public License is a free, copyleft license for software and other kinds of works.

The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.

When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.

To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.

For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.

Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.

For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.

Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.

Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.

The precise terms and conditions for copying, distribution and modification follow.

TERMS AND CONDITIONS

0. Definitions.

“This License” refers to version 3 of the GNU General Public License.

“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.

“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.

To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.

A “covered work” means either the unmodified Program or a work based on the Program.

To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.

To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.

An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.

1. Source Code.

The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.

A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.

The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.

The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.

The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.

The Corresponding Source for a work in source code form is that same work.

2. Basic Permissions.

All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.

You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.

Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.

3. Protecting Users' Legal Rights From Anti-Circumvention Law.

No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.

When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.

4. Conveying Verbatim Copies.

You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.

You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.

5. Conveying Modified Source Versions.

You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:

  • a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
  • b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
  • c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
  • d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.

A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.

6. Conveying Non-Source Forms.

You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:

  • a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
  • b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
  • c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
  • d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
  • e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.

A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.

A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.

“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.

If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).

The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.

Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.

7. Additional Terms.

“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.

When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.

Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:

  • a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
  • b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
  • c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
  • d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
  • e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
  • f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.

All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.

If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.

Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.

8. Termination.

You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).

However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.

Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.

Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.

9. Acceptance Not Required for Having Copies.

You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.

10. Automatic Licensing of Downstream Recipients.

Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.

An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.

You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.

11. Patents.

A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.

A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.

Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.

In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.

If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.

If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.

A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.

Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.

12. No Surrender of Others' Freedom.

If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.

13. Use with the GNU Affero General Public License.

Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.

14. Revised Versions of this License.

The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.

Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.

If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.

Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.

15. Disclaimer of Warranty.

THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

16. Limitation of Liability.

IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.

17. Interpretation of Sections 15 and 16.

If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.

END OF TERMS AND CONDITIONS

How to Apply These Terms to Your New Programs

If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.

To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.

    <one line to give the program's name and a brief idea of what it does.>
    Copyright (C) <year>  <name of author>

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program.  If not, see <http://www.gnu.org/licenses/>.

Also add information on how to contact you by electronic and paper mail.

If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:

    <program>  Copyright (C) <year>  <name of author>
    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
    This is free software, and you are welcome to redistribute it
    under certain conditions; type `show c' for details.

The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.

You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <http://www.gnu.org/licenses/>.

The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <http://www.gnu.org/philosophy/why-not-lgpl.html>.

tremotesf-2.8.2/src/literals.h000066400000000000000000000012141500171105600163040ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_LITERALS_H #define TREMOTESF_LITERALS_H #include namespace tremotesf { // QLatin1String(const char*) is not guaranteed to be constexpr in Qt < 6.4 since it uses strlen() // Add user-defined literal to circumvent this (and it's nicer to use!) inline constexpr QLatin1String operator""_l1(const char* str, size_t length) { // NOLINTNEXTLINE(modernize-return-braced-init-list) return QLatin1String(str, static_cast(length)); } } #endif // TREMOTESF_LITERALS_H tremotesf-2.8.2/src/log/000077500000000000000000000000001500171105600150775ustar00rootroot00000000000000tremotesf-2.8.2/src/log/demangle.cpp000066400000000000000000000024311500171105600173570ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "demangle.h" #include #include #if __has_include() # include # define TREMOTESF_HAVE_CXXABI_H #else # include #endif namespace tremotesf::impl { #ifdef TREMOTESF_HAVE_CXXABI_H std::string demangleTypeName(const char* typeName) { const std::unique_ptr demangled( abi::__cxa_demangle(typeName, nullptr, nullptr, nullptr), &free ); return demangled ? demangled.get() : typeName; } #else namespace { using namespace std::string_view_literals; constexpr auto structPrefix = "struct "sv; constexpr auto classPrefix = "class "sv; void removeSubstring(std::string& str, std::string_view substring) { size_t pos{}; while ((pos = str.find(substring, pos)) != std::string::npos) { str.erase(pos, substring.size()); } } } std::string demangleTypeName(const char* typeName) { std::string demangled = typeName; removeSubstring(demangled, structPrefix); removeSubstring(demangled, classPrefix); return demangled; } #endif } tremotesf-2.8.2/src/log/demangle.h000066400000000000000000000007411500171105600170260ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_LOG_DEMANGLE_H #define TREMOTESF_LOG_DEMANGLE_H #include #include namespace tremotesf { namespace impl { std::string demangleTypeName(const char* typeName); } template std::string typeName(const T& t) { return impl::demangleTypeName(typeid(t).name()); } } #endif // TREMOTESF_LOG_DEMANGLE_H tremotesf-2.8.2/src/log/demangle_test.cpp000066400000000000000000000025011500171105600204140ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include "demangle.h" using namespace tremotesf; struct Foo {}; class Bar {}; namespace foobar { struct Foo {}; class Bar {}; } template struct What {}; class DemangleTest final : public QObject { Q_OBJECT private slots: void checkInt() { const int foo{}; QCOMPARE(typeName(foo), "int"); } void checkStruct() { const Foo foo{}; QCOMPARE(typeName(foo), "Foo"); } void checkClass() { const Bar bar{}; QCOMPARE(typeName(bar), "Bar"); } void checkNamespacedStruct() { const foobar::Foo foo{}; QCOMPARE(typeName(foo), "foobar::Foo"); } void checkNamespacedClass() { const foobar::Bar bar{}; QCOMPARE(typeName(bar), "foobar::Bar"); } void checkTemplatedStruct() { const What what{}; QCOMPARE(typeName(what), "What"); } void checkTemplatedStruct2() { const What what{}; QCOMPARE(typeName(what), "What"); } void checkTemplatedStruct3() { const What what{}; QCOMPARE(typeName(what), "What"); } }; QTEST_GUILESS_MAIN(DemangleTest) #include "demangle_test.moc" tremotesf-2.8.2/src/log/formatters.cpp000066400000000000000000000113301500171105600177670ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "formatters.h" #include // If we don't include it here we will get undefined reference link error for fmt::formatter #include #include #include #include #if QT_VERSION_MAJOR >= 6 # include #endif #ifdef Q_OS_WIN # include # include #endif #include "demangle.h" namespace tremotesf::impl { template fmt::format_context::iterator formatQEnumImpl(const QMetaEnum& meta, Integer value, fmt::format_context& ctx) { const auto named = #if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) meta.valueToKey(static_cast(value)); #else meta.valueToKey(static_cast(value)); #endif std::string unnamed{}; const auto string = [&] { if (named) return std::string_view(named); unnamed = fmt::format("", value); return std::string_view(unnamed); }(); return fmt::format_to(ctx.out(), "{}::{}::{}", meta.scope(), meta.enumName(), string); } fmt::format_context::iterator formatQEnum(const QMetaEnum& meta, std::intmax_t value, fmt::format_context& ctx) { return formatQEnumImpl(meta, value, ctx); } fmt::format_context::iterator formatQEnum(const QMetaEnum& meta, std::uintmax_t value, fmt::format_context& ctx) { return formatQEnumImpl(meta, value, ctx); } } namespace { fmt::string_view toFmtStringView(const QByteArray& str) { return {str.data(), static_cast(str.size())}; } fmt::format_context::iterator formatSystemError(std::string_view type, const std::system_error& e, fmt::format_context& ctx) { const int code = e.code().value(); return fmt::format_to( ctx.out(), "{}: {} (error code {} ({:#x}))", type, e.what(), code, static_cast(code) ); } } namespace fmt { format_context::iterator formatter::format(const QString& string, format_context& ctx) const { return formatter::format(toFmtStringView(string.toUtf8()), ctx); } format_context::iterator formatter::format(const QStringView& string, format_context& ctx) const { return formatter::format(toFmtStringView(string.toUtf8()), ctx); } format_context::iterator formatter::format(const QLatin1String& string, format_context& ctx) const { return formatter::format(std::string_view(string.data(), static_cast(string.size())), ctx); } format_context::iterator formatter::format(const QByteArray& array, format_context& ctx) const { return formatter::format(toFmtStringView(array), ctx); } #if QT_VERSION_MAJOR >= 6 format_context::iterator formatter::format(const QUtf8StringView& string, format_context& ctx) const { return formatter::format(string_view(string.data(), static_cast(string.size())), ctx); } format_context::iterator formatter::format(const QAnyStringView& string, format_context& ctx) const { return formatter::format(string.toString(), ctx); } #endif format_context::iterator formatter::format(const std::exception& e, format_context& ctx) const { const auto type = tremotesf::typeName(e); const auto what = e.what(); if (auto s = dynamic_cast(&e); s) { return formatSystemError(type, *s, ctx); } return fmt::format_to(ctx.out(), "{}: {}", type, what); } format_context::iterator formatter::format(const std::system_error& e, format_context& ctx) const { return formatSystemError(tremotesf::typeName(e), e, ctx); } #ifdef Q_OS_WIN format_context::iterator formatter::format(const winrt::hstring& str, format_context& ctx) const { return formatter::format( QString::fromWCharArray(str.data(), static_cast(str.size())), ctx ); } format_context::iterator formatter::format(const winrt::hresult_error& e, format_context& ctx) const { const auto code = e.code().value; return fmt::format_to( ctx.out(), "{}: {} (error code {} ({:#x}))", tremotesf::typeName(e), e.message(), code, static_cast(code) ); } #endif // Q_OS_WIN } tremotesf-2.8.2/src/log/formatters.h000066400000000000000000000131031500171105600174340ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_LOG_FORMATTERS_H #define TREMOTESF_LOG_FORMATTERS_H #include #include #include #include #include #include #include #include #include #if QT_VERSION_MAJOR >= 6 # include #endif #if FMT_VERSION_MAJOR >= 11 # include #else # include #endif namespace tremotesf { struct SimpleFormatter { constexpr fmt::format_parse_context::iterator parse(fmt::format_parse_context& ctx) { return ctx.begin(); } }; } template<> struct fmt::formatter : formatter { format_context::iterator format(const QString& string, format_context& ctx) const; }; class QStringView; template<> struct fmt::formatter : formatter { format_context::iterator format(const QStringView& string, format_context& ctx) const; }; class QLatin1String; template<> struct fmt::formatter : formatter { format_context::iterator format(const QLatin1String& string, format_context& ctx) const; }; class QByteArray; template<> struct fmt::formatter : formatter { format_context::iterator format(const QByteArray& array, format_context& ctx) const; }; #if QT_VERSION_MAJOR >= 6 template<> struct fmt::formatter : formatter { format_context::iterator format(const QUtf8StringView& string, format_context& ctx) const; }; class QAnyStringView; template<> struct fmt::formatter : formatter { format_context::iterator format(const QAnyStringView& string, format_context& ctx) const; }; #endif namespace tremotesf::impl { inline constexpr auto singleArgumentFormatString = "{}"; template concept QDebugPrintable = requires(T t, QDebug d) { d << t; }; template struct QDebugFormatter : SimpleFormatter { fmt::format_context::iterator format(const T& t, fmt::format_context& ctx) const { QString buffer{}; QDebug stream(&buffer); stream.nospace() << t; return fmt::format_to(ctx.out(), singleArgumentFormatString, buffer); } }; // This relies on private Qt API but it should work at least until Qt 7 template concept QEnum = std::is_enum_v && requires { qt_getEnumMetaObject(T{}); }; fmt::format_context::iterator formatQEnum(const QMetaEnum& meta, std::intmax_t value, fmt::format_context& ctx); fmt::format_context::iterator formatQEnum(const QMetaEnum& meta, std::uintmax_t value, fmt::format_context& ctx); } namespace fmt { template T> struct formatter : tremotesf::SimpleFormatter { format_context::iterator format(const T& object, format_context& ctx) const { QString buffer{}; QDebug stream(&buffer); stream.nospace() << &object; return fmt::format_to(ctx.out(), tremotesf::impl::singleArgumentFormatString, buffer); } }; template struct formatter : tremotesf::SimpleFormatter { fmt::format_context::iterator format(T t, fmt::format_context& ctx) const { const auto meta = QMetaEnum::fromType(); if constexpr (std::signed_integral>) { return tremotesf::impl::formatQEnum(meta, static_cast(t), ctx); } else { return tremotesf::impl::formatQEnum(meta, static_cast(t), ctx); } } }; } #define SPECIALIZE_FORMATTER_FOR_QDEBUG(Class) \ namespace fmt { \ template<> \ struct formatter : tremotesf::impl::QDebugFormatter {}; \ } #define DISABLE_RANGE_FORMATTING(Class) \ namespace fmt { \ template<> \ struct is_range : std::false_type {}; \ template<> \ struct is_range : std::false_type {}; \ } namespace fmt { template<> struct formatter : tremotesf::SimpleFormatter { format_context::iterator format(const std::exception& e, format_context& ctx) const; }; template T> requires(!std::derived_from) struct formatter : formatter {}; template<> struct formatter : tremotesf::SimpleFormatter { format_context::iterator format(const std::system_error& e, format_context& ctx) const; }; template T> struct formatter : formatter {}; } #ifdef Q_OS_WIN namespace winrt { struct hstring; struct hresult_error; } namespace fmt { template<> struct formatter : formatter { format_context::iterator format(const winrt::hstring& str, format_context& ctx) const; }; template<> struct formatter : tremotesf::SimpleFormatter { format_context::iterator format(const winrt::hresult_error& e, format_context& ctx) const; }; } #endif #endif // TREMOTESF_LOG_FORMATTERS_H tremotesf-2.8.2/src/log/log.cpp000066400000000000000000000063521500171105600163720ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "log.h" #include #ifdef Q_OS_WIN # include # include #endif namespace tremotesf { constexpr auto defaultLogLevel = #ifdef QT_DEBUG QtDebugMsg; #else QtInfoMsg; #endif Q_LOGGING_CATEGORY(tremotesfLoggingCategory, tremotesfLoggingCategoryName, defaultLogLevel) void overrideDebugLogs(bool enable) { constexpr auto loggingRulesEnvVariable = "QT_LOGGING_RULES"; QByteArray rules = qgetenv(loggingRulesEnvVariable); if (!rules.isEmpty()) { rules += ";"; } rules += fmt::format("{}.debug={}", tremotesfLoggingCategoryName, enable).c_str(); qputenv(loggingRulesEnvVariable, rules); } namespace { template void appendNestedException(std::string& out, const T& e) { fmt::format_to(std::back_insert_iterator(out), "\nCaused by: {}", e); } template void appendNestedExceptions(std::string& out, const T& e) { try { std::rethrow_if_nested(e); } catch (const std::system_error& nested) { appendNestedException(out, nested); appendNestedExceptions(out, nested); } catch (const std::exception& nested) { appendNestedException(out, nested); appendNestedExceptions(out, nested); } #ifdef Q_OS_WIN catch (const winrt::hresult_error& nested) { appendNestedException(out, nested); appendNestedExceptions(out, nested); } #endif catch (...) { fmt::format_to(std::back_insert_iterator(out), "\nCaused by: unknown exception"); } } } template std::string formatExceptionRecursivelyImpl(const E& e) { std::string out = fmt::format(impl::singleArgumentFormatString, e); appendNestedExceptions(out, e); return out; } std::string formatExceptionRecursively(const std::exception& e) { return formatExceptionRecursivelyImpl(e); } std::string formatExceptionRecursively(const std::system_error& e) { return formatExceptionRecursivelyImpl(e); } #ifdef Q_OS_WIN std::string formatExceptionRecursively(const winrt::hresult_error& e) { return formatExceptionRecursivelyImpl(e); } #endif QString Logger::formatToQString(fmt::string_view fmt, fmt::format_args args) { return QString::fromStdString(fmt::vformat(fmt, args)); } void Logger::logWithFormatArgs(fmt::string_view fmt, fmt::format_args args) const { logImpl(formatToQString(fmt, args)); } void Logger::logImpl(const QString& string) const { // We use internal qt_message_output() function here because there are only two methods // to output string to QMessageLogger and they have overheads that are unneccessary // when we are doing formatting on our own: // 1. QDebug marshalls everything through QTextStream // 2. QMessageLogger::<>(const char*, ...) overloads perform QString::vasprintf() formatting qt_message_output(type, context, string); } } tremotesf-2.8.2/src/log/log.h000066400000000000000000000157111500171105600160360ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_LOG_LOG_H #define TREMOTESF_LOG_LOG_H #include #include #include #include #include #include #include #if FMT_VERSION_MAJOR >= 11 # include #else # include #endif #ifdef Q_OS_WIN # include # include #endif #include "formatters.h" #if __has_cpp_attribute(gnu::always_inline) # define ALWAYS_INLINE [[gnu::always_inline]] inline #elif __has_cpp_attribute(msvc::forceinline) # define ALWAYS_INLINE [[msvc::forceinline]] inline #elif defined(_MSC_VER) # define ALWAYS_INLINE __forceinline #else # define ALWAYS_INLINE inline #endif namespace tremotesf { namespace impl { template concept IsException = std::derived_from, std::exception> #ifdef Q_OS_WIN || std::derived_from, winrt::hresult_error> #endif ; template concept IsQStringView = std::same_as, QStringView> #if QT_VERSION_MAJOR >= 6 || std::same_as, QUtf8StringView> || std::same_as, QAnyStringView> #endif ; template T> ALWAYS_INLINE QString convertToQString(const T& string) { return static_cast(string); } template T> requires(!std::convertible_to && !impl::IsQStringView) ALWAYS_INLINE QString convertToQString(const T& string) { const auto stringView = static_cast(string); return QString::fromUtf8(stringView.data(), static_cast(stringView.size())); } template ALWAYS_INLINE QString convertToQString(const T& string) { return string.toString(); } template concept CanConvertToQString = !std::same_as, QString> && requires(T string) { tremotesf::impl::convertToQString(string); }; inline void printNewline(FILE* stream) { std::fwrite("\n", 1, 1, stream); } } constexpr auto tremotesfLoggingCategoryName = "tremotesf"; Q_DECLARE_LOGGING_CATEGORY(tremotesfLoggingCategory) void overrideDebugLogs(bool enable); std::string formatExceptionRecursively(const std::exception& e); std::string formatExceptionRecursively(const std::system_error& e); #ifdef Q_OS_WIN std::string formatExceptionRecursively(const winrt::hresult_error& e); #endif struct Logger { ALWAYS_INLINE consteval explicit Logger(QtMsgType type, std::source_location location) : type(type), context( location.file_name(), static_cast(location.line()), location.function_name(), tremotesfLoggingCategoryName ) {} ALWAYS_INLINE void log(const QString& string) const { if (isEnabled()) { logImpl(string); } } template ALWAYS_INLINE void log(const T& string) const { if (isEnabled()) { logImpl(impl::convertToQString(string)); } } /** * Format then print */ template ALWAYS_INLINE void log(const T& value) const { if (isEnabled()) { logWithFormatArgs( fmt::format_string(impl::singleArgumentFormatString), fmt::make_format_args(value) ); } } template requires(sizeof...(Args) != 0) ALWAYS_INLINE void log(fmt::format_string fmt, const Args&... args) const { if (isEnabled()) { logWithFormatArgs(fmt, fmt::make_format_args(args...)); } } /** * Special functions to print nested exceptions recursively */ template ALWAYS_INLINE void logWithException(const E& e, const T& value) const { if (isEnabled()) { const auto formattedException = formatExceptionRecursively(e); logWithFormatArgs( fmt::format_string("{}\n{}"), fmt::make_format_args(value, formattedException) ); } } template requires(sizeof...(Args) != 0) ALWAYS_INLINE void logWithException(const E& e, fmt::format_string fmt, const Args&... args) const { if (isEnabled()) { auto message = formatToQString(fmt, fmt::make_format_args(args...)); message += '\n'; #if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) message += formatExceptionRecursively(e); #else message += formatExceptionRecursively(e).c_str(); #endif logImpl(message); } } private: ALWAYS_INLINE bool isEnabled() const { return tremotesfLoggingCategory().isEnabled(type); } static QString formatToQString(fmt::string_view fmt, fmt::format_args args); void logWithFormatArgs(fmt::string_view fmt, fmt::format_args args) const; /** * Actual log function */ void logImpl(const QString& string) const; QtMsgType type; QMessageLogContext context; }; ALWAYS_INLINE consteval Logger debug(std::source_location location = std::source_location::current()) { return Logger(QtDebugMsg, location); } ALWAYS_INLINE consteval Logger info(std::source_location location = std::source_location::current()) { return Logger(QtInfoMsg, location); } ALWAYS_INLINE consteval Logger warning(std::source_location location = std::source_location::current()) { return Logger(QtWarningMsg, location); } ALWAYS_INLINE consteval Logger fatal(std::source_location location = std::source_location::current()) { return Logger(QtFatalMsg, location); } template ALWAYS_INLINE void printlnStdout(const T& value) { fmt::print(stdout, fmt::format_string(impl::singleArgumentFormatString), value); impl::printNewline(stdout); } template requires(sizeof...(Args) != 0) ALWAYS_INLINE void printlnStdout(fmt::format_string fmt, const Args&... args) { fmt::vprint(stdout, fmt, fmt::make_format_args(args...)); impl::printNewline(stdout); } } #endif // TREMOTESF_LOG_LOG_H tremotesf-2.8.2/src/log/log_test.cpp000066400000000000000000000206301500171105600174240ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include #ifdef Q_OS_WIN # include # include #endif #include #include #include #include "rpc/torrent.h" #include "log.h" SPECIALIZE_FORMATTER_FOR_QDEBUG(QVariant) using namespace tremotesf; #ifdef Q_OS_WIN static constexpr auto E_ACCESSDENIED = static_cast(0x80070005); #endif class PrintlnTest final : public QObject { Q_OBJECT private slots: void stdoutStringLiteral() { printlnStdout("foo"); printlnStdout("{}", "foo"); printlnStdout(FMT_STRING("{}"), "foo"); printlnStdout(fmt::runtime("{}"), "foo"); } void stdoutStdString() { const std::string str = "foo"; printlnStdout(str); printlnStdout("{}", str); printlnStdout(FMT_STRING("{}"), str); printlnStdout(fmt::runtime("{}"), str); } void stdoutStdStringView() { const std::string_view str = "foo"; printlnStdout(str); printlnStdout("{}", str); printlnStdout(FMT_STRING("{}"), str); printlnStdout(fmt::runtime("{}"), str); } void stdoutQString() { const QString str = "foo"; printlnStdout(str); printlnStdout("{}", str); printlnStdout(FMT_STRING("{}"), str); printlnStdout(fmt::runtime("{}"), str); } void stdoutQStringView() { const QString _str = "foo"; const QStringView str = _str; printlnStdout(str); printlnStdout("{}", str); printlnStdout(FMT_STRING("{}"), str); printlnStdout(fmt::runtime("{}"), str); } void stdoutQLatin1String() { const auto str = "foo"_l1; printlnStdout(str); printlnStdout("{}", str); printlnStdout(FMT_STRING("{}"), str); printlnStdout(fmt::runtime("{}"), str); } #if QT_VERSION_MAJOR >= 6 void stdoutQUtf8StringView() { const QUtf8StringView str = "foo"; printlnStdout(str); printlnStdout("{}", str); printlnStdout(FMT_STRING("{}"), str); printlnStdout(fmt::runtime("{}"), str); } void stdoutQAnyStringView() { const QAnyStringView str = "foo"; printlnStdout(str); printlnStdout("{}", str); printlnStdout(FMT_STRING("{}"), str); printlnStdout(fmt::runtime("{}"), str); } #endif void stdoutQVariant() { const QVariant value = "foo"; printlnStdout(value); printlnStdout("{}", value); printlnStdout(FMT_STRING("{}"), value); printlnStdout(fmt::runtime("{}"), value); } void stdoutQStringList() { const QStringList list{"foo"}; printlnStdout(list); printlnStdout("{}", list); printlnStdout(FMT_STRING("{}"), list); printlnStdout(fmt::runtime("{}"), list); } void stdoutTorrent() { const Torrent value{}; printlnStdout(value); printlnStdout("{}", value); printlnStdout(FMT_STRING("{}"), value); printlnStdout(fmt::runtime("{}"), value); } void stdoutThis() { printlnStdout(*this); printlnStdout("{}", *this); printlnStdout(FMT_STRING("{}"), *this); printlnStdout(fmt::runtime("{}"), *this); } void stdoutQObject() { QObject value{}; printlnStdout(value); printlnStdout("{}", value); printlnStdout(FMT_STRING("{}"), value); printlnStdout(fmt::runtime("{}"), value); } void infoStringLiteral() { info().log("foo"); info().log("{}", "foo"); info().log(FMT_STRING("{}"), "foo"); info().log(fmt::runtime("{}"), "foo"); } void infoStdString() { const std::string str = "foo"; info().log(str); info().log("{}", str); info().log(FMT_STRING("{}"), str); info().log(fmt::runtime("{}"), str); } void infoStdStringView() { const std::string_view str = "foo"; info().log(str); info().log("{}", str); info().log(FMT_STRING("{}"), str); info().log(fmt::runtime("{}"), str); } void infoQString() { const QString str = "foo"; info().log(str); info().log("{}", str); info().log(FMT_STRING("{}"), str); info().log(fmt::runtime("{}"), str); } void infoQStringView() { const QString _str = "foo"; const QStringView str = _str; info().log(str); info().log("{}", str); info().log(FMT_STRING("{}"), str); info().log(fmt::runtime("{}"), str); } void infoQLatin1String() { const auto str = "foo"_l1; info().log(str); info().log("{}", str); info().log(FMT_STRING("{}"), str); info().log(fmt::runtime("{}"), str); } #if QT_VERSION_MAJOR >= 6 void infoQUtf8StringView() { const QUtf8StringView str = "foo"; info().log(str); info().log("{}", str); info().log(FMT_STRING("{}"), str); info().log(fmt::runtime("{}"), str); } void infoQAnyStringView() { const QAnyStringView str = "foo"; info().log(str); info().log("{}", str); info().log(FMT_STRING("{}"), str); info().log(fmt::runtime("{}"), str); } #endif void infoQVariant() { const QVariant value = "foo"; info().log(value); info().log("{}", value); info().log(FMT_STRING("{}"), value); info().log(fmt::runtime("{}"), value); } void infoQStringList() { const QStringList list{"foo"}; info().log(list); info().log("{}", list); info().log(FMT_STRING("{}"), list); info().log(fmt::runtime("{}"), list); } void infoTorrent() { const Torrent value{}; info().log(value); info().log("{}", value); info().log(FMT_STRING("{}"), value); info().log(fmt::runtime("{}"), value); } void infoThis() { info().log(*this); info().log("{}", *this); info().log(FMT_STRING("{}"), *this); info().log(fmt::runtime("{}"), *this); } void infoQObject() { QObject value{}; info().log(value); info().log("{}", value); info().log(FMT_STRING("{}"), value); info().log(fmt::runtime("{}"), value); } void warningStdException() { const std::runtime_error e("nope"); warning().log(e); } void warningWithStdException() { const std::runtime_error e("nope"); warning().logWithException(e, "oh no"); } void warningNested() { try { try { throw std::runtime_error("nope"); } catch (const std::runtime_error&) { std::throw_with_nested(std::runtime_error("higher-level nope")); } } catch (const std::runtime_error& e) { warning().log(e); } } void warningWithNested() { try { try { throw std::runtime_error("nope"); } catch (const std::runtime_error&) { std::throw_with_nested(std::runtime_error("higher-level nope")); } } catch (const std::runtime_error& e) { warning().logWithException(e, "oh no"); } } #ifdef Q_OS_WIN void warningHresultError() { winrt::hresult_error e(E_ACCESSDENIED); warning().log(e); } void warningWithHresultError() { winrt::hresult_error e(E_ACCESSDENIED); warning().logWithException(e, "oh no"); } void warningHresultErrorNested() { try { try { throw winrt::hresult_error(E_ACCESSDENIED); } catch (const winrt::hresult_error&) { std::throw_with_nested(std::runtime_error("higher-level nope")); } } catch (const std::runtime_error& e) { warning().log(e); } } void warningWithHresultErrorNested() { try { try { throw winrt::hresult_error(E_ACCESSDENIED); } catch (const winrt::hresult_error&) { std::throw_with_nested(std::runtime_error("higher-level nope")); } } catch (const std::runtime_error& e) { warning().logWithException(e, "oh no"); } } #endif }; QTEST_GUILESS_MAIN(PrintlnTest) #include "log_test.moc" tremotesf-2.8.2/src/macoshelpers.h000066400000000000000000000005431500171105600171560ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_MACOSHELPERS_H #define TREMOTESF_MACOSHELPERS_H #include namespace tremotesf { void hideNSApp(); void unhideNSApp(); bool isNSAppHidden(); QString bundleResourcesPath(); } #endif // TREMOTESF_MACOSHELPERS_H tremotesf-2.8.2/src/macoshelpers.mm000066400000000000000000000012441500171105600173370ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "macoshelpers.h" #include #include namespace tremotesf { void hideNSApp() { [NSApp hide:nullptr]; } void unhideNSApp() { [NSApp unhide:nullptr]; } bool isNSAppHidden() { return [NSApp isHidden]; } QString bundleResourcesPath() { auto* const bundle = [NSBundle mainBundle]; if (!bundle) { throw std::runtime_error("[NSBundle mainBundle] returned null"); } auto* const resourcePath = [bundle resourcePath]; return QString::fromNSString(resourcePath); } } tremotesf-2.8.2/src/magnetlinkparser.cpp000066400000000000000000000036551500171105600204010ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include "magnetlinkparser.h" #include "stdutils.h" namespace tremotesf { namespace { constexpr auto xtKey = "xt"_l1; constexpr auto xtValuePrefix = "urn:btih:"_l1; constexpr auto trKey = "tr"_l1; } TorrentMagnetLink parseMagnetLink(const QUrl& url) { if (url.scheme() != magnetScheme) { throw std::runtime_error("URL scheme must be magnet"); } const QUrlQuery query(url); const auto xtValues = query.allQueryItemValues(xtKey, QUrl::FullyDecoded); const auto infoHashV1Value = std::ranges::find_if(xtValues, [](const auto& value) { return value.startsWith(xtValuePrefix); }); if (infoHashV1Value == xtValues.end()) { throw std::runtime_error("Did not find v1 info hash in the URL"); } auto infoHashV1 = infoHashV1Value->mid(xtValuePrefix.size()).toLower(); auto trackers = toContainer( query.allQueryItemValues(trKey, QUrl::FullyDecoded) | std::views::transform([](auto tracker) { return std::set{tracker}; }) ); return {.infoHashV1 = std::move(infoHashV1), .trackers = std::move(trackers)}; } QDebug operator<<(QDebug debug, const TorrentMagnetLink& magnetLink) { const QDebugStateSaver saver(debug); debug.noquote() << fmt::format(impl::singleArgumentFormatString, magnetLink).c_str(); return debug; } } fmt::format_context::iterator fmt::formatter::format( const tremotesf::TorrentMagnetLink& magnetLink, format_context& ctx ) const { return fmt::format_to( ctx.out(), "MagnetLink(infoHashV1={}, trackers={})", magnetLink.infoHashV1, magnetLink.trackers ); } tremotesf-2.8.2/src/magnetlinkparser.h000066400000000000000000000017071500171105600200420ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_MAGNETLINKPARSER_H #define TREMOTESF_MAGNETLINKPARSER_H #include #include #include "log/formatters.h" #include "literals.h" class QUrl; namespace tremotesf { inline constexpr auto magnetScheme = "magnet"_l1; struct TorrentMagnetLink { QString infoHashV1; std::vector> trackers; bool operator==(const TorrentMagnetLink&) const = default; }; /** * @throws std::runtime_error */ TorrentMagnetLink parseMagnetLink(const QUrl& url); QDebug operator<<(QDebug debug, const TorrentMagnetLink& magnetLink); } template<> struct fmt::formatter : tremotesf::SimpleFormatter { format_context::iterator format(const tremotesf::TorrentMagnetLink& magnetLink, format_context& ctx) const; }; #endif // TREMOTESF_MAGNETLINKPARSER_H tremotesf-2.8.2/src/magnetlinkparser_test.cpp000066400000000000000000000041621500171105600214320ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include "literals.h" #include "magnetlinkparser.h" namespace tremotesf { class MagnetLinkParserTest final : public QObject { Q_OBJECT private slots: void parseWikipediaLink() { const QUrl url("magnet:?xt=urn:btih:779D06C72A13A72DA82611F44F07543AC691DC54&dn=enwiki-20240701-pages-" "articles-multistream.xml.bz2&tr=wss%3a%2f%2fwstracker.online"); const TorrentMagnetLink expected{ .infoHashV1 = "779d06c72a13a72da82611f44f07543ac691dc54"_l1, .trackers = {{"wss://wstracker.online"_l1}} }; QCOMPARE(expected, parseMagnetLink(url)); } void parseAltWikipediaLink() { const QUrl url("magnet:?xt=urn:btih:GVED7WSKNQJUIBE2KYU3SRWFRDEN4JVK&dn=simplewiki-20230820-pages-articles-" "multistream.xml.bz2&xl=283045562&tr=http%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&" "tr=http%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr." "org%3A1337&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A80%2Fannounce&tr=http%3A%2F%" "2Ffosstorrents.com%3A6969%2Fannounce&tr=udp%3A%2F%2Ffosstorrents.com%3A6969%2Fannounce"); const TorrentMagnetLink expected{ .infoHashV1 = "gved7wsknqjuibe2kyu3srwfrden4jvk"_l1, .trackers = {{"http://tracker.opentrackr.org:1337/announce"_l1}, {"http://tracker.opentrackr.org:1337/announce"_l1}, {"udp://tracker.opentrackr.org:1337"_l1}, {"udp://tracker.openbittorrent.com:80/announce"_l1}, {"http://fosstorrents.com:6969/announce"_l1}, {"udp://fosstorrents.com:6969/announce"_l1}} }; QCOMPARE(expected, parseMagnetLink(url)); } }; } QTEST_GUILESS_MAIN(tremotesf::MagnetLinkParserTest) #include "magnetlinkparser_test.moc" tremotesf-2.8.2/src/org.freedesktop.FileManager1.xml000066400000000000000000000007351500171105600224000ustar00rootroot00000000000000 tremotesf-2.8.2/src/org.freedesktop.Notifications.xml000066400000000000000000000052221500171105600227520ustar00rootroot00000000000000 tremotesf-2.8.2/src/org.freedesktop.portal.Notification.xml000066400000000000000000000127511500171105600240740ustar00rootroot00000000000000 tremotesf-2.8.2/src/pragmamacros.h000066400000000000000000000012311500171105600171400ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_PRAGMAMACROS_H #define TREMOTESF_PRAGMAMACROS_H #if defined(__GNUC__) || defined(__clang__) # define SUPPRESS_DEPRECATED_WARNINGS_BEGIN \ _Pragma("GCC diagnostic push") _Pragma("GCC diagnostic ignored \"-Wdeprecated-declarations\"") # define SUPPRESS_DEPRECATED_WARNINGS_END _Pragma("GCC diagnostic pop") #else # define SUPPRESS_DEPRECATED_WARNINGS_BEGIN _Pragma("warning(push)") _Pragma("warning(disable : 4996)") # define SUPPRESS_DEPRECATED_WARNINGS_END _Pragma("warning(pop)") #endif #endif // TREMOTESF_PRAGMAMACROS_H tremotesf-2.8.2/src/resources.qrc000066400000000000000000000003771500171105600170460ustar00rootroot00000000000000 authors.html license.html translators.html tremotesf-2.8.2/src/rpc/000077500000000000000000000000001500171105600151025ustar00rootroot00000000000000tremotesf-2.8.2/src/rpc/addressutils.cpp000066400000000000000000000031141500171105600203130ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "addressutils.h" #include #include #include "log/log.h" SPECIALIZE_FORMATTER_FOR_QDEBUG(QHostAddress) namespace tremotesf { bool isLocalIpAddress(const QHostAddress& ipAddress) { info().log("isLocalIpAddress() called for: ipAddress = {}", ipAddress); if (ipAddress.isLoopback()) { info().log("isLocalIpAddress: address is loopback, return true"); return true; } const auto addresses = QNetworkInterface::allAddresses(); info().log("isLocalIpAddress: this machine's IP addresses:"); for (const auto& address : addresses) { info().log("isLocalIpAddress: - {}", address); } if (QNetworkInterface::allAddresses().contains(ipAddress)) { info().log("isLocalIpAddress: address is this machine's IP address, return true"); return true; } info().log("isLocalIpAddress: address is not this machine's IP address, return false"); return false; } [[nodiscard]] std::optional isLocalIpAddress(const QString& address) { info().log("isLocalIpAddress() called for: address = {}", address); const QHostAddress ipAddress(address); if (ipAddress.isNull()) { info().log("isLocalIpAddress: address is not an IP address"); return std::nullopt; } info().log("isLocalIpAddress: address is an IP address"); return isLocalIpAddress(ipAddress); } } tremotesf-2.8.2/src/rpc/addressutils.h000066400000000000000000000007001500171105600177560ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_RPC_ADDRESSUTILS_H #define TREMOTESF_RPC_ADDRESSUTILS_H #include class QHostAddress; class QString; namespace tremotesf { [[nodiscard]] bool isLocalIpAddress(const QHostAddress& ipAddress); [[nodiscard]] std::optional isLocalIpAddress(const QString& address); } #endif // TREMOTESF_RPC_ADDRESSUTILS_H tremotesf-2.8.2/src/rpc/jsonutils.h000066400000000000000000000100731500171105600173060ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_RPC_JSONUTILS_H #define TREMOTESF_RPC_JSONUTILS_H #include #include #include #include #include #include #include #include #include #include "log/log.h" SPECIALIZE_FORMATTER_FOR_QDEBUG(QJsonValue) namespace tremotesf::impl { template concept EnumConstant = std::is_enum_v; template concept JsonConstant = std::same_as || std::same_as; template struct EnumMapping { constexpr explicit EnumMapping(EnumConstantT enumValue, JsonConstantT jsonValue) : enumValue(enumValue), jsonValue(jsonValue) {} EnumConstantT enumValue; JsonConstantT jsonValue; }; template struct EnumMapper { constexpr explicit EnumMapper(std::array, EnumCount>&& mappings) : mappings(std::move(mappings)) {} EnumConstantT fromJsonValue(const QJsonValue& value, QLatin1String key) const { const auto jsonValue = [&] { if constexpr (std::same_as) { if (!value.isDouble()) { warning().log("JSON field with key {} and value {} is not a number", key, value); return std::optional{}; } return std::optional(value.toInt()); } else if constexpr (std::same_as) { if (!value.isString()) { warning().log("JSON field with key {} and value {} is not a string", key, value); return std::optional{}; } return std::optional(value.toString()); } }(); if (!jsonValue.has_value()) { return {}; } const auto found = std::ranges::find(mappings, jsonValue, &EnumMapping::jsonValue); if (found == mappings.end()) { warning().log("JSON field with key {} has unknown value {}", key, value); return {}; } return found->enumValue; } JsonConstantT toJsonConstant(EnumConstantT value) const { const auto found = std::ranges::find(mappings, value, &EnumMapping::enumValue); if (found == mappings.end()) { throw std::logic_error(fmt::format("Unknown enum value {}", value)); } return found->jsonValue; } private: std::array, EnumCount> mappings{}; }; template requires std::convertible_to, QJsonValue> inline QJsonArray toJsonArray(FromRange&& from) { QJsonArray array{}; std::ranges::copy(from, std::back_insert_iterator(array)); return array; } inline qint64 toInt64(const QJsonValue& value) { #if QT_VERSION_MAJOR > 5 return value.toInteger(); #else return static_cast(value.toDouble()); #endif } inline void updateDateTime(QDateTime& dateTime, const QJsonValue& value, bool& changed) { const auto newDateTime = toInt64(value); if (newDateTime > 0) { if (!dateTime.isValid() || newDateTime != dateTime.toSecsSinceEpoch()) { dateTime.setSecsSinceEpoch(newDateTime); changed = true; } } else { if (!dateTime.isNull()) { dateTime.setDate({}); dateTime.setTime({}); changed = true; } } } } #endif // TREMOTESF_RPC_JSONUTILS_H tremotesf-2.8.2/src/rpc/mounteddirectoriesutils.cpp000066400000000000000000000036601500171105600226040ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "mounteddirectoriesutils.h" #include "rpc.h" #include "serversettings.h" #include "servers.h" #include namespace tremotesf { bool isServerLocalOrTorrentIsMounted(const Rpc* rpc, const Torrent* torrent) { if (rpc->isLocal()) { return true; } if (!Servers::instance()->currentServerHasMountedDirectories()) { return false; } return !localTorrentDownloadDirectoryPath(rpc, torrent).isEmpty(); } QString localTorrentDownloadDirectoryPath(const Rpc* rpc, const Torrent* torrent) { const auto serverSettings = rpc->serverSettings(); const bool incompleteDirectoryEnabled = serverSettings->data().incompleteDirectoryEnabled; QString directory{}; if (incompleteDirectoryEnabled && torrent->data().leftUntilDone > 0) { directory = serverSettings->data().incompleteDirectory; } else { directory = torrent->data().downloadDirectory; } if (!rpc->isLocal() && !directory.isEmpty() && Servers::instance()->currentServerHasMountedDirectories()) { directory = Servers::instance()->fromRemoteToLocalDirectory(directory, serverSettings); } return directory; } QString localTorrentRootFilePath(const Rpc* rpc, const Torrent* torrent) { const QString downloadDirectoryPath = localTorrentDownloadDirectoryPath(rpc, torrent); if (downloadDirectoryPath.isEmpty()) { return {}; } const auto& torrentName = torrent->data().name; if (torrent->data().singleFile && torrent->data().leftUntilDone > 0 && rpc->serverSettings()->data().renameIncompleteFiles) { return downloadDirectoryPath % '/' % torrentName % ".part"_l1; } return downloadDirectoryPath % '/' % torrentName; } } tremotesf-2.8.2/src/rpc/mounteddirectoriesutils.h000066400000000000000000000010761500171105600222500ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_RPC_MOUNTEDDIRECTORIESUTILS_H #define TREMOTESF_RPC_MOUNTEDDIRECTORIESUTILS_H #include namespace tremotesf { class Rpc; class Torrent; bool isServerLocalOrTorrentIsMounted(const Rpc* rpc, const Torrent* torrent); QString localTorrentDownloadDirectoryPath(const Rpc* rpc, const Torrent* torrent); QString localTorrentRootFilePath(const Rpc* rpc, const Torrent* torrent); } #endif // TREMOTESF_RPC_MOUNTEDDIRECTORIESUTILS_H tremotesf-2.8.2/src/rpc/pathutils.cpp000066400000000000000000000123251500171105600176260ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include "pathutils.h" #include "literals.h" namespace tremotesf { // We can't use QDir::to/fromNativeSeparators because it checks for current OS, // and we need it to work regardless of OS we are running on namespace { constexpr auto windowsSeparatorChar = '\\'; constexpr auto unixSeparatorChar = '/'; constexpr auto unixSeparatorString = "/"_l1; enum class PathType { Unix, WindowsAbsoluteDOSFilePath, WindowsUNCOrDOSDevicePath }; QRegularExpressionMatch regexMatch(const QRegularExpression& regex, QStringView subjectView) { return regex #if QT_VERSION_MAJOR >= 6 .matchView(subjectView); #else .match(subjectView); #endif } bool isWindowsUNCOrDOSDevicePath(QStringView path) { static const QRegularExpression regex(R"(^(?:\\|//).*$)"_l1); return regexMatch(regex, path).hasMatch(); } PathType determinePathType(QStringView path, PathOs pathOs) { switch (pathOs) { case PathOs::Unix: return PathType::Unix; case PathOs::Windows: if (isAbsoluteWindowsDOSFilePath(path)) { return PathType::WindowsAbsoluteDOSFilePath; } if (isWindowsUNCOrDOSDevicePath(path)) { return PathType::WindowsUNCOrDOSDevicePath; } return PathType::WindowsAbsoluteDOSFilePath; } throw std::logic_error("Unknown PathOs value"); } void convertFromNativeWindowsSeparators(QString& path) { path.replace(windowsSeparatorChar, unixSeparatorChar); } void convertToNativeWindowsSeparators(QString& path) { path.replace(unixSeparatorChar, windowsSeparatorChar); } void capitalizeWindowsDriveLetter(QString& path) { if (path.size() >= 2 && path[1] == ':') { const auto drive = path[0]; if (drive.isLower()) { path[0] = drive.toUpper(); } } } void collapseRepeatingSeparators(QString& path, PathType pathType) { const auto& regex = [pathType]() -> const QRegularExpression& { if (pathType == PathType::WindowsUNCOrDOSDevicePath) { // Don't collapse leading '//' static const QRegularExpression regex(R"((?!^)//+)"_l1); return regex; } static const QRegularExpression regex(R"(//+)"_l1); return regex; }(); path.replace(regex, unixSeparatorString); } void dropOrAddTrailingSeparator(QString& path, PathType pathType) { const auto minimumLength = [pathType] { switch (pathType) { case PathType::Unix: return 1; // e.g. '/' case PathType::WindowsAbsoluteDOSFilePath: return 3; // e.g. 'C:/' case PathType::WindowsUNCOrDOSDevicePath: return 2; // e.g. '//' } throw std::logic_error("Unknown PathOs value"); }(); if (path.size() <= minimumLength) { if (pathType == PathType::WindowsAbsoluteDOSFilePath && path.size() == 2) { path.append(unixSeparatorChar); } return; } if (path.back() == unixSeparatorChar) { path.chop(1); } } } bool isAbsoluteWindowsDOSFilePath(QStringView path) { static const QRegularExpression regex(R"(^[A-Za-z]:[\\/]?.*$)"_l1); return regexMatch(regex, path).hasMatch(); } QString normalizePath(const QString& path, PathOs pathOs) { if (path.isEmpty()) { return path; } QString normalized = path.trimmed(); if (normalized.isEmpty()) { return normalized; } const auto pathType = determinePathType(normalized, pathOs); if (pathType != PathType::Unix) { convertFromNativeWindowsSeparators(normalized); if (pathType == PathType::WindowsAbsoluteDOSFilePath) { capitalizeWindowsDriveLetter(normalized); } } collapseRepeatingSeparators(normalized, pathType); dropOrAddTrailingSeparator(normalized, pathType); return normalized; } QString toNativeSeparators(const QString& path, PathOs pathOs) { if (path.isEmpty()) { return path; } QString native = path; if (determinePathType(native, pathOs) != PathType::Unix) { convertToNativeWindowsSeparators(native); } return native; } QString lastPathSegment(const QString& path) { const auto index = path.lastIndexOf(unixSeparatorChar); if (index == -1 || index == (path.size() - 1)) { return path; } #if QT_VERSION_MAJOR >= 6 return path.sliced(index + 1); #else return path.mid(index + 1); #endif } } tremotesf-2.8.2/src/rpc/pathutils.h000066400000000000000000000017621500171105600172760ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_RPC_PATHUTILS_H #define TREMOTESF_RPC_PATHUTILS_H #include #include "target_os.h" namespace tremotesf { bool isAbsoluteWindowsDOSFilePath(QStringView path); /** * We need to pass PathOs explicitly because we can't determing whether given path is Unix or Windows path from its string alone: * There is no way to distinguish whether '//foo' is Unix path with duplicate directory separator, or Windows UNC path * (we need to handle Windows paths with both '\' and '/' separators) */ enum class PathOs { Unix, Windows }; constexpr inline PathOs localPathOs = targetOs == TargetOs::Windows ? PathOs::Windows : PathOs::Unix; QString normalizePath(const QString& path, PathOs pathOs); QString toNativeSeparators(const QString& path, PathOs pathOs); QString lastPathSegment(const QString& path); } #endif // TREMOTESF_RPC_PATHUTILS_H tremotesf-2.8.2/src/rpc/pathutils_test.cpp000066400000000000000000000074411500171105600206700ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include "pathutils.h" using namespace tremotesf; class PathUtilsTest final : public QObject { Q_OBJECT private: struct NormalizeTestCase { QString inputPath{}; QString expectedNormalizedPath{}; PathOs pathOs; }; struct NativeSeparatorsTestCase { QString inputPath{}; QString expectedNativeSeparatorsPath{}; PathOs pathOs; }; private slots: void checkNormalize() { const auto testCases = std::array{ NormalizeTestCase{"", "", PathOs::Unix}, NormalizeTestCase{"", "", PathOs::Windows}, NormalizeTestCase{"/", "/", PathOs::Unix}, NormalizeTestCase{"/", "/", PathOs::Windows}, // Whatever that is we leave it as it is NormalizeTestCase{"//", "/", PathOs::Unix}, NormalizeTestCase{"//", "//", PathOs::Windows}, // UNC path NormalizeTestCase{"///", "/", PathOs::Unix}, NormalizeTestCase{"///", "//", PathOs::Windows}, // UNC path? whatever NormalizeTestCase{" / ", "/", PathOs::Unix}, NormalizeTestCase{" / ", "/", PathOs::Windows}, // Whatever that is we leave it as it is NormalizeTestCase{"///home//foo", "/home/foo", PathOs::Unix}, NormalizeTestCase{"C:/home//foo", "C:/home/foo", PathOs::Windows}, NormalizeTestCase{"C:/home//foo/", "C:/home/foo", PathOs::Windows}, NormalizeTestCase{R"(C:\home\foo)", "C:/home/foo", PathOs::Windows}, NormalizeTestCase{R"(C:\home\foo\\)", "C:/home/foo", PathOs::Windows}, NormalizeTestCase{R"(z:\home\foo)", "Z:/home/foo", PathOs::Windows}, NormalizeTestCase{R"(D:\)", "D:/", PathOs::Windows}, NormalizeTestCase{R"( D:\ )", "D:/", PathOs::Windows}, NormalizeTestCase{R"(D:\\)", "D:/", PathOs::Windows}, NormalizeTestCase{"D:/", "D:/", PathOs::Windows}, NormalizeTestCase{"D://", "D:/", PathOs::Windows}, NormalizeTestCase{R"(\\LOCALHOST\c$\home\foo)", R"(//LOCALHOST/c$/home/foo)", PathOs::Windows}, // Backslashes in Unix paths are untouched NormalizeTestCase{R"(///home//fo\o)", R"(/home/fo\o)", PathOs::Unix}, // Internal whitespace is untouched NormalizeTestCase{"///home//fo o", "/home/fo o", PathOs::Unix}, NormalizeTestCase{R"(C:\home\fo o)", "C:/home/fo o", PathOs::Windows}, // Weird cases from the top of my head NormalizeTestCase{"d:", "D:/", PathOs::Windows}, NormalizeTestCase{"d:foo", "D:foo", PathOs::Windows}, NormalizeTestCase{R"(c::\wtf)", R"(C::/wtf)", PathOs::Windows} }; for (const auto& [inputPath, expectedNormalizedPath, pathOs] : testCases) { QCOMPARE(normalizePath(inputPath, pathOs), expectedNormalizedPath); } } void checkToNativeSeparators() { const auto testCases = std::array{ NativeSeparatorsTestCase{"/", "/", PathOs::Unix}, NativeSeparatorsTestCase{"/home/foo", "/home/foo", PathOs::Unix}, NativeSeparatorsTestCase{"C:/", R"(C:\)", PathOs::Windows}, NativeSeparatorsTestCase{"C:/home/foo", R"(C:\home\foo)", PathOs::Windows}, NativeSeparatorsTestCase{R"(//LOCALHOST/c$/home/foo)", R"(\\LOCALHOST\c$\home\foo)", PathOs::Windows}, NativeSeparatorsTestCase{R"(C::/wtf)", R"(C::\wtf)", PathOs::Windows} }; for (const auto& [inputPath, expectedNativeSeparatorsPath, pathOs] : testCases) { QCOMPARE(toNativeSeparators(inputPath, pathOs), expectedNativeSeparatorsPath); } } }; QTEST_GUILESS_MAIN(PathUtilsTest) #include "pathutils_test.moc" tremotesf-2.8.2/src/rpc/peer.cpp000066400000000000000000000015751500171105600165510ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "peer.h" #include #include "jsonutils.h" #include "literals.h" #include "stdutils.h" namespace tremotesf { using namespace impl; Peer::Peer(QString&& address, const QJsonObject& peerJson) : address(std::move(address)), client(peerJson.value("clientName"_l1).toString()) { update(peerJson); } bool Peer::update(const QJsonObject& peerJson) { bool changed = false; setChanged(downloadSpeed, toInt64(peerJson.value("rateToClient"_l1)), changed); setChanged(uploadSpeed, toInt64(peerJson.value("rateToPeer"_l1)), changed); setChanged(progress, peerJson.value("progress"_l1).toDouble(), changed); setChanged(flags, peerJson.value("flagStr"_l1).toString(), changed); return changed; } } tremotesf-2.8.2/src/rpc/peer.h000066400000000000000000000013061500171105600162060ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_RPC_PEER_H #define TREMOTESF_RPC_PEER_H #include #include "literals.h" class QJsonObject; namespace tremotesf { struct Peer { static constexpr auto addressKey = "address"_l1; explicit Peer(QString&& address, const QJsonObject& peerJson); bool update(const QJsonObject& peerJson); bool operator==(const Peer& other) const = default; QString address{}; QString client{}; qint64 downloadSpeed{}; qint64 uploadSpeed{}; double progress{}; QString flags{}; }; } #endif // TREMOTESF_RPC_PEER_H tremotesf-2.8.2/src/rpc/requestrouter.cpp000066400000000000000000000444551500171105600205530ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later // Needed to access deprecated QSsl::SslProtocol enum values #undef QT_DISABLE_DEPRECATED_BEFORE #undef QT_DISABLE_DEPRECATED_UP_TO #include "requestrouter.h" #include #include #include #include #include #include #include #include #include #include "coroutines/network.h" #include "coroutines/threadpool.h" #include "log/log.h" #include "literals.h" #include "pragmamacros.h" #include "rpc.h" DISABLE_RANGE_FORMATTING(QJsonObject) SPECIALIZE_FORMATTER_FOR_QDEBUG(QJsonObject) SPECIALIZE_FORMATTER_FOR_QDEBUG(QNetworkProxy) SPECIALIZE_FORMATTER_FOR_QDEBUG(QSslError) namespace fmt { template<> struct formatter : tremotesf::SimpleFormatter { fmt::format_context::iterator format(const QSslCertificate& certificate, fmt::format_context& ctx) const { // QSslCertificate::toText is implemented only for OpenSSL backend #if QT_VERSION_MAJOR >= 6 using tremotesf::operator""_l1; static const bool isOpenSSL = (QSslSocket::activeBackend() == "openssl"_l1); if (!isOpenSSL) { return tremotesf::impl::QDebugFormatter{}.format(certificate, ctx); } #endif return fmt::formatter{}.format(certificate.toText(), ctx); } }; template<> struct formatter : tremotesf::SimpleFormatter { fmt::format_context::iterator format(QSsl::SslProtocol protocol, fmt::format_context& ctx) const { const auto str = [&]() -> std::optional { SUPPRESS_DEPRECATED_WARNINGS_BEGIN switch (protocol) { case QSsl::TlsV1_0: return "TlsV1_0"; case QSsl::TlsV1_1: return "TlsV1_1"; case QSsl::TlsV1_2: return "TlsV1_2"; case QSsl::AnyProtocol: return "AnyProtocol"; case QSsl::SecureProtocols: return "SecureProtocols"; case QSsl::TlsV1_0OrLater: return "TlsV1_0OrLater"; case QSsl::TlsV1_1OrLater: return "TlsV1_1OrLater"; case QSsl::TlsV1_2OrLater: return "TlsV1_2OrLater"; case QSsl::DtlsV1_0: return "DtlsV1_0"; case QSsl::DtlsV1_0OrLater: return "DtlsV1_0OrLater"; case QSsl::DtlsV1_2: return "DtlsV1_2"; case QSsl::DtlsV1_2OrLater: return "DtlsV1_2OrLater"; case QSsl::TlsV1_3: return "TlsV1_3"; case QSsl::TlsV1_3OrLater: return "TlsV1_3OrLater"; case QSsl::UnknownProtocol: return "UnknownProtocol"; #if QT_VERSION_MAJOR < 6 case QSsl::SslV3: return "SslV3"; case QSsl::SslV2: return "SslV2"; case QSsl::TlsV1SslV3: return "TlsV1SslV3"; #endif } SUPPRESS_DEPRECATED_WARNINGS_END return std::nullopt; }(); if (str) { return fmt::format_to(ctx.out(), tremotesf::impl::singleArgumentFormatString, *str); } return fmt::format_to( ctx.out(), tremotesf::impl::singleArgumentFormatString, static_cast>(protocol) ); } }; } namespace tremotesf::impl { struct NetworkReplyDeleter { inline void operator()(QNetworkReply* reply) { reply->deleteLater(); } }; namespace { const auto sessionIdHeader = QByteArrayLiteral("X-Transmission-Session-Id"); const auto authorizationHeader = QByteArrayLiteral("Authorization"); QJsonObject getReplyArguments(const QJsonObject& parseResult) { return parseResult.value("arguments"_l1).toObject(); } bool isResultSuccessful(const QJsonObject& parseResult) { return (parseResult.value("result"_l1).toString() == "success"_l1); } QString httpStatus(QNetworkReply* reply) { const auto statusCodeAttr = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); if (!statusCodeAttr.isValid()) { return {}; } auto status = QString::number(statusCodeAttr.toInt()); const auto reasonAttr = reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute); if (!reasonAttr.isValid()) { return status; } const auto reason = QString::fromUtf8(reasonAttr.toByteArray()); if (reason.isEmpty()) { return status; } status += ' '; status += reason; return status; } QString makeDetailedErrorMessage(QNetworkReply* reply, const QList& sslErrors) { auto detailedErrorMessage = QString::fromStdString(fmt::format("{}: {}", reply->error(), reply->errorString())); if (reply->url() == reply->request().url()) { detailedErrorMessage += QString::fromStdString(fmt::format("\nURL: {}", reply->url().toString())); } else { detailedErrorMessage += QString::fromStdString(fmt::format( "\nOriginal URL: {}\nRedirected URL: {}", reply->request().url().toString(), reply->url().toString() )); } if (const auto status = httpStatus(reply); !status.isEmpty()) { detailedErrorMessage += QString::fromStdString(fmt::format( "\nHTTP status: {}\nConnection was encrypted: {}", status, reply->attribute(QNetworkRequest::ConnectionEncryptedAttribute).toBool() )); if (!reply->rawHeaderPairs().isEmpty()) { detailedErrorMessage += "\nReply headers:"_l1; for (const QNetworkReply::RawHeaderPair& pair : reply->rawHeaderPairs()) { detailedErrorMessage += QString::fromStdString(fmt::format("\n {}: {}", pair.first, pair.second)); } } } else { detailedErrorMessage += "\nDid not establish HTTP connection"_l1; } if (!sslErrors.isEmpty()) { detailedErrorMessage += QString::fromStdString(fmt::format("\n\n{} TLS errors:", sslErrors.size())); int i = 1; for (const QSslError& sslError : sslErrors) { detailedErrorMessage += QString::fromStdString(fmt::format( "\n\n {}. {}: {} on certificate:\n - {}", i, sslError.error(), sslError.errorString(), sslError.certificate() )); ++i; } } return detailedErrorMessage; } QNetworkRequest takeRequest(NetworkReplyUniquePtr reply) { return reply->request(); } } struct RpcRequestMetadata { QLatin1String method{}; }; struct NetworkRequestMetadata { QByteArray postData{}; int retryAttempts{}; RpcRequestMetadata rpcMetadata{}; }; RequestRouter::RequestRouter(QThreadPool* threadPool, QObject* parent) : QObject(parent), mNetwork(new QNetworkAccessManager(this)), mThreadPool(threadPool ? threadPool : QThreadPool::globalInstance()) {} RequestRouter::RequestRouter(QObject* parent) : RequestRouter(nullptr, parent) {} void RequestRouter::setConfiguration(RequestsConfiguration configuration) { debug().log("Setting requests configuration"); mConfiguration = std::move(configuration); mNetwork->setProxy(mConfiguration->proxy); mNetwork->clearAccessCache(); const bool https = mConfiguration->serverUrl.scheme() == "https"_l1; mSslConfiguration = QSslConfiguration::defaultConfiguration(); if (https) { if (!mConfiguration->clientCertificate.isNull()) { mSslConfiguration.setLocalCertificate(mConfiguration->clientCertificate); } if (!mConfiguration->clientPrivateKey.isNull()) { mSslConfiguration.setPrivateKey(mConfiguration->clientPrivateKey); } mExpectedSslErrors.clear(); mExpectedSslErrors.reserve(mConfiguration->serverCertificateChain.size() * 4); for (const auto& certificate : mConfiguration->serverCertificateChain) { mExpectedSslErrors.push_back(QSslError(QSslError::HostNameMismatch, certificate)); mExpectedSslErrors.push_back(QSslError(QSslError::SelfSignedCertificate, certificate)); mExpectedSslErrors.push_back(QSslError(QSslError::SelfSignedCertificateInChain, certificate)); mExpectedSslErrors.push_back(QSslError(QSslError::CertificateUntrusted, certificate)); } } if (!mConfiguration->serverUrl.isEmpty()) { debug().log("Connection configuration:"); debug().log(" - Server url: {}", mConfiguration->serverUrl.toString()); if (mConfiguration->proxy.type() != QNetworkProxy::NoProxy) { debug().log(" - Proxy: {}", mConfiguration->proxy); } debug().log(" - Timeout: {}", mConfiguration->timeout); debug().log(" - HTTP Basic access authentication: {}", mConfiguration->authentication); if (mConfiguration->authentication) { auto base64Credentials = QString("%1:%2") .arg(mConfiguration->username, mConfiguration->password) .normalized(QString::NormalizationForm_C) .toUtf8() .toBase64(); mAuthorizationHeaderValue = QByteArray("Basic ").append(base64Credentials); } if (https) { #if QT_VERSION_MAJOR >= 6 debug().log(" - Available TLS backends: {}", QSslSocket::availableBackends()); debug().log(" - Active TLS backend: {}", QSslSocket::activeBackend()); debug().log(" - Supported TLS protocols: {}", QSslSocket::supportedProtocols()); #endif debug().log(" - TLS library version: {}", QSslSocket::sslLibraryVersionString()); debug().log( " - Manually validating server's certificate chain: {}", !mConfiguration->serverCertificateChain.isEmpty() ); debug().log( " - Client certificate authentication: {}", !mConfiguration->clientCertificate.isNull() && !mConfiguration->clientPrivateKey.isNull() ); } } } void RequestRouter::resetConfiguration() { debug().log("Resetting requests configuration"); mConfiguration.reset(); mNetwork->clearAccessCache(); } Coroutine RequestRouter::postRequest(QLatin1String method, QJsonObject arguments) { co_return co_await postRequest(method, makeRequestData(method, arguments)); } Coroutine RequestRouter::postRequest(QLatin1String method, QByteArray data) { if (!mConfiguration.has_value()) { throw std::runtime_error("Requests configuration is not set"); } QNetworkRequest request(mConfiguration->serverUrl); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"_l1); // NOLINTNEXTLINE(bugprone-unchecked-optional-access) if (mConfiguration->authentication) { request.setRawHeader(authorizationHeader, mAuthorizationHeaderValue); } request.setSslConfiguration(mSslConfiguration); request.setTransferTimeout( static_cast(std::chrono::duration_cast(mConfiguration->timeout).count()) ); NetworkRequestMetadata metadata{}; metadata.postData = data; metadata.rpcMetadata = {method}; co_return co_await performRequest(request, metadata); } void RequestRouter::abortNetworkRequestsAndClearSessionId() { auto children = mNetwork->children(); for (auto* child : children) { if (auto* reply = qobject_cast(child); reply && reply->isRunning()) { reply->abort(); } } mNetwork->clearConnectionCache(); mSessionId.clear(); } QByteArray RequestRouter::makeRequestData(QLatin1String method, QJsonObject arguments) { return QJsonDocument(QJsonObject{ {QStringLiteral("method"), QJsonValue(QString(method))}, {QStringLiteral("arguments"), QJsonValue(std::move(arguments))}, }) .toJson(QJsonDocument::Compact); } Coroutine RequestRouter::performRequest(QNetworkRequest request, NetworkRequestMetadata metadata) { if (!mSessionId.isEmpty()) { request.setRawHeader(sessionIdHeader, mSessionId); } NetworkReplyUniquePtr reply(mNetwork->post(request, metadata.postData)); auto expectedSslErrors = mExpectedSslErrors; reply->ignoreSslErrors(expectedSslErrors); auto sslErrors = std::make_shared>(); QObject::connect(reply.get(), &QNetworkReply::sslErrors, reply.get(), [=](const QList& errors) { for (const QSslError& error : errors) { if (!expectedSslErrors.contains(error)) { sslErrors->push_back(error); } } }); co_await *reply; if (reply->error() == QNetworkReply::NoError) { co_return co_await onRequestSuccess(std::move(reply), metadata.rpcMetadata); } else { co_return co_await onRequestError(std::move(reply), *sslErrors, metadata); } } Coroutine RequestRouter::onRequestSuccess(NetworkReplyUniquePtr reply, RpcRequestMetadata metadata) { debug() .log("HTTP request for method '{}' succeeded, HTTP status: {}", metadata.method, httpStatus(reply.get())); const auto json = co_await runOnThreadPool( [](NetworkReplyUniquePtr reply) -> std::optional { const auto replyData = reply->readAll(); QJsonParseError error{}; QJsonObject json = QJsonDocument::fromJson(replyData, &error).object(); if (error.error != QJsonParseError::NoError) { warning().log( "Failed to parse JSON reply from server:\n{}\nError '{}' at offset {}", replyData, error.errorString(), error.offset ); return {}; } return json; }, std::move(reply) ); if (!json.has_value()) { emit requestFailed(RpcError::ParseError, {}, {}); cancelCoroutine(); } const bool success = isResultSuccessful(*json); if (!success) { warning().log("method '{}' failed, response: {}", metadata.method, *json); } co_return Response{.arguments = getReplyArguments(*json), .success = success}; } Coroutine RequestRouter::onRequestError( NetworkReplyUniquePtr reply, QList sslErrors, NetworkRequestMetadata metadata ) { if (reply->error() == QNetworkReply::ContentConflictError && reply->hasRawHeader(sessionIdHeader)) { QByteArray newSessionId = reply->rawHeader(sessionIdHeader); // Check against session id of request instead of current session id, // to handle case when current session id have already been overwritten by another failed request if (newSessionId != reply->request().rawHeader(sessionIdHeader)) { if (!mSessionId.isEmpty()) { info().log("Session id changed"); } debug().log("Session id is {}, retrying '{}' request", newSessionId, metadata.rpcMetadata.method); mSessionId = std::move(newSessionId); // Retry without incrementing retryAttempts co_return co_await performRequest(takeRequest(std::move(reply)), metadata); } } const QString detailedErrorMessage = makeDetailedErrorMessage(reply.get(), sslErrors); warning().log("HTTP request for method '{}' failed:\n{}", metadata.rpcMetadata.method, detailedErrorMessage); RpcError error{}; bool shouldRetry{}; switch (reply->error()) { case QNetworkReply::AuthenticationRequiredError: warning().log("Authentication error"); error = RpcError::AuthenticationError; shouldRetry = false; break; case QNetworkReply::OperationCanceledError: case QNetworkReply::TimeoutError: warning().log("Timed out"); error = RpcError::TimedOut; shouldRetry = true; break; default: error = RpcError::ConnectionError; shouldRetry = true; break; } // NOLINTNEXTLINE(bugprone-unchecked-optional-access) if (shouldRetry && metadata.retryAttempts < mConfiguration.value().retryAttempts) { metadata.retryAttempts++; warning() .log("Retrying '{}' request, retry attempts = {}", metadata.rpcMetadata.method, metadata.retryAttempts); co_return co_await performRequest(takeRequest(std::move(reply)), metadata); } emit requestFailed(error, reply->errorString(), detailedErrorMessage); cancelCoroutine(); } } tremotesf-2.8.2/src/rpc/requestrouter.h000066400000000000000000000063771500171105600202210ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_RPC_REQUESTROUTER_H #define TREMOTESF_RPC_REQUESTROUTER_H #include #include #include #include #include #include #include #include #include #include #include "coroutines/coroutinefwd.h" class QNetworkAccessManager; class QNetworkRequest; class QNetworkReply; class QSslError; class QThreadPool; namespace tremotesf { enum class RpcError; } namespace tremotesf::impl { struct RpcRequestMetadata; struct NetworkRequestMetadata; struct NetworkReplyDeleter; using NetworkReplyUniquePtr = std::unique_ptr; class RequestRouter final : public QObject { Q_OBJECT public: explicit RequestRouter(QThreadPool* threadPool, QObject* parent = nullptr); explicit RequestRouter(QObject* parent = nullptr); struct RequestsConfiguration { QUrl serverUrl{}; QNetworkProxy proxy{QNetworkProxy::applicationProxy()}; QList serverCertificateChain{}; QSslCertificate clientCertificate{}; QSslKey clientPrivateKey{}; std::chrono::milliseconds timeout{}; int retryAttempts{2}; bool authentication{}; QString username{}; QString password{}; }; const std::optional& configuration() const { return mConfiguration; } void setConfiguration(RequestsConfiguration configuration); void resetConfiguration(); struct Response { QJsonObject arguments{}; bool success{}; }; Coroutine postRequest(QLatin1String method, QJsonObject arguments); Coroutine postRequest(QLatin1String method, QByteArray data); const QByteArray& sessionId() const { return mSessionId; }; void abortNetworkRequestsAndClearSessionId(); static QByteArray makeRequestData(QLatin1String method, QJsonObject arguments); private: Coroutine performRequest(QNetworkRequest request, NetworkRequestMetadata metadata); Coroutine onRequestSuccess(NetworkReplyUniquePtr reply, RpcRequestMetadata metadata); Coroutine onRequestError(NetworkReplyUniquePtr reply, QList sslErrors, NetworkRequestMetadata metadata); QNetworkAccessManager* mNetwork{}; QThreadPool* mThreadPool{}; QByteArray mSessionId{}; QByteArray mAuthorizationHeaderValue{}; std::optional mConfiguration{}; QSslConfiguration mSslConfiguration{}; QList mExpectedSslErrors{}; signals: /** * Emitted if request has failed with network or HTTP error * @brief requestFailed * @param error Error type * @param errorMessage Short error message * @param detailedErrorMessage Detailed error message */ void requestFailed(RpcError error, const QString& errorMessage, const QString& detailedErrorMessage); }; } #endif // TREMOTESF_RPC_REQUESTROUTER_H tremotesf-2.8.2/src/rpc/requestrouter_test.cpp000066400000000000000000000567571500171105600216220ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include #include #include #include #include #include #include #include #include #include "coroutines/coroutines.h" #include "fileutils.h" #include "literals.h" #include "log/log.h" #include "requestrouter.h" #include "rpc.h" using namespace std::chrono; using namespace std::chrono_literals; using namespace std::string_literals; using namespace tremotesf; using namespace tremotesf::impl; #define QFAIL_THROW(message) \ do { \ QTest::qFail(static_cast(message), __FILE__, __LINE__); \ throw std::exception(); \ } while (false) // NOLINTBEGIN(bugprone-unchecked-optional-access) namespace { constexpr auto testApiPath = "/"_l1; constexpr auto testTimeout = 5s; constexpr auto testRetryAttempts = 0; const auto contentType = "application/json"s; const auto successResponse = "{\"result\":\"success\"}"s; const auto invalidJsonResponse = "{\"result\":\"success}"s; const auto sessionIdHeader = "X-Transmission-Session-Id"s; template Server = httplib::Server> class TestHttpServer { public: template explicit TestHttpServer(Args&&... args) : mServer(std::forward(args)...) { info().log("Server is valid = {}", mServer.is_valid()); mServer.set_keep_alive_max_count(1); mServer.set_keep_alive_timeout(1); port = mServer.bind_to_any_port(host.toStdString()); info().log("Bound to port {}", port); mServer.Post(testApiPath.data(), [=, this](const httplib::Request& req, httplib::Response& res) { httplib::Server::Handler handler{}; { const std::unique_lock lock(mHandlerMutex); handler = mHandler; } if (handler) { handler(req, res); } else { res.status = 500; } }); mListenFuture = std::async([=, this] { info().log("Starting listening on address {} and port {}", host, port); const bool ok = mServer.listen_after_bind(); info().log("Stopped listening, ok = {}", ok); }); } ~TestHttpServer() { // Wait until we've started listing or already finished because of error while (true) { if (mServer.is_running()) break; if (mListenFuture.wait_for(10ms) == std::future_status::ready) break; } if (mServer.is_running()) { info().log("Stopping server"); mServer.stop(); } else { warning().log("Server has already stopped"); } mListenFuture.wait(); } Q_DISABLE_COPY_MOVE(TestHttpServer) const QString host{QHostAddress(QHostAddress::LocalHost).toString()}; int port{}; void handle(httplib::Server::Handler&& handler) { const std::unique_lock lock(mHandlerMutex); mHandler = std::move(handler); } void clearHandler() { const std::unique_lock lock(mHandlerMutex); mHandler = {}; } private: Server mServer{}; std::future mListenFuture{}; httplib::Server::Handler mHandler{}; std::mutex mHandlerMutex{}; }; void success(httplib::Response& res) { res.set_content(successResponse, contentType); } void checkAuthentication( const httplib::Request& req, httplib::Response& res, const std::string& username, const std::string& password ) { const auto header = httplib::make_basic_authentication_header(username, password); if (req.get_header_value(header.first) == header.second) { success(res); } else { res.status = 401; res.set_header("WWW-Authenticate", R"(Basic realm="Do it")"); } } class RequestRouterTest final : public QObject { Q_OBJECT public: RequestRouterTest() { mThreadPool.setMaxThreadCount(1); } private slots: void init() { const int port = mServer.port; RequestRouter::RequestsConfiguration config{}; config.serverUrl.setScheme("http"_l1); config.serverUrl.setHost(mServer.host); config.serverUrl.setPort(port); config.serverUrl.setPath(testApiPath); config.timeout = testTimeout; config.retryAttempts = testRetryAttempts; mRouter.setConfiguration(std::move(config)); } void cleanup() { mServer.clearHandler(); mRouter.abortNetworkRequestsAndClearSessionId(); } void checkUrlIsCorrect() { mServer.handle([&](const httplib::Request&, httplib::Response& res) { success(res); }); const auto response = waitForResponse("foo"_l1, QByteArray{}); QCOMPARE(response.has_value(), true); QCOMPARE(response->arguments, QJsonObject{}); QCOMPARE(response->success, true); } void checkThatJsonIsFormedCorrectly() { const auto method = "foo"_l1; const QJsonObject arguments{{"bar", "foobar"}}; mServer.handle([&](const httplib::Request& req, httplib::Response& res) { const auto json = QJsonDocument::fromJson(req.body.c_str()); const auto expectedJson = QJsonDocument(QJsonObject{{"method"_l1, method}, {"arguments"_l1, arguments}}); if (json == expectedJson) { success(res); } else { res.status = 400; } }); const auto response = waitForResponse(method, arguments); QCOMPARE(response.has_value(), true); QCOMPARE(response->success, true); } void checkTimeoutIsHandled() { QTcpServer tcpServer{}; tcpServer.listen(QHostAddress::LocalHost); { RequestRouter::RequestsConfiguration config = mRouter.configuration().value(); config.serverUrl.setPort(tcpServer.serverPort()); config.timeout = 100ms; mRouter.setConfiguration(std::move(config)); } const auto error = waitForError("foo"_l1, QByteArray{}); QCOMPARE(error.has_value(), true); QCOMPARE(error.value(), RpcError::TimedOut); } void checkTcpConnectionRefusedIsHandled() { { RequestRouter::RequestsConfiguration config = mRouter.configuration().value(); // It is likely that there is nothing listening on this port, so we will get ConnectionRefusedError config.serverUrl.setPort(9); mRouter.setConfiguration(std::move(config)); } const auto error = waitForError("foo"_l1, QByteArray{}); QCOMPARE(error.has_value(), true); if (error.value() == RpcError::TimedOut) { // This is not what we test here but it can happen on some systems warning().log("Connection to port 9 timed out instead of being refused"); } else { QCOMPARE(error.value(), RpcError::ConnectionError); } } void checkTcpConnectionClosedErrorIsHandled() { QTcpServer tcpServer{}; QObject::connect(&tcpServer, &QTcpServer::newConnection, this, [&tcpServer] { tcpServer.nextPendingConnection()->close(); }); tcpServer.listen(QHostAddress::LocalHost); { RequestRouter::RequestsConfiguration config = mRouter.configuration().value(); config.serverUrl.setPort(tcpServer.serverPort()); mRouter.setConfiguration(std::move(config)); } const auto error = waitForError("foo"_l1, QByteArray{}); QCOMPARE(error.has_value(), true); QCOMPARE(error.value(), RpcError::ConnectionError); } void checkThatRequestsAreRetried() { const int retryAttempts = 2; std::atomic_int requestsCount{}; mServer.handle([&](const httplib::Request&, httplib::Response& res) { ++requestsCount; res.status = 500; }); { RequestRouter::RequestsConfiguration config = mRouter.configuration().value(); config.retryAttempts = retryAttempts; mRouter.setConfiguration(std::move(config)); } const auto error = waitForError("foo"_l1, QByteArray{}); QCOMPARE(error.has_value(), true); QCOMPARE(error.value(), RpcError::ConnectionError); QCOMPARE(requestsCount.load(), retryAttempts + 1); } #ifdef CPPHTTPLIB_OPENSSL_SUPPORT void checkSelfSignedCertificateError() { TestHttpServer server( TEST_DATA_PATH "/root-certificate.pem", TEST_DATA_PATH "/root-certificate-key.pem" ); server.handle([&](const httplib::Request&, httplib::Response& res) { success(res); }); { RequestRouter::RequestsConfiguration config = mRouter.configuration().value(); config.serverUrl.setScheme("https"_l1); config.serverUrl.setPort(server.port); mRouter.setConfiguration(std::move(config)); } const auto error = waitForError("foo"_l1, QByteArray{}); QCOMPARE(error.has_value(), true); QCOMPARE(error.value(), RpcError::ConnectionError); } void checkSelfSignedCertificateSuccess() { TestHttpServer server( TEST_DATA_PATH "/root-certificate.pem", TEST_DATA_PATH "/root-certificate-key.pem" ); server.handle([&](const httplib::Request&, httplib::Response& res) { success(res); }); { RequestRouter::RequestsConfiguration config = mRouter.configuration().value(); config.serverUrl.setScheme("https"_l1); config.serverUrl.setPort(server.port); config.serverCertificateChain = QSslCertificate::fromPath(TEST_DATA_PATH "/root-certificate.pem", QSsl::Pem); mRouter.setConfiguration(std::move(config)); } const auto response = waitForResponse("foo"_l1, QByteArray{}); QCOMPARE(response.has_value(), true); QCOMPARE(response->success, true); } void checkSelfSignedCertificateChainSuccess() { TestHttpServer server( TEST_DATA_PATH "/chain.pem", TEST_DATA_PATH "/signed-certificate-key.pem" ); server.handle([&](const httplib::Request&, httplib::Response& res) { success(res); }); { RequestRouter::RequestsConfiguration config = mRouter.configuration().value(); config.serverUrl.setScheme("https"_l1); config.serverUrl.setPort(server.port); config.serverCertificateChain = QSslCertificate::fromPath(TEST_DATA_PATH "/chain.pem", QSsl::Pem); mRouter.setConfiguration(std::move(config)); } const auto response = waitForResponse("foo"_l1, QByteArray{}); QCOMPARE(response.has_value(), true); QCOMPARE(response->success, true); } void checkClientCertificateError() { TestHttpServer server( TEST_DATA_PATH "/root-certificate.pem", TEST_DATA_PATH "/root-certificate-key.pem", TEST_DATA_PATH "/client-certificate-and-key.pem" ); server.handle([&](const httplib::Request&, httplib::Response& res) { success(res); }); { RequestRouter::RequestsConfiguration config = mRouter.configuration().value(); config.serverUrl.setScheme("https"_l1); config.serverUrl.setPort(server.port); config.serverCertificateChain = QSslCertificate::fromPath(TEST_DATA_PATH "/root-certificate.pem", QSsl::Pem); mRouter.setConfiguration(std::move(config)); } const auto error = waitForError("foo"_l1, QByteArray{}); QCOMPARE(error.has_value(), true); if (error.value() == RpcError::TimedOut) { // Can happen with TLS 1.3 warning().log("Connecting to server requiring client certificate timed out"); } else { QCOMPARE(error.value(), RpcError::ConnectionError); } } void checkClientCertificateSuccess() { TestHttpServer server( TEST_DATA_PATH "/root-certificate.pem", TEST_DATA_PATH "/root-certificate-key.pem", TEST_DATA_PATH "/client-certificate-and-key.pem" ); server.handle([&](const httplib::Request&, httplib::Response& res) { success(res); }); { RequestRouter::RequestsConfiguration config = mRouter.configuration().value(); config.serverUrl.setScheme("https"_l1); config.serverUrl.setPort(server.port); config.serverCertificateChain = QSslCertificate::fromPath(TEST_DATA_PATH "/root-certificate.pem", QSsl::Pem); config.clientCertificate = QSslCertificate::fromPath(TEST_DATA_PATH "/client-certificate-and-key.pem", QSsl::Pem).first(); { QFile file(TEST_DATA_PATH "/client-certificate-and-key.pem"); openFile(file, QIODevice::ReadOnly); config.clientPrivateKey = QSslKey(&file, QSsl::Rsa); } mRouter.setConfiguration(std::move(config)); } const auto response = waitForResponse("foo"_l1, QByteArray{}); QCOMPARE(response.has_value(), true); QCOMPARE(response->success, true); } #endif void checkInvalidJsonIsHandled() { mServer.handle([&](const httplib::Request&, httplib::Response& res) { res.set_content(invalidJsonResponse, contentType); }); const auto error = waitForError("foo"_l1, QByteArray{}); QCOMPARE(error.has_value(), true); QCOMPARE(error.value(), RpcError::ParseError); } void checkConflictErrorWithoutSessionIdIsHandled() { mServer.handle([&](const httplib::Request&, httplib::Response& res) { res.status = 409; }); const auto error = waitForError("foo"_l1, QByteArray{}); QCOMPARE(error.has_value(), true); QCOMPARE(error.value(), RpcError::ConnectionError); } void checkPersistentConflictErrorWithSessionIdIsHandled() { const std::string sessionIdValue = "id"; mServer.handle([&](const httplib::Request&, httplib::Response& res) { res.status = 409; res.set_header(sessionIdHeader, sessionIdValue); }); const auto error = waitForError("foo"_l1, QByteArray{}); QCOMPARE(error.has_value(), true); QCOMPARE(error.value(), RpcError::ConnectionError); } void checkConflictErrorWithSessionIdIsHandled() { const std::string sessionIdValue = "id"; mServer.handle([&](const httplib::Request& req, httplib::Response& res) { if (req.get_header_value(sessionIdHeader) == sessionIdValue) { success(res); } else { res.status = 409; } res.set_header(sessionIdHeader, sessionIdValue); }); const auto response = waitForResponse("foo"_l1, QByteArray{}); QCOMPARE(response.has_value(), true); QCOMPARE(response->arguments, QJsonObject{}); QCOMPARE(response->success, true); } void checkConflictErrorWithChangingSessionIdIsHandled() { std::string sessionIdValue = "session id"; mServer.handle([&](const httplib::Request& req, httplib::Response& res) { if (req.get_header_value(sessionIdHeader) == sessionIdValue) { success(res); } else { res.status = 409; } res.set_header(sessionIdHeader, sessionIdValue); }); auto response = waitForResponse("foo"_l1, QByteArray{}); QCOMPARE(response.has_value(), true); QCOMPARE(response->arguments, QJsonObject{}); QCOMPARE(response->success, true); sessionIdValue = "session id 2"; response = waitForResponse("foo"_l1, QByteArray{}); QCOMPARE(response.has_value(), true); QCOMPARE(response->arguments, QJsonObject{}); QCOMPARE(response->success, true); } void checkThatAuthenticationWorks() { const QString user = "foo"_l1; const QString password = "bar"_l1; const auto header = httplib::make_basic_authentication_header(user.toStdString(), password.toStdString()); mServer.handle([&](const httplib::Request& req, httplib::Response& res) { checkAuthentication(req, res, user.toStdString(), password.toStdString()); }); { RequestRouter::RequestsConfiguration config = mRouter.configuration().value(); config.authentication = true; config.username = user; config.password = password; mRouter.setConfiguration(std::move(config)); } const auto response = waitForResponse("foo"_l1, QByteArray{}); QCOMPARE(response.has_value(), true); QCOMPARE(response->arguments, QJsonObject{}); QCOMPARE(response->success, true); } void checkAuthenticationErrorIsHandled() { mServer.handle([&](const httplib::Request& req, httplib::Response& res) { checkAuthentication(req, res, "foo", "bar"); }); const auto error = waitForError("foo"_l1, QByteArray{}); QCOMPARE(error.has_value(), true); QCOMPARE(error.value(), RpcError::AuthenticationError); } void checkRequestAbort() { QTcpServer tcpServer{}; tcpServer.listen(QHostAddress::LocalHost); { RequestRouter::RequestsConfiguration config = mRouter.configuration().value(); config.serverUrl.setPort(tcpServer.serverPort()); mRouter.setConfiguration(std::move(config)); } std::variant responseOrError = std::monostate{}; CoroutineScope scope{}; const auto connection = connectToErrorSignal(responseOrError, scope); const auto connectionGuard = QScopeGuard([=] { QObject::disconnect(connection); }); scope.launch(waitForResponseCoroutine(mRouter.postRequest("foo"_l1, QByteArray{}), responseOrError)); QTest::qWait(100); mRouter.abortNetworkRequestsAndClearSessionId(); const bool ok = QTest::qWaitFor( [&] { return !std::holds_alternative(responseOrError); }, static_cast( duration_cast(testTimeout * (mRouter.configuration().value().retryAttempts + 1) + 1s) .count() ) ); if (!ok) { warning().log("Timed out when waiting for response"); } if (std::holds_alternative(responseOrError)) { QCOMPARE(std::get(responseOrError), RpcError::TimedOut); } else { QFAIL("Request must return error"); } } private: Coroutine<> waitForResponseCoroutine( Coroutine requestCoroutine, std::variant& responseOrError ) { responseOrError = co_await requestCoroutine; } QMetaObject::Connection connectToErrorSignal( std::variant& responseOrError, CoroutineScope& scope ) { return QObject::connect( &mRouter, &RequestRouter::requestFailed, this, [&](RpcError error, [[maybe_unused]] const QString& errorMessage, [[maybe_unused]] const QString& detailedErrorMessage) { responseOrError = error; scope.cancelAll(); }, Qt::DirectConnection ); } template std::variant waitForResponseOrError(const Args&... args) { std::variant responseOrError = std::monostate{}; CoroutineScope scope{}; const auto connection = connectToErrorSignal(responseOrError, scope); const auto connectionGuard = QScopeGuard([=] { QObject::disconnect(connection); }); scope.launch(waitForResponseCoroutine(mRouter.postRequest(args...), responseOrError)); const bool ok = QTest::qWaitFor( [&] { return scope.coroutinesCount() == 0; }, static_cast( duration_cast(testTimeout * (mRouter.configuration().value().retryAttempts + 1) + 1s) .count() ) ); if (!ok) { warning().log("Timed out when waiting for response"); } return responseOrError; } template std::optional waitForResponse(const Args&... args) { const auto responseOrError = waitForResponseOrError(args...); if (std::holds_alternative(responseOrError)) { return std::get(responseOrError); } return {}; } template std::optional waitForError(const Args&... args) { const auto responseOrError = waitForResponseOrError(args...); if (std::holds_alternative(responseOrError)) { return std::get(responseOrError); } return {}; } template Coroutine<> detachRequest(const Args&... args) { co_await mRouter.postRequest(args...); } TestHttpServer mServer{}; QThreadPool mThreadPool{}; RequestRouter mRouter{nullptr, &mThreadPool}; }; } // NOLINTEND(bugprone-unchecked-optional-access) QTEST_GUILESS_MAIN(RequestRouterTest) #include "requestrouter_test.moc" tremotesf-2.8.2/src/rpc/rpc.cpp000066400000000000000000001164661500171105600164100ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "rpc.h" #include #include #include #include #include #include #include #include #include "addressutils.h" #include "coroutines/hostinfo.h" #include "coroutines/threadpool.h" #include "coroutines/timer.h" #include "coroutines/waitall.h" #include "fileutils.h" #include "jsonutils.h" #include "itemlistupdater.h" #include "log/log.h" #include "requestrouter.h" #include "serversettings.h" #include "serverstats.h" #include "stdutils.h" #include "torrent.h" SPECIALIZE_FORMATTER_FOR_QDEBUG(QHostAddress) SPECIALIZE_FORMATTER_FOR_QDEBUG(QUrl) namespace tremotesf { using namespace impl; namespace { // Transmission 2.40+ constexpr int minimumRpcVersion = 14; constexpr auto torrentsKey = "torrents"_l1; constexpr auto torrentDuplicateKey = "torrent-duplicate"_l1; } using namespace impl; QString Rpc::Status::toString() const { switch (connectionState) { case ConnectionState::Disconnected: switch (error) { case Error::NoError: //: Server connection status return qApp->translate("tremotesf", "Disconnected"); case Error::TimedOut: //: Server connection status return qApp->translate("tremotesf", "Timed out"); case Error::ConnectionError: //: Server connection status return qApp->translate("tremotesf", "Connection error"); case Error::AuthenticationError: //: Server connection status return qApp->translate("tremotesf", "Authentication error"); case Error::ParseError: //: Server connection status return qApp->translate("tremotesf", "Parse error"); case Error::ServerIsTooNew: //: Server connection status return qApp->translate("tremotesf", "Server is too new"); case Error::ServerIsTooOld: //: Server connection status return qApp->translate("tremotesf", "Server is too old"); } break; case ConnectionState::Connecting: //: Server connection status return qApp->translate("tremotesf", "Connecting..."); case ConnectionState::Connected: //: Server connection status return qApp->translate("tremotesf", "Connected"); } return {}; } Rpc::Rpc(QObject* parent) : QObject(parent), mRequestRouter(new RequestRouter(this)), mServerSettings(new ServerSettings(this, this)), mServerStats(new ServerStats(this)) { QObject::connect(mRequestRouter, &RequestRouter::requestFailed, this, &Rpc::onRequestFailed); } Rpc::~Rpc() = default; ServerSettings* Rpc::serverSettings() const { return mServerSettings; } ServerStats* Rpc::serverStats() const { return mServerStats; } const std::vector>& Rpc::torrents() const { return mTorrents; } Torrent* Rpc::torrentByHash(const QString& hash) const { for (const std::unique_ptr& torrent : mTorrents) { if (torrent->data().hashString == hash) { return torrent.get(); } } return nullptr; } Torrent* Rpc::torrentById(int id) const { const auto found = std::ranges::find(mTorrents, id, [](const auto& torrent) { return torrent->data().id; }); return (found != mTorrents.end()) ? found->get() : nullptr; } bool Rpc::isConnected() const { return (mStatus.connectionState == ConnectionState::Connected); } const Rpc::Status& Rpc::status() const { return mStatus; } Rpc::ConnectionState Rpc::connectionState() const { return mStatus.connectionState; } Rpc::Error Rpc::error() const { return mStatus.error; } const QString& Rpc::errorMessage() const { return mStatus.errorMessage; } const QString& Rpc::detailedErrorMessage() const { return mStatus.detailedErrorMessage; } bool Rpc::isLocal() const { return mServerIsLocal.value_or(false); } int Rpc::torrentsCount() const { return static_cast(mTorrents.size()); } void Rpc::setConnectionConfiguration(const ConnectionConfiguration& configuration) { disconnect(); RequestRouter::RequestsConfiguration requestsConfig{}; if (configuration.https) { requestsConfig.serverUrl.setScheme("https"_l1); } else { requestsConfig.serverUrl.setScheme("http"_l1); } requestsConfig.serverUrl.setHost(configuration.address); if (auto error = requestsConfig.serverUrl.errorString(); !error.isEmpty()) { warning().log("Error setting URL hostname: {}", error); } requestsConfig.serverUrl.setPort(configuration.port); if (auto error = requestsConfig.serverUrl.errorString(); !error.isEmpty()) { warning().log("Error setting URL port: {}", error); } if (auto i = configuration.apiPath.indexOf('?'); i != -1) { requestsConfig.serverUrl.setPath(configuration.apiPath.mid(0, i)); if (auto error = requestsConfig.serverUrl.errorString(); !error.isEmpty()) { warning().log("Error setting URL path: {}", error); } if ((i + 1) < configuration.apiPath.size()) { requestsConfig.serverUrl.setQuery(configuration.apiPath.mid(i + 1)); if (auto error = requestsConfig.serverUrl.errorString(); !error.isEmpty()) { warning().log("Error setting URL query: {}", error); } } } else { requestsConfig.serverUrl.setPath(configuration.apiPath); if (auto error = requestsConfig.serverUrl.errorString(); !error.isEmpty()) { warning().log("Error setting URL path: {}", error); } } if (!requestsConfig.serverUrl.isValid()) { warning().log("URL {} is invalid", requestsConfig.serverUrl); } switch (configuration.proxyType) { case ConnectionConfiguration::ProxyType::Default: break; case ConnectionConfiguration::ProxyType::Http: requestsConfig.proxy = QNetworkProxy( QNetworkProxy::HttpProxy, configuration.proxyHostname, static_cast(configuration.proxyPort), configuration.proxyUser, configuration.proxyPassword ); break; case ConnectionConfiguration::ProxyType::Socks5: requestsConfig.proxy = QNetworkProxy( QNetworkProxy::Socks5Proxy, configuration.proxyHostname, static_cast(configuration.proxyPort), configuration.proxyUser, configuration.proxyPassword ); break; case ConnectionConfiguration::ProxyType::None: requestsConfig.proxy = QNetworkProxy(QNetworkProxy::NoProxy); break; } if (configuration.https && configuration.selfSignedCertificateEnabled) { requestsConfig.serverCertificateChain = QSslCertificate::fromData(configuration.selfSignedCertificate, QSsl::Pem); } if (configuration.clientCertificateEnabled) { requestsConfig.clientCertificate = QSslCertificate(configuration.clientCertificate, QSsl::Pem); requestsConfig.clientPrivateKey = QSslKey(configuration.clientCertificate, QSsl::Rsa); } requestsConfig.authentication = configuration.authentication; requestsConfig.username = configuration.username; requestsConfig.password = configuration.password; requestsConfig.timeout = std::chrono::seconds(configuration.timeout); // server.timeout is in seconds mRequestRouter->setConfiguration(std::move(requestsConfig)); mUpdateInterval = std::chrono::seconds(configuration.updateInterval); mAutoReconnectEnabled = configuration.autoReconnectEnabled; mAutoReconnectInterval = std::chrono::seconds(configuration.autoReconnectInterval); } void Rpc::resetConnectionConfiguration() { disconnect(); mRequestRouter->resetConfiguration(); mAutoReconnectEnabled = false; mAutoReconnectCoroutineScope.cancelAll(); } void Rpc::connect() { if (connectionState() == ConnectionState::Disconnected && mRequestRouter->configuration().has_value()) { setStatus(Status{.connectionState = ConnectionState::Connecting}); mBackgroundRequestsCoroutineScope.launch(connectAndPerformDataUpdates()); } } void Rpc::disconnect() { setStatus(Status{.connectionState = ConnectionState::Disconnected}); } void Rpc::addTorrentFile( QString filePath, QString downloadDirectory, std::vector unwantedFiles, std::vector highPriorityFiles, std::vector lowPriorityFiles, std::map renamedFiles, TorrentData::Priority bandwidthPriority, bool start, DeleteFileMode deleteFileMode, std::vector labels ) { if (isConnected()) { mBackgroundRequestsCoroutineScope.launch(addTorrentFileImpl( std::move(filePath), std::move(downloadDirectory), std::move(unwantedFiles), std::move(highPriorityFiles), std::move(lowPriorityFiles), std::move(renamedFiles), bandwidthPriority, start, deleteFileMode, std::move(labels) )); } } namespace { std::optional makeAddTorrentFileRequestData( const QString& filePath, const QString& downloadDirectory, const std::vector& unwantedFiles, const std::vector& highPriorityFiles, const std::vector& lowPriorityFiles, TorrentData::Priority bandwidthPriority, bool start, const std::vector& labels ) { QString fileData{}; try { QFile file(filePath); openFile(file, QIODevice::ReadOnly); fileData = readFileAsBase64String(file); } catch (const QFileError& e) { warning().logWithException(e, "addTorrentFile: failed to read torrent file"); return std::nullopt; } QJsonObject arguments{ {"metainfo"_l1, fileData}, {"download-dir"_l1, downloadDirectory}, {"bandwidthPriority"_l1, TorrentData::priorityToInt(bandwidthPriority)}, {"paused"_l1, !start} }; if (!unwantedFiles.empty()) { arguments.insert("files-unwanted"_l1, toJsonArray(unwantedFiles)); } if (!highPriorityFiles.empty()) { arguments.insert("priority-high"_l1, toJsonArray(highPriorityFiles)); } if (!lowPriorityFiles.empty()) { arguments.insert("priority-low"_l1, toJsonArray(lowPriorityFiles)); } if (!labels.empty()) { arguments.insert("labels"_l1, toJsonArray(labels)); } return RequestRouter::makeRequestData("torrent-add"_l1, std::move(arguments)); } Coroutine<> deleteTorrentFile(QString filePath, bool moveToTrash) { co_await runOnThreadPool([moveToTrash, filePath = std::move(filePath)] { try { if (moveToTrash) { moveFileToTrashOrDelete(filePath); } else { deleteFile(filePath); } } catch (const QFileError& e) { warning().logWithException(e, "Failed to delete torrent file"); } }); } } Coroutine<> Rpc::addTorrentFileImpl( QString filePath, QString downloadDirectory, std::vector unwantedFiles, std::vector highPriorityFiles, std::vector lowPriorityFiles, std::map renamedFiles, TorrentData::Priority bandwidthPriority, bool start, DeleteFileMode deleteFileMode, std::vector labels ) { std::optional requestData = co_await runOnThreadPool( makeAddTorrentFileRequestData, filePath, downloadDirectory, std::move(unwantedFiles), std::move(highPriorityFiles), std::move(lowPriorityFiles), bandwidthPriority, start, std::move(labels) ); if (!requestData.has_value()) { emit torrentAddError(filePath); co_return; } if (deleteFileMode != DeleteFileMode::No) { mDeletingFilesCoroutineScope.launch( deleteTorrentFile(filePath, deleteFileMode == DeleteFileMode::MoveToTrash) ); } if (!isConnected()) co_return; const auto response = co_await mRequestRouter->postRequest("torrent-add"_l1, std::move(requestData).value()); if (response.arguments.contains(torrentDuplicateKey)) { emit torrentAddDuplicate(); } else if (response.success) { if (!renamedFiles.empty()) { const auto torrentJson = response.arguments.value("torrent-added"_l1).toObject(); const auto id = Torrent::idFromJson(torrentJson); if (id.has_value()) { for (const auto& [filePathToRename, newName] : renamedFiles) { renameTorrentFile(*id, filePathToRename, newName); } } } mBackgroundRequestsCoroutineScope.launch(updateData()); } else { emit torrentAddError(filePath); } } void Rpc::addTorrentLinks( QStringList links, QString downloadDirectory, TorrentData::Priority bandwidthPriority, bool start, std::vector labels ) { if (isConnected()) { mBackgroundRequestsCoroutineScope.launch(addTorrentLinksImpl( std::move(links), std::move(downloadDirectory), bandwidthPriority, start, std::move(labels) )); } } Coroutine<> Rpc::addTorrentLinksImpl( QStringList links, QString downloadDirectory, TorrentData::Priority bandwidthPriority, bool start, std::vector labels ) { const int priorityInt = TorrentData::priorityToInt(bandwidthPriority); co_await waitAll(toContainer( links | std::views::transform([&](QString& link) { return addTorrentLinkImpl(std::move(link), downloadDirectory, priorityInt, start, labels); }) )); mBackgroundRequestsCoroutineScope.launch(updateData()); } Coroutine<> Rpc::addTorrentLinkImpl( QString link, QString downloadDirectory, int bandwidthPriority, bool start, std::vector labels ) { QJsonObject arguments{ {"filename"_l1, link}, {"download-dir"_l1, downloadDirectory}, {"bandwidthPriority"_l1, bandwidthPriority}, {"paused"_l1, !start} }; if (!labels.empty()) { arguments.insert("labels"_l1, toJsonArray(labels)); } const auto response = co_await mRequestRouter->postRequest("torrent-add"_l1, std::move(arguments)); if (response.arguments.contains(torrentDuplicateKey)) { emit torrentAddDuplicate(); } else if (!response.success) { emit torrentAddError(link); } } void Rpc::startTorrents(std::span ids) { mBackgroundRequestsCoroutineScope.launch(postRequest("torrent-start"_l1, {{"ids"_l1, toJsonArray(ids)}})); } void Rpc::startTorrentsNow(std::span ids) { mBackgroundRequestsCoroutineScope.launch(postRequest("torrent-start-now"_l1, {{"ids"_l1, toJsonArray(ids)}})); } void Rpc::pauseTorrents(std::span ids) { mBackgroundRequestsCoroutineScope.launch(postRequest("torrent-stop"_l1, {{"ids"_l1, toJsonArray(ids)}})); } void Rpc::removeTorrents(std::span ids, bool deleteFiles) { mBackgroundRequestsCoroutineScope.launch( postRequest("torrent-remove"_l1, {{"ids"_l1, toJsonArray(ids)}, {"delete-local-data"_l1, deleteFiles}}) ); } void Rpc::checkTorrents(std::span ids) { mBackgroundRequestsCoroutineScope.launch(postRequest("torrent-verify"_l1, {{"ids"_l1, toJsonArray(ids)}})); } void Rpc::moveTorrentsToTop(std::span ids) { mBackgroundRequestsCoroutineScope.launch(postRequest("queue-move-top"_l1, {{"ids"_l1, toJsonArray(ids)}})); } void Rpc::moveTorrentsUp(std::span ids) { mBackgroundRequestsCoroutineScope.launch(postRequest("queue-move-up"_l1, {{"ids"_l1, toJsonArray(ids)}})); } void Rpc::moveTorrentsDown(std::span ids) { mBackgroundRequestsCoroutineScope.launch(postRequest("queue-move-down"_l1, {{"ids"_l1, toJsonArray(ids)}})); } void Rpc::moveTorrentsToBottom(std::span ids) { mBackgroundRequestsCoroutineScope.launch(postRequest("queue-move-bottom"_l1, {{"ids"_l1, toJsonArray(ids)}})); } void Rpc::reannounceTorrents(std::span ids) { mBackgroundRequestsCoroutineScope.launch(postRequest("torrent-reannounce"_l1, {{"ids"_l1, toJsonArray(ids)}})); } void Rpc::setSessionProperty(QString property, QJsonValue value) { setSessionProperties({{property, std::move(value)}}); } void Rpc::setSessionProperties(QJsonObject properties) { mBackgroundRequestsCoroutineScope.launch(postRequest("session-set"_l1, std::move(properties), false)); } void Rpc::setTorrentProperty(int id, QString property, QJsonValue value, bool updateIfSuccessful) { mBackgroundRequestsCoroutineScope.launch(postRequest( "torrent-set"_l1, {{"ids"_l1, QJsonArray{id}}, {property, std::move(value)}}, updateIfSuccessful )); } void Rpc::setTorrentsLocation(std::span ids, QString location, bool moveFiles) { mBackgroundRequestsCoroutineScope.launch(postRequest( "torrent-set-location"_l1, {{"ids"_l1, toJsonArray(ids)}, {"location"_l1, location}, {"move"_l1, moveFiles}} )); } void Rpc::setTorrentsLabels(std::span ids, std::span labels) { mBackgroundRequestsCoroutineScope.launch( postRequest("torrent-set"_l1, {{"ids"_l1, toJsonArray(ids)}, {"labels"_l1, toJsonArray(labels)}}) ); } void Rpc::getTorrentFiles(int torrentId) { if (isConnected()) { mBackgroundRequestsCoroutineScope.launch(getTorrentsFiles({torrentId})); } } Coroutine<> Rpc::getTorrentsFiles(QJsonArray ids) { QJsonObject arguments{ {"fields"_l1, QJsonArray{"id"_l1, "files"_l1, "fileStats"_l1}}, {"ids"_l1, std::move(ids)} }; const auto response = co_await mRequestRouter->postRequest("torrent-get"_l1, std::move(arguments)); if (!response.success) co_return; const QJsonArray torrents = response.arguments.value(torrentsKey).toArray(); for (const auto& torrentJson : torrents) { const auto object = torrentJson.toObject(); const auto torrentId = Torrent::idFromJson(object); if (torrentId.has_value()) { Torrent* torrent = torrentById(*torrentId); if (torrent && torrent->isFilesEnabled()) { torrent->updateFiles(object); } } } } void Rpc::getTorrentPeers(int torrentId) { if (isConnected()) { mBackgroundRequestsCoroutineScope.launch(getTorrentsPeers({torrentId})); } } Coroutine<> Rpc::getTorrentsPeers(QJsonArray ids) { QJsonObject arguments{{"fields"_l1, QJsonArray{"id"_l1, "peers"_l1}}, {"ids"_l1, std::move(ids)}}; const auto response = co_await mRequestRouter->postRequest("torrent-get"_l1, std::move(arguments)); if (!response.success) co_return; const QJsonArray torrents = response.arguments.value(torrentsKey).toArray(); for (const auto& torrentJson : torrents) { const auto object = torrentJson.toObject(); const auto torrentId = Torrent::idFromJson(object); if (torrentId.has_value()) { Torrent* torrent = torrentById(*torrentId); if (torrent && torrent->isPeersEnabled()) { torrent->updatePeers(object); } } } } void Rpc::renameTorrentFile(int torrentId, QString filePath, QString newName) { if (isConnected()) { mBackgroundRequestsCoroutineScope.launch( renameTorrentFileImpl(torrentId, std::move(filePath), std::move(newName)) ); } } Coroutine<> Rpc::renameTorrentFileImpl(int torrentId, QString filePath, QString newName) { QJsonObject arguments = {{"ids"_l1, QJsonArray{torrentId}}, {"path"_l1, filePath}, {"name"_l1, newName}}; const auto response = co_await mRequestRouter->postRequest("torrent-rename-path"_l1, std::move(arguments)); if (response.success) { Torrent* torrent = torrentById(torrentId); if (torrent) { const QString filePathFromReponse = response.arguments.value("path"_l1).toString(); const QString newNameFromReponse = response.arguments.value("name"_l1).toString(); emit torrent->fileRenamed(filePathFromReponse, newNameFromReponse); mBackgroundRequestsCoroutineScope.launch(updateData()); } } } Coroutine> Rpc::getDownloadDirFreeSpace() { if (isConnected()) { if (mServerSettings->data().canShowFreeSpaceForPath()) { co_return co_await getFreeSpaceForPathImpl(mServerSettings->data().downloadDirectory); } co_return co_await getDownloadDirFreeSpaceImpl(); } cancelCoroutine(); } Coroutine> Rpc::getDownloadDirFreeSpaceImpl() { const auto response = co_await mRequestRouter->postRequest( "download-dir-free-space"_l1, QByteArrayLiteral("{" "\"arguments\":{" "\"fields\":[" "\"download-dir-free-space\"" "]" "}," "\"method\":\"session-get\"" "}") ); if (response.success) { co_return toInt64(response.arguments.value("download-dir-free-space"_l1)); } co_return std::nullopt; } Coroutine> Rpc::getFreeSpaceForPath(QString path) { if (isConnected()) { if (mServerSettings->data().canShowFreeSpaceForPath()) { co_return co_await getFreeSpaceForPathImpl(std::move(path)); } if (path == mServerSettings->data().downloadDirectory) { co_return co_await getDownloadDirFreeSpaceImpl(); } } cancelCoroutine(); } Coroutine> Rpc::getFreeSpaceForPathImpl(QString path) { QJsonObject arguments{{"path"_l1, path}}; const auto response = co_await mRequestRouter->postRequest("free-space"_l1, std::move(arguments)); if (response.success) { co_return toInt64(response.arguments.value("size-bytes"_l1)); } co_return std::nullopt; } void Rpc::shutdownServer() { if (isConnected()) { mBackgroundRequestsCoroutineScope.launch(shutdownServerImpl()); } } Coroutine<> Rpc::shutdownServerImpl() { const auto response = co_await mRequestRouter->postRequest("session-close"_l1, QJsonObject{}); if (response.success) { info().log("Successfully sent shutdown request, disconnecting"); disconnect(); } } void Rpc::setStatus(Status&& status) { if (status == mStatus) { return; } const Status oldStatus = mStatus; const bool connectionStateChanged = status.connectionState != oldStatus.connectionState; if (connectionStateChanged && oldStatus.connectionState == ConnectionState::Connected) { emit aboutToDisconnect(); } mStatus = std::move(status); if (connectionStateChanged) { resetStateOnConnectionStateChanged(oldStatus.connectionState); } emit statusChanged(); if (connectionStateChanged) { emitSignalsOnConnectionStateChanged(oldStatus.connectionState); } } void Rpc::resetStateOnConnectionStateChanged(ConnectionState oldConnectionState) { switch (mStatus.connectionState) { case ConnectionState::Disconnected: { info().log("Disconnected"); mBackgroundRequestsCoroutineScope.cancelAll(); mRequestRouter->abortNetworkRequestsAndClearSessionId(); mServerIsLocal = std::nullopt; mGetTorrentsRequestData.clear(); if (!mTorrents.empty() && oldConnectionState == ConnectionState::Connected) { const auto removedTorrentsCount = mTorrents.size(); emit onAboutToRemoveTorrents(0, removedTorrentsCount); mTorrents.clear(); emit onRemovedTorrents(0, removedTorrentsCount); } if (error() != RpcError::NoError && mAutoReconnectEnabled) { mAutoReconnectCoroutineScope.launch(autoReconnect()); } break; } case ConnectionState::Connecting: info().log("Connecting"); mAutoReconnectCoroutineScope.cancelAll(); break; case ConnectionState::Connected: { info().log("Connected"); break; } } } void Rpc::emitSignalsOnConnectionStateChanged(Rpc::ConnectionState oldConnectionState) { switch (mStatus.connectionState) { case ConnectionState::Disconnected: { emit connectionStateChanged(); if (oldConnectionState == ConnectionState::Connected) { emit connectedChanged(); emit torrentsUpdated(); } break; } case ConnectionState::Connecting: emit connectionStateChanged(); break; case ConnectionState::Connected: { emit torrentsUpdated(); emit connectionStateChanged(); emit connectedChanged(); break; } } } Coroutine<> Rpc::postRequest(QLatin1String method, QJsonObject arguments, bool updateIfSuccessful) { if (isConnected()) { const auto response = co_await mRequestRouter->postRequest(method, std::move(arguments)); if (updateIfSuccessful && response.success) { mBackgroundRequestsCoroutineScope.launch(updateData()); } } } Coroutine<> Rpc::getServerSettings() { const auto response = co_await mRequestRouter->postRequest("session-get"_l1, QByteArrayLiteral("{\"method\":\"session-get\"}")); if (response.success) { mServerSettings->update(response.arguments); if (mServerSettings->data().hasTableMode()) { mGetTorrentsRequestData = RequestRouter::makeRequestData( "torrent-get"_l1, QJsonObject{{"fields"_l1, Torrent::updateFields(mServerSettings)}, {"format"_l1, "table"_l1}} ); } else { mGetTorrentsRequestData = RequestRouter::makeRequestData( "torrent-get"_l1, QJsonObject{{"fields"_l1, Torrent::updateFields(mServerSettings)}} ); } } } struct NewTorrent { int id{}; QJsonValue json{}; }; class TorrentsListUpdater final : public ItemListUpdater, std::vector> { public: inline explicit TorrentsListUpdater(Rpc& rpc) : mRpc(rpc) {} const std::vector>* keys{}; std::vector> removedIndexRanges{}; std::vector> changedIndexRanges{}; int addedCount{}; std::vector metadataCompletedIds{}; protected: std::vector::iterator findNewItemForItem(std::vector& newTorrents, const std::unique_ptr& torrent) override { return std::ranges::find(newTorrents, torrent->data().id, &NewTorrent::id); } void onAboutToRemoveItems(size_t first, size_t last) override { emit mRpc.onAboutToRemoveTorrents(first, last); }; void onRemovedItems(size_t first, size_t last) override { removedIndexRanges.emplace_back(static_cast(first), static_cast(last)); emit mRpc.onRemovedTorrents(first, last); } // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) bool updateItem(std::unique_ptr& torrent, NewTorrent&& newTorrent) override { const bool wasFinished = torrent->data().isFinished(); const bool wasPaused = (torrent->data().status == TorrentData::Status::Paused); const auto oldSizeWhenDone = torrent->data().sizeWhenDone; const bool metadataWasComplete = torrent->data().metadataComplete; bool changed{}; if (keys) { changed = torrent->update(*keys, newTorrent.json.toArray()); } else { changed = torrent->update(newTorrent.json.toObject()); } if (changed) { // Don't emit torrentFinished() if torrent's size became smaller // since there is high chance that it happened because user unselected some files // and torrent immediately became finished. We don't want notification in that case if (!wasFinished && torrent->data().isFinished() && !wasPaused && torrent->data().sizeWhenDone >= oldSizeWhenDone) { emit mRpc.torrentFinished(torrent.get()); } if (!metadataWasComplete && torrent->data().metadataComplete) { metadataCompletedIds.push_back(newTorrent.id); } } return changed; } void onChangedItems(size_t first, size_t last) override { changedIndexRanges.emplace_back(static_cast(first), static_cast(last)); emit mRpc.onChangedTorrents(first, last); } // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) std::unique_ptr createItemFromNewItem(NewTorrent&& newTorrent) override { std::unique_ptr torrent{}; if (keys) { torrent = std::make_unique(newTorrent.id, *keys, newTorrent.json.toArray(), &mRpc); } else { torrent = std::make_unique(newTorrent.id, newTorrent.json.toObject(), &mRpc); } if (mRpc.isConnected()) { emit mRpc.torrentAdded(torrent.get()); } if (torrent->data().metadataComplete) { metadataCompletedIds.push_back(newTorrent.id); } return torrent; } void onAboutToAddItems(size_t count) override { emit mRpc.onAboutToAddTorrents(count); } void onAddedItems(size_t count) override { addedCount = static_cast(count); emit mRpc.onAddedTorrents(count); }; private: Rpc& mRpc; }; Coroutine<> Rpc::getTorrents() { const auto response = co_await mRequestRouter->postRequest("torrent-get"_l1, mGetTorrentsRequestData); if (!response.success) co_return; TorrentsListUpdater updater(*this); { const QJsonArray torrentsJsons = response.arguments.value("torrents"_l1).toArray(); std::vector newTorrents{}; if (mServerSettings->data().hasTableMode()) { if (!torrentsJsons.empty()) { const auto keys = Torrent::mapUpdateKeys(torrentsJsons.first().toArray()); const auto idKeyIndex = Torrent::idKeyIndex(keys); if (idKeyIndex.has_value()) { newTorrents.reserve(static_cast(torrentsJsons.size() - 1)); for (const auto& json : torrentsJsons | std::views::drop(1)) { const auto array = json.toArray(); if (static_cast(array.size()) == keys.size()) { newTorrents.push_back(NewTorrent{array[*idKeyIndex].toInt(), json}); } } updater.keys = &keys; updater.update(mTorrents, std::move(newTorrents)); } } } else { newTorrents.reserve(static_cast(torrentsJsons.size())); for (const auto& torrentJson : torrentsJsons) { const auto id = Torrent::idFromJson(torrentJson.toObject()); if (id.has_value()) { newTorrents.push_back(NewTorrent{*id, torrentJson}); } } updater.update(mTorrents, std::move(newTorrents)); } } std::vector> additionalRequests{}; if (!mServerSettings->data().hasFileCountProperty() && !updater.metadataCompletedIds.empty()) { additionalRequests.push_back(checkTorrentsSingleFile(std::move(updater.metadataCompletedIds))); } QJsonArray getFilesIds{}; QJsonArray getPeersIds{}; for (const auto& torrent : mTorrents) { if (torrent->isFilesEnabled()) { getFilesIds.push_back(torrent->data().id); } if (torrent->isPeersEnabled()) { getPeersIds.push_back(torrent->data().id); } } if (!getFilesIds.empty()) { additionalRequests.push_back(getTorrentsFiles(getFilesIds)); } if (!getPeersIds.empty()) { additionalRequests.push_back(getTorrentsPeers(getPeersIds)); } if (!additionalRequests.empty()) { co_await waitAll(std::move(additionalRequests)); } const bool wasConnecting = connectionState() == ConnectionState::Connecting; if (!wasConnecting) { emit torrentsUpdated(); } } Coroutine<> Rpc::checkTorrentsSingleFile(std::vector torrentIds) { QJsonObject arguments{{"fields"_l1, QJsonArray{"id"_l1, "priorities"_l1}}, {"ids"_l1, toJsonArray(torrentIds)}}; const auto response = co_await mRequestRouter->postRequest("torrent-get"_l1, std::move(arguments)); if (!response.success) co_return; const auto torrentJsons = response.arguments.value(torrentsKey).toArray(); for (const auto& torrentJson : torrentJsons) { const auto object = torrentJson.toObject(); const auto torrentId = Torrent::idFromJson(object); if (torrentId.has_value()) { Torrent* torrent = torrentById(*torrentId); if (torrent) { torrent->checkSingleFile(object); } } } } Coroutine<> Rpc::getServerStats() { const auto response = co_await mRequestRouter->postRequest( "session-stats"_l1, QByteArrayLiteral("{\"method\":\"session-stats\"}") ); if (response.success) { mServerStats->update(response.arguments); } } Coroutine<> Rpc::connectAndPerformDataUpdates() { co_await getServerSettings(); if (mServerSettings->data().minimumRpcVersion > minimumRpcVersion) { setStatus(Status{.connectionState = ConnectionState::Disconnected, .error = Error::ServerIsTooNew}); co_return; } if (mServerSettings->data().rpcVersion < minimumRpcVersion) { setStatus(Status{.connectionState = ConnectionState::Disconnected, .error = Error::ServerIsTooOld}); co_return; } co_await waitAll(checkIfServerIsLocal(), getTorrents(), getServerStats()); setStatus(Status{.connectionState = ConnectionState::Connected}); while (true) { co_await waitFor(mUpdateInterval); co_await updateData(); } } Coroutine<> Rpc::updateData() { debug().log("Updating data"); co_await waitAll(getServerSettings(), getTorrents(), getServerStats()); debug().log("Finished updating data"); } Coroutine<> Rpc::checkIfServerIsLocal() { info().log("checkIfServerIsLocal() called"); if (mServerSettings->data().hasSessionIdFile() && !mRequestRouter->sessionId().isEmpty() && isTransmissionSessionIdFileExists(mRequestRouter->sessionId())) { mServerIsLocal = true; info().log("checkIfServerIsLocal: server is running locally: true"); co_return; } const auto configuration = mRequestRouter->configuration(); if (!configuration.has_value()) { co_return; } const auto host = configuration->serverUrl.host(); if (auto localIp = isLocalIpAddress(host); localIp.has_value()) { mServerIsLocal = localIp; info().log("checkIfServerIsLocal: server is running locally: {}", *mServerIsLocal); co_return; } info().log("checkIfServerIsLocal: resolving IP address for host name {}", host); const QHostInfo hostInfo = co_await lookupHost(host); info().log("checkIfServerIsLocal: resolved IP address for host name {}", host); const auto addresses = hostInfo.addresses(); if (!addresses.isEmpty()) { info().log("checkIfServerIsLocal: IP addresses:"); for (const auto& address : addresses) { info().log("checkIfServerIsLocal: - {}", address); } info().log("checkIfServerIsLocal: checking first address"); mServerIsLocal = isLocalIpAddress(addresses.first()); } else { mServerIsLocal = false; } info().log("checkIfServerIsLocal: server is running locally: {}", *mServerIsLocal); } void Rpc::onRequestFailed(RpcError error, const QString& errorMessage, const QString& detailedErrorMessage) { setStatus({RpcConnectionState::Disconnected, error, errorMessage, detailedErrorMessage}); } Coroutine<> Rpc::autoReconnect() { info().log("Auto reconnecting in {} seconds", mAutoReconnectInterval.count()); co_await waitFor(mAutoReconnectInterval); info().log("Auto reconnection"); connect(); } } tremotesf-2.8.2/src/rpc/rpc.h000066400000000000000000000207631500171105600160470ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_RPC_RPC_H #define TREMOTESF_RPC_RPC_H #include #include #include #include #include #include #include "coroutines/scope.h" #include "torrent.h" #ifdef Q_MOC_RUN # include "serversettings.h" # include "serverstats.h" #else namespace tremotesf { class ServerSettings; class ServerStats; } #endif namespace tremotesf { Q_NAMESPACE namespace impl { class RequestRouter; } struct ConnectionConfiguration { Q_GADGET public: enum class ProxyType { Default, Http, Socks5, None }; Q_ENUM(ProxyType) QString address{}; int port{}; QString apiPath{}; ProxyType proxyType{ProxyType::Default}; QString proxyHostname{}; int proxyPort{}; QString proxyUser{}; QString proxyPassword{}; bool https{}; bool selfSignedCertificateEnabled{}; QByteArray selfSignedCertificate{}; bool clientCertificateEnabled{}; QByteArray clientCertificate{}; bool authentication{}; QString username{}; QString password{}; int updateInterval{}; int timeout{}; bool autoReconnectEnabled{}; int autoReconnectInterval{}; bool operator==(const ConnectionConfiguration& rhs) const = default; }; enum class RpcConnectionState { Disconnected, Connecting, Connected }; Q_ENUM_NS(RpcConnectionState) enum class RpcError { NoError, TimedOut, ConnectionError, AuthenticationError, ParseError, ServerIsTooNew, ServerIsTooOld }; Q_ENUM_NS(RpcError) class Rpc : public QObject { Q_OBJECT public: using ConnectionState = RpcConnectionState; using Error = RpcError; explicit Rpc(QObject* parent = nullptr); ~Rpc() override; Q_DISABLE_COPY_MOVE(Rpc) ServerSettings* serverSettings() const; ServerStats* serverStats() const; const std::vector>& torrents() const; Torrent* torrentByHash(const QString& hash) const; Torrent* torrentById(int id) const; struct Status { ConnectionState connectionState{ConnectionState::Disconnected}; Error error{Error::NoError}; QString errorMessage{}; QString detailedErrorMessage{}; bool operator==(const Status& other) const = default; QString toString() const; }; bool isConnected() const; const Status& status() const; ConnectionState connectionState() const; Error error() const; const QString& errorMessage() const; const QString& detailedErrorMessage() const; bool isLocal() const; int torrentsCount() const; void setConnectionConfiguration(const ConnectionConfiguration& configuration); void resetConnectionConfiguration(); void connect(); void disconnect(); enum class DeleteFileMode { No, Delete, MoveToTrash }; void addTorrentFile( QString filePath, QString downloadDirectory, std::vector unwantedFiles, std::vector highPriorityFiles, std::vector lowPriorityFiles, std::map renamedFiles, TorrentData::Priority bandwidthPriority, bool start, DeleteFileMode deleteFileMode, std::vector labels ); void addTorrentLinks( QStringList links, QString downloadDirectory, TorrentData::Priority bandwidthPriority, bool start, std::vector labels ); void startTorrents(std::span ids); void startTorrentsNow(std::span ids); void pauseTorrents(std::span ids); void removeTorrents(std::span ids, bool deleteFiles); void checkTorrents(std::span ids); void moveTorrentsToTop(std::span ids); void moveTorrentsUp(std::span ids); void moveTorrentsDown(std::span ids); void moveTorrentsToBottom(std::span ids); void reannounceTorrents(std::span ids); void setSessionProperty(QString property, QJsonValue value); void setSessionProperties(QJsonObject properties); void setTorrentProperty(int id, QString property, QJsonValue value, bool updateIfSuccessful = false); void setTorrentsLocation(std::span ids, QString location, bool moveFiles); void setTorrentsLabels(std::span ids, std::span labels); void getTorrentFiles(int torrentId); void getTorrentPeers(int torrentId); void renameTorrentFile(int torrentId, QString filePath, QString newName); Coroutine> getDownloadDirFreeSpace(); Coroutine> getFreeSpaceForPath(QString path); void shutdownServer(); private: void setStatus(Status&& status); void resetStateOnConnectionStateChanged(ConnectionState oldConnectionState); void emitSignalsOnConnectionStateChanged(ConnectionState oldConnectionState); Coroutine<> postRequest(QLatin1String method, QJsonObject arguments, bool updateIfSuccessful = true); Coroutine<> addTorrentFileImpl( QString filePath, QString downloadDirectory, std::vector unwantedFiles, std::vector highPriorityFiles, std::vector lowPriorityFiles, std::map renamedFiles, TorrentData::Priority bandwidthPriority, bool start, DeleteFileMode deleteFileMode, std::vector labels ); Coroutine<> addTorrentLinksImpl( QStringList links, QString downloadDirectory, TorrentData::Priority bandwidthPriority, bool start, std::vector labels ); Coroutine<> addTorrentLinkImpl( QString link, QString downloadDirectory, int bandwidthPriority, bool start, std::vector labels ); Coroutine<> getTorrentsFiles(QJsonArray ids); Coroutine<> getTorrentsPeers(QJsonArray ids); Coroutine<> renameTorrentFileImpl(int torrentId, QString filePath, QString newName); Coroutine> getDownloadDirFreeSpaceImpl(); Coroutine> getFreeSpaceForPathImpl(QString path); Coroutine<> shutdownServerImpl(); Coroutine<> getServerSettings(); Coroutine<> getTorrents(); Coroutine<> checkTorrentsSingleFile(std::vector torrentIds); Coroutine<> getServerStats(); Coroutine<> connectAndPerformDataUpdates(); Coroutine<> updateData(); Coroutine<> checkIfServerIsLocal(); void onRequestFailed(RpcError error, const QString& errorMessage, const QString& detailedErrorMessage); Coroutine<> autoReconnect(); impl::RequestRouter* mRequestRouter{}; CoroutineScope mBackgroundRequestsCoroutineScope{}; CoroutineScope mAutoReconnectCoroutineScope{}; CoroutineScope mDeletingFilesCoroutineScope{}; bool mAutoReconnectEnabled{}; std::optional mServerIsLocal{}; QByteArray mGetTorrentsRequestData{}; std::chrono::seconds mUpdateInterval{}; std::chrono::seconds mAutoReconnectInterval{}; ServerSettings* mServerSettings{}; std::vector> mTorrents{}; ServerStats* mServerStats{}; Status mStatus{}; signals: void aboutToDisconnect(); void statusChanged(); void connectedChanged(); void connectionStateChanged(); void onAboutToRemoveTorrents(size_t first, size_t last); void onRemovedTorrents(size_t first, size_t last); void onChangedTorrents(size_t first, size_t last); void onAboutToAddTorrents(size_t count); void onAddedTorrents(size_t count); void torrentsUpdated(); void torrentAdded(tremotesf::Torrent* torrent); void torrentFinished(tremotesf::Torrent* torrent); void torrentAddDuplicate(); void torrentAddError(const QString& filePathOrUrl); }; } #endif // TREMOTESF_RPC_RPC_H tremotesf-2.8.2/src/rpc/servers.cpp000066400000000000000000000550521500171105600173060ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "servers.h" #include #include #include #include "rpc/pathutils.h" #include "rpc/serversettings.h" #include "stdutils.h" #include "target_os.h" namespace tremotesf { namespace { constexpr QSettings::Format settingsFormat = [] { if constexpr (targetOs == TargetOs::Windows) { return QSettings::IniFormat; } else { return QSettings::NativeFormat; } }(); constexpr auto fileName = "servers"_l1; constexpr auto currentServerKey = "current"_l1; constexpr auto addressKey = "address"_l1; constexpr auto portKey = "port"_l1; constexpr auto apiPathKey = "apiPath"_l1; constexpr auto proxyTypeKey = "proxyType"_l1; constexpr auto proxyHostnameKey = "proxyHostname"_l1; constexpr auto proxyPortKey = "proxyPort"_l1; constexpr auto proxyUserKey = "proxyUser"_l1; constexpr auto proxyPasswordKey = "proxyPassword"_l1; constexpr auto httpsKey = "https"_l1; constexpr auto selfSignedCertificateEnabledKey = "selfSignedCertificateEnabled"_l1; constexpr auto selfSignedCertificateKey = "selfSignedCertificate"_l1; constexpr auto clientCertificateEnabledKey = "clientCertificateEnabled"_l1; constexpr auto clientCertificateKey = "clientCertificate"_l1; constexpr auto authenticationKey = "authentication"_l1; constexpr auto usernameKey = "username"_l1; constexpr auto passwordKey = "password"_l1; constexpr auto updateIntervalKey = "updateInterval"_l1; constexpr auto timeoutKey = "timeout"_l1; constexpr auto autoReconnectEnabledKey = "autoReconnectEnabled"_l1; constexpr auto autoReconnectIntervalKey = "autoReconnectInterval"_l1; constexpr auto mountedDirectoriesKey = "mountedDirectories"_l1; constexpr auto lastDownloadDirectoriesKey = "addTorrentDialogDirectories"_l1; constexpr auto lastDownloadDirectoryKey = "lastDownloadDirectory"_l1; constexpr auto lastTorrentsKey = "lastTorrents"_l1; constexpr auto lastTorrentsHashStringKey = "hashString"_l1; constexpr auto lastTorrentsFinishedKey = "finished"_l1; constexpr auto localCertificateKey = "localCertificate"_l1; constexpr auto proxyTypeDefault = "Default"_l1; constexpr auto proxyTypeHttp = "HTTP"_l1; constexpr auto proxyTypeSocks5 = "SOCKS5"_l1; constexpr auto proxyTypeNone = "None"_l1; ConnectionConfiguration::ProxyType proxyTypeFromSettings(const QString& value) { if (value.isEmpty() || value == proxyTypeDefault) { return ConnectionConfiguration::ProxyType::Default; } if (value == proxyTypeHttp) { return ConnectionConfiguration::ProxyType::Http; } if (value == proxyTypeSocks5) { return ConnectionConfiguration::ProxyType::Socks5; } if (value == proxyTypeNone) { return ConnectionConfiguration::ProxyType::None; } return ConnectionConfiguration::ProxyType::Default; } QLatin1String proxyTypeToSettings(ConnectionConfiguration::ProxyType type) { switch (type) { case ConnectionConfiguration::ProxyType::Default: return proxyTypeDefault; case ConnectionConfiguration::ProxyType::Http: return proxyTypeHttp; case ConnectionConfiguration::ProxyType::Socks5: return proxyTypeSocks5; case ConnectionConfiguration::ProxyType::None: return proxyTypeNone; } return proxyTypeDefault; } bool isPathUnderThisDirectory(QStringView path, QStringView directory) { // Path is not under parentDirectory if (!path.startsWith(directory)) { return false; } // Path is the same as parentDirectory - that's ok if (path.size() == directory.size()) { return true; } // Path ends with segment that's not actually the last segment of parentDirectory, just prefixed by it if (!directory.endsWith('/') && path[directory.size()] != '/') { return false; } return true; } } QVariant MountedDirectory::toVariant(std::span dirs) { QVariantMap map{}; for (const auto& dir : dirs) { map.insert(dir.localPath, dir.remotePath); } return map; } std::vector MountedDirectory::fromVariant(const QVariant& var) { const QVariantMap map = var.toMap(); std::vector dirs{}; dirs.reserve(static_cast(map.size())); for (auto i = map.begin(), end = map.end(); i != end; ++i) { dirs.push_back({.localPath = normalizePath(i.key(), localPathOs), .remotePath = i.value().toString()}); } return dirs; } QVariant LastTorrents::toVariant() const { return toContainer(torrents | std::views::transform([](const LastTorrents::Torrent& torrent) { return QVariant(QVariantMap{ {lastTorrentsHashStringKey, torrent.hashString}, {lastTorrentsFinishedKey, torrent.finished} }); })); } LastTorrents LastTorrents::fromVariant(const QVariant& var) { if (!var.isValid() || #if QT_VERSION_MAJOR >= 6 var.typeId() != QMetaType::QVariantList #else var.type() != QVariant::List #endif ) { return {}; } return { .saved = true, .torrents = toContainer(var.toList() | std::views::transform([](const QVariant& torrentVar) { const QVariantMap map = torrentVar.toMap(); return LastTorrents::Torrent{ .hashString = map[lastTorrentsHashStringKey].toString(), .finished = map[lastTorrentsFinishedKey].toBool() }; })) }; } Servers* Servers::instance() { static auto* const instance = new Servers(nullptr, qApp); return instance; } bool Servers::hasServers() const { return !mSettings->childGroups().isEmpty(); } std::vector Servers::servers() { std::vector list; const QStringList groups(mSettings->childGroups()); list.reserve(static_cast(groups.size())); for (const QString& group : groups) { list.push_back(getServer(group)); } return list; } Server Servers::currentServer() const { return getServer(currentServerName()); } QString Servers::currentServerName() const { return mSettings->value(currentServerKey).toString(); } QString Servers::currentServerAddress() { mSettings->beginGroup(currentServerName()); QString address(mSettings->value(addressKey).toString()); mSettings->endGroup(); return address; } void Servers::setCurrentServer(const QString& name) { if (mSettings->value(currentServerKey) != name) { mSettings->setValue(currentServerKey, name); updateMountedDirectories(); emit currentServerChanged(); } } bool Servers::currentServerHasMountedDirectories() const { return !mCurrentServerMountedDirectories.empty(); } QString Servers::fromLocalToRemoteDirectory(const QString& localPath, PathOs remotePathOs) { const auto localPathNormalized = normalizePath(localPath, localPathOs); for (const auto& [localDirectory, remoteDirectory] : mCurrentServerMountedDirectories) { if (isPathUnderThisDirectory(localPathNormalized, localDirectory)) { const auto remoteDirectoryNormalized = normalizePath(remoteDirectory, remotePathOs); auto relativePathIndex = localDirectory.size(); if (localDirectory.endsWith('/')) { relativePathIndex -= 1; } return normalizePath( remoteDirectoryNormalized + localPathNormalized.mid(relativePathIndex), remotePathOs ); } } return {}; } QString Servers::fromLocalToRemoteDirectory(const QString& localPath, const ServerSettings* serverSettings) { return fromLocalToRemoteDirectory(localPath, serverSettings->data().pathOs); } QString Servers::fromRemoteToLocalDirectory(const QString& remotePath, PathOs remotePathOs) { const auto remotePathNormalized = normalizePath(remotePath, remotePathOs); for (const auto& [localDirectory, remoteDirectory] : mCurrentServerMountedDirectories) { const auto remoteDirectoryNormalized = normalizePath(remoteDirectory, remotePathOs); if (isPathUnderThisDirectory(remotePathNormalized, remoteDirectoryNormalized)) { auto relativePathIndex = remoteDirectoryNormalized.size(); if (remoteDirectoryNormalized.endsWith('/')) { relativePathIndex -= 1; } return normalizePath(localDirectory + remotePathNormalized.mid(relativePathIndex), localPathOs); } } return {}; } QString Servers::fromRemoteToLocalDirectory(const QString& remotePath, const ServerSettings* serverSettings) { return fromRemoteToLocalDirectory(remotePath, serverSettings->data().pathOs); } LastTorrents Servers::currentServerLastTorrents() const { mSettings->beginGroup(currentServerName()); auto lastTorrents = LastTorrents::fromVariant(mSettings->value(lastTorrentsKey)); mSettings->endGroup(); return lastTorrents; } void Servers::saveCurrentServerLastTorrents(const Rpc* rpc) { if (!hasServers()) { return; } mSettings->beginGroup(currentServerName()); LastTorrents torrents{}; torrents.torrents = toContainer(rpc->torrents() | std::views::transform([](const auto& torrent) { return LastTorrents::Torrent{ .hashString = torrent->data().hashString, .finished = torrent->data().isFinished() }; })); mSettings->setValue(lastTorrentsKey, torrents.toVariant()); mSettings->endGroup(); } QStringList Servers::currentServerLastDownloadDirectories(const ServerSettings* serverSettings) const { QStringList directories{}; mSettings->beginGroup(currentServerName()); directories = toContainer( mSettings->value(lastDownloadDirectoriesKey).toStringList() | std::views::transform([serverSettings](const QString& dir) { return normalizePath(dir, serverSettings->data().pathOs); }) ); mSettings->endGroup(); return directories; } void Servers::setCurrentServerLastDownloadDirectories(const QStringList& directories) { mSettings->beginGroup(currentServerName()); mSettings->setValue(lastDownloadDirectoriesKey, directories); mSettings->endGroup(); } QString Servers::currentServerLastDownloadDirectory(const ServerSettings* serverSettings) const { QString directory{}; mSettings->beginGroup(currentServerName()); directory = normalizePath(mSettings->value(lastDownloadDirectoryKey).toString(), serverSettings->data().pathOs); mSettings->endGroup(); return directory; } void Servers::setCurrentServerLastDownloadDirectory(const QString& directory) { mSettings->beginGroup(currentServerName()); mSettings->setValue(lastDownloadDirectoryKey, directory); mSettings->endGroup(); } void Servers::setServer( const QString& oldName, const QString& name, const QString& address, int port, const QString& apiPath, ConnectionConfiguration::ProxyType proxyType, const QString& proxyHostname, int proxyPort, const QString& proxyUser, const QString& proxyPassword, bool https, bool selfSignedCertificateEnabled, const QByteArray& selfSignedCertificate, bool clientCertificateEnabled, const QByteArray& clientCertificate, bool authentication, const QString& username, const QString& password, int updateInterval, int timeout, bool autoReconnectEnabled, int autoReconnectInterval, const std::vector& mountedDirectories ) { bool currentChanged = false; const QString current(currentServerName()); if (oldName == current) { if (name != oldName) { mSettings->setValue(currentServerKey, name); } currentChanged = true; } else if (name == current) { currentChanged = true; } QStringList lastDownloadDirectories{}; QString lastDownloadDirectory{}; if (!oldName.isEmpty() && name != oldName) { lastDownloadDirectories = mSettings->value(oldName % '/' % lastDownloadDirectoriesKey).toStringList(); lastDownloadDirectory = mSettings->value(oldName % '/' % lastDownloadDirectoryKey).toString(); mSettings->remove(oldName); } mSettings->beginGroup(name); mSettings->setValue(addressKey, address); mSettings->setValue(portKey, port); mSettings->setValue(apiPathKey, apiPath); mSettings->setValue(proxyTypeKey, proxyTypeToSettings(proxyType)); mSettings->setValue(proxyHostnameKey, proxyHostname); mSettings->setValue(proxyPortKey, proxyPort); mSettings->setValue(proxyUserKey, proxyUser); mSettings->setValue(proxyPasswordKey, proxyPassword); mSettings->setValue(httpsKey, https); mSettings->setValue(selfSignedCertificateEnabledKey, selfSignedCertificateEnabled); mSettings->setValue(selfSignedCertificateKey, selfSignedCertificate); mSettings->setValue(clientCertificateEnabledKey, clientCertificateEnabled); mSettings->setValue(clientCertificateKey, clientCertificate); mSettings->setValue(authenticationKey, authentication); mSettings->setValue(usernameKey, username); mSettings->setValue(passwordKey, password); mSettings->setValue(updateIntervalKey, updateInterval); mSettings->setValue(timeoutKey, timeout); mSettings->setValue(autoReconnectEnabledKey, autoReconnectEnabled); mSettings->setValue(autoReconnectEnabledKey, autoReconnectInterval); mSettings->setValue(mountedDirectoriesKey, MountedDirectory::toVariant(mountedDirectories)); mSettings->setValue(lastDownloadDirectoriesKey, lastDownloadDirectories); mSettings->setValue(lastDownloadDirectoryKey, lastDownloadDirectory); mSettings->endGroup(); if (currentChanged) { mCurrentServerMountedDirectories = mountedDirectories; emit currentServerChanged(); } if (oldName.isEmpty() && mSettings->childGroups().size() == 1) { emit hasServersChanged(); } } void Servers::saveServers(const std::vector& servers, const QString& current) { const bool hadServers = hasServers(); mSettings->clear(); if (!current.isEmpty()) { mSettings->setValue(currentServerKey, current); } for (const Server& server : servers) { mSettings->beginGroup(server.name); mSettings->setValue(addressKey, server.connectionConfiguration.address); mSettings->setValue(portKey, server.connectionConfiguration.port); mSettings->setValue(apiPathKey, server.connectionConfiguration.apiPath); mSettings->setValue(proxyTypeKey, proxyTypeToSettings(server.connectionConfiguration.proxyType)); mSettings->setValue(proxyHostnameKey, server.connectionConfiguration.proxyHostname); mSettings->setValue(proxyPortKey, server.connectionConfiguration.proxyPort); mSettings->setValue(proxyUserKey, server.connectionConfiguration.proxyUser); mSettings->setValue(proxyPasswordKey, server.connectionConfiguration.proxyPassword); mSettings->setValue(httpsKey, server.connectionConfiguration.https); mSettings->setValue( selfSignedCertificateEnabledKey, server.connectionConfiguration.selfSignedCertificateEnabled ); mSettings->setValue(selfSignedCertificateKey, server.connectionConfiguration.selfSignedCertificate); mSettings->setValue(clientCertificateEnabledKey, server.connectionConfiguration.clientCertificateEnabled); mSettings->setValue(clientCertificateKey, server.connectionConfiguration.clientCertificate); mSettings->setValue(authenticationKey, server.connectionConfiguration.authentication); mSettings->setValue(usernameKey, server.connectionConfiguration.username); mSettings->setValue(passwordKey, server.connectionConfiguration.password); mSettings->setValue(updateIntervalKey, server.connectionConfiguration.updateInterval); mSettings->setValue(timeoutKey, server.connectionConfiguration.timeout); mSettings->setValue(autoReconnectEnabledKey, server.connectionConfiguration.autoReconnectEnabled); mSettings->setValue(autoReconnectIntervalKey, server.connectionConfiguration.autoReconnectInterval); mSettings->setValue(mountedDirectoriesKey, MountedDirectory::toVariant(server.mountedDirectories)); mSettings->setValue(lastTorrentsKey, server.lastTorrents.toVariant()); mSettings->setValue(lastDownloadDirectoriesKey, server.lastDownloadDirectories); mSettings->setValue(lastDownloadDirectoryKey, server.lastDownloadDirectory); mSettings->endGroup(); } updateMountedDirectories(); emit currentServerChanged(); if (hasServers() != hadServers) { emit hasServersChanged(); } } Servers::Servers(QSettings* settings, QObject* parent) : QObject(parent), mSettings( settings ? settings : new QSettings(settingsFormat, QSettings::UserScope, qApp->organizationName(), fileName, this) ) { mSettings->setFallbacksEnabled(false); if (hasServers()) { bool foundCurrent = false; const QString current(currentServerName()); if (!current.isEmpty()) { const QStringList groups(mSettings->childGroups()); for (const QString& group : groups) { if (group == current) { foundCurrent = true; break; } } } if (!foundCurrent) { mSettings->setValue(currentServerKey, mSettings->childGroups().constFirst()); } } else { mSettings->remove(currentServerKey); } const QStringList groups(mSettings->childGroups()); for (const QString& group : groups) { mSettings->beginGroup(group); if (mSettings->contains(localCertificateKey)) { const QByteArray localCertificate(mSettings->value(localCertificateKey).toByteArray()); if (!localCertificate.isEmpty()) { mSettings->setValue(clientCertificateEnabledKey, true); mSettings->setValue(clientCertificateKey, localCertificate); } mSettings->remove(localCertificateKey); } mSettings->endGroup(); } updateMountedDirectories(); } Server Servers::getServer(const QString& name) const { mSettings->beginGroup(name); Server server{ mSettings->group(), ConnectionConfiguration{ mSettings->value(addressKey).toString(), mSettings->value(portKey).toInt(), mSettings->value(apiPathKey).toString(), proxyTypeFromSettings(mSettings->value(proxyTypeKey).toString()), mSettings->value(proxyHostnameKey).toString(), mSettings->value(proxyPortKey).toInt(), mSettings->value(proxyUserKey).toString(), mSettings->value(proxyPasswordKey).toString(), mSettings->value(httpsKey, false).toBool(), mSettings->value(selfSignedCertificateEnabledKey, false).toBool(), mSettings->value(selfSignedCertificateKey).toByteArray(), mSettings->value(clientCertificateEnabledKey, false).toBool(), mSettings->value(clientCertificateKey).toByteArray(), mSettings->value(authenticationKey, false).toBool(), mSettings->value(usernameKey).toString(), mSettings->value(passwordKey).toString(), mSettings->value(updateIntervalKey, 5).toInt(), mSettings->value(timeoutKey, 30).toInt(), mSettings->value(autoReconnectEnabledKey, false).toBool(), mSettings->value(autoReconnectIntervalKey, 30).toInt() }, MountedDirectory::fromVariant(mSettings->value(mountedDirectoriesKey)), LastTorrents::fromVariant(mSettings->value(lastTorrentsKey)), mSettings->value(lastDownloadDirectoriesKey).toStringList(), mSettings->value(lastDownloadDirectoryKey).toString() }; mSettings->endGroup(); return server; } void Servers::updateMountedDirectories() { mSettings->beginGroup(currentServerName()); mCurrentServerMountedDirectories = MountedDirectory::fromVariant(mSettings->value(mountedDirectoriesKey)); mSettings->endGroup(); } void Servers::sync() { mSettings->sync(); } } tremotesf-2.8.2/src/rpc/servers.h000066400000000000000000000074541500171105600167560ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_RPC_SERVERS_H #define TREMOTESF_RPC_SERVERS_H #include #include #include #include "rpc.h" class QSettings; namespace tremotesf { enum class PathOs; struct MountedDirectory { QString localPath{}; QString remotePath{}; static QVariant toVariant(std::span dirs); static std::vector fromVariant(const QVariant& var); }; struct LastTorrents { struct Torrent { QString hashString{}; bool finished{}; }; bool saved{}; std::vector torrents{}; QVariant toVariant() const; static LastTorrents fromVariant(const QVariant& var); }; struct Server { QString name{}; ConnectionConfiguration connectionConfiguration{}; std::vector mountedDirectories{}; LastTorrents lastTorrents{}; QStringList lastDownloadDirectories{}; QString lastDownloadDirectory{}; }; class Servers final : public QObject { Q_OBJECT public: explicit Servers(QSettings* settings, QObject* parent); static Servers* instance(); bool hasServers() const; std::vector servers(); Server currentServer() const; QString currentServerName() const; QString currentServerAddress(); void setCurrentServer(const QString& name); bool currentServerHasMountedDirectories() const; QString fromLocalToRemoteDirectory(const QString& localPath, PathOs pathOs); QString fromLocalToRemoteDirectory(const QString& localPath, const ServerSettings* serverSettings); QString fromRemoteToLocalDirectory(const QString& remotePath, PathOs pathOs); QString fromRemoteToLocalDirectory(const QString& remotePath, const ServerSettings* serverSettings); LastTorrents currentServerLastTorrents() const; void saveCurrentServerLastTorrents(const Rpc* rpc); QStringList currentServerLastDownloadDirectories(const ServerSettings* serverSettings) const; void setCurrentServerLastDownloadDirectories(const QStringList& directories); QString currentServerLastDownloadDirectory(const ServerSettings* serverSettings) const; void setCurrentServerLastDownloadDirectory(const QString& directory); void setServer( const QString& oldName, const QString& name, const QString& address, int port, const QString& apiPath, ConnectionConfiguration::ProxyType proxyType, const QString& proxyHostname, int proxyPort, const QString& proxyUser, const QString& proxyPassword, bool https, bool selfSignedCertificateEnabled, const QByteArray& selfSignedCertificate, bool clientCertificateEnabled, const QByteArray& clientCertificate, bool authentication, const QString& username, const QString& password, int updateInterval, int timeout, bool autoReconnectEnabled, int autoReconnectInterval, const std::vector& mountedDirectories ); void saveServers(const std::vector& servers, const QString& current); void sync(); private: Server getServer(const QString& name) const; void updateMountedDirectories(); QSettings* mSettings{}; std::vector mCurrentServerMountedDirectories{}; signals: void currentServerChanged(); void hasServersChanged(); }; } #endif // TREMOTESF_RPC_SERVERS_H tremotesf-2.8.2/src/rpc/servers_test.cpp000066400000000000000000000325301500171105600203410ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include "servers.h" #include "pathutils.h" #include "log/log.h" namespace fmt { template<> struct formatter : tremotesf::SimpleFormatter { format_context::iterator format(tremotesf::PathOs pathOs, format_context& ctx) const { switch (pathOs) { case tremotesf::PathOs::Unix: return fmt::format_to(ctx.out(), "Unix"); case tremotesf::PathOs::Windows: return fmt::format_to(ctx.out(), "Windows"); break; } throw std::logic_error("Unknown PathOs value"); } }; } namespace tremotesf { std::vector createMountedDirectories(std::span localDirs, std::span remoteDirs) { std::vector dirs{}; dirs.reserve(localDirs.size()); for (size_t i = 0; i < localDirs.size(); ++i) { dirs.push_back({.localPath = localDirs[i], .remotePath = remoteDirs[i]}); } return dirs; } class ServersTest final : public QObject { Q_OBJECT private slots: void Case1() { check( {.mountedLocalDirectories = []() -> std::vector { if constexpr (targetOs == TargetOs::Windows) { return {"G:/downloads"_l1, "G:/downloads2"_l1}; } else { return {"/mnt/downloads"_l1, "/mnt/downloads2"_l1}; } }(), .mountedRemoteDirectoriesWhenServerIsUnix = {"/home/test/Downloads"_l1, "/home/test/Downloads2"_l1}, .mountedRemoteDirectoriesWhenServerIsWindows = {"C:/Users/test/Downloads"_l1, "C:/Users/test/Downloads2"_l1}, .localPathsToCheck = []() -> std::vector { if constexpr (targetOs == TargetOs::Windows) { return { {}, "G:/"_l1, "G:/nope"_l1, "G:/downloads"_l1, "G:/downloads/"_l1, "G:/downloads/hmm"_l1, "G:/downloads2"_l1, "G:/downloads2/"_l1, "G:/downloads2/hmm"_l1, }; } else { return { {}, "/"_l1, "/nope"_l1, "/mnt/downloads"_l1, "/mnt/downloads/"_l1, "/mnt/downloads/hmm"_l1, "/mnt/downloads2"_l1, "/mnt/downloads2/"_l1, "/mnt/downloads2/hmm"_l1, }; } }(), .expectedRemotePathsWhenServerIsUnix = { {}, {}, {}, "/home/test/Downloads"_l1, "/home/test/Downloads"_l1, "/home/test/Downloads/hmm"_l1, "/home/test/Downloads2"_l1, "/home/test/Downloads2"_l1, "/home/test/Downloads2/hmm"_l1, }, .expectedRemotePathsWhenServerIsWindows = { {}, {}, {}, "C:/Users/test/Downloads"_l1, "C:/Users/test/Downloads"_l1, "C:/Users/test/Downloads/hmm"_l1, "C:/Users/test/Downloads2"_l1, "C:/Users/test/Downloads2"_l1, "C:/Users/test/Downloads2/hmm"_l1, }, .remotePathsToCheckWhenServerIsUnix = { {}, "/"_l1, "/nope"_l1, "/home/test/Downloads"_l1, "/home/test/Downloads/"_l1, "/home/test/Downloads/hmm"_l1, "/home/test/Downloads2"_l1, "/home/test/Downloads2/"_l1, "/home/test/Downloads2/hmm"_l1, }, .remotePathsToCheckWhenServerIsWindows = { {}, "C:/"_l1, "C:/nope"_l1, "C:/Users/test/Downloads"_l1, "C:/Users/test/Downloads/"_l1, "C:/Users/test/Downloads/hmm"_l1, "C:/Users/test/Downloads2"_l1, "C:/Users/test/Downloads2/"_l1, "C:/Users/test/Downloads2/hmm"_l1, }, .expectedLocalPaths = []() -> std::vector { if constexpr (targetOs == TargetOs::Windows) { return { {}, {}, {}, "G:/downloads"_l1, "G:/downloads"_l1, "G:/downloads/hmm"_l1, "G:/downloads2"_l1, "G:/downloads2"_l1, "G:/downloads2/hmm"_l1, }; } else { return { {}, {}, {}, "/mnt/downloads"_l1, "/mnt/downloads"_l1, "/mnt/downloads/hmm"_l1, "/mnt/downloads2"_l1, "/mnt/downloads2"_l1, "/mnt/downloads2/hmm"_l1, }; } }()} ); } void Case2() { check( {.mountedLocalDirectories = []() -> std::vector { if constexpr (targetOs == TargetOs::Windows) { return {"G:/root"_l1}; } else { return {"/mnt/root"_l1}; } }(), .mountedRemoteDirectoriesWhenServerIsUnix = {"/"_l1}, .mountedRemoteDirectoriesWhenServerIsWindows = {"D:/"_l1}, .localPathsToCheck = []() -> std::vector { if constexpr (targetOs == TargetOs::Windows) { return {"G:/root"_l1, "G:/root/"_l1, "G:/root/hmm"_l1, "G:/root/hmm/"_l1}; } else { return {"/mnt/root"_l1, "/mnt/root/"_l1, "/mnt/root/hmm"_l1, "/mnt/root/hmm/"_l1}; } }(), .expectedRemotePathsWhenServerIsUnix = {"/"_l1, "/"_l1, "/hmm"_l1, "/hmm"_l1}, .expectedRemotePathsWhenServerIsWindows = {"D:/"_l1, "D:/"_l1, "D:/hmm"_l1, "D:/hmm"}, .remotePathsToCheckWhenServerIsUnix = {"/"_l1, "/hmm"_l1, "/hmm/"_l1}, .remotePathsToCheckWhenServerIsWindows = {"D:/"_l1, "D:/hmm"_l1, "D:/hmm/"_l1}, .expectedLocalPaths = []() -> std::vector { if constexpr (targetOs == TargetOs::Windows) { return {"G:/root"_l1, "G:/root/hmm"_l1, "G:/root/hmm"_l1}; } else { return {"/mnt/root"_l1, "/mnt/root/hmm"_l1, "/mnt/root/hmm"_l1}; } }()} ); } void Case3() { check( {.mountedLocalDirectories = []() -> std::vector { if constexpr (targetOs == TargetOs::Windows) { return {"//42.42.42.42/D"_l1}; } else { return {"/remote"_l1}; } }(), .mountedRemoteDirectoriesWhenServerIsUnix = {"/"_l1}, .mountedRemoteDirectoriesWhenServerIsWindows = {"D:"_l1}, .localPathsToCheck = []() -> std::vector { if constexpr (targetOs == TargetOs::Windows) { return { "//42.42.42.42/D"_l1, "//42.42.42.42/D/"_l1, "//42.42.42.42/D/hmm"_l1, "//42.42.42.42/D/hmm/"_l1, }; } else { return { "/remote"_l1, "/remote/"_l1, "/remote/hmm"_l1, "/remote/hmm/"_l1, }; } }(), .expectedRemotePathsWhenServerIsUnix = {"/"_l1, "/"_l1, "/hmm"_l1, "/hmm"_l1}, .expectedRemotePathsWhenServerIsWindows = {"D:/"_l1, "D:/"_l1, "D:/hmm"_l1, "D:/hmm"_l1}, .remotePathsToCheckWhenServerIsUnix = {"/"_l1, "/hmm"_l1, "/hmm/"_l1}, .remotePathsToCheckWhenServerIsWindows = {"D:/"_l1, "D:/hmm"_l1, "D:/hmm/"_l1}, .expectedLocalPaths = []() -> std::vector { if constexpr (targetOs == TargetOs::Windows) { return {"//42.42.42.42/D"_l1, "//42.42.42.42/D/hmm"_l1, "//42.42.42.42/D/hmm"_l1}; } else { return {"/remote"_l1, "/remote/hmm"_l1, "/remote/hmm"_l1}; } }()} ); } private: struct TestCase { std::vector mountedLocalDirectories; std::vector mountedRemoteDirectoriesWhenServerIsUnix; std::vector mountedRemoteDirectoriesWhenServerIsWindows; std::vector localPathsToCheck; std::vector expectedRemotePathsWhenServerIsUnix; std::vector expectedRemotePathsWhenServerIsWindows; std::vector remotePathsToCheckWhenServerIsUnix; std::vector remotePathsToCheckWhenServerIsWindows; std::vector expectedLocalPaths; }; void check(TestCase testCase) { mServers.saveServers( {{.name = "test", .mountedDirectories = createMountedDirectories( testCase.mountedLocalDirectories, testCase.mountedRemoteDirectoriesWhenServerIsUnix )}}, "test" ); checkFromLocalToRemoteDirectory( testCase.localPathsToCheck, PathOs::Unix, testCase.expectedRemotePathsWhenServerIsUnix ); if (QTest::currentTestFailed()) return; checkFromRemoteToLocalDirectory( testCase.remotePathsToCheckWhenServerIsUnix, PathOs::Unix, testCase.expectedLocalPaths ); if (QTest::currentTestFailed()) return; mServers.saveServers( {{.name = "test", .mountedDirectories = createMountedDirectories( testCase.mountedLocalDirectories, testCase.mountedRemoteDirectoriesWhenServerIsWindows )}}, "test" ); checkFromLocalToRemoteDirectory( testCase.localPathsToCheck, PathOs::Windows, testCase.expectedRemotePathsWhenServerIsWindows ); if (QTest::currentTestFailed()) return; checkFromRemoteToLocalDirectory( testCase.remotePathsToCheckWhenServerIsWindows, PathOs::Windows, testCase.expectedLocalPaths ); } void checkFromLocalToRemoteDirectory( std::span localPaths, PathOs remotePathOs, std::span expectedRemotePaths ) { for (size_t i = 0; i < localPaths.size(); ++i) { info().log("Converting {} to remote path when server is {}", localPaths[i], remotePathOs); QCOMPARE(mServers.fromLocalToRemoteDirectory(localPaths[i], remotePathOs), expectedRemotePaths[i]); } } void checkFromRemoteToLocalDirectory( std::span remotePaths, PathOs remotePathOs, std::span expectedLocalPaths ) { for (size_t i = 0; i < remotePaths.size(); ++i) { info().log("Converting {} to local path when server is {}", remotePaths[i], remotePathOs); QCOMPARE(mServers.fromRemoteToLocalDirectory(remotePaths[i], remotePathOs), expectedLocalPaths[i]); } } Servers mServers{new QSettings(QString{}, QSettings::IniFormat), nullptr}; }; } QTEST_GUILESS_MAIN(tremotesf::ServersTest) #include "servers_test.moc" tremotesf-2.8.2/src/rpc/serversettings.cpp000066400000000000000000000554471500171105600207140ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "serversettings.h" #include #include "jsonutils.h" #include "literals.h" #include "pathutils.h" #include "rpc.h" #include "stdutils.h" namespace tremotesf { using namespace impl; namespace { constexpr auto downloadDirectoryKey = "download-dir"_l1; constexpr auto trashTorrentFilesKey = "trash-original-torrent-files"_l1; constexpr auto startAddedTorrentsKey = "start-added-torrents"_l1; constexpr auto renameIncompleteFilesKey = "rename-partial-files"_l1; constexpr auto incompleteDirectoryEnabledKey = "incomplete-dir-enabled"_l1; constexpr auto incompleteDirectoryKey = "incomplete-dir"_l1; constexpr auto ratioLimitedKey = "seedRatioLimited"_l1; constexpr auto ratioLimitKey = "seedRatioLimit"_l1; constexpr auto idleSeedingLimitedKey = "idle-seeding-limit-enabled"_l1; constexpr auto idleSeedingLimitKey = "idle-seeding-limit"_l1; constexpr auto downloadQueueEnabledKey = "download-queue-enabled"_l1; constexpr auto downloadQueueSizeKey = "download-queue-size"_l1; constexpr auto seedQueueEnabledKey = "seed-queue-enabled"_l1; constexpr auto seedQueueSizeKey = "seed-queue-size"_l1; constexpr auto idleQueueLimitedKey = "queue-stalled-enabled"_l1; constexpr auto idleQueueLimitKey = "queue-stalled-minutes"_l1; constexpr auto downloadSpeedLimitedKey = "speed-limit-down-enabled"_l1; constexpr auto downloadSpeedLimitKey = "speed-limit-down"_l1; constexpr auto uploadSpeedLimitedKey = "speed-limit-up-enabled"_l1; constexpr auto uploadSpeedLimitKey = "speed-limit-up"_l1; constexpr auto alternativeSpeedLimitsEnabledKey = "alt-speed-enabled"_l1; constexpr auto alternativeDownloadSpeedLimitKey = "alt-speed-down"_l1; constexpr auto alternativeUploadSpeedLimitKey = "alt-speed-up"_l1; constexpr auto alternativeSpeedLimitsScheduledKey = "alt-speed-time-enabled"_l1; constexpr auto alternativeSpeedLimitsBeginTimeKey = "alt-speed-time-begin"_l1; constexpr auto alternativeSpeedLimitsEndTimeKey = "alt-speed-time-end"_l1; constexpr auto alternativeSpeedLimitsDaysKey = "alt-speed-time-day"_l1; constexpr auto peerPortKey = "peer-port"_l1; constexpr auto randomPortEnabledKey = "peer-port-random-on-start"_l1; constexpr auto portForwardingEnabledKey = "port-forwarding-enabled"_l1; constexpr auto encryptionModeKey = "encryption"_l1; constexpr auto utpEnabledKey = "utp-enabled"_l1; constexpr auto pexEnabledKey = "pex-enabled"_l1; constexpr auto dhtEnabledKey = "dht-enabled"_l1; constexpr auto lpdEnabledKey = "lpd-enabled"_l1; constexpr auto maximumPeersPerTorrentKey = "peer-limit-per-torrent"_l1; constexpr auto maximumPeersGloballyKey = "peer-limit-global"_l1; constexpr auto alternativeSpeedLimitsDaysMapper = EnumMapper(std::array{ // (1 << 0) EnumMapping(ServerSettingsData::AlternativeSpeedLimitsDays::Sunday, 1), // (1 << 1) EnumMapping(ServerSettingsData::AlternativeSpeedLimitsDays::Monday, 2), // (1 << 2) EnumMapping(ServerSettingsData::AlternativeSpeedLimitsDays::Tuesday, 4), // (1 << 3) EnumMapping(ServerSettingsData::AlternativeSpeedLimitsDays::Wednesday, 8), // (1 << 4) EnumMapping(ServerSettingsData::AlternativeSpeedLimitsDays::Thursday, 16), // (1 << 5) EnumMapping(ServerSettingsData::AlternativeSpeedLimitsDays::Friday, 32), // (1 << 6) EnumMapping(ServerSettingsData::AlternativeSpeedLimitsDays::Saturday, 64), // (Monday | Tuesday | Wednesday | Thursday | Friday) EnumMapping(ServerSettingsData::AlternativeSpeedLimitsDays::Weekdays, 62), // (Sunday | Saturday) EnumMapping(ServerSettingsData::AlternativeSpeedLimitsDays::Weekends, 65), // (Weekdays | Weekends) EnumMapping(ServerSettingsData::AlternativeSpeedLimitsDays::All, 127), }); constexpr auto encryptionModeMapper = EnumMapper(std::array{ EnumMapping(ServerSettingsData::EncryptionMode::Allowed, "tolerated"_l1), EnumMapping(ServerSettingsData::EncryptionMode::Preferred, "preferred"_l1), EnumMapping(ServerSettingsData::EncryptionMode::Required, "required"_l1) }); } bool ServerSettingsData::canRenameFiles() const { return (rpcVersion >= 15); } bool ServerSettingsData::canShowFreeSpaceForPath() const { return (rpcVersion >= 15); } bool ServerSettingsData::hasSessionIdFile() const { return (rpcVersion >= 16); } bool ServerSettingsData::hasTableMode() const { return (rpcVersion >= 16); } bool ServerSettingsData::hasTrackerListProperty() const { return (rpcVersion >= 17); } bool ServerSettingsData::hasFileCountProperty() const { return (rpcVersion >= 17); } bool ServerSettingsData::hasLabelsProperty() const { return (rpcVersion >= 16); } ServerSettings::ServerSettings(Rpc* rpc, QObject* parent) : QObject(parent), mRpc(rpc), mSaveOnSet(true) {} void ServerSettings::setDownloadDirectory(const QString& directory) { if (directory != mData.downloadDirectory) { mData.downloadDirectory = directory; if (mSaveOnSet) { mRpc->setSessionProperty(downloadDirectoryKey, mData.downloadDirectory); } } } void ServerSettings::setStartAddedTorrents(bool start) { mData.startAddedTorrents = start; if (mSaveOnSet) { mRpc->setSessionProperty(startAddedTorrentsKey, mData.startAddedTorrents); } } void ServerSettings::setTrashTorrentFiles(bool trash) { mData.trashTorrentFiles = trash; if (mSaveOnSet) { mRpc->setSessionProperty(trashTorrentFilesKey, mData.trashTorrentFiles); } } void ServerSettings::setRenameIncompleteFiles(bool rename) { mData.renameIncompleteFiles = rename; if (mSaveOnSet) { mRpc->setSessionProperty(renameIncompleteFilesKey, mData.renameIncompleteFiles); } } void ServerSettings::setIncompleteDirectoryEnabled(bool enabled) { mData.incompleteDirectoryEnabled = enabled; if (mSaveOnSet) { mRpc->setSessionProperty(incompleteDirectoryEnabledKey, mData.incompleteDirectoryEnabled); } } void ServerSettings::setIncompleteDirectory(const QString& directory) { if (directory != mData.incompleteDirectory) { mData.incompleteDirectory = directory; if (mSaveOnSet) { mRpc->setSessionProperty(incompleteDirectoryKey, mData.incompleteDirectory); } } } void ServerSettings::setRatioLimited(bool limited) { mData.ratioLimited = limited; if (mSaveOnSet) { mRpc->setSessionProperty(ratioLimitedKey, mData.ratioLimited); } } void ServerSettings::setRatioLimit(double limit) { mData.ratioLimit = limit; if (mSaveOnSet) { mRpc->setSessionProperty(ratioLimitKey, mData.ratioLimit); } } void ServerSettings::setIdleSeedingLimited(bool limited) { mData.idleSeedingLimited = limited; if (mSaveOnSet) { mRpc->setSessionProperty(idleSeedingLimitedKey, mData.idleSeedingLimited); } } void ServerSettings::setIdleSeedingLimit(int limit) { mData.idleSeedingLimit = limit; if (mSaveOnSet) { mRpc->setSessionProperty(idleSeedingLimitKey, mData.idleSeedingLimit); } } void ServerSettings::setDownloadQueueEnabled(bool enabled) { mData.downloadQueueEnabled = enabled; if (mSaveOnSet) { mRpc->setSessionProperty(downloadQueueEnabledKey, mData.downloadQueueEnabled); } } void ServerSettings::setDownloadQueueSize(int size) { mData.downloadQueueSize = size; if (mSaveOnSet) { mRpc->setSessionProperty(downloadQueueSizeKey, mData.downloadQueueSize); } } void ServerSettings::setSeedQueueEnabled(bool enabled) { mData.seedQueueEnabled = enabled; if (mSaveOnSet) { mRpc->setSessionProperty(seedQueueEnabledKey, mData.seedQueueEnabled); } } void ServerSettings::setSeedQueueSize(int size) { mData.seedQueueSize = size; if (mSaveOnSet) { mRpc->setSessionProperty(seedQueueSizeKey, mData.seedQueueSize); } } void ServerSettings::setIdleQueueLimited(bool limited) { mData.idleQueueLimited = limited; if (mSaveOnSet) { mRpc->setSessionProperty(idleQueueLimitedKey, mData.idleQueueLimited); } } void ServerSettings::setIdleQueueLimit(int limit) { mData.idleQueueLimit = limit; if (mSaveOnSet) { mRpc->setSessionProperty(idleQueueLimitKey, mData.idleQueueLimit); } } void ServerSettings::setDownloadSpeedLimited(bool limited) { mData.downloadSpeedLimited = limited; if (mSaveOnSet) { mRpc->setSessionProperty(downloadSpeedLimitedKey, mData.downloadSpeedLimited); } } void ServerSettings::setDownloadSpeedLimit(int limit) { mData.downloadSpeedLimit = limit; if (mSaveOnSet) { mRpc->setSessionProperty(downloadSpeedLimitKey, mData.downloadSpeedLimit); } } void ServerSettings::setUploadSpeedLimited(bool limited) { mData.uploadSpeedLimited = limited; if (mSaveOnSet) { mRpc->setSessionProperty(uploadSpeedLimitedKey, mData.uploadSpeedLimited); } } void ServerSettings::setUploadSpeedLimit(int limit) { mData.uploadSpeedLimit = limit; if (mSaveOnSet) { mRpc->setSessionProperty(uploadSpeedLimitKey, mData.uploadSpeedLimit); } } void ServerSettings::setAlternativeSpeedLimitsEnabled(bool enabled) { mData.alternativeSpeedLimitsEnabled = enabled; if (mSaveOnSet) { mRpc->setSessionProperty(alternativeSpeedLimitsEnabledKey, mData.alternativeSpeedLimitsEnabled); } } void ServerSettings::setAlternativeDownloadSpeedLimit(int limit) { mData.alternativeDownloadSpeedLimit = limit; if (mSaveOnSet) { mRpc->setSessionProperty(alternativeDownloadSpeedLimitKey, mData.alternativeDownloadSpeedLimit); } } void ServerSettings::setAlternativeUploadSpeedLimit(int limit) { mData.alternativeUploadSpeedLimit = limit; if (mSaveOnSet) { mRpc->setSessionProperty(alternativeUploadSpeedLimitKey, mData.alternativeUploadSpeedLimit); } } void ServerSettings::setAlternativeSpeedLimitsScheduled(bool scheduled) { mData.alternativeSpeedLimitsScheduled = scheduled; if (mSaveOnSet) { mRpc->setSessionProperty(alternativeSpeedLimitsScheduledKey, mData.alternativeSpeedLimitsScheduled); } } void ServerSettings::setAlternativeSpeedLimitsBeginTime(QTime time) { mData.alternativeSpeedLimitsBeginTime = time; if (mSaveOnSet) { mRpc->setSessionProperty( alternativeSpeedLimitsBeginTimeKey, mData.alternativeSpeedLimitsBeginTime.msecsSinceStartOfDay() / 60000 ); } } void ServerSettings::setAlternativeSpeedLimitsEndTime(QTime time) { mData.alternativeSpeedLimitsEndTime = time; if (mSaveOnSet) { mRpc->setSessionProperty( alternativeSpeedLimitsEndTimeKey, mData.alternativeSpeedLimitsEndTime.msecsSinceStartOfDay() / 60000 ); } } void ServerSettings::setAlternativeSpeedLimitsDays(ServerSettingsData::AlternativeSpeedLimitsDays days) { if (days != mData.alternativeSpeedLimitsDays) { mData.alternativeSpeedLimitsDays = days; if (mSaveOnSet) { mRpc->setSessionProperty( alternativeSpeedLimitsDaysKey, alternativeSpeedLimitsDaysMapper.toJsonConstant(days) ); } } } void ServerSettings::setPeerPort(int port) { mData.peerPort = port; if (mSaveOnSet) { mRpc->setSessionProperty(peerPortKey, mData.peerPort); } } void ServerSettings::setRandomPortEnabled(bool enabled) { mData.randomPortEnabled = enabled; if (mSaveOnSet) { mRpc->setSessionProperty(randomPortEnabledKey, mData.randomPortEnabled); } } void ServerSettings::setPortForwardingEnabled(bool enabled) { mData.portForwardingEnabled = enabled; if (mSaveOnSet) { mRpc->setSessionProperty(portForwardingEnabledKey, mData.portForwardingEnabled); } } void ServerSettings::setEncryptionMode(ServerSettingsData::EncryptionMode mode) { mData.encryptionMode = mode; if (mSaveOnSet) { mRpc->setSessionProperty(encryptionModeKey, encryptionModeMapper.toJsonConstant(mode)); } } void ServerSettings::setUtpEnabled(bool enabled) { mData.utpEnabled = enabled; if (mSaveOnSet) { mRpc->setSessionProperty(utpEnabledKey, mData.utpEnabled); } } void ServerSettings::setPexEnabled(bool enabled) { mData.pexEnabled = enabled; if (mSaveOnSet) { mRpc->setSessionProperty(pexEnabledKey, mData.pexEnabled); } } void ServerSettings::setDhtEnabled(bool enabled) { mData.dhtEnabled = enabled; if (mSaveOnSet) { mRpc->setSessionProperty(dhtEnabledKey, mData.dhtEnabled); } } void ServerSettings::setLpdEnabled(bool enabled) { mData.lpdEnabled = enabled; if (mSaveOnSet) { mRpc->setSessionProperty(lpdEnabledKey, mData.lpdEnabled); } } void ServerSettings::setMaximumPeersPerTorrent(int peers) { mData.maximumPeersPerTorrent = peers; if (mSaveOnSet) { mRpc->setSessionProperty(maximumPeersPerTorrentKey, mData.maximumPeersPerTorrent); } } void ServerSettings::setMaximumPeersGlobally(int peers) { mData.maximumPeersGlobally = peers; if (mSaveOnSet) { mRpc->setSessionProperty(maximumPeersGloballyKey, mData.maximumPeersGlobally); } } bool ServerSettings::saveOnSet() const { return mSaveOnSet; } void ServerSettings::setSaveOnSet(bool save) { mSaveOnSet = save; } void ServerSettings::update(const QJsonObject& serverSettings) { bool changed = false; mData.rpcVersion = serverSettings.value("rpc-version"_l1).toInt(); mData.minimumRpcVersion = serverSettings.value("rpc-version-minimum"_l1).toInt(); if (const auto newConfigDir = serverSettings.value("config-dir"_l1).toString(); newConfigDir != mData.configDirectory) { mData.configDirectory = newConfigDir; // Transmission's config directory is likely located under 'C:\Users' on Windows // If config-dir somehow is an UNC path then we are out of luck - we can't reliably distinguish // between Unix path and Windows UNC path unless we already know the OS. And that's what we are trying to determine here mData.pathOs = isAbsoluteWindowsDOSFilePath(mData.configDirectory) ? PathOs::Windows : PathOs::Unix; } setChanged( mData.downloadDirectory, normalizePath(serverSettings.value(downloadDirectoryKey).toString(), mData.pathOs), changed ); setChanged(mData.trashTorrentFiles, serverSettings.value(trashTorrentFilesKey).toBool(), changed); setChanged(mData.startAddedTorrents, serverSettings.value(startAddedTorrentsKey).toBool(), changed); setChanged(mData.renameIncompleteFiles, serverSettings.value(renameIncompleteFilesKey).toBool(), changed); setChanged( mData.incompleteDirectoryEnabled, serverSettings.value(incompleteDirectoryEnabledKey).toBool(), changed ); setChanged( mData.incompleteDirectory, normalizePath(serverSettings.value(incompleteDirectoryKey).toString(), mData.pathOs), changed ); setChanged(mData.ratioLimited, serverSettings.value(ratioLimitedKey).toBool(), changed); setChanged(mData.ratioLimit, serverSettings.value(ratioLimitKey).toDouble(), changed); setChanged(mData.idleSeedingLimited, serverSettings.value(idleSeedingLimitedKey).toBool(), changed); setChanged(mData.idleSeedingLimit, serverSettings.value(idleSeedingLimitKey).toInt(), changed); setChanged(mData.downloadQueueEnabled, serverSettings.value(downloadQueueEnabledKey).toBool(), changed); setChanged(mData.downloadQueueSize, serverSettings.value(downloadQueueSizeKey).toInt(), changed); setChanged(mData.seedQueueEnabled, serverSettings.value(seedQueueEnabledKey).toBool(), changed); setChanged(mData.seedQueueSize, serverSettings.value(seedQueueSizeKey).toInt(), changed); setChanged(mData.idleQueueLimited, serverSettings.value(idleQueueLimitedKey).toBool(), changed); setChanged(mData.idleQueueLimit, serverSettings.value(idleQueueLimitKey).toInt(), changed); setChanged(mData.downloadSpeedLimited, serverSettings.value(downloadSpeedLimitedKey).toBool(), changed); setChanged(mData.downloadSpeedLimit, serverSettings.value(downloadSpeedLimitKey).toInt(), changed); setChanged(mData.uploadSpeedLimited, serverSettings.value(uploadSpeedLimitedKey).toBool(), changed); setChanged(mData.uploadSpeedLimit, serverSettings.value(uploadSpeedLimitKey).toInt(), changed); setChanged( mData.alternativeSpeedLimitsEnabled, serverSettings.value(alternativeSpeedLimitsEnabledKey).toBool(), changed ); setChanged( mData.alternativeDownloadSpeedLimit, serverSettings.value(alternativeDownloadSpeedLimitKey).toInt(), changed ); setChanged( mData.alternativeUploadSpeedLimit, serverSettings.value(alternativeUploadSpeedLimitKey).toInt(), changed ); setChanged( mData.alternativeSpeedLimitsScheduled, serverSettings.value(alternativeSpeedLimitsScheduledKey).toBool(), changed ); setChanged( mData.alternativeSpeedLimitsBeginTime, QTime::fromMSecsSinceStartOfDay(serverSettings.value(alternativeSpeedLimitsBeginTimeKey).toInt() * 60000), changed ); setChanged( mData.alternativeSpeedLimitsEndTime, QTime::fromMSecsSinceStartOfDay(serverSettings.value(alternativeSpeedLimitsEndTimeKey).toInt() * 60000), changed ); setChanged( mData.alternativeSpeedLimitsDays, alternativeSpeedLimitsDaysMapper .fromJsonValue(serverSettings.value(alternativeSpeedLimitsDaysKey), alternativeDownloadSpeedLimitKey), changed ); setChanged(mData.peerPort, serverSettings.value(peerPortKey).toInt(), changed); setChanged(mData.randomPortEnabled, serverSettings.value(randomPortEnabledKey).toBool(), changed); setChanged(mData.portForwardingEnabled, serverSettings.value(portForwardingEnabledKey).toBool(), changed); setChanged( mData.encryptionMode, encryptionModeMapper.fromJsonValue(serverSettings.value(encryptionModeKey), encryptionModeKey), changed ); setChanged(mData.utpEnabled, serverSettings.value(utpEnabledKey).toBool(), changed); setChanged(mData.pexEnabled, serverSettings.value(pexEnabledKey).toBool(), changed); setChanged(mData.dhtEnabled, serverSettings.value(dhtEnabledKey).toBool(), changed); setChanged(mData.lpdEnabled, serverSettings.value(lpdEnabledKey).toBool(), changed); setChanged(mData.maximumPeersPerTorrent, serverSettings.value(maximumPeersPerTorrentKey).toInt(), changed); setChanged(mData.maximumPeersGlobally, serverSettings.value(maximumPeersGloballyKey).toInt(), changed); if (changed) { emit this->changed(); } } void ServerSettings::save() const { mRpc->setSessionProperties( {{downloadDirectoryKey, mData.downloadDirectory}, {trashTorrentFilesKey, mData.trashTorrentFiles}, {startAddedTorrentsKey, mData.startAddedTorrents}, {renameIncompleteFilesKey, mData.renameIncompleteFiles}, {incompleteDirectoryEnabledKey, mData.incompleteDirectoryEnabled}, {incompleteDirectoryKey, mData.incompleteDirectory}, {ratioLimitedKey, mData.ratioLimited}, {ratioLimitKey, mData.ratioLimit}, {idleSeedingLimitedKey, mData.idleSeedingLimit}, {idleSeedingLimitKey, mData.idleSeedingLimit}, {downloadQueueEnabledKey, mData.downloadQueueEnabled}, {downloadQueueSizeKey, mData.downloadQueueSize}, {seedQueueEnabledKey, mData.seedQueueEnabled}, {seedQueueSizeKey, mData.seedQueueSize}, {idleQueueLimitedKey, mData.idleQueueLimited}, {idleQueueLimitKey, mData.idleQueueLimit}, {downloadSpeedLimitedKey, mData.downloadSpeedLimited}, {downloadSpeedLimitKey, mData.downloadSpeedLimit}, {uploadSpeedLimitedKey, mData.uploadSpeedLimited}, {uploadSpeedLimitKey, mData.uploadSpeedLimit}, {alternativeSpeedLimitsEnabledKey, mData.alternativeSpeedLimitsEnabled}, {alternativeDownloadSpeedLimitKey, mData.alternativeDownloadSpeedLimit}, {alternativeUploadSpeedLimitKey, mData.alternativeUploadSpeedLimit}, {alternativeSpeedLimitsScheduledKey, mData.alternativeSpeedLimitsScheduled}, {alternativeSpeedLimitsBeginTimeKey, mData.alternativeSpeedLimitsBeginTime.msecsSinceStartOfDay() / 60000}, {alternativeSpeedLimitsEndTimeKey, mData.alternativeSpeedLimitsEndTime.msecsSinceStartOfDay() / 60000}, {alternativeSpeedLimitsDaysKey, alternativeSpeedLimitsDaysMapper.toJsonConstant(mData.alternativeSpeedLimitsDays)}, {peerPortKey, mData.peerPort}, {randomPortEnabledKey, mData.randomPortEnabled}, {portForwardingEnabledKey, mData.portForwardingEnabled}, {encryptionModeKey, encryptionModeMapper.toJsonConstant(mData.encryptionMode)}, {utpEnabledKey, mData.utpEnabled}, {pexEnabledKey, mData.pexEnabled}, {dhtEnabledKey, mData.dhtEnabled}, {lpdEnabledKey, mData.lpdEnabled}, {maximumPeersPerTorrentKey, mData.maximumPeersPerTorrent}, {maximumPeersGloballyKey, mData.maximumPeersGlobally}} ); } } tremotesf-2.8.2/src/rpc/serversettings.h000066400000000000000000000122651500171105600203500ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_RPC_SERVERSETTINGS_H #define TREMOTESF_RPC_SERVERSETTINGS_H #include #include #include "pathutils.h" class QJsonObject; namespace tremotesf { class Rpc; struct ServerSettingsData { Q_GADGET public: enum class AlternativeSpeedLimitsDays { Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Weekdays, Weekends, All }; Q_ENUM(AlternativeSpeedLimitsDays) enum class EncryptionMode { Allowed, Preferred, Required }; Q_ENUM(EncryptionMode) [[nodiscard]] bool canRenameFiles() const; [[nodiscard]] bool canShowFreeSpaceForPath() const; [[nodiscard]] bool hasSessionIdFile() const; [[nodiscard]] bool hasTableMode() const; [[nodiscard]] bool hasTrackerListProperty() const; [[nodiscard]] bool hasFileCountProperty() const; [[nodiscard]] bool hasLabelsProperty() const; int rpcVersion = 0; int minimumRpcVersion = 0; QString configDirectory; PathOs pathOs = PathOs::Unix; QString downloadDirectory; bool startAddedTorrents = false; bool trashTorrentFiles = false; bool renameIncompleteFiles = false; bool incompleteDirectoryEnabled = false; QString incompleteDirectory; bool ratioLimited = false; double ratioLimit = 0.0; bool idleSeedingLimited = false; int idleSeedingLimit = 0; bool downloadQueueEnabled = false; int downloadQueueSize = 0; bool seedQueueEnabled = false; int seedQueueSize = 0; bool idleQueueLimited = false; int idleQueueLimit = 0; bool downloadSpeedLimited = false; int downloadSpeedLimit = 0; bool uploadSpeedLimited = false; int uploadSpeedLimit = 0; bool alternativeSpeedLimitsEnabled = false; int alternativeDownloadSpeedLimit = 0; int alternativeUploadSpeedLimit = 0; bool alternativeSpeedLimitsScheduled = false; QTime alternativeSpeedLimitsBeginTime; QTime alternativeSpeedLimitsEndTime; AlternativeSpeedLimitsDays alternativeSpeedLimitsDays{}; int peerPort = 0; bool randomPortEnabled = false; bool portForwardingEnabled = false; EncryptionMode encryptionMode{}; bool utpEnabled = false; bool pexEnabled = false; bool dhtEnabled = false; bool lpdEnabled = false; int maximumPeersPerTorrent = 0; int maximumPeersGlobally = 0; }; class ServerSettings final : public QObject { Q_OBJECT public: explicit ServerSettings(Rpc* rpc = nullptr, QObject* parent = nullptr); void setDownloadDirectory(const QString& directory); void setStartAddedTorrents(bool start); void setTrashTorrentFiles(bool trash); void setRenameIncompleteFiles(bool rename); void setIncompleteDirectoryEnabled(bool enabled); void setIncompleteDirectory(const QString& directory); void setRatioLimited(bool limited); void setRatioLimit(double limit); void setIdleSeedingLimited(bool limited); void setIdleSeedingLimit(int limit); void setDownloadQueueEnabled(bool enabled); void setDownloadQueueSize(int size); void setSeedQueueEnabled(bool enabled); void setSeedQueueSize(int size); void setIdleQueueLimited(bool limited); void setIdleQueueLimit(int limit); void setDownloadSpeedLimited(bool limited); void setDownloadSpeedLimit(int limit); void setUploadSpeedLimited(bool limited); void setUploadSpeedLimit(int limit); void setAlternativeSpeedLimitsEnabled(bool enabled); void setAlternativeDownloadSpeedLimit(int limit); // kB/s void setAlternativeUploadSpeedLimit(int limit); void setAlternativeSpeedLimitsScheduled(bool scheduled); void setAlternativeSpeedLimitsBeginTime(QTime time); void setAlternativeSpeedLimitsEndTime(QTime time); void setAlternativeSpeedLimitsDays(ServerSettingsData::AlternativeSpeedLimitsDays days); void setPeerPort(int port); void setRandomPortEnabled(bool enabled); void setPortForwardingEnabled(bool enabled); void setEncryptionMode(ServerSettingsData::EncryptionMode mode); void setUtpEnabled(bool enabled); void setPexEnabled(bool enabled); void setDhtEnabled(bool enabled); void setLpdEnabled(bool enabled); void setMaximumPeersPerTorrent(int peers); void setMaximumPeersGlobally(int peers); [[nodiscard]] bool saveOnSet() const; void setSaveOnSet(bool save); void update(const QJsonObject& serverSettings); void save() const; [[nodiscard]] const ServerSettingsData& data() const { return mData; }; private: Rpc* mRpc; ServerSettingsData mData; bool mSaveOnSet; signals: void changed(); }; } #endif // TREMOTESF_RPC_SERVERSETTINGS_H tremotesf-2.8.2/src/rpc/serverstats.cpp000066400000000000000000000016761500171105600202050ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "serverstats.h" #include #include "jsonutils.h" #include "literals.h" namespace tremotesf { using namespace impl; void SessionStats::update(const QJsonObject& stats) { mDownloaded = toInt64(stats.value("downloadedBytes"_l1)); mUploaded = toInt64(stats.value("uploadedBytes"_l1)); mDuration = stats.value("secondsActive"_l1).toInt(); mSessionCount = stats.value("sessionCount"_l1).toInt(); } void ServerStats::update(const QJsonObject& serverStats) { mDownloadSpeed = toInt64(serverStats.value("downloadSpeed"_l1)); mUploadSpeed = toInt64(serverStats.value("uploadSpeed"_l1)); mCurrentSession.update(serverStats.value("current-stats"_l1).toObject()); mTotal.update(serverStats.value("cumulative-stats"_l1).toObject()); emit updated(); } } tremotesf-2.8.2/src/rpc/serverstats.h000066400000000000000000000026451500171105600176470ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_RPC_SERVERSTATS_H #define TREMOTESF_RPC_SERVERSTATS_H #include class QJsonObject; namespace tremotesf { class SessionStats { public: [[nodiscard]] qint64 downloaded() const { return mDownloaded; }; [[nodiscard]] qint64 uploaded() const { return mUploaded; }; [[nodiscard]] int duration() const { return mDuration; }; [[nodiscard]] int sessionCount() const { return mSessionCount; }; void update(const QJsonObject& stats); private: qint64 mDownloaded{}; qint64 mUploaded{}; int mDuration{}; int mSessionCount{}; }; class ServerStats final : public QObject { Q_OBJECT public: using QObject::QObject; [[nodiscard]] qint64 downloadSpeed() const { return mDownloadSpeed; }; [[nodiscard]] qint64 uploadSpeed() const { return mUploadSpeed; }; [[nodiscard]] SessionStats currentSession() const { return mCurrentSession; }; [[nodiscard]] SessionStats total() const { return mTotal; }; void update(const QJsonObject& serverStats); private: qint64 mDownloadSpeed{}; qint64 mUploadSpeed{}; SessionStats mCurrentSession{}; SessionStats mTotal{}; signals: void updated(); }; } #endif // TREMOTESF_RPC_SERVERSTATS_H tremotesf-2.8.2/src/rpc/test-data/000077500000000000000000000000001500171105600167705ustar00rootroot00000000000000tremotesf-2.8.2/src/rpc/test-data/chain.pem000066400000000000000000000044441500171105600205630ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIIDBzCCAe8CFChXpRhHbaZks98eb+bjvUq/G/zPMA0GCSqGSIb3DQEBCwUAMD8x CzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxGTAXBgNVBAoMEFJv b3QgY2VydGlmaWNhdGUwHhcNMjIxMjExMjIxNTU2WhcNMzIxMjA4MjIxNTU2WjBB MQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRswGQYDVQQKDBJT aWduZWQgY2VydGlmaWNhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB AQCgOPAxFzWr2Rup0fMHlTKDhyybmTgCsuw+9w6V7bgW2V99t5Z6FCYlXmnclcGX s+34M06IbazfVoY+umPRM5I71kldfTqwXHI/Qr47x7j9TBcD4RJEcv+/ZsqV6liG j3X/FKFzzBMExOQv9XoiNf/HJkaXn7XRgv89Ui1xmooA5YHVk2VQUcHUxDRrJvaL RSY254C+2t/TKjUMKMyGYXfZ1gTtdO3cJD+pkTF3K09jUM9fCGDIp4sjAI3DgSD/ 1rfOawR8vYxUVDe20x4UNNALkctr7p/HeRSjzPRwA5ry27NJ7FSr0AE8X9EFVAkl esg6qgpDIMEho7kcZs+MdVxtAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAIpkqT6M hHBX6rX4q7tx6qgGOCRixbGSKPpYnz+3ACFYcCQAxM7yAx9ffuiMLLDDAiifUg7r X1Fd1FUpAS9/9dTGtY0/VdL6NIiZDiHFnWBkEsY9W0r5XqxfRsypEfaYg0rRkwM+ 4CDw0NbM+cEBnM872i3QxTMFPugIRRabRJ54nBwui6QbW2InjtMR0rRdxTZ/nI54 itKJGdY2r4xnSR4T8d3OlqfOqWEnEPNtMhSdvFXvJCU/nrFrVtsyx6HK1UZ9PbsH OAdbyCraFETTOVP3wumUStFMVDXMUNjPRVWoUETm059gG378WHwF+b0QjGYZB7DM OywGoHnBwmmQ0xs= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDXzCCAkegAwIBAgIUS7MCuQY+NU78pfE67ep0q5tWZSMwDQYJKoZIhvcNAQEL BQAwPzELMAkGA1UEBhMCWFgxFTATBgNVBAcMDERlZmF1bHQgQ2l0eTEZMBcGA1UE CgwQUm9vdCBjZXJ0aWZpY2F0ZTAeFw0yMjEyMTEyMjE1MTJaFw0zMjEyMDgyMjE1 MTJaMD8xCzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxGTAXBgNV BAoMEFJvb3QgY2VydGlmaWNhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK AoIBAQC08XdgaZYOkT2lfQxB3xhRuB5Dg6tkKkkDyZczkJ4EC1pGF51a+bd8NlHy mzxAVwB9rob8GBCSI8HmBqTSqMtb08eZcX+79l1WuT/SA8hV3A+hpF8tE0dhwe9u 0YlH+QONyJl+CQV/g8TVLsL9IQJu6t2KzsEACfeyhr5sXnSBahwclynA+mtM6Wne DVWMAqfvYm5CAhZqzSrUcdABb8Dfy2aV2rZWTGbtYD1y13dF7fSBI1Zu7kj58Pn2 cK1Vtip/YEZoz0keboPT/XXVmJER9syFWGmxM6Wsto0OOC4VFk54jS7FOgdTtVoL 0ZW3kKaxI4DrdOqpJQs4feS0vYLhAgMBAAGjUzBRMB0GA1UdDgQWBBRbB9gkHAvb S7FtTeDPTDF9OGephjAfBgNVHSMEGDAWgBRbB9gkHAvbS7FtTeDPTDF9OGephjAP BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCgtb0toT5Ww/e9ojH0 HxfXNLdCWMsEk7XiJc5buTe+HecSvpHnFHeZ8IE/plNJyExYRTHbF7UYTFjw1ENG XTGA427PrUdhPHm7T+5m87HQCxXmd8Js4h1Na8mL/8xNU6wcN+K15HJsInhAAb2/ SCuE9gK4H0KTIycYz0yMNympGnzmpKpM4fod3Gc7U/jmiPjc4VgWl/xq1oYSGjbE vfE6GiWNxOO9HMq7JennVevD6DIrnWM6MQZQJnrXEWBrWzxpNo3bKIDWbpIV/2Dh iAe91Y3pAXwoN96XzWGAEtAYmjFaxxjyTm+ZFwNzfNCUDdrsm6MbogOXNYp84d4Z stYN -----END CERTIFICATE----- tremotesf-2.8.2/src/rpc/test-data/client-certificate-and-key.pem000066400000000000000000000056011500171105600245610ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIIDYzCCAkugAwIBAgIUOUActRWAe1BWnwvxRlVsLQZwTokwDQYJKoZIhvcNAQEL BQAwQTELMAkGA1UEBhMCWFgxFTATBgNVBAcMDERlZmF1bHQgQ2l0eTEbMBkGA1UE CgwSQ2xpZW50IGNlcnRpZmljYXRlMB4XDTIyMTIxMTIyNTI1M1oXDTMyMTIwODIy NTI1M1owQTELMAkGA1UEBhMCWFgxFTATBgNVBAcMDERlZmF1bHQgQ2l0eTEbMBkG A1UECgwSQ2xpZW50IGNlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A MIIBCgKCAQEAs5P9AVu9kamTU4pDB2o6SSfTJXCSTCGJrA9ODXVbblc7Qk4DJziF +Cere4UHkhpCrIXqHc4IqA+HwZW0jl1USuqxNTrPTctwj2CDBsgFDi7r6jZvRu0H IkteLOsZLZwGKhLeuvREkBw6ul0YiTtQpC68hnfA3YKEmX30TNYMIEUnWHV1xp+e VKV9DxmY7+sinIH92npJjK86a0j0wic8v8UBGZjasDb4ew/usrsld2Bf3koyx13R UTUEeOL06mtpjmO2p99iNoS1Chl/PjbI2H/fMF5tvzS3FZMV6SkFPXdPPkK7387l 7hU5/Z2ria5XGWEzZzDKhgyuQ5y5y9FHJQIDAQABo1MwUTAdBgNVHQ4EFgQUnYxk 6PHtjZ1H9Xn1LgZO0KoGqaMwHwYDVR0jBBgwFoAUnYxk6PHtjZ1H9Xn1LgZO0KoG qaMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEArQqOTQNTmJM+ C2qfUX3fOaL23XKCeKbpGIKeaQRJT9s1Zzzdi7TcGgcaok7RGYflhDit50Bao2Hr 3JQIwLBD6YJwXG0LKDbarjzNNFJ1shbMF2dJz675//WtnET88ICYO1mmoYA3DLSF jdjiXqzlJSF/Ehy5VV/Il1TwNrpGeHgabmPZ611Qy6izZ1VzIGcLo81m8uwd5CEj TXFkImzrhryfI3Ax8bt4obYVf1cCWMIWh6VmCEn00SMmJepL1oZ4/sP9VAojzMxd ahoXCn2txFhGOXRcauAypJ0St6H9A5KRaBgryvnCRz5W16JAa0Ne2ZfTUQI0OdA0 ZCpT04u0ng== -----END CERTIFICATE----- -----BEGIN PRIVATE KEY----- MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCzk/0BW72RqZNT ikMHajpJJ9MlcJJMIYmsD04NdVtuVztCTgMnOIX4J6t7hQeSGkKsheodzgioD4fB lbSOXVRK6rE1Os9Ny3CPYIMGyAUOLuvqNm9G7QciS14s6xktnAYqEt669ESQHDq6 XRiJO1CkLryGd8DdgoSZffRM1gwgRSdYdXXGn55UpX0PGZjv6yKcgf3aekmMrzpr SPTCJzy/xQEZmNqwNvh7D+6yuyV3YF/eSjLHXdFRNQR44vTqa2mOY7an32I2hLUK GX8+NsjYf98wXm2/NLcVkxXpKQU9d08+QrvfzuXuFTn9nauJrlcZYTNnMMqGDK5D nLnL0UclAgMBAAECggEAOK0gUOlvbyWiBd/BP/na43PaRBq/UZ/UH6XE8KJ1dOG7 JjYQ8LP6NFPw308hEI+RM3ogZb+9I62jHwnsrnHuRKbFvxMMknT+1YGUWPOQBOXy Nz1u6WettLksw+h/TdHMcEL8YOzvJryCHId9UvKRhP/rKFVrXX3v87G5BPcZZKVm ZhPGkyfOltgi0p2t2P3o60DMXjCWmp/qd4etexT2uIsO55MEs0IJgVvVua9o8rEU TTSgpx/7Qaq/hwfvj6B8UxQSd+mPivoP/+2Vk9mVFWMiwQhRIvJfCGA1BG6HgK2k h2WJ3W+hdBvg2hXzWTfmOL4YrGDhERJ0FQD+rCbdEQKBgQDyVybCHquiR944yYug edY5sGdGflIxWMixZORWsyrq8RWjjOkoZzE6uib93Qc6e44odC3iBaFPAW8DkUFp 3spz2WzkYN+DTFdAG6MrKQAav3am1kaTzdFnLPm/f/PFwYa7hmMQxxb1WSqgG1oA WMYmiO+kKQ4BsJrbkPjk7TtvMwKBgQC9szVL89FO5D9/i/8jM77X4KWjH8vBIUE3 49w2p2+/TEMNHinG5sRVTTIfjOnltb5tGeXiaLcVU5w0eAyYK49irUkh1cblJV4v JtS4KwHLkS+8Fm3Etq3LpEn19rRIjb+AMx7GI9bL2dBAZo++NkNWueB8paKxPo27 Ck2k4hTQRwKBgQDaU7/8VStl6X+AA7vCWOGyWYXBkZ61DHrKrs20engo8AgBr7qD BuzoLrtgLNgNTTEWqwyHO3FHP1Bnk16uZeRZGMIswkW8AXP9sqh/AtIwRtw7lIJD OML2RCPA7iKNwDuFCJ6JiAPcCHgJhHrCIzhpkSbs63vN8/Cf7Wz+uee41wKBgQCw n1mhNQsNtDB29gcAZJ5s6yntXp5cXDUX75zKekzuRPgtD4eAPL5SWcSwYYgpK3V4 qWND0ZGdVrKam6fGStB+5K6xxRQhqBAwQKxQKSLLwYs7SXq8bAYXFAkU7LVg1DGY EIC3pQjJ1iwyugtd47IA3qHoDGQVORPHMUmnmiQc4wKBgQCULcOIT5Ib/TaJvZsL Jr6tQu8nWYk6ZxLvHikWSeOyQtaPTKt3eXw2zJfFvSsKxT59jgiikF3e30Gllwom QdtE4mGKHWycGga0I2KikX5hSFjRhdwH+E2dSnhwWbGZlE2W0IYKSjyFjI6YOl3/ S0JlK9MXpBd/2yhwmkQP0jqkcg== -----END PRIVATE KEY----- tremotesf-2.8.2/src/rpc/test-data/root-certificate-key.pem000066400000000000000000000032441500171105600235270ustar00rootroot00000000000000-----BEGIN PRIVATE KEY----- MIIEugIBADANBgkqhkiG9w0BAQEFAASCBKQwggSgAgEAAoIBAQC08XdgaZYOkT2l fQxB3xhRuB5Dg6tkKkkDyZczkJ4EC1pGF51a+bd8NlHymzxAVwB9rob8GBCSI8Hm BqTSqMtb08eZcX+79l1WuT/SA8hV3A+hpF8tE0dhwe9u0YlH+QONyJl+CQV/g8TV LsL9IQJu6t2KzsEACfeyhr5sXnSBahwclynA+mtM6WneDVWMAqfvYm5CAhZqzSrU cdABb8Dfy2aV2rZWTGbtYD1y13dF7fSBI1Zu7kj58Pn2cK1Vtip/YEZoz0keboPT /XXVmJER9syFWGmxM6Wsto0OOC4VFk54jS7FOgdTtVoL0ZW3kKaxI4DrdOqpJQs4 feS0vYLhAgMBAAECgf8PWJWzR94cwMFHZEjmGrlEhYnVLtYPJbgnn3+DIjeYlWBQ VByb4GqVLusIointXqJqC+WeHx+kMLPU7WFYcSRMH7ILAGfJLdFQ4qmNzZrcjltw CxYMinoVsNxDehbMMpXtEfIji1TNrZs/pRQegI4devzkTfqXf/lYqYhXRESp3J1B VINwdCiFucHEhAkMmW/711ggtuaqFCOImcThpBpOft7W1lRtO9Z6UYVTSmvMkFB4 X2j0/5GofSzK1HL6WLxtTCDJsfHcQxBS/9k+Rekz6/dQnVgADTtN4DvLGbUllGKO pKfrgDGjH5pFCDHeKgX5hL3BDrhxuyhukcphnsECgYEA7nQJzUA8XxCSQVRZ0dVX vNxFcy3dBwLflKWJfAIZ/PX3TyIA+Wtu0GL0V7Svo6737bpfsfkRD9Hapsu/Ir/S sfiGq2KhYlU3Ou4B2e7JU2cO49iwlSEUyI7Ilpapay7A12CPN2bw63Rb/Eh/N/iK IRf0qLYVWxLUGPJL+zE6q0ECgYEAwkIPELA+n3UX1qAWmHsAWihjVQocP+4cv5zc Qddfjj6KK32WrrtnxIRCv9ji8VnVSnxfZl03b1NLVXr14fAnCNr0vOVYAOwXg0eI jaZfP9p2643DiRbe07kBwEDBqCFP10TLS8ckBLRvkO+L0j00O/EERHyCd7YrrAdl JMYPD6ECgYBCXYlc1sP2sWYDSLa27+m7ZpLtu5YInYQcmvXozazt+ocaPxyGTqBI 30GiJ2e65reaMoTvw6I8BOwWAB7yTPEXF1Rj3s+LzqvQeu2I+iyOSeCbCXQcDVj7 eMHbJ5N/gUOqrfUuNjhXT8tKK+M8cLABBenSCttmvZbKWqVLBCiQAQKBgAEJlUcD ifIUEAKHbFd4ILJakN09ZpU40lJ7pfl8CviZgOdmjk10lsNH6YtYvy2Gy0rQizni uY8QpNBaDcIdJDg54yC3INcwa5e55BLNlqiipAvx/99Vje8Xh9jc/6vEMcb2iRdo gtq7k/T0Moz24raHPPyYpaG6CVWr3HBr1lzBAoGAQaow5Q6TL8ucFP8PsMpUzj3L y4ym++TQjxTu3Mcvgss33BZUE9s4pJzRxBg6kFK64Hv17zRvK7DAYsje9SIKnBG3 /MO4mmUDXKcW7pJiL1+KclNMeVJqxNc9j04h/UAv8lgAy2z8P16nnQ8I9jBWlcJr 4LsSucEOB/JHviHvIdk= -----END PRIVATE KEY----- tremotesf-2.8.2/src/rpc/test-data/root-certificate.pem000066400000000000000000000023151500171105600227370ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIIDXzCCAkegAwIBAgIUS7MCuQY+NU78pfE67ep0q5tWZSMwDQYJKoZIhvcNAQEL BQAwPzELMAkGA1UEBhMCWFgxFTATBgNVBAcMDERlZmF1bHQgQ2l0eTEZMBcGA1UE CgwQUm9vdCBjZXJ0aWZpY2F0ZTAeFw0yMjEyMTEyMjE1MTJaFw0zMjEyMDgyMjE1 MTJaMD8xCzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxGTAXBgNV BAoMEFJvb3QgY2VydGlmaWNhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK AoIBAQC08XdgaZYOkT2lfQxB3xhRuB5Dg6tkKkkDyZczkJ4EC1pGF51a+bd8NlHy mzxAVwB9rob8GBCSI8HmBqTSqMtb08eZcX+79l1WuT/SA8hV3A+hpF8tE0dhwe9u 0YlH+QONyJl+CQV/g8TVLsL9IQJu6t2KzsEACfeyhr5sXnSBahwclynA+mtM6Wne DVWMAqfvYm5CAhZqzSrUcdABb8Dfy2aV2rZWTGbtYD1y13dF7fSBI1Zu7kj58Pn2 cK1Vtip/YEZoz0keboPT/XXVmJER9syFWGmxM6Wsto0OOC4VFk54jS7FOgdTtVoL 0ZW3kKaxI4DrdOqpJQs4feS0vYLhAgMBAAGjUzBRMB0GA1UdDgQWBBRbB9gkHAvb S7FtTeDPTDF9OGephjAfBgNVHSMEGDAWgBRbB9gkHAvbS7FtTeDPTDF9OGephjAP BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCgtb0toT5Ww/e9ojH0 HxfXNLdCWMsEk7XiJc5buTe+HecSvpHnFHeZ8IE/plNJyExYRTHbF7UYTFjw1ENG XTGA427PrUdhPHm7T+5m87HQCxXmd8Js4h1Na8mL/8xNU6wcN+K15HJsInhAAb2/ SCuE9gK4H0KTIycYz0yMNympGnzmpKpM4fod3Gc7U/jmiPjc4VgWl/xq1oYSGjbE vfE6GiWNxOO9HMq7JennVevD6DIrnWM6MQZQJnrXEWBrWzxpNo3bKIDWbpIV/2Dh iAe91Y3pAXwoN96XzWGAEtAYmjFaxxjyTm+ZFwNzfNCUDdrsm6MbogOXNYp84d4Z stYN -----END CERTIFICATE----- tremotesf-2.8.2/src/rpc/test-data/signed-certificate-csr.pem000066400000000000000000000016701500171105600240150ustar00rootroot00000000000000-----BEGIN CERTIFICATE REQUEST----- MIIChjCCAW4CAQAwQTELMAkGA1UEBhMCWFgxFTATBgNVBAcMDERlZmF1bHQgQ2l0 eTEbMBkGA1UECgwSU2lnbmVkIGNlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEF AAOCAQ8AMIIBCgKCAQEAoDjwMRc1q9kbqdHzB5Uyg4csm5k4ArLsPvcOle24Ftlf fbeWehQmJV5p3JXBl7Pt+DNOiG2s31aGPrpj0TOSO9ZJXX06sFxyP0K+O8e4/UwX A+ESRHL/v2bKlepYho91/xShc8wTBMTkL/V6IjX/xyZGl5+10YL/PVItcZqKAOWB 1ZNlUFHB1MQ0ayb2i0UmNueAvtrf0yo1DCjMhmF32dYE7XTt3CQ/qZExdytPY1DP XwhgyKeLIwCNw4Eg/9a3zmsEfL2MVFQ3ttMeFDTQC5HLa+6fx3kUo8z0cAOa8tuz SexUq9ABPF/RBVQJJXrIOqoKQyDBIaO5HGbPjHVcbQIDAQABoAAwDQYJKoZIhvcN AQELBQADggEBAB1B2K1pkX7uhCIRiGJOrz2/aC2pw7WoHxWUBwaLwL99PJqoIktZ tAGcp/Mhp02l4K0yYA88ZLMSqxhO83GLbodUxAw2K4cOyxg6DcGmE5iJL0ebhjjD 1FvEA7oZs9IL0JyQ3K0iDGEWs/DnFZF6OjzxmoGtsW7WO75Ud7IsgR52dyZrH64h IU7VKMVUod+0raZkmppnJCtuwJg1JnPLVv87k3KOPqKqBTxhnDN/KCrs2PYZMkUp 0NbEtNPF2qn7rwRauGtRvdmVJ2Fwmbqoh9rbW7a/M90aKv/6vM35ynzOGGmqiI6Y vUlpmNTeZ8DwuzMhBa8wI5EPrlnFvD4jdlM= -----END CERTIFICATE REQUEST----- tremotesf-2.8.2/src/rpc/test-data/signed-certificate-key.pem000066400000000000000000000032501500171105600240120ustar00rootroot00000000000000-----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCgOPAxFzWr2Rup 0fMHlTKDhyybmTgCsuw+9w6V7bgW2V99t5Z6FCYlXmnclcGXs+34M06IbazfVoY+ umPRM5I71kldfTqwXHI/Qr47x7j9TBcD4RJEcv+/ZsqV6liGj3X/FKFzzBMExOQv 9XoiNf/HJkaXn7XRgv89Ui1xmooA5YHVk2VQUcHUxDRrJvaLRSY254C+2t/TKjUM KMyGYXfZ1gTtdO3cJD+pkTF3K09jUM9fCGDIp4sjAI3DgSD/1rfOawR8vYxUVDe2 0x4UNNALkctr7p/HeRSjzPRwA5ry27NJ7FSr0AE8X9EFVAklesg6qgpDIMEho7kc Zs+MdVxtAgMBAAECggEADDai6p8SDo4/e1e/fqO7JMcUWa0Zla0VcKxNDpU/K/C4 hIEO5bHtAXq7t56r0fhbisjAcwpnO+Qg2h3Dt6IGguywDYIbC/AUHmnkTfLI0Xgw JfHNfm3EvI6loT1qr6E2dbIZJ5ZWGc43dcdw3rQ+kewDRBIe3kBt2/sMb0VAQVae iBDniErAX/vGllPAAGgA/587KeDmxL6kd1BOpibN8o4xm4xQSrUl8XT4Xd4gX2iv WxlGbY4ClQeIuzukcrKcEjmuXPizE8mXJMrTXzevFh7/LoADeCe0eWs8zJLKOFQY ENf2tZbRWlJ3DwG4Gxj75QKP6bZAW8T9e/OWQ0K5QQKBgQDOMXHM8/sptQZpqaCu R7JDadIRwyu7lHC6+/JGXO1CXhVlxlI4TtnhcyQ9apI3wDyEEb68tDuBW/wpQgo8 6YjmgQnd0KMIDYRR04kErPcaoDkrxVjFOXDrDsllmD23YRdxk7vSlF1tYrk354FF uUEXDzHH+eCdVYAxdzx2dvygQQKBgQDG7MJrA00EFQYUipmLuxyldMQ5NdEOqfta N7u/FTPKasvfxXtyE2xv7/7qtKBBDYWhnrT7ZQtL0MXp0KEeg1JaB03GxmNofY6n 2ckL9mMWjHVEHfkMzaROx0+4aTxwTcNdhv4ex2QUHlOTrF5e5GzigT2qdoqbHfGu uKw6AzDxLQKBgGf93fRNNOZDC3ns+EINnOWNEEqvEXZoljZn7Tf5lBu90bLjxAHs Gs0uwh9LiXUeuiatwHHxwHUsjE/Oo9U2vznp6Kz7lc3w60RNmLRH+9Rs7Iib3nqR ztZuPbrEfpPnHujEZpz9AOWzPdDpLHSayy4zFptR9ivDvIS2K0NgHWdBAoGBAKXV fOLPlqX/jNkVDpphe6knpen3xnfOF2AHtHnBCDMIQzwimx3nuW+8CKzLtgllZ3Ds KP6nJvqmakfZCGiym7W3/wvmGbtjaMjfk25okgSbRatqvVQCH6cZG4mmGZ+aBHN0 9WbdXL405gHnIalEDs3pZmo0dqqIFRJOnC2kuWllAoGAOT2mk+jE9iZhfHFKR/cc amYXFpkM4iCISW7LcbCWEZ8BTf0O9ULwFewO9yGhsK+Y6VFjUvYa4EFvixBN8eh8 V6nsf4nCCAem9VVNKy8SjastZgRR1Lbxr7y/HnV7SyAVHihlKsN3fME7rP6wg3Ju /qrRPtR3CSaa9VcyzKGtiZo= -----END PRIVATE KEY----- tremotesf-2.8.2/src/rpc/test-data/signed-certificate.pem000066400000000000000000000021271500171105600232260ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIIDBzCCAe8CFChXpRhHbaZks98eb+bjvUq/G/zPMA0GCSqGSIb3DQEBCwUAMD8x CzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxGTAXBgNVBAoMEFJv b3QgY2VydGlmaWNhdGUwHhcNMjIxMjExMjIxNTU2WhcNMzIxMjA4MjIxNTU2WjBB MQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRswGQYDVQQKDBJT aWduZWQgY2VydGlmaWNhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB AQCgOPAxFzWr2Rup0fMHlTKDhyybmTgCsuw+9w6V7bgW2V99t5Z6FCYlXmnclcGX s+34M06IbazfVoY+umPRM5I71kldfTqwXHI/Qr47x7j9TBcD4RJEcv+/ZsqV6liG j3X/FKFzzBMExOQv9XoiNf/HJkaXn7XRgv89Ui1xmooA5YHVk2VQUcHUxDRrJvaL RSY254C+2t/TKjUMKMyGYXfZ1gTtdO3cJD+pkTF3K09jUM9fCGDIp4sjAI3DgSD/ 1rfOawR8vYxUVDe20x4UNNALkctr7p/HeRSjzPRwA5ry27NJ7FSr0AE8X9EFVAkl esg6qgpDIMEho7kcZs+MdVxtAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAIpkqT6M hHBX6rX4q7tx6qgGOCRixbGSKPpYnz+3ACFYcCQAxM7yAx9ffuiMLLDDAiifUg7r X1Fd1FUpAS9/9dTGtY0/VdL6NIiZDiHFnWBkEsY9W0r5XqxfRsypEfaYg0rRkwM+ 4CDw0NbM+cEBnM872i3QxTMFPugIRRabRJ54nBwui6QbW2InjtMR0rRdxTZ/nI54 itKJGdY2r4xnSR4T8d3OlqfOqWEnEPNtMhSdvFXvJCU/nrFrVtsyx6HK1UZ9PbsH OAdbyCraFETTOVP3wumUStFMVDXMUNjPRVWoUETm059gG378WHwF+b0QjGYZB7DM OywGoHnBwmmQ0xs= -----END CERTIFICATE----- tremotesf-2.8.2/src/rpc/torrent.cpp000066400000000000000000001072241500171105600173110ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include #include #include #include #include "rpc.h" #include "serversettings.h" #include "torrent.h" #include "log/log.h" #include "jsonutils.h" #include "itemlistupdater.h" #include "pathutils.h" #include "stdutils.h" namespace tremotesf { using namespace impl; enum class TorrentData::UpdateKey { Id, HashString, AddedDate, Name, MagnetLink, QueuePosition, TotalSize, CompletedSize, LeftUntilDone, SizeWhenDone, PercentDone, RecheckProgress, Eta, MetadataPercentComplete, DownloadSpeed, UploadSpeed, DownloadSpeedLimited, DownloadSpeedLimit, UploadSpeedLimited, UploadSpeedLimit, TotalDownloaded, TotalUploaded, Ratio, RatioLimitMode, RatioLimit, PeersSendingToUsCount, PeersGettingFromUsCount, WebSeeders, WebSeedersSendingToUsCount, Status, Error, ErrorString, ActivityDate, DoneDate, PeersLimit, HonorSessionLimits, BandwidthPriority, IdleSeedingLimitMode, IdleSeedingLimit, DownloadDirectory, Creator, CreationDate, Comment, TrackerStats, FileCount, Labels, Count }; namespace { constexpr QLatin1String updateKeyString(TorrentData::UpdateKey key) { switch (key) { case TorrentData::UpdateKey::Id: return "id"_l1; case TorrentData::UpdateKey::HashString: return "hashString"_l1; case TorrentData::UpdateKey::AddedDate: return "addedDate"_l1; case TorrentData::UpdateKey::Name: return "name"_l1; case TorrentData::UpdateKey::MagnetLink: return "magnetLink"_l1; case TorrentData::UpdateKey::QueuePosition: return "queuePosition"_l1; case TorrentData::UpdateKey::TotalSize: return "totalSize"_l1; case TorrentData::UpdateKey::CompletedSize: return "haveValid"_l1; case TorrentData::UpdateKey::LeftUntilDone: return "leftUntilDone"_l1; case TorrentData::UpdateKey::SizeWhenDone: return "sizeWhenDone"_l1; case TorrentData::UpdateKey::PercentDone: return "percentDone"_l1; case TorrentData::UpdateKey::RecheckProgress: return "recheckProgress"_l1; case TorrentData::UpdateKey::Eta: return "eta"_l1; case TorrentData::UpdateKey::MetadataPercentComplete: return "metadataPercentComplete"_l1; case TorrentData::UpdateKey::DownloadSpeed: return "rateDownload"_l1; case TorrentData::UpdateKey::UploadSpeed: return "rateUpload"_l1; case TorrentData::UpdateKey::DownloadSpeedLimited: return "downloadLimited"_l1; case TorrentData::UpdateKey::DownloadSpeedLimit: return "downloadLimit"_l1; case TorrentData::UpdateKey::UploadSpeedLimited: return "uploadLimited"_l1; case TorrentData::UpdateKey::UploadSpeedLimit: return "uploadLimit"_l1; case TorrentData::UpdateKey::TotalDownloaded: return "downloadedEver"_l1; case TorrentData::UpdateKey::TotalUploaded: return "uploadedEver"_l1; case TorrentData::UpdateKey::Ratio: return "uploadRatio"_l1; case TorrentData::UpdateKey::RatioLimitMode: return "seedRatioMode"_l1; case TorrentData::UpdateKey::RatioLimit: return "seedRatioLimit"_l1; case TorrentData::UpdateKey::PeersSendingToUsCount: return "peersSendingToUs"_l1; case TorrentData::UpdateKey::PeersGettingFromUsCount: return "peersGettingFromUs"_l1; case TorrentData::UpdateKey::WebSeeders: return "webseeds"_l1; case TorrentData::UpdateKey::WebSeedersSendingToUsCount: return "webseedsSendingToUs"_l1; case TorrentData::UpdateKey::Status: return "status"_l1; case TorrentData::UpdateKey::Error: return "error"_l1; case TorrentData::UpdateKey::ErrorString: return "errorString"_l1; case TorrentData::UpdateKey::ActivityDate: return "activityDate"_l1; case TorrentData::UpdateKey::DoneDate: return "doneDate"_l1; case TorrentData::UpdateKey::PeersLimit: return "peer-limit"_l1; case TorrentData::UpdateKey::HonorSessionLimits: return "honorsSessionLimits"_l1; case TorrentData::UpdateKey::BandwidthPriority: return "bandwidthPriority"_l1; case TorrentData::UpdateKey::IdleSeedingLimitMode: return "seedIdleMode"_l1; case TorrentData::UpdateKey::IdleSeedingLimit: return "seedIdleLimit"_l1; case TorrentData::UpdateKey::DownloadDirectory: return "downloadDir"_l1; case TorrentData::UpdateKey::Creator: return "creator"_l1; case TorrentData::UpdateKey::CreationDate: return "dateCreated"_l1; case TorrentData::UpdateKey::Comment: return "comment"_l1; case TorrentData::UpdateKey::TrackerStats: return "trackerStats"_l1; case TorrentData::UpdateKey::FileCount: return "file-count"_l1; case TorrentData::UpdateKey::Labels: return "labels"_l1; case TorrentData::UpdateKey::Count: return {}; } return {}; } std::optional mapUpdateKey(const QString& stringKey) { static const auto mapping = [] { std::map> map{}; for (int i = 0; i < static_cast(TorrentData::UpdateKey::Count); ++i) { const auto key = static_cast(i); map.emplace(updateKeyString(key), key); } return map; }(); const auto foundKey = mapping.find(stringKey); if (foundKey == mapping.end()) { warning().log("Unknown torrent field '{}'", stringKey); return {}; } return static_cast(foundKey->second); } constexpr auto prioritiesKey = "priorities"_l1; constexpr auto wantedFilesKey = "files-wanted"_l1; constexpr auto unwantedFilesKey = "files-unwanted"_l1; constexpr auto lowPriorityKey = "priority-low"_l1; constexpr auto normalPriorityKey = "priority-normal"_l1; constexpr auto highPriorityKey = "priority-high"_l1; constexpr auto addTrackerKey = "trackerAdd"_l1; constexpr auto replaceTrackerKey = "trackerReplace"_l1; constexpr auto removeTrackerKey = "trackerRemove"_l1; constexpr auto trackerListKey = "trackerList"_l1; constexpr auto statusMapper = EnumMapper(std::array{ EnumMapping(TorrentData::Status::Paused, 0), EnumMapping(TorrentData::Status::QueuedForChecking, 1), EnumMapping(TorrentData::Status::Checking, 2), EnumMapping(TorrentData::Status::QueuedForDownloading, 3), EnumMapping(TorrentData::Status::Downloading, 4), EnumMapping(TorrentData::Status::QueuedForSeeding, 5), EnumMapping(TorrentData::Status::Seeding, 6) }); constexpr auto errorMapper = EnumMapper(std::array{ EnumMapping(TorrentData::Error::None, 0), EnumMapping(TorrentData::Error::TrackerWarning, 1), EnumMapping(TorrentData::Error::TrackerError, 2), EnumMapping(TorrentData::Error::LocalError, 3) }); constexpr auto priorityMapper = EnumMapper(std::array{ EnumMapping(TorrentData::Priority::Low, -1), EnumMapping(TorrentData::Priority::Normal, 0), EnumMapping(TorrentData::Priority::High, 1) }); constexpr auto ratioLimitModeMapper = EnumMapper(std::array{ EnumMapping(TorrentData::RatioLimitMode::Global, 0), EnumMapping(TorrentData::RatioLimitMode::Single, 1), EnumMapping(TorrentData::RatioLimitMode::Unlimited, 2) }); constexpr auto idleSeedingLimitModeMapper = EnumMapper(std::array{ EnumMapping(TorrentData::IdleSeedingLimitMode::Global, 0), EnumMapping(TorrentData::IdleSeedingLimitMode::Single, 1), EnumMapping(TorrentData::IdleSeedingLimitMode::Unlimited, 2) }); } int TorrentData::priorityToInt(Priority value) { return priorityMapper.toJsonConstant(value); } bool TorrentData::update(const QJsonObject& object, bool firstTime, const Rpc* rpc) { bool changed = false; for (auto i = object.begin(), end = object.end(); i != end; ++i) { const auto key = mapUpdateKey(i.key()); if (key.has_value()) { updateProperty(*key, i.value(), changed, firstTime, rpc); } } applyTrackerErrorWorkaround(changed); return changed; } bool TorrentData::update( std::span> keys, const QJsonArray& values, bool firstTime, const Rpc* rpc ) { bool changed = false; const auto count = std::min(keys.size(), static_cast(values.size())); for (size_t i = 0; i < count; ++i) { const auto key = keys[i]; if (key.has_value()) { updateProperty(*key, values[static_cast(i)], changed, firstTime, rpc); } } applyTrackerErrorWorkaround(changed); return changed; } void TorrentData::updateProperty( TorrentData::UpdateKey intKey, const QJsonValue& value, bool& changed, bool firstTime, const Rpc* rpc ) { const auto key = static_cast(intKey); switch (static_cast(key)) { case TorrentData::UpdateKey::Id: return; case TorrentData::UpdateKey::HashString: if (firstTime) { hashString = value.toString(); } return; case TorrentData::UpdateKey::AddedDate: updateDateTime(addedDate, value, changed); return; case TorrentData::UpdateKey::Name: setChanged(name, value.toString(), changed); return; case TorrentData::UpdateKey::MagnetLink: setChanged(magnetLink, value.toString(), changed); return; case TorrentData::UpdateKey::QueuePosition: setChanged(queuePosition, value.toInt(), changed); return; case TorrentData::UpdateKey::TotalSize: setChanged(totalSize, toInt64(value), changed); return; case TorrentData::UpdateKey::CompletedSize: setChanged(completedSize, toInt64(value), changed); return; case TorrentData::UpdateKey::LeftUntilDone: setChanged(leftUntilDone, toInt64(value), changed); return; case TorrentData::UpdateKey::SizeWhenDone: setChanged(sizeWhenDone, toInt64(value), changed); return; case TorrentData::UpdateKey::PercentDone: setChanged(percentDone, value.toDouble(), changed); return; case TorrentData::UpdateKey::RecheckProgress: setChanged(recheckProgress, value.toDouble(), changed); return; case TorrentData::UpdateKey::Eta: setChanged(eta, value.toInt(), changed); return; case TorrentData::UpdateKey::MetadataPercentComplete: setChanged(metadataComplete, value.toInt() == 1, changed); return; case TorrentData::UpdateKey::DownloadSpeed: setChanged(downloadSpeed, toInt64(value), changed); return; case TorrentData::UpdateKey::UploadSpeed: setChanged(uploadSpeed, toInt64(value), changed); return; case TorrentData::UpdateKey::DownloadSpeedLimited: setChanged(downloadSpeedLimited, value.toBool(), changed); return; case TorrentData::UpdateKey::DownloadSpeedLimit: setChanged(downloadSpeedLimit, value.toInt(), changed); return; case TorrentData::UpdateKey::UploadSpeedLimited: setChanged(uploadSpeedLimited, value.toBool(), changed); return; case TorrentData::UpdateKey::UploadSpeedLimit: setChanged(uploadSpeedLimit, value.toInt(), changed); return; case TorrentData::UpdateKey::TotalDownloaded: setChanged(totalDownloaded, toInt64(value), changed); return; case TorrentData::UpdateKey::TotalUploaded: setChanged(totalUploaded, toInt64(value), changed); return; case TorrentData::UpdateKey::Ratio: setChanged(ratio, value.toDouble(), changed); return; case TorrentData::UpdateKey::RatioLimitMode: setChanged(ratioLimitMode, ratioLimitModeMapper.fromJsonValue(value, updateKeyString(key)), changed); return; case TorrentData::UpdateKey::RatioLimit: setChanged(ratioLimit, value.toDouble(), changed); return; case TorrentData::UpdateKey::PeersSendingToUsCount: setChanged(peersSendingToUsCount, value.toInt(), changed); return; case TorrentData::UpdateKey::PeersGettingFromUsCount: setChanged(peersGettingFromUsCount, value.toInt(), changed); return; case TorrentData::UpdateKey::WebSeeders: { setChanged( webSeeders, toContainer(value.toArray() | std::views::transform([](auto value) { return value.toString(); })), changed ); return; } case TorrentData::UpdateKey::WebSeedersSendingToUsCount: setChanged(webSeedersSendingToUsCount, value.toInt(), changed); return; case TorrentData::UpdateKey::Status: setChanged(status, statusMapper.fromJsonValue(value, updateKeyString(key)), changed); return; case TorrentData::UpdateKey::Error: setChanged(error, errorMapper.fromJsonValue(value, updateKeyString(key)), changed); return; case TorrentData::UpdateKey::ErrorString: setChanged(errorString, value.toString(), changed); return; case TorrentData::UpdateKey::ActivityDate: updateDateTime(activityDate, value, changed); return; case TorrentData::UpdateKey::DoneDate: updateDateTime(doneDate, value, changed); return; case TorrentData::UpdateKey::PeersLimit: setChanged(peersLimit, value.toInt(), changed); return; case TorrentData::UpdateKey::HonorSessionLimits: setChanged(honorSessionLimits, value.toBool(), changed); return; case TorrentData::UpdateKey::BandwidthPriority: setChanged(bandwidthPriority, priorityMapper.fromJsonValue(value, updateKeyString(key)), changed); return; case TorrentData::UpdateKey::IdleSeedingLimitMode: setChanged( idleSeedingLimitMode, idleSeedingLimitModeMapper.fromJsonValue(value, updateKeyString(key)), changed ); return; case TorrentData::UpdateKey::IdleSeedingLimit: setChanged(idleSeedingLimit, value.toInt(), changed); return; case TorrentData::UpdateKey::DownloadDirectory: setChanged( downloadDirectory, normalizePath(value.toString(), rpc->serverSettings()->data().pathOs), changed ); return; case TorrentData::UpdateKey::Creator: setChanged(creator, value.toString(), changed); return; case TorrentData::UpdateKey::CreationDate: updateDateTime(creationDate, value, changed); return; case TorrentData::UpdateKey::Comment: setChanged(comment, value.toString(), changed); return; case TorrentData::UpdateKey::TrackerStats: { std::vector newTrackers{}; const QJsonArray trackerJsons = value.toArray(); newTrackers.reserve(static_cast(trackerJsons.size())); int newTotalSeeders{}; int newTotalLeechers{}; for (const auto& i : trackerJsons) { const QJsonObject trackerMap = i.toObject(); const int trackerId = trackerMap.value("id"_l1).toInt(); const auto found = std::ranges::find(trackers, trackerId, &Tracker::id); if (found == trackers.end()) { newTrackers.emplace_back(trackerId, trackerMap); changed = true; } else { if (found->update(trackerMap)) { changed = true; } newTrackers.push_back(std::move(*found)); } newTotalSeeders += newTrackers.back().seeders(); newTotalLeechers += newTrackers.back().leechers(); } trackers = std::move(newTrackers); setChanged(totalSeedersFromTrackersCount, newTotalSeeders, changed); setChanged(totalLeechersFromTrackersCount, newTotalLeechers, changed); return; } case TorrentData::UpdateKey::FileCount: setChanged(singleFile, value.toInt() == 1, changed); return; case TorrentData::UpdateKey::Labels: { setChanged( labels, toContainer(value.toArray() | std::views::transform([](auto value) { return value.toString(); })), changed ); return; } case TorrentData::UpdateKey::Count: throw std::logic_error("UpdateKey::Count should not be mapped"); } throw std::logic_error(fmt::format("Can't update key {}", static_cast(intKey))); } void TorrentData::applyTrackerErrorWorkaround(bool& changed) { // Sometimes Transmission doesn't propagate tracker error to the torrent's status if (error != Error::None) { return; } if (trackers.empty()) { return; } // Only set error if *all* trackers have an error if (std::ranges::any_of(trackers, [](const Tracker& tracker) { return tracker.errorMessage().isEmpty(); })) { return; } // Set error from first tracker error = Error::TrackerError; errorString = trackers.front().errorMessage(); changed = true; } Torrent::Torrent(int id, const QJsonObject& object, Rpc* rpc, QObject* parent) : QObject(parent), mRpc(rpc) { mData.id = id; [[maybe_unused]] const bool changed = mData.update(object, true, rpc); } Torrent::Torrent( int id, std::span> keys, const QJsonArray& values, Rpc* rpc, QObject* parent ) : QObject(parent), mRpc(rpc) { mData.id = id; [[maybe_unused]] const bool changed = mData.update(keys, values, true, rpc); } QJsonArray Torrent::updateFields(const ServerSettings* serverSettings) { QJsonArray fields{}; for (int i = 0; i < static_cast(TorrentData::UpdateKey::Count); ++i) { const auto key = static_cast(i); if (key == TorrentData::UpdateKey::FileCount && !serverSettings->data().hasFileCountProperty()) { continue; } if (key == TorrentData::UpdateKey::Labels && !serverSettings->data().hasLabelsProperty()) { continue; } fields.push_back(updateKeyString(key)); } return fields; } std::optional Torrent::idFromJson(const QJsonObject& object) { const auto value = object.value(updateKeyString(TorrentData::UpdateKey::Id)); if (value.isDouble()) { return value.toInt(); } return {}; } std::optional Torrent::idKeyIndex(std::span> keys ) { return indexOfCasted(keys, TorrentData::UpdateKey::Id); } std::vector> Torrent::mapUpdateKeys(const QJsonArray& stringKeys) { return toContainer(stringKeys | std::views::transform([](auto value) { return mapUpdateKey(value.toString()); })); } void Torrent::setDownloadSpeedLimited(bool limited) { mData.downloadSpeedLimited = limited; mRpc->setTorrentProperty(mData.id, updateKeyString(TorrentData::UpdateKey::DownloadSpeedLimited), limited); } void Torrent::setDownloadSpeedLimit(int limit) { mData.downloadSpeedLimit = limit; mRpc->setTorrentProperty(mData.id, updateKeyString(TorrentData::UpdateKey::DownloadSpeedLimit), limit); } void Torrent::setUploadSpeedLimited(bool limited) { mData.uploadSpeedLimited = limited; mRpc->setTorrentProperty(mData.id, updateKeyString(TorrentData::UpdateKey::UploadSpeedLimited), limited); } void Torrent::setUploadSpeedLimit(int limit) { mData.uploadSpeedLimit = limit; mRpc->setTorrentProperty(mData.id, updateKeyString(TorrentData::UpdateKey::UploadSpeedLimit), limit); } void Torrent::setRatioLimitMode(TorrentData::RatioLimitMode mode) { mData.ratioLimitMode = mode; mRpc->setTorrentProperty( mData.id, updateKeyString(TorrentData::UpdateKey::RatioLimitMode), ratioLimitModeMapper.toJsonConstant(mode) ); } void Torrent::setRatioLimit(double limit) { mData.ratioLimit = limit; mRpc->setTorrentProperty(mData.id, updateKeyString(TorrentData::UpdateKey::RatioLimit), limit); } void Torrent::setPeersLimit(int limit) { mData.peersLimit = limit; mRpc->setTorrentProperty(mData.id, updateKeyString(TorrentData::UpdateKey::PeersLimit), limit); } void Torrent::setHonorSessionLimits(bool honor) { mData.honorSessionLimits = honor; mRpc->setTorrentProperty(mData.id, updateKeyString(TorrentData::UpdateKey::HonorSessionLimits), honor); } void Torrent::setBandwidthPriority(TorrentData::Priority priority) { mData.bandwidthPriority = priority; mRpc->setTorrentProperty( mData.id, updateKeyString(TorrentData::UpdateKey::BandwidthPriority), priorityMapper.toJsonConstant(priority) ); } void Torrent::setIdleSeedingLimitMode(TorrentData::IdleSeedingLimitMode mode) { mData.idleSeedingLimitMode = mode; mRpc->setTorrentProperty( mData.id, updateKeyString(TorrentData::UpdateKey::IdleSeedingLimitMode), idleSeedingLimitModeMapper.toJsonConstant(mode) ); } void Torrent::setIdleSeedingLimit(int limit) { mData.idleSeedingLimit = limit; mRpc->setTorrentProperty(mData.id, updateKeyString(TorrentData::UpdateKey::IdleSeedingLimit), limit); } namespace { std::vector> toTieredAnnounceUrls(std::span trackers) { std::map> tiered{}; for (const auto& tracker : trackers) { tiered[tracker.id()].insert(tracker.announce()); } return moveToContainer(std::views::values(tiered)); } QString toTrackerList(std::span> tieredAnnounceUrls) { QString trackerList{}; bool processedFirstTier{}; for (const auto& tier : tieredAnnounceUrls) { if (processedFirstTier) { trackerList += "\n\n"_l1; } for (const auto& announceUrl : tier) { trackerList += announceUrl; trackerList += '\n'; } processedFirstTier = true; } return trackerList; } bool isIntersect(const std::set& existingTier, const std::set& newTier) { return std::ranges::any_of(newTier, [&](const auto& announceUrl) { return existingTier.contains(announceUrl); }); } QJsonArray filterOutExistingTrackers( std::span> newTrackers, std::span existingTrackers ) { QJsonArray trackersToAdd{}; for (const auto& tier : newTrackers) { if (tier.empty()) continue; // Transmission adds each announce URL to each own tier when using trackerAdd property, so take first URL from each tier const auto& first = *tier.begin(); const auto existingTracker = std::ranges::find(existingTrackers, first, &Tracker::announce); if (existingTracker == existingTrackers.end()) { trackersToAdd.push_back(first); } } return trackersToAdd; } } namespace impl { std::vector> mergeTrackers( const std::vector>& existingTrackers, std::span> newTrackers ) { auto merged = existingTrackers; for (const auto& newTier : newTrackers) { if (newTier.empty()) continue; const auto existingTier = std::ranges::find_if(merged, [&](auto& tier) { return isIntersect(tier, newTier); }); if (existingTier != merged.end()) { existingTier->insert(newTier.begin(), newTier.end()); } else { merged.push_back(newTier); } } return merged; } } void Torrent::addTrackers(std::span> announceUrls) { if (mRpc->serverSettings()->data().hasTrackerListProperty()) { const auto existingTrackers = toTieredAnnounceUrls(mData.trackers); debug().log("Merging exisiting trackers {} with {}", existingTrackers, announceUrls); const auto merged = mergeTrackers(existingTrackers, announceUrls); const bool changed = merged != existingTrackers; debug().log("Result is {}, changed: {}", merged, changed); if (changed) { mRpc->setTorrentProperty(mData.id, trackerListKey, toTrackerList(merged), true); } } else { auto trackersToAdd = filterOutExistingTrackers(announceUrls, mData.trackers); if (!trackersToAdd.empty()) { mRpc->setTorrentProperty(mData.id, addTrackerKey, std::move(trackersToAdd), true); } } } void Torrent::setTracker(int trackerId, const QString& announce) { if (!mRpc->serverSettings()->data().hasTrackerListProperty()) { mRpc->setTorrentProperty(mData.id, replaceTrackerKey, QJsonArray{trackerId, announce}, true); return; } auto trackers = mData.trackers; const auto tracker = std::ranges::find(trackers, trackerId, &Tracker::id); if (tracker == trackers.end()) { warning().log("setTracker: did not find tracker with id {}", trackerId); return; } if (tracker->announce() == announce) { return; } tracker->replaceAnnounceUrl(announce); mRpc->setTorrentProperty(mData.id, trackerListKey, toTrackerList(toTieredAnnounceUrls(trackers)), true); } void Torrent::removeTrackers(std::span ids) { if (!mRpc->serverSettings()->data().hasTrackerListProperty()) { mRpc->setTorrentProperty(mData.id, removeTrackerKey, toJsonArray(ids), true); return; } auto trackers = mData.trackers; const auto erased = std::erase_if(trackers, [ids](const auto& tracker) { return std::ranges::find(ids, tracker.id()) != ids.end(); }); if (erased == 0) { return; } mRpc->setTorrentProperty(mData.id, trackerListKey, toTrackerList(toTieredAnnounceUrls(trackers)), true); } void Torrent::setFilesEnabled(bool enabled) { if (enabled != mFilesEnabled) { mFilesEnabled = enabled; if (mFilesEnabled) { mRpc->getTorrentFiles(mData.id); } else { mFiles.clear(); } } } void Torrent::setFilesWanted(std::span fileIds, bool wanted) { mRpc->setTorrentProperty(mData.id, wanted ? wantedFilesKey : unwantedFilesKey, toJsonArray(fileIds)); } void Torrent::setFilesPriority(std::span fileIds, TorrentFile::Priority priority) { QLatin1String propertyName; switch (priority) { case TorrentFile::Priority::Low: propertyName = lowPriorityKey; break; case TorrentFile::Priority::Normal: propertyName = normalPriorityKey; break; case TorrentFile::Priority::High: propertyName = highPriorityKey; break; } mRpc->setTorrentProperty(mData.id, propertyName, toJsonArray(fileIds)); } void Torrent::renameFile(const QString& path, const QString& newName) { mRpc->renameTorrentFile(mData.id, path, newName); } void Torrent::setPeersEnabled(bool enabled) { if (enabled != mPeersEnabled) { mPeersEnabled = enabled; if (mPeersEnabled) { mRpc->getTorrentPeers(mData.id); } else { mPeers.clear(); } } } bool Torrent::update(const QJsonObject& object) { const bool c = mData.update(object, false, mRpc); emit updated(); if (c) { emit changed(); } return c; } bool Torrent::update(std::span> keys, const QJsonArray& values) { const bool c = mData.update(keys, values, false, mRpc); emit updated(); if (c) { emit changed(); } return c; } void Torrent::updateFiles(const QJsonObject& torrentMap) { std::vector changed{}; const QJsonArray fileStats = torrentMap.value("fileStats"_l1).toArray(); if (!fileStats.isEmpty()) { if (mFiles.empty()) { const QJsonArray fileJsons = torrentMap.value("files"_l1).toArray(); if (fileJsons.size() == fileStats.size()) { const auto count = fileJsons.size(); mFiles.reserve(static_cast(count)); changed.reserve(static_cast(count)); for (QJsonArray::size_type i = 0; i < count; ++i) { mFiles.emplace_back(i, fileJsons[i].toObject(), fileStats[i].toObject()); changed.push_back(static_cast(i)); } } else { warning().log("fileStats and files arrays have different sizes for torrent {}", *this); } } else { if (static_cast(fileStats.size()) == mFiles.size()) { for (QJsonArray::size_type i = 0, max = fileStats.size(); i < max; ++i) { TorrentFile& file = mFiles[static_cast(i)]; if (file.update(fileStats[i].toObject())) { changed.push_back(static_cast(i)); } } } else { warning().log("fileStats array has different size than in previous update for torrent {}", *this); } } } emit filesUpdated(changed); } namespace { struct NewPeer { QJsonObject json; QString address; }; class PeersListUpdater final : public ItemListUpdater> { public: PeersListUpdater() = default; std::vector> removedIndexRanges{}; std::vector> changedIndexRanges{}; int addedCount{}; protected: std::vector::iterator findNewItemForItem(std::vector& newPeers, const Peer& peer) override { return std::ranges::find(newPeers, peer.address, &NewPeer::address); } void onAboutToRemoveItems(size_t, size_t) override {}; void onRemovedItems(size_t first, size_t last) override { removedIndexRanges.emplace_back(static_cast(first), static_cast(last)); } // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) bool updateItem(Peer& peer, NewPeer&& newPeer) override { const auto& [json, address] = newPeer; return peer.update(json); } void onChangedItems(size_t first, size_t last) override { changedIndexRanges.emplace_back(static_cast(first), static_cast(last)); } // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) Peer createItemFromNewItem(NewPeer&& newPeer) override { auto& [json, address] = newPeer; return Peer(std::move(address), json); } void onAboutToAddItems(size_t) override {} void onAddedItems(size_t count) override { addedCount = static_cast(count); }; }; } void Torrent::updatePeers(const QJsonObject& torrentMap) { std::vector newPeers; { const QJsonArray peers(torrentMap.value("peers"_l1).toArray()); newPeers.reserve(static_cast(peers.size())); for (const auto& i : peers) { QJsonObject json = i.toObject(); QString address(json.value(Peer::addressKey).toString()); newPeers.push_back(NewPeer{std::move(json), std::move(address)}); } } PeersListUpdater updater{}; updater.update(mPeers, std::move(newPeers)); emit peersUpdated(updater.removedIndexRanges, updater.changedIndexRanges, updater.addedCount); } void Torrent::checkSingleFile(const QJsonObject& torrentMap) { mData.singleFile = (torrentMap.value(prioritiesKey).toArray().size() == 1); } } namespace fmt { format_context::iterator formatter::format(const tremotesf::Torrent& torrent, format_context& ctx) const { return fmt::format_to(ctx.out(), "Torrent(id={}, name={})", torrent.data().id, torrent.data().name); } } tremotesf-2.8.2/src/rpc/torrent.h000066400000000000000000000170701500171105600167550ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_RPC_TORRENT_H #define TREMOTESF_RPC_TORRENT_H #include #include #include #include #include #include #include #include #include "log/formatters.h" #include "peer.h" #include "torrentfile.h" #include "tracker.h" class QJsonObject; namespace tremotesf { class Rpc; class ServerSettings; struct TorrentData { Q_GADGET public: enum class Status { Paused, QueuedForChecking, Checking, QueuedForDownloading, Downloading, QueuedForSeeding, Seeding }; Q_ENUM(Status) enum class Error { None, TrackerWarning, TrackerError, LocalError }; Q_ENUM(Error) enum class Priority { Low, Normal, High }; Q_ENUM(Priority) static int priorityToInt(Priority value); enum class RatioLimitMode { Global, Single, Unlimited }; Q_ENUM(RatioLimitMode) enum class IdleSeedingLimitMode { Global, Single, Unlimited }; Q_ENUM(IdleSeedingLimitMode) [[nodiscard]] bool update(const QJsonObject& object, bool firstTime, const Rpc* rpc); enum class UpdateKey; [[nodiscard]] bool update( std::span> keys, const QJsonArray& values, bool firstTime, const Rpc* rpc ); int id{}; QString hashString{}; QString name{}; QString magnetLink{}; Status status{}; Error error{}; QString errorString{}; int queuePosition{}; qint64 totalSize{}; qint64 completedSize{}; qint64 leftUntilDone{}; qint64 sizeWhenDone{}; double percentDone{}; double recheckProgress{}; int eta{}; bool metadataComplete{}; qint64 downloadSpeed{}; qint64 uploadSpeed{}; bool downloadSpeedLimited{}; int downloadSpeedLimit{}; // kB/s bool uploadSpeedLimited{}; int uploadSpeedLimit{}; // kB/s qint64 totalDownloaded{}; qint64 totalUploaded{}; double ratio{}; double ratioLimit{}; RatioLimitMode ratioLimitMode{}; int totalSeedersFromTrackersCount{}; int peersSendingToUsCount{}; std::vector webSeeders{}; int webSeedersSendingToUsCount{}; int totalLeechersFromTrackersCount{}; int peersGettingFromUsCount{}; int peersLimit{}; QDateTime addedDate{{}, {}, QTimeZone::utc()}; QDateTime activityDate{{}, {}, QTimeZone::utc()}; QDateTime doneDate{{}, {}, QTimeZone::utc()}; IdleSeedingLimitMode idleSeedingLimitMode{}; int idleSeedingLimit{}; QString downloadDirectory{}; QString comment{}; QString creator{}; QDateTime creationDate{{}, {}, QTimeZone::utc()}; Priority bandwidthPriority{}; bool honorSessionLimits; std::vector labels{}; bool singleFile = true; std::vector trackers{}; [[nodiscard]] bool hasError() const { return error != Error::None; } [[nodiscard]] bool isFinished() const { return leftUntilDone == 0; } [[nodiscard]] bool isDownloadingStalled() const { return (peersSendingToUsCount == 0 && webSeedersSendingToUsCount == 0); } [[nodiscard]] bool isSeedingStalled() const { return peersGettingFromUsCount == 0; } private: void updateProperty( TorrentData::UpdateKey key, const QJsonValue& value, bool& changed, bool firstTime, const Rpc* rpc ); void applyTrackerErrorWorkaround(bool& changed); }; class Torrent final : public QObject { Q_OBJECT public: explicit Torrent(int id, const QJsonObject& object, Rpc* rpc, QObject* parent = nullptr); explicit Torrent( int id, std::span> keys, const QJsonArray& values, Rpc* rpc, QObject* parent = nullptr ); // For testing only explicit Torrent() = default; [[nodiscard]] static QJsonArray updateFields(const ServerSettings* serverSettings); [[nodiscard]] static std::optional idFromJson(const QJsonObject& object); [[nodiscard]] static std::optional idKeyIndex(std::span> keys); [[nodiscard]] static std::vector> mapUpdateKeys(const QJsonArray& stringKeys); void setDownloadSpeedLimited(bool limited); void setDownloadSpeedLimit(int limit); void setUploadSpeedLimited(bool limited); void setUploadSpeedLimit(int limit); void setRatioLimitMode(TorrentData::RatioLimitMode mode); void setRatioLimit(double limit); void setPeersLimit(int limit); void setHonorSessionLimits(bool honor); void setBandwidthPriority(TorrentData::Priority priority); void setIdleSeedingLimitMode(TorrentData::IdleSeedingLimitMode mode); void setIdleSeedingLimit(int limit); void addTrackers(std::span> announceUrls); void setTracker(int trackerId, const QString& announce); void removeTrackers(std::span trackerIds); [[nodiscard]] const TorrentData& data() const { return mData; }; [[nodiscard]] bool isFilesEnabled() const { return mFilesEnabled; }; void setFilesEnabled(bool enabled); [[nodiscard]] const std::vector& files() const { return mFiles; }; void setFilesWanted(std::span fileIds, bool wanted); void setFilesPriority(std::span fileIds, TorrentFile::Priority priority); void renameFile(const QString& path, const QString& newName); [[nodiscard]] bool isPeersEnabled() const { return mPeersEnabled; }; void setPeersEnabled(bool enabled); [[nodiscard]] const std::vector& peers() const { return mPeers; }; [[nodiscard]] bool update(const QJsonObject& object); [[nodiscard]] bool update(std::span> keys, const QJsonArray& values); void updateFiles(const QJsonObject& torrentMap); void updatePeers(const QJsonObject& torrentMap); void checkSingleFile(const QJsonObject& torrentMap); private: Rpc* mRpc{}; TorrentData mData{}; std::vector mFiles{}; bool mFilesEnabled{}; std::vector mPeers{}; bool mPeersEnabled{}; signals: void updated(); void changed(); void filesUpdated(const std::vector& changedIndexes); void peersUpdated( const std::vector>& removedIndexRanges, const std::vector>& changedIndexRanges, int addedCount ); void fileRenamed(const QString& filePath, const QString& newName); }; namespace impl { std::vector> mergeTrackers( const std::vector>& existingTrackers, std::span> newTrackers ); } } namespace fmt { template<> struct formatter : tremotesf::SimpleFormatter { format_context::iterator format(const tremotesf::Torrent& torrent, format_context& ctx) const; }; } #endif // TREMOTESF_RPC_TORRENT_H tremotesf-2.8.2/src/rpc/torrentfile.cpp000066400000000000000000000026411500171105600201460ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "torrentfile.h" #include #include "jsonutils.h" #include "literals.h" #include "stdutils.h" namespace tremotesf { using namespace impl; namespace { constexpr auto priorityMapper = EnumMapper(std::array{ EnumMapping(TorrentFile::Priority::Low, -1), EnumMapping(TorrentFile::Priority::Normal, 0), EnumMapping(TorrentFile::Priority::High, 1) }); } TorrentFile::TorrentFile(int id, const QJsonObject& fileMap, const QJsonObject& fileStatsMap) : id(id), size(toInt64(fileMap.value("length"_l1))) { auto p = fileMap.value("name"_l1).toString().split(QLatin1Char('/'), Qt::SkipEmptyParts); path.reserve(static_cast(p.size())); for (QString& part : p) { path.push_back(std::move(part)); } update(fileStatsMap); } bool TorrentFile::update(const QJsonObject& fileStatsMap) { bool changed = false; setChanged(completedSize, toInt64(fileStatsMap.value("bytesCompleted"_l1)), changed); constexpr auto priorityKey = "priority"_l1; setChanged(priority, priorityMapper.fromJsonValue(fileStatsMap.value(priorityKey), priorityKey), changed); setChanged(wanted, fileStatsMap.value("wanted"_l1).toBool(), changed); return changed; } } tremotesf-2.8.2/src/rpc/torrentfile.h000066400000000000000000000014001500171105600176030ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_RPC_TORRENTFILE_H #define TREMOTESF_RPC_TORRENTFILE_H #include #include #include class QJsonObject; namespace tremotesf { struct TorrentFile { Q_GADGET public: enum class Priority { Low, Normal, High }; Q_ENUM(Priority) explicit TorrentFile(int id, const QJsonObject& fileMap, const QJsonObject& fileStatsMap); bool update(const QJsonObject& fileStatsMap); int id{}; std::vector path{}; qint64 size{}; qint64 completedSize{}; Priority priority{}; bool wanted{}; }; } #endif // TREMOTESF_RPC_TORRENTFILE_H tremotesf-2.8.2/src/rpc/tracker.cpp000066400000000000000000000112351500171105600172430ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "tracker.h" #include #include #include #ifndef TREMOTESF_REGISTRABLE_DOMAIN_QT # include #endif #include "jsonutils.h" #include "literals.h" #include "pragmamacros.h" #include "stdutils.h" namespace tremotesf { using namespace impl; namespace { constexpr auto statusMapper = EnumMapper(std::array{ EnumMapping(Tracker::Status::Inactive, 0), EnumMapping(Tracker::Status::WaitingForUpdate, 1), EnumMapping(Tracker::Status::QueuedForUpdate, 2), EnumMapping(Tracker::Status::Updating, 3) }); } Tracker::Tracker(int id, const QJsonObject& trackerMap) : mId(id) { update(trackerMap); } bool Tracker::update(const QJsonObject& trackerMap) { bool changed = false; QString announce(trackerMap.value("announce"_l1).toString()); if (announce != mAnnounce) { changed = true; mAnnounce = std::move(announce); mSite = registrableDomainFromUrl(QUrl(mAnnounce)); } setChanged(mTier, trackerMap.value("tier"_l1).toInt(), changed); const bool announceError = (!trackerMap.value("lastAnnounceSucceeded"_l1).toBool() && trackerMap.value("lastAnnounceTime"_l1).toInt() != 0); if (announceError) { setChanged(mErrorMessage, trackerMap.value("lastAnnounceResult"_l1).toString(), changed); } else { setChanged(mErrorMessage, {}, changed); } constexpr auto announceStateKey = "announceState"_l1; setChanged(mStatus, statusMapper.fromJsonValue(trackerMap.value(announceStateKey), announceStateKey), changed); setChanged(mPeers, trackerMap.value("lastAnnouncePeerCount"_l1).toInt(), changed); setChanged( mSeeders, [&] { if (auto seeders = trackerMap.value("seederCount"_l1).toInt(); seeders >= 0) { return seeders; } return 0; }(), changed ); setChanged( mLeechers, [&] { if (auto leechers = trackerMap.value("leecherCount"_l1).toInt(); leechers >= 0) { return leechers; } return 0; }(), changed ); updateDateTime(mNextUpdateTime, trackerMap.value("nextAnnounceTime"_l1), changed); return changed; } } #ifdef TREMOTESF_REGISTRABLE_DOMAIN_QT # if QT_VERSION_MAJOR >= 6 // Private Qt API bool qIsEffectiveTLD(QStringView domain); namespace { QString registrableDomainFromDomain(const QString& fullDomain, [[maybe_unused]] const QUrl& url) { QStringView domain = fullDomain; QStringView previousDomain = fullDomain; while (!domain.isEmpty()) { if (qIsEffectiveTLD(domain)) { return previousDomain.toString(); } const auto dotIndex = domain.indexOf('.'); if (dotIndex == -1) { break; } previousDomain = domain; domain = domain.sliced(dotIndex + 1); } return fullDomain; } } # else namespace { QString registrableDomainFromDomain(const QString& fullDomain, const QUrl& url) { SUPPRESS_DEPRECATED_WARNINGS_BEGIN const auto tld = url.topLevelDomain(); SUPPRESS_DEPRECATED_WARNINGS_END if (tld.isEmpty()) { return fullDomain; } const auto dotBeforeTldIndex = fullDomain.lastIndexOf(tld); if (dotBeforeTldIndex == -1) { return fullDomain; } const auto dotBeforeRegistrableIndex = fullDomain.lastIndexOf('.', dotBeforeTldIndex - 1); return fullDomain.mid(dotBeforeRegistrableIndex + 1); } } # endif #else namespace { QString registrableDomainFromDomain(const QString& fullDomain, [[maybe_unused]] const QUrl& url) { const auto fullDomainUtf8 = fullDomain.toUtf8(); const auto psl = psl_builtin(); if (!psl) { return fullDomain; } const auto registrable = psl_registrable_domain(psl, fullDomainUtf8); return registrable ? QString(registrable) : fullDomain; } } #endif QString tremotesf::impl::registrableDomainFromUrl(const QUrl& url) { auto host = url.host().toLower().normalized(QString::NormalizationForm_KC); if (host.isEmpty()) { return {}; } if (const bool isIpAddress = !QHostAddress(host).isNull(); isIpAddress) { return host; } return registrableDomainFromDomain(host, url); } tremotesf-2.8.2/src/rpc/tracker.h000066400000000000000000000036561500171105600167200ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_RPC_TRACKER_H #define TREMOTESF_RPC_TRACKER_H #include #include #include #include class QJsonObject; class QUrl; namespace tremotesf { class Tracker { Q_GADGET public: enum class Status { // Tracker is inactive, possibly due to error Inactive, // Waiting for announce/scrape WaitingForUpdate, // Queued for immediate announce/scrape QueuedForUpdate, // We are announcing/scraping Updating, }; Q_ENUM(Status) explicit Tracker(int id, const QJsonObject& trackerMap); int id() const { return mId; }; const QString& announce() const { return mAnnounce; }; const QString& site() const { return mSite; }; int tier() const { return mTier; } Status status() const { return mStatus; }; const QString& errorMessage() const { return mErrorMessage; }; int peers() const { return mPeers; }; int seeders() const { return mSeeders; } int leechers() const { return mLeechers; } const QDateTime& nextUpdateTime() const { return mNextUpdateTime; }; bool update(const QJsonObject& trackerMap); void replaceAnnounceUrl(const QString& announceUrl) { mAnnounce = announceUrl; } bool operator==(const Tracker& other) const = default; private: QString mAnnounce{}; QString mSite{}; int mTier{}; Status mStatus{}; QString mErrorMessage{}; QDateTime mNextUpdateTime{{}, {}, QTimeZone::utc()}; int mPeers{}; int mSeeders{}; int mLeechers{}; int mId{}; }; namespace impl { QString registrableDomainFromUrl(const QUrl& url); } } #endif // TREMOTESF_RPC_TRACKER_H tremotesf-2.8.2/src/rpc/tracker_test.cpp000066400000000000000000000057201500171105600203040ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include "tracker.h" #include "torrent.h" using namespace tremotesf::impl; class TrackerTest final : public QObject { Q_OBJECT private slots: void registrableDomainTest() { QCOMPARE(registrableDomainFromUrl(QUrl("https://doc.qt.io")), QString("qt.io")); QCOMPARE(registrableDomainFromUrl(QUrl("https://github.com")), QString("github.com")); QCOMPARE(registrableDomainFromUrl(QUrl("https://www.bbc.co.uk/")), QString("bbc.co.uk")); QCOMPARE(registrableDomainFromUrl(QUrl("https://en.wikipedia.org/wiki/Main_Page")), QString("wikipedia.org")); QCOMPARE(registrableDomainFromUrl(QUrl("https://forgot.his.name")), QString("forgot.his.name")); } void registrableDomainIpTest() { constexpr std::array ips{"127.0.0.1", "2001:0db8:0000:0000:0000:ff00:0042:8329"}; for (const auto& ip : ips) { const QHostAddress expectedIp(ip); QUrl url{}; url.setScheme("https"); if (expectedIp.protocol() == QAbstractSocket::IPv6Protocol) { url.setHost(fmt::format("[{}]", ip).c_str()); } else { url.setHost(ip); } const auto actualIpString = registrableDomainFromUrl(url); const QHostAddress actualIp(actualIpString); QCOMPARE(actualIp, expectedIp); } } void registrableDomainUnknownTest() { QCOMPARE(registrableDomainFromUrl(QUrl("https://foobar")), QString("foobar")); QCOMPARE(registrableDomainFromUrl(QUrl("https://")), QString()); } void mergingTackersCompletelyNewTest() { const auto existingTrackers = std::vector{std::set{QString("foo")}, std::set{QString("bar")}}; const auto newTrackers = std::vector{std::set{QString("lol")}, std::set{QString("nope")}}; const auto expectedResult = std::vector{ std::set{QString("foo")}, std::set{QString("bar")}, std::set{QString("lol")}, std::set{QString("nope")} }; const auto merged = mergeTrackers(existingTrackers, newTrackers); QCOMPARE(merged, expectedResult); } void mergingTrackersAddingToExistingTierTest() { const auto existingTrackers = std::vector{std::set{QString("foo")}, std::set{QString("bar")}}; const auto newTrackers = std::vector{std::set{QString("foo"), QString("foo.alt1"), QString("foo.alt2")}, std::set{QString("nope")}}; const auto expectedResult = std::vector{ std::set{QString("foo"), QString("foo.alt1"), QString("foo.alt2")}, std::set{QString("bar")}, std::set{QString("nope")} }; const auto merged = mergeTrackers(existingTrackers, newTrackers); QCOMPARE(merged, expectedResult); } }; QTEST_GUILESS_MAIN(TrackerTest) #include "tracker_test.moc" tremotesf-2.8.2/src/settings.cpp000066400000000000000000000216221500171105600166650ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // SPDX-FileCopyrightText: 2021 LuK1337 // SPDX-FileCopyrightText: 2022 Alex // // SPDX-License-Identifier: GPL-3.0-or-later #include "settings.h" #include #include #include #if QT_VERSION_MAJOR < 6 # include #endif #include "log/log.h" #include "literals.h" #include "target_os.h" #define SETTINGS_PROPERTY_DEF(type, name, key, defaultValue) \ const QVariant& name##_defaultValue() { \ static const auto v = QVariant::fromValue(defaultValue); \ return v; \ } \ type Settings::get_##name() const { return getValue(mSettings, key##_l1, name##_defaultValue()); } \ void Settings::set_##name(type value) { \ if (setValue(mSettings, key##_l1, std::move(value), name##_defaultValue())) { \ emit name##Changed(); \ } \ } namespace tremotesf { namespace { template T getValue(QSettings* settings, QLatin1String key, const QVariant& defaultValue) { T value = settings->value(key, defaultValue).value(); if constexpr (std::is_enum_v) { const auto meta = QMetaEnum::fromType(); const auto named = #if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) meta.valueToKey(static_cast(value)); #else meta.valueToKey(static_cast(value)); #endif if (!named) { warning().log("Settings: key {} has invalid value {}, returning default value", key, value); return defaultValue.value(); } } return value; } template bool setValue(QSettings* settings, QLatin1String key, T newValue, const QVariant& defaultValue) { const auto currentValue = getValue(settings, key, defaultValue); if (newValue != currentValue) { settings->setValue(key, QVariant::fromValue(newValue)); return true; } return false; } } Settings* Settings::instance() { static auto* const instance = new Settings(qApp); return instance; } SETTINGS_PROPERTY_DEF(bool, connectOnStartup, "connectOnStartup", true) SETTINGS_PROPERTY_DEF(bool, notificationOnDisconnecting, "notificationOnDisconnecting", true) SETTINGS_PROPERTY_DEF(bool, notificationOnAddingTorrent, "notificationOnAddingTorrent", true) SETTINGS_PROPERTY_DEF(bool, notificationOfFinishedTorrents, "notificationOfFinishedTorrents", true) SETTINGS_PROPERTY_DEF( bool, notificationsOnAddedTorrentsSinceLastConnection, "notificationsOnAddedTorrentsSinceLastConnection", false ) SETTINGS_PROPERTY_DEF( bool, notificationsOnFinishedTorrentsSinceLastConnection, "notificationsOnFinishedTorrentsSinceLastConnection", false ) SETTINGS_PROPERTY_DEF(bool, rememberOpenTorrentDir, "rememberOpenTorrentTorrentDir", true) SETTINGS_PROPERTY_DEF(QString, lastOpenTorrentDirectory, "lastOpenTorrentDirectory", {}) SETTINGS_PROPERTY_DEF(bool, rememberAddTorrentParameters, "rememberAddTorrentParameters", true) SETTINGS_PROPERTY_DEF( TorrentData::Priority, lastAddTorrentPriority, "lastAddTorrentPriority", TorrentData::Priority::Normal ) SETTINGS_PROPERTY_DEF(bool, lastAddTorrentStartAfterAdding, "lastAddTorrentStartAfterAdding", true) SETTINGS_PROPERTY_DEF(bool, lastAddTorrentDeleteTorrentFile, "lastAddTorrentDeleteTorrentFile", false) SETTINGS_PROPERTY_DEF(bool, lastAddTorrentMoveTorrentFileToTrash, "lastAddTorrentMoveTorrentFileToTrash", true) SETTINGS_PROPERTY_DEF(bool, fillTorrentLinkFromClipboard, "fillTorrentLinkFromClipboard", false) SETTINGS_PROPERTY_DEF(bool, showMainWindowWhenAddingTorrent, "showMainWindowWhenAddingTorrent", true) SETTINGS_PROPERTY_DEF(bool, showAddTorrentDialog, "showAddTorrentDialog", true) SETTINGS_PROPERTY_DEF(bool, torrentsStatusFilterEnabled, "torrentsStatusFilterEnabled", true) SETTINGS_PROPERTY_DEF(bool, mergeTrackersWhenAddingExistingTorrent, "mergeTrackersWhenAddingExistingTorrent", false) SETTINGS_PROPERTY_DEF( bool, askForMergingTrackersWhenAddingExistingTorrent, "askForMergingTrackersWhenAddingExistingTorrent", true ) SETTINGS_PROPERTY_DEF( TorrentsProxyModel::StatusFilter, torrentsStatusFilter, "torrentsStatusFilter", TorrentsProxyModel::StatusFilter::All ) SETTINGS_PROPERTY_DEF(bool, torrentsLabelFilterEnabled, "torrentsLabelFilterEnabled", true) SETTINGS_PROPERTY_DEF(QString, torrentsLabelFilter, "torrentsLabelFilter", {}) SETTINGS_PROPERTY_DEF(bool, torrentsTrackerFilterEnabled, "torrentsTrackerFilterEnabled", true) SETTINGS_PROPERTY_DEF(QString, torrentsTrackerFilter, "torrentsTrackerFilter", {}) SETTINGS_PROPERTY_DEF(bool, torrentsDownloadDirectoryFilterEnabled, "torrentsDownloadDirectoryFilterEnabled", true) SETTINGS_PROPERTY_DEF(QString, torrentsDownloadDirectoryFilter, "torrentsDownloadDirectoryFilter", {}) SETTINGS_PROPERTY_DEF(bool, showTrayIcon, "showTrayIcon", true) SETTINGS_PROPERTY_DEF(Qt::ToolButtonStyle, toolButtonStyle, "toolButtonStyle", Qt::ToolButtonFollowStyle) SETTINGS_PROPERTY_DEF(bool, toolBarLocked, "toolBarLocked", true) SETTINGS_PROPERTY_DEF(bool, sideBarVisible, "sideBarVisible", true) SETTINGS_PROPERTY_DEF(bool, statusBarVisible, "statusBarVisible", true) SETTINGS_PROPERTY_DEF(bool, showTorrentPropertiesInMainWindow, "showTorrentPropertiesInMainWindow", false) SETTINGS_PROPERTY_DEF(QByteArray, mainWindowGeometry, "mainWindowGeometry", {}) SETTINGS_PROPERTY_DEF(QByteArray, mainWindowState, "mainWindowState", {}) SETTINGS_PROPERTY_DEF(QByteArray, horizontalSplitterState, "splitterState", {}) SETTINGS_PROPERTY_DEF(QByteArray, verticalSplitterState, "verticalSplitterState", {}) SETTINGS_PROPERTY_DEF(QByteArray, torrentsViewHeaderState, "torrentsViewHeaderState", {}) SETTINGS_PROPERTY_DEF(QByteArray, torrentPropertiesDialogGeometry, "torrentPropertiesDialogGeometry", {}) SETTINGS_PROPERTY_DEF(QByteArray, torrentFilesViewHeaderState, "torrentFilesViewHeaderState", {}) SETTINGS_PROPERTY_DEF(QByteArray, trackersViewHeaderState, "trackersViewHeaderState", {}) SETTINGS_PROPERTY_DEF(QByteArray, peersViewHeaderState, "peersViewHeaderState", {}) SETTINGS_PROPERTY_DEF(QByteArray, localTorrentFilesViewHeaderState, "localTorrentFilesViewHeaderState", {}) SETTINGS_PROPERTY_DEF( Settings::DarkThemeMode, darkThemeMode, "darkThemeMode", Settings::DarkThemeMode::FollowSystem ) SETTINGS_PROPERTY_DEF(bool, useSystemAccentColor, "useSystemAccentColor", true) SETTINGS_PROPERTY_DEF( Settings::TorrentDoubleClickAction, torrentDoubleClickAction, "torrentDoubleClickAction", Settings::TorrentDoubleClickAction::OpenPropertiesDialog ) SETTINGS_PROPERTY_DEF(bool, displayRelativeTime, "displayRelativeTime", false) SETTINGS_PROPERTY_DEF(bool, displayFullDownloadDirectoryPath, "displayFullDownloadDirectoryPath", true) Settings::Settings(QObject* parent) : QObject(parent) { if constexpr (targetOs == TargetOs::Windows) { mSettings = new QSettings( QSettings::IniFormat, QSettings::UserScope, qApp->organizationName(), qApp->applicationName(), this ); } else { mSettings = new QSettings(this); } mSettings->setFallbacksEnabled(false); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); #if QT_VERSION_MAJOR < 6 qRegisterMetaTypeStreamOperators(); qRegisterMetaTypeStreamOperators(); qRegisterMetaTypeStreamOperators(); qRegisterMetaTypeStreamOperators(); qRegisterMetaTypeStreamOperators(); #endif } void Settings::sync() { mSettings->sync(); } } tremotesf-2.8.2/src/settings.h000066400000000000000000000101071500171105600163260ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // SPDX-FileCopyrightText: 2021 LuK1337 // SPDX-FileCopyrightText: 2022 Alex // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_SETTINGS_H #define TREMOTESF_SETTINGS_H #include #include "rpc/torrent.h" #include "ui/screens/mainwindow/torrentsproxymodel.h" class QSettings; #define SETTINGS_PROPERTY(type, name) \ public: \ type get_##name() const; \ void set_##name(type value); \ Q_SIGNALS: \ void name##Changed(); namespace tremotesf { class Settings final : public QObject { Q_OBJECT SETTINGS_PROPERTY(bool, connectOnStartup) SETTINGS_PROPERTY(bool, notificationOnDisconnecting) SETTINGS_PROPERTY(bool, notificationOnAddingTorrent) SETTINGS_PROPERTY(bool, notificationOfFinishedTorrents) SETTINGS_PROPERTY(bool, notificationsOnAddedTorrentsSinceLastConnection) SETTINGS_PROPERTY(bool, notificationsOnFinishedTorrentsSinceLastConnection) SETTINGS_PROPERTY(bool, rememberOpenTorrentDir) SETTINGS_PROPERTY(QString, lastOpenTorrentDirectory) SETTINGS_PROPERTY(bool, rememberAddTorrentParameters) SETTINGS_PROPERTY(TorrentData::Priority, lastAddTorrentPriority) SETTINGS_PROPERTY(bool, lastAddTorrentStartAfterAdding) SETTINGS_PROPERTY(bool, lastAddTorrentDeleteTorrentFile) SETTINGS_PROPERTY(bool, lastAddTorrentMoveTorrentFileToTrash) SETTINGS_PROPERTY(bool, fillTorrentLinkFromClipboard) SETTINGS_PROPERTY(bool, showMainWindowWhenAddingTorrent) SETTINGS_PROPERTY(bool, showAddTorrentDialog) SETTINGS_PROPERTY(bool, mergeTrackersWhenAddingExistingTorrent) SETTINGS_PROPERTY(bool, askForMergingTrackersWhenAddingExistingTorrent) SETTINGS_PROPERTY(bool, torrentsStatusFilterEnabled) SETTINGS_PROPERTY(TorrentsProxyModel::StatusFilter, torrentsStatusFilter) SETTINGS_PROPERTY(bool, torrentsLabelFilterEnabled) SETTINGS_PROPERTY(QString, torrentsLabelFilter) SETTINGS_PROPERTY(bool, torrentsTrackerFilterEnabled) SETTINGS_PROPERTY(QString, torrentsTrackerFilter) SETTINGS_PROPERTY(bool, torrentsDownloadDirectoryFilterEnabled) SETTINGS_PROPERTY(QString, torrentsDownloadDirectoryFilter) SETTINGS_PROPERTY(bool, showTrayIcon) SETTINGS_PROPERTY(Qt::ToolButtonStyle, toolButtonStyle) SETTINGS_PROPERTY(bool, toolBarLocked) SETTINGS_PROPERTY(bool, sideBarVisible) SETTINGS_PROPERTY(bool, statusBarVisible) SETTINGS_PROPERTY(bool, showTorrentPropertiesInMainWindow) SETTINGS_PROPERTY(QByteArray, mainWindowGeometry) SETTINGS_PROPERTY(QByteArray, mainWindowState) SETTINGS_PROPERTY(QByteArray, horizontalSplitterState) SETTINGS_PROPERTY(QByteArray, verticalSplitterState) SETTINGS_PROPERTY(QByteArray, torrentsViewHeaderState) SETTINGS_PROPERTY(QByteArray, torrentPropertiesDialogGeometry) SETTINGS_PROPERTY(QByteArray, torrentFilesViewHeaderState) SETTINGS_PROPERTY(QByteArray, trackersViewHeaderState) SETTINGS_PROPERTY(QByteArray, peersViewHeaderState) SETTINGS_PROPERTY(QByteArray, localTorrentFilesViewHeaderState) SETTINGS_PROPERTY(bool, displayRelativeTime) SETTINGS_PROPERTY(bool, displayFullDownloadDirectoryPath) public: enum class DarkThemeMode { FollowSystem, On, Off }; Q_ENUM(DarkThemeMode) enum class TorrentDoubleClickAction { OpenPropertiesDialog, OpenTorrentFile, OpenDownloadDirectory }; Q_ENUM(TorrentDoubleClickAction) SETTINGS_PROPERTY(Settings::DarkThemeMode, darkThemeMode) SETTINGS_PROPERTY(bool, useSystemAccentColor) SETTINGS_PROPERTY(Settings::TorrentDoubleClickAction, torrentDoubleClickAction) public: static Settings* instance(); void sync(); private: explicit Settings(QObject* parent = nullptr); QSettings* mSettings{}; }; } #endif // TREMOTESF_SETTINGS_H tremotesf-2.8.2/src/startup/000077500000000000000000000000001500171105600160205ustar00rootroot00000000000000tremotesf-2.8.2/src/startup/commandlineparser.cpp000066400000000000000000000071431500171105600222340ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "commandlineparser.h" #include #include #include #include #include #include #include #define CXXOPTS_VECTOR_DELIMITER '\0' #include #include "log/log.h" #include "target_os.h" namespace tremotesf { namespace { std::optional substrAfterChar(std::string_view str, char ch) { const auto index = str.rfind(ch); if (index == std::string_view::npos) { return std::nullopt; } return str.substr(index + 1); } std::string_view executableFileName(std::string_view arg0) { if constexpr (targetOs == TargetOs::Windows) { if (const auto name = substrAfterChar(arg0, '\\'); name) { return *name; } } if (const auto name = substrAfterChar(arg0, '/'); name) { return *name; } return arg0; } void parsePositionals(std::span torrents, CommandLineArgs& args) { for (const std::string& arg : torrents) { if (!arg.empty()) { const auto argument(QString::fromStdString(arg)); const QFileInfo info(argument); if (info.isFile()) { args.files.push_back(info.absoluteFilePath()); } else { const QUrl url(argument); if (url.isLocalFile()) { const auto path = url.toLocalFile(); if (QFileInfo(path).isFile()) { args.files.push_back(path); } } else { args.urls.push_back(argument); } } } } } } CommandLineArgs parseCommandLine(int& argc, char**& argv) { CommandLineArgs args{}; // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) const std::string_view appName = executableFileName(argv[0]); const auto versionString = fmt::format("{} {}", appName, TREMOTESF_VERSION); cxxopts::Options opts(std::string(appName), versionString); std::vector torrents; opts.add_options( )("v,version", "display version information", cxxopts::value()->default_value("false") )("h,help", "display this help", cxxopts::value()->default_value("false") )("m,minimized", "start minimized in notification area", cxxopts::value(args.minimized)->default_value("false") )("d,debug-logs", "enable debug logs", cxxopts::value(args.enableDebugLogs)->implicit_value("true") )("torrents", "", cxxopts::value(torrents)); opts.parse_positional("torrents"); opts.positional_help("torrents"); try { const auto result(opts.parse(argc, argv)); if (result["help"].as()) { printlnStdout(opts.help()); args.exit = true; return args; } if (result["version"].as()) { printlnStdout(versionString); args.exit = true; return args; } parsePositionals(torrents, args); } catch (const std::exception& e) { throw std::runtime_error(e.what()); } return args; } } tremotesf-2.8.2/src/startup/commandlineparser.h000066400000000000000000000010321500171105600216700ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_COMMANDLINEPARSER_H #define TREMOTESF_COMMANDLINEPARSER_H #include #include namespace tremotesf { struct CommandLineArgs { QStringList files{}; QStringList urls{}; bool minimized{}; std::optional enableDebugLogs{}; bool exit{}; }; CommandLineArgs parseCommandLine(int& argc, char**& argv); } #endif // TREMOTESF_COMMANDLINEPARSER_H tremotesf-2.8.2/src/startup/main.cpp000066400000000000000000000167471500171105600174670ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include #include #include #include #include "commandlineparser.h" #include "literals.h" #include "signalhandler.h" #include "target_os.h" #include "ipc/ipcclient.h" #include "log/log.h" #include "ui/iconthemesetup.h" #include "ui/savewindowstatedispatcher.h" #include "ui/screens/mainwindow/mainwindow.h" #ifdef Q_OS_WIN # include "main_windows.h" # include "windowsfatalerrorhandlers.h" #endif #ifdef Q_OS_MACOS # include # include # include "ipc/fileopeneventhandler.h" using namespace std::chrono_literals; #endif #ifdef TREMOTESF_USE_BUNDLED_QT_TRANSLATIONS # include "fileutils.h" #endif SPECIALIZE_FORMATTER_FOR_QDEBUG(QLocale) using namespace tremotesf; namespace { #ifdef Q_OS_MACOS std::pair receiveFileOpenEvents() { std::pair filesAndUrls{}; info().log("Waiting for file open events"); const FileOpenEventHandler handler{}; QObject::connect( &handler, &FileOpenEventHandler::filesOpeningRequested, qApp, [&](const auto& files, const auto& urls) { filesAndUrls = {files, urls}; QCoreApplication::quit(); } ); QTimer::singleShot(500ms, qApp, [] { info().log("Did not receive file open events"); QCoreApplication::quit(); }); QCoreApplication::exec(); return filesAndUrls; } #endif bool shouldExitBecauseAnotherInstanceIsRunning(const CommandLineArgs& args) { const auto client = IpcClient::createInstance(); if (!client->isConnected()) { return false; } info().log("Only one instance of Tremotesf can be run at the same time"); const auto activateOtherInstance = [&client](const QStringList& files, const QStringList& urls) { if (files.isEmpty() && urls.isEmpty()) { info().log("Activating other instance"); client->activateWindow(); } else { info().log("Activating other instance and requesting torrent adding"); info().log("files = {}", files); info().log("urls = {}", urls); client->addTorrents(files, urls); } }; #ifdef Q_OS_MACOS if (args.files.isEmpty() && args.urls.isEmpty()) { const auto [files, urls] = receiveFileOpenEvents(); activateOtherInstance(files, urls); return true; } #endif activateOtherInstance(args.files, args.urls); return true; } void logLocaleInfo() { if (!tremotesfLoggingCategory().isDebugEnabled()) { return; } QLocale locale{}; debug().log("Current locale is: {}", locale.name()); debug().log("Language: {}", locale.language()); debug().log("Script: {}", locale.script()); #if QT_VERSION_MAJOR >= 6 debug().log("Territory: {}", locale.territory()); #endif debug().log("UI languages: {}", locale.uiLanguages()); } bool loadTranslation(QTranslator& translator, const QString& filename, const QString& prefix, const QString& directory) { // https://bugreports.qt.io/browse/QTBUG-129434 static const bool applyWorkaround = [] { const bool apply = (QLibraryInfo::version() == QVersionNumber(6, 7, 3)); debug().log("Applying QTranslator workaround for Qt 6.7.3"); return apply; }(); QLocale locale{}; if (applyWorkaround) { QString actualFilename = filename + prefix + locale.name(); return translator.load(actualFilename, directory); } else { return translator.load(locale, filename, prefix, directory); } } } int main(int argc, char** argv) { // This does not need QApplication instance, and we need it in windowsInitPrelude() QCoreApplication::setOrganizationName(TREMOTESF_EXECUTABLE_NAME ""_l1); QCoreApplication::setApplicationName(QCoreApplication::organizationName()); QCoreApplication::setApplicationVersion(TREMOTESF_VERSION ""_l1); // // Command line parsing // CommandLineArgs args{}; try { args = parseCommandLine(argc, argv); if (args.exit) { return EXIT_SUCCESS; } } catch (const std::runtime_error& e) { warning().log("Failed to parse command line arguments: {}", e.what()); return EXIT_FAILURE; } if (args.enableDebugLogs.has_value()) { overrideDebugLogs(*args.enableDebugLogs); } #ifdef Q_OS_WIN windowsSetUpFatalErrorHandlers(); const WindowsLogger logger{}; #endif if (tremotesfLoggingCategory().isDebugEnabled()) { debug().log("Debug logging is enabled"); } // Setup handler for UNIX signals or Windows console handler const SignalHandler signalHandler{}; #ifdef Q_OS_WIN const WinrtApartment apartment{}; #endif // // QApplication initialization // #if QT_VERSION_MAJOR < 6 QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps); #endif const QApplication app(argc, argv); if (shouldExitBecauseAnotherInstanceIsRunning(args)) { return EXIT_SUCCESS; } QGuiApplication::setQuitOnLastWindowClosed(false); // Workaround for application quitting when creating QFileDialog in KDE // https://bugs.kde.org/show_bug.cgi?id=471941 // https://bugs.kde.org/show_bug.cgi?id=483439 QCoreApplication::setQuitLockEnabled(false); #ifdef Q_OS_WIN windowsInitApplication(); #endif setupIconTheme(); QGuiApplication::setDesktopFileName(TREMOTESF_APP_ID ""_l1); QGuiApplication::setWindowIcon(QIcon::fromTheme(TREMOTESF_APP_ID ""_l1)); // // End of QApplication initialization // logLocaleInfo(); QTranslator qtTranslator; { const QString qtTranslationsPath = #ifdef TREMOTESF_USE_BUNDLED_QT_TRANSLATIONS resolveExternalBundledResourcesPath("qt-translations"_l1); #else # if QT_VERSION_MAJOR >= 6 QLibraryInfo::path( # else QLibraryInfo::location( # endif QLibraryInfo::TranslationsPath ); #endif if (loadTranslation(qtTranslator, "qt"_l1, "_"_l1, qtTranslationsPath)) { info().log("Loaded Qt translation {}", qtTranslator.filePath()); qApp->installTranslator(&qtTranslator); } else { warning().log("Failed to load Qt translation for {} from {}", QLocale(), qtTranslationsPath); } } QTranslator appTranslator; if (loadTranslation(appTranslator, {}, {}, ":/translations/"_l1)) { info().log("Loaded Tremotesf translation {}", appTranslator.filePath()); qApp->installTranslator(&appTranslator); } else { warning().log("Failed to load Tremotesf translation for {}", QLocale{}); } const SaveWindowStateDispatcher saveStateDispatcher{}; MainWindow window(std::move(args.files), std::move(args.urls)); window.initialShow(args.minimized); if (signalHandler.isExitRequested()) { return EXIT_SUCCESS; } const int exitStatus = QCoreApplication::exec(); debug().log("Returning from main with exit status {}", exitStatus); return exitStatus; } tremotesf-2.8.2/src/startup/main_windows.cpp000066400000000000000000000023641500171105600212270ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include "log/log.h" #include "startup/windowsmessagehandler.h" #include "ui/darkthemeapplier_windows.h" #include "ui/systemcolorsprovider.h" #include "main_windows.h" #include "windowshelpers.h" namespace tremotesf { WindowsLogger::WindowsLogger() { initWindowsMessageHandler(); } WindowsLogger::~WindowsLogger() { deinitWindowsMessageHandler(); } WinrtApartment::WinrtApartment() { try { winrt::init_apartment(winrt::apartment_type::single_threaded); } catch (const winrt::hresult_error& e) { warning().log("winrt::init_apartment failed: {}", e); } } WinrtApartment::~WinrtApartment() { winrt::uninit_apartment(); } void windowsInitApplication() { try { checkWin32Bool(AllowSetForegroundWindow(ASFW_ANY), "AllowSetForegroundWindow"); } catch (const std::system_error& e) { warning().log(e); } const auto systemColorsProvider = SystemColorsProvider::createInstance(QCoreApplication::instance()); applyDarkThemeToPalette(systemColorsProvider); } } tremotesf-2.8.2/src/startup/main_windows.h000066400000000000000000000007271500171105600206750ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTEST_MAIN_WINDOWS_H #define TREMOTEST_MAIN_WINDOWS_H namespace tremotesf { class WindowsLogger final { public: WindowsLogger(); ~WindowsLogger(); }; class WinrtApartment final { public: WinrtApartment(); ~WinrtApartment(); }; void windowsInitApplication(); } #endif // TREMOTEST_MAIN_WINDOWS_H tremotesf-2.8.2/src/startup/recoloringsvgiconengineplugin.cpp000066400000000000000000000050751500171105600246740ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include "literals.h" #include "recoloringsvgiconengineplugin.h" #include "target_os.h" #include "ui/recoloringsvgiconengine.h" namespace tremotesf { namespace { thread_local bool drawingSelectedMenuItem = false; constexpr auto overrideStyleEnvVariable = "QT_STYLE_OVERRIDE"; QString defaultStyle() { if (qEnvironmentVariableIsSet(overrideStyleEnvVariable)) { return qEnvironmentVariable(overrideStyleEnvVariable); } if constexpr (targetOs == TargetOs::UnixMacOS) { return "macOS"_l1; } return "fusion"_l1; } } RecoloringSvgIconStyle::RecoloringSvgIconStyle(QObject* parent) : QProxyStyle(defaultStyle()) { setParent(parent); } void RecoloringSvgIconStyle::drawControl( QStyle::ControlElement element, const QStyleOption* option, QPainter* painter, const QWidget* widget ) const { if (const auto mi = qstyleoption_cast(option)) { if (mi->state & State_Selected) { drawingSelectedMenuItem = true; } } QProxyStyle::drawControl(element, option, painter, widget); if (drawingSelectedMenuItem) { drawingSelectedMenuItem = false; } } class EngineForStyle final : public RecoloringSvgIconEngine { public: QPixmap scaledPixmap(const QSize& size, QIcon::Mode mode, QIcon::State state, qreal scale) override { // QFusionStyle passes QIcon::Active for selected menu items, but RecoloringSvgIconEngine/KIconEngine expects QIcon::Selected if (drawingSelectedMenuItem) { mode = QIcon::Selected; } return RecoloringSvgIconEngine::scaledPixmap(size, mode, state, scale); } }; class RecoloringSvgIconEnginePlugin final : public QIconEnginePlugin { Q_OBJECT Q_PLUGIN_METADATA(IID "org.qt-project.Qt.QIconEngineFactoryInterface" FILE "recoloringsvgiconengineplugin.json") public: QIconEngine* create(const QString& file) override { auto engine = new EngineForStyle{}; if (!file.isNull()) { engine->addFile(file, QSize(), QIcon::Normal, QIcon::Off); } return engine; } }; } Q_IMPORT_PLUGIN(RecoloringSvgIconEnginePlugin) #include "recoloringsvgiconengineplugin.moc" tremotesf-2.8.2/src/startup/recoloringsvgiconengineplugin.h000066400000000000000000000011261500171105600243320ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_RECOLORINGSVGICONENGINEPLUGIN_H #define TREMOTESF_RECOLORINGSVGICONENGINEPLUGIN_H #include namespace tremotesf { class RecoloringSvgIconStyle : public QProxyStyle { Q_OBJECT public: explicit RecoloringSvgIconStyle(QObject* parent); void drawControl(ControlElement element, const QStyleOption* option, QPainter* painter, const QWidget* widget) const override; }; } #endif //TREMOTESF_RECOLORINGSVGICONENGINEPLUGIN_H tremotesf-2.8.2/src/startup/recoloringsvgiconengineplugin.json000066400000000000000000000000451500171105600250530ustar00rootroot00000000000000{"Keys": [ "svg", "svgz", "svg.gz" ]}tremotesf-2.8.2/src/startup/signalhandler.h000066400000000000000000000011521500171105600210030ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_SIGNALHANDLER_H #define TREMOTESF_SIGNALHANDLER_H #include #if __has_include() # include #else # include #endif namespace tremotesf { class SignalHandler final { public: SignalHandler(); ~SignalHandler(); Q_DISABLE_COPY_MOVE(SignalHandler) bool isExitRequested() const; private: class Impl; std::unique_ptr mImpl; }; } #endif // TREMOTESF_SIGNALHANDLER_H tremotesf-2.8.2/src/startup/signalhandler_unix.cpp000066400000000000000000000142121500171105600224020ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "signalhandler.h" #include #include #include #include #include #include #include #include #include #include #include #include #include "log/log.h" #include "unixhelpers.h" using namespace std::string_view_literals; namespace tremotesf { namespace { constexpr std::array expectedSignals{ std::pair{SIGINT, "SIGINT"sv}, std::pair{SIGTERM, "SIGTERM"sv}, std::pair{SIGHUP, "SIGHUP"sv}, std::pair{SIGQUIT, "SIGQUIT"sv} }; std::optional signalName(int signal) { for (auto [expectedSignal, name] : expectedSignals) { if (signal == expectedSignal) { return name; } } return std::nullopt; } int writeSocket{}; // Not using std::atomic> because Clang might require linking to libatomic constexpr int notReceivedSignal = std::numeric_limits::min(); std::atomic_int receivedSignal{notReceivedSignal}; static_assert(std::atomic_int::is_always_lock_free, "std::atomic_int must be lock-free"); void signalHandler(int signal) { int expected = notReceivedSignal; if (!receivedSignal.compare_exchange_strong(expected, signal)) { // Already requested exit return; } while (true) { const char byte{}; const auto bytes = write(writeSocket, &byte, 1); if (bytes == -1 && errno == EINTR) { continue; } break; } } } class SignalHandler::Impl { public: Impl() { try { int sockets[2]{}; checkPosixError(socketpair(AF_UNIX, SOCK_STREAM, 0, static_cast(sockets)), "socketpair"); writeSocket = sockets[0]; const int readSocket = sockets[1]; struct sigaction action {}; action.sa_handler = signalHandler; action.sa_flags |= SA_RESTART; for (auto [signal, _] : expectedSignals) { checkPosixError(sigaction(signal, &action, nullptr), "sigaction"); } debug().log("signalhandler: created socket pair and set up signal handlers"); try { debug().log("signalhandler: starting read socket thread"); mThread = std::thread(&Impl::readFromSocket, this, readSocket); } catch (const std::system_error& e) { warning().logWithException(e, "signalhandler: failed to start thread"); } } catch (const std::system_error& e) { warning().logWithException(e, "Failed to setup signal handlers"); return; } } ~Impl() { debug().log("signalhandler: closing write socket"); try { checkPosixError(close(writeSocket), "close"); } catch (const std::system_error& e) { warning().logWithException(e, "signalhandler: failed to close write socket"); } debug().log("signalhandler: joining read socket thread"); mThread.join(); debug().log("signalhandler: joined read socket thread"); } Q_DISABLE_COPY_MOVE(Impl) private: void readFromSocket(int readSocket) const { debug().log("signalhandler: started read socket thread"); auto finishGuard = QScopeGuard([readSocket] { debug().log("signalhandler: closing read socket"); try { checkPosixError(close(readSocket), "close"); } catch (const std::system_error& e) { warning().logWithException(e, "signalhandler: failed to close read socket"); } debug().log("signalhandler: finished read socket thread"); }); while (true) { char byte{}; try { const ssize_t bytes = checkPosixError(read(readSocket, &byte, 1), "read"); if (bytes == 0) { debug().log("signalhandler: write socket was closed, end thread"); return; } } catch (const std::system_error& e) { if (e.code() == std::errc::interrupted) { warning().log("signalhandler: read interrupted, continue"); continue; } warning().logWithException(e, "signalhandler: failed to read from socket, end thread"); return; } break; } if (int signal = receivedSignal; signal != notReceivedSignal) { if (const auto name = signalName(signal); name.has_value()) { info().log("signalhandler: received signal {}", *name); } else { info().log("signalhandler: received signal {}", signal); } } else { warning().log("signalhandler: read from socket but signal was not received"); return; } const auto app = QCoreApplication::instance(); if (app) { info().log("signalhandler: post QCoreApplication::quit() to event loop"); QMetaObject::invokeMethod(app, &QCoreApplication::quit, Qt::QueuedConnection); } else { warning().log("signalhandler: QApplication is not created yet"); } } std::thread mThread{}; }; SignalHandler::SignalHandler() : mImpl(std::make_unique()) {} SignalHandler::~SignalHandler() = default; bool SignalHandler::isExitRequested() const { return receivedSignal.load() != notReceivedSignal; } } tremotesf-2.8.2/src/startup/signalhandler_windows.cpp000066400000000000000000000050261500171105600231140ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "signalhandler.h" #include #include #include #include "log/log.h" #include "windowshelpers.h" namespace tremotesf { namespace { std::atomic_bool exitRequested{}; BOOL WINAPI consoleHandler(DWORD dwCtrlType) { exitRequested = true; std::string_view type{}; switch (dwCtrlType) { case CTRL_C_EVENT: type = "CTRL_C_EVENT"; break; case CTRL_BREAK_EVENT: type = "CTRL_BREAK_EVENT"; break; case CTRL_CLOSE_EVENT: type = "CTRL_CLOSE_EVENT"; break; case CTRL_LOGOFF_EVENT: type = "CTRL_LOGOFF_EVENT"; break; case CTRL_SHUTDOWN_EVENT: type = "CTRL_SHUTDOWN_EVENT"; break; default: break; } if (!type.empty()) { info().log("Received signal with type = {}", type); } else { info().log("Received signal with type = {}", dwCtrlType); } const auto app = QCoreApplication::instance(); if (app) { info().log("signalhandler: post QCoreApplication::quit() to event loop"); QMetaObject::invokeMethod(app, &QCoreApplication::quit, Qt::QueuedConnection); } else { warning().log("signalhandler: QApplication is not created yet"); } return TRUE; } } class SignalHandler::Impl {}; SignalHandler::SignalHandler() : mImpl{} { try { checkWin32Bool(SetConsoleCtrlHandler(&consoleHandler, TRUE), "SetConsoleCtrlHandler"); debug().log("signalhandler: added console signal handler"); } catch (const std::system_error& e) { warning().logWithException(e, "signalhandler: failed to add console signal handler"); } } SignalHandler::~SignalHandler() { try { checkWin32Bool(SetConsoleCtrlHandler(&consoleHandler, FALSE), "SetConsoleCtrlHandler"); debug().log("signalhandler: removed console signal handler"); } catch (const std::system_error& e) { warning().logWithException(e, "signalhandler: failed to remove console signal handler"); } } bool SignalHandler::isExitRequested() const { return exitRequested; } } tremotesf-2.8.2/src/startup/windowsfatalerrorhandlers.cpp000066400000000000000000000214251500171105600240250ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #ifdef __cpp_lib_stacktrace # include #endif #include #include #include #include #include "literals.h" #include "windowsfatalerrorhandlers.h" #include "windowshelpers.h" #include "log/log.h" namespace tremotesf { namespace { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) bool showedReport{}; #ifdef __cpp_lib_stacktrace constexpr size_t maxPathLength = 65535; constexpr auto maxPathLengthDword = static_cast(maxPathLength); # ifdef _MSC_VER QString getExecutablePath() { std::wstring executablePath(maxPathLength, L'\0'); const auto length = GetModuleFileNameW(nullptr, executablePath.data(), maxPathLengthDword); if (length == 0 || (length == maxPathLengthDword && GetLastError() == ERROR_INSUFFICIENT_BUFFER)) { return {}; } executablePath.resize(static_cast(length)); return QString::fromStdWString(executablePath); } # endif void appendStackTraceToReport(std::string& report) { # ifdef _MSC_VER const auto executablePath = getExecutablePath(); if (executablePath.isEmpty()) { report += "\n\nFailed to determine path to executable, not reporting stack trace\n"; return; } const QFileInfo executable(executablePath); const auto executableDir = executable.path(); const QString pdbPath = executableDir % '/' % executable.completeBaseName() % ".pdb"_l1; if (!QFileInfo::exists(pdbPath)) { fmt::format_to( std::back_insert_iterator(report), "\n\nPDB file does not exist at expected path {}, not reporting stack trace\n", pdbPath ); return; } SetEnvironmentVariableW(L"_NT_SYMBOL_PATH", getCWString(executableDir)); # endif const auto trace = std::stacktrace::current(); report += "\n\nStack trace:\n"; report += std::to_string(trace); } #endif void showReport(std::string report) { showedReport = true; #ifdef __cpp_lib_stacktrace appendStackTraceToReport(report); #endif warning().log(report); showFatalErrorReportInDialog(std::move(report)); } void onTerminate() { std::string report = "FATAL ERROR: std::terminate called"; if (const auto exception_ptr = std::current_exception(); exception_ptr) { report += "\n\nUnhandled C++ exception:\n"; try { std::rethrow_exception(exception_ptr); } catch (const std::exception& e) { fmt::format_to( std::back_insert_iterator(report), impl::singleArgumentFormatString, formatExceptionRecursively(e) ); } catch (const winrt::hresult_error& e) { fmt::format_to( std::back_insert_iterator(report), impl::singleArgumentFormatString, formatExceptionRecursively(e) ); } catch (...) { report += "Type of exception is unknown"; } } showReport(std::move(report)); std::abort(); } std::string_view sehExceptionCodeName(DWORD exceptionCode) { switch (exceptionCode) { case EXCEPTION_ACCESS_VIOLATION: return "EXCEPTION_ACCESS_VIOLATION"; case EXCEPTION_ARRAY_BOUNDS_EXCEEDED: return "EXCEPTION_ARRAY_BOUNDS_EXCEEDED"; case EXCEPTION_BREAKPOINT: return "EXCEPTION_BREAKPOINT"; case EXCEPTION_DATATYPE_MISALIGNMENT: return "EXCEPTION_DATATYPE_MISALIGNMENT"; case EXCEPTION_FLT_DENORMAL_OPERAND: return "EXCEPTION_FLT_DENORMAL_OPERAND"; case EXCEPTION_FLT_DIVIDE_BY_ZERO: return "EXCEPTION_FLT_DIVIDE_BY_ZERO"; case EXCEPTION_FLT_INEXACT_RESULT: return "EXCEPTION_FLT_INEXACT_RESULT"; case EXCEPTION_FLT_INVALID_OPERATION: return "EXCEPTION_FLT_INVALID_OPERATION"; case EXCEPTION_FLT_OVERFLOW: return "EXCEPTION_FLT_OVERFLOW"; case EXCEPTION_FLT_STACK_CHECK: return "EXCEPTION_FLT_STACK_CHECK"; case EXCEPTION_FLT_UNDERFLOW: return "EXCEPTION_FLT_UNDERFLOW"; case EXCEPTION_ILLEGAL_INSTRUCTION: return "EXCEPTION_ILLEGAL_INSTRUCTION"; case EXCEPTION_IN_PAGE_ERROR: return "EXCEPTION_IN_PAGE_ERROR"; case EXCEPTION_INT_DIVIDE_BY_ZERO: return "EXCEPTION_INT_DIVIDE_BY_ZERO"; case EXCEPTION_INT_OVERFLOW: return "EXCEPTION_INT_OVERFLOW"; case EXCEPTION_INVALID_DISPOSITION: return "EXCEPTION_INVALID_DISPOSITION"; case EXCEPTION_NONCONTINUABLE_EXCEPTION: return "EXCEPTION_NONCONTINUABLE_EXCEPTION"; case EXCEPTION_PRIV_INSTRUCTION: return "EXCEPTION_PRIV_INSTRUCTION"; case EXCEPTION_SINGLE_STEP: return "EXCEPTION_SINGLE_STEP"; case EXCEPTION_STACK_OVERFLOW: return "EXCEPTION_STACK_OVERFLOW"; default: break; } return "UNKNOWN EXCEPTION"; } constexpr DWORD cppExceptionCode = 0xe06d7363; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) LPTOP_LEVEL_EXCEPTION_FILTER defaultSehExceptionFilter{}; LONG WINAPI sehExceptionFilter(LPEXCEPTION_POINTERS exceptionInfo) { if (exceptionInfo->ExceptionRecord->ExceptionCode == cppExceptionCode) { // C++ exception, delegate to onTerminate if (defaultSehExceptionFilter) { defaultSehExceptionFilter(exceptionInfo); } return EXCEPTION_CONTINUE_SEARCH; } std::string report{}; fmt::format_to( std::back_insert_iterator(report), "FATAL ERROR: Unhandled SEH exception 0x{:x} {}", exceptionInfo->ExceptionRecord->ExceptionCode, sehExceptionCodeName(exceptionInfo->ExceptionRecord->ExceptionCode) ); { PEXCEPTION_RECORD nested = exceptionInfo->ExceptionRecord->ExceptionRecord; while (nested) { fmt::format_to( std::back_insert_iterator(report), "\nCaused by: 0x{:x} {}", nested->ExceptionCode, sehExceptionCodeName(nested->ExceptionCode) ); nested = nested->ExceptionRecord; } } showReport(std::move(report)); return EXCEPTION_CONTINUE_SEARCH; } void abortHandler(int) { if (!showedReport) { showReport("FATAL ERROR: std::abort called"); } } } void windowsSetUpFatalErrorHandlers() { /** * std::set_terminate is thread-local * SetUnhandledExceptionFilter and std::signal are global */ std::set_terminate(&onTerminate); defaultSehExceptionFilter = SetUnhandledExceptionFilter(&sehExceptionFilter); _set_abort_behavior(0, _WRITE_ABORT_MSG); std::signal(SIGABRT, &abortHandler); } void windowsSetUpFatalErrorHandlersInThread() { static thread_local bool set = false; if (!set) { std::set_terminate(&onTerminate); set = true; } } std::string makeFatalErrorReportFromLogMessage(const QString& message, const QMessageLogContext& context) { std::string report = fmt::format( "FATAL ERROR: {}\nFunction: {}\nSource file: {}:{}", message, context.function, context.file, context.line ); #ifdef __cpp_lib_stacktrace appendStackTraceToReport(report); #endif return report; } void showFatalErrorReportInDialog(std::string report) { showedReport = true; report.insert(0, "Press Ctrl+C to copy this report\n\n"); MessageBoxA(nullptr, report.c_str(), "Fatal error", MB_OK | MB_ICONERROR); } } tremotesf-2.8.2/src/startup/windowsfatalerrorhandlers.h000066400000000000000000000011201500171105600234600ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_WINDOWSFATALERRORHANDLERS_H #define TREMOTESF_WINDOWSFATALERRORHANDLERS_H #include class QMessageLogContext; class QString; namespace tremotesf { void windowsSetUpFatalErrorHandlers(); void windowsSetUpFatalErrorHandlersInThread(); std::string makeFatalErrorReportFromLogMessage(const QString& message, const QMessageLogContext& context); void showFatalErrorReportInDialog(std::string report); } #endif // TREMOTESF_WINDOWSFATALERRORHANDLERS_H tremotesf-2.8.2/src/startup/windowsmessagehandler.cpp000066400000000000000000000212141500171105600231210ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "windowsmessagehandler.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "fileutils.h" #include "literals.h" #include "log/log.h" #include "windowshelpers.h" #include "windowsfatalerrorhandlers.h" namespace fs = std::filesystem; namespace tremotesf { namespace { class [[maybe_unused]] MessageQueue final { public: void pushEvicting(QString&& message) { { const std::lock_guard lock(mMutex); if (mNewMessagesCancelled) return; if (mQueue.size() == maximumSize) { mQueue.pop_front(); } mQueue.push_back(std::move(message)); } mCv.notify_one(); } std::optional popBlocking() { std::unique_lock lock(mMutex); mCv.wait(lock, [&] { return !mQueue.empty() || mNewMessagesCancelled; }); if (mQueue.empty()) return {}; QString message = std::move(mQueue.front()); mQueue.pop_front(); return message; } void cancelNewMessages() { { const std::lock_guard lock(mMutex); mNewMessagesCancelled = true; } mCv.notify_one(); } private: std::deque mQueue{}; std::mutex mMutex{}; std::condition_variable mCv{}; bool mNewMessagesCancelled{}; static constexpr size_t maximumSize = 10000; }; class [[maybe_unused]] FileLogger final { public: void logMessage(QString&& message) { mQueue.pushEvicting(std::move(message)); } // We are not doing this in destructor because we need // thread to be able to call logMessage() while we are joining it void finishWriting() { info().log("FileLogger: finishing logging"); debug().log("FileLogger: wait until thread started writing or finished with error"); { std::unique_lock lock(mMutex); mCv.wait(lock, [&] { return mStartedWriting || mFinishedWriting; }); } debug().log("FileLogger: cancelling new messages"); mQueue.cancelNewMessages(); debug().log("FileLogger: joining write thread"); mWriteThread.join(); debug().log("FileLogger: joined write thread"); } private: void writeMessagesToFile() { windowsSetUpFatalErrorHandlersInThread(); debug().log("FileLogger: started write thread"); auto finishGuard = QScopeGuard([this] { debug().log("FileLogger: finished write thread"); mQueue.cancelNewMessages(); { const std::lock_guard lock(mMutex); mFinishedWriting = true; } mCv.notify_one(); }); const auto dirPath = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation); debug().log("FileLogger: creating logs directory {}", QDir::toNativeSeparators(dirPath)); try { fs::create_directories(fs::path(getCWString(dirPath))); } catch (const fs::filesystem_error& e) { warning().logWithException(e, "FileLogger: failed to create logs directory"); return; } debug().log("FileLogger: created logs directory"); auto filePath = QString::fromStdString( fmt::format("{}/{}.log", dirPath, QDateTime::currentDateTime().toString(u"yyyy-MM-dd_hh-mm-ss.zzz")) ); debug().log("FileLogger: creating log file {}", QDir::toNativeSeparators(filePath)); QFile file(filePath); try { openFile(file, QIODevice::WriteOnly | QIODevice::NewOnly | QIODevice::Text | QIODevice::Unbuffered); } catch (const QFileError& e) { warning().logWithException(e, "FileLogger: failed to create log file"); return; } debug().log("FileLogger: created log file"); { const std::lock_guard lock(mMutex); mStartedWriting = true; } mCv.notify_one(); while (true) { const auto message = mQueue.popBlocking(); if (!message.has_value()) { return; } writeMessageToFile(*message, file); } } void writeMessageToFile(const QString& message, QFile& file) { try { writeBytes(file, message.toUtf8()); static constexpr std::array lineTerminator{'\n'}; writeBytes(file, lineTerminator); } catch ([[maybe_unused]] const QFileError& e) {} } MessageQueue mQueue{}; std::mutex mMutex{}; std::condition_variable mCv{}; bool mStartedWriting{}; bool mFinishedWriting{}; std::thread mWriteThread{&FileLogger::writeMessagesToFile, this}; }; void writeToDebugger(const wchar_t* message) { if (IsDebuggerPresent()) { OutputDebugStringW(message); OutputDebugStringW(L"\r\n"); } } std::unique_ptr globalFileLogger{}; [[maybe_unused]] void releaseMessageHandler(QString message) { writeToDebugger(getCWString(message)); if (globalFileLogger) { globalFileLogger->logMessage(std::move(message)); } } [[maybe_unused]] void debugMessageHandler(const QString& message) { const auto wstr = getCWString(message); writeToDebugger(wstr); static const auto stderrHandle = GetStdHandle(STD_ERROR_HANDLE); static const bool stderrIsConsole = [] { DWORD mode{}; return GetConsoleMode(stderrHandle, &mode) != FALSE; }(); if (stderrIsConsole) { WriteConsoleW(stderrHandle, wstr, static_cast(message.size()), nullptr, nullptr); WriteConsoleW(stderrHandle, L"\n", 1, nullptr, nullptr); } else { // stderr is redirected to pipe or a file, write UTF-8 const auto utf8 = message.toUtf8(); fwrite(utf8.data(), 1, static_cast(utf8.size()), stderr); fputc('\n', stderr); } } void callReleaseOrDebugHandler(QtMsgType type, const QMessageLogContext& context, const QString& message) { const QString formatted = qFormatLogMessage(type, context, message); #ifdef QT_DEBUG debugMessageHandler(formatted); #else releaseMessageHandler(formatted); #endif } void windowsMessageHandler(QtMsgType type, const QMessageLogContext& context, const QString& message) { if (type == QtFatalMsg) { std::string report = makeFatalErrorReportFromLogMessage(message, context); callReleaseOrDebugHandler(type, context, QString::fromStdString(report)); showFatalErrorReportInDialog(std::move(report)); std::abort(); } callReleaseOrDebugHandler(type, context, message); } } void initWindowsMessageHandler() { qInstallMessageHandler(windowsMessageHandler); qSetMessagePattern( "[%{time yyyy.MM.dd h:mm:ss.zzz t} %{if-debug}D%{endif}%{if-info}I%{endif}%{if-warning}W%{endif}%{if-critical}C%{endif}%{if-fatal}F%{endif}] %{message}"_l1 ); #ifndef QT_DEBUG globalFileLogger = std::make_unique(); debug().log("FileLogger: created, starting write thread"); #endif } void deinitWindowsMessageHandler() { #ifndef QT_DEBUG if (globalFileLogger) { globalFileLogger->finishWriting(); } #endif } } tremotesf-2.8.2/src/startup/windowsmessagehandler.h000066400000000000000000000004471500171105600225730ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_WINDOWSMESSAGEHANDLER_H #define TREMOTESF_WINDOWSMESSAGEHANDLER_H namespace tremotesf { void initWindowsMessageHandler(); void deinitWindowsMessageHandler(); } #endif tremotesf-2.8.2/src/stdutils.h000066400000000000000000000111261500171105600163430ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_STDUTILS_H #define TREMOTESF_STDUTILS_H #include #include #include #include #include #if __has_include() # include #else # include #endif namespace tremotesf { template inline constexpr std::optional> indexOf(const Range& range, std::ranges::range_value_t value) { namespace r = std::ranges; const auto found = r::find(range, value); if (found == r::end(range)) { return std::nullopt; }; return static_cast>(r::distance(r::begin(range), found)); } template inline constexpr std::optional indexOfCasted(const Range& range, std::ranges::range_value_t value) { const auto index = indexOf(range, value); if (!index.has_value()) return std::nullopt; return static_cast(*index); } template inline constexpr auto slice(const Range& range, Index first, Index last) { const auto begin = std::ranges::begin(range); return std::ranges::subrange( begin + static_cast>(first), begin + static_cast>(last) ); } namespace impl { template inline NewContainer toContainer(FromRange&& from) { if constexpr (std::ranges::common_range) { return {std::ranges::begin(from), std::ranges::end(from)}; } else { auto common = std::views::common(std::forward(from)); return {std::ranges::begin(common), std::ranges::end(common)}; } } template inline NewContainer moveToContainer(FromRange&& from) { if constexpr (std::ranges::common_range) { return {std::move_iterator(std::ranges::begin(from)), std::move_iterator(std::ranges::end(from))}; } else { auto common = std::views::common(std::forward(from)); return {std::move_iterator(std::ranges::begin(common)), std::move_iterator(std::ranges::end(common))}; } } } template typename NewContainer, std::ranges::input_range FromRange> // NOLINTNEXTLINE(cppcoreguidelines-missing-std-forward) inline auto toContainer(FromRange&& from) { return impl::toContainer>, FromRange>( std::forward(from) ); } template requires(std::ranges::common_range) // NOLINTNEXTLINE(cppcoreguidelines-missing-std-forward) inline auto toContainer(FromRange&& from) { return impl::toContainer(std::forward(from)); } template typename NewContainer, std::ranges::forward_range FromRange> // NOLINTNEXTLINE(cppcoreguidelines-missing-std-forward) inline auto moveToContainer(FromRange&& from) { return impl::moveToContainer>, FromRange>( std::forward(from) ); } template // NOLINTNEXTLINE(cppcoreguidelines-missing-std-forward) inline auto moveToContainer(FromRange&& from) { return impl::moveToContainer(std::forward(from)); } /** * T1 is always deduced as value type, T2 may be a reference */ template requires(!std::floating_point && std::same_as, std::remove_cvref_t>) inline void setChanged(T1& value, T2&& newValue, bool& changed) { if (newValue != value) { value = std::forward(newValue); changed = true; } } template inline void setChanged(T& value, T newValue, bool& changed) { if (!qFuzzyCompare(newValue, value)) { value = newValue; changed = true; } } } #endif // TREMOTESF_STDUTILS_H tremotesf-2.8.2/src/target_os.h000066400000000000000000000013571500171105600164640ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_TARGET_OS_H #define TREMOTESF_TARGET_OS_H #if __has_include() # include #else # include #endif namespace tremotesf { enum class TargetOs { UnixFreedesktop, UnixMacOS, Windows }; inline constexpr TargetOs targetOs = #if defined(TREMOTESF_UNIX_FREEDESKTOP) TargetOs::UnixFreedesktop; #elif defined(Q_OS_MACOS) TargetOs::UnixMacOS; #elif defined(Q_OS_WIN) TargetOs::Windows; #else // We shouldn't even get here since we will fail at CMake configuration step # error "Unsupported target platform" #endif } #endif // TREMOTESF_TARGET_OS_H tremotesf-2.8.2/src/test-torrents/000077500000000000000000000000001500171105600171535ustar00rootroot00000000000000tremotesf-2.8.2/src/test-torrents/Fedora-Workstation-Live-x86_64-34.torrent000066400000000000000000004536201500171105600264030ustar00rootroot00000000000000d8:announce46:http://torrent.fedoraproject.org:6969/announce13:creation datei1619460779e4:infod5:filesld6:lengthi1062e4:pathl41:Fedora-Workstation-34-1.2-x86_64-CHECKSUMeed6:lengthi2007367680e4:pathl41:Fedora-Workstation-Live-x86_64-34-1.2.isoeee4:name33:Fedora-Workstation-Live-x86_64-3412:piece lengthi262144e6:pieces153160:v!D[';8;.Ch ˺Rs 4+]axJǢ%y߿meo1T?5e5@qwz#NI㔠hRJIR4E i=ЯV gio\Ӥ_V5u-$WkQ-'^~]1eV\0oxIw9quKB]Aduo"^l3_U,cV:6MMӸt`7cZ*GTӁ|+f ٺR'h@c"R2w\=Ej%Vj>̵ \}?YPBF AÛ,{n$h85zO22ν .q'NkʭWm]5 ڌuDVSKD1ɷvPty`0U ٲņIr0f!qAr莇e}<]Tz 6+DLvhՈ)ZA{29ǪO.H:>ma |~8"`ɪkM@< aaq4\\7[̣HPoOعsp,I36%񰼸ք$<6 c /17l!ᵡ;24\i֗&NXrS zT^݅{2+@v$u8^s4@N?>,lI<@bBLZ}7_p>ݹ.WYT>YY30!H|Un\5t Yo=c_P?c^U!.OV;XEReI>`pزCh6.NbD<НSTK4k)PVcYKnBQ_ܖ7՝ .5)hV;Ftòݜ\ ?o sYpdLļٞ@TX/MSh/Q~*ԫg4!4i(u涋Ȟ@_E[FSһL9}z=@G0-9qg<%faLS`_up p8e/_,]c"8?V#d(cq`mIf4r|V`+颯Oe4Vj=&lfw"X!$}NIQe)cYIrn6u–* P bsM~e1 P#|li%3bmgKUt.숆w97#wD t%3xW]Ҵ:ۓ =/ǗjtnjQaS|>å+H`UtUĴ(G_n(wѧIC=4Zɖ Xo0f_@T; kl@ -AAqLLVǒ.DOvh|R&&$شq-wgh9>ḍ;҇Y ڞ^r? D2~zE{f4^JUm>o=Ô2dJxEB?n-5'@E4vnYH&s0#/ =GdSAQp$,JA⪽"Pf7\>Ӈ7E.H7P梞q<M^F.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>Y.WYT>YpٗC ܁V8m?mD7Yba1}wjz,qom?p#FC;–VH$f9 +1[TNYp3;a4 K.JA22R-8ZnQ7f@$(eu$2~Q$!ԁ^öHvȨ/9be[H+>Zk%j?iz8%!bıc0Y$ 9)"PCPECn*UHؽ}E/jY(h#gs6;Wyh$k:C(CN*2=1nV .CuAra L_?}=qZjc IݬְFc%҆%0#Gn7x+j5S dWfvgԎ^a1~oZ>JoC֚|m"U&" O 6@l=K4z UθJwoi'pwb0C-o;` TOW=Dَ!Cu{b)#A3QApN]?Q# ˓{xܡHy1>LMu3Jv|7ڈ9olE᏾*vWQ{Mf@n)[=hv߻o2PT-d<|Ri |)]':Jn[USκE?<-WK8)2YbNNQ* d!ݩO]A?dRGX,lj Q3wz_B/CRN;gcOT`cyʼnWS.94{A+/Zup Y=d(Kp3r)ue*Xcm|u8)nԷw% ',zA=әz|.7Wޗx#cD,Nӗ ӬA/N(5^\{-Sk%@w'd#(UZЦ#gƚ*,8] 2~$y. :h ΦKiNVUv[JO2v oަU˘<:/|^艡ZL⾹$"uoZoZ[ #fƁp$~q<Y p<^b's2Zm$A`G_%V)hf3|w]/֟aRG 8BS]oCҔq05Fpz# 015L+(_ rWqSVO~>+e'h$*-w7v, YvqN1ϘiUݤegUUsVЇ4S{|nŭl$bM36rBf=i9̿$B/¨@!H;,CށFo,xz{D {o$"ΰψCj Q~c=.Ddĩ B4`[Cu@vmGKC8⸩#Z=FI]Ǿ0dk >5i|)7n 8ʅ=nP߇ BEs L \GZ L p!a@zݳ%I>G(:v jN Ԕ- kqZ5jƽ;oh3?}Fp~X~K*#FͫKkwzn<16Pg0^iC&?KbKHDT`q'enC.0ii3qѳ0nxt]tގ1'L-A&WPiaInP8Mfa@ui$u֥SP#T*軠Yv!=*T ;' _mxMv̌6 u/mrz皶JV6޵Z,5b;FKuYYly$ZbSx-CH ^*4R܄UO4(^ILXgKN1R d=JZjIǍ؉fO 1߱ urSK{[ vd>~HuSs+-.kA0$NKHqP׊%)O+4K?m}9:׹yqBCV8c{&#Nߊ|玽m|u~p-L7#},I% \nqp-c"kc9tp`Īnz?V ki`%VD/* wgͻ(g(sog8`}]uBZڬ`DPԆRc<$W(06 )#rIeIJ6T2_@ 8p|v7䱰n-B e5T:i1H\1:ᄰ1ٯd;OС+Rh.twaϾCMkx݉DoQ SKK%ܚ5uW8wm> 9<!J"U(M=UtYNF(ú4CQoEUK4s:mI#R +Xˤyk%YV aO'F+YcQfj-1gT\wShwGi8(ܟ!ѧ7Omf(߱}u Ma֥-'JOut|_1SO}KHD`x )(ÿG]MU!ʠ].:xz AY‚2ؐ>/~*"(AI=[UBVHe!ܫ(r{|w Qrs}>+YR%uihk, 5ݙ^0B)M@!$  "D„E6%._hXAH ˘I.c8 3 5ޣNJڱ[pG?f 1”޺,d2syFjV[>ӢM\&3{?vԍ-7(8n&O3"7+jݲ\ZﲧnKPt ]`E:te3k@i7Ɠo٧a^XbCT HX`BpUy$Ƽ,bZx/jω&USOD}mu[RC8J5/b6QuJ]~rl`p"6! ʼWV'kr>: jXVdA-?۔hR( x}6>pA+p1CN[( "a@& 5̞vE~KyT+:p<u oɈXl !xw筫V︢gr#ǹdbbEOD@8l/[5!">6Ώ ֦r@>}],TU25=2_x gVº״kJr=. Kӌ'v}BhnkµZEXkIM_EbuI[9!_ʏqؠ 1"Aϴ$!GY%'!]/a5 OzOW7ښO6>Cg޴b8\L ju&d53Wre <)Jj8.Mf@#H@i};l'}FN?྆O1DzށjyxR.-F1Yr<sDўp-ԪPIZ+q; O{1s%;<K d;Wv:{Yý]`&y?1 XAvzG`;t*éIZzٳkjL^\a 4&[5p[J[U!S̢$~ڍNuOgH&RյG8c翆騝v\#yBl9s-8ڊUENQh,8^mK$'D掘->6Njޠ0!z*~Y<[}'v# b (gZΔ4=;Ω0=8\p7N(_%"teQ&kE'99gAP~mRzxX 0Pkq: yN6Ar`0gTDkgBi\S-|0փ pm|#*r =nC,'Pf=):cp0ZhGaꢳ`N%#|Fҡ7,㴲5|$+Jb<(@dG}p5ț;JTݹwo8/R ~uJ wˀ.r ;.ZjSg1yn R:d(i"Y0 ,ݰ <8>ߌr͵%UkK]& Ko@8g9Z=9D]yTӺyn[t,@6J%^op (g}vD K+L} N q"[Ak鼶 \$ԘuJ$`Mq4NN cb]}"QE'VP %8b%'yk>?.j~ k8Jѵq[P{G aVO.:2FnGFxhQiQh)HJ@ i`'?wL{ 16{WUݥߛ [s *hq`cʼnBW'w Nof.lP}b`҅g3+G#zEF6<-l xZF+SgĜ0G?ʿp7KR1 8AppM|S]xS;bs^x=  +*l5$+C98_G;㳿cU;Me'܍r]VkFkta(m<3=: mhfVt&KrE7z .\Cp`)& Zx7<^n.] kQ8/}Hެ<2 F@=R? m )gbKjK2,&NByT>/ȤդÎ7D9>R1L A_ˈ0CSQ4'-RA3IJwzC7Suo4\H<}_^8VKnd^Y!MLFe:_w$cEnDe_^{B fɓw1c(7qOP4Qҟ"xhK̥mYt6v=5;34dan3Mb_ zMd;9 otӨj_%-5M|7soSRWqerts4rTԧe՗kz ]7&v:B;Qsw)|p -֔Od|+p͕ ѵ\.ޮ  ZKMҍx9Х';R%?T6c/7oħsݫc %ʜZY1'"*/ 6f}QT0T>5vt}6ݔo17CJ$'8Eb :D3fŕ-+Z6?" 'AusYjS0'AKp;iakM3z1o Q_;Eb c=`xvGfd-OZ"%/.]~fGlU湆Hqb".ϖN"Ng 7J5 )nQ&S>D=]ȧWzLILB ɟ)cwZuc11 b:E2^ĸ OyTNg~]ƝTW1/4Sr^jvF@\%i/<[5wHgMn>u^?&S̉b'^ CV=.&יh{IUl6NkjP\|xQUDϞP>cQn`! ?@HNԌz=uHѤ}`SwtT;rku 2g.ke<"GLTvK2.%ec3 @=o &oP.E\)Du@1"e ]g\QH"ibIX=m4-'=>7\?B1$+oXǤރWɷJ>_%Xݚ߅OR|0sď:@jn[M yD;7!eÖNm I5je}lA$+v˘)w? 8?N}5|vp;Si,6鼲aT3RdLՠo)iG, IcRkѷOI"zg_qi*Sd/Cm̕L6@7G7ڄSzogΰ\|[e~79ԟ83}h̒3R[^c>eǯeec5IěcG}"x,bqrAhbH )w2! Xai;WkWQV ;Ӟ= ύ6oY Ea,t#eA(KM 's0%W&z eb wm&ΨLXyցEX=_ 1$=MBKB&c~1]J*$KKO$ qHh9\7/t"ގ&Zlj:[soWA`eh+%*+-R-hyYv.-`CBÁWRw,fB,I!@vs;h ڬ!"e3y)ZEf|ڳVyg I!mTfp|hg8y-޼o{cfm[ߴ4 ]h-5eFs(v1DD$pVr m#"r274DGkp4YT=C0[lr3⻁ͣ?AdN (ZfbP6Ce2/ Y`x}Q܈wY&5vFWO)j ]_j$ƆUK,jךU$hvk dp Kd͉י50M+RHa'۝&d'NYOK2V>oDL7V0&jRPYq8ʪF&&'pHg q G>YwCԉ)[}(fndYW\vn8󣖿td'|v$->ˍo  9ـ)?Io5pJn]{=iex ?\"y1NM*XU$wN\2гVʫg:M ˝ ̦7@ k͞4j,4R}*n=v"h S zTcS75nhn+ÃOxw\mgf,Z_ ֣wk0C&:X] @4tKL|a,"RlI 2Xg90WJ+5%oq~(JsZ<'.>ݖy5Èщ쭩AV4sD+~ K}dzp(Ev!/ryFeךǓpG}b<ځj642@c{ul{'0c:k?|Tٲ؊\jlm=2 aff(?5 Mp@z#ǨV7+;DEMDp|F ;-j'mS~\A~!"bpdEE2sVd>Ǖp kzbw娏La֌wkطz;(#2I>}5dWta4 $vr r"o"Ǯ{Ɠ~3| ypoWI4ufݱtmޛ  |Ep4S 6E*=OD]F]XvIpKs|4Z<`s틊0YQs`KB7Zyӑ83za;d]ibHoj$gnSqdKNt;mG.r!36Qiڍ5aNc!X\&>Zwt .%/ Vz֢9l{v{,IH"N3N u({k)>I:hu*`i}{r{!g_ V)tTxΰgp_{@B<y3|jX¶ȝA~(n!+"yYn<yϘ;[#ZT2g>BorC0bfwcl4!lv=$"׮y1ɇLo? hC2PlrF{AjK =D$Yدf!R-DpHUnW K) !>".`;̀EIUoD/R3LRXn+-uڦ%@3+ak䰶\T{n*\K3xf =t΂iSQsUvbv{xuPR @>gCD|6F,BބT3;?`7 W<&pvXtZGvҢ -, J`?^puΟl,A%$cd6ץѾVlɱ\e)U A8-a߯mͺܧrB]| Ȣkϻո̓~+ׅ7W)Z[p74=9f18Oa(/vtuem9_s> w\B?-A%ua~BQOѩLzjf?!諅ы隮wsFF"}:o|~W]]J`{tT.H[6]6EVyG̃A6}т2bQ;QB ._# L[.܋T@Kk}Jc&0Z0W{ihOEo |3l􀍨My9EZ;֕HMuQwYDՉվ_Q0t#)p a !Fj8q;8>S1 cPUe-'g9/茺=Ƿj:ԘU#8yp)$<ߢAwWc[dF>r 5:gFEM{dyW\DCqL? Wv 0 Rٚr%K@@>@2:G3t.xcZ{& g*ϞU Rm=P6޼tA`yV̢O}Ø ^LR4%nѥ2#/-ѤZT@Yni}͛&GՑEG7-1 HKycJǻd$ H%]Ř/"7A [s&=YѨ7K؉: #b/K6I2;;,dgGdO x7Ki`A]/@a[A"Cr nfȫYfK #2|~nԅ]D;~(M0bݩ,y elJh2m00LFJ% .Hf&ͅ~gv{LK& gEP$]͎: ɯ0bDڑenjCKk,'ӬRYgmD@FG1#w<2om:sw|==Pn_̒dJ5|XuoT& C`o7)[*E-LU/"N2T>>rKQoMO풨mNphiSזXJVUa_'w\pTge;)lAǷ:[`ݵBwՂP|$2Aayu-b1V$m.O_j!3NWg`ײ+Kn[Jjo( *3D(L-\s#Y6#jAH*1-@@" q;@/Η5X$q^ESXH K1Cam9B܎#kpҢ'H᠐4&"fjK,H;z 욶ȻD09yӒ x7< j!1X9ݨ-;sݛ-L]u4l1.:2>A%'Eb'/(#p9ηcvJuE%ƈ|:+8ATqCӾmW]xuh7rSĪ e ཻm"2 b TQTaC(3 ihZq)])DXcHgfNi{/ګHt_9$ j:gK vn| {zҢV3wڐ"n߆+n,IUWphmFf0}h:Dwa$|$_]dIM&V3k/lOtS,D@N@߰Ts>%!I ^ٿ)7^]o =#\J}BgPՆz6oD s4UXF[r}V5NU[8{3?['WgVsDާ5 G\`rf6Tݦ1)U^`%ef"W3LwAL'_fe-q.$ik2!-s#$}s=ĤGyB+u8gpSg#iO`h)[b!)24_mV{z@?bc͐@õ{2q(56&0;cTIVьyNg +pl_Oj776Hēx~7Kvs| J=l~ 9Ư|n ?8cv} vD e,-O5*k&Muը`B%&+㵡0J 2%K ̻Ӡ0x۩0%n#Z.P{`)*% e.#$/TjOOk 7L.Ìa;И,R3J5*_5>eD+MX /" 0|(~B(s}Be\ӻ.8 l6^"z[Ӊ ,d28٘^J̃oHHDGfwh-b3FC7[S<2g}A7-T@b<Spc}7%jחc"RJ_ťR &IA;0 *pdI3 ~rPBrlJV35%1;ڥ)k=@9<ӑJlEFe(y!|V_jȬ薾7K:d39ۻ1Ȕz)D?\lInE!C%u)2{4ؒ:#oZ%p{7.)s.y%b1d*<}:v=R͉`vD}j6 ha UKdq`:E /^ xI+:/4ݖ;μ(ͬ-&H4-1V-_HuU{-M$UŪ #>ND~l 6MN?\%joK^&N!~uǖ$CC{)\9ήkb7iv a U3e,O'5Y^c50 "Μ8pYcMV}f}Qs~XD@{ \d^2V2>rG9j 6M1IB0VS}<(G93} P@S4/=JY<}Q;м^~znQQS hT )G3tnv㛛l>Xźà!~`%5KL_$YpvRr>]qhG-<6c} z1b#Ml<.=5&hH{dZp`:yDsUY~1IK]au]hXmͱXcP\i·NhٜyfOýu"޶R} a˫q3@^ K #C ?*}1G/qJGdo<ߩs~{pa~l Zap#ܰS#6%R!3GY*MDDR^bWXjCNc!$jqg"#榗P%ϋil/{ΝXj^\I ] ;xOgĉ U~7ѮMPRjVw/7 i(N (~JAĬG8]vپR ʺwa)rFD1?(:B/š r0 ΍afeu*gbRƒ."z,#@ ½}/X o L߮x[irk I"C@t7 9sMM ÕÜ EB9|\*F1pZX;̠m!eHODK1_#uޅ.k}Gvh 3DThgDٖhuBD^vf]PE5Q)*LE_4¢Ä5zQw!YW`-UtKR^7 >k s~v=6ޞcw}+݁ ݡ\teh0'I%/loJ,I6tĶ3ec yYJE6v"K:u7m{ &9t-٠}1NshtWTe(͋\DITkWߊ̋2(Ts&[RץϿ_>0ҺU\oc<t3aƓI HQʐ: T-ҏGmؔ?%D L8LBuixn'RG,qU7 ( 1_ 葓iJY.Fw Ɣ=j+DiT3"ظgmah+y |@J䎸-_M1EnmGK'/& S (<<̞E QUy+mu ue'AQrh @n눛\_E[\ZP_%aԂxdYv,iCAqn#P@ߛ#!B)0g۹ 0$J6nv?&p21f6Vn!ӑc3TibV?5̅9meR\_}l_5e/'4sY%ZmtFȉVf(p28tZX:4q1ꀳ!Qs Zh*fQ}1x20Ǟ/26eeǨao9)ct5%x7Cv^NUBa ̽ěqp}ȤrﶙsZ!ԛt2Nr㋫1Fn#)D zlF60_Q7^/FLζ 8Blg_b:ǯt'f9y%"xp ̛=bۧr..)Nj}=go)&:~f硻=.JXq<%iT[yni,Ұ Sj#'0p+>3=Zå7N/8ʧ^-U3t(7K>=[}= XΉKx}~~*J RMwY,#d?A| PvzΈݾ<lO >Fc8pPyr|h.Nֹ%W]r`1KWboCeK A `1}e i܎9M 6eR=W5O" f%NNC`;XB5}\iz B_WN֤/0ľJ ob ښu?y#xSrkE-_ p+c_M#.GhQ9mnf-/8)eZF`fٗЛ &,yv@)XFtk3*%@VZ!SU.5dSU@"N I2܈`8P~iX17.NҳTЊ6Ǔv$'G!Vؾ⛉A յQ'Z N ?㋎9B?mZ!ⓘ ΊVbk.0pz#ӦUt23DeєiZqZ}B!9>U!Q2 Vu/2_XZ;# Gg5$3&[[)OԷG}UM?%ۻs|;^o ")ؚJ>8KFJiӥ~9#@+sM7FB$2b% $-eƿ6oU*K&<}~xgQ"fVE%% ŷhel%f[~d}iYPu& aJ{SDBDlKCM5pCO~AGtrD, |aDDjs'ӱ 捼Օ[j?V/Sm"/AD_4q艥;}[ :؆y0¾U㍇ei-*7ܺ^vX+ /@( NlK.Nd2_ܑ(!;+|&Gl(_Ј8g gŹ>m%yzIM-2;m'嶮oR{89q8EyhaZb`{ 3Y,nEV R&\,^ϔ.`$>slh'rH!Q#C1W[W eVP#pz[12$gM11-rNB g엕V s0^k:6+8Y =BuWe{<SB\P:VY$S1 2~9] 1V[CEQWTT|7ɾ9do ireNl2hX0i/Rݠ?~ 'chmȢH ,lDf Qk>N=g T''DYO~)t2e`:%1OS_z9l5L^|Vu8OIvh"^j4Wӣ[3^CB_$zIBf~Y.a*P\Qszx.,Ӎ9=)#&LXۀx%Ee0w$vGU~-0>;rx"6!x>Z&;SR<Ǩ`Z?iNG=j0(且ػr D["ӔZ)xZ됎 5um;4'A2@縕5l6 ,/fLȜ SR 6 iU8fW F۩ [&[i) -""P3-o#+d#~-kMO=q1Ԑq / s(v"Ymg-nUBTB6wE  Zg( J_I%o*;1N߹a :KNL}C8{P,g̩ᓧ,ϥeByĸ_IFIz@Qq>G5x!SM|x_NyK` D1)Myg*sD2! ד h(\RpB+Y?$!?==Ikr6ܵHʞ&>._։;/K䐧4G[V1Ct#HۥjuQ)N{UX#*nјg," .:T4s F -M {Y/ |p%ELjzy`g;l{cqLX~?_>M_iv?HERX*; Y39tǡ7LD#vDm{ *7a-p \ \oҎYYk$Z^A;^$Y5k 1/\D 'g\?d1]NHON8̋Z yF3ZJ`OԪFNk uT̔ZlϬ hj@*|#lXy5DG\x׮;:A(Ǻow=ͷ r0GZ;yֵuǐCNPrӚC~e S@ jL1w2a KyYd Har[}T^gZ mN3xeЩoZ;O;)27]jP :Ń 3*NoAШR'lH#=Zsm>:}wȃ \Y(̕tB-eKҕDR1vT6| ݗ-%!ACW u>.i>B9HU8q!p}fG]8F(c*Wsc{5@Ԡ!ο>F2ԇrS3/}uPNkG᭿68EWM)Pvt(]ĀYNX!ԷZy~w)Z2P] 6I# -o7d umJP0 sia)@>v| C:$գk_VFEr8~V&M5 ZR席a#WX6w9*]5J$awCu-YFpѶHx vD(߈R!Y 0roaܣh &ae ˱>`@-{Ս@vGQ-}І(Fq3i ϲg\`rC{3|!;L͆菤t7̌l["M KJIS<+1mUp!^2ť+ӄ>tF9lM# 0¹6hvxq91[<3XzP/hB5>^cG$kW~2rqO l_Yda:ژl6n=f7yD%y'-Y#T j0Ru 3%ׄGAIl{. Qmv~$AvQ}#0/SdK-zZ]g`U!(GJKRHz lBgIǴMDUR ٿV/1zBS)n0[HnM~p bA.]?fC1 >E,FYh<{ Bs:_1e[WЄű__̟€^lC*9Y@F,]=:z-r-,hߞnʛ ~sKU&L!Ԅ!{c6ސ\a$/bE6ֆ/Ĥr㾺?Ӫ Bp;NӲه:`aCc!t5%"yF!ʴ]3TIgw HdH]aGR ) -CމrFqo6&MEp/rQQꬬtW 0u 'zt%<YvGƄ ME`2XҪGpf|ra{ifԾEX$ XO8`?e6nTЗy>x~roCaHX^70Yg!`-Rhm^6;"Sc G税Q`PP`R<Q͟olt΅w Au,^H@ÝO}u1[|z䜝A v Je8fs3 `=p?d9IQ1FV?%PF'%c&/UYN|uoѠDqZ~~ ϰpOA!c7JaQyOec[*-C ɤ:#".(^e* c9^n_П˞ycDZdՇ_GUU '8#@>S"ļƕI9ٕv$p'bR >_ ̛dNߕfEj҂@R5`D_3LI” \u$~{Zt cSl:l]x? ||}a )1dL,tVut$%ZEiEy|ٟ ե%"yoWG/JP!JUg4 W}pg?|/LJ&ͅDIDW6mScm,XeݩZ ; #C)!)o8[b_T;HYRqNe+ӟ԰mFV}Z.J?_(c{0]Nu5H†ybz@;aP yky_S[5L[:0oU6iy[mDdJ,q@j76^o"\T9\[S|6d{NQd3݂1(tVg۝rlM.ۚO'1W@ {\E|ɾu]^GUrY6`Knx|ӰC: 'U~aq 5Qc_}UqU9[|1}Cq)<,0s+LkND8KTx QM6.WeS' o& >ipۊj[8rJHc^HU/ewb>0)kY 65y%-+͌ z_EwϏ$.2C eA4aEq6Ŀkl2Tiyoټs_f5wksJ*8wb`Ӽ R3hd $78T}tn6wئn75&Q@8cMHtM?hWqOBjq8$0z:o8L t̜l7O@졟'&q92@V$,uS>>#=|N'=>|C8 rg`NrV;/0(;Y rws䊈 Xl> nU=?-Ӿ:S}WgX.M<~}LJh@ QL?U~NMB~|k(NVo<߫~|4A&!ÀiX4\fäϜWjj, |d 2""zSb1In.6_exNÏ`'W BVVF/{FmEAw!!5ײQ ﲢȧ"E)) %M 4p`ܞscaM~LrgsT|2sm$+!*#wϯ o&]'@ )X\L׺95K<0 "EmVEt^#I٨XNW,_eU7`I4k _t %'q|gvBj ƽiyYu ^M$ulH )>aPcJ[ŨDUL֗P U!5?R!.l@25uSTMDqZ Q3(4LI:xPg Nmn|s" 8ftytFV!h{0K E ׼]cW:\ں?pulԱ4Ww%}*Q }>N ( ý*y~>ԶU##XOm•iOLEW3Ko$H;=JvCE/\e?lkWV iIEg6sD2`ܤK])!lZ Ad.+!ENRxmNUI@&+3Xl˅m%'7 6M_0&8y%'/Ar𬧅=41;G?N+F5Ϲ޺l)sMNe9\BP|5[TW!}`}]h>yuc /:j2zNg"F0.uBov_-?R!d h3ܘO6bz{Zy 9of瞧h< OQ>&gm=p~y~֪?>S8j̹O˅ͨUzz!G1n-b: (j;w'sMr7s0E)𠹠'ψ<UƬqxnPҗ(^2)GFLxxzUb ]Ƃ <$`Huwa@U-LR$k*8QR)_C* ˣ6XԎ l Lhhfߚp w&C?̒[4#~{7#_<'Z-j^]8hML-O}}#!'OE21*˧H`w>>z)en,hCaRTyNe3{MI<7spׅ=oL,zR{ڳCQcgNPpBi(>UEaި$#-D/7Փ=}R(z b6:8yYj#]13O}U ԎC 8s'&m{.׃UASfNgeJ8_4-Y1-r [1{o-4P'>%=`7lQ4GHunN&Ċ #giaO#Ze#rA?h8Ԙޠ:Hū]r>6*i[cšQ8uo c&h5p"GΠE@S'+Q`JW}+/n kybhvKE^|(QW.>;~^*fIq!ï/Θ ?]w.5V^RNXLkU)&k 4/~)/ xϓXI&-Qe ipMv)ǴNKC/J|wLq HIɾ'eT¯ƉM=y]1ׄ ~6UY6d'/@U8 hԓW~B]`NI^,(kӻhF pҡ~Hj0BtmO"G[Q3nJH_طkl GA<*B8UO ,ԏC‰AvxؓùBvn%c=S4=@SuOLsЃᖴ"ߩ.D0{IJoU]%A`T =VamE {Ll|U6} ^R˜ b$a9l5Rg |*xF,c:e2*k 2]Qvo[j,paH[B2 Z4b#+#M$xwK^LWV,$Q߂!U-mWdw(0Ӎ>;OiY&1)9m .u|an݁L,"2ư2;wf]+aH͆R)[*(5>bqb5Q.ziR-=ի;[Է=/͹<&ɽRQ!Jt<%u#GVU/!g+ϳnK d˥RhX`]<DVG8'qSw&ny&~&zHX㕀:Oh=>#k)o?V>eN .Cꂝ:bVڿEr&i®#MS9w]>aD3\Ψ~ǥDa@ M;W]UV\7_CyiaPGn_Nao!I³}25KL ?,Oeo* T>*-j6g d56~ sxq"r`8-vnB(vy霚tނAgcʭ!2aVLns詜ϸ3mdP(m_2ɘ9߈t;*YTiY9u N>Y(Dg,%I^º{4օiaxGC[]u˦StŐ;`7mDxlReܧ]f}B=WXWVbxme~=`<TJq?v8{#1gu#e6֑2Qz{GhtQ-PwBLlC*Z=ywBaT[ :#1oq]e"Bx/VT3$<-~TI8hbZw!mL Sknex\OX1~&{`H7p _] 5Z^Y5f$ ~<8Xn`L ˥b_Sz-J<x8K%>W )|IŒO˒cI GIJS4"˖_t45[ UT!HHkOwjr ߆a06u5|N&$6MR+_&ސ3:;֊eLKϷGf%ug(Pēv9$T!6G tȌpETiCUga6%%aS:?+3叿1ScmR"`zSu?K"#}~Z3K@q'~o6 NRX:qQХ< Yb)AGU bv d*,pȟSm^ .u7K%$v@ȔV'ciyW'syf(& 'J[58!D鏗qf.HQ3wax6;c+gfwYa!Ը&@xV~;É bcTh:y5m<U-$ pPwhEAOûCOta㨕oZ!M=yCn%0t~JfJo?[|lT1 ^^X7PGx֩ӆGDҡxwu=h<؄Dʝ* |5[5u:AOGrwvn؊B+ KoՇ8vBe._Gz dđdbecG5hז.p$.)"y<1zdfagv{e(!٢ X$>Go;M#{i/RC;2y@}6Ƨ? ҺDžfHZUSY5f_4EO VLS{Zg"H`\+{ZDf&R!F*6ŠKDh 3(ݞS{BXqj-z~JՉIh"#pR(M:1yy{1(R`K3UUPKI<{6!S;\J_76]E\ c+7Qq4^1B{s [X|vBIAz}ʁ;tٛHlQ ;Ds@W\?/d"/ Ka~pNN` P.ė24TDXJrhص0'2}D綽e19|ʹ"DR檌,;Vw$#M38,w;hbP{Bz﷖j 3j_Oց\2v2t>"C-cBn%&, P2]]Ye 4J|m0KBE7MQ9?]a՗նBk]vK1>hON 3eUʍ*_UyivodDNt4 5.@pO46a=Ҵ :Ͳt48* cFwpon,e+1G\Ul{#wr܄k&i9-R|M샯*%`ɹ\Vŷ R7McUiCbn{5^)~> I]# )ABt dQAA 7Ԁ1YZFw}BF'~Ÿ3ͷ=ziG1kHPv.SCkDj ۄcđL74=۷i=[ iP8lbCլ }؀xɼX1:naFB7v̌է`k )4+B[WHH&)Y^S0BA)qڟK[ۣUfpSrtORB.V2o N5fq[\:Y}ߚ+fRQqHAmqWMTd"Qk%2%8N9<|{si:;р'Z3 t]"1;" ZCG#m`m/@b\N2cbN+̵6OKdZEr_YB n#ޚA*Ք97 Hp{3IIXf3/.ϞU=KZ92>8pF"`CŨdwh**іNHɺ f(?3cH[hOk6':yh&@d !i1C&)?ENqY ;Ej<[9i.Xh[T탡r>rY.%t0/;#+_yE%(7yq1׆4kuAcG_@ X :)` .89BDo7ΨÂu_Ye__UNG|+]mZE(7Px"|s ׂ6 a^McE(Y ?~},i%=K%IڢW"7x`!+2wېU)wU ^2"LT]d:4[E;^OܒlQ`qa^aĉ1rNw"6b. e1FEEw3 b]enw@!e8Z!Q`EY2. ,ҥߠ5DP5d )G }Q?9݂x/9a,&޸?1ᢑWe(gj00ABlZSraNbgm! R#p;) H@Zk#`h# ,TuK 몀ty1P\^,m@]uD4tY-(O?\"#f`r{c7wιۓT)Qؖr/pK3U%7ًO8Fi;K*NՇ`ATR7502gd40E}6=4"H화xV)q5FmT9>iD/tC`//]hQܽ0y/vw/Gw0oF,T!%Uˆ3ױ\)\_cIDL'\I' -J!=vzy . ezjX͘Dմgx'a,9ӹǗi^& Zs9]4RIV=)@ /Hs17ŔY?FoYslApg naӴupX!o `3ak##{hJkO"-2"_n~'U@D"-\<}1 < H J0(<%nj`=fIbp:&3 ƯNWs!Z-Ӑy|MThN¸;&_l!WOPic,TMz@ڵg45aA8}HsѬAoa' vkCH UM|ZW=y:9;~3rд@e ϧso(@$_ʦfHɖdUR US|" mQy׼&/[>{3StAY`sY,UneC # Qh xV ?3ǚ ) ت3tqFO_dIC>w~fzEo/\3'\Cc  hz(Dg? EA,A}H2*Kj*cMu=ЮDcŒNJD tm41/]Z.dZb2 U1 `A5{Uhwpv!m ϥ-N?u'\: Qtڬ`Fj=B8x&IK!KAmnRʃF.;U^;k LV\: 棬?4ķYbUJL GSW6|M4Ե"rs ,)v,f5@ gu\ I3S=#B`uBIPz'1 oRmX8X(>; ZZopۛa*^HQ-[ gA7}2!!eΆu/VǨS5_p$Ҽ䮟x.wb8r33[ur6Y4.`}`Af1\|"-VE)ŧPidd!p N\-؁FoIBBC3o,W;w#$fQx|O^~gː/&zoV/J;J>եX͂ p$ar/Lďy9A_I3ًPcYh뇰ȦAF7#cTeNFdqG2ldO#sPD(Bc3 D+?'iٓk`? ԈtqrmTP8K~EvQJ)t&*ѫkUꓕ1Gvl(H}{>p8i` vIBuӏ.΁ v CD%З6>ۧK#@ο?;H~_N z|:H!weKZB ^(K6>7dVyxq^DFAO]z{@VM@%!ѸO<^> aK!/V+Ao81&ٹw5{C"a_ 0n3P4upt/=S:)ĚOeJaA<~jWf FI?t$ɄDjgWP Wv(}KS2ÛJ5Q:ŔP\0)-Jkb%Zc/m]^GbBF)f{G]sduNe&Bg`XR ~ U(G}Th!!-F*2_L';gBme{ĎMTQ0ods4K.I~Nγ1k$@zYٚ3*H7u: i%)(!~Qkp%ւ?DSM߶)(#@(<|ry0b1{`# moVi҉A\N;o]˕vxodltጏ95ll—F]oaq _o3UBv 쿇15{D,ax$m8m73!뷾sM7/a>oO }JT%*um/iR F 8ThJGxG9 /J--rOL_[gVE)@: /~539])P7em˜߻kAh_ڀ\}3ؒ]CAqx#,ހKezڳo|@P-p+᯻c"|hl^Q1͖g989KSnڼaY`^߯4rev휒xKW+) Tji{LCzP>ocxӠ5N~D1v\w\0~Y c߭pw:ٟ~^m5B!p, )( KoxEjgɩrҦbk.u@}m'Ȍv,Ý4T]љ<"u?Ojn؅hxh} orqIZq 7$']زbM!tB@8`x'm%`# 7Js#ٕT pt)2 VZ+)] tqwI9ք>Hc6ick2bc* og/$ M"0s3RXS{ۓW3pl!e<*E: z[;SŒP6K(c()eGP6h o4F$6,-'N>&Vup bn~(@ 2[_N :iFWWuOc:TayeZ +m/ [MT5Ģ/ub|Gՙ`M,y_ O&`T6 7ݭ( ˆcI է (mF#|d`QqEh."Ԭ$%ҔKr dTDc;)Cc|g^F j/X<me=szP Q9bfF^:af@41|R>PӆFe0FВfn&cJYEǿ v=@CV[YG^5xoX[f,r&:צGcҒ^(IL!&s}2 .;=E^F0I/p"Z%Ӄt2aA]ó +dB@ML{EA8$k*nq@gj"iF"ݴF0Q?xJ0!sרzq1!{J%N#z-+cү(/IdkyU ҠsVC Hʘ= 'xӑ-kV}ʛ;{!;,LbƤfƳW.f\h>FA_]Q, okX$@9p4(B45kEvHoN+rX4yྂmQA $5n%6dats땓4@Y7Λww?_y>"Q~ȉ<6TK]l22<b4;jjܣ@s2$PX,1GhxT ^(ׁ>2t}/>̠'w)s ܹ; *tH>m>cSFWnDd 󶷈XZC:`G`i#x9"WNs{FF@ccITSCk9bԈ;֦W\ҤJA{,IwE-&g`zg.Ͷ8ȵk_U ,*)ή`#0GvYů$Z;X1A0xMX.]ؽ\V$y;b:X-1Z˻Dv۔V5"ݣ\iFHw>7hvED2k'G,Hz'?5VM.]"ii)"}5`ՙzsNzD͜u囇g=S\MN1xT\zG'f"A.m|ǎY7V*˹48xfDJ!:6vZ-xܫ9?~EWu`پ;U6l3k]yƫv4pUiCP܏R}5ԽvC`+Qf@A2:aX,G)ϰTb!prc<21_5imsw|&s0b!?wǥޟؓnb@8! 0{;w,+|SnEtuyϾsVꄋ'!*Ҟ1%O^,Ы,,FAB7{7,ߊJ..U{ɓ'Oه }5*`7?GhF]^nH֤A=.Fdi~5k߻^J[$G b28Y|:^Yu4]ru9SS[O2"j`=}j#Ksqx\1,\[4@V^{MrHRN1&B]/Bh PCPAX"m&+CbYC1##o(+C5''ׯZ0-۽6pnWfJpeKo><ȃØGNVC Z|Ĺ.a2|\ R h(F{o6HP?&~8 뉎E`4钀"ęs%+NZ<&OOL/QE %Ŷ1ob*o h`;p:Qukgw>foPa^^>4 ?2~'t_I 10 ^\4 SJt^b0wfʽ - ³16۹Kn`cU9}"-s g(p˽b* D *$Ѣy iq?;+ hfΦ'e AB#Ӆ-ʟ ]ޜ\Y4/r6[! !8t蹞# ;xR&_챥;Fu (ݨFeu`\(Ux_@Ϲ7N葖H?L $D4?i ܢj!$! @q6ןPUG*}ieU5̱DkP]9ӤjlT[//IMug#!O<\[_\_t$ 0MMPwXC -впBDGiH*lQp;)`@(7f)>VX'bG45z F݈^>@zc# OYȚmtdBâY9Y_§SIkI6:EX@aG0X!ʃxBlPdUK?W{|IeXb4\#杙ž*dK'4W}"V.Z\L4ʴ gѓK aSrh1g J3 Ee%AnM%όh,^m: @1,l.Q>1@jꈼK|S3x9kGZu7{YHH!5x -('ݖ$@^!A W߹*-o/ hnyy2iF:Q55w1M;uCUc^n7#YM+xRU4H+?S.ZRM_fʞ"8ˀ{Mvbn Ί-P3H$+wj|]aEuBt';16Ky}j {,c^w73Wv Vw `'ӄh`8 >p03gOcP@EC+G 0ny6w%bgP y8Hm( |(VsVf))Ϡ n`j\$Str6. N0 ª#I@"O]Uf;Ks-#'YǒVfv]#!~2u|dCr$cPC؀Ae= z&CGCarXcC;$1?\xZLn0a`7?ilT G7?g8L r<)dAw-EV]-v#2y|o8(d6l?څNyOjUH͋{mG-oumarMFH$/lH>Ġ{ 1:?R<IJImou!1@-L&pv*"tߦ%g=k?bm0?$dy˱4`{; @ 33#ި5!d=sbb6$h'f,'.olhj:ɡW#HMׂo1$V9YprR(1yMשqP+22vgs"qG`dY{RY|&&[-~Gne=;:odWq&U3vF{9W MbY;6oޟiS輢xsU*:˼{ 'Jc0;,s̭da#69)su{~mCrYZX}>6cYzjZ'68Rp*tKU3ӣnMoV5T-4zj! {x%@s?J!_'>@יhWO{"ָ+ OHQ6#ee%[6 GAyd+#.L6$i5slqӒx7&nBx iu5^wY~sB 2u]oCH9:)#H* {aʲ Y|psBcҶ7$N꧳iqz.4jڗq%,zɾ~R;W0 R^؝lg 紁{5SzӅ1 & E`LoX ]_աHGMKR(e̢!F=={ ~ٱ,ҁB;DRF z_4yv 7:u[3.:s-*f@5+Qr],nvT-d2袴+*J b?Gظ:zpSԵ?lECbˋ]`ZЖ_/"9჈ə`#!Z}3P[`0<9s@^K!A &iiÜL햢8YBUF.=4iP`Z0l¼ }m1R!lBVwm؋}J6N}JP%eWco^PR۳EC 5 ؀ Z{Xٙ?gM]&~FU93>FvD$T-YeJ-{c\&a !.;005ƨ*+kuNrOf Uٺ[Kϝ(@lx /RԻ9>j? $Du}^ T+I6DHHjTEbʨ燞eM9kx58ey>$k`^|`dj< *_W:Wl)ބԑbf@Y瘟uVtτ_BNNOmN (^3dcoENMVO:j֯ ͫZD0AB5GIAŃ|yy{ B9%Vc'b5}9\dղlԌ~erjdc9-bcslU&%J(6ڼ?27,_r hUap g%h&RSWif K)D"-ZC055|=kvPΙGW 9c{%_@xSК!Ւ$$dĪ rO4xk-8~ : ƽCOg{FXM l?q&tT +G`A"z]jFk f30Ǵm*~巗?K(%uZZ耙}&-kA-?iҢ=EwڟӃ'-rÏeTOE4gfS8Nj?U{ѭVL%< ;tĚ@a.Jsj}UHѢ(԰q孶(޲ӈ|(:jOc漻=HoHx&S)@Dk7[->I*G5abSt Y[ 9&LK˔+zNec˪JO>qFI*֛ {-c@3ISʄCHx/Bݎlx(&'<6-[NAis>LK|%?>aU6X}p̊slIB='sv6ELLK\1?OZ`7kд=EPpz!xM͇ h;5j5z/)UǤnBQv8UQQ > _* w mWeAm{-`6NZc?TGs@7ygGS0~OorlwM>RRxd@Dv˯ށ3͉&>,ԥn`pWwVhL "Kw =kX8DwWޙѭ3KX ,O/P,1OnIhn)ǩ&\ew5igwrQKTԎJLWn>!p6EA!] [g/4Y^ VC.xcK|Q .fH7@ۙu=$8u<quXݍ?m &8O1&"z)fŋ}GAw@pW WxrL^I6yhB5=+X+!"_`pECT}z[S}qdHJ9u7 K gɈO{ G*U_(*jiԂGOޔefa~|GHrxtnJ:%%,gosaELm"E/nxSsKsa8n6P\ cHBȹ }en&2В*?kZ$2[v Pt'_K8l_ >l7R)S[gRIH1[*" N28td9IU%g_U܃]\ .pG9[Pn)+: —ǀ*G)vV-I_JT[ŁZlץ\:˟r9|d"Cӹ 6޲ XkdJc: !.9z"O[+"䲽00C䷦sw e WNݒ(CWTj5kre !_ @8)aG} GIV  ]^"޹MŸ 5\6Ӎ^T;SOMrƵjb |PcYOfzE?r孤!U#A%tKTP80Cl`G&[õVD=?=/9 hIB"hT GhWZEN6|+XgĆk3xFC 5t沝"]lKr5jQCo o4 -z;rP@oEo/x\3yS8n=*8#j(R+(4'`\Uۜ-j/r r W(UQQF^nZ (l;}'oR&ݤMGJD̒C=K'wlZ|IN/ ^$o M "ǨH0'ޜ7EK-\ MjKM;Lɻâ"`~Zf! ~L9$H,<a͉:2Toj> X#`jV?F ȭ4;*.ydY2\WL`< JEhNso^e5CX+gQ@ Ipd@*ɑvЭƤ/ǵ616*߻&U)KnK8wrg1t?rm_)ʮ=%wd.LOF[lJ>r))݀y91S29_Y~|- ҫ&!CzVDQ{ =M1*RcaRqu WZ#hI&_E&Lؙvkc<$4``wnFm0.32$}.*V2+4*jh$"[}՟D&[(x}M "kj::#-}ꕻ`H K3m⸕ `ӓ+J ޚ?:Pt3*nў܎tP=@;۫峥[D5~b}hQE3T̩jo214vf%6'*TL7Z3>jGc d*!T"\-RYr]Y>(kˊQcUΞVC` OAC%s<:j W91|co\. d!6|9&$Z̋ 72/=2UUsi);ZS#\ˋjɊkVz+/&Yep>H6K>29ITBפ:Sݒ"y 1TfHQWNͽ,~%1u-U+]%P r 1 'n#f8l#>b;nAu4HΕ΍j*2qNQJ UHFP 1=lZyoZ$XVLsd3|?drtg< y;nR?å7 RN_$'0>1٥9Ptf !zW$jlK>q}0NO-w\DžsXha ?+p'1D |Z)_m *9.xҸ --D%F2n 8/8R#6^NmH m.1iBP ަ% ۇ"A}S _I` D`1^4Oq;ٌBYq\|vzcj2Hu=Ks3f,Qo$Z.c~UrI ''T/;t]mbA9[2rԼO/UtҿY3.(݌&Ƒme@-y a@>9.r`^2P^R8U0?v݈w4lwz#_{kt36 Ss. yAeቕɹ;ۢ!s %)|=T%2)dAf*~3,KhG {"kr<'wnJC*BBP}aSR-Zܸ5 yT)Bup<)ʫ":d HA=  kGtXn1RQ=XT1bIc=O}ՒAF$&6!%"t <On\ PR|%FnI09%~BwxQI޺2 c2_@zەܧ??ᨪ 3ׂ1c`biZEzC Oƒ]N|՗ 9>t [16B^A5˙DΞ^*Dou"x'ys <@tud&+ y/[G)^a"ř&[H$ÜArao[ S0j} EX+}rl|e^drR$"\ڊned1 +@x\5H"wB|qnANY[jx ]nlI'o2^7OJd^Vȶ8iWq_& Z]$!ºA?oc h,ac,qH' \X@J]5 /[qU?I7Ja':c+( Ƕ-tJPM9gJ 9{wd_]> ;S/]NsiL6w _SSҕ4I!}w<5N""PZs3LcVSq6lT!D>mڋIP,K=;U=d%yp-y{,1*LDZI,ҷd>4Pئx<5D[+$D~I=Kv˷K|0.:?D)A1+`mQ^,UToMOYo=WVhRheNMB{Iny&UmmrW:!؎o [;}7S3S Ef N$B&yg;ܕ"R2V =5-pO_F{z?>޳nVjJMX h+mXՒCe(&ڻ??]OөO# aOdaƢMeR }Y3(p?ն|{vA ;̔ ۛ~E#pBW WOb(-q٬= ]uҿvëހR~ab962MvSU٫k޴D wA/%Bj.QuWۇ-+ycֽ*#T\ʤk<)3@n]2Ǝ#9b; TOΎC+>^MIdVI01sG~;Fg/^ԍ}]?lg! ivxVOY9K%;ޒlM-*.褏f 㯺I jSΎqAm~.?JCҦ֟ER;I gFl/xb߾_ jY R0yY2mhNSaiw.4{c^.ݭ'c\FW$*oO.L7~&\mI @v!q 6p\tTu %fkff,T9&{XB!\.FT4#ŠT/\sۋ\YL8-u]{i&fEJC:x"UvNQUm9/)붓 )wS%kcH < X|;?2R6JvƀsF歳\?L)zrkIK&8Ǫvs^_A=oT" -呖B#[& a7P̫|X;w0<%s&`s^'ѲC4Ks./IM @IWGz)_+ѰpH:K0[ ,|Sy2ؒtPXYoLH{!`ؐ''Dg 9 Qza<,+E=I<:ظ`VPXs8l}8bN.*vm+m#>@`Uuf*fj§S;v&1,an,c]Jdh cp~SnT%C<Ȱ 5n*PC_ߘwnؽVdՕ68^2l g!H-ZU/h"{8d*o sL gFɳDD<])I.˹2lljڠ?qxf?:.Fp=XK?~AAvfq%0Tc= Ё&o oOmI .NzdŭsRڪ`ʽWT xiZ=8.e2,'KFd ۫wFbI‰"GĚK'c)O3϶:0+\T&~~ D*FגiK-fBdS"!4^ Vϑ3Ighkt[[}lljT:&Wz>U0ʬO m[zmT}xD(AE[p#yf7ZL`x"YX]Œw|̤GEi]iʁ%8RMKs@Y1<ڮ?,e$[\.^&+o5\^劜u/5IG=L:-ˍ$z^>* oH26Ԫ&-fK.o2e?Jo2`hMU!vn~Sprx=<-uӄx7_;h P=t-ymwu?ʹn8ssIOf%cun^ShhT#5_6c`5 nE9u6W ǜ3./dv2Z 紾С- >M!6sh%\A$V.8)MP?Ve"-Od6 8j˴80,R6(Sc[Xqs36o*-@en7# dH.pk`.-fFosm֡z:xyL ^ѥ_s ,yݽ/3w'heET]J,Bg♟x.ۆTAS Nf_&If5y=bmlz(]\K/7-W0X}W7{RܰMסӜ_]zMla|G1IyXN򵛊/o{:,mNwvl۟6bҾXLG.Aڼ(\ᵙ +jࢣj?{,P_'7$ tЅKmA-G{"H3˻Ee`Nvdm&`gLܽp4fR 쭇qͳZo3Asad\P%1|'BHQUKG /[n;RPN@v7>kÊ/CL2WW2 b[VW&tMҚZ6RWT$N,O' Pa/N78Gbaް9i^jF20y'gX]5'#FO5\Hm; Й* Oio12?ㅴ6T9qq;M"ˌDi k`ܼFvZyl'vO_p^K{sH|L+8B$3n%%D+6]>VKv"uc'efZ\}`;u;xBM-|j9A%'W3_Lhv/x]`#v(x*ykf,m"ϰ궂%n/+ >reݓV<̳mb`ȏ sB|mYI ta1H8-=<1YđݬfUY/{`|EUlx!矶OcY?B !1'QqtV?h+!q a?qZ@eS۩vB<+PjXa~Փpi )Dh0@v=IµkURB81-[*PHcy5b?{<ʹ+V.#+q<֐>h5)A6s{,GJcK҉-(Fϭ{C͏gM TW-{AӝմAa飯uHDr$&OJ!bO ,Gzju Sg:a}mh G~CKvpaẌByPp*?,mMQFz6՘%pNI]G1,O5 S7w ;_w/e rxt.Z 8Uz+2K5餌 *R W:rݸ49s^Aͤ=ޫXfpg]lRf)Z%/\$Ỳ#uBf/ XNsA ?]Z͝cň:HIc!RW|# cU&ڍw۾/2a6e#vO7x%pd+] s8r@XÏdH؟I~kd:$3{vڑT3 <}&i$+Lc3ȈĮv"0njn]r?ıHhia*E+٢[uNOyaHX!-L%#% /k\loqz"MN :l,kL4t}(-7ͱ`-̠9滆|v K_)_+!ҎF׵c H`jHO^;fD\W)sa sƫGraL@7J94%G҆l(tz\>0#sH_7OŔv):*3Vg`}3::IwZDo?$ɚ9*pU8%#Ϭ l[ۍ%zZY|6v$>!ӷ=;T͍@25.v}xdCW|`JJԽcV}!gTտ f_1 Fc1㱽eɵп㘰?IW)`@l;tt쓖bO$dUL^J@pReTN]l(ʟ<7~\\nxVf53e /ˎJ͍?"+W2^^j;#iDruJSL+ oz $M.(_ڍ8)KD틛ɦK5ӏL:jgVchҥ>ww [6 S2%楻9:8):c'̉!N#QW\\MiY"u<.d>&yo٩*&-uF(c)oȩx,m阽sH݇~Y1 <zŦ1cs>.VThh?;dR7|tji,9coZKNՓ ~W@i<>NoAukJR|JlO+y)TKgk5$NF\s+=l^9(x^2¸4TV}`X(cX| L 0ڣC^he۰L u?SFݙsD ^SȻHm$~Iu7hچk.K@UQRvztyhgC Y PX}g^k0 yT|:Gz`Ɂ _oUL,鸒%5kYaݫrMw!iEaCSK#$Hx --0~k[0ǡp|@ 07Aߎ$2g۫w&Mؖg{sL' K{\ݗZp}󯝹c 7r</ .9ORDjNBWLڭOJvVDťu+;x&qbxݨⰹKβs|yh@A5tTԙVB28ߣB5v 7e2~aR`qV:GG# x @Wd[~3,i%ihݘbùϱOTd;Ҝ#"pubO1JP{iȂM5|aCʬlk2if:F.OeQ#k02#B?`i$/B1R[7mم 69%HVd;t$=/)+Ģa&'99 Sȥ/np\4䲣$fP Z?LVI˜þSR3cӘکcN_&@) ~hkVe}m7tԖĖyU.'NK˜ :t]DU@lQTwљN#BLQ}Ox!V6L9%@žߞ6`ֈ=n&0È9pWG:[YvyfLPCcJ6ЕdRWƩo,t?ME`Hh\|/)Se+K])rWdMDDrߣR;1&.l LSVvr[R 45? B3ϧɒ0ib?f[)7_:wQ+\hQqxd]$%==-ŷpGo>׻" Ʃ F.Ph.,9pl7VgI<šg*Fhxs>V}1Ƽ )kM糋\(UbŷL&,`()D "orq/]<nȭУp %z,~2Pzڛm; s U~'y499%<\ԥHr$/9,lV,p[a3<weHZ5:h\!>ڞ؋$o>ROHH\$eSk2AhN˵uDCJVwˆOlR4\ 'Pw`o0Q`Z"4m?P7Z1p/ܛNawb#eg'q+{7ъh4\OX2[8Jۼg|ZηGz=8«sGVlf3o+CsKHg 5GKQ[5c#s[Q]ʷCѡf`7奜vhU]K"8&wTjB=J@WY( 0=d2ZG+Z$4=8 _&wB2f=MߢhF[jUXh5#_Em[Dd9?\)'IQuqV~-Ud-*V[<"ƀaa*H8ΖF^MGɥlZ钡5$ޏ6=]G@庍9 lkRpUDn*qQvd@~ y9j6֝{v\Bw aG"c/D u\vLڳU@c܁M3hA]|NK)sdg2Jz|up;5W"k]k_*F!]V;11RAUfEL"2c`=yuzL _ &I`imt 7f]kzʟ5~7wMqv(ynm}()}ӿUxDOބEW2t0-qdl2k=Jnn|UZ *ŮY/pq#XW_TYh1V玝PtxR]ಉ=O&>21FA.G"=|ʛ˱siuާeMwaR#^\Z1jN;tpa e f؞FC~tV _['t8Y qG-bG{ r/Ҍ2HZ']{n= UW3p#x'q!]}$sNL7Dx诣P $'PYZ6`ՄՓ_@[wN_N{ OmumV1ʐ@^_[gR@kp60'I!NR(/}Wy1l }:jO%+@9t9Ӈa!|c< /W4I Rqm2gtȐ!42d*0(Rdk1pD*Թ:3@"o..w CCJ#`n 21"4s*"Za՛Ym*cy|3N(KIs])KH./o6k8\(ݗ( Ngؼ9>l¦ZVTd&kh!IA]`d߀A(6& 3T5F=#$gۗL! %IHQ܅3i F(7TA&ta, =X mlN{4uk-DԖkpXL'p;o71Z^Diwa-Ar_mj_UA"0|TڰgZ;r,?^E0d f nfL''Qڪ(n)*f"E6R*?% CXJ-,>.[;vf6kre>n}e9)Hy&5 bP;3;~#}(:v]o>=692,Ǖ##ݾ `Q8;X 2F[!-ʆ 'YEI^GyB"Syܶ͟dpb{N)A$& Z[N\8mJ{[8'xT/B{UId(u8{@nRa& I=ƋsT;?v/m Py`Q#|?-PlNꜫ':\DBF0vΣ9EFg(ZwY-Cl3m>I6kv+691&@ߺ{,.+R_vj.FEۢg"FSG+a~md JIrT["q_w&uX_{˽OOQ),5D MvS3.;502tP}8hT>t\_;&FEWՓPfny(.gE[$1]DԄ2Q(rךWVO5{/ =*iϪGŨ'=G_sӠ?Ef+ !C_"!z&g1){lox:B2#[u /q9dC< aL]ͼjS$NwrYAo':o[($ -\ {i"U)iO :">Y͈ڟfm XnE9P Zk< IT 2򅡶ОIb*2Ώ ٓ|*I͖#,[6zȦ&3x߆R%6vFdax SZ[Yl&L~KnXcQg8{ci#ukŌO/hKn=Y, _m!QY@4rIXTJI Z#M ."\ >􉥢׽Z 1'`Y{Cg[L[|Dju*`m&"0< ɋj2=K9󶔾<4X8\[VJ24PрbZ ?9"sFz ÆJ{H@k"ɌF)ȈVOv{uZhy ΧØX-,8m"T$LL`H{H!,>( I /F|XҥVݏDWAw_;yg茐{ѝ{4$'M oCl5߇?K9aX&O7Hoa ~NR&z;TXaQ A[śD>b5 U#C0 !3.j) /HCY8PxPe=a/ݓ.BnG k?LQXT^Kmԧ?܋2C=KutͨPں˅g)F; 7X08 Xݣj3͔ëLx,+܆Vc'otE;<+԰S(E6HX /}?~)kS*B! xT;S0I V 9_d+goLqf|o1~laAkk!aqIϊlrҺPmga^@̀H+X '' f}lF:Cp5 BOp )P Mi+r2T-&!r+GwP9gnzOMI [Wk{iO5㳏kkE @gj(sVTLq^l7(`o9,?dug|<‡[bXi.rD Ŧ-vU 0yn^ 9fMp܍"[]L5p a=OؓX+ȾfىȱQ5F3^M:&u 2+L`$dee=fp9DuKyH4 9lj t723kE%VjwjurV_OGl-~{]8^݊F}m} uV (![9Q푂!;\~嚍N4 SGtƕ<:]'Ex 7J AnXd P<o0/SLQyS:{"_Z-oFF|dJ-YVkM01VdyA~:Tjs]ywxh@1L%cXT0Mg~,zƑixMz,~jR'n_PCBn}tở^آGBN]-iiy2a?i{fpˉú_kxw(װI6-'9 gWx!  Y:^-Ѻdz Ez x$"* W݉L'?Zl0#ns3 | uwP{}CwH(s+`vm`_0͋A #zb6=`9/$bؚ775tPL%"3fTK^ l3x*qjnOQ[^Czd#fg 2* Y(e6WSge3j:t]6- tǁ7T(~Wi&ȺXTJnגkע³n)K?d`f\&&e3CbŀD GZ}[N^>p-RC=CHP?nMq;Ĺ]f@z 7hL$O©K!?_YVBu02mج^]70~dw_a|Ɔ{rNjqЁxDmdAP82 Q{}NPhڻj מ36˹i5N>7TԺ*4K[Eߨfؚ;my+ lo!$So8'M׎ )L KJ}P Cu QVN|ΜT,@ʜ2ro `,DT̸U3yAVnoDOl)0c$6gsAgFki,7y9dP K]{Rf GW^E 8ޤح&$Mo*:~+ʋX&jϣHRA`Jjvj['?PuBjz 퉩yqLfw/hMi",F|URP>hZ_SgwI١_K(u0La5ĦWA`B7!0Z6M[n}?I0z9@<(9Y@fwMԦ-ZE#Ed5~s ąo*ES/5oWq ]ʻK2i! 2@=I@IdVNU<PqMaGjR|FS v[ ~T`gPbM \:f_7Vu" ݫzf}V{ե?!7ڸu G'<įJBY?6V꧰{ޥoh, ?'f\(?5>%4aP;~{&9]9Obd!+d(w:0r(ff?V590 ICH\h`dJ\uξ;BZ4 !İ^ŶT'IHf[]enPD s;H3K]o {iԏGGCzYfùO\7!K&ͮ<8\_x1yBG}tFn%*ϚlI|])Z2JAႂzT-|A -=bU"(3144xshޝxOBx8rq^fmo5eHDArBVf- ;NQ.=iE[VmnaT'M^v7eނdw ɽ{7kj]W޿f/NubE(9YLY΄ BXj09lMM5zhM=>0HB?ȵޔ5dA+wӐ)J~#ó.z WlxOJe]sc߹q6C}%3Q^ 3ǎ3,=EqalE+lMkfMH8>O[SV\)1=V"vaQZ=MJǀ2M,R=l0j̬E V+dL}G{Y6AU`m|O{ih2&R/eStHtOጏ1)s_7Q{WlT\Y֬LNctK]罶u%^ZSgZ$:bZRR ROT?iʨ4J)o_M?^/pL!VbS d3-qBQ<#:uLw4ԕzz_764Z!i:キ!>8<McV:%NrM#qaRT 3ŜpWo0 5=k8>2xkU LoLPݾ&  >T'o=PVxH; 6*5TLxFO<=8zXZayHn  |\5wlIjt4d.qS]:[-NZ.OPoXrsUͽ#wK\R.W 0謹^YضsN3L?a"$(8^h$sP];)-|0<+hE ^FgKP&:E\TXùd.>kUh1 6N6ϳRƦ/y^Bۄ Ww~']60F̬Hn, ٭d $6/R#XIO!cWI @e=H/)u r3җd\Q)\s,Xa 1SeP[[*8U?W7?EV=)-d^,Gɮ(a-_Y+c5_;Un *f3L t}nMNVe*z(1~P]ك`q2טCȬi6Coh"Xx mF65'CqE \ʻn+K94ݫ:i R]qnHCһsf5e fT|m9L~nbjXE}(_.ǁF˹\<-LN|ePNi@b 1`?d`B5`V2nFL\5:#g\'sGt^D(#HQ)-&t oz "-)-zz3`0 m eUQY7 fWH'.[#2Ht}5/('Z``;l Vn@T)# 'pK.>gh[E9#VE*g!Am-uFm&}:O;\0S[MTꆒ;adM/%~]o|FY-+ ?"8*#$u]WksgHբ ~ uwé nD0\sj,*fo\>+3OcMb !,w jxyz5V%?c` \a:Ԯ>/ -m~*R8˓bgƿ?N@>}}/XF@w 4'S4D'wO6B&+mrRZ!Z yyTP@_9 htgZ6.9Z;[ J60ы׮Qi 8jo>ijy¯3눛)36:2E‰nъ1>"i@@pL10Pz|CV孹> i&g9w֗!jY8&< oY^]ybqg)9~MB3EF- ]M]D#6;h镱i*`AQ.3E䒂6<-UT_/0Ҋ&gA%i !6sD%2ՉSxmh/j*V6XcN#KP3)^yghv` g^.LS ׬y뇳$6{ss+v̻zuY2z2`09!%xD7O&63pˑݵ>O8 bh+]:zl~͞{<?R"`Q)_ސO "v5г[b¯吟 J쟭]q <6\R/r:_@d4-pP׫~XHXO?&/ S=׍l5yoER~.”;GqSp֘tkpZay `Ϣ~_R6m|u5,8L=m3!S$KTM֢ha͗A,ffS9`qvQQ+ꅺӔ*uxji[\%u߱n)\&LhkGaf*K!#CƞN#BBil7&A$:^6J鱞{=*GpyB;|5mx\ݪ*xfcku&P ey(NNB+4SOG*HrJlPWO[j^B<e9RӺ<) e_YɴHtWuش;ip|J?]UZ>]jAbX5.Fwxj0͢aЋVQ+nS+ vݺ80L/iMp iLhQhC+.X&PHX+%56`r=S\vU@"RĢP6c|X}ˌ>ӛA]$~ݴKE@n,8^r4"IeT's =Έi7S1yٗ3=uS4#񴃅@[fه>Xj glx ݗl\2_cLv@I3+K5\ o- Ԓz*J9R]n`9j\5W*zF&usFU]҆BS[|΄3~7l8~ n':13&qh6jBbm1XT.~k-F@P%Ō1ƫ+{s? XgäDO >6]Xh%JH*QO6Wh%ڍ)d2s3X~ϯjݹJ(ˏpZl`[)uj ̎tF9!)js6rᖝ1e=@Y J=KH+:Gs::Y G_ 0?; +oM{t>Fg^d?ľ&mS7SWhĂQ$h<⒦_A8-=DJL"tiY鞕/h2kuOӒ1ȨGJl^M({-䏮 \m$ȭwzM6f2+O3|D^Q&_:V(/Dys+CAխecI(ʄd"I@- rӫTt'Chë̚|~RVkzF*"{ћ?i1Qz!b)a~[7cehZ5H͜aGy̸ G+Cڧ 4esxfޯ!ar\J#fuʌF,ț)E>ER(g #8ڮg% #]Yƨ7>2}Tu:'/|nK-ṯwdtAd{HH> +=8K|8/z/tdY͆W`v_zk!}@STSPIOaL;J8 CtD* #2|%?IKxb'qB3Oo[`ι3<\3N؁\\ੴ[e.$6.mtX<4ql&Əۄ)f]Ͱ?Zzx J4C8QN ڶ)%m&NI-C1j>0x @:% &؅edz, us! y'ڬ 7'=ܴUO@P5~6Xj3]N#ZD?h&K!e3Y VlZ}Qf9)`W[-!ȿv*}cV >G'!Kp^cI/ap< |z}bAEޅwjj܄:0IofΚ7Dx}jtϿXVj=r)BG&Vn Bs6)+euZ -Nh^5t&\O cRv%l'%z{] aޡݾ[ 9s& / NɝR0'PG&-X-5G Vnw3BL%EɊ-eI-πR_7yTkOS`7Q2:5i5 @L#y۪=Ύs#L\?'I19ЄŭTɑLE2,K5/ZP3tF.C+J۩W{QD!iVK/{^yS7FN'jk\F"--Ih`|k^vRk_{nxC; <>wx괻Q_@ }мv cA/3pO`F`)nbE{>fl΂/'[4"-&+P=}#"4\v(Tj/9OUL:nֻtZEfPAbT1)==i G5{%M7եL*" /aKDkGI:+>6ŭи{P44Tu3,ǐfH4 .LNOAu^bӣY . \VcN8z20E:$ܔ~|+h g*--S]\c>D[psϩlFz9wlBt~|;uPj3nVsfHJ Uvq1_Ή/~hQ@pMtx&çPkeO:+pl((6GӪHYE{ .hwlzoJ7ngX#[8JX "ԧ)M.zB (h8o,8!VH:,hyWYXDv(WY|?k?[(cdÂbBֈͪߡX^ i)_AsnhvuEh] GM/A:N_y6< 5@ .ږ3}wo\ Ty>95.x2z};lDtPT>p ""vc+p,fH4AxA󸙤(0Ю%m-r)L MdJm<ù5cp! 1wg j4LagzLg6 oCN{:y]"rp_^HS}*Łbk@7(2׏3nkj?6UUat.Pz&[-H%/@Cu aZ,Ru8ϨHؓ/, å܆YkLXU+(۫u6пmj_Ė1$%62[_ ?زƈeRժZTcW*xW"T}Z&םQIer׼?,񪄉BR5o40(n'd4)^iωi~d(^@S8 +L\_yH.n9zlqQ;־GL\$Z[hMi=okYtZ{&38iσogmi`.ʓ/@Phdo^7;V-?$oNzFb,NQ2(IVYO|B֒^~-'xH/O5UO!h]б־8dJqز^7W[, {tw0,lC@KY(!v*0]%m*pk]f oD &>،w^>ٱ| {I}l_ )\4p+ݰ'fn=6.jۧ9FyI7ۄiʼ[u/e\9W8~k!8 ;9CLN0Du3 o-LGpMlkLb2f.%q%g@]{9׷Q@et!06 5y{m eqS7z|~QҦYDB ]h5Iu&P&7$zEba2z͡=26IXK=+2탓l(PLKK-Rs[gq'hMe,a׹ץqV9 /P3СhOJA8lC={tƣF\J:"qEB+}fkwxp7&fY԰[G ׇ)T5)sa&t?i[B\Ro}BJ"(3SCsX**4NyTn|_y،ԋbEb  ڦW߮*n&U Fo!@w ^ ~RuKy6rvTT㐳uw3XwnT((n4HI#kEdCΥ)O:A2̫GWO̭ۂvg|hR߂Hk|.E}gq<y*FHpRS77ɘWBysrEJO641{H˯vě&0?X Qپ(qRZaЧ9Wџܐv.шڵN*.]T/ AGd-0/eB'7,a׈ ysoڭa8Q9M@')oHdDWSwo[l5Lh)_/9T2 W"Ls4ժ@;!+SP-&@cVku +:.uжТH)bhi9`n#nyMTW"8u;T~@^-׫Ap0?cy}8cTcǢ,x65|V)Q+I$tDW/%b~n3׾pl̀ҷ WYN~|TQnYh1FaM:._$P%/j4#ΐf|sE_VGάmI6= nWZ '/H"#*ʑą7#Q[N^X?u˝TLl_ؿDf12£ B ?̧ۧ;xoq漊D!-ٱỳliXT$@*V6kY׷4Bp^]Jg*o^.[:E} yH2n4If-F9^b}!7pD?]^hkcI@O ZfC^ IZI2z$HhqSt=aׅx#*yOg~q~^˫#TCܿ4ݪ&CV3ZeFehxYEgnȷ8ULVܜ7đ|va%6@\F_Ԃ/ġtoкMf12h$0Us`k82):Z,sDUÑ{)<(^kdӍ#*-O-1=Sը~9xüN8MQi4G/W`wYK&))6(ivm^Ͷ<{"%Uj@Kj"+fXҸDnC./d)ʸz-A(9<u!t|l!J3:)+,113̵lڕTBAl!u"C||r3RT;[-] 7;Bǽ==1ȱltH|y`6xw.t\mlab1r@}R|FNktT!Bdw)Y}y}?VDaM@*37r+tvF*F:,cG%Uf/l抌|wu=}A2ODE7{`RW6/׽R c$>X\?;B=q L/U|PI9Ğ1lv/җOj1?-s/;ۘ_&H\zp߱-]:a$,5j8"80ޜ_# $Ɗ3-lٯ,jpV.)C{_5='j;>Hff wːcܣYG,%Pz>`'[֧'À[ 4QdSRt/IX8R?_ESѩbQ׬y!7,ɴKwT ka)ZYFDUA- *1cI.+Hì:WJWd-p&ƙcwi5J@ !e]i@9%a3tꢩ tMmpfє"C_|{*ZTyjiϽV7ma玓tI{l<- G-/*Mgk^R[_εX1`Q/nC >5s?Lۃ~AΕr d#^L8HO(% m/" -/^U!HXSfkJW@:2ln?m쳸`s[d8Gj 1us`6P7=諎+]HGF m~/r{'uE`ꊇ#O Vc;Ҙ:SEDDR#5b& ۤ-$n:nS+=C|"ғL9ey1:la;s\eco6x-oTw7Iy6D1%%>~\:%L4H* ^Ց|Oewl^b<.Ti)q⸁ YYhW|ӹkU6LtPw7X4 ]oOCD=1o.HOxf$XRp*U4r ]ZD$}qov yH k a Kj ydU(ͭ wF}a?P\k!" r݌&IQ!$rXm8A ru`$jr3.⏛gd6t@FQ\Ю3$A=U?\YZXEt͜~;ZҀ]uHOܥyR[9ILCď3.vpJCD=s;˒i%/?`:]v?0mOM!8q|#ZeYbR%yzuT@,#t6"aTݲuiY|gJsRliǻU%l鴔Cw9| Rn&L$(:h7gls^$vtH4BPYuz{dz~L>EqZR<m5GG{*3oqS ky,rhv(YB&?dsT;66WIܰKI̿ Kqnw}D` @_Pag€S1"P5ïHՓ`T$ϵ ہNo@y mOvԿLcjgh6 beF s2\N{wԔc suWDC] yNqAr3,FTT*rB+@%2! l&zh|[EˎG HJ#:xQm*|[w)uUdMqdtbᲫIMfHkZ[j ج5<3{(nHCJS61ąFXX|Y.P"M7^{  %Bǻ0 =JaёʾC8xk{@^|q)v3噢'\l]+,XpI) 6`1$gxY8}@UTA)ȷcu'[1{hĀaF΋ _`64%i&/m#x>Lk+8B*!1ŗzmn?'eMM"2Zˌ'-klz0S_)t 1Ar.֖Ҳ:N\$bT'~%~a;-ks/Z <~ XY#-83y筏Pv7 obLJkA!`1A4厽= w>~y7Ee;0'3MmԘӗuPٞ?k|dIoݘ5ڳHֻ 6{H$M)_hL& ᫆h$-iGkѬ]20_[CRmV Ty[8Gtl]$`STf<=93HO( $Jm/{Wh{]CMo fyYDXx"OǁR'nߍ,ԧj};OPO mӃ0Zȼ GH֭;hg }5б`is€J83 cܼKܤؑnO9ՕL[tObc;&'$^Ƨ^.k#1 rڊ|#6xG~&ᒹT]D9 u݉)Lk ǀո>uQ:_&SpqX@})G~@o&52-Dc4KbeR8y qvTчVӗ R(*HRPS)} oH$?4 50Uͪ7[]¥?' c-xrcB[P{uV󼚣\J(ׯ4b'Ɓ^Np4D ͐Au<'‘/ur@t 3 Ov?]HD!q:Xtyi>0XĠl mȱBoSU0YR;J'o,:h޷U5x*KZ4T1Pb|3*s^0[ cQ m. kQ|c־6{Phi1#ʓ@,PhCY϶dn58TȃFS:XU0Eu y5EO!DE<_j3Dlj/banNcvW>l~{nYr2Eᯀ}w[mP#qAB j># XuBk$ D@#26ӭ/&BpZUu}BQ?7rU$Zeֈ醅,.f0xzdU+?9q@} Pq:)/&4ʬk*O ,$v`̹>ZJcWNx2CˏDu⧌. rLܥPI$X0c^γ&|pKz R@{5Qӂq[}sBHZ Ұ,,y"ߜ90aݘo;ݶ_xeL1sm_zr~̍%iF"k*-^HOwi܅sD蒳r1s#fa [&[эvVU5(k"ór]w5.ȥ<>"dkH)"m۴FUJHbd8P]D e7~Ay51}KE:*4:.BХ Bw溉P}N 7ԧ܅JЧX'Jz\M'sC@Q7IW4#U%3}6\2(N"1K݇ VpSHrv|$I`_㦘 !q]=B=:3Zd 9m 3C2'Un`@{㫕b}UK@AV^8n\!RO1wXs="8.ՄL~25^(3b΁P@W Uqfq" y.2oYJVEdwZ0zfɲLhkW8x1u)H̘UbAPx0"AZ<w㋚MbxHY9o; 7Cm=Y` ǮGu(ݻ;t 6kњnYRH \bKx*nziiC@A dM'Ld&E|@bz^da}EY$F{՝z,)Z95ҥ3C9:X O#4awpK،0 dg㳱$ wPv_& %P4l:~s@Je:9MȔOAsPM6*;a}.~c|c^~=a.iEsPͩS49>8y;px`k~~Pa-/;*L[?-CY="d=Xճq8Y@∙ |ב×%XX - WuaU9?0f3I3CW& %humV >`Abͷ&Ӹ=]AE\@c(N*c& ~ٗes Ϡh0QCn3Aўc>^]̒Mi>u"cnh-gZ1tlֈKrMpsil:!) =x~ ŋq!^@uv{}W:L7 qG)0aA0{??`S.0:UUQ}놑Țvgܝ]OOvy:'ÙNKiJ@σ]H[B$M.1ЧG˄y=+/?ATKhhi5zz0/+IΌЁA]t/_շv+^~gwo>*7P[d(x}N W{7<҃:0Ôjo pqa3 sr koB^=8]]!GlVUANJ d;~a.i"W_6z2,xhun3 P}A9F u~ƨ3k#*|D{ʞdi6E!eDe+lݺuֻ5v`/ٖ~}+z{qVcؐvaÈZ^-vYԜ"ͦGpl-|r& \kv9۷]ȩGHHUxj̖ I3V۽]c •Ijp.q˳-ЋPҾ9v~Tbă#}¦)Cbw JNI DkSܫ):^Aa[=ywv@Yȿ9!\ `(䲚 i})x6= %2y\]Kkwizf6x|ݸiH3R~mN4iW˟_ޕ$bQB)1(FD|u\BJ|AZ[6)C6z/śIW)!,6;Cp޾D*x-t\ 3ռ >PtFQM?u=Z+>_yU,gl w84T>@@IBV^12 {+Bdps (`=VV5e ][付bK[|Mz/8E;DɁscqH%?o`F`*;%+'6ب9m3R޾(Ó{:QT jmNĂ5Wua1̧ҒV\ ^P稅R=IXIק;v a?cI /8fݖχOg:!Znf I7ZC4:YhQmDRlPϯ}SKm!_b[jC/"Gd*%Ҟ8*l Cל QHEE%+37wy0Fs޿igFl-Y]C~ܞÓ`SxGf( @aݪϩz&tŲe-FtϮ,!q%QHdY»y\XMV֛:)Eןϥi|en3g:fb>OOlX'$guBi9=,vX@K8BO= +H6&LmN胉'ҷ|BY վk̈́Ǘ!y2!HA"p^tJE6'Yݴ"A:R4 ?(8yPUKG#}ɸkp)ݳ8ّ(q`E9E&^-?Cs-Tq+ը,dI4ȵ}ΜVT]-(S26ΩjEƓ‹K–YyނIږv^zzGqܸ!DJa{W:ŮYY9$qڋ379M 5[4 2:rn~1 ۔hpN g 3s8}$د997{lW˦tՓT:1)u8 Wr>ƛq97]xh}di%hߦA?Q 8?ϥ0<;2+U{ ,eCqc$yMl,T}Ox5˲S 47غ3EXg)Ios x_U߭YS==:#MBqD'vj ucm2\X6/Sc}?2fz i d+RC+MR0>J#bǖcDae mClVß@x?>kOB"#.I)X~0!7v1SU84Q說%f$ 1Ƈ#iMI5֨CӃfZ6ydD ~}zönZRĩ53pap=,qJ6L "y>%-w"dwm Edj/_mB`Hu UQE ʦ,jysP2q.N~7p74q+v"J򑑖*nsK#E Ɲ+h0yK -dD&8aR٩B"'n.U"[Gq}q=hZ2BfR}kj#^/ UZ_3W!2TKyF_iF0BP\TFNքЎGEKg%>͌jv`۸kD.o} *8D(jbM|=qyGJ> #O%3]Y"%av'a虊ӾcQ. 2**mLs2p G A%>cŰ韊n|!@ ŋk%sШH;%9y' T.0-loLOSzv?`R񢡤 SUvfhR E1?"qs&8u)3GN~䥬q`fA Ft[qfRb >і5jk8/!P4B@h,:0.i`ю# 1&VV-0oX,:8-+?fDz AM:k9ZTW>I LC糓ީ(w)U aW[o (9pWq3J'FDd4uzm](ފ޷'SI[w<ZVJܠ1*se(B-Uu\y_d[p9`Q\8r+_gL@hbD^»z]xTG,J>hD{Ή mQvhV6ί`-F.J3D%I}WM2LӚ H!OOD1 `z>Z9YuaYkϲ',$ J ֹ~n_GZKm&>'O=_uWk͛E =#\&rU@v bE ̚C)_4,%Elg2n"65)5DAd~D|*V̔KfcA:x+՘8J|Xr?ǥO~A'/(7VY1@W=c/w/#\(/&ip^mñ6BeQsǸ!3МlJPV'?O,03GɌ#npb>$}/jtyb0Nr'-qaFT[5`]R6i=5&Nb*HL4&YW硌2o(*&*3wɣ.Nfg2KԠ+ɼ w@gtA0d91 4{ ˬ08>@X% (N`6^dф^65rk.:&g*{ROpD,}wbܬC4έp6s_d3ۅo٦c& ojE碉'%ۼg f( }&D~J!W*Axd%m;@|S)D}`Do.[F9#rnH`$qaQp<었CPL׫hә΃-8lÌ6¹ZP[1\OI9vZa|gϔeji}9J}o;j4w [~jnć{cГ)JbA{)F)qKĭ\{|Q1_@Kq^$a ŕv\esNr뚌Xƃ0(_y|?Gx;(;Ƿ@]3Hg#ctQ}לp\z{ﰽ#$ҪZgaz ?M Ǻ 'bzt}A1HZ<OºZ.kJ"iSdn7#;-7GԲ gTw^U!X4)Jhp^X)JƳXXHw5,1=Ր'T|hۭ"VB=hYci{}fHy.ث4@oJDyNxop>U^qsEK>$ͧtd&ʤ))Ͳb,8=f7<4X p2SZ9|7#),p"a{$ `^nccgN`L/UR B"''2F@ynkrA[BIsbmb:O_zylTQ0frY%)E"VhF&y}^?+P Q_7wǾz-HHVY+6V{zGtC?ƶ̈́VDe9)ב:btFe Dz@à&sJ܂Ϳۙ'yW ¤]dd)~=c)#&N 64޼,ݚ;'A?!~,,\櫏okvJ7e-'_ AtF`dSX(Wankm Kb x\{e0,н%2YSw<;Wi1o(7x6Bۡm$?b}Q|xjଜ$2@FE(8 ]ljWRGq?GE-q[ +܏w6->s:C@ '7ac( a>Cݧ%/ ĩ'AlTL y_ Y VmZ'z`];lVVyB4 b{y3i}]r+%O&kGx,jD _e|035Ç?;%0ѷSok!X˶1+H˥G4J < t;|FG^l(t.BO&*>?6 \ zϽhm6;PLG T*3Li6 1ӺTK(t<s*\]Xբ~A}g]; ova[jluYn$C.F5Et7Zf*xzh~wҕZ#~5WxDZlȳٺ]Wk='[`3Έ1rX́S>Ӭ#VTKc胛"Wn;Kn>>On ]GbPl_=-wO/K oB < nzrm|XeznhVM8/ܭ'l)ܨ&DPӶJU"{cbRCwyZH9W8̎ ͹"C) ǕUƺ#&acw!\ܝ/IwzIT}I.rD$x$erEgrH⮼ Ա0&~Y/SsuI&|Et]JeGeL"( # UgkŽex҆Жi+Bfb0(Ƃ4<+<3,?;?̦d+о`';4җ|=8?m&*c(é{^CP9778ܽ& rvP|qq3HsBUD7.2qEp+C6u{SOb75F "Wʢ»`M)nyu3M[$r4\B| +$/1"yC:bjA{An ]bS7AǷ'~Df5\A%(Rg1w\"s QPhөAnN! ɰYF*I7 c3_j_[8šf4r!Z5JO"uSUz3)|$-[5@cCdq++[ZK~~qG{x y}܆䭎c]UO~eUJݏ@ZɖkANGM"~Sm, bǥ8esNߐcj+sG.6q}̀3~m lSjߚfOIiu96L.= j埙Gd/*%a1+piL) ]?*fv!k Pluut+YǵZ "{ْ?4ݯ6: ȯN]Q)oo=|rh[B[6uVO&cdܓ2߻<*q^C`I2EO^M`+CΝ{_ PN>!l!J<;@YA:.^ M2mߪOk21}L2|/#äzZԶdW?&W> #*"G#@]F N6U]@:#~±i+,fBӡ3Aa _RV,hLg#blT_F:\$Kr}kY %dvo^p߶N,`UT7`B1O~ܦ1)V\ޫo*8O߯<Ǚ1e昳PRP։"PpԢG0q֚V'03uG8LlvFQBcV Q`.-/dzz[>z@\m'Ɂ )I{eIe6Dj^!)2+gCM{`2RdsI(^,WV Rx)~`4;ŅjPd8ү$~n2"M߶| y{P)Cw$bR cT.12"9dz* !n+QCo8S}6۾Xi+|t6Y( -VE{l̀0;-A$jt*+*yR/zʗC~_S4nl3s|_{eR0ς !?DHd[ t)O f$ |s%~T`OPQMTb9jQuDa!Vns[$3L[9Jb:4LlLwr2E[ }{hũhiuMR~O"LNvNv} >=m^5P0mFh׎ŕ!RiG6K.i2㵉\E]à q23+U.X twpHnAU>ML^F+5w?x 2iXiY4`ʂjPz?^2:KW W[\*~/:7 8#]!1yDFqm ݼnPUpS-z'xv08O W?8km7)}DzWƒvʌ tSQ^d`ڼP8bͤ⡑[K5*䃥I=whtxu:cѻ4|Rn ދ&Ѱ1} ;ɝ6-htzʕD/ȍF#U%Hfaa=Ii&o.}Sc89T#!v ,.]߉)*CBgXp,8|m|F|Gu$ JQLa2 qjzAyP\}~dIƧy:^fe"wnTJXǤ=7 Nq4ExᚰAΘ/^ehfronRR%%p[cHwÔsrI>zLn($G3'M?[:!g~Iy%if ~h yBɁPoCEjOӿ> UN$&CD Ε%vظ]BH7gCFQmp ;pW>eqE~SZ("!hcq˼5 f),$ IPjjάaݘKJUKo()BPɌSܕ'6܋P6߾wLxWwC_Zc8@ 8Nk/4S_nxxEp[0.+{kGTwc@jNٕRU Π&Y| /.s3vȜ84YߗqɿfSjz)^kT/l>*⠯=O?AS|!8{bq! ?ބo߬o'CK15m&z0l::qA4]*F}=%]+be+j jl·Mƒ}!c!wUFƣrKIķbcV Z5zi$Hx;['lmTos0 wIC-)͆)G/z2d}c'?~MU.eY{́(<~nrI+@1I gOI&zJ`6Lr\kRXXU=sGF ;Uwk Bc( IB\z k%T{ILA{jPdsG`tqz'eY)glgcX}6⛛#be=OIZWEZb?U7`0 yҞ<6I6Cxl<y-u!%Os93V-V0 ]]2.ķo?}Q%P2^'J|2 M(M_I9x,F`$L*Xz5rTd~k ţ'5i&_kATl4mK _+x݆xY냬>fONj^y_@H"W},&¹Id bh ,66<=m4Fo!R5]_yA1{j;xL#B<萌&%Mل -ҕjvP^\aI)FzQܗ B#9 <:RpFRcNҴ̄a{$s)4xA.G ڽR3Pn-e> ׀3~1`6ʪ{YxYjqW_}cʷRþ$Zm~n2hc>n V,X .=Pg&phvGRFyis`yWSw6sԨі?3732,_ŸMk 4bu'(|ā r$[\82~q#?j*bIR]7&n&?mpMQrW[aP^6@Ro T.b^x=C 4 h^jpR4Zh %x~S-{L.wkkG^LR/!ȋ em,rvt(nae((L_1CGyV 3(vЫ NsI5~T0xUTq2> %tU L_a6b [TPav?MсDXRӞṯ{ඃj7{M bw/4&K܍?q*7-S 6j"5J 炋LO|1iYo#Ӏ[1LsZ`*-n6|d%f QeE"X`b|~_мyyY0UX"]۩fU>9'g2@g{}5 ;|r\ .$[E|jNcK5゚Jm 겥v٨%%5=ةsO֪T^ d͎0![z"(Jacv Ne@6$1aq_ mw`Ϟs#R#[cNh=Nˋ;Xx4Y7aH1W_RI CR9hu]%{otSڋ~~j^dGX k_2"XՄ1Ci5+cd]Dkc"xKk n9lY0+3Gώh_͖U#ɣT*cPFH٪ =Hy?U_$\լ қsŶH) TOٺO ˽b`|L&[(QZnv!lA= =ذkVxBA/fPy ;9u\$5RqƦȞ߫yJ5]fc|5i@ "\Y _YBs1և2\.d"Xv> *:/[CWIW CsA^" WIa{h/w/^,"?RJF`ڥ7, _6ˤOn3%N٧|o&v.tC )(JuD&^ü٘?Z۾eT4 U5a}LT倸Nҕ03 m|.phq g:4gGws`PP,16L\X̫[5䋩{&B_9~ qKF~7 ̋5 ޟm&hX?cUK6&e װNWTYhQ3DrбI8@|@m:)DoλDJܮF8T$L͎=kR_.ʑXo} 5Z !eޠtr86ס1Dytv |ȬJvlu`A -D2q/P8i',>\qۻ_SE*S#| \tmboTw]rf_*P 5( EC>~ȤʡemQ 9"ٟ$5{kG\賨B uA dlvq #}#4k"t?"<h] 7B/g!;\WhU` }gw{,pqG8̇)zDjR:=DžKQ_;L%}n FAlKW0Zg8BԒw̬tm=T5B>^*@eGeM?.$qҵ#nR oʺ6Gܵױ[&\﷋~֮r^5Ez܍՛9;K0A`K96s$PFC:LB֖b&Onд^e&ԽQN$ LufYD\!$Nk"dBUu1Mlr1*ғ?R&W$y^AZaPq}U}܃|ANH`vK-1_x <#\<9 T[v$y rq "d[mQp#+-bu-mhPkNj˾mOǜK!xVAJ=h$;hfW::LfQ5msI2aQ2hi X֐L%kQ=;Lߚ~3H.7]lrl~ 'od4%*u/`0$Uy ٺ3*aPQ1[X #>ir?{ DhD▱ۖPCcϼJ^{6b #iѤś2nfxVgqO. f 9?  ~¡aHc_UV:`HmN\ z#CGQX_|>P%km}OHm0wȀH q`ڲ!rE-|$ڗ ūưPi{F%5jV|617^efnk ngo48+p3.zjs *wEZ>aV?Ejh^I)O [m`q;Ѭod0̤s]`4EúolBUP_TLʂɭn./Y%Dgx=x| s?,q:'y$0O`-0߮p+`8['$F:0w;^7N>oZtKm^ba=%A3eFA2sGUxJkny9!vutoO5x2z]`fUAؼ65Wi\l~E*FLl)<>٢mTu*4D040~ GA\3e@B=o76H.`v/vk,ִWljOo:ȏ$#C@+ep&9+y"'6c~-ba^+vLW#4FN#x|"HLmbf=a"!/9it_YOkos!VpwGվI꺯:l!5Vdc6#N!,@4 )6Y,5!r)ƟX}OSBT+Hf`uP!hdXm';29Oa@@,tiDQ՚+ԉx|I)Fm MdnU*p^maIl# @ŕn 铅eX#擸0-Yga'$W297yo.=>@``(_D5us!jNl1GȈjJi [vv]`9=%%ZAH,s@iU(W&"h`^NLѐKNSr`gZ œS >hJLm%nGHArT;my;0S]RWh;V, /Y*Fݟrc\D _B7=iwK,E ij)ʄsEÌ>v3 گ)UAvev:n ^ˌ 72.&[r]Jݿ  b$M~3x~8[=s#*zIu&€ ,mdb*Dl91G;RbrjjWFyEcɭ(=4 :ce!@<DE feuoMQ%GtZK4pB 1 T0EsDh}.)-] UQcY9줣]ʏ4SHT%=;\B vMd`Wx=m^B_dH G JLs9֞suzt3KZ8iAi$dВ_L̈́eXmBghS0l`{&g/eQɉzk}qKsNԖD Sgb*+NhOQt}#P="ޭKKb8YA+W&/$ڗJ}vJT_DՀ\-V1>*ov^Th[_FE\nl/ /@Y:ZRWF,(ң5jǯfABܲ24g: 3h@h`^A4Hl0q51_c% '/"ԋ `[H 50A &I<9!YJDTtU(')f69?<~q*ăU#Q52L>}OlÁs"~UձU≯0lۉWN8ԁ;:R[t823[+ެWp)N}NC+}3{ U3.\ Bٓȫ5 *5h\: R?7M\+8aF0a h%TZk{3fJlYTn~@UO$LoYGYko%&n;.hנ{wggf\oe)ϵFg2H({ߓ>P ͣ8 ع咎4Sp!Q_un#7oNLw0~˄7--zM(ғKbMFVZ@ՠzG[f&(n9ӹMUlQInRZ:z4Xf ^?Ce/6:zd_{1 %݆{ ϣ%cA?U HlkGYIa~M3Iq_2 w=qIrNe^F]Ԧm MMngk@7LJ?w7CVWgi6{`Hj.:XȢy$吗ފq;b2?TWؤHlOISvۿ+x`?3PfUeddA*qg@l <wWZk@ xƑoD"׈ '?sӐZ^Q(6i!jKEfVA3@X13F#g?_rԹC̙ɋ/eT|{_ Fɱæ`i{z"K`KX=nэ7~dE_;I+=w{F7wrC,`[j:~*o; EJש+q\/imO`+hgx;Yo>@L++Ë\FRXn &u1KXMwY`Gi/9b%$΅Mmi}[3=0ofvcb]b 6wQ1~SV m4N#"0lEnQin'Xccȇ+I{h*BU|GVH&CL 1`Gh ւ;e ðƽ1n2-d . "0i f*B<,9 쵌|sɗ!04TU!{\9y`6ҥ%:$&QK:Kϴ G!Љ&Qp Wg6HT8STګgP9+]{ Muu!2,܂s>y|(_@{Bޗ`>+ۯ@qשּׂD!MȪv<W"4wAfGx4`p? c}?? ` N {hgI+಼ T7t-ˍh+V[wuO_O):Wb^ȱwyk+^0cRfjQ'?QGmE1 |((0c~!׶d&(S3CliÅz46~~e~iIK+훅Wk(]- !ަP3ޱg_Gy]&CQ31KKg3zHzXi94xiEyK6m [ʎ1Vqe (pɖ= W4A>⯞7VjRZzas%-TNb::#$~TJ ! 43n_*#k;ߩ[[ &.ַd+' ݦ~-W7Grc#y/96CUm(31X#Wh*Ʃ؄ߜP: w.h@ܠd݌/1d Nk-cyLUU@ۼ-̍B|aa -6EeNoV챺ka<@'oXCQc56V>*y ekz`of躓YOS;H@wfŰ?pN/+\g%@jS3/Z-r,}P~Ɩ9݊$ n!Fq 3R6@RGHzL GsϜShp|Af6 Ź1ȕI@o-_tیR;!<Xr# `s]շ^1 ֒03_~yٛLojF,ESeȗ*rho@$z1~볻/]x݌1DF̫nwI3+)oE;a_)ޡJIۥ%Uq]p` @{?JgbF|/Uɍ.@4%{5f6D8E_.lSjֽj+64  ]yMh/+?G|1h|'#aѺ/@1GqvWRoB8(jaRh'$_IY(Ff=XŝYfY,0<)wUԎz[H YB#>TmVAK'V # F'n\/$p g<<\-!wʸ'AAS32̱_j(D&2Kif D7.ivMqw=r$2[snޖ#vj%@ʅjhw'4*PqJ776ZŖGKd9b4tOE0췯X8"`y'MKl̤!Ut],L*Y5#w{j/{0/M^> :Lw&(2'H*sI#fԎcf@+aH$>L&/3&vi\pSw8aIhM~%%]I9 v`)^=:b8<#GQgg$J1`y:{BU;yQtí/p۫,mRDPB:2J'9|6΂"Lo⽒hf.U]Asz)2A;vaY2=ߜw]/AlKꂆߣ)O{Y)N麅oF5ȶ4)6~<`̾HYaP=-#Gݬ/"5/PEn1dM2qK_R"1\$K= GNj||?ўw+ҁ]xv'ȄarP`3-;6ieSgxUc(:)z{ @[|IXDvFŐ7"vC\JJ4ȽY-*]̌iY0O~g%X7tOӆu!13,\{yy%s-VEI`s a=T,i/>~c  <ky|< )jnf/!!W캹o8e~*z"5G?p" <; xetnv f4;6SӴU)b: Ψ8N(Zzq} Cg_C*poQuM]*r|c/TLt磜0݂tc1?t攖35\;ReHZ o eD "~#eK"YC!TVfɦmw[#eKnD@>L~!o(2^I Ix;=uFr-8/YZ՚J{nL:#3+V)=PFr4;)ݧ& ꚮ {߿UnB aD4Q'[@~aαlp̊BYT+CxKzFts @Ğ6ocE/xıTG`i0tn*E{MA8aKc<9V^0rx1gPy,1`_.q,tڿ̗t,?FD\k\%q4PT\^j4̖zg$1dm &6Ii+Pd !q#9i탈mk< "{$_j)|bO8q)KćȲV\ j-ff,)$Hw({\J%wXu`-2hQV;/4lm^Qv#"wVnl ATC݉hEaoAA,l樤ia7j;& V_1Vd:8"Y񐐂:mx:d\TQ)_x_r5/rO2crd-BL@W)CFؗF^մ7.LO:`@CI;:)ضi,k -R]%d Q_M KFʢ~鮼)e9E(UyDv~<[ظ2_ke^/JZ^Jh+7Lj2ͧy"\yKr[kW>u[;O/$rf8@~WH5w\BU K}`<##kP'Y)sXXd^jߏ`LøKoV-c}IKr o-n04&6YZ(㧛Hyh.|?MfX%6ZQ(7ۊL6y< r3īg"'i?CYq-Ye$%RwcGJPaѼގ[UcoBDS~+kVVUbh*ijf9vw|^NnҰ|]lJ3&o\bKڣdd*sߩ*FM_aJ_&gH[W-Y/oύRa\&ac G;V,iQ<L!-B1ta@cxAe'SDX s<Nqeu f +G&1RQbLkMuJ1U)=$dcՍis mAf9@yE+M~UKjM q\(lk4=AVgR&/ݖ$}au~>*U ]aM)oH,OGXJ}zv;>?WֿLgtRtO4:D{$[gpQ7XÜ0\.. OY4+2ބl; x`%ˋ"sUe"q %I13)!r udאZB.T4 u[Ã8{K7{~+&F2ܲF+&jŕ$8(nӿ' +ym̽{?%ALD߫$.Cc1waTCB B里 @qw9 ^#G(1gB)fr̭iՊX|1BQ9>Џ콫שGxd93ۍ(3Z*)p9]#!q뜕cBc$m$]8!ow`V?G2NSʽZl&u^Ѕj[[?qKxO[EDlR١?Cl]M" ռلCa w$: Rj_{Xoٮ]o6ݔk=,IsI[O 뀥`if`@+PkTP7\<'J0B_# [+Dm@jFA_(w챺MEwG\&{v+m"y֏|L @Nȋ-ǣK}9-$QCvN#UaSq*Üۀ 9uÊMǍ5uX4$b6LV"V/ΟG}N ,C`Lp{-̝p&6?O IsDlN%oa]w3! IV6ʅ7mp$3J_xw 6iYy]V0i-{M NM]G V߉)SQ א~vlU؋;.wJzږtJd!rcRFb#kË  :#Lp3Y܄VU|iLhI?^ps*jO@/joPlwPQ*Gd~o}F<"荡Ii*sU3ٵzdMF^gs1Ͽ!yjzf҂.ЁE ڷ:R1g%Ujk.[fCYH? *5UoY#L?,1>=)bo$BUl]o=;;RHt77bs>ĴşqCS*o*dgYuj\_[SWxߧ9Xvr%UC#𘝮f 7uu2ZkZ^+q VX0)_娒?Ҙ["SEr^ZGV }56t*(n[ Ch=b8i[7+8BFzlEj6e-^ 2^Y׭C= UCZK%x&`z*&^4Em2ٟ/>DxKc åCtjGN(HחQ-u+t:"sui&GzD 7kP'Ͱ83C%}Z`Bqj,-Ճ2zq 'y0ᄒw7 =7I8|3T~F(FIٸ fL>ىjGvY[T6IΎ?T[7}*S(}ak}Uz]&=t+# 5D*l/tBڝ > ؊@,̳J,Ț)Y*D]ű[y[h^xd%$R<'t|d ;&$uSrAI,+ 48T~5TQI\Uύ] :LTm5w׽($W\ۢ /I }0I0Bnz&`s"+6P{7uMF ʜqdBz٣1|t>LK2FȰ 06/F\0 ,Q E6/bU+D$z"3--;kaBa+kP>jxtCB ]xxS 69Trb9O5r SՎXjuܔ᭘%u;VZdöE2MwJ7\Qj~S !!Dcl%S %E*uDΚ)t$}TM yQV34X\.@g?J}o泭 cdj|7J*bn&Pn>Z||s<9ʨ [pqa5jMa#3)w~3E6ԋR @q©bN=RFI ֲ.NCe@Y[?ѫ%UzdwOgtg(kY!VFs9&#iJ˦+<"S1z5O{>ʾvV5.罟L\ Z'.׸2"@KqMAQۼrnOF<[5xBL>׿+^3se,1:I!0X.OƮ[,O¿lb2Xrty~nʂ3R:464i^;gruc8 t1xŌ^, t 4 +x<4Px7zwl0q- qf13qjU$kE~2]yx 5hxl> %W@a4SHxbkyvw NhII&O6]W^pNpnN" o)3pGf(esQVs¹{,O8SM.Ey8G^1<Ѓ=˙|)JL_}m+ OuBQkZjTl!7eFWq꥾ qDSޒQzXZOJk-;m*K, ̢}m#V7paWET4;s.y/׉? 'ysVKUw]mOλFm % J2T ë]z`iMȜx3o^mtޡO+⋶U/:{ )beH4,LO7aWEf%UURmvrW?\HJik$C8Hv:Gǡ:`ʟd/}k[xb .kHM54͍e-YlUj^#Xx;RiLX]8T59J|#kq7=1DIP ~8YC 2tЍ؂]w=“Z*~`ڥi@؍`{kÄ |maiWi$a(^b8BeqQCȼ|q֬3V{éE-j#H>>`*OC]y,9oVXIc( ڈO*.:ỶV<*\dπ!P}+!)Ɇ dqRyj~aI++4:MaéUWXYVLr1)Kw>KF&y"]Ɍޒ%#n01@˸rp2f\.}yÁgaѩrMB]s)W^cҞN:pTT =Kvc44p!@r7ՓHF8@Pط1K f!N./"כq"L1~ Z/=MI`Ht UpMO-I1a]3EIκ%Iw7Г.2K" Y֞ls~qGrWsl?)6.UMHÐsd["n<G$+bGB4ʣ4ӭ=eEr~i*O&4"ў M*IC|{/%Y w']hlSJ!R:+˰n#hr#զg߯ꕛ6vb~!:BW{[(|g>B[6|?Y@OT` "mV<2S.\W(Htui jI…ŧIbp:ľ۽yB= 3@}SR^I2-/ȓp2=A1u֎FWs7muPYK9XIk}F^A[{vQ%(꧎{55#񮷏!?i-c2гk}^K9㬾6BGuS/ux?QAoHZ2QGb@cJS"\&W5bt`;jB1" r{$daP5ЪqB94Fry(ʋ .i$P 5~O8b]] l@r8TM(8p)xnҪ2JIDB%c+֗I>JIv <iA/`M zxK5H߭_ -003pE|7$Nʄu 1~Ok5ro;0ߧGs1UQfh^JcHR{",4aIsr#*A/M @! $%2]_`:qd!2,`d)孾Я+zf-g^nvw< ]cFyZP7NjUQIL7 .VxS]xE[`" |3aL;]@ ς\QW T(<"*m-%u>-Hౝ g߈]%J~ ^,>ןݓ17|ZoFu O~2TO!J8ٽ X ± %HۯX;9 2euYw#N[zd <$9S{m{𔏉w& '%%AnࣝPED=zN:o/JPrTha]A(o Y"w'.v4U{2^rM\q*#~ض\:>6m$}9'I~[t5u r5zAٯ4.Dl|&{dEuCdIN •}:4Z2+XN|_0A*7^cX!_Q4%HݓVQ{_!l~#0ˆ_b$*O9fn@ zaiJëNPUdGYcrDnہ<ÿ=4kHQJ*eR-V\ hƵ- QOr;,,}U2̱qs8 F"[b52 SGrz~m ɩT ϴTo",~#C W5-v@Z h:H;IkP?II=DRy nqሉa᷀䗸}DX{?Pq1(n8̻Yڗ}D@1,%ܽgGEi.Y`%Iۙ"D&礧&xȹQǥ-Vxץƭ81wHShu2§{^wXj$zL.b& QyZ2$즊b UZvo #{tͷގ{>dJI&iss!h%慕s?ͿaXƆRQk,"6Hl-JVTEP8 @/1#arń6x+u$3@@.4} /'W(u;0(?=N#2iKL_RƁ}$R$݀W+ kwqK,ljw2V )uYX(J#%1c[Z|i*fcA=FYoH?-yM@.'NtG{ʎL1P&*a8PEB?Td:Uu`ѣOes~?ڄX'W#|ȯfY]/zno$r^, qEtn>8bJd 258@]>֢+#nP|V~tb΃wgP3D)5˜o(Mᚠ2FK nuZi*#z6.^FTm'b\Å]H2B|=nA3k7.'ifTR,㊕׻ gu@2Zo&GFfyNSӾWGaY#ӡJgyNႊ|Ogctg0uQ_ie)JJP^a㌡Z]ڤD[~kfqGzUYkERR4 >I s@n6Jv,1g'Cb<%6h,#]8}߹7_H\"#!QfYF-B14h< 3E(/V6F}ߢչݦŠ}vS?դ>ӳfs8G[V;;r=BV1?gJp0!*oh;hŭ~a{L^h_ARR@k~A|tv|+SAWnVμX)kK`2OQ~¥7]ђ#dӼ( -*#U q ,YplK=l8Y|]ҁ/NGy75 x 0 S1ˁkAAaqxPK~QsҢC!cnj&i$/̪ jL0aL+J)|jP?]ʋCf]kMXy^)dyʅ'lKcὌkw-Fˉ悮) #~bHb[*4;ygWPԟܺjWΓ!;l $BB#x r~ɹ'O0tF;W&ڟ2z*Q(߿|R}G1-a͋ݢ'5yP`3v{+vY+Zwwƶ=]}xuf o '{-1[j=:ՉPz F&z[9t\hP;ovy4M Q*ȉ͸o3d۸J Z]"K(}OpecQ+Tەk|8WP[%ؾqNM,L` SiS<@l @@x^hǩ HiDž\lm^WX MW3sRx5p z q/++2rX "#viyhNܔ67 IdѨZC:e,}֙gwέ#v|+k+P\F /3Œ>Ué醔JL"CJ' +ͥhD VE;_1Zo"TQ++'w,^jbXyҜ)\T0 _y!㩂 N2eV]Ock`R$-*oU_c&y\ꈏD5H,Dp)l핾0[Ti-ֻĶxy w~LB;:dQ/sF*hgfY7[uWUY i=3 VFNxlr̛v>q قΨ8w-AQ4>kZ)`5q(pe?xK/*(k]T8Gpۿ8ljR<6>HΨg'( w,Cgcy]7uH<ZRȑ]iKй9uOME fJZ'{S'u۷pm0q%KMLK7ˬۇ}f䱷 Ze7_`XWu9y2r ,bo]ڭIB;\N=傄1wR~޿KCDxϡ8\ "K挬7x6%|S-c%ZAʁ ^>35]M/uY?dGȝ#A w e<~-њ< Ȕ[hk 0ˆԗwEQ J/9IVJNVy>~%K!pƲtR*SWN)')2qTtنK?骩c+M\+FÉMO/Drvj78{A2ሐ]U$7[l4Ā?kXt\!'u[6(ֱ jgp<_i|3jFer >m̐럷X4I.t||` #_+f!NkA:]̜!TZ Y-!U-ĠP3qءJF}:[^sYGб@Jat?` `\u(Eզw#%R 8rp}5lPk}̃ ZC'zcIU R<ܩɵd˻(%T˅'dwsmįk&#5 @2nՆ;Ɍ\xq1mBKDwR%Ɠ2}vW- xzE;d<(b/ظ=##b?!Y({uP_)ғ)_{g:Po:΍eܰpKb액aRD2Gxss7-1*«)dSSbq"aD_b\f5򴾠 VE:zfqp,χCc׍GX;{µE wvKO'Ǣi¦р\҉-K#Ŋ' O"aޘ#NF[RloyCýKWڶfFUJAŜx|#AN8|W@f'|T]  L'$y_8dbG ߠM&eP:NK$пW55*Ϯ-x[3iDS 7_Dd͛ ijaDs84bn1bu\eS(),2lYlwY$W+4sor/qLIv܊jY 1r O-BUHR2ם/B 2b1#G4I0CY5p^?Íp&ĞTIa ݠpP +_2wi. b>'L|sDf+n>,THXJ.*%x7Qpػ:{ֆ`V wwf)@(D^0[7D{ 7Bok$&}$ хi4 9`n~[),_39>~NQ[>ΏhA|2? 94a77Q7ZiՑ[E!&uZ5 cÖ4Lx,7)/wOo\bwV5'1.rl-U>7{ |{=K{ql k=1CƴK҇Mt9R~I)ضCVr _AVk83 Aŕ0a$?T+jB7QL`HiQhaTu@i2`G z KPI Q)A.7!|TnjlU9 p*hz'pNZ/8:7s]QĚ^ǒzj.ڰ.I*y% f>zZ*@>v*Eڽ 8Hљva(d #kJ@?.Gq3Q;O!Ɯ>(ŨfXv3%bdz.|X3D1R}b~?#Cc∟>ONOe}4DKh/d.ad7{2,O-0g7j r_A{Ҙ{r]r' x?2 ڇ%FuLcu*pcfx1{t$%Am,BLl=trtY|+S5.溧<^q\ҁp[еiӳ(ۑ&Ki1F["4 v rN&fj`7. T}#=DxErJ񡩦pLd<`/P֚/}ujάL-o9IV0)nJmKf HKΘ0.G@cav)1:!H^o8 *"^в 9*ġۏUa+;4Z5dNw9 E#H$ T͟wDT1 B,x?8f R>QyĘh"~++5t{ Em l]z@YL^W{\_ r uw 3~QaS$t'?6݅cu`Rwm, كDobpC)z➰z~ }(Nm 'S7LLIGpBOoxv5t44ӵ_:ˢJm11VNtgabhd (g,N3p8"5CEbC>}h-Ux;w#Xm~|x g̺$6jcS0UQo ;C~6N :S-(ǸY,p/^{ !*x+U"ᇃc';ʌNT0MUsU&'ɫ=sv(w;WHpZzd<lyFU TNgXj 8HDetCi1!ïlr2޹Y'D o*̏n[Bu\ 6`<j'2ouĤ2wkbx3=!ϒ2襷m&^.f,۵ve*qr/"l}bAMy.҉c)|#g"ek uhWDk6Jm ;w!GJl]S@>!j2R \wGR?fQ.鐆ĝ;Sk&\|u)fOuA`8Ԡ$}PaὫi#Hs6ΩlJDSt!sWPF`Ƚˎ K%]3<דıt&j Ř]D̤k~)VuN٣E7oa|MXC1xܦcz  z1OEA4y9:XxL`h|D^@@BG%{36:llij7G i]jEYCC3ļ|+t=t=W'a:rhM7kV߇F]~dB{+ΒR}Ijgukn;7=r𑧂TQ<(z#6qvc8 O7G*qGp-KDqa[3cc 164aY}i3+[ o!'v Z8C(Ne]<|POݑ2."3m@[`B%01;8ڱ|ܾVQ2 6Zx$`TTV &ZjJؽ |*Tg{@XoPͻpOtn%?t߻L$rҜ-bb2GKH>ArVo*G3X܅ՕZ zn 841h`%: 6\JVMSb;m]'yr(F :I/[g+[x(A?y9%oz /L[8Z?&]Ts=D=OaKe`Xz7ۯ$o8%/@L^9 |!uy-VHy6ŸIkCȂl#Qi)€C[7Ԃ:H5NK -wHt5Lz_W|{E֪hbg=k.&ZRdž@F >:/eM:bv4@YRF6NmwEH$Z1͑qrO2-o|)JrB g^,uvS^_u@ThA+.WYT>Yta*\8얩n'!eetremotesf-2.8.2/src/test-torrents/bittorrent-v2-hybrid-test.torrent000066400000000000000000002626751500171105600255710ustar00rootroot00000000000000d10:created by10:libtorrent13:creation datei1591173906e4:infod9:file treed42:Darkroom (Stellar, 1994, Amiga ECS) HQ.mp4d0:d6:lengthi6535405e11:pieces root32:1%3ghx&ȩ}fD(ee28:Spaceballs-StateOfTheArt.avid0:d4:attr1:x6:lengthi20506624e11:pieces root32:$&=U݄,6o` ֗.Qee51:cncd_fairlight-ceasefire_(all_falls_down)-1080p.mp4d0:d6:lengthi342230630e11:pieces root32:i}Sa~]]*~lꪃDee12:eld-dust.mkvd0:d6:lengthi61638604e11:pieces root32:ǩj 2aX:@ee50:fairlight_cncd-agenda_circling_forth-1080p30lq.mp4d0:d6:lengthi277889766e11:pieces root32: k6853 |'g8ree42:meet the deadline - Still _ Evoke 2014.mp4d0:d6:lengthi44577773e11:pieces root32:ϩOGyjθnHCPCK"7-Uee10:readme.txtd0:d4:attr1:x6:lengthi61e11:pieces root32:;/Z5l'-nf0i_ᘃLzjRjwvoɥ0MΒCJlD/8Āhm͈qcehg>5c(G!L@DCs-}4YI= Ԡ} @ͣL晻)Q脠TH1w,eҞ,ǴG6nUHݐ A- <'^2cDq1enqj2Z&qX [@zL0Ckrxodh-^KP1NV_WgcZͫT 4'F#͏G܊OrJ'g!-]Սar }(0*͸ݥsI (Fks'4hǪ0"^Sҁwҡy7%ElNܾK'Œy2 Jz/<۽A[SYA[Q<)qW.q}ޣ`7QfiA; X* jЩj%N_ =N`W1fwjcDmz{:iy X+ ћ4Ԧ9!zV"f%ͥ4 x7a 3sDPhɷf, o=9խ5"}/01h0L44TNPۿH֒Tg'̤C~#&EWM7cz`;vш |[~FPCD:s7%+0TWU8OKk8e }!ĔyFXQǕThKԛ#I<=;0+WGt4 #D)T{[FGvqbԪ biXX%z~#({Y%)Nb O'Gn.Nir7<7M>BWK~w'`r0A _kp[*p?|9U1yԊD+q 0&{wY9u)γFbg`w}?cl_2" @zвړw<3!I`#RϭvwaXol+#qp:QY˜?j"QR.V/ۈM,2ch-"oEm1O)F sxQȏryNo@w-ho48-HYzA>aP?Y,)d!>w[ܖ<%ײ=NDl-.oZDXAncUOESϱ}=^<YS3IN}TD o]P$) 2D2/:`?/u?22.3@mt@?QݮNBE`CݹAvd_k2\At &Qa.8<Șڞbe \]OlL;O .IMEhN AO֩_`5xRt~vĝa6nw_]brVܿY R!1#ZJ.u,@栰-bbvN^\#״ILVZ=? f0h>JЭ*!ǣ퐰)P6w;7\F)-xvjSY=wFa8 `y)OsL\"8!OjpZZT/V˙R*m/ rFX 4ԽTQ^@«>D#(&"Q?B#h mDI_\L eM|\.! _) ǿg|KSM,_^K30eٹPy#!nT&#Ez"zץ ̜'?C']tyVht lKϧoDQYLu9oi*媁1wHx#. .c+;qt&9F(nP k&aƛWy-> ()+f23n~!?JplC?&iY/?P@Y!}9!On#ʮ,Y̏e9ӯ'V`2[#줦g'B3l7?KJeW7yNgK>DMMEqߙQKܥ#އN^2Y9wP ]jwS@Մ:^Je|,n%.4M`5e͒4:3!'9}BKt$T$n!RLsE@iG ܂ey7q}$@T0{e= ۼv4ףƽ<z$*=Z3;p"Hh _.*zR_S­O u* x!K7h|>DOzH XWj{8u5Q(SKT>k`OxCʱ6x'QKv~ZgN8Ɇ(Yr >$K>]D &I㻲(IrC0Tx+C܈Kk' (( 'RF-ȍ"Nd؁b:MH>3 M_h_~xTi>AU95`YoV@u|>`jֈ?w2e,ߡ01H d m%{^d)DbKށjjx_1e[}eg+HMTpk4H Zl01wKJ} ̤%ҥWE3 Vw2 yqLkV4fl*F[G90V3UjVo O{5D~ΛX)dI-ШY)|7'@ʰс]Dy;pt{bݸ,sq6 ]HL˃E u+=~-Zb;C؆\ tNn+HP|1~rSe!9]3|~d* t&;rs^.\u!Ib5898nna8OYU8{WYÇЎyYRCYrş!>>|zIM |Vʽ^ u μQ)ڎ°1x3aMD7p֒bOG uc.ju&\ܢ(-߶ w?{<_".q}_ {4h!~VzCek\rKi쯋^77 BV͸FK(25vڻhȼ?9wQu( =uUm)Qͼ2[ا$:u|HY}, F+1 `jv8$N%|`j"ذPsB+v =ʝ:Ev^e6Gs(:6P迢NcuYL5`岃~px|M (?(4ÞpeCT\}r)hSĢq.F`L}DL?>lP *j@TziIt1|aUb_\$@--+b33[59\GuO0lH$aNLٮQGVoX"aPcCmkp6T4k!~qCߡAK&9Fy0ǮSҝoFhzMso\Dg<h3O%ʟCviXK"JŌՓ[BKa /QlhuaD6:u g*e]LuZI}qn=?@Ї7,ur*& ]F-"5N PKmf*•w.^Kbп*^BB^) Btt+$bީ=}S9W]8.,RIda+R(-ZyլO8z`\ bġբk13xf6}0OA, brk w,V9]"BU]Ym$Bjw9h>39B)|{("Oɦ{,_ިD֩{!cy)RvcHN<-e4벛i+CZ7h˅O naT)M #@E? kwb.VQ@*&MX]xܢ՘M#$Cai&'݌8:dr.7}]EA/5]zNiUQcGŮTfCcG|WϽ>t}bn*JR5@4Tf ٳik]Rm_j'1S!s9vuToZEfǣ[+j5GM9|v[.2m(C/SKͥ1H9<0xI#z2a.7"27}U%RԎ{DJ@ * {e6m?>4oC*NO*u52(,!ܴ֞H&AO0P8TΒbGo4ё7^ǭhvȠpWt,Upyse->hdI؈x

E(JHF}BwM2=LlP&7_0OH5?7_4<.ԗl8$]Dh /:*m_gȠ6~d0 ,]3od"!R&!(?Mdڋ]R.?T ߉0wC/PqIAmgoB\4&` (H)c.Tx`aV'3hJm)c|Ka q9};%2\+(g[3 p)r4'egLw0Y7*/ 1=%ȉmI2G\>򙙠QU29bw9zz~Ԅ9$I`lsױb#6á{lOKՖ#w"PBw_M#n6pxϧ`!έm$r/Kn`߱o}Gt|=OzMȤ5$ YP"QѾӃrî+XZGkח罡\E1HS|a|@HNJUJ}&~2EWZ] 87PV]Cç=r1eaeB0kWMā5\3Žz X$yŅ=ZIH6뚈iN^Ƭ)%ϯNeNqx:/^zN7/18O\/pȖ^ ":$ @*x]bM&$)U1ݺuPv bl7ׂnaw .*X"bk?N8y)eqroIuV&;oEiK!LJj>eNy|6 ?872$8SP[ 9ˡ9 G)w+ ΡcJ&Gee3{ƾ.iϊ֜$Sv4 Ɓkk!hAA{bfkmrC˘U'L.{jvtf<&L]!K$Sa&ai-B; U}B-bKaARjK$?&C7Fw>zD+B>F sV*xJ7F4AVp h#/9::zHz/O_<-$ȚVNlZr1s)ѵW!&pI]Gk]Օu,S]Lv20g/(25ܹ}mMv7?Ob[E"~,Ն?t  "W' x 1Tis*{\OtYJ˷knYsBBk 7wޡk+4329IЍR*7c9E.(U¤^sux,}ےcRur{B-*tC wٶ6RԷۓ[ll|e7ڐo }ZkS $vn,C0|2 C><Y`}ct~KoeR' fj$<ד迃E+rҦM"b>n襛D2̥~N#~":̱=9Y;F SSE5BHaؐ2xxn%z炱é?P35ZBs:t,(KeGח[ imN^`ݼc}j{"3)mŴMO,Yxfb ܥeb]vT*J)V2Ùd GyRCRgX #A+64Va҅Io[q`j*dE2ZwE6HNk5t5XNxkn\$*VM+kOȓO,T`Q䕽ޓ Bi~/IG(_IO]`44u7>G-¥z` WkAv A~an `~\zĻD,es=U/xǕ+ot]xU#IPђ>0R@vR)&T-=wq~DG3),fЖQo3%}s^g` Se.(o aMeR h&+l@I˘7qFGuBGsi m48-Ad֔k^{(W Ǡl,CIylVc>l|,s#5Xot *O[DD-Yzh/dy) J$nAp6߸^0h5^n2#LmXS])ysCL> 1pv|/Ө}`{]&ǣ\K(>@TցSw%6(r0ǴnSBua֍> eE7ЗolmotxS{"*;Lrḙְl^vDn&5VaRȔ=>0sO! ˖hQKUa#%j8'w%C/~"m _k,rhU)AXLVe (Gb{)hw1ў=;-|3=Fr<-%P12ժsvIB ˥ *p1V^8'S"Y-}˴EP$ܳ;e(2\Th:祕 o+m6-=XOڹz2 `2-Rq"{5b}tv/;}1۝)eHk,^yT*yQvهw, 'RO/n)sV:\8E0s)9M6F V %W d9-_cIT0VIMQ-f4o9ãծ~56lkKްP.3']|@r'xfĊ!*9s쳊 D&փt-~9f%+ތU]^;}ǯ1b.$۹f,㥄=iedO8/XٝeiA`5dí0v(N1~ ޫ4HEHNIw[xxhts+~gxХծhC׸G݌:|R) >,2GR&.#n?fɚ͡ O #821J\ɱ4 ?%njS+"[h4ax$4[&dO[Z_t3b _ɓ͒߄~F1IAEu5^t8CxDף4,՛O;kIג]\8D +>o`vMC!.ك3 Lyec9 /ޛ" #2w ܅i+U?aa>&;R(\_  ~ُKek$>Eՙn8}Rr:m^J3.trd]^Ԭx-C?s9}A[no?T/o_Z]p%#`3T dta"O3(,a$WNI;, cLKLr3҅;͇o%[}섙 b6 ~ G%ƾXr 6r6ܻobHA] s/Pۮqz4uQj2 TB'(1C={(@ j.S,I8w7b:'KUriIk6ڀPHFKɗ5gV¡š'{/nYT))L[Cɺ`zeѤ 6po65N3TيU h2ؾ z;:mwO>ܷ4;oIBdxtCE 0vR3zw6#MsDU(BYQ # G:p"Ug=clH%CMd߰ NDe(^x8,;c6=~Q}߾ÝwqSP(8K) L =WѤA, hr`wNmje6s!VI׆5g߽O1n*CVCrfeE}~&Ag:YiߍI+s؅9 9? `RH Ҋ7i5Lr[kq7κUUͳG] 6eiTs4R"2?2Z]Qx&M̅ M՚|r¨Ie~303. ?A9Vpz)AFR1M&AwgӅ| di^btÁЧ@o-p\-ʊ!BcX_Tefm$.Tk SėjS }+koJ9h~(n Ul79 U-#wX L`mK~|E7ȸYgQ:J ǷDjއO{Q$W{6Y|5-nh2'ap*Dwy}Z[yWſ<%pOBC#wO X)s E/>"P!y -5ˑ7Xz˭[CQho4S`U"$]ǜ޻KڨZk#M3yjEU =!׏vcā<Ő\n!8`9\TUC:*tan؛}RVx\H)m0r N{V'(@Lz$`=b( "%eP,Sb`A AχC$=sqpd{Q]Nڂ zݕU)^n-D3DBIzfrXk{JD}GYc(D7tkӅ@]̶:s2c:-5SendgV:mf=wtA;{YfI~ 7Ws>֊ڂ>~Nւm!Qq?,0,?G\'T)q[!.x^˰&9>:OJ`ɁZOQk8Ihoo 6'Sh4\FGMĎ>FaAzVp砪~>+mQPEPzV"aQẅwk玲#*ַh@_y(?_UX7=LQ &DD)c+Bhu G()&LЮDHXf'H7{,x*Ӑe/63r2P5Tm-ɳ~n_;IIIArϞJJ7W/%Qrm{?;It, Ƀd@ *rk[8B{i /ۡ{޷F/35X6i$0Jorx{l%ȑb֙9dHziJyFFx Z`>3qqu>;AcX1#)Oa+3фmc8,5˵$WZ#7I w\}DX;~s^]4wVf_r07EQ>9|e؜+H!)P0*¿|8E8}Uc cC(~eOî|i{$HkB Hb'[yf@.ոSp]PD1AVgwNey4U"$\:-T >T+0N#xdO:ˉƧEx&hss3qe=]/$eT~Y8q [>dOXjHr:x4i2fƢ1vfwYo_fx3K.&5WajV{(Ž8AҠ;s'q&qiԔD\6fGGwg~{ 6"A:B w`wɲ5O_rpGyفN<B*WH>)"\wLEO^a8 5Uk5txx|u GU~@o~GÝeV`9$ RT{ڑ9PO,NA.L~Ͽ#mFO*.b)K?@Ao{~t%,M}wyhOK;i: {G6}+؜ Ib7Mb2M*ͩ/Ivp Jq=Y}}ݒ푡^;kmMPdQ??!.N ^XiɫW<ߦFz z âylma܁L޽ ӻҽ3OM颧I RE9 SPS\;#~WSr1K_/f:}j{Y3<=bdWX_G)lNۏAЖ q%40P:0i+#dhfDafhPq!]4r+b/^77k}4 &6Omy߇Z*#3ll6s=%_K8YZt F`C64˜xBf֕y4{S(eQz/!F 3pq;/d bqp$y07?,aq09x؜U'Or9B$9֥n}k ͸Ym6l$ekWz_F16j*ـRH˰׫ gV C]xZ5kqr$0<$ "!KRd4]#_D(]Dt.w,eXǏYzĠV2?9oZu-=5|sS0OT2ÍA1KlcaG[%AW4.8 82γh#sK.q*qNUΏsIXkqoa"̄K+9?MZmltأ{&i6=/cE '7vUчvg5.'<>|La 7IZJ8.oޡ6wQ[a;tJk]N7rH^.8;je#e8%o!bEl1wvz?ȣ{rڤ(󄵹<$B}Lβ<.SVR=VO5'K|XO9mĸP\8+3qisD`!wc͊v- qk!z81}b,'% WuKDb r!dcIxP{΃9*oB|-nٹ?ko{ w璐O]q eͧ~8*H`>. Tk]cˊAr4Dtr:m="H :F[["|q!?&owHfr`#U0)Ǵ f'IH#?f G3Kf3#B~4ЕW/ʓR}: $L8ɧb Ӫ6C&eAvzhFÃ#kޠg݊ .i1M_ץQ@$C[n( ߒ l|T ([湝F_3vG뭾e,yKpԡ)n@/[$ 3J;ߌA+q ,8fԥ/uzɁ{=~" _0PփyaC ?l"o-gr(1‚ᎏjYW E}1pOap 0gOzFF<+\_K0$6 wi9.T=J0(O 02-Nᥖ8\4gi0/;$׶n!j ת'/lK˃|T jRLdM|m׷Jcf O4Rs|̳Mw]z-h>SբhaT&danȞrӰ!܁ةP0h<&`צB]H~ rقg;gUoaFC[z?;*㫓@Uƴ#=U'-8gWWU.|VD4y܌?D+lh-I~M86Ko*@=ժsig-z|u i.]PZ 8+1u| LrrEՐ ^#<$&GQ=Pv D4wABfuLe[@[c [6)$rAWZ=3s˒k\K=I@V(xsE;j!@`hKT"BPd DuWb,F*SnRM)aRj/#]mi[I xJpdkH=(ߔ!{ń15_oPe"6E.y16E Э7+5v6Ced)'Z3,OӍFj:w1ҬVEF zd`|AC ֻW6$DWTAZ^М sO+]ť,QVK"3;ؿõ&%fݛN޸i КƟ#UB($P*o2Y~`cVR7GDFԅj<혛` s [=G2% ¼Q:xi :p*H6m.`#Z~ܨ648P“bM0E1f.}r.qFߝmurRVSBOqt\w^[lJlisW-+dߢ;CK*^'{KR^5!IUڨ.Ċ+Ȱ)^m wK!q|GBbG\ ȐNCOۣN; H<&q" @͟g!WĸqGD$OTqP`VB?yIs2a%"k [2x F] _L S_H (HKlF aϝK/?nUoo WF7|yNC4\7*j`D ؀9^|pmhmҞbPILFk>w3o8a-.d8tKҵ))n괝}vm1N% QI/ij.h4) <_ 5܃54HD'w_8-=pʄ0]Z e<12(WW= Nq[.Juq/cAhGcLD8vO rޡTg3q4PΠ~?Cw^zSa@^n;x)$F ,9 ,@#dJ`JS>),I  E3y)3{gŮ(d·8gX 6:pz8`Vbkpt||*ln}hSE *Vm54|Ԥ^cv;{Wh(s"Cέ2Lq t,p2̇^SvzM ݡEB\˫Ky-ZĤxE;J|ܐ_煂~ [;DgkߖF9SXi+>wXbV;9@LU1x:%D~xS>a aMƝ Q!=(gOiUS%KBk.SK787ne(x+wP퍮-CEo-TsMqS`hq VPcUߙ[eH2bqYQ'k )_BI(Y@E7%Y 9 9lg+0.Vۊe `Rmg36L9oTj?WC1PylU8Pf UVQꘆrmQSjD;øɴ?m}QKCPneUd3\ 0!29;#@\|lKFɂ#OC Abv"Xn;KyxlR BDq^&BtRDV0x'sԟ 9z6w~] bst66^Pdh{?mQʯ/Y/C %7mQ`ծW3ϛ!.V&!|,I#< ]Ї"` AL,TǞ='P6*̺Ou`LH]wS]Q]>{~)Xxβ91*zNRuRZ0GArƬ $hqZjCмiȘnxz_oT@Smn ǩ)% g]7lm (OO_4xhB y[ԯw%!x5OjhQSFOj_&B٬ڭ5 {(cr+1ѠCU!g?a3'js0/y'lS K? ү'K _b6zSZ81׎7#MZ"qw&ٷ=Jd*yB#D9MCC!p }}6)@oyT^zi"&oSrQP$5CumIG^ұt3tH/KN#VNe/Jb?ߺ@e NRSW,^n}-5=.u]ziyr5N-\7`a]'qgRF=[i bq^AYw|ba҄lٖ ٠l;NzPz&Er]=xvn JZU >()pi[S~h)v: E umeSV bjٵd5@TD/'K3q(˩R""F@W4K1d+7R{/Cj.yPC{h?e+ hG :كt䝁V1~2L5/\o!56)^ bF`] tv[5޵;(1'=H~saE`ukG{kVt. ΣFr f&|ģsBݰB3-9 4R^f w$l[J8BpmS#ʂU3ӊk6%ziMyBi|iږȈD_*jdd,U\ E]ε&l;ÓO'SȊ]sH&r}Y|[US \f~(pG?eQk/c"HE)t*Aʐh@WdcpFP[˝!/hYdדS;|K3ݿ' fn[8۸%J<nUGξ(Jg91@b3kZq[q̠j-^P<X8O̦HOQ6,n80XDG =7shf[[-zu 9!] _7F]%?Y.Z+(0Ug *(w_qn%kq<_uGjF1^H6ޯ#ΐVQY~hJ35Tؖ w{*VRUyRd[KI{9c/.0IBΉ?Q?*W5i`cKͺB !C\|7i>b/ *Z6K(-׳Q!^jC DW+9o rrk|^e v;j5@zH~LBR=N/mh)#t ѽC=B\Sj<*7ƟnXԀ6rP Τ5CWxOy.(q!YGo5|Nۘ;;EXEf-5~@[J%QRU7DF5]JEh<Yұ )`G ҁ[aSIl>CoY 3Xk^56.]L 6t6 _#Yk }PuN"hWU%VU({D `nM\! QI)L 5 bybkp])`]k߫O!*z2:3lFII"DZhPd𝄡E7:9zQK?jbcq|J0-}2gX#UḼuWv\Xť5C գL\[FDždb[=mtK2q>;E>Ϡ+8C,'} ԥ=7A۰ix]ac90~0'őqWUMv}ct>i{g/Ք%;_AbNM?5z~)G,oͽq; Ai&< EYg]1iiC[;]{%Lh4"0BY^FUV.=R #is)H#"󕍜bs ˿Ylf Ec ^0V]FS7zNڱ/z\+2!2ODЅ ` W|DR]ѷ?J &_a#۽W#ܞCvق? ܺ+88^^W<-̤>T5EIH؁gNH;U"`cwsʃy7g3[eY:%6 xgR[/Ҁw)rg C*X 0M C5 - (ҁ}(/Gc 9Ky[9%u QM>.ådu9ɝ Ca;(T [j ?*q 9Ij%q=웩nu`bV$n'F@ΏuX/K++=|xM8&B=_n6JfD^;dh6t4 xy.TО$gOT= |cek 5ƩE*[xR/uVO6E$PPhD|B Vbh~w)+!tkHJ128;lc'H+pp44vL6>!-7˫C-dAm $7Y%V7t4-Q/Yʳ7հWg-kGf09z\ts]I)[n?/bM">02ЂH7WMnↄz+Rs=GY!:f+e͂eNa Ι' `(]ևmFq>>e_'_cVLJI-Aô[Lճ$18ߍal%̄Q$2%6ĠgMu]W,`p~jw)|zuĥ[_Fq+%m>ü1IX)s~GΗ ]ݖn+tɚ 7%U#pfyf!2 {* Ăy%R@clqaރju*w@oLZ[ WSJ>W;b&co>.-\)+J;$HnVQzM2BHOQqPÀR싄fOPuS>([eTr!eOFUm3GfI0aT5nPr%W|h}ǐUQDxIemay 796]!P-k\ K ]~[cԶNM"*|,$/iC62t9ƫ*͊.sRz'-K5.y=fB}ʣN{JӂZA>*~5/\21Pl_D'o!q=$jN`Hen1El@X+M`i)нLwDQVfBYpѮ)`{2:.s ѧQ\S2Zhq^t Tቖ>zf۪O-2t(xEl_.OȿGcM#}Q2K w(aa0؂Zm7u\Ic%Y@[6'j6Z:чȧ(~נVrt 0_56OC◲^ym|Z^=]5 ;fs X68cY7p!'ey5RV lkwWᄾ=fcg5=F@gd+eKE˓>]ڣ$%T-7\VlzЮSC岽L⧾rىXWfrpr C%9a" / :4n*`N9W(5Aؑ神'E\Q%PNF#a@bb1.;g4ʤ8)=lj '[P"`X]w4g߯w\`bGPQ#wo.*7% rS6 r?M^*k׎|5*f8ߍbmC r>i_#o~P:78Aޱ_5n]uc+.r.b隟bN$ A^m$lH.a^w`& "Q)vעl77& Vx(Qq 118`\mHF{AeCR5쮔 E.WO4ܓÃ&N[j$xދlc.4+\Momci?Eԍ:Uns$YY^Q_B+"V7AEMRuZ땃f& qƃÑ[dQxЁOy08!eE\jO>,2~Jm7 8 4h7%E̪Oi'Iܫκ!,UL[Q!O?H gcBxN9czth8VK3]b\䲒1Mfy S2WyW3):lP:{Pj#_#fĴ7zVh&ѿvWKg-" g\mPC9'gw` ,֣O465&:9x=;[`Ul$RW=65U %m!4eʝ61 s;6pBAI)k>iUuM[%Slp?/dF ZtbVo e2}iRɱBIU~ [Asq}ap;RVLw'‷ld]+`EB o;!0Fq8& lhl&l=2@ò ވ@$4u3 $/2pc>j.EH~Je<! k(!x?_6)"[5Mf#!ڦkcĵ:FhK |nOJ j8~28W*Ȟ+TH/D#&n~R]Uav ۣ`GX.L&nkBMFƛ]N?)8IEйGz" 3'b./$6_;A>wt6w D !mW߇""X <_Žy;X٘s8B%q^Tr}} wۘ B vǘ9| 8tvxS_!eKw%+̀gb)A\q _F󳠖+OW%`:>.lx o [`k|pheQixxvzVLN?&C£ߺo eϠ_\U_PC`NV#qvdԓ-e/3T;Y7Ҿ@_&t9џ5@ې%!x;KI6s/P ̇})azZݽbnظNSHJ^Ȭ0VuXpe p(W),%( Iд9e楃N歟Ccvp j[* sl~NrZ S(LZZ_dQ5zws-BLBrm(MB }:ke)iқattg\wCKFMTf*B!ϔ:@;O zzb >LVj I)QXl^xFR/`4,ENmHd$7g6q~27T>É˫kZUghAn &;R&гVO1)nJI,yMp}$Ɩ#znq*<ֳ #5@~"Snd)=Q)ʝNI/<ݗBsgn$. uF#BMXVm(gQ(.j(8/=F$JtY`na6t.2CPWlpuŠLlTX[S! nI^8V-RdUJ$4RY?sM[IEŜ&;6p=Az . >%n2s36wR{LYqʮ.H] ,kxU,k68r9MF;]>4Ӄ0Rr Y]qJ>t &3ft ԴSҟ_bvQq]9 \ %N/df_Y9QbDmn>VTJ|?P':ӣ9㪂}$P <&0*A {AB]Vѹb?ը)oQS.I4=Ra>sFGO&&Ψ=)5u0}g8L1W=$% 2E+jtjx47xNcKNt:#MdմdV˸)8` X9( *Q`/@41oCJC5^ʔU^/'^%ý7޶2Bwjem4hZwˁxP8KEKs׏y{C"0nmqLc1&A=N27?a,_r5^tf'8]:v.O=F3R^GXeZk 梎0ڻsH6X6&K Է',1etFL']JCZT ۓֆ=Gh%?< ] 3ŰN\p ]<͌=~V,}4´t᧹c"!t&aD=e+FL|<#|¶-yx> W w3FiVEYct>וb&>|,͐PzkUOaٱ3A_ 8,Ѧ}8P 'ݿUyp1Ct턩j8̠@JܔUN` 5mdE܇)5R0$R"#y fSu6]V^9݂̗ڴXio|_M\GN{:/X;36 St%OTŝvL aDoP;d*@rGF2(_~2Or蚤%eRlֆN02ȡH05/SsӑZ3HrR/gj456"7* bKW[Z=bU#LjIQR0EVZ5kj Hh}f}$dˆcp*y.*$Y.i8_j-"DݩJK]ռS,CT)7vf54Qg5mSo!ܿb~?p6Cھ+;>mP|ܙz_`ϕzCL9ЎA3ab:+f|8`bˁB!c@tlgZDA2z(6&58N ;w YK_7i"_3q)5kNWp 4%O.j:_F"󻿠 W&mJPA_3I $4 f$g9%;A=J2bWYzɖ@0H(h>:AAkk~K\PϜ'=[9bxڊ 7ߟIoy4abr LFp 6ܜȴV#VMbJ`2Irr;zY 8\e؋*OiDrZ1˒Cab gϝUlԖǑrXW Ń {>.X5'd~$^ω&} w^Voqf}0Bv1|H( XIߛlQݲ?w 4PvWO QžYo`ClS?_aD<t@1"PۃκVNS!.Ap-V Rn]Q_g߃L6*Lǝ=rP#i7G $PqWj@eJo:?A.jgv7)hЛ| 12NdgOj F~E`^^ ݫW\O 8$zaq=)JjL t#.e.PLcU{'|ˡ">e&*&I9Ze 9{ >i4ЭLI m#~;H!'1LH!?RqcZ7kfN? ]4nN\P獚 p:W^_~& qFSX5yT\O [FH*̶x IM B|k򰑰 G-BN*௫*ii LRrG.m\/O{wim\%ɵYMH€na%e ŀ2BHVAMCmf!qVA1)ɺ>z= tU'A}qg+R4ΤFӀ9|(JO,f'"CshcB !SI%W?ÌTDKeGLU#boa XNն@xf[XP!m 5k$P8zf*0@ :L+ͬ{$wRz~Al 5V>ʟ?eơ.fF vlc:A(oz;[*8F8|vuV?To'|~|`#Pfw0B~A]nb^sd2n%˭r0M *6Nsӝm̚*ݲR9\5z2B6 Z;L$#B)K_ؿQLcU ϙ\H&HhGA-%a f 8D?Q`{HW;<˫+\z@meRix17^lZ TNK}甊c)X,E2$rm(i [Pm@;[-$U<#gOq{hv$Thi靘K:ԁ-,5|!Aa9peK/p tuA xOy4MU2 i;j^iwlSޜ 0%8WtMdt#MCEHZ4r/sIbSop~G>#E;]&;A5(-' ͅ?/Ӊ#$?;(V4ٖRψEozC?Q/T2eL'+4u*aC=.si?&}XFY[U|ܽ/.B3bܼit3q11?ԅ]0p^(:mp8+Kk'S@ -Ch35NGB$L7:Ne (Yz k!8vnM0C=h7ZkZӝCl`( J鐧gI|[o(٦{KhNsCH/ !TTN9jxpo^)6ky\$Wdj ]7qu1d:r @^rTH)Y7l8R\ sIq:tކOwrt@5if\`n iy#1CB`j@bOҐ :PvlzB3T{Cݯk3c}i̍;+HჁ\! (i9cD$!_d~z+>1瀩ʧGpH$jߖp`G}~EA,KVdxUeѲiԞu yҮGǥ#V? +_dE`7JT:a(L T. ΏyNux\  #kfH55W![b YzjzU )2kZ;X#ݑu'&l ;H^,LJI;k/3GZ|u)^E7c&rp|4ML 64YHξp4y#O3f90{PB|;y=ȍQ} B n.;$'[m򱸖T(v֜WۓߛQ:3tTafb`z]ɸP<M΍ #g265xWDL뽵fHSA3cMYU) h>hTC/E4RVA\\A|[pXT $mHsSoȧ$Mڞ35n<__|/m ܔγ/Q~YV[i1f@$M~T)%Ad"d{jȯ%q5/4j z|I./;=<p}uV'V|=UC]`y4EOqˣ8xd++kmj؏yQ=h<֣&ٽJ ɌXmv_S<MdCS%PUq3M Fw>f|98KZ{gB-Qxrc7ΠR,cww>Uq0yiY5'4"AU%(hI PTb+t N`t6&bq̶=2Co$~Z.:p^vV6q$Y-y395~'V87). /9~Wc)b*Fo UmEf8%6e1@] o%A*M#d lȋKmh˷Juʭm~@~=X;m.D% PZ?b;[鱃\!q"`n)#YOܬ/`V}MdX:ޏɀsl=s9to;opt 11|oMZ&qo^)4aan]0 y8|-ƶ͠.!mκ`a`]J&kx2+k )r_ W̩_[Qu7Ggst;*jtի_)<-&E*Kwʩ۰l{V%/: Q疈ޟŗpΆ";,+?P@X2>h &vj'%Ϸ=&hx&SZ/U*VhRg(O.kNk^CghZ #] >,ɣoyTe ؉BN"j0fnOH7K?] Z6Ì=G K }m˃fNQ*1w^a SRˏFmwi%IKτZpڏp]R5|$kBWV8V@B r H.:7lLu^Ԝmȸ&-Cbћ|G$>Ɨ) |]q2Ռ¯ s9} RmmL.:ǩ^#$ ۛ 0x8R%ǰN55n!7N蔲5/,& }E X_Amvz;{Z }k8SCdD[/uwZnD'Fr WGNU'j+P1easر= 4hu3W`au =jn #rh8TgH󡉰Q dVL!AcʫqaY=̀ի0}re(y,3[4LH2fὤO0n6eI$ %(Z|A?Lf+t/l~OmE\aZczOOJAܿZK/n9zdg{L^og'܂TE 1u ]4:::PERz I+$,c+_f# O]f(@t38B>ezǂ%G2#NFߪ ؕK ;J[)YWê&`+EJկA WkȈxN^xKnLn (ta RL!jdᄋY܂q&Fg:q  1g]Z5 ^o%P™FAгM :y>A! ΄W;V:9phyS*筌HqI>v_ vYDWc)J[a68$q!Sݰm.4D'qS+ m (Ҹ]mJ-md8(m.+q$q #כD½^6|k<C1ܰD>#5M?K0H?%-+AL6ߎkj@H{>oz0G~OE"\8/Y(nl(amF6.>{:XЎ&öv*٧}i&vJ aM\ѲbESi'o% Wʆ= D I]='8!Uks' 51_J 8#k oQNO5'NsၷŢӈ Wfm$(`G4QNp26SFN)`!0泬hk4IhIB}-jWΪ0-Hy\@;ZWeXJVJ$h< !̩#c:B^ᝆWmeH_? Xxpٲ;ۢXhL󌞜o2DRMryl-HxܛL)6 :f[ W響 ne~A3dPVFbgv$[ƹ);feҟaț8;4Zd-7: Ry"2D}\\z>S@?/>>(\/K,\EC>00kg*seuh5M'pn`15HJT_A|dɇkpNcTDKF쓈6S7^I.˪!FķZ]'<܂MT= t Tk0oΩ6LŶ8:j >/1@Z뱮=w`˚a(! ddC }Ц;G؉ )~[Ӻ.WvU%s:NviSIhvYqh20=۵1k`P.B/\ h8{|m:m=y3̤X ysV-^j2uJlq"!aH|ԍ>iq w41Ѭ%>MgY4v ۖ@׈xA (m4u>c40Z vѯז?qʛm׍DUc7a7\;le,F[덴 ,,IRO/ʇLr6Vd` 7 QCs)tNuXyXAJ $.qH5Cq aROp}m]c~FJ}?M?-X~g3fɾlX-UcZ~ݖ#T&,'9ͪzۇݘ9"a{XN*7aV-%W { gf/woݞGƒ nɀ >{mY "`H1rb6㊥e2#hll ,-DZԢ9eżSO6D#fWks0$reHuRP.ⓃJL1\๼a0AXDOWiep#%,λz-MnWT87ћ 8S$ _{E(W`[)DhZ6˸L!)PҲ[d[u|IB&Z$g)j x[xV b a2sSCKK=t-s~gۋǟmӇ;Yy$4dPz[ڤQbϛK.Tz۟FP$[F혀KKȤlKY9'1FȡkaxdC7튂vH4lQtvMD[$@ d2Asx3Sh G؄kEwPXl^_v$RIɧWtsBf>1x@Lvךaĕ)jXK_!v}Vxek~+(GO;D}>SΉHG7{=ꁱ6ߎ%Ǻc:/X .1huQ4$c*f6s><KHBS߱h%74'ϩCg @Ǩ˄x\&6?KH=HNi/^nK,rlѾSd${pp~j^% k8F Z7dau:pry k%lT(׎~y \8qLH.&c\bP\hgXY{ߋ&[Wz{۠~'΢NoL@T1#j! j4"yOF/:uo?ЯLf_Y |O0}ITя4L8z%-kz}-"É^e ͏4+4)vdYR_o@޹te')k[m fpl'JP?EvA_ neg=s=rΦ Ji4`7Ftg8I!cV[!/r~=G|@4HGDЗ6 9asax7H UG,0Ky` 9eYG&)E:y{j]Qi܆aÊ"J?=fC?i ;;e'S5^Ӹw>֠)KNH{{UsxNZ業ܡRI._[:MzI<0mJ Neinqt?P |3$2# ${ME <-qt4a,`ԀͰ=^t&Ⱦkh y9urMNbEu  >&?4֘٪Ό^'o]WuykW wlG(POQ%Oo2(%[Z;x0?4Ö&EL^ UY\X5G890W +P?IL9C&%_js}{t%/#S<ǟQ3we.jIx?-t/O6q9]g`ͻ>c=5p3l}5p?28i*Cŏ0K &c7|EYx}?H@º6MQ[A'N\Uk}8s&6L[zYj-̘{fj%KGs:!ӻkf[`dpX8T#yGd{#_Q]3}AUs|0/@8ѽ:5v`J. }T9,dUV i8Fp=ۤĘ5k-t\܅. ;QmAZ8Nj- ; ar@ycS}חx/'oy\E.O!O歩^D e:v*V˽?YTP`[Y[J1}NsTcA|fCű{o =QX@L֯?ZxhN:-l;Au[m`vnjHė-<I\FLT?{ 1zF=jW(9iO1sy}7uc%^8zVoTc[G)rvJt` 5z4-skK5[jO;{ Dx3ij9TgY̱W9 #I"}^H$W#.=7@ÐdCfV?530p';DvVQE]X 6BP%Fΰ^|xx<ErnvgQ-h|H_5v)Tmoz!_9U_n~%Y 9![$Z>I-A>&,Kw[ػKX%A_k}>[ dKrL# 4eCCib;fi5 3 <:b%.'a@i#ܸjNAUMEG7@1s69΀Oذe+EVmDw'(٨Iq'(fAN.:_Y`.T:~i̞x vp=ő·["iҽM!NܪGl>)@%O#LinNmW݃[#;ّ/ l7)w|7%ԁzv<:DGkF1;?;٢MTjwHiC[Q)g(R=yחf ㄫ0A"^"P;b37::$1)gz9i#z,5}P/3z-LpԐb<}P١a1ȕlD @ m?aaSCKf3gWg9w2j&al#Dj+ڰ?$ 5XKТXC6zR\*;HSc?H+>M_RD]d6!(:ܼU~HݡBy2%eZ9|t R$ﲋ|q`zUPS7[A<L0.gS&K-/mV.eMc*{Ágs^]01Eu(${Vy<^v$ǿIhUIXR'0D VMLZl(G*Vd5}M{yêQ9乊#Z/vྚ49dp`!D\0t0r8L5":CoI:+>[]%J.nշ\ڎB/γ)?0I)-8x5}[9bT84Apc@$Ɗ&B͎ `x)_,mL'2 d߉U+q!Ml!MLa~5ja<֌x:+eA5*ٳX ؟c"g2jqoCԍL]pj VGj[DRKAjNLm-4՝ 8 "^jwÌ3+5]7f3}a+~/]mTb~?rAzS&}؟ Ag?aslt;1xN Oߌ2w W}<^\LYV[f8(ql*r-po .NJALŢvD΀yYt̸IE:EȘXke )wk2'tNFjJ܉v_TǶI(G4qa\A'em+Ż~7 Ԕ}@ W'ڗ""Hzyv `J{s!.zYS͸4!}>Zk&1# ָ§NQ5DaP?6Cn %2=7EqO&>IyÃ2y/c盰Mt=ZADW5Zl]c:,a"s~J '߿h"]^^9955%dƙԾռtCIR~sB`nfU87l˕d!J2e5.;~HJcsZ\$ܰ"*nvOЪ}hCLuFA^sxF\t]BHsqJzYݢ͌#+ѐ 7QgTчXKZȐg3y5V>8q+޴ Q'W<(r?^i\2*`E)v6LD/ FaX{ɁCJ8lBUfDz"eTlsYH3RG=50w9LOxHך>s :+C*/9i%x/czR$IŊÂkPa8U&%4N9TOlZ%뙪3 Najo ]+ 6cިüIyY>(SLJ{GAkO'fo3b!"MRtT%J|,$gΕBI+,V5*~] ID:W3Enr(H^LXuw;~ަ~x5Y n]k\^BیIS,&^Bxd@d#'(cQ[s.DOlN &R`d=n1վɵH.e?6g3ʙ8z7 3+sj8~ \kNW>cPa< H){SЅɹ隸GeQ)?"E1NaVגY&h~X [^#zA>zxFz7^iFhg 3,0l6A jHt=a sp2ћ ,%YDn 1k?,wE%^r5; /C-< Mfn2r~ң6\E$@r7" /o(2Fzp46"Ç oVɡ}P?vE5? ;6Enym t j0&7ᬚCP !`& T|L"`!aLJa&/G2L.jΦ~daydŀAYxRִ @ѿ|c<<"XFrekُM{#'f<0p<8BUڪK2O!^r3#$α 'qY^pֈ-SΚF_DѪ.pٺfYqcH: b!$ҭ`)+EqiC,v*9<~fi4;suo`^t;V#+C2kǾH"܃R]kBzR ж#UlT05G o+E pz~nD7UgeXjhCҽ0S {b?O>dnNmn!.$HHVeDJ86r­ᮺSkúj =7xpQh~4n; K`]#mIߵ<:-(?#5btf } ХrLY=Gɓjr &ۭ Uc|&U dWG ΉPib pGHex}xnz q2n&L cqww>aƃL~+2lڞyّQUqS9sY s\T2 E#_Rj#y,DcVXS<t! )dz _?KA1a8߫ KB[R B={'. ("YJ[ɒބֹB6!T-"(+顦c3773@I)M.*O+PB*we32:i}Sa~]]*~lꪃD20896:EށFhԘ/{<4 `E?d!A+BX%N?%0{׾ 4L鋠0:7) Hųw&Fov:ZيRWNvL TW0wL{) U/e] Wn20C@%LSC"uJ?ms,w#)By+T["pku>4]^ҦӒSuue9ã q2{﹉c 4aV5"%  ),SRƈhpօǜ_ H4}/7dD߸(2DzSdz=| WHҞ’UP ʊ9 M՟B/kWj'\`̜4gDЪR."oO:~zV slwyyhP (스8c' -L v !mFA˖Hr'BO'Bt67`N7#Vht֌\kYȡ9f,?A DL) "q#}ݙ'k58?~zոy-!0;k1#.B"KuJj@cX)-7A ڳJ [}8l YhcFPUNَ/G/HCB(f ˑa]sTt>E\ˢ&DBs9qӦޝm`[Xpv.npZ<|lAy|VmdW? jPfҖQ]% WGiE_'4M+iP&Q+VJK ThkʲC܌{y/כ7?zeHy掺0-X7[hA~#}d YGd̈́*C]S@/eTy`TH8[[M -25x{Cāc[e[#:/|Ng]Ib07iD1a y@e"R80ĈRg2,\RE5$N&;!V9F33Yn>ը.6OH-?qַ#'$u6Zz1!HD(ǮfhyZCV# /LǺshZNe_ ,eb)'1 3n jPHL,T {橷'*_D]X:t $l"3lWoSG><,Bid;ʯ1-bߒGTX_J;:, QN}N"M8kW99!DP5^@O0}dt7ñyY; QҺ @_ sRx}3zl߯&iNjZIg Kd'zz5fgFwhf $|n&Zy 0&" wFKhY7D}ytlo,[}Yk 详U>U?ϗv O6?ʪ(՘R+$J͈L r*ݐ)1;zM߾sA8F _:Us,ZZFT;}Qvp!ygw꠾o4'Q;dI)[ =hb mMh > VO4`6}ҁ姰;WchjtaSiG^_CtՊ2 Xyћ.kIh$GjlPB K[%cH"@;*7|>\wx::62k1hlɹ&{ETVrmXTU{G2`[{:`z4lbrƿ>=w|[l]gjq:?EB^U{ FXA0mCsk@XKUHUTi:´eYZH,쐿'e.hP0dnB1#_5qߣ&F=odZWilY@q|59 &h"YAGߡvAo4&v4A6!o7[SmI] 9:&shFxDD#(M3)#&*W}䮭DG7uzqz?咽noYIN^P%~(`6jR69E>eKK(ABYW{~2p'G*ܶ"g-jfus|{Ugd0/*bX"{H Vmʳ~]mf4]Q<ü78.^ӺZ%clߎVgkY_xl&x b7md1Ha:tH`dwk:kW^.$u2fe,Ci Z@g DDxD48mT|QO.!cm_8/ su=AW'GgK<>' g1@Ui8GR<~[v7vW QI|Y_D< p88،JpT^z%8yW5[F96$ BL]c}=!:ȕt&˕ ,蚷tA8  $EN Rɤ3>XIg{Ja-x<Q N9i>JCܡhװ E ()CLP~%GlOY/} 4SmX=d23!sv0U\׮he9pr8戎0r/flڜC> LTc'>Gtb )D*qnд3X؂ɭZԮ!DrXqԮY}BVJ:mvS-ϦgLFL!.O475˼ԜZ}cJ;^[@L}˞.YoE #o'4#̫\%R=1st2*KR[aܩReJc h0us+_+jvWG/|1"c8N,g'ܦ/9|YQu+ThlQl‹?S^ S= k)y.I/IN˝ .@L-ar*[JT5v=exO6;A2{ D'@at)VnB fGd%UՇ@U2vPޯgu@Z7{g#7)I6g/2`gM+dѽTtq a84vV@;d{$ΪY!1` ^Mhh \M6Z{mP]js&iE9wKW=R{޼|9kB˨%` ׌)6ab汙BNIv1R*V~49a~Mԁv$OEZTɊm( >/"7\%tHZ4\Nz{cvG$v7>#E|NK5ퟏT/Fg5{TX3[_`j~’C@d M,cD]#ՅF(vF&A08J 3?ӘaNj`i;*|wq}nwb#zL5t&Fg1?bJ'݅mhh%p4sf%QSU}.tΰ6t-P)1V/waRuu~g yRƄe|44ggVPWMϻW C8dDStvmlZ_V\-$t4~ AjoQԇ͊ /Y[l\+Fb=n"["Îky.i4y{O1R97@2%j1 !!8BP+Xu9Hܒ[E=ŏBξ LiJ9NER˻w&S"Hs`-^ w%3^i3JIs7V&{P>gSSĹ  ꓑ;;1& Y席C4vㄴS+O!̚*+թ9{ >1\)_5㘦it\M_(ZA%[5iHxm@ZQYl?ֹ[ HK:~Dܑ'wi:oWfdzX0|H|O+3]mW@w8h`[>l({5iJ/Pl9a !\MaNV8?%u{U)-zhmҟ+3ӿMz'v,ɗSpZ5yPѴK5Ld7}S1qfMepp?3$D!/\f+;[^KrGw<1"D=BXRn+QW6ϜGWʅđA,X)Q;3!hN :M 9>[2Y3h_C`yMn^ jjȸ%|?-pp'qsIslQ4o\sz'%Lr,76C!LS))XBF6yMsnL[[*ι'(30Oٺh_?L]r:&~W-D㦒stFB%f`! iڹJt2EZ%_t}2j~*26, sux<voʿiu^L>t# |*coxl8ݲ&"5z?)w%ۅlՠ.*S`csG7 6k.[s[~%S=_p[kv喵N^6u}Zs>9T I1&6πTg1 IA)hUѫewo cQei=NĀ#3e h+\;_1Z,Xe (oӇV!Jō i`Cbt;> vLWjPBQ=^bW T6] Qa4!t‘<:k䥟Y/k,GacZtۓvB^=yY^E@&JizsaLP1iL稰UΤ6PzӛBV 8gZ|I[7ߩx]޻uI,rƆ%Sb1F$՚JEmJV+/j?ua9 ,}%CK{.#0wn` b.~Z Q{@D̥t4^ HU#zDH }_vJmRP/+P CJ#ߡ ]RRKb9 ?ހsLH]Cʤ=#}-ŵ@I (!9_<Қܙ3nP:cs dՈFBXw *_tOgJ6Qۥ8{( GnG@'g֘Db܅ ()@ u/£m`4@b4bp"KV)?v$h4}P@'uQYjKh nj-MJisFL4`Y{}9,YYN!􇋈:Z,896N5$dhȍtkBIjmBu~lzNҿxGR5덠g{ǘzFh ,l"W8=v2!`(ԎG{3W:N'{0|rY#X=lޱSG0dXDBȣA"˴fQ4%K }=B2dj^zc ^ff8A]DQ#?m#v;R#]Kz]c&!W1BCے9WR g 2VO^MJ԰V l|^rb'AxiWJ̟m5ٮ׬Z0OoL^ oz8U5^h[3i<9TPt^(^H"i8)6LmOzc& x ;)ez 7*Xem;$,cg56[7Dv\lLit]8@# 댁gθ. UDMxՠewV)?z&j°00פ^O[꧱. T;-t+絀~r鸼F[ֲW㥤73 ꡎ+4^÷NЈbڷxǤ "): S`" kẃ7_e@+>;vɽKJVSM5Zow^8BH v猴d $x|Uzi9J;X3.ڊT@\A3tGfחw kpV:y8_븛?SU]J!0T\-gVY=~-o[ltS1fn`=];^O+=q;)öCVT֍-/,%ܖhߠKg79H|7؄L^׿f1|@ԢY[U 8(ru( t3^ Ñd5/Dʅ03-\ACzDb`ʁC{BuzsK|7»e YͺR Ofܸ6&7SDPR&oS +)C׶'.Y@J61i HLYf&? aBZkISTQwja"jE<ݤ_>@G4ޯ 2k+XȒ'I\|RI9r9`=H D>bT5Q71SAO&1/ |_Hgٙ^ޔ;tEebG):YӓZb# Et(2Pstb?@ b.$Wb FT6$Dj_B>LQӾs ɖ<xԝyQi&Jˌ "%ʴC"U3sߟAaz&5&aLW}A,xpB|?k$}bęy)C[j,YoW7hNjW&t͝+C:8| H/:D~A|[wރ< ;E=@ؼcfJ_Wpި])HX,#b9~e,d 4PA7^lN6zwܤ;fytfƱmaR[(,ziu%mj8]DWMM} 2KeyACFN\!nO|^Xɥ*Q =^@,lM ?_p"Ɠ +hmt&+גp{ؑ,FP J`k*>S|=135H5yc)fV4QlX8oz4,o>-j8repƍAr6p%i8{Njm3v@$/ݏܢ QVeM$I?эiߛY>vv*W—*׽Wb-؎0ơ5畂ݭ `{6KYoADnMSj?K?g^h}cN-;sYy?5 9.H ꁔ5 %_I*4\b<-I~|  E9YJ[j\rDˡG' &͈(Ih"Lפtz4VbPpndgeƨZ# @]ĐF P VjUR%6LlJ}{cjS0)f6xUH4<<]!YyM*|zO"Kצ#. *vpt&&u?z>Uol2ǸY {O٨,iV HGhJ,X@@&3nɛd{sãEp*CiHp7vr@~>{!mg@Sl*7<@teB7 \fkwktrjr.tNrrw>S*h> 7;艹X<XJ1;V{ \Kճg9&VS$L~T\.'LZW-xdP /ڜͻ/Q| J+Ӿ#C(G0}r$mH^GW&$Y\ ,¢JT(3m<YQ4e pv5 < ^4 qyEXQ`vB]bp^ƜC\7gS.H( %L%_2/)_C[ݍ;nh]lI(C l:Xݾv/׀R. N IeqʽM~̛y7:wPq ݉+)Ր;Qf)^Re&;a H&WL🝍J LJh%))EYr%5AGWĠd$TLuf,HDFi|C'p5vQ+gL-4`EJHiJF9]X4h]HҶV:OOY,N6[0ei¯]"GS͎z8w Vo 6!r#)=Qvu'17YOj'yWyMs#v: (.jz:Medv^JIB j6|F!X$'E=XJZ8wK;N& `V׽1K|46kC) smT:e\@lPVg/䕷.xj0> gQC˃U1үSI)]9p(CNY`X}͈XmM,&HV =bSȭ{{/'̓Z-E+UVv~?͒Ѵv[r?6^_7HJ_ϐlyԿLrݑ q,T}j59x΃wx|Yy0iv7.' L=׳ TC7Fd1{((7|.1>80g>fu{`!YFd $*hi~TD'&f:2 Tdt HF܎95gbϤ]r2.dOQbM`S2]%G?v$uX\i=?ALPgӮ}Ծ€Qrh*)`.(.f<]Vyv1~}] #g k9hw2&!f]/ǼTsLmTE1U u۸kB%+{`fq!lMڨ8܀UT<4B2š).Lrj<$߹3*zl %CT9X}ztͱZ!P;{6<Ø"ukw`o=C,mnaMpM榡eW]Ӵ*&(pq=6CU (o"u`=J E̺ QNڭk7[U'gRK藈*e YzG=JV|O7{(6ށ7FUi ,lߕRs6DS+]8e$x0yO瀏e7e|y_RK2/5!h*S7-l\^ߗKk ⛒~&BS4ՉQXqD l <giu !`sÑsFjQun"`y"()3*5ؙ)6KƵ>gbK y/6ŕ`G[ Wo-m~z %i8,ɧ+^!Ù uTY\4?b>gǢ f&:sBLr\k }7]'U8-[^406MR }5{ g8`"5%~fp[AMrN@9Sm}rnֺ}N*UuIaRWAYEEhBԔ UK`2<> Ь\FlXR~}x`>ִr>Bu("~G:z ZfUsbȓj6R@e\Nqk5qG)EQav;FvPBitc*t5@{rk{8 ?~d9C77l0=x<0Gm)gc7 귧U-HgXU_r-0kSQ>|- -{VtgcHgNHJ,1EW&*˷Q䅜oވr-kPݨȪ>AVzљ%ȼ!`+9Rp}Npd(x;h^A F@TJ#԰R<&h=a8qj9 cG5jߑKhO9(ajgLlDKyNXA/Ckos>fӼK,P: @޿HJ6@h(hs`=J mŞQҟ+f ٙ#AՓG Sio/ETǎD! 5+\1?d]VX[c6n-t+CA4!]Ql?E,d$ȈT Vmp/FE Tp|Gh]pF%!ʂPWN@d(!Sgn #n@M)`S&hIpWQ?w`p?A֐Q+ߞ'3z{b@+ vPU &[RݠpWÅۧA;̀ ;kr3FMGk:Ԧt={FA2j/|HpxIcQ0ᢐ, <줛puc$-joPWꇖK TL[iWP) ͜?/bַIk]?G+ Ɉv [ʎI:6EWMpv`1Y,ɰ,pK ZYA,8t_0/* Up]|MA2= ׫y#w Pjޘ_zdP>[9pē8ĝyVRK>:Q”wC􇿋GqhiYMjktwae@7B 2ɡu BU9e `be1I/[Ҕ]AYp4Y旊W 5"=⍗*tW@w&q-°My]v{bKSIN6[֓C.īC% )ɓ=5cc8 En劼F$Ü<'z? ÎL%(ːȓ_aΐ]_,~tfcrRJ[#pFNM-֌wC 0)BN2z̧ؐ3 񹑢,Uv)V⋽4A^Z M 6b h: YR!:I?|٧&bd#p_f藅EH7"F;ڸչf>agfHcv.,-. B8> dȭvM[ @DkÇ%e8֔2[5Lgs-9hW$~b#~Jlcg9;϶={81'&^{'{U=񈙶+G~Y+։$Ki=}>Ӂ̣W>(D[ߓ-=0G:P(`a.=Pe]K2 {l/tE%,t!QP; jFO7Am{ VPt\i R'>V߅ |X_MM׷($K'I:}<0,-F=1?f/*0Lyafn+8E--Kc1B(e\%=&>E~[C`5ej:Ʈ־VRc}jϮF&&="WƩ7j| ka΅$Ķ d `Ӿl\au]'ޭAR&[K6:GȘwEL.rdvcph<9h P;ڠ}SU?ԁ>;W*]KX#82B=?TҲˠ@(wSz֨ r ӟ!ל$=D6;F'$-o7yppPW NoGXIEKThːTyAĢl֜L(JU~c_cNUFt |3\Eֲ@.p`]ʵj&Կ55T27F\! <^mCn L*Y J0OE¤{m]!/dl~ZQ:u@uLtyvA_\U?a>ho8aOm =>">sR? ZhSt/~19zښpTGFTڐ-Ķo?$m](ɡFdPRKg٩J&uQwO@.n0EVP,XxU<)㟝XWLx^? JZzbg=BEv#WL[AM 6`ʫ~atrZJ ~eZ":_iie\uQLagiV2( dq#gƞE'l`RqܐaHW*vĄS_wm‡ERfL ?U6['o Kp…9bR;3'ؓ T%Ryp'BlMq|LLvtAP^`HLJ_kgZC1S4>cAQκ)G kT~>Qnɤ9  L +s1@9&%<d,0DD(_rn pOģ=lw$8!_L($4 ]Ԇrxif@5Q9_Q!y_jcN̗jTm Y޹S  b$ys m{!q/'Ym#P5FQq)->]klW nG P)yqMz zPPXV%:tk ~d6n`2-ܾ܊,.='G;T *Uǵ?_X^j@D$l; } M@ TRkhp`~փ=#2!ڔf^(d#&_#l]IdKnq6ϟ?|-n@$i!+p# xI\Kh@LcnH27RkZ/]z>N_stLA> VLhHzS+ 3DU)RiИnEgXI3Jf=mm(J`6士k ԇ6kBjvu\ۘzp6^wA!6QEyʂ&җ<,\S8(`Rc!21>pjÎ*ԩny:Q-Y9&'PvNz6&ά1~ FPRM!nVe3?ՙǧ.G)(Tߴ:"NAWFҽtFbU.^HqX`UK\;Vd[Ǿ10s&75GW=:epPLp!i|} A+07T!ƄA{9*yƤr>n#;rR\dAzY@,qO䒕o~ZzR飞xюᯰkڪII'GOI$G,aI`cqPq h;]gPm`~U1R*Gg͛Em)#ǽ/d;Z(LΊ3tE䍺 ZXb[\?L+^xl|YS$TG3ڍ*%EFY(r/ocp&)"Fr?6=҄cil J>d2]j{xbXh;G V"s!g>#.JĎRjntUsj+j-*!,Ig1V ̀!QH~+S,4Ef~U`zK{5cF~& A֐/\1. 9gr"WiV$UV{{;2N}Y7W>k'3.kVXWoIEʜ$4rGMv[L"q#e (;ZK8m4Z-X{h7(5蟷Y =q23 *?{[P$iSp{lo2`; Xjs@JdȌӭQ6@6@:cHɓ ,B ˌ̥^q%u* .=8}iĴ~H|7$VnˬR&E©Z |b8߹bn\ 5{G \{|cV$.+4d?"$Zql.% _rzSm{{F.ny H.#N\|L#HJ_S=;n'Ib_?c(7yΞuh9:=ay/׼n|\pB#Ir.BC@*UMw8UD!bWQ%چ4y@Ńْ\6,|<['׸q{Tq0{Ƚ^&'^笴E,^r<`3RtYdG5=-dٗcV2#JZ]#IzA8]Xn΁&,DK E0ynCNw6g`t}|G4kʑ!t ]fHY 3!k" 2ogMȐ* sƄ 'CK03RaZZ̴m ft,v%o#ϻ3 cD{#z #B67;QPE ^[njݯn׼caѳJl0&'2Ȼ)'cAS-z v 9IR-evOC6[xV XD9wپBݕpj~If ݟ7Pʌ|Fڎ+r^Hpm:g 4⏣ᏳUͬ r'[y<hҿ~N bP?W-SPH^1_~< ȟNP 4 ~-3#r#| paXx$t$5A(w _b{ )N\'o qob2 qDzڟ2AggcS` %QPX)Hq>`wB 3׼NԧV/̕蠤83 aoH۞om=[EZJ9s`B'U6?Y17¨Ĝɷ'i8%jKp-~ڣvd}S^Xe0^:|2Lx~vNWb@oXv˥u}J@!Auއ&I/KD x Qo%1_!=O<6M(^+e `۩uP]C#h|І7Ŗ {Τ}/Lm.Ub Á% QQ =7%҂Cĺ5{\PG)5g[i1:>Kjt`~ufZ bKXUtmF3^pLDZTŲ^`OS3#+T]_cZ?X(T4JoꅲOB:5`Oy͢D;e=brS-$Vzj $!$D~qnə6*Q?6Lky'nmA_AF5GEoH7<-3Fօ5u 1sMX$9837v ez(^`7ʀ.,-"$([YN&kOr=^8sX,  Lpxȁ`%|mQΣJ(W+ *=9ErD-p!xNځ ҎWZE21$tBL?ڄ\_]wE0 ,(^IԁA,3NCSJ뚏ބ?9Q.Aݧ׾j^OoV>vhc>$M2W 1ZdeOu /Z~&iD]}BCJځ2 ?kvN1gj؛e x@EI)>4)(yĔ l JAV8ж%G-<*% Hf1 7|%icCeB覉>j`xʂ %v@0+)ZG~OЂ (O'`e0NnvNCك$[ h('J[en%dH([#/0XdnmGC̨&N ǿd;Qj@ŕbTov8:t~>J 8GBb^q6v/5qZ MYf l˓8y%+_1 Hg[.c$M.Ǖ$䜑8a˄,7;p7LƝ`pˋmT꩝Yc>I_ȢTaxk8w֭+Z'G/Hn{.&3J}? S&d~sL#uF>X$S/(py8܃饗jkVK =!J+0g`˭ ?Wi6I;웍 >74p_Fe=YPm[f,a)weLr*4Ysʍh)ԝj-o:Nc/ IL b疻0\͐_[-ט"Yګ ޞ7SJ8I[npx*%TiDqsz `Mi$SDZ? ts`g2ݭ{UBwOwNW `G߄?n@jz8ˉr2+izD ^Hi~Upd)RWg2%֨w82^C5]l0;!49"ć"~~a2)d(%==ϟ+K}ۃTXa?L9 ^CF#`AO b66 0ܴlcW*DD`ʕ׉ r7} MrYm]tХk% iX p(Qi MLdO8qQ0mbwnB bĒOJ( dZL:xRS=4~^>.b5) @]ŮpL#E)ܿ %.|^tgi|\`mJxR1,rkD.>lFl=ATB`dm!^=ϋil9kV.m}>7/RhC^;;EiID/0 un10u P _Cru/ c8 ,~p*yc1:tc COdSj (QcbvԩS ,~_k~RFʹJS^AT}Q^3L-, nIH#f#QAeԫn:}p,VJkPnGmye?vmYN|(Eu*akl %C#C` ͣf;](cbѶ4eέGFE(e'nFAU -}J~H]T+%J c-~=,a8,@$O Vٴ+8nCkHϠJ#4wjhUJ|(F4E B41ڔ>@zTDřqЋC*٭`ST5cv\ulOp骔`P]yf/Pi ߫gQvTٹPnnNl,;cBW+qʵÒ;O5b?K(Xׄu}9N4}@e#L Lbogëb9nAUpY3P4*tfLh 7@MH |:i7*EbZ1Y9:f#}cg0vwCȼ'Gqُ -m e32:1%3ghx&ȩ}fD(416:]h#lnL̑%\& S= \nDk&"  %F^5^lyFNKT1U݂ 茎Werl *^F>eMZZf %,rtGYeC_ ~şOMhϷxthMov+VK d2cAǫ}X5:kR!7I-e-ֹ%@M *.nqVw1:N@vXSNzN!X`S2b.}c*r-؈M)id@k#O|"8_W[h\}`x-onn6bڡXT͋B_[AS3ЫO5K32:ǩj 2aX:@3776:횒V[Fʺ+̀z~$"_==.BŜ9lUE.4mu n>]AUx2!ɟo. 4N֏ZIy)|rqX(f"!E%%?X E%OW#X4c+߶n}n(ߟ\{V,sƆ EA1!*y@ (TnՐUNvq#nz#tڙKzoLuvWہ/`i/ܩEMOg(?rt`O.WؘgIE˻8Csڰw'6b%ZW'ak+Y51r!lOHH1fȻ ޻9+ӻ0d StL}'έzf%OUkT07԰e`ff碑n$5&|/r`"1MSw$XV' 0ZN,G3x]OÒ<Nïv+)iZmPW ZNAݡoZ IgGUKf~ĞZ~CfÄ { ~Z7Lda7sP1I?Ja`g͇6\!SdDo2kŒhl%qwAj؀Cߐ`}̑BEs37_W]v->2e耨B ;(t`;Y2%t, tΥuRTte&{IiKF=4^Lei eqx# ff)TD/N)@ w#'P+"ΧY>"2`4sj/۪23w!R"}Qt|ȻPg좼D;*@6 S*k\kY9UK veN߰7q.ez]VXgGbVo0O¾ Ƈ٨~1V|: J K`EmY|N`z!^e$5i ~K)K{?;X-' >gQFBGOM=0Ɲ EGּڸjjYtrbU#L@T?`ȗ$vHp+4blK1CP25fw}aGzOab/$˃X㞕BpEW @g^ ,xvg@Im~G-PZ* .ɕ~UpپY(M;% 2cFRqN&3EE u[XuüWe9܈{ :|菎FW {)zGjm0A5|w`d|t9_(cE<\꠫މ zq)IOU|u2' c`:߂BCa'c p|j*᪭0}_}KFyвHGƛpQGt& AM@I]'0iL+2~ F9njZv?ns"deߵ;]dPf*(tp#Vx u5[O Ԯ$ s)\QyG @ 7،)  27 ߰"r.ywd3o~ŧX'~R"ūy:う`a0 ]#cdRR4 #$ D0C_v HP`쉞F汩k^CD<'Jw)PwE&@j.7c)_D.= ؈Yվufm7>yfNك0:q}SZijyΰ HZ B=$b6>; &^7Wk.^>`XƉ(/5.s!$ gLK;:b< @>3QCYjdEG(s+dDbg#U?='~N5bn sD$;֫ +@y*& $EO=̟cT~:!Jh^!.1w8OXi= psbu_2 \8 csVޟ FJdpmO3 ,w;JF?ZxB/;kEIX^npYgd~a})"ML{̯';&L5O"aCo1{km糙#Q.]}.^{vͱ\%\BQ1!wjP e+M'^Xxn"@ٖ-4w mu]HC~U I\A5] hn;I\}wIa(QNdON'I' \1G4=LH/2B=N a9+ C,]rp-`g +,s|l_b^#}q!¼w%xu^0n7Hb|jZjT\•&nB=ڽPrp,dCYSdSg5#~#{_ڰu ,W2~,k%kТ4N3{H; &ą\$zᲃPY+ep\?W mP zy|:m!éI:,*!ؤhW'۫pJsfojx !pbD8gv(r/2 5הF.[H~gB] r}*(uŀR{9*32:D<"FPwO÷Q1632:49;lN=x^ގC[. hyZ]ަqY(>#x jriMj[\(+ᗸN mƉ(U``pj^( Oq2Cv$궢GF[M&19fE/^C 9U;7*B1d 2f4uIxxmpKl/s`P *cjEhD Ilk4x"ߍVpK%r6~]e뛄m 6P66}?"1j2Eaʴ!5?NEV+REV2s>iB(DZվNxtKOHφ1ZNJ#lT+fg /|, }꒶ERqlpQJ}|uxbw4rEA+(7/dO+3SbϦG׼ZĊ_6N$'R%8i;h^n&ZsLȮSd)ckQ 4!Kr5 ܱ?]{aqGU|;UJ mL0h9@(1%v 2e¿#jM),҇|;XK-\Wlr7wwY vOT὎HrɗQ7E0!xᕪy݁` 4WKaC놤DRb|bQ[d9Ч,cH2<jV#ajފ,f1?(CEl1ͻ,JP%i eP,BD FQvRٴqT}]lןeh t o8WCĴ@Fg5`Th+O4FYo(SȢ =Q{%~ࠆ(,yE kf9dQN+'qY0qMt"T@?{ERQU(@~ ׄ"ýHmB+`&'[HxdGMG H@7?#Pk;R]!\eBy1 bzgi}ۗt32:ϩOGyjθnHCPCK"7-U2752:]4t87Tzl:T Gޮe33-V1D."/"D hMUK\JS/IoŰF#x{JeRTg[cUxk.lI[G3 wז)a'OҽwZ{u[w Spj{OdÝqM^h& _eLB&p?y[Xu~{綼Ĺ3֍NS !cpL1]>H3O?npUAƨQEHѮDZ"D dԲE3T:28gr w>֏Kꌈ<ԸW;8>Pw!vg`szM3D#09Ww %xb\٪B3–c:+6Fe d {h?>vu  Th 6wZ%r~<֯i"JL) GH@O.3-w͆,R])@0E4"a9w|]eWl: -)w6tB j}?ۯ{I-;:CD/sw9ϊYSD(ᙳ6%'`:`dB'WM6 {h{W^[.hMPEO9&EL-4] Ai6Ygիr䪩P5,Z( n: B[i,d?gaNhnV(FN cA3E yٞ_%TΖmfvToקc8Ԋ~W.ign |8Onu69;Hy_?t̖b%p"!A* z/sTFޚy CiNIS肿Yep?w6\TZ^PH20ˆ{>_kL0P3(6EY+E%Ծ3ucC6mՈϥyށ=2 K&SF0 sqF<L01#8Q}AvQ Jyc7Pq]3z404VOLklWDt;G}hNHM@ +Ǡڝl¬MfGet_&>iw N$*8j.xnEw]zP,ft!~x5UDG_p|zK{(Iw԰:C.:z@r%)WA_EmCk߸G-9߇aAD}@-9t!KP`!==̊>3s8ٺ~>X@V "2,=_Uo=o 'a)x߳ZŤJj.ʖ4ݻM u  wO5N9`6?;Nٍb*+'3B{ E:aȤ6szBA>8{qCjd;Z`,r8reK~ 1:GJlA&d8 XBDOS 9B͌P%? :4 z T3qFR||My ܏RbѭHYOZW*n|ik6O=A_Jrd32:$&=U݄,6o` ֗.Q1280:ĝBh'gß3<jGhuhzb!0/FrVvi S!DeƱh ?W3f t) nɶvK1Sa\Fڏ0 ~{ξX{}آԺCaA,+?/\͋r[($~No[ZHP;&e )[T4z$[bLtA(a;s@"ļ÷\۱`il#x߯F^﷦)OYOq>aπ-~bh&_|+,W[Y=HHk֬-X^B=߳AҚJژҝʚr'E:"l^Y 8-Ĝ?i`Dܻ)AN3 9ѓՊaIq$%'U.IipUҬtB7!-`M$_+khZ18Gױ1o. Z/Y9z?另Ʈd v[Z'fP6gvqm:/Μ} 7\GBH ՝ڝp({mtS1S΍tu }ZH\k #ir1D (tYCzl˛hQK=?>zW2EKB-ƁA],ŹIPTCd,PTҹ>/nVԔR|+[}KRj\HEu^ȵɏv~K;&jԧ1I Xm+i17ZU(`4i+KrΓL(<ܧ9}`nf {ݓc 9Xډ=Xbcs ά˯kP%FE;ee15:asd-rupture.mp4d0:d6:lengthi263671468e11:pieces root32:=9 PKՆ.=z5״f,ee51:cncd_fairlight-ceasefire_(all_falls_down)-1080p.mp4d0:d6:lengthi342230630e11:pieces root32:i}Sa~]]*~lꪃDee54:crionics & silents - hardwired (1991, hpad, divx5).avid0:d6:lengthi135000064e11:pieces root32:-xYdiVK9Ʉ ~ee17:elevated_4000.avid0:d6:lengthi113274880e11:pieces root32:] Xv&o^]ͫ(kK?:&i`ъ\ee32:luma - mercury _ 64k _ Final.mp4d0:d6:lengthi183814243e11:pieces root32:bi4ܝL >c(fNOݻww=ee10:readme.txtd0:d6:lengthi61e11:pieces root32:;ݯW_ m%AAU me>7*ͩ7^&lavs˿IZ|"mzJ޷>YP@I`x{NvjApzF^ @dNLz4\O7vpەqN _`91AR\Robj`$ e'a#vkgFABC%XOn +4|Ug8'J[2H}Bneo@$H&.WAm͝rxq2=(تS{4'o@* Ia'㮎B9j2O6@SjEBem=GXS qQ=}nALt5Ӽ73Mg |vgQK-npOUDGRwӲ'^WKh`(hE(/V%/ P hJyc;(¬z5јt79^2d#7p$URPO~-\GпՐ<>tky_@wCS *6._f:5:;/`;왊v6aeI; NYteHU? !Ezz$f'AFh*GM(32:M!ķ5x9>˯kP%FE;1088:,$|~/xį}uuCnt>柇eFq{ ݮ6z]ĦSI=S8);*̀.k)>nMP vk1]ԛ_i)R 潯 tƟTLn%H˝/0IԸZ}?0Zp)#V*@T1۽ 45uPt! 9NZCiL!yE=9qs Tl/W*O)z3PziF4Q9?Е+ 4Y/5Wd nq+m_0K;Q5GbV3_&L`RDjuYvr'p`('q>ˇ2Bxr8D{J'PDJٜYgW:/˂ iY5Jws?:,/$UѾddIL8mbk:c͚.,7*.apZ[]mY_J88#)F+i7:ܯX]h*T^nRa}Gy7؀~kX'XnH-Sמ[2ؚ׉() xCDE_>Տaڴ" my8QseZZ:Wg:.K}bZ^mP |UlZ>ۇX^CԨ~KR *+,v-ar0`Q4UR=n(xơJz](x>Ӯ E=?|܋s7BDv!%9rIAr`1<+or5Z_}3A#ϰEvB_ Wtǯͻ4}ZH{>L8/6h}.?qd# R"}4nLGPj<JpUE"z`+ w6 I'kQ XʅyɯQ^0aNL((O .{* &32:] Xv&o^]ͫ(kK?:&i`ъ\896:v>b?}v1 JΙ wsVdDϊ`@p +ev~ɢ.1\a[ b0([Za8P#R{ -4'A^_>a 6rKS|^Zm6blUhZ^2{Ohb4F Y-s弁klѵ a,jGWx/3e2ę1]3phX@X,u>S -"|ÐEw|ݿ )66w bDjPz ЁbJIKnLZyA#`` .o>n4QK~hmƋAcض/"4nᛏyjӸ|KTT6K2GAۚb2Hs ԏ!xbMk@s%(x]lj j96Yw\f@ 1v[UPu]P+ےP/3Z-i\]kIX~2h qœ0i/q<_?8_ tn7cf؁/qDghiy,,?Go9dhHvFI`"'aX9/۝LgU,32:bi4ܝL >c(fNOݻww=1408:rq&!HS:[eg8-C١ wgx9/ ѧ};v08G^ $44cĩ<滁k7nI V}q.W$ǎS;!G ʕR~&&Vr>ޮ ]>Ȣf]`%j;y+}+N|+mM[Yڲ3"X)>IJ&Nej,{9maԩ$Pܼ5+h.s.WRyNt BF;)4ZȀI* Y6J|?!cx#Y'nﬥgvi L(Cq ^ &r y(0v$XC%hCU,j][wj{+US~lүDaLX~MVbΝg.(IرȸpL/VwHkf[j-tCl;=1U:,AaF 2oFyl;<^x,*Ҿ8 INRF˫^FذR**SpUN´9@=YbO+&ts{Pwl3|Ppnm%F5Jo40\g frRuT/Ӯb-v rk`mKʘM6X=2$)WE΅ fE{OR^zwZ!.:Ŀ-0C"[(;-XjI"]4NdF2-q`/Ns2rvkKӪlꨐ:Z''8 JSJ&NeXxvBFOK`ܺ~L Ko_Y;B;@Hx| ℼ:VJz׈|pc#[ "~FJ6@N1E6?T9Zv1gx5í̑QisDF^>Aʨ>QO "t]1נzQ}lr&p]nsEHF5>h(Jp(FLUj=Fn#t'8*IIVMo[OԞ2ŗ*13 @LVeJzqc8QwiqJ@[D8Q'떩1r9swL1X@8D(!C\̘l;}4 hO{ O8la MD@ߜ >uvk+5=hHƒ_~#,rH6EpM(EFI:i D47nb}jmڙ(!TkU!'=mBMA8@ P32:i}Sa~]]*~lꪃD2624:c#'Hl ދΫ?l$#5[UDz

 702zmdM=JEJ뿇~(ww\TEimG`DdMŃ7 r-C'o`)p;bbw RsH7Swºɹ4 i x&7XUl2Q%ɵg`JzDA7/2h[hL;`iAh}/dBa5~Ua=5{U[PA홰XRx܂DGQG=SȝZWDv *+e<'6'LǸjs/Jio86?,2L'qmF ,λIBH{4u116]S.K-`Q;ya'1]\H eəW ~;" 6oq(e88za= I@'=QIKg%X^ag0oēł;/Os]q0s'mɯ@=bPa˛d{8S#Lv[Ȟt}w![4 *zzt?9ĸR;>jYS-Qx1iDrX~"84>,8Uo& Ue,/M8':R52v cTgx2C6ٲ$xkH6){?6\oP`krKm&>"1~Üq~z=/_[۟Z=^*F/& \3LTDy]8:V3zCn°4ΥqG-2]:6vp</ {%،fZ1:s{}'F dWRt˙SpYTv:~&8M5xg 5\(6Z vN҈^bpAŇ &B"?+%j"-Z`2JŸC6f-cǒCַw8TBs;lCY^˙x[-}G~߇]l[fFεsy<.[hg16LiYS:iL}FNBvTkp;Q5^ @VGo5ߦ˘/1n |D:]O\,ãlIq)"fMk%]%_+0*G:Z:Rh:'~ӨϡiK9L`$D{5 >YA]UIYmMHaq%)a<1q&~WGH^}Q뒂P]V|G&&؝|r䤕||!ŧ)K;$J/-j7|Kڢ~ eɶXKP -:/lxlTF][pSc6},Y (qIbrqnXges^H3ztNw}J$3=wlc /`LѾϗW$$f~fŃz+!Jw2 C_|6Dtb±]Ŕ>p L! '7cFi.Tõbߴ տÃb'ֶTK;X~Kt?,q ¿ʏ($.ň[4+&71~,F嘣t@|k6_eIgOY+U3xNj-4~ I~%L U类"F绫"-(`] + !i}r%##Jtp&G> #}lx xW4(wL!F,eC!DLg#Xvi*mU?5~"|U|U9 gJn'yЬP˧}2!JRH*ߟ'<<7I KDžI7b!7uKǚ-&sI-?Ei2kl ?]" nfޡsH^CꑌRz6›]'gMIUK۪ Ow]_ȅߠ!.C8a9(:T@ Ud層yM"x;M[އzGU=r`2+x'D3pʉhQ/1OuZXbZl~nV3 WCP8,>10(5~~ l&|Ey63M=5Okc]N!32:HNAH"뢂@`e9c=N/C~k)T=D&'}/3oHo}:PR+ִdlpĖ9VɧQq+Z%B4PK9G7x=f>}=JZࣂ7BBy.|5jW?O,*&Wt=]I% ƾ0⷟p@U{'Ė8i6GvuA˅'(֡Q7!#Y}1d75)(r* SZ*9MKxԎHRi f<34 v M?g3b"ml,uoڮjw[79G-hO~`l3=Y/332:=9 PKՆ.=z5״f,2016:; ]Bd;|mlpRñ E߰i[T>FyA-evn{[@k>C@kR¬?uDկ$f l5H~HY~)CY]2p;@Wjb`&Z(tepqypx(*^]F G߫Ygz*Æ'-M aM{_sJggIi.KS=V@qnvB:%JV|vuÑ/=A\ ;=eeM(؛`B8< MyEy,}$*Js=7O|p?!kkf - Pg:bhGqc^C4Wz$;Be.{EZ SL)$[=C1VxiC42g}T+S@Kd1CS3p-sأ~ӱORM?ۧ0}$2a]O`@R櫫(߶w3D\YB]wI%bf'h %LܕvD9A|va{OA$}]֛: I.IJճ *?v"0{ Ktky{}Z3[ƼEB 8SKd2Y<='߱Pm.>2LSy 괪_E Bu%|}xuV/ :ZnLs@#yJ铚=v-K*2 fl:ck: N-0uֿCw9_kS \/FB%40›l#|\T ֞iw꼮ngVnoXO<)f2/6(;!ދ\AILQ|MȒ!oJi͸-nuQ.U0 Θ}R -J*ˢ:*jYqMnUD QB lC%*G^RPVhd</뉆_|kI# 5;ӆoWie/+8WD|ݎ\%h).M|4u, f_)Q+xd:?IRMk$ڨΖFΕl k;$\]tpee&t uL'kdX[7 rQ E7 MIWu,aؓ/"۽'3ʧELm&(q9RM \0dGWA:y1 uO=O/;vBQ932:?_θU/! 쬕quTJ(eDz1760:ӌel;0,orKr+[ Y/rcaԌj :?o[b?pl AZ`<,yA: R-h*7pQjè|;DK'C{U^dَk`o&tlֺrY9b$v391~X(DuQY\P0n+70j X%zx!EDEwM{O%5@" Prδ#4opuzHhS Kb\eiţ2xCyP}k4B7B  <7nmf}lҵσ@{N-.@s a]Aұoi>lBh|+]s,6և^df~ZDUt|}]m8 9%Kk r1/dmm+wbh4ϴ4>A+%/`ֽd]䦒19^yPhė6f9_dr߯ZMR7n6IoWZji/xoqS:ƖױW0ڠEdIz*šz7QDivN%#X1!lq/asm6IreEv$"QQN() E6_? 34 BIXmNa5O.Xu0[HA .Uw/D*C.-2!9wP@@y1 \U*9+hy喀Ǵy/(A:>ʶ֠l;kj[ƻ$?; *mN`F/}HqI\5;mS}_L6HB-sf@~i2W)ʍM ,$]|v,Tg= sơLҕ:8՟k5{' 1oWS{d`1c(x.se{ԪㄵS^ .`JnuLCU H/;1>LowYFq? J'7] *%Z'd!oA0=PZHQU>@E VZvsXKi~DHghn5kw5˿K ̆IР_o%*8%^v;Ji\2U1.j?Ź -^].):cʱJ$ߍȒ;q^"r /[N5}O6K OEм-E#,j'lYY0\xZ p GYB>Oó;@ vQ r`j;"./,8 Z{ kPd?=`1ϥc[ Qx@!(om7`r^ڂ$' "M (;bT.,v>]%".E1cAF UOAKUG}(Yp;P:Q#@Vcj4Zcj<'QKykJz ^?:QQ, ڱ[&rS|umꊯbk~7Jp@RTc1[PJFZ"sw2-\ ]ҮA&G˖^ȾʃdאT|9Mwoo-Q[O %~^[6O`#FZjS.י8٪ {l\l&in#.1l5׌X=eغ岐zaY>%A*Eg#Ć *d)F-dTDt-E~nLekZ}@Mf*NY7uwI$$E9͛%49XD'_@'g~J(EFxPEA3_Uh/qzxM{F[}}5s O-;ތqMk(j Iֽn3fJ];i_;jЉZbם#y{Fbcb"]#hE!gCXp.c)sV!߽c#?DU`h_θ ,ymT.'6mݐ~}Z$9 .D&G rM=d.=όK $^bdzΩ5Mz-sCAމݸЙnԁ8<7z[a#3 358;r}z]moi B)`d{`mu$%\=U@a% bwH4M¿T)&ec,b8;' `h^i@Ft1DJ?|iS+b@;/4 -u hDfs!']]'-P|wK;cQ^K7(҈E#iQ*5UՖ~Czrۨ]y 9JfsFZ0ahb<ϧgΪYe1&"&o{3zJZ!Qd@ +M~efn43)XG:|47Uza"=NlI[Tu?Lpq^g|**ɟ㼎sp6]]B&VRr.Q}Ep>4zB 7Ar)k/.Q'!?*Ky B7ᄘ%y2l8SDw/{қ "J6cq$m7ޔr%[ a&ؚ"$/$C nfulk27[Ec;+Mbw.(|x#aϮ*x)$SqsK# 7j];!tw ї_;"SFjι ( M'_i632&-ĩ@ u1PҤ1l">od 潉^t&sbسӋ3|,Fo_K[嬰lGÔ;A02@V4|, &Zr]rOëjOcdPMGt %l:&>kA V Vا:o(9+_\Ȗo$InE;SC=CKW|vǞ_#گBvcl fhyb~mjnWD%DEj@Fk>Gr[p2c9\,:<Ÿ,((#8ϗ0ٴTa?-ޤ|ۧ"[ ^uva\>M]Gs BSRigsÇBpW~΀r -[N<@OnqІ?bFs`R;,Z3t UpFl] 2%}ѷCX.h{# LvY5tۂG`G]o~/Dx(&`UHQ Mp"ު#S~brbN],>l{㛳uq Nۚqeudf[v }x9*8-Q`d!v@xp[:tPܲ oSEqaSX[rd* {ЎߵslJ"qS[y*!-0&{s(55P"|..#ZRnK^XK,]M&C#Qw\˃)>m[UKƵcR;'&T_ M|([Z .`IU_a<2P1m%L){tS] oH.'3!Ǣδ6Jy! s`cb`]bTY#!(yy Zs.`#XB@׶^IĖ HhV5:kO{ȶb/ά> DA[U5Vn4HuZVUUC';y"uؗ∻O iHrAf\ CVڔ3Vҥl ]#%w]QdF"7&:tlz X_eуFRBjAJ< s?=$E R;4ta)<Fȷrڊ%noOCEAûz"=91Pqb3h(BKhI (yh v]? d;Q-J{,iy=`b(wGPP٘uY!8тV\Z0[ӛٱ@#|JT=zq!M%6 as 5{&0U)53MM:\ AhqIhQD7/?#Yϑ5almd`Ҽ@T`\д|)_M`k*EIDup|g:EwNE/0rݛ1,ur@)j2{3Ŷ]oFZr7}KԇֽD`w%bcbʬ}H_P,66_듾Luzۣf1٘̏ B nH!XVX_g+ha43VP+Ǖd [` wɱ8tRCɽLh*jR/WQ)B\Nvp1e3,T3$(&&|h{+=;̈fi6 L \dG{8tjj੸W祖J :0P }A<0ۜ\Pt1 Cf¬Y`cc\8۳2w=n?Π<^>ƫ;[ '!~_njITܺ|bʑDUշ /g*-N k/}d;A F J2T @+IDCyntd5xA=t0+#hgLiuzĮN<PQhTVq= 3/I n1.`WH|s˿3S'~*n<[<}$W|SSOX]~_nvD\"w5INU$#Kh*G eG@=/KՀYǜ7d^CgЦ*M:>8Tlcnxu?>jFA|}\*GL}6#juX~+zRu B X4sD>'3J!EA  f]l8{WSqM;@i s>KAv}$h`6E&EwX}(+vNc+1i rp^zw_8┚J~ǂ2U,/&`pL$D 8’Уŏ=n7#\TsBZ(eF˵cg$)h6 A;t"}M 'F{.1zӎ/\Շ LrvVkhJDZ$ +%Uz>a; xpb! T2fp&Z#gXo!՟1 &X 8_UZgL0n73]`{n9#[p*{H vzC!1P&͠Nyv*^J~#%GŘۧ6dlSŹ'hޕUksL_ #<ޱ3%VKu,Cr5EtП?+KVw6q;h,tkdarg@P0Ɂv0H,IM , --t2gn㲈u s5SSYڡOt9dk*gю~rc0 f(EzRazD[-2>N{1Q,Mo]Ϋr/x%J(NYw Fǡ_TbTR^1-k%8?2^ZzzIӦ)6+$-lx!,:줄;1Hur/bT; | o>qߦO%˦QhI\2b_Vz~QG;5 :j;P'{~W+qqCa#,*t+5&OCH[`]b%B4rksNqԄ D.7&fטVQ "pNcwc?y`%-\ ɠx-9{wik-A g63 }Fq) mL ybW_l \{ƌx9(2Ҷ4 Lpx4 B;qb䲟RZFas*sVB43[YrP|ͿMsEÄ6zΌf;zAcw{?;Ӗ#2!X88MUҵE CQH'H3S[+鑛M{lyZF.z-UmtH"N9rr>>7RdN1ؙHm@ծw˩U# K~ 7C" i+uU952&ЯwE|jN=LDŽ +8ۿ.(QrG>W|)o9Ea]hl,=m wmXk?Hzr*S;g&INs9wm= ڂ?Yv]Cms9D2E!ÅҘ?r^ד0Xh?@ccG-1\dRvB^ r~ES Rޡ2  W\˚[6Ck(M?_ R_Xe_Оm-qzdXob#C#3;Hy roUm? :).%*VgYPjݸнb-IoAȠo̬\Cov^+M;g~7߾̖"yɛY*"PS (e}ߔ1:Eȣ 'o' d .hѸdX@eλ,\{~| H k 5,=u[Kq66lH A(N8o3ZZQjc3, \}ᩒsV|%.m R ˄,IvkQ޴3thEolWptκ L1s;ѧzE::#!,Wp6>\p%D^8X@OR~1]lAF ޳77⨤$a=j F, /5sEw{} N9eH,҇HJk:qH'9ƵBrxYT *t"gcFbj!N\6e@?M(z`z2TTIN%?XqZ)G<6čːm"Fpyvx'@*if}=)M}jSB]'M\Ƃ 8R:GL umVMq#p<4^jKЭ+}_D5e^Jp!5/gOx4D":U@kB\?~s+*NԾ ]Wt&Ckh"s 0?"<@}AIq[-K-7/*wI>=О̮j hU/k~R_Xrͪd0~Cfֻ2UI ;V-="b9J_?u:̜c,B2sk cͰEl *7N(h,hù"sRcԋ{-KqFBLaOO׌ DV0ӄdž3ӔuG4K)4jIE(i} u{7i,8i˾ܚc#ivpe9 !Cج`m&Tb%`~?Z٧eiF^ިD/%惮v?S`t^&JQ9Me=Vm%tVU NQx~S%gEUr{'ו8+45^ W:`SKPѭ$/ⴑe½* {k7 @EPÀzڰDI JQadev\'+2kswh77WsH~ >@[VP@!yTdaҋJhͫg}0Г|\'ٗ̕uεBpY ]Mv!O URE()";0]giHzH{C lGat\$ _TxmƠ6l~)]zat23Pɜqhj* 2q> =&uR'ݡu8,I;x1wsy3Z`,dƔT'o k)|F kA`T36^C oIWb4SΠG{±C[K ^.c8޺_+#SxV9=!%kdhisbpr.,pW \Z} zp>@&.`ZLiy#:Wrs>2J[ܠ xG0gmG}χi/ZmrD[A@FC~= P.(B{ٞۍ3ן`K$~B̟Hl00V;d]!g$íA?> ïD2F 0=w\Z_g4/;Uf_ d0Y|В{`J矮nNS*H H>m2I@[F  =VN'dWμ8 x;R~>;Bsgp\s%糱d%acIj*S~G2r1( [9͌Y% |El/%z*,A_K2IK Ⱥ7>]"jm1 .W5>Jjzd;}fQgӃo4vᭀC{RIU9p莸Y(K󷻸yxUwpN@ @'~}࡫d!Z&Vz[M j.~hNwbH${E>+υNTj>\S{''1> %̛>1t޽p]SZM7!q^7Ë(`;wc6]ZrFܸa}[P;Ϻ+0ROh7#1QGϻ2)]JKu /bWSD]ZKPexo]'OMuoTChΦXLgcPaw }s`G#n tӊX&u\KH(Hsߋ7GCt\h#k7 YVHzφF%y!r4q0t6EcFYJB/~5WyB_Qt!QeS}VLV^_E}SpOU֨vx7`wՍL_?oc#'일aʽ1u֦(V&t|x'ge'TiAfNT׆,#?'GM38u8ڣ1"xb+<ꗛ:zP cӌ'!ս??=C:Y=wUBU:z|qk9xpCl+2Ea;rl^}210 !9>FJk7SӋہ%<ô|xz:-ƭz{EޅtK_ΉR\mo ׺ RŤdDp$pRKge{/|psQ S4c@|5S(S^T;=0R4|M2ۀpS[.챕Z)L6?N9zֳ-f'sS:?2l*z\o.y"ާ\~]s:3 ͡B 3&-܂ l<@:3QBP InՆ07$ԤP3$nh݄ج!̅Wq{&9-":vEq+V]Aziic03cA=[3␦nYQ׻qH۔{"=W_ C\ Xq>n=@Mb#hʝb 7׻@r+=s-r_)!cؤյ["&JoI%K.Ic>P46gI0-ZpU0 |ōRe+!3Jk;܈.^A,F|uM-,\m!1"U5&~! ]HB-:sKPl[-tȘVͧ6++5*gTqN@MK࿞:/2%aQ:yCb~3Rl̔>=oLtT %Enۃ`{+X^]б>{j7ߴ)I|( LCZM)_Aƅj- fRJ /l!d/&dKG~|b=XlBb[;!P7ixGgeb]zW|ƟA*:ZyPK –ʱG|PT}&8h]*씈"L@m$5 I>C<7$fܦv $<ֈ2@XTC+r5hHӚH 1i$oUŶI? [2fz*\'wJ0: cm=K[F<շ47A„`(%0%ߓJ7c8aO3 z\W,'2DA*DsbP̵ZeƑÓc y(*p?KzYkfX|B6mռcd='=t< V">YtW(_JCn~mդeyJ|ձx'MVwtRnK@JMr9;) aJa *䟖\D[3uACγ8N~ K҃qF9 ]ek cZR3edy^_~ژm26yjBZnmբglRkC{UZ,/oO~rNHy P-XȽ')K1O_si'1~f*Zeܘ.p%8? 9oj/n?EEBPh+N<4>~,1o ݑ`83=G{9Px߹B5\X B"YNI.zSKB%79TL*EAU1Kjf0Lژ2v@_q7ܩ Mf`=Z$RU>[ kzi5ƬY0%L kmt~ 8:$H?$zP~4iS3f~eZm8i،rd@,!Ih2e{g0 B!5L' YoEr]{c+]2 $qBFyY[!"_M,PM*ޛ_F4ӵLdV 0 WObN'6E@&Qr't=ךQDsN 1V n`SOxmb%7ABKЈء(ߡi*|␇ٴDչeT!۽'k ?Vȷo#z.%m֦?["Õf[Hkʯ?dW{ysZ-8Z>mWg(!>g%)uPlιM\#DN*]sii%뾈&Uy L%W(Z)DC7ئ[%<A"# 0[9WաimM"?iApc#d}S)GD8b6œ`k/o՘jPd\4[a5%DЕ+WQddۆݮKF.)vr Иw@QEO*>' Hw.idz@D\cDJY)@l"m'ƭqaΌ, [gb)CCR٤HtE?;[L_PCcvTI2.x9uG67xV"7iCy~­kdzPz9Հu{(CewH/[kؑβX"XP=ry#V7p7p"U1>>T+ljZqLkQKƔ&9_!?KJ;;&cy06lown1]59>4UxN}X{Z؋õ!>Gwt_іOuU!I%rL˺T\i)$d'9nffqpGa$Zd7['+ a\QhG$V2s:S@lqj9lůnLJiBtЈe"%klGvnlM&?:~x$RZ@V(DsjH㷼`2r;`ЊZayS[M)01˥xxP"188mY|hFoZ|j)u>I|7fM=pO,j,X [ݗE$gC[\k4\ RQ*dAFn}n m|evݯJyS:~gDmh9a.H&L%@{";9 Wm:u|.F< 9CЩhDj..po}-dŁ$`i911*J~JaI7u 3[_`3Vч_]sdl]opӯrᩂѸep 5^aW|otsmܝ 5BH6h6)W߫vёeRG MhE\(6E=:ҿsT;$QI5/hQÒ QWg']bV8Cv?$ &צ!VB4g#&FZrpPT–,wk`ӠŕjR2щ&}4g6pfhr@]'xmZ~RVc*j61Ӕd/QagJ9N781狦pC9k`\Ok+6qN y*^Pb6~d/}Qu]%-`p<:$%BaLuPI=v.A]h~"/ܾ@>!yvG@ 3X 7HS&Q=lwe,c-~l >@Yk^VJMt@%̓_jg>|f6@r=*0duۘ%T6"PU %NFg̀7Jൃl` {F4=LfUtWnOm`aڸBqp\NE28s5w`0Q6*ZzidЍc3rE9 LLXa}3(H}9Gd%1vT9nf_tsRVcwG]TZCоWԆut87fYW0\ʌ! gޑfGvtٯ1-]C2P׾}뱪 8;&Nqw||^RU7_)\E*iaAB Zo5ɗXgkǪ،t2bU2^텍ҳgJ~eƂ,:8j2 8gc@e/ӁM4$֦?,Dj&ǃu-pZQu ޶N@ >ГfM޼RnGIOcЇ+XԞ>:H*X=_OZcL W^wA͢S )qtɲ".#{͂i[ 1K"YO ՇRHa(y@6-҉)wo1PHHo/W+ CeoiF3%'d ׭NIF>%FҨX~Zl1l-w gEH++2̖dY`M:dq*ǚPDRtC V|5'z!3M.`ww,l:*; u$t*2@sj6߭мS>!,wkzӠQ7ZvcD uGhbErVx$tG!d m+mG_0) 02'Ze*O0sFc-Ao]ԯ^ec L۷D ;쵞=NV7zӹ5 Vh +ĕ瞖0G߱UL&Gر^!2M njD"JnٍdNgQ 36 |<&Qm>(#\0sYr=I2  ^q-9W[\ǖ GA+{̇BJC5I Y|Iџ(S1жi*9j!]hz9vnA<ɮ0>fƽ˖$|iݧYtaN=&:Z?+{LS'hN~a m[ у a9Iq"]Y`sB$NB~3Ѩ_uz@@##/k-+e9Ft>:\YPeSX7\[֝('//LIٱ+w?]Vɣ}tSpe=c=-sjpڈ2IHLsF+(o*7@'{u(_ pu9o97\m]  YUmH$Of UU WP6J  8 QJ]X{R-:#{{T;EO X6M|>,_v`YXi UQ&G8BDae],SHVGH'aZ ;VC r_"hQff*9vM8RK2oC ĈzxJU˘ }ťE,o.QȥҊߖM!B;B+_s; nz/ '=4#d39P=J=CNT1(IFu6߾W 8]= kQ)p}Do[MO6 PA? ߫l%:3:€n۶Qvzˣ**11B nq}zMWX,\CHF6  /yu%]ԪCvG: 6)0Dk6o~Et66rZHޛǣsUu4ӊ^h*Nc',ZeLa+ x WyaS4e::!+6u]9 %gѭwg؈)H" oa{"R@ *]uK"ֲEDhvfTsopKzSݖ5i%gzuf7<[&28xWҸЮ_sk|h+<4;%ЧW)P k[#2~Zc_FEwxq؃NҺ7ufҺJ7Ց=:ʀ$ۜq@|՘"_¤* #6Aa/WOWčj.)W *&j#CJn&oѣg3€$2J4+P J^W4Tc@ܸMƋk.2*FЭ,M7Љi0$jzGp>J;zLcR tYTf/Z7!m&o^#6![ D<;D ͔K|;/{%za3rفސ S;Pa*~PS nкQ%ݤN`(Q7XR~"]Iڟ֓lKʤwbseb%9Ƀfga:LR V~?g]y=3{qgtT3F;E]Bh⼡ ^u~? wt"r)@`? ꬡySn'OG''vc'3B[ X#v__&ri+Y*6] nAF',ixYgYIts%M:yzzW:E;522HLJH2"|gd֧ewr &t>heN@'x"p'Е\  P-)V5D ~Pj 6mZ%D#Pxi[HRR|AW2_j (];,$Ȅ&I;TF]4 홼5/tǂȽ 1ɘäC;_ / MmDXRk  <~q;‰[[V@qq޾Eaf ^#O_*,v@'9MIR'|$ QcuT\ d">dF:*![eg2uDN NEEdYX0vҵ}gDᑴm p1R;o!B c IɲI'\Π@UR S)9ʼn-@. MP\s6JFNVp?߫= yqSH&-"򏤦_ ZD_ޜ9>ޚeS0jª+zJYˮV3\D&R}~xN:5PfL[Z?5I֦NG[&QN$t>Hvm/#Ԓ(Cݎv8-]OrDi_$_W s? |ļihH ʀH.I> ixb~S2z LƷ|+4&qe'ΗZID%jհyPʅ8!pQ%5Psh7J 2.sh}h"C0kGח_L8m=oea3]H{:L%&yq2| Emni:кo x1LKo>#9FmЋf &LDEz5yMm҆ګ6ˬu!Oe+C};b&E`dA}F ҹ9"v .E, ahMG|N4]xz3Y.WYT>Y.WYT>Y.WYT>Y+i>aSХeetremotesf-2.8.2/src/test-torrents/enwiki-20231220-pages-articles-multistream.xml.bz2.torrent000066400000000000000000015176721500171105600315340ustar00rootroot00000000000000d8:announce43:http://tracker.opentrackr.org:1337/announce13:announce-listll43:http://tracker.opentrackr.org:1337/announceel33:udp://tracker.opentrackr.org:1337el44:udp://tracker.openbittorrent.com:80/announceel37:http://fosstorrents.com:6969/announceel36:udp://fosstorrents.com:6969/announceee10:created by13:mktorrent 1.113:creation datei1704190324e4:infod6:lengthi22711545577e4:name50:enwiki-20231220-pages-articles-multistream.xml.bz212:piece lengthi1048576e6:pieces433200:jD"w"nKd^J15}SwbbK̎[|҄< .dIF?܈$_<{*lO֖CcI%V/\T Aw+#%Jma9e8I|!1QdAiphN42]7IuOeq8! |OAkF:t]MA>?e!kbg?0VVg α$ͷƏ6PO)^/b-A^ 6``LCF4*-;սN)Yj";`(k%+f/ح`RS5/f@ D:zLXRfF9Efw-)bz^Vz{OUw!<렛SEGh|qN E2N2-!OrmCer°cINJN״IAM/NiEݶHԈdhBWdyg%*,Ȕ% _ޝy[Spz_u m=.6&v阎@ܲuXLТjz9./>(e?6]hWIZ_6'"go"FUtR{DKUz!:CI/Jҷ -M݇(9F7d%GXQ`GT'NECΗ[ugkDRO_.v[q@BD-׈}EYC# n%qs{ί>"7^Cft)'Z;3ʦ 5US>g;ِ_{7DZ50-^@KIdiJCx!N+}=uGHQ]sI8yQi\FC3ff=S1Py,jDn=O)c щyK$uҦ_NO X_+Zd;vA=V,XcljdwWx=<3ŝp;u>ZBl}凁1i64J9~E.'Hx۫ ^O3BDԳx6{!oTdӤi,mӃG<.z[l`gk/_^N)C_+K7q>#sDm8U:>A>9•RC/Nnp %?O<.<3=8_!%]Aց}:(AeY^\[py&mvyp?Y&dAAJGj@ IE>e\Bg R'#x?Bl HONô . ݦ-snN`i5'|i(&D\k² oamr?bT!ubpI.}͖47f.*n(r%-w͝ޭa: }OBOf,` xM-7o~趣HMע$e)r:(i) ^eK5θDJ3#z-aK 16mp24{J<دK eLv/D\ u4=Խ?NYmZ?/{ҹ72effYG^l_TaFyf[;$y߫".6n7y母b MʶOj $i%&,̵P6'g3J륫j%|9]% ׺l70'ehvzd ꄃ]!x8/z'h  /NE'f[vdRޏ{ey; Ar"Z 'na XT-\R&i7Qoph0#%ڰ|e`s~BK-F Q% b+dF_N&(ԍ߈7w-[JN "o*@A&YKn!JvRNkOҞ3zWM07d$C8]?[PsrI(MLʓFHl="ҵ$ftK?f$>`ICls=ϣn AU'S)X-XUS/ =_o'HF{أKrU?oǝl#is`CYHcKQ} \΢KwzdWћd){+E.> gHU6)<2i;yMzw):}Lkg^ZmzObjkXjL'Dp+ҞtYgG>l^njgmr E^w #{bOfVhA#O z0PӡN tJ9fxS~Ӯ|i\ncVӖFæ,k>nAFz_ދ!e#Zol _S0y!(UochMi䦋ՀOд?8 OtǣoCvS?ZSƕ!+;1QBL{ 2Fm5Su03[,ikUџy*iEqi:Rl!UD.hM).5jN`!HxӞ1w|͚3,WqnbdmMP:Kxeсܺ 'nĸT^C߰- l^40x _ٺ;F*GgRIh7K?tD٭oųyb&*9-,)gSq,TcZ%86FLk٫ZQjb:M!DW>@-:ī_]gK͙պ1XQ9O /Ǭd O4;.o?sD'hEY'Ḇkiu㢫0mҡvAsf>HVb?RNYX.X:ÆR ^Ey#?@GfޛۦUo?z#f/guԬ<3QqARwZ(=Z@UR 2TűfL͝2 7*mKX`Av*!Zn䖞+__>% `_C.ߣġ]GK"H4O'B Ls%fy 2_g6@ 7P)k 4CV$qOzL GZdwϽEt8љi1~gǫUt.42?ȴYm1I[T |xEY"҉wGg >#Y*Ť0L%W [ V8zU!V QowC'\| hf ɭ;U'W BDȳ!˥<C5i"C0Zu @n"0݋6 ŏ;<5K"%rO_c"ԀkH>vx:ģc;pY@0d6Lx%but^ EZ[;(S)aߜ4f6\P ^KȤ2 fi.]e1s8;W0XN뽏)w+LMg_ˈ@Q+w*:i*5l6]HA(TnrOk*1ݟcODδW7Nu PJ X/b戋N',i'i^hY$ܖ8RIµ$cP14w<4̰a]@N>TxVDiFzyn2d: ->[foɩ!23;1;()B]1U6xV$τjZm<%dV:&Sz`-G0ܾ'BgǶ<17][?/|o#{8N Zl(|on>:bQI(FV4F.KNmVj|R9TdEfEBt"6ﲥ,A\ʜ{o{nV"/w{]4_6tG>GGD䗼κ&àuV78(wY\#Y>{\{|$KO@,Fl.!mgJ}7  ;Y4H2{u]ME5'h+6<7Ż O03sc3YQ_+s?H*bVO7 dfiEc,r8ͤyųD W%J`s*RJV\W#@ !hH` ;J ճ} 2'`pu2-xVZw%׽D",`(Dj_֟SeB@ǰ7qmnQjqq7O8bgV*.\o)7/B&vn83eE6:|N3vLع|u_ A8I`כᏘ6#DpH^;E Bm.P9UGaTVh[jU`K, BM"B, ^J>Ը؎2͵E{ÖDkMTnƘ DeT)qy5䁞FPWuYYvqFY|NvʸyԸ%uMgyW|Cu:_<ŏ[w.Wr2XdK@h/C= 8J+)烐۾x$E.qy잸d^;(1:l\ӲqDd3]3_0Oor(+}ձJpK<"#đ[^ WNw(cRxR>`%,5o! 7- Q?X=Ⱥm & cR,t;(+BRS(bTZh1jkY+LCACl3A2NL& ! AwA%nSˁj3#svztd͜|[lXѵaBJw8Ȣ]x@VX)EJGaU"u=. AN8&M=5$/Ol/uE=l{ 菋huG!3S d =gd;xqg<,Մ}+XSFoB=ӂ2V˱3ETjHrz KDVZI64ڢ&Tcnd@La!iqL{5Y l (N*basj1m'g0b ۀ=pZu5AȘ9]M dڗ UF`ܰW3K5l%%:>@Ve@3՟z1>{塬'vkk 3huNRz oejjЇ.'Ha5`Ym 6]}3 ph`oSuTu>S xR9 ;Dzh arisO9=},fvM84Oʂ+`ILKU?ZvH\~k2WeMӧ7WQ(ڹy]&c|Aև4SAFGȈ-n81Ag]7A{Y$(l2e~ f[0YMro#'ΛTfALU*n#sK+}Q-=7=2^ j*;/h;8^-6XiM/875w0XXd.s;fa m:6ڷL*Β\nvR Q" } OrH]VM_6gŹJY&aӢyi]p]_=O<=q~}-dvM>魵Rн*'`lC|'q(SGV~ut|K.jڨJ,e'dsjM˲'4}43c*{">O:S)oy$kSLf+$;ȓ*9i]aVnoG_<b=X[]0@0O81n(˴)(7 $ J`܉\fJֵu ̭}2は6 NP'6 <{Zާi_;4$˅w2s$ C9S#Q{cSgEc}SR^ruef)^(O'a \ti%M:la/m~p -|%?pZ飬wcA')Ϩ4kj[ߝFIQ,MAS{QVSp(aZtߪ A8A[Ð .>Կ\V kYw#1h& 9Gm鞻}2*+i4sOΰt,ț"'̄GE潼] 8pF14= GqF󥖼8Pϻ3en@>Ȟ?ڤ@de5o#vߟ$Tҏ”V N ֙wF".˴,ﷵ˿o; dU;wF$$_%PUf/@g#Wj ϜhqH+8:Yä]#my# (Od`zzyVHKPy$X{ erpa Qӻ6TP;q_aJd4s^WCEk}h.WRRYYtto*;qg vWШ֒SG"m|{l^ɞ [}~iq|<.z|@8Ix_4^1 ;W>8%3 %:bazz GZXSҼQBGST\'CKG8^ٸH̭k(给d>HLL\Du4!^rˀX}?%i r8r(* l6A_C@Hp1m()ꏤ 9jzüޙp%Ks#2M$au*Vn^pii}慄?͒/FCfN闰f>b s=121-رf`24ٜ6u踔ViMџ~=hӼTMC31I|^NALl <0C u7:Zg$BtU1Avo- ןZLvX#BDi-A@T t=4U+*b4k7vSOkP|xJC}8j;A$>Ɛ"#Yi#h]ȯnr\4ޒ|05]Lj;J[d:&QXuG&DD5 -RjH΢?)vޭ*o.aa]i[*}}K3J y<[[< BHjrmRlZZ//3dԥ0OGT6c$0@Ɏbʟ/Od_ ˇ60SÂ"4_?5uqå |xlVlqn, Ơ+&<`sD;JGQy՚ޓy.$<~<~-r\e IiK|O:@3faZ\NW^>k̀U+aZs܀rBNQ|jɵp@!PQD7|Uѭ^H6V$1`L_(sfR|$,HL^(^A;&a?HA{ȳli1_NbSUs7i,$Cz&sSRٽQ;ej(X[\a '13v1gxi ˜C;rC9K2-wokTByyYJx[`_>jR?/4"]#i}L4~&<Q$""5*N{Ό^QxNX^ x01 ?t?03)2T1h$8rDQ1k1h ]4Zu,ݶO䜘Cr 6>79n"DvtOjM kB~` Ob?jCہN;XGuowKr,HU5Y@tq0)/ [:"؜)wFOʄQ!< %iR[Sufb w d=F7R]{B4GHș]<{hv/.o{Ya˷2ޯJD@D|H7B\biP($}yptd!6_EQ~fID+nVXq,zTTi`Xvpi.̈́䌦8q6N`n*nѽcy/sW'3`M۱5a l"$f3"Jhԉ: \f(+۬㶪U[KHGP1H!s$qy幭Nl疟ۤe9t3Ү_Dt-@o;'l 3C8w#?jvSSjg$kBM+0E Cľ;Csk2؝ӹ$M ܅tB"w ۺتX<^o<_k׫pX,=0SՖp;>(㗟-d>.l+Qv>Rr`<@.UӴkl%UpLKƬ٘ GJٓK3"2+Tdh9ԒУB\[R1qM3}Ę&YDIW/Tb)r+~O>nA7oPB(+ݡE^bEStL0 mU*9nM2UX)7< XY*֩bx=RD-t_Ż?>P F~fUR5( U͢cwZDL4A*/?q=8 Ȥ%bkȳۜdrE 'FaVI,0r 2h%DԾ@柝?19kuǘ/y"pQ2 ~[o*IbƯ&)2cT[5x U .}WEjFA9<24nIz &1 Vѿ{jQ´C5xʴG=MoszW)nZ|e ߢͳ[]}=C 1cR|XSZ7/.릙`R(0k`_!VXQ H_mHax xU( Mgv^% Ŕ Ğ/qvА 2(.{>s)#&&pGTB( mRLw~C|,vL̒~K;\ ܐ rah#ee[rpprެQyt| 0#jG拣 >)nM7:τ0[EIR\ zmd@A _٪8&|`;8mM'm ~\&e1m6XaU~h^rW(Ff کKq\[2BB4Z̨߫[3:JEE~f$x]ۄ[+ )4ʁdcg/.mVLʪw%5STBzcÐ,]?Vx@u 8Qp7Bd6~%4?gMb Rmy:8b/H-@ׯV+O&nZOx3~=.D˳*m:uV …u??2xmń8IrbnU>uvvhC3 C]a+˹hND bhk>@[A8|f2#8HiM`båQ|O&\ġ\t6&Ņ7<$NОf~:moC6 FGSoƤ^ki wV`w.f#EƉ]OҫEҡ8%:| { ql,1ɓR0ԴR,hBM(plғwϲymQ(TaNIJYz_xMe`@N[$9e'댯0%^8vYmgX/ExwUsx7SĐxS { @>B)J((IT wMK*jF=$7HЌηL2RH%dFMor۟A!ڝ k>˪`q\JPԍzL'[uCjA%{n’/P T{&tOX8:HlC=a٢]od)A]S6~;zxd[L+oi.YpqZ',rf?&d+:S)p!},HtNMk-|Y!؁3=F6Z5}K=Pjs`%$jC#8?k2]䰜$_VunO4 cWBp6"(zT0秼ÔRA^k|]R BDDZ՛Cd<=]4i Yәq)Z>S:h_5=T@HS]t@U//:ORC@qr۫N?I;9w)nK/_ Q/ENl>mSC¤fF(~diU,D ]LU})X _ ٌ4b}do2FңV?llZߐjz h(n秶/O?۲08Ƌ3M2Q#Lm23+s*o =|xsTcU~n(= /c G<~ Z_i"vQ3e[e2"P_nKW" Dby\L76 /L[qe;?@<~^~Z Cq61|]3R}ǨT# !BkPkHƐ$[)VI)I&nU DO jA_q'8< rVdl7ԏ?SoX$gYh&zye \Ȭ98 -wj, wFnm<C8yO Kq>k_:s(V7޸Y$\Ӟ&SP);4w?fڒaȅ׾Ec*ѕ*?X=ba7D\t zV*AR4D'lY&6Kѫfh~a8Ķ.H[Up Ut77-ұIsH 7_d-6JO93y_YiߗE0'+^җ6R&|Td>̀vgKpKi#sELQ6@ۚ.|耉%7M%dnšUX &Oa:}%SNFodR ˫ENoc݌#h22>sP kЭ,Ùs$筆m!H{l}t~ |Ĕef:9'Su*16oe6WU60l˸}}?գw4xu{"cΘ`v>3T`őBEn0-@*wPTlkw>_@!gY'ڶ *ZLeO (LE3ngP6xA/o3HR:p>L-Ke8O,2k#3Z E5$w 0Zl^e lY)E`Rka#/ yM=g iDǬc6絻d*e8 g,9Q5QǼ1] έ8h^T}ށ&O*s_DذYj͵X#N7< ![TCiUULd3~ azm,L/T'9$2m#3)B᠚$:& TNbEZ]|ԶV5RL{[6[ bY*CQMWt$/ :_5RbL2bDW3As%[!f^(6FSpwo15x%QB_6 _{'? b陨 7C1NVzjl.~_|M"e='SIt\|hr 0tjȢX-O@7u)!Xt;9S@j˴ m[u 8"ȝr6sKQBLr!4niSS5^u %Q9H ,}-a( 0#"W^JE] z#! RFBv<P|J֣CEyY]ˤâ

tQ'dw6>LaEh߀SVRBC{)ć :;'Ys1ݱd/UEcqEqq2݆ddq.ýh|oY䓞meT7-ְDYn a.:ߐɅDnX3:=a*w>Rmsj^T!/tIYb;8tV-fa v7سI g|yy.\@Xq=,0RgI#NCl0W{~jqP7wל4#> -Э;]I"cndǞw"Gfh1<.^ _Z|8uyÚnQCk< ,;Cy:`CN5s]ef@|o(p*E$KM-vY͘p=C2=ZeK{h_?@ Fc;Xs=.$ΫFj{[(m<8ZA1:qh cVD++~߳&VrV]0Y/*oG~ސS-wVH3ɁfI{"m ʴS :-J adGR>Ea 0Iq &4Ht=taƝ"Rbh=c Dx^H9 I>azMCgfUϝIlU6gu0 <} \z` w5U<-Kt`QsM#{"9Gue^D3&@1C5D_IO#ߐ':! )Rzɭ=YH ٤ǗTeIJYˑfgPi#kߴme09.=% 2P(I[^RQ60{kB)&V[,OyW/b#F`4Ȳ8I3r*3yƠ޾vחq+22qIZO2κ暚Q0WW yc`4d/{ߟ*:'hd,%pMCXS*!:,Jk>o&)"O2ƾ'&JЖﴏ|#.`,Uw+`-~.ve(,l5#q" ~s L*Č]rFQG cL~jП&1W&#\;e%ϣ|(Na؂|#/jLrl.E;sL{u]׶cyWWb:1R9J!T4z|WxYhZ6Ϛt+#9FI1?0'vsCj4QJf1wMPŞvgA4U5H Im Go-79#]/-UJ=4*A ݕi\\[e.H-ď$}YB4D\cHSҲ+=fJE~D["rYԘ ebo~ 5A^Xux v^s\j)*\j[x$W=Ŀ [j)=l6gM. I7xtcp RI Pq\)=v2x&K7}]6AebGTOClA<ڿq}T0N񺶢T 6X8)Wj QSxkjV;&Imn{vfqn H0S xߺKvͣK˭ᏊlU#f>)J2 jHU9eljh3N!u' K*BϿ“W1qծv0+Gaʃbҙ݊mj3k Y{ׇD)҂Cwokˌ;H<)*oߪT_%G&-ae%c P@,,FWX+u a1w(g-:` "d6SV¾?(+m#,6Z U;cL66S2CS3 yB#֧7RZd`>{ >OMc{fqѻFJ_DCˤu3诂W'UeFL.r hSR70n\ 8OzAA6F@in>Tr%ݓؕ}Rp9;_Sp("d4c)Mo= DaT7rnކm0Ru6{S: ofeBcN}g(wiঃ Cq9smP3S`<8yh6MsЕy`EՆ0)jnдһh]8 A nhy?ۥL ߼#0m(Q49T k (@4 wwrY|օȤ:b8*i\P=t7:{C68 $n ^<ڏ0al;eiJ~^nb߷jS鍖|I+8WddLݠfǤqK$iPwUk,hyJ2~m\A-^u'EiH8#nUv~^<*oٜٖrj=.Q|\Eɡosi&]ݓ3KQH# R!Yݑ#c {C +m1=L?v띹9՞ 8K507>;q& uLN7Xp.<ɧN 8-QSyt7/P~o"zFb*n$O:U[+8|hen Q{2P0ᶪ]zJt,[XJgYSS6 jVOɵ s]W덵:Έ~9Q7J,3u<t7!7V ?kkte܂$̗fE阧50Q !ћK{I ۖqx,<>Nƿg!Lӂdh.P5ܠRwǕ|bEN1${ؙ0F1+&rR BнYzB TiծH3nZ,%@3:p`z@嵀Y?MTO[6ݟ l{D_مŸ)LL~2 Ϊ\uA t6ZWNfQ:O>@?^}lЩ hpˀv>vP-IEP+dtsPmq+;H%M,WLM-)˘ q`Ksr@k$$8[4?fz p$UCb%6_Ŷr֫T~p( |8Vf|hާ+nyY*oQs$X9I(xE*݉bAXz Oϻlfr&мm@ lCeW{r*&*:4m ,׀$<ڸplZvFhl;*=ڡlafA/yqY)wM$Q|_֭JH%g&;a *-y!7#Bk; ¨A@@6D (|t:'"]]b;#8z>x 8hlR_(?Vv@ɐ&.9kͥ"6Zձo=o+Yxv-H^2x_^EinW~]cPWybgq@M^o\Kr'(DQxQyΗ긟c|W.uwif"Æ(>Y #]kf4.>3 FVaԢ!}#$c?e<02w۹ó&{^I]~#2cXVl`Z[Hhp(q{3l.v 5|'~ކ۲ -K hxH]^ ,hRX\NmO4D|FF?i3MiL'ڤ(,s " ?Nՙz;6%"7LL rA`TiYв]%Q ;@8T:!iգ2xS NC87o Ḵz+o2ŗ/&}sY'9˘nfD;gĉ݊0xSsC"o?;vjLӻy ;ͤ<[?:5 p 2:{"$U*om %0xx$/m%Z, h6RęDK ˲J ާI`HE}er:}w ttHS`%C~``Rkad\@pqě5OClGF-:T.XnuMBy:c7s볗:.J"C]kYvjZChPR;[;D<["˿;6ڑ}ێyhjPD>ɬKŜ|0<}=S$Y:fDT9Pd=$g 6B=_,9n"LN1q廙S޽݄\ TpDz# 2j 6kj,~in.Ϋo{Z񩕌 3Yy>7j\'ʎ.`,дn5FV&??ܞpǬm.XxBd^űz˺8fm:jqZf9]\?ʈ{†ti|)!,=S24 se2I*͹a8|_ed셨ԍx%ܸ {UKc/qj8vHg2)o5An0᯦#А*)zy>_I>_Ӥ<Anf%7]\]D{|v,y,؏>d?WM=)yYr<+m>%γ~WaK~wdM^G'O|* ..ؼRf.涧0ciVP/F b4iҘ*$Aӝe0Wwx@%,V_^h? ~h7gۯ.lEmz4o;d*c'hS+\mGsP}*E\$6"N.XkOz6hD~U9킝kA:^M,w,uCt> ]z1[3Z:"SLQ\ ez33{Kşؐ7Á/<5EvKj+^(e%t%7+=IfJ@1Eor"6&,˔y(ssߞ Ub@jxˣQp ]:֥mߤ(Lc!et<viɛ }LadPymΉD򼱇ksRKeOԁO-+ym99+l[׺|D "@j*=p@~5/O's=9q\bJPYAZ;Q$hPS `Fly3hԾ#3=U59vVt5<1LFT"TP% C.ubfL{jh6b^*UT^Ds mXr\0꠲4 QxZo!DbVxSC)]B爢†2ᅰUy [EHm98e:hS%"d&\@7 &ofn #'WL4a'ݓSR&IY^X"(gN=-kK9l_~(hg5;u\P߀3%etݔTN|fgZ:0ŝ8Il|yy/\a*T2``03H>L_-T(?O u.XoX*1oY}g0*T卨7K c,S|ZULP\GƔeǝM, ໶G#T܇~03~Э_črXʄZXS1 #v6`ԤS9V <(uę޶aMbʅ5P!a`  8-HzR9!!"]]Z+LKN!33FB!#(֕QQ;K\3^;Gj3[ϋ(^ -8.J Yl n;ɰ%F̱tX,IQӒAq;|ӤLng=G!JyM>Tzeu#PQ:*/[h0Vlwf寧:Cm$ΏtE!||ln|@E7 H=¨L`5ci,TJ1 T mJ$P&8Si`*OHfGwSxWH{2dM"Zuuk@K6pAݴ)v4 j将m6ߒQp{+Pʀj) [Q!lO5ثYٗ˓A4\7@fQk۳\>AU3gȜ zk; SڮG hQ6ײ> F6ި්W $j4`L̞931Qa/"9kHǂ&=}}sA1-p+ln'K}g*}ĽXct^P"[K%o7{%P B9 1tJy1.BWfǢo+eir_4."HBKF15VU ×2c(hlMY)mLJWr_ex܈Q ހ9:@KʰYÃǬQd%Qh(6o[FG#֍7E{`m1vJkn:]bh,2kN5;߃K&e~szߦ)˼S,Ni|RZ%,y|*Y=cXM0 k8`6~;R636$Ev՞mF+5nfG <M5i ՝odIc`ݛ?]H*d伱Gf 9NI1!8FTMOH 3B0QriyQ ^ѴԗÏ@Xx~4.Z:oKp:@xk׫UשITsU8f:cXVaHQ8kjʵ2+] jL|ě%XeA@lk];ڱmMTLIs "Nxl%Cl2}?!䋑_5 D?z,9ISDH{'G`:_F4Q Kh|5-zy,d̓ʦ'!Ppp_-`j f[d2kL 2O}|hQm Z Hpf OR0;XXx%/(8D1J'/k "(SqY-Z,k7׍S7HXhr owIK8`Hl[LƈYdAŧ͎Nm0%SߠTs+S̐_9.2|E@% $hh&S+>/B񠲝F`}%>}cJ 92ok&lkkΧjSena&mQ.ws MܕX0Z5m7k2]x nzY&jxZy,ۏci1Θ,ϘCÕfF4MzcJYgpIM=Vי?pT6:LsV,cֆ*@MV[v&4S٨OpޓE韞i)uBY2рĶb9ʾP_C4CM\E2yid@#nbI·Qh,|b83#W(._\azrڙ!.kLЫErw86.@`8C]yFHְZ0v7}kC>mRgYĶ6bOA5Oޯ,PIZI5Kq&_0ܥUXdYsխyQִ U uc%>*ADp4S5+ȸ;tkx&$)yZղ2e>;%ݯ9eۜWn .hO8gWnd+FGs!*[fb87Ҏ-\жrOI^6:A @Izʡ# sWXnX_STGڸ'X@ ]l] &&ڃ7l`\g4?,U.O>[e9J6$؋h~ot5\6*ܹ ZqyA 貥yt jui|pj[,*ڣ%WN3~B{MpK_\H ְxǸ6r[# 5n&5b7d-*IQ-qsݗ=2ߗԫ$Nྼ!  91I0M0am닎w x"8HY}aֽ djIjL#u[XMsjLBhF&`ӡgS?>,+؋Q?׬@S"L!ܷ{P~nDj0bf`[ *\hV߁ d鑽W{9`%,Zgs43{f;+J]ԏ"pךjb8%V_m`usp6 wݏXg YYxcKH 942^ӭN#O0s^Rnky^|"@Įe`;vIIjQU4Nsrm[ $3 78,`_Օ\BsԑY ǶØmWe x*I&9) YVXUO[ҙ ֐\X,)#ltG?[P>XK)56?$ߚ5ƊlDx'*ѐѮ75kt0Аz/k^+@[MQ-4M`v-FDq Zc;lq4кX5=AU!mئn|$QG?̞x;KSi6Ӿ* K1C__ѿ$]Ji;OI2M* \$)YQ̕x)JR>$ XMIJ OpD8xGw. JIa7eaQʅv_TZh=̭ ~As5HT.-ZjF즛^tA;) nԆ Εb:#>|0RMDBMjXL#MWPEA$( HB`rRtӊDy74Ja|O0&[G{բ 5:JPcL낉6O ڧB[VSI{ocKA|¥gfz"Ev|&$qk`Acndh4ڊuO!J ,~=)O<3BS{Tp.߇[! ,oo@zr}u&yni9) 8w% _E)W,;گWzV⒂E'\!1@iO]g dI S!f ]oR>h0?no'K cʰE ksoM'wNQfE7خo8Et94lh )qyA2]0_%lQw,#Af:,ᄁjZW=b8nF+*E;XY{ \`N X`ur pII*wy<\w@3@e62@?|~ ?f[sX$9b{{`x-YY НG `"rE5rP\+d ߣҽ|MSH6 ܐRN5M_2>AK\(QG$?}pM> lSwg!G!D2t0r,1(.GVV,vo<,O3L%ޜTaĤbWSGzxUGmQ t.%'~:)7'Odf±:5!asҜ`)l bS ~z^t*ʄǼ%v),}<&ͷe;FZ8Wͭ= *$?1T+Jqe֎m\fLC \D[ w Έtǥ5{a* ͑9TZձHgGۖ~>9Q $u;Dqbmן譨Q سC qoģ3]If`AE7*x[+tv;$K] N$OiXCB *&Q5ɞ'l\}zX+|OY#~%KHIJcGHA^ 2rWÍ?re *kjʯI,) DGMhǤrjH9?1TLN 68?-A+`"/uuό8N uqKer ÓC%In2ns :Ob*sHYDIx}jɴ[kyz]+@VZ9Zb~·8UwZD$w; !+Hy >׬ZVcݹ2CEfL/RU3;/%K$W/@ 'K*קQq:dY' ~,{:^(gϵt\\(g*[]|4-: m Ix+uX{]"o_BrMल ]r-'<^$ --pNBS}e=8A^*Ca*A s %>G H  G?M6ܒm:l[>e׌+'1D}9a &H 0(S|qR&+8jX.rTRoˠ| #R' B%*e]뾲hX@xN83{"*Y8h|_O`4 qNsL]$4²*uPҲό6SИ(qhvpֶ -~Ġ(#3uR<Q~w>GP;D"@@QX#-qرSmo閮be<^[ sN4@+'8bA%j#|ОP Z|xដF;ݳ1B=|Cj6%,Rt=դ+d_0 Vnx!)Ln$F5 b%(8et٢jwCjEy{DeԬڼ*]#5]gsQvu:lXYr?etӮZKռu`鎭=Z9&0euIcdq)$3) =vAM\iEhA)_~KB!) Jnzn⡶ a[O{*o*R5oE)z{-!3>-nm6Ad @"bA Ļ$.lN]2&դئoe}ٝ̀}w~/8%Y .le5Fl$楐!\$#E;c -ht"Z]BAI ;}E,g& ޽y$Fě.,XF ;J;SLuȢ8ެMWL\]L:2ea;oTtC %^8$6z&¥g*߾71A 3!~onO ?z߇.TRZNwqzށnADBP)+ƖcGnLF`zImS:'e~G}%y#>SrT/]+co~(a{ .&iw c4+Bdr)%Wty#7r =W$|șśwЉqN gv}}0cS1Ņ)6!i4ߗDtڣPf 5)D74'QaAH C45E ^&)a ,d كZ&# 9 Y]_MQ\sĩb$BA=t 6}3U]NFi 5NҰp`;OiΫgLxᯊh~[ &saP] @m"65s~Pi)87ˏ;pGLGi"a}kvF!)O{6J'_4!S BCU*uM PK͋!#|tZegJIJCFs͈}y&!^Ai"J?oonǦ\ԧY˾TprB8&V0z{[7 R!u0{Dh< 6l3^⋄6œ[6E;x+|f2M\ܣfe T; wG~@2TF(["-Xn>xzJ҂{/0B8yr"ҧ9m컎_p8kq1m+K.[3EKw,Gr kj5Vt)?eTaG}X-GxdЙCU,rڅc4>RF}1^4ms*WI0QhtEEM}6)֕6(%L^b`6+<ʺp؎ar_6;Z6DnR Cc\ R<)H[AA VŜ7Rg 9FBȋfH 8`eQ,%L'@!y=;[~ώlb75k$r ;+ݥ#|L!MVw( FWFK\ vIL_`͝AW oz2ψ=E8;yoGd2 Yǧ4!F:~< xBTj6KK;y ͛2X#al2 Yǯ`…LC wJ:":E9Y2)XrJ|c$Z~ 騖B%d4]yњ,HiDouCHP#&p!F#M^b8衻 "m$, MkaP!4Uͻ'du3Z&0*  -OҩW[ :4#SIdaKWYԮoRI#5.<598%g^0kwqdW֌DaU&lģY&ƘN Ϙ.`ܐ(EXV?$V\Mιƒ>("6 Jv<תI+9P`ps=\"xՄ,ډyΉ(l3>:/٣YV؁n2^seM ~2m_.0!Mw7JlO9YQt U2df) IjsWr16{$%.?)nĸ)n{]1OK0ڡgJI嫓>äPv-<$Nu;LkFvK> AeEغ* @^[Td!4PjEQzFҲ/27ق1{&|5 q 5B(_tF^)ك;f鯉Gp5Hv "@xxlџ#!Gf}mƽkz2x8GJx[ X9˕>F N ʡq}d?椧MN4yjr=߁<]W7i Rz/ь& ŰI' K5S4PC5Q'9jbSOp ?'ڐ*cŦ#jQKU ql:\^G[sIJ%YMiX\jAԩP&2V%ܰ1$(H O Kx`JLxJ 2|SrB~Gl_[Q-e,$7yUyuh1>D~s8_My`s_ee/؉bR Sh6m=*Oc2KgH!*-/LG \4Y8|MeKwaq]!r޵GhގD6.0Tn>}Q<.9HaۉB>smIOZ6gCO5@KKv׷=[lN;;xV-v絞ov]Lq=-1U)*jdWMO @P v2I6 + ~78> ;8uB̠flu W|E1 $9y|fC7 ?:N)Kk0I4i``b8ց | gb5\G_XRPCsHx_A :0BV5I& W'}!g+%3&fe}omM ]>No{q0SJtW s܄ۨr7h:zYB IaunBH[CӯQH n1x *'Om86 Z(q/UaEz'bOx1-]h{L'M!ᆵc|1 M޽M+~|vOC7{9# X]hB~gomܿ)Og)kL yȘ9W98 zlkF&{P%šzWr\\9G)B]ڿNbn ^coXeEٸmZ럆nw~KogB_!xҿ;qBts~2by%h+iS Q f\r茶(Ҡ].J,0%Yf/ kԨ)0,ƞk?muekH-bNOn\ qo#6YƋ& {By0v<#sij E?+VWdDwcMW|/y/ b5%-Gt*8/X$ j&_T۪Et91 _V[ `sjΔ$5#H{=\f![ I82|)SZQ ]8v8]:FσK }ᴥ._vl(ݫ.MR`dv۞'{Bf@U*` mO&GŚysIoY{x/(GW ޫY2Lʘ% 'ws Ay.N[HјcБa1tC b/r:ߜ18b؏pҋUcǷWHMqBrm=fXc9ᾖ?5QԶ\E(ErScoͫBePq~NyZ cY^o RU zHWk.`fR[c Mn,A*i7Bo։ }S )p{m&6-~tų0؛30P1]X `/_yP%[KJ+/oMxWw3P@{m{!$/%҈9bw\}ѩբ"8Y@oz| Ey_0}_B6yTxFԂ ʄ? iIATnm|:{K[Q/ ֜h°LS_LDDۭ^#j| b^ ISgpMg [шˏC(VSQߵr&_]} N 6%8u6GZ(%Nf   'Xk b]pɢZ6j?uץPCg>|xj_1oJ~;ķfO΃ltb&Ddy9,.Nf;+_I9*YQ, M<|3brjӳ]⬈W(8래̔IwC+Iqr.twO؈E_6@6Jk0v<'+RC6dtD=^Y:qeόq>zX*;} b6FH&3e)凬YξIKj@y ñ#8^| M;cS4C@8N5L>vEf:NF]~EXk&kI9ǖjDX폹cFf :}u\67EM(/vEس DbV=iYɾ?E䃉tgxLEw +hY55/kʴ/O_V'leR"wH\)` h2JL3*]HcQ <Z]SjlcHU C=VfC>5<:9vZv|THUPjX>d'$'I)ڐ D )ȉ.eSa$y=WJpILfBŰC7t0 Q/R7JoX#'c# O >۬]ܢo UzyꭉjN!BhTͳBLxm-*~ՋiPbXJA0LmCTKY?HzLM߁!*Vi)s6A$z>,u N͗k%eIy`B.үPU 4D.Mnyk ?ߺY93%첾ʸ=I :F[fWP5qz{I?}\ )!&׆j=ufka1_7 x4lA?uTD([^dToN]r5UdGUJp*oUuӾc#ToztAPBky|)ܞ|Aڦr ?I[Âzw1*RrQ&(tBTR$E<aX8R=HVf\@$if }2ee@`*kHdśNlo3zsj,"n#قY }R,C!n=g˹ ^ W5 nvỳ,θGŠ@p[~|#ʹ;0KJU0v:4A94&>KqY-ᣛj02ǝ^9DdgM/'v䁸1 l x%9磗Iٴ޺ 4nFK1PM=`)3&a.h_ C`&:3 tCj7v_jTʯC+Ǖkؑ@Jotө$brAgoV2QvObSm6$~qQ}7z7?pzr (xʌs\Ozϣd=SkG2D'@Ѻt7Sa@#Ré(} ?5soĆ7oALb#asãOJ &.cB:Ghǥc|R Ȱ=s"@/)-&]gsB4`9ߺCZ}1 $sMӠZ/Dzlu%IX/E/MhSVn&h(R&ae }S,L? OR k2U gڀUbC[‰pvaS.6sLW* XM1$Nj׊6D vjAi'Rkͮ,Ѵ2"s# uz)d;G\SCkfݩr#P H 5Jt9K/ d]Ԏ_pW]E.yH[ĎLQhgnt/>(!cnz_pE.㪟kqt$I 8.ZcLɏ$qBy.^ cZa,!gv9dc9:8noPc3k/ P 'N u~$=/Y>hFC4,5' euFDЀup| o *[(*t- 7A V}"!xd7|xܱ|cjҏ#*3 ъ{ `x125l _g wnZ=1扼t37/r65R!yԻ!q] $1BD :.uE)5UݏUAg}7d; 3]dr{2neUsF+ygXI`Ō4۱H>Na }Q'OCA[A3vءP6L#8iZ:=_ \x|*8==9pĤ& | ) /d%5xhmk)_w f B#_{x?Z6kV/"AfMK8@Av^8=u^zU=~ubY?)J?>KL1SKEg7~ʳP/j#{0wJKCdm:ah\dU?wja"C̚)[ڱ/tbD۠+b8Q1^2who][i?J?o|J/G4 |]kBmq7y z2Q%Uq EJʦx0Zϫ݋FS6%ilER 1yÊgw@iH&Wv!ΪF7 ˑK<`؂oqHSIhnc?f$f2T_F>?}}8ڝ"1BvX+2<2Hsfti-ťCEKu߿R~PА{Lڷ'4cChA¹W\ H׊|p{X4D*qrX&&؉h_ ӅT&*oQ)C`h]cǷuhN"}U_4 9nʷ] ר~Iu9j4aIxkdx಼|?ʦ U\$]yZz|j WOA!%+)9Qh_'\ZXxr\hՎne*=a9&刭rȖG&tg/x\B=K&#],ˤ?eѴ;v$idQ :vF[YyU\G=tRf8&bMXG[9ݥK]/Mm`;W8\AJѻqن0 ;ȵu.k95 ]$:$vaW&%_o8=w$nzjbWOa z5ںz/ ֚%낟4T HH&nyGPʹ %Fڝ6Q>* >^Zeai`Q Q}?KI }[njefaJ)SC i>:gPQ)̅؛)cݿ.)w]S3 o fȡwe0#KYoq8zp.cHyeqwxd~RhJk:x RR\4:7f9f;~X(m N-oLƩPjGޝuyD1LېSu:ułLАc8p zIP!;{hd%Ly{ ܸO. U8^ԗ+O#^#Fv7l,.B"`,8g(ͱ@>NNqdѭfmV4*x [XlnS>9c@%Sf;{rb`T vO?$;_Yxj! bq T?u5~R&ʿ ptO$e а xjeJG;87RrneIi I\˳AueFp(qC+4n7<꬜1Ѷ^>c/rj{l6$ ];MZ |,:1)X*Hm0>N0M2( ^_鶳i hybڍ-OԊNxep2&RiǨ%zR!^ٹ8Q+TgݽĎO*˾S!># %?YoS>ficP l3h|BcXSrI5Hq^:iw9ʌ'AX=5)IsPG.x \—hyv7 PܴVemppA͎O)>-^jAĽ̒2gf]ӃԴƑ(9s6V s[)WbXBAp,*z;EAr0ʫdq4r ɀy+ŠW&(PiDn!0m(K1!y4 4I·$~+aU.T7<.mYkv2r gQ V*їx45gCʄү`[wF gI3m 6IHq؁.H>fc+}q?52Ջ"?U AwUhhŃ1(Cξ'3HFB.x'. E7ɥN fo. 3D\c4WKܜjYDq)C"wbˮnIs,ndG Dģ)'_~)Of=g'5.>uH4A։jrE"`'/yX{>C~q$v]_/pk;Bn!stΉ_M ͺżf<="}g "y/xvM'ˡ!֭`[bK#ImV8M)K#<~A ~_OhkDIe\cPP]l!m-/P2{$ Ns\ HCJڃ5-Ѐ!=9gUpy^P{֞} $(F ' )Nz={pr!0Ͻrz,QE+zkP{&CQ_ON8,K~/hqOܿ^"\U#9l;͵;ROB䡿Aq ŠNVWPL.6yHi]gDno((\ttY֪Sdž_ ˁP>my:.\/M$rA29Zrwj­Vo&=: )kLu<^8&_q>'vAM&[x{r-ҟ*άŚp6ZP|N ^#۲D袅aZ-F\ Jo@F `k鯳14BN${WM粳]1 l\=TLUFĽmԝI>3͕uvǒ4T OvnWPD ~an>t3wC"aE܎5 ZڮTd)ZI2´E\-Ձs)*?2fҵJpzeN(agکZ X /'uGBvl?|嗇wI8+p ]_pX)J a~($E{bULZ)-h-O0A!AʈsnIkG)}"`n7x Ԗ'$!Atu hYRok4]NGK]IMAxf.ix.KdR* =*q00H;=jS6)i2w.W~p-`q[Q (^Fk/ "N5^$l%1~}ŠPu04xZp$2m_?Ĉ :s\ڮǞd]ly;%ikK))YqphA[O:[$n)O {4V}N̄_Ij**fb/8 a>!W,I8Vk;& v)ױ\~UoX=h}lØS`l3hthkʚfWF{@qXwF[[>LK5.-HXW#hg ,n() fAYS^ Ik[I1OS3}kco'u8sx@DxOοfG~ҦۤZxjW^9gj\~f8s@ES C HTRcC>DzG/J+r)J6'$[ً/=%:Ml+= ygEY3+32v?%"d۰;$D[`H=%vD+x.z8Z۞+&{_i~'hFH"`n;2)WǛف~WR#Td*um@3ODYzrQVQ=+ oQc z`Rynq!^_ZLөvYd7PMMf^vb nvuh/HA 7EkY"oPy\~'3%Է,/1s>o 1_1=&L$#xJ<<C߫!x$g@`ފM.דӶEG39VzIZyq gOĈJJg~ E!C7Rmw@#y!Sa`;ڵ;Wm;h!6~pҟQ-}UM^A5o֖Ĝ[׀bâ*bqOo^c8R-@B!-G: o[񿠚9~h])nNs 8B8i:7.qDn;JΔ@D6޳AsSRׅ_yz^F.1o)gqa ZY}#_XAq TU|izqƲ٣'%faĀE,E;v,ia,JVX_2Cbz7g e 7L?&!y!qܣ\4*켘i'5INZynU31'J2M_ŋWK6x4=R +$t,AXjl "#[ *2Ӑ 7rb<8؋_AJKɌάƶ88Κ-aK-9f^<-,}qdm2I[R_,Wו9b SbT6^ Ho.}]uh&6`ɚ1ԵJdHXhNɓK 43ވmzԱfy;ij;Ϡa2Ur%?J+uLPA,'ҚN悃Sp - vc_f}*SN\x4EZN;}QN*J8Yx6ǼWyLiD>lVgg,U^-Zw1{RqS}%JJpd/PAIUi\{)^iqsXwY N.c+ڀ_5FFt? P QKQj <>iѓZMpP>饭U;Nߺ>%oY#.KZ;0y@S{7xdJ1<ע1~T`''M" K\w w+4)( 6_;V0\*71 vu`) Fgn^!Y``#Wk~MW\_.~f2$B$cOG.eqX٫f761CM>d-fĽcpj:8-eP{<@8ypc(=_|E"GwRdzz5Zd"/pJՊo1z ߛV.NLk^pؔQ6p,$9`VAE6N ,v[6`,  ~6{z9C7.=֞v¢HAKbU4oAU3f61( ^7D;| LTZI cyC.'?3<\0{0Jc-UKVs$E/MUL )_2 ǹ׉7;gЩVtmbӊdajVhH5E+OmSI%&F 9ak%U? . qHW!h#Y/fN")HY# *3 |4/) t]/%Ĕ郾n^t2ze5vQWw2\NWRPƇo^-7͓Ay,>cײRkb\UIԾ.tF{@i4b;P mWL$"{UGGA$S^JS1TnIMW0Iu -tG/ IH0r?4 u~Xqm`X287ى}zOfI7`Y MhW_{ەwPmH{~Ujk%a>]w™r*e_ɘ(uY4NMJ=v4 ϥ#ڠgj&+iqDRyq.D T¦wF=/7ѭal,C;{@ne*&Ŕc[)Z @v?>82q"7+\ eu9;Ejbg޾ Lj.-=ƖZ1[&35򘥪f΀_~J nX|&j@po1@.scTg (&RZ>MssUXȧw163зet6H'%=ގ3QSᲾoZğF:gNAOP)y#);sjpǓSKxgc&rzɋQ$⵶_Ɯe=Q+F $~!8QhF Rߵ|#1܊<ڤQyH|JCCg‹Q|ӭ3-uT fzo|j>b DX~2|fw!+tPؖ2ʴ^?ڌsj8Mh"is7'v}_/">$=ĺ+a]>89E4+O@%Be%,ǫ1V& (RgPG<~7}lGwrn B;yՄk(0g ,,(Kӱ 2$Dܫ歕nzգUиfkډέ_ CcygڟjƜZ.WEX w&4Xę OC]y_uU_ G?y1PoJI$Y~5 Ë~tgtDi$BO%'ĦMvh$%PuFpSG|X+>}Iߣ)8?zf/xL q3^0{O'8Zqhs|YsY"e)Nd`G,^^o'4DҎ%Z(`ϷTmΰx]ᥒǮ/34A8JO3O`G Fԟzrެv\AQ_+]jڎEԓw'ۨ⠵eMUZ.S`[éJBG ~9>nlȐwzȀTkH zNkd.`Pm*yk1o SDcIe{.#Έҳ:B3Ј1'krlrVPMq"z K%'zP\_5Ԓ vu!mkɘ71ֆ:?7Xvs0-p[-skqvY~yϭγB;EEe+̥:t<UcY*YD4J+bhf%}F⿍KQ ;"0 i$߼ 0eUwcI8,=,H. i,>{%t6Uؤrk(_I|*~蒸*/z"rǩܺB3EǶrĖn2H0 70FU-HBˀ'x-0$iinج1̖O_@hۊ]HzvXdlQ}JE'xN>?V,;̣.VI1l0}Ҳ'[eL:*U0gnő2}®ntUeo&4SS~H] +QaXIa% X s1A+Ll?h})g0G|M|Dct{i5o<O{Ju\A ( j~KαQVh&Ac$o.,ka(KIW= Aiqt!^? " (&ӓ%`@]]MeGUmXdiy<@GYuhS]_HN * vNbj6ֳ84P>9i:gzݍ۹V] /e2^:-;r:Fb<]$D { `ef`Fk|$ m7,ۗȲl><4<>ހɫLlà>Y!f θGGi5CB#r=ˌȜj+\v'OS^|f&W:>(=>|/B1gs/!~@3Iä$.%Qܻ0.E1Cs,oD=r/'NlAunYg jo/zqZv40DHE]G$6F5"GhI]Vɖ5 1a3AAKjvy!=؃w^49D85lXfsVլLʫ \1yGR[i[fʄjZ$$R{%o~vXJ r|+a;L c0d"jo/!r#y"|;r v*C&f Dl$tEFeY?xkSFkr ˵M%Ճ.-bw8fo6]Ay&rh3uS,WxhvB5%XŠkjdpxo!Ewm> '8(0-w'ufnd{7/d-`DiCkR?pQ3F3c4Yv}lP1APU fKwee?pº 7=&* |VGʔqK[l% -PI[ frw lۦ8OH2tmZCjT BvF]*_it. Eiw@hBpӈژZ@^I87cbhD%Ʌo\h'Ƣ~NTRM'18wrg׵'AEOg-j ^~^Yh`׬ۇAeAOF;ԋο"<ݵ)RTZ[;0@ Fxah;zSt,,M*h]Gw$^'a}uA>)9j@DKw[nCZT ŘbjM, X2F6q+WIŮhQ\`cKޖGi :v 7 &jeOID\9Oī!X*^a;uzl(jDOj3l䥽47IT#jZ~$z ~F+9G8|SESω| 9d&jfy-|_6 j*im}yUX W e}zC%8QDל`5M@+]t2fδ8|j'ANyW0 ȷlb8>׃\a><12J}ZJJ H%U5vt=ʋ9s(%80̏V!VOa̺V.@sTftx$XĆW,=2uCg9I 9lPbE?:+ALq5bC(B PA.⚻_AɰytM\G 9@Bkjp$ufϟR^Za܏tfusBҺb}o+<|?ɣr, [:못 _uc jH>S;%h1OB:GiQIsIUi D]k9`iAk@C" 844}RlwnKȈ~)m6J3WlW1@g7)1籒W BɽFӝ/_^y^U|A8JC}Cu'H3_4ЫEqNg".Ѐjm]x!0sQ;Yz.}4΄,/x:~,:R1%Z9=D) D AkhnӰ;gjӻP',j:mDw3 ٧v"-|'H'||Г2]m7M灗&S L)G,W* HiG /X{Ubq ^ _1gb{w9EF~%ҵzN}+Yw!T>)ڞwL{oK2 H n85`OvcyײCRXO{gkhW*\V$8 #w:Jo9YD S B̠Vk7tiW{z܇#{ )#OPX#ps]5\m%?o8[1;&RQaɱW!.8Ȏ,7 ޠzk׺TN$ Oʇ91<컲\yhM>"ZcsЉY,הfas˪$eo.?O!(NvtzPj%Ky784gq)eBog5ЛY*fҖ+kaeogWnyB/akcg;UHQ]NHݖ)l"Mn5)LTv98لZa?Rlƍi[2M2Լ!U%WD,J3Z?E2~E= /xٽhr%RBt9M@LD☀q֝0 挼O d)bm |?".ק]}L[LI8UPK5k<-u:ڠ[]I"'{Ed7G1tݴ?>_ZgY"֧]~TcwJC`x;eb3;>\b͐t. 5Y2?4o 6w(UeH%3C?;J(Ǿ%m̚lYmjw"~qo9w#va!͂|8`r _ucp3mL%!h uUQެ)`'b 'TJxQZ"ھyVH,7V<ې%ӆiG{@kߜD!vj~S+L)63첪 :)80ŻXGբ\eJe䝶C3ё8s2@2iH ;9HAPmND"p34(⩚E8 }m2i.bujR{̔cwzi_xZ_,@ j]shD^`b܃e Ɗ4 ~[+s 8 DpBz񀖫N3$RxOԋn{|Ϸz횆sF@nF&}!: Hug/ wiI{s"iI)clzq0S`n |axhZ{Sɇ/q% aߒ/yƦ3ݼV)ڧb?)rOD].UP[R4mR3ZTyA :^czg=HHVLt%恺 tY.ާ\myxv$[2(e܂W7<1+S7{f '9n*E]sIC8t)[҃4L9v'#BqF c[4V@U,&펙;v`.IVV$Dc~1]ݣr#,FS Rl~ DV xcɢ0>XӞWOCآmǪdLX~Y'叱)rAU#b&i. #sCb|: w$y/6E鯧#"%'UamcDu(|϶c_i̍+*ŖR` On&r?Pa~W}9.6'Hx {EH0NzZ }+E9J"& 9V`6 /Cj9Gb|($`Ԣ>j2j#]ń⁗гYuIPԷ.g4;AR=[ O‰X) ~E˱7 } 45^t2Wp1.CI{Yzٝ8UT?cq68%߇A1ܺV?Й2[oQn4薗 t$lal6L9!f k.;܋s(+@}~բa ͩEgnLd"Q@Jsڭtg8Y%[F$`"BÐXcOCad1]r-*=~b^@69`xߝ0!|@˺IJy=s0F\q,4L6տc}xc]RB>~gx"R^?$>iݝk5Y(:WVyf/lˀkN ?V+=ȐdY^=`+XݘĽ`haaf"+@ "08xۣq9~.neğ勨VsFƟj;<3\Skl$$a^"ksxt7~Ÿ:\NƺZx#a46T*dL"e%D5J)㛿΁7Xl[A D9^g"lwlU^4b܃E#{B&zY29KE2dVRTwjc>(G~STotD5PyIE+X.ڴ~N; 20[ba"Uә1 ?Y| 8&Ek3uMCLt.ú8`WنWo*sf&kp)Ha8y)Lrx <%>zck9 KM_yvy/~A){OiYW=Bl{N cR&rND)m$\FK͌30\vkGGLgd] ['Imu{:k6ԚOJ]sbcaٺH&*ބnNX f:a?:Q>tLvpUέVz3諪a\;s9d7`] Jvkl#]4dl=j?^;^Fh)3|i.)1Fz-Gf1(f- ,HTǴ$a=;AšupWz+"J1AMsb;i4=q:x5ۚ-^ L(_ڥO0sƤeSxvyȅ4yJaPbƻ+ĹM'Carςi%frYe,章h: y;ˋ(6ȅ\6㗀V[hp$'ݍb0H_O]GEeeQ ҸMrY -N C #'W1O,RKV\ HjKuÕF*0md/|.\JXd!sɏX%>{}~p% #NTKn4vf-tw甌xW~J'{vz:X~Dy-EFt+51y~&DdAdmJgܓ #L'Q*ZҢU6S,]rqm`P;e Bׄ;f [yjb$x]e`XQAf {Z)\>ݶklfz] |B#j7oV)Fb`?eTД2k!׽'>\1o߬,::yr}eVwxHv>~];k2{M7MX*#IE ثUti^Bnzm7$]'aki*FuՏm<؜-=0׊)@ap4O\!ƪ,iXLO}lؚ!<« USZfM c=J.".*H|-IE#/ 8EBfLׯA/>k:yR=z՟Z 힐.VP&Ms#΅Ђ'V:5`}@ lP*+K֓I}XyODNQ{ƽO լ=a`Ze K$$2譙Z8 jn5O!-ۊx4*3^Hz*ճw}FGd:ω2{:;0FKG~?#/QfH&mWeDն\H5_T)5'o\=ώOC;iRL`/0eVcNp]!3Zx95i ,v.̩OS+UJhCU{&fD٥2WFd@(F  & c\1whD"jZW-mNEJ1' !rJBDe@)qsJvڭ}3vǗ/RwHz,(ma&e[GewꚜJ "A ۟Bwa .WgRޜ$4t/m?, E]UKp|e#YD-2'GQMR fPa+ L(!>P?U!P&}!XWع[Ąpi+P2]]g&rO } RV\u\" +j[N9f] z6@JBz?H!t&2/v|5kiApy<mbM%I`3"yʿ.3lJtK@UA:bmC`OīƬ~<S bQqt;kv" */4Ј%̺n@b`_:F:߲;A ,r"]IVЍH5z[Qr3!cJu@hyI (NX$#\_Q\  Z 9%bڴ K@>[ 63jǝqnOXA;\չjԡ MwފaD) t}65SbK\"oHO * vm6ܞ`Z81;PM*T\PчsJٯ"}w'-3~XNz3NQWhaOؔsLQs| *M!E\Q;ƭ6} ;0+'A;QUd!H~m>49iӮ!'8& &?"COc#h~)2?7=$.+Z07ve\ mgQZe;Բrp=-U *\FKJmWeR0 :z.7@d{s,+) g8$Q; ;ip.dڗy\N2w3PՋ-m_;J< N hˣ*z.S:8q wkK+hݫJ3P񫠝7QIv}QpJZnVF'Te e}Ns r w^.~k'_ͭ;-3kţ~ FYT.ryV6 9-&Q=鰏UڪS07}\uv:~ّGtP>NMy΂`گ?a?骛bpmj=f9kv4s4YbwPez+"йRl:o|,C%JM wnG>A et+ϼ1>RУSpXgD*G!zV# S\|;{ӝV׾_c6iJ Uk/hَ3cr1fݾd fò59vG+3+Gy]@x: !~T gz#):R`dx]l[U- B3?2^?[Xs3~Y1T3Z8vXUҋ9ѣ^rG/GG8dӑf_bGH&+n뇉jiFwZ}9yQɆ0ܩ%*WT97c,8Ϊ7kv^ FQa^>Wr{MI(+7f>P^?~>bd r1A*nا"|Ҝ9s ՙlP+??2?R7(?T154~bhDspFxEMvX(5YlΊZch|3"K DVoJ~,RFv7^a0%}).,;^]85$4 wjkFd%*V%i9Wp}-|8 gc!!Aƹ|R)NlJ~8:wDC"ŭZǮ?oVyV5"~eBkbq{!u^$֦be;qyƹ8?V )l(#*V+UvEM9;lGcHsimaBfq *8'"nte˵(|*G{1[n%SB V-T-^h5岤jBl%zB{<ŠIRm"p|Mxr )b6'\1޾m/ %i= ' 5>tYS2CݑЀ1n3V(up;>腷N]F2zٮgO 0;Z݁F50 G-ls/TY% tBI,dz/k>b%9Sծ#R~cIN:+Z%]IS3/-^(sٯ/+ZUWkj9xiV(Lta| \MlءL5Nnm %exJFUR:gmIM |`ocXUC߮k"5;`)A}ӴG^>hْ]4U˩vf yêUqaw!xsyƊEX_Fwk ~v(nSV%º'Ϸq%Oǘv_M \ak*ɌI3iB\)Cܶ(ÙuW/v p|%+ ʊd}`oXσd)X/ z:T#9T hhL1rQe f/~i dl PnEzYP%Ƅ~# }v? ][n2d|mw(D/ ZxN'cBl4´2zR(?+`,lɎĆv(jwO@ c+eʰj<W!vx9*WϼxѴef,y C>[_)[U[pTd[uQףL(Uguٕ]?E[jк1Vt^D r*k_(؉;3dx7O=6l溯֣ D9/ j%8 \N#{EՅ]+bw {T[zaޙg;`#Ly_z'leʖ!ulZ`tnٕ,k*Nۀ6̼ 0DPw5Ssm,|ZDWueYG92^jP{hLuEY[\0&-E*Ttm3K$cV,#~ýC; nQA) ꘤,`8eP&lJlA&ZIMnx:FN{g3!QLQvt|kq.`*J8LVl^Ը]`u`6F7aB]!o`y 8٣3sHbcˆ?`bځӟCݙ)CL,OĽV/aim-J?K0#ɺw$WiytQ뮵.ᖈߢnī&nޛٴ#En1 t˝,&b>fޤ7*|F-Jfh*#W8ViUӖJ<.WmטlW6ӄDo:W3)J 2^ _& e>w,0B-5IDr{`3׏Cw'-ys!-.,3!ijhkwCw qp˜m$y8Nj+6ԇ(3MV;4)Tt ̚GJˁ%q}Nj>M9!(9ӵx!ԨoXjq5G)ݝ%TGf~瑄Y-ӋR &:lyMyͶQ.Έτ/V[@)3S$eXdw']:Z(ʈߥ;y|= xTe08z%|hU㟮-ah|UA`գ-Ri@!8=n&?#AtPt .=vf@`K.2a#YnXY ZynW-Lm;L üH/r[ =qWfNĹ+|?>!a2L+~ȥx\_ԃPң/uOg5VsW9-)jZdwM Q[7rmװ!J8DXArb FŮ}aqZSmkh91t˱WJEE0dA92um%ƶR묁TWwv2{ˬƓFDwd.5)^%SkR-Ǿl"hC)&|5!u(KP#-\S^F |@%Ms_([^j뾮f^ 2ZiY{Z-iqb/R& ~J.똟8Ī1 HJ#L ;TtcysTԡHsHHά%8)K̔Ye"egʌeҞCѤ1=b Uيpܒ|"dd 5bGU83=LϹeeTpe^<8ڱv4MNXfݪe+"jt22ٟ~J@QwNPU4-1گb|Բo>eI'4p,$%'E.8bup`a$" v|A43lF7!Im&h!6\{#ζ6m3.6)/e$V%_r3[M{z*Lq5Ϝ-QߺV HC(lY7/[ŏV׾J y5R y%MCQ,<{$d.1;]uS>PvRF19~i?p- IR;<hBu;>6ibg]?Wib"6Yid?SP+p;[=@ SQӴŔx$Qr}TzmA*6LPB~16@#~[B #ڊcիs~!~׹t !}>Rw\rzcs& 5 \#_{͟`FblVftޝaRF} 8AOcZ v()ұ=&_+.tA6׬1#5ۖvvB/էTP)W<#%f ;~WzύCJ,nARmrdչyhf)亙*p~WBTQ>ON]ou8`*PX/MPEǧx'SB kH ~Uܞ_͑LtZ, #TpMrL%6#EEE3S:F {3h{}cN:­}zJP4$}Q! Ef DӢ4gvXn:# _BmjY\Q14HNMs>9fND p('愽G-0PȚoڣ eԷɇ'8m5 ^2jUr<(e`P a :aYBlp07ta۬l78{]h?x .:k!zÏogqFU әfƽTgў6GhrTO瞖d:6זt Ո?P AA!N.:5WE([x Ɲ=R] N^. i>Wcגई=k|s/9]&pH Ex(o2Y_tSyY,xӆQ# Rqt2@sWa|>SzHxb^%}vn,+#V tn6Hz1BOË¡G?X:@OA.C4sҐn.,(WZuP.&T#؅zpW1*GLEq@ur3 XrM45W}ƽRRk cb1/F=X5*ܐ yg}+$Xޝm,%y=\z;z99Վ$:~+L Nr譿/ohBKt\*l[Kia0 N6uK; |snQJ${Yʖ;͋@NW `\vÅ|SݨP-5Z|Í(f_03yR+.쓞{')\IQO)?%A8Yt ^s/OYFly5,H+ h'%$]CENԭ%>ԩѤ%\Ӣ r/YQh 1x_FoFf*&q޽>QQD_}I^6Y1GDhm%j}z8`tsFQOBT~tC٣\-׹DYҸa2_?#2h`Ixb! i]plx *LUgo,[&>'8ZQjK'zHyi3lTl@{>yݭxtMvkv褲zH~ЅxG_TkfW.TC{)t 1&(߿j6.T)xL4t#; ?P0˦76j $ wa7#om;{kYؾMRšzDڣVIrEVڟ@  g<4X݉VB:zXYV}]:0G]D0N39!]7a\%ރ)%Q7C"N#1*»}i(U9U+>5П祖Eu*JHtRng"*>nJZ6/}I P3f9y䟞.A%wX <+ ά'RoDn)) [mVys<-g+W,p&x/1J+닷|g!ꅛaߡLd͙Y\nEȧOYE]I_o5O[!'-K3uhd"@FX[UV"( Kߏ2$+4ROp pKZvN 7po>GAZͩys1㹱_K48kO4~Н=98AN-gf{xd&xBsN֮dPEVrpw1e7l$pB[q5sf*j)Zwƒ'<$wSb!h L[.vfDimύ^+>1«'#f${ cx>:$H[ ܊l1^W ZYjਾg)VGFkſ l G= ncdy06\ݙE-)BcTG5ߟ^qZ!ueV.ttndjE(ROw>h yp%HO rN ZLfVsc.\vJnȕvM pPj˨`=N !|ev03qUOEZ$Ca PJ䭖d-}|& HcVIK]XWU<{< ya}D,v׍ofИ])9_IW`ȿ5.O*P)oXHk1okۖђ,!87wh$;>ҘȃS,y?[ٳhc r>ݼ%8LUgH@WN|eHMi~0>A:qGav_VVγOs1o@%#toŠ0%w 1Yx&Rv&#Qr VkW%CwvX7_mV6p N8}gm]Y9ϰ3a^Z1.tۛV)a^dF)UkcVI#A i)$ޞYIl%h`ǹ]z5kۄI|@K: vxٴ\3M4 IcOym} Z$Oe d{geQ-Dҫ=1`♂>Dt;yFg¸&olcYcQZ>9뾤w,>H.-JCMA9!Oj(߰Kðpկ|&4,M|>F..W^8I)l[APZӚyZ>rUk8=s YՂ3#7fN'%r]hoC?8jcR]u3l{2.VY%z(H-" znu [틼TݕTik=bˆL6 s;qn@Tv޲@1 :2bShQB.k8 "D>{0'(DI;PBY#ϞXvbc3iGmeBʕh0wG:C<0icx|7w1yUZ{zKAq/#Pw$^PoO_s9eD |p TNlLQ/RU'{ThSh:d%"; ۀFCvq4=P1y$ꥇdf 5:)ƶ*O~}oSGy!O~ t^O҄G*rz]U/Ȝm/k06\ -hbͦiCsa7gӋ:8&$@_aX ރYJtn5 sX2]g*֌LoFO8E8.b'F;xkj^*MTlRt=dziP<-"Z 3少tH}ŔZϊ>!-u:ԓN"8`\U#P_68c6l'45+j6ېf*ѪٸLUђ{Ga+ @R]vGp8{"~T)΄bCof6#T֊n3v`vA)fNj{'Yt3fBi $ܯ~u[%p:n" $T-Ң7+zA?5,&&@wÒ$1tElU7lam@⻆AgoHj'p2k>hBxq ͧfȞ7f;N M 0bJ 0i5MK~; HSX隠$V?bKpq,;X y1UV"A XGf9q' `uțz0Cab2oqO \ηNXx_NSi-86\&7q+q3pC9aT4+T8t)K9 8@-cQ9Ln 8&jkfMNeuߓ_dMnA)qpd^1M6h~8{CVJapSj^t)]Ϣ1 phVC*/mOiTĮ0jaW7{tंGXqAaXX.L3gf-bgGT/Y:u—u|$'iyҾҎke;8HjDž)B|ZK%zskN:s>h..p2ێ*-(rPA÷[Ԍ`Nks"2-H>'1=q逑8hrN䭦A%w@}(Qxl$B!8C]^n)PL*Xs>4Z5}؟lXa_&u(elE=:r"-1Q61ݽ3鵊_{I/<+1U(p/•wo"z¼mLo|jiE @{bI%f!~UN#!$HMz?#ec5ޔQPģTgcA٬y@!#t^LMjaC<Ǚ9x@S ٕ .Reb( !Ky"АpGX 7YbTTSa$: [kpkft3t'&bG ELju7ŦqqAfevHgxJ|IY0ٟBs?pXͷx}H55/a*ş:#Mݢa9|VsQ.BnhpK.7a*Zt;+e5$Rch3!S7,Dzޘs i:ىYI!Kh!㇟wzF RBҤԮ0ġyn1f{`hi<%"ġhV:캠@Ѝo±EG5Uy{lǜB@uб %Cxf4Yg񓉮f )?kL ;`V);eB-\^A =jF\\!<1"HwFB˹ˀTcEc9WL-WlpKg]7}wT/rj19wa#"Xxt YӢ|1Wuvs<^jO1?޳Nֱuymc]#c7,~W$R |ZIK'gZJV ,Vs 5Cٔg3% y\qL2R*ZL`1bҵF'j`4rmb: >7{U\ &@UmDO3HE<$H]W}BU_r1(-U H xIk9:{xCLԹ#rV\I&VA2Yvk'g7.eoB}/}G;w+f;̡թCJ{y+J"3;ol+)rN t]o!Js.|ëbOgiإ5zDrop5oȉ{e':%ZLOf0.rsIJ9šI=5dP h 4Rɠ3x@qSe?rL~ {`9rXH{paB}7X5oYL#&'83˭{)/ kI4#C}DnJ}I9 fے-k};\%M9q? (r3в/_[B(lgg5x&^s@fjkz"ߗRT۴9ކ'NN}G[>YXLS?>y)/wi Ee(N(okB8{2Zfo= 'ዑDQ݅Ҏ~Xx>| D9,vNj) 9Qt5όtQ`=%Чr%f)kY Q9b|'?!|YMZ#oj)5R=J[H?S,7%]kU]Yba7ü?5.lKeud➦! V"p"X́ րlje:BY Bj(N4qE1P]:}?/ nW>kt}aڠX"'mAζ{;q8Q=6` \N\=S _ 8(er^΁r{<2; `~W*'@K%N%:G}/Y! v1%~SZICާt9#su8\ 1/P|.q]øƞF~$ +c4`Sҿ罍xUV.ϛ-:hW ƅVȾzȬY<ȣٻev 0=csϋ'tυ4@{|Dv-j݇ b_]|hӖ6P1-h<2{RѸ ~ {fkֵ~:U5v7 Ӝgv3ׯMD쏫 @lrt?]-j}Fo^׎pݩ$7 BY>!ERbcAv5-c\ iQk,+cCVxnhZT=ry'׹/ۘ+'Vd$#mN,/yaB@y#< r{Tw _r_">%*Z( Jת1`rkD%dt b9Kzޔwkdib k;"%h)mAmYFƃ7Ve%yO3in--<ҒcO{8qA }u;U[esR_Q^4'!+ƒj0E_}"u KT\Oa9 tQNI: #ޖU&H& LRd;8pdW9RfѻYDSƩlގf TG" &Im쀃AlC}?/;k {:OG=Q;βq┟ _ϼRկeɑr*k2DXǫ;qmӥSH( Ch^Tzٹ(DˣYhC)&4*. #+p3_W!&uWч*CƩ[2D1I(,nAd= U롭z3ҊzkeVGE5^hqB%;T3湑)2 78M,0kUzVS_'J9SeLvlwrG[@GEs%ߖw;MX0gO4If+:$8[%!0d>Uq\db 49E8Vy9ׅ YL."K5EyI '+X<0!뱍kz֐N߅L FZ e7aʤy~'F.?O3iDY,0wCh::ٓgBsj²r=Jm%OX;w""+qͥƝòqL"ߔ[sK45t8A d&NLb\͝"@'oԗԾdu8aq+Q-_IZR2Z1XKg؜~-)ќѐSɮkFy<&UYu#w}I(xQsV7Cva9RLCy%Ű]? iB%=d2Wu%S"TVYJJ`*G2t{WAyYmG`Su"BjCe pX(VBK+@:<]ӥSQޢH"c_ම,C (VGQ. 6Ұs) #' IZSDK^bV/%˘צu.UJC34upH=pdogunwcz'FR4r"(e  "G e7p=Aɞ_Yަѯ^&<,.hTCvM'7؀Ȇ{$"5uH^~Mqldr t.k۹,~Vy[hvG!˅~kiYLG6VKlX;f)4uAdP\=)9yqs#މxE<~c2f^;iihpWg}>HQz{8UVLv5#YGe -QIёŁ4#X&XiPEIV󞌚x.,F#j}c7?-8E^h/a}S۷9'` 7o!B3,3T4ST"VKXiNbU/ v7ڃ'~^JrǡX2:mZ?7qJ3.t Z &+bSNc_AcA'8m,繐9KPB |5I246bvT^qZjKvN6UZ4OCM6sVͽ6qNWyu^3 4jQnw26-968={xWP _|osgmA@C6_k,e[\/M2(O,s@rE )x=6{L,>| LVz`CIJfrВEj`ja9cp>WTRԺEtj\ϥ@a41719=zEFҁ$\*g!ۖ@joqɂ9>OݮRs?Y=fBN%}4<,8.ѝx 4u9: dZ׋|4i'Ly\ Vrc[;ci]QђBUV4n#c|ss3OY+`M}3咑aek[j+J= |o#ѽ['دM|6\ Wxd)…8dcj6(Q+ 農c7Q{Wn=w%:t~}^l!?0 +ϓ1$=hH^]IeN%$l:k?~2 Dp:iZ[ȜLhDžC笰a e`y=J3[_NO~:ۤy`ޜ4&^&ky:"#6[(RYQ]abpC +#ӱk05oeQ +(Dܢ sqOlO,P|:Cz+\ywޟ7j }-7r:ӆ(6e3Kz/QVvJǜX$cnקXCF@r bqRsewEPw6q ^wqQe);2OQ|٦[뾘K#&]8SJ%3Jh@b]wk!UBAC8zŗH#- ͼ#l'ɔ(&yg'zIڢ+/Iq`˰q|7]5ag3, WBRhC_'[Ē>>,A'P5-BFQ5H& 5˜UW\'CJO 1r2N"$Rlvpy"huTSQ#tD5$c٥j?pCltUj\ZKZ:-1Xr'2ٯu ].VײI>@4;6ُ&H˸3I2P31~'Z*ɆM=][*?E* Gy~x,v͹ Hi3Pb,id:]lnMr>O5"k2iAMV2aTjP$Չ),h,!]:V+C̎6zO龾D ~4#`ԍtݲ^Q\M`"\d&s_=c3uHc4ǎUunl/}xS5F1mNڤ9Ԍ:^mQ 䍲9߶P.E{˙ }ˋ1G_l`*b 6\*k p&--a'>CC戔?LANKX"| f%s}0gP,ntc"B8'Ba `f *8W[J5obS+u{28f=C]*Z>Og y v =+WqysG#FEa}oK\Z)24X{Pʰ _PĜ.N(} ușM*gYx֪]<7~>>g'L:0E/Q"^7byvU cTOS=lFB﫫 VzqebP|w6o#?K|Ί2wOwlpo!i]m zufbEnXua dct0ʃ )d.&ȜnoTy`6|,_cR#3q3sG$ΚquɎu{ԡuFػ^+ +;&Nyv#3rqUӵi:- =KY0|NkmU9 {+h |{";K!QǠg?r7-;㙏*LFi&)9 +@N4i4jWy>_ybT$ۯJ. M@4F=t6>y~1ym Q(A e;#4yif n({|&uV0AA!HCr m]VsWp#j]sVb4],K"]Et\M Y[@:œ[׻,Z۹';WL ݽf9oBX>י4Eo2HL'nyYt4nt >eB/$p {7>'>|Md z}85Y6!m[{%xKVnqwTI+|佉sȓv~ѴW/W&"(AIO%[G$-a \%jɷM 8NӎEdod넃BKǢ%~j!&Cٛ?] :Y2qQKN3[ nX8yŁW1i1٭efɡOqɟ%#*.qsqp,fKi$5McPmXOnVڃrd59h!9M6ƁwڭSb%fCs/┄fp"ȏ?Qv=$xK>I$E{Q;u3+{VM^X6v= QͧBcgn+(?1:g0 ÅpK+qL$`B$JEӞ/qug=VO||tZĎ/3]E4ΗUYy"J47s9gqC."A$ado$/>rdJzl)Si =ߵ߈ K6LiaJۭ/6cס؞X< g#DcC { W'-ivO?dǦg Q7%~6Wƒ9泥&R{BYije.۽*X!gXOSVǕ[I;_*3\vq1>a})UȚ!Cna42uTJ z-̺ágH B܀.M՗}KwH cц;ڗ8Dɏ::RU="MtG|;h "UeR$Fo+.Jjey$("W凜P"lh~muGZLSHu[ϞT iw)PȝJ |{ޔ SʅE'Br *nd?B:W⫒wLNUw?@J7ªgNf~fzCGT<0x{˜<|4rt FkX{u[WN=6|Pr7;b dy#C2LtB%Z-WX2xJr ÿԛgh SG~h-L;z06e2g* ь⡕7 0?e G7QF] Ў#ge?FGYؙ2N<=/BXMo P[o92mmôp0nXo@xh{}9W tx/ z2ט]MtVz_9T ApoFڧ` ' Q5'i4ҾKخo&2 (h } 'KjR#\&ҕjIS'p8I{X '.%LuiTkr8[zmNO5˵Mwb=5XAZրg\JM>Da 'bW\ W7f;{ b\#.! zʳ2!yy6CXDtt$)TqRgEc mew;Ѻ .Tsu#%UY+׌%1]w凰 &sjjXOyЈ].-7oxQE@X1+|}*2UShh-!=kp6"%'gܒē޾Mv/2EZC1h]P٠4|Rvn.=,7o#dSƛ6,ԕ%UYr qX5&2V$+!Bw\\!Hz ^>@C! "(檭T#m|6KXj 9&֤ j\c;<DP$w"ϋ\2DXD{T ߋ[5ۋ|O@&}zp*OA.ɼcM&]H/較_ƩeVfx6MdžLSr׉\$䰃o͔$4U1tIZ) ?LTwzpgYzkQKvJҭ O1*ii3U DU!jJVa͹4czO]{w&d@SMw<ǞAm0)sթY17_|ݥ'LCɧ+Ga >Z/wSGCy/Yye|ĸָ]5S5!q>IW0٩S]$cP3VR~<*)>z Dk(f/xI.m$w!cht{Mml?su(O9i[C}s.Wbg6Tss>:g#2LK`#ErO茈K/V2^b{ؾi֚cLLrZ޾V|u;~!BmI.PQx3EG"dF|u*n]h=AJ"14 ]gq chFϨ aU|خ &_^}m*ܴ,nV`|AvOb N "EcPuod1巆b"m5Q.'{r +qFIC+B}W'׻믖QWQFº "T㟆֦ʿS8lQ {]o#i6&<;oOym6N.tds1TUTtl҅ѶVk)!&A^^+N-'0!Xէa|xn><0c4x ZpAxA\/(ҝ,ßD][߰u _k)M9(.ߟ>6g8>̒ @|t"['g5ؿ׹i#ϼ>}.EehE[TqSgyJBlqlY~}c۔%{ˑQD ݆Ű'n%\|7NRツyՠAJǬ[OWb:u7b?.BjcN>jqJmpU:dq2;-fSFY8/wc~o{Wb#ܶ7"Qz}$"! /1H1 EE n^מ*Z`/h-3ʠ+By\s˅r4] M#B2؏IZ+v:K%ZPE}0 % bn5BM]eM{'ZsWh[# Xpr )"=}%+tiB3bFJBv5w"mӶ} ~cC̫ݾ#)'E͏H#|iHbBBxޘ)C2_桞_wQv5ʺGm}lkCKH ix핈ʪ`W'kgzhVAI<lK<:W&jw;NoEqq,r5+;]:Rۓ|R^Kpճqx2 J"JE^ `\+Nrh_ :!r$Rxjo!!^"yRdquYڃ>4|֗vx։ %TZG'{ Z#1!zT;,=!R9PiWΟAn߻'0 $,%w/9OҽRhТtt! waa0 ̮x@~(;;Q}R:5# ͞%{C0;bk6Y4@n{IIk miftv1!mBqGe/!w<(?ԜQv+&qME# YRhǍÖd?O:&J 3%'3jnW =0v9u72M3͛dqѦ͟ѠV`$eE %PO6P4ms?6`rɀ)ܿPokq9OIh(RN:Ϣxo0Wu~OuvRHn@D 7N{ҍzq&ڻv=Cnq%@v!=-'0YvV^njg6Ԑ5x>NG-!|+TUuHV /pw.D Brb)/ qWUɣ00\XȵLSٔYa>—3b%/q@?ak!Wksk6`!#83oxjXvt\*Pw=5$- 7Wݶq&{2c_Q$f*# /yBސq!a@N8VQcvXl@:_.6p㷭zLZֺz[Lӗab."Px\W+ +nfIM%pQ~Τu!4rgp]<xĀ Oz Y/59C8,;`Δ9g!,JSrf _uwQ4M_ߛ1׾ 8 Qup0"2~͋-j'kfa{fY%eN)0HgqjLSn7'ap23!f $@9uKUZ3Kod/u5X[73C &=Jb;'d[e.IЬ046~|SG]k<|BH=>YN%iodz}mD d0 M^S[~&}(SpBn,|k;/^A@~\M<,6{y`&ʾcԸJ)ƅloZBˤX<ݰ~"ŒPlF0Z! u_y!}&b]6lj<ڇdqB6qAi"Kl^2 / #}A@Vd[v=2)榁^a y5,lct/{[GH62}@]ׄ99\|n.+t^_LD_kt Ky4Y%^aEz#e0a(_CZ`7c#p[i_ ʴ_p(Z 5 t83L GQG++2נW&o ;h~; W?lC"ykw< `B ҧBM+ .lJ9K<$EԝmpynF,} {摤qVou }IVLc6xr:g.9m"òTZoj [ /f2 X.\]5rS~ҐeBx3ƅ$ ֽ(M ,5TM/ RS}>abTܨמm™/+r]OqO3L9n U%#JU5FiQVF1Ϭ g<'qXL UQ m!`(X[E鿾bY~h= (L&m2Y-Bp>\5UH>)D̏?ZgMM_SI4 ŐW<k9^U`MTǮfL%$w!A/cm[J\փ)}o{P3<+m‚M..Pݭ:?Q"s'Ngְi?ȗ ;$کY/\Bzϓ"orFð xoh`Z'nJBhe#sgqԋ=Mȼ{o`@{^,E_B?d44'-Uz~jy d1 ?AWo] P` Zθ#0\jkG+}#sWf@:WpoݧvdU5¦yƕD0**4Hŧ>i!PP6z !Lt"/gn8 Afয়U+R,G,EyzJ[\NVXt\V~ vW~t~TZBmh\a׷Ȣ n/+@;.ً-郜0.[CD~vt!E TPC,r=y&O^lPH N YI D*Òe.r\,?mz2ڠijc NFPHXP;Bx}J7\{ڜw_o d)9-{y"T$_h!!^dz6݁v~j`.. >Ewl\ <3USE⺒-6FvC1H( + 4-Ɖ\j)o|wK؂cZIp(g@,5w _jY)"y(?%MoMsL0吭cKP'lqVa0-;+2WMTLBFqJP֧-aq8n`1~n%G؞J>Q{9D=v#nPνk+*r81\=\LS5 7h3Ex~ª^5Zo1Յ֓]ڞ~v[m;iC]kBt) *`hr =7+cӪT?{_[ID>nJ(B::k?Vk46ru"DH3 EqB͗#BΓ{fM YAuɗ^Cd2Rk=mY2 5CSq55* Yag L(,X6`*i*iK5z=q25XfxK?>]c`ygWhaI !iFU\ў`ٟSyN9X:LjuFus?8 n0}qƶ]\ pvw^>٠{`u3p`*@D֖qGtᜦla)'ۦk3 Є&jPrd?68K]Q?ūmW^+ Kſ<\6s@*8ʧ23x_Aᶠc]IVSx}2&»," 53-Ƈt'08R1ez~㓙ʆ4b?JآciE]wQ+ /GaB􉴚ŽnLz9A7o 6M @ӅZƏ–IF xZCܢpڄYbz$uXCgH_g=AS '=J {ե47iu̓/'?u44̂"iX1g_xH "Nxp41Y!Cܺ2;ɔix\[6vf2~&ۖPXt jsڄ WtʎU(TI: [,[X dfD n}ڬ}ڑ]'"rCiaiM~F/uϯBVU=ɪ/Zu7E8[GmeyBPTjarݬ&nƼH=9{8Y}bc`K%!@¨I%D/[ (;𞌖y$Yh@{_BʒvӐo_k*dgp@XL%?Ftg$חG=?Wj]|(V~MdCpfH[6 S$HjDZynuw\ۖl}0PTjaHDH_&C%VvżR!RG]ǶXkS{E (yR_d ՃjVú~6 !f5lɾB@'֘UjE.@ZK8 V{ɒ u{'[OܟLa|S|ƬuB&չ+wgZy*,2kS%POBxĂhOlAg3̙j2-=6&bf5JeUY TYlBw2A^ T ujEbgeE2ՆamIS&62gk,%p4"E}lnJw—`:/6ls ^?Ob L7Jޢa{"ʅ-#\oK*v?M6?,NCv2?8(-ʭ۫r2nёįQPZ𴡠!J82w!%)t5'7rTqOxD# ?a9f"?k((I[i18ѳSHXcSg2h֯CN>/0~KefQ1qCS*G*,Ϥ!\bIk7վ#E;;3Hk\:/F[aX91R/ :r/L#lڅ; W^* gDE+(E!C+9msfX u}mzzujŸ!VߎH-Q~7' I?_ `GƑR"I,V*o$"6irN R埥xb[+ \o%v8D]T.'r&=N$LBs)g STICwdn<,R-=01oSK7m &F8d ;34xNE{8[ͻ0FUy\7]bİdO\0)ɺcfˊgg 0CضYS+0L)Sp /"+a,}S%IlIpƇY)3_!JCqMGD:S]j^8&hc'5+ /Se)3gP=:)dSΘ?TM;+J@s?Hj~%MP5 q nncbRzkI8&(Ty(K0ˏyR-v ZxXkҍd~ 3ጳ^?| Ep"ά[h@AZdQDGn' v{bvwQQaTF>kwCVN#8Vn-FtIL ,xom- ~𗫜Xnm8eKwΕ$L~$j|. vKI-Dp>*Vzj!bQR l8hCuSIwTc=u}ӯ6xv})c(Z}1b3ßkF OǺN]2ФM/IZոCG@ =e8Ǐ̭vU2)J+Ͳ]C^ d](G~Bo>C%冟DCEkD_N^-Row#;ߒ[>0ԥJЧRZԧXT2Sc5"($]H*[\q$;}ir|x 0F@˂[MHy)f܊{ af"bB[ jF,TWe`<\ܗ (%G81V8Wa@LIdFd^HӰ\Jlp='9䖣pNvR<Rvv ӆ-Q"Эڣ[O F,Bl\[#dQa2,/A@qTYeH*;3 6̘lQa{N!: {t*wuwh{sԮ$LtN>*AJ8xIy-aO *ԧtqd!nc\UlDI Պq CxLM&8?M\3 yлL4Ƽ+291FB^Eq;]!o*4n jW-$,|"Ihew'=JQj EW+1{C *x,Ҋ*h(]Lj|/L7;")$V(Nm[sM QkhFqћWN'#!SVB=7sd S,4P&r`#_hݦ\dCt:4B dR|N>TRʰ&oVuC˲STwC&; -6"ᏭLS7+ +f20&6/:$ŗd AHWb?I,ajlq2f'RS %A%/4vBT^9}JjkY0C"mߚX~dvU pQQ$-.ܼb[NMQg]r*v'$gPuk%ȫ)6Ua|3(xA1 K(R${zEi*OT2hlA|[K9VO̦eA0 Ep#Wﴷyo(BԀ:R}%z瞂Ă,cVC->c0\SODK=[dzJmw(zy7įe1 4DfiyαA;ׯ,l aTh;[BE:L-$Owfx֦r)?'獣߭"*?cޱ[W4o&OCξ(Avn% xQ$$%jslqg'<k 'M3<ʓ늠oC9 E$?i>\I#Xr #}|{tUO@"6 >Hf 5MJp#d-r'v 5iED=B{opNJۻl4R'c8f+y1=T8:wlX6RHP )'KmX.LX.M*SF΂<6^ N=2nTy^fY6F|2&+Kj|O53w mT0"WߩE^}tas9dp諆i NkWa^L2$ACӫϢ$[SbnTrDr bf%߆mMRw@ٕyd8X21P(' +ZRjOLHd_,衜M:e&ՖpNi7D[Pr^-xŪwp4BSD"P])cΤ刎IaX'j]J{x$k֒,ÆĬK0^ ;SpXI#kҺ=J}5xp_G6M*RqbIq| e)I"5D0G87liJO ~Lb Yfн֚P$F*H_cx Ƿ7 #JRb!O>&9]K!%,_pd$ohAFA\= };-^O 5 qܣ(h2aƄMK2 ]+оd8.߽;:~(I|Ĭ붢lW/uH@PxyQN%89{!x-Ӓv4 ּ^?C@th7FT,= |i8˒Hv@n))GI߆T`xHJO6j V!O D1@ sy~.pނcxV27`1cZ(RqgDWnI˂}1jQj7oI#Jm#Д \j\KpWѠTl(dw~XwbQvr:%6s-_`&qyÙD4 ܃F,ޖyk$vKp_żVbnK ʻԔK99+R.ÈB<,@}< }s fz؍Լ@Y,#G=̾N0ijP4H)j)omRpc]Uo.ͦX%-lQm%$12綿.t._M={ѝpo W|]WBPSQOǙ*i&`~Pv,-{uX1ϏjLi1@+C5;m0,n$DZ }yg15m}vyן4oۯRDɽS*J`n-'&@w]|sܐ8llg'[a)%v.tlF|4xGͣ"w7#T.2 }ONSNH?,Xy,FYp)hmNBfrc}+C +f3Z}x0Nb$"A@)$^3tuG8hQs( .騍nZ gYHClUSP^ܭL?\*TRYJ۴mJHxV<%)/阼#&L;cy KG ܢڱNyv @ӂbaa ו1^?`k*&ԴժNj&S8d x*[ ő)u`aςOM4cF|% m4!GT =J%*t'<$g;hHIzK?i4 13!OlNִm>(sz.pYy{qaؑ&ZD\_p2Yԡz̞#( !) |ZDQ^ߧyY.\GD9d|:xE)CMEϽ8b;8$El,nǘěxuiP7y9ũN&Gʆu7 a^F ] meMq<4aP8ɠ&^WpHmt.4׊Y.>'yyT%_2#堠򠱠* Rj}t*o 5z?eVyɖ犎W(:R&b44mtpn* i~H%Fuuޡ]OYqAGE;c+!W bhPk tS dg85߰= .'y[g!a+0.gu '^̹ Yދ;&CiS'/EM[9L]l]y$mਿƤB23_ ^¤bJrVXa=\bGJ;N)OuP,&yWStԕ t^ai֣M\Urb\v)r $/^#}ߗ S 2ݦ'B |mLdf̲}tp Wgǡr1weOQĶxbN"ۉ? 4+2-˾$EqVM|+-TEpA:S(l嬒SU+:#֘S+/A `,~nx[f8~8>,Ry;|obEkñ `sؐTD܏f0Ul^nX$![OP##̌]z LEU]J/G5Ұ8} dUۍ&ʋo&w[CYYZ'i7 >>>SU)!ܦzB|[չ&z7fm; W=)UDǧC[jmCmھ(EjU`r? "so%qFhs $#~q5ԧʠxH7pasKXð{|G:"e>ql\qi8RLjk ŏ&|b_X~;3B_s|CWda5v +?DZpPɼh;Lz_I?l0mhjo.O`Zv+?uUtU<78l<څS'[}D<`٧\Yx#LmF6N9TЙݑ _O6Yj\`/we"6+jEn&8 U8 <Z7+U'KIe Im//]eكFi 2 P+_f}QaٓŁIb8!^ n8%nPqڔmC2uZVU,Zqo!2S&bKBd#w##q7(N? Z?(,}{7k\4F,a/}ك]@U??́Ú[05m[ݾ.]);jƉ0156bbcqm`N Dw`&YnǏ\ w Ho[rߙ¤8[DzΦ=֌r"?pEjU]16ahr>#t N,gJ)IƇ Ü9BZjP6Bu*PF|)Pc%-JJA\o·aXhpQN-n;ܐ@nD Xy/|qxjy43 8X ;Ben>e_+rPF=FAm^14sl]bכd|U6,0D:PtLu}}S J-!N_aMZLd[ёlQ) ȮŨ4-l*"=>$*.`7 !Vc>]V .t6.zy UAmb^XeXzZЅ5d P0sm)z3Cz 6P;daWLwd__v kA PFQyv׫(!emu™e\PN-x \=N@ 855 Nf%4. Ӄ)8Wq^<Gy3:z 0iBi|'ᱢ1cZEl:7|aE>.sdސew2ނ `9>o]CKtMД'ok :XsbFԩEgvR7 /, Jىh" 8`7HϬ5Vԛ2$s_nlʴfkQD)N1 ]3s (>%]to0|4L!TX;Ó:aBŹYXYˬ0:֖( ܐAo<^9lY r-djէӫڀ~e\3>9CD[fSӗ`wV5vsPdvvVҦVZlg|T;C1Tد匠l{ȼvʺx|D1Xj$E?bP$kk7ٜI:Q-%R}p'\6*qZ>S<@,6ne`l,J@N+ lme`NY*+mίr<7G.-FZE <\~f<㻿~D^j'jSc:4y7ߪƟvUeÔ=ףX`! &:O ^*wL.1d |YD!h>?| `E<#e ŢBf.kYߺhZ-\P0v[R_)Wrcq8@k[ezyK\17U!fy^;?o/7 7}9fV/IT>: gmT+~}?/6Cy bA⼜)2)uLI)=VO|ӱDb-CY]#?dM%@߹W\DNJFJ)Bۨ3q$hSl-L)JY19gae?cX/9RSTL|4k;>\-#$Z9zI&l.sl-M.ؖ"|>z8Ӆ.]@њ Zj2ң%#z׀]gWgW'+[Jdo8H\Ϧt RaY Ϝ2Hl' xHF&?17k53U-.y)GAH/,cALJ,bbe{y|tQ On<PfiFME7TkbWx 3FZoNVEby%h`)w"IL ,wJ%R=U\W~"BvY ebg qR\_>WEG۱qtﮚ`!:=KuEdWySve@.~'ѭYa]SnwuOU)i- nq,RoП&fo彞^gbSpw c[##XϽsElmCfE-{qҁ'hrFgK?dzSG܀Ri4pMc*>xAtL4rTp,(iBMF®8Ut ŒDO|(k\ go‹1617f\p(t~!uv!Ф{_l\k^<0S_u^$'EN8-~ J? L{dBk',T|&j TU-GcMvZp0t H~=EY2X 3yQܽ^YdđDG*dl58h.܍O \.]mY8]F%P SQcYO;<r*5uG^S;ą_ϘuDKewRBP5L"x=P8ůMlmsYS,%RBbt0e," .ڍ`k++ _[:N/3RT#*7JJAy Fddha6رruB(>Iжtj F2?bb}gTXmM7h7g,ʶaL7 ] f 2"%WWo|ޯ9GebՍ %/&[Wr4l8w2"fqk x9t3 gYvk4Xuf=1>aE{;Fl{11E[tQcdhţ9SwZcEWU̧gU];WVtFꯆ^'*23ַ,[cl\4Tf&K+? :a]g ƁrLTn|ygU`'ywX {+%}^!2+ &K&[W ,kg=kkNK_*Xɼfלjoy@p׊ɇ%ܻ3c,QӻI|%ItNYƤBhðb ~&U? &liձ-YT3J7FI&q\~θIBy y(Ѯ&k>3SSĄa6T`^|b 3[I}O5yL(Dh_ %<1/=ȟB)hЀy*9و(w>ɉ~[cm&ڒ'/RC448eR rTbsӅ|ϖ.h!ȴNg0`^u48é|Ú3lӦdv>;8`[KS;.1~TFlJS獠rLg'^8"ѹ2)q1_e3FTN̶PŽ}6'z끧X"Hjol{ho>6@AXÊFJ)Fd*\h|Va+G爦[_-Q}ǔ{|t8Y7nGvM|v@zBr7COԓ{, ej fo+7{dx1ଁaP5a2TGII7|vnpjicQ@a-z-~f!np{8e`6?)L5 hRYgzZDzۙF{6~¦e,"1H e WT=խhM؂MNbbSaxɶfRo!Y@#c\ŝʄ27G?D"E`51끯f%jYk-5V2Q. fFY}:~>+}F zhE?rY 0(y(e7m`/&Al`8q|y&Buߘ&#F [vdw}dB2nFcB!0#sft'WB͍ۥ }620ޙ/=ͳ&~_}_gr?An7gzYT{-X#6ͯ)PӣuGf鸶ѷyhk{]n~:fZ°eϡb7#TR~I^x ӐWPC| |6Ȓ˙z)^!8d2|wý~kYBi_TO(|/C>7Un(%rU b4RwRHl;Mq}R|jsG.O&Yux;^MBKX(r FLv >Y-Cð +8,_svZIJEhvi( "_Ϙ 蟉w4w7dMp̧N{i=:OˁÍOd-M7"e~!q74p3yBT4 %sT.aeG)QRz&tsxLJ}gTcO$gU,Q(*1.O&D",銙kʊr^,}wIC܌!UTڲ52QSv/4,5#9^lɗ;BJ ØI{1”/GM G)k,hZ4gjYZ悼vjg>ė.7ޖң96"p$هUd\$ e,[*MCDUx&D=Š^-}d+>)y3O?Bgz |1&1\Փz $~wp=Bn؏\}SNv  0E-z_bQO1c:wPΙ8Wt5DdWK[Ģ2ka6&{302Cabkdsh>`8*x$]o8%*"=S_L#:$޺#(>g QiEA>M{~ζɿ%'`k('Gv?o'νn5 `8x~T๏?;rƹ N Šܹ&htf=!I5Fmu:/Ke ~ymBfmMlC- !%ZcoAV߀I2|OEcMqULz9ә, 9?魉7d&Dr@jAKgNJDW+"A+yEkdg$b[Kd6zh@ͧ]TfWښ@z!~1_Z2jQ$:DY!Fysd@K:J&Mk0+HmzqCc}QwG&H۫=ƽ`2#%ۅB>tf/ev^JTDqÈiI)鑝g6}І J: e-WP\^;I88 ĵ D)Rqq{87.?k1R:תm2?3C M y|,Հ&m~o3 +}jxTblbs-GWjs<?Yz0 +i*\`;A\<*VjX_5isg(~=rur‡ MXgBg 2&TFĆqguddLJʱ|lUFc/e>AWw7)K%Ͳ\+|nѼ|S勬xs];ωV|sQ (6s5)}:10` ZV/̣GYھ1E @՞თ}/ ,^NLcxuU:܂: vŅ0Q@ xo[AXvXU6#.Rzp}7c*͑c-Su@@AcY<7[VxKمВo%G&]\ٰ _#'F.9= =Z[g[1 +Ё2V[PD=_459CH(v-CWR_rd0[]NL1ej/Z{\Th3<] jumz½lRm% l? S(AF7"lA?Ub$"5<[ՐGITSd^w=6$RK^'m: K, [X|Ω4*+*9쵙ݸ~:  PkDqheב dZ\LBEW25v+BPWف]k$O_/d\7% L0PsΌTR} ;N۬WlBH%k5460iXNsdL##ܺJe E}x gTe#e6}0W>}9x$Cr6fyǢEPg;8ֲKͪZ!GNYl/q!iw :c-Fjk@RUeM\&vGzΊI,BC}&6+ fHrVL$$H[?*/Saob >߇u ^SnP#~GNH:"jz<R#?BM2'%"%E1}) 'K (1t 80cY c"iNL Yq;>.!ó@G x3Fȃp2tI6cհx:p r[./ֺ( Z0a}assAv~gjg$2~PCYg9)ip6H^fl~jXmpXDYk޳]R2ߥe,; jç}P͚9s⃰2x zkכ[L*fuWD²Uʩd1*cEU. nW)V"ՄSk/g24_o+t#ɺ`MXN:P CW!LH%%6nBU +ZL|5XW(>ED5sh|I͖ZI@&j$'YhfHb(>:W\lj}7ܔ@;&nVfR9.4wK@O$X5 ƒ^4?j pTvIYqg5%ZS kݮtS jDx>, )bԼ ^q̦݇χ7PIL9Ԩ !h,>.aj<2xӻ NqA5z#n.QgR?3C%!}}}@8#H/ FkKD]݈!1cC~ӤJ7i۬nt>JH~]tuZO!b۞ 0Rr h-!t,$x!a_'ϒ6!MF )2ܬJ;:Yfx׺ԋAHR7JU^i]2Hcj˻!Vau4 ~r}2ɚg6.\C  dJ"St E59X@pWބ!=]N()1 -ewG{Vȍ.@<',r&troʱ?ǹw VO 7dk PRʍ"]~S "{a]I4]ֈ{-4],(_/-9XSBFqp&|8Pm/P 8l؅XQCZdz>NQf)}0,xwB譐%K,(Ά92 WQ&# UO9+7F ~|S秈}sjmH aa}E\o⦊08O+Xsهh-Υߜ`|Oݾ6av_ :J2{i ټgJPȄĒC}f=i򤇘4z@p+Y%3MB@`^7!#ld{GE뫄 􃃱,(q[]4N*԰9s}JgJXf'(;y j8\fގ;Z"4ƄǛ#+43W r3ݾwGmxqͯE&F-B45z:.$x/݀7: 1U}ǎPNԗH5C2=l,<;1 9b> nE3xL'U¬4tYϫ z\S@?0\VlAP^a>0{YP(X tA`*d\cøzkm0Yi|YwPL&V(]Lަ|ܸb 8Ng˴3aJ)&y??j7n԰ ҁ<`H5 tcgXA XI'ka[hٟwLAcQ6!mOVc3Bsxs,K^G&~UxX,]˯gBF|*}<AשX! };'']`ӝ;j&}պJ `eSсcwg`ܘB@O BU\1?pYSۚm-lЇ?G}p|$n*F:4BO*1Qwy6)YI)n3N'N62noxU:Bk d2Y#8 ^p ˋ 'Oϣ2 *b:w)QB0,Zt c3Kb;:S wpt13݅~ě]f}1PhK ήO=U⪱T 7=0΢>.|#EPA XwGxw} _}9vѤ:u1JʮU;mt'd/n˥#p%j| =CuCw|(z #t,p% сH9;(ƄR{anݰߩg=֑3fqtdxtFgL POփSp,äS>idT@w}yz)p3 q;k nCyG'`niABw: dus>Z; +^6 g˔.qu nk# SNdG]eV IJdV%q=ZdDer %)MQTG̵u:\qL9 (}^)^8Y~HYmtٿR+Ty 0\ƈ^K$4jE0iFvߑ{<2y Fu:BeLdtwQ'@&HKa63BJaPNJ.X 0<9[ύ|I7-Y5t#6~6cYAt("7{H[LBJ/I>_Z~_6So8sS$$)R)#ZlMJa߿3vM,E$r)ǀ>-֥)8Mףl[f+Tf@ +[@o/t g_㨤Do9+)8Iv=LJZsVQ\Ɵۧ^h7xXC`!2+|(.zCe-7HxZ*i:Rz坯\.ˑvM⟵z&G/-Mc2DSWat8ٶL\)HUh|+%u@)"?){y=INYZ:1)n4b&͈wtAE +PORjo?kKT=Ei(a/xQ`a6h uFrH>R;RÞ(F[\lc'?J62mOyѫ^P}jFX$7}%[&E'i{}%s3gJob"Mkt-.3p EEJ)7#X=)KAhTxa.*X6s63ޛ ļW d{@ρ{gݾmY+" Uƙ'0]U; "?Dwy,2KLxZ=VJ%HlfR}<( }R Vw6\w8i9 jyH/m}#;Ř-4 }\ѽ_YN-}m`X0fw9\lH uSTI9Uׯ"b!G&0O`;Jg:Zz!?* xMPĬVsp/"vh /~ൟ[єڟ@h"W7ϮoP\7(aK'AWo֝[V4lD$qt7N\ݸH]$rP1~c:8 ބ`; P2PU.#1ϮEVh tOQUY04v'=<:~%@ȴ}=: rds-`b*.?sl;ݔQdWlV@ҋT G6Ҧ2׵/>dfM a.Y8=T]_L Y:I$7b_MyH`iC M F@Dm$zm=ƾ‰UX!{] Zݿs8e5 0Q$UqGV:22nl x6ȅvTR#/hRpƏN_Z 1$68}OV'1RS8EB)X }z =G {_m/8A%?lnj@5n2 /Uf:)w*F:#29nRur}i#=YhK:9Ycv3{/R ”QA^e! C"j96t2K ;3aaWl%~tWHul%2Pц!hYWUbhdcC`q]g̠FkS@dy{mC[}X$q99eRPCgjMxVY,d?2g1IcZz-<#VWcd7u@Ca2BKj7=uΤ 2SɝRW'Jťa/ˉ:}wRjҺ үkڞݾgKe #ie%ekFIA vUP~סח;2\闧C'{FtEI@@B{os`Q_2p*8:ͯv()) f?dr,˓n={Yk!:=Txg}*Uk0մf՚4g%uR2(k4t҇i|l#lK`BTl>ʗD'ӯiD$ΐ(\e0Ƅ G(2I˖y8\MW'7NǸ| 2[l@bJz/F85ٴ<&-3vD^YF#b'F;a | ~hHR6n aνMY^gt%OaxX.NS+|ƙOٽ 7}l`0z ^W4̿L6z0+HaÖw<42P]:.^t2KV&=f{Ak+#ߒ}>gɅ验kJ iA/&zCdV*:AEmڟ:^ܢIȹ=OPJ`qWG2ǟoSVXdKKS6K5U9& HOrno4gMmת?KCNl̰C9f(8$ǩ XbEAV#* Y!Rv&+/}B|bٶ3A홳!((_m^)qnFoʙ0hi-=' $UĊџ{g^yCpt, ]*bvHЎ7ʵ514?C`w n+|Rm zkE.w*c 8KG{Ues_3$ct/u9qYPÊ$(}IQ@g)_. C!ֵU& 1օh_|kܻwOlP/=!Ay25>N˼pW/G4NT{K-:fFZWc&" Q_(5z/veX+cO={P{4/44u qSD*Rwz Gҕ"ca"mJ]~^YU@MC"t6spAG;njɱd#n BPє`u|nF=/#٤yf.$+.u][ T 8ފPB]b1znH:4Sh;%  cfBpo? 1?Q 를2qaTiƫߋL"G r\^3 "ȡ7 bus:<LL32Ut`KQW<rxixBr:mw3( s:4x=_81OtmKA(;rNj |Ԝ/Fݫ)a n#+Tv?޾4 ܚ02jVjZ<=ZHJOԤ"=6y o5FdatdZ/9BlUbd-?p-'R'ɯEFojGME ^ 4Kt%:Z4dD[v'20)!J!R@gC(+U-J'A> 9]e;V)W't]{A"^D_2Y9Ȟm֔P4#$5,y>y|zk ͊ފ0D.A!25"9gbBݘ1E5d).?kCkg4p6[RZ;%lQރe:YtpfZ_tnBH` %RR ('CwÅP]Pϕ@.kf >b#UPpG,wP`SF%i[B <1JԊ@SxsVzBOɩ5feZdV]s<ŧ:xwraǕ}FS o)Rl7@oiAd!0lVN5n;+qQuSUYA9\wx _dD4"% !_NzF OwYq7U;%+G,ǟUH;tra oZpNiǓ E sވsMUC$w[dsA.n48yA0>xt:a}At%r!). ؿi_>ck;fwN7J +jb_ ob Xq*U)S!Aʡ;8F9j{ v43NbRFOz -'mHO14؁%|TW| 7@;Nr]BAz\52:`z!xs? `YsL]GCs9HR+H||T M|`Ng~ˬX *5,)k_=1$v'&o3LfxưYCFm۶lėQDBm-fي~@0Q[;z=հ_V-fe_ ||@]t41re\WIy3@)(z9)S*/ʐ%C-z$"PbHM[v%l=Ww AIoީ3&ΣWXǣ*6pHH)&f2o}k`;Ysur#VPeVwA[`)fw4W&V8fSH:]bo3ʨ(q6TҠCR<2 LFRfiD.mޝXQduHj` JAFqަ;3g˶K\xVPolr)պ=zn$v."y;"СTYiS_WYٚ¤ln&Wڤxm5U[kLȚnCs 2i!(@HJ8RƜ-}'Wm v ʍ%H~4?[xSkug]J1eֿfQF?D<*!#j 7٩SjmANL3X^MP@&4`)yLeg:TkHZF+4菖 62G T[ phT>W `?$~BcfHȮa{ĚUEi ^y>/^O|HA*Ǵ ,G%iN[k39zQQ8g!I/ hQ_΋GWzcHn!J|l>@_h[xGEQ_-ٳ^55| !!bO 5Po\HP!}'nt.}o7.R1q zVfHO=&_ lJ-q~]N=rƷ2^mw9T{qm*rSǺ=h*BӔ|L!YVLGE\OHPZƘk*f G߼^v$=>6 a0J}Agg+:_,4d/kx@lHҕ`;4=jw74L4%q5(^`RPo㿙8$_i`,Յtl:;G O>$Ō!elwZ6< a%Zuqm:Ğ`|qj{]5)qU}wXSZ{y'NT? _)t왶ZDZIdv&w:Y%)]LP5}]QOlcT1w]5Ko{FĀ55 YS"U)ӎz!sB8BH=p"ROFSv[ErW3~Wr"VH#'Ŷ(- ~9D0E :ea3;r#`u=|dm\o林w2-r!M8jU%*)gLQW-gA(.w4 K.H -2@Ђ"l)pVW~ eTHrnRGHR QI5 0>x!l(7x OD 2 B߅lG} IH/iqh |K"R%lŁjjxFkrxJ&n s6Ѭ>҇"l0e?>Ha'mѪlc٢X&0bVe:&x* +Dۘfl+GZO 9DebsI2}e* b=3ņq5[b~d5m Vb+[ʍ[A9c3\\[K1қdo A].['3Cn{ ߮4!h [(1"n*? z<~^}n0؎bŖ}qJ].o:r{XGWŒUJj Yr65*6X .v\ mlm["fh.S@<V9ngRAb_R Bw$MkPS 7PњAWKץ*O߂jpQ$^0Af~F9 ^hi&xBn.kE˷|ċt_'~l&KJ_̍D$hJR@Z{ez'5Ndr033y= ,l %38vW*c$ 7nՙg 40%jݔQۉ>@P ԏ&֬B4'朰rE:D q $:^6;'V/:Fl$&7NNAQ<B1?םf 3D0u),xR6=D<5Q+dU^jn %%Uz *\pOA  9/X1 @-B)>046qt>D^-.BWZs6eFCUgЖdž5#jbPMkylqDlR<t"flո~ݜbe=>Ӱ1(Bp KmFE_d-/PB-~uT58IWˌZt#mPb "}+DFBPsŻLu\eX!.eK$|[Ed=@=,iubK(ߘhv;KyRN.yKrFҕvGt ԏ:??z=WQp~<`zB=z %HdһrɐC$zbb<>M/ keQyX]B#V6L"Zfȷm5@$IuDqUH"TF|\زà}o}}m=[ZJbr4] "r*c·UIh9O Uw F š,cƇ^}Y`*PƊ =E<< ܍LđNn4‚f7h+n2؆N^SL +^$tR^Cwm4?.tE=T oOA~HYnv*h!ݏDDƸ[KYCϽmAuƥ=5C ͽKd'ԉPtΆ!,,0y>VX[e#JfcliY+Y"%NDjۓ41s3>*Gcrc'!npNz']c`p3!h6CѬw& Ay8]goEùtpdXqKS v^3haj>St?n&I3,sH:ݙ8V>dOʢDK0 C5Q B?xj\J=Xπe"qjkh&ȭ\:cm>-O7s%((In5> PѯqO)0.c}NS BPl1O,I O`fhM)[^D`Z394pf鷸x"@b/0x_b LP=Fjˆ),ZB@ j\#{ɖ ٟfpcT5Ug/.,fǬdǸ0bCzgIs3d"RT8ROAseo )Ad;Iͭ* 22N-٪@'ڍvLqR59.;P]XFϧyB"5]p ҍ=:v|bDX ' n_;ڝՎ>8db)KMи`#,Wqꁡ܉۰{v?MݛsĨY/ L|Hwh*h'.1N{ dB0Gi!KY1,x .{T+ 檃toվ13Iv@Xþ!Z"qHANYQz6|IΆ yI.vV6a3<* MQLhmVÅv7co^6;:x/R67&"^T3I`)Pܵ#B͂_}P6>Jn%G-t3U8&璪MnKS{bLY*Mʖj/,coWB]PGΨ(bw8YgLhE,H"oBm 4ޅ}.`Qj(гCSc6z#oኋycVkV6xbxqH|0>@Gc X|I B.W7eÅޱE.RfLgR.9@&&dQ:ژ8՘~KfaK}ĭfv,p)hB?ZA<ts xYnIj2ߘdjx#EG//Y! &v='(g5`5 u큖F}*?eqŎMB z}#1W}"ƆN55h.#~E$m>U7PTB!4* ..G,L(yべ,;i欿}U 1HA?HJ+WSWnG,F "@!q+=gh֑[u7*5#3̨,}\]!d_gJ]5 h\ }qn @cl-o.c#1ϏX?9rD!;ZoWpna>&f^u4ݻ0aAOQAWx@4U rN$2J)}JN&+!R=hH)#? K&bAB÷PǜCoqK&i}!)1͉ &&t3zSUУXߒ[E08 >\蕯-X|!&Xf~p{J]4 wKf;B+d,Ģ%!}tm<ׂ;U.a>/Dz<I:Y,"| ? *FӰ2Eh<ܭ3k>~&5pU35jKH3Ʋt0>_al}Kj8򻨎7O+LɣԘ^&4!6B8[[XQҎC!yUwd~GV&J9{oq +#z}V+)̹o1qa 3i{&#UF<,;C4U3-^lc,u؅ez,nr@&ڤ3cb :[e:2M)̩Dx`"᰻դȎ>PItO{GmÛXLS7QSqbVhq|sfJ9ݬ1WV|~a4|:eTMʟo`8Qo}"n rߦ H(&Jq-YZc';Q6PޒZ_M`eF ꧝PFpOmxb vi]f"nuFdϥc-Ϋp+X*E:4T: cA:}4&Yj"8sfJ_ 8A ]ƋI8ւ$3rCfKh!k, ,\;{[ XZiǽ@%Y1:? JVh~ s˗a?`U MLfw7-]cTϣ s2|QUPs4AgvpU$ďoJroL" :m H NU`1F~iZR6ԙ&5| t Ifa+Ct({Q%+C\y3CQ ex>ξ {t\m ԕ*rW wO&n!l3`tP\^eD=_kW8W&]bp &F? l !0̞y"^|&vҡ>S1y __+{]j [cU\AJlUǨq?p$?W)P | œa4G?yasDsbqj>p-A܃& a-iAa+ ܄R"R`&#&jңMAdG EiE#y<^~:wE69̩K Ճ=Freg\ʼnEY  wth$.budt^›I[<` XWB\x't/_%sݽ7uǗ㒔u $ڙlٜ˫w?Oy8t]v#f :JZӅM/I,a>-cwPvJrOSȹrWڶdVfx,/ܨhOZ<&2C' w6e+ FVlD≊ǩ,ۏ8q#: (8thQ4-)N>5|;4JdP(`>8Tvf%.F_b7bG=#; N8iMTN:7x!iC1snnBfu/R$uLw%|Gِۂt4_gjM NpUEY]Xu4奏֗ ͔.ٙ 1|QXep2mw<対 yv]-T@_CK#G~bTn 2e֯9^=ot )C6o!Æ> XI p190Y K!HXc#(ܾk_ynfHEWuԱ`kۛǤ7l6?h;e(]Y`Z AEyFn:m=9ly0*B9Wb{eq 8$|"eq?J=7paP+BwOOk~'%{!u59ڔEf8mY*Ws q`o{aZ@+ӮK^8A%9@Oo|V!GiL?̳Nl0̋) f/:k8 ~S걟p znJ l6v"/?|5zuua3t!EhzH?ו ՜ Q6I[ΙtX)"&CEҰrFY!wTg).e:F۶VSj q5:4UVMq-|yp3n'1e<E]*HHH :lMcSш~frAsM3Z+ctd^VUU4޽ !3J]5'ΊMN pD|. 6vac;^-擏eZ& ]1}U00RXȡyPysPߐ@xI RUfjYQ@]\񾂯QkZGC3sRF2 *ݪ>ͯihsL/O3ط@u&3E<Įb1#AA[02%^_ ÆD5Ojmx娴jQMuކ7ݓԟϊbL^je ?u vT*``ۢ l4mG)ܪͼ^Ж>7d_DцvMKzT1ރ`|Hwh䶲ZQo^W4y  .ڎchj@&ԡi\+5GlB0]۲sYK%mQhU ({ϧww=ah%B36NE' ޳ʿ׾EH9L4{Zer Cb Q5'nK+CmRq#Y]oS]$ mS5"NyBb 10ܵd:av%L#w$gI t"$&4wӂgn{H|w/NHF잃|MbqjYv#;O1L֬%7yr'O 3l5|MMgW_m vƴ[G˕bgO*ﲓ|e $$jN0I{i]E7u薞e0f v5nn]72eUUc~|:h[jt%~ʪ0V E ;DKBtXQ(U2b^4B]4axc Mr,jYt݌=_g.P?mCO$X 1۔MNT ?4w{$6Hd.;<<$vδI=Zu27@\Nؘ7v ԼZ@IA(b?)V`3o鉞/ZGĺ>WD.St#-OTx9,lr/ 9E^,Tほ@:>Q.hBzkOz%F3xdŪ/=˳h69N,{ݼC5,HDBa4C׭uʁX|Y%I+C;o3h K-;YɞmOFƳ4>C$-׵}c+b?0/m'H7{~ߴrxzɪ.sz؋qz *ثчlH 1BP :8)]"R8c/@.%R.d f.*m@ OCz)dFמCJȴqGAX6pN΀ƪu=UY_ b5"cpɼ iz[yoVF],P7]o5Ψ&9h`X;sʖuN ʑ,QH -3;POO0>4`07 B,`iFC7O#qW_ǐ:zTAX ]@#2ɮ\{rj蝦ĢU1򞮙PDAs.i^R!z֬r!,-dQGO=;w ːYNkNV>'OD#.$&Gj h=pLzHOA(z?0.U(;͐orZu* a:W) 1@9|ĝ]¶an\%`=%'2u\NpDɰ_799ytW?j k4/)C:cI3@Kz>X };_ft#Aƃ - '246 -*F|ԱA$8cg'`f+REUqMV./:7E BPo7DW#Bx^G>CZ1g|z^Cl;dW?bvܰ %SR-dyl@;8>Dqw3X@O0+?Hfl̝.,8X /d&L>ڗjh :*OѴ% OG6v-h&ߤqة&q#xgPEżEKAL&#{kv[Q-4Cfi!3?M17ayE #A+ꀰATfn1M0)Vp,c?Ӭ D]uMkԤLe[=顄g;aOHv"Y,F&rK.2hgS;S]35MJt8oфl~`s L:*M̀ll2=>:-OcFgRӹSs {Rgramތѫ)g`0L.;Č"Vr-|7 dGP$J@P%"-Yy>&<È׍Z@@oK~>(;epyK x1Ѩ[.-[`HhZVNV(aU{R]F!a<{;MYe.A2= ЁQB)u.9Iz+e|Ha=uoߴy8pSaw5p^T3UM쿩j^OBW?9x21C s vBG¯bCOCJa\-GQ}k`e4OQ^e nK1pB /.kgO(-HvcP1u? 44YnrgXFN6_Xo@7,ۈؚa [77wi1$d-xiItt#OIgKf3xI V;{5`¾p'URsxUkfOq`ٌ }Qk'zƵ-İ@ͭ}guD"c-R!oߨI [ (.sJ %OLD`S0m( TAE%mNZ噅cc%/+ p]/%s bN0h7@8}KC8ƲXknH=R8_+u&εߔVvrpj屵TQd鉌G]Ak1ܠ<jg,gQa\}-&+(c ˒+ݴJqtZf{Tt$C ޻,U3 szGPA9bx+Q:o nSl@0 tn6 όR;ՎũjrЍQ Q X{2`Uq1ʛ|C21X8',Ud f}B{ H1۵#qyfTQV>.l(pճ?pSܡ,ǵ進Sy>t0Qd\#22N[lz5?-uE1"߲_c=37y|[J`7~aR u,̲22?< ~WK=S׽=_l-QlۼVvoYVs":I  _\j\ȂO)PI.a2f;~Tu.t8<>gG[: N|*9M EğP@|}YAݽ3 ~Utd:O=+"F^Ur$/쇨\W[nvZkʗ+F3 B#?uotB`tp :B&zʰӕ$}%I؋|ޙue#^Xm-8@+[0u{o2adժmeat9s{Lb:TT""kގM ćGN #ȱc.ue H{ai~+ ZQǝzwm67k߸I?kWbY9WL|o5@bԅ?`5E-O}F%f O*v翰`R6/z) rۧ|Qj:۰$M3ggvg _ sHx;ƒ>$^Mxm,5;ȱҼ؁VtY-ZL`9|9xHTJͨS:_'}>X2:ףC oDcU"cE箜|fDΏ "~9(Vpg};L|Ç| 9=HU uE&Bag2i7l,_7&<[]d6#*C":z BlLh^bGJ/\gyѭ4v զ(L*^/W7щ%f$Ĵ9I}}@ Ŵl;c\ B^AG3\lytn2sjSFK8)ѫW?_J` WS$Aj-<5}:h[+%+K8b:hR زUf=. ټāk-hÍdWEQAsqT}h+КH%)Wz-\125kw\Aua1=}ˬ,>^~K] <0q|1ie][%g4ϜsAl0zĈމԢ NٰFpp*3z`5-@jea݉f<;k3jg{f]]_ӏ漨.4T Scgo^M>mLї.>U5wSyh4d# 6\ - ('=t߃fŽ8|D;4o ~ }[P|>b>Ë%"%g(ܵ g7 /hw?o8X9z-yM>6yp͐%(z x5S xX->&rbW$di=tK"s>%* xX>@lJ%FR_>1鈴ܚSa!%w ϯL;،f$;`Y-OF'qԄ*R ckuخW6Xco~xGj ˊ~jcl4S6y0:vTNB;^-*t^S' ,9mNIZCcb.XC(d(Piʝwt_nN,']0:<|,f, ˋ #nL<01a*DU + [t2KK%h' .{Ň|l}{UI^}HY!E9\Tʛ՗["_ib*x_r ޹6Fֆ?euJ`MNO+ !k{xHGm۰Xn ƾyW$ɶSc=K ԪjX;X2FwD4i9SkJE:JQp ɶnw\}d643?.5$޽gGgZRVcCq|BkcV7hF I̧٪/)uQn;n23Ο_mRW3|F3GRe_|+3] *i-6qpXoAݳ^tW¹ŬV4+KТlbNhaM6 \@H޵*/ -Dnݐg{zT.c荒4+!(t@ iJ^e>ZQ@8wd3]g\jQoIS҄ޡ!an$fpf Ih*)ʴFB!_lsK`+ˍjY 6QzC"wn%[GKhWzr0g{"ݕlJXeZᝓԕX;IlH6^k u"'*K$dg@>ՍrރmG}Zl!N ߎ7-hMiqiL<`uajaOBndۚ !Èq ҫtPᢡ,cۑ@FPԴ <<&bМaXiK$ |q$GpfC…EȌH=%{cn{F)L+ki{,2b}(tfpUuF #WrX"^a;?CȎ7MKC呠N &ȏ#HCer 4wĹM !A麅f5 R1ԈofUI: A[훠%UnGemK6e+K#v{Q~R믗bx8R]w$[b+4 >y+zԕهb}dSBD>QBࠂ;(yB2ppo_jL ,uhnΐe=A~1LMY%0Z͉8If\b/ΠIU#6O~}m۲DI?'V!4, fi'`jণCwķ.%bD~=ZWmF95H7i9/,r(Px{-;%YJܩGꛑEc0D(xl$r) ֤$\:*~{G01 czK4 s|sv}um (K3y9Z)A-W85Ƞ&~v/Fa!;>v2vNÕLp58(/CQC:W}8RIc{5Gydۯ;ZOusg3,wsU=iu X^[gPlB6g>L48Ŀ}檞e G^=;AU2e(uT'Ğl]-ƃ}gu(K!cDPmTh*' PIIc#$yTPp"ٞu7FL -BՔc4=uHc56 MI}bδ m<؂YZ21Qj%'ݗYt1"EEe.`ebXjOy+I >43QT_bgIyga<ƅXcMD. \JrJ]c bO7<&sqMM!X 6io\.vZ)?XrCƖƂX-(R._2L+P@`KQ{ -VRSsE01;RjKlJmv>^GߗE_ 9ᶦܶ-/7>N }2sؾCgO"B2& Դ\:.yC2IG  hz7 p}80逇଒v4 d>F;q^{ϖ}3&Uj/gNj е"=rvN ,W!{-gȻ`FYr\`SRM1;u1)P6wQ`:r+R'G Dtٰ[c=6R<14A@38w\Q7[U awv:m-9Lu[XL&{% =;=mkv. 8ngGWcvD`Z4coq۪N&Xi "cv53LW1x:b.܈C9*|LO58/OB"7^ rܲu00J.BT wx`S$J_XJ%_N9kC6F `Gu~/H6^bKJcFOU017xNezOXbnXbԁ)*~a茇lL,D Ֆ_@ۆZIHT{G÷FY@x< |MǙ=ْG,)m} E|e*FQ,Gؐ'jgL'1V˦e6YV.:i8ݒw(plLa7k*q\[8 xe;Α$KsAr^xP}h/Ff ,CF|8'sc({d[NL\W.hK,`TYF*WY?Mophٷk1g`J~cҦ:oOFķIO/D.boIS7KMZAj0l CLjw7SX8pAOf&hn\,rl0xjwiRx}B%I4H#2cM/r?j(RG(l ȬUoB|!$P'Wb&8vRz, PS7y`I17j(XG!A%{&d&qgef̽{dx 1P YN)XQx'3JRf*n]#,23Sz=m[٢6| UַK2^ p>{_5#IȔV5 )ؚC@Hq2Z $&O/fEru ӄ -0V` 6[Xµx%EB1ؒ+D=&m)A# SLBRvCűZ'ɷLe4[;IOAmi+tfo^ng^P%'FD+:\ߟ.JP'k_:n(=<ѱJ? O w'Q[>QtR.l/[̨B@8耻ͬX*& =] ͉` gsx=0wb7OR߆_쿼k/ujځ^o60wSHLi; `xhgFh^_9 "L̳>8+\HWDY֘1_Ywއd]|>o:KRF.b3elV. @qpVZs#ʛu7pήSJXXKɺˋ; 򘛖m4!FavC ݷ szkqJu3zrr^^kwJ<'<)_Ņ)dc  ;kȺ-rCm[OyyYD"oZş!(K}- sj_zc3Yi54pj}y\zNێUϧhjלG󕶔B$9ottk\X7w ]"+zj=i3[oM9hv`DE8xޅ[lP.807M 8zǓU$)m\s\HkyDWm:a2˲ %.٧ z9],=t-(7A&";D& ds[ I׮˛4^tgP$qemFcȁט^i'ȶ? =51wT7O!E BAqC).uk'TzXCh-^)|/G>m0D)c9!{uV,3]:%M-b\U[QѫNR Uo{rDvŠ(!11&zyeݴW_)abҫ_vRt~2$SB{){+qA gz^9/{9`Z{mkzg,Fa"^.4 `cϰsJub}H_R/Ke`kBP~qGWp{*7weÉ~9hmfS OڥzU1K,cz(KMz;w]YUb01$ >kMAH>oޘ 6 1I\F 5M.h EA1a$4 X"Z#L i\-BeBEBn܇pLlíʡ{n23O[gh6ʻ;p|cf*Ǒ]rw!$ʬj>E}[ud|]Ng*mW'2VMӓ}]pbTPF)u+ʗ-F H1ň-BNsDc>G!l1+=knv;AwΘ߃wH ?rj\ONOh:jbJ8!jL&DS~qB]-ZʉWiUj6h0Tn7 e^B^_9NX}PҚ#BbJ398Ŗ ǚ*?s]c<|ijğ _'GӪ5'd{EAf3l7"Pn<5jh\!{IT pYL 5CPr"sf缢T(2U,*H !a抓NN] *9 bKq61gj HS|@5O]k.4%e -4qktYԊ%<_DrJ뒬YmoAΩm⭎D%19,b eׄŌXCt1c֟6W@})x>Qp7.<ѭbAVqu.x;uW&1pD3}bq%|/ 4&'ii/ 7Bѧ\K8l"ol\D鲿Lp~c2KJD ^Pͯs qӐlj2>"g2a@(1:9J)> ܱIY[D 3{ }'wbr}*z$2l^O XyM`vf 56TN 95I%9%<.'M2$eH}B߁Ai w~EU=vPd]u?+kZ}?V{hUdN6!X=Sތ8 ȫ"ksmZ7'BQX%0o$SjWl 54A3q[f=42rw{7rr3wE7 J#o^KAtq#)֍E.p4DiM691۳!E4[݈7_׿3ڗ@J==?zJg@Z] qL厮Jo{Ry*hxF&>4A7%*L Um&J@ m61YS4&~7Y+3ϽXZУyZzx}Q9PdY(`5HYpoݲ왨S7V]ށ%U}O}u{.1DzKaq*[@X9'hlYD+[m&c&4W{MAF:{gruB0K^ 95X7ѝȮ3 |l0E2#u)H/Yy8- QuQc?qt+dSL}t`H ,ъh!D>Hv$li%@Se[[gP68PKpp3* \p-UڍљT |_Th u,BnK]6)5Fi~Frkip M3,#FBPl34a7.V6Mb$|7I@p .d)+m^-W"7=I>i_K0:dٕTusfޫ2RBJ1>UmU[=Bn }O4{f:e0'z@x 1V(UP/Us&/]NHc)4iT$A#=I-3TxXpoF2ql)^h;)y\]TB?;s %7^zcMw̛ JwҫSCPWQ> M&@qݳ]>kĂtSfJh^'w5I2y 'قI÷nEJ\R:t>N^qq5|Z\z]^3\c,KK@i`$V.u?#{F'nY9SwFȔjl T,3X{.R`.1yH+84حȐ+Ca4%gc+}N ;y+c* u8Whf= M@ڧ2j!3݇+|Z#Δ?L֬Oӈ`|rټuE>+\pM.'he&Aj8k] jzXͷkf G :ʣȑ#<7$DfJ2.谻D? F)FH>M$*{gDیYO O;]ʒ]Dl3#jRqI`KuoqN}O:^ks&gmƱjtk"ވD>LfIёIpQ:Ҳ6S]hԷҳ9 O'YREy0KڐҀ$ab9ӟ6%3QV䋽t(9$Md1G\3/Ӆ'+ ~f)Wd%5~on3|9)ph,YY6qA3PY>[R@Y!E7slph~;jl8Vz0$4c|m7!>NL_v :F7hUvG(Kݎ@d$aQu@z\lNyVn98Ka^ӟxsx_dG[qݲF{(yB@٠옱F$:~ s`&;W|_ ¾z贳QcߓoD9, p_EqatUAk]o&>mG9n8MOCUt4d κccv E [taJ -.-D=Ɵf.sq҇ҚG?[O_i/cZXk߉D R.A5b8UHH1 yGL[STC'q܆s@[zJZ?ֲYiLiE9xnooYI)qG`8J08*6 AD|%=sųZoKK/ېA7чđ}86҇7)h ʅŌTUE0T(TE_NN,] ZT9#H!#ptB_U{u(΂`qJC#%!p=$ v1۟I?n;kn \z9iX]!x89ɐddKIUTW,nYz*7O[;pԥ4<9ξ.HK4x(n/ 2Il(m ]^qB0aNAR_s YOR'+G+tmCOrV[sS;&TḰ;Aʵ B$70V4Ѱt91+@ʁEe1X!=|h$Xw%Č'10+SIZUM+=W/qi|Mi''@Ti\|E(w}CךJn,{oUսzu*Y2v%`{ %Ԥ*fS57" ؈FN[`3ى)3[E! B[YU< _zC ya*#,`Z0;iKJ]wD+^[oQgٷ?kKΕ6:'t9 ,<*y$K|^l|ܤwA._g9W uH\ߦk*TbzppC0{u-?e^W/ḿ\j);wReCn뭽͈EʨB#0fr5Hw[U@}opp0*.g=+L8ks~f)m!]gWZ$UbIc+(:N$ϰrScj8A9[%<9̺KiG;tBG1у%?o1:T Sq O6eޙ \[ [b[ȵ(|d I-IzCuc.AF_KUϯ =h(eʒ%.bnK1uw?_g† P"zr ]s`LO65ZOGIU8{NIn' Ka'OhP[ Lss|rv&- 4: .*mQ"E||֊_PpS:Li%i_lLQ%x\78Љ;KMvg{W'M=6եL+ou( 7"ՕW#/sEXҁTV?WژmdcDI8.7'ȖPǡM+ibmuv:㘢LU X\A޲f$״ iH~~^]gC{54R/A=#^DPAP 6ߜ@lgȢ^r."HTx$f%{؋+YLk­{Y׫\B $.}2/;Θ ކ.C1w}.voޠ'W0lBO>A6 o&JBB]C=,d4n|)U Ue02D_A*FC~m ,n{+>nPqINc wS"5 ܓ2qd"ia3X:0`yʲ0GNL@??HlYו.*h?^ܰڔdbفUX>;.~e *vNڳ@<+rڷvæeD,ޑ*vݫPHIaTD{in>!Vax(@At(*Ko ne7%G39""i77ѝ,P]&r:;>JKj(Kv[ ͈z! ƧargHI1:zU,ƁN_i+ UCmf/N.'i@ 1Hn2֗Zs+w큄&~H_xƇ.rTcx0DÛT 1--CRu/c*f _<cQvg9]J+5&3rXB$)Ypkq K\+/Ҍ|[4Cg|=`W1>y[?+qeEn -=ou;XNeD dDZn!%8#M|Q"<팋ntO2U" hJP][ؕHsdկ,-2NHM79.>!ݭ go]O+ho08Ic%gˌ<" {硊B'" -0H' 2dY -NO"${n$\R w-l avJTYyP:!M}Ѽ[P&yS^ @S"cW8ޘٙ}΋uWNKI3ծ2 f?eS٨iw>3kinvupcJ_#YOo2#Jd =uiA: *)${ u[_]{?"q<ݻZߥXeivG}3u+Qc')l%y#6(j%fG=8Q1̠JdbM~L?F1D6p"="h#E1vo>Y-xdiC=GBGߪҭ*k]|9`zPQv_7= \zױhth*nۗRA5 Y)s~70keeZκ+gsg8Yy-#~=[C!Y! m%Y Vڹt[2I:h#K+X 0 bpbJ٣cD5ʈ+r:1ԌE 'yrތpLK0Ƈ|8%R.q.jh5?@ox >f oܮ7VGQa,9׶ JWʪ'+]ݔɎ1)*fTGSe\~p z{:b|~OL4Utř41s&Q=LH8kdDAV-p[WطYfIRvyJ 'GF~Kbiđvd[U{ᆍ~nW+Sy#-] ^)}2'BSOUbuyx{q$F4W4(4TL< &5UvJ ȑKTäZn3$q+}zEWp-1 3 Fܫw x;&5lUS>EcZRa.$DB9ky$d6j]}2xBϳ bءu;HǏ:]C-n%zHݞtLI:T<".Pk"&iT_9`~?i-Ո1\icGxN6މsCgS iy0?Rcy-|$0½Kid[T>4Ze5uj: 4FP<O)RO \_~Z[![=*[=bˋ\;r;Dp@lL,` !Oy/4to٭L,c# 0 ]7boYn~S5g@Nf2*sWzXg7-#IcrqW­-4d*\)CTn#>9vJvz&Eۮ*4I.ARw*+`w%9t 5~DX*.?C07)t7->is-vdfc$@Gs?.#ZkOL jBfTjͨs  RѩKէQ6>t,s)"P_[&Auգb?nV.78 g[Gdc}h*䙶8L(4RN#= v`z/3VCpwdUK8lw<_aVt\NP yIg'[ ZVD'b)(~ʒv=i&zpUw DYx'Eo]m]$HPBʻ'PA,mn&Z é,$ű.5(嵃nlt~k^XLvn};K)tzmٛ9O&ٸ#IkQ;P"s.!,㋯B!1Q$s&ٴV[QE9Ǵq$>\* Xi;nlgs*Ltn&5Dq~hfO Sx;NY-.S8| ¢6/}J"SV_8;ٻނ[L;89x(Y'q! ? OPzx}_ Kve2M+3 9ђ?tFP ݙN5a'4ZDŵUGPh]XUea~'x:3@Ɂ1>BLĘRb@yRMNRSUJ⑔vŅdbK"_ǎ"dMvR͛JůUZ.|8[1w0@CXwc{G'&ѝ#ҜH2co8=߆EߏA 51OB]aO8vn63&U4U{i_Ƨ}C5FwԐʢNxKUg,.SqLKGO K2CQ歑p~'F7cRO sȿ8T81  ER(f(B)eX6" 2h{hT~U (bM&{+ P>UORI [yL3ǧHm+Mz2֤| -OD{44S_|ְ't$2]oG3hǕ{Ų;:˛=Bse"Kn^ZͬQ]' T TeN5I9j 5k IB]Ma#L|[l*\%b ZMŸ SE7-G>c!쎢-`ӰW''Y\rc4&h)*0~Ⱥ6dJm<.81)5nH#?x6CwNOJZn}K)uqV:Rp)E:b EgO"<9 O͡c8*l\*cfCSL2eʜK G1 ;|?A!}77e/MDcdS4YEz_V?)S9Q9}|rBied Qm7K/>qnҚ.ijz"+bySh#.Rk ¨éx% 2*I!UDߥǬ91Wp}ND_eP (us|j/&QxIM5<ĝd9:i?s /c4,!}ҏoW̺zPKy)p&$f1[X_^LȏUם7j7W{hۋ} X9Yk(N @\"MΗI͋쏛1t(KzNa9yyfF uM)em- JJm'U{ӓS=d9pgb^>h{'uni x`ρӍU4]qhNKU&h(gҐoǘggc8笓˳P eklAC o6nʉk: E q|Z΁E/$\jVuR@iAwLj;pxW"P/wA)?9xB%m2-pwNx%CMڥOރ Y2w~V_ yrlxyȚJʿq{KTazuTY#DGD"Lմ46ַzi%B%X@$(!u7+G7Z!4cy$*2jlѬPӿ'yω$<DŽ->[zZDOtn E%mZghCc]sގ\5M⍻:D"@ U4B/):'T[x,3jJgSBݨz0R0t-2y{fEiiF|!xcRv66L7Ri+e #3/ . ϒAnx0oz =Tt {u|rrMK7[ݞՄKvB4-Hb$e)ŖTʄUӘfE\S6 0|Sxs۾'D<]yl* z#Syi`,. Hh=L `-5\(@]Zc.F # ?MU,߷+2_9~`._I&Fڈ.0u?hD~]śDtxtvgp{'@N "2Z`Ԟ97p VSs1 ,w  ԩҷ ~q0`VOD_P q,wlp%)pgZޫKReGCgsjE\b'AKV))gO#hqS GqGF:R"O y"Y]26X$ gumLds6 !fl?ed_H8&]NcDWo#bDwKk} *Fmh&TժH&-ŷ%ouULjjRlRv κOi}5a ඃfȍ/;U ;9PsKqKߑf:Cz擐;!b>7O_J3 ɦF[6\,U'b-q?.aWm70;-[ȏ+|W,5>UK Uo'X5nOAUgݟ)y>G]Q G2)ՂB@]JĶqX+H ?"{".> -4j s>ѷ|-%'6eMvpO|y!2I'T*yv #<'"#o9!G+ ඏ[s`R.8ƐJuXoP{vu};Xʓ]Q״ L,6G!ԽuJs.{ nP/|kBiv6 ~$&a z#,];YTaAeG,tP"K)`盪! ZnPLy\ke r`92s R+jIL ;% \ڒ.$?A(b1]mXue:\f6Sa d*J% mX.%҆NQ;LjP_U[*G!).`#+ TLb7"̬yJEܩ&+:HI#rq2SM*d44ҁ!7 ~ݮR%n8|\,F<\AL9h#B$m&`bam龌Y5:gU$.RT!`uZh~<;\bhe(_B?X+7]ORF]gНG HL̍BRZ}D S}!DXH`8~hؾ86ZZͪG[#W)\A{xlAP\2]$7}os~ݨR@hNޚ q< msqGI//+zE֠[~gUNIlH4]4XVK垈&V$ ظwO~ݐnU#>$.k >dz#a"-4۽_cLh,vLey~{]K *3Zr:Vc!Ɔ-'\DMk @fߧsZu^ٱ6k?F׿U=\:?s;'GuX I:7Y9@#E\}Toox{.0X25p,/; nFJƀRߏ'Dzwyџ85)ɧU4w(=%]j4 ~Er}_olL{7T _MnoNq]3\ H0 3/v6{5S:bl _AZ,W\ RS `A>nTE#6?S P0xaj3x0,'CJ@TpUpjkiCOp| sk5_8oD0rCdڢa `Rifߛd4vEÜ{]ZW(EA<9o-C!Ԇ'B(bu.Mfӆ>5ĮQ8*=*H=Hka=5AGlC0wj\ Cpl@dt$4ǽ!{Q1F GrS)Oc<^&w_YO\ts  }#sXLgPD/Be 5S-̋q/VǢ%c+>G-՘oK?6]kVCeKa\+0 h;`+^ !0$OBy*[MbzUTVg"=gJ{$N-f6VQgxCcMl` P﴿m7HD?#$`9FN9 y)k |hR[2pGX˹âBdy%ċs/x&勌4kN eƝ ]_{R#:U6KK-Kb^RoT"c5RUagh;Vo $ XTb7$9'mP c"K3*qagbqt@gҿ4붾_ΘZ5 /Yː;= n8>Tx&B_]t特(SNÈu}IQ>e[U刽c7:"\Ȯ5{=Sk7&m(M \(Ỡ|&!,Pr|l62|' 9ED.L0mz( +ͦN=a2E=ӏGuɏŘwdib3ko'QiLTWNvTi]C7@^\bLHc-4bô͞/y<4;4\BUS[&ۑɅpS8p=@ 2@rI =V-(ӄ551hr"phz>*_vR ogJ+}Ƚp?]+ei1m"rz=\^iOdj.BlW d*);3Y a m+"H^/@VhzݰbR7Y$uIrPP9&|FUZ"P/./R@<3.J(%h)V'!{7by'Y X9g0%`:2%0>%b=k~vG0cs=%ތL.#$oIk#՞o@/0@wFRza%OF+Hٳi1@L5HM oX(SőP$Uqۗ(PN8OU (bUjZ]F38 UoCBw>O2a{l&~ݜUANwꏫy|FD#J)Zˠlru|ʚ/BԩeF@*,͓NZ$5y"s v ~ pt0!]ͮ-qÁOma>4bfs.~U'kd+M%w{bzl8eCM ,3(*9/YgUmIn Y´=T2 ϏL"!7󲒞UT/IA#xtO7 0<Wt@W4ϷQv'Y vfN&o)؄%~qF1xLjKHMmZo m#oH.]|.器tP# <7NOVl>w&~#:վ('qFnw w=d>4/ "A4qْz}1o>~AtzF7+Եs~x=|}&a F(mQy/yuy3!5Mէ?=\@ڄZRF,Fif|Vj eH6 <uyב~F5xv^#<FЌflX^"Lz>x Cp 䰨19.:pE % P)!y<Ioa. pE[F7eBl)/#I ߛ2tÙ7"3<94Ԗ<˩/%L:ʆfLeZ?IrooΠ}/>"w U9wxwn*M9. P6gC i @iT!NB/ Ո34!of)i^O³t/XOBbm¤˱3;yh>`+x7ݬVG/VpyĒtT5T`+N{N]b+sp+ɸ*<|6YQk~)tr@Z:Ħ̎ mgyPaJ' ~ڱZl 38U圷9vDhBhV=&:7o?b&լH0eZ Y4cAنK2sAee+@`;h臡6g_kk# l.3"% 3 Sb&ƠwIHBp=];,AThy[U`kb _,e4˂Is'FʬJg5藛&ʥG2{@9nW7p&ղޱfb$E1#rP-+`dh`afņ+gCj %9]x\$/ njcDY͆M{|}̄r㳌@kQ+ߣwk4^Xfd;Q8hPAg&#L­nʟRO6]05>?kQ}1@7 q%2NGI_At\_\R+NR  a "+:̒lvF0`ƪkjb|rH}ƠƪZ#nƤEP MjςYAÌtv: |]zqmTeɐv\'x{RyG߹\xB]ߧu:5ÚcN H&iIP;:Z-(8lEʏ ~?>pb^u#8GHJ]61iB0.i/Y,B@nϽeyHC|p<N]Gʜ:^Um(׵0mu-=G+Rne&u1ДQ\9x ?Bgp~f㫳p*LZuHMg3O{ z2␗@G*^sL測Å ܍l1Xc;{K@@&kX?mvN%MnG Dgm'uc-Z}xSJ8+gkNQPv5%9ij|Z7+EfMM:)8c_P 7 3%om[ |$mLB%5x9&2[ڨO7S)@؎-}|xs\[UM*@,ߢ|9mFq\Q]#k6BlRn6׳?j1d0BԦazG p3ɡS9wpDiM,AؼZdbz:kx̺]K1V]M|49{ xnAS[𶿴n&r#qjbn5d9]oN4͞p Dyȇv :htH0T=|I#N2(GzwI?]2%<1 ۔k/O@ dgr-/ NbN6?%E,P1Lػ6)~4 r\_OC>l%yk//03*nK9K]H4]2C+0Ȑs%"LBdR=t8st&רF`SYfgvf6)llhCc9Bv2iWyfEON h={ݤ4l؋9nNv'G d-m+ L"/Jq^2#*poG$Px^ CHewTK3&G &k^^{nzMJkz?5h"=LN%<2#ݯتpHԋbЕ40I* }&")Lh5`N}1f4Ctz%}*t? xS E qsmMԫl܁jp%Zo=L9X4F  ~a FKOtE!܌L$KqW@&dዱcF<[ Z@1OYK |*QH}rmxPDu`fR:ݘrST F,s"F}mAQ㳯Cp`3SƦ=lEǐXBuKI#a)@ C;4D(J `Q[wJօ{Hi|жsY8Gy5\yĿpS,g1ǗO; ?>HZUpNxXA{u?&|]fv;\r$bY& ~7zm=U \,WVS jw"ǓU؞%2Xװ271!#ZNt+sԇʘ_K0/H̢I2;ղy `lf'@9I$ >}`ϓ1Bk@2l["zd1W%s5QO`qv;gL:70 g'(Eњ}F7a!!9t|z'&/ 7# im-na6ED`Wd I%Vq8?h]ÀKGj^Zfz&sk.1Te΀kOx H}\[̇p-.X0q+I_wwz(ͅ-hRYY@}^W`٬ o#mR'*y׈űuA <2Xl'3i.,_Šs^w]&?dX-D>'ǕiD[CNs6y27^= -q%=)z0i_ܦG/RTނ''o%& %ݺn/ybP[0rt>s;NBq&wM?R+ͩ_V-Sĝ,Oyّ F<#u| ]%η+_\ \ 8G`VxhZiٔq@C h5%Z +0?}{TInPjPS2PɒD\gʭwi c͒^{1{?]qɁ_&SL\Gh6>Љt`8UG()>ǮpC!hSʊ@ ]w st17-lm$vkhߎZD-N/ZVëͰ>LmzbTjSw4Ѹ"qY~ܝLޗaT}ŦSǮW4,CKh4\=$6{k" mɭG!: %Os?*id̸wW2٣}v aYWQQNx[JX>j"XYKCjIIΦULf>zZȽ ygz8%VwzzOPZz JK-&cMc= r :ťV UsCs紧m38Q2UʙSm1W@CAU^㉠!`5*"W vB[Ng~~sjTFơ(Xר)><)yDCsST;@=#juB<3kb.统Jni0L9rF=wfಀMāVƚ*\>9c]V }-{ҿ=䚩SdmMj-)?{H]MaђjFh7)Gh3V7J/{ߌ%IAy*wߑW_&`D8u &詒è_57AyK'%JL\ȴ:$ :c lqh<¯x&J̈́ V+𻙅*犃ӿ"݈)l!&XV)<`@F]HeNZn$6˺c4MϤ>+Q-Ҷ_P<ݎCɢ+?q[O TŜf 9+ \x澈yn.ƅGO'ƭ(iiaJH[fZ>v(z=2$%RPSٳ,e%_-t5Tհ OI)jUCY/jh+^\l;e6%1ц0*͜7WyB{6`0|d^EMaeӍ/ =7MV0#p<<[\CymŔ6 w]9u YhTK(%f\Kpj_m(vKOkTax^ ˌK֥Z"[([tE_*?k(+7p 6Y4cbxNB.JjpA4f16țLт,7 MpC J8 |*oYAOnVo&x̨P5Uӡq c~}o:J]ӊS7X2B&+2' QX?fVD5r׃}8FAN0Ou#^msH "cFŚ-czH8+X4tz&=jMZVE r-#/Hg.a@MITXɗn?SռƁ! a]o)eya?XYIaG7?Ŗ&XŬX4z\g1yͦZ7Ma 3L]IM#M6i7#g̮STD:$Wtp{&6-rSl7s%CoP<~.mNAR)eXh kZ|"65%}=ZQɅ(ag$I_>)TI_' Gjd`P ^ f[&~KUhfd8Pf\Jq](wGb  $3, K6Df@mŎO_Q̞@='Emѽĩ2V&,{UhI'.M,WQt~'GgHNR* Eu[=J"9Z*RkݟwO?(߀ Xl,FS}9r&~b{k2|&f+DF9 ͋P)ra' q;˽B : Z&Ex:ogQ `m( Nj_+(-d:]kq U#m-RЉg0 wºs~FZc俸yxN֐Nі+[pDJ}78[&+6%<݋{)t\i+֤OzӉ)9_ƹkcWXSZUzO;3`,0p-RC""$ u7h9aC4`?֒Hw:Id[6с4v"\pVUMzWtSVx"ޔRd2Y 7brlt,C?!Te.G^c ޚ{z>nnf7re #<Tk4NW)w wEBu iZk3~E;TQS+i%}FMe{Ub@PoNf#> [{<[2f R" >uxP~ͣLQɻ.JQQ`C}3CWݽ肅WΧ>b:aNC/~xPm-8E &6\P(KC/+}e+&k)wFnw&Jw"$w=Qi\\c"Qew]yMC2_]\'(aT ?J.wƾكme6+yv*dӜ-){}NE"LNٱȥ hV.o(c:ujvMz8֠':굉뚞xZ\*A?}͈h淿l )D5%Ɉ ÁX:Il]ؠPlaSA<ǭJS%:|89QM)꩔ug@Xd2Pbp7фʹ9vxWjp}C%aD8)y:P#n<+;5#h61 >(mՖ1Q9֔{[957]X/AG5'C:<<Y\COX 5֨nW(|I[ߛВ\4zaDBpi:l 8!ٵ+sͨ(Nbnܑ65_x1N|m~|~T/ 01AF5RM R| ǖ]DuID|4F+7~ʫ[HY٭Xg 켎+8W\r^=i$iĔYr8,E]_Xt! sZoeۖ9xD@ a_ѥ8A>T[a; -L,& !CuqQaV>i !cvekc.PT3?S}IXΗό)dIPb ffn=G08J췓쨇rE湠êX0s0fo,>TV.o@ 9M)H{#VW|^iuoRI郥Luve-;n#4O(~,mMWb q#Ek3cDyxy\3B1PLiR9Mֽ |AH@dsrK|}MK\cx&*!PcD…;ARDfY?múW ?NuXAJPCF^|.W¢39ժ'8?맅פ1dksfl_R!y؆ u m7 h%gLaKMm1FZ_u r^M|wR ;_fuGQ\~bVWKD$ũ͍UcM{9g); 7MGs0)nVDD7_j 9𧱤0zy%D?x9Rn]|OMsF)mߖ /nt E-6"g<'SA=ϨE A\IH$oz3{ݹ)]9'{Y; >#ޅԈƯ8NKC)Fɴ@=Ya/=e_ Ȩ#i8L>xmRĪܮ X 1"l_=6Vܠ6Sir\}ki5,h:#VW3|v`*#רm=ͬ#9U⺝>Oi4lՌR'.1ߎ3TOZ QB kwhyqK$CoZ D 0je)7_.G/-9OpҮ.gضwx>(3{A0c5mp _ dr ٪q-{< >$"ƽ[i`X\*xS\8>&>$Ր\d\&'bYou4#ޖ`f6Zn 2: ~HL 74mw)oΒp>py $)]+5Fd&^/r 4qf٪FKFzQ\5P\_wO dϾ}ckjst}H7sCH ӕ0^}(&`·tڷPaf tZWބ fi:aW1_4,*ˉ>.wf= HU&e@rtuo:NdK!jnlBuY׊ -{|dD,hݲ G-j]UBc~@,_}liD5ᾲv pjd__é :/9r C&TJ>rPԓұI6M+>QJb=z@VZEi>E0+e%+4Ul&3N޻I;%o o6/ggH([r4ьe 'w竆L>6c(($)PCĻͿؤ+7+SğAx{=HZ*xd3wDVThÅ&_ɗc뛴Czd%Z/&75+Tj*%h'+1 3Y<:J:@B~szDK A P!Xphgm-q#vz`R3," 8Њ{$Iؖ $24,ax$3yk" F\K,*.ո\ ̝:D[zPain$z^['k7}gap8CeiAip}uGl?V,w2=ioa*/;lBh[R1#NQɱ ZWcS2eCoRwr»4P%,e%Fp#EJ h܂c:,NlJRn^"RU8%O<)yŃX&g-vbClX\@׌\fR]\.-Y'#=v+ꌺ,~ VDĥK?/λN)ʳ:dz˜ڮ@oξqY3fnvǠ&AaG쪫2۱w9`OԸ㏚G RL"BksªDToe([IVP"wUܣr-6-E[+kn$$Jq.żѦ PUa=KDWJ)2y ]_|.!٥C˹)0E<pA3oz|>{0Xx{lŸYaQp g؈A=~h{Dg`2KXJ4= 2N}q˯Rt /06QW}H`O)HI#dQXMCG|}TęU~gcH\QN$$ŤalHIq>#\u;@;ˬyLsqj;(\{Z#IAx2 HFt>TV˄3>{HBj:Tٽe^ȃ~%eas NǑi#61t^cVbS]CHwZ4ckzl|5!}8"y&ŇcpκqcT,wCY1Hli2&>;@xidڍIgOjc<@XKn]4y HװXq cqڡAQfkX*d~'vݩR^I@f$ &@go\/ zaA!ޙlO!MhkuxH CO_ҁbt,g]cWr3$L- T9 IGνC Po&`O I]]R`/0!0D;6%ȆKsy~(hVZSPfSǞ?$@Y q"JwU{Xup`~چ+.{P@BMlR"+L B'&eT^{~>L6۽n VKQDD-M(d6bGlCF6D7}璢XLv'h"XJ`b63|0 D (ڨI/tz M@c'l`V7<9QU5l ߐڑ0G )E?乽]bm!Yr437h?C$pky ɦb,r&< .eMgN  eeX=O$⁛[Cj{!h8unbJ}CN.=N9_dr2x~ }$;2d?eR @5L,+9&=Z'/WG !`wpL#U JhVwH~GO4~ʆl׳E^Ʌ8(<,EHS100&E9@ GVzh[sBbJМ&e;;.T^/f*.c%t'Qz&LhYzähaܥ|Me.# shPE5sLoM۳,(.ԾF9H=Ψ,ũvA =+”j@iCLè|?witjVA*ܒLqU, |<d~}|I\MVy,4ugҜ.*GS!.tZEǴ?YTrWq^=ABbZ8-Nh摮ɬ+IMh'VIe]+?lgu}"N䖑R3Z\sqW 2\O }%OR+i[Cn20iU6sLK6S8n% ?<^JK&xe_rK9(6m3&-RFٽHo'R_X /-BݟB XgCn]UR8v>#ޒ, )Q|N,X4I*Sj$b*L) X׷2{3g#Y $0ز 7nl%Eӯ``)x%I,BS5dȩ?QqVJ?zc$5)J:eҪBS:is.G3qe'pXR_3B7 pgj;fݤ pڅW3Z3[`F)| Y"Bg"?q3A@[ Q ;ӬmqIz,smƄrMo5˲̔[ aM% /ӦAyVKbD7s[ 1_۱U )!a>cz 0rS[v v=`>pchHl HrwKiN(|%+ +B Tf0g󭤟.sҫscoL7X'9,pɝk IE2c 5-axwi6ӗۻyЇ"59mGw1i^lrوtY٤^tg'V;x4ylvۿ!dj}Ixj S-_&@# ư"i~` #Z^Nb#Fgr5 *U3_,}.hY4 M8tDYI8M>}h]ÛEuS@SW ]@quNduj4oQ? ?mm̃| K?3XvE`൙zdulݭ.֙)]öJX٩S%XA\c˦pb 9|nlހ^uZ3>Afpr7DͺrUKȗd0g CN#WyY"_zM|_(6 9wdVEL".*|xN8_a^"柒=xx p' >3Y'g=iP='X]u]#X٪=_yoٶi (yIiPw]dG~9GWV3ٽtPkb:=QüR~4QRcja>i7m涃gzfxKRNY+% aEi'Z܁0g,yj/~s3|Woܐ-mi\w7ːP4ft,1sWDBz k@Z̒ ;^G8-r>;@lhGܬ|t P|18&[ } fTh ֣/E=oh_@w>w^\W?(FMkX +bB$-3Z >j.9~V՛~h˴HBb%8ٔIŨ\b IB4vz8SF?C:.s,^]ZYijanS=㌦iMC >OrUi gzRz Ҵ}nsK&RaRmPzY618U@ V{$\8\jXWT5*% 6mll cT!5!UX].XR$!G=i!4܃/T&e$I҆>ɧy# y7W~ӟP2g.|4NS^pBC;䂖 Xl{ETR5cwej3z;8V:7+N3˵<98R`&>O^OmQ)fW'*ikAX-ʘZνzאdT[PQSAn!yq݂C 휇,ivc aF9&GiJ"e y%c _ywwe`ё< [-T"fs#mk<yN;:ًJ:̋p1Fx XMyt>L0F}=s`桑l,&IV WM2dKfOr*vvk '-;)+#m}[ mLs:Amn&% ymH}84 I x}:8;@eND9ͮ;Dc`@4C+<= BZYQ^l,k/WD(xaro6EU3oo펠`0<<PVycdghP_Rb۵ s#1;dΝhDV?L=k6 "p(nQ&cJ *u%.=3D ~Ltcmsr7ɛe14UP5NCu5JѰ]THC[@}]H?9"kYoM6cH}PѪv]mp EqC= RSd@Kwʋ`~+! IN͐ngg˭/D,y :m`Lb1)ֳn><Ȅ1^',͛ǂ}Bs&Yy$\9M Ψ1ߌ RkRaE!}tXi܋vq 8zXWxՕv-(ҍ!jIZh,8DF\zf ?1ƀ#y8틷 d]bdǝ.Fi>^)CHԛߠuÒTzV3^^]k/;2q|y6X5C{[#llZ))c, /Du܀E&SK,t׀^5^y>e^ a*V^'H|pxԵg{o:3H&jVݾ n`*:!W B3RVV5P(UޘJ"YO̱12rq;{:7.xWMFyWh9ßAAV;URօ/ ،sJ{2u?q N4},1zdu vg<4L Vkz pc%}B@ ݂47Ok$|g;:nJ/ uB!9(_*S zϵX&(b)A$_)L6Ε$r OnSߣ(jd([±oZ7MZs mPR d__f R~>:EgIw OW#WM4oh,&l{=*Y'cŴkyûo Os|i8yVDd6؊+4D4-V)7֣Z)!r2. ^LY|2Vi@!|vn4c!\')^ܒp$hsnPcYxȲĊ:F $hD* J)!׋eKfM3"g]&Kƾdh)tjXC qM=t,9I@4VݠɃaX2BAZze>ݍTs2, XǀB=đa:$PO '4N˄ic!^"4ˌkkNbU8$# ihlSqgFT4ڸ_jstQJSU9 }yl d8Ϥ'`[؄+%o/3)B.5>D]B#[0-oock ZziⓚtТS`ȋgePY%>Pǰ))pz%OʻMc-zme`#'"p`!j^ }/4KxT I-2ɆfjG9^E3; 4b$bY$l%oLQFoڶ=O~Bj;g=FڮdqS)u,XGǿvCETk m!{m;di՘C%<{gYq}SY^krnR WSۭbtQa};f+*.lzOl28zf/ 9$`3>q#íSZ6 60gH`FZPRXڪ́vƅ#_3rdWpE=cgя X0 A<]~тܞez^jYe[O 4Ԭ_oPdQV1]9/No eTqpO9 fҵ\}ZO܀9AHEO*w~x!]:G''+]2E";4v:It"-H 5RG4P vt~$b} ZJ'6]fFD1^_ <Y߰,F]az==u%GB?1\}ʝv`)8(O ^lߙᝯLe!lV0I |Y\'tff`UY_p(Q0jᯩa|X#1nYiIO'YBtPnb3qy:^Aݐ<f]F:sVqEqfg O{эro4i%uOk3,bW 긬a)gBsS7Ȕc\Dpo[W+.t]bVϜf+Sp-KBSz8~rp~-G ~c ,@<(< ?C;,&_O'Zm izFՂ=O|Ko2DUo d9g rUxɞ2?1"nk5hlS$౜tqYx-l" G%o1KsHy@7.n4|re wHl0L蝴V.̭J o]m)a\ڈUD$d%7)G,()Eg&ԝEqxF S&N{q'A◥jKKC~b$1Bt‚]7txN{£9o.F_M# Kd4V+D9ggvA's,'\NnB;;WjGD, ah%^$iiԂ(ϚX3xVƫAXN [Cwzs釸_LP%CBʝ;%5۴3 HO73 4tTΚÅJfW z.೩JXS/-G n5eK"xw𢢅O: V=+W=*'}nČ5iR] :[W,Rs3Nۈw1ks($/DσeiH:OiΥhMEʽף=6j/Z]2a\Fi[4I',bXl(ۚ:]ę+uYy2 +j8L#n:Fʽg$",&smcgz  [ )Qs9ZF˼kX(a5(_̭ѦwlÅVγ `aCNH])ҥT.}0}BQf$3-^:?[{!]y" EׅP3ȗ] 3> GwGY@l:(YDQldֳNs"[H(r]9 ¯a_x$ǏK&02s24vZ;*]s_';WHG2NŸߺKw>.N|= lV4;bdT%TX~D,<`mu8/fLzЫgd8 8\[}Y%o?{hAۉjr 7#rȵlMXR$ O!KhO =Bf1ḒhKܓs<.C֨gjC Z3ϯvjinj`ڞ3MQ밝f!IĠ7Cß2ǡ"c 5Me{݉s:p~-pҿ&UH JS%<Іei4{xM*JҏYA&JOj4l>>c4s`8 A@P.MSgekXGjy@N P 4J$7Ԫ5"V'#AETZvSj6B=CrQƽloElHnp,$kĕ-eQe 92e\lTCí!^ߛin&aFǹ !r,c@7IyR^\DVva KZUX;*8f8im?rXݱ3Na\mBDbFz aXq?LڇJͳ uMz t&!{u# n-;;yo;ȕI X5kۻ)^D R/ܸD.G悷@Q| 5p|BJ˝ ^XDykh?L\2D{1[O=cےq{7POzpy 7Z7,KUoJ"mH22ٮ?@]?]Rm8l'^'e}5Uzje-YK7z#+ a`* ݲab]#* Ƽrfoa5&|HCʨ#W?,PG})CO=p|Lo:e"o2w{F&u4u/]#'Wő {um&ܮP!(TBJ"+k42\dwx S M? DꢡaG00`z ǃP;̗5RBKpg p{ )-5 (s_vץ6Z=~/}|[km<~&?_7D;AVj;V@Rʐ`o@@.GA6 q 1{onҩ;忯sN Ych}SHWr;l5'$I-Z;v(?r/1/߯M^7[ta2;~Ɣ͕6|%Bi4+\2-˽CXw3{Tb'S|Tjp'Upx5&#X;a:!w-,v*mܑKJ\vwhzW@JgDb~#t6=qgŮ>`ەJ9ol'bEUk`2Rp507hq gH=eǶEKSbr}mLGIjc-3&A6> 3\3_v/v)^D$3 ^swH * ݆Zt!ˤknV f?sZ؎U.)\@;4=ڑa -9+Cp"YY#a8x/,I7= 9S%ֶyxuWX>k5{A~Y)C^"o6UN尲œP U߇7zYv{Vxl7NJCH?]2?fDQu. ta64'?o0;sPTO e}P]Ă704QC@= 6Ψ$ݓZܞ #T!N_JC(o. HUzwvwVn>6IeT.|#%նj@y;N;6Qd0c.Fl¡Wej.O-*8,+̳ax$uNf"%U;t~`IJ%+wDJ]Ƴ#v*\`^Uq)6sZ/^7Ђ'[M *xџ6KBv2z iڳ[0TԕKD&zVMT9b k9'Ne$X[8l|a7͆AeOM\١(g<N69pS^ړ2A5$)LDlQzS sv"z7 1k`LbըhHKugXvnR羹XPIqAj5Ms q_<5׮9w{3G+膱6*g᤟N.5niyMà2H`4_ OO{2 3`sq/lb%K#VRPՎW?kȱk(Usнa28̺ku+!:[J RWR]h=|`(3G-Mq^i3?o!V/'fwz]B~Jl2LTX?ֿBj}C+]0TGs˜yᯟj i]-B9#cg 榉X_rCXաȥnLEڊ/-! 古w>;8$P]/(C\=/f/̜X>;цMl\-t,F#{qq ܲ@s|&c^^2!@&4U?Q;>`$̱[&"+V19+^N_)KTa>@|]|騟<^d[߻ͼ $5K`f D;$LXӶ{cqh |i0bc `"i$+44YQ%[?u ԥJw nulNnv%C̜bVn8xtMv^˦Oo;j#hide) )4>@F~\ch e#yY^}shn/n'Y<ݗ3WmAN_Ap=`٤KܷJjEeZP,Nu,4); W#y JJ=;r  87^Ό\vy"'Kl~W곈RϦB`>?+ UY)RQEqHz?\)$5p`e]{I5̓q-MM\tqH1BV,A{hmʅJ(RĨ̈Y ,wh&<>hEq'qâ $_IQl @>Ñ0b N mtGώ 1s,6{g"-CQY-H’~kF&Btߢa)u%CYK@{ ?\/هL#"B+c*st!w%c`Iy{&;Dpwp+Z2Ps:&/-J6$zetw㯈^jʣx [ qu^w02$ވL[`R j>Kgb# 9.A^`W2g@S7vQ6(\ Dyu@hYc{v-^q*SĢ/bmX$ ;N[ d/Xq8d)rD++M{2TB}ݿ_pgG loעbyBןr1 'j!7/ .Ɉ V5vvzn &E> v?x UCꑯ~\yvAG}8(sc`-g["vӱSCᆿ#ꯒo雨V _[ }&6TG?:_LU"ArG56E`'s/y-0 \ܢe(>F>PM6@OsU*^Qc K3nۇCS.ܺ:6UzD6Ks<#44?vA @[5 F*2t}BT#6M )Kܛk?rPH@>UV$Y/g.Տ.N}LV2lvwї C y<[٠=dmVK{=8?IQ;,IGg+zVq~)5êW˜;v<FBC5e§oԴmW>8 HWguk0!z^a 2GBþxCLU\^oV UЊ_%kɹ5BS踑H/ڳIn6VV :~A7v^SIcd_q؋k@dO! HGus);S ,^Dj"R{MJ`& |n>X5#% 5sGJ OOm| {i\R([ErrƂJxFrJn=)X痷*;8Ux&hc1I I!XԄ5_ ny{cN2*"5$k8v/[WU<^˨o%f!@ˉT{75%es@};F" ͥ*l=A ‘U);T'<>6++9m(H5؎KB.Q>Hu@,- MY}_]|U+@;KU0hoIЅKܖPCW&f*@;tZqcyy2.&?u5~E'M>MuG$Q%3;Tl׺zlڬBZp +8X)S3Kȏ:L ^ׄe^l?^wn`4 k*˨,` cNxV4xXD<]YxJ$ "{{ Y>RgŊCZG; q~BIf 6OЬ0: Q𛤦H3-S&Ɖ?6By30g&16݋[,pʯbYEk%CqrWbT t-S E`h08dw07M3Zߊ?go/zdk,2>‰b5(; 䢏^:W]*iv¿OXB<-񥏬Gj7wfpg2`B Op"x Ty &TMo ͯS> <7EL w_(+N`tϗ̬?KRk&JI,KV%XRŊ5YWT @joĈ t FkZdAfGl3"Z3X٫rk#(GPKnXt6eŨ 65`8ەPBZj͒H=q?g|ܣ4FЕ2n/zox)pٺ$pVY$)befrR3Sݻ{+&H㴄Hg3YA -kyc% ࣾcYܾG!E .\)Yܚ~;++K}R|t~X5Ri @bi]Ab,{f|P=ϞcJ})pѻ|{DQ29Ԫ= PhMNݑzl& 85bO{K-)V^t KY$!Yzf^~O }_D]w-zS hVBo֘p#LBXڹhA4TxS 9󆀒E5]s؜Ry~8P ^JbQ`?]^b@Jg!LeBnõgnRYko]lGk;*tFYk=g4M~UY Z؁ԗ ]$g1Rmfm0@wIA*:h,\, LfK= TfďdhK{en&țಀ\6 T%<Ĺ2igCc@)>ԵT?s|0ٔߚl1.W.C~H6Z|Useܮ+ڲOlƞ\?Z|eQwWCY'L.*BW-U`='҅*c-CMWbO+Ljtz'!}!BqFV4\VAW(tC(Դ 4 [qWDs==OBkR}?a+lRQP4!kB<qHSj0GZ6 uѝJ1z%`B6/Zl-%\3fhTcdN8snagΐxhL=5:Ȯ Y䊜u(}/&{nx\XD$p;]Cs oYc1?9MӞTԐEoADѯv{X\rEVh(3V bh8&1lI/pnѾ4TE ~;ѵM̋/OC%mV0ߤ]VjPϡ^`"߹)Gɋt7 Y!ܓ* 1[h<g^)Kp z[ Pg,T>;&/uC A0B3 {ͳA 9oqgui'Qǿ)h s9uTjh>a'%kRH!˪;y W 1Ci>&NqL2E}yH j@tB>|ؾ)X40:ykHArɼ7 gYOڂ瀯z^Tid-Z/ٓ6(}Vl\9)jRwPЕ(&pھwaiKVYuzJ Tb8w#VRkSTX9܈#MkȨ8R܂o`{gdSgOc*8nۘ8.uKɍ {#bf%zmC#;W苈yZz'Pk^x yH, d`-G/CM:#Qr b,O7?d13g8t6B};Qyɍhk_=/̬h$'|gPюRM=g@E%/=%4c"B= >"=z kظJQ^ѽ( _>fR{J^Ԩg, HP?Wl]d?J;X̯  P3lv56/3\>w/a\j ΥtI_YcUl- Cڐ|g#Ld_!-bULu3҅s䞿Z@ZSe CmOc)޾PHbRP)z׏}CzLOwl+#jݻ[$YjGxv Hړ{F:G΍+)14GY\w,0Sgeg+fߍQ[ם1}_ܯT : s.">RR/\-"Vwt hF -;gR lw 2tߺ >XESN)\ay52G Agf ,@ LT=cwE4mQa!#S -' AZ+5s'?@S5^㸆s$}2 5zqVu88RGpk(~%(6eF R`g/uM~f11>A|mHG9Ost2xv r| 9TœFx\ryaRug!׎T<탈'\\D.$ywxDojF&ɘzѦ%thW%_n:o2A|"6*ɭ۝ "1zm/V|3e9icN%"+т{3A kIҏd xnhk\=sUq\A!"do`\Zʌiҍ@q[$o \ "fLJEIlmZ@}Әq$t{|r([ NB?Uj~s23XrDxꗂB%TTG wV vSmB|=,c8 >K1Y]w$*iXr! l:EMRoj:@RZyˠl9=SnS^.|:NBSG_ R^76W1c;hŃikm:c:_J7e>c^Y~V'23v/Ha8PTkNPen xZcCA9Qj1ص-5f^(ģԏ|+`) q2ǽo/&"-y472Ro& =)]>knި[uq֯P Hk1gu+& I66UԺ Ra;m赯1 " {_N߲TEj~IB {U^Jh?/vfkSؘw =bڝ@ h =\KnPIkE=ca ae0B7+4SEFA ':pw&Ԛr't@P>Mj:zypݝefDdPB__UOO>PQL *5D _D]R tpo A8aVSOS<=ngLM8.YO쏇s P1=ta hUދlB{ !^>s9k&0wgL Ǖ6 .lǿ" fkˉ2S}gv)8K Nn+pѻ$BF_)y'%E=4zeSMt sɉ]oW QׅYկ.' Ld jɁ+ VJ6@(H=K"gh&Md \U@..H]2`AEhI4}М/mnt̆puBgyT @Ȍ2c+2. M 4{?ǽJwɦaB`/>%%BEVr259 #lnvQԗ|S'Sꕻ#)u Y8n>7X=`gzH%3nȰ{!1{)\d>lE6jB *xJ`oc ʨr}XT 4P=r2m <j_ }O9cjگfhǁf8Tu<Rg`/Ko@ѶUU#mxc`OGh#5,祕1lgOX-^ݶ(ya=g&UH&ۿKp! u <6!BN7v] UkPN@l7e?]4Iyf |٭_hCkLSw |![9N֛5hx;+Ϯ2(r`ւ|"5,;[4MIց "RCdX tnui "<{rs.3)խ((6'I(drl0Piy) ;Sm*Jl4tʝWrs4/ME:AV:t2V-p}Gf-7ͿIA$NN1k|BiT 2\el(ZSqAL%@77M29a4\TCA%VͺA "@J֝!sn46fhfR/?{w/ m*!-\f;QEa%'/bv"A_O+_v,9ɐ^/$"sޒF-zUtX_uʳI<]['QW_,[1 K|P)ҧ4'=W'D&̝MFËpro=l6S(jkM>3~mwuvm=0G!Vm ][f˼>Tcc=n2mHBs.b'$KwL\3yKJreB'$k#{wAN#|V 3¬q)`+gbΪ[ Z9L4)K5<2fR2Omq'kbWrsLVԽڴ+7Q{k!߬^ #le^SR){VrV*mAn(2m˅c&6M>eX82:^&Y:.֘,'@Kv-bʍs!MaK;114^kFܬuXM]_GZyo#ȿJC*fɐupdtWJrlΔDE?f]5cb /j@zx?L:M-@3=c?2 AZ=۬~_m5kiٖ[KRKE KƵ@cE:VaiH6(Nn7M`B9f%Ӻ (P$S n>e $buӺ_W_AWu xDKs0Tɳ"=6(~Ods> vS-sJEFu1 Cg|R0o !TꫝNڿ\ZkЀ ONÖۜah 9< kšJz $XRξ$x0L8i(D%o"Y&؜'w ن|aiuw.Q9ߙ\Y#SoP)Rc!TFѸQkѣf?ݻ Hd^iF!(F"-My $- QaMU]L֤M@i<&2U2LN$>$,9툔FJB&j+ !;݇TYŌg Q؆jdfY/ƹ#0ؑ2!* 63;"~7 p#+=ܻ?@ӽOacȪ[M1 ۣ&Y5a!)`cXT wu5Mqa[Hy\PGl5`dk^1"@`17ł@c)&LJFwCn0P2=MHIÏdTr~H1eaQzs 2nZvuߴ@'d T`AvYDxof$yJ/k\4 =jIbt!kZ6g Ф f}$wV3(3$zT/ 0PMNC>╦szGӪÑjG!4a\a4nB,7$ /QX1ǍjuZ>vs4yZk8|$W'0 XR֮i V*Au'YFz/~z*,ܢr55bx`!u8h3F[s?w3YԚ#xy`؋r8jb>6ijI`SYTe9 'xW_B:-ui^ln*D)5Lڼ(ͧK$Sxxpc ^Ck?NVo2l&Nl z 3…kœqa h-pr&w RZx;S+x"ZE历t"O0%iw`!yį:Mm)Sw,dNJ iٍJJ'57 bf}n8+*^鐺#@}dJxTM+na޹ >X^jKO_DŽ3utb#"o=aב-c'P_Oya6x/ a>b5la)TC#Gb,Hbd#5ݬ&LK|ثwl?9e# hk6w"(7(ť:N_) MvT o'e)2C "``|KJv4p-tq?e)"$@qH?H 5jU EBO2}, pCПQ)f?r6Y<$/xm:aȂ7\"#@Aw X" dɛН"c>PvS$A jrye-u*PӖsʷ \&zsƓ tF7U&N,x3Pv*:! /7G (`ȝbG8aF`pk~i%qõ̏@i4fc#Ks)p k5SRC/ II=19!`pb%JKضZX NdMʋc ^ZP bT[ [51R F.QPNgԛk yhY M%׼pGEh("9ja3U _ Ǯ]r}.|j9wV_ }#+Fp(vGV{ {E䀒QdC`W&]{/̢y‚|t8ԀuF̄N)PaO6\]v`'+cE$剧"LP"| mxQ.͐K鼁 Y.% }x#~3tݸ|uʡiBפYBiMIĖE*O! -Vӝn1{ UWSۧ{5?Wŏ0ߦ|cuճ8C0MurMG-QAɜ5yj'فs@ ka7?H"2m[)rB_ݔ?+/T_*3JatA[YQ̔z4СV`Oo4@$q)߮( Ú`Ҵ &7@ B;g$ E4@P7 L8%X҈AL\ 4f.Q<P 7DY/.T)%NkDq@$ۣvq{dfQiثd_c1"cgd'5 TrBkZ#:ay+ѷ$Idno2633M`00T\n#3lTno7w ]j@č[~S nȷo)cmȖ Mu $FYK ޣΠpw!@D)iXPh,e a; ߗ ip&EҨ"y/ʑH*ڲc.RvP-a0]ȁՑkaj?x$%X\kH{ =eU mx>}ugJ, z2ߕ EM$ZCη2j#Q4 CrGUܿWx*f ;^hʀ1 ǯDϨ9pVy=W juDyN0{XTUgDu[,Wܤ`|^ <I%YΓD<Y nWkzvH`] ^0VAQZd+P_ʔv;NPi˗}>9ie[_ʶ 忨\a/p]HR9ܕ-2nSpGf-M^Nμa ?s\ uG*Vd.`]뫀gzVEH;Pi̲wƙ%%Q(jalk LeX@RIƺm"{p/7Mx? <`)NDb jjRo'XeݣAYhy6!Ykss!k[3S0s g9N&WK̖#z:rkLUQRp`Zu0nSnCHP,;Nb5G+3C(rGqSXyY6W l|\,A|IBƙW|plwE}@uKe7sKK hJv8_ ފ7F4(\ 3mOEP7!|ݬܽOX}E YчaU>; k9ދi$uoJ:r*J B٘0*T[t6"k(OTV7a"HB{ވ[H[p[RfkeJ/pњ Swͱ*b`]Ƈw-Ͷ}N:.%(3arqϤʃ[0qGrJdYCwOD'&/#PWpUob݈Ak Jk2]èP&oWL>uf0NmM$`ePGR};=3s\ǬN|_ 8 ɚ:ˤu١&XjnnCBbpžg*PAXʉ?+~TVp lfvw.|a$[JTO>vYQitsԇ)O2.A.n_1\y id.&ҁS[bLFR5.8ŵWCŧH8nHe 5 jmC6jO8/b@Ihx bS.&8C_ C4E:8t3 mi[Cb;IL‚Oi/[b t;C]‚e*1'W[uR+΂Bs2vI]޾:vJعk&"ro |MŷԠjnlr@+r cUl%x1x豹D=Wt%p~oPF]3ĿhmCNK\:eq7OыGX< 1p""\ޣyoB@fǒZƉ{ #]F035C$"26Y`Ϭ!9%!T()cWM+xLœ}k dJY<ˠ I8xG՘t hlwb'Pi>8x} ^q#pg?*`oy+ p/"K_a'6 68z2*' Ɓ%r(=[' |rF87%`YyzD~+r6M <$ur&`3OYlVƊj|^ѿ7\H%ظXnE6o6ևAۙ4J6ZsQ&6}/~qVA'"{;qsxz|E ;L [;!Qt5Z My [ɌvD F0KG墚Lu WG'_c4ϡ=Z] !nKPqP2D :AAW^w\Ct*VeCKvvuЋLӘ-Q;*=Y[C¥qc~Cz8fEܣ -rNb;B4ќl&f!=2 :& \DӸ"W@lOb>\ԍ\ɶw%lhG]GiH8mĘ>S0u_H&(YO% ST.#p@@%˜R ?IUzJ]Q>vR\FP{-.%϶V,3So( hjOvh`W%vor0| &~.0q}̔4/-o\#:S˜}4; XMil]^a]XYƕKdVĒ1jo-([x!uJsMjYeV&dB0/kcSV=S[(&%Me̔D&.3 0jDm˝M7JYS@;[,O.izcMPYanx-ٖ ٣w9SىΘ!-%J؅X9*j;cw_yM9^_TJ #lڦs/s8*BP/A-zzlSݕn_Ac5s!рU~QM|Ms+Q7~UD՚,dDJȕݣ];ժG!+1륚;浂,S &s\uvO(}.=DW^b}1FU PIJ>0 -l,b}jTн)0 jmg3i7M~ˌR$ͩdfHF0NxEE.^.WBscEH0.(.ݗ 2Ỹ2lCl>Y iAyiKw4)= >w\z{qc˕FSjɟ@X3B]S5n7Uvl6ħLuU}ĤR.jp qIB0tގ; \s a}03п<7]X%m4g?'B{rGnTǬlL0> 1|$V7袄F@@L $º^4X5X┡Ѡ[MI v`s[uRڰ!i =_M>Uu;S˯[ŒP e- CL/@9`~ 95a}FOY[\e(ۈHU~GѣL#0𔽯+qe:+Kc:GCDaJ/HMq}l{bp 3SMs^„lal2Y 0/N\f*(ՅI&¹uZr@Q`}2Jĸ',3.D?OQ6~Gf{1xh}p7ǀ(v{Aqi~b+Ms&OW[eAn0s4fK2`po68C|WǎEhpy\;D$ww")B(>"? L4:q 6p֙?pU|hI;Mo:UxQ'Si\s_ڗM7!U٢a/Pv5HP Ƥ3X?.2A"9gX$یi=agDmO͹ "-{Pa1h'6;$Y?L-rn*G@X r,ɟ}cXԌxF&lAkSaZsSpȫ<*4ͣt ,4sċHB +ъ5Ǔ rcth3_36syַvp2 (h{%gj2`'ͳ:^Tr3p>A|soأw-k5:WULp0[KmԜ^q`VJTwmR$a<'ntw K0ZC@~RbċrGV*{ ^l+~$l^[dsO]xppckI'BPŏl)D2#*sE7ΠQ5n4 94I^qU!+3p熗,{U/F#( 嘂)5maod"Qw1Mox$89 Tݭ(BkkVWLdVEsؒ?k%*4}~ ;C:NDԒ4[SDn~e&i;&Ue?>ـH )MP]#_p<9[dsU]Ɉ[֔ _f7 )"1l}KAx6sSQyQQE#NsDzqu=ێ"I A!U=5Pid-I[;8U8pc*=҇]PkIwr>E\ZEy(L4d?]qC$@/3pXw˴ĺhQqsA 5Q gok^\ac2 Y5s'\U\Q{zd8k<AIJ_D ~2!XuEd {Y8ы͟_쓧\<|3*5悔3#%ņKcm1*%ڑTt ' Z HċꄔGOBDo6BE/nϛ.ufQ5v9G<ƁL)͓[wǀ`C+ULļ"cJf'YE#\"zOpvӜ1iu'(5/^-$r<긬#_DdS)rP ר;Zr;vKѽN?BP׍Hڡ7~,I IА>~gKKP C32|4:FR =$N_A Kj6DQ{<[DB jz@E=b)YIQϽ?`[CPj7_ڑD cT:ʦ2HCqj{ '3+^ho{%tG-c2H)qtq!noR>zB`$ƙFDoRY6=+x#s ժpϰ\gg۠Bj+qtAM՗/3MX&\RP7UbS8H6G_!s4h7#͹&bVዾt,G (]ȿڃL:.48wnOG`WnFyBʭ>a@*"mEtKu;i T|o=S8#íy<^4S_M9IVFGR@ Q cw ֠3d؇L'_LG :be!q!5EE/#Cgf6DHN% aWN`D4k૬SGn>SA;D$ƳpV&kJ?LeT8dN*&uɬag5Nz&@q_>8xXh]2ze=ٝ(,~ځq݋"f.p:UWF9+~#`Z(\6Zա%Q_49%J(4SaJUc˩9T1D%%UKvYq}S`&_NY ќNW9ըSf8%mEQ>5S] o`<1*; AvًN5@'d9Z 6$(b׊kaC $w+ГU\8 4%[(F*ϴ4e|\IVR$Q\AP A\9l BMԯZVvvћ_aa׬QS*ɍ6 < ƿYwnѠ 6ͻ\ m~2\YcXUjjjP_rYxLkʋDKY4Oݞ (8X[Rhcp3W#ٙn.Go_S` /yaxNhx_Lf)CCBjzSF*4.Xgl퐙NՈAԹLQɂ֟5# #.LE^xyC氏ʰCJNS a; 7k;R+$A񫽺 l5g[سh@/ 'ʙV%~m,$(LghY#hF ſjF|Ąp`JzJj;!}~7ہ3z4˖fJ/O?Y}njzZKY\E#8"ӈSdׅ!mOc$C1ReUO>O&3WX(;3ŨTV1/n?}S 0(%"]4XdgEeCz$BF#Ytlsy6dMTi;p~@Xݑ80\ߺ?XiaznAtF먊mO1v'|dh2U~r9fI892 CGKug:ި/K'nqVuXvg[4]YM(Us5֠{zoaQeXk=j\VOS$ov"CҤ)Zf( Ezs   hP+B3ynCFU-or r dЊhexkubQ ;kdbY#=eG\kWSGNߞ$ӗda7j;HWw)Z F]P~gc 45R._8G_h÷jj-+ʥ4K"6dWQ Ɛ1Ure>aј3֬(4ߏu$ϠX觙S'?U @UaR{ Б5=. t̆<8jz-&x+1X#*˻~34^Ra܄ `٨`V?!3K0mL+,u~MfN=IZ,E_s{ ;O*߯㽋'?Up $$c+c.E4]~݌\C:Ts^6g2*_˔Np*8LW2lC71[MXqyb >~$PhټH`OXp)"9dv[p=u<{a61}~9xP M~\07Yi@*CV.H- z[IY#kD9цݹ{_HMyR+`2>tXH3re6Ӭ$kB&1:ES@aaܷ+/:얐2*Ss{/  ӳ y_N}HAw8HlCFJthclm R`\.dP$bC$_n4ERnf!blC8' VĈpOPy#{>5bf?䡾wޚ7N1"郳p" c N#F ]RZgG+(jDkkzc +׶(JZ>DAwn+PЮ0~C%T.N/d Vg "gHaޜ0*Hҙ6Μ܋sYk"e1_¹S?|5$ ^"r%L3tTDzR ?e+VG.t+%]493b~i o1H-s[BW`˴iM 44f\ دGBT-hheLHr?0 Dxk3w }sQ=;<`SQ҅;^ \(0x'vAyzfѸy \'ұOlEF50NHKEn+\\Dp)-p>Jv7>%tAħMM\n@߰FыaxUtjwbQr-ƒNTջ{oF˞~) 3{Us mѽ ynX$L" `4'"_fty~r]Q(^|(GXrk4"W=aWNN{aّ#È PoFĶfa `L"}#n[ U{69NYH&o\ {Cu uWM} (-CĬ1Bah;UwiG7 En1O`srU NJl|O)m]6T*_>~KUiai#w}%K E K6r 4lzo5Vt,^O'Vxd@.`#pDו4ѝ :"IEM~x1FB.w@ObV4S'{2OXbzJdHeusϊ-"^͒X^_M"PJ!;2y:NƥDLB,58Jnz{t&%n`Wg0`:UeX%mi~"C 2ڬrZ5>%r8U+Ja' Cyp\ĸEc j 8F T9~X<[h_7Cަ*S(trOe!B?%d⠳>CcGy11΅ *nO-5*QL9]` .s9)HAog_߂ sU5/k@vI &=0 $L$]h_fh3#\S-zwt n)+>LVr M涢T\Aޚ R!{;6^&5'_^޹Xw@Fl ᅾY6vvymlc93)<-Kp[3MHvzl2uJ['&X:ًf=+LA2Аx"sL{.hs7Óig|є z[Qo|)5(q+Sr[ /$nb:Kk/~'ᯣveؽBkEMA,tdJ$/[2hG,$qm`U6/ v `1 ֝"qyaXO%3y]R<(%<sTj6=l{¨?%ݙ2{‰ Vl$T~yKEb?FcN[2@ճ) B8;xƑ6˔XF]!njlaj n4 #[.k֞*\vƓfe?`3uy,,ůwTD.EIoJ':Jj`j>Pxt64۽IB㝂1y;J9F[25G}el=8]# F4[w$fM,O=v(F? ^57|5Q`sj_̳X +;kLDLbêbB !=xBע $ʰ Z&!a;m l-*Ҷ7QvjEÞ3 73tB~:C1ي үbTYXS!h xݎ(Ґg7I etذ`I 6HeS{?S;99S4\ImR%At(y,ech`0\!b&pq H@ E76guzrE5U,6B̚~ć5MA# 6Mi2mcqο=W8"+ƉX{ވ`eowXxDo111%GYvVMGR z3lj!  Cօ1RKz"wLZO\0$+,px(H;Vu­b6W?Q.kbqNbcl1ǽ}TZyųDB鮺#4ɓ\@TE[[lJmDgDIAs(i3&t]Oi3DQ>zQ(>OG[ZёOr-!iw>WPcWށjPoB`un3^F"J`AFuٗgmqhے\1[eV6 Pgz0 :="F &k=m8;;GIeEmFi:%r}2xj5?R,)ݪ0"%fA:t͎=~41(HN#={baOFkU ,moĀJ^s"q<נQ<왳)dx+}Ctx f!Ġ&flWNy^{섚 yYbJI:8Y92GtB#Gy^e/h7v̶mGJ:f-e}=yj"e~؆WJ4E ɰ|H7IьىQG2_8cUtFB֡H% l&کnZ6iLOSvɀw\4Gi۾Q+xhWt8iQ 9gѳ6q[!x{Q;3̤&]!sffT<IA *X nCz0ZL73v 9j# CVh8?k0xV#<9Ş6U!ı /|ﲷp#"FQC|"PB7'E4f>34aT1xIf9vőɺ k,g2:d7'dY&0Lͳ-+w9q))g'Ң.vepqw/BY 9,mΓU;wgY\)_CZYC==:ݵ]~[O]I&ydvKܡ:[cӏ@#F{.JAZX̶L{#7&oq];)r[9, ChY5$O.:R4X*s}wO MD6v&j+>\$g!m(#0,@c:apZkV|RZ`vDZg-+ۡJ_ #VPbMz;j]=-TY 7V6 7'֚@ žIZ$aаno]WH{ |t ,z~"/9"86>c4!8. EUHH-ӝ! |zh;zbN|]A׏wN875Iv嵤KlXWΥ EmužB"3 Eũj4Uޜ*?b])'DQUHpSwUD+aiJoUw::)*d˦80 _˄Qބ@]rf|0O@V M/WѧnʖgHet'tm:NȆWoo= D`-v wT[׸fEk. 0[,,=e =VD4j!+YED'vuϚ l[ Q+8G ŲW:Wע5Mj4Dw+ Z1˰+0G'8f"idåKv Sm9q깴Bl:C}? ݹKL_ªg_*^]Py>\-h =n('dJф  EC 阻*MOߺ?=!JRVmT8x7$X̜Ԁ_"p\ˌ+z|g?:l\sFc/y\q#'&fjZ2cю"/bZÔ޳ew񵲜ߪ{lAll0m&8zjbiFQXō4(ȸ<ee}ҩ(2 tBtiP.kCQ!bMSKB֮" \ޯ5sYȞZ[s*uQ"ˠ*)K*^4,x>N;qnɜ k.ܞ|Jf Oͩ4AG` <GҞdH{pRHDC|M§Z%B 4!3AQkYPq~<2%Ob_m~4.H }#@(~a,X5}Mq׹r7GqJBm&A-Q)VGF71p'uc~w#CsE:ct]3HZy鉖[RK<̤Mا̋{  zs\ؔW݋JA֫w/@S*6)i Ȝ}4O~3-'ʰ򼴦_5T|eN:L{jL'GVGsW3z6ˬ?. IC15C W  kDjb˹9k4NGu3*A"Dez%_E˙`C. ۽}ip\pi(6qx)킢zsD#R+.&=NS,2^(j$dai:I/ H{?mSr򛤸Pf| Pcq3{~@k$n:>}{h!o nEFEz$eG &`Y壔 ko'>A^"/N8؉»jK S3=scZAq0m UZᲡ$fl5)z h'-'XM/6=N십W)\_@Ԫ938/judClv8健[pO 4a ?%|`2q+ :|'],X;[uwҠG.!?ݼQ]!>K7|,)6NkCsN9蚞ɨ~֊ TuG/ DqW]87]=V S*Eq^}~wEگ]A%7]z1RJnu Q:8憢>; fMc[~e}ۧJ=ɽؽ3ځLv6i˨z!.pbүH,4-N#-־^t"BXL }J _5jn(ڇ*:h^v\Y&cBE%.!1 'E\HBcĬQJQ{ v ګ fr"2$.чN9=vjFGdG8P!6)JU"i2?!)t&Xٓ?pKe$Q(np;m%_`ZzE@Q}m|eaZwPr. FdhO <%>3GZVAA뙞j!itU:Өu,kU8<  TwEs+]aX{r@z 3ctwwW1; -PR^<|nZܷ}[8܍c ލu~/5:B<$:Osu7#9sJJA]9|Y\sjJD/"4r"t+uF/'%r@iKPm:td5q*9EaF!-ZV;11x&CX62"5wj`Eh9hB:lE?k5O d:a0h"âR>DTE;ƅ 3Dt. ;vKmʒ| D):7̻3΀H>~~]\:qHP;'׆0Y|smkԏY 4@2j<ӷ_5:0rmj"+|C=SEB_3;1E^ -@B'%D% \AvFf1,\[-'v19=1ѡ`q` !i߾"Q!"7zQG1iя%D_!0¤Z$ofWtR\W4 od=%AJ6VMk}f% 2ƈZw{Lk9fP+F$z_mlauΘJ,Y5$^٫({V[9E9If(x8eh%%0ZuhPUœ>`1 5 OP7^~ 3'C:WuTe0'Kl֮05nQk5y>#U|6U[#7P|A1^`ؘ5 q {׫8-KsӸ4rp" U:*Q!~v ySkDP^zW2RJyFp J"]CF̶Uz@mT2Da.Vݶ-DfȊﱋ좥`EVb1 <'̲~RevWwlScz gr])n!M:XP /V6:`KBG рs;W* KnQ=Ԕ]—q5L| *LumIET6$d?>USYU'WV {}CAX(hvubVlD  yVr E8VgRaLRN\gtT'*IlB^JѴ/_ n‰Zj I؀}ռR6GM3eB/ͯ\6+ sx`_ΡYg:EdSW@h7c$}>~FS1 g*xJ0F0^  ȫy_̐ yj'~Y' z4IC><zdgG5j<ߤܻE >z帥t\>]dZWh#mdDтc$AxVATP:R*v ?p J*L=[/iyZavO]}{8nf$0)kerQAħ`=bE"R  -PbifM%Goo/|ȫ~ /mBu $, ߄l^r q(˙u|>/s ~#գObqŐ`O|-P~眶ABQ4X\!5+ 7CmZ;FA7}û;q-B@C:(>c]KMI)/f6KyÊE5NfO3HL?;䑫 j{hհk7}Ձxq'lf=x L-tlX4?}%!_͸ S4?Q7|*8[}pjҝ ʿO"JO=p{5Z$Bc$<޿ꟑZu?9`B#o ؉> T:Ȳ:7m ymD?SC(#7oYЬ6^f~E5[ƤZh "1Xe}؝ImlUf~5;?t;Ƙ yx,˂m+ |FÐ3-4~mgIALNY#: nV9^6^+j :ԯ!VI]+F#\[AN% 4\Er̕'HϷPR)7ՙ]j]);UdZF9l }=qosT njfUoRU%Pٝ\ǗIAx[)!X)ψfuR+-+lTMh谮>JI#bݲmw>AWW&ŕ+p> MsC>]1eyҾ"Vw͔M, GfL la Up ҲjBb[S,Jb^% rkKm"=؁ %)+!~9`${fQP:Ϻ!2a{_RDU]Op;'_L5u M 0H$RgWr0wZIm+JZ΀Q E\WXWA0$MpVsҒ (yj{eD")E3T:C2I@$ix OviP$ebeSj:lQhu&w;&}z s1 ?>ЖXDt__k^]+lOꋚFgrгј}Jp  BhIV-hefWa`fL+N25 V2O48(|i,AmOo+?ߑX;p @'cWچiO>;>HhG?1uXPC)@Tz(h")BFsv3; ?L[r&sw G| 'X/2a1T ,5\E筲`K.N3рC2yPuBU$c{\ěTkg-7DSX)9}c^ڜnۅb"NXd+w yyEF8BDw ,{v <3/f2.8%ލ87x$@pK ~QQg*h`ݲBklje3ėY"`ҽj8T3q5[BPBO; G_O[pTRnĺ򉹑_"Am=Ũ[UQ8K5ݓ,v,4ث/ʽ0i]7c -`Ӡ囐w`QPuԯ: /KXlA:ˣIh12sB$M(:AD D< Pv)I/dcf vUL}>GcG&"\(7Ql_MALpd싯LoalFV*'nr݀9H;by\?dU"(}[lO+ +春=2_bĞTO!zDk}cm.22q*tӋ8ہ2ԩ*9Aʈ1k#T%AbOePP{YgFgK3Hr(Lj׋Z@b!@JjA _r 5IZTtǔZl*R2V6#= $ UVi 2q~'Gē_r135-. NI/$~d:[ | YWC3Q龪2bF-Oә1d[N&mRT/rAxj' /փueCqo$<7NBt1.pbΤqAWˎ0RP:˖!<`'T^$/TaÇÈDҽ^\-3AL!ȌE' 63@9FȪewu#R BUPkɜOQWad]MyJ7Yٯ6`Q.\34D]!8ϡ{Q9yۜmx&ΑFX=2w27S@: >^Ѓ-wÝ4~D,DoܖXf& 9!~'"&~h_|ĄC4h@ r #Ŗ'#\1 l#D̗MG1.W;tu4\qr *{N"x;%قPa] #Ȏϙ±ߴ^-a( T2>p 5[qbs6 ~j@)*$JƤ4C$q(> wJч3+\^ t :P-BGŝ̆5uɸXnv ?_|ܤC6KuRj#&Hp b0 `]ݹ]bU PֶGWpރ \A݂gJ @$$R?InCGk -1yo6 0rǹϡnT#<'cx+xV{fowϒuhQj`0/k߇zCƼ gіΌև72}*t.6 ֧I?zv\_1B-D4rߺqMuPl/ז$.P*lOvͰ%@HE-w*8ʮj1RP2e+J𹎾S:'֞%yu`3蔩XToTfjª$GD@/FBkX v3aK 1ywj&atyOf;I $xbv< [VY%ҵhBʯ=Q/Hj*v+H"^A5&Yr.`IʹcXc˃ V-ws޽ŧSv^ӛYWGkaFy˯t,J7*6_Q7c oE 7$9Fj0= wA&a0Y@u'ab+@ 2RM` X7Ek >-Z$Uglo̟,2$3~!竀0B?|}"?) s3_S/8<|3U5N]*%F)A須\)oWBG> :ĕ_u#(׋!:2ha.ӤIRo7WFCCs̛"so1jD*;$w@qܟhF4!c;{q*B(]p1#G;6l6A 37͉N rr Xx8d\ml ) [R"F$fLJ(?H C3_33ȧ09~H@cI5 CJa.Z^0U ozxF̋=E+rFAwNAJ.)[y=˝,~fWkL%n?AK\$~a?-#4_C!1ZU3,|#XDDM>E9Y 'Y bh@@wfw YKón^ͣ7 Y5/ȝ5snL8a7q ncT- Ssի0gCZ:~?CABK( 9k7ͺMJ7k9$Iz$AR蛜׫r9%ylD{/X6o[^gX~`0Oo+c(PlyiI* ,pN{Z) %II;^GΝbT1ļ/B:WM{T&Kz| .EJ/3.C-?i 8vTR\ERbDӁ__t8JtC<"tiyͰ) MFdΣ">Ő#|:YTq]ݨ'2;2dՃKr1rCj$NZn50;&d(g}YfQ.) q]b칰Xc,!Lu(G$b@O!c"_nB>hF9d&A> A'ƪAp@gi`lS8O6T'(WIAt4{#?Aޥgb&4n. >Р ]b.g%}u#m3Rr"D:kЃOҙҟ_Oq$\.!p rǶ*#&jeɭr`؎ZQ`9ie"2]v-C4dP4 qt GvZ#,BPJox6tl#d1c^O,=l=i!g9K} bԫdRU9{!zu_=E4Xë* %8Z;3*1}ԗ4 1U.akX TNlVO@" )O =reଡ଼b"MggrPj 6 Ư" Jgȇ)Iأ߲+RBxY9N!/ujcpME͂1>OIe\@}Jlw@E%uke6D2qkut @Q%*zqԋT~!oBQࣣ0%+enN͟ܖ^5Zoإ7ޠD΄.aيܝ Z'm]:ra2oNB_]ޜl]٠hרa̻ d߃DUNufGC^n5F*/4 *ỹ܈dk)jlLp6-g:@s$2Q  0X8Tc\mr R44L4S aC̫GVÄayTC33dj~pHCMmЇYa嬽9x :JGl ;棾i d%= PҎ 9+%cx׭ے>9u &T P|!9]SAA{srzy za "[-U_}AW"tc0D QxV~,yZo^2}S𒔒Z#QpGaҳLhjězUgnN!iXYTS,ON U")ɟx+ +W,pJs'#Z S~9κN l$q_::ؕ3(׋#CBƬ'j/A]zۡ&[Gx)@1nLb0-=݁'\Y"ө3k\Z@#}nۃ>SDvl_!" 6r{OA0t/io<+m^nTLN6AYҢ6Fʓ B@{߾D?[pӰYؚ&ՏjU %gM :'CZj-T1[ʛV,zD%;nW}k0Gzh&q K›EQ 1dwgBdSIfRGdt mPMYAo6N“1>NnKrfGZµJoAz0HeE }QQ̪pRxݏaڌ9@OTP}I`3fu\\#PB$2p%/w0BNBK/B=ߜF ,np !4eJIIw!ҌE~_V ?dOR,cBkB;iYꑹH3VƦ"37PlY1 kaJeī5J>zbSӏ_3Q>Aڏɭj xH77J).q1@Az}K@$Pd= ueA GtJ]ẶOf9|^vx;Ee}}!1Of 東@GI倝ŀ2 k^͢P|5ݛ'FyfYOjȯ0M$1KÓTwA!v®6qIXl1v>Ӯ5k2"s/E.IB"ʆ:iO9, Zco#׹}{'m }?={>z2t>=EHgt3ag0xR?+U{%2FT#9sQ.,|10ՓOԔseI90#ӎÉJzsBITIژuN.TQMMFY'F v'#@y K:WЗp5?UĪZCjJRA4xB7\l*wf N宩,QJKUF@m_"D\P#Q"*CmlÞCZD鬋 4 {dz?qDfӶ#/Q)*Γ5$ 5f裊F˪$r\C{B԰4+MA~}C쫟!yՔ4*7CV5zB;c|XaV(>r iAS*N`Keݜ"x*R|;tt+8I3drd?М*< bE2Ʒ`S,Rg:pBtF" SlxywMWR>+M$K|P24"ʐx(!Vf=1 )JΞsLNc2bNpe+?nJ\ f.N#ޘB%d]0G:PlDEكhIbs=ߦz]0u{!\AghQ)Nm2Y6%e/+'lf{vr 1FΡ!sW,f3yFԌ]tَu/*ki.Z(3t)6N:_/Wi ԤCa$i[s=l~**vE~sLmCA6S@)&;VV'.ܜǑvҾd@뱀yՋ l8_,i]ovVa\dק7Ñs-4j%ډ{CZ);.KlI;jd =Ŧr{Aawb#k`͕Sd[FEnXā!eE $jHvҨ̢Bl qLR.[v۱|pLȗ|7\fG2D ߍ'&C<%y5.[CcD#9Pmm&@U v 2KW`l9aVl޹qG P+(eO} DZݥy[I7,%R4ձtY^%vJm31~tjx(mp,kկE/V ";7+}yܽ3 ۃyx]ؑ EQثpCq&lme F0R0P *l=t"-s )vcim[fuԈp]{tъy=X~98I#U2ir*@>5VV?p/O}ߝį^1an3v G)N d$=߀XL̦zLj~.k~]k-yn-*P Zcr;Wc,yuS-@.?w@~ޔfWK&R--IS?N:ZN<f}AOC3tQ!\\Dη*U46Ifh.`1[mB|:3 ܤ<#%578 …Fs{>|xL, {s3:~Ta9 (@G.A, ƚ8Rp_m sS討~\70[ٷtLU ؙC3̣`Ƚ۽K9p=vE(~ |{R.3:eΡ>G?9\hWibv{ȁgm[o;'q\_,E^gbZzCL +:)2;S6憵a*WB51@fm&un!+*Č2-oOi< Mb(Υ3GKT'Rz  5b`@bѹ&Z9ڴ} hP_<`)KRW`Q>3+,^PPTű;=񊗏ݦ*@_>u@Ļ^Dy.N9w3ٽajJz,`Fݫ/:t5N@u˹[?Ma!*=*Qv@PbML1.լýt~6S[r~']y9 1F%Xô2?z 9nz\ MZJ]0'C.fi*=1,t_%27n^&!fW~=LtP\ZSQS}`.o |姕4.S~!7p9WiȈvm,_k^fp3x$s*z=>n p{HWjmꛩZϏ5=E,fa&Xg\ 찕b$ P .CȀ6E{d+# 1}⺵[q:U:j\^Q\*o,BtCe[&,@=f!zM;wf>EfAP⸺8FvkBe 6[J,yY1R32cىPif-hP@$~dI뿶2<'`WĄ[-Z]`Z>l4 r[@gbRue> \k[SYX Ս1J+:&2q1 <3?Eq1 kM,mr_ڷ-?biŒ ,#,_ٞbnFf^P&h̜:K0be)Lt=A.CZ!1 kqBp`:‰7u sͷ$f=~l|`GXho!q埣5Qa/%~yޅUճW:Uy<_Ӿ4h 8.H:naqx>cÓ 8 ԫf@]^}-!V |/a;_GgUZt?[0iUMB9N 1K(g ZHUĎL@ )Qז@ܠ5ivLך&lۢ!}C=];-]M E9&0uk|9~kH< ;BqV>BHoKK)B^tZy`WkeS7M`:,~Zkp%ЦL""X"#ˠvYѝ[y"]mvgn#'))p0 AX%> 3⍦r`9(b[qݞ+w= 3/'|I !H(ɿͳu Lkۃ+7| .ԢZgYRPݙvPhs }'OL 1ص"f@C1ggdR4RY"|d{4,d!x<$ӡ5C =@SP=R;[Ӝ̐6)ļ'ZpM`cn;c*)etR-c7\XAG@""ÊA#%-u⩒C;L=їn?x.ϟK6-CL"KWpj]_J7kԥZBHLJߛhmn6[ cEW%Eu;b~ 1\|O*8O"@ PUcj7Z DV+Fdbb2f{L.̓VO<;o:BLxMy~g 嫧?u \u並 F ֔Ce]`%ϙ_Wk)b6Pw(8X{je/Yg= *U,Q;+ ,r0,[~SkzMk98F`0+Kacיwwƾz.^-*SMl;CCޒǃT!w} o PF8}4La%CӐX5nX졍Yma>ŸtvnAyYpf %~||ߨd^zkb\כ(WşT 9; vidv(8ܻ8x;=7so~ۉO-u+韂R~F[i\ǝ`Y1蘰ŖGeҕha&&NlNSofKෆ-]:`&Mn!AxܗGq{.cBvDO\;PgIw>T}A\5)*!찑!?t0of@Z@t rJbS󢐀|,6Jd.}}3W_#[IQ"9goAhl1iOD 4bח;u<] ݠs_ v4PY}CCm?ʾYh$xҶŠeItEJkx;z+,%#$,^:`lvDž\3I")yFw4 aփ_vIGᜬ}AP耾 4aQ-sQ. RhU芯_I|*{)s헜 )haL=>&^!0He>X!J\Mn>J we^*swxkNn2{5oKqՃ|oW=ƜgJ/4?Zf}_@j ީ?;.5j]RzyH;5"t(&5yF:Ƚ/mY3MNTO˹75Jlfzi$PTJ?0HVMFCa ){~4tqS ]dd9SFiIG]wKOÙZk&+!=l]`@z e. !vt#2FZi^$ߺSe+C*׈oU^+%hN' @V`7tR9rWŸ>.D.㘥@BmT{;X܂b.A 1߰EH`fvup?l֢G@KpySFȵ$y:Dk M%A #qG$5ZΠ:ނ!"Gû]$D[O_+b}nm'K%Uxe㮇ƻ]IcVV92Iyf{{h3"brN6Wڵ)9/uԨ}\!<;^p̋핂IJWm&F7~*+bk{SNVu@LY@Z"Ig(gBYb141&ѥ;tKކ.u>vH6E{"a뮴H]DjOB \}Nscc Xijӏ]76)u4g!M&΀*ʳf$p}#:wB0Ya/'ؙ*U..@Bg pgw1eC3"?O:U/wHk6Ar$TOvu kd1PZm&qo,~,*ת4l\CRY<%7ٴqR f`k@~JP1M$ʼw(-3ja<bӎMNlֻq&=¢ߍqԀ1U/s18#L$_i0 g~m% L^+;xTulętxcH whlsN{}ޛq.d\(zI9ޜUbt R(Dnz5OD^PH`V}?Yu!WKٲMvq/@\% q4twWB\8D:ƮvU5B̼7 ?ϯu@Y)Ul\Oظ 5/UPַE'L;ԔxqqG X&<ք<1g/-[Wү Ct3ؙ52;G$'~'>F MͥZX;SW)$⒢Z46oEmp gBDeEBfY/;![mia`*Rr,2h F|bFF1!ao(a\S\i?ʺ't֍ﰳe<_(ud:[| չB{&EKFYBXTylbN!bd8GzL\'8vxܜ3txMlݲ0,nk~$XH [\ a7 {)ÉMjGݫV)M_unvז(9j# A:zp{>Sq P;kCa'R'Ÿi .pet3.$YW3wz@}gnqϲKqxf{{,oئPϽD 8djh΅ؒRdSFt&&h+o!F[0ۗ6~c +Y.i\EUF 7HZ \㥸/Yu(%  `hCY*YT :5WYn(vU C8-yYt@s;=i}D(6&LQp$ՍJZ5\0OMc *xmRKGᴵ-ҙi|Ubt32C0 J7ƍp̀`6d<2l* bTLCAqzALd3{+DÔtǮGĊ-A>Aņr윰o |*ˡ࣭e>rzDO=۱,]7gubԪܱ'÷-l~R"@{d1 _'d(*i찉<݅K?2e;a"l#wV? %ZE=_`^¹K 7vk)t9[bJWɩ>}̊w&0 K2qsZǴʙ"99^%/Ss$&ӆ%hD6Yh#lBF`\QP]_a*;h-jd(vxU7tQitf q5^Zz=a<BK [jjuARg VX?PU{]4Ԅ`غ/fϰ p{j|24^EԘh٠Gkɾ8(Ia (& dRhxE2"T{jp恝$H>\}D"Jt9cU^fS} J( K^-, E}SX馯|JWDy[Ǭ%q"K0hl Œ'[AXmR?myzڡG(I%#y8IŶu7{@FG B-@GL~RV8 s{y&UTFe ,GNzJ{%<91+XI,#~pNd:p!e`;f_*t܄P`0.n*eX2#bl82⍭8O$g8dZ >`C[_NxPIL|ݳ` N7WHm'Vݝ(&?uOP ]:r3]ŸB {ێ8J-1=Ai(RPX:#yx7oC uF.`hxQP]Y3SZi"9g;F<[؃ Nަjt@nn%t,{ZJJͥ~x" X;hD,p(4SY e>>q{ûfa9%2υ^Rg̕Bi6N't붌N:6ڧN8f‹ν kvS4s@Wx\6y cC ަI%fDG Y+.&7 W;8=I9:M3n~A_316šYU`0ܜ7'mXNwP ֶgMN;)3ĵ:]ShHXGG \NI1d0sAO[;LNfH̷?GKbP1weI"X3~'/Oqu 4.{W $9~Ap5k*N痴g|%ZZR87nCk!H h^eOD h*@Oj'!?xLo Zp#n)b_ ѱoB(ɹm].Ϲ+/3´(cA;?p`Ѕco&&S{ګE ;QAN+ָ!\sӍm^-5E8"mAv'])P ŪjuIEð9t\N?)J%xQz'pH׵ÑMzqT8,sܑPN9ITfeL5nLkl.ɷpiFf;@\G^r,>cOF~ed=sԳNI\MVCbQZqn(M 68&/qpEc0LCelN`djJMA׌xo~LIW%|MO8SŸ~Y,9o8``t~p+1gB $s 6qI%S54/(4bX)v)trCmLts%`91.xu>zs!hk%6h&9Z7Tk`􊈹+ՙg%y`?Cs#bx⬷l?iASH"8v?B\#;PT8Anu[u'j\-~jL`z'TشG5 \6}^pm9ROd'wvWΰT5?NH{i`yv_%qNK˔ (q˩k2$&Vx(CW`݇I꟧9)]P Cb@QHd҉);horَ(zZ,b#j*qڡPF4JCpb4Uص`-*3rN{&">/B\ѯnV|rrR^NѸ;EBzhc0n .ǯbƺ%6Gr*qbIUE̲yA\87Y<8a3LugP|[vva ͽ3CWRCۣmK~,FF+ѷU46px.?^#&@ wѰ65}z)~A\ъ֖B.}}ٮ L k^%Oi\jM/j}rGѶpbEVor$G~Ch=ѦwfYZE񭛇xd Z,Dt%U>gYʂۅ&S9c_"(Kq @sRB>݃"-`ʑ3,uUDu>ݧYn0HZNJ]PUY`UGw2XP$=59$/c$Ǒ6FHMo{s̃D*t:2NrmU 7Ό9I  OZV a6X$c٥D|B4X!ʏT452|M+)/͊f=B' >`-?&-DOc5S fF/g>3 :cM4o|e?IpN2Yjor&%[4{LA*(ƣ-#t]ƴR!CII+i,Wg۾ݷ9!?-УWNۚOTeZgiqҺ[Q9p.X-u@ O6ѶOVᲣ6itV2{%$ܰ@]cIލ/rI?(| l] ñ'gaZ;K4{v9㙞ARe7xkq#p\̏_G >!?XG\G[av,GQ{~pXoŎ<h\觳iТyw=\K~0oaτ)NlSjD3۽d@{X:u~cp f4 ІWaG%[Ɯ\4tF,h@Q_5&/dc'-V$% Q`^<5?|u/om!J*BȏZ%){ 4Q Il׽ ;w4d \͸-,%M'kҖC/zmY z<}R]|!oK0MwBGqbkݼ0(c3T< #fuiITKhߏ5NoOx9N cxj :{gQt"vȂ zЩQRGuH# IJ ĘtiUS,vqW+-M''ZW:$b<j[?M[0lմ{Hm%7v9ZHtaRԡZ>TpK<6 Px<Ѩy(&-2۩P>>/}lћ [6ax r>]ŁLc1A1Yw_v3KFಜqVU^T?A ]m7,*.*Mtw:l)9n\V+RZznu^8k;t0 ?8$vG ADOM9,T(9I|n^Dt^ ?CҨP8POv8Թ%?`ػ%'B 17@C؄)ϖme*cu }DBR82.yWaĮLsmkXn`ߏw`Q:r\aie˚ĺaKh၎{s`Ȧ,jSO/.bǢ2-Ywzj=)0d/#d|53$rs_!v0.VUM`;@Ytu&rГt@4( Wltodئ`*?9ǒ eբF, o'Z]Yक़ "҄xKUҿMep0ypٸC3Iu{_R|G~0c?6e)&0""DdTwrʭol!I4ạ1Yz~K=";7QjurY7p{HsyR̗GA:ogٝ㷨u C':ȔS[)B<Zpt~% +]s8~XиG͌snŷ5_B|KT Mҽy*fLŸ3iZd ~qR,]sߛ6L3 nczlJGn$6lcZAn1Rk>i csM)Cn& .3߁ j6 PqwkVy^-<#X"! d͠:xY3艜P`K˗]= VEOzhەd^ݹUlU)9 T6t:{`d[06q:k9a;Jp23\4o7΃/TA;(? 7rzr#|xR0>Kͽe1K@|qkݖrƭ+ gZ**#h*hf&!k}!j>̹[ubn M i aqZy"K9]7e0J GSWq@ +T%Հ:>xC3 Y6?*o]%TjQ~xI+Ighܝ*sA=%=馄86ªDQHH3BS\(QLٿW6WeyG c>hϬ3g@C&D 6^2k}O&g%?(sXc5/$ A83nes~4I,l:Gԫn,i":6Ԧc61xӸҠ:4Kצxu*=Rw|%dޢG)2w|PHgyH4h S  ӓ cL2w<+U6J־!zw'\l(݉s@ԙ6W2Y ~$ P%.^6gp%7@U/1ƉLH0{Huana,},-!fL8Ӥ\dv1&~ͪI9F0F63Ԇr?7a3f VM-CJiFFj:1*WC..vMØx|_j{cO^ac|hcz=wxi.gΐQ[GRtP%δ$xR]’l!gA+6Aal!⨽ ~#9z%(^!C Ir ";-[/][} 9TyFP~^pVGYg=ED?~Y.[ ~U@+C\׭J(#5 2hh/OGj5:HFX?Ç{`蔏pBLk!ˈLQ `vvd|,] sf}ˏ; [kSβQ^$έ lC P)$>+}Ԫ0|r9GQ"sRN#4eeg9sc Ne6y ,C vG_#o2/ƒMc}.|UuƠP zց~?C_Q #vϪ#jדf¥ַYNmI|G4ϙ ?FAtnEH,dVR}kD[&~ w|!Hw4+ȁ'eǂAّ]qWތ,^{c_dNJׇ)d'K(/20Z_kH7-5PZlzœ <7 b4Gt{Z χLes䆑[k~ h3dpn:74FMoq3}e[=m]!6]V)j]|Nϛkk0o7{Ԁʸ j?.zΛٰD=<\ޓi't% dF=8k Džd(?KӔҍ9͊c!gNi--p̖$rƦqO؆gMMyM@xIɁUykKwPO nט"klMNl:?HW يԽɃhҐ˩QOq#^7&T+—)*뀸3 ɫT\3Mc96rw|ub.A{G*6_xXmw.87#rވR㉻O>J]Zܝ(Up7&ƀ"aT…+p˝0#9-]g3qg Gv9.ٻ"A160E( *"E]<~mA%nHЏ >pr/jK E=N|xAOm:*c*wXq$ϟKv@356)[@u2@^zP#Jv uDiht#NYRA| h[<āR. -~{Fcs-\ZjpNz!|>)z66e2(Tu:U'@ϐM]G|lrjн*oodh$?`Jɠ>{fJ1W'ӛiEgQ4`+߳b!˹ۊ XՄ%oe!E#a[ uQ("=B[eFk Qe[jY;`q:7Ԅ(HR&sPdYbV2;ѹ}r)ZvJ),2X.䟻׶+[S[`\2!DRlO HmJ)pT*||0^-BӘU$猱F+S ; cXB3DS>5ζ{-~¡'C8d9FNƫI]:1 >;z#fyۤߊL|iЯ~њĄbhZ)̐&ؾTUo6}BqF#iPRDb*]ؙItA%@R+l"1R%D9m~Pm|C9PL*khS2D-ʐ UࠖH :maM x _^Hg er !N|p˦Ħv:|tFNjŇI8})]p׵L!kmPd%admh*8¹zv G(LP#2  4_ 9J?7'OA5pB٩\}ݑD9s|I>c0J_iݛL"KɌ#V"{  aфs==)*Oل)zhl0:/4 [hͷޅhIM Qk39=[ Oޛ2Ryb{e-gP61N= ARo`;O$G)+*g=z Q / d0di˖fff[> ۍ]ki]pVXw\s!р̬ g`3VR(Lj5Ee2Cˁ+R `gf-D||N<F('2)8W`-YH^WY$\4#1 #a7I"_`TZzA h`ݧoIF_< JrʬWy{\MõswcU, F@E5Ta5?#`(^L˅t[JK(suCs+$`k=yf4iHCFpId~ "*T]6Uܧ)II}n;$vtExWY콍rAإ0(ʗڌ2׋~!7we b], bKlX63P=ttbՓR7$$ -GQ'G4 {=(@poa{ B}0}յ9ohga=O̙5o:7)9f.LY_\θs"m|5*&  ,6 Ͱ/op2Zf)Npݬ{L]h-E0tVxjλ[+=.Vlx?!(/\A\'ڋ /\20d&&:PaJI[iЌ.6]&ɕE(x[jXޭT~6vA4t>}@s ʜƽ<"7>Kj9-/`{KdYbϬK 5eиYdNU2%PBO$L}(L۔ÌwҬ>2:0%|cML>jH|yUsi"X3:7S /gbY֟/ A_el:֡f^eOSV ƩС`' 2\#mx`l]x,=78e9}25V*4Y|Txkl>%-lyw;)ՂOii0E31a匵-AdTl3Sk>P_ B/ }{ĉ,p5>?hVzU%rTu-_kĖŏ1ZPQjpt,4^Tݽ〨rcw4 crP.S 9Jhi)D1UoЛ36kwc$@OUaG'2QQ@Ǥѹw^EO&b, e5SvɻDGt_j"5 _֦akù5wtMgLa)fO,MOao«6tR,ՉUL&+r7࿔-WZ HY>ut>JDBH+NmyüE6e*JVzFhuf׀5.1B?{jy#݂wkn3]&IF4~k[,Iy(uN+>ZCr5}FkHp&A|)fM"i^8TCy7PRT85[1" 34,Yws9: d"+N02/{#DB?]jk6?m۞9[r~0C咽cj|c YIMۨՒp1*P7o-hϜ:d ʁ{/J084`Sy(&ϵ^ 4U{bK/G&=oFc~B >oJWC7#pb7C?߉1ݼHqn eIM lQ= 0zM>@ q*xn@ݼgdD%h,vM$!~ J:w#,>~z҈(/nF ꍸ%.+[Hh$n\_:k:}36(K^?k ,,1 d]nRx}*B:,Uݟpa\X˶e7T`#G<&3,l>T\\>q>_Lއt.\&'T_PegM^0OL$O ]Xc 4R'T2\QժA@?Qeo,7=rJ*ӜI^+ec'w%i$qs՗A)҃[F2ńJj^wYyy:l CzV/mM[2l>v[:C0 J_ 5v u_||Sv ]VRMzD7)ңhLL4Be[Ea.]<*ޔY)FQWz_EY3a!Jm~[ wA/e9sdYJviQ^Ͷq_.G+6iꈇSN3!? *Oк(C76.^^7qkڙ"̱DX6ʅiB%*sx6 b@< @^"lm]>+x*;߱-uƑp16иVavE}"oWVic @ر@ l|JݰN:f ^}hR^⊟a^;UK?FMGc63YĨ )2Ykc)$ F?xM@)C>-JLP_ί%JhĄgcn4$9؇)RZ-"JϳFEQa\=s4Cԩ/.bY" "4 ݳUxV= ga-79(gh4g_ƞOP/{nJ+Q5Ȃt˶ܻtQy@`jR 3YuYxSŗH$oAv&¿.5*9ދ cK%\:kՓ+_NjSZidgC^:&}'g6΀ٷX|sh7ҕBrNYٶX @2xa=5ۆvTDtmW)Ѩ++:t)ƪM3<'re:fL]P"|r=H)0&xXADmyaMp =i:! /y3*!%,|$я4tE='dE q W |usipL\+owthoglGg3;_]Q'&ww){!a 6Pxk&D[ ܻB۩mi}}b E3E*$[ p*,r>2KMJ)8'.d2QYv#E.:PDtCfU1n\e.@͕X"Њ-B6XU#X 'OKGU Ubk\$er!aDk1I ߰ {fLG7(s9m>%p8廰3Fu&1D^r/z_Yac/ F'ҐuAL H҃J]ښC-`^ISo$aO@dX'?-Yd FF{ank5Lzپ2V,.?㨲5kKeJ 1Le}K.~|JHB RE)f=?rI93;${p}kbLvJ$NAzlf, w8UC>hFT+Eic]Ub0βgc¦Okmm7 Wv4b."4'GdT3Eq6 ]f {Tbg 2ħ9KTd,aq7D6AG=qQCbՀ}݁L`/}Y1mm7p,S3$XZ:8~ с(%ڂ vlk٧2IyNe`+r а^!{_enj(w)O.H. *@!nԧ3)S{RW|}܉A=23kx `Jc{_E(}b:IϵYB<>F<焆lSmHDz#>b>a xOr\gLϡG< UۺS5\P6 ekUtrcdd, DP:)ʡܞn>n4 Q ԧhe\%5f-i6N *9" eBM 9 EB[jV[aA7]TZ7b[; tml!bC.sFˌS.X\~KZsiڽ^0g?8%( { ]XGӚS͹=_l <Zdb^!y&,]Mcp7UUfKj GbK$@>0Vl+!i"V,N@wlvHstgA%P?SMJ-˕Gf܋ЦB)Դ 1ۿ,ßd#9@Zd$1wUrzxlD}6%.|Es:&<P'='RR:b\?hWI̍UBjMBuc*sǀlrwZ橵v GciZ4u*Teaspya.8ߘWa]!R3xj&b;LֺPeIp]*bSP32SY:"@ԗHjA7?*&V笁tZ'rsJM_);zcR(>([j`ْNF B.vnI#Ex]v+eӗ+dGm9 G rJ [NΞ ]=tHmumXĉUtѭ Ju> ^#r!Y'4Ջ;|i8l C$Uw@XЈ(uJ;2v ʸOi]ƄP&qGZˎІ  3)Pa໇7L%3=2?QVX%+&L);bĆ|s;YFKV8,DFZOE4F?֫hNeջ R-E)Va7NZpbe4n/Zߣ$F(Y.; kw%6E'ÃhI*/D+|\09tѴ M8t>\X(M ks%`Lޑɽ&ʓHd<Ȣ9",fLKluCPT̞8 og9\Pw!< [ye2`yR¥3I^K"De*}¡ҙ;LK'ʁ/-^Գ\?PZ/KuYoS!)q$7usS87evxG[0j=Bf͇5`9#@5II{ͥ?r%}-|9PEa)FZN`-ykX'\)7}v)(Zw劂+jxS둭y+,n-I$ hgپ*A$"%5)Hdwaےĺ8ǽЉ?jX^Pv*L﹊|Htx'_=^LZl$@:i 8xͭ 3E34 SkYuRzlyV9W殒W%ΞC'41yv D0e]ټ7V譁~h54aETm)) <%,XIɵ2@vG9xff1g^% o ~p45~{s{­hs.Walv^$8b^$칲U+T o1: G|z8~ʁN1YhjĆ 353$B DJc攄 @SvkG2Vht (gɩ"p[e(ȡZn„j$6ndxw箐,ݛ4D/a'R)fRaS4#f x |}+n$3P>2d9@ ޔ,W7R3T(LN:+#Nʥ,jմ4?da4E؇R4A%2Xe#3zi0%Ӏ׆ezpCGFsqYw?'}j#i3ں$/(kǮ%YDCb{ `([$Н8eZĬ9ifO)ɟhFִS Mj?)vAv5v_ev&vNIM۽!5, ٓ> v2IXƿ'y|!FnFbBK ,^]m)ߝ 33dN.Y}<,yHT/x5ll(}0%qUpVæ4ye^ o<{c`7#͏#"tU?Ur=(UbS kΫ>0x3SMyrѨWA!UǮ G1"x+ ‹{.,)<"SFV< c h-Ө9{j#|TLđŊf ׸O%JRG(omr5tɇt4S8-j~D uP7Rļ81q@N.4i1qС)iTT:fWM*,%em1n}0VKN]S?sn}i "7AxX_DӤL 9iδCC/Ur!8{97QQ2E^Q'cnT$.ROqi;YN )(I_80dN,2\!  @帋 U sPBJvm`g\xhA\]Y]+8c~P/CVD3tdp:Y)u[~ o5'.z0aJN_RqsJbȅLԢoe.̃ 9$@_M33Pu(8hVzдTm;7nXrПwM,0a5s8(܇w$.y+4]k@s bOp$Iim$ɮ9dQP bQsZ#".C*HZےiܯ.6= 4x}8˅5g͎=źZRfj&ɕD D9q&[Ւ߉[fJ $p(UFpP3s$ mEy#4<~Ol\©%i+i0<9N9($ٚNcBiII -V-!nJ3H t 7r>"(kKrNK {6T@0{Fny;_JKх\~F\b? .ߒtAj,p(甩K3<0b}}S |Y()humv1x] Kԙ;,ޖ/-zr>CB7u# ` :<*M492-k~#WG)+@ålYN6/0MC>=` &mPY \EM~i݆Zik^ pDm2 jƝ*3Śzww0мж,iCQҞI&hU RckwxCf Q{'(s^EQ\Ră; _m@-9)QȤPbkJd!gkV\Fm-¤be[-fa> HI,.yL^VCY=Y%یf.wcZ&Q 6Q(ۀc`ݓ7|FkHPZ[8]U$N|h{b #:xn*teⱰRF9M"}"؂54|NCЉ[?-wbvB ht@R Ò(YWP D|K Iu%cU>RQ-($(k[=o]cXPu޳Ku[r:찯wLʲ'fW)-XR٤6m{ ;x X(<_S7: 0KE3ʢkЉWI._F ユ 3 dˏ4/B(TJN Kˌ^1fbUsQn) Ogh6+öË|xҌQX=gd!Ƿ+R A-Z!,˹af&c/{S4ʂne]i6X6 5i&`xfV Jz>1=GJ4v!EČ~b X=:Dm'4Aj aĮȢ'ZAB0h8P$s֌$*joSO\G TӭKf+'gwgt}ʓBndO B - |`G8[)¸k`Ѥ7_z|Bpd/?z` X+EBgK-hJM dͣApE:dFuD#6HptHې87JPM@Q8-.{b5҄= `ITFx#}Ӊ 0dRߥf4nAΚ폾HQ 9N]\8!vּō'f9aP{1冥ȟGͥZX+9n"hO<_#Tz T>H r)+enUGoF_H)RKXT#yM0 ;/YJh&R H||\UB,;15o`Xũ*YLsG}y#PrMk\=|zPJ,X7c͂aJO qwɭy ByЋOK+z;&p˦`(\RS`O*LSu4kc]u`RNْ+%z+0%:< Ż{( %W4Z!ש-O8 J%a䀖Fƞִ+pH։`GܥgB,8RZF+~JȎ-RM^T (F5l!SϭY{_q ^uK_fsbrxr2`xSW"Z"Evyo- - l g4`88\H\X8 u҆ ZBڰ_ x'<<3=_4ca܃;b{]rLGj@&3yzpGm,ӑYw(x\#8s:`}0:nl=Ia#lHPno3W@=$ݫ7m=lU e`@<lU^)P0;ˌELg?-d3"LVq4*c*d}^ ,.}yn{4 ҩywXF/Z;M^=NXLp]⼮}Z:z`8+9L3Syk"!-X|զ _ &@Z'ܷ[QC[g{}K+6ՐL@B˦'O\1!Zϝ2D숒[Df> p[;&WSˀ[ZH:~0BJ6z>O;z&.n>dEUz@ǃӕCF]T 'ڽcyc@Rƛ];JqnZ.$KPVS7`c$>3ow\Wܥ\jԺF~8Bfr42E!'v'ti7#\:l FaLD1ZLϡM%I$^\|#t0rg{M8Ǟ,u9 DM"ʘx~RaƋ݇ V[ZTkʙ.űyo6av(Lr VyFߓTb=w q"f_MI&ni/@i#ml>eTt1lV< b}ifH8  aրG?X7Zxmuޤ+e8\^b/K2'W L32e_IcE ÎM`\OG2o9Kwb Oڧ!v?r神24*nO 6ԡ(|yr׌?U. mQ-"cqG g<S:ᕼ6| W:"Ȕg}o/rHH 3cQbfiUx(K 47P) W1”1Qq'?/5\R7"OEɣ~)=XY g//c{OFa{S;9i5U Ŋy Gml ,˺#,\QwWKDJ: z#R&aufx*,ՠ=3c^{+1[d3)>!#9Ws;%mr܉[6jJb'] /l=c}둚5QOc !ә0j ]SIM$c \3@ +$CC`v\iFfkntL6-,Rϔ#4&sgˀ^QmZ6s(ꃐRG-))!@7}ka N_D#WU\q޺?AϣA,2E= \3-ڵLpٌؘ!KTx4m@JĔ^ї%bM펞v.(ˆ eXL\h`m.h:ԝLP?gr"t/_lB`GXО Letjx! 岸G-`ʋkhNp OL.Q9)]t;o}f0cs5*Z`6n⌙?eT;"y֫i * 䍒p5feԫZ07(y}r(\ 2kS\ ;"Fn?tb >s}E-[y݀eSUzf"icOD-V.L_hk .I?9S[.( 9Ad~kyP'(ϚB4J)i8Av^FQ|{>g7{X9 !UAwcmu~:kܥDp+&/rY`oi-.taX iށƆE eWkCYV+Uob|ek|B[yܸca 0[Jk%_pٔKZUD3xb:Lbo\\ޏπ?m$QuUK1[[$כLd "N-$"e3XhQ;ӎʩ*:-/s]i AP^Ch&F{O]BfGXcm3Fʰ̔xΦgƃ zTG9P#Jxm[:2M `'_&,ٍo:W@B?>J%=o~P &"?6 ,,b#G%5p8I݉O; [MX2qw:#j7(-b6nǒ)Jq:d/ILs9DlCi GY( ݫxIJ&F[TiUa@>j..P6j: z]/=`;|PPДW eTVd#gb)lx-ޓ/A fڦ'b4_Ӟk X<1Q;ºFjk K= 6Z#zNx^:_x #:jl֩œc`{9톍6 -,$E6JN8hDnRL.(E?7QVnZ_77g"aE!ΖַթFc7nI=tGNkmd* KQr: &[|0_uxq? WdV ^()3ZDvJ#f"I󏀍$[Ff,Ttm-}9I*2MZdenBmSu0p/ӕRƫ8g@2,it 1]_nYR=Ě,å0lfLVϣeA[D2\ ~IĦT\n7 ꉿ e _ans#j8ZFj'Za0;4S2H7\R)7˔L41|+{\/t '%Y0iH3E3qt%BM *X$ <+6Z?h0h$&[\w)'0nXkOHYaMěkGtTax.NqWWj`Ԕ ܧn3gԐttw: Qe߸Ϋ%(R"j=%:+ G~ZM,as {|Aj6p3_ͺ\8:x5pVlB.@+)V7Y6O|.A/}=Ύ)֒aT53.`2'!Hy+ dN=қ "sE}UGg8Ny. (ư$dԔf+K2K$cb>*Dy+d27n'Z44%4YƯ<&h<| m/dH?bA>IvpItJl_mm "aR7n",f:lfii9rdG -\[ PeRWQg%w=t Hi >$ Nk{qei˒)Zztv}{PIk 1B L1<6:g @@06J?10>YU#m4ŧhi $D /xְJa@7Qle߄[ 0FYGԜ(PzO7/)R$|xd oeo0Hٯ6@mrq2t'kW#zej`KL<3F;g$Dν֋XYsff^?S|bf^5Ff:Qj -b|=1\㰽X4Q΃!='kʛA#pk u8|a qECCBг$hzZFzbImtVJ5זMѵ|\:0 -POc*=ܟ|z1\Khā V )6Y?&p$a~ ,ۃ׃,ţh/5/S*Cs~#.;v.tWsvO əx ?:lz1G4cn.V]$쩼q^HDxR_C wǚ'PV6_Po:L":W.KյLeU+K>Xs)EY@-H໰RGV[~'r`nePo>@jQ U-I2Lj ,+[5%ʑ: mD&]ZC|/+)HUMӘ %1w/ ~0,V- &bLaX>MjYJT rh 㭴8TO ;=iOSߠPp( ٹˑG{F lEcO U0d6G޷k ׶n8㲗rxtWiQ=E;{]x"dPfUh[yLc$OB:&rkvmAZeUln(,X}$^c؛;,=ʾ:{  Qo%ʕ ]c=7֪ J HV(X,ބ3NFR9¹IV_[¡:3Z?հ'5VE[ۢVFJIv7DvKHIT怇dBVW'}/KoעٟBRtFM\, t"%lXk S _rd'TlL=WjOo5 X#;(XfMPűK<#; k%:(jn\-=K7o,:3(6~=K&ɡ; wZc!Ђ``o ed>0碷qqL%,IڂփIUf-M,,o\eԾm R$vT2!υNu.V>`ir!=m3Ob*S.w,w\l9N[9r_4DfNR$=2kGmѴt jz T޺EbZ࿀Gg7\Q0&B;jJnL (NF_8# QQąѦ6H ;=)}y YœaBC i„=8ڎXLbD4K3 KG>e7PMO7${l 1j #]gϖ$VM"l舘AћyU<<8joAStM)6@;onKws,0x2ϛz]._cPI+=MPQ7 `KAtE,Sfc%:u˽wRuyj4 k^Dҍ_l˺; >Ocu5p84h]*ӻ_mgܹw,m MDZdt6 6F e@Q\])S\e&6>ʖWkݑ5AZ݌W0jE]x-C n7)tW0j;LJT@ (%й!]D>?QGzӷXD 9kxi >S9kI6oGjC s2(}@5޾FJ%D*MٜK O:2&Ń\Kf"( :2<Я5d (m]T娒AMEm*"29Z1p.vIP݇GCHKU`I|R[{ Ӊ]] >v|S>s=YskzQKpx+7>Q92`YiE@ tt;/uD~"$a]!J`ӧL@[-0~l(UX" kfK'*dǜ7L8U{PQrck'VwS(]- tg֯F^ ls[Lف'|H$ZҢ,I//ɑlޟ@xj:,ϫBf܊ MZf 1ʑrw|E$cE 8PcJg5W7EyL jcҪE)p C[ 7b1f[N L.-תoZu#-ʗkE$Pu 2>~oN4tB{ : }DVļ!48 fWyDRT|+0Ȇ4Hbu X:>k !U3AUAwOWN$C%f@Hi B&WMkDzA <%3x& ^|7p ,n$>ԃER@=+ Zz1sgo@2YDbu{~x0M콁V.[^u4> A015͖ܿj)ޓ_8x$Nƨ$ѤpMqe',5}E`=lJiXތΗ) üW~F_YtG%ǧzN4^ӥIru&/ZH'?fC2FE|k!xLqk8ZVƗ^8z:"!?z>JC`!\XBP eyg~sYVb^NR ^`?-[ ֓Ė^zPa@w>7X>JYEEmsO,ʡ4e7֖lnRg5g r|֍2ҚzwiHS &a< {?";!ɋnd*@{Å86Q- #}Y/M"6x/-MeW'E )aIGz=dLЧ?y8=KJ?W\ Ln6@<0}@/`֣ķkqSY4w1G-UV~4X |,#%(2|;>:"]g_Ή.lEmyt:Sj7e8 C,Y9kM!:6ǙeOqo;󚝕$K;VPpwT>l@+ ՕhG6?zXm7$C=(bs܈C\M#h5"3%-lV*$N4#+g"H:Įx rJ.$n\#35ΒL`c5yYz|Զ^`_PX 8HN=u1ǭED~=%fOGr!36sƄKYjDX bY=/kn˴i9?1USK֡ (2Y&.\a7ߕ,kKي&}=2?*#&%z&X4hPV>-^υZ7r+>u hxub߿O$5T˃jpVHئx_{W`e- Q^?s 24 6惹`Tx~46wˤo/\q{DpBX7/پ$dqUYp8 }>>=+_}8GG0{6]e߇(XL"o }j?.kNtAPMF{i(^2܂ ۗ2ٓ )^;?#+%jt ݊BHƣ #y6<)磨2]-^=^SnFըeHj2JYj Q,?vda@$H mo>5`uf6:Q U}Y'*E+Pk=Tͣ[苄?#LZAAa3ozڳg@!lΦ7W3jfwxq-v" >2 ߣeGg`$4_M=\,59]kitw =/sPmG4&!Ce ]YORB=7{82L@{El"\xtTo3%e͸"? ~-yήgTJueb} )Ngd=S_*+F`lLUFg( a0^gcN}}t-O.p 4z]Djc_-G\82зY"a˅Rd4ɑzܫPl/6spl0eWQAzȚĎ}d B*z}DG*lWW]UBJ~;^_USN࢒na:5'ʝ?3 qgT.|\Gc!k//^g`8qRqf"`U`hWMiQ@3Sc֌7/+s:Ric; ՗Uy ]yL'2+.jE34H> )`~xJQ2g.0\ج֛7e$`S(7. ⿁q9>l&z8ݟ8 | 0 0MLY5`w`E MX`ǤKTV3|*6ҘE0:,Rh"yk{ HeC{YY R8>'pI7Ցš{ QMal}hWAjh`_١  I$G`"!y60ﲝsqjmpn%/ Ҕ"nh-Y(o~5IkZ;8fy pBkB1U zHSp\r/Bԡa];@ҺumW&74#jQ\WI"mnjXG,LjFy\0&Z[m[ V`,f3WJEOIk8?z FHbڛȰyq9*48)bәx=8f=.֥ u M+?6wqZNsxkYӴ-Ych+`T1%x؆,D+ȡX@EJ%6@8`뒎BR8qa/[t?C{d: HDx;ЍȵSګp#k5ϧ(ǐnZ;Ir˦+j,`П3vMbqbϽ.wVW:CMYt;LSgmfU;xV;0`+MA׽w\u닰~ÁkZK(@)f_KC{ɷ|h= "|w6P ،6% hqWd1 PRF'CJbk0ŵw=ͷST߷g=NF|M+U yx}ZX&}:1:T |ӀpIh,Te"`NExmyvy[Ն+5+:^tRMYXH>J HơoAma)>^^p>fQIXpc"G{p*'<$w❶`nT{BqMX疖{%y,<%'ݵ69BX` l;]M]T',usʰ`4 4?ٝkʸJNoM=zDh=hM泙M*0Qsp8RCF$gT*& sTG-z;9wM>-RzK?> ,WDAP9Ԣ&_ p?}>hNt:s \50ccG,Q*;.; et-|rP:k6hLCiZXmbg/T+Х\Wk}APÖ!_Ӽ;ZCE?? 2m!T8(_*Wd0mo?hZ;]ow+yR>ewN'UESϛ[Ikru=w ,z1@c \pl2aǟ|o=Kj(Hph|1BGQ?}̋>yE4j7ˬ'HW.ͦ؋f-<#zDxmܿGh~C{ꮀ<9J4[bw5f+/ e#P|.dM/:w]&e0ΪJX^'n8)SF6|&/;rQbvQHBEG 9Уڀ"|RbԠsZ|]qSekd'bcȰ6hG>qvG޼$ldI(fٸ׬I%7oթ~dGBɤŠ/Xx\蛾~]j$عO͟B&O(:iNF3;Xj|<$=4'[bx=rGľ˪8 U?P^vY[vE#Y'$y<^ϽiQ@ܕH0`.R쒦^[ƾ]nGM@8[#;jG"E"z+{bo{f>dZ24|4~'RzNE,!+j`-(Dm v2*E6.L$ *us2IW]^s+㛸EszdW.;b:],"w:=BfԴ.x#P`=.h_B{B DԎ)VB}Jr@4;b_4zA` 85h =+{TVCQQ)@7*5AOY!iʍguCaxy Xب$RJLB*5 tg[zi'*jTw>r g8dA9Ee]e,`3KBk1IE,YSR p꥟!!tCz0 !O!h`(p_a36 8n;H_0@8Tof'=9dqgR33*P"tNң5 F6[`UX$!ꆷ_rN{Jiح lgtBXn;pB(K1P0GeRQ'A~0;x[""Mt+HoE^XF:74@U?'h g%W ֠o;?|̋G^R.+L5b/`J f ӵ%W{itM!K%nHf߷vD ~pT8};%,dzk}\ H#Aq#1\po*YtČMTᝂ~QJa'jBƖ6d= P? "DŽ,|_\L.X>|u:NmY@-"b˳j%&jkIKq;KhipG딵Y\s]=Otϻ#Ӈ1Ѝ"ėgh >EDaYkR )b}Ruʧ 1%ܤjIWr*e.37rBqMN7 "+"eWQI>nRs3R[@xcQ{/ RtY8pm،{`HġOI޸\$r0?;xʁEm'hA7rLܲ4')/_)Gn} ` Ru6y?l){łU3į"ԥY5M i7Dջ馈 "a JKUs"&l5X^haVC=GF\`V\K>AW( [:D``MH*lJz>Cn +36N!J;?k!%Rva,O׮3K~'^ii^a"'nj8 z/S>|H1 GZTwtÈV2ʇ[*Å$KWHħD_.E=yDO1hEBךP+CSLҤ2S.dK;BuFtжmvč4#-0ezlQ4W-).GO氧֭ϴ527ԜKڹAEަR qcmN۩~<g'igFl;X07r<$c*b!fTaJ6RxFr[W_\< hr^˃<_S哞zǸZ{gP5wp2 k:5Pmp~Xɣ"a/@146ȟ7en1HDYt .[j NO.Z-`|v2?xtN<*N n3εv n4O{yM<߿0e/ |Ik}`r j^˔}%?Q3'SFޞ)nlṄJ$~55l̬駂v;.L NxduвUwK5DV.[͐?,Zb&WVg=KCX]dD  4H#$ -砹Qc\`sܹ%J <: ibCa 'k[${qQPz'F\Xa'E JU,O+%^4s~$E sL"e}8Q,`ʦ !aiɈ>>3 LpS0 \:Ƌre=Ũ[ĚvuQSΏ״-$u UPa,ŅwF(zж ؇p8{:&^Va#Z*[ #{O4yrG퀸XⒷYH؏rs1p"ʵ/)z;{J 800+[528KKXlx_}߅]P;Ξzν U4I4Cjy7iP$->[>< LZ{so<+hPlG-K,ex k2`Ox*蓨C06mGvPE75@tPl%eI#@]+>t!|H$ƽjH=/ &bUHyMLΝ%v/[ <:V$t[(w9U4R{p YLCK˲Cאz43b8|;0J_ˁF)KW㹘ћ<˖fJ˛=9[1B|ʁۤ'7s/1Ǵsr7ʀlABV51P/.WT ,wP!0l'.~KNЎ[Y]YE op fϭUVG exe_zL]KW#(ILZo:?fT'9,.Y2 4NO!԰g~صRI[|hl~2胀#jrmi75 ICI\Cpp\rwN(&ΈL.k腍?/f5 ; Ugd 2G ֤ioR>򌬯KK/ef.*500;CsG]*׈yn30.ڮ&.^C:ϸS}֌N,Ppaf˯[Y ?)\i{`v@_BA9NA㙝"pHxŬB9XpY(ʄޚz'!ﭯo80F{.^:Ҟ~tҁf35"l[>' /0Jcӎ*-} LkFY W [s`iUCVPKt$߾__a%̝4>@[TMgْpA5 g9!}6让0dYNRyXᲂ]u8KeK%G~/H+vdaݒ:qzߠNvu?y ݬǺv l7dR$vpM[-rp062U2i iW7MmΧD2ۈhczT<g ~|Kt݅s0W[mCt]Y$.@^g"t{=.sTKY:%^Ǯ0o0+6b x\~N]oaFe:MԁfQm#:D TR;!qT߸^QgJ[ .gtD *bz_Ռ*XF ^>sPZz!ʤBu@77p):> *p5ٗx D$r4YOk *kG/̮"HsG HrkV$.qפӼ'Yhb0`mnv *ۅoؚEÄI[?ԶL8 `@SUogi 퐐uN(C02*wtR[0my|ҖI8;Ul %6M9.gՍ:F,%<^1c}><ʉ&-& 2-0.km{Ҡ#RC6 bDu{xg&"| ;uPyeD˙o2V2 iR7^&n^~v{]W%ɢ-ەiOcTFـfjсl*[0pBr~:= ՞c"r*]mNy,OyPeȂpXE_f#2R͟:x6~Z$@C5{昺zUŚGJ`\,E$xvSuA z1S /v0Ws ۩9B)2?^ UiHeg;騙RT>78W;]IC)OV U <mp;p9[褽όuuj,F"s1xIMk|S'iIQ z T 炿ª %a<.; 6A 'ZNsb SQQ3j@#B37D,ANtr{R>ԓᅒ/Dz ׾k!cVr5ڌJZ撸DL! XT5Tf}R萈v0R}^:H0~p7pdJD2 *6OY+݀yePgfx1 ӕP6R"=jЀ7!W)p\LY^N+$#6R/ny\{cz Z8+:+ƊJ\Qi7H}2}eEuC($hCt14)^c1;;>Z8r˅4nq"u.ȧ(kDqubEPiVb:*=-/V?c4Z S6[vg]uZ@POnz(Zp8|@`V}frAi+H%ZqPGbWp}Ќ O~vy>]m!jq^3S9, P%\aӋye(NvџAr|9rgA#,أt%k6tƻz0cZ]Z5tK߳|Jtlk<=%p0筳垖);$sIQH ȼ_KUKEAR;ԩ<_6D Ay"w?zz+@JEBM3UdZ>vgvcrГ^O |ǃ[msCu CL__Lv/b܉֩E춀 {J5 *s\Z[@ؕyߐ`F$`C`!Ri**}@-U\qZ ˸ vI> ڰ9iVp^ӌ&V)MXK>AkFbL:oUóuYz 6B[,l+?J˘P $- ͆.&PhԠ+L'O EuG嗫TN͌V6ZVD;36 f@ O>*ggH9\Fs_=xj9c0=BN+<,&fu -U>$S @f\T׃Vʨt7kR\ޏH%o v+J[RXBMF: '"AĦokG~OArA81A#hfEk\8;: -jMwn3 j12g)rΒ BU*+a{. رN)Q[*'6ږ|T=\Y3]Iuτ~^6,] h/\{WnmW%fğ' >U7!+w10yX1'i^KF N=)eq] :iH{M,XM " iHjsIx̣X8]:Nhv+#Zb1xk(`󡤭J#'Pᣞ Ȍ7C'L3q.G2(4-wFUW9Jm~_poϛݿ#o#=E ^u)oa΍ ?_JDf{qE-A4AŌ7u1Cr8R'΂[t,@d\u/3w\ȵfy~2=뼳e!޹|zr2Xih4WR[vnвa{င؏/Gp.}'pFz.&iI`~Uw:L UǶm?:qW{/4Y+jI5MJǧAu67 NhFD|̅ ?_sv[$GYǖɇ\/XfC 8qlin Z4 hPj(y"㐆yĸ*`d DY2Wg33F~ZmsN~GP bou<] SjMn/4>,&갠 ,qNh6׊ TuJ3;&)2[$MhiP~F@%d|Zo#K׸KI 4MzW7Zkod6}Xi+Nq{5S?Aʼn/ Y]._7(U^$9 I/aVzFPsXT uZ~(J%PJd.b(dnKpqOJcE;z"dŮA6*LSd^dsb~6sÜz2 o!$b#'^tC^+ueoۨ}'S@Y)E|Tߑ Nۦ7]Cu\zm1JeC9K`1B ɤ*KS<|X]oQ*i. .b*0ll@JˀٞnN|c4t ^beD~V $7-r>m.SD_he ,WZtO;XwVA\dLɂjB+ͧ.x MҥGeTfvICs]ǎVz\S%WS5V24{:̚7TkIrj%yG]By7φ" u]d5u8 _#![k>pZ1- ߋ)A$}8Yl5ʁ(-/_Z\r /xEsW;$F4`.oڊo8 M:aaϔ 14V=_`/uj2a4:1 !JS64Oi|r:-)*{c{wtfP;Îfj6hHe5feՎ~*T/u|A1k3_?6z?ZvbK{S;xDZ`ÐG}MG!UyrZVp lҨԪG"@v@=@`53uR[yEkv!,>|Pl*’(4x=[%}V'WhԈ?[ u |ś:~xJ8%ha^;qM]ş8R_:6 1~#to3cmR1L"Ս(btS75=9RS;=zC0}aGI >?M5(dB$_wl}>g ' 4Ԉ (JFH[fYGHjn%08rJp ,UZJS4NJqҔB7). y +І-pkVPȩ6yBz4:1;9>.}A![ܑ?#i_xTRxiO0k LTcO` "X+'$Vld Iri-%VA«Uv<> @2)μFJmmr*b#\@zp?bbuNK, ?WB3k;U66d!'h2[Qa 1Dr4*j u3~"IV~g=Lri#_t袺 wCAj\D1!'^)vg޳#&6S}9lXf5nGm}(oD_.z^&%Վݞn|Ďþ֫!bAL6 <ߵD,*O::[ZQ%w^YkﱴwP@ }49A! reaSMh<)\n(Jw)qii?y=L+ӯبmW9]C@Ñ3DMf-wMi?:n{bOfIhw`} k~Pqݨ=BLݠ<7Gf#C1ODSo߼<~eV.y= A➀O{{{. ԁ*<n9rq^P띶C@ڗa% !d/UdSwb[K9Uvd!,3.y,1 =ħXϱw(׫%}+T i=p` 瘻K94H E2^iMe8Nk7"$(Ufi£v_2$UlF6r# 0pgI:4#tKa$e(]6UZJ*UxrΉ=m֩.\),<lQN4ݍ bιQ״Aycx(Mm/Y9| {X,9 n5z/nJf}l90rp߲.Jڲ¯2< =!7t|(. e䩛S,=`4<{_P $[ӥkE^c)KሂA }"9ƀ}NUlF1*&n i$!Ðu(,Esl|5U :MƧNr;;MdvvܐEI1ۜs%RgG6 =9 JCޛ^;ȅUV&DG$q賠X@ t/bC"^B<@ ,ݪQuŸ ++~`?5q1bmlP# PG>4S!1ۊE~=Jvgb !eIkQg&Z:W Mİs!_z{+*φP@Yw`v\eCi cka5t_U5[[ƗAL#E͔-ʮ]NJKkGGpEe#oԚD~$C&-_i"4`f=w@2w-2]XV!| ꅽRpޫpb,|FVK< QKy!+ R{aŋ2Tzƶ&h oav `l0,AI;@M-2/F7*9䫩kuQ T 0(YBfY߻[IQ' _}Rz$h}85u=Gq\`N/["lrRfGh/|Ch4q-.(|skݕ#c/W,+(K̃xE TȮ y?fkތKPG(P0Aɵ}tgV (y226KrVt]7d|$Tπ*Kۛﱧ yXVeU2,s\s_ٕiKK%ǔ?tKczp(2Trh2Th1N!r7 7*6 bu"\O",6mڧ }c2Cl"\Qj :RPmF3ѬZ:m v6P0_s8XT )k'34"KJ4K[5 Fbg)^5+Bdᦖ\S>usOzާfHǞ.b>#n=Z)ql]b7*j=y[m\~"e]!V3c,GpReQaدO->fc\k( M=a8P *335;JYGmq HY^WFl0<`;w⋷e-z+ۭ|!Qd3l n^y;muR%[N^nmP7}3!۝nlq`7O뾈4o9^?[=pn=]~(%R\ gSP;v!hW1܅l|1԰u,UK-ŭY C-\>-ZiU/O!`m"/YcP[Tެc("v2ZVE"*SFձlMɟ#[D/>JXUl~/ŭqakEPaEa#Ns@bY\#z%3e0~WPU{FC6Ho_ ")Ɵcd).UE,{UJ.lb$vR牷§\|ӓ$@\YnHܠEX2(XO]MB&C~ =-ԆہsXWudon= Mq6S4,^z9A>fQ  v~x܏OnFnϙݩUV M X װI Tf:$ L.gWoH8&c~q !<=j}6ᾞf۠Rޅ#8rϥ d]y=`R<IџWBL(,%ab@OLa:dyj' '|]@>z-䯋Qmq܍_-ܙ=ɹ>fQ\"8[K7_%XQ4=w} Y߸p~oYb(!Y,CFlvA2k r|.#.痋@CrAp-Gv`L O9# iӉ8W5fn#X~XFWpp39(y6RkIޑÜ]ƻ8hirݳ]P~Dϡ/7"5Uk֊Ov946.]UGZVIvL.̺El"p 'YSu$צ݄жdPACg~顯 ,:ǃvʘ147o8o3N ȶofDUE#"T0CFVVd BЩ(0Չ l(~2V:0+NSx$ڬAZf pb>`DHX{s7fM5Y5FHfD𘠟H\Wa@H}zչFe=j/'L[Rˣg^[,1m[WU%F?}T/@|n ͚IRKNE 'Ѷ@HxFc̓Q]8I@ Ыś6wA36]m{2Dp]䫙yyyƶ25(Z-Z~ʝh'Dg6H_ϤrQ=OZ枙4wT(gcJ0nT][4t*\U]HxK)̓?A|:'יsv up={`w ;VM4WA>.ͅc2^oqn Q{]ghsnIdZ"@wuZhVf{0X.Jz#f܀ ݲZeRZ6u䨃#2!3 pڼ3k;O$UvI=8tiubTC%(^MKSo6xJ-8>ѱ82ȗbCDAQ7*2 qq:8-Nj`y/u˚j= f gXuz(̟h~Qr zg1?@%U8or;ʋ6Q h[K,ƺxo%>?l7.Ղ[l.taA(Ϝ9ӟĵ"B׷ZN-x쭧۞LNG[۞=Oo"UϙEi\f|^ ؔ AgX<ϚC`Vi'jCP1ws n$G<ɸz?_i374+>UW>Mqa"s8ʆUa`r%$"eo/,V!'OԈܓ͸]q6.IOt60uCu'*DRg'h؞oYD:jynp QDK>he0m Mnd'kl3к݇F"FUG4\x"V%u-nZ҃`>1bѩwjw"9quV,EoLZI}߹9 NM^K $dq:ȹb6=R೷M򡢀Џd.@v;KYhY YHppod7xkǘ4ytev]Onc/<p8l`qFfdh v rXSP>o'{ѾbshVpьQu'Ya}Mr؄!okIwVڊtT-y߀!ߠQ݇r7ttbUGw@ÓǪLORStױƵoVXu*TeO]ak+},%no0AJQ*@0fIwyz1a5krSbU'ߎQNYqwNSߎBGg!"]îc.m-UvN[\ZBQIMmjBlV t\LT$~S~6ljwPHo'ar@H#ۍoFg$O~JfScV(>^$ݔ^~ҝCB7>nc"\-cb4)ϥU18~tɞSi5&c@xd4֎dGn6`K肤؆T}:C#.m`*U.pj#lyn~*[ .ue+_lvJWp:.M?ar  f2A8}|͐+if˻c⛗eԄ3.Qj*#~J^|o(#31NÁ3D DϝRN Q%g\l{ 1 n!OK oxNxizϦP}εF"Tđc3,EUe >a<jA3AFV}tR+J/->U$VX[3pg.J+qȇ|$Es7&稥e, @$xarh6RۨmmG"$2)'(r+7Sqp:,0vk]yЬs.4I@"K` !jΜ;_P$/CcLU J̛yH`V{\!/-3FXd@Iog {oo?'L;ڳJM> \^eaMx kA9urwqߐ4H[0JGB> {Arp%6x|V2e4f~V&`ꀪ+ehb6"c$40U$c4)A~Lɞ6EiA"ދo@N}ur-y %e_pw7zgw1$݂oISg UXd': \/Ph$G͒u%$g537dc?4NmR|7[4bAv#i?.6D#URo [jt&ׄZn)9vkUh^k<2R|r=#gDqi4pw[i<rP `4A+ȵ@Ks;:7u/%f@ݱ8TqЛrׅHV`UbŢ{8}Oy\`^5GNr>@zu"ǂX.Fzyˆ+r?G 9μM #UWo9 U;hlv A6J-D'sʴE5^H5,HX^f(!7RZHQ L?`sOwN]kK,*ytl K.gA4(h;{!o K7{3x L;3p:[X7K^rSY }(qo8vyk9Fz-]#0gZFDOmTPe,,O"{_b׻.K̂8Y~ i<)ze] K3BfAgPۮ6^lKa#-׿D#GK.I cI!-7ŚV%f᪹noT!-gB5^q~ۦxy_|1\8jg4.2kWɥ@M8[͹\_1irVъʴ r0$Z'=87{3ů!uIQ ~ oVij">Zy:9{Fw$K1ˁI!n^ʞ VFJWq;2V`TP1lw1x(/paR ս[Ʊ1"$W~[VAMzX4ﳬixmM/8%hf]ԐE}7ɉ[l_q8d.p~) y,fFR*mTK'n aMu=WBvb*Bw_>c) Z1{ק۾O/ ~)hLUIxf:O7`Os p™D ilNx3u.fXSMtHjYOHFE"F/]άQl[PŏU(f(Nm傺I4 4>{? TѦe+]%fѤlΝvȈaДLB>X>ʥ~}:ȯ_H?ڡ@RrdQ{ 2>1rqDXa/>i) ZDUʪAҶr>@ smʆJKKᱮ qRh ^:X۷@UcHVPtR.^v>NRY$]_w۬?OP~&oPVoBL;<{ \#Y9"HO 퓊MWANlOjme*SJrLS.fM-Zj?$ y.?_G2(%]"iJ܄1$>d U+.\Hkz|Ρ8-d! ZJ LDyV]L BVY;s_%Lx_-B Z l6'W'^N=|Hg Re m!F2&٘j kǠ2 R~>V){T+;2)(kV#`˿a45/T$?Mue!s%7h2˵J–dBVIeSade |[HMڈM.^E8O~!5buB6[*|)AO B3`׵h&T 1$eO/49Ojtʣ|2 "^d 5eY[ u.&\mqΘ(N@>Jtm,B14? Xzkn_ N)g(1S Q5Hh1@v$Tq૒h)Jux8\EQZ-q2U|d\pB4t+H ڵ(zɩx1YUx0`2"JW0"jVh/y@))djlTEazʶMQEGjзt;6ҼoLo(85}+l/.0M2UYٷU}xX:8Îo:y,:ٳˈFog~deq2hIW! :NXgA9x-_3+=` WCV 5g9 > ,;y񟕦.3lVv$|)+-ɚ @x.3696 "GKEϽâ*ukIHR_W'wZX@0S<'luejGʈJ_O.?_8R{:pw1{1lA/:T} DR&bKPrO,^~IqA%([2Jȡ{,%w!J.ȖHiKzH.oQ\ {Z"iӯYSͱ5iPW7yoNv{rlfFb}ayܞ1t#4Cd$w@H_o,WIz.Qht>5m]'FΞ|И0Qr+uQcӒNc-(A%# }-T7' JuVhL6+xUx^y> 죴6nXS2okς0V8|_0^M 1*4ynجtzf'A"!6xuݗ6EQ0b"5]=C*\O]@ˏ) \Quoc?m.1x RInRLjE,I=-dLknY[xo\nm`}݀tS1|A7+h5q]bUGk29Z`ڙe#0Cм}Y}߳5BneCO 8]C 7B3{j`~aug8ZI^RY+c|%SUy[Z$^Yf1>ϲbyǥ~ sGijMƧlFpHd} lv>Bxlb;să)T?on,. }Hng]{:ZXRށ} vFhA-`' X5-zIڭ/(\;KPȥ| C,zv!nw^C+󻆠?{0Ĺ?ض;w!wemWl4uȩ-G2"$J@CR/*gezc\WD+cp=Od.lbS,j,zJؿb:a9 j`ӷQtlY0hÊn+V3:K~hy¢wtmw\hЯ cbQ9ؖ:/(Gaπv2 vDo*B-{Z9IpKl(n"SV'n(.UYSh8ߢqcSB #DL ľXSj0l_8V֗}J]6=R)ۋtHbq&`;a]BF uyb2&4=TV؝l"#;*, Q M?ת \ Fq\﹵\^({o^L#oO뫣{sb\ /1~B̩B@hTp Zӿb^O"*0 rӤGXH<hXNPŨ>P~=ETCM3,վIݘ'`ŸmrAa^M:N'@VH_з/]n vDAh8I[:,O o(s$L)2U 2$ݨ&ܵiJCc}c]jz[)PTb}z?ˤ{$v)fr3DfyT659~V3LSfh4جBWm<0 մ+)0SOOn<̀ ml}n%|eГj0:bme uzF4*rENi"4͸=x&8=!OƳ{0WK{B Ⱦ!G=h ~|>R.,\Pۓ>...7-"?0Sr]fK.eϻ:LW5-!ti='T!zH,7';$R{`iOG:(\1x ]ɛOn^ϼe p+ϔ!*6hdt~Fa@P|׃cUPg=0BzP.qnZ9aآ3P#־`^m#q-l+IEf= iFLѴY#-sA -Wl޾PsasO+'gT C#t~79#d mu>73e:=肩0BҏݏܝsndTi[KE#K)2ƀ=.k#&C|=\UZ󲥖NG\I!F:@ɬش j=#';7\1Ҡ.g-"+ph[X.酿忧Иo1.AK Xs򜮉/1 }dǃWEy[7]w ,C%Ȥ6dŮ%($Nqb0)FSh")Frؼ:%v:G޶dnuer YRVͬp꾂%zũ/ji-ioPa^_2E/OXukxlEo%(?tIN⃨/~"yf5 ?0D-an?"gGB#ٷCnQhFH̰4(ie*P)L3U-2P VnySIo-u8G%W x}kt:UsUmM%1p؇=nԖhxY48Ke[8W6frF̦Bwhx!nON II9l vηl5 Q՚r`zjoL$ fx ܁7h-zw"L"A R2dW NFqJCe!ɛa 2+".׳[,5rltjTq[ox/<˒O^\ u@(Z}R6 `Bu9=? h|CC\m QLZ?z2{6aoկ0]5CGUK%˕uoWIɩ/<7L2Lh==(Z^R~A'z=i49* Z/BM; _:XZ/]xy ^eNO b<#۶upT9q:o  ^( ϣ;0?`d`(6ϐ ~zHщ t;;kQ]0~"bg3$PK*lъuM8qkρS BM}@@("HNy`xb: X6's.Us&rYGdr+JHsTaSP+nVr ;<JȆ>xCl<4Z<Ź1&OsFm~a JkO?V>6.l7u#W*iL ːt8 Ƽb 5Zw7{`jBaibC+%Hw.jc֨7M{ Ԋ-^qoиzOXv% { s?'Hgly&YͿ.^,l!`jC퇺Ǐ G:Fhhe-A3Bi~s7RX-SC#oUڳE[-ݿ'5OErCnérdL§1PwaI889V7b6G4jq0%x3" eUcajm:BW" t&7Vi_ P, hB'L Z@vX8ñշMn dk^_UʌX-F}jۺw"uΰC>/|SYO:`ZZ%34YAT'3!Rݭz\TCurNJ,N%to,VVZ֯a'4k#BD@! D.zgMO.UvHܬTtR~K$ogRo8S(G=n\ s ;װ[.r]Ju1+\EH\G YUQsLIXuet\۵ݔ]W.ɺm%gd)զD()'= *;ۍ6>'!uW~Y\f]*?ԙW%!j@'AqaW_˥ɭt=֫KSF-t|3U"*͵-~LrGO8"IYV*RvE?X[U~}oX/Ş3iKӱ~yUޛM51=@ہqL L@7k蓢g߽|3v[5dogl,Z[Mwp&tȶZna&tlH*Ї8|ߵg5ƆA;jߺJCCWSɘajm-2Z>[U_6PN?b-%kXv.ˬ7(jV1 0qFܧ=y߱!W[Xy1ܤ9?XV0=aL,%F 6v\-޸BBPQNvKrR9r[>Wy}Wҁp`pѶ$Tp@sc w2-l<ġ_βEI-ŬI"ضy ;CLGi$lkN2-ˣx>qHz_"Id4+Ή Eb6Zcy{cm@Ŭ&2^Rz@PW@vh7.N.2=~9" eRqu f私#)(R9 `jҪU˹";/%jRc:LYBX%Р0w|ĕJ'?Xu@¸kHT+ 6"J4?_E7JAyO* O{h:}s,=s0m?: PE?VNؤ GlRt:|1!|hQ"6֟l=ĠpFFRᥟƓE XW\ ΍d+ 'dzYU xi#DhGRy'bQ$DLP97sH&TRS>Դ@hY}Q]V@cìhW9"bOZS^`${ܹg9.&=i=%\3r|q=~Y%fp&dr=} C@y:AQ 623+q}Z }їnv|hy|49&7󻏘gj uɇ~=+#+be]t7/ۍ&OMc.N襶et6NnN1"%jʴ@8o„R܇{k9;ʿk=i,,A'˄ 9@ L:6R5_K+g;ڃݗx<uqT0,t§+)+,#Vcgb;V{s(7ԨvcAx?~DYl{ķr|ɲ]J< 9{IM`%L Q"ZZSmڥ-vB=EAKGñku8.c.W cifAzC&p0[u6"m5tId~B`lHx꟣w//nߺOB̝RZ@0p{b1ҩyo+beeMr杬XTMOF|#F(Xn>:*L.H]CrYӬ~%O4KNEk2gKm4wW)㏎Q@+fKq8޳6AZ!CY Q"ޟ<@+4ЯRh&*߅YpZ (0|^:V@k|fx4@hw Phmݤ3/ @|w+eo~X|ceN}-^/Z#}[n**$Xz;Lh/ )`>S. \ӮxzKDN6ƎRXMt,c0딃?/x=q[To zS[ 3]DxL꺭R^'bbsZmXe쫈&FL(9ՁPgyB- _zZ-j'&WF~) F2rn8)*. N` dVB.࿿5q%XLZpG7MmX#$Cq@ٽy+m*~̳!:@iQx}?b8?9VLgD7̯VUH!sv%님W׫֊$c Iz%ra H|=R]C { |r-ːDwF+#ښ˫W%aᮚ*>4[73M#Nk<}54y_0xbuIv ֶ3{{#t+/M4oWަ=[BZX*c6c_x}$#߬G?P˴FUMQLol7rV|Qh8*D '^ REX~3{&vm"D;99?F-o{KieZ_(5 VtN].E̎ALf2i8ڊ%ȱq"~qø@B䲶.S}E^hF@Ejqʄ<>A[Ľ-;i<4'\B@\^%uv[@ '`X >B51 SKi}38E fSQ Y9#]N BHoUv7 Pظq"-{ ;,&;S=~Ȥ{ ך=,#7qawJm/Lk > PCnD39=ȐM 'f76o٭r ǁ, \ɩ" gҲfktAR.! a$ޥaⲰ%7Cw(=o 2πFy3եWmY!H&!% ;D!2oE@L:%CW p:f+dZ줁 HJZh-eݰadK_m?=GqqͶ1E\<'W7IHm])2MlU=o!CbCH`${2\'9nD HRFG+}ir2vk%S6?yI۹Ф=yF>N ,>6S Nc3V0?lv5i>mGjFtWnYE7)cJ#|EC88 ^BdeA -PUq_^qOzJQ|NOؔ\ق35aJ!cfb$;F!?[#a'Q/fZ+[tGvUU{ g)懓 rV@Xzn@j!Y@IY 2E`9W (( c˸^/q,< KixC|2]} e`Q{(`G)Ѵu,L;EU-n&2_XS XݷYBr!u0Y|yUm7+?U\Ui?wvO֎t6VS Y16(LK i]U>of^H޳%R۱A6U]N?_%ͷln?J\SR\u?'Jf02ʴv^4ISt;]Qbfh/ 5`6,ʑkR'ϐ>2бYK֬ornie}CmB}` '+D3z.+Y um&4z8Y1/$4Vtiep^ݷEopύrwQ;Xd^gp+kz`7&2s޶Fx |I,?J|:>_ޫiNY2z==4b?)|mnrf1gFxQ##R|hGY94$0<&Kd2fK< fEJhX$mH, P 0Onqu'd$o?7~6dP{+twuBV<[/ D뱭c7D&5%۠Q , oamUx- LKE4GsUJ\a:*aZE#V-h=ob1[󥙪nI){+zZ+wn z L5SD Ri~cYLރː Y4f',dV+a|~ґS7ޘLLG!FƑ{MM͏Hxc6C"Aeq},dZWo;j7rrkc8udd(bbdqB^ziaL =@znͩoT0{{\azELĆ>|Pދ7?ԢZ4#=RcH&UݧFk]{btJ8NAmoHWuQL|[ï)c;ʃnxAUF鈔053 dTn;с4F|mrEw._#,BW㋷te嵕1@^rŬ +kZ`,7_)mt^sൗ̼Nvf16oȮ2}4ݣR*w;Ȗ i7z^YHsdjW͹\x/L?Ⲛ j) &6ڋzS9n}1)ժKs+䑗-g+'X/M^,BYjcu͸ h1{1"Z}FxubϧSco3m\5Ad.[Jn)蕟A]8(޲Yr_K@R>Aca n}Bni2D:#e\giXLW,־5B8 6:PBQ+[<-tl3} s ;w翋Rnpie6ɻrU WRMlA$Y=$i5o&H"):: F|"tS8bS(W}eӾgH[x9οQ8Ea)ĤSs1LjfCX8[ꈐ39.̱uKխ'Y娑O7d+i8_vsԴ{o3ѱB5VUxn%)Hg1/2֗s 4vV9'iK|v%y$#ggf2tNgG{Cq0G˺JZ`POC5h5 **Ac{&p_,c/~S\#pO63!T4̷ƄziKVPV^a{fH}h *e$37$fdKkpe#BAc}>A7y1T94# z]A7#ŹZ^x;ίx2{%_'Fu3|XVfs?ԛZVKZpkA!2GḁᥳmPkg 'Lu/4,nu<}J%c3 D'_-¤1v? tv{cO͔ijIe[nb2p`I pWO~Z=5lvsĝِ0k/z噅ާǂNɪs!# jRHat63#zZ p jCӾDau"1`V}LeU5R+ݲC`97 Ρ2avee=!|Nj.-%c 5ᛥ/vs\#|i a*pr %qA(h'Fx[XQX2FXèXR9&w9S SCEJ QWZL;4o%k70(hmƨHEw,IXQ'b̻p؈~FNCq:J=\~4x[Ն09Gŋyϱ \zR8x= aѦ~JU4iuO=l 7*K >RmN颜l{v*FuM|0rTD5߼癦߈ȿ@~+ye I /Z8+OЪ,[ɏI/^gG=Op޽cҞg/5 a)Iek 5}|$m[tflmEz>Ɂԟ_*E6]¿4]P 41R{)Kڋm 0՘W+P&>I~4pe1 T+<"07QbV,i~cq143ȡyHbϾā{5~[\rMܗ'HXEF79*70e'%Õz3Tf}An盁|.uaa!-P6F遅ΝI;9<` +ԣk1,uObVkNja6@u,SsUx/"kȞg+CUP塉jx).T+l}7Qbl#s Th2VP'F+>'S#!"Lif3B6eW#f>V46ɽ'Izc e fsƿ9i=5f5| $s_z]{2^ʙ:Qk:,s;QڎMߣހL)i|)N89Q,Ƥ=6f'O,5|NW%ļN1s5RFD]w͊cPm^t4ڼv."Fb̹";|˱w /{GeD;'.-uKKmMGUn]HUy:T,(vqnhEO^lSYV^r l汏~ gw>qm>p _yd7P+DQF>#( EP.D2¤Q FPFn_Nʼn*Z4 C}ڒ2~Ȓ0~1dD.B( ;e~[ZXƋZ W@P)oj4p݅C<5f {8LUG4IJ4dE>֯7Bb 3ek^+S?.[=!֮lv'^#Ƀ>H_tPw{XN'3Kz 2߽T9HtGx\8ےEtmڡSЬET))Z۫U;+圖ekaۜľ,tuE -Ԑ"br^gžP~H2x}Sgi'lKV_$7sǯY;]˛ j]yfB9~iɾ&H&%Ns4sjVlLǐDÇ A1>d;sq9FEnTg4qܔU` @A*Dž(L4QlEF"|9ܦu&~ 'GVry`oCRQZD4 A'UfͰt*a5P™ NۘecRN;ăE6S11 q7/0[S${a{\PGmZ}s.4C :&8e/1{JxqUcTP}ȅW(dݭ69J_Z.51~n~(wpM\ ~rl3,ݔ>^ ,PoSRsjnX.PݙzyLOR ѧ9ztU ߘud6,`=y8dmtDAIT C")d.?Tla;Ρ 4X Pb@}rzIW$CG#p wJuVC$hYk;} #J9y]FO@dT88[YƊ!<"㴲 stv;r=/~M):J>Kȃ4u\9p h@=h$^ʫ$0Y)f:1?v߉(yT^y BW;P4fgHUGL"yX)]Ӝ\ʟ[ZUf.?eyZi rgJ4i,8n.f_Uoʘ!Jv'>7΂`uuaWl@}Vղ;FA+%)L7A)ΧomdiS!2pϺaZ;'K5XSht2'DWI@QJ]1D˓$O[Chq#A 0}O?\XtqŖRS̰"7$ǵA3fdQR᭷nχ$Ck:pMCxk*}fI&$SM@wǁ-1Wi3U\BR:P^~?%pG(U>l#jIByI';VjqϢgRPZ:5/:*M2Ň,byg,srW{XOES(%J;Z`"ګ6 E̲xTtѦHG$n\O9"tGϴ Ep $ @"4؁$%Mn:D $PK86b}rAkl!CCgki*IkduQa<mLkqxm=9cv] H Jߙվ&DZ@)TR|HuT?覔IK&QEҠ.=Z_YqJ$օ\)Q}r8~([6x5KM Y)csa?a/>qtMoHsNag7"@y62]!UFD_4kU=sv'̺F8$&bPfwhzC-g;'@NLC!~~Z4CױYb_hpQj>a!9%E %(pG +sBOE1&&Κj̬gwip 4d995MKM?wXpE*KVACPJIY#~6 SN3YCÍMa`b"jձ^ڭr~GX<~xc6`fx/&kES vw-=t/uԖ:d($ܺ"'DT5'{ᑲ̭쩕S"Ju}V{"T Xj. ;l8˜z _|ʉG:Id]amHaHl _w)1H4pN/AКQ5!7kv/X}Rb$i>Ĭ8' nrt4eO{7N'kunvMua[bQ +=SY{ /+Y.k.O-2 |n,_{&B5Es 9p^ N#:1DSŸ~ȁ 5xUA;T0y_Ԗ2ë{pVqjk2k߀,(gwZ);)2[!\>RjGd&;FwP_ OLީT*Ft`"*<"gTv7cjf19``'QpAP Ne 5]$ 4f؉ͅ+"4sHEC1&g\am|Ml6R-_Edƹ'-!73 d"?Hrrt==~dW7pr8PzUOFC `<4kF$08aa:v/ JF9{5$ARá62}tR5\B6 t!7F_㫠}ȇoS;=fP*z& M)^ )+ݪeeFp.HdCz)4x_DQ@c"(;4Bdv6p4ǻs/ Z*5_h]}G[bmƆ"|htPǟpDe{R>yTll YY%:{:x߃Q>Dd)* #XZ6ʯB#~k큀U=W1 FA903LD?n"~ϓ>[4&7+5 eNx_BA@qMZz8ԎgR<sSFRӒ3S R dF'J"ARav_J]׽ (Ջa^VyLX cSeiK;.,LWƾ/ã*o֛N#%-m =õ \(lE<?iqBJv)D.ZDU&s>ٜ̚hoAw<h ptxeVqXʠQ"7Y-${X79!~͹uFz/7Ia5sG% Cjη!2Y'gsced5c5Nf' иYCOn+7:~~M"z6'=땠{~bȊEؑ%ċ0ٯjv/ɮp qd@9B6꿟p`'4?GNeIjB v߼t.= 2wϠgm?ߊprv,!y0Lɺ] ? #&`Akb?rC[Uʺ((NWV!N_) Wħ]v63 J: 1[Mx4t=G~H]tzE ("ŒlbH]ZVWyS*~^BII#X-MbL3lrx4i&+@9Nfn,UqT9ۻկzwA%O ЂF\7gF:Gçbg%WLӥX0Rg¬!Z&<]-hHG!pًw&lU~[|)8tpoTm9C u K/K^T t=)Qj<@jKZzPR=xSw)xJO6`~JQK8uFQb+VNt>Lcߟ@ `f_4JŎdi;TmyxFÊ  dcQb=-tx;ٯP;~IE'U)A - q &tC3WLщsnn3f^ rfBJ8*G$ߏFEur>bh7j> ظ?`CU6]jcǸ0hybG˃aYGtHWϖ9yM*Vf ]%ߖH5G|Ć,m)Rؗ׋xp.V.Ldp?_;}>wӈ$= y#wC/T|剜 ^[ ;3 ", txcz\WfPfxy hb.z"?w1ABX@g.%SkյGX9)eej .wyGm.+\7?;qa! }i%+rʟ>dɡ1G΄ z~a#,ޠ=i`Ɲ&fLSpM.8p/trՐ>j$$ߋDk&6 u~(,/XZͯ̍W0 ȶ큩/ ZfK5vXF)pf #HÊb]%W[lL~/Q*:Q.>oR Z%yaW Ўnxc4 *lȫ&+Hfr^TZ 8{XM>;sE2LiOS-۴Ps2GPH%~8)"$f#GzG&HlKd.ؾ6\@3:(#\vM? R'dk=Er=o0Vzdub%#4ǝ:7қvSڽAW|a-Ll}bu)Qm>;oBٝ`dX_C_ZD2t~7ST (^B6xۤxhtyZgǔh{_i@ݔN@VJzxLlI_AV+Fҧ,蟞v lAuN";m|LeZ' 9G1E-$MBZ|o~K! n薾hdbP-m%p0רLo(hة"MBڍVիTh.enT_F3AS1;9`"7` b*i/4Ka%mCևy$yԹj~߃* boB%#8wuےk&#<2߭"țĢDaRJ$TBntцblLTMY._8bZ,V"=OrlNX̷xr}N?{; !KCz~Y':y-峅gxlZ Z b]ocK~z Em(N: 8Q v&UɕF'#ɳ"f9wBH~{SYKh `?g?-RAzTl2Z(?nZkE+rqkQu՜:N6">PI9J2J"UßȕGz}¤}Bud*ЇmlW˄"nq>m3^<1{kȶ繤C9+8~]v?{m?)WZ46[]ä(l OCF\d攗27.#I ~o ?*IʀLj v2:v8J9)k'|ǷRZSRhQq|uC9&zͳ+8Y3;1 y=)nNGĦլO{!=4)1 R{GXu?r~$7>sp8D6c<c -9]٘]> g"݌,D!ׁF?<WLs¤yNPka3.bb͐?򿧝TOjh^(}GU17{m$6%64K Ns͝r'Po31 Dԉ`kTCD ~f0=uv&z@5` `D5f7-xטXIN&^ynp%L:Qx3 ,V?PDUK=ݦ*@"1FrL \&BO퇚|.JaPA)n:_US 6k8lfڂ!+Z|*tH&*Ϟ /!js#]kͳ?~ S~9Jڗ}q,eMP1%K!ik\yM]1eNI4\b7'5GO RéojEH]ϷlA\1= \Y!C!SrO)) ;FO9?Zpm#.F SZ_xbA_|4hz2j CwNLnA Hl»0eyJ|kIE@Cr3 F>Tvl%K`_#)+fs'*/+sݓuBH'2 Oσpϖ\ ՇJBi\ 4˽ŎNSK9Xuɳ  ~u=o5Zr..SQKV Xo`w2Af4/WE4hݏI䰍H6986 AbM YN$#^L/<9 Ri0G+( Xٞ_l u! @v7INu?*]QS7Kj0z*YuzV'gM'xbg CC|̚Z7M@0uޑܽ\e/A We S(=x: 8+FqM 3J3 \vh2i?^tk^>kTf;gd,ܠD.šThv1g|&{N5)&'Ե»epqz:{lKmo'Aq yc 5yT'lqޢX؉~±Ι[!;Wm葶B9!F qA.[OXzQƢE}:&U͕Ʈia5wtJR6T&zm&wm0_j 8aLa)p*x{M[RFϖhMkCxj 2i{>bq\'h B{ W)R__)p< е|tL5`RɰR c*1ҎRb_? ɰs`{).t) 9&)3meaUK`4Ϡ{?d߽U&E'ԐI?@)M~. ":vpv߆|1 סh_L=7!V9k׹IIH{*inv {w vĈbŠD}сi۟!9{:<d XAm0P2E|銘H`ќ&34c^u6JwA%wi^]JwxXi:VăBG#JAH9''"xL5e)A\Ho4N"=n$j53ӸcV_xa*psU~c?NCt"QF47,!>9,%zg /oY-v-_)Bhk5z(Y 15UKlJHf>Mdn vA} 49ߴhh'F BV ;S,x!؀EOJYq3.uX#g/j8?p1B[1`/@x o9e(mדƏܢxqpݒw];cڻ@KYlqkW\:SowԺȥx^!f5M_[1kqk՛[qar!XĿ7q8@yox{Ά b[\6?$kt[F#D hVem7}xâb" : 4da$}CFx [qP'UgM=F6&녊eg;ivx)̉ȏ MT`A"Lr JVDNn݁Ju #pgB23۽G,#56Ȭ-$ʱS,ߧ߀L͗kUT+ .7ܭ<~qqNֺP~~J ʢbmy"eCX ZjTOzbd׻?evCE !t. Kji7٠bʀ3d 5آ}MkpVyHFDνT) IY0iP]٦Т Sޅ4Ŝ]|w%غ'J/e1֚Y.LԦǤ67KsH(/{cN %jyߤB\S=uQN*B9_@N:mVzW `)K&uFIגUL~-D/ɏPo.g ˫/n_`V,+@SLp47ڟ.jab%8Xjx|P3ҭ@_gy?sD`RRiY vSWnoβznyWE(Y8%f}tca6=o7K1gO1RP-S/eY`S:4Lg1 Q~Xcy1tP[YH ( 0.FmO < UJd< -=Z`9TG./qWMOkI=R<`~XW !R^s,s,DoL c+V!O)t2N镇 6Zn(M'wm͐\nO; rcVA]ٌdX> :'wZRw¾ZrI=)e#d!|\=q! 2ä ~?x!tT=׺p2.Ekc YyЭ>_]gG]{~$2DyK6n%PݽdY&OəmݸwpO^x _;mJnƈEwšӰRLLqRe:鹩_<|VjxB o//8[$bdM~BH'涊l{;T L_.m_!HMcGŌ?c6 nSVi<.}Eՙ/ʶnL'7kҿd?|f%z;( ʸMG,*}]p *'e7,:2JKh^NRulu^B@7ZrO2tR˦V`\+3 3A 5; | W_CJ08ם_"Ѽ=jo3VdjsۛCU)gc>rg{зH_fT*vwyN'Y,kmzݝ'r/C,('P[?kK'дyœ^pIoho@jHK靭Gg^lvQUgD4+,쥅Vp5qዯ f>?ѩڧ퇰&4GyR*Fsg$*]C "gTR6pnE{.xnԗtC~rЮӛ}'Y@uˏ9cɘ)s$h3WL$ &,k|un<4Xe$ ׳A V5¡t8aV\л֎)H>r2h+eFrpQ,,R>`Yq?%mglBd)}5"C~ TY<=<M=:W+P,  jzس2[z㡜'=\[kbݸ,g;˖UKȘ9sD$0yf*E- 7XEv-.,*EoP#52&Qy0h!\t/+4(:GU#~ -n\S&7w)$gBP&Z}_$ґGU$ƑO{)à^oH.[۩߀L-Y,gLm˚gI/HݍIXQ;YuB$6aD!ۉnW_,w:">nA" X ut"WoT}%e. TjбY-](nuyraQqCׇխg`A}Exrx20EB ˋ?y,hxP l>Vڴ -GZ y#J Zzhb; }.ӊK&D J>3dmP>D^l϶uWN%7V% Dr=o"KWW ~.\٠U1u1AGu,&1Bqǒ n#$Q>A=kJK{j4T^NY 9'@ &lF+_wQ ]Ii:n[G7# ?m{q{VY++yUgGV˪J߉= ) DZK[\86SVڕg-X8x9,*cw0]#Q"YiK>г6LCkx꫄L*YsUНo\')>$.9Yo&W=?72hņd5=s %*3bd/Xm* ]yDm<8v7,]?TH5M|P4UHY[pe`3wl0(}VAh~zon $)Q]mgF歳 ܕvE#:xZ[>sI|tDQ[KuLty6! )Es|!d˸d@*R1 Pkk*حlG Zxmh[w:t.Zun-<3&{*$BW^|Vq1c\*KkWZ(D.Rb؅|B,o1Bh LCnՊqniY%jn[ec%iW!. <\k ȪDCјFG5gϾ!]C>dnk)b3Llt#徥dG%HEBJ)@5]]1B1&^Uw&Ķ ҠxI#1Zx|ANVfc3LaFnH2!e.^/)x.?a!*uRpۣ}|L;eEfw\27+I #sOO-` ;I(> ?wPQ͒70m$Urg Rd x&8?B!(j Kmރd1vp$HmbϔfLc@e jp,rbйFXỦf4L]ilHד'c?:z~ІL'.gSDUqsPHR<ꎼȯ-ݷf)BjIͰ^ub&qqlqvք2cRF̧:x(ެA+8\Kθn<iH'1sI4HDa@h` _+y"7f> ?=l{s Xhcas#J-k?΍r@S?7;L;\m a U_2I%W9~8?Hډ0mr|03srzN:̡'ˈaC4 u{vF#ޫeO$86a L탤(3['6d #<׮Inz-3ЬbKUOsZ#Zr.eÏp*U3R'D׆]&h;L$͟6`vuTsj: g@c!";@TV`VNFżȷߏՒW"̚V>/AjXwE*PXuH^kzma#~N#>nN EG^׵}\lN)U ؞72#_xjQQNYϴk~;C' C2u?.Ak֖-;a˫ ʋĥJ&BhȘbXlS6V`ݮ*pLޤ{$[[y:u>"Bt<'SZeZHթ}c+.BA^eJoV܇^J#{@eX>B-G$vK Ebff-٤#K` їk=S"g=,$}OȝXu~2zv)kVx#ݺb}~z.{$g˹ HtWy.;kNc$JPf}F~,naZ͵@YAW1 tgvt6|BicjQ A%Oƿxw F)yx~¡ݥBl&?B_C7Xr|qD{l&kQeF1z _,5%WZ%=3H$Ș<"{#Z`x_A?F}x-c@3 Q=wq 5GM⯕ ]k'"E$[ Lٱ\w IØInD檪^)_ t^81e6uvs`nKxBj>u\2qI} * 40rA] {J6xTtLEO dz'~z P2yV#f_XjAI{d:ZaD{}lռQ؅8 }|'߰|w7NY Ih Q4,rEFLm*@,KR#X!^ouجx{^PgY"4OzLضMs ЛT`9 4>_R Xp%dpizhNÚ0D;鲥V62 q~%jz+p m]514ڕHIrcdy 3ş /VeX_ۅُ Wp;m +CX 1KQBU6L|ͯ@ל}b= gGzQ%.@ :B{sW&-ɑ/E<7Tr]Nyˆ#P%bY9\btlo,~x%9CdтQ LNW3f 7CBɏ>:ʔܷBd2cB H4_928':0[)@(&9FLR N2q=jk@0%6cvÓfoEAwdk J߻#3uvtwH)bEx&Lz7{_M ͒<dFԺ?|;- qXs,,,>wlv83: {9XO+9b}:6tn*uhc[X^YkX 3E%>mZmoE@7, Oo14#MqMI^f */G(w]! R~A!T %g$ӣGV@^<GDܮcvwK!:ܝ e1n\^z*4,ch'Ū bƽBr3|z5^%OJ\:<~pP$o3%^@~1"nu"'ЬZdD@%t9eĵH 1lx}$!tg!\z"zQIt咧~24h3MKbgc-(V )ћԭc. 1A{\e pl륞\0@O'J!'eM?=0[$u cyכu}bX 6e@(դ{iL6 fX{} )'G(:.~xOXq(Ժs`9r2ھ-tsI=3m8rv_6p6ljr.>=6p5Q*df -! Erʿv<9 ٭9h0*ʷr;h0r\r+뽥l#\?8唯CqU@(.63ta:㣻3ոg KoCWb/ޥa&!gi[K T)"(|!E%+]KRjk)©MhuV(5Gi[7&5i_)5Z{KXjkomSG}>{80'{_ޢ'u!+wNS2Ĉ;lHU#TXZZjZ7)ڨ(j|p\pĽVmhb"*PҀ2A 9*/Tc $"Ѩ}k_r\;_vUHX*%$X x ZHFiC@s]sSb%w/?Ɂ 1~agRrk|Wau!&}(˘q3 : .3m\X{7C.%iJd-El6Teme_FW %dlF] n'G>qIZi 8KqDٺ|_cv9EX(1ykaCLoFRXST8Rq߉5lSRB'PzcE#Cնˤ&e&lVyUoe yj'tf'O㚕Q~zNm4-Kme` r_"U"E08vYy^)19@m3k'|ޢ Ƞ.1U+Yc(& &{?ɿ׀JWՋ93_ήjIޓfCClbdq|A})hs05:i5.2Ҁ56_vኍvivo }ȍNCc=١4b&4?>DZ\3c'[,<2fU R8!QE#a&C;$hfzF%+VMEK8#`~\=u[VgZQE f̰j9%}nH%/*ų> t2^Mv)A;hM&;@S0CY D+Z_1r|1fx(90Ύ??N?(DUx؋bއC WM~XGK$6jvG(LYAE5YH].1v?TP*2fQ:cIDL7f0}tx;1uJW"_Й{׼`w tHIs@S}C1wF v$gF.$ދXX=ɷlk+["6&V?:`@{FRub$M<[Z= ^뮝Ȃ<,T ~ςZڮzԫM\7S͑%E58-ƤT!ӟQI fgaH} y8V`X"^fQ_bP+xT5kg>BDCN? 3$mC;nH Iqx&Mk;97Ε+/flm 'YTl,fŋ1N 0S2#W@0nt3PCRzәv¤k5QxmwsO5s9 +;A (G 7:pQ8t&epsX 1*Ihz%rjzkM,MC;]N:%tLK^PCϳI>v:s#,/ GTA^1AwTI뢃8CRE]jٳX(# nq}=*R(FCPdRif6*D;N\̀ҿ,%C7̀viA>-LlU%z{5Ng㰟5Z\H*֘W/mnrHxˀ[Yrzwsa_N)[ikAdA F=BNwBMP!xaH=tx6S|6Q'o n'l*.,{9ZX^L+dw+C?N<-!akZ@2[ 2<" %$L.Hcu7IXqoe_S 6?,tD# /ˍۆS>~ZNSfW-сnqw?'}2Riȋ-T׎z49 Z娩q*%|;N,^$*=,Op;nŊL,Nl`pf{&e!XSMHqAQI7k\ɢ"Wi}ZCZFeLuE}_9w"tݿ Ә)Uinw$h 6 A4 h8&=mɦI?n- ˡl7c`V7" y{}b·/ܱ_o2@iJ+NlBNԴ@lb<ĮK>77Kb1]/5|CWT=2lBS/ IV@,(xM8-[UqlZst9~kd%rM d9 t0#~M0l<*d(4AE4UPR"rc`F6Py㨯HP9.'`'tλF9rNyfEi{/3l<dlDNSi Gb+ rښ>JsT۽C\KxkR)KaǾ̴}LDVa`)[y^UMV"#KO>N0fίd"b|˕ASS)7[@ܠJmR "Oc0!iFxnBNudN?~)0--Ʋk7[jP8ˎ BCܸFj*JL0"f<{0}9lSg[y tKw\ љOX65ts٠5TfDQf Z& vy~1e 1x{\(ښGUFp'({ p;ճB. ^G z,LL7ȝSj^4ʦi_ n4/@C!]Vҙ4(c )jc^%en])>ͪ}XrS"he !eJe0e0bw吗@8"ЯvA W0cqoɑ(q3Gz+<'#II|^>mnnMLoܧiAČ?Jd"B\[;xiosb}jA?e'#(_uA 73/b]o=[ {l AZGy *# Y4` pW 0Q"[}:KZ`-3E;=vd𞝵^ndD!GvN|켹4OStcqt2Ge\cMy3ܠer)fvr&μOI4D]y?J&@ /:=E~HD.˽ABn4e̵GjD:khŰG2]wnszdtib$_C:Z $]@p>:4z Cb؟ĊCE} NePP,缫*7oFT³co;8oSjX\S2\\N=~m{TyHTH:1.fw̼,*ˉ$n1t%BM4338X܀&ЌV>EJ4 Į`hU5s5 ҩr9c0B"hTP?>Ĩy*V#1@Li7(^>8IY~@r: lU v1fS%Iuq`=+mQMKůgc3}omj?ʶą;Tvȇ7tucy L*_EU7[eVopgX6qS/BܜwYLD yS)ы&3x1snߌ|<E@)c90-L;ʯG}3kQ@=@UIX^wYÞ9؄K:;}}xmêY$<4G!`@mĈe 91)MA$EDmYW{qEP%[}vli::/Կ_\t'ΧiqĂy0SO} hɝo)/luMDmA~ESyu59\#C'w⒘Y{Pư8O[5m.`Z?)ey;kb  /R|ktB-a[[%jvCnq1k% Gk1<̠Bpu GR)cA6>O(`NTjtoTRXlLqߪ)+G'E>؂ +}~e_?#vMLB޾d{P-G1ĪHݬݑIY^{&$(YȆ;#F ؅0b;qb/TTV+.^ ـ@YUŁ3Y1c\^)=R_b`nʇQqyt_o{G%4bUS# Up3<7ÈΖ 3 夊W~,n_˲X>5SS|'pYYQ! :c5 02z`=P8ET  &͋ Q]5Co xr7igr埰~qhFE Pfd|iHڛŴ*PݳKY?z>jY{Dzd'!62wzzeHx!ZJ!6.~:TBH ]f)V7#*C$Iӣi3{C_c&2.><`-QLIx+_nM<[_aa&<7TY3#7o<4$.yןG%Xթ ]oHtr=ɭ#|C!%QuTg8_<ð4[#.֛NBE"䁂 ([g'+Ce0:@|ŕKҨO[~yEHgqqDs+GU&ur4)5==X :B#AC#%?RºO Gq> Z|-*MU>U ˩3@ I E&E]5+s0:O75ȁ /PSe\]`ocw<s1<~s2۵X.-?;v/% 8A~ 򪉼ctղ%T ^2yq+xox!@h?%MG D(SI3Z~n X e8c\5s>$AL PkkpK>a[^f ;Dͻ9;sW#i.DO H?FS Aϡ!v&hon3Cfo"b=#R1!zi2MNd1C3=fz:y)1ϗhY7ɵXh xWfڋ1j?Yk6ErGMT6\+Nҡ,lDqgG̘Nw}H 9k:BEZb҆T;;++ IQPzgc@<}O٢vq.\\s.CFp6p<~'%h{И6 i8& j lBHGC\bV)R'N#Fm7ŲhÚ 0v8"ţ@K,-7M~]00gi9x*a;:8=_u$JC/efDFUKۋzp,R1]h&K,X60$eΙNf^`]:L9p˕沀rMRP[:nPL ⼆ ̹eyooF ~mEB>lCb|rG-33+vR\(򏕫+f,]%)df40G5I[6j+l] 7#B^rڃ?P0l !n)'f'|HB٘dL 3+tߣc_(w#JH[,Ȧ(,]S7:K*-%F'>hS>7ѮbR90$N0ht?Q.(A,"}!:^=]>&qV֛V f8-H R~~ӆt7Nk(Z8ߏy-Dʇ~/ѴIi 8&1ҠeoeoS٢'l݋`D Ak&|˅j+5+.k_bSE:8[{eT#+F11mZǶ_4hA]W>O/2C}؇ *ߛ J4\kT%%CnVOɸ ]Q7C쩫N"!zיUUrc)%X`}hmj~Ǟx7YMq4Y'#wVЯ?U9}.9 k6 wڮ2;Z\#U!as3O'~DOB">#&^_)fp{ZilAOnأ#g޲׺PmW/~(6؀ň0|Âɰ a2sGßQeL'O$)| PVKdQɔpyl8|Ȍ̷Q[M±ܽ|GHi} YY; WtF"ݖ=nI@/Ie 9yB DZp$ *CZZ䛺dDPrq=,T n|Zu-g%ߕn2SL0_i*tA@nd}S7l#rrXVG'FD!Flؓo+ISbe&Q-vv;Rc$PB>KybF VFrXX]z]~Op`jL>ܡӡ*zrUAJϮlq."Z`*?!w%VڨQR?v\Lؙ'y\8Km'|]3^$,6 e>1!5 tZ.TOߑ/С1K8ŇYDN35 PEm@8/|RloCx8uUmլ?6[KyXHC}߷F*ԭLRY}D~r +3+ ] qr"HGhNb黧쁉d\3~,pzZ@,6{6k U,h8a&;$եd/k rKͷqTjv$z%q-oo#*`k ݯ'mà]F?xnkH[SǠGe R?x:͹kgon#_r+y8GV:B,Wr棩s^Z١ \8СLڳzp0ȴGaكH;ucY8E䥸m~"bGs~h.;13r:1SpF: Q*Ÿ,mWEPRz,ЗU5pc6 SB17,r\yz権-,s2}˨6;by^-qmHjp"R [ªhMR 9+>gJ(-0`lw߸!$>D.^" V|˯HUelxS ~7& EwuJl>~6ѦLQ-jBR͘cHkP$BvrK@wV*(;'PɄklYxϾd$QD*w0"zV_mL~zVo6`ؓ'V!;6m`[ӶI6gjlWΧ.D8[ ?xvDWyx{LD-]6RBLgyJ$xW\X%8Hm[{%ΆM<ڠ(jԓ39P>qmJhHX U9uK"&ypG#z4_mcRϡgS' O%)OeI[ڽXz9<V XU$WXܦ$[?9w9\pN"aYG{ $|grOi(tk~,j? 0{* p阶LhN]HD p?[fCh?\0ki" m fH ӯN\A5=~Q6Z%䀕t7Ͼ 19yNpD{ʝt_'Nuк^>ÀwW8hk{̜uh9%R!8KT "3@'a:qLk`CԨ\A`n (mAu,:lqlw}mV3 D'RWg:iPY(_7|ύ c_76ᑮP"wՃh(S<\򡅦Ï-<i8gIm\~I{(ڻ mٯEg)ۼ%16Nt]P֤!`+>zVȪinO3%z|ΐ$,!rӵ \ {z3xgjށF^={Ǥ.8)vˌ1'^D֐r j_L5x&?bsBȕ~!ABͺ?F=1kn=T`G^0q[=~kb_)H;tH߅.uj؃Φ.HIZa\5[WNhn`Ƥ7p_COX6dF/Vtk3~|oC2 8/}WOk0+OkzMߗ!\d4*ЋC-N$mm5`#B}5r ` ~XvE@̻WF0_S-qQ32x[Yc!]qû)Kކ%z1:ȝ_6X+GOA4a_fhm[?JUgޝ}w?̦GT\ 85H󟒹*uwܴ4#>6Ϯk*%.BQBy فW"}s ZY_eZޏT 5anv`X!'->=SճokkPq .Dm$O$rtb~ŧ}#2WwҖLZfX2= ĹI%"pܳOF:Խtulc:eG3R:>oM@"+L ^ٲhRFYZ%4p@-Ae 7ױ E|i'y"U)s% \U.7SgҨ7 8VMGU6:Fm|BۘK9ǀ/Ld-X`}lE sMĺy38Z/I+^v1$NVX0D϶q_}7o[L] Hsۍ By>&jI9&znrsl\*Zᴇ3._C1ހ~m\ ͙D{MOsHw^Ƣ/& B ݹd];>J$$  ʭ184\5Ltġ://pM<j,u IīiIGT`qyZ-5&eΤG߀&>.LxsAV5RgrC[PYe~uIpc[!i&KmxČAGi{F-V\تR)M׉MsEJ Y䇹ic$u+_ek\ `U3Ml W#ɾ^@w#&V14wejxBZY?yGj1/W(7%W$ Q@ʆtlL,ISvtDHeB޼ ۉ(ًcv@ԋ֎`vTsnrvZحC5Cb5dz%Nhک h{8ۖ,.q=$,J,S^3)NS@"pK(ҰXrr@u#Lz4E'yKE'@lSjb]""N u{ .U-,oq{܍ID6vϡ3vSui':0UpL[0bqv8jX*Kq« =6^}o{z ,v(ȊQmU(+Y&֟jBy\?7.da]YPo>d_|O* a IIa?'I[͓OxR FψNYm5^RaCgǘ\ T%"d2_pj@s`1jN5-t!vtї'`ErJ6?_p{ skm'HoTlWצ,;np`ʸɶ5 .^鞘3B5o"24A(I.|k|ۆ_v5O9=yUrWwPkHyQ*6=?8'D,AG_4,\ڥ.DOg,KU{Raz`P4rj/j]IDj~AO)]\Ė3iㆻقC>)"ϊ2k#U :ljZxq9~?= \lN^2r:Z(>,JSbDeDߍ^B"ɟl7ozX507-(c7u,zfTUsfwwu,hI1X"%d(kXmL1]#ڠ1@ǏT=626FFJ۠C6ѸsQ\r\?1xvEU_e80~=q/MmZw$\ys/0pΨS.^.AŻED,?k~3YGkA[;2۪x&:SJ\iˌAlP5񇒪JgH>ؤVSH1uuGy`]E2$N_ t)Fn`\=6]4Hۦzsi[pC&mWF>ktG:_y$6گl_:g7U>lUr=ʙ[9L㨅O $򓟇gRd";Aɶ#ڦtpi> EyY/4, Tk̔)ڼ5ϵ_L?K ]WfRhSӘ[MҬy&s?0YN4jv}p*ENVɀl۞,MCk39"8Bv:L1K[YTsKvfCq)/~;&ŒfRN: ʱeH;לٕDH71kކ,@K?h Y]-WRn(-Wmml?kos]HmMBamhi)< zF=b#$7aI֍.yVH[Hg e9y:t̫-8&7* {{(lm7:-S8:t96B.k }5IX=[ּ^aA,qykGǃbnQFZ4GJl$mg+IHg_qċnW\W;VR%0Mg'cOoKV/B<=q=_) EBAm?9F Lu wG`}{KLl0Xp}l&.SML쟢ܣTnn1282ḷXa&2 w:Ume jqou7"k?HQ݀iOKA2 BXk ςe"l?(I͟3C1p^zV qEtr-J7%7 @>C6O xڴxmAKHNů=eXgTӠ=/I5KQΦ*@oR<(q~wp)c=VoaC3Se7\!#&㇙ )G$:GV8)/XFyiGcep7rfS"k R ኸڻ_XǍ@MfXx3Aͽ<q[Riy>My1w풴5tKqSRx}/a$Rh[sc,=zzbn/g`|" :c~9 TهKԾ5QqAs_E36:1@{EEHջ l=@;0(ktʌғcͱAxu.ʳGԽ:٢ c*U[]@[:a? xPȝL^ߍurFԇq#z9x?o qo _ͿQUCF1}u99Q-t7\>ޢ Ɛݮ@k '.ҿ,VNf ozgûrax}b,*LZŜAȐ+4OZЃhBmt#N`YO^$4f'5^wM!C̪$Hj_i_f@TF\X^G:d  v&BX!*c9s+TMAdvO.Ȫ [Q>QYmx(hf^g|rA[Z ]AJ kb};u[pRF!CFxSnj8iW7ZBec۠)ҹ T>_ 8+p{[G{*M#XgͭE+.JWH&=;Ғ Yb]Ďx." d Mm^X-BFx3; {!a%zzFBq*06f ؄yӆ! i@cVaD3~8J*maVp!.2P4;8(z\jDlqB1uoán]+: ﮟ@>(XdyL Sg- Fv:ˊ)9g'PnbT|xIKM9.Iϛ7O-%,C_EFi81"nѳ7\?"h>ٰ:u :9=<Ċ-'k'#L]UG.P=";D9/gYmMxCz;|^b PegFJmI>K |B9*1m;N0>< }K-\),pI!|$]LrmjÉx;ͭ7'@au1\RG[6@Hbլ;[&`N3? -\(ƹ7 8vu?K4MKk~g;4NDںD,)dF;i[t)穐9f@(-/*\.d=GJnƨF7`<8#⻘ެ'趥C厺@."d,%rf}plJAOhHL|m ;'BRi|C.ewav@`5ʞ%":c"P,G闃LӀ ƿ3ȣ߷ j_gl@lT뤋8΍0?s9,| pBd;(-,G`((5CMy+q$4Z$!At,^~[eCFt;uƹ .n.Ɠ_!(M0A讧:г領=+K'f ږ'\.դSe^'v5$w2 )qedO~Rm^ ##Z\GZkOGHF SaD+A]=)hPu'~fK0ؑB0ٝv,?WXbpX;C`an\%,{[;p%e 6q5}uLBc_8'rbADONVNy `[EpoSn4U? hֱ$91/UUL,zF[YI:[jɷ xI<AOnh63t+QE[izQ\@=y4R1 $}K'䓈B6Ʉ$i T5moAßb/GW/ŚH"(4QGA<ws-Dl7Z]{t`UCe\)m#q?4괖lo xn 8CQܒR1+:(D AmCD~[X`(pt/\{;¡|pXi`) EN>& J84k$R|:XO9 joA2>1nVz246cNIqk$ ]m繱GrIz! "B2kk$]!Ƥ`t n}DWݥ:v$aD鸭 =3Yß c 'M 7On&HXcG4A0 n(\jϯt?vH0 UkYPAr+z!!4DVkK;,@t覒@HNqAdǢ7.%)wUI]NiS \>(#s;(j 5\fʀϽN$ܰŅ-k{G9(&<03 pv+,}4G[.J╁,ӋK( d떿)F9V] oD_)*5+QJg1NyHm5{A+,Kl݂"I^}q{}Iss+5i7DKdm5L)(JZxG;h's3̿xY`49=}asʿr꾊BVm˂2"zƂm>| "#?H!qY_O@tļlMkV^ˎii -].% 0? R?{ٰ<=d|Y( ."ƶ D'5YHzF*SqYlLegWa}̿mYrrrO s77g "Ĭα8 N:6g51pqi.*Y"䊀C*lј1[_}izc?Tmb&wοʲ%^(w`xlyşp˰[n`֘ -kh}!X޼4k.N:2$ȃ_Y+zaul7/rA9SG="Q!k-59E^]`t?8T\D 1;єXkBV!> fl6[:h`I*rd˾e]Zx PF (~{ waf=nDm *EqΰkftitqKB/)3Nx|rRDcH)i; nSX }-II]/cokG6UJG@ l?벼9 {9 B%ܓ nqMF OxV7 O_5y.Z1!2ʳ޵ԚrCmM/.kE!Mͧӌt4Wb"LQ G @||`ZsJWäo'$]npLD YL8VE/hQ(|#e9?炵ZA;m{&?(J=Plw#xlk8YbgD!Txx_tiE% AEDx0hzmEHJāNhdUi{}iAPqnUEwavKN8^{364-E_pB1u3]~8q|FГR‹HGА^! U Tm2S]pHo*tt:O~[ڻaR!Tby=:)%Q,0d0f`j:D+rpЉ;O) Xk@|Q)f5(cQ*:i#G"XCL;xq^ʄQ~*.jrfg޷bmJY-:J*[R{ӺƢ9<}υs1)8OO>u[\* %h}> }aR^_RAˌL_+pcVr"Qܰg%NZ!U>A>ϱJ TViyIa^c-|e#AziB/GdڅO+J^E=fnV~6BS%ؒumz0z5MSyN:5." FxxƝq>UdZC!w3u'TpnUö7&(Y?tJgChTIH-({NlU4n#Qy=/=x 0ﭥ;ACd=KEziv{:)d7/6.LLlLld krrMn/_UZ1vG1&Hg6#j~9)9r$y pےj[q^"Y>pΦu~l<"_f)@P$O+_Xy)=s-83q٢u(Jrd C"&Dd{ \%Yx*, KXB wcٍ[r7Xz-DeFOÈClBl|jPfJښu׊R4+|@,\¥ce|mRb읬PnMm [*(/BMsSҡLlq"/ 61Xjk4[-,MrY4RRBJLweiGddT8u)XGwdq>fnߘscN'6̎߾[Hss 7DOPk FBrzm%$@ǹTGwv4?QT2#K[A;/W^hPy~cjd{x Yqij#Ӭf柽6:Ɣi\`OOdH#,WGvdxfbyc` E/d2ҟz.?k/0μ"nFx@JA[٦~ڔtO`ttaDhVm%xycUF1S VR8?F#P#i&k@AW]\m"_ڦ@o}_QjNN+ݧ\Ŧn X@Ry@y/57l(M%va~w8Js7n.ik`_]u@q}|Q5""c]%9WVS9,( Ɏh$ԸUFSΘ6ȔĄ6e+s$zGK-%+xGM5>H%fs0.(%Ԫw@+C6<8<5ǚ-̠ŧ;qc]վI͈\]I'uTDAR=/X˾Pj&R +WSjz)[RQX($Jc+;$*iU5 }wB%>/Yf2>aĝ#W8LNg=ƁiHWrSǢhqtdpS!j)\>7Gy SϵSІ=coa9+UJ%YgF7%,@TOVY)0w"Y:޻"jPd ֚YQo` eYKO_6cNQTkY^0$`HŬC H w>X0Sy 4!L^4s2Sjwo<@ A5Y27sɢ8;T˪t 1*T&Mrt6{ %[7b+^썞 \l>>YGG>tLY (FC494,&M)8/7r5 e鑢u%Q5o6L#-poXctEJ$!nI;$zļ]D lÔZaHSnf'M^{8j+e+[K4#= Wh jo>?rTD0_Z|"oЈS'Ga`ʷ ]A8G 6ð%9Q2 d}*{ւN^ ]թ]N sF5WxzBD-.J7'χܐ~"`/Cx!&RV98 YO5Agrq(.ayK?/쮲f}[S9ww{~䩶#}F Lإa}1 丑`m.f ~K,( \E ܳsN嵦:Bxw.%劎Xm05ۄ?H~'e/ē#KN˄`'7"b_Ob1XPk@HN?ª< &MQͼ'\7eJprz/@,)hz8i[fz\1/3漟<ٞ-vDSS\XHT˼ыEa*|lbE,WZ~Vzl 3$Ei%ݪGmV(Fcuק/]MQ,4d mbFw<;.u#iwn-Sbܖl ?DPW7juvT tJ (<[vEEJ<§_-K~lҔHX ~^bWhF4L!# Qk jͲ v NrV eK۔6{܄]}F OS2x%Q8bU\_!cx%D1%icqobjB3Vf{;i0zh FqY52L.Q~6D8^ap:'lc(f6)0[2EÝdObgizI$m ,^3j>@vMxa(E 6>cO ؅bq2 \V[bvh8O~hx 5h!3zN9(S0M>"]f1Rw[I?˹}L1S5[Xx#h.c *ST3}E铓- j.#/!CL ]-afCNDg!2KJgIsDF)^"}XKhq2)tMĽ$ |R!L(ݗ*$$}@'*fK4s!wƗpRߣ9ݫn5~9Β}!t}捧5DNOgx8{g!T,#4Bdx n^Nw$L#wZN)Lhf]-y[+²" ]{@Rܢ+0v"xPMv[owUU= 0`dcl80lM=$?QŌ(Rz#[ԣ82bS\@ {EKX u^`2\yt7eȀs)4x\by;aTs8fbka.d׆Ce ·%HrHnBD^}\)݃ 3YDFX܆PZ&NL o{ < 列0Tuf'O7qTX#c9&V*-8㰣?$7UI8“oZ] eM{d( U2!{Ër&ck*?Z=Kک'n kb'ltXzvw?N[6'=q[b}^2K"ٔu|To/oJECy$U6̣S>B~N))8:##m+eBgD6t;bDnKz0rVz{P6E5Nݭmv\I6C:Ĝt)ORoZ}*g4#)};1a)-s~ըU..  +Y< Lp,A Wrp||!+^#ۿXyp>AeW:dq&5bA8MԔf%PZ(ieT13؜A-ȧ Vi[ /}Dñ\‚&mA𾴬GNb7H1tLSO$#NʄY-K{ϒę(w:{7^t̀j) p0|(WvM NK$bٚmMףցy*ccsHbqz*{D0-8O!l|55ՃlIi13>ĴK "ȒvU.֯:tB/aWC3o ev{Qpd?fyٞA(`-<^xW(6ºBK2p?TtKվ~W`[QXWR $'hYtU?x&nYx?7FDShki4[ ? gC24]IS hq]Ձ(~^8WE=$% A~h:;B٦ǮEZ}86!z3h:KZ19w8uBT {/&:bjE5e?c,( f,75ؤe>VkADՇio[|啂<?cʬ=u -ڦ0BO7@ޘh\8 C 4NuUN d(Tz3ʸxWYK[̘'$w"} o,pVt[ t2%O8=7nx(I"ǶX i"j*oF1r 1CQ*o-advSĒOpCKDCn=mi`33Ai*̓"1M޶}n;X by nts(}b"Lv?/1T}S!;ICo/=}i=ٸfr YzTbt>1]V?1*/^D>-(WIs7R>rx׆9d|tPؠ2FW$1:Az Μl7 U2i KEcGXFp[0e .ls٩(7<aTm]fD$ us^'qv7hl Г޶ ͚8x5{vD=fnӑ C c[E,n!S)T PoSE\3> а˙DxjkRKQ2}I.D"! 2]S+ ã #Ji?s$BzfHLLcg,;8Ӌ7Fn t\)}BZI~b6NJmtEUa8JXJ~g|a2ltbK`})~J(l% 5ʡs2Ǟa i ?m"!_O.Lh-.1ZԴ`u;&@p-tLXG58θU !N&De > ]pZCCtACv? W촧ٍWM<"Wb1.{qG\!m^H>2uչe::?|nͷFЋm5"Q hl&, 0kbkw2a,a0?:^O ,n{#l!2~/I>:WDj>3'I"ֶHIm- Uioʠ0Y^ : aǗ~ - {vʗK![[(ɷUlF 5Y.i;J$$|~"M>A'DF 8~w<7%#RݝpIڪZDKqXRmt=UN@ƥGN#GDپ1<$Sf|k.~1=z&mngn@~IzW1Т8MjeU<bb75} 67Kv)bDAڞ#DjvA,<6ZGoE\ńiDM|BGkߜɪ.ЩAv`e vJҔv{7o¥L.0>nFݜH x'Eհ6&p=@XΞl`fU\o*"[s4oU jɹL;c(F:`` —L0ZW9Ή>G-U6]~0Mzp^6k5~IW,bUD2D$?х, T捭Ln=}rGfVߥ?)ꨚ負|@", Tdjbt/`̺tmum۵Cj~%D\qWbVuNk:Y{c3l.RkE}&7 L:߆zga=<  MN}K:}b2w'#(oQ;U$wlBIY/Ѱ2U'f4T.ɀӽս7핷Qεd./{^0C u!}bx&tkMT|/8zobr*,q-QR1@1+f.D9l#yR |b{o`GGMƱd {*FlMk\c~Z5!/ Ⱦ$|GhD_7:_԰K~颰5$UZɡE.O k8P~ cbm"C~psevH.X$Md;܎!\R^R3OϮ|ә_LRb6qFDeL YGP+*d 9Br ;5Q`; )YlN7ҷ#?πHz+UIb'=7^0Kk.2=Ǐtkzm>* JiA.`6'C:%3L9T]:`EXmI|-73YڂAqa@p}Tb^2s):?QyNuRAQue4 F4Lu B ADׂ(΀2ߌ34CUEЫ;I"쳍P9ZF/jQq;]=L~_VMp5H(^=P%y?ecIE U6fV`YYԢI~=lb LũiLD,,R7fvYDGMLEԙ7?YK2~s.cθWDWk+9shB}+ǶEmȗ`}WO(yt6ghiÀwבfեFи$Q6j?qb;2xRvk.ωq@X[$5#_(-!gFoЍiZM0R݀g@:4'!x.pB>A9~S(n(sa/n@0 ,e 4Ti2&0'´n809 Qc%/̚I)%bՒͪYPVdn؀&e/cn!,USyzD|s\Am K1G9G(x,WƳn)%hl-F=^9..~ּWԾ'O[ȅ#uaE.kW0o! Gs!@ޚpԐ >Fm0RtQ_&Dm~C_'U4Wڑ p(ZCz^W+<XpiV@&>~ڕsXg9Qw]/sX#7']0"1/D'I3I&Z@!/RAd֨ai琴neqgMy#+[Rʿ~h):{yh%)jx:O&o=įh_Ǣ/W[&sb9dE$m d;i/3=l@s~d2ޡqZKCMU(iLk!H 65e*-j%_.-`6Xx0XȵTy~ri`T Yv{Sx]Iָ:YeM"݄G,\}N!\ylμϜw ~Zi? zgIP'R ˗Y gZC4%0+PV{2!]lU87#(v{5fθ8iHx|ZΆ#rgq'{'AE="jS!ͺ)!5r2ho϶1Y)L[b!&wP eBM-g8-DDm$ +ȟB>2H[#n`6\5 _)A",būGWoyܮ.8{To}>L|  *Tгđ-BIf@o9nu^jUO_QN[4 y jgIo#8/~]9l_̃s%Zt6|$vp{YAo#*0~%$(EڣJEXu#뛛2*0kT3 ^\s-'~s dm>+] #4Uh'tuZ> D=MNG5uRs's}m&f%ad)GT[N !5a˝O;M]&l\~wQQpbwQ]$\>p0TڧZ#}|BG,Rj ŧiSzxq;I!X5DR8t5oCJ嶄D ŕpkbYũO0"IDG#J+  YPӑ`S뮵v Ɗ(/3f^5'Ү\ ڐ' H:h o>QwZ$xk5=F}`^ݼ%RCAj9a)dyg+MGIfN꧎Tx޶"ފAd1}rB?o Iэ)ImANQUSrD43Uo.w‹`ǚ}6pA|px!п=бJ(b"7[?ysz[+,Rʔm7V/o$6@T31ʼn(!QLUz8."r8c]yn22湱|y Z6|+ālhH xS[4 <}*)ԏWr@YTpf?!YtցC>E*ldie>uތOʥ<ZStiU9~Q@UgUt]n&Cp4П)ե0{gS!y^R(1ԣ3FDm3El5gfPk` X8d N].0&_U|j2r_cA_`~ۆ7,\yh Ȕq"Eׇ| [Dšځi2;{ Zľ-JG}Dr')2׊:(r\HEjCZ}IH3qn ;v_b19Fe⦹@h,auԵl#n 4W0孃zn|[ M_û*deS%!\uFwMya߶ʚPr>]qC[kP/|D 3B V~,^eXqMe%T#M@˗bn=3|LdI:&d*Sn!_[cGzp7rҬZpFh`M9$<=Nc4!n|Nv?br*GR6ݺNb:y$_õLi$l2&}kb IhcՅ̺<[ <}v ty%jU{Ǩ 7薬E O}5̣$-A>Ed1Hr0G\<?BR_٣XboS09A12!`3iG?X@g'mÞ4;#@'w`WmbH'y޳mIiB w`F*`b i>cfm=R{(PpZ5nM O@.Ok4Z]HZag?68StmUzO `s\i7(ؖců*\&~W,L" Ͻ>=!MS_KPo4CEFHtub4\Mg ëAG|PJ@L#w 1L9J-+U Pm#46{AT $em \4Jn7h#,l/?xÜx 4smhUg{$ -tjH.Cih4+v? I@LRˤ}o}mdy91ch 깢68n[BIKmZ|L~ӻ'3|\bFM@1 mT8Qj"zk갳6Zux䴆`6bBǽm9 p7x$w\@6t5 G2]Tjle/_.ן)\+(͂9J`( t8o{ێcϞePSP喂qy z+Rj8{[oipE&on3£ȍ)ܫQM'tY_ϕ*Wblc\ {Um&7R/"26x9S^Gp=]ݤzrױ3EE4,iϷG#|je ruWoItV$^Eɕ;BW,+]r"͂X١vVIOomf|-T`=i&4pF$:Okzk-j'>:hhf¬aFTYYϰ-\ )n+&M!ةf@Pd7`ɞHi/:'D0dp+exT !yq?zxCh'/Ct;C3qtj-Oh, nfhməݦ+-nrJPzE3I4]u ^u3 ؈xSvb<`! ӢP2&YȮRy"BAX@;Q%;k'簓Ps9^ȼD&wZEX96/v `Ϩ_2B}% ~R;cx>׆o*|]x>ʬ4jTVPL^卐k㤀8J+H5NNk!m#o~ZKاC?.I=7;o:,ZXGZ^^DS#"4{^$bt3|.d(wP\,lko'`{Lt!],)u .]CZlC#WťmklLE=vK9wr Cck 0ӇD!R\p=̦3̴- O$7".I{hVg0ZVσa[3jgMJ0n1daqCrBj`Fo33ag!|bQl5yS \YF,x&c -q*<a H)qlspZ džd,P+ ɕvpgK0DB`](םBߥߤ*TA_Sg=|Ұ\BgP/z;BC`|G\ocM$[@7@V gݓ_d%WPARċэ I8@V*fEr5 ^{xUݤ|Qul:}-:7Jq[ n›ʤ4߼~-q:?r-Pt8 Cb=gt R3e$LI٫KvIIO3WZ޸E1=73[bqMrCPT}on !~^GFv1Uc G1M8* +(Ges/RٚT/,IqD+"Roϸ̣>sTS<*f__|4(,4fz!}<;T0?ӱx-`fF-S.iDJTв@>YA6g+ ~ZɪBbjUYk @erw<Ô{z=1)Z;ֵ|/UM"G.ݝ _$M桡B Fps &8vKH) 3p|7/9uX Rk,enɸR('p`Po'+|7866~ 7a)8DwQ,xaT9Bly9.O+ B?&2ڞCL -Rs e(`=~~{dV-Q|~jPm{p垈XlkxiT$ Z{(.W?[O|&!?$ϸIA;ьdЍB_8h7ӑvtgpe8Bݕ;Ԑy4UصI`Ee֙ta29=f5`T>{,k!_Q3o|!cn6m(eoiE1jk+dPpϱl=\$" Y4: Őe 1Ph_ ڮ] ?0`K*Ɩm!={. WH2_z9+xm'nV!Ȅ_Dw MFc^{ ͯ1ipk:L\@[L0rz7r Ͻ/>רLҜ|f>E޳z5F P;+?hs(QM-" *nq󈘵:̚(1[N=U'Q'WT3[bx=OEU=| &Ody XpM-n6kE4+d_3FZpJ[y1Eܹ\@Hjҝ$2%#]J]X0{MAkO}/ I'$I5\!%{9UL:TtNήLy˰â<2*벺K AMqC[ 2%IÃʕwc$|-g$o4Эq{؎ʏ}ֵm@ժpI>҃T`1Ii*Rsa aNvj](`N@@y2H7 "{0f^[~rĹ&9%$`0A̯Sjs*B KkRtND45yF&#М̴-2jI⢃b%H@`F+nѾCoqqĒ+c6/_>01!pm(04Mc3ElOx.j*^zpD "TАg`Kȶ=u4jD ur_uE]slTn }kߍ)c>rxVX:j{.3f }k)81DL&Y%;{l]pGD'ZڕCD}5LՉ‏DڼarL>Q4qvaZޖu^Xy*pƌYnT38٘SWif׿а\`|xJ&\%9.{3Fq>KY ӟp! F`:#/ⅼ'. `I"8&FU0*-X 4D,j2+#\"r72C2D< n-J gZGu"< M}ʮb5s lSÑA?Ks+v~P#@_}2^cWl`m5p_|O&ζ_'Q^DB}O1eƽY>-Uxflt-Y)'l4`^xWw>ze'w8B̑^0-jSU"M}<6[CcB/wth,}dxPaPpY12gْ4H<"W.I,~d\b2PW+3$;&TE v)߮;05 ٘q~rDf<"vWa@qZw[J[4 4<8~%} fb\ mOX퉑rԨ'PmBɜܮj.Q^qc̢zNڡ5yw*IK~6o[ҫE @⠛=EyH߸m7/Wlk?XURIį7Iigߠ!{q_o@SZ| $Quˑ+CPMIeF t|M{don`=Th^BJv~K+ 47$Ioss&yͭYP, @G2CbV~ޡB)ņ* EM^߰Tg1[ nA35S֪muD̓e%aSrw3ҩ2UWoEzQ0]~tw&uD0~ycYd/ 83.E<Ia J,;a4Ax#¡l<f =R lnrX+X-@GZ`0O'\|vcvĽivMa >$cqK_\Jx&r^`.}eB/(,ҌvAѓLZ${/E"(xMa1TL:we&aJSuÉW ^[,C J MoC0T{G2sĜV}lCu.&XYQ5\N2}  ^"2 &ٶfh²~b5j|:sv=1B<}4o:@ylh_P]A@d9I{bbBDǶE$)<ߴ>aCWf"2AN8*I*FXwmeyXmiWAӨR0y 8HVg/m, yL (Bxã[lMHP <:4F|%][PTh:ەa.#13>0GXRJsRw,g汱H=%v qIo־u+ӁRXI^,b KPsԓYw:Q{mWpC[Fth^@I(HYŃ^:@3ҫ3h ʤ B5.vR$|lGQ=zbQtJ17A^:l->դ+OdОT !pǨPcRe+5t) 3($:bEymw`)(Y?1k۪qފ]٨H b]Nx @4}H|Ex(o5UʏWte{IYӻ_3;B20ֹ1)al2=`{i 4v{EۙwVk4l2K G#ux #4ܨubi~x&QߢDڮ I5av]N~Do`y#bS/bVֹX1^? ^-ǒqqsBG^I74DpQ O|'$WW'53$s||qCz:zsGUg(y0*KGf?*(f_%5v:| j \⷇5I.o!@>[݋@!4ˍb "R`X7r x΃]& <c꽦l7 iZT: 8x8CC 0&G;_oĊ~tLHa^[\N5 1YbezLӔ'u~\k\z]dW,؁Z(,8PJƽb_-*zG'0r~QDձ}Jqwi(dU/ /l.B2d6°ot7ZזSyse&O#miђw FJޛ"_aѓ8uν$M ׳x=|T2@_Po#zjP@6]I>3-}M{=ӵۘOxx^>t|aobm67wU-= }G'Tzq."fvӿUWE w1.yP64%X N9|׼?jLnve?*?^y*|#WۀPky:`6itS20E)AX^E,WrFUQ2蔧/nZHgKi^*9<%c[lgT- qMݓcUn;ѽ|x>-3p4[ ̺Ua0RO,%2.+-@?v]K`! yfW#w Gra`+f*ʖ:,D^,t.Մ"&( qHV҂P M>v]Px%fne Y)܂GI3aq ](Ofq!F~_9wQ@%N y3neU3D7N{eX< @֮rS3M ͥba,DN0y(hɆJLo28Ps9>(y& Lλ :ulV[H7~1wbpXH9F7_r;M^Gǖ{Sja/Jr#& )^^; ﯷ=_X"ܒH-.lWcרH]"j"f] Qpiaڧ[?BS~q@o\` 2 m("ΞþE1V5蘜jPc9`vH^=_CʼnY4lA7kfw9% #;ʷ'e:\Tc0Fe`'S #zli^6%`>A<"\IHwJ:c{qݒ#ƍvf.X_$| r7ؒ˳7Nd۵4Ft|c:@יX< l?Ybvv[`3+\H- AC-x8DL=/0&oHڛxlϣ:ykz}s~veJ؝Cmv1B +C!^ hP:NބWnqբvbF.ڬʸ<"S"YTl,?"[NG/oC{P29 X,ۤ:ђi1˼6!1B֫R$1$ϵB15iS,;Q%{F)i5emX3XſOx}9DsS-D kL&La#!{J#^BOJ;χC "8DI4ʩK` Ui0$:OU%ZIDfJAD$ h &󍓍\ vV)6 ],6JX^8^{ ]r%G->u;t .;03zE< )dNݤ-;(G|(E"8M8ʅ R.*V6VL^p3k"݅FcCO[(+`jq,\F@Z7 /pЯeP0xDG3 tF996薔 ˚SFhB+`@| 37j;7vD˺c9ݬpហc@,><ѡ⤰J@BATVv ]G).p.E0Kq԰ή@2-PSj <|x7Gm}'\;Cc7E3\ef!cy 8$PlEx8l^g@DDrY ?OH!ffh'ZNx}yD%*_aAp 荪B4Δ 6K{e؏͊crݺ)h`5]19;QnaqBHwq6 Y?"!@C~ݥA QwV3t'-Djܮ]-Q 7}Rgi:maQ-G˄yDg/1I#d) . }SO6GcF({d+ A`'hsOcP5ι^=x&{ =5c[Io0Y{YRřG) A=^6VE-4BH=Jve~Zcxҩ;SCwfLܲ*;3+~{F8vE7b#n}A[ɃtdϱX[OU[!M-(1*[\x|2#7wSH9`땥1efE,LBI=}Ӂ5,λ5 測B% - :ƼԏTRT@8q.L{\8iXuoX($ww`[Df*avkB}xӟ!A$pb3w5? jC0]&doF:JeX9'䐭rd:A[љ!gsԊ8ZX|Tܳ::v|!=lkLTqO^[Ju\us⢧+E,m7tI\4ƶĉ( ?b  F'sPGPXwhcUcf\L1wT9tm "1Fx5qӳD*<;囩1&, $ɨ>j{M4{٩r?MD鑽ÈHNx5p~ Ɏ4**==R7"2Ƀ#h뼾˿QJlչLE!-K:Pp]{9YSMh0H`fZӧhj ~4#sA̐( \D![1X41ubE0#}DA/](*3QZ,\6у HNgR'̎TŸLI*0(Uǎ]GXyϏ${\ft28&iݽQCnh&6 c4x.ܖ ØG<i'YD=ۮDKB~Ґ>`X }']Y'b\D9V X}įXL6]xQ/'WW? ݂b X7?!v@z5V:FjJ }Kx,&Ǒbʏc02s) 6r\aW, PDΟ pdӺG^L hg6>J$Y,=WЪ)n̮P}eӐ,L*Z7;c.9UwqwuiJ']ű&30@626J$CY\څ%'_&DB)Xan0?qW0jlj,O //>X;dNq7$ⶭ;V Pt"֫7gϗײ8U岅 #(u\.#A(&3CRIJّ%N]ے<уm .(*R e[mwM`7wA$k Vٱڇ6+ 5P1; N)VDak>.7ltSȥhZ IOCr=й {[fjG?%ը(*?|iq㎀l5 >7Od@`O:u@WXdi:y|>F}VRxSkCЕ-(VYO>Ȳbk"C|>3Ӫʹ귶ʛG ?`[z}e `'*Nx^y~G-  *RPy3藕#H#1N.s9$O%3z J r["n>!dR{VH8}jQ4SS+4Z_I mƓJ,hg<f+M:6┿QDz,{Ea5:!\3FtMgir#7~/M8fJes!P]q4Ef,4G#y6]|wvױ6?b [=lp*y.efgAY/,hKY Mܥq f|0 !n%0;_zFV_篠~G @D-ڻ誫S}ssx/&lsJa({߶v׭ 롘uUrN*URlRP9gyW)^q?To܅ ͟rN SaW'f>rIfrFk*bfK(EMsƦ{`2<.D\ʥLh/wP$䁉Xmm7nm!cd 8ɐqǗ$ k%;;ƹT]WŠŎ3e\W ^!EfIIp3z6[634bG ̱{ C*7\lʟ2֗G ݗ*X_ E]U{+:`YoWDHpK MG4CJʏ4g`z ^?c_Rf~Rf2I;0~O|w6&k Ԩ:8LdI Uw/b6:S )j%JX&@{}H0NȿB`ǧ{݉.X6kKy.} ةNE1?t|gs4dEq=ؽg4@{ؽQ2x3Ak# 90ig#"=r{p?X:z੏ͩ0 ,Nh`iơ (C0Tߞ*uu=o7ju`{c?E%8'䷄UH(A\X/?Yvd' !g wu@(",zEnOsz]Fk9H9Q`2,n! v/Ջ9L{:]& GPfE#= 0"tK#hw,SfxMzX-9x|&[G/s\$- q ̇3Ò@0.]J"}U9kM4#訠5o@{|QE%.δ&} >e^}WŌ- j"uoC^&tZUz%"sY!ySڝVo<",G;V;XZDub]LLcKE@njG`͐K;& {#N^(aeqh LGG3SGY9p:{"do׀_.?'1ޏՖ1` 4C'.j#.ﰐEͯҲ0^ zj`<; h%n=ztuc[m_ d㳜^FȺ2 S [ Zwvp|>lхm̰]MYEGs6Ǜ|M[ FL ܾx{Rⱶ»g NQ1$WsKn Pna=;=|sC--sJ2YM2\p"ݲXsO}ȥ"h?&Gs6ƫ1!^~7#^xf(MFWOWnd4-ǂ.}pHE֭fT(;J]߈ tD6M<޸WfbZV7ǧY j<&-j{tg " ;;NY]ِ&"CL!ÕǔkQ #>2Y}XLi]:R{e!JR[<]4%f+CHYaAÍɯq#߹O lcpKِokʫ BMR \TU`;eS+pl[0GŦ Bc-Ԇ+\2:7n?iA_=1.Iq8.iv{[>_j{GLq-fiT\ um >JF=Q$5E֮Td~6PP\͵0v;SDfq@=gOrY2Khg4.u/wh獇_P?2-攛] 1/zBkkDV 6[D0A;*9$rM{BaҙG4@d lEթr|^M =TƯTWYQp0 J}L +NBvꨎ*M5pE`Q I`BPtgQ$J>|:9a5N0b1\"nl^e]3+zAfE_&.d]6V#-G2miiӳ-7Eݷ/*VĿ~׍>g/lHpKĹj~v+Kdi0 ҆"aL(5-[}+*bctfl~7^ s ȭN ޴:Fm;3zD+Hnҍ\"zF5xj"5N2$՜-x#lUqq{b,Wcd~9'f"eq%j"+g浩>-}2D$]tY^MvMZkjz!"D+v}ED]VgT~J^Ğ0̑.v='ѷޘ$ꖠjRUqKtXseG!H*F%R%`S{ ػ(3/*2eSY%4vfaft8l͂NB4ׇUH@62EGhvew3@Nˋ8-߀63P*͔v25`<8Kh5ֺ(P JjMላ`7\慄 U`޲[_fg/Hƒ ЮfE':{ Y(qAQ]O>&cJk*4$MG>֎m>/'d 8 N2ALE5,vqZAB c;h/ ғMIm:Hr#MPqVBK ]g1OȦG6h)Q*>FeH aH+Fخ+úhyJoytPc`u[ڢ߸kj{ VIB΍,yMSϝpj) [GKM jDa\i=φRƅ=/x;Jn-FM2n2Cz}UMٱ[12RU@0&?IH._I =Nd"TFFeZbs6:n{D&go(tfynMR $.Ws~U 40w>[ n\аNZ/T45/3f+(5q%"(}PטNl9 Y G2ӶdD1@|0DL yt :*#]Y0Dl3gpa.A.71T i͖LYc y(QzhG$,7s񿏕|ˍsw^>q$f\Et5|#bFfv5}&s?BJ&|6a:rDW-hPaeT)6`\8Shj| L+-3Q, 0XiC@6M!I%kͩh N~kV[3I:Ҡ;/sȝxfus]qz[cޘ})[jibYiW=q\~wߛW|BUb|V SQr6Yqe -+_< dTA@iBOEkvdIn$=!IHYs8 JwA} 4rھPgU,=K1E^%`l㶴sA :VL288h] Ǹd $լX5H71+Xh9L%J̪l=U(  C/hKB 2[p!g G;a7r8?J4pnLk|MG BlԧH4U4Ox'.L9 YrWZ m! "bp!f]WHaL:8wv7x!Bv?<>ܮ=7S-r!umJ"V!A37 '2F},Rj'2#q6R;D{fm pIQIor[ |}.5HVx.O]mX? tzI':-5mչ>qFJCpdbPܓ4xޛt (f2'/tNNCM,  d}rT`ST O [ԫAKE#x:4/;A^֓GFٝrgZQ-hze9Ŝ4߁}91OJ2q5z&}ZTNews{h?/3O+h<-էm('ߋ3fÖtyuw69 + dޝ7No^/5:Y=qukܮ+" J',)YૡQҷ TGɭlʊh6?պ0}$i!Sӌ4ӝIFƓ'F.7[ \fڭ6dpkԭ%V$!]?+SO&DfYc}%PJj .%Tn:DmU7agJW~4?6ydsvϝ%PBJr۹D+vgI -<O pzbj|=a;nR|݂2ӌ7PјptH^Xiىӂ D^>B `' L- ;̱^jS~ZO*:GV 5E(77cz+Cga.rloKw̚D($_{LYԏX*(% }FY/E(%Blcw8qLz~$i 1]]I;pWIM&ԒJ ]4kUn7bbTy=s$mºwHrog#s~|h=89L +[A•s^pI&Z)8E7ޏH.84cGҎ+:9[">wmnֳBqTC3%[9L1uf!֬c%7,JaC/HX\wp|VXJpqC?MhN|XvXhEtMˇHUZ&>Y{ҲUkρ%w.(<5{QM&]i񣙯@<.ćYb酱 a1KOxj鬟hE23mށbNEmK8-*iwbyw1qh .e"U*~ wTY lتa&g*-4&HGs]͊-?m*Xh9S- įK %n \/uC 1An򉵜i6 !vgY6>J7xIn}Б#Vkp:%@Hdv Xg>@)g#6JCGuN&{w?~@ø @xL QwT)F̴kws׀h9T Mײo쬎":"qB3)Dr_ˑ֒+ vm/,/6Vj7,qHJ+I=k7joB'0ss[FQ8i] O^\y'6݇.CMBݝ?Ne`xKk# VcvT(tDݳ ~M"=8 ,ζwDHY1$gv u&xQ)q+9/l{hވ13k-XU4>77({ Lf, 5)[j<ƖX} -QNmm~/&wbbR?]ΐ "N=6]EF0%f7b_21&N]B_T q)mEuRhe8:url-listl94:https://dumps.wikimedia.org/enwiki/20231220/enwiki-20231220-pages-articles-multistream.xml.bz298:http://dumps.wikimedia.your.org/enwiki/20231220/enwiki-20231220-pages-articles-multistream.xml.bz2115:http://ftp.acc.umu.se/mirror/wikimedia.org/dumps/enwiki/20231220/enwiki-20231220-pages-articles-multistream.xml.bz296:http://wikipedia.c3sl.ufpr.br/enwiki/20231220/enwiki-20231220-pages-articles-multistream.xml.bz2eetremotesf-2.8.2/src/torrentfileparser.cpp000066400000000000000000000131661500171105600206030ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include "torrentfileparser.h" #include "fileutils.h" #include "stdutils.h" using namespace std::string_view_literals; namespace tremotesf { namespace { constexpr auto infoKey = "info"sv; constexpr auto filesKey = "files"sv; constexpr auto pathKey = "path"sv; constexpr auto lengthKey = "length"sv; constexpr auto nameKey = "name"sv; constexpr auto announceListKey = "announce-list"sv; constexpr auto announceKey = "announce"sv; template std::optional maybeTakeDictValue(bencode::Dictionary& dict, std::string_view key) { const auto found = dict.find(key); return found != dict.end() ? std::optional(std::move(found->second).takeValue()) : std::nullopt; } template Expected takeDictValue(bencode::Dictionary& dict, std::string_view key, std::string_view dictName) { auto found = dict.find(key); if (found == dict.end()) { throw bencode::Error( bencode::Error::Type::Parsing, fmt::format("{} dictionary does not contain key '{}'", dictName, key) ); } return std::move(found->second).takeValue(); } QString computeInfoHashV1(const QString& path, qint64 infoDictOffset, qint64 infoDictLength) { try { QFile file(path); openFile(file, QIODevice::ReadOnly); skipBytes(file, infoDictOffset); QCryptographicHash hash(QCryptographicHash::Sha1); static constexpr QByteArray::size_type maxBufferSize = 1024 * 1024 * 1024; // 1 MiB QByteArray buffer(std::min(maxBufferSize, static_cast(infoDictLength)), '\0'); auto remaining = static_cast(infoDictLength); while (remaining > 0) { if (remaining < buffer.size()) { buffer.resize(remaining); } readBytes(file, buffer); hash.addData(buffer); remaining -= buffer.size(); } return {hash.result().toHex()}; } catch (const QFileError&) { std::throw_with_nested(bencode::Error(bencode::Error::Type::Reading, "Failed to compute info hash")); } } } TorrentMetainfoFile::File::File(bencode::Value&& value) { auto dict = std::move(value).takeDictionary(); mPath = takeDictValue(dict, pathKey, "info"); size = takeDictValue(dict, lengthKey, "info"); } TorrentMetainfoFile::TorrentMetainfoFile(bencode::Value&& value, QString&& infoHashV1) : infoHashV1(std::move(infoHashV1)) { auto rootDict = std::move(value).takeDictionary(); if (auto announceList = maybeTakeDictValue(rootDict, announceListKey); announceList) { trackers = toContainer(*announceList | std::views::transform([](bencode::Value& value) { return toContainer( std::move(value).takeList() | std::views::transform([](bencode::Value& value) { return std::move(value).takeString(); }) ); })); } else if (auto announce = maybeTakeDictValue(rootDict, announceKey); announce) { trackers = std::vector{std::set{std::move(*announce)}}; } auto infoDict = takeDictValue(rootDict, infoKey, "root"); rootFileName = takeDictValue(infoDict, nameKey, "info"); if (auto size = maybeTakeDictValue(infoDict, lengthKey); size) { mSingleFileSizeOrFiles = *size; } else { mSingleFileSizeOrFiles = takeDictValue(infoDict, filesKey, "info"); } } TorrentMetainfoFile parseTorrentFile(const QString& path) { std::optional> infoDictOffsetAndLength{}; auto bencodeValue = bencode::parse(path, [&](const bencode::ByteArray& key, qint64 offset, qint64 length) { if (key == infoKey) { infoDictOffsetAndLength = std::pair(offset, length); } }); if (!infoDictOffsetAndLength.has_value()) { throw bencode::Error(bencode::Error::Type::Parsing, "root dictionary does not contain key 'info'"); } const auto [offset, length] = *infoDictOffsetAndLength; return TorrentMetainfoFile(std::move(bencodeValue), computeInfoHashV1(path, offset, length)); } QDebug operator<<(QDebug debug, const TorrentMetainfoFile& torrentFile) { const QDebugStateSaver saver(debug); debug.noquote() << fmt::format(impl::singleArgumentFormatString, torrentFile).c_str(); return debug; } } fmt::format_context::iterator fmt::formatter::format( const tremotesf::TorrentMetainfoFile& torrentFile, format_context& ctx ) const { return fmt::format_to( ctx.out(), "TorrentMetainfoFile(infoHashV1={}, trackers={}, rootFileName={:?}, filesCount={})", torrentFile.infoHashV1, torrentFile.trackers, torrentFile.rootFileName, torrentFile.isSingleFile() ? 1 : std::get(torrentFile.mSingleFileSizeOrFiles).size() ); } tremotesf-2.8.2/src/torrentfileparser.h000066400000000000000000000040441500171105600202430ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_TORRENTFILEPARSER_H #define TREMOTESF_TORRENTFILEPARSER_H #include #include #include #include #include #include "log/formatters.h" #include "bencodeparser.h" namespace tremotesf { class TorrentMetainfoFile final { public: explicit TorrentMetainfoFile(bencode::Value&& value, QString&& infoHashV1); class File final { public: explicit File(bencode::Value&& value); bencode::Integer size; std::ranges::view auto path() { return mPath | std::views::transform([](bencode::Value& value) { return std::move(value).takeString(); }); } private: bencode::List mPath; }; QString infoHashV1; std::vector> trackers; QString rootFileName; inline bool isSingleFile() const { return std::holds_alternative(mSingleFileSizeOrFiles); } inline bencode::Integer singleFileSize() const { return std::get(mSingleFileSizeOrFiles); } inline std::ranges::view auto files() { return std::get(mSingleFileSizeOrFiles) | std::views::transform([](bencode::Value& value) { return File(std::move(value)); }); } private: std::variant mSingleFileSizeOrFiles; friend struct fmt::formatter; }; /** * @throws tremotesf::bencode::Error */ TorrentMetainfoFile parseTorrentFile(const QString& path); QDebug operator<<(QDebug debug, const TorrentMetainfoFile& torrentFile); } template<> struct fmt::formatter : tremotesf::SimpleFormatter { format_context::iterator format(const tremotesf::TorrentMetainfoFile& torrentFile, format_context& ctx) const; }; #endif // TREMOTESF_TORRENTFILEPARSER_H tremotesf-2.8.2/src/torrentfileparser_test.cpp000066400000000000000000000134561500171105600216440ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include "log/formatters.h" #include "literals.h" #include "torrentfileparser.h" #include "stdutils.h" QDebug operator<<(QDebug debug, const std::vector>& trackers) { const QDebugStateSaver saver(debug); debug.noquote() << fmt::format(tremotesf::impl::singleArgumentFormatString, trackers).c_str(); return debug; } namespace tremotesf { class TorrentFileParserTest final : public QObject { Q_OBJECT private slots: void parseDebianTorrentFile() { auto torrentFile = parseTorrentFile(QDir::current().filePath("debian-10.9.0-amd64-netinst.iso.torrent"_l1)); QCOMPARE(torrentFile.infoHashV1, "9f292c93eb0dbdd7ff7a4aa551aaa1ea7cafe004"_l1); const std::vector> expectedTrackers{{"http://bttracker.debian.org:6969/announce"_l1}}; QCOMPARE(torrentFile.trackers, expectedTrackers); QCOMPARE(torrentFile.isSingleFile(), true); QCOMPARE(torrentFile.singleFileSize(), 353370112); QCOMPARE(torrentFile.rootFileName, "debian-10.9.0-amd64-netinst.iso"_l1); } void parseWikiTorrentFile() { auto torrentFile = parseTorrentFile( QDir::current().filePath("enwiki-20231220-pages-articles-multistream.xml.bz2.torrent"_l1) ); QCOMPARE(torrentFile.infoHashV1, "80fb3b384728e950f2fd09e5929970d3d576270d"_l1); qInfo() << torrentFile.trackers; const std::vector> expectedTrackers{ {"http://tracker.opentrackr.org:1337/announce"_l1}, {"udp://tracker.opentrackr.org:1337"_l1}, {"udp://tracker.openbittorrent.com:80/announce"_l1}, {"http://fosstorrents.com:6969/announce"_l1}, {"udp://fosstorrents.com:6969/announce"_l1} }; QCOMPARE(torrentFile.trackers, expectedTrackers); QCOMPARE(torrentFile.isSingleFile(), true); QCOMPARE(torrentFile.singleFileSize(), 22711545577); QCOMPARE(torrentFile.rootFileName, "enwiki-20231220-pages-articles-multistream.xml.bz2"_l1); } void parseFedoraTorrentFile() { auto torrentFile = parseTorrentFile(QDir::current().filePath("Fedora-Workstation-Live-x86_64-34.torrent"_l1)); QCOMPARE(torrentFile.infoHashV1, "2046e45fb6cf298cd25e4c0decbea40c6603d91b"_l1); const std::vector> expectedTrackers{{"http://torrent.fedoraproject.org:6969/announce"_l1} }; QCOMPARE(torrentFile.trackers, expectedTrackers); QCOMPARE(torrentFile.isSingleFile(), false); QCOMPARE(torrentFile.rootFileName, "Fedora-Workstation-Live-x86_64-34"_l1); const auto files = toContainer(torrentFile.files() | std::views::transform([](TorrentMetainfoFile::File file) { return std::pair{file.size, toContainer(file.path())}; })); const std::set>> expected{ {1062, {"Fedora-Workstation-34-1.2-x86_64-CHECKSUM"_l1}}, {2007367680, {"Fedora-Workstation-Live-x86_64-34-1.2.iso"_l1}} }; QCOMPARE(files, expected); } void parseHybridV2TorrentFile() { auto torrentFile = parseTorrentFile(QDir::current().filePath("bittorrent-v2-hybrid-test.torrent"_l1)); QCOMPARE(torrentFile.infoHashV1, "631a31dd0a46257d5078c0dee4e66e26f73e42ac"_l1); const std::vector> expectedTrackers{}; QCOMPARE(torrentFile.trackers, expectedTrackers); QCOMPARE(torrentFile.isSingleFile(), false); QCOMPARE(torrentFile.rootFileName, "bittorrent-v1-v2-hybrid-test"_l1); const auto files = toContainer(torrentFile.files() | std::views::transform([](TorrentMetainfoFile::File file) { return std::pair{file.size, toContainer(file.path())}; })); const std::set>> expected{ {129434, {".pad"_l1, "129434"_l1}}, {227380, {".pad"_l1, "227380"_l1}}, {280339, {".pad"_l1, "280339"_l1}}, {442368, {".pad"_l1, "442368"_l1}}, {464896, {".pad"_l1, "464896"_l1}}, {507162, {".pad"_l1, "507162"_l1}}, {510995, {".pad"_l1, "510995"_l1}}, {524227, {".pad"_l1, "524227"_l1}}, {6535405, {"Darkroom (Stellar, 1994, Amiga ECS) HQ.mp4"_l1}}, {20506624, {"Spaceballs-StateOfTheArt.avi"_l1}}, {342230630, {"cncd_fairlight-ceasefire_(all_falls_down)-1080p.mp4"_l1}}, {61638604, {"eld-dust.mkv"_l1}}, {277889766, {"fairlight_cncd-agenda_circling_forth-1080p30lq.mp4"_l1}}, {44577773, {"meet the deadline - Still _ Evoke 2014.mp4"_l1}}, {61, {"readme.txt"_l1}}, {26296320, {"tbl-goa.avi"_l1}}, {115869700, {"tbl-tint.mpg"_l1}} }; QCOMPARE(files, expected); } void parseV2TorrentFile() { try { auto torrentFile = parseTorrentFile(QDir::current().filePath("bittorrent-v2-test.torrent"_l1)); QFAIL("parseTorrentFile must throw an exception"); } catch (const bencode::Error&) {} } }; } QTEST_GUILESS_MAIN(tremotesf::TorrentFileParserTest) #include "torrentfileparser_test.moc" tremotesf-2.8.2/src/translators.html000066400000000000000000000021151500171105600175570ustar00rootroot00000000000000

tremotesf-2.8.2/src/tremotesf.ico000066400000000000000000000226761500171105600170370ustar00rootroot0000000000000000 %(0` 77$;;4'7728885A>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M>?;M;<9H99/6770%... +++771*66.BJLITXVW[YY][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][Y][X\ZTXVSWUBDAd770%333@@@77/2*333---Y\Z˯w{y[_]dgfbgecfdbedafcadbadb`cb_da_ca^ba^ca]a_]a_]a_]a_[_][`^[_]Y][Z^\Y][Y][Y][Y][X\ZVZXlomX\Y77$VZXrusZ^\SWUnosp\`^W[YSWUM~򶹷uwvjmi@>9gieafcSWUmSWU+osp󴷵|}LLG:93:93:93EE@wzwosqpsrSWUGSWU `dbfieAA=7?>8?=8><7<:5ED@`dacfdvywSWUeSWU@nqo񝠞hmi`a_GGAgfbxwswwrwwrxwsggaGF@dfbjomeigSWU@SWUcfdmrn^`\RSNde`bc^bc^de`TSO`b^qtrX\ZSWUSWU[_]osqRSO_a]tvrtvrsuqsuq_a]VVQy}vzxTXVX\Z蔗twt~MLHilgjliTTPfli|}W[XW[Yȋw{xnspWXRswsuxv^^ZW\ZV[XUYWz~{`daqtp~~rtoX\ZtxvTXVSWUvuxw{}UZWadc}hljSWU]SWUTkol򃉆z|Z][eigz|]a_SWU8SWU2afc򃈅v{xglikpmZ^\nspw|yUYWSWUSWUX\Zv{xuzwfjhW\ZSWUSWUSWUSWUSWUSWUTXVX\ZSWUSWUSWUSWUSWUSWU_cbkomv{x~TXVUYW|~y~{w|yw|yw|yw|yw|yw|yw|yw|yw|yy}{w|yw|yw|yw|yw|yw|yw|yx}zafcTXV|SWUYZ^\vzx{~_daTXVSWU SWUSWU_UYWTXVTXVTXVTXVTXVTXVTXVTXVTXVUYW񇌊Y][TXVTXVTXVTXVTXVTXVTXVTXVUYWTXVsSWU ++VV& N W  ((  ))KK DD[[--   ((^^``~~~~cc##9Aa懍񇍋񇍋񇍋񈍊艍n???????tremotesf-2.8.2/src/tremotesf.manifest.in000066400000000000000000000012261500171105600204640ustar00rootroot00000000000000 UTF-8 tremotesf-2.8.2/src/tremotesf.manifest.rc000066400000000000000000000002641500171105600204630ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: CC0-1.0 #include CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "tremotesf.manifest" tremotesf-2.8.2/src/tremotesf.rc.in000066400000000000000000000030251500171105600172610ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: CC0-1.0 #include #define TREMOTESF_ICON 0 TREMOTESF_ICON ICON tremotesf.ico #define TORRENT_FILE_FRIENDLY_TYPE_NAME 0 #define MAGNET_LINK_FRIENDLY_TYPE_NAME 1 STRINGTABLE { TORRENT_FILE_FRIENDLY_TYPE_NAME, "Torrent file" MAGNET_LINK_FRIENDLY_TYPE_NAME, "Magnet link" } #ifdef _DEBUG #define TREMOTESF_FILEFLAGS VS_FF_DEBUG #else #define TREMOTESF_FILEFLAGS 0 #endif VS_VERSION_INFO VERSIONINFO FILEVERSION @PROJECT_VERSION_MAJOR@,@PROJECT_VERSION_MINOR@,@PROJECT_VERSION_PATCH@,0 PRODUCTVERSION @PROJECT_VERSION_MAJOR@,@PROJECT_VERSION_MINOR@,@PROJECT_VERSION_PATCH@,0 FILEFLAGSMASK VS_FFI_FILEFLAGSMASK FILEFLAGS TREMOTESF_FILEFLAGS FILEOS VOS__WINDOWS32 FILETYPE VFT_APP FILESUBTYPE VFT2_UNKNOWN BEGIN BLOCK "StringFileInfo" BEGIN BLOCK "040904B0" BEGIN VALUE "CompanyName", "Tremotesf" VALUE "FileDescription", "Tremotesf - Remote GUI for transmission-daemon" VALUE "FileVersion", "@PROJECT_VERSION@" VALUE "InternalName", "@TREMOTESF_EXECUTABLE_NAME@" VALUE "LegalCopyright", "Copyright 2015-2024 Alexey Rochev" VALUE "OriginalFilename", "@TREMOTESF_EXECUTABLE_NAME@.exe" VALUE "ProductName", "@TREMOTESF_APP_NAME@" VALUE "ProductVersion", "@PROJECT_VERSION@" END END BLOCK "VarFileInfo" BEGIN VALUE "Translation", 0x409, 1200 END END tremotesf-2.8.2/src/ui/000077500000000000000000000000001500171105600147335ustar00rootroot00000000000000tremotesf-2.8.2/src/ui/darkthemeapplier_windows.cpp000066400000000000000000000302501500171105600225320ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "darkthemeapplier_windows.h" #include #include #include #include #include #include #include #include #include #include #include "log/log.h" #include "settings.h" #include "windowshelpers.h" #include "systemcolorsprovider.h" namespace tremotesf { namespace { namespace DWMWINDOWATTRIBUTE_compat { inline constexpr DWORD DWMWA_USE_IMMERSIVE_DARK_MODE_1809_UNTIL_2004 = 19; inline constexpr DWORD DWMWA_USE_IMMERSIVE_DARK_MODE_SINCE_2004 = 20; inline constexpr DWORD DWMWA_CAPTION_COLOR = 35; } class TitleBarBackgroundEventFilter final : public QObject { Q_OBJECT public: explicit TitleBarBackgroundEventFilter( SystemColorsProvider* systemColorsProvider, QObject* parent = nullptr ) : QObject{parent}, mSystemColorsProvider{systemColorsProvider} {} bool eventFilter(QObject* watched, QEvent* event) override { switch (event->type()) { case QEvent::WinIdChange: { // Using QWindow::winId instead of QWidget::winId directly because // QWidget::winId may create native window prematurely which we don't want // QWidget::windowHandle will return nullptr if native window doesn't exist yet so we check for that QWindow* window{}; if (auto widget = qobject_cast(watched); widget) { window = widget->windowHandle(); } if (window) { switch (window->type()) { case Qt::Window: case Qt::Dialog: applyDarkThemeToTitleBarAndConnectSignal(window); break; default: break; } } break; } default: break; } return false; } private: static inline constexpr auto signalAddedProperty = "tremotesf::TitleBarBackgroundEventFilter"; void applyDarkThemeToTitleBarAndConnectSignal(QWindow* window) { applyDarkThemeToTitleBar(window); if (!window->property(signalAddedProperty).toBool()) { QObject::connect( mSystemColorsProvider, &SystemColorsProvider::darkThemeEnabledChanged, window, [this, window] { applyDarkThemeToTitleBar(window); } ); QObject::connect(Settings::instance(), &Settings::darkThemeModeChanged, window, [this, window] { applyDarkThemeToTitleBar(window); }); window->setProperty(signalAddedProperty, true); } } void applyDarkThemeToTitleBar(QWindow* window) { if (QOperatingSystemVersion::current() >= QOperatingSystemVersion::Windows11) { debug().log("Setting DWMWA_CAPTION_COLOR on {}", *window); const auto qcolor = QGuiApplication::palette().color(QPalette::Window); const auto color = RGB(qcolor.red(), qcolor.green(), qcolor.blue()); try { checkHResult( DwmSetWindowAttribute( reinterpret_cast(window->winId()), DWMWINDOWATTRIBUTE_compat::DWMWA_CAPTION_COLOR, &color, sizeof(color) ), "DwmSetWindowAttribute" ); } catch (const winrt::hresult_error& e) { warning().logWithException(e, "Failed to set DWMWA_CAPTION_COLOR on {}", *window); } } else { debug().log("Setting DWMWA_USE_IMMERSIVE_DARK_MODE on {}", *window); const auto attribute = []() -> DWORD { if (QOperatingSystemVersion::current() >= QOperatingSystemVersion::Windows10_2004) { return DWMWINDOWATTRIBUTE_compat::DWMWA_USE_IMMERSIVE_DARK_MODE_SINCE_2004; } return DWMWINDOWATTRIBUTE_compat::DWMWA_USE_IMMERSIVE_DARK_MODE_1809_UNTIL_2004; }(); const bool darkTheme = [&] { switch (Settings::instance()->get_darkThemeMode()) { case Settings::DarkThemeMode::FollowSystem: return mSystemColorsProvider->isDarkThemeEnabled(); case Settings::DarkThemeMode::On: return true; case Settings::DarkThemeMode::Off: return false; } throw std::logic_error("Unknown DarkThemeMode value"); }(); const auto useImmersiveDarkMode = static_cast(darkTheme); try { checkHResult( DwmSetWindowAttribute( reinterpret_cast(window->winId()), attribute, &useImmersiveDarkMode, sizeof(useImmersiveDarkMode) ), "DwmSetWindowAttribute" ); } catch (const winrt::hresult_error& e) { warning().logWithException(e, "Failed to set DWMWA_USE_IMMERSIVE_DARK_MODE on {}", *window); } } } SystemColorsProvider* mSystemColorsProvider{}; }; QColor blendAtop(QColor source, QColor background) { const int alpha = source.alpha(); if (alpha == 255) return source; const auto blend = [&](int s, int b) { return (s * alpha / 255 + b * (255 - alpha) / 255); }; return { blend(source.red(), background.red()), blend(source.green(), background.green()), blend(source.blue(), background.blue()) }; } double getRelativeLuminance(QColor color) { const auto trans = [](auto compF) { const auto comp = static_cast(compF); if (comp <= 0.03928) { return comp / 12.92; } return std::pow(((comp + 0.055) / 1.055), 2.4); }; return 0.2126 * trans(color.redF()) + 0.7152 * trans(color.greenF()) + 0.0722 * trans(color.blueF()); } double getContrastRatio(QColor lighterColor, QColor darkerColor) { return (getRelativeLuminance(lighterColor) + 0.05) / (getRelativeLuminance(darkerColor) + 0.05); } // From Web Content Accessibility Guidelines 2.2 bool isLegibleWithWhiteText(QColor backgroundColor) { const auto ratio = getContrastRatio(blendAtop(Qt::white, backgroundColor), backgroundColor); return std::ceil(ratio * 10.0) >= 45.0; } QColor withAlpha(QColor color, int alpha) { color.setAlpha(alpha); return color; } inline constexpr SystemColorsProvider::AccentColors defaultAccentColors{ .accentColor = QColor(48, 140, 198), .accentColorLight1 = QColor(58, 168, 238), .accentColorDark1 = QColor(40, 117, 165), .accentColorDark2 = QColor(33, 97, 137) }; void applyAccentToPalette(Settings* settings, SystemColorsProvider* systemColorsProvider) { info().log("Applying accent colors to palette"); SystemColorsProvider::AccentColors accentColors; if (settings->get_useSystemAccentColor()) { accentColors = systemColorsProvider->accentColors(); if (!accentColors.isValid()) { accentColors = defaultAccentColors; } } else { accentColors = defaultAccentColors; } info().log("Accent colors are {}", accentColors); QPalette palette{}; if (isLegibleWithWhiteText(accentColors.accentColor)) { palette.setColor(QPalette::Active, QPalette::Highlight, accentColors.accentColor); palette.setColor(QPalette::Disabled, QPalette::Highlight, withAlpha(accentColors.accentColor, 93)); } else { palette.setColor(QPalette::Active, QPalette::Highlight, accentColors.accentColorDark1); palette.setColor(QPalette::Disabled, QPalette::Highlight, withAlpha(accentColors.accentColorDark1, 93)); } palette.setColor(QPalette::Active, QPalette::HighlightedText, Qt::white); palette.setColor(QPalette::Disabled, QPalette::HighlightedText, withAlpha(Qt::white, 93)); palette.setColor( QPalette::Inactive, QPalette::Highlight, withAlpha(palette.color(QPalette::Active, QPalette::Highlight), 153) ); palette.setColor(QPalette::Inactive, QPalette::HighlightedText, Qt::white); palette.setColor(QPalette::Active, QPalette::Accent, palette.color(QPalette::Active, QPalette::Highlight)); palette .setColor(QPalette::Disabled, QPalette::Accent, palette.color(QPalette::Disabled, QPalette::Highlight)); QGuiApplication::setPalette(palette); if (qApp->styleHints()->colorScheme() == Qt::ColorScheme::Dark) { QPalette checkBoxPalette{}; checkBoxPalette.setColor(QPalette::Active, QPalette::Base, accentColors.accentColorDark1); checkBoxPalette.setColor(QPalette::Active, QPalette::Button, accentColors.accentColorLight1); checkBoxPalette.setColor(QPalette::Inactive, QPalette::Base, accentColors.accentColorDark2); QApplication::setPalette(checkBoxPalette, "QCheckBox"); QApplication::setPalette(checkBoxPalette, "QRadioButton"); } } } void applyDarkThemeToPalette(SystemColorsProvider* systemColorsProvider) { const auto settings = Settings::instance(); const auto apply = [=] { const bool darkTheme = [&] { switch (settings->get_darkThemeMode()) { case Settings::DarkThemeMode::FollowSystem: return systemColorsProvider->isDarkThemeEnabled(); case Settings::DarkThemeMode::On: return true; case Settings::DarkThemeMode::Off: return false; } throw std::logic_error("Unknown DarkThemeMode value"); }(); const auto colorScheme = darkTheme ? Qt::ColorScheme::Dark : Qt::ColorScheme::Light; info().log("Setting color scheme {}", colorScheme); qApp->styleHints()->setColorScheme(colorScheme); QMetaObject::invokeMethod( qApp, [=]() { applyAccentToPalette(settings, systemColorsProvider); }, Qt::QueuedConnection ); }; apply(); QObject::connect(systemColorsProvider, &SystemColorsProvider::darkThemeEnabledChanged, qApp, apply); QObject::connect(systemColorsProvider, &SystemColorsProvider::accentColorsChanged, qApp, apply); QObject::connect(settings, &Settings::darkThemeModeChanged, qApp, apply); QObject::connect(settings, &Settings::useSystemAccentColorChanged, qApp, apply); qApp->installEventFilter(new TitleBarBackgroundEventFilter(systemColorsProvider, QGuiApplication::instance())); } } #include "darkthemeapplier_windows.moc" tremotesf-2.8.2/src/ui/darkthemeapplier_windows.h000066400000000000000000000006051500171105600222000ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_DARKTHEMEAPPLIER_WINDOWS_H #define TREMOTESF_DARKTHEMEAPPLIER_WINDOWS_H class QWindow; namespace tremotesf { class SystemColorsProvider; void applyDarkThemeToPalette(SystemColorsProvider* systemColorsProvider); } #endif // TREMOTESF_DARKTHEMEAPPLIER_WINDOWS_H tremotesf-2.8.2/src/ui/iconthemesetup.h000066400000000000000000000004641500171105600201440ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_FALLBACK_ICON_THEME_H #define TREMOTESF_FALLBACK_ICON_THEME_H namespace tremotesf { void setupIconTheme(); void setFallbackIconTheme(); } #endif // TREMOTESF_FALLBACK_ICON_THEME_H tremotesf-2.8.2/src/ui/iconthemesetup_bundled.cpp000066400000000000000000000010401500171105600221630ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include "iconthemesetup.h" #include "fileutils.h" #include "literals.h" #include "startup/recoloringsvgiconengineplugin.h" namespace tremotesf { void setupIconTheme() { QIcon::setThemeSearchPaths({resolveExternalBundledResourcesPath("icons"_l1)}); QIcon::setThemeName(TREMOTESF_BUNDLED_ICON_THEME ""_l1); QApplication::setStyle(new RecoloringSvgIconStyle(qApp)); } } tremotesf-2.8.2/src/ui/iconthemesetup_freedesktop.cpp000066400000000000000000000015521500171105600230710ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include "iconthemesetup.h" #include "literals.h" #include "log/log.h" namespace tremotesf { namespace { bool isPaletteDark() { // Can't use QStyleHints::colorScheme since it can lie const QPalette palette = QGuiApplication::palette(); const int windowBackgroundGray = qGray(palette.window().color().rgb()); return windowBackgroundGray < 192; } QLatin1String fallbackTheme() { return isPaletteDark() ? "breeze-dark"_l1 : "breeze"_l1; } } void setupIconTheme() { const auto theme = fallbackTheme(); info().log("Setting {} as fallback icon theme", theme); QIcon::setFallbackThemeName(theme); } } tremotesf-2.8.2/src/ui/itemmodels/000077500000000000000000000000001500171105600170755ustar00rootroot00000000000000tremotesf-2.8.2/src/ui/itemmodels/baseproxymodel.cpp000066400000000000000000000050761500171105600226460ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "baseproxymodel.h" namespace tremotesf { BaseProxyModel::BaseProxyModel( QAbstractItemModel* sourceModel, int sortRole, std::optional fallbackColumn, QObject* parent ) : QSortFilterProxyModel(parent), mFallbackColumn(fallbackColumn) { setSourceModel(sourceModel); QSortFilterProxyModel::setSortRole(sortRole); mCollator.setCaseSensitivity(Qt::CaseInsensitive); mCollator.setNumericMode(true); } QModelIndex BaseProxyModel::sourceIndex(const QModelIndex& proxyIndex) const { return mapToSource(proxyIndex); } QModelIndex BaseProxyModel::sourceIndex(int proxyRow) const { return mapToSource(index(proxyRow, 0)); } QModelIndexList BaseProxyModel::sourceIndexes(const QModelIndexList& proxyIndexes) const { QModelIndexList indexes; indexes.reserve(proxyIndexes.size()); for (const QModelIndex& index : proxyIndexes) { indexes.append(mapToSource(index)); } return indexes; } void BaseProxyModel::sort(int column, Qt::SortOrder order) { QSortFilterProxyModel::sort(column, order); emit sortOrderChanged(); } bool BaseProxyModel::lessThan(const QModelIndex& source_left, const QModelIndex& source_right) const { std::partial_ordering ord = compare(source_left, source_right); if (ord == std::partial_ordering::equivalent && mFallbackColumn.has_value() && source_left.column() != *mFallbackColumn) { ord = compare(source_left.siblingAtColumn(*mFallbackColumn), source_right.siblingAtColumn(*mFallbackColumn)); } return ord == std::partial_ordering::less; } std::partial_ordering BaseProxyModel::compare(const QModelIndex& source_left, const QModelIndex& source_right) const { const auto role = sortRole(); const QVariant leftData = source_left.data(role); const QVariant rightData = source_right.data(role); if (leftData.userType() == QMetaType::QString && rightData.userType() == QMetaType::QString) { return mCollator.compare(leftData.toString(), rightData.toString()) <=> 0; } if (QSortFilterProxyModel::lessThan(source_left, source_right)) { return std::partial_ordering::less; } if (leftData.userType() == rightData.userType() && leftData == rightData) { return std::partial_ordering::equivalent; } return std::partial_ordering::unordered; } } tremotesf-2.8.2/src/ui/itemmodels/baseproxymodel.h000066400000000000000000000026371500171105600223130ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_BASEPROXYMODEL_H #define TREMOTESF_BASEPROXYMODEL_H #ifndef Q_MOC_RUN // compare includes concepts and moc can't handle it :/ # include #endif #include #include #include #include namespace tremotesf { class BaseProxyModel : public QSortFilterProxyModel { Q_OBJECT public: explicit BaseProxyModel( QAbstractItemModel* sourceModel = nullptr, int sortRole = Qt::DisplayRole, std::optional fallbackColumn = std::nullopt, QObject* parent = nullptr ); QModelIndex sourceIndex(const QModelIndex& proxyIndex) const; QModelIndex sourceIndex(int proxyRow) const; QModelIndexList sourceIndexes(const QModelIndexList& proxyIndexes) const; void sort(int column = 0, Qt::SortOrder order = Qt::AscendingOrder) override; protected: bool lessThan(const QModelIndex& source_left, const QModelIndex& source_right) const override; private: std::partial_ordering compare(const QModelIndex& source_left, const QModelIndex& source_right) const; std::optional mFallbackColumn{}; QCollator mCollator{}; signals: void sortOrderChanged(); }; } #endif // TREMOTESF_BASEPROXYMODEL_H tremotesf-2.8.2/src/ui/itemmodels/basetorrentfilesmodel.cpp000066400000000000000000000213671500171105600242060ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "basetorrentfilesmodel.h" #include #include #include "desktoputils.h" #include "itemlistupdater.h" #include "formatutils.h" namespace tremotesf { BaseTorrentFilesModel::BaseTorrentFilesModel(std::vector&& columns, QObject* parent) : QAbstractItemModel(parent), mColumns(std::move(columns)) {} int BaseTorrentFilesModel::columnCount(const QModelIndex&) const { return static_cast(mColumns.size()); } QVariant BaseTorrentFilesModel::data(const QModelIndex& index, int role) const { if (!index.isValid()) { return {}; } const TorrentFilesModelEntry* entry = static_cast(index.internalPointer()); const Column column = mColumns.at(static_cast(index.column())); switch (role) { case Qt::CheckStateRole: if (column == Column::Name) { switch (entry->wantedState()) { case TorrentFilesModelEntry::Wanted: return Qt::Checked; case TorrentFilesModelEntry::Unwanted: return Qt::Unchecked; case TorrentFilesModelEntry::MixedWanted: return Qt::PartiallyChecked; } } break; case Qt::DecorationRole: if (column == Column::Name) { if (entry->isDirectory()) { return desktoputils::standardDirIcon(); } return desktoputils::standardFileIcon(); } break; case Qt::DisplayRole: switch (column) { case Column::Name: return entry->name(); case Column::Size: return formatutils::formatByteSize(entry->size()); case Column::ProgressBar: case Column::Progress: return formatutils::formatProgress(entry->progress()); case Column::Priority: return entry->priorityString(); default: break; } break; case Qt::ToolTipRole: if (column == Column::Name) { return entry->name(); } break; case SortRole: switch (column) { case Column::Size: return entry->size(); case Column::ProgressBar: case Column::Progress: return entry->progress(); case Column::Priority: return entry->priority(); default: return data(index, Qt::DisplayRole); } default: return {}; } return {}; } Qt::ItemFlags BaseTorrentFilesModel::flags(const QModelIndex& index) const { if (!index.isValid()) { return {}; } if (static_cast(index.column()) == Column::Name) { return QAbstractItemModel::flags(index) | Qt::ItemIsUserCheckable; } return QAbstractItemModel::flags(index); } QVariant BaseTorrentFilesModel::headerData(int section, Qt::Orientation orientation, int role) const { if (orientation != Qt::Horizontal || role != Qt::DisplayRole) { return {}; } switch (mColumns.at(static_cast(section))) { case Column::Name: //: Column title in torrent's file list return qApp->translate("tremotesf", "Name"); case Column::Size: //: Column title in torrent's file list return qApp->translate("tremotesf", "Size"); case Column::ProgressBar: //: Column title in torrent's file list return qApp->translate("tremotesf", "Progress Bar"); case Column::Progress: //: Column title in torrent's file list return qApp->translate("tremotesf", "Progress"); case Column::Priority: //: Column title in torrent's file list return qApp->translate("tremotesf", "Priority"); default: return {}; } } bool BaseTorrentFilesModel::setData(const QModelIndex& index, const QVariant& value, int role) { if (!index.isValid()) { return false; } if (static_cast(index.column()) == Column::Name && role == Qt::CheckStateRole) { setFileWanted(index, (value.toInt() == Qt::Checked)); return true; } return false; } QModelIndex BaseTorrentFilesModel::index(int row, int column, const QModelIndex& parent) const { const TorrentFilesModelDirectory* parentDirectory{}; if (parent.isValid()) { parentDirectory = static_cast(parent.internalPointer()); } else if (mRootDirectory) { parentDirectory = mRootDirectory.get(); } else { return {}; } return createIndex(row, column, parentDirectory->children().at(static_cast(row)).get()); } QModelIndex BaseTorrentFilesModel::parent(const QModelIndex& child) const { if (!child.isValid()) { return {}; } TorrentFilesModelDirectory* parentDirectory = static_cast(child.internalPointer())->parentDirectory(); if (parentDirectory == mRootDirectory.get()) { return {}; } return createIndex(parentDirectory->row(), 0, parentDirectory); } int BaseTorrentFilesModel::rowCount(const QModelIndex& parent) const { if (parent.isValid()) { const TorrentFilesModelEntry* entry = static_cast(parent.internalPointer()); if (entry->isDirectory()) { return static_cast(static_cast(entry)->children().size()); } return 0; } if (mRootDirectory) { return static_cast(mRootDirectory->children().size()); } return 0; } void BaseTorrentFilesModel::setFileWanted(const QModelIndex& index, bool wanted) { if (index.isValid()) { static_cast(index.internalPointer())->setWanted(wanted); updateDirectoryChildren(); } } void BaseTorrentFilesModel::setFilesWanted(const QModelIndexList& indexes, bool wanted) { for (const QModelIndex& index : indexes) { if (index.isValid()) { static_cast(index.internalPointer())->setWanted(wanted); } } updateDirectoryChildren(); } void BaseTorrentFilesModel::setFilePriority(const QModelIndex& index, TorrentFilesModelEntry::Priority priority) { if (index.isValid()) { static_cast(index.internalPointer())->setPriority(priority); updateDirectoryChildren(); } } void BaseTorrentFilesModel::setFilesPriority(const QModelIndexList& indexes, TorrentFilesModelEntry::Priority priority) { for (const QModelIndex& index : indexes) { if (index.isValid()) { static_cast(index.internalPointer())->setPriority(priority); } } updateDirectoryChildren(); } void BaseTorrentFilesModel::fileRenamed(TorrentFilesModelEntry* entry, const QString& newName) { entry->setName(newName); emit dataChanged(createIndex(entry->row(), 0, entry), createIndex(entry->row(), columnCount() - 1, entry)); } void BaseTorrentFilesModel::updateDirectoryChildren(const QModelIndex& parent) { const TorrentFilesModelDirectory* directory{}; if (parent.isValid()) { directory = static_cast(parent.internalPointer()); } else if (mRootDirectory) { directory = mRootDirectory.get(); } else { return; } auto changedBatchProcessor = ItemBatchProcessor([&](size_t first, size_t last) { emit dataChanged( index(static_cast(first), 0, parent), index(static_cast(last) - 1, columnCount() - 1, parent) ); }); for (auto& child : directory->children()) { if (child->isChanged()) { changedBatchProcessor.nextIndex(static_cast(child->row())); if (child->isDirectory()) { updateDirectoryChildren(index(child->row(), 0, parent)); } else { static_cast(child.get())->setChanged(false); } } } changedBatchProcessor.commitIfNeeded(); } } tremotesf-2.8.2/src/ui/itemmodels/basetorrentfilesmodel.h000066400000000000000000000041461500171105600236470ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_BASETORRENTFILESMODEL_H #define TREMOTESF_BASETORRENTFILESMODEL_H #include #include #include #include "torrentfilesmodelentry.h" namespace tremotesf { class BaseTorrentFilesModel : public QAbstractItemModel { Q_OBJECT public: enum class Column { Name, Size, ProgressBar, Progress, Priority }; static constexpr auto SortRole = Qt::UserRole; explicit BaseTorrentFilesModel(std::vector&& columns, QObject* parent = nullptr); int columnCount(const QModelIndex& = {}) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; Qt::ItemFlags flags(const QModelIndex& index) const override; QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole) override; QModelIndex index(int row, int column, const QModelIndex& parent = QModelIndex()) const override; QModelIndex parent(const QModelIndex& child) const override; int rowCount(const QModelIndex& parent = {}) const override; virtual void setFileWanted(const QModelIndex& index, bool wanted); virtual void setFilesWanted(const QModelIndexList& indexes, bool wanted); virtual void setFilePriority(const QModelIndex& index, TorrentFilesModelEntry::Priority priority); virtual void setFilesPriority(const QModelIndexList& indexes, TorrentFilesModelEntry::Priority priority); virtual void renameFile(const QModelIndex& index, const QString& newName) = 0; void fileRenamed(TorrentFilesModelEntry* entry, const QString& newName); protected: void updateDirectoryChildren(const QModelIndex& parent = QModelIndex()); std::shared_ptr mRootDirectory; private: const std::vector mColumns; }; } #endif // TREMOTESF_BASETORRENTFILESMODEL_H tremotesf-2.8.2/src/ui/itemmodels/modelutils.h000066400000000000000000000030141500171105600214250ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_MODELUTILS_H #define TREMOTESF_MODELUTILS_H #include #include #include "itemlistupdater.h" namespace tremotesf { template< std::derived_from Model, typename Item, std::ranges::forward_range NewItemsRange = std::vector> class ModelListUpdater : public ItemListUpdater { public: inline explicit ModelListUpdater(Model& model) : mModel(model) {} protected: void onChangedItems(size_t first, size_t last) override { emit mModel.dataChanged( mModel.index(static_cast(first), 0), mModel.index(static_cast(last - 1), lastColumn) ); } void onAboutToRemoveItems(size_t first, size_t last) override { mModel.beginRemoveRows({}, static_cast(first), static_cast(last - 1)); }; void onRemovedItems(size_t, size_t) override { mModel.endRemoveRows(); } void onAboutToAddItems(size_t count) override { const int first = mModel.rowCount(); mModel.beginInsertRows({}, first, first + static_cast(count) - 1); } void onAddedItems(size_t) override { mModel.endInsertRows(); }; Model& mModel; const int lastColumn = static_cast(mModel).columnCount() - 1; }; } #endif // TREMOTESF_MODELUTILS_H tremotesf-2.8.2/src/ui/itemmodels/stringlistmodel.cpp000066400000000000000000000006321500171105600230250ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "stringlistmodel.h" #include "ui/itemmodels/modelutils.h" namespace tremotesf { void StringListModel::setStringList(const std::vector& stringList) { ModelListUpdater updater(*this); updater.update(mStringList, std::vector(stringList)); } } tremotesf-2.8.2/src/ui/itemmodels/stringlistmodel.h000066400000000000000000000035331500171105600224750ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_STRINGLISTMODEL_H #define TREMOTESF_STRINGLISTMODEL_H #include #include #include #include #include namespace tremotesf { class StringListModel final : public QAbstractListModel { Q_OBJECT public: explicit inline StringListModel(std::optional header, std::optional decoration, QObject* parent) : QAbstractListModel(parent), mHeader(std::move(header)), mDecoration(std::move(decoration)) {}; inline QVariant data(const QModelIndex& index, int role) const override { if (!index.isValid()) return {}; switch (role) { case Qt::DisplayRole: return mStringList.at(static_cast(index.row())); case Qt::DecorationRole: if (mDecoration) { return *mDecoration; } break; } return {}; }; inline QVariant headerData(int, Qt::Orientation orientation, int role) const override { return (orientation == Qt::Horizontal && role == Qt::DisplayRole && mHeader) ? *mHeader : QVariant{}; }; inline int rowCount(const QModelIndex& = {}) const override { return static_cast(mStringList.size()); }; void setStringList(const std::vector& stringList); using QAbstractItemModel::beginInsertRows; using QAbstractItemModel::beginRemoveRows; using QAbstractItemModel::endInsertRows; using QAbstractItemModel::endRemoveRows; private: std::optional mHeader; std::optional mDecoration; std::vector mStringList; }; } #endif // TREMOTESF_STRINGLISTMODEL_H tremotesf-2.8.2/src/ui/itemmodels/torrentfilesmodelentry.cpp000066400000000000000000000214301500171105600244240ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include "torrentfilesmodelentry.h" #include namespace tremotesf { TorrentFilesModelEntry::Priority TorrentFilesModelEntry::fromFilePriority(TorrentFile::Priority priority) { switch (priority) { case TorrentFile::Priority::Low: return LowPriority; case TorrentFile::Priority::Normal: return NormalPriority; case TorrentFile::Priority::High: return HighPriority; default: return NormalPriority; } } TorrentFile::Priority TorrentFilesModelEntry::toFilePriority(TorrentFilesModelEntry::Priority priority) { switch (priority) { case LowPriority: return TorrentFile::Priority::Low; case NormalPriority: return TorrentFile::Priority::Normal; case HighPriority: return TorrentFile::Priority::High; default: return TorrentFile::Priority::Normal; } } TorrentFilesModelEntry::TorrentFilesModelEntry(int row, TorrentFilesModelDirectory* parentDirectory, QString name) : mRow(row), mParentDirectory(parentDirectory), mName(std::move(name)) {} int TorrentFilesModelEntry::row() const { return mRow; } TorrentFilesModelDirectory* TorrentFilesModelEntry::parentDirectory() const { return mParentDirectory; } QString TorrentFilesModelEntry::name() const { return mName; } void TorrentFilesModelEntry::setName(const QString& name) { mName = name; } QString TorrentFilesModelEntry::path() const { QString path(mName); TorrentFilesModelDirectory* parent = mParentDirectory; while (parent && !parent->name().isEmpty()) { path.prepend('/'); path.prepend(parent->name()); parent = parent->parentDirectory(); } return path; } QString TorrentFilesModelEntry::priorityString() const { switch (priority()) { case LowPriority: //: Torrent's file loading priority return qApp->translate("tremotesf", "Low"); case NormalPriority: //: Torrent's file loading priority return qApp->translate("tremotesf", "Normal"); case HighPriority: //: Torrent's file loading priority return qApp->translate("tremotesf", "High"); case MixedPriority: //: Torrent's file loading priority return qApp->translate("tremotesf", "Mixed"); } return {}; } TorrentFilesModelDirectory::TorrentFilesModelDirectory( int row, TorrentFilesModelDirectory* parentDirectory, const QString& name ) : TorrentFilesModelEntry(row, parentDirectory, name) {} bool TorrentFilesModelDirectory::isDirectory() const { return true; } long long TorrentFilesModelDirectory::size() const { long long bytes = 0; for (const auto& child : mChildren) { bytes += child->size(); } return bytes; } long long TorrentFilesModelDirectory::completedSize() const { long long bytes = 0; for (const auto& child : mChildren) { bytes += child->completedSize(); } return bytes; } double TorrentFilesModelDirectory::progress() const { const long long bytes = size(); if (bytes > 0) { return static_cast(completedSize()) / static_cast(bytes); } return 0; } TorrentFilesModelEntry::WantedState TorrentFilesModelDirectory::wantedState() const { const TorrentFilesModelEntry::WantedState first = mChildren.front()->wantedState(); if (mChildren.size() > 1) { for (const auto& child : mChildren | std::views::drop(1)) { if (child->wantedState() != first) { return MixedWanted; } } } return first; } void TorrentFilesModelDirectory::setWanted(bool wanted) { for (auto& child : mChildren) { child->setWanted(wanted); } } TorrentFilesModelEntry::Priority TorrentFilesModelDirectory::priority() const { const Priority first = mChildren.front()->priority(); if (mChildren.size() > 1) { for (const auto& child : mChildren | std::views::drop(1)) { if (child->priority() != first) { return MixedPriority; } } } return first; } void TorrentFilesModelDirectory::setPriority(Priority priority) { for (const auto& child : mChildren) { child->setPriority(priority); } } const std::vector>& TorrentFilesModelDirectory::children() const { return mChildren; } const std::unordered_map& TorrentFilesModelDirectory::childrenHash() const { return mChildrenHash; } TorrentFilesModelFile* TorrentFilesModelDirectory::addFile(int id, const QString& name, long long size) { const int row = static_cast(mChildren.size()); auto file = std::make_unique(row, this, id, name, size); auto* filePtr = file.get(); addChild(std::move(file)); return filePtr; } TorrentFilesModelDirectory* TorrentFilesModelDirectory::addDirectory(const QString& name) { const int row = static_cast(mChildren.size()); auto directory = std::make_unique(row, this, name); auto* directoryPtr = directory.get(); addChild(std::move(directory)); return directoryPtr; } void TorrentFilesModelDirectory::clearChildren() { mChildren.clear(); mChildrenHash.clear(); } std::vector TorrentFilesModelDirectory::childrenIds() const { std::vector ids{}; ids.reserve(mChildren.size()); for (const auto& child : mChildren) { if (child->isDirectory()) { const auto childrenIds = static_cast(child.get())->childrenIds(); ids.reserve(ids.size() + childrenIds.size()); ids.insert(ids.end(), childrenIds.begin(), childrenIds.end()); } else { ids.push_back(static_cast(child.get())->id()); } } return ids; } bool TorrentFilesModelDirectory::isChanged() const { return std::ranges::any_of(mChildren, [](const auto& child) { return child->isChanged(); }); } void TorrentFilesModelDirectory::addChild(std::unique_ptr&& child) { mChildrenHash.emplace(child->name(), child.get()); mChildren.push_back(std::move(child)); } TorrentFilesModelFile::TorrentFilesModelFile( int row, TorrentFilesModelDirectory* parentDirectory, int id, const QString& name, long long size ) : TorrentFilesModelEntry(row, parentDirectory, name), mSize(size), mCompletedSize(0), mWantedState(Unwanted), mPriority(NormalPriority), mId(id), mChanged(false) {} bool TorrentFilesModelFile::isDirectory() const { return false; } long long TorrentFilesModelFile::size() const { return mSize; } long long TorrentFilesModelFile::completedSize() const { return mCompletedSize; } double TorrentFilesModelFile::progress() const { if (mSize > 0) { return static_cast(mCompletedSize) / static_cast(mSize); } return 0; } TorrentFilesModelEntry::WantedState TorrentFilesModelFile::wantedState() const { return mWantedState; } void TorrentFilesModelFile::setWanted(bool wanted) { WantedState wantedState{}; if (wanted) { wantedState = Wanted; } else { wantedState = Unwanted; } if (wantedState != mWantedState) { mWantedState = wantedState; mChanged = true; } } TorrentFilesModelEntry::Priority TorrentFilesModelFile::priority() const { return mPriority; } void TorrentFilesModelFile::setPriority(Priority priority) { if (priority != mPriority) { mPriority = priority; } } bool TorrentFilesModelFile::isChanged() const { return mChanged; } void TorrentFilesModelFile::setChanged(bool changed) { mChanged = changed; } int TorrentFilesModelFile::id() const { return mId; } void TorrentFilesModelFile::setSize(long long size) { mSize = size; } void TorrentFilesModelFile::setCompletedSize(long long completedSize) { if (completedSize != mCompletedSize) { mCompletedSize = completedSize; mChanged = true; } } } tremotesf-2.8.2/src/ui/itemmodels/torrentfilesmodelentry.h000066400000000000000000000102171500171105600240720ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_TORRENTFILESMODELENTRY_H #define TREMOTESF_TORRENTFILESMODELENTRY_H #include #include #include #include #include "rpc/torrentfile.h" namespace tremotesf { class TorrentFilesModelDirectory; class TorrentFilesModelEntry { Q_GADGET public: enum WantedState { Wanted, Unwanted, MixedWanted }; Q_ENUM(WantedState) enum Priority { LowPriority, NormalPriority, HighPriority, MixedPriority }; Q_ENUM(Priority) static Priority fromFilePriority(TorrentFile::Priority priority); static TorrentFile::Priority toFilePriority(Priority priority); TorrentFilesModelEntry() = default; explicit TorrentFilesModelEntry(int row, TorrentFilesModelDirectory* parentDirectory, QString name); virtual ~TorrentFilesModelEntry() = default; Q_DISABLE_COPY_MOVE(TorrentFilesModelEntry) int row() const; TorrentFilesModelDirectory* parentDirectory() const; QString name() const; void setName(const QString& name); QString path() const; virtual bool isDirectory() const = 0; virtual long long size() const = 0; virtual long long completedSize() const = 0; virtual double progress() const = 0; virtual WantedState wantedState() const = 0; virtual void setWanted(bool wanted) = 0; virtual Priority priority() const = 0; QString priorityString() const; virtual void setPriority(Priority priority) = 0; virtual bool isChanged() const = 0; private: int mRow = 0; TorrentFilesModelDirectory* mParentDirectory = nullptr; QString mName; }; class TorrentFilesModelFile; class TorrentFilesModelDirectory final : public TorrentFilesModelEntry { public: TorrentFilesModelDirectory() = default; explicit TorrentFilesModelDirectory(int row, TorrentFilesModelDirectory* parentDirectory, const QString& name); bool isDirectory() const override; long long size() const override; long long completedSize() const override; double progress() const override; WantedState wantedState() const override; void setWanted(bool wanted) override; Priority priority() const override; void setPriority(Priority priority) override; const std::vector>& children() const; const std::unordered_map& childrenHash() const; TorrentFilesModelFile* addFile(int id, const QString& name, long long size); TorrentFilesModelDirectory* addDirectory(const QString& name); void clearChildren(); std::vector childrenIds() const; bool isChanged() const override; private: void addChild(std::unique_ptr&& child); std::vector> mChildren; std::unordered_map mChildrenHash; }; class TorrentFilesModelFile final : public TorrentFilesModelEntry { public: explicit TorrentFilesModelFile( int row, TorrentFilesModelDirectory* parentDirectory, int id, const QString& name, long long size ); bool isDirectory() const override; long long size() const override; long long completedSize() const override; double progress() const override; WantedState wantedState() const override; void setWanted(bool wanted) override; Priority priority() const override; void setPriority(Priority priority) override; bool isChanged() const override; void setChanged(bool changed); int id() const; void setSize(long long size); void setCompletedSize(long long completedSize); private: long long mSize; long long mCompletedSize; WantedState mWantedState; Priority mPriority; int mId; bool mChanged; }; } #endif // TREMOTESF_TORRENTFILESMODELENTRY_H tremotesf-2.8.2/src/ui/itemmodels/torrentfilesproxymodel.cpp000066400000000000000000000020151500171105600244420ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "torrentfilesproxymodel.h" #include "ui/itemmodels/basetorrentfilesmodel.h" namespace tremotesf { TorrentFilesProxyModel::TorrentFilesProxyModel( BaseTorrentFilesModel* sourceModel, int sortRole, int fallbackColumn, QObject* parent ) : BaseProxyModel(sourceModel, sortRole, fallbackColumn, parent) {} bool TorrentFilesProxyModel::lessThan(const QModelIndex& left, const QModelIndex& right) const { const bool leftIsDirectory = static_cast(left.internalPointer())->isDirectory(); const bool rightIsDirectory = static_cast(right.internalPointer())->isDirectory(); if (leftIsDirectory != rightIsDirectory) { if (sortOrder() == Qt::AscendingOrder) { return leftIsDirectory; } return rightIsDirectory; } return BaseProxyModel::lessThan(left, right); } } tremotesf-2.8.2/src/ui/itemmodels/torrentfilesproxymodel.h000066400000000000000000000012751500171105600241160ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_TORRENTFILESPROXYMODEL_H #define TREMOTESF_TORRENTFILESPROXYMODEL_H #include "ui/itemmodels/baseproxymodel.h" namespace tremotesf { class BaseTorrentFilesModel; class TorrentFilesProxyModel final : public BaseProxyModel { Q_OBJECT public: explicit TorrentFilesProxyModel( BaseTorrentFilesModel* sourceModel, int sortRole, int fallbackColumn, QObject* parent = nullptr ); protected: bool lessThan(const QModelIndex& left, const QModelIndex& right) const override; }; } #endif // TREMOTESF_TORRENTFILESPROXYMODEL_H tremotesf-2.8.2/src/ui/notificationscontroller.cpp000066400000000000000000000125651500171105600224250ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "notificationscontroller.h" #include #include #include "log/log.h" #include "rpc/rpc.h" #include "rpc/servers.h" #include "settings.h" namespace tremotesf { void NotificationsController::showNotification(const QString& title, const QString& message) { fallbackToSystemTrayIcon(title, message); } NotificationsController::NotificationsController(QSystemTrayIcon* trayIcon, const Rpc* rpc, QObject* parent) : QObject(parent), mTrayIcon(trayIcon) { QObject::connect(mTrayIcon, &QSystemTrayIcon::messageClicked, this, [this] { info().log("NotificationsController: notification clicked"); emit notificationClicked({}); }); QObject::connect(rpc, &Rpc::connectedChanged, this, [rpc, this] { if (rpc->isConnected()) { onConnected(rpc); } else { onDisconnected(rpc); } }); QObject::connect(rpc, &Rpc::torrentAdded, this, [this](const auto* torrent) { if (Settings::instance()->get_notificationOnAddingTorrent()) { showAddedTorrentsNotification({torrent->data().name}); } }); QObject::connect(rpc, &Rpc::torrentFinished, this, [this](const auto* torrent) { if (Settings::instance()->get_notificationOfFinishedTorrents()) { showFinishedTorrentsNotification({torrent->data().name}); } }); } void NotificationsController::fallbackToSystemTrayIcon(const QString& title, const QString& message) { if (mTrayIcon->isVisible()) { info().log("NotificationsController: executing QSystemTrayIcon::showMessage()"); mTrayIcon->showMessage(title, message, QSystemTrayIcon::Information, 0); } else { warning().log("NotificationsController: system tray icon is not visible, don't show notification"); } } void NotificationsController::onConnected(const Rpc* rpc) { const bool notifyOnAdded = Settings::instance()->get_notificationsOnAddedTorrentsSinceLastConnection(); const bool notifyOnFinished = Settings::instance()->get_notificationsOnFinishedTorrentsSinceLastConnection(); if (!notifyOnAdded && !notifyOnFinished) { return; } const LastTorrents lastTorrents(Servers::instance()->currentServerLastTorrents()); if (!lastTorrents.saved) { return; } QStringList addedNames{}; QStringList finishedNames{}; for (const auto& torrent : rpc->torrents()) { const auto found = std::ranges::find( lastTorrents.torrents, torrent->data().hashString, &LastTorrents::Torrent::hashString ); if (found == lastTorrents.torrents.cend()) { if (notifyOnAdded) { addedNames.push_back(torrent->data().name); } } else { if (notifyOnFinished && !found->finished && torrent->data().isFinished()) { finishedNames.push_back(torrent->data().name); } } } if (notifyOnAdded && !addedNames.isEmpty()) { showAddedTorrentsNotification(addedNames); } if (notifyOnFinished && !finishedNames.isEmpty()) { showFinishedTorrentsNotification(finishedNames); } } void NotificationsController::onDisconnected(const Rpc* rpc) { const auto& status = rpc->status(); if ((status.error != Rpc::Error::NoError) && Settings::instance()->get_notificationOnDisconnecting()) { showNotification( //: Notification title when disconnected from server qApp->translate("tremotesf", "Disconnected"), status.toString() ); } } void NotificationsController::showFinishedTorrentsNotification(const QStringList& torrentNames) { showTorrentsNotification( torrentNames.size() == 1 //: Notification title ? qApp->translate("tremotesf", "Torrent finished") //: Notification title, %Ln is number of finished torrents : qApp->translate("tremotesf", "%Ln torrents finished", nullptr, static_cast(torrentNames.size())), torrentNames ); } void NotificationsController::showAddedTorrentsNotification(const QStringList& torrentNames) { showTorrentsNotification( torrentNames.size() == 1 //: Notification title ? qApp->translate("tremotesf", "Torrent added") //: Notification title, %Ln is number of added torrents : qApp->translate("tremotesf", "%Ln torrents added", nullptr, static_cast(torrentNames.size())), torrentNames ); } void NotificationsController::showTorrentsNotification(const QString& title, const QStringList& torrentNames) { if (torrentNames.size() == 1) { showNotification(title, torrentNames.first()); } else { auto names = torrentNames; for (auto& name : names) { name.prepend(u"\u2022 "); } showNotification(title, names.join('\n')); } } } tremotesf-2.8.2/src/ui/notificationscontroller.h000066400000000000000000000025161500171105600220650ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_NOTIFICATIONSCONTROLLER_H #define TREMOTESF_NOTIFICATIONSCONTROLLER_H #include #include class QSystemTrayIcon; namespace tremotesf { class Rpc; class NotificationsController : public QObject { Q_OBJECT public: static NotificationsController* createInstance(QSystemTrayIcon* trayIcon, const Rpc* rpc, QObject* parent = nullptr); protected: explicit NotificationsController(QSystemTrayIcon* trayIcon, const Rpc* rpc, QObject* parent = nullptr); virtual void showNotification(const QString& title, const QString& message); void fallbackToSystemTrayIcon(const QString& title, const QString& message); private: void onConnected(const Rpc* rpc); void onDisconnected(const Rpc* rpc); void showFinishedTorrentsNotification(const QStringList& torrentNames); void showAddedTorrentsNotification(const QStringList& torrentNames); void showTorrentsNotification(const QString& title, const QStringList& torrentNames); QSystemTrayIcon* mTrayIcon{}; signals: void notificationClicked(const std::optional& windowActivationToken); }; } #endif // TREMOTESF_NOTIFICATIONSCONTROLLER_H tremotesf-2.8.2/src/ui/notificationscontroller_fallback.cpp000066400000000000000000000005621500171105600242360ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "notificationscontroller.h" namespace tremotesf { NotificationsController* NotificationsController::createInstance(QSystemTrayIcon* trayIcon, const Rpc* rpc, QObject* parent) { return new NotificationsController(trayIcon, rpc, parent); } } tremotesf-2.8.2/src/ui/notificationscontroller_freedesktop.cpp000066400000000000000000000163211500171105600250120ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "notificationscontroller.h" #include #include #include #include #include "coroutines/dbus.h" #include "coroutines/scope.h" #include "desktoputils.h" #include "log/log.h" #include "tremotesf_dbus_generated/org.freedesktop.portal.Notification.h" #include "tremotesf_dbus_generated/org.freedesktop.Notifications.h" #include "rpc/servers.h" SPECIALIZE_FORMATTER_FOR_QDEBUG(QDBusError) namespace tremotesf { namespace { constexpr auto flatpakEnvVariable = "FLATPAK_ID"; class PortalNotificationsController final : public NotificationsController { public: explicit PortalNotificationsController(QSystemTrayIcon* trayIcon, const Rpc* rpc, QObject* parent = nullptr) : NotificationsController(trayIcon, rpc, parent) { qDBusRegisterMetaType>(); mPortalInterface.setTimeout(desktoputils::defaultDbusTimeout); } protected: void showNotification(const QString& title, const QString& message) override { mCoroutineScope.launch(showNotificationViaPortal(title, message)); } private: Coroutine<> showNotificationViaPortal(QString title, QString message) { info().log("PortalNotificationsController: executing " "org.freedesktop.portal.Notification.AddNotification() D-Bus call"); const auto reply = mPortalInterface.AddNotification( QUuid::createUuid().toString(QUuid::WithoutBraces), { {"title"_l1, title}, {"body"_l1, message}, {"icon"_l1, QVariant::fromValue(QPair( "themed"_l1, QDBusVariant(QStringList{TREMOTESF_APP_ID ""_l1}) ))}, {"default-action"_l1, "app.default"_l1}, // We just need to put something here {"default-action-target", true}, } ); co_await reply; if (!reply.isError()) { info().log("PortalNotificationsController: executed " "org.freedesktop.portal.Notification.AddNotification() D-Bus call"); co_return; } warning().log( "PortalNotificationsController: org.freedesktop.portal.Notification.AddNotification() D-Bus " "call failed: " "{}", reply.error() ); fallbackToSystemTrayIcon(title, message); } OrgFreedesktopPortalNotificationInterface mPortalInterface{ "org.freedesktop.portal.Desktop"_l1, "/org/freedesktop/portal/desktop"_l1, QDBusConnection::sessionBus() }; CoroutineScope mCoroutineScope{}; }; class LegacyFreedesktopNotificationsController final : public NotificationsController { public: explicit LegacyFreedesktopNotificationsController( QSystemTrayIcon* trayIcon, const Rpc* rpc, QObject* parent = nullptr ) : NotificationsController(trayIcon, rpc, parent) { qDBusRegisterMetaType>(); mLegacyInterface.setTimeout(desktoputils::defaultDbusTimeout); QObject::connect(&mLegacyInterface, &OrgFreedesktopNotificationsInterface::ActionInvoked, this, [this] { info().log("LegacyFreedesktopNotificationsController: notification clicked"); emit notificationClicked(mActivationToken); mActivationToken.reset(); }); QObject::connect( &mLegacyInterface, &OrgFreedesktopNotificationsInterface::ActivationToken, this, [this](uint, const QString& activationToken) { info().log("LegacyFreedesktopNotificationsController: activationToken = {}", activationToken); mActivationToken = activationToken.toUtf8(); } ); } protected: void showNotification(const QString& title, const QString& message) override { mCoroutineScope.launch(showNotificationViaLegacyInterface(title, message)); } private: Coroutine<> showNotificationViaLegacyInterface(QString title, QString message) { info().log("LegacyFreedesktopNotificationsController: executing org.freedesktop.Notifications.Notify() " "D-Bus call"); const auto reply = mLegacyInterface.Notify( TREMOTESF_APP_NAME ""_l1, 0, TREMOTESF_APP_ID ""_l1, title, message, //: Button on notification {"default"_l1, qApp->translate("tremotesf", "Show Tremotesf")}, {{"desktop-entry"_l1, TREMOTESF_APP_ID ""_l1}, {"x-kde-origin-name"_l1, Servers::instance()->currentServerName()}}, -1 ); co_await reply; if (!reply.isError()) { info().log("LegacyFreedesktopNotificationsController: executed " "org.freedesktop.Notifications.Notify() D-Bus call"); co_return; } warning().log( "LegacyFreedesktopNotificationsController: org.freedesktop.Notifications.Notify() D-Bus call " "failed: " "{}", reply.error() ); fallbackToSystemTrayIcon(title, message); } OrgFreedesktopNotificationsInterface mLegacyInterface{ "org.freedesktop.Notifications"_l1, "/org/freedesktop/Notifications"_l1, QDBusConnection::sessionBus() }; std::optional mActivationToken{}; CoroutineScope mCoroutineScope{}; }; } NotificationsController* NotificationsController::createInstance(QSystemTrayIcon* trayIcon, const Rpc* rpc, QObject* parent) { // We can technically use Notification portal outside of Flatpak, // however it only works properly if we were launched in a very special way through systemd // (https://systemd.io/DESKTOP_ENVIRONMENTS, "XDG standardization for applications" section) // If we were launched from command line or any other way, then notification won't be properly associated with the app // Just use legacy interface instead, it still works if (qEnvironmentVariableIsSet(flatpakEnvVariable)) { return new PortalNotificationsController(trayIcon, rpc, parent); } return new LegacyFreedesktopNotificationsController(trayIcon, rpc, parent); } } tremotesf-2.8.2/src/ui/recoloringsvgiconengine.cpp000066400000000000000000000422521500171105600223660ustar00rootroot00000000000000// Copyright (C) 2016 The Qt Company Ltd. // SPDX-License-Identifier: LGPL-3.0-only #include "recoloringsvgiconengine.h" // clang-format off #include "qpainter.h" #include "qpixmap.h" #include "qsvgrenderer.h" #include "qpixmapcache.h" #include "qfileinfo.h" #if QT_CONFIG(mimetype) #include #include #endif #include #include "qdebug.h" #include #include #include #include #include #include namespace tremotesf { namespace { QString STYLESHEET_TEMPLATE() { return QStringLiteral(".ColorScheme-Text { color:%1; }\ .ColorScheme-Background{ color:%2; }\ .ColorScheme-Highlight{ color:%3; }\ .ColorScheme-HighlightedText{ color:%4; }\ .ColorScheme-PositiveText{ color:%5; }\ .ColorScheme-NeutralText{ color:%6; }\ .ColorScheme-NegativeText{ color:%7; }\ .ColorScheme-ActiveText{ color:%8; }\ .ColorScheme-Complement{ color:%9; }\ .ColorScheme-Contrast{ color:%10; }\ .ColorScheme-Accent{ color:%11; }\ "); } qreal luma(const QColor &color) { return (0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()) / 255; } QPalette paletteForStylesheet() { return QApplication::palette("QMenu"); } } enum FileType { OtherFile, SvgFile, CompressedSvgFile }; static FileType fileType(const QFileInfo &fi); class RecoloringSvgIconEnginePrivate : public QSharedData { public: RecoloringSvgIconEnginePrivate() { stepSerialNum(); } static int hashKey(QIcon::Mode mode, QIcon::State state) { return ((mode << 4) | state); } QString pmcKey(const QSize &size, QIcon::Mode mode, QIcon::State state, qreal scale) const { return QLatin1String("$qt_svgicon_") % HexString(serialNum) % HexString(mode) % HexString(state) % HexString(size.width()) % HexString(size.height()) % HexString(qRound(scale * 1000)) % HexString(paletteForStylesheet().cacheKey()); } void stepSerialNum() { serialNum = lastSerialNum.fetchAndAddRelaxed(1); } bool tryLoad(QSvgRenderer *renderer, QIcon::Mode tryMode, QIcon::State tryState, QIcon::Mode actualMode); QIcon::Mode loadDataForModeAndState(QSvgRenderer *renderer, QIcon::Mode mode, QIcon::State state); QHash svgFiles; QHash svgBuffers; QMultiHash addedPixmaps; int serialNum = 0; static QAtomicInt lastSerialNum; }; QAtomicInt RecoloringSvgIconEnginePrivate::lastSerialNum; RecoloringSvgIconEngine::RecoloringSvgIconEngine() : d(new RecoloringSvgIconEnginePrivate) { } RecoloringSvgIconEngine::RecoloringSvgIconEngine(const RecoloringSvgIconEngine &other) : QIconEngine(other), d(new RecoloringSvgIconEnginePrivate) { d->svgFiles = other.d->svgFiles; d->svgBuffers = other.d->svgBuffers; d->addedPixmaps = other.d->addedPixmaps; } RecoloringSvgIconEngine::~RecoloringSvgIconEngine() { } QSize RecoloringSvgIconEngine::actualSize(const QSize &size, QIcon::Mode mode, QIcon::State state) { if (!d->addedPixmaps.isEmpty()) { const auto key = d->hashKey(mode, state); auto it = d->addedPixmaps.constFind(key); while (it != d->addedPixmaps.end() && it.key() == key) { const auto &pm = it.value(); if (!pm.isNull() && pm.size() == size) return size; ++it; } } QPixmap pm = pixmap(size, mode, state); if (pm.isNull()) return QSize(); return pm.size(); } static inline QByteArray maybeUncompress(const QByteArray &ba) { #ifndef QT_NO_COMPRESS return qUncompress(ba); #else return ba; #endif } bool RecoloringSvgIconEnginePrivate::tryLoad(QSvgRenderer *renderer, QIcon::Mode tryMode, QIcon::State tryState, QIcon::Mode actualMode) { const auto key = hashKey(tryMode, tryState); QByteArray buf = svgBuffers.value(key); if (!buf.isEmpty()) { if (renderer->load(maybeUncompress(buf))) return true; svgBuffers.remove(key); } QString svgFile = svgFiles.value(key); if (!svgFile.isEmpty()) { if (fileType(QFileInfo(svgFile)) == CompressedSvgFile) { qWarning() << "Can't recolor compressed svg" << svgFile; return renderer->load(svgFile); } const auto pal = paletteForStylesheet(); const QColor complement = luma(pal.window().color()) > 0.5 ? Qt::white : Qt::black; const QColor contrast = luma(pal.window().color()) > 0.5 ? Qt::black : Qt::white; QColor accentColor = pal.accent().color(); // When selected, tint the accent color with a small portion of highlighted text color, // because since the accent color used to be the same as the highlight color, it might cause // icons, especially folders to "disappear" against the background if (actualMode == QIcon::Selected) { const qreal tintRatio = 0.85; const auto highlightedText = pal.highlightedText().color(); const qreal r = accentColor.redF() * tintRatio + highlightedText.redF() * (1.0 - tintRatio); const qreal g = accentColor.greenF() * tintRatio + highlightedText.greenF() * (1.0 - tintRatio); const qreal b = accentColor.blueF() * tintRatio + highlightedText.blueF() * (1.0 - tintRatio); accentColor.setRgbF(r, g, b, accentColor.alphaF()); } const QString styleSheet = STYLESHEET_TEMPLATE().arg( // ColorScheme-Text actualMode == QIcon::Selected ? pal.highlightedText().color().name() : pal.windowText().color().name(), // ColorScheme-Background actualMode == QIcon::Selected ? pal.highlight().color().name() : pal.window().color().name(), // ColorScheme-Highlight actualMode == QIcon::Selected ? pal.highlightedText().color().name() : pal.highlight().color().name(), // ColorScheme-HighlightedText actualMode == QIcon::Selected ? pal.highlight().color().name() : pal.highlightedText().color().name(), // ColorScheme-PositiveText actualMode == QIcon::Selected ? pal.highlightedText().color().name() : pal.windowText().color().name(), // ColorScheme-NeutralText actualMode == QIcon::Selected ? pal.highlightedText().color().name() : pal.windowText().color().name(), // ColorScheme-NegativeText actualMode == QIcon::Selected ? pal.highlightedText().color().name() : pal.windowText().color().name(), // ColorScheme-ActiveText actualMode == QIcon::Selected ? pal.highlightedText().color().name() : pal.windowText().color().name(), // ColorScheme-Complement complement.name(), // ColorScheme-Contrast contrast.name(), // ColorScheme-Accent accentColor.name() ); QFile file(svgFile); if (!file.open(QIODevice::ReadOnly)) { return false; } QByteArray processedContents; QXmlStreamReader reader(&file); QBuffer buffer(&processedContents); buffer.open(QIODevice::WriteOnly); QXmlStreamWriter writer(&buffer); while (!reader.atEnd()) { if (reader.readNext() == QXmlStreamReader::StartElement // && reader.qualifiedName() == QLatin1String("style") // && reader.attributes().value(QLatin1String("id")) == QLatin1String("current-color-scheme")) { writer.writeStartElement(QStringLiteral("style")); writer.writeAttributes(reader.attributes()); writer.writeCharacters(styleSheet); writer.writeEndElement(); while (reader.tokenType() != QXmlStreamReader::EndElement) { reader.readNext(); } } else if (reader.tokenType() != QXmlStreamReader::Invalid) { writer.writeCurrentToken(reader); } } return renderer->load(processedContents); } return false; } QIcon::Mode RecoloringSvgIconEnginePrivate::loadDataForModeAndState(QSvgRenderer *renderer, QIcon::Mode mode, QIcon::State state) { if (tryLoad(renderer, mode, state, mode)) return mode; const QIcon::State oppositeState = (state == QIcon::On) ? QIcon::Off : QIcon::On; if (mode == QIcon::Disabled || mode == QIcon::Selected) { const QIcon::Mode oppositeMode = (mode == QIcon::Disabled) ? QIcon::Selected : QIcon::Disabled; if (tryLoad(renderer, QIcon::Normal, state, mode)) return QIcon::Normal; if (tryLoad(renderer, QIcon::Active, state, mode)) return QIcon::Active; if (tryLoad(renderer, mode, oppositeState, mode)) return mode; if (tryLoad(renderer, QIcon::Normal, oppositeState, mode)) return QIcon::Normal; if (tryLoad(renderer, QIcon::Active, oppositeState, mode)) return QIcon::Active; if (tryLoad(renderer, oppositeMode, state, mode)) return oppositeMode; if (tryLoad(renderer, oppositeMode, oppositeState, mode)) return oppositeMode; } else { const QIcon::Mode oppositeMode = (mode == QIcon::Normal) ? QIcon::Active : QIcon::Normal; if (tryLoad(renderer, oppositeMode, state, mode)) return oppositeMode; if (tryLoad(renderer, mode, oppositeState, mode)) return mode; if (tryLoad(renderer, oppositeMode, oppositeState, mode)) return oppositeMode; if (tryLoad(renderer, QIcon::Disabled, state, mode)) return QIcon::Disabled; if (tryLoad(renderer, QIcon::Selected, state, mode)) return QIcon::Selected; if (tryLoad(renderer, QIcon::Disabled, oppositeState, mode)) return QIcon::Disabled; if (tryLoad(renderer, QIcon::Selected, oppositeState, mode)) return QIcon::Selected; } return QIcon::Normal; } QPixmap RecoloringSvgIconEngine::pixmap(const QSize &size, QIcon::Mode mode, QIcon::State state) { return scaledPixmap(size, mode, state, 1.0); } QPixmap RecoloringSvgIconEngine::scaledPixmap(const QSize &size, QIcon::Mode mode, QIcon::State state, qreal scale) { QPixmap pm; QString pmckey(d->pmcKey(size, mode, state, scale)); if (QPixmapCache::find(pmckey, &pm)) return pm; if (!d->addedPixmaps.isEmpty()) { const auto realSize = size * scale; const auto key = d->hashKey(mode, state); auto it = d->addedPixmaps.constFind(key); while (it != d->addedPixmaps.end() && it.key() == key) { const auto &pm = it.value(); if (!pm.isNull()) { // we don't care about dpr here - don't use RecoloringSvgIconEngine when // there are a lot of raster images are to handle. if (pm.size() == realSize) return pm; } ++it; } } QSvgRenderer renderer; const QIcon::Mode loadmode = d->loadDataForModeAndState(&renderer, mode, state); if (!renderer.isValid()) return pm; QSize actualSize = renderer.defaultSize(); if (!actualSize.isNull()) actualSize.scale(size * scale, Qt::KeepAspectRatio); if (actualSize.isEmpty()) return pm; pm = QPixmap(actualSize); pm.fill(Qt::transparent); QPainter p(&pm); renderer.render(&p); p.end(); if (qobject_cast(QCoreApplication::instance())) { if (loadmode != mode && mode != QIcon::Normal) { const QPixmap generated = QGuiApplicationPrivate::instance()->applyQIconStyleHelper(mode, pm); if (!generated.isNull()) pm = generated; } } if (!pm.isNull()) { pm.setDevicePixelRatio(scale); QPixmapCache::insert(pmckey, pm); } return pm; } void RecoloringSvgIconEngine::addPixmap(const QPixmap &pixmap, QIcon::Mode mode, QIcon::State state) { d->stepSerialNum(); d->addedPixmaps.insert(d->hashKey(mode, state), pixmap); } static FileType fileType(const QFileInfo &fi) { const QString &suffix = fi.completeSuffix(); if (suffix.endsWith(QLatin1String("svg"), Qt::CaseInsensitive)) return SvgFile; if (suffix.endsWith(QLatin1String("svgz"), Qt::CaseInsensitive) || suffix.endsWith(QLatin1String("svg.gz"), Qt::CaseInsensitive)) { return CompressedSvgFile; } #if QT_CONFIG(mimetype) const QString &mimeTypeName = QMimeDatabase().mimeTypeForFile(fi).name(); if (mimeTypeName == QLatin1String("image/svg+xml")) return SvgFile; if (mimeTypeName == QLatin1String("image/svg+xml-compressed")) return CompressedSvgFile; #endif return OtherFile; } void RecoloringSvgIconEngine::addFile(const QString &fileName, const QSize &, QIcon::Mode mode, QIcon::State state) { if (!fileName.isEmpty()) { const QFileInfo fi(fileName); const QString abs = fi.absoluteFilePath(); const FileType type = fileType(fi); #ifndef QT_NO_COMPRESS if (type == SvgFile || type == CompressedSvgFile) { #else if (type == SvgFile) { #endif QSvgRenderer renderer(abs); if (renderer.isValid()) { d->stepSerialNum(); d->svgFiles.insert(d->hashKey(mode, state), abs); } } else if (type == OtherFile) { QPixmap pm(abs); if (!pm.isNull()) addPixmap(pm, mode, state); } } } void RecoloringSvgIconEngine::paint(QPainter *painter, const QRect &rect, QIcon::Mode mode, QIcon::State state) { QSize pixmapSize = rect.size(); if (painter->device()) pixmapSize *= painter->device()->devicePixelRatio(); painter->drawPixmap(rect, pixmap(pixmapSize, mode, state)); } bool RecoloringSvgIconEngine::isNull() { return d->svgFiles.isEmpty() && d->addedPixmaps.isEmpty() && d->svgBuffers.isEmpty(); } QString RecoloringSvgIconEngine::key() const { return QLatin1String("svg"); } QIconEngine *RecoloringSvgIconEngine::clone() const { return new RecoloringSvgIconEngine(*this); } bool RecoloringSvgIconEngine::read(QDataStream &in) { d = new RecoloringSvgIconEnginePrivate; if (in.version() >= QDataStream::Qt_4_4) { int isCompressed; QHash fileNames; // For memoryoptimization later in >> fileNames >> isCompressed >> d->svgBuffers; #ifndef QT_NO_COMPRESS if (!isCompressed) { for (auto &svgBuf : d->svgBuffers) svgBuf = qCompress(svgBuf); } #else if (isCompressed) { qWarning("RecoloringSvgIconEngine: Can not decompress SVG data"); d->svgBuffers.clear(); } #endif int hasAddedPixmaps; in >> hasAddedPixmaps; if (hasAddedPixmaps) { in >> d->addedPixmaps; } } else { QPixmap pixmap; QByteArray data; uint mode; uint state; int num_entries; in >> data; if (!data.isEmpty()) { #ifndef QT_NO_COMPRESS data = qUncompress(data); #endif if (!data.isEmpty()) d->svgBuffers.insert(d->hashKey(QIcon::Normal, QIcon::Off), data); } in >> num_entries; for (int i=0; i> pixmap; in >> mode; in >> state; // The pm list written by 4.3 is buggy and/or useless, so ignore. //addPixmap(pixmap, QIcon::Mode(mode), QIcon::State(state)); } } return true; } bool RecoloringSvgIconEngine::write(QDataStream &out) const { if (out.version() >= QDataStream::Qt_4_4) { int isCompressed = 0; #ifndef QT_NO_COMPRESS isCompressed = 1; #endif QHash svgBuffers = d->svgBuffers; for (const auto &it : d->svgFiles.asKeyValueRange()) { QByteArray buf; QFile f(it.second); if (f.open(QIODevice::ReadOnly)) buf = f.readAll(); #ifndef QT_NO_COMPRESS buf = qCompress(buf); #endif svgBuffers.insert(it.first, buf); } out << d->svgFiles << isCompressed << svgBuffers; if (d->addedPixmaps.isEmpty()) out << 0; else out << 1 << d->addedPixmaps; } else { const auto key = d->hashKey(QIcon::Normal, QIcon::Off); QByteArray buf = d->svgBuffers.value(key); if (buf.isEmpty()) { QString svgFile = d->svgFiles.value(key); if (!svgFile.isEmpty()) { QFile f(svgFile); if (f.open(QIODevice::ReadOnly)) buf = f.readAll(); } } #ifndef QT_NO_COMPRESS buf = qCompress(buf); #endif out << buf; // 4.3 has buggy handling of added pixmaps, so don't write any out << (int)0; } return true; } } tremotesf-2.8.2/src/ui/recoloringsvgiconengine.h000066400000000000000000000030201500171105600220210ustar00rootroot00000000000000// Copyright (C) 2016 The Qt Company Ltd. // SPDX-License-Identifier: LGPL-3.0-only #ifndef TREMOTESF_RECOLORINGSVGICONENGINE_H #define TREMOTESF_RECOLORINGSVGICONENGINE_H #include #include // clang-format off namespace tremotesf { class RecoloringSvgIconEnginePrivate; // QSvgIconEngine fork that injects CSS derived from current QPalette class RecoloringSvgIconEngine : public QIconEngine { public: RecoloringSvgIconEngine(); RecoloringSvgIconEngine(const RecoloringSvgIconEngine&other); ~RecoloringSvgIconEngine(); void paint(QPainter *painter, const QRect &rect, QIcon::Mode mode, QIcon::State state) override; QSize actualSize(const QSize &size, QIcon::Mode mode, QIcon::State state) override; QPixmap pixmap(const QSize &size, QIcon::Mode mode, QIcon::State state) override; QPixmap scaledPixmap(const QSize &size, QIcon::Mode mode, QIcon::State state, qreal scale) override; void addPixmap(const QPixmap &pixmap, QIcon::Mode mode, QIcon::State state) override; void addFile(const QString &fileName, const QSize &size, QIcon::Mode mode, QIcon::State state) override; bool isNull() override; QString key() const override; QIconEngine *clone() const override; bool read(QDataStream &in) override; bool write(QDataStream &out) const override; private: QSharedDataPointer d; }; } #endif tremotesf-2.8.2/src/ui/savewindowstatedispatcher.cpp000066400000000000000000000073551500171105600227470ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "savewindowstatedispatcher.h" #include #include #include #include "settings.h" #include "log/log.h" #include "rpc/servers.h" namespace tremotesf { namespace { constexpr auto windowHasSaveStateHandlerProperty = "tremotesf_windowHasSaveStateHandler"; QEvent::Type saveStateOnQuitEventType() { static const auto type = static_cast(QEvent::registerEventType()); return type; } } SaveWindowStateDispatcher::SaveWindowStateDispatcher() { QObject::connect( qApp, &QCoreApplication::aboutToQuit, this, &SaveWindowStateDispatcher::onAboutToQuit, Qt::DirectConnection ); } void SaveWindowStateDispatcher::onAboutToQuit() { debug().log("Received aboutToQuit signal"); for (QWidget* window : QApplication::topLevelWidgets()) { if (window->property(windowHasSaveStateHandlerProperty).toBool()) { debug().log("Sending save state event to window {}", *window); QEvent event(saveStateOnQuitEventType()); QCoreApplication::sendEvent(window, &event); } } // On Windows our process might be terminated immediately after returning from this function // and QSettings destructors won't be called, so sync them immediately debug().log("Syncing settings"); Settings::instance()->sync(); Servers::instance()->sync(); } #if QT_VERSION_MAJOR >= 6 ApplicationQuitEventFilter::ApplicationQuitEventFilter(QObject* parent) : QObject(parent) { qApp->installEventFilter(this); } ApplicationQuitEventFilter::~ApplicationQuitEventFilter() { qApp->removeEventFilter(this); } bool ApplicationQuitEventFilter::eventFilter(QObject*, QEvent* event) { if (event->type() == QEvent::Quit) { isQuittingApplication = true; QMetaObject::invokeMethod(qApp, [this] { isQuittingApplication = false; }, Qt::QueuedConnection); } return false; } #endif SaveWindowStateHandler::SaveWindowStateHandler(QWidget* window, std::function saveState, QObject* parent) : QObject(parent), mWindow(window), mSaveState(std::move(saveState)) { mWindow->installEventFilter(this); mWindow->setProperty(windowHasSaveStateHandlerProperty, true); } SaveWindowStateHandler::~SaveWindowStateHandler() { mWindow->removeEventFilter(this); mWindow->setProperty(windowHasSaveStateHandlerProperty, QVariant{}); } bool SaveWindowStateHandler::eventFilter(QObject* watched, QEvent* event) { const auto window = static_cast(watched); // Window can be moved without activation, so we also need to handle Hide event switch (event->type()) { case QEvent::WindowDeactivate: case QEvent::Hide: debug().log("Received {} event for {}", event->type(), *window); #if QT_VERSION_MAJOR >= 6 if (mApplicationEventFilter.isQuittingApplication) { debug().log("Already quitting application, ignore"); break; } #endif if (event->type() == QEvent::WindowDeactivate && window->isHidden()) { debug().log("Window is hidden, ignore"); break; } mSaveState(); break; default: if (event->type() == saveStateOnQuitEventType()) { debug().log("Received save state event for {}", *window); mSaveState(); } break; } return false; } } tremotesf-2.8.2/src/ui/savewindowstatedispatcher.h000066400000000000000000000025671500171105600224140ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_SAVEWINDOWSTATEDISPATCHER_H #define TREMOTESF_SAVEWINDOWSTATEDISPATCHER_H #include #include class QWidget; namespace tremotesf { class SaveWindowStateDispatcher : public QObject { Q_OBJECT public: SaveWindowStateDispatcher(); private: void onAboutToQuit(); }; #if QT_VERSION_MAJOR >= 6 class ApplicationQuitEventFilter : public QObject { Q_OBJECT public: explicit ApplicationQuitEventFilter(QObject* parent = nullptr); ~ApplicationQuitEventFilter() override; bool eventFilter(QObject* watched, QEvent* event) override; bool isQuittingApplication{}; }; #endif class SaveWindowStateHandler : public QObject { Q_OBJECT public: explicit SaveWindowStateHandler(QWidget* window, std::function saveState, QObject* parent = nullptr); ~SaveWindowStateHandler() override; Q_DISABLE_COPY_MOVE(SaveWindowStateHandler) bool eventFilter(QObject* watched, QEvent* event) override; private: QWidget* mWindow{}; std::function mSaveState{}; #if QT_VERSION_MAJOR >= 6 ApplicationQuitEventFilter mApplicationEventFilter{}; #endif }; } #endif //TREMOTESF_SAVEWINDOWSTATEDISPATCHER_H tremotesf-2.8.2/src/ui/screens/000077500000000000000000000000001500171105600163755ustar00rootroot00000000000000tremotesf-2.8.2/src/ui/screens/aboutdialog.cpp000066400000000000000000000065531500171105600214040ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "aboutdialog.h" #include #include #include #include #include #include #include #include #include #include "fileutils.h" #include "literals.h" namespace tremotesf { AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent) { //: "About" dialog title setWindowTitle(qApp->translate("tremotesf", "About")); auto layout = new QVBoxLayout(this); auto titleWidget = new KTitleWidget(this); titleWidget->setIcon(qApp->windowIcon(), KTitleWidget::ImageLeft); titleWidget->setText(QString::fromLatin1("%1 %2").arg(TREMOTESF_APP_NAME ""_l1, qApp->applicationVersion())); layout->addWidget(titleWidget); auto tabWidget = new QTabWidget(this); auto aboutPage = new QWidget(this); auto aboutPageLayout = new QVBoxLayout(aboutPage); auto aboutPageLabel = new QLabel( //: "About" dialog text qApp->translate( "tremotesf", "

© 2015-2024 Alexey Rochev <equeim@gmail.com>

\n" "

Source code: https://github.com/equeim/tremotesf2

\n" "

Translations: https://www.transifex.com/equeim/tremotesf

" ), this ); QObject::connect(aboutPageLabel, &QLabel::linkActivated, this, &QDesktopServices::openUrl); aboutPageLayout->addWidget(aboutPageLabel); //: "About" dialog's "About" tab title tabWidget->addTab(aboutPage, qApp->translate("tremotesf", "About")); auto authorsWidget = new QTextBrowser(this); authorsWidget->setHtml( QString(readFile(":/authors.html")) .arg(qApp->translate("tremotesf", "Maintainer"), qApp->translate("tremotesf", "Contributor")) ); authorsWidget->setOpenExternalLinks(true); //: "About" dialog's "Authors" tab title tabWidget->addTab(authorsWidget, qApp->translate("tremotesf", "Authors")); auto translatorsWidget = new QTextBrowser(this); translatorsWidget->setHtml(readFile(":/translators.html")); translatorsWidget->setOpenExternalLinks(true); //: "About" dialog's "Translators" tab title tabWidget->addTab(translatorsWidget, qApp->translate("tremotesf", "Translators")); auto licenseWidget = new QTextBrowser(this); licenseWidget->setHtml(readFile(":/license.html")); licenseWidget->setOpenExternalLinks(true); //: "About" dialog's "License" tab title tabWidget->addTab(licenseWidget, qApp->translate("tremotesf", "License")); layout->addWidget(tabWidget); auto dialogButtonBox = new QDialogButtonBox(QDialogButtonBox::Close, this); QObject::connect(dialogButtonBox, &QDialogButtonBox::rejected, this, &AboutDialog::reject); layout->addWidget(dialogButtonBox); dialogButtonBox->button(QDialogButtonBox::Close)->setDefault(true); resize(sizeHint().expandedTo(QSize(420, 384))); } } tremotesf-2.8.2/src/ui/screens/aboutdialog.h000066400000000000000000000006011500171105600210350ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_ABOUTDIALOG_H #define TREMOTESF_ABOUTDIALOG_H #include namespace tremotesf { class AboutDialog final : public QDialog { Q_OBJECT public: explicit AboutDialog(QWidget* parent = nullptr); }; } #endif // TREMOTESF_ABOUTDIALOG_H tremotesf-2.8.2/src/ui/screens/addtorrent/000077500000000000000000000000001500171105600205435ustar00rootroot00000000000000tremotesf-2.8.2/src/ui/screens/addtorrent/addtorrentdialog.cpp000066400000000000000000000503161500171105600246020ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // SPDX-FileCopyrightText: 2022 Alex // // SPDX-License-Identifier: GPL-3.0-or-later #include "addtorrentdialog.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "addtorrenthelpers.h" #include "fileutils.h" #include "formatutils.h" #include "magnetlinkparser.h" #include "settings.h" #include "stdutils.h" #include "torrentfileparser.h" #include "coroutines/threadpool.h" #include "log/log.h" #include "rpc/rpc.h" #include "rpc/serversettings.h" #include "rpc/torrent.h" #include "ui/widgets/editlabelswidget.h" #include "ui/widgets/torrentremotedirectoryselectionwidget.h" #include "ui/widgets/torrentfilesview.h" #include "localtorrentfilesmodel.h" namespace tremotesf { namespace { constexpr std::array priorityComboBoxItems{ TorrentData::Priority::High, TorrentData::Priority::Normal, TorrentData::Priority::Low }; TorrentData::Priority priorityFromComboBoxIndex(int index) { if (index == -1) { return TorrentData::Priority::Normal; } return priorityComboBoxItems.at(static_cast(index)); } int rowForWidget(QFormLayout* layout, QWidget* widget) { int row{}; QFormLayout::ItemRole role{}; layout->getWidgetPosition(widget, &row, &role); if (row == -1) { throw std::logic_error(fmt::format("Did not find widget {} in layout {}", *widget, *layout)); } return row; } Rpc::DeleteFileMode determineDeleteFileMode(const AddTorrentDialog::AddTorrentParametersWidgets& widgets) { if (!widgets.deleteTorrentFileGroupBox || !widgets.moveTorrentFileToTrashCheckBox) { return Rpc::DeleteFileMode::No; } if (!widgets.deleteTorrentFileGroupBox->isChecked()) { return Rpc::DeleteFileMode::No; } if (widgets.moveTorrentFileToTrashCheckBox->isChecked()) { return Rpc::DeleteFileMode::MoveToTrash; } return Rpc::DeleteFileMode::Delete; } } AddTorrentDialog::AddTorrentDialog(Rpc* rpc, std::variant params, QWidget* parent) : QDialog(parent), mRpc(rpc), mParams(std::move(params)), mFilesModel(isAddingFile() ? new LocalTorrentFilesModel(this) : nullptr) { setupUi(); QSize size(448, 0); if (isAddingFile()) { size.setHeight(512); } resize(sizeHint().expandedTo(size)); if (isAddingFile()) { mParseTorrentFileCoroutineScope.launch(parseTorrentFile()); } } void AddTorrentDialog::accept() { if (isAddingFile()) { if (!checkIfTorrentFileExists()) { mRpc->addTorrentFile( std::get(mParams).filePath, mAddTorrentParametersWidgets.downloadDirectoryWidget->path(), mFilesModel->unwantedFiles(), mFilesModel->highPriorityFiles(), mFilesModel->lowPriorityFiles(), mFilesModel->renamedFiles(), priorityFromComboBoxIndex(mAddTorrentParametersWidgets.priorityComboBox->currentIndex()), mAddTorrentParametersWidgets.startTorrentCheckBox->isChecked(), determineDeleteFileMode(mAddTorrentParametersWidgets), mEditLabelsWidget->enabledLabels() ); } } else { QStringList urls = mTorrentLinkTextField->toPlainText().split('\n', Qt::SkipEmptyParts); parseMagnetLinksAndCheckIfTorrentsExist(urls); if (!urls.empty()) { mRpc->addTorrentLinks( std::move(urls), mAddTorrentParametersWidgets.downloadDirectoryWidget->path(), priorityFromComboBoxIndex(mAddTorrentParametersWidgets.priorityComboBox->currentIndex()), mAddTorrentParametersWidgets.startTorrentCheckBox->isChecked(), mEditLabelsWidget->enabledLabels() ); } } const auto settings = Settings::instance(); if (settings->get_rememberOpenTorrentDir() && isAddingFile()) { settings->set_lastOpenTorrentDirectory(QFileInfo(std::get(mParams).filePath).path()); } if (settings->get_rememberAddTorrentParameters()) { mAddTorrentParametersWidgets.saveToSettings(); } QDialog::accept(); } AddTorrentDialog::AddTorrentParametersWidgets AddTorrentDialog::createAddTorrentParametersWidgets(bool forTorrentFile, QFormLayout* layout, Rpc* rpc) { const auto parameters = getAddTorrentParameters(rpc); auto* const downloadDirectoryWidget = new TorrentDownloadDirectoryDirectorySelectionWidget{}; downloadDirectoryWidget->setup(parameters.downloadDirectory, rpc); //: Input field's label layout->addRow(qApp->translate("tremotesf", "Download directory:"), downloadDirectoryWidget); auto* const priorityComboBox = new QComboBox{}; priorityComboBox->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); for (const auto priority : priorityComboBoxItems) { switch (priority) { case TorrentData::Priority::High: //: Torrent's loading priority priorityComboBox->addItem(qApp->translate("tremotesf", "High")); break; case TorrentData::Priority::Normal: //: Torrent's loading priority priorityComboBox->addItem(qApp->translate("tremotesf", "Normal")); break; case TorrentData::Priority::Low: //: Torrent's loading priority priorityComboBox->addItem(qApp->translate("tremotesf", "Low")); break; } } // NOLINTNEXTLINE(bugprone-unchecked-optional-access) priorityComboBox->setCurrentIndex(indexOfCasted(priorityComboBoxItems, parameters.priority).value()); //: Combo box label layout->addRow(qApp->translate("tremotesf", "Torrent priority:"), priorityComboBox); auto* const startTorrentCheckBox = new QCheckBox(qApp->translate("tremotesf", "Start downloading after adding")); startTorrentCheckBox->setChecked(parameters.startAfterAdding); layout->addRow(startTorrentCheckBox); QGroupBox* deleteTorrentFileGroupBox{}; QCheckBox* moveTorrentFileToTrashCheckBox{}; if (forTorrentFile) { deleteTorrentFileGroupBox = new QGroupBox(qApp->translate("tremotesf", "Delete .torrent file")); layout->addWidget(deleteTorrentFileGroupBox); deleteTorrentFileGroupBox->setCheckable(true); const auto groupBoxLayout = new QVBoxLayout(deleteTorrentFileGroupBox); moveTorrentFileToTrashCheckBox = new QCheckBox(qApp->translate("tremotesf", "Move .torrent file to trash")); groupBoxLayout->addWidget(moveTorrentFileToTrashCheckBox); deleteTorrentFileGroupBox->setChecked(parameters.deleteTorrentFile); moveTorrentFileToTrashCheckBox->setChecked(parameters.moveTorrentFileToTrash); layout->addRow(deleteTorrentFileGroupBox); } return { .downloadDirectoryWidget = downloadDirectoryWidget, .priorityComboBox = priorityComboBox, .startTorrentCheckBox = startTorrentCheckBox, .deleteTorrentFileGroupBox = deleteTorrentFileGroupBox, .moveTorrentFileToTrashCheckBox = moveTorrentFileToTrashCheckBox }; } bool AddTorrentDialog::isAddingFile() const { return std::holds_alternative(mParams); } void AddTorrentDialog::setupUi() { if (isAddingFile()) { //: Dialog title setWindowTitle(qApp->translate("tremotesf", "Add Torrent File")); } else { //: Dialog title setWindowTitle(qApp->translate("tremotesf", "Add Torrent Link")); } auto layout = new QFormLayout(this); layout->setSizeConstraint(QLayout::SetMinAndMaxSize); layout->setFieldGrowthPolicy(QFormLayout::FieldGrowthPolicy::AllNonFixedFieldsGrow); mMessageWidget = new KMessageWidget(this); mMessageWidget->setCloseButtonVisible(false); mMessageWidget->hide(); layout->addRow(mMessageWidget); if (isAddingFile()) { mTorrentFilePathTextField = new QLineEdit(std::get(mParams).filePath, this); mTorrentFilePathTextField->setReadOnly(true); //: Input field's label layout->addRow(qApp->translate("tremotesf", "Torrent file:"), mTorrentFilePathTextField); } else { mTorrentLinkTextField = new QPlainTextEdit(std::get(mParams).urls.join('\n'), this); mTorrentLinkTextField->textCursor().setPosition(0); //: Input field's label layout->addRow(qApp->translate("tremotesf", "Torrent link:"), mTorrentLinkTextField); QObject::connect( mTorrentLinkTextField, &QPlainTextEdit::textChanged, this, &AddTorrentDialog::canAcceptUpdate ); } mAddTorrentParametersWidgets = createAddTorrentParametersWidgets(isAddingFile(), layout, mRpc); mFreeSpaceLabel = new QLabel(this); QObject::connect( mAddTorrentParametersWidgets.downloadDirectoryWidget, &RemoteDirectorySelectionWidget::pathChanged, this, [=, this] { onDownloadDirectoryPathChanged(mAddTorrentParametersWidgets.downloadDirectoryWidget->path()); } ); onDownloadDirectoryPathChanged(mAddTorrentParametersWidgets.downloadDirectoryWidget->path()); layout->insertRow( rowForWidget(layout, mAddTorrentParametersWidgets.downloadDirectoryWidget) + 1, nullptr, mFreeSpaceLabel ); if (isAddingFile()) { mTorrentFilesView = new TorrentFilesView(mFilesModel, mRpc); layout->insertRow(rowForWidget(layout, mFreeSpaceLabel) + 1, mTorrentFilesView); } mEditLabelsGroupBox = new QGroupBox(qApp->translate("tremotesf", "Labels"), this); layout->addRow(mEditLabelsGroupBox); auto labelsGroupBoxLayout = new QVBoxLayout(mEditLabelsGroupBox); mEditLabelsWidget = new EditLabelsWidget({}, mRpc, this); labelsGroupBoxLayout->addWidget(mEditLabelsWidget); if (!isAddingFile()) { layout->addItem(new QSpacerItem(0, 0, QSizePolicy::Minimum, QSizePolicy::Expanding)); } mDialogButtonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); QObject::connect(mDialogButtonBox, &QDialogButtonBox::accepted, this, [this] { if (!mEditLabelsWidget->comboBoxHasFocus()) { accept(); } }); QObject::connect(mDialogButtonBox, &QDialogButtonBox::rejected, this, &AddTorrentDialog::reject); layout->addRow(mDialogButtonBox); QObject::connect(mRpc, &Rpc::connectedChanged, this, &AddTorrentDialog::updateUi); QObject::connect( mAddTorrentParametersWidgets.downloadDirectoryWidget, &RemoteDirectorySelectionWidget::pathChanged, this, &AddTorrentDialog::canAcceptUpdate ); // If call updateUi() right now and need to show messageWidget, it will be // shown without animation, because we are not visible yet // Call it at the next available event loop iteration QTimer::singleShot(0, this, &AddTorrentDialog::updateUi); } void AddTorrentDialog::updateUi() { const bool enabled = mRpc->isConnected() && (mFilesModel ? mFilesModel->isLoaded() : true); if (enabled) { // Update parameters which initial values depend on server state const auto parameters = getAddTorrentParameters(mRpc); mAddTorrentParametersWidgets.downloadDirectoryWidget->updatePath(parameters.downloadDirectory); mAddTorrentParametersWidgets.startTorrentCheckBox->setChecked(parameters.startAfterAdding); } for (int i = 1, max = layout()->count(); i < max; ++i) { auto* const widget = layout()->itemAt(i)->widget(); if (widget) { widget->setEnabled(enabled); } } mAddTorrentParametersWidgets.startTorrentCheckBox->setEnabled(enabled); if (enabled) { if (mMessageWidget->isShowAnimationRunning()) { mMessageWidget->hide(); } else { mMessageWidget->animatedHide(); } mMessageWidget->animatedHide(); } else if (mFilesModel && !mFilesModel->isLoaded()) { mMessageWidget->setMessageType(KMessageWidget::Information); //: Placeholder shown when torrent file is being read/parsed mMessageWidget->setText(qApp->translate("tremotesf", "Loading")); mMessageWidget->animatedShow(); } else { mMessageWidget->setMessageType(KMessageWidget::Warning); //: Server connection status mMessageWidget->setText(qApp->translate("tremotesf", "Disconnected")); mMessageWidget->animatedShow(); } mEditLabelsGroupBox->setVisible( mRpc->isConnected() ? mRpc->serverSettings()->data().hasLabelsProperty() : true ); canAcceptUpdate(); } void AddTorrentDialog::canAcceptUpdate() { bool can = mRpc->isConnected() && (mFilesModel ? mFilesModel->isLoaded() : true); if (mTorrentLinkTextField && mTorrentLinkTextField->document()->isEmpty()) { can = false; } if (mAddTorrentParametersWidgets.downloadDirectoryWidget->path().isEmpty()) { can = false; } mDialogButtonBox->button(QDialogButtonBox::Ok)->setEnabled(can); } void AddTorrentDialog::saveState() { if (mTorrentFilesView) { mTorrentFilesView->saveState(); } } void AddTorrentDialog::onDownloadDirectoryPathChanged(QString path) { mFreeSpaceLabel->hide(); mFreeSpaceLabel->clear(); mFreeSpaceCoroutineScope.cancelAll(); if (!path.isEmpty()) { mFreeSpaceCoroutineScope.launch(getFreeSpaceForPath(std::move(path))); } } Coroutine<> AddTorrentDialog::getFreeSpaceForPath(QString path) { const auto freeSpace = co_await mRpc->getFreeSpaceForPath(std::move(path)); if (freeSpace) { mFreeSpaceLabel->setText( //: %1 is a amount of free space in a directory, e.g. 1 GiB qApp->translate("tremotesf", "Free space: %1").arg(formatutils::formatByteSize(*freeSpace)) ); } else { mFreeSpaceLabel->setText(qApp->translate("tremotesf", "Error getting free space")); } mFreeSpaceLabel->show(); } Coroutine<> AddTorrentDialog::parseTorrentFile() { const auto& filePath = std::get(mParams).filePath; try { info().log("Parsing torrent file {}", filePath); auto torrentFile = co_await runOnThreadPool(&tremotesf::parseTorrentFile, filePath); info().log("Parsed, result = {}", torrentFile); mTorrentFileInfoHashAndTrackers = std::pair{std::move(torrentFile.infoHashV1), std::move(torrentFile.trackers)}; if (checkIfTorrentFileExists()) { if (isAddingFile()) { co_await deleteTorrentFileIfEnabled(); } close(); co_return; } co_await mFilesModel->load(std::move(torrentFile)); updateUi(); } catch (const bencode::Error& e) { warning().logWithException(e, "Failed to parse torrent file {}", filePath); showTorrentParsingError(bencodeErrorString(e.type())); close(); } } void AddTorrentDialog::showTorrentParsingError(const QString& errorString) { auto* const messageBox = new QMessageBox( QMessageBox::Critical, //: Dialog title qApp->translate("tremotesf", "Error"), errorString, QMessageBox::Close, parentWidget() ); messageBox->setAttribute(Qt::WA_DeleteOnClose); messageBox->show(); } void AddTorrentDialog::parseMagnetLinksAndCheckIfTorrentsExist(QStringList& urls) { auto toErase = std::ranges::remove_if(urls, [&](const QString& url) { try { info().log("Parsing {} as a magnet link", url); auto magnetLink = tremotesf::parseMagnetLink(QUrl(url)); info().log("Parsed, result = {}", magnetLink); auto* const torrent = mRpc->torrentByHash(magnetLink.infoHashV1); if (torrent) { askForMergingTrackers(torrent, std::move(magnetLink.trackers), parentWidget()); return true; } } catch (const std::runtime_error& e) { warning().logWithException(e, "Failed to parse {} as a magnet link", url); } return false; }); if (!toErase.empty()) { urls.erase(toErase.begin(), toErase.end()); } } bool AddTorrentDialog::checkIfTorrentFileExists() { if (!mTorrentFileInfoHashAndTrackers.has_value()) return false; auto& [infoHashV1, trackers] = *mTorrentFileInfoHashAndTrackers; auto* const torrent = mRpc->torrentByHash(infoHashV1); if (torrent) { askForMergingTrackers(torrent, std::move(trackers), parentWidget()); return true; } return false; } Coroutine<> AddTorrentDialog::deleteTorrentFileIfEnabled() { const auto deleteFileMode = determineDeleteFileMode(mAddTorrentParametersWidgets); if (deleteFileMode == Rpc::DeleteFileMode::No) { co_return; } co_await runOnThreadPool([deleteFileMode, filePath = std::get(mParams).filePath] { try { if (deleteFileMode == Rpc::DeleteFileMode::MoveToTrash) { moveFileToTrashOrDelete(filePath); } else { deleteFile(filePath); } } catch (const QFileError& e) { warning().logWithException(e, "Failed to delete torrent file"); } }); } void AddTorrentDialog::AddTorrentParametersWidgets::reset(Rpc* rpc) const { const auto initialParameters = getInitialAddTorrentParameters(rpc); downloadDirectoryWidget->updatePath(initialParameters.downloadDirectory); // NOLINTNEXTLINE(bugprone-unchecked-optional-access) priorityComboBox->setCurrentIndex(indexOfCasted(priorityComboBoxItems, initialParameters.priority).value() ); startTorrentCheckBox->setChecked(initialParameters.startAfterAdding); if (deleteTorrentFileGroupBox) { deleteTorrentFileGroupBox->setChecked(initialParameters.deleteTorrentFile); moveTorrentFileToTrashCheckBox->setChecked(initialParameters.moveTorrentFileToTrash); } } void AddTorrentDialog::AddTorrentParametersWidgets::saveToSettings() const { downloadDirectoryWidget->saveDirectories(); auto* const settings = Settings::instance(); settings->set_lastAddTorrentPriority(priorityFromComboBoxIndex(priorityComboBox->currentIndex())); settings->set_lastAddTorrentStartAfterAdding(startTorrentCheckBox->isChecked()); if (deleteTorrentFileGroupBox) { settings->set_lastAddTorrentDeleteTorrentFile(deleteTorrentFileGroupBox->isChecked()); settings->set_lastAddTorrentMoveTorrentFileToTrash(moveTorrentFileToTrashCheckBox->isChecked()); } } } tremotesf-2.8.2/src/ui/screens/addtorrent/addtorrentdialog.h000066400000000000000000000060461500171105600242500ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_ADDTORRENTDIALOG_H #define TREMOTESF_ADDTORRENTDIALOG_H #include #include #include #include #include #include #include #include "coroutines/scope.h" #include "ui/savewindowstatedispatcher.h" class QCheckBox; class QComboBox; class QDialogButtonBox; class QFormLayout; class QGroupBox; class QLabel; class QLineEdit; class QPlainTextEdit; class KMessageWidget; namespace tremotesf { class EditLabelsWidget; class LocalTorrentFilesModel; class TorrentDownloadDirectoryDirectorySelectionWidget; class TorrentFilesView; class Rpc; class AddTorrentDialog final : public QDialog { Q_OBJECT public: struct FileParams { QString filePath; }; struct UrlParams { QStringList urls; }; explicit AddTorrentDialog(Rpc* rpc, std::variant params, QWidget* parent = nullptr); void accept() override; struct AddTorrentParametersWidgets { TorrentDownloadDirectoryDirectorySelectionWidget* downloadDirectoryWidget; QComboBox* priorityComboBox; QCheckBox* startTorrentCheckBox; QGroupBox* deleteTorrentFileGroupBox; QCheckBox* moveTorrentFileToTrashCheckBox; void reset(Rpc* rpc) const; void saveToSettings() const; }; static AddTorrentParametersWidgets createAddTorrentParametersWidgets(bool forTorrentFile, QFormLayout* layout, Rpc* rpc); private: bool isAddingFile() const; void setupUi(); void updateUi(); void canAcceptUpdate(); void saveState(); void onDownloadDirectoryPathChanged(QString path); Coroutine<> getFreeSpaceForPath(QString path); Coroutine<> parseTorrentFile(); void showTorrentParsingError(const QString& errorString); void parseMagnetLinksAndCheckIfTorrentsExist(QStringList& urls); bool checkIfTorrentFileExists(); Coroutine<> deleteTorrentFileIfEnabled(); Rpc* mRpc; std::variant mParams; LocalTorrentFilesModel* mFilesModel{}; CoroutineScope mParseTorrentFileCoroutineScope{}; std::optional>>> mTorrentFileInfoHashAndTrackers{}; KMessageWidget* mMessageWidget{}; QLineEdit* mTorrentFilePathTextField{}; QPlainTextEdit* mTorrentLinkTextField{}; QLabel* mFreeSpaceLabel{}; TorrentFilesView* mTorrentFilesView{}; AddTorrentParametersWidgets mAddTorrentParametersWidgets{}; QGroupBox* mEditLabelsGroupBox{}; EditLabelsWidget* mEditLabelsWidget{}; QDialogButtonBox* mDialogButtonBox{}; CoroutineScope mFreeSpaceCoroutineScope{}; SaveWindowStateHandler mSaveStateHandler{this, [this] { saveState(); }}; }; } #endif // TREMOTESF_ADDTORRENTDIALOG_H tremotesf-2.8.2/src/ui/screens/addtorrent/addtorrenthelpers.cpp000066400000000000000000000110151500171105600247760ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include "addtorrenthelpers.h" #include "settings.h" #include "rpc/rpc.h" #include "rpc/servers.h" #include "rpc/serversettings.h" namespace tremotesf { AddTorrentParameters getAddTorrentParameters(Rpc* rpc) { auto* const settings = Settings::instance(); auto* const serverSettings = rpc->serverSettings(); return { .downloadDirectory = [&] { const auto lastDir = Servers::instance()->currentServerLastDownloadDirectory(serverSettings); return !lastDir.isEmpty() ? lastDir : serverSettings->data().downloadDirectory; }(), .priority = settings->get_lastAddTorrentPriority(), .startAfterAdding = settings->get_lastAddTorrentStartAfterAdding(), .deleteTorrentFile = settings->get_lastAddTorrentDeleteTorrentFile(), .moveTorrentFileToTrash = settings->get_lastAddTorrentMoveTorrentFileToTrash() }; } AddTorrentParameters getInitialAddTorrentParameters(Rpc* rpc) { auto* const serverSettings = rpc->serverSettings(); return { .downloadDirectory = serverSettings->data().downloadDirectory, .priority = TorrentData::Priority::Normal, .startAfterAdding = serverSettings->data().startAddedTorrents, .deleteTorrentFile = false, .moveTorrentFileToTrash = true }; } QDialog* askForMergingTrackers(Torrent* torrent, std::vector> trackers, QWidget* parent) { auto* const settings = Settings::instance(); QMessageBox* messageBox{}; if (settings->get_askForMergingTrackersWhenAddingExistingTorrent()) { messageBox = new QMessageBox( QMessageBox::Question, //: Dialog title qApp->translate("tremotesf", "Merge trackers?"), qApp->translate("tremotesf", "Torrent «%1» is already added, merge trackers?") .arg(torrent->data().name), QMessageBox::Ok | QMessageBox::Cancel, parent ); messageBox->button(QMessageBox::Ok)->setText(qApp->translate("tremotesf", "Merge")); messageBox->setCheckBox(new QCheckBox(qApp->translate("tremotesf", "Do not ask again"), messageBox)); QObject::connect( messageBox, &QDialog::finished, messageBox, [=, torrent = QPointer(torrent), trackers = std::move(trackers)](int result) { if (result == QMessageBox::Ok && torrent) { torrent->addTrackers(trackers); } if (messageBox->checkBox()->isChecked()) { settings->set_mergeTrackersWhenAddingExistingTorrent(result == QMessageBox::Ok); settings->set_askForMergingTrackersWhenAddingExistingTorrent(false); } } ); } else if (settings->get_mergeTrackersWhenAddingExistingTorrent()) { torrent->addTrackers(trackers); messageBox = new QMessageBox( QMessageBox::Information, //: Dialog title qApp->translate("tremotesf", "Merged trackers"), qApp->translate("tremotesf", "Merged trackers for torrent «%1»").arg(torrent->data().name), QMessageBox::Close, parent ); } else { messageBox = new QMessageBox( QMessageBox::Warning, //: Dialog title qApp->translate("tremotesf", "Torrent already exists"), qApp->translate("tremotesf", "Torrent «%1» already exists").arg(torrent->data().name), QMessageBox::Close, parent ); } messageBox->setAttribute(Qt::WA_DeleteOnClose); messageBox->setModal(false); messageBox->show(); return messageBox; } QString bencodeErrorString(bencode::Error::Type type) { switch (type) { case bencode::Error::Type::Reading: return qApp->translate("tremotesf", "Error reading torrent file"); case bencode::Error::Type::Parsing: return qApp->translate("tremotesf", "Error parsing torrent file"); default: return {}; } } } tremotesf-2.8.2/src/ui/screens/addtorrent/addtorrenthelpers.h000066400000000000000000000015571500171105600244550ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_ADDTORRENTHELPERS_H #define TREMOTESF_ADDTORRENTHELPERS_H #include #include "bencodeparser.h" #include "rpc/torrent.h" class QDialog; class QWidget; namespace tremotesf { class Rpc; struct AddTorrentParameters { QString downloadDirectory; TorrentData::Priority priority; bool startAfterAdding; bool deleteTorrentFile; bool moveTorrentFileToTrash; }; AddTorrentParameters getAddTorrentParameters(Rpc* rpc); AddTorrentParameters getInitialAddTorrentParameters(Rpc* rpc); QDialog* askForMergingTrackers(Torrent* torrent, std::vector> trackers, QWidget* parent); QString bencodeErrorString(bencode::Error::Type type); } #endif // TREMOTESF_ADDTORRENTHELPERS_H tremotesf-2.8.2/src/ui/screens/addtorrent/droppedtorrents.cpp000066400000000000000000000036561500171105600245170ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "droppedtorrents.h" #include #include #include "literals.h" #include "log/log.h" #include "magnetlinkparser.h" SPECIALIZE_FORMATTER_FOR_QDEBUG(QUrl) namespace tremotesf { namespace { constexpr auto torrentFileSuffix = ".torrent"_l1; constexpr auto httpScheme = "http"_l1; constexpr auto httpsScheme = "https"_l1; } DroppedTorrents::DroppedTorrents(const QMimeData* mime) { // We need to validate whether we are actually opening torrent file (based on extension) // or BitTorrent magnet link in order to not accept event and tell OS that we don't want it if (mime->hasUrls()) { const auto mimeUrls = mime->urls(); for (const auto& url : mimeUrls) { processUrl(url); } } else if (mime->hasText()) { const auto text = mime->text(); const auto lines = QStringView(text).split(u'\n', Qt::SkipEmptyParts); for (auto line : lines) { processUrl(QUrl(line.toString())); } } } void DroppedTorrents::processUrl(const QUrl& url) { if (url.isLocalFile()) { if (auto path = url.toLocalFile(); path.endsWith(torrentFileSuffix)) { files.push_back(path); } } else { const auto scheme = url.scheme(); if (scheme == magnetScheme) { try { parseMagnetLink(url); urls.push_back(url.toString()); } catch (const std::runtime_error& e) { warning().logWithException(e, "Failed to parse URL {} as magnet link", url); } } else if (scheme == httpScheme || scheme == httpsScheme) { urls.push_back(url.toString()); } } } } tremotesf-2.8.2/src/ui/screens/addtorrent/droppedtorrents.h000066400000000000000000000011131500171105600241460ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_DROPPEDTORRENTS_H #define TREMOTESF_DROPPEDTORRENTS_H #include class QMimeData; class QUrl; namespace tremotesf { struct DroppedTorrents { explicit DroppedTorrents(const QMimeData* mime); [[nodiscard]] bool isEmpty() const { return files.isEmpty() && urls.isEmpty(); } QStringList files{}; QStringList urls{}; private: void processUrl(const QUrl& url); }; } #endif // TREMOTESF_DROPPEDTORRENTS_H tremotesf-2.8.2/src/ui/screens/addtorrent/localtorrentfilesmodel.cpp000066400000000000000000000117141500171105600260270ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "localtorrentfilesmodel.h" #include "coroutines/threadpool.h" #include "ui/itemmodels/torrentfilesmodelentry.h" #include "torrentfileparser.h" namespace tremotesf { namespace { struct CreateTreeResult { std::shared_ptr rootDirectory; std::vector files; }; CreateTreeResult createTree(TorrentMetainfoFile torrentFile) { auto rootDirectory = std::make_shared(); std::vector files; if (torrentFile.isSingleFile()) { auto* file = rootDirectory->addFile(0, torrentFile.rootFileName, torrentFile.singleFileSize()); file->setWanted(true); file->setPriority(TorrentFilesModelEntry::NormalPriority); file->setChanged(false); files.push_back(file); } else { const auto torrentDirectoryName = torrentFile.rootFileName; auto* torrentDirectory = rootDirectory->addDirectory(torrentDirectoryName); auto torrentFiles = torrentFile.files(); files.reserve(torrentFiles.size()); int fileIndex = -1; for (TorrentMetainfoFile::File file : torrentFiles) { ++fileIndex; TorrentFilesModelDirectory* currentDirectory = torrentDirectory; auto pathParts = file.path(); int partIndex = -1; const int lastPartIndex = static_cast(pathParts.size()) - 1; for (const QString& part : pathParts) { ++partIndex; if (partIndex == lastPartIndex) { auto* childFile = currentDirectory->addFile(fileIndex, part, file.size); childFile->setWanted(true); childFile->setPriority(TorrentFilesModelEntry::NormalPriority); childFile->setChanged(false); files.push_back(childFile); } else { const auto& childrenHash = currentDirectory->childrenHash(); const auto found = childrenHash.find(part); if (found != childrenHash.end()) { currentDirectory = static_cast(found->second); } else { currentDirectory = currentDirectory->addDirectory(part); } } } } } return {.rootDirectory = std::move(rootDirectory), .files = std::move(files)}; } } LocalTorrentFilesModel::LocalTorrentFilesModel(QObject* parent) : BaseTorrentFilesModel({Column::Name, Column::Size, Column::Priority}, parent) {} Coroutine<> LocalTorrentFilesModel::load(TorrentMetainfoFile torrentFile) { beginResetModel(); try { auto [rootDirectory, files] = co_await runOnThreadPool(&createTree, std::move(torrentFile)); mRootDirectory = std::move(rootDirectory); mFiles = std::move(files); mLoaded = true; endResetModel(); } catch (const bencode::Error&) { endResetModel(); throw; } } bool LocalTorrentFilesModel::isLoaded() const { return mLoaded; } std::vector LocalTorrentFilesModel::unwantedFiles() const { std::vector files; for (const TorrentFilesModelFile* file : mFiles) { if (file->wantedState() == TorrentFilesModelEntry::Unwanted) { files.push_back(file->id()); } } return files; } std::vector LocalTorrentFilesModel::highPriorityFiles() const { std::vector files; for (const TorrentFilesModelFile* file : mFiles) { if (file->priority() == TorrentFilesModelEntry::HighPriority) { files.push_back(file->id()); } } return files; } std::vector LocalTorrentFilesModel::lowPriorityFiles() const { std::vector files; for (const TorrentFilesModelFile* file : mFiles) { if (file->priority() == TorrentFilesModelEntry::LowPriority) { files.push_back(file->id()); } } return files; } const std::map& LocalTorrentFilesModel::renamedFiles() const { return mRenamedFiles; } void LocalTorrentFilesModel::renameFile(const QModelIndex& index, const QString& newName) { auto entry = static_cast(index.internalPointer()); mRenamedFiles.emplace(entry->path(), newName); fileRenamed(entry, newName); } } tremotesf-2.8.2/src/ui/screens/addtorrent/localtorrentfilesmodel.h000066400000000000000000000022401500171105600254660ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_LOCALTORRENTFILESMODEL_H #define TREMOTESF_LOCALTORRENTFILESMODEL_H #include #include #include "ui/itemmodels/basetorrentfilesmodel.h" #include "coroutines/coroutinefwd.h" namespace tremotesf { class TorrentMetainfoFile; class LocalTorrentFilesModel final : public BaseTorrentFilesModel { Q_OBJECT public: explicit LocalTorrentFilesModel(QObject* parent = nullptr); /** * @throws bencode::Error */ Coroutine<> load(TorrentMetainfoFile torrentFile); bool isLoaded() const; std::vector unwantedFiles() const; std::vector highPriorityFiles() const; std::vector lowPriorityFiles() const; const std::map& renamedFiles() const; void renameFile(const QModelIndex& index, const QString& newName) override; private: std::vector mFiles{}; bool mLoaded{}; std::map mRenamedFiles{}; }; } #endif // TREMOTESF_LOCALTORRENTFILESMODEL_H tremotesf-2.8.2/src/ui/screens/connectionsettings/000077500000000000000000000000001500171105600223155ustar00rootroot00000000000000tremotesf-2.8.2/src/ui/screens/connectionsettings/connectionsettingsdialog.cpp000066400000000000000000000134021500171105600301210ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "connectionsettingsdialog.h" #include #include #include #include #include #include #include #include #include #include #include "serversmodel.h" #include "servereditdialog.h" #include "ui/itemmodels/baseproxymodel.h" #include "ui/widgets/listplaceholder.h" namespace tremotesf { namespace { constexpr auto editIconName = "document-properties"_l1; constexpr auto removeIconName = "list-remove"_l1; } ConnectionSettingsDialog::ConnectionSettingsDialog(QWidget* parent) : QDialog(parent), //: Servers list placeholder mModel(new ServersModel(this)), mProxyModel(new BaseProxyModel(mModel, Qt::DisplayRole, std::nullopt, this)), mServersView(new QListView(this)) { //: Dialog title setWindowTitle(qApp->translate("tremotesf", "Connection Settings")); mProxyModel->sort(); auto layout = new QGridLayout(this); mServersView->setContextMenuPolicy(Qt::CustomContextMenu); mServersView->setSelectionMode(QListView::ExtendedSelection); mServersView->setModel(mProxyModel); QObject::connect(mServersView, &QListView::activated, this, &ConnectionSettingsDialog::showEditDialogs); auto removeAction = new QAction( QIcon::fromTheme(removeIconName), //: Server's context menu item qApp->translate("tremotesf", "&Remove"), this ); removeAction->setShortcut(QKeySequence::Delete); mServersView->addAction(removeAction); QObject::connect(removeAction, &QAction::triggered, this, &ConnectionSettingsDialog::removeServers); QObject::connect(mServersView, &QListView::customContextMenuRequested, this, [=, this](QPoint pos) { if (mServersView->indexAt(pos).isValid()) { QMenu contextMenu; QAction* editAction = contextMenu.addAction( QIcon::fromTheme(editIconName), //: Server's context menu item qApp->translate("tremotesf", "&Edit...") ); QObject::connect(editAction, &QAction::triggered, this, &ConnectionSettingsDialog::showEditDialogs); contextMenu.addAction(removeAction); contextMenu.exec(mServersView->viewport()->mapToGlobal(pos)); } }); auto placeholder = createListPlaceholderLabel(qApp->translate("tremotesf", "No servers")); addListPlaceholderLabelToViewportAndManageVisibility(mServersView, placeholder); layout->addWidget(mServersView, 0, 0); auto buttonsLayout = new QVBoxLayout(); layout->addLayout(buttonsLayout, 0, 1); auto addServerButton = new QPushButton( QIcon::fromTheme("list-add"_l1), //: Button qApp->translate("tremotesf", "Add Server..."), this ); QObject::connect(addServerButton, &QPushButton::clicked, this, [=, this] { auto dialog = new ServerEditDialog(mModel, -1, this); dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->show(); }); buttonsLayout->addWidget(addServerButton); auto editButton = new QPushButton( QIcon::fromTheme(editIconName), //: Button qApp->translate("tremotesf", "Edit..."), this ); editButton->setEnabled(false); QObject::connect(editButton, &QPushButton::clicked, this, &ConnectionSettingsDialog::showEditDialogs); buttonsLayout->addWidget(editButton); auto removeButton = new QPushButton( QIcon::fromTheme(removeIconName), //: Button qApp->translate("tremotesf", "Remove"), this ); removeButton->setEnabled(false); QObject::connect(removeButton, &QPushButton::clicked, this, &ConnectionSettingsDialog::removeServers); buttonsLayout->addWidget(removeButton); buttonsLayout->addStretch(); QObject::connect(mServersView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=, this] { const bool hasSelection = mServersView->selectionModel()->hasSelection(); editButton->setEnabled(hasSelection); removeButton->setEnabled(hasSelection); }); auto dialogButtonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); QObject::connect(dialogButtonBox, &QDialogButtonBox::accepted, this, &ConnectionSettingsDialog::accept); QObject::connect(dialogButtonBox, &QDialogButtonBox::rejected, this, &ConnectionSettingsDialog::reject); layout->addWidget(dialogButtonBox, 1, 0, 1, 2); resize(sizeHint().expandedTo(QSize(384, 320))); } void ConnectionSettingsDialog::accept() { Servers::instance()->saveServers(mModel->servers(), mModel->currentServerName()); QDialog::accept(); } void ConnectionSettingsDialog::showEditDialogs() { const QModelIndexList indexes(mServersView->selectionModel()->selectedIndexes()); for (const QModelIndex& index : indexes) { auto dialog = new ServerEditDialog(mModel, mProxyModel->sourceIndex(index).row(), this); dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->show(); } } void ConnectionSettingsDialog::removeServers() { while (mServersView->selectionModel()->hasSelection()) { mModel->removeServerAtIndex( mProxyModel->sourceIndex(mServersView->selectionModel()->selectedIndexes().first()) ); } } } tremotesf-2.8.2/src/ui/screens/connectionsettings/connectionsettingsdialog.h000066400000000000000000000012671500171105600275740ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_SERVERSDIALOG_H #define TREMOTESF_SERVERSDIALOG_H #include class QListView; namespace tremotesf { class ServersModel; class BaseProxyModel; class ConnectionSettingsDialog final : public QDialog { Q_OBJECT public: explicit ConnectionSettingsDialog(QWidget* parent = nullptr); void accept() override; private: void showEditDialogs(); void removeServers(); ServersModel* mModel; BaseProxyModel* mProxyModel; QListView* mServersView; }; } #endif // TREMOTESF_SERVERSDIALOG_H tremotesf-2.8.2/src/ui/screens/connectionsettings/servereditdialog.cpp000066400000000000000000000636441500171105600263720ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "servereditdialog.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "fileutils.h" #include "log/log.h" #include "rpc/pathutils.h" #include "stdutils.h" #include "target_os.h" #include "ui/widgets/commondelegate.h" #include "rpc/servers.h" #include "serversmodel.h" namespace tremotesf { namespace { constexpr auto removeIconName = "list-remove"_l1; constexpr std::array proxyTypeComboBoxValues{ ConnectionConfiguration::ProxyType::Default, ConnectionConfiguration::ProxyType::Http, ConnectionConfiguration::ProxyType::Socks5, ConnectionConfiguration::ProxyType::None, }; ConnectionConfiguration::ProxyType proxyTypeFromComboBoxIndex(int index) { if (index == -1) { return ConnectionConfiguration::ProxyType::Default; } return proxyTypeComboBoxValues.at(static_cast(index)); } } class MountedDirectoriesWidget final : public QTableWidget { Q_OBJECT public: MountedDirectoriesWidget(int rows, int columns, QWidget* parent = nullptr) : QTableWidget(rows, columns, parent) { setMinimumHeight(192); setSelectionMode(QAbstractItemView::SingleSelection); setItemDelegate(new CommonDelegate(this)); setHorizontalHeaderLabels({//: Column title in the list of mounted directories qApp->translate("tremotesf", "Local directory"), //: Column title in the list of mounted directories qApp->translate("tremotesf", "Remote directory") }); horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); horizontalHeader()->setSectionsClickable(false); verticalHeader()->setVisible(false); setContextMenuPolicy(Qt::CustomContextMenu); //: Context menu item auto removeAction = new QAction(QIcon::fromTheme(removeIconName), qApp->translate("tremotesf", "&Remove"), this); removeAction->setShortcut(QKeySequence::Delete); addAction(removeAction); QObject::connect(removeAction, &QAction::triggered, this, [=, this] { const auto items(selectionModel()->selectedIndexes()); if (!items.isEmpty()) { removeRow(items.first().row()); } }); QObject::connect(this, &QWidget::customContextMenuRequested, this, [=, this](QPoint pos) { const QModelIndex index(indexAt(pos)); if (!index.isValid()) { return; } QMenu contextMenu; QAction* selectAction = nullptr; if (index.column() == 0) { //: Context menu item to open directory chooser selectAction = contextMenu.addAction(qApp->translate("tremotesf", "&Select...")); } contextMenu.addAction(removeAction); auto executed = contextMenu.exec(viewport()->mapToGlobal(pos)); if (executed && executed == selectAction) { const QString directory(QFileDialog::getExistingDirectory(this)); if (!directory.isEmpty()) { const auto item = this->item(index.row(), index.column()); if (item) { item->setText(directory); item->setToolTip(directory); } } } }); } void addRow(const QString& localDirectory, const QString& remoteDirectory) { const int row = rowCount(); insertRow(row); const auto localItem = new QTableWidgetItem(localDirectory); localItem->setToolTip(localDirectory); setItem(row, 0, localItem); const auto remoteItem = new QTableWidgetItem(remoteDirectory); remoteItem->setToolTip(remoteDirectory); setItem(row, 1, remoteItem); } protected: void keyPressEvent(QKeyEvent* event) override { QTableWidget::keyPressEvent(event); switch (event->key()) { case Qt::Key_Return: case Qt::Key_Enter: event->accept(); if (state() != EditingState) { edit(currentIndex()); } break; default: break; } } }; ServerEditDialog::ServerEditDialog(ServersModel* serversModel, int row, QWidget* parent) : QDialog(parent), mServersModel(serversModel) { setupUi(); resize(sizeHint().expandedTo(QSize(384, 512))); if (row == -1) { //: Dialog title setWindowTitle(qApp->translate("tremotesf", "Add Server")); mPortSpinBox->setValue(9091); mApiPathLineEdit->setText("/transmission/rpc"_l1); mProxyTypeComboBox->setCurrentIndex( // NOLINTNEXTLINE(bugprone-unchecked-optional-access) indexOfCasted(proxyTypeComboBoxValues, ConnectionConfiguration::ProxyType::Default).value() ); mHttpsGroupBox->setChecked(false); mAuthenticationGroupBox->setChecked(false); mUpdateIntervalSpinBox->setValue(5); mTimeoutSpinBox->setValue(30); mAutoReconnectGroupBox->setChecked(false); mAutoReconnectSpinBox->setValue(30); } else { const Server& server = mServersModel->servers().at(static_cast(row)); mServerName = server.name; setWindowTitle(mServerName); mNameLineEdit->setText(mServerName); mAddressLineEdit->setText(server.connectionConfiguration.address); mPortSpinBox->setValue(server.connectionConfiguration.port); mApiPathLineEdit->setText(server.connectionConfiguration.apiPath); mProxyTypeComboBox->setCurrentIndex( // NOLINTNEXTLINE(bugprone-unchecked-optional-access) indexOfCasted(proxyTypeComboBoxValues, server.connectionConfiguration.proxyType).value() ); mProxyHostnameLineEdit->setText(server.connectionConfiguration.proxyHostname); mProxyPortSpinBox->setValue(server.connectionConfiguration.proxyPort); mProxyUserLineEdit->setText(server.connectionConfiguration.proxyUser); mProxyPasswordLineEdit->setText(server.connectionConfiguration.proxyPassword); mHttpsGroupBox->setChecked(server.connectionConfiguration.https); mSelfSignedCertificateCheckBox->setChecked(server.connectionConfiguration.selfSignedCertificateEnabled); mSelfSignedCertificateEdit->setPlainText(server.connectionConfiguration.selfSignedCertificate); mClientCertificateCheckBox->setChecked(server.connectionConfiguration.clientCertificateEnabled); mClientCertificateEdit->setPlainText(server.connectionConfiguration.clientCertificate); mAuthenticationGroupBox->setChecked(server.connectionConfiguration.authentication); mUsernameLineEdit->setText(server.connectionConfiguration.username); mPasswordLineEdit->setText(server.connectionConfiguration.password); mUpdateIntervalSpinBox->setValue(server.connectionConfiguration.updateInterval); mTimeoutSpinBox->setValue(server.connectionConfiguration.timeout); mAutoReconnectGroupBox->setChecked(server.connectionConfiguration.autoReconnectEnabled); mAutoReconnectSpinBox->setValue(server.connectionConfiguration.autoReconnectInterval); for (const auto& [localDirectory, remoteDirectory] : server.mountedDirectories) { mMountedDirectoriesWidget->addRow(localDirectory, remoteDirectory); } } setProxyFieldsVisible(); } void ServerEditDialog::accept() { if (mServersModel) { const QString newName(mNameLineEdit->text()); if (newName != mServerName && mServersModel->hasServer(newName)) { QMessageBox messageBox( QMessageBox::Warning, //: Dialog title qApp->translate("tremotesf", "Overwrite Server"), qApp->translate("tremotesf", "Server already exists"), QMessageBox::Ok | QMessageBox::Cancel, this ); messageBox.setDefaultButton(QMessageBox::Cancel); //: Dialog's confirmation button messageBox.button(QMessageBox::Ok)->setText(qApp->translate("tremotesf", "Overwrite")); if (messageBox.exec() != QMessageBox::Ok) { return; } } } setServer(); QDialog::accept(); } void ServerEditDialog::setupUi() { auto topLayout = new QVBoxLayout(this); auto scrollArea = new QScrollArea(this); scrollArea->setFrameShape(QFrame::NoFrame); scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); scrollArea->setWidgetResizable(true); topLayout->addWidget(scrollArea); auto widget = new QWidget(this); auto formLayout = new QFormLayout(widget); formLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); mNameLineEdit = new QLineEdit(this); mNameLineEdit->setValidator(new QRegularExpressionValidator(QRegularExpression(R"(^\S.*)"), this)); QObject::connect(mNameLineEdit, &QLineEdit::textChanged, this, &ServerEditDialog::canAcceptUpdate); formLayout->addRow(qApp->translate("tremotesf", "Name:"), mNameLineEdit); mAddressLineEdit = new QLineEdit(this); auto addressValidator = new QRegularExpressionValidator(QRegularExpression(R"(^\S+)"), this); mAddressLineEdit->setValidator(addressValidator); QObject::connect(mAddressLineEdit, &QLineEdit::textChanged, this, &ServerEditDialog::canAcceptUpdate); formLayout->addRow(qApp->translate("tremotesf", "Address:"), mAddressLineEdit); mPortSpinBox = new QSpinBox(this); const int maxPort = 65535; mPortSpinBox->setMaximum(maxPort); formLayout->addRow(qApp->translate("tremotesf", "Port:"), mPortSpinBox); mApiPathLineEdit = new QLineEdit(this); formLayout->addRow(qApp->translate("tremotesf", "API path:"), mApiPathLineEdit); auto proxyGroupBox = new QGroupBox(qApp->translate("tremotesf", "Proxy"), this); mProxyLayout = new QFormLayout(proxyGroupBox); mProxyLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); mProxyTypeComboBox = new QComboBox(this); mProxyTypeComboBox->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); for (const auto type : proxyTypeComboBoxValues) { switch (type) { case ConnectionConfiguration::ProxyType::Default: //: Default proxy option mProxyTypeComboBox->addItem(qApp->translate("tremotesf", "Default")); break; case ConnectionConfiguration::ProxyType::Http: //: HTTP proxy option mProxyTypeComboBox->addItem(qApp->translate("tremotesf", "HTTP")); break; case ConnectionConfiguration::ProxyType::Socks5: //: SOCKS5 proxy option mProxyTypeComboBox->addItem(qApp->translate("tremotesf", "SOCKS5")); break; case ConnectionConfiguration::ProxyType::None: //: None proxy option mProxyTypeComboBox->addItem(qApp->translate("tremotesf", "None")); break; } } QObject::connect( mProxyTypeComboBox, &QComboBox::currentTextChanged, this, &ServerEditDialog::setProxyFieldsVisible ); mProxyLayout->addRow(qApp->translate("tremotesf", "Proxy type:"), mProxyTypeComboBox); mProxyHostnameLineEdit = new QLineEdit(this); mProxyHostnameLineEdit->setValidator(addressValidator); mProxyLayout->addRow(qApp->translate("tremotesf", "Address:"), mProxyHostnameLineEdit); mProxyPortSpinBox = new QSpinBox(this); mProxyPortSpinBox->setMaximum(maxPort); mProxyLayout->addRow(qApp->translate("tremotesf", "Port:"), mProxyPortSpinBox); mProxyUserLineEdit = new QLineEdit(this); mProxyLayout->addRow(qApp->translate("tremotesf", "Username:"), mProxyUserLineEdit); mProxyPasswordLineEdit = new QLineEdit(this); mProxyPasswordLineEdit->setEchoMode(QLineEdit::Password); mProxyLayout->addRow(qApp->translate("tremotesf", "Password:"), mProxyPasswordLineEdit); formLayout->addRow(proxyGroupBox); mHttpsGroupBox = new QGroupBox(qApp->translate("tremotesf", "HTTPS"), this); mHttpsGroupBox->setCheckable(true); auto httpsGroupBoxLayout = new QVBoxLayout(mHttpsGroupBox); mSelfSignedCertificateCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Server uses self-signed certificate"), this ); httpsGroupBoxLayout->addWidget(mSelfSignedCertificateCheckBox); mSelfSignedCertificateEdit = new QPlainTextEdit(this); mSelfSignedCertificateEdit->setMinimumHeight(192); mSelfSignedCertificateEdit->setPlaceholderText( //: Text field placeholder qApp->translate("tremotesf", "Server's certificate in PEM format") ); mSelfSignedCertificateEdit->setVisible(false); httpsGroupBoxLayout->addWidget(mSelfSignedCertificateEdit); auto* selfSignedCertificateLoadFromFile = new QPushButton( //: Button qApp->translate("tremotesf", "Load from file..."), this ); selfSignedCertificateLoadFromFile->setVisible(false); QObject::connect(selfSignedCertificateLoadFromFile, &QPushButton::clicked, this, [this] { loadCertificateFromFile(mSelfSignedCertificateEdit); }); httpsGroupBoxLayout->addWidget(selfSignedCertificateLoadFromFile); QObject::connect(mSelfSignedCertificateCheckBox, &QCheckBox::toggled, this, [=, this](bool checked) { mSelfSignedCertificateEdit->setVisible(checked); selfSignedCertificateLoadFromFile->setVisible(checked); }); mClientCertificateCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Use client certificate authentication"), this ); httpsGroupBoxLayout->addWidget(mClientCertificateCheckBox); mClientCertificateEdit = new QPlainTextEdit(this); mClientCertificateEdit->setMinimumHeight(192); mClientCertificateEdit->setPlaceholderText( //: Text field placeholder qApp->translate("tremotesf", "Certificate in PEM format with private key") ); mClientCertificateEdit->setVisible(false); httpsGroupBoxLayout->addWidget(mClientCertificateEdit); //: Button auto* clientCertificateLoadFromFile = new QPushButton(qApp->translate("tremotesf", "Load from file..."), this); clientCertificateLoadFromFile->setVisible(false); QObject::connect(clientCertificateLoadFromFile, &QPushButton::clicked, this, [this] { loadCertificateFromFile(mClientCertificateEdit); }); httpsGroupBoxLayout->addWidget(clientCertificateLoadFromFile); QObject::connect(mClientCertificateCheckBox, &QCheckBox::toggled, this, [=, this](bool checked) { mClientCertificateEdit->setVisible(checked); clientCertificateLoadFromFile->setVisible(checked); }); formLayout->addRow(mHttpsGroupBox); //: Check box label mAuthenticationGroupBox = new QGroupBox(qApp->translate("tremotesf", "Authentication"), this); mAuthenticationGroupBox->setCheckable(true); auto authenticationGroupBoxLayout = new QFormLayout(mAuthenticationGroupBox); authenticationGroupBoxLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); mUsernameLineEdit = new QLineEdit(this); authenticationGroupBoxLayout->addRow(qApp->translate("tremotesf", "Username:"), mUsernameLineEdit); mPasswordLineEdit = new QLineEdit(this); mPasswordLineEdit->setEchoMode(QLineEdit::Password); authenticationGroupBoxLayout->addRow(qApp->translate("tremotesf", "Password:"), mPasswordLineEdit); formLayout->addRow(mAuthenticationGroupBox); mUpdateIntervalSpinBox = new QSpinBox(this); mUpdateIntervalSpinBox->setMinimum(1); mUpdateIntervalSpinBox->setMaximum(3600); //: Suffix that is added to input field with number of seconds, e.g. "30 s" mUpdateIntervalSpinBox->setSuffix(qApp->translate("tremotesf", " s")); formLayout->addRow(qApp->translate("tremotesf", "Update interval:"), mUpdateIntervalSpinBox); mTimeoutSpinBox = new QSpinBox(this); mTimeoutSpinBox->setMinimum(5); mTimeoutSpinBox->setMaximum(60); //: Suffix that is added to input field with number of seconds, e.g. "30 s" mTimeoutSpinBox->setSuffix(qApp->translate("tremotesf", " s")); formLayout->addRow(qApp->translate("tremotesf", "Timeout:"), mTimeoutSpinBox); //: Check box label mAutoReconnectGroupBox = new QGroupBox(qApp->translate("tremotesf", "Auto reconnect on error"), this); mAutoReconnectGroupBox->setCheckable(true); formLayout->addRow(mAutoReconnectGroupBox); auto* autoReconnectFormLayout = new QFormLayout(mAutoReconnectGroupBox); autoReconnectFormLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); mAutoReconnectSpinBox = new QSpinBox(this); mAutoReconnectSpinBox->setMinimum(1); mAutoReconnectSpinBox->setMaximum(3600); //: Suffix that is added to input field with number of seconds, e.g. "30 s" mAutoReconnectSpinBox->setSuffix(qApp->translate("tremotesf", " s")); autoReconnectFormLayout->addRow( qApp->translate("tremotesf", "Auto reconnect interval:"), mAutoReconnectSpinBox ); auto mountedDirectoriesGroupBox = new QGroupBox(qApp->translate("tremotesf", "Mounted directories"), this); auto mountedDirectoriesLayout = new QGridLayout(mountedDirectoriesGroupBox); mMountedDirectoriesWidget = new MountedDirectoriesWidget(0, 2); mountedDirectoriesLayout->addWidget(mMountedDirectoriesWidget, 0, 0, 1, 2); auto addDirectoriesButton = new QPushButton( QIcon::fromTheme("list-add"_l1), //: Button qApp->translate("tremotesf", "Add"), this ); QObject::connect(addDirectoriesButton, &QPushButton::clicked, this, [=, this] { const QString directory(QFileDialog::getExistingDirectory(this)); if (!directory.isEmpty()) { mMountedDirectoriesWidget->addRow(directory, QString()); } }); mountedDirectoriesLayout->addWidget(addDirectoriesButton, 1, 0); auto removeDirectoriesButton = new QPushButton( QIcon::fromTheme(removeIconName), //: Button qApp->translate("tremotesf", "Remove"), this ); QObject::connect(removeDirectoriesButton, &QPushButton::clicked, this, [=, this] { const auto items(mMountedDirectoriesWidget->selectionModel()->selectedIndexes()); if (!items.isEmpty()) { mMountedDirectoriesWidget->removeRow(items.first().row()); } }); mountedDirectoriesLayout->addWidget(removeDirectoriesButton, 1, 1); formLayout->addRow(mountedDirectoriesGroupBox); scrollArea->setWidget(widget); mDialogButtonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); QObject::connect(mDialogButtonBox, &QDialogButtonBox::accepted, this, &ServerEditDialog::accept); QObject::connect(mDialogButtonBox, &QDialogButtonBox::rejected, this, &ServerEditDialog::reject); topLayout->addWidget(mDialogButtonBox); } void ServerEditDialog::setProxyFieldsVisible() { bool visible{}; switch (proxyTypeFromComboBoxIndex(mProxyTypeComboBox->currentIndex())) { case ConnectionConfiguration::ProxyType::Default: case ConnectionConfiguration::ProxyType::None: visible = false; break; default: visible = true; } for (int i = 1, max = mProxyLayout->rowCount(); i < max; ++i) { mProxyLayout->itemAt(i, QFormLayout::LabelRole)->widget()->setVisible(visible); mProxyLayout->itemAt(i, QFormLayout::FieldRole)->widget()->setVisible(visible); } } void ServerEditDialog::canAcceptUpdate() { mDialogButtonBox->button(QDialogButtonBox::Ok) ->setEnabled(mNameLineEdit->hasAcceptableInput() && mAddressLineEdit->hasAcceptableInput()); } void ServerEditDialog::setServer() { std::vector mountedDirectories{}; mountedDirectories.reserve(static_cast(mMountedDirectoriesWidget->rowCount())); for (int i = 0, max = mMountedDirectoriesWidget->rowCount(); i < max; ++i) { const auto localItem = mMountedDirectoriesWidget->item(i, 0); const QString localDirectory = localItem ? normalizePath(localItem->text().trimmed(), localPathOs) : QString{}; const auto remoteItem = mMountedDirectoriesWidget->item(i, 1); const QString remoteDirectory = remoteItem ? remoteItem->text().trimmed() : QString{}; if (!localDirectory.isEmpty() && !remoteDirectory.isEmpty()) { mountedDirectories.push_back({.localPath = localDirectory, .remotePath = remoteDirectory}); } } if (mServersModel) { mServersModel->setServer( mServerName, mNameLineEdit->text(), mAddressLineEdit->text(), mPortSpinBox->value(), mApiPathLineEdit->text(), proxyTypeFromComboBoxIndex(mProxyTypeComboBox->currentIndex()), mProxyHostnameLineEdit->text(), mProxyPortSpinBox->value(), mProxyUserLineEdit->text(), mProxyPasswordLineEdit->text(), mHttpsGroupBox->isChecked(), mSelfSignedCertificateCheckBox->isChecked(), mSelfSignedCertificateEdit->toPlainText().toLatin1(), mClientCertificateCheckBox->isChecked(), mClientCertificateEdit->toPlainText().toLatin1(), mAuthenticationGroupBox->isChecked(), mUsernameLineEdit->text(), mPasswordLineEdit->text(), mUpdateIntervalSpinBox->value(), mTimeoutSpinBox->value(), mAutoReconnectGroupBox->isChecked(), mAutoReconnectSpinBox->value(), mountedDirectories ); } else { Servers::instance()->setServer( mServerName, mNameLineEdit->text(), mAddressLineEdit->text(), mPortSpinBox->value(), mApiPathLineEdit->text(), proxyTypeFromComboBoxIndex(mProxyTypeComboBox->currentIndex()), mProxyHostnameLineEdit->text(), mProxyPortSpinBox->value(), mProxyUserLineEdit->text(), mProxyPasswordLineEdit->text(), mHttpsGroupBox->isChecked(), mSelfSignedCertificateCheckBox->isChecked(), mSelfSignedCertificateEdit->toPlainText().toLatin1(), mClientCertificateCheckBox->isChecked(), mClientCertificateEdit->toPlainText().toLatin1(), mAuthenticationGroupBox->isChecked(), mUsernameLineEdit->text(), mPasswordLineEdit->text(), mUpdateIntervalSpinBox->value(), mTimeoutSpinBox->value(), mAutoReconnectGroupBox->isChecked(), mAutoReconnectSpinBox->value(), mountedDirectories ); } } void ServerEditDialog::loadCertificateFromFile(QPlainTextEdit* target) { auto* fileDialog = new QFileDialog( this, //: File chooser dialog title qApp->translate("tremotesf", "Select Files"), {}, /*qApp->translate("tremotesf", "Torrent Files (*.torrent)")*/ {} ); fileDialog->setAttribute(Qt::WA_DeleteOnClose); fileDialog->setFileMode(QFileDialog::ExistingFile); fileDialog->setMimeTypeFilters({"application/x-pem-file"_l1}); QObject::connect(fileDialog, &QFileDialog::accepted, this, [=] { try { target->setPlainText(readFile(fileDialog->selectedFiles().first())); } catch (const QFileError& e) { warning().logWithException(e, "Failed to read certificate from file"); } }); if constexpr (targetOs == TargetOs::Windows) { fileDialog->open(); } else { fileDialog->show(); } } } #include "servereditdialog.moc" tremotesf-2.8.2/src/ui/screens/connectionsettings/servereditdialog.h000066400000000000000000000042241500171105600260240ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_SERVEREDITDIALOG_H #define TREMOTESF_SERVEREDITDIALOG_H #include class QCheckBox; class QComboBox; class QDialogButtonBox; class QFormLayout; class QGroupBox; class QLineEdit; class QPlainTextEdit; class QSpinBox; namespace tremotesf { class MountedDirectoriesWidget; class ServersModel; class ServerEditDialog final : public QDialog { Q_OBJECT public: explicit ServerEditDialog(ServersModel* serversModel, int row, QWidget* parent = nullptr); void accept() override; private: void setupUi(); void setProxyFieldsVisible(); void canAcceptUpdate(); void setServer(); void loadCertificateFromFile(QPlainTextEdit* target); ServersModel* mServersModel; QString mServerName; QLineEdit* mNameLineEdit = nullptr; QLineEdit* mAddressLineEdit = nullptr; QSpinBox* mPortSpinBox = nullptr; QLineEdit* mApiPathLineEdit = nullptr; QFormLayout* mProxyLayout = nullptr; QComboBox* mProxyTypeComboBox = nullptr; QLineEdit* mProxyHostnameLineEdit = nullptr; QSpinBox* mProxyPortSpinBox = nullptr; QLineEdit* mProxyUserLineEdit = nullptr; QLineEdit* mProxyPasswordLineEdit = nullptr; QGroupBox* mHttpsGroupBox = nullptr; QCheckBox* mSelfSignedCertificateCheckBox = nullptr; QPlainTextEdit* mSelfSignedCertificateEdit = nullptr; QCheckBox* mClientCertificateCheckBox = nullptr; QPlainTextEdit* mClientCertificateEdit = nullptr; QGroupBox* mAuthenticationGroupBox = nullptr; QLineEdit* mUsernameLineEdit = nullptr; QLineEdit* mPasswordLineEdit = nullptr; QSpinBox* mUpdateIntervalSpinBox = nullptr; QSpinBox* mTimeoutSpinBox = nullptr; QGroupBox* mAutoReconnectGroupBox = nullptr; QSpinBox* mAutoReconnectSpinBox = nullptr; MountedDirectoriesWidget* mMountedDirectoriesWidget = nullptr; QDialogButtonBox* mDialogButtonBox = nullptr; }; } #endif // TREMOTESF_SERVEREDITDIALOG_H tremotesf-2.8.2/src/ui/screens/connectionsettings/serversmodel.cpp000066400000000000000000000172451500171105600255440ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "serversmodel.h" namespace tremotesf { ServersModel::ServersModel(QObject* parent) : QAbstractListModel(parent), mServers(Servers::instance()->servers()), mCurrentServer(Servers::instance()->currentServerName()) {} QVariant ServersModel::data(const QModelIndex& index, int role) const { if (!index.isValid()) { return {}; } const Server& server = mServers.at(static_cast(index.row())); switch (role) { case Qt::CheckStateRole: if (server.name == mCurrentServer) { return Qt::Checked; } return Qt::Unchecked; case Qt::DisplayRole: return server.name; default: return {}; } } Qt::ItemFlags ServersModel::flags(const QModelIndex& index) const { if (!index.isValid()) { return {}; } return QAbstractListModel::flags(index) | Qt::ItemIsUserCheckable; } int ServersModel::rowCount(const QModelIndex&) const { return static_cast(mServers.size()); } bool ServersModel::setData(const QModelIndex& modelIndex, const QVariant& value, int role) { if (!modelIndex.isValid() || role != Qt::CheckStateRole || value.value() != Qt::Checked) { return false; } const auto& current = mServers.at(static_cast(modelIndex.row())); if (current.name != mCurrentServer) { mCurrentServer = current.name; emit dataChanged(index(0), index(static_cast(mServers.size()) - 1)); return true; } return false; } const std::vector& ServersModel::servers() const { return mServers; } const QString& ServersModel::currentServerName() const { return mCurrentServer; } bool ServersModel::hasServer(const QString& name) const { return serverRow(name) != -1; } void ServersModel::setServer( const QString& oldName, const QString& name, const QString& address, int port, const QString& apiPath, ConnectionConfiguration::ProxyType proxyType, const QString& proxyHostname, int proxyPort, const QString& proxyUser, const QString& proxyPassword, bool https, bool selfSignedCertificateEnabled, const QByteArray& selfSignedCertificate, bool clientCertificateEnabled, const QByteArray& clientCertificate, bool authentication, const QString& username, const QString& password, int updateInterval, int timeout, bool autoReconnectEnabled, int autoReconnectInterval, const std::vector& mountedDirectories ) { const int oldRow = serverRow(oldName); int row = serverRow(name); Server* const server = [=, this]() -> Server* { if (oldRow != -1) { return &mServers.at(static_cast(oldRow)); } if (row != -1) { return &mServers.at(static_cast(row)); } return nullptr; }(); if (server) { // Overwrite an existing server server->name = name; server->connectionConfiguration.address = address; server->connectionConfiguration.port = port; server->connectionConfiguration.apiPath = apiPath; server->connectionConfiguration.proxyType = proxyType; server->connectionConfiguration.proxyHostname = proxyHostname; server->connectionConfiguration.proxyPort = proxyPort; server->connectionConfiguration.proxyUser = proxyUser; server->connectionConfiguration.proxyPassword = proxyPassword; server->connectionConfiguration.https = https; server->connectionConfiguration.selfSignedCertificateEnabled = selfSignedCertificateEnabled; server->connectionConfiguration.selfSignedCertificate = selfSignedCertificate; server->connectionConfiguration.clientCertificateEnabled = clientCertificateEnabled; server->connectionConfiguration.clientCertificate = clientCertificate; server->connectionConfiguration.authentication = authentication; server->connectionConfiguration.username = username; server->connectionConfiguration.password = password; server->connectionConfiguration.updateInterval = updateInterval; server->connectionConfiguration.timeout = timeout; server->connectionConfiguration.autoReconnectEnabled = autoReconnectEnabled; server->connectionConfiguration.autoReconnectInterval = autoReconnectInterval; server->mountedDirectories = mountedDirectories; const QModelIndex modelIndex(index(oldRow)); emit dataChanged(modelIndex, modelIndex); if (oldRow != -1 && row != -1 && row != oldRow) { // Remove overwritten server if we overwrite when renaming beginRemoveRows(QModelIndex(), row, row); mServers.erase(mServers.begin() + row); endRemoveRows(); } if (oldName == mCurrentServer) { mCurrentServer = name; } } else { row = static_cast(mServers.size()); beginInsertRows(QModelIndex(), row, row); mServers.push_back(Server{ .name = name, .connectionConfiguration = ConnectionConfiguration{ address, port, apiPath, proxyType, proxyHostname, proxyPort, proxyUser, proxyPassword, https, selfSignedCertificateEnabled, selfSignedCertificate, clientCertificateEnabled, clientCertificate, authentication, username, password, updateInterval, timeout, autoReconnectEnabled, autoReconnectInterval }, .mountedDirectories = mountedDirectories, .lastTorrents = {}, .lastDownloadDirectories = {}, .lastDownloadDirectory = {} }); endInsertRows(); if (row == 0) { mCurrentServer = name; } } } void ServersModel::removeServerAtIndex(const QModelIndex& index) { removeServerAtRow(index.row()); } void ServersModel::removeServerAtRow(int row) { const bool current = (mServers.at(static_cast(row)).name == mCurrentServer); beginRemoveRows(QModelIndex(), row, row); mServers.erase(mServers.begin() + row); endRemoveRows(); if (current) { if (mServers.empty()) { mCurrentServer.clear(); } else { mCurrentServer = mServers.front().name; const QModelIndex modelIndex(index(0, 0)); emit dataChanged(modelIndex, modelIndex); } } } int ServersModel::serverRow(const QString& name) const { for (size_t i = 0, max = mServers.size(); i < max; ++i) { if (mServers[i].name == name) { return static_cast(i); } } return -1; } } tremotesf-2.8.2/src/ui/screens/connectionsettings/serversmodel.h000066400000000000000000000041101500171105600251740ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_SERVERSMODEL_H #define TREMOTESF_SERVERSMODEL_H #include #include #include "rpc/servers.h" namespace tremotesf { class ServersModel final : public QAbstractListModel { Q_OBJECT public: explicit ServersModel(QObject* parent = nullptr); QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; Qt::ItemFlags flags(const QModelIndex& index) const override; int rowCount(const QModelIndex& = {}) const override; bool setData(const QModelIndex& modelIndex, const QVariant& value, int role = Qt::EditRole) override; const std::vector& servers() const; const QString& currentServerName() const; bool hasServer(const QString& name) const; void setServer( const QString& oldName, const QString& name, const QString& address, int port, const QString& apiPath, ConnectionConfiguration::ProxyType proxyType, const QString& proxyHostname, int proxyPort, const QString& proxyUser, const QString& proxyPassword, bool https, bool selfSignedCertificateEnabled, const QByteArray& selfSignedCertificate, bool clientCertificateEnabled, const QByteArray& clientCertificate, bool authentication, const QString& username, const QString& password, int updateInterval, int timeout, bool autoReconnectEnabled, int autoReconnectInterval, const std::vector& mountedDirectories ); void removeServerAtIndex(const QModelIndex& index); void removeServerAtRow(int row); private: int serverRow(const QString& name) const; std::vector mServers; QString mCurrentServer; }; } #endif // TREMOTESF_SERVERSMODEL_H tremotesf-2.8.2/src/ui/screens/mainwindow/000077500000000000000000000000001500171105600205515ustar00rootroot00000000000000tremotesf-2.8.2/src/ui/screens/mainwindow/alltrackersmodel.cpp000066400000000000000000000102351500171105600246060ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "alltrackersmodel.h" #include #include #include #include "rpc/torrent.h" #include "rpc/tracker.h" #include "rpc/rpc.h" #include "ui/itemmodels/modelutils.h" #include "torrentsproxymodel.h" #include "desktoputils.h" namespace tremotesf { QVariant AllTrackersModel::data(const QModelIndex& index, int role) const { if (!index.isValid()) { return {}; } const TrackerItem& item = mTrackers.at(static_cast(index.row())); switch (role) { case TrackerRole: return item.tracker; case Qt::DecorationRole: { if (item.tracker.isEmpty()) { return desktoputils::standardDirIcon(); } static const auto networkServerIcon = QIcon::fromTheme("network-server"_l1); return networkServerIcon; } case Qt::DisplayRole: case Qt::ToolTipRole: if (item.tracker.isEmpty()) { //: Filter option of torrents list's tracker filter. %L1 is total number of torrents return qApp->translate("tremotesf", "All (%L1)").arg(item.torrents); } //: Filter option of torrents list's tracker filter. %1 is tracker domain name, %L2 is number of torrents with that tracker return qApp->translate("tremotesf", "%1 (%L2)").arg(item.tracker).arg(item.torrents); default: return {}; } } int AllTrackersModel::rowCount(const QModelIndex&) const { return static_cast(mTrackers.size()); } QModelIndex AllTrackersModel::indexForTorrentsProxyModelFilter() const { if (!torrentsProxyModel()) { return {}; } const auto filter = torrentsProxyModel()->trackerFilter(); for (size_t i = 0, max = mTrackers.size(); i < max; ++i) { const auto& item = mTrackers[i]; if (item.tracker == filter) { return index(static_cast(i)); } } return {}; } void AllTrackersModel::resetTorrentsProxyModelFilter() const { if (torrentsProxyModel()) { torrentsProxyModel()->setTrackerFilter({}); } } class AllTrackersModelUpdater : public ModelListUpdater> { public: inline explicit AllTrackersModelUpdater(AllTrackersModel& model) : ModelListUpdater(model) {} protected: std::map::iterator findNewItemForItem(std::map& newTrackers, const AllTrackersModel::TrackerItem& tracker) override { return newTrackers.find(tracker.tracker); } // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) bool updateItem(AllTrackersModel::TrackerItem& tracker, std::pair&& newTracker) override { const auto& [site, torrents] = newTracker; if (tracker.torrents != torrents) { tracker.torrents = torrents; return true; } return false; } // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) AllTrackersModel::TrackerItem createItemFromNewItem(std::pair&& newTracker) override { return AllTrackersModel::TrackerItem{.tracker = newTracker.first, .torrents = newTracker.second}; } }; void AllTrackersModel::update() { std::map trackers; trackers.emplace(QString(), rpc()->torrentsCount()); for (const auto& torrent : rpc()->torrents()) { for (const Tracker& tracker : torrent->data().trackers) { const QString& site = tracker.site(); auto found = trackers.find(site); if (found == trackers.end()) { trackers.emplace(site, 1); } else { ++(found->second); } } } AllTrackersModelUpdater updater(*this); updater.update(mTrackers, std::move(trackers)); } } tremotesf-2.8.2/src/ui/screens/mainwindow/alltrackersmodel.h000066400000000000000000000020741500171105600242550ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_ALLTRACKERSMODEL_H #define TREMOTESF_ALLTRACKERSMODEL_H #include #include "basetorrentsfilterssettingsmodel.h" namespace tremotesf { class AllTrackersModel final : public BaseTorrentsFiltersSettingsModel { Q_OBJECT public: static constexpr auto TrackerRole = Qt::UserRole; inline explicit AllTrackersModel(QObject* parent = nullptr) : BaseTorrentsFiltersSettingsModel(parent) {}; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; int rowCount(const QModelIndex& = {}) const override; QModelIndex indexForTorrentsProxyModelFilter() const override; struct TrackerItem { QString tracker; int torrents; }; protected: void resetTorrentsProxyModelFilter() const override; private: void update() override; std::vector mTrackers; }; } #endif // TREMOTESF_ALLTRACKERSMODEL_H tremotesf-2.8.2/src/ui/screens/mainwindow/basetorrentsfilterssettingsmodel.cpp000066400000000000000000000031351500171105600301650ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "basetorrentsfilterssettingsmodel.h" #include #include "rpc/rpc.h" namespace tremotesf { Rpc* BaseTorrentsFiltersSettingsModel::rpc() const { return mRpc; } void BaseTorrentsFiltersSettingsModel::setRpc(Rpc* rpc) { if (rpc != mRpc.data()) { if (const auto oldRpc = mRpc.data(); oldRpc) { QObject::disconnect(oldRpc, nullptr, this, nullptr); } mRpc = rpc; if (rpc) { QObject::connect(rpc, &Rpc::torrentsUpdated, this, &BaseTorrentsFiltersSettingsModel::updateImpl); QTimer::singleShot(0, this, &BaseTorrentsFiltersSettingsModel::updateImpl); } } } TorrentsProxyModel* BaseTorrentsFiltersSettingsModel::torrentsProxyModel() const { return mTorrentsProxyModel; } void BaseTorrentsFiltersSettingsModel::setTorrentsProxyModel(TorrentsProxyModel* model) { mTorrentsProxyModel = model; } bool BaseTorrentsFiltersSettingsModel::isPopulated() const { return mPopulated; } void BaseTorrentsFiltersSettingsModel::updateImpl() { bool populatedChanged = false; if (mPopulated != mRpc->isConnected()) { mPopulated = mRpc->isConnected(); populatedChanged = true; } update(); if (mPopulated && !indexForTorrentsProxyModelFilter().isValid()) { resetTorrentsProxyModelFilter(); } if (populatedChanged) { emit this->populatedChanged(); } } } tremotesf-2.8.2/src/ui/screens/mainwindow/basetorrentsfilterssettingsmodel.h000066400000000000000000000026601500171105600276340ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_BASETORRENTSFILTERSSETTINGSMODEL_H #define TREMOTESF_BASETORRENTSFILTERSSETTINGSMODEL_H #include #include namespace tremotesf { class Rpc; class TorrentsProxyModel; class BaseTorrentsFiltersSettingsModel : public QAbstractListModel { Q_OBJECT public: inline explicit BaseTorrentsFiltersSettingsModel(QObject* parent = nullptr) : QAbstractListModel(parent){}; Rpc* rpc() const; void setRpc(Rpc* rpc); TorrentsProxyModel* torrentsProxyModel() const; void setTorrentsProxyModel(TorrentsProxyModel* model); bool isPopulated() const; virtual QModelIndex indexForTorrentsProxyModelFilter() const = 0; // Needed for ModelListUpdater using QAbstractItemModel::beginInsertRows; using QAbstractItemModel::beginRemoveRows; using QAbstractItemModel::endInsertRows; using QAbstractItemModel::endRemoveRows; protected: virtual void update() = 0; virtual void resetTorrentsProxyModelFilter() const = 0; private: void updateImpl(); QPointer mRpc{}; TorrentsProxyModel* mTorrentsProxyModel{}; bool mPopulated = false; signals: void populatedChanged(); }; } #endif // TREMOTESF_BASETORRENTSFILTERSSETTINGSMODEL_H tremotesf-2.8.2/src/ui/screens/mainwindow/downloaddirectoriesmodel.cpp000066400000000000000000000132161500171105600263450ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "downloaddirectoriesmodel.h" #include #include #include #include "rpc/pathutils.h" #include "rpc/rpc.h" #include "rpc/torrent.h" #include "rpc/serversettings.h" #include "ui/itemmodels/modelutils.h" #include "ui/screens/mainwindow/downloaddirectoriesmodel.h" #include "desktoputils.h" #include "settings.h" #include "torrentsproxymodel.h" namespace tremotesf { DownloadDirectoriesModel::DownloadDirectoriesModel(QObject* parent) : BaseTorrentsFiltersSettingsModel(parent) { const auto settings = Settings::instance(); mDisplayFullDownloadDirectoryPath = settings->get_displayFullDownloadDirectoryPath(); QObject::connect(settings, &Settings::displayFullDownloadDirectoryPathChanged, this, [this, settings] { mDisplayFullDownloadDirectoryPath = settings->get_displayFullDownloadDirectoryPath(); }); } QVariant DownloadDirectoriesModel::data(const QModelIndex& index, int role) const { if (!index.isValid()) { return {}; } const DirectoryItem& item = mDirectories.at(static_cast(index.row())); switch (role) { case static_cast(Role::Directory): return item.directory; case Qt::DecorationRole: { return desktoputils::standardDirIcon(); } case Qt::DisplayRole: { if (item.directory.isEmpty()) { //: Filter option of torrents list's download directory filter. %L1 is total number of torrents return qApp->translate("tremotesf", "All (%L1)").arg(item.torrents); } //: Filter option of torrents list's download directory filter. %1 is download directory, %L2 is number of torrents with that download directory const auto& text = mDisplayFullDownloadDirectoryPath ? item.displayDirectory : lastPathSegment(item.directory); return qApp->translate("tremotesf", "%1 (%L2)").arg(text).arg(item.torrents); } case Qt::ToolTipRole: if (item.directory.isEmpty()) { return data(index, Qt::DisplayRole); } return item.displayDirectory; case static_cast(Role::AlwaysShowTooltip): return !mDisplayFullDownloadDirectoryPath && !item.directory.isEmpty(); default: return {}; } } int DownloadDirectoriesModel::rowCount(const QModelIndex&) const { return static_cast(mDirectories.size()); } QModelIndex DownloadDirectoriesModel::indexForTorrentsProxyModelFilter() const { if (!torrentsProxyModel()) { return {}; } const auto filter = torrentsProxyModel()->downloadDirectoryFilter(); for (size_t i = 0, max = mDirectories.size(); i < max; ++i) { const auto& item = mDirectories[i]; if (item.directory == filter) { return index(static_cast(i)); } } return {}; } void DownloadDirectoriesModel::resetTorrentsProxyModelFilter() const { if (torrentsProxyModel()) { torrentsProxyModel()->setDownloadDirectoryFilter({}); } } class DownloadDirectoriesModelUpdater final : public ModelListUpdater< DownloadDirectoriesModel, DownloadDirectoriesModel::DirectoryItem, std::vector> { public: inline explicit DownloadDirectoriesModelUpdater(DownloadDirectoriesModel& model) : ModelListUpdater(model) {} protected: std::vector::iterator findNewItemForItem( std::vector& newItems, const DownloadDirectoriesModel::DirectoryItem& item ) override { return std::ranges::find(newItems, item.directory, &DownloadDirectoriesModel::DirectoryItem::directory); } bool updateItem( DownloadDirectoriesModel::DirectoryItem& item, // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) DownloadDirectoriesModel::DirectoryItem&& newItem ) override { if (item.torrents != newItem.torrents) { item.torrents = newItem.torrents; return true; } return false; } DownloadDirectoriesModel::DirectoryItem createItemFromNewItem(DownloadDirectoriesModel::DirectoryItem&& newItem ) override { return DownloadDirectoriesModel::DirectoryItem{std::move(newItem)}; } }; void DownloadDirectoriesModel::update() { std::vector directories; directories.push_back({.torrents = rpc()->torrentsCount()}); for (const auto& torrent : rpc()->torrents()) { const QString& directory = torrent->data().downloadDirectory; auto found = std::ranges::find(directories, directory, &DirectoryItem::directory); if (found == directories.end()) { directories.push_back( {.directory = directory, .displayDirectory = toNativeSeparators(directory, rpc()->serverSettings()->data().pathOs), .torrents = 1} ); } else { ++(found->torrents); } } DownloadDirectoriesModelUpdater updater(*this); updater.update(mDirectories, std::move(directories)); } } tremotesf-2.8.2/src/ui/screens/mainwindow/downloaddirectoriesmodel.h000066400000000000000000000022471500171105600260140ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_DOWNLOADDIRECTORIESMODEL_H #define TREMOTESF_DOWNLOADDIRECTORIESMODEL_H #include #include "basetorrentsfilterssettingsmodel.h" namespace tremotesf { class DownloadDirectoriesModel final : public BaseTorrentsFiltersSettingsModel { Q_OBJECT public: enum class Role { Directory = Qt::UserRole, AlwaysShowTooltip }; explicit DownloadDirectoriesModel(QObject* parent = nullptr); QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; int rowCount(const QModelIndex& = {}) const override; QModelIndex indexForTorrentsProxyModelFilter() const override; struct DirectoryItem { QString directory{}; QString displayDirectory{}; int torrents{}; }; protected: void resetTorrentsProxyModelFilter() const override; private: void update() override; std::vector mDirectories{}; bool mDisplayFullDownloadDirectoryPath{}; }; } #endif // TREMOTESF_DOWNLOADDIRECTORIESMODEL_H tremotesf-2.8.2/src/ui/screens/mainwindow/editlabelsdialog.cpp000066400000000000000000000036771500171105600245620ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2025 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include "editlabelsdialog.h" #include "rpc/rpc.h" #include "rpc/torrent.h" #include "stdutils.h" #include "ui/widgets/editlabelswidget.h" namespace tremotesf { EditLabelsDialog::EditLabelsDialog(const std::vector selectedTorrents, Rpc* rpc, QWidget* parent) : QDialog(parent) { setWindowTitle(qApp->translate("tremotesf", "Edit Labels")); auto layout = new QVBoxLayout(this); auto enabledLabels = selectedTorrents.at(0)->data().labels; if (!enabledLabels.empty() && selectedTorrents.size() > 1) { for (Torrent* torrent : std::views::drop(selectedTorrents, 1)) { if (torrent->data().labels != enabledLabels) { enabledLabels.clear(); break; } } } auto editLabelsWidget = new EditLabelsWidget(enabledLabels, rpc, this); layout->addWidget(editLabelsWidget); auto torrentIds = toContainer(selectedTorrents | std::views::transform([](Torrent* t) { return t->data().id; })); const auto saveLabels = [=, torrentIds = std::move(torrentIds)] { rpc->setTorrentsLabels(torrentIds, editLabelsWidget->enabledLabels()); }; auto dialogButtonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); layout->addWidget(dialogButtonBox); QObject::connect(dialogButtonBox, &QDialogButtonBox::accepted, this, [=, this] { if (!editLabelsWidget->comboBoxHasFocus()) { saveLabels(); accept(); } }); QObject::connect(dialogButtonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); editLabelsWidget->setFocusOnComboBox(); } } tremotesf-2.8.2/src/ui/screens/mainwindow/editlabelsdialog.h000066400000000000000000000007671500171105600242240ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2025 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_EDITLABELSDIALOG_H #define TREMOTESF_EDITLABELSDIALOG_H #include #include namespace tremotesf { class Rpc; class Torrent; class EditLabelsDialog : public QDialog { Q_OBJECT public: explicit EditLabelsDialog(const std::vector selectedTorrents, Rpc* rpc, QWidget* parent); }; } #endif // TREMOTESF_EDITLABELSDIALOG_H tremotesf-2.8.2/src/ui/screens/mainwindow/labelsmodel.cpp000066400000000000000000000101561500171105600235430ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "labelsmodel.h" #include #include #include #include "rpc/rpc.h" #include "rpc/serversettings.h" #include "rpc/torrent.h" #include "ui/itemmodels/modelutils.h" #include "torrentsproxymodel.h" #include "desktoputils.h" namespace tremotesf { QVariant LabelsModel::data(const QModelIndex& index, int role) const { if (!index.isValid()) { return {}; } const LabelItem& item = mLabels.at(static_cast(index.row())); switch (role) { case LabelRole: return item.label; case Qt::DecorationRole: { if (item.label.isEmpty()) { return desktoputils::standardDirIcon(); } static const auto tagIcon = QIcon::fromTheme("tag"_l1); return tagIcon; } case Qt::DisplayRole: case Qt::ToolTipRole: if (item.label.isEmpty()) { //: Filter option of torrents list's label filter. %L1 is total number of torrents return qApp->translate("tremotesf", "All (%L1)").arg(item.torrents); } //: Filter option of torrents list's label filter. %1 is label, %L2 is number of torrents with that label return qApp->translate("tremotesf", "%1 (%L2)").arg(item.label).arg(item.torrents); default: return {}; } } int LabelsModel::rowCount(const QModelIndex&) const { return static_cast(mLabels.size()); } QModelIndex LabelsModel::indexForTorrentsProxyModelFilter() const { if (!torrentsProxyModel()) { return {}; } const auto filter = torrentsProxyModel()->labelFilter(); for (size_t i = 0, max = mLabels.size(); i < max; ++i) { const auto& item = mLabels[i]; if (item.label == filter) { return index(static_cast(i)); } } return {}; } void LabelsModel::resetTorrentsProxyModelFilter() const { if (torrentsProxyModel()) { torrentsProxyModel()->setLabelFilter({}); } } class LabelsModelUpdater : public ModelListUpdater> { public: inline explicit LabelsModelUpdater(LabelsModel& model) : ModelListUpdater(model) {} protected: std::map::iterator findNewItemForItem(std::map& newLabels, const LabelsModel::LabelItem& item) override { return newLabels.find(item.label); } // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) bool updateItem(LabelsModel::LabelItem& item, std::pair&& newItem) override { const auto& [label, torrents] = newItem; if (item.torrents != torrents) { item.torrents = torrents; return true; } return false; } // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) LabelsModel::LabelItem createItemFromNewItem(std::pair&& newItem) override { return LabelsModel::LabelItem{.label = newItem.first, .torrents = newItem.second}; } }; void LabelsModel::update() { std::map labels{}; const bool serverSupportsLabels = rpc()->isConnected() ? rpc()->serverSettings()->data().hasLabelsProperty() : true; if (serverSupportsLabels) { labels.emplace(QString(), rpc()->torrentsCount()); for (const auto& torrent : rpc()->torrents()) { for (const QString& label : torrent->data().labels) { auto found = labels.find(label); if (found == labels.end()) { labels.emplace(label, 1); } else { ++(found->second); } } } } LabelsModelUpdater updater(*this); updater.update(mLabels, std::move(labels)); } } tremotesf-2.8.2/src/ui/screens/mainwindow/labelsmodel.h000066400000000000000000000020311500171105600232010ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_LABELSMODEL_H #define TREMOTESF_LABELSMODEL_H #include #include "basetorrentsfilterssettingsmodel.h" namespace tremotesf { class LabelsModel final : public BaseTorrentsFiltersSettingsModel { Q_OBJECT public: static constexpr auto LabelRole = Qt::UserRole; inline explicit LabelsModel(QObject* parent = nullptr) : BaseTorrentsFiltersSettingsModel(parent) {}; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; int rowCount(const QModelIndex& = {}) const override; QModelIndex indexForTorrentsProxyModelFilter() const override; struct LabelItem { QString label; int torrents; }; protected: void resetTorrentsProxyModelFilter() const override; private: void update() override; std::vector mLabels; }; } #endif // TREMOTESF_LABELSMODEL_H tremotesf-2.8.2/src/ui/screens/mainwindow/mainwindow.cpp000066400000000000000000002332721500171105600234420ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // SPDX-FileCopyrightText: 2021 LuK1337 // // SPDX-License-Identifier: GPL-3.0-or-later #include "mainwindow.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef TREMOTESF_UNIX_FREEDESKTOP # include # include # include "unixhelpers.h" #endif #include "log/log.h" #include "rpc/serverstats.h" #include "rpc/mounteddirectoriesutils.h" #include "rpc/torrent.h" #include "rpc/servers.h" #include "ui/savewindowstatedispatcher.h" #include "ui/stylehelpers.h" #include "ui/screens/aboutdialog.h" #include "ui/screens/addtorrent/addtorrentdialog.h" #include "ui/screens/addtorrent/addtorrenthelpers.h" #include "ui/screens/connectionsettings/servereditdialog.h" #include "ui/screens/connectionsettings/connectionsettingsdialog.h" #include "ui/screens/serversettings/serversettingsdialog.h" #include "ui/screens/serverstatsdialog.h" #include "ui/screens/settingsdialog.h" #include "ui/screens/torrentproperties/torrentpropertiesdialog.h" #include "ui/screens/torrentproperties/torrentpropertieswidget.h" #include "ui/widgets/listplaceholder.h" #include "ui/widgets/torrentremotedirectoryselectionwidget.h" #include "ui/widgets/torrentfilesview.h" #include "desktoputils.h" #include "editlabelsdialog.h" #include "filemanagerlauncher.h" #include "formatutils.h" #include "macoshelpers.h" #include "mainwindowsidebar.h" #include "mainwindowstatusbar.h" #include "mainwindowviewmodel.h" #include "settings.h" #include "stdutils.h" #include "target_os.h" #include "torrentsmodel.h" #include "torrentsproxymodel.h" #include "torrentsview.h" SPECIALIZE_FORMATTER_FOR_QDEBUG(QRect) namespace tremotesf { namespace { class SetLocationDialog final : public QDialog { Q_OBJECT public: explicit SetLocationDialog(const QString& downloadDirectory, Rpc* rpc, QWidget* parent = nullptr) : QDialog(parent), mDirectoryWidget(new TorrentDownloadDirectoryDirectorySelectionWidget(this)), mMoveFilesCheckBox( //: Check box label new QCheckBox(qApp->translate("tremotesf", "Move files from current directory"), this) ) { //: Dialog title for changing torrent's download directory setWindowTitle(qApp->translate("tremotesf", "Set Location")); auto layout = new QVBoxLayout(this); layout->setSizeConstraint(QLayout::SetMinAndMaxSize); auto label = new QLabel(qApp->translate("tremotesf", "Download directory:"), this); label->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); layout->addWidget(label); mDirectoryWidget->setup(downloadDirectory, rpc); mDirectoryWidget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); layout->addWidget(mDirectoryWidget); mMoveFilesCheckBox->setChecked(true); layout->addWidget(mMoveFilesCheckBox); auto dialogButtonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); QObject::connect(dialogButtonBox, &QDialogButtonBox::accepted, this, [this] { mDirectoryWidget->saveDirectories(); accept(); }); QObject::connect(dialogButtonBox, &QDialogButtonBox::rejected, this, &SetLocationDialog::reject); QObject::connect( mDirectoryWidget, &RemoteDirectorySelectionWidget::pathChanged, this, [dialogButtonBox, this] { dialogButtonBox->button(QDialogButtonBox::Ok)->setEnabled(!mDirectoryWidget->path().isEmpty()); } ); layout->addWidget(dialogButtonBox); QObject::connect(rpc, &Rpc::connectedChanged, this, [rpc, this] { if (!rpc->isConnected()) { reject(); } }); resize(sizeHint().expandedTo(QSize(320, 0))); } [[nodiscard]] QString downloadDirectory() const { return mDirectoryWidget->path(); } [[nodiscard]] bool moveFiles() const { return mMoveFilesCheckBox->isChecked(); } private: TorrentDownloadDirectoryDirectorySelectionWidget* const mDirectoryWidget; QCheckBox* const mMoveFilesCheckBox; }; #ifndef Q_OS_MACOS bool isAllowedToHide(const QWidget* window) { static constexpr std::array classNames{// Managed by QFileDialog "KDEPlatformFileDialog"_l1, "KDirSelectDialog"_l1, // Managed by QSystemTrayIcon "QSystemTrayIconSys"_l1 }; auto* const metaObject = window->metaObject(); return metaObject && std::ranges::find(classNames, QLatin1String(metaObject->className())) == classNames.end(); } [[nodiscard]] std::vector> toQPointers(const QWidgetList& widgets) { return {widgets.begin(), widgets.end()}; } #endif void unminimizeAndRaiseWindow(QWidget* window) { if (window->isMinimized()) { info().log("Unminimizing window {}", *window); window->setWindowState(window->windowState().setFlag(Qt::WindowMinimized, false)); } info().log("Raising window {}", *window); window->raise(); } #ifdef TREMOTESF_UNIX_FREEDESKTOP void activeWindowOnX11(QWidget* window, const std::optional& startupNotificationId) { if (startupNotificationId.has_value()) { info().log("Removing startup notification with id {}", *startupNotificationId); KStartupInfo::setNewStartupId(window->windowHandle(), *startupNotificationId); KStartupInfo::appStarted(*startupNotificationId); } window->activateWindow(); } void activeWindowOnWayland( [[maybe_unused]] QWidget* window, [[maybe_unused]] const std::optional& xdgActivationToken ) { # if QT_VERSION_MAJOR >= 6 if (xdgActivationToken.has_value()) { info().log("Activating window with token {}", *xdgActivationToken); // Qt gets new token from XDG_ACTIVATION_TOKEN environment variable // It we be read and unset in QWidget::activateWindow() call below qputenv(xdgActivationTokenEnvVariable, *xdgActivationToken); } window->activateWindow(); # else if (xdgActivationToken.has_value()) { info().log("Activating window with token {}", *xdgActivationToken); KWindowSystem::setCurrentXdgActivationToken(*xdgActivationToken); } if (const auto handle = window->windowHandle(); handle) { KWindowSystem::activateWindow(handle); } else { warning().log("This window's QWidget::windowHandle() is null"); } # endif } #endif void activateWindowCompat( QWidget* window, [[maybe_unused]] const std::optional& windowActivationToken = {} ) { info().log("Activating window {}", *window); #ifdef TREMOTESF_UNIX_FREEDESKTOP switch (KWindowSystem::platform()) { case KWindowSystem::Platform::X11: debug().log("Windowing system is X11"); activeWindowOnX11(window, windowActivationToken); break; case KWindowSystem::Platform::Wayland: debug().log("Windowing system is Wayland"); activeWindowOnWayland(window, windowActivationToken); break; default: warning().log("Unknown windowing system"); window->activateWindow(); break; } #else window->activateWindow(); #endif } } class MainWindow::Impl : public QObject { Q_OBJECT public: explicit Impl(QStringList&& commandLineFiles, QStringList&& commandLineUrls, MainWindow* window) : mWindow(window), mViewModel{std::move(commandLineFiles), std::move(commandLineUrls)} { mHorizontalSplitter.setChildrenCollapsible(false); if (!Settings::instance()->get_sideBarVisible()) { mSideBar.hide(); } mHorizontalSplitter.addWidget(&mSideBar); mHorizontalSplitter.addWidget(&mVerticalSplitter); mHorizontalSplitter.setStretchFactor(1, 1); mVerticalSplitter.setChildrenCollapsible(false); mVerticalSplitter.setOrientation(Qt::Vertical); mVerticalSplitter.addWidget(&mTorrentsView); mVerticalSplitter.setStretchFactor(0, 1); QObject::connect(&mTorrentsView, &TorrentsView::customContextMenuRequested, this, [this](QPoint pos) { if (mTorrentsView.indexAt(pos).isValid()) { mTorrentMenu->popup(mTorrentsView.viewport()->mapToGlobal(pos)); } }); QObject::connect( &mTorrentsView, &TorrentsView::activated, this, &MainWindow::Impl::performTorrentDoubleClickAction ); QObject::connect(mViewModel.rpc(), &Rpc::connectedChanged, this, [this] { if (mViewModel.rpc()->isConnected() && mTorrentsProxyModel.rowCount() > 0) { mTorrentsView.setCurrentIndex(mTorrentsProxyModel.index(0, 0)); } }); setupTorrentsPlaceholder(); setupTorrentPropertiesWidget(); mHorizontalSplitter.restoreState(Settings::instance()->get_horizontalSplitterState()); mVerticalSplitter.restoreState(Settings::instance()->get_verticalSplitterState()); mWindow->setCentralWidget(&mHorizontalSplitter); auto* const statusBar = new MainWindowStatusBar(mViewModel.rpc()); mWindow->setStatusBar(statusBar); if (!Settings::instance()->get_statusBarVisible()) { statusBar->hide(); } QObject::connect(statusBar, &MainWindowStatusBar::showConnectionSettingsDialog, this, [this] { showSingleInstanceDialog([this] { return new ConnectionSettingsDialog(mWindow); }); }); setupActions(); updateTorrentActions(); QObject::connect(mViewModel.rpc(), &Rpc::torrentsUpdated, this, &MainWindow::Impl::updateTorrentActions); QObject::connect( mTorrentsView.selectionModel(), &QItemSelectionModel::selectionChanged, this, &MainWindow::Impl::updateTorrentActions ); setupMenuBar(); setupToolBar(); setupTrayIcon(); mViewModel.setupNotificationsController(&mTrayIcon); updateRpcActions(); QObject::connect(mViewModel.rpc(), &Rpc::connectionStateChanged, this, &MainWindow::Impl::updateRpcActions); QObject::connect( Servers::instance(), &Servers::hasServersChanged, this, &MainWindow::Impl::updateRpcActions ); mWindow->restoreState(Settings::instance()->get_mainWindowState()); mToolBarAction->setChecked(!mToolBar.isHidden()); QObject::connect( &mViewModel, &MainWindowViewModel::showWindow, this, &MainWindow::Impl::showWindowsOrActivateMainWindow ); QObject::connect( &mViewModel, &MainWindowViewModel::showAddTorrentDialogs, this, &MainWindow::Impl::showAddTorrentDialogs ); QObject::connect( &mViewModel, &MainWindowViewModel::askForMergingTrackers, this, &MainWindow::Impl::askForMergingTrackers ); QObject::connect( &mViewModel, &MainWindowViewModel::showDelayedTorrentAddDialog, this, &MainWindow::Impl::showDelayedTorrentAddDialog ); showAddTorrentErrors(); auto pasteShortcut = new QShortcut(QKeySequence::Paste, mWindow); QObject::connect( pasteShortcut, &QShortcut::activated, &mViewModel, &MainWindowViewModel::pasteShortcutActivated ); if (mViewModel.performStartupAction() == MainWindowViewModel::StartupActionResult::ShowAddServerDialog) { QMetaObject::invokeMethod( this, [this] { auto* const dialog = new ServerEditDialog(nullptr, -1, mWindow); dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->show(); }, Qt::QueuedConnection ); } QObject::connect(qApp, &QCoreApplication::aboutToQuit, this, [this] { mViewModel.rpc()->disconnect(); mTrayIcon.hide(); }); if constexpr (targetOs == TargetOs::UnixMacOS) { QObject::connect( qApp, &QGuiApplication::applicationStateChanged, this, &MainWindow::Impl::updateShowHideAction ); } info().log("Restoring main window geometry"); if (!mWindow->restoreGeometry(Settings::instance()->get_mainWindowGeometry())) { info().log("Did not restore geometry"); mWindow->resize(mWindow->sizeHint().expandedTo(QSize(896, 640))); } else { info().log("Restored geometry {}", mWindow->geometry()); } } Q_DISABLE_COPY_MOVE(Impl) void updateShowHideAction() { QObject::disconnect(&mShowHideAppAction, &QAction::triggered, nullptr, nullptr); if (isMainWindowHiddenOrMinimized()) { mShowHideAppAction.setText(qApp->translate("tremotesf", "&Show Tremotesf")); QObject::connect(&mShowHideAppAction, &QAction::triggered, this, [this] { showWindowsOrActivateMainWindow(); }); } else { mShowHideAppAction.setText(qApp->translate("tremotesf", "&Hide Tremotesf")); QObject::connect(&mShowHideAppAction, &QAction::triggered, this, [this] { hideWindows(); }); } } /** * @return true if event should be ignored, false otherwise */ bool onCloseEvent() { #if QT_VERSION_MAJOR >= 6 if (mAppQuitEventFilter.isQuittingApplication) { debug().log("Received close event on main window while quitting app, just close window"); return false; } #endif // Do stuff at the next event loop iteration since we are in the middle of event handling if (mTrayIcon.isVisible() && QSystemTrayIcon::isSystemTrayAvailable()) { info().log("Closed main window but tray icon is active, hide windows without quitting app"); QMetaObject::invokeMethod(this, &MainWindow::Impl::hideWindows, Qt::QueuedConnection); return true; } info().log("Closed main window when tray icon is not active, quitting app"); QMetaObject::invokeMethod(qApp, &QCoreApplication::quit, Qt::QueuedConnection); return false; } void onDragEnterEvent(QDragEnterEvent* event) { MainWindowViewModel::processDragEnterEvent(event); } void onDropEvent(QDropEvent* event) { mViewModel.processDropEvent(event); } void saveState() { debug().log("Saving MainWindow state, window geometry is {}", mWindow->geometry()); Settings::instance()->set_mainWindowGeometry(mWindow->saveGeometry()); Settings::instance()->set_mainWindowState(mWindow->saveState()); Settings::instance()->set_horizontalSplitterState(mHorizontalSplitter.saveState()); Settings::instance()->set_verticalSplitterState(mVerticalSplitter.saveState()); mTorrentsView.saveState(); if (mTorrentPropertiesWidget) { mTorrentPropertiesWidget->saveState(); } } #if defined(TREMOTESF_UNIX_FREEDESKTOP) void activateMainWindowOnWayland() { if (KWindowSystem::isPlatformWayland()) { activeWindowOnWayland(mWindow, {}); } } #endif private: MainWindow* mWindow; MainWindowViewModel mViewModel; QSplitter mHorizontalSplitter{}; QSplitter mVerticalSplitter{}; TorrentsModel mTorrentsModel{mViewModel.rpc()}; TorrentsProxyModel mTorrentsProxyModel{&mTorrentsModel}; TorrentsView mTorrentsView{&mTorrentsProxyModel}; TorrentPropertiesWidget* mTorrentPropertiesWidget{}; MainWindowSideBar mSideBar{mViewModel.rpc(), &mTorrentsProxyModel}; std::unordered_map mTorrentPropertiesDialogs{}; QAction mShowHideAppAction{}; //: Button / menu item to connect to server QAction mConnectAction{qApp->translate("tremotesf", "&Connect")}; //: Button / menu item to disconnect from server QAction mDisconnectAction{qApp->translate("tremotesf", "&Disconnect")}; QAction mAddTorrentFileAction{ QIcon::fromTheme("list-add"_l1), //: Menu item qApp->translate("tremotesf", "&Add Torrent File...") }; QAction mAddTorrentLinkAction{ QIcon::fromTheme("insert-link"_l1), //: Menu item qApp->translate("tremotesf", "Add Torrent &Link..."), }; QMenu* mTorrentMenu{}; QAction* mStartTorrentAction{}; QAction* mStartTorrentNowAction{}; QAction* mPauseTorrentAction{}; QAction* mRemoveTorrentAction{}; QAction* mRenameTorrentAction{}; QAction* mOpenTorrentFilesAction{}; QAction* mOpenTorrentDownloadDirectoryAction{}; QAction* mHighPriorityAction{}; QAction* mNormalPriorityAction{}; QAction* mLowPriorityAction{}; std::vector mConnectionDependentActions; QMenu* mFileMenu{}; QToolBar mToolBar{}; QAction* mToolBarAction{}; QSystemTrayIcon mTrayIcon{QIcon::fromTheme("tremotesf-tray-icon"_l1, mWindow->windowIcon())}; #ifndef Q_OS_MACOS std::vector> mOtherWindowsHiddenByUs; #endif SaveWindowStateHandler mSaveStateHandler{mWindow, [this] { saveState(); }}; #if QT_VERSION_MAJOR >= 6 ApplicationQuitEventFilter mAppQuitEventFilter{}; #endif void setupActions() { updateShowHideAction(); QObject::connect(&mConnectAction, &QAction::triggered, mViewModel.rpc(), &Rpc::connect); QObject::connect(&mDisconnectAction, &QAction::triggered, mViewModel.rpc(), &Rpc::disconnect); const auto connectIcon = QIcon::fromTheme("network-connect"_l1); const auto disconnectIcon = QIcon::fromTheme("network-disconnect"_l1); if (connectIcon.name() != disconnectIcon.name()) { mConnectAction.setIcon(connectIcon); mDisconnectAction.setIcon(disconnectIcon); } mAddTorrentFileAction.setShortcuts(QKeySequence::Open); QObject::connect(&mAddTorrentFileAction, &QAction::triggered, this, &MainWindow::Impl::openTorrentFiles); mConnectionDependentActions.push_back(&mAddTorrentFileAction); QObject::connect(&mAddTorrentLinkAction, &QAction::triggered, this, [this] { if (Settings::instance()->get_showMainWindowWhenAddingTorrent() && isMainWindowHiddenOrMinimized()) { showWindowsOrActivateMainWindow(); } mViewModel.triggeredAddTorrentLinkAction(); }); mConnectionDependentActions.push_back(&mAddTorrentLinkAction); // // Torrent menu // //: Menu bar item mTorrentMenu = new QMenu(qApp->translate("tremotesf", "&Torrent"), mWindow); QAction* torrentPropertiesAction = mTorrentMenu->addAction( QIcon::fromTheme("document-properties"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "&Properties") ); QObject::connect( torrentPropertiesAction, &QAction::triggered, this, &MainWindow::Impl::showTorrentsPropertiesDialogs ); torrentPropertiesAction->setVisible(!Settings::instance()->get_showTorrentPropertiesInMainWindow()); QObject::connect( Settings::instance(), &Settings::showTorrentPropertiesInMainWindowChanged, this, [torrentPropertiesAction] { torrentPropertiesAction->setVisible(!Settings::instance()->get_showTorrentPropertiesInMainWindow()); } ); mTorrentMenu->addSeparator(); mStartTorrentAction = mTorrentMenu->addAction( QIcon::fromTheme("media-playback-start"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "&Start") ); QObject::connect(mStartTorrentAction, &QAction::triggered, this, [this] { mViewModel.rpc()->startTorrents(mTorrentsModel.idsFromIndexes( mTorrentsProxyModel.sourceIndexes(mTorrentsView.selectionModel()->selectedRows()) )); }); mStartTorrentNowAction = mTorrentMenu->addAction( QIcon::fromTheme("media-playback-start"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "Start &Now") ); QObject::connect(mStartTorrentNowAction, &QAction::triggered, this, [this] { mViewModel.rpc()->startTorrentsNow(mTorrentsModel.idsFromIndexes( mTorrentsProxyModel.sourceIndexes(mTorrentsView.selectionModel()->selectedRows()) )); }); mPauseTorrentAction = mTorrentMenu->addAction( QIcon::fromTheme("media-playback-pause"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "P&ause") ); QObject::connect(mPauseTorrentAction, &QAction::triggered, this, [this] { mViewModel.rpc()->pauseTorrents(mTorrentsModel.idsFromIndexes( mTorrentsProxyModel.sourceIndexes(mTorrentsView.selectionModel()->selectedRows()) )); }); mTorrentMenu->addSeparator(); QAction* copyMagnetLinkAction = mTorrentMenu->addAction( QIcon::fromTheme("edit-copy"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "Copy &Magnet Link") ); QObject::connect(copyMagnetLinkAction, &QAction::triggered, this, [this] { const auto indexes(mTorrentsProxyModel.sourceIndexes(mTorrentsView.selectionModel()->selectedRows())); QStringList links; links.reserve(indexes.size()); for (const auto& index : indexes) { links.push_back(mTorrentsModel.torrentAtIndex(index)->data().magnetLink); } qApp->clipboard()->setText(links.join('\n')); }); mTorrentMenu->addSeparator(); mRemoveTorrentAction = mTorrentMenu->addAction( QIcon::fromTheme("edit-delete"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "&Delete") ); mRemoveTorrentAction->setShortcut(QKeySequence::Delete); QObject::connect(mRemoveTorrentAction, &QAction::triggered, this, [this] { removeSelectedTorrents(false); }); const auto removeTorrentWithFilesShortcut = new QShortcut( #if QT_VERSION_MAJOR >= 6 QKeyCombination(Qt::ShiftModifier, Qt::Key_Delete), #else QKeySequence(static_cast(Qt::ShiftModifier) | static_cast(Qt::Key_Delete)), #endif mWindow ); QObject::connect(removeTorrentWithFilesShortcut, &QShortcut::activated, this, [this] { removeSelectedTorrents(true); }); QAction* setLocationAction = mTorrentMenu->addAction( QIcon::fromTheme("mark-location"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "Set &Location") ); QObject::connect(setLocationAction, &QAction::triggered, this, [this] { if (mTorrentsView.selectionModel()->hasSelection()) { QModelIndexList indexes( mTorrentsProxyModel.sourceIndexes(mTorrentsView.selectionModel()->selectedRows()) ); auto dialog = new SetLocationDialog( mTorrentsModel.torrentAtIndex(indexes.first())->data().downloadDirectory, mViewModel.rpc(), mWindow ); dialog->setAttribute(Qt::WA_DeleteOnClose); QObject::connect(dialog, &SetLocationDialog::accepted, this, [indexes, dialog, this] { mViewModel.rpc()->setTorrentsLocation( mTorrentsModel.idsFromIndexes(indexes), dialog->downloadDirectory(), dialog->moveFiles() ); }); dialog->show(); } }); mRenameTorrentAction = mTorrentMenu->addAction( QIcon::fromTheme("edit-rename"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "&Rename") ); QObject::connect(mRenameTorrentAction, &QAction::triggered, this, [this] { const auto indexes = mTorrentsView.selectionModel()->selectedRows(); if (indexes.size() == 1) { const auto torrent = mTorrentsModel.torrentAtIndex(mTorrentsProxyModel.sourceIndex(indexes.first())); const auto id = torrent->data().id; const auto name = torrent->data().name; TorrentFilesView::showFileRenameDialog(name, mWindow, [id, name, this](const auto& newName) { mViewModel.rpc()->renameTorrentFile(id, name, newName); }); } }); QAction* editLabelsAction = mTorrentMenu->addAction( QIcon::fromTheme("tag"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "Edi&t Labels") ); QObject::connect(editLabelsAction, &QAction::triggered, this, [this] { if (mTorrentsView.selectionModel()->hasSelection()) { const auto selectedTorrents = toContainer( mTorrentsProxyModel.sourceIndexes(mTorrentsView.selectionModel()->selectedRows()) | std::views::transform([this](const QModelIndex& index) { return mTorrentsModel.torrentAtIndex(index); }) ); auto dialog = new EditLabelsDialog(selectedTorrents, mViewModel.rpc(), mWindow); dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->show(); } }); mTorrentMenu->addSeparator(); mOpenTorrentFilesAction = mTorrentMenu->addAction( QIcon::fromTheme("document-open"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "&Open") ); QObject::connect(mOpenTorrentFilesAction, &QAction::triggered, this, &MainWindow::Impl::openTorrentsFiles); mOpenTorrentDownloadDirectoryAction = mTorrentMenu->addAction( QIcon::fromTheme("go-jump"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "Op&en Download Directory") ); QObject::connect( mOpenTorrentDownloadDirectoryAction, &QAction::triggered, this, &MainWindow::Impl::showTorrentsInFileManager ); mTorrentMenu->addSeparator(); QAction* checkTorrentAction = mTorrentMenu->addAction( QIcon::fromTheme("document-preview"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "&Check Local Data") ); QObject::connect(checkTorrentAction, &QAction::triggered, this, [this] { mViewModel.rpc()->checkTorrents(mTorrentsModel.idsFromIndexes( mTorrentsProxyModel.sourceIndexes(mTorrentsView.selectionModel()->selectedRows()) )); }); QAction* reannounceAction = mTorrentMenu->addAction( QIcon::fromTheme("view-refresh"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "Reanno&unce") ); QObject::connect(reannounceAction, &QAction::triggered, this, [this] { mViewModel.rpc()->reannounceTorrents(mTorrentsModel.idsFromIndexes( mTorrentsProxyModel.sourceIndexes(mTorrentsView.selectionModel()->selectedRows()) )); }); mTorrentMenu->addSeparator(); QMenu* queueMenu = mTorrentMenu->addMenu( //: Torrent's context menu item qApp->translate("tremotesf", "&Queue") ); QAction* moveTorrentToTopAction = queueMenu->addAction( QIcon::fromTheme("go-top"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "Move To &Top") ); QObject::connect(moveTorrentToTopAction, &QAction::triggered, this, [this] { mViewModel.rpc()->moveTorrentsToTop(mTorrentsModel.idsFromIndexes( mTorrentsProxyModel.sourceIndexes(mTorrentsView.selectionModel()->selectedRows()) )); }); QAction* moveTorrentUpAction = queueMenu->addAction( QIcon::fromTheme("go-up"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "Move &Up") ); QObject::connect(moveTorrentUpAction, &QAction::triggered, this, [this] { mViewModel.rpc()->moveTorrentsUp(mTorrentsModel.idsFromIndexes( mTorrentsProxyModel.sourceIndexes(mTorrentsView.selectionModel()->selectedRows()) )); }); QAction* moveTorrentDownAction = queueMenu->addAction( QIcon::fromTheme("go-down"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "Move &Down") ); QObject::connect(moveTorrentDownAction, &QAction::triggered, this, [this] { mViewModel.rpc()->moveTorrentsDown(mTorrentsModel.idsFromIndexes( mTorrentsProxyModel.sourceIndexes(mTorrentsView.selectionModel()->selectedRows()) )); }); QAction* moveTorrentToBottomAction = queueMenu->addAction( QIcon::fromTheme("go-bottom"_l1), //: Torrent's context menu item qApp->translate("tremotesf", "Move To &Bottom") ); QObject::connect(moveTorrentToBottomAction, &QAction::triggered, this, [this] { mViewModel.rpc()->moveTorrentsToBottom(mTorrentsModel.idsFromIndexes( mTorrentsProxyModel.sourceIndexes(mTorrentsView.selectionModel()->selectedRows()) )); }); const auto priorityMenu = mTorrentMenu->addMenu( //: Torrent's context menu item qApp->translate("tremotesf", "&Priority") ); const auto priorityGroup = new QActionGroup(priorityMenu); priorityGroup->setExclusive(true); const auto setTorrentsPriority = [this](TorrentData::Priority priority) { for (const auto& index : mTorrentsProxyModel.sourceIndexes(mTorrentsView.selectionModel()->selectedRows())) { mTorrentsModel.torrentAtIndex(index)->setBandwidthPriority(priority); } }; //: Torrent's loading priority mHighPriorityAction = priorityMenu->addAction(qApp->translate("tremotesf", "High")); QObject::connect( mHighPriorityAction, &QAction::triggered, this, std::bind_front(setTorrentsPriority, TorrentData::Priority::High) ); mHighPriorityAction->setCheckable(true); priorityGroup->addAction(mHighPriorityAction); //: Torrent's loading priority mNormalPriorityAction = priorityMenu->addAction(qApp->translate("tremotesf", "Normal")); QObject::connect( mNormalPriorityAction, &QAction::triggered, this, std::bind_front(setTorrentsPriority, TorrentData::Priority::Normal) ); mNormalPriorityAction->setCheckable(true); priorityGroup->addAction(mNormalPriorityAction); //: Torrent's loading priority mLowPriorityAction = priorityMenu->addAction(qApp->translate("tremotesf", "Low")); QObject::connect( mLowPriorityAction, &QAction::triggered, this, std::bind_front(setTorrentsPriority, TorrentData::Priority::Low) ); mLowPriorityAction->setCheckable(true); priorityGroup->addAction(mLowPriorityAction); } QAction* createQuitAction() { const auto action = new QAction( QIcon::fromTheme("application-exit"_l1), //: Menu item qApp->translate("tremotesf", "&Quit"), this ); if constexpr (targetOs == TargetOs::Windows) { #if QT_VERSION_MAJOR >= 6 action->setShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_Q)); #else action->setShortcut(QKeySequence(static_cast(Qt::ControlModifier) | static_cast(Qt::Key_Q))); #endif } else { action->setShortcuts(QKeySequence::Quit); } QObject::connect(action, &QAction::triggered, this, &QCoreApplication::quit); return action; } void updateRpcActions() { if (Servers::instance()->hasServers()) { const bool disconnected = mViewModel.rpc()->connectionState() == Rpc::ConnectionState::Disconnected; mConnectAction.setEnabled(disconnected); mDisconnectAction.setEnabled(!disconnected); } else { mConnectAction.setEnabled(false); mDisconnectAction.setEnabled(false); } const bool connected = mViewModel.rpc()->isConnected(); for (auto action : mConnectionDependentActions) { action->setEnabled(connected); } } void openTorrentFiles() { auto* const settings = Settings::instance(); if (settings->get_showMainWindowWhenAddingTorrent() && isMainWindowHiddenOrMinimized()) { showWindowsOrActivateMainWindow(); } auto directory = settings->get_rememberOpenTorrentDir() ? settings->get_lastOpenTorrentDirectory() : QString{}; if (directory.isEmpty()) { directory = QDir::homePath(); } auto* const fileDialog = new QFileDialog( settings->get_showMainWindowWhenAddingTorrent() ? mWindow : nullptr, //: File chooser dialog title qApp->translate("tremotesf", "Select Files"), directory, //: Torrent file type. Parentheses and text within them must remain unchanged qApp->translate("tremotesf", "Torrent Files (*.torrent)") ); fileDialog->setAttribute(Qt::WA_DeleteOnClose); fileDialog->setFileMode(QFileDialog::ExistingFiles); QObject::connect(fileDialog, &QFileDialog::accepted, this, [fileDialog, this] { mViewModel.acceptedFileDialog(fileDialog->selectedFiles()); }); if constexpr (targetOs == TargetOs::Windows) { fileDialog->open(); } else { fileDialog->show(); } } void setupTorrentPropertiesWidget() { const auto setup = [this] { if (Settings::instance()->get_showTorrentPropertiesInMainWindow()) { useTorrentPropertiesWidget(); } else { useTorrentPropertiesDialogs(); } }; setup(); QObject::connect(Settings::instance(), &Settings::showTorrentPropertiesInMainWindowChanged, this, setup); } void useTorrentPropertiesDialogs() { if (!mTorrentPropertiesWidget) { return; } mTorrentPropertiesWidget->deleteLater(); mTorrentPropertiesWidget = nullptr; } void useTorrentPropertiesWidget() { if (mTorrentPropertiesWidget) { return; } if (!mTorrentPropertiesDialogs.empty()) { // Don't iterate over mTorrentPropertiesDialogs directly since call to reject() will modify it (through QDialog::finished slot) const auto dialogs = mTorrentPropertiesDialogs; mTorrentPropertiesDialogs.clear(); for (const auto& [hashString, dialog] : dialogs) { dialog->reject(); } } mTorrentPropertiesWidget = new TorrentPropertiesWidget(mViewModel.rpc(), true, mWindow); mVerticalSplitter.addWidget(mTorrentPropertiesWidget); const auto updateCurrentTorrent = [this] { const auto currentIndex = mTorrentsView.selectionModel()->currentIndex(); if (currentIndex.isValid()) { auto source = mTorrentsProxyModel.sourceIndex(currentIndex); mTorrentPropertiesWidget->setTorrent(mTorrentsModel.torrentAtIndex(source)); } else { mTorrentPropertiesWidget->setTorrent(nullptr); } }; updateCurrentTorrent(); QObject::connect( mTorrentsView.selectionModel(), &QItemSelectionModel::currentChanged, mTorrentPropertiesWidget, updateCurrentTorrent ); } void updateTorrentActions() { const auto actions = mTorrentMenu->actions(); if (!mTorrentsView.selectionModel()->hasSelection()) { for (QAction* action : actions) { action->setEnabled(false); } return; } for (QAction* action : actions) { action->setEnabled(true); } mHighPriorityAction->setChecked(false); mNormalPriorityAction->setChecked(false); mLowPriorityAction->setChecked(false); const QModelIndexList selectedRows = mTorrentsView.selectionModel()->selectedRows(); if (selectedRows.size() == 1) { const auto torrent = mTorrentsModel.torrentAtIndex(mTorrentsProxyModel.sourceIndex(selectedRows.first())); if (torrent->data().status == TorrentData::Status::Paused) { mPauseTorrentAction->setEnabled(false); } else { mStartTorrentAction->setEnabled(false); mStartTorrentNowAction->setEnabled(false); } switch (torrent->data().bandwidthPriority) { case TorrentData::Priority::High: mHighPriorityAction->setChecked(true); break; case TorrentData::Priority::Normal: mNormalPriorityAction->setChecked(true); break; case TorrentData::Priority::Low: mLowPriorityAction->setChecked(true); break; } } else { mRenameTorrentAction->setEnabled(false); } bool localOrMounted = true; if (!mViewModel.rpc()->isLocal()) { for (const QModelIndex& index : selectedRows) { Torrent* torrent = mTorrentsModel.torrentAtIndex(mTorrentsProxyModel.sourceIndex(index)); if (!isServerLocalOrTorrentIsMounted(mViewModel.rpc(), torrent)) { localOrMounted = false; break; } } } mOpenTorrentFilesAction->setEnabled(localOrMounted); mOpenTorrentDownloadDirectoryAction->setEnabled(localOrMounted); } void performTorrentDoubleClickAction() { switch (Settings::instance()->get_torrentDoubleClickAction()) { case Settings::TorrentDoubleClickAction::OpenPropertiesDialog: if (Settings::instance()->get_showTorrentPropertiesInMainWindow()) { warning().log("torrentDoubleClickAction is OpenPropertiesDialog, but " "showTorrentPropertiesInMainWindow is true"); } else { showTorrentsPropertiesDialogs(); } break; case Settings::TorrentDoubleClickAction::OpenTorrentFile: if (mOpenTorrentFilesAction->isEnabled()) { openTorrentsFiles(); } break; case Settings::TorrentDoubleClickAction::OpenDownloadDirectory: showTorrentsInFileManager(); break; default: break; } } void showTorrentsPropertiesDialogs() { const QModelIndexList selectedRows(mTorrentsView.selectionModel()->selectedRows()); for (const auto& index : selectedRows) { auto* const torrent = mTorrentsModel.torrentAtIndex(mTorrentsProxyModel.sourceIndex(index)); const auto hashString = torrent->data().hashString; const auto existingDialog = mTorrentPropertiesDialogs.find(hashString); if (existingDialog != mTorrentPropertiesDialogs.end()) { unminimizeAndRaiseWindow(existingDialog->second); activateWindowCompat(existingDialog->second); } else { auto dialog = new TorrentPropertiesDialog(torrent, mViewModel.rpc(), mWindow); dialog->setAttribute(Qt::WA_DeleteOnClose); mTorrentPropertiesDialogs.emplace(hashString, dialog); QObject::connect(dialog, &TorrentPropertiesDialog::finished, this, [hashString, this] { mTorrentPropertiesDialogs.erase(hashString); }); dialog->show(); } } } void removeSelectedTorrents(bool deleteFiles) { const auto ids = mTorrentsModel.idsFromIndexes( mTorrentsProxyModel.sourceIndexes(mTorrentsView.selectionModel()->selectedRows()) ); if (ids.empty()) { return; } QMessageBox dialog(mWindow); dialog.setIcon(QMessageBox::Warning); dialog.setStandardButtons(QMessageBox::Ok | QMessageBox::Cancel); dialog.setDefaultButton(QMessageBox::Cancel); const auto okButton = dialog.button(QMessageBox::Ok); if (dialog.style()->styleHint(QStyle::SH_DialogButtonBox_ButtonsHaveIcons)) { okButton->setIcon(QIcon::fromTheme("edit-delete"_l1)); } //: Check box label QCheckBox deleteFilesCheckBox(qApp->translate("tremotesf", "Also delete the files on the hard disk")); deleteFilesCheckBox.setChecked(deleteFiles); dialog.setCheckBox(&deleteFilesCheckBox); auto setRemoveText = [&] { okButton->setText( deleteFilesCheckBox.isChecked() //: Check box label ? qApp->translate("tremotesf", "Delete with files") //: Check box label : qApp->translate("tremotesf", "Delete") ); }; setRemoveText(); QObject::connect(&deleteFilesCheckBox, &QCheckBox::toggled, this, setRemoveText); if (ids.size() == 1) { //: Dialog title dialog.setWindowTitle(qApp->translate("tremotesf", "Delete Torrent")); dialog.setText(qApp->translate("tremotesf", "Are you sure you want to delete this torrent?")); } else { //: Dialog title dialog.setWindowTitle(qApp->translate("tremotesf", "Delete Torrents")); // Don't put static_cast in qApp->translate() - lupdate doesn't like it const auto count = static_cast(ids.size()); //: %Ln is a number of torrents selected for deletion dialog.setText(qApp->translate( "tremotesf", "Are you sure you want to delete %Ln selected torrents?", nullptr, count )); } if (dialog.exec() == QMessageBox::Ok) { mViewModel.rpc()->removeTorrents(ids, deleteFilesCheckBox.checkState() == Qt::Checked); } } void setupTorrentsPlaceholder() { auto layout = new QVBoxLayout(mTorrentsView.viewport()); layout->addStretch(); auto status = createListPlaceholderLabel(); layout->addWidget(status); layout->setAlignment(status, Qt::AlignCenter); { auto font = status->font(); constexpr int minFontSize = 12; font.setPointSize(std::max(minFontSize, static_cast(std::round(font.pointSize() * 1.3)))); status->setFont(font); } auto error = createListPlaceholderLabel(); layout->addWidget(error); layout->setAlignment(error, Qt::AlignCenter); { auto font = error->font(); constexpr int minFontSize = 10; font.setPointSize(std::max(minFontSize, font.pointSize())); error->setFont(font); } layout->addStretch(); const auto updatePlaceholder = [status, error, this] { QString statusText{}; QString errorText{}; if (mViewModel.rpc()->isConnected()) { if (mTorrentsProxyModel.rowCount() == 0) { if (mTorrentsModel.rowCount() == 0) { //: Torrents list placeholder statusText = qApp->translate("tremotesf", "No torrents"); } else { //: Torrents list placeholder statusText = qApp->translate("tremotesf", "No torrents matching filters"); } } } else if (Servers::instance()->hasServers()) { statusText = mViewModel.rpc()->status().toString(); if (mViewModel.rpc()->error() != Rpc::Error::NoError) { errorText = mViewModel.rpc()->errorMessage(); } } else { statusText = qApp->translate("tremotesf", "No servers"); } status->setText(statusText); status->setVisible(!statusText.isEmpty()); error->setText(errorText); error->setVisible(!errorText.isEmpty()); }; updatePlaceholder(); QObject::connect(mViewModel.rpc(), &Rpc::statusChanged, this, updatePlaceholder); QObject::connect( &mTorrentsProxyModel, &TorrentsModel::rowsInserted, this, [updatePlaceholder, this](const QModelIndex&, int first, int last) { if ((last - first) + 1 == mTorrentsProxyModel.rowCount()) { // Model was empty updatePlaceholder(); } } ); QObject::connect(&mTorrentsProxyModel, &TorrentsModel::rowsRemoved, this, [updatePlaceholder, this] { if (mTorrentsProxyModel.rowCount() == 0) { // Model is now empty updatePlaceholder(); } }); } template Dialog, typename CreateDialogFunction> requires std::is_invocable_r_v void showSingleInstanceDialog(CreateDialogFunction createDialog) { auto existingDialog = mWindow->findChild({}, Qt::FindDirectChildrenOnly); if (existingDialog) { unminimizeAndRaiseWindow(existingDialog); activateWindowCompat(existingDialog); } else { auto dialog = createDialog(); dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->show(); } } void setupMenuBar() { //: Menu bar item mFileMenu = mWindow->menuBar()->addMenu(qApp->translate("tremotesf", "&File")); mFileMenu->addAction(&mConnectAction); mFileMenu->addAction(&mDisconnectAction); mFileMenu->addSeparator(); mFileMenu->addAction(&mAddTorrentFileAction); mFileMenu->addAction(&mAddTorrentLinkAction); mFileMenu->addSeparator(); if constexpr (targetOs == TargetOs::UnixMacOS) { auto closeWindowAction = mFileMenu->addAction(qApp->translate("tremotesf", "&Close Window")); closeWindowAction->setShortcuts(QKeySequence::Close); QObject::connect(closeWindowAction, &QAction::triggered, this, [] { auto window = QApplication::activeWindow(); if (window) { window->close(); } }); } const auto quitAction = createQuitAction(); quitAction->setMenuRole(QAction::QuitRole); mFileMenu->addAction(quitAction); //: Menu bar item QMenu* editMenu = mWindow->menuBar()->addMenu(qApp->translate("tremotesf", "&Edit")); QAction* selectAllAction = editMenu->addAction( QIcon::fromTheme("edit-select-all"_l1), qApp->translate("tremotesf", "Select &All") ); selectAllAction->setShortcut(QKeySequence::SelectAll); QObject::connect(selectAllAction, &QAction::triggered, &mTorrentsView, &TorrentsView::selectAll); QAction* invertSelectionAction = editMenu->addAction( QIcon::fromTheme("edit-select-invert"_l1), qApp->translate("tremotesf", "&Invert Selection") ); QObject::connect(invertSelectionAction, &QAction::triggered, this, [this] { mTorrentsView.selectionModel()->select( QItemSelection( mTorrentsProxyModel.index(0, 0), mTorrentsProxyModel .index(mTorrentsProxyModel.rowCount() - 1, mTorrentsProxyModel.columnCount() - 1) ), QItemSelectionModel::Toggle ); }); mWindow->menuBar()->addMenu(mTorrentMenu); //: Menu bar item QMenu* viewMenu = mWindow->menuBar()->addMenu(qApp->translate("tremotesf", "&View")); mToolBarAction = viewMenu->addAction(qApp->translate("tremotesf", "&Toolbar")); mToolBarAction->setCheckable(true); QObject::connect(mToolBarAction, &QAction::triggered, &mToolBar, &QToolBar::setVisible); QAction* sideBarAction = viewMenu->addAction(qApp->translate("tremotesf", "&Sidebar")); sideBarAction->setCheckable(true); sideBarAction->setChecked(Settings::instance()->get_sideBarVisible()); QObject::connect(sideBarAction, &QAction::triggered, this, [this](bool checked) { mSideBar.setVisible(checked); Settings::instance()->set_sideBarVisible(checked); }); QAction* statusBarAction = viewMenu->addAction(qApp->translate("tremotesf", "St&atusbar")); statusBarAction->setCheckable(true); statusBarAction->setChecked(Settings::instance()->get_statusBarVisible()); QObject::connect(statusBarAction, &QAction::triggered, this, [this](bool checked) { mWindow->statusBar()->setVisible(checked); Settings::instance()->set_statusBarVisible(checked); }); QAction* torrentPropertiesWidgetAction = viewMenu->addAction(qApp->translate("tremotesf", "Torrent properties &panel")); torrentPropertiesWidgetAction->setCheckable(true); torrentPropertiesWidgetAction->setChecked(Settings::instance()->get_showTorrentPropertiesInMainWindow()); QObject::connect(torrentPropertiesWidgetAction, &QAction::triggered, this, [](bool checked) { Settings::instance()->set_showTorrentPropertiesInMainWindow(checked); Settings::TorrentDoubleClickAction action; if (checked) { action = Settings::TorrentDoubleClickAction::OpenTorrentFile; } else { action = Settings::TorrentDoubleClickAction::OpenPropertiesDialog; } Settings::instance()->set_torrentDoubleClickAction(action); }); QObject::connect(Settings::instance(), &Settings::showTorrentPropertiesInMainWindowChanged, this, [=] { torrentPropertiesWidgetAction->setChecked(Settings::instance()->get_showTorrentPropertiesInMainWindow() ); }); viewMenu->addSeparator(); QAction* lockToolBarAction = viewMenu->addAction(qApp->translate("tremotesf", "&Lock Toolbar")); lockToolBarAction->setCheckable(true); lockToolBarAction->setChecked(Settings::instance()->get_toolBarLocked()); QObject::connect(lockToolBarAction, &QAction::triggered, &mToolBar, [this](bool checked) { mToolBar.setMovable(!checked); Settings::instance()->set_toolBarLocked(checked); }); //: Menu bar item QMenu* toolsMenu = mWindow->menuBar()->addMenu(qApp->translate("tremotesf", "T&ools")); QAction* settingsAction = toolsMenu->addAction( QIcon::fromTheme("configure"_l1, QIcon::fromTheme("preferences-system"_l1)), qApp->translate("tremotesf", "&Options") ); settingsAction->setShortcut(QKeySequence::Preferences); settingsAction->setMenuRole(QAction::PreferencesRole); QObject::connect(settingsAction, &QAction::triggered, this, [this] { showSingleInstanceDialog([this] { return new SettingsDialog(mViewModel.rpc(), mWindow); }); }); QAction* serversAction = toolsMenu->addAction( QIcon::fromTheme("network-server"_l1), qApp->translate("tremotesf", "&Connection Settings") ); serversAction->setMenuRole(QAction::NoRole); QObject::connect(serversAction, &QAction::triggered, this, [this] { showSingleInstanceDialog([this] { return new ConnectionSettingsDialog(mWindow); }); }); toolsMenu->addSeparator(); auto serverSettingsAction = new QAction( QIcon::fromTheme("preferences-system-network"_l1, QIcon::fromTheme("preferences-system"_l1)), qApp->translate("tremotesf", "&Server Options"), this ); serverSettingsAction->setMenuRole(QAction::NoRole); QObject::connect(serverSettingsAction, &QAction::triggered, this, [this] { showSingleInstanceDialog([this] { return new ServerSettingsDialog(mViewModel.rpc(), mWindow); }); }); mConnectionDependentActions.push_back(serverSettingsAction); toolsMenu->addAction(serverSettingsAction); auto serverStatsAction = new QAction( QIcon::fromTheme("view-statistics"_l1), qApp->translate("tremotesf", "Server S&tats"), this ); QObject::connect(serverStatsAction, &QAction::triggered, this, [this] { showSingleInstanceDialog([this] { return new ServerStatsDialog(mViewModel.rpc(), mWindow); }); }); mConnectionDependentActions.push_back(serverStatsAction); toolsMenu->addAction(serverStatsAction); auto shutdownServerAction = new QAction( QIcon::fromTheme("system-shutdown"), qApp->translate("tremotesf", "S&hutdown Server"), this ); QObject::connect(shutdownServerAction, &QAction::triggered, this, [this] { auto dialog = new QMessageBox( QMessageBox::Warning, //: Dialog title qApp->translate("tremotesf", "Shutdown Server"), qApp->translate("tremotesf", "Are you sure you want to shutdown remote Transmission instance?"), QMessageBox::Cancel | QMessageBox::Ok, mWindow ); auto okButton = dialog->button(QMessageBox::Ok); okButton->setIcon(QIcon::fromTheme("system-shutdown")); //: Dialog confirmation button okButton->setText(qApp->translate("tremotesf", "Shutdown")); dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->setModal(true); QObject::connect(dialog, &QDialog::accepted, this, [this] { mViewModel.rpc()->shutdownServer(); }); dialog->show(); }); mConnectionDependentActions.push_back(shutdownServerAction); toolsMenu->addAction(shutdownServerAction); //: Menu bar item QMenu* helpMenu = mWindow->menuBar()->addMenu(qApp->translate("tremotesf", "&Help")); QAction* aboutAction = helpMenu->addAction( QIcon::fromTheme("help-about"_l1), //: Menu item opening "About" dialog qApp->translate("tremotesf", "&About") ); aboutAction->setMenuRole(QAction::AboutRole); QObject::connect(aboutAction, &QAction::triggered, this, [this] { showSingleInstanceDialog([this] { return new AboutDialog(mWindow); }); }); } void setupToolBar() { mToolBar.setObjectName("toolBar"_l1); mToolBar.setContextMenuPolicy(Qt::CustomContextMenu); mToolBar.setMovable(!Settings::instance()->get_toolBarLocked()); mWindow->addToolBar(Qt::TopToolBarArea, &mToolBar); mToolBar.addAction(&mConnectAction); mToolBar.addAction(&mDisconnectAction); mToolBar.addSeparator(); mToolBar.addAction(&mAddTorrentFileAction); mToolBar.addAction(&mAddTorrentLinkAction); mToolBar.addSeparator(); mToolBar.addAction(mStartTorrentAction); mToolBar.addAction(mPauseTorrentAction); mToolBar.addAction(mRemoveTorrentAction); QObject::connect(&mToolBar, &QToolBar::customContextMenuRequested, this, [this](QPoint pos) { QMenu contextMenu; QActionGroup group(this); //: Toolbar mode group.addAction(qApp->translate("tremotesf", "Icon Only"))->setCheckable(true); //: Toolbar mode group.addAction(qApp->translate("tremotesf", "Text Only"))->setCheckable(true); //: Toolbar mode group.addAction(qApp->translate("tremotesf", "Text Beside Icon"))->setCheckable(true); //: Toolbar mode group.addAction(qApp->translate("tremotesf", "Text Under Icon"))->setCheckable(true); //: Toolbar mode group.addAction(qApp->translate("tremotesf", "Follow System Style"))->setCheckable(true); group.actions().at(mWindow->toolButtonStyle())->setChecked(true); contextMenu.addActions(group.actions()); QAction* action = contextMenu.exec(mToolBar.mapToGlobal(pos)); if (action) { const auto style = static_cast(contextMenu.actions().indexOf(action)); mWindow->setToolButtonStyle(style); Settings::instance()->set_toolButtonStyle(style); } }); } void setupTrayIcon() { auto contextMenu = new QMenu(mWindow); contextMenu->addAction(&mShowHideAppAction); contextMenu->addSeparator(); if constexpr (targetOs != TargetOs::UnixMacOS) { QObject::connect(&mTrayIcon, &QSystemTrayIcon::activated, this, [this](auto reason) { if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) { if (isMainWindowHiddenOrMinimized()) { showWindowsOrActivateMainWindow(); } else { hideWindows(); } } }); } contextMenu->addAction(&mConnectAction); contextMenu->addAction(&mDisconnectAction); contextMenu->addSeparator(); contextMenu->addAction(&mAddTorrentFileAction); contextMenu->addAction(&mAddTorrentLinkAction); contextMenu->addSeparator(); contextMenu->addAction(createQuitAction()); mTrayIcon.setContextMenu(contextMenu); mTrayIcon.setToolTip(mViewModel.rpc()->status().toString()); QObject::connect(mViewModel.rpc(), &Rpc::statusChanged, this, [this] { mTrayIcon.setToolTip(mViewModel.rpc()->status().toString()); }); QObject::connect(mViewModel.rpc()->serverStats(), &ServerStats::updated, this, [this] { mTrayIcon.setToolTip( QString("\u25be %1\n\u25b4 %2") .arg( formatutils::formatByteSpeed(mViewModel.rpc()->serverStats()->downloadSpeed()), formatutils::formatByteSpeed(mViewModel.rpc()->serverStats()->uploadSpeed()) ) ); }); if (Settings::instance()->get_showTrayIcon()) { mTrayIcon.show(); } QObject::connect(Settings::instance(), &Settings::showTrayIconChanged, this, [this] { if (Settings::instance()->get_showTrayIcon()) { mTrayIcon.show(); } else { mTrayIcon.hide(); showWindowsOrActivateMainWindow(); } }); } bool isMainWindowHiddenOrMinimized() const { if constexpr (targetOs == TargetOs::UnixMacOS) { if (isNSAppHidden()) return true; } return mWindow->isHidden() || mWindow->isMinimized(); } void showWindowsOrActivateMainWindow([[maybe_unused]] std::optional windowActivationToken = {}) { info().log("Showing windows"); #ifdef Q_OS_MACOS if (isNSAppHidden()) { info().log("NSApp is hidden, unhiding it"); unhideNSApp(); } else { info().log("NSApp is not hidden, activating main window"); unminimizeAndRaiseWindow(mWindow); activateWindowCompat(mWindow); } #else if (!mWindow->isHidden()) { info().log("Main window is not hidden, activating it"); unminimizeAndRaiseWindow(mWindow); activateWindowCompat(mWindow, windowActivationToken); return; } # if defined(TREMOTESF_UNIX_FREEDESKTOP) && QT_VERSION_MAJOR >= 6 // With Qt 6 and Wayland we need to set XDG_ACTIVATION_TOKEN environment variable before show() // so that Qt handles activation automatically if (windowActivationToken.has_value() && KWindowSystem::isPlatformWayland()) { info().log("Showing window with token {}", *windowActivationToken); // Qt gets new token from XDG_ACTIVATION_TOKEN environment variable // It we be read and unset in QWidget::show() call below qputenv(xdgActivationTokenEnvVariable, *windowActivationToken); windowActivationToken.reset(); } # endif info().log("Showing window {}", *mWindow); mWindow->show(); unminimizeAndRaiseWindow(mWindow); if (windowActivationToken.has_value()) { activateWindowCompat(mWindow, windowActivationToken); } for (const auto& window : mOtherWindowsHiddenByUs) { if (window) { info().log("Showing window {}", *window); window->show(); unminimizeAndRaiseWindow(window); } } mOtherWindowsHiddenByUs.clear(); #endif } void hideWindows() { info().log("Hiding windows"); #ifdef Q_OS_MACOS if (isNSAppHidden()) { info().log("NSApp is already hidden, do nothing"); return; } // Hiding application doesn't work in fullscreen mode if (mWindow->isFullScreen()) { info().log("Exiting fullscreen"); mWindow->setWindowState(mWindow->windowState().setFlag(Qt::WindowFullScreen, false)); } info().log("Hiding NSApp"); hideNSApp(); #else if (mWindow->isHidden()) { info().log("Main window is already hidden, do nothing"); return; } info().log("Hiding {}", *mWindow); mWindow->hide(); mOtherWindowsHiddenByUs.clear(); for (auto&& widget : toQPointers(qApp->topLevelWidgets())) { if (widget != mWindow && widget->isWindow() && !widget->isHidden() && isAllowedToHide(widget)) { info().log("Hiding {}", *widget); widget->hide(); mOtherWindowsHiddenByUs.push_back(std::move(widget)); } } #endif } void openTorrentsFiles() { const QModelIndexList selectedRows(mTorrentsView.selectionModel()->selectedRows()); for (const QModelIndex& index : selectedRows) { desktoputils::openFile( localTorrentRootFilePath( mViewModel.rpc(), mTorrentsModel.torrentAtIndex(mTorrentsProxyModel.sourceIndex(index)) ), mWindow ); } } void showTorrentsInFileManager() { std::vector files{}; const QModelIndexList selectedRows(mTorrentsView.selectionModel()->selectedRows()); files.reserve(static_cast(selectedRows.size())); for (const QModelIndex& index : selectedRows) { Torrent* torrent = mTorrentsModel.torrentAtIndex(mTorrentsProxyModel.sourceIndex(index)); files.push_back(localTorrentRootFilePath(mViewModel.rpc(), torrent)); } launchFileManagerAndSelectFiles(files, mWindow); } void showAddTorrentDialogs( const QStringList& files, const QStringList& urls, std::optional windowActivationToken ) { if (!files.isEmpty()) { showAddTorrentFileDialogs(files, std::move(windowActivationToken)); // NOLINTNEXTLINE(bugprone-use-after-move) windowActivationToken.reset(); } if (!urls.isEmpty()) { showAddTorrentLinksDialog(urls, std::move(windowActivationToken)); } } void showAddTorrentFileDialogs(const QStringList& files, std::optional windowActivationToken) { const bool setParent = Settings::instance()->get_showMainWindowWhenAddingTorrent(); for (const QString& filePath : files) { auto* const dialog = showAddTorrentFileDialog(filePath, setParent); if (windowActivationToken.has_value()) { activateWindowCompat(dialog, windowActivationToken); // Can use token only once windowActivationToken.reset(); } } } QDialog* showAddTorrentFileDialog(const QString& filePath, bool setParent) { auto* const dialog = new AddTorrentDialog( mViewModel.rpc(), AddTorrentDialog::FileParams{filePath}, setParent ? mWindow : nullptr ); dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->show(); return dialog; } void showAddTorrentLinksDialog(const QStringList& urls, std::optional windowActivationToken) { auto* const dialog = new AddTorrentDialog( mViewModel.rpc(), AddTorrentDialog::UrlParams{urls}, Settings::instance()->get_showMainWindowWhenAddingTorrent() ? mWindow : nullptr ); dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->show(); if (windowActivationToken.has_value()) { activateWindowCompat(dialog, windowActivationToken); } } void askForMergingTrackers( std::vector>>> existingTorrents, std::optional windowActivationToken ) { const bool setParent = Settings::instance()->get_showMainWindowWhenAddingTorrent(); for (auto& [torrent, trackers] : existingTorrents) { auto* const dialog = tremotesf::askForMergingTrackers(torrent, std::move(trackers), setParent ? mWindow : nullptr); if (windowActivationToken.has_value()) { activateWindowCompat(dialog, windowActivationToken); // Can use token only once windowActivationToken.reset(); } } } void showAddTorrentErrors() { const auto showError = [this](const QString& title, const QString& text) { QWidget* parent{}; if (Settings::instance()->get_showMainWindowWhenAddingTorrent()) { parent = mWindow; showWindowsOrActivateMainWindow(); } auto* const dialog = new QMessageBox(QMessageBox::Warning, title, text, QMessageBox::Close, parent); dialog->setAttribute(Qt::WA_DeleteOnClose, true); dialog->setModal(false); dialog->show(); activateWindowCompat(dialog); }; QObject::connect(mViewModel.rpc(), &Rpc::torrentAddDuplicate, this, [=] { showError( qApp->translate("tremotesf", "Error adding torrent"), qApp->translate("tremotesf", "This torrent is already added") ); }); QObject::connect(mViewModel.rpc(), &Rpc::torrentAddError, this, [=](const QString& filePathOrUrl) { showError( qApp->translate("tremotesf", "Error adding torrent"), qApp->translate("tremotesf", "Error adding torrent «%1»").arg(filePathOrUrl) ); }); } void showDelayedTorrentAddDialog( const QStringList& torrents, const std::optional& windowActivationToken ) { debug().log("MainWindow: showing delayed torrent add dialog"); const auto dialog = new QMessageBox( QMessageBox::Information, qApp->translate("tremotesf", "Disconnected"), //: Message shown when user attempts to add torrent while disconnect from server. qApp->translate("tremotesf", "Torrents will be added after connection to server"), QMessageBox::Close, Settings::instance()->get_showMainWindowWhenAddingTorrent() ? mWindow : nullptr ); dialog->setAttribute(Qt::WA_DeleteOnClose, true); dialog->setModal(false); QString detailedText{}; for (const auto& torrent : torrents) { detailedText += "\u2022 "; detailedText += torrent; detailedText += '\n'; } dialog->setDetailedText(detailedText); dialog->show(); if (windowActivationToken.has_value()) { activateWindowCompat(dialog, windowActivationToken); } QObject::connect(mViewModel.rpc(), &Rpc::connectedChanged, dialog, [=, this] { if (mViewModel.rpc()->isConnected()) dialog->close(); }); } }; MainWindow::MainWindow(QStringList&& commandLineFiles, QStringList&& commandLineUrls, QWidget* parent) : QMainWindow(parent), mImpl(new Impl(std::move(commandLineFiles), std::move(commandLineUrls), this)) { setWindowTitle(TREMOTESF_APP_NAME ""_l1); setMinimumSize(minimumSizeHint().expandedTo(QSize(384, 256))); setContextMenuPolicy(Qt::NoContextMenu); setToolButtonStyle(Settings::instance()->get_toolButtonStyle()); setAcceptDrops(true); if constexpr (targetOs == TargetOs::UnixMacOS) { if (determineStyle() == KnownStyle::macOS) { setUnifiedTitleAndToolBarOnMac(true); } } } MainWindow::~MainWindow() = default; void MainWindow::initialShow(bool minimized) { if (!(minimized && Settings::instance()->get_showTrayIcon() && QSystemTrayIcon::isSystemTrayAvailable())) { show(); #if defined(TREMOTESF_UNIX_FREEDESKTOP) // On Wayland we need to explicitly activate our window to consume XDG_ACTIVATION_TOKEN environment variable // possible set by whoever launched us, both in Qt 6 and Qt 5 (KWindowSystem) paths mImpl->activateMainWindowOnWayland(); #endif } } bool MainWindow::event(QEvent* event) { if (event->type() == QEvent::WindowStateChange) { // This may be called in Impl constructor from restoreGeometry(), when mImpl is still uninitialized, so access it inside the lambda QMetaObject::invokeMethod(this, [this] { mImpl->updateShowHideAction(); }, Qt::QueuedConnection); } return QMainWindow::event(event); } void MainWindow::showEvent(QShowEvent* event) { QMetaObject::invokeMethod(this, [this] { mImpl->updateShowHideAction(); }, Qt::QueuedConnection); QMainWindow::showEvent(event); } void MainWindow::hideEvent(QHideEvent* event) { QMetaObject::invokeMethod(this, [this] { mImpl->updateShowHideAction(); }, Qt::QueuedConnection); QMainWindow::hideEvent(event); } void MainWindow::closeEvent(QCloseEvent* event) { if (mImpl->onCloseEvent()) { event->ignore(); } else { QMainWindow::closeEvent(event); } } void MainWindow::dragEnterEvent(QDragEnterEvent* event) { mImpl->onDragEnterEvent(event); } void MainWindow::dropEvent(QDropEvent* event) { mImpl->onDropEvent(event); } } #include "mainwindow.moc" tremotesf-2.8.2/src/ui/screens/mainwindow/mainwindow.h000066400000000000000000000017221500171105600231000ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_MAINWINDOW_H #define TREMOTESF_MAINWINDOW_H #include #include namespace tremotesf { class MainWindow final : public QMainWindow { Q_OBJECT public: MainWindow(QStringList&& commandLineFiles, QStringList&& commandLineUrls, QWidget* parent = nullptr); ~MainWindow() override; Q_DISABLE_COPY_MOVE(MainWindow) void initialShow(bool minimized); protected: bool event(QEvent* event) override; void showEvent(QShowEvent* event) override; void hideEvent(QHideEvent* event) override; void closeEvent(QCloseEvent* event) override; void dragEnterEvent(QDragEnterEvent* event) override; void dropEvent(QDropEvent* event) override; private: class Impl; std::unique_ptr mImpl; }; } #endif // TREMOTESF_MAINWINDOW_H tremotesf-2.8.2/src/ui/screens/mainwindow/mainwindowsidebar.cpp000066400000000000000000000324071500171105600247710ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "mainwindowsidebar.h" #include #include #include #include #include #include #include #include #include "rpc/rpc.h" #include "rpc/serversettings.h" #include "ui/stylehelpers.h" #include "ui/widgets/commondelegate.h" #include "alltrackersmodel.h" #include "downloaddirectoriesmodel.h" #include "labelsmodel.h" #include "statusfiltersmodel.h" #include "torrentsproxymodel.h" namespace tremotesf { namespace { class BaseListView : public QListView { Q_OBJECT public: explicit BaseListView( TorrentsProxyModel* torrentsProxyModel, BaseTorrentsFiltersSettingsModel* model, std::optional alwaysShowTooltipRole, QWidget* parent = nullptr ) : QListView(parent), mTorrentsProxyModel(torrentsProxyModel), mModel(model) { mModel->setParent(this); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setIconSize(QSize(16, 16)); setItemDelegate(new CommonDelegate({.alwaysShowTooltipRole = alwaysShowTooltipRole}, this)); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); setTextElideMode(Qt::ElideMiddle); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); makeScrollAreaTransparent(this); } [[nodiscard]] QSize minimumSizeHint() const override { return {8, 0}; } [[nodiscard]] QSize sizeHint() const override { int height = 0; for (int i = 0, max = model()->rowCount(); i < max; ++i) { height += sizeHintForRow(i); height += spacing(); } height += spacing(); return {sizeHintForColumn(0), height}; } void setFilterEnabled(bool enabled) { setVisible(enabled); setTorrentsModelFilterEnabled(enabled); } protected: void init(Rpc* rpc, std::optional sortByRole = {}) { QAbstractItemModel* actualModel{}; if (sortByRole.has_value()) { mProxyModel = new QSortFilterProxyModel(this); mProxyModel->setSourceModel(mModel); mProxyModel->setSortRole(*sortByRole); mProxyModel->sort(0); actualModel = mProxyModel; } else { actualModel = mModel; } setModel(actualModel); QObject::connect( mModel, &BaseTorrentsFiltersSettingsModel::populatedChanged, this, &BaseListView::updateCurrentIndex ); QObject::connect( selectionModel(), &QItemSelectionModel::currentChanged, this, [this](const QModelIndex& current) { if (mModel->isPopulated()) { setTorrentsModelFilterFromIndex(current); } } ); QObject::connect( actualModel, &BaseTorrentsFiltersSettingsModel::rowsInserted, this, &BaseListView::updateGeometry ); QObject::connect( actualModel, &BaseTorrentsFiltersSettingsModel::rowsRemoved, this, &BaseListView::updateGeometry ); mModel->setTorrentsProxyModel(mTorrentsProxyModel); mModel->setRpc(rpc); if (mModel->isPopulated()) { updateCurrentIndex(); } } void updateCurrentIndex() { auto index = mModel->indexForTorrentsProxyModelFilter(); if (index.isValid() && mProxyModel) { index = mProxyModel->mapFromSource(index); } if (!index.isValid() && model()->rowCount() > 0) { index = model()->index(0, 0); } if (index.isValid()) { setCurrentIndex(index); } } virtual void setTorrentsModelFilterFromIndex(const QModelIndex& index) = 0; virtual void setTorrentsModelFilterEnabled(bool enabled) = 0; TorrentsProxyModel* mTorrentsProxyModel; BaseTorrentsFiltersSettingsModel* mModel; QSortFilterProxyModel* mProxyModel{}; }; class StatusFiltersListView final : public BaseListView { Q_OBJECT public: StatusFiltersListView(Rpc* rpc, TorrentsProxyModel* torrentsModel, QWidget* parent = nullptr) : BaseListView(torrentsModel, new StatusFiltersModel(), {}, parent) { init(rpc); QObject::connect(torrentsModel, &TorrentsProxyModel::statusFilterChanged, this, [=, this] { updateCurrentIndex(); }); } protected: void setTorrentsModelFilterFromIndex(const QModelIndex& index) override { mTorrentsProxyModel->setStatusFilter( index.data(StatusFiltersModel::FilterRole).value() ); } void setTorrentsModelFilterEnabled(bool enabled) override { mTorrentsProxyModel->setStatusFilterEnabled(enabled); } }; class LabelsListView final : public BaseListView { Q_OBJECT public: LabelsListView(Rpc* rpc, TorrentsProxyModel* torrentsModel, QWidget* parent = nullptr) : BaseListView(torrentsModel, new LabelsModel(), {}, parent) { init(rpc, LabelsModel::LabelRole); QObject::connect(torrentsModel, &TorrentsProxyModel::labelFilterChanged, this, [=, this] { updateCurrentIndex(); }); } protected: void setTorrentsModelFilterFromIndex(const QModelIndex& index) override { mTorrentsProxyModel->setLabelFilter(index.data(LabelsModel::LabelRole).toString()); } void setTorrentsModelFilterEnabled(bool enabled) override { mTorrentsProxyModel->setLabelFilterEnabled(enabled); } }; class TrackersListView final : public BaseListView { Q_OBJECT public: TrackersListView(Rpc* rpc, TorrentsProxyModel* torrentsModel, QWidget* parent = nullptr) : BaseListView(torrentsModel, new AllTrackersModel(), {}, parent) { init(rpc, AllTrackersModel::TrackerRole); QObject::connect(torrentsModel, &TorrentsProxyModel::trackerFilterChanged, this, [=, this] { updateCurrentIndex(); }); } protected: void setTorrentsModelFilterFromIndex(const QModelIndex& index) override { mTorrentsProxyModel->setTrackerFilter(index.data(AllTrackersModel::TrackerRole).toString()); } void setTorrentsModelFilterEnabled(bool enabled) override { mTorrentsProxyModel->setTrackerFilterEnabled(enabled); } }; class DirectoriesListView final : public BaseListView { Q_OBJECT public: DirectoriesListView(Rpc* rpc, TorrentsProxyModel* torrentsModel, QWidget* parent = nullptr) : BaseListView( torrentsModel, new DownloadDirectoriesModel(), static_cast(DownloadDirectoriesModel::Role::AlwaysShowTooltip), parent ) { init(rpc, static_cast(DownloadDirectoriesModel::Role::Directory)); QObject::connect(torrentsModel, &TorrentsProxyModel::downloadDirectoryFilterChanged, this, [=, this] { updateCurrentIndex(); }); } protected: void setTorrentsModelFilterFromIndex(const QModelIndex& index) override { mTorrentsProxyModel->setDownloadDirectoryFilter( index.data(static_cast(DownloadDirectoriesModel::Role::Directory)).toString() ); } void setTorrentsModelFilterEnabled(bool enabled) override { mTorrentsProxyModel->setDownloadDirectoryFilterEnabled(enabled); } }; } MainWindowSideBar::MainWindowSideBar(Rpc* rpc, TorrentsProxyModel* proxyModel, QWidget* parent) : QScrollArea(parent) { setFrameShape(QFrame::NoFrame); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setWidgetResizable(true); auto contentWidget = new QWidget(this); auto layout = new QVBoxLayout(contentWidget); auto searchLineEdit = new QLineEdit(this); searchLineEdit->setClearButtonEnabled(true); //: Search field placeholder searchLineEdit->setPlaceholderText(qApp->translate("tremotesf", "Search...")); QObject::connect(searchLineEdit, &QLineEdit::textChanged, this, [proxyModel](const auto& text) { proxyModel->setSearchString(text); }); layout->addWidget(searchLineEdit); auto* const searchShortcut = new QShortcut( #if QT_VERSION_MAJOR >= 6 QKeyCombination(Qt::ControlModifier, Qt::Key_F), #else QKeySequence(static_cast(Qt::ControlModifier) | static_cast(Qt::Key_F)), #endif this ); QObject::connect(searchShortcut, &QShortcut::activated, this, [searchLineEdit] { searchLineEdit->selectAll(); searchLineEdit->setFocus(); }); QFont checkBoxFont(QApplication::font()); checkBoxFont.setBold(true); checkBoxFont.setCapitalization(QFont::AllUppercase); //: Title of torrents status filters list auto statusCheckBox = new QCheckBox(qApp->translate("tremotesf", "Status"), this); statusCheckBox->setChecked(true); statusCheckBox->setFont(checkBoxFont); layout->addWidget(statusCheckBox); auto statusFiltersListView = new StatusFiltersListView(rpc, proxyModel, this); QObject::connect( statusCheckBox, &QCheckBox::toggled, statusFiltersListView, &StatusFiltersListView::setFilterEnabled ); layout->addWidget(statusFiltersListView); statusCheckBox->setChecked(proxyModel->isStatusFilterEnabled()); //: Title of torrents label filters list auto labelsCheckBox = new QCheckBox(qApp->translate("tremotesf", "Labels"), this); labelsCheckBox->setChecked(proxyModel->isLabelFilterEnabled()); labelsCheckBox->setFont(checkBoxFont); layout->addWidget(labelsCheckBox); auto labelsListView = new LabelsListView(rpc, proxyModel, this); QObject::connect(labelsCheckBox, &QCheckBox::toggled, labelsListView, &LabelsListView::setFilterEnabled); layout->addWidget(labelsListView); QObject::connect(rpc, &Rpc::connectedChanged, this, [=] { const bool serverSupportsLabels = rpc->isConnected() ? rpc->serverSettings()->data().hasLabelsProperty() : true; if (serverSupportsLabels) { labelsCheckBox->setVisible(true); labelsListView->setVisible(labelsCheckBox->isChecked()); } else { labelsCheckBox->setVisible(false); labelsListView->setVisible(false); } }); //: Title of torrents download directory filters list auto directoriesCheckBox = new QCheckBox(qApp->translate("tremotesf", "Directories"), this); directoriesCheckBox->setChecked(true); directoriesCheckBox->setFont(checkBoxFont); layout->addWidget(directoriesCheckBox); auto directoriesListView = new DirectoriesListView(rpc, proxyModel, this); QObject::connect( directoriesCheckBox, &QCheckBox::toggled, directoriesListView, &DirectoriesListView::setFilterEnabled ); layout->addWidget(directoriesListView); directoriesCheckBox->setChecked(proxyModel->isDownloadDirectoryFilterEnabled()); //: Title of torrents tracker filters list auto trackersCheckBox = new QCheckBox(qApp->translate("tremotesf", "Trackers"), this); trackersCheckBox->setChecked(true); trackersCheckBox->setFont(checkBoxFont); layout->addWidget(trackersCheckBox); auto trackersListView = new TrackersListView(rpc, proxyModel, this); QObject::connect(trackersCheckBox, &QCheckBox::toggled, trackersListView, &TrackersListView::setFilterEnabled); layout->addWidget(trackersListView); trackersCheckBox->setChecked(proxyModel->isTrackerFilterEnabled()); layout->addStretch(); setWidget(contentWidget); } } #include "mainwindowsidebar.moc" tremotesf-2.8.2/src/ui/screens/mainwindow/mainwindowsidebar.h000066400000000000000000000007771500171105600244430ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_MAINWINDOWSIDEBAR_H #define TREMOTESF_MAINWINDOWSIDEBAR_H #include namespace tremotesf { class Rpc; class TorrentsProxyModel; class MainWindowSideBar final : public QScrollArea { Q_OBJECT public: explicit MainWindowSideBar(Rpc* rpc, TorrentsProxyModel* proxyModel, QWidget* parent = nullptr); }; } #endif // TREMOTESF_MAINWINDOWSIDEBAR_H tremotesf-2.8.2/src/ui/screens/mainwindow/mainwindowstatusbar.cpp000066400000000000000000000201161500171105600253620ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "mainwindowstatusbar.h" #include #include #include #include #include #include #include #include #include "log/log.h" #include "rpc/serverstats.h" #include "rpc/servers.h" #include "rpc/rpc.h" #include "formatutils.h" namespace tremotesf { MainWindowStatusBar::MainWindowStatusBar(const Rpc* rpc, QWidget* parent) : QStatusBar(parent), mRpc(rpc) { setSizeGripEnabled(false); setContextMenuPolicy(Qt::CustomContextMenu); QObject::connect(this, &QWidget::customContextMenuRequested, this, &MainWindowStatusBar::showContextMenu); auto container = new QWidget(this); addPermanentWidget(container, 1); auto layout = new QHBoxLayout(container); // Top/bottom margins are set on mServerLabel below so that they don't affect separators layout->setContentsMargins(8, 0, 8, 0); mNoServersErrorImage = new QLabel(this); mNoServersErrorImage->setPixmap(QIcon::fromTheme("dialog-error"_l1).pixmap(16, 16)); mNoServersErrorImage->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); layout->addWidget(mNoServersErrorImage); mServerLabel = new QLabel(this); mServerLabel->setContentsMargins(0, 5, 0, 5); layout->addWidget(mServerLabel); mFirstSeparator = new StatusBarSeparator(this); layout->addWidget(mFirstSeparator); mStatusLabel = new QLabel(this); layout->addWidget(mStatusLabel); mSecondSeparator = new StatusBarSeparator(this); layout->addWidget(mSecondSeparator); mDownloadSpeedImage = new QLabel(this); mDownloadSpeedImage->setPixmap(QIcon::fromTheme("go-down"_l1).pixmap(16, 16)); mDownloadSpeedImage->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); layout->addWidget(mDownloadSpeedImage); mDownloadSpeedLabel = new QLabel(this); layout->addWidget(mDownloadSpeedLabel); mThirdSeparator = new StatusBarSeparator(this); layout->addWidget(mThirdSeparator); mUploadSpeedImage = new QLabel(this); mUploadSpeedImage->setPixmap(QIcon::fromTheme("go-up"_l1).pixmap(16, 16)); mUploadSpeedImage->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); layout->addWidget(mUploadSpeedImage); mUploadSpeedLabel = new QLabel(this); layout->addWidget(mUploadSpeedLabel); updateLayout(); QObject::connect(mRpc, &Rpc::connectionStateChanged, this, &MainWindowStatusBar::updateLayout); QObject::connect(Servers::instance(), &Servers::hasServersChanged, this, &MainWindowStatusBar::updateLayout); updateServerLabel(); QObject::connect( Servers::instance(), &Servers::currentServerChanged, this, &MainWindowStatusBar::updateServerLabel ); QObject::connect( Servers::instance(), &Servers::hasServersChanged, this, &MainWindowStatusBar::updateServerLabel ); updateStatusLabels(); QObject::connect(mRpc, &Rpc::statusChanged, this, &MainWindowStatusBar::updateStatusLabels); QObject::connect(mRpc->serverStats(), &ServerStats::updated, this, [=, this] { mDownloadSpeedLabel->setText(formatutils::formatByteSpeed(mRpc->serverStats()->downloadSpeed())); mUploadSpeedLabel->setText(formatutils::formatByteSpeed(mRpc->serverStats()->uploadSpeed())); }); } void MainWindowStatusBar::updateLayout() { if (Servers::instance()->hasServers()) { mNoServersErrorImage->hide(); mServerLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); mFirstSeparator->show(); mStatusLabel->show(); if (mRpc->connectionState() == Rpc::ConnectionState::Connected) { mSecondSeparator->show(); mDownloadSpeedImage->show(); mDownloadSpeedLabel->show(); mThirdSeparator->show(); mUploadSpeedImage->show(); mUploadSpeedLabel->show(); } else { mSecondSeparator->hide(); mDownloadSpeedImage->hide(); mDownloadSpeedLabel->hide(); mThirdSeparator->hide(); mUploadSpeedImage->hide(); mUploadSpeedLabel->hide(); } } else { mNoServersErrorImage->show(); mServerLabel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); mFirstSeparator->hide(); mStatusLabel->hide(); mSecondSeparator->hide(); mDownloadSpeedImage->hide(); mDownloadSpeedLabel->hide(); mThirdSeparator->hide(); mUploadSpeedImage->hide(); mUploadSpeedLabel->hide(); } } void MainWindowStatusBar::updateServerLabel() { if (Servers::instance()->hasServers()) { mServerLabel->setText(QString::fromLatin1("%1 (%2)").arg( Servers::instance()->currentServerName(), Servers::instance()->currentServerAddress() )); } else { mServerLabel->setText(qApp->translate("tremotesf", "No servers")); } } void MainWindowStatusBar::updateStatusLabels() { mStatusLabel->setText(mRpc->status().toString()); if (mRpc->error() != RpcError::NoError) { mStatusLabel->setToolTip(mRpc->errorMessage()); } else { mStatusLabel->setToolTip({}); } } void MainWindowStatusBar::showContextMenu(QPoint pos) { auto* const menu = new QMenu(this); auto* const group = new QActionGroup(menu); group->setExclusive(true); menu->setAttribute(Qt::WA_DeleteOnClose, true); const auto servers = Servers::instance()->servers(); const auto currentServerName = Servers::instance()->currentServerName(); for (const auto& server : servers) { auto* const action = menu->addAction(server.name); action->setData(server.name); action->setCheckable(true); group->addAction(action); if (server.name == currentServerName) { action->setChecked(true); } } menu->addSeparator(); auto* const connectionSettingsAction = menu->addAction( QIcon::fromTheme("network-server"_l1), qApp->translate("tremotesf", "&Connection Settings") ); QObject::connect(menu, &QMenu::triggered, this, [this, connectionSettingsAction](QAction* action) { if (action == connectionSettingsAction) { emit showConnectionSettingsDialog(); } else { const auto selectedName = action->data().toString(); const auto servers = Servers::instance()->servers(); const auto found = std::ranges::find(servers, selectedName, &Server::name); if (found != servers.end()) { Servers::instance()->setCurrentServer(selectedName); } else { warning().log("Selected server {} which no longer exists", selectedName); } } }); menu->popup(mapToGlobal(pos)); } StatusBarSeparator::StatusBarSeparator(QWidget* parent) : QWidget(parent) { setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding); } QSize StatusBarSeparator::sizeHint() const { QStyleOption opt{}; opt.initFrom(this); opt.state.setFlag(QStyle::State_Horizontal); const int extent = style()->pixelMetric(QStyle::PM_ToolBarSeparatorExtent, &opt, this); return {extent, extent}; } void StatusBarSeparator::paintEvent(QPaintEvent*) { QPainter p(this); QStyleOption opt{}; opt.initFrom(this); opt.state.setFlag(QStyle::State_Horizontal); style()->drawPrimitive(QStyle::PE_IndicatorToolBarSeparator, &opt, &p, this); } } tremotesf-2.8.2/src/ui/screens/mainwindow/mainwindowstatusbar.h000066400000000000000000000026061500171105600250330ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_MAINWINDOWSTATUSBAR_H #define TREMOTESF_MAINWINDOWSTATUSBAR_H #include class QLabel; namespace tremotesf { class Rpc; class StatusBarSeparator; class MainWindowStatusBar final : public QStatusBar { Q_OBJECT public: explicit MainWindowStatusBar(const Rpc* rpc, QWidget* parent = nullptr); private: void updateLayout(); void updateServerLabel(); void updateStatusLabels(); void showContextMenu(QPoint pos); const Rpc* mRpc{}; QLabel* mNoServersErrorImage{}; QLabel* mServerLabel{}; StatusBarSeparator* mFirstSeparator{}; QLabel* mStatusLabel{}; StatusBarSeparator* mSecondSeparator{}; QLabel* mDownloadSpeedImage{}; QLabel* mDownloadSpeedLabel{}; StatusBarSeparator* mThirdSeparator{}; QLabel* mUploadSpeedImage{}; QLabel* mUploadSpeedLabel{}; signals: void showConnectionSettingsDialog(); }; class StatusBarSeparator final : public QWidget { Q_OBJECT public: explicit StatusBarSeparator(QWidget* parent = nullptr); QSize sizeHint() const override; protected: void paintEvent(QPaintEvent* event) override; }; } #endif // TREMOTESF_MAINWINDOWSTATUSBAR_H tremotesf-2.8.2/src/ui/screens/mainwindow/mainwindowviewmodel.cpp000066400000000000000000000337331500171105600253560ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "mainwindowviewmodel.h" #include #include #include #include #include #include #include #include #include "coroutines/qobjectsignal.h" #include "coroutines/timer.h" #include "coroutines/threadpool.h" #include "coroutines/waitall.h" #include "log/log.h" #include "ipc/ipcserver.h" #include "rpc/servers.h" #include "ui/screens/addtorrent/addtorrenthelpers.h" #include "ui/screens/addtorrent/droppedtorrents.h" #include "ui/notificationscontroller.h" #include "magnetlinkparser.h" #include "settings.h" #include "stdutils.h" #include "torrentfileparser.h" SPECIALIZE_FORMATTER_FOR_QDEBUG(QUrl) SPECIALIZE_FORMATTER_FOR_QDEBUG(Qt::DropActions) namespace tremotesf { namespace { std::string formatDropEvent(const QDropEvent* event) { const auto mime = event->mimeData(); return fmt::format( R"( proposedAction = {} possibleActions = {} formats = {} urls = {} text = {})", event->proposedAction(), event->possibleActions(), mime->formats(), mime->urls(), mime->text() ); } std::string formatMimeData(const QMimeData* mime) { return fmt::format( R"( formats = {} urls = {} text = {})", mime->formats(), mime->urls(), mime->text() ); } using namespace std::chrono_literals; constexpr auto initialDelayedTorrentAddMessageDelay = 500ms; } MainWindowViewModel::MainWindowViewModel( QStringList&& commandLineFiles, QStringList&& commandLineUrls, QObject* parent ) : QObject(parent) { if (!commandLineFiles.isEmpty() || !commandLineUrls.isEmpty()) { QMetaObject::invokeMethod( this, [this, files = std::move(commandLineFiles), urls = std::move(commandLineUrls)]() mutable { mAddingTorrentsCoroutineScope.launch(addTorrents(std::move(files), std::move(urls))); }, Qt::QueuedConnection ); } const auto* const ipcServer = IpcServer::createInstance(this); QObject::connect( ipcServer, &IpcServer::windowActivationRequested, this, [=, this](const auto& activationToken) { emit showWindow(activationToken); } ); QObject::connect( ipcServer, &IpcServer::torrentsAddingRequested, this, [=, this](const auto& files, const auto& urls, const auto& activationToken) { mAddingTorrentsCoroutineScope.launch(addTorrents(files, urls, activationToken)); } ); QObject::connect(Servers::instance(), &Servers::currentServerChanged, this, [this] { if (Servers::instance()->hasServers()) { mRpc.setConnectionConfiguration(Servers::instance()->currentServer().connectionConfiguration); mRpc.connect(); } else { mRpc.resetConnectionConfiguration(); } }); QObject::connect(&mRpc, &Rpc::aboutToDisconnect, this, [this] { Servers::instance()->saveCurrentServerLastTorrents(&mRpc); }); } void MainWindowViewModel::processDragEnterEvent(QDragEnterEvent* event) { debug().log("MainWindowViewModel: processing QDragEnterEvent"); debug().log("MainWindowViewModel: event: {}", formatDropEvent(event)); const auto dropped = DroppedTorrents(event->mimeData()); if (dropped.isEmpty()) { debug().log("MainWindowViewModel: not accepting QDragEnterEvent"); return; } info().log("MainWindowViewModel: accepting QDragEnterEvent"); if (event->possibleActions().testFlag(Qt::CopyAction)) { event->setDropAction(Qt::CopyAction); event->accept(); } else { event->acceptProposedAction(); } } void MainWindowViewModel::processDropEvent(QDropEvent* event) { debug().log("MainWindowViewModel: processing QDropEvent"); debug().log("MainWindowViewModel: event: {}", formatDropEvent(event)); auto dropped = DroppedTorrents(event->mimeData()); if (dropped.isEmpty()) { warning().log("Dropped torrents are empty"); return; } info().log("MainWindowViewModel: accepting QDropEvent"); event->acceptProposedAction(); mAddingTorrentsCoroutineScope.launch(addTorrents(std::move(dropped.files), std::move(dropped.urls))); } void MainWindowViewModel::pasteShortcutActivated() { debug().log("MainWindowViewModel: pasteShortcutActivated() called"); addTorrentsFromClipboard(); } void MainWindowViewModel::triggeredAddTorrentLinkAction() { debug().log("MainWindowViewModel: triggeredAddTorrentLinkAction() called"); if (Settings::instance()->get_fillTorrentLinkFromClipboard() && addTorrentsFromClipboard(true)) { return; } emit showAddTorrentDialogs({}, {QString{}}, {}); } void MainWindowViewModel::acceptedFileDialog(QStringList files) { debug().log("MainWindowViewModel: acceptedFileDialog() called with: files = {}", files); mAddingTorrentsCoroutineScope.launch(addTorrents(std::move(files), {})); } void MainWindowViewModel::setupNotificationsController(QSystemTrayIcon* trayIcon) { const auto controller = NotificationsController::createInstance(trayIcon, &mRpc, this); QObject::connect( controller, &NotificationsController::notificationClicked, this, [this](const std::optional& windowActivationToken) { emit showWindow(windowActivationToken); } ); } MainWindowViewModel::StartupActionResult MainWindowViewModel::performStartupAction() { if (!Servers::instance()->hasServers()) { return StartupActionResult::ShowAddServerDialog; } mRpc.setConnectionConfiguration(Servers::instance()->currentServer().connectionConfiguration); if (Settings::instance()->get_connectOnStartup()) { mRpc.connect(); } return StartupActionResult::DoNothing; } bool MainWindowViewModel::addTorrentsFromClipboard(bool onlyUrls) { const auto* const mimeData = QGuiApplication::clipboard()->mimeData(); if (!mimeData) { warning().log("MainWindowViewModel: clipboard data is null"); return false; } debug().log("MainWindowViewModel: clipboard data: {}", formatMimeData(mimeData)); if (addTorrentsFromMimeData(mimeData, onlyUrls)) { return true; } debug().log("MainWindowViewModel: ignoring clipboard data"); return false; } bool MainWindowViewModel::addTorrentsFromMimeData(const QMimeData* mimeData, bool onlyUrls) { auto dropped = DroppedTorrents(mimeData); if (dropped.isEmpty()) { return false; } mAddingTorrentsCoroutineScope.launch( addTorrents(onlyUrls ? QStringList{} : std::move(dropped.files), std::move(dropped.urls)) ); return true; } Coroutine>>>> MainWindowViewModel::separateTorrentsThatAlreadyExistForFiles( // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) QStringList& files ) { std::vector> torrentFiles{}; torrentFiles.reserve(static_cast(files.size())); co_await waitAll(toContainer(files | std::views::transform([&](const QString& filePath) { return parseTorrentFile(filePath, torrentFiles); }))); std::vector>>> existingTorrents{}; for (auto& [filePath, torrentFile] : torrentFiles) { auto* const torrent = mRpc.torrentByHash(torrentFile.infoHashV1); if (torrent) { existingTorrents.emplace_back(torrent, std::move(torrentFile.trackers)); files.removeOne(filePath); } } co_return existingTorrents; } Coroutine<> MainWindowViewModel::parseTorrentFile( QString filePath, // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) std::vector>& output ) { try { info().log("Parsing torrent file {}", filePath); auto torrentFile = co_await runOnThreadPool(&tremotesf::parseTorrentFile, filePath); info().log("Parsed {}, result = {}", filePath, torrentFile); output.emplace_back(std::move(filePath), std::move(torrentFile)); } catch (const bencode::Error& e) { warning().logWithException(e, "Failed to parse torrent file {}", filePath); } } void MainWindowViewModel::addTorrentFilesWithoutDialog(const QStringList& files) { if (files.isEmpty()) return; const auto parameters = getAddTorrentParameters(&mRpc); for (const auto& filePath : files) { mRpc.addTorrentFile( filePath, parameters.downloadDirectory, {}, {}, {}, {}, parameters.priority, parameters.startAfterAdding, parameters.deleteTorrentFile ? (parameters.moveTorrentFileToTrash ? Rpc::DeleteFileMode::MoveToTrash : Rpc::DeleteFileMode::Delete) : Rpc::DeleteFileMode::No, {} ); } } std::vector>>> MainWindowViewModel::separateTorrentsThatAlreadyExistForLinks(QStringList& urls) { std::vector>>> existingTorrents{}; const auto toErase = std::ranges::remove_if(urls, [&](const QString& url) { try { info().log("Parsing {} as a magnet link", url); auto magnetLink = parseMagnetLink(QUrl(url)); info().log("Parsed, result = {}", magnetLink); auto* const torrent = mRpc.torrentByHash(magnetLink.infoHashV1); if (torrent) { existingTorrents.emplace_back(torrent, std::move(magnetLink.trackers)); return true; } } catch (const std::runtime_error& e) { warning().logWithException(e, "Failed to parse {} as a magnet link", url); } return false; }); if (!toErase.empty()) { urls.erase(toErase.begin(), toErase.end()); } return existingTorrents; } void MainWindowViewModel::addTorrentLinksWithoutDialog(QStringList urls) { if (urls.isEmpty()) return; auto parameters = getAddTorrentParameters(&mRpc); mRpc.addTorrentLinks( std::move(urls), std::move(parameters.downloadDirectory), parameters.priority, parameters.startAfterAdding, {} ); } Coroutine<> MainWindowViewModel::addTorrents( QStringList files, QStringList urls, std::optional windowActivationToken ) { info().log("MainWindowViewModel: addTorrents() called"); info().log("MainWindowViewModel: files = {}", files); info().log("MainWindowViewModel: urls = {}", urls); const auto* const settings = Settings::instance(); const auto showMainWindowIfNeeded = [&] { if (settings->get_showMainWindowWhenAddingTorrent()) { emit showWindow(windowActivationToken); windowActivationToken.reset(); } }; if (!mRpc.isConnected()) { info().log("Postponing opening torrents until connected to server"); if (mRpc.connectionState() == RpcConnectionState::Connecting) { info().log("We are already connecting, wait a bit before showing message"); co_await waitAny( []() -> Coroutine<> { co_await waitFor(initialDelayedTorrentAddMessageDelay); }(), [](Rpc* rpc) -> Coroutine<> { co_await waitForSignal(rpc, &Rpc::connectedChanged); }(&mRpc) ); } if (!mRpc.isConnected()) { info().log("Showing delayed torrent adding message"); showMainWindowIfNeeded(); emit showDelayedTorrentAddDialog(files + urls, windowActivationToken); windowActivationToken.reset(); co_await waitForSignal(&mRpc, &Rpc::connectedChanged); } } showMainWindowIfNeeded(); // We can parse magnet links immediately so check whether torrents exist event if we show dialogs if (const auto existingTorrents = separateTorrentsThatAlreadyExistForLinks(urls); !existingTorrents.empty()) { emit askForMergingTrackers(existingTorrents, windowActivationToken); windowActivationToken.reset(); } if (settings->get_showAddTorrentDialog()) { emit showAddTorrentDialogs(files, urls, windowActivationToken); } else { addTorrentLinksWithoutDialog(urls); if (const auto existingTorrents = co_await separateTorrentsThatAlreadyExistForFiles(files); !existingTorrents.empty()) { emit askForMergingTrackers(existingTorrents, windowActivationToken); windowActivationToken.reset(); } addTorrentFilesWithoutDialog(files); } } } tremotesf-2.8.2/src/ui/screens/mainwindow/mainwindowviewmodel.h000066400000000000000000000052531500171105600250170ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_MAINWINDOWVIEWMODEL_H #define TREMOTESF_MAINWINDOWVIEWMODEL_H #include #include #include "rpc/rpc.h" #include "coroutines/scope.h" class QByteArray; class QDragEnterEvent; class QDropEvent; class QMimeData; class QString; class QSystemTrayIcon; namespace tremotesf { class TorrentMetainfoFile; class MainWindowViewModel final : public QObject { Q_OBJECT public: MainWindowViewModel(QStringList&& commandLineFiles, QStringList&& commandLineUrls, QObject* parent = nullptr); Rpc* rpc() { return &mRpc; }; static void processDragEnterEvent(QDragEnterEvent* event); void processDropEvent(QDropEvent* event); void pasteShortcutActivated(); void triggeredAddTorrentLinkAction(); void acceptedFileDialog(QStringList files); void setupNotificationsController(QSystemTrayIcon* trayIcon); enum class StartupActionResult { ShowAddServerDialog, DoNothing }; StartupActionResult performStartupAction(); private: Rpc mRpc{}; CoroutineScope mAddingTorrentsCoroutineScope{}; bool addTorrentsFromClipboard(bool onlyUrls = false); bool addTorrentsFromMimeData(const QMimeData* mimeData, bool onlyUrls); Coroutine>>>> separateTorrentsThatAlreadyExistForFiles(QStringList& files); Coroutine<> parseTorrentFile(QString filePath, std::vector>& output); void addTorrentFilesWithoutDialog(const QStringList& files); std::vector>>> separateTorrentsThatAlreadyExistForLinks(QStringList& urls); void addTorrentLinksWithoutDialog(QStringList urls); Coroutine<> addTorrents(QStringList files, QStringList urls, std::optional windowActivationToken = {}); signals: void showWindow(const std::optional& windowActivationToken); void showAddTorrentDialogs( const QStringList& files, const QStringList& urls, const std::optional& windowActivationToken ); void askForMergingTrackers( const std::vector>>>& existingTorrents, const std::optional& windowActivationToken ); void showDelayedTorrentAddDialog( const QStringList& torrents, const std::optional& windowActivationToken ); }; } #endif // TREMOTESF_MAINWINDOWVIEWMODEL_H tremotesf-2.8.2/src/ui/screens/mainwindow/statusfiltersmodel.cpp000066400000000000000000000160771500171105600252250ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "statusfiltersmodel.h" #include #include #include #include "rpc/rpc.h" #include "ui/itemmodels/modelutils.h" #include "desktoputils.h" #include "torrentsproxymodel.h" namespace tremotesf { QVariant StatusFiltersModel::data(const QModelIndex& index, int role) const { if (!index.isValid()) { return {}; } const Item& item = mItems.at(static_cast(index.row())); switch (role) { case FilterRole: return item.filter; case Qt::DecorationRole: switch (item.filter) { case TorrentsProxyModel::All: { return desktoputils::standardDirIcon(); } case TorrentsProxyModel::Active: return desktoputils::statusIcon(desktoputils::ActiveIcon); case TorrentsProxyModel::Downloading: return desktoputils::statusIcon(desktoputils::DownloadingIcon); case TorrentsProxyModel::Seeding: return desktoputils::statusIcon(desktoputils::SeedingIcon); case TorrentsProxyModel::Paused: return desktoputils::statusIcon(desktoputils::PausedIcon); case TorrentsProxyModel::Checking: return desktoputils::statusIcon(desktoputils::CheckingIcon); case TorrentsProxyModel::Errored: return desktoputils::statusIcon(desktoputils::ErroredIcon); default: break; } break; case Qt::DisplayRole: case Qt::ToolTipRole: switch (item.filter) { case TorrentsProxyModel::All: //: Filter option of torrents list's status filter. %L1 is total number of torrents return qApp->translate("tremotesf", "All (%L1)").arg(item.torrents); case TorrentsProxyModel::Active: //: Filter option of torrents list's status filter. %L1 is a number of torrents with that status return qApp->translate("tremotesf", "Active (%L1)").arg(item.torrents); case TorrentsProxyModel::Downloading: //: Filter option of torrents list's status filter. %L1 is a number of torrents with that status return qApp->translate("tremotesf", "Downloading (%L1)").arg(item.torrents); case TorrentsProxyModel::Seeding: //: Filter option of torrents list's status filter. %L1 is a number of torrents with that status return qApp->translate("tremotesf", "Seeding (%L1)").arg(item.torrents); case TorrentsProxyModel::Paused: //: Filter option of torrents list's status filter. %L1 is a number of torrents with that status return qApp->translate("tremotesf", "Paused (%L1)").arg(item.torrents); case TorrentsProxyModel::Checking: //: Filter option of torrents list's status filter. %L1 is a number of torrents with that status return qApp->translate("tremotesf", "Checking (%L1)").arg(item.torrents); case TorrentsProxyModel::Errored: //: Filter option of torrents list's status filter. %L1 is a number of torrents with that status return qApp->translate("tremotesf", "Errored (%L1)").arg(item.torrents); default: break; } break; default: break; } return {}; } int StatusFiltersModel::rowCount(const QModelIndex&) const { return static_cast(mItems.size()); } QModelIndex StatusFiltersModel::indexForTorrentsProxyModelFilter() const { if (!torrentsProxyModel()) { return {}; } const auto filter = torrentsProxyModel()->statusFilter(); for (size_t i = 0, max = mItems.size(); i < max; ++i) { const auto& item = mItems[i]; if (item.filter == filter) { return index(static_cast(i)); } } return {}; } void StatusFiltersModel::resetTorrentsProxyModelFilter() const { if (torrentsProxyModel()) { torrentsProxyModel()->setStatusFilter(TorrentsProxyModel::All); } } class StatusFiltersModelUpdater final : public ModelListUpdater< StatusFiltersModel, StatusFiltersModel::Item, std::map> { public: inline explicit StatusFiltersModelUpdater(StatusFiltersModel& model) : ModelListUpdater(model) {} protected: std::map::iterator findNewItemForItem( std::map& newItems, const StatusFiltersModel::Item& item ) override { return newItems.find(item.filter); } bool updateItem( StatusFiltersModel::Item& item, // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) std::pair&& newItem ) override { const auto& [filter, torrents] = newItem; if (item.torrents != torrents) { item.torrents = torrents; return true; } return false; } StatusFiltersModel::Item createItemFromNewItem( // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) std::pair&& newTracker ) override { return StatusFiltersModel::Item{.filter = newTracker.first, .torrents = newTracker.second}; } }; void StatusFiltersModel::update() { std::map items{ {TorrentsProxyModel::All, rpc()->torrentsCount()}, {TorrentsProxyModel::Active, 0}, {TorrentsProxyModel::Downloading, 0}, {TorrentsProxyModel::Seeding, 0}, {TorrentsProxyModel::Paused, 0}, {TorrentsProxyModel::Checking, 0}, {TorrentsProxyModel::Errored, 0} }; const auto processFilter = [&](Torrent* torrent, TorrentsProxyModel::StatusFilter filter) { if (TorrentsProxyModel::statusFilterAcceptsTorrent(torrent, filter)) { ++(items.find(filter)->second); } }; for (const auto& torrent : rpc()->torrents()) { processFilter(torrent.get(), TorrentsProxyModel::Active); processFilter(torrent.get(), TorrentsProxyModel::Downloading); processFilter(torrent.get(), TorrentsProxyModel::Seeding); processFilter(torrent.get(), TorrentsProxyModel::Paused); processFilter(torrent.get(), TorrentsProxyModel::Checking); processFilter(torrent.get(), TorrentsProxyModel::Errored); } StatusFiltersModelUpdater updater(*this); updater.update(mItems, std::move(items)); } } tremotesf-2.8.2/src/ui/screens/mainwindow/statusfiltersmodel.h000066400000000000000000000021541500171105600246610ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_STATUSFILTERSMODEL_H #define TREMOTESF_STATUSFILTERSMODEL_H #include #include "basetorrentsfilterssettingsmodel.h" #include "torrentsproxymodel.h" namespace tremotesf { class StatusFiltersModel final : public BaseTorrentsFiltersSettingsModel { Q_OBJECT public: static constexpr auto FilterRole = Qt::UserRole; inline explicit StatusFiltersModel(QObject* parent = nullptr) : BaseTorrentsFiltersSettingsModel(parent) {}; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; int rowCount(const QModelIndex& = {}) const override; QModelIndex indexForTorrentsProxyModelFilter() const override; struct Item { TorrentsProxyModel::StatusFilter filter; int torrents; }; protected: void resetTorrentsProxyModelFilter() const override; private: void update() override; std::vector mItems; }; } #endif // TREMOTESF_STATUSFILTERSMODEL_H tremotesf-2.8.2/src/ui/screens/mainwindow/torrentsmodel.cpp000066400000000000000000000474431500171105600241720ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "torrentsmodel.h" #include #include #include #include #include "rpc/pathutils.h" #include "rpc/rpc.h" #include "rpc/serversettings.h" #include "rpc/torrent.h" #include "desktoputils.h" #include "formatutils.h" #include "settings.h" #include "stdutils.h" namespace tremotesf { TorrentsModel::TorrentsModel(Rpc* rpc, QObject* parent) : QAbstractTableModel(parent), mRpc(nullptr) { setRpc(rpc); const auto settings = Settings::instance(); mUseRelativeTime = settings->get_displayRelativeTime(); QObject::connect(settings, &Settings::displayRelativeTimeChanged, this, [this, settings] { mUseRelativeTime = settings->get_displayRelativeTime(); }); mDisplayFullDownloadDirectoryPath = settings->get_displayFullDownloadDirectoryPath(); QObject::connect(settings, &Settings::displayFullDownloadDirectoryPathChanged, this, [this, settings] { mDisplayFullDownloadDirectoryPath = settings->get_displayFullDownloadDirectoryPath(); }); } int TorrentsModel::columnCount(const QModelIndex&) const { return QMetaEnum::fromType().keyCount(); } QVariant TorrentsModel::data(const QModelIndex& index, int role) const { if (!index.isValid()) { return {}; } Torrent* torrent = mRpc->torrents().at(static_cast(index.row())).get(); switch (role) { case Qt::DecorationRole: if (static_cast(index.column()) == Column::Name) { using namespace desktoputils; if (torrent->data().error != TorrentData::Error::None) { return statusIcon(ErroredIcon); } switch (torrent->data().status) { case TorrentData::Status::Paused: return statusIcon(PausedIcon); case TorrentData::Status::Seeding: if (torrent->data().isSeedingStalled()) { return statusIcon(StalledSeedingIcon); } return statusIcon(SeedingIcon); case TorrentData::Status::Downloading: if (torrent->data().isDownloadingStalled()) { return statusIcon(StalledDownloadingIcon); } return statusIcon(DownloadingIcon); case TorrentData::Status::QueuedForDownloading: case TorrentData::Status::QueuedForSeeding: return statusIcon(QueuedIcon); case TorrentData::Status::Checking: case TorrentData::Status::QueuedForChecking: return statusIcon(CheckingIcon); } } break; case Qt::DisplayRole: switch (static_cast(index.column())) { case Column::Name: return torrent->data().name; case Column::SizeWhenDone: return formatutils::formatByteSize(torrent->data().sizeWhenDone); case Column::TotalSize: return formatutils::formatByteSize(torrent->data().totalSize); case Column::ProgressBar: case Column::Progress: if (torrent->data().status == TorrentData::Status::Checking) { return formatutils::formatProgress(torrent->data().recheckProgress); } return formatutils::formatProgress(torrent->data().percentDone); case Column::Status: { switch (torrent->data().status) { case TorrentData::Status::Paused: if (torrent->data().hasError()) { //: Torrent status while torrent also has an error. %1 is error string return qApp->translate("tremotesf", "Paused (%1)").arg(torrent->data().errorString); } //: Torrent status return qApp->translate("tremotesf", "Paused"); case TorrentData::Status::Downloading: if (torrent->data().hasError()) { //: Torrent status while torrent also has an error. %1 is error string return qApp->translate("tremotesf", "Downloading (%1)").arg(torrent->data().errorString); } //: Torrent status return qApp->translate("tremotesf", "Downloading", "Torrent status"); case TorrentData::Status::Seeding: if (torrent->data().hasError()) { //: Torrent status while torrent also has an error. %1 is error string return qApp->translate("tremotesf", "Seeding (%1)").arg(torrent->data().errorString); } //: Torrent status return qApp->translate("tremotesf", "Seeding", "Torrent status"); case TorrentData::Status::QueuedForDownloading: case TorrentData::Status::QueuedForSeeding: if (torrent->data().hasError()) { //: Torrent status while torrent also has an error. %1 is error string return qApp->translate("tremotesf", "Queued (%1)").arg(torrent->data().errorString); } //: Torrent status return qApp->translate("tremotesf", "Queued"); case TorrentData::Status::Checking: if (torrent->data().hasError()) { //: Torrent status while torrent also has an error. %1 is error string return qApp->translate("tremotesf", "Checking (%1)").arg(torrent->data().errorString); } //: Torrent status return qApp->translate("tremotesf", "Checking"); case TorrentData::Status::QueuedForChecking: if (torrent->data().hasError()) { //: Torrent status while torrent also has an error. %1 is error string return qApp->translate("tremotesf", "Queued for checking (%1)") .arg(torrent->data().errorString); } //: Torrent status return qApp->translate("tremotesf", "Queued for checking"); } break; } case Column::Priority: switch (torrent->data().bandwidthPriority) { case TorrentData::Priority::High: //: Torrent's loading priority return qApp->translate("tremotesf", "High"); case TorrentData::Priority::Normal: //: Torrent's loading priority return qApp->translate("tremotesf", "Normal"); case TorrentData::Priority::Low: //: Torrent's loading priority return qApp->translate("tremotesf", "Low"); } break; case Column::QueuePosition: return torrent->data().queuePosition; case Column::Seeders: return torrent->data().totalSeedersFromTrackersCount; case Column::Leechers: return torrent->data().totalLeechersFromTrackersCount; case Column::PeersSendingToUs: return torrent->data().peersSendingToUsCount; case Column::PeersGettingFromUs: return torrent->data().peersGettingFromUsCount; case Column::DownloadSpeed: return formatutils::formatByteSpeed(torrent->data().downloadSpeed); case Column::UploadSpeed: return formatutils::formatByteSpeed(torrent->data().uploadSpeed); case Column::Eta: return formatutils::formatEta(torrent->data().eta); case Column::Ratio: return formatutils::formatRatio(torrent->data().ratio); case Column::AddedDate: return formatutils::formatDateTime( torrent->data().addedDate.toLocalTime(), QLocale::ShortFormat, mUseRelativeTime ); case Column::DoneDate: return formatutils::formatDateTime( torrent->data().doneDate.toLocalTime(), QLocale::ShortFormat, mUseRelativeTime ); case Column::DownloadSpeedLimit: if (torrent->data().downloadSpeedLimited) { return formatutils::formatSpeedLimit(torrent->data().downloadSpeedLimit); } break; case Column::UploadSpeedLimit: if (torrent->data().uploadSpeedLimited) { return formatutils::formatSpeedLimit(torrent->data().uploadSpeedLimit); } break; case Column::TotalDownloaded: return formatutils::formatByteSize(torrent->data().totalDownloaded); case Column::TotalUploaded: return formatutils::formatByteSize(torrent->data().totalUploaded); case Column::LeftUntilDone: return formatutils::formatByteSize(torrent->data().leftUntilDone); case Column::DownloadDirectory: if (mDisplayFullDownloadDirectoryPath) { return toNativeSeparators(torrent->data().downloadDirectory, mRpc->serverSettings()->data().pathOs); } return lastPathSegment(torrent->data().downloadDirectory); case Column::CompletedSize: return formatutils::formatByteSize(torrent->data().completedSize); case Column::ActivityDate: return formatutils::formatDateTime( torrent->data().activityDate.toLocalTime(), QLocale::ShortFormat, mUseRelativeTime ); default: break; } break; case Qt::ToolTipRole: switch (static_cast(index.column())) { case Column::Name: case Column::Status: case Column::AddedDate: case Column::DoneDate: case Column::ActivityDate: return data(index, Qt::DisplayRole); case Column::DownloadDirectory: return torrent->data().downloadDirectory; default: break; } break; case static_cast(Role::Sort): switch (static_cast(index.column())) { case Column::SizeWhenDone: return torrent->data().sizeWhenDone; case Column::TotalSize: return torrent->data().totalSize; case Column::ProgressBar: case Column::Progress: if (torrent->data().status == TorrentData::Status::Checking) { return torrent->data().recheckProgress; } return torrent->data().percentDone; case Column::Priority: return static_cast(torrent->data().bandwidthPriority); case Column::Status: return static_cast(torrent->data().status); case Column::DownloadSpeed: return torrent->data().downloadSpeed; case Column::UploadSpeed: return torrent->data().uploadSpeed; case Column::Eta: { const auto eta = torrent->data().eta; if (eta < 0) { return std::numeric_limits::max(); } return eta; } case Column::Ratio: return torrent->data().ratio; case Column::AddedDate: return torrent->data().addedDate; case Column::DoneDate: return torrent->data().doneDate; case Column::DownloadSpeedLimit: if (torrent->data().downloadSpeedLimited) { return torrent->data().downloadSpeedLimit; } return -1; case Column::UploadSpeedLimit: if (torrent->data().uploadSpeedLimited) { return torrent->data().uploadSpeedLimit; } return -1; case Column::TotalDownloaded: return torrent->data().totalDownloaded; case Column::TotalUploaded: return torrent->data().totalUploaded; case Column::LeftUntilDone: return torrent->data().leftUntilDone; case Column::CompletedSize: return torrent->data().completedSize; case Column::ActivityDate: return torrent->data().activityDate; default: return data(index, Qt::DisplayRole); } case static_cast(Role::TextElideMode): if (static_cast(index.column()) == Column::DownloadDirectory) { return Qt::ElideMiddle; } return Qt::ElideRight; case static_cast(Role::AlwaysShowTooltip): return static_cast(index.column()) == Column::DownloadDirectory && !mDisplayFullDownloadDirectoryPath; default: break; } return {}; } QVariant TorrentsModel::headerData(int section, Qt::Orientation orientation, int role) const { if (orientation != Qt::Horizontal || role != Qt::DisplayRole) { return {}; } switch (static_cast(section)) { case Column::Name: //: Torrents list column name return qApp->translate("tremotesf", "Name"); case Column::SizeWhenDone: //: Torrents list column name return qApp->translate("tremotesf", "Size"); case Column::TotalSize: //: Torrents list column name return qApp->translate("tremotesf", "Total Size"); case Column::ProgressBar: //: Torrents list column name return qApp->translate("tremotesf", "Progress Bar"); case Column::Progress: //: Torrents list column name return qApp->translate("tremotesf", "Progress"); case Column::Priority: //: Torrents list column name return qApp->translate("tremotesf", "Priority"); case Column::QueuePosition: //: Torrents list column name return qApp->translate("tremotesf", "Queue Position"); case Column::Status: //: Torrents list column name return qApp->translate("tremotesf", "Status"); case Column::Seeders: //: Torrents list column name, number of seeders reported by trackers return qApp->translate("tremotesf", "Seeders"); case Column::Leechers: //: Torrents list column name, number of leechers reported by trackers return qApp->translate("tremotesf", "Leechers"); case Column::PeersSendingToUs: //: Torrents list column name, number of peers that we are downloading from return qApp->translate("tremotesf", "Downloading to peers"); case Column::PeersGettingFromUs: //: Torrents list column name, number of peers that we are uploading to return qApp->translate("tremotesf", "Uploading to peers"); case Column::DownloadSpeed: //: Torrents list column name return qApp->translate("tremotesf", "Down Speed"); case Column::UploadSpeed: //: Torrents list column name return qApp->translate("tremotesf", "Up Speed"); case Column::Eta: //: Torrents list column name return qApp->translate("tremotesf", "ETA"); case Column::Ratio: //: Torrents list column name return qApp->translate("tremotesf", "Ratio"); case Column::AddedDate: //: Torrents list column name, date/time when torrent was added return qApp->translate("tremotesf", "Added on"); case Column::DoneDate: //: Torrents list column name, date/time when torrent was completed return qApp->translate("tremotesf", "Completed on"); case Column::DownloadSpeedLimit: //: Torrents list column name, download speed limit return qApp->translate("tremotesf", "Down Limit"); case Column::UploadSpeedLimit: //: Torrents list column name, upload speed limit return qApp->translate("tremotesf", "Up Limit"); case Column::TotalDownloaded: //: Torrents list column name, downloaded byte size return qApp->translate("tremotesf", "Downloaded"); case Column::TotalUploaded: //: Torrents list column name, uploaded byte size return qApp->translate("tremotesf", "Uploaded"); case Column::LeftUntilDone: //: Torrents list column name, remaining byte size return qApp->translate("tremotesf", "Remaining"); case Column::DownloadDirectory: //: Torrents list column name return qApp->translate("tremotesf", "Download Directory"); case Column::CompletedSize: //: Torrents list column name, completed byte size return qApp->translate("tremotesf", "Completed"); case Column::ActivityDate: //: Torrents list column name return qApp->translate("tremotesf", "Last Activity"); default: return {}; } } int TorrentsModel::rowCount(const QModelIndex&) const { return static_cast(mRpc->torrentsCount()); } Rpc* TorrentsModel::rpc() const { return mRpc; } void TorrentsModel::setRpc(Rpc* rpc) { if (rpc != mRpc) { if (const auto oldRpc = mRpc.data(); oldRpc) { QObject::disconnect(oldRpc, nullptr, this, nullptr); } mRpc = rpc; if (rpc) { QObject::connect(rpc, &Rpc::onAboutToAddTorrents, this, [=, this](size_t count) { const auto first = mRpc->torrentsCount(); beginInsertRows({}, first, first + static_cast(count) - 1); }); QObject::connect(rpc, &Rpc::onAddedTorrents, this, [=, this] { endInsertRows(); }); QObject::connect(rpc, &Rpc::onAboutToRemoveTorrents, this, [=, this](size_t first, size_t last) { beginRemoveRows({}, static_cast(first), static_cast(last - 1)); }); QObject::connect(rpc, &Rpc::onRemovedTorrents, this, [=, this] { endRemoveRows(); }); QObject::connect(rpc, &Rpc::onChangedTorrents, this, [=, this](size_t first, size_t last) { emit dataChanged( index(static_cast(first), 0), index(static_cast(last - 1), columnCount() - 1) ); }); const auto count = rpc->torrentsCount(); if (count != 0) { beginInsertRows({}, 0, count - 1); endInsertRows(); } } } } Torrent* TorrentsModel::torrentAtIndex(const QModelIndex& index) const { return torrentAtRow(index.row()); } Torrent* TorrentsModel::torrentAtRow(int row) const { return mRpc->torrents()[static_cast(row)].get(); } std::vector TorrentsModel::idsFromIndexes(const QModelIndexList& indexes) const { return toContainer(indexes | std::views::transform([this](const QModelIndex& index) { return torrentAtIndex(index)->data().id; })); } } tremotesf-2.8.2/src/ui/screens/mainwindow/torrentsmodel.h000066400000000000000000000037731500171105600236350ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_TORRENTSMODEL_H #define TREMOTESF_TORRENTSMODEL_H #include #include #include namespace tremotesf { class Torrent; } namespace tremotesf { class Rpc; class TorrentsModel final : public QAbstractTableModel { Q_OBJECT public: enum class Column { Name, SizeWhenDone, TotalSize, ProgressBar, Progress, Status, Priority, QueuePosition, Seeders, Leechers, PeersSendingToUs, PeersGettingFromUs, DownloadSpeed, UploadSpeed, Eta, Ratio, AddedDate, DoneDate, DownloadSpeedLimit, UploadSpeedLimit, TotalDownloaded, TotalUploaded, LeftUntilDone, DownloadDirectory, CompletedSize, ActivityDate }; Q_ENUM(Column) enum class Role { Sort = Qt::UserRole, TextElideMode, AlwaysShowTooltip }; explicit TorrentsModel(Rpc* rpc = nullptr, QObject* parent = nullptr); int columnCount(const QModelIndex& = {}) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; int rowCount(const QModelIndex& = {}) const override; Rpc* rpc() const; void setRpc(Rpc* rpc); Torrent* torrentAtIndex(const QModelIndex& index) const; Torrent* torrentAtRow(int row) const; std::vector idsFromIndexes(const QModelIndexList& indexes) const; private: QPointer mRpc; bool mUseRelativeTime{}; bool mDisplayFullDownloadDirectoryPath{}; }; } #endif // TREMOTESF_TORRENTSMODEL_H tremotesf-2.8.2/src/ui/screens/mainwindow/torrentsproxymodel.cpp000066400000000000000000000165011500171105600252630ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include "torrentsproxymodel.h" #include "rpc/torrent.h" #include "rpc/tracker.h" #include "settings.h" #include "torrentsmodel.h" namespace tremotesf { TorrentsProxyModel::TorrentsProxyModel(TorrentsModel* sourceModel, QObject* parent) : BaseProxyModel( sourceModel, static_cast(TorrentsModel::Role::Sort), static_cast(TorrentsModel::Column::Name), parent ), mStatusFilterEnabled(Settings::instance()->get_torrentsStatusFilterEnabled()), mStatusFilter(Settings::instance()->get_torrentsStatusFilter()), mLabelFilterEnabled(Settings::instance()->get_torrentsLabelFilterEnabled()), mLabelFilter(Settings::instance()->get_torrentsLabelFilter()), mTrackerFilterEnabled(Settings::instance()->get_torrentsTrackerFilterEnabled()), mTrackerFilter(Settings::instance()->get_torrentsTrackerFilter()), mDownloadDirectoryFilterEnabled(Settings::instance()->get_torrentsDownloadDirectoryFilterEnabled()), mDownloadDirectoryFilter(Settings::instance()->get_torrentsDownloadDirectoryFilter()) {} QString TorrentsProxyModel::searchString() const { return mSearchString; } void TorrentsProxyModel::setSearchString(const QString& string) { if (string != mSearchString) { mSearchString = string; invalidateFilter(); } } bool TorrentsProxyModel::isStatusFilterEnabled() const { return mStatusFilterEnabled; } void TorrentsProxyModel::setStatusFilterEnabled(bool enabled) { if (enabled != mStatusFilterEnabled) { mStatusFilterEnabled = enabled; invalidateFilter(); Settings::instance()->set_torrentsStatusFilterEnabled(mStatusFilterEnabled); } } TorrentsProxyModel::StatusFilter TorrentsProxyModel::statusFilter() const { return mStatusFilter; } void TorrentsProxyModel::setStatusFilter(TorrentsProxyModel::StatusFilter filter) { if (filter != mStatusFilter) { mStatusFilter = filter; invalidateFilter(); emit statusFilterChanged(); Settings::instance()->set_torrentsStatusFilter(mStatusFilter); } } bool TorrentsProxyModel::isLabelFilterEnabled() const { return mLabelFilterEnabled; } void TorrentsProxyModel::setLabelFilterEnabled(bool enabled) { if (enabled != mLabelFilterEnabled) { mLabelFilterEnabled = enabled; invalidateFilter(); Settings::instance()->set_torrentsLabelFilterEnabled(mLabelFilterEnabled); } } QString TorrentsProxyModel::labelFilter() const { return mLabelFilter; } void TorrentsProxyModel::setLabelFilter(const QString& filter) { if (filter != mLabelFilter) { mLabelFilter = filter; invalidateFilter(); emit labelFilterChanged(); Settings::instance()->set_torrentsLabelFilter(mLabelFilter); } } bool TorrentsProxyModel::isTrackerFilterEnabled() const { return mTrackerFilterEnabled; } void TorrentsProxyModel::setTrackerFilterEnabled(bool enabled) { if (enabled != mTrackerFilterEnabled) { mTrackerFilterEnabled = enabled; invalidateFilter(); Settings::instance()->set_torrentsTrackerFilterEnabled(mTrackerFilterEnabled); } } QString TorrentsProxyModel::trackerFilter() const { return mTrackerFilter; } void TorrentsProxyModel::setTrackerFilter(const QString& filter) { if (filter != mTrackerFilter) { mTrackerFilter = filter; invalidateFilter(); emit trackerFilterChanged(); Settings::instance()->set_torrentsTrackerFilter(mTrackerFilter); } } bool TorrentsProxyModel::isDownloadDirectoryFilterEnabled() const { return mDownloadDirectoryFilterEnabled; } void TorrentsProxyModel::setDownloadDirectoryFilterEnabled(bool enabled) { if (enabled != mDownloadDirectoryFilterEnabled) { mDownloadDirectoryFilterEnabled = enabled; invalidateFilter(); Settings::instance()->set_torrentsDownloadDirectoryFilterEnabled(mDownloadDirectoryFilterEnabled); } } QString TorrentsProxyModel::downloadDirectoryFilter() const { return mDownloadDirectoryFilter; } void TorrentsProxyModel::setDownloadDirectoryFilter(const QString& filter) { if (filter != mDownloadDirectoryFilter) { mDownloadDirectoryFilter = filter; invalidateFilter(); emit downloadDirectoryFilterChanged(); Settings::instance()->set_torrentsDownloadDirectoryFilter(mDownloadDirectoryFilter); } } bool TorrentsProxyModel::statusFilterAcceptsTorrent(const Torrent* torrent, StatusFilter filter) { switch (filter) { case Active: return ( (torrent->data().status == TorrentData::Status::Downloading && !torrent->data().isDownloadingStalled() ) || (torrent->data().status == TorrentData::Status::Seeding && !torrent->data().isSeedingStalled()) ); case Downloading: return ( torrent->data().status == TorrentData::Status::Downloading || torrent->data().status == TorrentData::Status::QueuedForDownloading ); case Seeding: return ( torrent->data().status == TorrentData::Status::Seeding || torrent->data().status == TorrentData::Status::QueuedForSeeding ); case Paused: return torrent->data().status == TorrentData::Status::Paused; case Checking: return ( torrent->data().status == TorrentData::Status::Checking || torrent->data().status == TorrentData::Status::QueuedForChecking ); case Errored: return torrent->data().hasError(); default: return true; } } bool TorrentsProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex&) const { const Torrent* torrent = static_cast(sourceModel())->torrentAtRow(sourceRow); if (!mSearchString.isEmpty() && !torrent->data().name.contains(mSearchString, Qt::CaseInsensitive)) { return false; } if (mStatusFilterEnabled && !statusFilterAcceptsTorrent(torrent, mStatusFilter)) { return false; } if (mLabelFilterEnabled && !mLabelFilter.isEmpty()) { const auto matchingLabel = std::ranges::find(torrent->data().labels, mLabelFilter); if (matchingLabel == torrent->data().labels.end()) { return false; } } if (mTrackerFilterEnabled && !mTrackerFilter.isEmpty()) { const auto matchingTracker = std::ranges::find(torrent->data().trackers, mTrackerFilter, &Tracker::site); if (matchingTracker == torrent->data().trackers.end()) { return false; } } if (mDownloadDirectoryFilterEnabled && !mDownloadDirectoryFilter.isEmpty()) { if (torrent->data().downloadDirectory != mDownloadDirectoryFilter) { return false; } } return true; } } tremotesf-2.8.2/src/ui/screens/mainwindow/torrentsproxymodel.h000066400000000000000000000044271500171105600247340ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_TORRENTSPROXYMODEL_H #define TREMOTESF_TORRENTSPROXYMODEL_H #include "ui/itemmodels/baseproxymodel.h" namespace tremotesf { class Torrent; } namespace tremotesf { class TorrentsModel; class TorrentsProxyModel final : public BaseProxyModel { Q_OBJECT public: enum StatusFilter { All, Active, Downloading, Seeding, Paused, Checking, Errored, StatusFilterCount }; Q_ENUM(StatusFilter) explicit TorrentsProxyModel(TorrentsModel* sourceModel, QObject* parent = nullptr); Q_DISABLE_COPY_MOVE(TorrentsProxyModel) QString searchString() const; void setSearchString(const QString& string); bool isStatusFilterEnabled() const; void setStatusFilterEnabled(bool enabled); StatusFilter statusFilter() const; void setStatusFilter(StatusFilter filter); bool isLabelFilterEnabled() const; void setLabelFilterEnabled(bool enabled); QString labelFilter() const; void setLabelFilter(const QString& filter); bool isTrackerFilterEnabled() const; void setTrackerFilterEnabled(bool enabled); QString trackerFilter() const; void setTrackerFilter(const QString& filter); bool isDownloadDirectoryFilterEnabled() const; void setDownloadDirectoryFilterEnabled(bool enabled); QString downloadDirectoryFilter() const; void setDownloadDirectoryFilter(const QString& filter); static bool statusFilterAcceptsTorrent(const Torrent* torrent, StatusFilter filter); protected: bool filterAcceptsRow(int sourceRow, const QModelIndex&) const override; private: QString mSearchString; bool mStatusFilterEnabled; StatusFilter mStatusFilter; bool mLabelFilterEnabled; QString mLabelFilter; bool mTrackerFilterEnabled; QString mTrackerFilter; bool mDownloadDirectoryFilterEnabled; QString mDownloadDirectoryFilter; signals: void statusFilterChanged(); void labelFilterChanged(); void trackerFilterChanged(); void downloadDirectoryFilterChanged(); }; } #endif // TREMOTESF_TORRENTSPROXYMODEL_H tremotesf-2.8.2/src/ui/screens/mainwindow/torrentsview.cpp000066400000000000000000000046201500171105600240320ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "torrentsview.h" #include #include #include "ui/widgets/commondelegate.h" #include "settings.h" #include "torrentsmodel.h" #include "torrentsproxymodel.h" namespace tremotesf { TorrentsView::TorrentsView(TorrentsProxyModel* model, QWidget* parent) : BaseTreeView(parent) { setContextMenuPolicy(Qt::CustomContextMenu); setItemDelegate(new CommonDelegate( {.progressBarColumn = static_cast(TorrentsModel::Column::ProgressBar), .progressRole = static_cast(TorrentsModel::Role::Sort), .textElideModeRole = static_cast(TorrentsModel::Role::TextElideMode), .alwaysShowTooltipRole = static_cast(TorrentsModel::Role::AlwaysShowTooltip)}, this )); setModel(model); setSelectionMode(QAbstractItemView::ExtendedSelection); setRootIsDecorated(false); const auto header = this->header(); if (!header->restoreState(Settings::instance()->get_torrentsViewHeaderState())) { using C = TorrentsModel::Column; const std::set defaultColumns{ C::Name, C::TotalSize, C::ProgressBar, C::Status, C::Seeders, C::Leechers, C::PeersSendingToUs, C::PeersGettingFromUs, C::DownloadSpeed, C::UploadSpeed, C::Eta, C::Ratio, C::AddedDate, }; for (int i = 0, max = header->count(); i < max; ++i) { if (!defaultColumns.contains(static_cast(i))) { header->hideSection(i); } } header->moveSection( header->visualIndex(static_cast(C::AddedDate)), header->visualIndex(static_cast(C::Status)) + 1 ); header->moveSection( header->visualIndex(static_cast(C::Eta)), header->visualIndex(static_cast(C::AddedDate)) + 1 ); sortByColumn(static_cast(TorrentsModel::Column::AddedDate), Qt::DescendingOrder); } } void TorrentsView::saveState() { Settings::instance()->set_torrentsViewHeaderState(header()->saveState()); } } tremotesf-2.8.2/src/ui/screens/mainwindow/torrentsview.h000066400000000000000000000010221500171105600234700ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_TORRENTSVIEW_H #define TREMOTESF_TORRENTSVIEW_H #include "ui/widgets/basetreeview.h" namespace tremotesf { class TorrentsProxyModel; class TorrentsView final : public BaseTreeView { Q_OBJECT public: TorrentsView(TorrentsProxyModel* model, QWidget* parent = nullptr); Q_DISABLE_COPY_MOVE(TorrentsView) void saveState(); }; } #endif // TREMOTESF_TORRENTSVIEW_H tremotesf-2.8.2/src/ui/screens/serversettings/000077500000000000000000000000001500171105600214645ustar00rootroot00000000000000tremotesf-2.8.2/src/ui/screens/serversettings/serversettingsdialog.cpp000066400000000000000000000727061500171105600264530ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "serversettingsdialog.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "rpc/serversettings.h" #include "stdutils.h" #include "rpc/rpc.h" #include "ui/widgets/remotedirectoryselectionwidget.h" namespace tremotesf { namespace { constexpr std::array encryptionModeComboBoxItems{ ServerSettingsData::EncryptionMode::Allowed, ServerSettingsData::EncryptionMode::Preferred, ServerSettingsData::EncryptionMode::Required }; ServerSettingsData::EncryptionMode encryptionModeFromComboBoxItem(int index) { if (index == -1) { return {}; } return encryptionModeComboBoxItems.at(static_cast(index)); } } ServerSettingsDialog::ServerSettingsDialog(const Rpc* rpc, QWidget* parent) : QDialog(parent), mRpc(rpc) { //: Dialog title setWindowTitle(qApp->translate("tremotesf", "Server Options")); setupUi(); resize(sizeHint().expandedTo(QSize(700, 550))); const auto onConnectedChanged = [this] { if (mRpc->isConnected()) { mDisconnectedMessageWidget->animatedHide(); mDisconnectedMessageWidget->setEnabled(true); mDownloadingPageWidget->setEnabled(true); mSeedingPageWidget->setEnabled(true); mQueuePageWidget->setEnabled(true); mSpeedPageWidget->setEnabled(true); mNetworkPageWidget->setEnabled(true); loadSettings(); } else { mDisconnectedMessageWidget->hide(); mDisconnectedMessageWidget->animatedShow(); mDownloadingPageWidget->setEnabled(false); mSeedingPageWidget->setEnabled(false); mQueuePageWidget->setEnabled(false); mSpeedPageWidget->setEnabled(false); mNetworkPageWidget->setEnabled(false); } }; QObject::connect(mRpc, &Rpc::connectedChanged, this, onConnectedChanged); onConnectedChanged(); loadSettings(); } void ServerSettingsDialog::accept() { ServerSettings* settings = mRpc->serverSettings(); settings->setSaveOnSet(false); settings->setDownloadDirectory(mDownloadDirectoryWidget->path()); settings->setStartAddedTorrents(mStartAddedTorrentsCheckBox->isChecked()); //settings->setTrashTorrentFiles(mTrashTorrentFilesCheckBox->isChecked()); settings->setRenameIncompleteFiles(mIncompleteFilesCheckBox->isChecked()); settings->setIncompleteDirectoryEnabled(mIncompleteDirectoryCheckBox->isChecked()); settings->setIncompleteDirectory(mIncompleteDirectoryWidget->path()); settings->setRatioLimited(mRatioLimitCheckBox->isChecked()); settings->setRatioLimit(mRatioLimitSpinBox->value()); settings->setIdleSeedingLimited(mIdleSeedingLimitCheckBox->isChecked()); settings->setIdleSeedingLimit(mIdleSeedingLimitSpinBox->value()); settings->setDownloadQueueEnabled(mMaximumActiveDownloadsCheckBox->isChecked()); settings->setDownloadQueueSize(mMaximumActiveDownloadsSpinBox->value()); settings->setSeedQueueEnabled(mMaximumActiveUploadsCheckBox->isChecked()); settings->setSeedQueueSize(mMaximumActiveUploadsSpinBox->value()); settings->setIdleQueueLimited(mIdleQueueLimitCheckBox->isChecked()); settings->setIdleQueueLimit(mIdleQueueLimitSpinBox->value()); settings->setDownloadSpeedLimited(mDownloadSpeedLimitCheckBox->isChecked()); settings->setDownloadSpeedLimit(mDownloadSpeedLimitSpinBox->value()); settings->setUploadSpeedLimited(mUploadSpeedLimitCheckBox->isChecked()); settings->setUploadSpeedLimit(mUploadSpeedLimitSpinBox->value()); settings->setAlternativeSpeedLimitsEnabled(mEnableAlternativeSpeedLimitsGroupBox->isChecked()); settings->setAlternativeDownloadSpeedLimit(mAlternativeDownloadSpeedLimitSpinBox->value()); settings->setAlternativeUploadSpeedLimit(mAlternativeUploadSpeedLimitSpinBox->value()); settings->setAlternativeSpeedLimitsScheduled(mLimitScheduleGroupBox->isChecked()); settings->setAlternativeSpeedLimitsBeginTime(mLimitScheduleBeginTimeEdit->time()); settings->setAlternativeSpeedLimitsEndTime(mLimitScheduleEndTimeEdit->time()); settings->setAlternativeSpeedLimitsDays(static_cast( mLimitScheduleDaysComboBox->currentData().toInt() )); settings->setPeerPort(mPeerPortSpinBox->value()); settings->setRandomPortEnabled(mRandomPortCheckBox->isChecked()); settings->setPortForwardingEnabled(mPortForwardingCheckBox->isChecked()); settings->setEncryptionMode(encryptionModeFromComboBoxItem(mEncryptionComboBox->currentIndex())); settings->setUtpEnabled(mUtpCheckBox->isChecked()); settings->setPexEnabled(mPexCheckBox->isChecked()); settings->setDhtEnabled(mDhtCheckBox->isChecked()); settings->setLpdEnabled(mLpdCheckBox->isChecked()); settings->setMaximumPeersPerTorrent(mTorrentPeerLimitSpinBox->value()); settings->setMaximumPeersGlobally(mGlobalPeerLimitSpinBox->value()); settings->save(); settings->setSaveOnSet(true); QDialog::accept(); } void ServerSettingsDialog::setupUi() { // // Creating layout // auto layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); //: Message that appears when disconnected from server mDisconnectedMessageWidget = new KMessageWidget(qApp->translate("tremotesf", "Disconnected"), this); mDisconnectedMessageWidget->setCloseButtonVisible(false); mDisconnectedMessageWidget->setMessageType(KMessageWidget::Warning); mDisconnectedMessageWidget->setVisible(false); layout->addWidget(mDisconnectedMessageWidget); auto pageWidget = new KPageWidget(this); // Downloading page mDownloadingPageWidget = new QWidget(this); KPageWidgetItem* downloadingPageItem = pageWidget->addPage( mDownloadingPageWidget, //: "Downloading" server setting page qApp->translate("tremotesf", "Downloading", "Noun") ); downloadingPageItem->setIcon(QIcon::fromTheme("folder-download"_l1)); auto downloadingPageLayout = new QFormLayout(mDownloadingPageWidget); downloadingPageLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); mDownloadDirectoryWidget = new RemoteDirectorySelectionWidget(this); mDownloadDirectoryWidget->setup({}, mRpc); downloadingPageLayout->addRow(qApp->translate("tremotesf", "Download directory:"), mDownloadDirectoryWidget); //: Check box label mStartAddedTorrentsCheckBox = new QCheckBox(qApp->translate("tremotesf", "Start added torrents"), this); downloadingPageLayout->addRow(mStartAddedTorrentsCheckBox); /*mTrashTorrentFilesCheckBox = new QCheckBox(qApp->translate("tremotesf", "Trash .torrent files"), this); downloadingPageLayout->addRow(mTrashTorrentFilesCheckBox);*/ mIncompleteFilesCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Append \".part\" to names of incomplete files"), this ); downloadingPageLayout->addRow(mIncompleteFilesCheckBox); mIncompleteDirectoryCheckBox = new QCheckBox(qApp->translate("tremotesf", "Directory for incomplete files:"), this); downloadingPageLayout->addRow(mIncompleteDirectoryCheckBox); auto incompleteDirectoryWidgetLayout = new QHBoxLayout(); downloadingPageLayout->addRow(incompleteDirectoryWidgetLayout); mIncompleteDirectoryWidget = new RemoteDirectorySelectionWidget(this); mIncompleteDirectoryWidget->setup({}, mRpc); mIncompleteDirectoryWidget->setEnabled(false); QObject::connect( mIncompleteDirectoryCheckBox, &QCheckBox::toggled, mIncompleteDirectoryWidget, &RemoteDirectorySelectionWidget::setEnabled ); //downloadingPageLayout->addRow(mIncompleteDirectoryCheckBox, mIncompleteDirectoryWidget); incompleteDirectoryWidgetLayout->addSpacing(28); incompleteDirectoryWidgetLayout->addWidget(mIncompleteDirectoryWidget); // Seeding page mSeedingPageWidget = new QWidget(this); KPageWidgetItem* seedingPageItem = pageWidget->addPage( mSeedingPageWidget, //: "Seeding" server setting page qApp->translate("tremotesf", "Seeding", "Noun") ); seedingPageItem->setIcon(QIcon::fromTheme("network-server"_l1)); auto seedingPageLayout = new QGridLayout(mSeedingPageWidget); mRatioLimitCheckBox = new QCheckBox(qApp->translate("tremotesf", "Stop seeding at ratio:"), this); seedingPageLayout->addWidget(mRatioLimitCheckBox, 0, 0, 1, 2, Qt::AlignTop); mRatioLimitSpinBox = new QDoubleSpinBox(this); mRatioLimitSpinBox->setEnabled(false); mRatioLimitSpinBox->setMaximum(std::numeric_limits::max()); QObject::connect(mRatioLimitCheckBox, &QCheckBox::toggled, mRatioLimitSpinBox, &QSpinBox::setEnabled); seedingPageLayout->addWidget(mRatioLimitSpinBox, 1, 1, Qt::AlignTop); mIdleSeedingLimitCheckBox = new QCheckBox(qApp->translate("tremotesf", "Stop seeding if idle for:"), this); seedingPageLayout->addWidget(mIdleSeedingLimitCheckBox, 2, 0, 1, 2, Qt::AlignTop); mIdleSeedingLimitSpinBox = new QSpinBox(this); mIdleSeedingLimitSpinBox->setEnabled(false); mIdleSeedingLimitSpinBox->setMaximum(9999); //: Suffix that is added to input field with number of minuts, e.g. "5 min" mIdleSeedingLimitSpinBox->setSuffix(qApp->translate("tremotesf", " min")); QObject::connect( mIdleSeedingLimitCheckBox, &QCheckBox::toggled, mIdleSeedingLimitSpinBox, &QSpinBox::setEnabled ); seedingPageLayout->addWidget(mIdleSeedingLimitSpinBox, 3, 1, Qt::AlignTop); seedingPageLayout->setRowStretch(3, 1); seedingPageLayout->setColumnStretch(1, 1); seedingPageLayout->setColumnMinimumWidth(0, 28 - seedingPageLayout->spacing()); // Queue page mQueuePageWidget = new QWidget(this); KPageWidgetItem* queuePageItem = pageWidget->addPage( mQueuePageWidget, //: "Queue" server settings page qApp->translate("tremotesf", "Queue") ); queuePageItem->setIcon(QIcon::fromTheme("applications-utilities"_l1)); auto queuePageLayout = new QGridLayout(mQueuePageWidget); mMaximumActiveDownloadsCheckBox = new QCheckBox(qApp->translate("tremotesf", "Maximum active downloads:"), this); queuePageLayout->addWidget(mMaximumActiveDownloadsCheckBox, 0, 0, 1, 2, Qt::AlignTop); mMaximumActiveDownloadsSpinBox = new QSpinBox(this); mMaximumActiveDownloadsSpinBox->setEnabled(false); mMaximumActiveDownloadsSpinBox->setMaximum(std::numeric_limits::max()); QObject::connect( mMaximumActiveDownloadsCheckBox, &QCheckBox::toggled, mMaximumActiveDownloadsSpinBox, &QSpinBox::setEnabled ); queuePageLayout->addWidget(mMaximumActiveDownloadsSpinBox, 1, 1, Qt::AlignTop); mMaximumActiveUploadsCheckBox = new QCheckBox(qApp->translate("tremotesf", "Maximum active uploads:"), this); queuePageLayout->addWidget(mMaximumActiveUploadsCheckBox, 2, 0, 1, 2, Qt::AlignTop); mMaximumActiveUploadsSpinBox = new QSpinBox(this); mMaximumActiveUploadsSpinBox->setEnabled(false); mMaximumActiveUploadsSpinBox->setMaximum(std::numeric_limits::max()); QObject::connect( mMaximumActiveUploadsCheckBox, &QCheckBox::toggled, mMaximumActiveUploadsSpinBox, &QSpinBox::setEnabled ); queuePageLayout->addWidget(mMaximumActiveUploadsSpinBox, 3, 1, Qt::AlignTop); mIdleQueueLimitCheckBox = new QCheckBox(qApp->translate("tremotesf", "Ignore queue position if idle for:"), this); queuePageLayout->addWidget(mIdleQueueLimitCheckBox, 4, 0, 1, 2, Qt::AlignTop); mIdleQueueLimitSpinBox = new QSpinBox(this); mIdleQueueLimitSpinBox->setEnabled(false); mIdleQueueLimitSpinBox->setMaximum(9999); //: Suffix that is added to input field with number of minuts, e.g. "5 min" mIdleQueueLimitSpinBox->setSuffix(qApp->translate("tremotesf", " min")); QObject::connect(mIdleQueueLimitCheckBox, &QCheckBox::toggled, mIdleQueueLimitSpinBox, &QSpinBox::setEnabled); queuePageLayout->addWidget(mIdleQueueLimitSpinBox, 5, 1, Qt::AlignTop); queuePageLayout->setRowStretch(5, 1); queuePageLayout->setColumnStretch(1, 1); queuePageLayout->setColumnMinimumWidth(0, 28 - queuePageLayout->spacing()); // Speed page mSpeedPageWidget = new QWidget(this); KPageWidgetItem* speedPageItem = pageWidget->addPage( mSpeedPageWidget, //: "Speed" server settings page qApp->translate("tremotesf", "Speed") ); speedPageItem->setIcon(QIcon::fromTheme("preferences-system-time"_l1)); auto speedPageLayout = new QVBoxLayout(mSpeedPageWidget); //: Speed limits section auto speedLimitsGroupBox = new QGroupBox(qApp->translate("tremotesf", "Limits"), this); auto speedLimitsGroupBoxLayout = new QGridLayout(speedLimitsGroupBox); const int maxSpeedLimit = static_cast(std::numeric_limits::max() / 1024); //: Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes const QString suffix(qApp->translate("tremotesf", " kB/s")); //: Download speed limit input field label mDownloadSpeedLimitCheckBox = new QCheckBox(qApp->translate("tremotesf", "Download:"), this); speedLimitsGroupBoxLayout->addWidget(mDownloadSpeedLimitCheckBox, 0, 0, 1, 2); mDownloadSpeedLimitSpinBox = new QSpinBox(this); mDownloadSpeedLimitSpinBox->setEnabled(false); mDownloadSpeedLimitSpinBox->setMaximum(maxSpeedLimit); mDownloadSpeedLimitSpinBox->setSuffix(suffix); QObject::connect( mDownloadSpeedLimitCheckBox, &QCheckBox::toggled, mDownloadSpeedLimitSpinBox, &QSpinBox::setEnabled ); speedLimitsGroupBoxLayout->addWidget(mDownloadSpeedLimitSpinBox, 1, 1); //: Upload speed limit input field label mUploadSpeedLimitCheckBox = new QCheckBox(qApp->translate("tremotesf", "Upload:"), this); speedLimitsGroupBoxLayout->addWidget(mUploadSpeedLimitCheckBox, 2, 0, 1, 2); mUploadSpeedLimitSpinBox = new QSpinBox(this); mUploadSpeedLimitSpinBox->setEnabled(false); mUploadSpeedLimitSpinBox->setMaximum(maxSpeedLimit); mUploadSpeedLimitSpinBox->setSuffix(suffix); QObject::connect( mUploadSpeedLimitCheckBox, &QCheckBox::toggled, mUploadSpeedLimitSpinBox, &QSpinBox::setEnabled ); speedLimitsGroupBoxLayout->addWidget(mUploadSpeedLimitSpinBox, 3, 1); speedLimitsGroupBoxLayout->setColumnMinimumWidth(0, 28 - speedLimitsGroupBoxLayout->spacing()); speedPageLayout->addWidget(speedLimitsGroupBox); //: Alternative speed limits section auto alternativeSpeedLimitsGroupBox = new QGroupBox(qApp->translate("tremotesf", "Alternative Limits"), this); auto alternativeSpeedLimitsGroupBoxLayout = new QVBoxLayout(alternativeSpeedLimitsGroupBox); //: Check box label mEnableAlternativeSpeedLimitsGroupBox = new QGroupBox(qApp->translate("tremotesf", "Enable"), this); mEnableAlternativeSpeedLimitsGroupBox->setCheckable(true); auto enableAlternativeLimitsGroupBoxLayout = new QFormLayout(mEnableAlternativeSpeedLimitsGroupBox); enableAlternativeLimitsGroupBoxLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); mAlternativeDownloadSpeedLimitSpinBox = new QSpinBox(this); mAlternativeDownloadSpeedLimitSpinBox->setMaximum(maxSpeedLimit); mAlternativeDownloadSpeedLimitSpinBox->setSuffix(suffix); enableAlternativeLimitsGroupBoxLayout->addRow( //: Download speed limit input field label qApp->translate("tremotesf", "Download:"), mAlternativeDownloadSpeedLimitSpinBox ); mAlternativeUploadSpeedLimitSpinBox = new QSpinBox(this); mAlternativeUploadSpeedLimitSpinBox->setMaximum(maxSpeedLimit); mAlternativeUploadSpeedLimitSpinBox->setSuffix(suffix); enableAlternativeLimitsGroupBoxLayout->addRow( //: Upload speed limit input field label qApp->translate("tremotesf", "Upload:"), mAlternativeUploadSpeedLimitSpinBox ); alternativeSpeedLimitsGroupBoxLayout->addWidget(mEnableAlternativeSpeedLimitsGroupBox); //: Title of alternative speed limit scheduling section mLimitScheduleGroupBox = new QGroupBox(qApp->translate("tremotesf", "Scheduled"), this); mLimitScheduleGroupBox->setCheckable(true); auto scheduleGroupBoxLayout = new QFormLayout(mLimitScheduleGroupBox); scheduleGroupBoxLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); auto scheduleTimeLayout = new QHBoxLayout(); scheduleGroupBoxLayout->addRow(scheduleTimeLayout); mLimitScheduleBeginTimeEdit = new QTimeEdit(this); scheduleTimeLayout->addWidget(mLimitScheduleBeginTimeEdit, 1); //: Separates time range input fields. E.g. "to" inside "1:00 AM to 5:00 AM" scheduleTimeLayout->addWidget(new QLabel(qApp->translate("tremotesf", "to"))); mLimitScheduleEndTimeEdit = new QTimeEdit(this); scheduleTimeLayout->addWidget(mLimitScheduleEndTimeEdit, 1); mLimitScheduleDaysComboBox = new QComboBox(this); mLimitScheduleDaysComboBox->addItem( qApp->translate("tremotesf", "Every day"), QVariant::fromValue(ServerSettingsData::AlternativeSpeedLimitsDays::All) ); mLimitScheduleDaysComboBox->addItem( qApp->translate("tremotesf", "Weekdays"), QVariant::fromValue(ServerSettingsData::AlternativeSpeedLimitsDays::Weekdays) ); mLimitScheduleDaysComboBox->addItem( qApp->translate("tremotesf", "Weekends"), QVariant::fromValue(ServerSettingsData::AlternativeSpeedLimitsDays::Weekends) ); mLimitScheduleDaysComboBox->insertSeparator(mLimitScheduleDaysComboBox->count()); { auto nextDay = [](Qt::DayOfWeek day) { if (day == Qt::Sunday) { return Qt::Monday; } return static_cast(day + 1); }; auto daysFromQtDay = [](Qt::DayOfWeek day) { switch (day) { case Qt::Monday: return ServerSettingsData::AlternativeSpeedLimitsDays::Monday; case Qt::Tuesday: return ServerSettingsData::AlternativeSpeedLimitsDays::Tuesday; case Qt::Wednesday: return ServerSettingsData::AlternativeSpeedLimitsDays::Wednesday; case Qt::Thursday: return ServerSettingsData::AlternativeSpeedLimitsDays::Thursday; case Qt::Friday: return ServerSettingsData::AlternativeSpeedLimitsDays::Friday; case Qt::Saturday: return ServerSettingsData::AlternativeSpeedLimitsDays::Saturday; case Qt::Sunday: return ServerSettingsData::AlternativeSpeedLimitsDays::Sunday; } return ServerSettingsData::AlternativeSpeedLimitsDays::All; }; const QLocale locale; const Qt::DayOfWeek first = QLocale().firstDayOfWeek(); mLimitScheduleDaysComboBox->addItem(locale.dayName(first), QVariant::fromValue(daysFromQtDay(first))); for (Qt::DayOfWeek day = nextDay(first); day != first; day = nextDay(day)) { mLimitScheduleDaysComboBox->addItem(locale.dayName(day), QVariant::fromValue(daysFromQtDay(day))); } } scheduleGroupBoxLayout->addRow(qApp->translate("tremotesf", "Days:"), mLimitScheduleDaysComboBox); alternativeSpeedLimitsGroupBoxLayout->addWidget(mLimitScheduleGroupBox); auto alternativeSpeedLimitsResizer = new KColumnResizer(this); alternativeSpeedLimitsResizer->addWidgetsFromLayout(enableAlternativeLimitsGroupBoxLayout); alternativeSpeedLimitsResizer->addWidgetsFromLayout(scheduleGroupBoxLayout); speedPageLayout->addWidget(alternativeSpeedLimitsGroupBox); speedPageLayout->addStretch(); // Network page mNetworkPageWidget = new QWidget(this); KPageWidgetItem* networkPageItem = pageWidget->addPage( mNetworkPageWidget, //: "Network" server settings page qApp->translate("tremotesf", "Network") ); networkPageItem->setIcon(QIcon::fromTheme("preferences-system-network"_l1)); auto networkPageLayout = new QVBoxLayout(mNetworkPageWidget); //: Title of settings section related to peer connections auto connectionGroupBox = new QGroupBox(qApp->translate("tremotesf", "Connection"), this); auto connectionGroupBoxLayout = new QFormLayout(connectionGroupBox); connectionGroupBoxLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); mPeerPortSpinBox = new QSpinBox(this); mPeerPortSpinBox->setMaximum(65535); connectionGroupBoxLayout->addRow(qApp->translate("tremotesf", "Peer port:"), mPeerPortSpinBox); //: Check box label mRandomPortCheckBox = new QCheckBox(qApp->translate("tremotesf", "Random port on Transmission start"), this); connectionGroupBoxLayout->addRow(mRandomPortCheckBox); //: Check box label mPortForwardingCheckBox = new QCheckBox(qApp->translate("tremotesf", "Enable port forwarding")); connectionGroupBoxLayout->addRow(mPortForwardingCheckBox); mEncryptionComboBox = new QComboBox(); for (const auto mode : encryptionModeComboBoxItems) { switch (mode) { case ServerSettingsData::EncryptionMode::Allowed: //: Encryption mode (allow/prefer/require) mEncryptionComboBox->addItem(qApp->translate("tremotesf", "Allow")); break; case ServerSettingsData::EncryptionMode::Preferred: //: Encryption mode (allow/prefer/require) mEncryptionComboBox->addItem(qApp->translate("tremotesf", "Prefer")); break; case ServerSettingsData::EncryptionMode::Required: //: Encryption mode (allow/prefer/require) mEncryptionComboBox->addItem(qApp->translate("tremotesf", "Require")); break; } } connectionGroupBoxLayout->addRow(qApp->translate("tremotesf", "Encryption:"), mEncryptionComboBox); //: Check box label mUtpCheckBox = new QCheckBox(qApp->translate("tremotesf", "Enable μTP (Micro Transport Protocol)"), this); connectionGroupBoxLayout->addRow(mUtpCheckBox); //: Check box label mPexCheckBox = new QCheckBox(qApp->translate("tremotesf", "Enable PEX (Peer exchange)"), this); connectionGroupBoxLayout->addRow(mPexCheckBox); //: Check box label mDhtCheckBox = new QCheckBox(qApp->translate("tremotesf", "Enable DHT"), this); connectionGroupBoxLayout->addRow(mDhtCheckBox); //: Check box label mLpdCheckBox = new QCheckBox(qApp->translate("tremotesf", "Enable local peer discovery"), this); connectionGroupBoxLayout->addRow(mLpdCheckBox); networkPageLayout->addWidget(connectionGroupBox); auto peerLimitsGroupBox = new QGroupBox(qApp->translate("tremotesf", "Peer Limits"), this); auto peerLimitsGroupBoxLayout = new QFormLayout(peerLimitsGroupBox); peerLimitsGroupBoxLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); mTorrentPeerLimitSpinBox = new QSpinBox(this); mTorrentPeerLimitSpinBox->setMaximum(std::numeric_limits::max()); peerLimitsGroupBoxLayout->addRow( qApp->translate("tremotesf", "Maximum peers per torrent:"), mTorrentPeerLimitSpinBox ); mGlobalPeerLimitSpinBox = new QSpinBox(this); mGlobalPeerLimitSpinBox->setMaximum(std::numeric_limits::max()); peerLimitsGroupBoxLayout->addRow( qApp->translate("tremotesf", "Maximum peers globally:"), mGlobalPeerLimitSpinBox ); networkPageLayout->addWidget(peerLimitsGroupBox); networkPageLayout->addStretch(); auto networkPageResizer = new KColumnResizer(this); networkPageResizer->addWidgetsFromLayout(connectionGroupBoxLayout); networkPageResizer->addWidgetsFromLayout(peerLimitsGroupBoxLayout); layout->addWidget(pageWidget); auto dialogButtonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); QObject::connect(dialogButtonBox, &QDialogButtonBox::accepted, this, &ServerSettingsDialog::accept); QObject::connect(dialogButtonBox, &QDialogButtonBox::rejected, this, &ServerSettingsDialog::reject); pageWidget->setPageFooter(dialogButtonBox); } void ServerSettingsDialog::loadSettings() { const ServerSettings* settings = mRpc->serverSettings(); mDownloadDirectoryWidget->updatePath(settings->data().downloadDirectory); mStartAddedTorrentsCheckBox->setChecked(settings->data().startAddedTorrents); mIncompleteFilesCheckBox->setChecked(settings->data().renameIncompleteFiles); mIncompleteDirectoryCheckBox->setChecked(settings->data().incompleteDirectoryEnabled); mIncompleteDirectoryWidget->updatePath(settings->data().incompleteDirectory); mRatioLimitCheckBox->setChecked(settings->data().ratioLimited); mRatioLimitSpinBox->setValue(settings->data().ratioLimit); mIdleSeedingLimitCheckBox->setChecked(settings->data().idleSeedingLimited); mIdleSeedingLimitSpinBox->setValue(settings->data().idleSeedingLimit); mMaximumActiveDownloadsCheckBox->setChecked(settings->data().downloadQueueEnabled); mMaximumActiveDownloadsSpinBox->setValue(settings->data().downloadQueueSize); mMaximumActiveUploadsCheckBox->setChecked(settings->data().seedQueueEnabled); mMaximumActiveUploadsSpinBox->setValue(settings->data().seedQueueSize); mIdleQueueLimitCheckBox->setChecked(settings->data().idleQueueLimited); mIdleQueueLimitSpinBox->setValue(settings->data().idleQueueLimit); mDownloadSpeedLimitCheckBox->setChecked(settings->data().downloadSpeedLimited); mDownloadSpeedLimitSpinBox->setValue(settings->data().downloadSpeedLimit); mUploadSpeedLimitCheckBox->setChecked(settings->data().uploadSpeedLimited); mUploadSpeedLimitSpinBox->setValue(settings->data().uploadSpeedLimit); mEnableAlternativeSpeedLimitsGroupBox->setChecked(settings->data().alternativeSpeedLimitsEnabled); mAlternativeDownloadSpeedLimitSpinBox->setValue(settings->data().alternativeDownloadSpeedLimit); mAlternativeUploadSpeedLimitSpinBox->setValue(settings->data().alternativeUploadSpeedLimit); mLimitScheduleGroupBox->setChecked(settings->data().alternativeSpeedLimitsScheduled); mLimitScheduleBeginTimeEdit->setTime(settings->data().alternativeSpeedLimitsBeginTime); mLimitScheduleEndTimeEdit->setTime(settings->data().alternativeSpeedLimitsEndTime); const auto days = settings->data().alternativeSpeedLimitsDays; for (int i = 0, max = mLimitScheduleDaysComboBox->count(); i < max; i++) { if (mLimitScheduleDaysComboBox->itemData(i).value() == days) { mLimitScheduleDaysComboBox->setCurrentIndex(i); break; } } mPeerPortSpinBox->setValue(settings->data().peerPort); mRandomPortCheckBox->setChecked(settings->data().randomPortEnabled); mPortForwardingCheckBox->setChecked(settings->data().portForwardingEnabled); mEncryptionComboBox->setCurrentIndex( // NOLINTNEXTLINE(bugprone-unchecked-optional-access) indexOfCasted(encryptionModeComboBoxItems, settings->data().encryptionMode).value() ); mUtpCheckBox->setChecked(settings->data().utpEnabled); mPexCheckBox->setChecked(settings->data().pexEnabled); mDhtCheckBox->setChecked(settings->data().dhtEnabled); mLpdCheckBox->setChecked(settings->data().lpdEnabled); mTorrentPeerLimitSpinBox->setValue(settings->data().maximumPeersPerTorrent); mGlobalPeerLimitSpinBox->setValue(settings->data().maximumPeersGlobally); } } tremotesf-2.8.2/src/ui/screens/serversettings/serversettingsdialog.h000066400000000000000000000060031500171105600261030ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_SERVERSETTINGSDIALOG_H #define TREMOTESF_SERVERSETTINGSDIALOG_H #include class QCheckBox; class QComboBox; class QDoubleSpinBox; class QGroupBox; class QSpinBox; class QTimeEdit; class KMessageWidget; namespace tremotesf { class RemoteDirectorySelectionWidget; class Rpc; class ServerSettingsDialog final : public QDialog { Q_OBJECT public: explicit ServerSettingsDialog(const Rpc* rpc, QWidget* parent = nullptr); void accept() override; private: void setupUi(); void loadSettings(); const Rpc* mRpc; KMessageWidget* mDisconnectedMessageWidget = nullptr; QWidget* mDownloadingPageWidget = nullptr; RemoteDirectorySelectionWidget* mDownloadDirectoryWidget = nullptr; QCheckBox* mStartAddedTorrentsCheckBox = nullptr; QCheckBox* mIncompleteFilesCheckBox = nullptr; QCheckBox* mIncompleteDirectoryCheckBox = nullptr; RemoteDirectorySelectionWidget* mIncompleteDirectoryWidget = nullptr; QWidget* mSeedingPageWidget = nullptr; QCheckBox* mRatioLimitCheckBox = nullptr; QDoubleSpinBox* mRatioLimitSpinBox = nullptr; QCheckBox* mIdleSeedingLimitCheckBox = nullptr; QSpinBox* mIdleSeedingLimitSpinBox = nullptr; QWidget* mQueuePageWidget = nullptr; QCheckBox* mMaximumActiveDownloadsCheckBox = nullptr; QSpinBox* mMaximumActiveDownloadsSpinBox = nullptr; QCheckBox* mMaximumActiveUploadsCheckBox = nullptr; QSpinBox* mMaximumActiveUploadsSpinBox = nullptr; QCheckBox* mIdleQueueLimitCheckBox = nullptr; QSpinBox* mIdleQueueLimitSpinBox = nullptr; QWidget* mSpeedPageWidget = nullptr; QCheckBox* mDownloadSpeedLimitCheckBox = nullptr; QSpinBox* mDownloadSpeedLimitSpinBox = nullptr; QCheckBox* mUploadSpeedLimitCheckBox = nullptr; QSpinBox* mUploadSpeedLimitSpinBox = nullptr; QGroupBox* mEnableAlternativeSpeedLimitsGroupBox = nullptr; QSpinBox* mAlternativeDownloadSpeedLimitSpinBox = nullptr; QSpinBox* mAlternativeUploadSpeedLimitSpinBox = nullptr; QGroupBox* mLimitScheduleGroupBox = nullptr; QTimeEdit* mLimitScheduleBeginTimeEdit = nullptr; QTimeEdit* mLimitScheduleEndTimeEdit = nullptr; QComboBox* mLimitScheduleDaysComboBox = nullptr; QWidget* mNetworkPageWidget = nullptr; QSpinBox* mPeerPortSpinBox = nullptr; QCheckBox* mRandomPortCheckBox = nullptr; QCheckBox* mPortForwardingCheckBox = nullptr; QComboBox* mEncryptionComboBox = nullptr; QCheckBox* mUtpCheckBox = nullptr; QCheckBox* mPexCheckBox = nullptr; QCheckBox* mDhtCheckBox = nullptr; QCheckBox* mLpdCheckBox = nullptr; QSpinBox* mTorrentPeerLimitSpinBox = nullptr; QSpinBox* mGlobalPeerLimitSpinBox = nullptr; }; } #endif // TREMOTESF_SERVERSETTINGSDIALOG_H tremotesf-2.8.2/src/ui/screens/serverstatsdialog.cpp000066400000000000000000000151231500171105600226500ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "serverstatsdialog.h" #include #include #include #include #include #include #include #include #include #include "coroutines/coroutines.h" #include "rpc/serverstats.h" #include "rpc/rpc.h" #include "formatutils.h" namespace tremotesf { ServerStatsDialog::ServerStatsDialog(Rpc* rpc, QWidget* parent) : QDialog(parent) { //: Dialog title setWindowTitle(qApp->translate("tremotesf", "Server Stats")); auto layout = new QVBoxLayout(this); //: Message that appears when disconnected from server auto disconnectedWidget = new KMessageWidget(qApp->translate("tremotesf", "Disconnected")); disconnectedWidget->setCloseButtonVisible(false); disconnectedWidget->setMessageType(KMessageWidget::Warning); disconnectedWidget->hide(); layout->addWidget(disconnectedWidget); //: Server stats section for current Transmission launch auto currentSessionGroupBox = new QGroupBox(qApp->translate("tremotesf", "Current session"), this); auto currentSessionGroupBoxLayout = new QFormLayout(currentSessionGroupBox); currentSessionGroupBoxLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); auto sessionDownloadedLabel = new QLabel(this); //: Downloaded bytes currentSessionGroupBoxLayout->addRow(qApp->translate("tremotesf", "Downloaded:"), sessionDownloadedLabel); auto sessionUploadedLabel = new QLabel(this); //: Uploaded bytes currentSessionGroupBoxLayout->addRow(qApp->translate("tremotesf", "Uploaded:"), sessionUploadedLabel); auto sessionRatioLabel = new QLabel(this); currentSessionGroupBoxLayout->addRow(qApp->translate("tremotesf", "Ratio:"), sessionRatioLabel); auto sessionDurationLabel = new QLabel(this); //: How much time Transmission is running currentSessionGroupBoxLayout->addRow(qApp->translate("tremotesf", "Duration:"), sessionDurationLabel); layout->addWidget(currentSessionGroupBox); //: Server stats section for all Transmission launches (accumulated) auto totalGroupBox = new QGroupBox(qApp->translate("tremotesf", "Total"), this); auto totalGroupBoxLayout = new QFormLayout(totalGroupBox); totalGroupBoxLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); auto totalDownloadedLabel = new QLabel(this); //: Downloaded bytes totalGroupBoxLayout->addRow(qApp->translate("tremotesf", "Downloaded:"), totalDownloadedLabel); auto totalUploadedLabel = new QLabel(this); //: Uploaded bytes totalGroupBoxLayout->addRow(qApp->translate("tremotesf", "Uploaded:"), totalUploadedLabel); auto totalRatioLabel = new QLabel(this); totalGroupBoxLayout->addRow(qApp->translate("tremotesf", "Ratio:"), totalRatioLabel); auto totalDurationLabel = new QLabel(this); //: How much time Transmission is running totalGroupBoxLayout->addRow(qApp->translate("tremotesf", "Duration:"), totalDurationLabel); auto sessionCountLabel = new QLabel(this); //: How many times Transmission was launched totalGroupBoxLayout->addRow(qApp->translate("tremotesf", "Started:"), sessionCountLabel); auto freeSpaceField = new QLabel(this); totalGroupBoxLayout->addRow(qApp->translate("tremotesf", "Free space in download directory:"), freeSpaceField); auto* freeSpaceLabel = qobject_cast(totalGroupBoxLayout->labelForField(freeSpaceField)); freeSpaceLabel->setWordWrap(true); freeSpaceLabel->setAlignment(Qt::AlignTrailing | Qt::AlignVCenter); layout->addWidget(totalGroupBox); layout->addStretch(); auto resizer = new KColumnResizer(this); resizer->addWidgetsFromLayout(currentSessionGroupBoxLayout); resizer->addWidgetsFromLayout(totalGroupBoxLayout); auto dialogButtonBox = new QDialogButtonBox(QDialogButtonBox::Close, this); dialogButtonBox->button(QDialogButtonBox::Close)->setDefault(true); QObject::connect(dialogButtonBox, &QDialogButtonBox::rejected, this, &ServerStatsDialog::reject); layout->addWidget(dialogButtonBox); resize(sizeHint().expandedTo(QSize(300, 320))); QObject::connect(rpc, &Rpc::connectedChanged, this, [=] { if (rpc->isConnected()) { disconnectedWidget->animatedHide(); } else { disconnectedWidget->animatedShow(); } currentSessionGroupBox->setEnabled(rpc->isConnected()); totalGroupBox->setEnabled(rpc->isConnected()); sessionCountLabel->setEnabled(rpc->isConnected()); }); auto update = [=, this] { const SessionStats currentSessionStats(rpc->serverStats()->currentSession()); sessionDownloadedLabel->setText(formatutils::formatByteSize(currentSessionStats.downloaded())); sessionUploadedLabel->setText(formatutils::formatByteSize(currentSessionStats.uploaded())); sessionRatioLabel->setText( formatutils::formatRatio(currentSessionStats.downloaded(), currentSessionStats.uploaded()) ); sessionDurationLabel->setText(formatutils::formatEta(currentSessionStats.duration())); const SessionStats totalStats(rpc->serverStats()->total()); totalDownloadedLabel->setText(formatutils::formatByteSize(totalStats.downloaded())); totalUploadedLabel->setText(formatutils::formatByteSize(totalStats.uploaded())); totalRatioLabel->setText(formatutils::formatRatio(totalStats.downloaded(), totalStats.uploaded())); totalDurationLabel->setText(formatutils::formatEta(totalStats.duration())); //: How many times Transmission was launched sessionCountLabel->setText(qApp->translate("tremotesf", "%Ln times", nullptr, totalStats.sessionCount())); mFreeSpaceCoroutineScope.cancelAll(); mFreeSpaceCoroutineScope.launch(getDownloadDirFreeSpace(rpc, freeSpaceField)); }; QObject::connect(rpc->serverStats(), &ServerStats::updated, this, update); update(); } Coroutine<> ServerStatsDialog::getDownloadDirFreeSpace(Rpc* rpc, QLabel* freeSpaceField) { const auto freeSpace = co_await rpc->getDownloadDirFreeSpace(); if (freeSpace) { freeSpaceField->setText(formatutils::formatByteSize(*freeSpace)); } } } tremotesf-2.8.2/src/ui/screens/serverstatsdialog.h000066400000000000000000000011701500171105600223120ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_SERVERSTATSDIALOG_H #define TREMOTESF_SERVERSTATSDIALOG_H #include #include "coroutines/scope.h" class QLabel; namespace tremotesf { class Rpc; class ServerStatsDialog final : public QDialog { Q_OBJECT public: explicit ServerStatsDialog(Rpc* rpc, QWidget* parent = nullptr); private: Coroutine<> getDownloadDirFreeSpace(Rpc* rpc, QLabel* freeSpaceField); CoroutineScope mFreeSpaceCoroutineScope{}; }; } #endif // TREMOTESF_SERVERSTATSDIALOG_H tremotesf-2.8.2/src/ui/screens/settingsdialog.cpp000066400000000000000000000530561500171105600221320ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // SPDX-FileCopyrightText: 2021 LuK1337 // SPDX-FileCopyrightText: 2022 Alex // // SPDX-License-Identifier: GPL-3.0-or-later #include "settingsdialog.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include "stdutils.h" #include "target_os.h" #include "settings.h" #include "rpc/rpc.h" #include "ui/screens/addtorrent/addtorrentdialog.h" #include "ui/screens/addtorrent/addtorrenthelpers.h" #include "ui/widgets/torrentremotedirectoryselectionwidget.h" #ifdef Q_OS_WIN # include "ui/systemcolorsprovider.h" #endif namespace tremotesf { namespace { #ifdef Q_OS_WIN constexpr std::array darkThemeComboBoxValues{ Settings::DarkThemeMode::FollowSystem, Settings::DarkThemeMode::On, Settings::DarkThemeMode::Off }; #endif constexpr std::array torrentDoubleClickActionComboBoxValues{ Settings::TorrentDoubleClickAction::OpenPropertiesDialog, Settings::TorrentDoubleClickAction::OpenTorrentFile, Settings::TorrentDoubleClickAction::OpenDownloadDirectory }; std::invocable auto createGeneralPage(KPageWidget* pageWidget, Settings* settings) { auto page = new QWidget(); //: Options tab pageWidget->addPage(page, qApp->translate("tremotesf", "General")) ->setIcon(QIcon::fromTheme("preferences-desktop")); auto layout = new QFormLayout(page); layout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); #ifdef Q_OS_WIN auto darkThemeComboBox = new QComboBox(pageWidget); //: Dark theme mode darkThemeComboBox->addItem(qApp->translate("tremotesf", "Follow system")); //: Dark theme mode darkThemeComboBox->addItem(qApp->translate("tremotesf", "On")); //: Dark theme mode darkThemeComboBox->addItem(qApp->translate("tremotesf", "Off")); layout->addRow(qApp->translate("tremotesf", "Dark theme"), darkThemeComboBox); auto systemAccentColorCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Use system accent color"), page ); layout->addRow(systemAccentColorCheckBox); darkThemeComboBox->setCurrentIndex( // NOLINTNEXTLINE(bugprone-unchecked-optional-access) indexOfCasted(darkThemeComboBoxValues, settings->get_darkThemeMode()).value() ); systemAccentColorCheckBox->setChecked(settings->get_useSystemAccentColor()); #endif auto showTorrentPropertiesInMainWindowCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Show torrent properties in a panel in the main window"), page ); layout->addRow(showTorrentPropertiesInMainWindowCheckBox); showTorrentPropertiesInMainWindowCheckBox->setChecked(settings->get_showTorrentPropertiesInMainWindow()); auto torrentDoubleClickActionComboBox = new QComboBox(page); layout->addRow( qApp->translate("tremotesf", "What to do when torrent in the list is double clicked:"), torrentDoubleClickActionComboBox ); for (const auto action : torrentDoubleClickActionComboBoxValues) { switch (action) { case Settings::TorrentDoubleClickAction::OpenPropertiesDialog: torrentDoubleClickActionComboBox->addItem(qApp->translate("tremotesf", "Open properties dialog")); break; case Settings::TorrentDoubleClickAction::OpenTorrentFile: torrentDoubleClickActionComboBox->addItem(qApp->translate("tremotesf", "Open torrent's file")); break; case Settings::TorrentDoubleClickAction::OpenDownloadDirectory: torrentDoubleClickActionComboBox->addItem(qApp->translate("tremotesf", "Open download directory")); break; default: break; } } torrentDoubleClickActionComboBox->setCurrentIndex( // NOLINTNEXTLINE(bugprone-unchecked-optional-access) indexOfCasted(torrentDoubleClickActionComboBoxValues, settings->get_torrentDoubleClickAction()) .value() ); auto dialogWarningMessage = new KMessageWidget(page); layout->addRow(dialogWarningMessage); dialogWarningMessage->setMessageType(KMessageWidget::Warning); dialogWarningMessage->setCloseButtonVisible(false); dialogWarningMessage->setText(qApp->translate( "tremotesf", "Properties dialog won't be shown because torrent properties are shown in the main window" )); const auto updateDialogWarningMessage = [=] { if (showTorrentPropertiesInMainWindowCheckBox->isChecked() && torrentDoubleClickActionComboBox->currentIndex() == indexOfCasted( torrentDoubleClickActionComboBoxValues, Settings::TorrentDoubleClickAction::OpenPropertiesDialog )) { dialogWarningMessage->animatedShow(); } else { dialogWarningMessage->animatedHide(); } }; updateDialogWarningMessage(); QObject::connect( showTorrentPropertiesInMainWindowCheckBox, &QCheckBox::toggled, dialogWarningMessage, updateDialogWarningMessage ); QObject::connect( torrentDoubleClickActionComboBox, &QComboBox::currentIndexChanged, dialogWarningMessage, updateDialogWarningMessage ); auto connectOnStartupCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Connect to server on startup"), page ); layout->addRow(connectOnStartupCheckBox); connectOnStartupCheckBox->setChecked(settings->get_connectOnStartup()); auto displayRelativeTimeCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Display relative time"), page ); layout->addRow(displayRelativeTimeCheckBox); displayRelativeTimeCheckBox->setChecked(settings->get_displayRelativeTime()); auto displayFullDownloadDirectoryPathCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Display full path of download directories in sidebar and torrents list"), page ); layout->addRow(displayFullDownloadDirectoryPathCheckBox); displayFullDownloadDirectoryPathCheckBox->setChecked(settings->get_displayFullDownloadDirectoryPath()); return [=] { #ifdef Q_OS_WIN if (const auto index = darkThemeComboBox->currentIndex(); index != -1) { settings->set_darkThemeMode(darkThemeComboBoxValues[static_cast(index)]); } if (systemAccentColorCheckBox) { settings->set_useSystemAccentColor(systemAccentColorCheckBox->isChecked()); } #endif settings->set_showTorrentPropertiesInMainWindow(showTorrentPropertiesInMainWindowCheckBox->isChecked()); if (const auto index = torrentDoubleClickActionComboBox->currentIndex(); index != -1) { settings->set_torrentDoubleClickAction( torrentDoubleClickActionComboBoxValues[static_cast(index)] ); } settings->set_connectOnStartup(connectOnStartupCheckBox->isChecked()); settings->set_displayRelativeTime(displayRelativeTimeCheckBox->isChecked()); settings->set_displayFullDownloadDirectoryPath(displayFullDownloadDirectoryPathCheckBox->isChecked()); }; } std::invocable auto createAddingTorrentsPage(KPageWidget* pageWidget, Settings* settings, Rpc* rpc) { auto page = new QWidget(); //: Options tab pageWidget->addPage(page, qApp->translate("tremotesf", "Adding torrents")) ->setIcon(QIcon::fromTheme("folder-download")); auto layout = new QVBoxLayout(page); layout->setSizeConstraint(QLayout::SetMinAndMaxSize); auto rememberOpenTorrentDirCheckbox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Remember location of last opened torrent file"), page ); layout->addWidget(rememberOpenTorrentDirCheckbox); auto rememberAddTorrentParametersCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Remember parameters of last added torrent"), page ); layout->addWidget(rememberAddTorrentParametersCheckBox); auto addTorrentParametersDisconnectedMessage = new KMessageWidget( //: Server connection status qApp->translate("tremotesf", "Disconnected"), page ); addTorrentParametersDisconnectedMessage->setMessageType(KMessageWidget::Warning); addTorrentParametersDisconnectedMessage->setCloseButtonVisible(false); layout->addWidget(addTorrentParametersDisconnectedMessage); auto addTorrentParametersGroupBox = new QGroupBox(qApp->translate("tremotesf", "Add torrent parameters"), page); auto addTorrentParametersGroupBoxLayout = new QFormLayout(addTorrentParametersGroupBox); addTorrentParametersGroupBoxLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); const auto addTorrentParametersWidgets = AddTorrentDialog::createAddTorrentParametersWidgets(true, addTorrentParametersGroupBoxLayout, rpc); auto addTorrentParametersResetButton = new QPushButton(qApp->translate("tremotesf", "Reset"), page); addTorrentParametersGroupBoxLayout->addRow(addTorrentParametersResetButton); layout->addWidget(addTorrentParametersGroupBox); QCheckBox* showMainWindowWhenAddingTorrentsCheckBox{}; // Disabling this option does not work on macOS since the app is always activated when files are opened, // which causes us to show main window if constexpr (targetOs != TargetOs::UnixMacOS) { showMainWindowWhenAddingTorrentsCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Show main window when adding torrents"), page ); layout->addWidget(showMainWindowWhenAddingTorrentsCheckBox); } auto showDialogWhenAddingTorrentsCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Show dialog when adding torrents"), page ); layout->addWidget(showDialogWhenAddingTorrentsCheckBox); auto fillTorrentLinkFromKeyboardCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Automatically fill link from clipboard when adding torrent link"), page ); layout->addWidget(fillTorrentLinkFromKeyboardCheckBox); auto pasteTipLabel = new QLabel( //: %1 is a key binding, e.g. "Ctrl + C" qApp->translate("tremotesf", "Tip: you can also press %1 in main window to add torrents from clipboard") .arg(QKeySequence(QKeySequence::Paste).toString(QKeySequence::NativeText)) ); layout->addWidget(pasteTipLabel); auto askForMergingTrackersCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Ask for merging trackers when adding existing torrent"), page ); layout->addWidget(askForMergingTrackersCheckBox); auto mergeTrackersCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Merge trackers when adding existing torrent"), page ); layout->addWidget(mergeTrackersCheckBox); QObject::connect( askForMergingTrackersCheckBox, &QCheckBox::toggled, mergeTrackersCheckBox, [mergeTrackersCheckBox](bool checked) { mergeTrackersCheckBox->setEnabled(!checked); } ); layout->addStretch(); rememberOpenTorrentDirCheckbox->setChecked(settings->get_rememberOpenTorrentDir()); rememberAddTorrentParametersCheckBox->setChecked(settings->get_rememberAddTorrentParameters()); addTorrentParametersDisconnectedMessage->setVisible(!rpc->isConnected()); addTorrentParametersGroupBox->setEnabled(rpc->isConnected()); QObject::connect(rpc, &Rpc::connectedChanged, page, [=] { const bool connected = rpc->isConnected(); if (connected) { addTorrentParametersDisconnectedMessage->animatedHide(); } else { addTorrentParametersDisconnectedMessage->animatedShow(); } addTorrentParametersGroupBox->setEnabled(connected); if (connected) { // Update parameters which initial values depend on server state const auto parameters = getAddTorrentParameters(rpc); addTorrentParametersWidgets.downloadDirectoryWidget->updatePath(parameters.downloadDirectory); addTorrentParametersWidgets.startTorrentCheckBox->setChecked(parameters.startAfterAdding); } }); QObject::connect(addTorrentParametersResetButton, &QPushButton::clicked, page, [=] { addTorrentParametersWidgets.reset(rpc); }); if (showMainWindowWhenAddingTorrentsCheckBox) { showMainWindowWhenAddingTorrentsCheckBox->setChecked(settings->get_showMainWindowWhenAddingTorrent()); } showDialogWhenAddingTorrentsCheckBox->setChecked(settings->get_showAddTorrentDialog()); fillTorrentLinkFromKeyboardCheckBox->setChecked(settings->get_fillTorrentLinkFromClipboard()); askForMergingTrackersCheckBox->setChecked(settings->get_askForMergingTrackersWhenAddingExistingTorrent()); mergeTrackersCheckBox->setChecked(settings->get_mergeTrackersWhenAddingExistingTorrent()); mergeTrackersCheckBox->setEnabled(!askForMergingTrackersCheckBox->isChecked()); return [=] { settings->set_rememberOpenTorrentDir(rememberOpenTorrentDirCheckbox->isChecked()); settings->set_rememberAddTorrentParameters(rememberAddTorrentParametersCheckBox->isChecked()); addTorrentParametersWidgets.saveToSettings(); if (showMainWindowWhenAddingTorrentsCheckBox) { settings->set_showMainWindowWhenAddingTorrent(showMainWindowWhenAddingTorrentsCheckBox->isChecked() ); } settings->set_showAddTorrentDialog(showDialogWhenAddingTorrentsCheckBox->isChecked()); settings->set_fillTorrentLinkFromClipboard(fillTorrentLinkFromKeyboardCheckBox->isChecked()); settings->set_askForMergingTrackersWhenAddingExistingTorrent(askForMergingTrackersCheckBox->isChecked() ); settings->set_mergeTrackersWhenAddingExistingTorrent(mergeTrackersCheckBox->isChecked()); }; } std::invocable auto createNotificationsPage(KPageWidget* pageWidget, Settings* settings) { auto page = new QWidget(); //: Options tab pageWidget->addPage(page, qApp->translate("tremotesf", "Notifications")) ->setIcon(QIcon::fromTheme("preferences-desktop-notification")); auto layout = new QVBoxLayout(page); layout->setSizeConstraint(QLayout::SetMinAndMaxSize); auto notificationOnDisconnectingCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Notify when disconnecting from server"), page ); layout->addWidget(notificationOnDisconnectingCheckBox); auto notificationOnAddingTorrentCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Notify on added torrents"), page ); layout->addWidget(notificationOnAddingTorrentCheckBox); auto notificationOfFinishedTorrentsCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Notify on finished torrents"), page ); layout->addWidget(notificationOfFinishedTorrentsCheckBox); auto trayIconCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Show icon in the notification area"), page ); layout->addWidget(trayIconCheckBox); //: Notifications options section auto whenConnectingGroupBox = new QGroupBox(qApp->translate("tremotesf", "When connecting to server"), page); layout->addWidget(whenConnectingGroupBox); auto whenConnectingGroupBoxLayout = new QVBoxLayout(whenConnectingGroupBox); whenConnectingGroupBoxLayout->setSizeConstraint(QLayout::SetMinAndMaxSize); auto addedSinceLastConnectionCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Notify on added torrents since last connection to server"), whenConnectingGroupBox ); whenConnectingGroupBoxLayout->addWidget(addedSinceLastConnectionCheckBox); auto finishedSinceLastConnectionCheckBox = new QCheckBox( //: Check box label qApp->translate("tremotesf", "Notify on finished torrents since last connection to server"), whenConnectingGroupBox ); whenConnectingGroupBoxLayout->addWidget(finishedSinceLastConnectionCheckBox); notificationOnDisconnectingCheckBox->setChecked(settings->get_notificationOnDisconnecting()); notificationOnAddingTorrentCheckBox->setChecked(settings->get_notificationOnAddingTorrent()); notificationOfFinishedTorrentsCheckBox->setChecked(settings->get_notificationOfFinishedTorrents()); trayIconCheckBox->setChecked(settings->get_showTrayIcon()); addedSinceLastConnectionCheckBox->setChecked(settings->get_notificationsOnAddedTorrentsSinceLastConnection() ); finishedSinceLastConnectionCheckBox->setChecked( settings->get_notificationsOnFinishedTorrentsSinceLastConnection() ); return [=] { settings->set_notificationOnDisconnecting(notificationOnDisconnectingCheckBox->isChecked()); settings->set_notificationOnAddingTorrent(notificationOnAddingTorrentCheckBox->isChecked()); settings->set_notificationOfFinishedTorrents(notificationOfFinishedTorrentsCheckBox->isChecked()); settings->set_showTrayIcon(trayIconCheckBox->isChecked()); settings->set_notificationsOnAddedTorrentsSinceLastConnection( addedSinceLastConnectionCheckBox->isChecked() ); settings->set_notificationsOnFinishedTorrentsSinceLastConnection( finishedSinceLastConnectionCheckBox->isChecked() ); }; } } SettingsDialog::SettingsDialog(Rpc* rpc, QWidget* parent) : QDialog(parent) { //: Dialog title setWindowTitle(qApp->translate("tremotesf", "Options")); auto rootLayout = new QVBoxLayout(this); rootLayout->setContentsMargins(0, 0, 0, 0); auto pageWidget = new KPageWidget(this); rootLayout->addWidget(pageWidget); auto settings = Settings::instance(); const auto saveGeneralPage = createGeneralPage(pageWidget, settings); const auto saveAddingTorrentsPage = createAddingTorrentsPage(pageWidget, settings, rpc); const auto saveNotificationsPage = createNotificationsPage(pageWidget, settings); auto dialogButtonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); QObject::connect(dialogButtonBox, &QDialogButtonBox::accepted, this, &SettingsDialog::accept); QObject::connect(dialogButtonBox, &QDialogButtonBox::rejected, this, &SettingsDialog::reject); pageWidget->setPageFooter(dialogButtonBox); QObject::connect(this, &SettingsDialog::accepted, this, [=] { saveGeneralPage(); saveAddingTorrentsPage(); saveNotificationsPage(); }); } } tremotesf-2.8.2/src/ui/screens/settingsdialog.h000066400000000000000000000006521500171105600215710ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_SETTINGSDIALOG_H #define TREMOTESF_SETTINGSDIALOG_H #include namespace tremotesf { class Rpc; class SettingsDialog final : public QDialog { Q_OBJECT public: explicit SettingsDialog(Rpc* rpc, QWidget* parent = nullptr); }; } #endif // TREMOTESF_SETTINGSDIALOG_H tremotesf-2.8.2/src/ui/screens/torrentproperties/000077500000000000000000000000001500171105600222075ustar00rootroot00000000000000tremotesf-2.8.2/src/ui/screens/torrentproperties/peersmodel.cpp000066400000000000000000000131321500171105600250520ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "peersmodel.h" #include #include #include #include #include "log/log.h" #include "rpc/torrent.h" #include "formatutils.h" #include "stdutils.h" namespace tremotesf { PeersModel::~PeersModel() { if (mTorrent) { mTorrent->setPeersEnabled(false); } } int PeersModel::columnCount(const QModelIndex&) const { return QMetaEnum::fromType().keyCount(); } QVariant PeersModel::data(const QModelIndex& index, int role) const { if (!index.isValid()) { return {}; } const Peer& peer = mPeers.at(static_cast(index.row())); switch (role) { case Qt::DisplayRole: switch (static_cast(index.column())) { case Column::Address: return peer.address; case Column::DownloadSpeed: return formatutils::formatByteSpeed(peer.downloadSpeed); case Column::UploadSpeed: return formatutils::formatByteSpeed(peer.uploadSpeed); case Column::ProgressBar: case Column::Progress: return formatutils::formatProgress(peer.progress); case Column::Flags: return peer.flags; case Column::Client: return peer.client; default: break; } break; case Qt::ToolTipRole: switch (static_cast(index.column())) { case Column::Address: case Column::Client: return data(index, Qt::DisplayRole); default: break; } break; case SortRole: switch (static_cast(index.column())) { case Column::DownloadSpeed: return peer.downloadSpeed; case Column::UploadSpeed: return peer.uploadSpeed; case Column::ProgressBar: case Column::Progress: return peer.progress; default: return data(index, Qt::DisplayRole); } default: break; } return {}; } QVariant PeersModel::headerData(int section, Qt::Orientation orientation, int role) const { if (orientation != Qt::Horizontal || role != Qt::DisplayRole) { return {}; } switch (static_cast(section)) { case Column::Address: //: Peers list column title return qApp->translate("tremotesf", "Address"); case Column::DownloadSpeed: //: Peers list column title return qApp->translate("tremotesf", "Down Speed"); case Column::UploadSpeed: //: Peers list column title return qApp->translate("tremotesf", "Up Speed"); case Column::ProgressBar: //: Peers list column title return qApp->translate("tremotesf", "Progress Bar"); case Column::Progress: //: Peers list column title return qApp->translate("tremotesf", "Progress"); case Column::Flags: //: Peers list column title return qApp->translate("tremotesf", "Flags"); case Column::Client: //: Peers list column title return qApp->translate("tremotesf", "Client"); default: return {}; } } int PeersModel::rowCount(const QModelIndex&) const { return static_cast(mPeers.size()); } Torrent* PeersModel::torrent() const { return mTorrent; } void PeersModel::setTorrent(Torrent* torrent, bool oldTorrentDestroyed) { if (torrent == mTorrent) { return; } if (mTorrent && !oldTorrentDestroyed) { QObject::disconnect(mTorrent, nullptr, this, nullptr); mTorrent->setPeersEnabled(false); } mTorrent = torrent; beginResetModel(); mPeers.clear(); if (mTorrent) { if (mTorrent->isPeersEnabled()) { warning().log("{} already has enabled peers, this shouldn't happen", *mTorrent); } mTorrent->setPeersEnabled(true); QObject::connect(mTorrent, &Torrent::peersUpdated, this, &PeersModel::update); QObject::connect(mTorrent, &QObject::destroyed, this, [this] { setTorrent(nullptr, true); }); } endResetModel(); } void PeersModel::update( const std::vector>& removedIndexRanges, const std::vector>& changedIndexRanges, int addedCount ) { for (const auto& [first, last] : removedIndexRanges) { beginRemoveRows({}, first, last - 1); mPeers.erase(mPeers.begin() + first, mPeers.begin() + last); endRemoveRows(); } const auto& newPeers = mTorrent->peers(); for (const auto& [first, last] : changedIndexRanges) { std::ranges::copy(slice(newPeers, first, last), mPeers.begin() + first); emit dataChanged(index(0, columnCount() - 1), index(last - 1, columnCount() - 1)); } if (addedCount > 0) { beginInsertRows({}, static_cast(mPeers.size()), static_cast(newPeers.size()) - 1); mPeers.reserve(newPeers.size()); std::ranges::copy( std::views::drop(newPeers, static_cast(mPeers.size())), std::back_insert_iterator(mPeers) ); endInsertRows(); } } } tremotesf-2.8.2/src/ui/screens/torrentproperties/peersmodel.h000066400000000000000000000027361500171105600245270ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_PEERSMODEL_H #define TREMOTESF_PEERSMODEL_H #include #include #include "rpc/peer.h" namespace tremotesf { class Torrent; } namespace tremotesf { class PeersModel final : public QAbstractTableModel { Q_OBJECT public: enum class Column { Address, DownloadSpeed, UploadSpeed, ProgressBar, Progress, Flags, Client }; Q_ENUM(Column) static constexpr auto SortRole = Qt::UserRole; inline explicit PeersModel(QObject* parent = nullptr) : QAbstractTableModel(parent) {} ~PeersModel() override; Q_DISABLE_COPY_MOVE(PeersModel) int columnCount(const QModelIndex& = {}) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; int rowCount(const QModelIndex&) const override; Torrent* torrent() const; void setTorrent(Torrent* torrent, bool oldTorrentDestroyed); private: void update( const std::vector>& removedIndexRanges, const std::vector>& changedIndexRanges, int addedCount ); std::vector mPeers{}; Torrent* mTorrent{}; }; } #endif // TREMOTESF_PEERSMODEL_H tremotesf-2.8.2/src/ui/screens/torrentproperties/torrentfilesmodel.cpp000066400000000000000000000237751500171105600264720ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "torrentfilesmodel.h" #include #include "coroutines/threadpool.h" #include "log/log.h" #include "rpc/mounteddirectoriesutils.h" #include "rpc/torrent.h" #include "rpc/serversettings.h" #include "rpc/rpc.h" namespace tremotesf { namespace { void updateFile(TorrentFilesModelFile* treeFile, const TorrentFile& file) { treeFile->setChanged(false); treeFile->setCompletedSize(file.completedSize); treeFile->setWanted(file.wanted); treeFile->setPriority(TorrentFilesModelEntry::fromFilePriority(file.priority)); } std::vector idsFromIndex(const QModelIndex& index) { auto entry = static_cast(index.internalPointer()); if (entry->isDirectory()) { return static_cast(entry)->childrenIds(); } return {static_cast(entry)->id()}; } std::vector idsFromIndexes(const QList& indexes) { std::vector ids{}; // at least indexes.size(), but may be more ids.reserve(static_cast(indexes.size())); for (const QModelIndex& index : indexes) { auto entry = static_cast(index.internalPointer()); if (entry->isDirectory()) { const auto childrenIds = static_cast(entry)->childrenIds(); ids.reserve(ids.size() + childrenIds.size()); ids.insert(ids.end(), childrenIds.begin(), childrenIds.end()); } else { ids.push_back(static_cast(entry)->id()); } } std::ranges::sort(ids); const auto toErase = std::ranges::unique(ids); ids.erase(toErase.begin(), toErase.end()); return ids; } std::pair, std::vector> doCreateTree(const std::vector& files) { auto rootDirectory = std::make_shared(); std::vector treeFiles; treeFiles.reserve(files.size()); for (size_t fileIndex = 0, filesCount = files.size(); fileIndex < filesCount; ++fileIndex) { const TorrentFile& file = files[fileIndex]; TorrentFilesModelDirectory* currentDirectory = rootDirectory.get(); const std::vector parts(file.path); for (size_t partIndex = 0, partsCount = parts.size(), lastPartIndex = partsCount - 1; partIndex < partsCount; ++partIndex) { const QString& part = parts[partIndex]; if (partIndex == lastPartIndex) { auto* childFile = currentDirectory->addFile(static_cast(fileIndex), part, file.size); updateFile(childFile, file); childFile->setChanged(false); treeFiles.push_back(childFile); } else { const auto& childrenHash = currentDirectory->childrenHash(); const auto found = childrenHash.find(part); if (found != childrenHash.end()) { currentDirectory = static_cast(found->second); } else { currentDirectory = currentDirectory->addDirectory(part); } } } } return {std::move(rootDirectory), std::move(treeFiles)}; } } TorrentFilesModel::TorrentFilesModel(Rpc* rpc, QObject* parent) : BaseTorrentFilesModel( {Column::Name, Column::Size, Column::ProgressBar, Column::Progress, Column::Priority}, parent ) { setRpc(rpc); } TorrentFilesModel::~TorrentFilesModel() { if (mTorrent) { mTorrent->setFilesEnabled(false); } } Torrent* TorrentFilesModel::torrent() const { return mTorrent; } void TorrentFilesModel::setTorrent(Torrent* torrent, bool oldTorrentDestroyed) { if (torrent == mTorrent) { return; } if (mTorrent && !oldTorrentDestroyed) { QObject::disconnect(mTorrent, nullptr, this, nullptr); mTorrent->setFilesEnabled(false); } mTorrent = torrent; resetTree(); if (mTorrent) { QObject::connect(mTorrent, &Torrent::filesUpdated, this, &TorrentFilesModel::update); QObject::connect(mTorrent, &Torrent::fileRenamed, this, &TorrentFilesModel::fileRenamed); if (mTorrent->isFilesEnabled()) { warning().log("{} already has enabled files, this shouldn't happen", *mTorrent); } mTorrent->setFilesEnabled(true); QObject::connect(mTorrent, &QObject::destroyed, this, [this] { setTorrent(nullptr, true); }); } } Rpc* TorrentFilesModel::rpc() const { return mRpc; } void TorrentFilesModel::setRpc(Rpc* rpc) { mRpc = rpc; } void TorrentFilesModel::setFileWanted(const QModelIndex& index, bool wanted) { BaseTorrentFilesModel::setFileWanted(index, wanted); mTorrent->setFilesWanted(idsFromIndex(index), wanted); } void TorrentFilesModel::setFilesWanted(const QModelIndexList& indexes, bool wanted) { BaseTorrentFilesModel::setFilesWanted(indexes, wanted); mTorrent->setFilesWanted(idsFromIndexes(indexes), wanted); } void TorrentFilesModel::setFilePriority(const QModelIndex& index, TorrentFilesModelEntry::Priority priority) { BaseTorrentFilesModel::setFilePriority(index, priority); mTorrent->setFilesPriority(idsFromIndex(index), TorrentFilesModelEntry::toFilePriority(priority)); } void TorrentFilesModel::setFilesPriority(const QModelIndexList& indexes, TorrentFilesModelEntry::Priority priority) { BaseTorrentFilesModel::setFilesPriority(indexes, priority); mTorrent->setFilesPriority(idsFromIndexes(indexes), TorrentFilesModelEntry::toFilePriority(priority)); } void TorrentFilesModel::renameFile(const QModelIndex& index, const QString& newName) { mTorrent->renameFile(static_cast(index.internalPointer())->path(), newName); } void TorrentFilesModel::fileRenamed(const QString& path, const QString& newName) { if (!mLoaded || !mRootDirectory) { return; } TorrentFilesModelEntry* entry = mRootDirectory.get(); const auto parts = path.split('/', Qt::SkipEmptyParts); for (const QString& part : parts) { entry = static_cast(entry)->childrenHash().at(part); } BaseTorrentFilesModel::fileRenamed(entry, newName); } QString TorrentFilesModel::localFilePath(const QModelIndex& index) const { if (!index.isValid()) { return localTorrentDownloadDirectoryPath(mRpc, mTorrent); } if (mTorrent->data().singleFile) { return localTorrentRootFilePath(mRpc, mTorrent); } const auto* entry = static_cast(index.internalPointer()); QString path(entry->path()); if (!entry->isDirectory() && entry->progress() < 1 && mRpc->serverSettings()->data().renameIncompleteFiles) { path += ".part"_l1; } return localTorrentDownloadDirectoryPath(mRpc, mTorrent) % '/' % path; } bool TorrentFilesModel::isWanted(const QModelIndex& index) const { if (!index.isValid()) { return true; } return static_cast(index.internalPointer())->wantedState() != TorrentFilesModelEntry::Unwanted; } void TorrentFilesModel::update(std::span changed) { if (mLoaded) { updateTree(changed); } else { mCoroutineScope.launch(createTree()); } } Coroutine<> TorrentFilesModel::createTree() { if (mCreatingTree) { co_return; } mCreatingTree = true; beginResetModel(); auto [rootDirectory, files] = co_await runOnThreadPool( [](const std::vector& files) { return doCreateTree(files); }, mTorrent->files() ); mRootDirectory = std::move(rootDirectory); endResetModel(); mFiles = std::move(files); setLoaded(true); mCreatingTree = false; } void TorrentFilesModel::resetTree() { if (mLoaded) { beginResetModel(); mRootDirectory.reset(); endResetModel(); mFiles.clear(); setLoaded(false); } } void TorrentFilesModel::updateTree(std::span changed) { if (!changed.empty()) { const auto& torrentFiles = mTorrent->files(); auto changedIter(changed.begin()); int changedIndex = *changedIter; const auto changedEnd(changed.end()); for (int i = 0, max = static_cast(mFiles.size()); i < max; ++i) { const auto& file = mFiles[static_cast(i)]; if (i == changedIndex) { updateFile(file, torrentFiles.at(static_cast(changedIndex))); ++changedIter; if (changedIter == changedEnd) { changedIndex = -1; } else { changedIndex = *changedIter; } } else { file->setChanged(false); } } updateDirectoryChildren(); } } void TorrentFilesModel::setLoaded(bool loaded) { mLoaded = loaded; } } tremotesf-2.8.2/src/ui/screens/torrentproperties/torrentfilesmodel.h000066400000000000000000000035461500171105600261310ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_TORRENTFILESMODEL_H #define TREMOTESF_TORRENTFILESMODEL_H #include #include #include "coroutines/scope.h" #include "ui/itemmodels/basetorrentfilesmodel.h" namespace tremotesf { class Rpc; class Torrent; class TorrentFilesModel final : public BaseTorrentFilesModel { Q_OBJECT public: explicit TorrentFilesModel(Rpc* rpc, QObject* parent = nullptr); ~TorrentFilesModel() override; Q_DISABLE_COPY_MOVE(TorrentFilesModel) Torrent* torrent() const; void setTorrent(Torrent* torrent, bool oldTorrentDestroyed); Rpc* rpc() const; void setRpc(Rpc* rpc); void setFileWanted(const QModelIndex& index, bool wanted) override; void setFilesWanted(const QModelIndexList& indexes, bool wanted) override; void setFilePriority(const QModelIndex& index, TorrentFilesModelEntry::Priority priority) override; void setFilesPriority(const QModelIndexList& indexes, TorrentFilesModelEntry::Priority priority) override; void renameFile(const QModelIndex& index, const QString& newName) override; void fileRenamed(const QString& path, const QString& newName); QString localFilePath(const QModelIndex& index) const; bool isWanted(const QModelIndex& index) const; private: void update(std::span changed); Coroutine<> createTree(); void resetTree(); void updateTree(std::span changed); void setLoaded(bool loaded); Torrent* mTorrent{}; Rpc* mRpc{}; std::vector mFiles{}; bool mCreatingTree{}; bool mLoaded{}; CoroutineScope mCoroutineScope{}; }; } #endif // TREMOTESF_TORRENTFILESMODEL_H tremotesf-2.8.2/src/ui/screens/torrentproperties/torrentpropertiesdialog.cpp000066400000000000000000000060321500171105600277060ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "torrentpropertiesdialog.h" #include #include #include #include #include #include "torrentpropertieswidget.h" #include "settings.h" #include "log/log.h" #include "rpc/rpc.h" #include "rpc/torrent.h" SPECIALIZE_FORMATTER_FOR_QDEBUG(QRect) namespace tremotesf { TorrentPropertiesDialog::TorrentPropertiesDialog(Torrent* torrent, Rpc* rpc, QWidget* parent) : QDialog(parent), mTorrentPropertiesWidget(new TorrentPropertiesWidget(rpc, false, this)) { auto layout = new QVBoxLayout(this); auto messageWidget = new KMessageWidget(this); layout->addWidget(messageWidget); messageWidget->setCloseButtonVisible(false); messageWidget->setMessageType(KMessageWidget::Warning); messageWidget->hide(); mTorrentPropertiesWidget->setTorrent(torrent); layout->addWidget(mTorrentPropertiesWidget); QObject::connect( mTorrentPropertiesWidget, &TorrentPropertiesWidget::hasTorrentChanged, this, [=](bool hasTorrent) { if (hasTorrent) { if (messageWidget->isVisible()) { messageWidget->animatedHide(); } } else { if (rpc->connectionState() == Rpc::ConnectionState::Disconnected) { //: Message that appears when disconnected from server messageWidget->setText(qApp->translate("tremotesf", "Disconnected")); } else { //: Message that appears when torrent is removed messageWidget->setText(qApp->translate("tremotesf", "Torrent Removed")); } messageWidget->animatedShow(); } } ); auto dialogButtonBox = new QDialogButtonBox(QDialogButtonBox::Close, this); QObject::connect(dialogButtonBox, &QDialogButtonBox::rejected, this, &TorrentPropertiesDialog::reject); layout->addWidget(dialogButtonBox); dialogButtonBox->button(QDialogButtonBox::Close)->setDefault(true); setWindowTitle(torrent->data().name); const QString torrentHash = torrent->data().hashString; QObject::connect(rpc, &Rpc::torrentsUpdated, this, [=, this] { auto torrent = rpc->torrentByHash(torrentHash); mTorrentPropertiesWidget->setTorrent(torrent); if (torrent) { setWindowTitle(torrent->data().name); } }); restoreGeometry(Settings::instance()->get_torrentPropertiesDialogGeometry()); } void TorrentPropertiesDialog::saveState() { debug().log("Saving TorrentPropertiesDialog state, window geometry is {}", geometry()); Settings::instance()->set_torrentPropertiesDialogGeometry(saveGeometry()); mTorrentPropertiesWidget->saveState(); } } tremotesf-2.8.2/src/ui/screens/torrentproperties/torrentpropertiesdialog.h000066400000000000000000000014651500171105600273600ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_TORRENTPROPERTIESDIALOG_H #define TREMOTESF_TORRENTPROPERTIESDIALOG_H #include #include "ui/savewindowstatedispatcher.h" namespace tremotesf { class Rpc; class Torrent; class TorrentPropertiesWidget; class TorrentPropertiesDialog final : public QDialog { Q_OBJECT public: explicit TorrentPropertiesDialog(Torrent* torrent, Rpc* rpc, QWidget* parent = nullptr); Q_DISABLE_COPY_MOVE(TorrentPropertiesDialog) private: void saveState(); TorrentPropertiesWidget* mTorrentPropertiesWidget; SaveWindowStateHandler mSaveStateHandler{this, [this] { saveState(); }}; }; } #endif // TREMOTESF_TORRENTPROPERTIESDIALOG_H tremotesf-2.8.2/src/ui/screens/torrentproperties/torrentpropertieswidget.cpp000066400000000000000000000762631500171105600277470ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "torrentpropertieswidget.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "desktoputils.h" #include "formatutils.h" #include "log/log.h" #include "peersmodel.h" #include "settings.h" #include "stdutils.h" #include "torrentfilesmodel.h" #include "trackersviewwidget.h" #include "rpc/pathutils.h" #include "rpc/rpc.h" #include "rpc/serversettings.h" #include "rpc/torrent.h" #include "ui/itemmodels/baseproxymodel.h" #include "ui/itemmodels/stringlistmodel.h" #include "ui/stylehelpers.h" #include "ui/widgets/commondelegate.h" #include "ui/widgets/torrentfilesview.h" namespace tremotesf { namespace { constexpr TorrentData::Priority priorityComboBoxItems[] = { TorrentData::Priority::High, TorrentData::Priority::Normal, TorrentData::Priority::Low }; constexpr TorrentData::RatioLimitMode ratioLimitComboBoxItems[] = { TorrentData::RatioLimitMode::Global, TorrentData::RatioLimitMode::Unlimited, TorrentData::RatioLimitMode::Single }; constexpr TorrentData::IdleSeedingLimitMode idleSeedingLimitComboBoxItems[] = { TorrentData::IdleSeedingLimitMode::Global, TorrentData::IdleSeedingLimitMode::Unlimited, TorrentData::IdleSeedingLimitMode::Single }; } TorrentPropertiesWidget::TorrentPropertiesWidget(Rpc* rpc, bool horizontalDetails, QWidget* parent) : QTabWidget(parent), mRpc(rpc), mFilesModel(new TorrentFilesModel(mRpc, this)), mFilesView(new TorrentFilesView(mFilesModel, mRpc, this)), mTrackersViewWidget(new TrackersViewWidget(mRpc, this)) { setEnabled(false); setupDetailsTab(horizontalDetails); auto filesTab = new QWidget(this); auto filesTabLayout = new QVBoxLayout(filesTab); filesTabLayout->addWidget(mFilesView); overrideBreezeFramelessScrollAreaHeuristic(mFilesView, true); //: Torrent properties dialog tab addTab(filesTab, qApp->translate("tremotesf", "Files")); //: Torrent properties dialog tab addTab(mTrackersViewWidget, qApp->translate("tremotesf", "Trackers")); setupPeersTab(); setupWebSeedersTab(); setupLimitsTab(); } void TorrentPropertiesWidget::saveState() { Settings::instance()->set_peersViewHeaderState(mPeersView->header()->saveState()); mFilesView->saveState(); mTrackersViewWidget->saveState(); } void TorrentPropertiesWidget::setupDetailsTab(bool horizontal) { auto detailsTab = new QScrollArea(this); detailsTab->setWidgetResizable(true); makeScrollAreaTransparent(detailsTab); if (!horizontal) { detailsTab->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); } //: Torrent's properties dialog tab addTab(detailsTab, qApp->translate("tremotesf", "Details")); auto detailsTabScrollContent = new QWidget(detailsTab); detailsTab->setWidget(detailsTabScrollContent); QBoxLayout* detailsTabLayout{}; if (horizontal) { detailsTabLayout = new QHBoxLayout(detailsTabScrollContent); } else { detailsTabLayout = new QVBoxLayout(detailsTabScrollContent); } //: Torrent's details tab section auto activityGroupBox = new QGroupBox(qApp->translate("tremotesf", "Activity"), this); auto activityGroupBoxLayout = new QFormLayout(activityGroupBox); activityGroupBoxLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); auto completedLabel = new QLabel(this); //: Torrent's completed size activityGroupBoxLayout->addRow(qApp->translate("tremotesf", "Completed:"), completedLabel); auto downloadedLabel = new QLabel(this); //: Torrent's downloaded size activityGroupBoxLayout->addRow(qApp->translate("tremotesf", "Downloaded:"), downloadedLabel); auto uploadedLabel = new QLabel(this); //: Torrent's uploaded size activityGroupBoxLayout->addRow(qApp->translate("tremotesf", "Uploaded:"), uploadedLabel); auto ratioLabel = new QLabel(this); activityGroupBoxLayout->addRow(qApp->translate("tremotesf", "Ratio:"), ratioLabel); auto downloadSpeedLabel = new QLabel(this); activityGroupBoxLayout->addRow(qApp->translate("tremotesf", "Download speed:"), downloadSpeedLabel); auto uploadSpeedLabel = new QLabel(this); activityGroupBoxLayout->addRow(qApp->translate("tremotesf", "Upload speed:"), uploadSpeedLabel); auto etaLabel = new QLabel(this); activityGroupBoxLayout->addRow(qApp->translate("tremotesf", "ETA:"), etaLabel); auto seedersLabel = new QLabel(this); activityGroupBoxLayout->addRow(qApp->translate("tremotesf", "Seeders:"), seedersLabel); auto leechersLabel = new QLabel(this); activityGroupBoxLayout->addRow(qApp->translate("tremotesf", "Leechers:"), leechersLabel); auto peersSendingToUsLabel = new QLabel(this); activityGroupBoxLayout->addRow( qApp->translate("tremotesf", "Peers we are downloading from:"), peersSendingToUsLabel ); auto webSeedersSendingToUsLabel = new QLabel(this); activityGroupBoxLayout->addRow( qApp->translate("tremotesf", "Web seeders we are downloading from:"), webSeedersSendingToUsLabel ); auto peersGettingFromUsLabel = new QLabel(this); activityGroupBoxLayout->addRow( qApp->translate("tremotesf", "Peers we are uploading to:"), peersGettingFromUsLabel ); auto lastActivityLabel = new QLabel(this); activityGroupBoxLayout->addRow(qApp->translate("tremotesf", "Last activity:"), lastActivityLabel); detailsTabLayout->addWidget(activityGroupBox); //: Torrent's details tab section auto infoGroupBox = new QGroupBox(qApp->translate("tremotesf", "Information"), this); auto infoGroupBoxLayout = new QFormLayout(infoGroupBox); infoGroupBoxLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); auto totalSizeLabel = new QLabel(this); infoGroupBoxLayout->addRow(qApp->translate("tremotesf", "Total size:"), totalSizeLabel); auto locationLabel = new QLabel(this); locationLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); //: Torrent's download directory infoGroupBoxLayout->addRow(qApp->translate("tremotesf", "Location:"), locationLabel); auto hashLabel = new QLabel(this); hashLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); //: Torrent's hash string infoGroupBoxLayout->addRow(qApp->translate("tremotesf", "Hash:"), hashLabel); auto creatorLabel = new QLabel(this); //: Program that created torrent file infoGroupBoxLayout->addRow(qApp->translate("tremotesf", "Created by:"), creatorLabel); auto creationDateLabel = new QLabel(this); //: Date/time when torrent was created infoGroupBoxLayout->addRow(qApp->translate("tremotesf", "Created on:"), creationDateLabel); auto commentTextEdit = new QTextBrowser(this); commentTextEdit->setOpenExternalLinks(true); commentTextEdit->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Expanding); //: Torrent's comment text infoGroupBoxLayout->addRow(qApp->translate("tremotesf", "Comment:"), commentTextEdit); auto labelsModel = new StringListModel({}, QIcon::fromTheme("tag"_l1), this); auto labelsProxyModel = new BaseProxyModel(labelsModel, Qt::DisplayRole, std::nullopt, this); auto labelsView = new QListView(this); labelsView->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Expanding); labelsView->setFlow(QListView::LeftToRight); labelsView->setWrapping(true); labelsView->setResizeMode(QListView::Adjust); labelsView->setIconSize(QSize(16, 16)); labelsView->setModel(labelsProxyModel); //: Torrent's labels infoGroupBoxLayout->addRow(qApp->translate("tremotesf", "Labels:"), labelsView); auto labelsLabel = infoGroupBoxLayout->labelForField(labelsView); detailsTabLayout->addWidget(infoGroupBox); if (!horizontal) { auto resizer = new KColumnResizer(this); resizer->addWidgetsFromLayout(activityGroupBoxLayout); resizer->addWidgetsFromLayout(infoGroupBoxLayout); } mUpdateDetailsTab = [=, this] { if (mTorrent) { //: Torrent's completion size, e.g. 100 MiB of 200 MiB (50%). %1 is completed size, %2 is size, %3 is progress in percents completedLabel->setText(qApp->translate("tremotesf", "%1 of %2 (%3)") .arg( formatutils::formatByteSize(mTorrent->data().completedSize), formatutils::formatByteSize(mTorrent->data().sizeWhenDone), formatutils::formatProgress(mTorrent->data().percentDone) )); downloadedLabel->setText(formatutils::formatByteSize(mTorrent->data().totalDownloaded)); uploadedLabel->setText(formatutils::formatByteSize(mTorrent->data().totalUploaded)); ratioLabel->setText(formatutils::formatRatio(mTorrent->data().ratio)); downloadSpeedLabel->setText(formatutils::formatByteSpeed(mTorrent->data().downloadSpeed)); uploadSpeedLabel->setText(formatutils::formatByteSpeed(mTorrent->data().uploadSpeed)); etaLabel->setText(formatutils::formatEta(mTorrent->data().eta)); const QLocale locale{}; seedersLabel->setText(locale.toString(mTorrent->data().totalSeedersFromTrackersCount)); leechersLabel->setText(locale.toString(mTorrent->data().totalLeechersFromTrackersCount)); peersSendingToUsLabel->setText(locale.toString(mTorrent->data().peersSendingToUsCount)); webSeedersSendingToUsLabel->setText(locale.toString(mTorrent->data().webSeedersSendingToUsCount)); peersGettingFromUsLabel->setText(locale.toString(mTorrent->data().peersGettingFromUsCount)); lastActivityLabel->setText( formatutils::formatDateTime(mTorrent->data().activityDate.toLocalTime(), QLocale::LongFormat) ); totalSizeLabel->setText(formatutils::formatByteSize(mTorrent->data().totalSize)); locationLabel->setText( toNativeSeparators(mTorrent->data().downloadDirectory, mRpc->serverSettings()->data().pathOs) ); hashLabel->setText(mTorrent->data().hashString); creatorLabel->setText(mTorrent->data().creator); creationDateLabel->setText( formatutils::formatDateTime(mTorrent->data().creationDate.toLocalTime(), QLocale::LongFormat) ); if (mTorrent->data().comment != commentTextEdit->toPlainText()) { commentTextEdit->document()->setPlainText(mTorrent->data().comment); desktoputils::findLinksAndAddAnchors(commentTextEdit->document()); } labelsModel->setStringList(mTorrent->data().labels); } else { completedLabel->clear(); downloadedLabel->clear(); uploadedLabel->clear(); ratioLabel->clear(); downloadSpeedLabel->clear(); uploadSpeedLabel->clear(); etaLabel->clear(); seedersLabel->clear(); leechersLabel->clear(); peersSendingToUsLabel->clear(); webSeedersSendingToUsLabel->clear(); peersGettingFromUsLabel->clear(); lastActivityLabel->clear(); totalSizeLabel->clear(); locationLabel->clear(); hashLabel->clear(); creatorLabel->clear(); creationDateLabel->clear(); commentTextEdit->clear(); labelsModel->setStringList({}); } const bool labelsViewVisible = labelsModel->rowCount() > 0; labelsView->setVisible(labelsViewVisible); labelsLabel->setVisible(labelsViewVisible); }; QObject::connect(Settings::instance(), &Settings::displayRelativeTimeChanged, this, mUpdateDetailsTab); } void TorrentPropertiesWidget::setupPeersTab() { mPeersModel = new PeersModel(this); auto peersProxyModel = new BaseProxyModel(mPeersModel, PeersModel::SortRole, static_cast(PeersModel::Column::Address), this); auto peersTab = new QWidget(this); auto peersTabLayout = new QVBoxLayout(peersTab); mPeersView = new BaseTreeView(this); mPeersView->setItemDelegate(new CommonDelegate( {.progressBarColumn = static_cast(PeersModel::Column::ProgressBar), .progressRole = PeersModel::SortRole}, this )); mPeersView->setModel(peersProxyModel); mPeersView->setRootIsDecorated(false); mPeersView->header()->restoreState(Settings::instance()->get_peersViewHeaderState()); overrideBreezeFramelessScrollAreaHeuristic(mPeersView, true); peersTabLayout->addWidget(mPeersView); //: Torrent's properties dialog tab addTab(peersTab, qApp->translate("tremotesf", "Peers")); } void TorrentPropertiesWidget::setupWebSeedersTab() { //: Web seeders list column title mWebSeedersModel = new StringListModel(qApp->translate("tremotesf", "Web seeder"), {}, this); auto webSeedersProxyModel = new BaseProxyModel(mWebSeedersModel, Qt::DisplayRole, std::nullopt, this); auto webSeedersTab = new QWidget(this); auto webSeedersTabLayout = new QVBoxLayout(webSeedersTab); auto webSeedersView = new BaseTreeView(this); webSeedersView->header()->setContextMenuPolicy(Qt::DefaultContextMenu); webSeedersView->setModel(webSeedersProxyModel); webSeedersView->setRootIsDecorated(false); overrideBreezeFramelessScrollAreaHeuristic(webSeedersView, true); webSeedersTabLayout->addWidget(webSeedersView); //: Torrent's properties dialog tab addTab(webSeedersTab, qApp->translate("tremotesf", "Web seeders")); } void TorrentPropertiesWidget::setupLimitsTab() { auto limitsTab = new QScrollArea(this); limitsTab->setWidgetResizable(true); limitsTab->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); makeScrollAreaTransparent(limitsTab); //: Torrent's properties dialog tab addTab(limitsTab, qApp->translate("tremotesf", "Limits")); auto limitsTabScrollContent = new QWidget(limitsTab); limitsTab->setWidget(limitsTabScrollContent); auto limitsTabLayout = new QVBoxLayout(limitsTabScrollContent); // // Speed group box // //: Torrent's limits tab section auto speedGroupBox = new QGroupBox(qApp->translate("tremotesf", "Speed"), this); auto speedGroupBoxLayout = new QFormLayout(speedGroupBox); speedGroupBoxLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); speedGroupBoxLayout->setFormAlignment(Qt::AlignLeft | Qt::AlignVCenter); //: Check box label auto globalLimitsCheckBox = new QCheckBox(qApp->translate("tremotesf", "Honor global limits"), this); speedGroupBoxLayout->addRow(globalLimitsCheckBox); const int maxSpeedLimit = static_cast(std::numeric_limits::max() / 1024); //: Download speed limit input field label auto downloadSpeedCheckBox = new QCheckBox(qApp->translate("tremotesf", "Download:"), this); speedGroupBoxLayout->addRow(downloadSpeedCheckBox); auto downloadSpeedSpinBoxLayout = new QHBoxLayout(); speedGroupBoxLayout->addRow(downloadSpeedSpinBoxLayout); auto downloadSpeedSpinBox = new QSpinBox(this); downloadSpeedSpinBox->setEnabled(false); downloadSpeedSpinBox->setMaximum(maxSpeedLimit); //: Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes downloadSpeedSpinBox->setSuffix(qApp->translate("tremotesf", " kB/s")); QObject::connect(downloadSpeedCheckBox, &QCheckBox::toggled, downloadSpeedSpinBox, &QSpinBox::setEnabled); downloadSpeedSpinBoxLayout->addSpacing(28); downloadSpeedSpinBoxLayout->addWidget(downloadSpeedSpinBox); //: Upload speed limit input field label auto uploadSpeedCheckBox = new QCheckBox(qApp->translate("tremotesf", "Upload:"), this); speedGroupBoxLayout->addRow(uploadSpeedCheckBox); auto uploadSpeedSpinBoxLayout = new QHBoxLayout(); speedGroupBoxLayout->addRow(uploadSpeedSpinBoxLayout); auto uploadSpeedSpinBox = new QSpinBox(this); uploadSpeedSpinBox->setEnabled(false); uploadSpeedSpinBox->setMaximum(maxSpeedLimit); //: Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes uploadSpeedSpinBox->setSuffix(qApp->translate("tremotesf", " kB/s")); QObject::connect(uploadSpeedCheckBox, &QCheckBox::toggled, uploadSpeedSpinBox, &QSpinBox::setEnabled); uploadSpeedSpinBoxLayout->addSpacing(28); uploadSpeedSpinBoxLayout->addWidget(uploadSpeedSpinBox); auto priorityComboBox = new QComboBox(this); for (const TorrentData::Priority priority : priorityComboBoxItems) { switch (priority) { case TorrentData::Priority::High: //: Torrent's loading priority priorityComboBox->addItem(qApp->translate("tremotesf", "High")); break; case TorrentData::Priority::Normal: //: Torrent's loading priority priorityComboBox->addItem(qApp->translate("tremotesf", "Normal")); break; case TorrentData::Priority::Low: //: Torrent's loading priority priorityComboBox->addItem(qApp->translate("tremotesf", "Low")); break; } } speedGroupBoxLayout->addRow(qApp->translate("tremotesf", "Torrent priority:"), priorityComboBox); limitsTabLayout->addWidget(speedGroupBox); // // Seeding group box // //: Torrent's limits tab section auto seedingGroupBox = new QGroupBox(qApp->translate("tremotesf", "Seeding", "Options section"), this); auto seedingGroupBoxLayout = new QFormLayout(seedingGroupBox); seedingGroupBoxLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); auto ratioLimitLayout = new QHBoxLayout(); seedingGroupBoxLayout->addRow(qApp->translate("tremotesf", "Ratio limit mode:"), ratioLimitLayout); ratioLimitLayout->setContentsMargins(0, 0, 0, 0); auto ratioLimitComboBox = new QComboBox(this); for (const TorrentData::RatioLimitMode mode : ratioLimitComboBoxItems) { switch (mode) { case TorrentData::RatioLimitMode::Global: //: Seeding ratio limit mode (global settings/stop at ratio/unlimited) ratioLimitComboBox->addItem(qApp->translate("tremotesf", "Use global settings")); break; case TorrentData::RatioLimitMode::Single: //: Seeding ratio limit mode (global settings/stop at ratio/unlimited) ratioLimitComboBox->addItem(qApp->translate("tremotesf", "Stop seeding at ratio:")); break; case TorrentData::RatioLimitMode::Unlimited: //: Seeding ratio limit mode (global settings/stop at ratio/unlimited) ratioLimitComboBox->addItem(qApp->translate("tremotesf", "Seed regardless of ratio")); break; } } ratioLimitLayout->addWidget(ratioLimitComboBox); auto ratioLimitSpinBox = new QDoubleSpinBox(this); ratioLimitSpinBox->setMaximum(10000.0); ratioLimitSpinBox->setSingleStep(0.1); ratioLimitSpinBox->setVisible(false); QObject::connect( ratioLimitComboBox, static_cast(&QComboBox::currentIndexChanged), this, [ratioLimitSpinBox](int index) { if (index == indexOfCasted(ratioLimitComboBoxItems, TorrentData::RatioLimitMode::Single)) { ratioLimitSpinBox->show(); } else { ratioLimitSpinBox->hide(); } } ); ratioLimitLayout->addWidget(ratioLimitSpinBox); auto idleSeedingLimitLayout = new QHBoxLayout(); seedingGroupBoxLayout->addRow(qApp->translate("tremotesf", "Idle seeding mode:"), idleSeedingLimitLayout); idleSeedingLimitLayout->setContentsMargins(0, 0, 0, 0); auto idleSeedingLimitComboBox = new QComboBox(this); for (const TorrentData::IdleSeedingLimitMode mode : idleSeedingLimitComboBoxItems) { switch (mode) { case TorrentData::IdleSeedingLimitMode::Global: //: Seeding idle limit mode (global settings/stop if idle for/unlimited) idleSeedingLimitComboBox->addItem(qApp->translate("tremotesf", "Use global settings")); break; case TorrentData::IdleSeedingLimitMode::Single: //: Seeding idle limit mode (global settings/stop if idle for/unlimited) idleSeedingLimitComboBox->addItem(qApp->translate("tremotesf", "Stop seeding if idle for:")); break; case TorrentData::IdleSeedingLimitMode::Unlimited: //: Seeding idle limit mode (global settings/stop if idle for/unlimited) idleSeedingLimitComboBox->addItem(qApp->translate("tremotesf", "Seed regardless of activity")); break; } } idleSeedingLimitLayout->addWidget(idleSeedingLimitComboBox); auto idleSeedingLimitSpinBox = new QSpinBox(this); idleSeedingLimitSpinBox->setMaximum(9999); //: Suffix that is added to input field with number of minuts, e.g. "5 min" idleSeedingLimitSpinBox->setSuffix(qApp->translate("tremotesf", " min")); idleSeedingLimitSpinBox->setVisible(false); QObject::connect( idleSeedingLimitComboBox, static_cast(&QComboBox::currentIndexChanged), this, [idleSeedingLimitSpinBox](int index) { if (index == indexOfCasted(idleSeedingLimitComboBoxItems, TorrentData::IdleSeedingLimitMode::Single)) { idleSeedingLimitSpinBox->show(); } else { idleSeedingLimitSpinBox->hide(); } } ); idleSeedingLimitLayout->addWidget(idleSeedingLimitSpinBox); limitsTabLayout->addWidget(seedingGroupBox); // // Peers group box // //: Torrent's limits tab section auto peersGroupBox = new QGroupBox(qApp->translate("tremotesf", "Peers"), this); auto peersGroupBoxLayout = new QFormLayout(peersGroupBox); peersGroupBoxLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); auto peersLimitsSpinBox = new QSpinBox(this); peersLimitsSpinBox->setMaximum(9999); peersGroupBoxLayout->addRow(qApp->translate("tremotesf", "Maximum peers:"), peersLimitsSpinBox); limitsTabLayout->addWidget(peersGroupBox); limitsTabLayout->addStretch(); auto resizer = new KColumnResizer(this); resizer->addWidgetsFromLayout(speedGroupBoxLayout); resizer->addWidgetsFromLayout(seedingGroupBoxLayout); resizer->addWidgetsFromLayout(peersGroupBoxLayout); QObject::connect(globalLimitsCheckBox, &QCheckBox::toggled, this, [this](bool checked) { if (!mUpdatingLimits && mTorrent) { mTorrent->setHonorSessionLimits(checked); } }); QObject::connect(downloadSpeedCheckBox, &QCheckBox::toggled, this, [this](bool checked) { if (!mUpdatingLimits && mTorrent) { mTorrent->setDownloadSpeedLimited(checked); } }); QObject::connect( downloadSpeedSpinBox, static_cast(&QSpinBox::valueChanged), this, [this](int limit) { if (!mUpdatingLimits && mTorrent) { mTorrent->setDownloadSpeedLimit(limit); } } ); QObject::connect(uploadSpeedCheckBox, &QCheckBox::toggled, this, [this](bool checked) { if (!mUpdatingLimits && mTorrent) { mTorrent->setUploadSpeedLimited(checked); } }); QObject::connect( uploadSpeedSpinBox, static_cast(&QSpinBox::valueChanged), this, [this](int limit) { if (!mUpdatingLimits && mTorrent) { mTorrent->setUploadSpeedLimit(limit); } } ); QObject::connect( priorityComboBox, static_cast(&QComboBox::currentIndexChanged), this, [this](int index) { if (!mUpdatingLimits && mTorrent) { mTorrent->setBandwidthPriority(priorityComboBoxItems[index]); } } ); QObject::connect( ratioLimitComboBox, static_cast(&QComboBox::currentIndexChanged), this, [this](int index) { if (!mUpdatingLimits && mTorrent) { mTorrent->setRatioLimitMode(ratioLimitComboBoxItems[index]); } } ); QObject::connect( ratioLimitSpinBox, static_cast(&QDoubleSpinBox::valueChanged), this, [this](double limit) { if (!mUpdatingLimits && mTorrent) { mTorrent->setRatioLimit(limit); } } ); QObject::connect( idleSeedingLimitComboBox, static_cast(&QComboBox::currentIndexChanged), this, [this](int index) { if (!mUpdatingLimits && mTorrent) { mTorrent->setIdleSeedingLimitMode(idleSeedingLimitComboBoxItems[index]); } } ); QObject::connect( idleSeedingLimitSpinBox, static_cast(&QSpinBox::valueChanged), this, [this](int limit) { if (!mUpdatingLimits && mTorrent) { mTorrent->setIdleSeedingLimit(limit); } } ); QObject::connect( peersLimitsSpinBox, static_cast(&QSpinBox::valueChanged), this, [this](int limit) { if (!mUpdatingLimits && mTorrent) { mTorrent->setPeersLimit(limit); } } ); mUpdateLimitsTab = [=, this] { mUpdatingLimits = true; if (mTorrent) { globalLimitsCheckBox->setChecked(mTorrent->data().honorSessionLimits); downloadSpeedCheckBox->setChecked(mTorrent->data().downloadSpeedLimited); downloadSpeedSpinBox->setValue(mTorrent->data().downloadSpeedLimit); uploadSpeedCheckBox->setChecked(mTorrent->data().uploadSpeedLimited); uploadSpeedSpinBox->setValue(mTorrent->data().uploadSpeedLimit); priorityComboBox->setCurrentIndex( indexOfCasted(priorityComboBoxItems, mTorrent->data().bandwidthPriority).value() ); ratioLimitComboBox->setCurrentIndex( indexOfCasted(ratioLimitComboBoxItems, mTorrent->data().ratioLimitMode).value() ); ratioLimitSpinBox->setValue(mTorrent->data().ratioLimit); idleSeedingLimitComboBox->setCurrentIndex( indexOfCasted(idleSeedingLimitComboBoxItems, mTorrent->data().idleSeedingLimitMode).value() ); idleSeedingLimitSpinBox->setValue(mTorrent->data().idleSeedingLimit); peersLimitsSpinBox->setValue(mTorrent->data().peersLimit); } else { globalLimitsCheckBox->setChecked(false); downloadSpeedCheckBox->setChecked(false); downloadSpeedSpinBox->clear(); uploadSpeedCheckBox->setChecked(false); uploadSpeedSpinBox->clear(); priorityComboBox->setCurrentIndex(0); ratioLimitComboBox->setCurrentIndex(0); ratioLimitSpinBox->clear(); idleSeedingLimitComboBox->setCurrentIndex(0); idleSeedingLimitSpinBox->clear(); peersLimitsSpinBox->clear(); } mUpdatingLimits = false; }; } void TorrentPropertiesWidget::setTorrent(Torrent* torrent) { setTorrent(torrent, false); } void TorrentPropertiesWidget::setTorrent(Torrent* torrent, bool oldTorrentDestroyed) { if (torrent == mTorrent) { return; } if (mTorrent && !oldTorrentDestroyed) { QObject::disconnect(mTorrent, nullptr, this, nullptr); } const bool hadTorrent = mTorrent != nullptr; mTorrent = torrent; const auto update = [this] { mUpdateDetailsTab(); if (mTorrent) { mWebSeedersModel->setStringList(mTorrent->data().webSeeders); } else { mWebSeedersModel->setStringList({}); } mUpdateLimitsTab(); }; update(); if (mTorrent) { QObject::connect(mTorrent, &Torrent::changed, this, update); QObject::connect(mTorrent, &QObject::destroyed, this, [this] { setTorrent(nullptr, true); }); } mFilesModel->setTorrent(mTorrent, oldTorrentDestroyed); mTrackersViewWidget->setTorrent(mTorrent, oldTorrentDestroyed); mPeersModel->setTorrent(mTorrent, oldTorrentDestroyed); const auto onHasTorrentChanged = [this](bool hasTorrent) { setEnabled(hasTorrent); emit hasTorrentChanged(hasTorrent); }; if (hadTorrent && !mTorrent) { onHasTorrentChanged(false); } else if (!hadTorrent && mTorrent) { onHasTorrentChanged(true); } } } tremotesf-2.8.2/src/ui/screens/torrentproperties/torrentpropertieswidget.h000066400000000000000000000030241500171105600273750ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_TORRENTPROPERTIESWIDGET_H #define TREMOTESF_TORRENTPROPERTIESWIDGET_H #include #include namespace tremotesf { class BaseTreeView; class PeersModel; class Rpc; class StringListModel; class TorrentFilesModel; class TorrentFilesView; class Torrent; class TrackersViewWidget; class TorrentPropertiesWidget : public QTabWidget { Q_OBJECT public: explicit TorrentPropertiesWidget(Rpc* rpc, bool horizontalDetails, QWidget* parent = nullptr); void setTorrent(Torrent* torrent); bool hasTorrent() const { return mTorrent != nullptr; } void saveState(); private: void setupDetailsTab(bool horizontal); void setupPeersTab(); void setupWebSeedersTab(); void setupLimitsTab(); void setTorrent(Torrent* torrent, bool oldTorrentDestroyed); Torrent* mTorrent{}; Rpc* const mRpc{}; std::function mUpdateDetailsTab; TorrentFilesModel* mFilesModel{}; TorrentFilesView* mFilesView{}; TrackersViewWidget* mTrackersViewWidget{}; BaseTreeView* mPeersView{}; PeersModel* mPeersModel{}; StringListModel* mWebSeedersModel{}; bool mUpdatingLimits{}; std::function mUpdateLimitsTab; signals: void hasTorrentChanged(bool hasTorrent); }; } #endif // TREMOTESF_TORRENTPROPERTIESWIDGET_H tremotesf-2.8.2/src/ui/screens/torrentproperties/trackersmodel.cpp000066400000000000000000000176571500171105600255720ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "trackersmodel.h" #include #include #include #include #include #include "ui/itemmodels/modelutils.h" #include "formatutils.h" #include "stdutils.h" #include "rpc/torrent.h" #include "rpc/tracker.h" namespace tremotesf { using std::chrono::seconds; using namespace std::chrono_literals; namespace { QString trackerStatusString(const Tracker& tracker) { switch (tracker.status()) { case Tracker::Status::Inactive: //: Tracker status return qApp->translate("tremotesf", "Inactive"); case Tracker::Status::WaitingForUpdate: //: Tracker status return qApp->translate("tremotesf", "Waiting for update"); case Tracker::Status::QueuedForUpdate: //: Tracker status return qApp->translate("tremotesf", "About to update"); case Tracker::Status::Updating: //: Tracker status return qApp->translate("tremotesf", "Updating"); } return {}; } std::optional nextUpdateEtaFor(const Tracker& tracker) { if (!tracker.nextUpdateTime().isValid()) return std::nullopt; const auto secs = QDateTime::currentDateTimeUtc().secsTo(tracker.nextUpdateTime()); if (secs < 0) return std::nullopt; return seconds(secs); } } struct TrackersModel::TrackerItem { Tracker tracker; std::optional nextUpdateEta{}; TrackerItem(Tracker tracker) : tracker(std::move(tracker)), nextUpdateEta(nextUpdateEtaFor(this->tracker)) {} [[nodiscard]] bool operator==(const TrackerItem& other) const = default; [[nodiscard]] TrackerItem withUpdatedEta() const { TrackerItem updated = *this; updated.nextUpdateEta = nextUpdateEtaFor(tracker); return updated; } }; TrackersModel::TrackersModel(QObject* parent) : QAbstractTableModel(parent), mEtaUpdateTimer(new QTimer(this)) { mEtaUpdateTimer->setInterval(1s); mEtaUpdateTimer->setSingleShot(false); QObject::connect(mEtaUpdateTimer, &QTimer::timeout, this, &TrackersModel::updateEtas); } TrackersModel::~TrackersModel() = default; int TrackersModel::columnCount(const QModelIndex&) const { return QMetaEnum::fromType().keyCount(); } QVariant TrackersModel::data(const QModelIndex& index, int role) const { const auto& tracker = mTrackers.at(static_cast(index.row())); if (role == Qt::DisplayRole) { switch (static_cast(index.column())) { case Column::Announce: return tracker.tracker.announce(); case Column::Status: return trackerStatusString(tracker.tracker); case Column::Error: return tracker.tracker.errorMessage(); case Column::NextUpdate: if (tracker.nextUpdateEta.has_value()) { return formatutils::formatEta(static_cast(tracker.nextUpdateEta->count())); } break; case Column::Peers: return tracker.tracker.peers(); case Column::Seeders: return tracker.tracker.seeders(); case Column::Leechers: return tracker.tracker.leechers(); } } else if (role == SortRole) { if (static_cast(index.column()) == Column::NextUpdate && tracker.nextUpdateEta.has_value()) { return static_cast(tracker.nextUpdateEta->count()); } return data(index, Qt::DisplayRole); } return {}; } QVariant TrackersModel::headerData(int section, Qt::Orientation orientation, int role) const { if (orientation != Qt::Horizontal || role != Qt::DisplayRole) { return {}; } switch (static_cast(section)) { case Column::Announce: //: Trackers list column title return qApp->translate("tremotesf", "Address"); case Column::Status: //: Trackers list column title return qApp->translate("tremotesf", "Status"); case Column::Error: //: Trackers list column title return qApp->translate("tremotesf", "Error"); case Column::NextUpdate: //: Trackers list column title return qApp->translate("tremotesf", "Next Update"); case Column::Peers: //: Trackers list column title return qApp->translate("tremotesf", "Peers"); case Column::Seeders: //: Trackers list column title return qApp->translate("tremotesf", "Seeders"); case Column::Leechers: //: Trackers list column title return qApp->translate("tremotesf", "Leechers"); } return {}; } int TrackersModel::rowCount(const QModelIndex&) const { return static_cast(mTrackers.size()); } Torrent* TrackersModel::torrent() const { return mTorrent; } void TrackersModel::setTorrent(Torrent* torrent, bool oldTorrentDestroyed) { if (torrent == mTorrent) { return; } if (mTorrent && !oldTorrentDestroyed) { QObject::disconnect(mTorrent, nullptr, this, nullptr); } mTorrent = torrent; if (mTorrent) { update(); QObject::connect(mTorrent, &Torrent::updated, this, &TrackersModel::update); QObject::connect(mTorrent, &QObject::destroyed, this, [this] { setTorrent(nullptr, true); }); } else { beginResetModel(); mTrackers.clear(); endResetModel(); } } std::vector TrackersModel::idsFromIndexes(const QModelIndexList& indexes) const { return toContainer(indexes | std::views::transform([this](const QModelIndex& index) { return mTrackers.at(static_cast(index.row())).tracker.id(); })); } const Tracker& TrackersModel::trackerAtIndex(const QModelIndex& index) const { return mTrackers.at(static_cast(index.row())).tracker; } class TrackersModelUpdater : public ModelListUpdater> { public: inline explicit TrackersModelUpdater(TrackersModel& model) : ModelListUpdater(model) {} protected: std::vector::iterator findNewItemForItem( std::vector& newItems, const TrackersModel::TrackerItem& item ) override { return std::ranges::find(newItems, item.tracker.id(), [](const auto& tracker) { return tracker.tracker.id(); }); } bool updateItem(TrackersModel::TrackerItem& item, TrackersModel::TrackerItem&& newItem) override { if (newItem != item) { item = std::move(newItem); return true; } return false; } }; void TrackersModel::update() { mEtaUpdateTimer->stop(); TrackersModelUpdater updater(*this); const auto& trackers = mTorrent->data().trackers; updater.update(mTrackers, std::vector(trackers.begin(), trackers.end())); if (std::ranges::any_of(trackers, [](const Tracker& tracker) { return tracker.nextUpdateTime().isValid(); })) { mEtaUpdateTimer->start(); } } void TrackersModel::updateEtas() { TrackersModelUpdater updater(*this); updater.update( mTrackers, toContainer(mTrackers | std::views::transform(&TrackerItem::withUpdatedEta)) ); } } tremotesf-2.8.2/src/ui/screens/torrentproperties/trackersmodel.h000066400000000000000000000033411500171105600252200ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_TRACKERSMODEL_H #define TREMOTESF_TRACKERSMODEL_H #include #include class QTimer; namespace tremotesf { class Torrent; class Tracker; } namespace tremotesf { class TrackersModel final : public QAbstractTableModel { Q_OBJECT public: enum class Column { Announce, Status, Error, NextUpdate, Peers, Seeders, Leechers }; Q_ENUM(Column) static constexpr auto SortRole = Qt::UserRole; explicit TrackersModel(QObject* parent = nullptr); ~TrackersModel() override; Q_DISABLE_COPY_MOVE(TrackersModel) int columnCount(const QModelIndex& = {}) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; int rowCount(const QModelIndex& = {}) const override; Torrent* torrent() const; void setTorrent(Torrent* torrent, bool oldTorrentDestroyed); std::vector idsFromIndexes(const QModelIndexList& indexes) const; const Tracker& trackerAtIndex(const QModelIndex& index) const; using QAbstractItemModel::beginInsertRows; using QAbstractItemModel::beginRemoveRows; using QAbstractItemModel::endInsertRows; using QAbstractItemModel::endRemoveRows; struct TrackerItem; private: void update(); void updateEtas(); Torrent* mTorrent{}; std::vector mTrackers; QTimer* mEtaUpdateTimer{}; }; } #endif // TREMOTESF_TRACKERSMODEL_H tremotesf-2.8.2/src/ui/screens/torrentproperties/trackersviewwidget.cpp000066400000000000000000000214101500171105600266260ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "trackersviewwidget.h" #include #include #include #include #include #include #include #include #include #include #include #include "rpc/torrent.h" #include "rpc/tracker.h" #include "stdutils.h" #include "ui/itemmodels/baseproxymodel.h" #include "ui/widgets/basetreeview.h" #include "ui/widgets/textinputdialog.h" #include "rpc/rpc.h" #include "settings.h" #include "trackersmodel.h" namespace tremotesf { namespace { class EnterEatingTreeView final : public BaseTreeView { Q_OBJECT public: explicit EnterEatingTreeView(QWidget* parent = nullptr) : BaseTreeView(parent) {} protected: void keyPressEvent(QKeyEvent* event) override { BaseTreeView::keyPressEvent(event); switch (event->key()) { case Qt::Key_Enter: case Qt::Key_Return: event->accept(); default: break; } } }; } TrackersViewWidget::TrackersViewWidget(Rpc* rpc, QWidget* parent) : QWidget(parent), mRpc(rpc), mModel(new TrackersModel(this)), mProxyModel(new BaseProxyModel( mModel, TrackersModel::SortRole, static_cast(TrackersModel::Column::Announce), this )), mTrackersView(new EnterEatingTreeView(this)) { auto layout = new QHBoxLayout(this); mTrackersView->setContextMenuPolicy(Qt::CustomContextMenu); mTrackersView->setModel(mProxyModel); mTrackersView->setSelectionMode(QAbstractItemView::ExtendedSelection); mTrackersView->setRootIsDecorated(false); mTrackersView->header()->restoreState(Settings::instance()->get_trackersViewHeaderState()); QObject::connect(mTrackersView, &EnterEatingTreeView::activated, this, &TrackersViewWidget::showEditDialogs); auto removeAction = new QAction( QIcon::fromTheme("list-remove"_l1), //: Tracker's context menu item qApp->translate("tremotesf", "&Remove"), this ); removeAction->setShortcut(QKeySequence::Delete); mTrackersView->addAction(removeAction); QObject::connect(removeAction, &QAction::triggered, this, &TrackersViewWidget::removeTrackers); QObject::connect(mTrackersView, &EnterEatingTreeView::customContextMenuRequested, this, [=, this](QPoint pos) { if (mTrackersView->indexAt(pos).isValid()) { QMenu contextMenu; QAction* editAction = contextMenu.addAction( QIcon::fromTheme("document-properties"_l1), //: Tracker's context menu item qApp->translate("tremotesf", "&Edit...") ); QObject::connect(editAction, &QAction::triggered, this, &TrackersViewWidget::showEditDialogs); contextMenu.addAction(removeAction); contextMenu.exec(mTrackersView->viewport()->mapToGlobal(pos)); } }); layout->addWidget(mTrackersView); auto buttonsLayout = new QVBoxLayout(); layout->addLayout(buttonsLayout); auto addTrackersButton = new QPushButton( QIcon::fromTheme("list-add"_l1), //: Button qApp->translate("tremotesf", "Add..."), this ); QObject::connect(addTrackersButton, &QPushButton::clicked, this, &TrackersViewWidget::addTrackers); buttonsLayout->addWidget(addTrackersButton); auto editButton = new QPushButton( QIcon::fromTheme("document-properties"_l1), //: Button qApp->translate("tremotesf", "Edit..."), this ); QObject::connect(editButton, &QPushButton::clicked, this, &TrackersViewWidget::showEditDialogs); editButton->setEnabled(false); buttonsLayout->addWidget(editButton); auto removeButton = new QPushButton( QIcon::fromTheme("list-remove"_l1), //: Button qApp->translate("tremotesf", "Remove"), this ); removeButton->setEnabled(false); QObject::connect(removeButton, &QPushButton::clicked, this, &TrackersViewWidget::removeTrackers); buttonsLayout->addWidget(removeButton); auto reannounceButton = new QPushButton( QIcon::fromTheme("view-refresh"_l1), //: Button qApp->translate("tremotesf", "Reanno&unce"), this ); QObject::connect(reannounceButton, &QPushButton::clicked, this, [=, this] { mRpc->reannounceTorrents(std::array{mTorrent->data().id}); }); buttonsLayout->addWidget(reannounceButton); buttonsLayout->addStretch(); QObject::connect(mTrackersView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=, this] { const bool hasSelection = mTrackersView->selectionModel()->hasSelection(); editButton->setEnabled(hasSelection); removeButton->setEnabled(hasSelection); }); } void TrackersViewWidget::setTorrent(Torrent* torrent, bool oldTorrentDestroyed) { mTorrent = torrent; mModel->setTorrent(torrent, oldTorrentDestroyed); } void TrackersViewWidget::saveState() { Settings::instance()->set_trackersViewHeaderState(mTrackersView->header()->saveState()); } void TrackersViewWidget::addTrackers() { auto dialog = new TextInputDialog( //: Dialog title qApp->translate("tremotesf", "Add Trackers"), qApp->translate("tremotesf", "Trackers announce URLs:"), QString(), //: Dialog confirmation button qApp->translate("tremotesf", "Add"), true, this ); QObject::connect(dialog, &TextInputDialog::accepted, this, [=, this] { auto lines = dialog->text().split('\n', Qt::SkipEmptyParts); mTorrent->addTrackers(toContainer(lines | std::views::transform([](QString& announceUrl) { return std::set{std::move(announceUrl)}; }))); }); dialog->show(); } void TrackersViewWidget::showEditDialogs() { const QModelIndexList indexes(mTrackersView->selectionModel()->selectedRows()); for (const QModelIndex& index : indexes) { const Tracker& tracker = mModel->trackerAtIndex(mProxyModel->sourceIndex(index)); const int id = tracker.id(); auto dialog = new TextInputDialog( //: Dialog title qApp->translate("tremotesf", "Edit Tracker"), qApp->translate("tremotesf", "Tracker announce URL:"), tracker.announce(), QString(), false, this ); QObject::connect(dialog, &TextInputDialog::accepted, this, [=, this] { mTorrent->setTracker(id, dialog->text()); }); dialog->show(); } } void TrackersViewWidget::removeTrackers() { if (!mTrackersView->selectionModel()->hasSelection()) { return; } QMessageBox dialog(this); dialog.setIcon(QMessageBox::Warning); dialog.setStandardButtons(QMessageBox::Ok | QMessageBox::Cancel); //: Dialog confirmation button dialog.button(QMessageBox::Ok)->setText(qApp->translate("tremotesf", "Remove")); dialog.setDefaultButton(QMessageBox::Cancel); const auto ids = mModel->idsFromIndexes(mProxyModel->sourceIndexes(mTrackersView->selectionModel()->selectedRows())); if (ids.size() == 1) { //: Dialog title dialog.setWindowTitle(qApp->translate("tremotesf", "Remove Tracker")); dialog.setText(qApp->translate("tremotesf", "Are you sure you want to remove this tracker?")); } else { //: Dialog title dialog.setWindowTitle(qApp->translate("tremotesf", "Remove Trackers")); // Don't put static_cast in qApp->translate() - lupdate doesn't like it const auto count = static_cast(ids.size()); //: %Ln is number of trackers selected for deletion dialog.setText( qApp->translate("tremotesf", "Are you sure you want to remove %Ln selected trackers?", nullptr, count) ); } if (dialog.exec() == QMessageBox::Ok) { mTorrent->removeTrackers(ids); } } } #include "trackersviewwidget.moc" tremotesf-2.8.2/src/ui/screens/torrentproperties/trackersviewwidget.h000066400000000000000000000016511500171105600263000ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TRACKERSVIEWWIDGET_H #define TRACKERSVIEWWIDGET_H #include namespace tremotesf { class Torrent; } namespace tremotesf { class BaseProxyModel; class BaseTreeView; class Rpc; class TrackersModel; class TrackersViewWidget final : public QWidget { Q_OBJECT public: TrackersViewWidget(Rpc* rpc, QWidget* parent = nullptr); Q_DISABLE_COPY_MOVE(TrackersViewWidget) void setTorrent(Torrent* torrent, bool oldTorrentDestroyed); void saveState(); private: void addTrackers(); void showEditDialogs(); void removeTrackers(); Torrent* mTorrent{}; Rpc* mRpc{}; TrackersModel* mModel{}; BaseProxyModel* mProxyModel{}; BaseTreeView* mTrackersView{}; }; } #endif // TRACKERSVIEWWIDGET_H tremotesf-2.8.2/src/ui/stylehelpers.cpp000066400000000000000000000035161500171105600201670ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "stylehelpers.h" #include #include #include #include "literals.h" #include "target_os.h" namespace tremotesf { namespace { const QStyle* baseStyle(const QStyle* style) { while (style) { if (const auto proxyStyle = qobject_cast(style); proxyStyle) { style = proxyStyle->baseStyle(); if (style == proxyStyle) { break; } } else { break; } } return style; } } std::optional determineStyle(const QStyle* style) { style = baseStyle(style); #if QT_VERSION_MAJOR >= 6 const auto name = style->name(); #else const auto name = style->objectName(); #endif if constexpr (targetOs == TargetOs::UnixMacOS) { if (name.compare("macos"_l1, Qt::CaseInsensitive) == 0) { return KnownStyle::macOS; } } if (name.compare("breeze"_l1, Qt::CaseInsensitive) == 0) { return KnownStyle::Breeze; } return std::nullopt; } std::optional determineStyle() { return determineStyle(QApplication::style()); } void overrideBreezeFramelessScrollAreaHeuristic(QAbstractScrollArea* widget, bool drawFrame) { widget->setProperty("_breeze_force_frame", drawFrame); } void makeScrollAreaTransparent(QAbstractScrollArea* widget) { widget->setFrameShape(QFrame::NoFrame); QPalette palette{}; palette.setColor(widget->viewport()->backgroundRole(), QColor(Qt::transparent)); widget->setPalette(palette); } } tremotesf-2.8.2/src/ui/stylehelpers.h000066400000000000000000000011471500171105600176320ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_STYLEHELPERS_H #define TREMOTESF_STYLEHELPERS_H #include class QAbstractScrollArea; class QStyle; namespace tremotesf { enum class KnownStyle { Breeze, macOS }; std::optional determineStyle(const QStyle* style); std::optional determineStyle(); void overrideBreezeFramelessScrollAreaHeuristic(QAbstractScrollArea* widget, bool drawFrame); void makeScrollAreaTransparent(QAbstractScrollArea* widget); } #endif // TREMOTESF_STYLEHELPERS_H tremotesf-2.8.2/src/ui/systemcolorsprovider.h000066400000000000000000000035251500171105600214320ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef SYSTEMCOLORSPROVIDER_H #define SYSTEMCOLORSPROVIDER_H #include #include #include "log/formatters.h" namespace tremotesf { class SystemColorsProvider : public QObject { Q_OBJECT public: static SystemColorsProvider* createInstance(QObject* parent = nullptr); virtual bool isDarkThemeEnabled() const { return false; }; struct AccentColors { QColor accentColor{}; QColor accentColorLight1{}; QColor accentColorDark1{}; QColor accentColorDark2{}; [[nodiscard]] bool isValid() const { return accentColor.isValid() && accentColorLight1.isValid() && accentColorDark1.isValid() && accentColorDark2.isValid(); } [[nodiscard]] bool operator==(const AccentColors&) const = default; }; virtual AccentColors accentColors() const { return {}; }; protected: explicit SystemColorsProvider(QObject* parent = nullptr) : QObject{parent} {} signals: void darkThemeEnabledChanged(); void accentColorsChanged(); }; } template<> struct fmt::formatter : tremotesf::SimpleFormatter { format_context::iterator format(const tremotesf::SystemColorsProvider::AccentColors& colors, format_context& ctx) const { return fmt::format_to( ctx.out(), "AccentColors(accentColor={}, accentColorLight1={}, accentColorDark1={}, accentColorDark2={})", colors.accentColor.name(), colors.accentColorLight1.name(), colors.accentColorDark1.name(), colors.accentColorDark2.name() ); } }; #endif // SYSTEMCOLORSPROVIDER_H tremotesf-2.8.2/src/ui/systemcolorsprovider_windows.cpp000066400000000000000000000063401500171105600235350ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "systemcolorsprovider.h" #include #include #include #include "log/log.h" #include "windowshelpers.h" using namespace winrt::Windows::UI; using namespace winrt::Windows::UI::ViewManagement; namespace tremotesf { namespace { QColor qColor(Color color) { return {color.R, color.G, color.B, color.A}; } } namespace { class SystemColorsProviderWindows final : public SystemColorsProvider { Q_OBJECT public: explicit SystemColorsProviderWindows(QObject* parent = nullptr) : SystemColorsProvider(parent) { info().log("System dark theme enabled = {}", mDarkThemeEnabled); info().log("System accent colors = {}", mAccentColors); revoker = settings.ColorValuesChanged(winrt::auto_revoke, [this](auto...) { QMetaObject::invokeMethod(this, [this] { if (bool newDarkThemeEnabled = isDarkThemeEnabledImpl(); newDarkThemeEnabled != mDarkThemeEnabled) { info().log("System dark theme state changed to {}", newDarkThemeEnabled); mDarkThemeEnabled = newDarkThemeEnabled; emit darkThemeEnabledChanged(); } if (auto newAccentColors = accentColorsImpl(); newAccentColors != mAccentColors) { info().log("System accent colors changed to {}", newAccentColors); mAccentColors = newAccentColors; emit accentColorsChanged(); } }); }); } bool isDarkThemeEnabled() const override { return mDarkThemeEnabled; }; AccentColors accentColors() const override { return mAccentColors; }; private: bool isDarkThemeEnabledImpl() { // Apparently this is the way to do it according to Microsoft const auto foreground = settings.GetColorValue(UIColorType::Foreground); return (((5 * foreground.G) + (2 * foreground.R) + foreground.B) > (8 * 128)); } AccentColors accentColorsImpl() { return { .accentColor = qColor(settings.GetColorValue(UIColorType::Accent)), .accentColorLight1 = qColor(settings.GetColorValue(UIColorType::AccentLight1)), .accentColorDark1 = qColor(settings.GetColorValue(UIColorType::AccentDark1)), .accentColorDark2 = qColor(settings.GetColorValue(UIColorType::AccentDark2)), }; } UISettings settings{}; UISettings::ColorValuesChanged_revoker revoker{}; bool mDarkThemeEnabled{isDarkThemeEnabledImpl()}; AccentColors mAccentColors{accentColorsImpl()}; }; } SystemColorsProvider* SystemColorsProvider::createInstance(QObject* parent) { return new SystemColorsProviderWindows(parent); } } #include "systemcolorsprovider_windows.moc" tremotesf-2.8.2/src/ui/widgets/000077500000000000000000000000001500171105600164015ustar00rootroot00000000000000tremotesf-2.8.2/src/ui/widgets/basetreeview.cpp000066400000000000000000000025361500171105600216000ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "basetreeview.h" #include #include #include namespace tremotesf { BaseTreeView::BaseTreeView(QWidget* parent) : QTreeView(parent) { setAllColumnsShowFocus(true); setSortingEnabled(true); setUniformRowHeights(true); header()->setContextMenuPolicy(Qt::CustomContextMenu); QObject::connect(header(), &QHeaderView::customContextMenuRequested, this, [=, this](QPoint pos) { if (!model()) { return; } QMenu contextMenu; for (int i = 0, max = model()->columnCount(); i < max; ++i) { QAction* action = contextMenu.addAction(model()->headerData(i, Qt::Horizontal).toString()); action->setCheckable(true); action->setChecked(!isColumnHidden(i)); } QAction* action = contextMenu.exec(header()->viewport()->mapToGlobal(pos)); if (action) { const auto column = static_cast(contextMenu.actions().indexOf(action)); if (isColumnHidden(column)) { showColumn(column); } else { hideColumn(column); } } }); } } tremotesf-2.8.2/src/ui/widgets/basetreeview.h000066400000000000000000000006041500171105600212370ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_BASETREEVIEW_H #define TREMOTESF_BASETREEVIEW_H #include namespace tremotesf { class BaseTreeView : public QTreeView { Q_OBJECT public: explicit BaseTreeView(QWidget* parent = nullptr); }; } #endif // TREMOTESF_BASETREEVIEW_H tremotesf-2.8.2/src/ui/widgets/commondelegate.cpp000066400000000000000000000135231500171105600220740ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "commondelegate.h" #include #include #include #include #include #include #include #include #include #include "ui/stylehelpers.h" #include "target_os.h" namespace tremotesf { namespace { [[maybe_unused]] QStyle* fusionStyle() { static QStyle* const style = [] { const auto s = QStyleFactory::create("fusion"); if (!s) { throw std::runtime_error("Failed to create Fusion style"); } s->setParent(qApp); return s; }(); return style; } bool isTextElided(const QString& text, const QStyleOptionViewItem& option) { const QFontMetrics metrics(option.font); const int textWidth = metrics.horizontalAdvance(text); const auto style = option.widget ? option.widget->style() : qApp->style(); QRect textRect(style->subElementRect(QStyle::SE_ItemViewItemText, &option, option.widget)); const int textMargin = style->pixelMetric(QStyle::PM_FocusFrameHMargin, nullptr, option.widget) + 1; textRect.adjust(textMargin, 0, -textMargin, 0); return textWidth > textRect.width(); } } void CommonDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const { QStyleOptionViewItem opt = option; initStyleOption(&opt, index); auto* style = opt.widget ? opt.widget->style() : QApplication::style(); if (!(mParams.progressBarColumn.has_value() && mParams.progressRole.has_value() && index.column() == mParams.progressBarColumn)) { if (mParams.textElideModeRole.has_value()) { opt.textElideMode = index.data(*mParams.textElideModeRole).value(); } // Not progress bar style->drawControl(QStyle::CE_ItemViewItem, &opt, painter, opt.widget); return; } // Progress bar // Draw background style->drawPrimitive(QStyle::PE_PanelItemViewItem, &opt, painter, opt.widget); const int horizontalMargin = style->pixelMetric(QStyle::PM_FocusFrameHMargin); const int verticalMargin = style->pixelMetric(QStyle::PM_FocusFrameVMargin); QStyleOptionProgressBar progressBar{}; progressBar.rect = opt.rect.marginsRemoved(QMargins(horizontalMargin, verticalMargin, horizontalMargin, verticalMargin)); progressBar.minimum = 0; progressBar.maximum = 100; const auto progress = index.data(*mParams.progressRole).toDouble(); progressBar.progress = static_cast(progress * 100); if (progressBar.progress < 0) { progressBar.progress = 0; } else if (progressBar.progress > 100) { progressBar.progress = 100; } progressBar.state = opt.state | QStyle::State_Horizontal; progressBar.text = opt.text; progressBar.textVisible = true; progressBar.palette = opt.palette; // Sometimes this is out of sync if (opt.widget && opt.widget->isActiveWindow()) { progressBar.palette.setCurrentColorGroup(QPalette::Active); } if constexpr (targetOs == TargetOs::UnixMacOS) { if (determineStyle(style) == KnownStyle::macOS) { style = fusionStyle(); } } else { #if QT_VERSION_MAJOR == 5 if (determineStyle(style) == KnownStyle::Breeze) { // Breeze style incorrectly uses WindowText color for text if (progressBar.state.testFlag(QStyle::State_Selected)) { progressBar.palette.setColor( QPalette::WindowText, progressBar.palette.color(QPalette::HighlightedText) ); } else { progressBar.palette.setColor(QPalette::WindowText, progressBar.palette.color(QPalette::Text)); } } #endif } style->drawControl(QStyle::CE_ProgressBar, &progressBar, painter, opt.widget); } bool CommonDelegate::helpEvent( QHelpEvent* event, QAbstractItemView* view, const QStyleOptionViewItem& option, const QModelIndex& index ) { if (event->type() != QEvent::ToolTip) { return QStyledItemDelegate::helpEvent(event, view, option, index); } if (!index.isValid()) { event->ignore(); return false; } const auto tooltip = displayText(index.data(Qt::ToolTipRole), QLocale{}); if (tooltip.isEmpty()) { event->ignore(); return false; } if (QToolTip::isVisible() && QToolTip::text() == tooltip) { event->accept(); return true; } QStyleOptionViewItem opt(option); initStyleOption(&opt, index); const bool alwaysShowTooltip = mParams.alwaysShowTooltipRole.has_value() ? index.data(*mParams.alwaysShowTooltipRole).toBool() : false; if (!alwaysShowTooltip) { // Get real item rect const QRect intersected(opt.rect.intersected(view->viewport()->rect())); opt.rect.setLeft(intersected.left()); opt.rect.setRight(intersected.right()); // Show tooltip only if display text is elided if (!isTextElided(displayText(index.data(Qt::DisplayRole), opt.locale), opt)) { event->ignore(); return false; } } QToolTip::showText(event->globalPos(), tooltip, view->viewport(), opt.rect); event->accept(); return true; } } tremotesf-2.8.2/src/ui/widgets/commondelegate.h000066400000000000000000000022431500171105600215360ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_COMMONDELEGATE_H #define TREMOTESF_COMMONDELEGATE_H #include #include namespace tremotesf { class CommonDelegate final : public QStyledItemDelegate { Q_OBJECT public: struct Params { std::optional progressBarColumn{}; std::optional progressRole{}; std::optional textElideModeRole{}; std::optional alwaysShowTooltipRole{}; }; explicit CommonDelegate(Params params, QObject* parent = nullptr) : QStyledItemDelegate(parent), mParams(std::move(params)) {}; explicit CommonDelegate(QObject* parent = nullptr) : QStyledItemDelegate(parent) {} void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override; bool helpEvent( QHelpEvent* event, QAbstractItemView* view, const QStyleOptionViewItem& option, const QModelIndex& index ) override; private: Params mParams{}; }; } #endif // TREMOTESF_COMMONDELEGATE_H tremotesf-2.8.2/src/ui/widgets/editlabelswidget.cpp000066400000000000000000000125401500171105600224230ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2025 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include #include #include #include #include #include "editlabelswidget.h" #include "rpc/rpc.h" #include "rpc/torrent.h" #include "stdutils.h" namespace tremotesf { namespace { const QIcon& tagIcon() { static const auto icon = QIcon::fromTheme("tag"_l1); return icon; } } EditLabelsWidget::EditLabelsWidget(const std::vector& enabledLabels, Rpc* rpc, QWidget* parent) : QWidget{parent}, mRpc{rpc} { auto layout = new QGridLayout(this); layout->setContentsMargins(QMargins{}); mLabelsList = new QListWidget(this); layout->addWidget(mLabelsList, 0, 0, 1, 2); mLabelsList->setSelectionMode(QAbstractItemView::ExtendedSelection); mLabelsList->setIconSize(QSize(16, 16)); const auto addLabel = [=, this](const QString& label) { mLabelsList->addItem(label); const auto item = mLabelsList->item(mLabelsList->count() - 1); item->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemNeverHasChildren); item->setIcon(tagIcon()); }; for (const auto& label : enabledLabels) { addLabel(label); } mLabelsList->sortItems(); const auto updateListVisibility = [this] { mLabelsList->setVisible(mLabelsList->count() > 0); }; updateListVisibility(); QObject::connect(mLabelsList->model(), &QAbstractItemModel::rowsInserted, this, updateListVisibility); QObject::connect(mLabelsList->model(), &QAbstractItemModel::rowsRemoved, this, updateListVisibility); const auto removeAction = new QAction(QIcon::fromTheme("list-remove"_l1), qApp->translate("tremotesf", "&Remove"), mLabelsList); mLabelsList->addAction(removeAction); removeAction->setShortcut(QKeySequence::Delete); QObject::connect(removeAction, &QAction::triggered, this, [this] { qDeleteAll(mLabelsList->selectedItems()); }); mLabelsList->setContextMenuPolicy(Qt::CustomContextMenu); QObject::connect(mLabelsList, &QWidget::customContextMenuRequested, this, [=, this](const QPoint& pos) { if (mLabelsList->selectionModel()->hasSelection() && mLabelsList->indexAt(pos).isValid()) { const auto menu = new QMenu(this); menu->addAction(removeAction); menu->popup(mLabelsList->viewport()->mapToGlobal(pos)); } }); mComboBox = new QComboBox(this); layout->addWidget(mComboBox, 1, 0); mComboBox->setEditable(true); mComboBox->setInsertPolicy(QComboBox::NoInsert); mComboBox->lineEdit()->setPlaceholderText(qApp->translate("tremotesf", "New label...")); updateComboBoxLabels(); QObject::connect(rpc, &Rpc::connectedChanged, this, &EditLabelsWidget::updateComboBoxLabels); const auto addButton = new QPushButton(QIcon::fromTheme("list-add"_l1), qApp->translate("tremotesf", "Add"), this); layout->addWidget(addButton, 1, 1); addButton->setEnabled(false); const auto updateButtonEnabledState = [=, this] { addButton->setEnabled(!mComboBox->currentText().trimmed().isEmpty()); }; QObject::connect(mComboBox, &QComboBox::currentTextChanged, this, updateButtonEnabledState); layout->setColumnStretch(0, 1); const auto addLabelFromComboBox = [=, this] { const auto text = mComboBox->currentText().trimmed(); if (!text.isEmpty() && mLabelsList->findItems(text, Qt::MatchExactly).isEmpty()) { addLabel(text); return true; } return false; }; QObject::connect(mComboBox, &QComboBox::activated, this, [=, this] { addLabelFromComboBox(); mComboBox->setCurrentIndex(-1); }); QObject::connect(mComboBox->lineEdit(), &QLineEdit::returnPressed, this, [=, this] { if (addLabelFromComboBox()) { mComboBox->clearEditText(); } }); QObject::connect(addButton, &QPushButton::clicked, this, [=, this] { if (addLabelFromComboBox()) { mComboBox->clearEditText(); } }); } void EditLabelsWidget::setFocusOnComboBox() { mComboBox->setFocus(); } bool EditLabelsWidget::comboBoxHasFocus() const { return mComboBox->hasFocus(); } std::vector EditLabelsWidget::enabledLabels() const { return toContainer( std::views::iota(0, mLabelsList->count()) | std::views::transform([this](int i) { return mLabelsList->item(i)->text(); }) ); } void EditLabelsWidget::updateComboBoxLabels() { mComboBox->clear(); if (!mRpc->isConnected()) { return; } const auto allLabels = toContainer( mRpc->torrents() | std::views::transform([](const auto& t) { return t->data().labels; }) | std::views::join ); for (const auto& label : allLabels) { mComboBox->addItem(tagIcon(), label); } mComboBox->setCurrentIndex(-1); } } // namespace tremotesf tremotesf-2.8.2/src/ui/widgets/editlabelswidget.h000066400000000000000000000014261500171105600220710ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2025 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_EDITLABELSWIDGET_H #define TREMOTESF_EDITLABELSWIDGET_H #include class QComboBox; class QListWidget; namespace tremotesf { class Rpc; class EditLabelsWidget : public QWidget { Q_OBJECT public: explicit EditLabelsWidget(const std::vector& enabledLabels, Rpc* rpc, QWidget* parent); void setFocusOnComboBox(); bool comboBoxHasFocus() const; std::vector enabledLabels() const; private: void updateComboBoxLabels(); Rpc* mRpc; QListWidget* mLabelsList{}; QComboBox* mComboBox{}; }; } // namespace tremotesf #endif // TREMOTESF_EDITLABELSWIDGET_H tremotesf-2.8.2/src/ui/widgets/listplaceholder.cpp000066400000000000000000000033411500171105600222640ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2025 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "listplaceholder.h" #include #include #include #include namespace tremotesf { QLabel* createListPlaceholderLabel(const QString& text) { auto* const label = new QLabel(text); label->setForegroundRole(QPalette::PlaceholderText); label->setTextInteractionFlags(Qt::NoTextInteraction); #if QT_VERSION_MAJOR < 6 const auto setPalette = [label] { auto palette = label->palette(); auto brush = QGuiApplication::palette().placeholderText(); brush.setStyle(Qt::SolidPattern); palette.setBrush(QPalette::PlaceholderText, brush); label->setPalette(palette); }; setPalette(); QObject::connect(qApp, &QGuiApplication::paletteChanged, label, setPalette); #endif return label; } void addListPlaceholderLabelToViewportAndManageVisibility(QAbstractItemView* itemView, QLabel* placeholderLabel) { auto* const layout = new QVBoxLayout(itemView->viewport()); layout->addWidget(placeholderLabel); layout->setAlignment(placeholderLabel, Qt::AlignCenter); auto* const model = itemView->model(); const auto updateVisibility = [=] { placeholderLabel->setVisible(model->rowCount() == 0); }; updateVisibility(); QObject::connect(model, &QAbstractItemModel::rowsInserted, placeholderLabel, updateVisibility); QObject::connect(model, &QAbstractItemModel::rowsRemoved, placeholderLabel, updateVisibility); QObject::connect(model, &QAbstractItemModel::modelReset, placeholderLabel, updateVisibility); } } tremotesf-2.8.2/src/ui/widgets/listplaceholder.h000066400000000000000000000007401500171105600217310ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2025 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_LISTPLACEHOLDER_H #define TREMOTESF_LISTPLACEHOLDER_H #include class QAbstractItemView; class QLabel; namespace tremotesf { QLabel* createListPlaceholderLabel(const QString& text = {}); void addListPlaceholderLabelToViewportAndManageVisibility(QAbstractItemView* itemView, QLabel* placeholderLabel); } #endif // TREMOTESF_LISTPLACEHOLDER_H tremotesf-2.8.2/src/ui/widgets/remotedirectoryselectionwidget.cpp000066400000000000000000000147341500171105600254500ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "remotedirectoryselectionwidget.h" #include #include #include #include #include #include #include #include "literals.h" #include "target_os.h" #include "rpc/rpc.h" #include "rpc/servers.h" #include "rpc/serversettings.h" #include "ui/stylehelpers.h" namespace tremotesf { RemoteDirectorySelectionWidgetViewModel::RemoteDirectorySelectionWidgetViewModel( QString path, const Rpc* rpc, QObject* parent ) : QObject(parent), mRpc(rpc), mPath(std::move(path)), mMode( rpc->isLocal() ? Mode::Local : (Servers::instance()->currentServerHasMountedDirectories() ? Mode::RemoteMounted : Mode::Remote) ) {} QString RemoteDirectorySelectionWidgetViewModel::fileDialogDirectory() { if (mMode == Mode::RemoteMounted) { return Servers::instance()->fromRemoteToLocalDirectory(mPath, mRpc->serverSettings()); } return mPath; } void RemoteDirectorySelectionWidgetViewModel::updatePathProgrammatically(QString path) { auto displayPath = toNativeSeparators(path); updatePathImpl(std::move(path), std::move(displayPath)); } void RemoteDirectorySelectionWidgetViewModel::onPathEditedByUser(const QString& text) { auto displayPath = text.trimmed(); auto path = normalizePath(displayPath); updatePathImpl(std::move(path), std::move(displayPath)); } void RemoteDirectorySelectionWidgetViewModel::onFileDialogAccepted(QString path) { if (mMode != Mode::RemoteMounted) { updatePathProgrammatically(std::move(path)); return; } auto remoteDirectory = Servers::instance()->fromLocalToRemoteDirectory(path, mRpc->serverSettings()); if (remoteDirectory.isEmpty()) { emit showMountedDirectoryError(); } else { updatePathProgrammatically(std::move(remoteDirectory)); } } QString RemoteDirectorySelectionWidgetViewModel::normalizePath(const QString& path) const { return tremotesf::normalizePath(path, mRpc->serverSettings()->data().pathOs); } QString RemoteDirectorySelectionWidgetViewModel::toNativeSeparators(const QString& path) const { return tremotesf::toNativeSeparators(path, mRpc->serverSettings()->data().pathOs); } void RemoteDirectorySelectionWidgetViewModel::updatePathImpl(QString path, QString displayPath) { if (path != mPath) { mPath = std::move(path); mDisplayPath = std::move(displayPath); emit pathChanged(); } } RemoteDirectorySelectionWidget::RemoteDirectorySelectionWidget(QWidget* parent) : QWidget(parent) {} void RemoteDirectorySelectionWidget::setup(QString path, const Rpc* rpc) { auto layout = new QHBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); mTextField = createTextField(); layout->addWidget(mTextField, 1); mSelectDirectoryButton = new QPushButton(QIcon::fromTheme("document-open"_l1), QString(), this); layout->addWidget(mSelectDirectoryButton); mSelectDirectoryButton->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); if constexpr (targetOs == TargetOs::UnixMacOS) { if (determineStyle() == KnownStyle::macOS) { // Button becomes ugly if we don't set these specific values mSelectDirectoryButton->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); mSelectDirectoryButton->setMaximumSize(32, 32); } } mViewModel = createViewModel(std::move(path), rpc); mSelectDirectoryButton->setEnabled(mViewModel->enableFileDialog()); if (mViewModel->enableFileDialog()) { QObject::connect( mSelectDirectoryButton, &QPushButton::clicked, this, &RemoteDirectorySelectionWidget::showFileDialog ); } const auto lineEdit = lineEditFromTextField(); const auto updateLineEdit = [=, this]() { lineEdit->setText(mViewModel->displayPath()); }; updateLineEdit(); QObject::connect(mViewModel, &RemoteDirectorySelectionWidgetViewModel::pathChanged, this, updateLineEdit); QObject::connect( mViewModel, &RemoteDirectorySelectionWidgetViewModel::pathChanged, this, &RemoteDirectorySelectionWidget::pathChanged ); QObject::connect(lineEdit, &QLineEdit::textEdited, this, [=, this](const auto& text) { mViewModel->onPathEditedByUser(text); }); QObject::connect( static_cast(mViewModel), &RemoteDirectorySelectionWidgetViewModel::showMountedDirectoryError, this, [=, this] { QMessageBox::warning( this, //: Dialog title qApp->translate("tremotesf", "Error"), qApp->translate("tremotesf", "Selected directory should be inside mounted directory") ); } ); } RemoteDirectorySelectionWidgetViewModel* RemoteDirectorySelectionWidget::createViewModel(QString path, const Rpc* rpc) { return new RemoteDirectorySelectionWidgetViewModel(std::move(path), rpc, this); } QWidget* RemoteDirectorySelectionWidget::createTextField() { return new QLineEdit(this); } QLineEdit* RemoteDirectorySelectionWidget::lineEditFromTextField() { return qobject_cast(mTextField); } void RemoteDirectorySelectionWidget::showFileDialog() { auto dialog = new QFileDialog( this, //: Directory chooser dialog title qApp->translate("tremotesf", "Select Directory"), mViewModel->fileDialogDirectory() ); dialog->setFileMode(QFileDialog::Directory); dialog->setOptions(QFileDialog::ShowDirsOnly); dialog->setAttribute(Qt::WA_DeleteOnClose); QObject::connect(dialog, &QFileDialog::accepted, this, [=, this] { mViewModel->onFileDialogAccepted(dialog->selectedFiles().constFirst()); }); if constexpr (targetOs == TargetOs::Windows) { dialog->open(); } else { dialog->show(); } } } tremotesf-2.8.2/src/ui/widgets/remotedirectoryselectionwidget.h000066400000000000000000000044711500171105600251120ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_FILESELECTIONWIDGET_H #define TREMOTESF_FILESELECTIONWIDGET_H #include class QLineEdit; class QPushButton; namespace tremotesf { class Rpc; class RemoteDirectorySelectionWidgetViewModel : public QObject { Q_OBJECT public: explicit RemoteDirectorySelectionWidgetViewModel(QString path, const Rpc* rpc, QObject* parent = nullptr); [[nodiscard]] QString path() const { return mPath; }; [[nodiscard]] QString displayPath() const { return mDisplayPath; }; [[nodiscard]] virtual bool enableFileDialog() const { return mMode != Mode::Remote; } [[nodiscard]] virtual QString fileDialogDirectory(); void updatePathProgrammatically(QString path); void onPathEditedByUser(const QString& text); void onFileDialogAccepted(QString path); protected: [[nodiscard]] QString normalizePath(const QString& path) const; [[nodiscard]] QString toNativeSeparators(const QString& path) const; virtual void updatePathImpl(QString path, QString displayPath); const Rpc* mRpc{}; QString mPath{}; QString mDisplayPath{toNativeSeparators(mPath)}; enum class Mode { Local, RemoteMounted, Remote }; Mode mMode{}; signals: void pathChanged(); void showMountedDirectoryError(); }; class RemoteDirectorySelectionWidget : public QWidget { Q_OBJECT public: explicit RemoteDirectorySelectionWidget(QWidget* parent = nullptr); virtual void setup(QString path, const Rpc* rpc); [[nodiscard]] QString path() const { return mViewModel->path(); } void updatePath(QString path) { mViewModel->updatePathProgrammatically(std::move(path)); } protected: virtual QWidget* createTextField(); virtual QLineEdit* lineEditFromTextField(); virtual RemoteDirectorySelectionWidgetViewModel* createViewModel(QString path, const Rpc* rpc); RemoteDirectorySelectionWidgetViewModel* mViewModel{}; QWidget* mTextField{}; QPushButton* mSelectDirectoryButton{}; private: void showFileDialog(); signals: void pathChanged(); }; } #endif // TREMOTESF_FILESELECTIONWIDGET_H tremotesf-2.8.2/src/ui/widgets/textinputdialog.cpp000066400000000000000000000047251500171105600223410ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "textinputdialog.h" #include #include #include #include #include #include namespace tremotesf { TextInputDialog::TextInputDialog( const QString& title, const QString& labelText, const QString& text, const QString& okButtonText, bool multiline, QWidget* parent ) : QDialog(parent) { setWindowTitle(title); auto layout = new QVBoxLayout(this); layout->setSizeConstraint(QLayout::SetMinAndMaxSize); auto label = new QLabel(labelText, this); label->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); layout->addWidget(label); if (multiline) { mPlainTextEdit = new QPlainTextEdit(text, this); layout->addWidget(mPlainTextEdit); } else { mLineEdit = new QLineEdit(text, this); layout->addWidget(mLineEdit); } auto dialogButtonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); if (!okButtonText.isEmpty()) { dialogButtonBox->button(QDialogButtonBox::Ok)->setText(okButtonText); } QObject::connect(dialogButtonBox, &QDialogButtonBox::accepted, this, &TextInputDialog::accept); QObject::connect(dialogButtonBox, &QDialogButtonBox::rejected, this, &TextInputDialog::reject); if (text.isEmpty()) { dialogButtonBox->button(QDialogButtonBox::Ok)->setEnabled(false); } const auto onTextChanged = [dialogButtonBox](const QString& text) { if (text.isEmpty()) { dialogButtonBox->button(QDialogButtonBox::Ok)->setEnabled(false); } else { dialogButtonBox->button(QDialogButtonBox::Ok)->setEnabled(true); } }; if (multiline) { QObject::connect(mPlainTextEdit, &QPlainTextEdit::textChanged, this, [=, this] { onTextChanged(mPlainTextEdit->toPlainText()); }); } else { QObject::connect(mLineEdit, &QLineEdit::textChanged, this, onTextChanged); } layout->addWidget(dialogButtonBox); resize(sizeHint().expandedTo(QSize(256, 0))); } QString TextInputDialog::text() const { return mLineEdit ? mLineEdit->text() : mPlainTextEdit->toPlainText(); } } tremotesf-2.8.2/src/ui/widgets/textinputdialog.h000066400000000000000000000013501500171105600217750ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_TEXTINPUTDIALOG_H #define TREMOTESF_TEXTINPUTDIALOG_H #include class QLineEdit; class QPlainTextEdit; namespace tremotesf { class TextInputDialog final : public QDialog { Q_OBJECT public: explicit TextInputDialog( const QString& title, const QString& labelText, const QString& text, const QString& okButtonText, bool multiline, QWidget* parent = nullptr ); QString text() const; private: QLineEdit* mLineEdit = nullptr; QPlainTextEdit* mPlainTextEdit = nullptr; }; } #endif tremotesf-2.8.2/src/ui/widgets/torrentfilesview.cpp000066400000000000000000000266571500171105600225400ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "torrentfilesview.h" #include #include #include #include #include #include #include "rpc/mounteddirectoriesutils.h" #include "rpc/serversettings.h" #include "desktoputils.h" #include "filemanagerlauncher.h" #include "settings.h" #include "rpc/rpc.h" #include "ui/itemmodels/torrentfilesmodelentry.h" #include "ui/itemmodels/torrentfilesproxymodel.h" #include "ui/screens/addtorrent/localtorrentfilesmodel.h" #include "ui/screens/torrentproperties/torrentfilesmodel.h" #include "commondelegate.h" #include "textinputdialog.h" namespace tremotesf { TorrentFilesView::TorrentFilesView(LocalTorrentFilesModel* model, Rpc* rpc, QWidget* parent) : BaseTreeView(parent), mLocalFile(true), mModel(model), mProxyModel(new TorrentFilesProxyModel( mModel, LocalTorrentFilesModel::SortRole, static_cast(LocalTorrentFilesModel::Column::Name), this )), mRpc(rpc) { init(); setItemDelegate(new CommonDelegate(this)); if (!header()->restoreState(Settings::instance()->get_localTorrentFilesViewHeaderState())) { sortByColumn(static_cast(LocalTorrentFilesModel::Column::Name), Qt::AscendingOrder); } } TorrentFilesView::TorrentFilesView(TorrentFilesModel* model, Rpc* rpc, QWidget* parent) : BaseTreeView(parent), mLocalFile(false), mModel(model), mProxyModel(new TorrentFilesProxyModel( mModel, TorrentFilesModel::SortRole, static_cast(TorrentFilesModel::Column::Name), this )), mRpc(rpc) { init(); setItemDelegate(new CommonDelegate( { .progressBarColumn = static_cast(TorrentFilesModel::Column::ProgressBar), .progressRole = TorrentFilesModel::SortRole, }, this )); if (!header()->restoreState(Settings::instance()->get_torrentFilesViewHeaderState())) { sortByColumn(static_cast(TorrentFilesModel::Column::Name), Qt::AscendingOrder); } QObject::connect(this, &TorrentFilesView::activated, this, [=, this](const auto& index) { const QModelIndex sourceIndex(mProxyModel->sourceIndex(index)); auto entry = static_cast(mProxyModel->sourceIndex(index).internalPointer()); if (!entry->isDirectory() && isServerLocalOrTorrentIsMounted(mRpc, static_cast(mModel)->torrent()) && entry->wantedState() != TorrentFilesModelEntry::Unwanted) { desktoputils::openFile(static_cast(mModel)->localFilePath(sourceIndex), this); } }); } void TorrentFilesView::showFileRenameDialog( const QString& fileName, QWidget* parent, const std::function& onAccepted ) { auto dialog = new TextInputDialog( //: Dialog title qApp->translate("tremotesf", "Rename"), qApp->translate("tremotesf", "File name:"), fileName, //: Dialog confirmation button qApp->translate("tremotesf", "Rename"), false, parent ); dialog->setAttribute(Qt::WA_DeleteOnClose); QObject::connect(dialog, &QDialog::accepted, parent, [=] { onAccepted(dialog->text()); }); dialog->show(); } void TorrentFilesView::saveState() { if (mLocalFile) { Settings::instance()->set_localTorrentFilesViewHeaderState(header()->saveState()); } else { Settings::instance()->set_torrentFilesViewHeaderState(header()->saveState()); } } void TorrentFilesView::init() { setContextMenuPolicy(Qt::CustomContextMenu); setModel(mProxyModel); setSelectionMode(QAbstractItemView::ExtendedSelection); QObject::connect(mModel, &BaseTorrentFilesModel::modelReset, this, &TorrentFilesView::onModelReset); QObject::connect(this, &TorrentFilesView::customContextMenuRequested, this, &TorrentFilesView::showContextMenu); onModelReset(); } void TorrentFilesView::onModelReset() { if (mModel->rowCount() > 0) { if (mModel->rowCount(mModel->index(0, 0)) == 0) { setRootIsDecorated(false); } else { setRootIsDecorated(true); expand(mProxyModel->index(0, 0)); } } } void TorrentFilesView::showContextMenu(QPoint pos) { if (!indexAt(pos).isValid()) { return; } const QModelIndexList sourceIndexes(mProxyModel->sourceIndexes(selectionModel()->selectedRows())); QMenu contextMenu; if (!mLocalFile) { bool show = true; for (const QModelIndex& index : sourceIndexes) { if (static_cast(index.internalPointer())->wantedState() == TorrentFilesModelEntry::Unwanted) { show = false; break; } } if (show) { const bool localOrMounted = isServerLocalOrTorrentIsMounted(mRpc, static_cast(mModel)->torrent()); QAction* openAction = contextMenu.addAction( QIcon::fromTheme("document-open"_l1), //: Context menu item qApp->translate("tremotesf", "&Open") ); openAction->setEnabled(localOrMounted); QObject::connect(openAction, &QAction::triggered, this, [sourceIndexes, this] { for (const QModelIndex& index : sourceIndexes) { desktoputils::openFile( static_cast(mModel)->localFilePath(index), this ); } }); QAction* openDownloadDirectoryAction = contextMenu.addAction( QIcon::fromTheme("go-jump"_l1), //: Context menu item qApp->translate("tremotesf", "Open &Download Directory") ); openDownloadDirectoryAction->setEnabled(localOrMounted); QObject::connect(openDownloadDirectoryAction, &QAction::triggered, this, [sourceIndexes, this] { std::vector files{}; files.reserve(static_cast(sourceIndexes.size())); for (const QModelIndex& index : sourceIndexes) { files.push_back(static_cast(mModel)->localFilePath(index)); } launchFileManagerAndSelectFiles(files, this); }); } } contextMenu.addSeparator(); QAction* downloadAction = contextMenu.addAction( QIcon::fromTheme("download"_l1), //: Context menu item to select file for downloading qApp->translate("tremotesf", "&Download") ); QObject::connect(downloadAction, &QAction::triggered, this, [=, this] { mModel->setFilesWanted(sourceIndexes, true); }); QAction* notDownloadAction = contextMenu.addAction( QIcon::fromTheme("dialog-cancel"_l1), //: Context menu item to unselect file for downloading qApp->translate("tremotesf", "&Not Download") ); QObject::connect(notDownloadAction, &QAction::triggered, this, [=, this] { mModel->setFilesWanted(sourceIndexes, false); }); contextMenu.addSeparator(); QMenu* priorityMenu = contextMenu.addMenu(qApp->translate("tremotesf", "&Priority")); QActionGroup priorityGroup(this); priorityGroup.setExclusive(true); //: File loading priority QAction* highPriorityAction = priorityGroup.addAction(qApp->translate("tremotesf", "&High")); highPriorityAction->setCheckable(true); QObject::connect(highPriorityAction, &QAction::triggered, this, [=, this](bool checked) { if (checked) { mModel->setFilesPriority(sourceIndexes, TorrentFilesModelEntry::HighPriority); } }); //: File loading priority QAction* normalPriorityAction = priorityGroup.addAction(qApp->translate("tremotesf", "&Normal")); normalPriorityAction->setCheckable(true); QObject::connect(normalPriorityAction, &QAction::triggered, this, [=, this](bool checked) { if (checked) { mModel->setFilesPriority(sourceIndexes, TorrentFilesModelEntry::NormalPriority); } }); //: File loading priority QAction* lowPriorityAction = priorityGroup.addAction(qApp->translate("tremotesf", "&Low")); lowPriorityAction->setCheckable(true); QObject::connect(lowPriorityAction, &QAction::triggered, this, [=, this](bool checked) { if (checked) { mModel->setFilesPriority(sourceIndexes, TorrentFilesModelEntry::LowPriority); } }); //: File loading priority QAction* mixedPriorityAction = priorityGroup.addAction(qApp->translate("tremotesf", "Mixed")); mixedPriorityAction->setCheckable(true); mixedPriorityAction->setChecked(true); mixedPriorityAction->setVisible(false); priorityMenu->addActions(priorityGroup.actions()); if (sourceIndexes.size() == 1) { auto entry = static_cast(sourceIndexes.first().internalPointer()); if (entry->wantedState() == TorrentFilesModelEntry::Wanted) { downloadAction->setEnabled(false); } else if (entry->wantedState() == TorrentFilesModelEntry::Unwanted) { notDownloadAction->setEnabled(false); } switch (entry->priority()) { case TorrentFilesModelEntry::LowPriority: lowPriorityAction->setChecked(true); break; case TorrentFilesModelEntry::NormalPriority: normalPriorityAction->setChecked(true); break; case TorrentFilesModelEntry::HighPriority: highPriorityAction->setChecked(true); break; case TorrentFilesModelEntry::MixedPriority: mixedPriorityAction->setVisible(true); } } if (mRpc->serverSettings()->data().canRenameFiles()) { contextMenu.addSeparator(); QAction* renameAction = contextMenu.addAction( QIcon::fromTheme("edit-rename"_l1), //: Context menu item qApp->translate("tremotesf", "&Rename") ); renameAction->setEnabled(sourceIndexes.size() == 1); QObject::connect(renameAction, &QAction::triggered, this, [=, this] { const QModelIndex& index = sourceIndexes.first(); auto entry = static_cast(index.internalPointer()); showFileRenameDialog(entry->name(), this, [=, this](const auto& newName) { mModel->renameFile(index, newName); }); }); } contextMenu.exec(viewport()->mapToGlobal(pos)); } } tremotesf-2.8.2/src/ui/widgets/torrentfilesview.h000066400000000000000000000022601500171105600221650ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_TORRENTFILESVIEW_H #define TREMOTESF_TORRENTFILESVIEW_H #include #include "basetreeview.h" namespace tremotesf { class BaseTorrentFilesModel; class LocalTorrentFilesModel; class Rpc; class TorrentFilesModel; class TorrentFilesProxyModel; class TorrentFilesView final : public BaseTreeView { Q_OBJECT public: explicit TorrentFilesView(LocalTorrentFilesModel* model, Rpc* rpc, QWidget* parent = nullptr); explicit TorrentFilesView(TorrentFilesModel* model, Rpc* rpc, QWidget* parent = nullptr); Q_DISABLE_COPY_MOVE(TorrentFilesView) static void showFileRenameDialog( const QString& fileName, QWidget* parent, const std::function& onAccepted ); void saveState(); private: void init(); void onModelReset(); void showContextMenu(QPoint pos); bool mLocalFile; BaseTorrentFilesModel* mModel; TorrentFilesProxyModel* mProxyModel; Rpc* mRpc; }; } #endif // TREMOTESF_TORRENTFILESVIEW_H tremotesf-2.8.2/src/ui/widgets/torrentremotedirectoryselectionwidget.cpp000066400000000000000000000143551500171105600270650ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include #include #include "torrentremotedirectoryselectionwidget.h" #include "stdutils.h" #include "rpc/rpc.h" #include "rpc/servers.h" #include "rpc/serversettings.h" namespace tremotesf { void TorrentDownloadDirectoryDirectorySelectionWidgetViewModel::saveDirectories(std::vector comboBoxItems ) { if (mPath.isEmpty()) { return; } auto paths = moveToContainer(comboBoxItems | std::views::transform(&ComboBoxItem::path)); if (!paths.contains(mPath)) { paths.push_back(mPath); } auto servers = Servers::instance(); servers->setCurrentServerLastDownloadDirectories(paths); servers->setCurrentServerLastDownloadDirectory(mPath); } void TorrentDownloadDirectoryDirectorySelectionWidgetViewModel::updatePathImpl(QString path, QString displayPath) { RemoteDirectorySelectionWidgetViewModel::updatePathImpl(std::move(path), std::move(displayPath)); updateInitialComboBoxItems(); } std::vector TorrentDownloadDirectoryDirectorySelectionWidgetViewModel::createInitialComboBoxItems() const { QStringList directories = Servers::instance()->currentServerLastDownloadDirectories(mRpc->serverSettings()); directories.reserve(directories.size() + static_cast(mRpc->torrents().size()) + 2); for (const auto& torrent : mRpc->torrents()) { directories.push_back(torrent->data().downloadDirectory); } if (!mPath.isEmpty()) { directories.push_back(mPath); } directories.push_back(mRpc->serverSettings()->data().downloadDirectory); directories.removeDuplicates(); QCollator collator{}; collator.setCaseSensitivity(Qt::CaseInsensitive); collator.setNumericMode(true); // QStringList is not compatibly with std::ranges::sort in Qt 5 std::sort(directories.begin(), directories.end(), [&collator](const auto& first, const auto& second) { return collator.compare(first, second) < 0; }); auto ret = toContainer(directories | std::views::transform([=, this](QString& dir) { QString display = toNativeSeparators(dir); return TorrentDownloadDirectoryDirectorySelectionWidgetViewModel::ComboBoxItem{ .path = std::move(dir), .displayPath = std::move(display) }; })); return ret; } void TorrentDownloadDirectoryDirectorySelectionWidgetViewModel::updateInitialComboBoxItems() { auto items = createInitialComboBoxItems(); if (items != mInitialComboBoxItems) { mInitialComboBoxItems = std::move(items); emit initialComboBoxItemsChanged(); } } QLineEdit* TorrentDownloadDirectoryDirectorySelectionWidget::lineEditFromTextField() { return qobject_cast(mTextField)->lineEdit(); } QWidget* TorrentDownloadDirectoryDirectorySelectionWidget::createTextField() { const auto comboBox = new QComboBox(); comboBox->setEditable(true); comboBox->setInsertPolicy(QComboBox::NoInsert); new ComboBoxDeleteKeyEventFilter(comboBox); return comboBox; } void TorrentDownloadDirectoryDirectorySelectionWidget::setup(QString path, const Rpc* rpc) { RemoteDirectorySelectionWidget::setup(std::move(path), rpc); const auto viewModel = qobject_cast(mViewModel); const auto comboBox = qobject_cast(mTextField); const auto updateItems = [=] { const auto items = viewModel->initialComboBoxItems(); comboBox->clear(); for (const auto& [itemPath, itemDisplayPath] : items) { comboBox->addItem(itemDisplayPath, itemPath); } comboBox->lineEdit()->setText(viewModel->displayPath()); }; updateItems(); QObject::connect( viewModel, &TorrentDownloadDirectoryDirectorySelectionWidgetViewModel::initialComboBoxItemsChanged, this, updateItems ); QObject::connect(comboBox, qOverload(&QComboBox::activated), this, [=](int index) { if (index != -1) { viewModel->onComboBoxItemSelected(comboBox->itemData(index).toString(), comboBox->itemText(index)); } }); } void TorrentDownloadDirectoryDirectorySelectionWidget::saveDirectories() { auto comboBox = qobject_cast(mTextField); auto comboBoxItems = toContainer( std::views::iota(0, comboBox->count()) | std::views::transform([comboBox](int index) { return TorrentDownloadDirectoryDirectorySelectionWidgetViewModel::ComboBoxItem{ .path = comboBox->itemData(index).toString(), .displayPath = comboBox->itemText(index) }; }) ); qobject_cast(mViewModel) ->saveDirectories(std::move(comboBoxItems)); } ComboBoxDeleteKeyEventFilter::ComboBoxDeleteKeyEventFilter(QComboBox* comboBox) : QObject(comboBox) { comboBox->view()->installEventFilter(this); } bool ComboBoxDeleteKeyEventFilter::eventFilter(QObject* watched, QEvent* event) { if (event->type() == QEvent::KeyPress && static_cast(event)->matches(QKeySequence::Delete)) { const auto index = qobject_cast(watched)->currentIndex(); if (index.isValid()) { qobject_cast(parent())->removeItem(index.row()); return true; } } return QObject::eventFilter(watched, event); } } tremotesf-2.8.2/src/ui/widgets/torrentremotedirectoryselectionwidget.h000066400000000000000000000046261500171105600265320ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_TORRENTREMOTEDIRECTORYSELECTIONWIDGET_H #define TREMOTESF_TORRENTREMOTEDIRECTORYSELECTIONWIDGET_H #include "remotedirectoryselectionwidget.h" class QComboBox; namespace tremotesf { class Rpc; class TorrentDownloadDirectoryDirectorySelectionWidgetViewModel final : public RemoteDirectorySelectionWidgetViewModel { Q_OBJECT public: using RemoteDirectorySelectionWidgetViewModel::RemoteDirectorySelectionWidgetViewModel; struct ComboBoxItem { QString path{}; QString displayPath{}; [[nodiscard]] bool operator==(const ComboBoxItem&) const = default; }; [[nodiscard]] std::vector initialComboBoxItems() const { return mInitialComboBoxItems; }; void onComboBoxItemSelected(QString path, QString displayPath) { updatePathImpl(std::move(path), std::move(displayPath)); } void saveDirectories(std::vector comboBoxItems); protected: void updatePathImpl(QString path, QString displayPath) override; private: [[nodiscard]] std::vector createInitialComboBoxItems() const; void updateInitialComboBoxItems(); std::vector mInitialComboBoxItems{createInitialComboBoxItems()}; signals: void initialComboBoxItemsChanged(); }; class TorrentDownloadDirectoryDirectorySelectionWidget final : public RemoteDirectorySelectionWidget { Q_OBJECT public: using RemoteDirectorySelectionWidget::RemoteDirectorySelectionWidget; void setup(QString path, const Rpc* rpc) override; void saveDirectories(); protected: QWidget* createTextField() override; QLineEdit* lineEditFromTextField() override; RemoteDirectorySelectionWidgetViewModel* createViewModel(QString path, const Rpc* rpc) override { return new TorrentDownloadDirectoryDirectorySelectionWidgetViewModel(std::move(path), rpc, this); } }; class ComboBoxDeleteKeyEventFilter final : public QObject { Q_OBJECT public: explicit ComboBoxDeleteKeyEventFilter(QComboBox* comboBox); protected: bool eventFilter(QObject* watched, QEvent* event) override; }; } #endif // TREMOTESF_TORRENTREMOTEDIRECTORYSELECTIONWIDGET_H tremotesf-2.8.2/src/unixhelpers.cpp000066400000000000000000000007201500171105600173670ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "unixhelpers.h" #include #include #include namespace tremotesf::impl { void throwWithErrno(std::string_view functionName) { const auto baseError = std::system_error(errno, std::system_category()); throw std::system_error(baseError.code(), fmt::format("{} failed with", functionName)); } } tremotesf-2.8.2/src/unixhelpers.h000066400000000000000000000017011500171105600170340ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_UNIXHELPERS_H #define TREMOTESF_UNIXHELPERS_H #include #include namespace tremotesf { namespace impl { void throwWithErrno(std::string_view functionName); } /** * @brief checkPosixError * @param result Return value of POSIX function that returns -1 on failure and sets errno * @param functionName Name of function that returned result * @returns result * @throws std::system_error */ template T checkPosixError(T result, std::string_view functionName) { if (result == T{-1}) { impl::throwWithErrno(functionName); } return result; } #ifdef TREMOTESF_UNIX_FREEDESKTOP inline constexpr auto xdgActivationTokenEnvVariable = "XDG_ACTIVATION_TOKEN"; #endif } #endif // TREMOTESF_UNIXHELPERS_H tremotesf-2.8.2/src/windowshelpers.cpp000066400000000000000000000050161500171105600201010ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #include "windowshelpers.h" #include #include #include #include #include #include "log/log.h" namespace tremotesf { namespace { /** * @brief std::error_category for Win32 errors (returned by GetLastError()) * Returns UTF-8 strings * We need custom implementation instead of std::system_category() * because GCC < 12 doesn't support Windows errors at all, * and MSVC and GCC >= 12 use FormatMessageA function which may not return UTF-8 */ class Win32Category final : public std::error_category { public: const char* name() const noexcept override { return "Win32Category"; } std::string message(int code) const override { wchar_t* wstr{}; const auto size = FormatMessageW( FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, static_cast(code), MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), reinterpret_cast(&wstr), 0, nullptr ); if (size == 0) return "Unknown error"; return QString::fromWCharArray(wstr, static_cast(size)).trimmed().toStdString(); } static const Win32Category& instance() { static const Win32Category category{}; return category; } }; } void checkWin32Bool(int win32BoolResult, std::string_view functionName) { if (win32BoolResult != FALSE) return; // Don't use winrt::check_bool because it doesn't preserve Win32 error code // (it is converted to HRESULT) const auto error = GetLastError(); throw std::system_error( static_cast(error), Win32Category::instance(), fmt::format("{} failed with", functionName) ); } void checkHResult(int32_t hresult, std::string_view functionName) { if (hresult == S_OK) return; winrt::hstring message = winrt::hresult_error(hresult).message(); throw winrt::hresult_error( hresult, QString::fromStdString(fmt::format("{} failed with: {}", functionName, std::move(message))).toStdWString() ); } } tremotesf-2.8.2/src/windowshelpers.h000066400000000000000000000023021500171105600175410ustar00rootroot00000000000000// SPDX-FileCopyrightText: 2015-2024 Alexey Rochev // // SPDX-License-Identifier: GPL-3.0-or-later #ifndef TREMOTESF_WINDOWSHELPERS_H #define TREMOTESF_WINDOWSHELPERS_H #include #include #include #include namespace tremotesf { /** * @brief checkWin32Bool * @param win32BoolResult BOOL returned from Win32 function * @param functionName Name of function that returned BOOL * @throws std::system_error */ void checkWin32Bool(int win32BoolResult, std::string_view functionName); /** * @brief checkHResult * @param hresult HRESULT returned from COM function * @param functionName Name of function that returned HRESULT * @throws winrt::hresult_error */ void checkHResult(int32_t hresult, std::string_view functionName); [[nodiscard]] inline const wchar_t* getCWString(const QString& string) { using utf16Type = std::remove_pointer_t; static_assert(sizeof(utf16Type) == sizeof(wchar_t)); return reinterpret_cast(string.utf16()); } inline const wchar_t* getCWString(QString&&) = delete; } #endif // TREMOTESF_WINDOWSHELPERS_H tremotesf-2.8.2/translations/000077500000000000000000000000001500171105600162505ustar00rootroot00000000000000tremotesf-2.8.2/translations/CMakeLists.txt000066400000000000000000000044431500171105600210150ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 find_package(Qt${TREMOTESF_QT_VERSION_MAJOR} ${TREMOTESF_MINIMUM_QT_VERSION} REQUIRED COMPONENTS LinguistTools) add_custom_target( update_source_ts COMMAND $ "${CMAKE_SOURCE_DIR}/src" -no-obsolete -ts source.ts WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" ) set( ts_files de.ts en.ts es.ts es_ES.ts fr.ts it_IT.ts nl_BE.ts nl.ts pl.ts ru.ts tr.ts zh_CN.ts ) qt_add_translation(qm_files ${ts_files}) add_custom_target(translations ALL DEPENDS ${qm_files}) set(translations_qrc_content "\n") foreach(qm_file IN LISTS qm_files) string(FIND "${qm_file}" "/" last_separator_index REVERSE) if (last_separator_index EQUAL -1) message(FATAL_ERROR "Did not find last separator in path ${qm_file}") endif() string(SUBSTRING "${qm_file}" "${last_separator_index}" -1 qm_file) string(SUBSTRING "${qm_file}" 1 -1 qm_file) string(APPEND translations_qrc_content "\n") endforeach() string(APPEND translations_qrc_content "\n\n") set(translations_qrc "${CMAKE_CURRENT_BINARY_DIR}/translations.qrc") file(WRITE "${translations_qrc}" "${translations_qrc_content}") list(APPEND QRC_FILES "${translations_qrc}") set(QRC_FILES ${QRC_FILES} PARENT_SCOPE) if (WIN32 OR APPLE) message(STATUS "Building for Windows or macOS, deploying Qt translations") if (DEFINED VCPKG_TARGET_TRIPLET) # vcpkg set(relative_qt_translations_dir "translations/Qt6") else () # MSYS2 set(relative_qt_translations_dir "share/qt6/translations") endif () if (DEFINED QT_HOST_PATH) find_file(qt_translations_dir "${relative_qt_translations_dir}" PATHS "${QT_HOST_PATH}" REQUIRED) else () find_file(qt_translations_dir "${relative_qt_translations_dir}" REQUIRED) endif () message(STATUS "Deploying Qt translations from ${qt_translations_dir}") install(DIRECTORY "${qt_translations_dir}/" DESTINATION "${TREMOTESF_EXTERNAL_RESOURCES_PATH}/qt-translations" FILES_MATCHING PATTERN "qt_*.qm" PATTERN "qtbase_*.qm" PATTERN "qtmultimedia_*.qm" PATTERN "qt_help*" EXCLUDE) endif () tremotesf-2.8.2/translations/de.ts000066400000000000000000003453251500171105600172240ustar00rootroot00000000000000 tremotesf About "About" dialog title ---------- "About" dialog's "About" tab title Über <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Source code: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Translations: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> "About" dialog text <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Quelltext: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p>Übersetzungen: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> Maintainer Betreuer Contributor Mitwirkender Authors "About" dialog's "Authors" tab title Autoren Translators "About" dialog's "Translators" tab title Übersetzer License "About" dialog's "License" tab title Lizenz Add Torrent File Dialog title Torrent-Datei öffnen Free space: %1 %1 is a amount of free space in a directory, e.g. 1 GiB Freier Speicherplatz: %1 Error getting free space Fehler beim Berechnen von freiem Speicher Files Torrent properties dialog tab Dateien High Torrent's file loading priority ---------- Torrent's loading priority Hoch Normal Torrent's file loading priority ---------- Torrent's loading priority Normal Low Torrent's file loading priority ---------- Torrent's loading priority Niedrig Start downloading after adding Nach dem Hinzufügen mit dem Download starten Add Torrent Link Dialog title Torrent-Link öffnen No servers Keine Server Disconnected Server connection status ---------- Notification title when disconnected from server ---------- Message that appears when disconnected from server Getrennt Downloading Noun "Downloading" server setting page Herunterladen Start added torrents Check box label Hinzugefügte Torrents starten Append ".part" to names of incomplete files Check box label Zu nicht fertigen Dateinamen ".part" hinzufügen Rename Dialog title ---------- Dialog confirmation button Umbenennen Select Directory Directory chooser dialog title Verzeichnis wählen Status Title of torrents status filters list ---------- Torrents list column name ---------- Trackers list column title Status Directories Title of torrents download directory filters list Verzeichnisse Trackers Title of torrents tracker filters list ---------- Torrent properties dialog tab Tracker Priority Column title in torrent's file list ---------- Torrents list column name Priorität Mixed Torrent's file loading priority ---------- File loading priority Gemischt No torrents Torrents list placeholder Keine Torrents Remove Button ---------- Dialog confirmation button Entfernen Set Location Dialog title for changing torrent's download directory Ziel wählen Error adding torrent Fehler beim hinzufügen von Torrent This torrent is already added Dieser Torrent wurde bereits hinzugefügt Torrent added Notification title Torrent hinzugefügt Torrent finished Notification title Torrent fertig Network "Network" server settings page Netzwerk Connection Title of settings section related to peer connections Verbindung Random port on Transmission start Check box label Zufälliger Port beim Start von Transmission Enable port forwarding Check box label Port-Forwarding aktivieren Allow Encryption mode (allow/prefer/require) Erlauben Prefer Encryption mode (allow/prefer/require) Bevorzugen Require Encryption mode (allow/prefer/require) Voraussetzen Enable DHT Check box label DHT aktivieren Peer Limits Peer-Limits Peers Torrent's properties dialog tab ---------- Torrent's limits tab section ---------- Trackers list column title Peers Queue "Queue" server settings page Warteschlange Also delete the files on the hard disk Check box label Auch die Dateien auf dem Datenträger löschen Seeding Noun "Seeding" server setting page Seeden Overwrite Dialog's confirmation button Überschreiben Server already exists Server existiert bereits Add Server Dialog title Server hinzufügen Name Column title in torrent's file list ---------- Torrents list column name Name Address Peers list column title ---------- Trackers list column title Adresse Default Default proxy option Standard HTTPS HTTPS SOCKS5 SOCKS5 proxy option SOCKS5 Server uses self-signed certificate Check box label Server nutzt selbst signiertes Zertifikat Server's certificate in PEM format Text field placeholder Server-Zertifikat in PEM-Format Load from file... Button Datei laden von... Use client certificate authentication Check box label Client-Zertifikat-Authentifizierung nutzen Certificate in PEM format with private key Text field placeholder Zertifikat in PEM-Format mit privatem Schlüssel Authentication Check box label Authentifizierung Auto reconnect on error Check box label Bei Fehler automatisch neu verbinden Mounted directories Gemountete Verzeichnisse Local directory Column title in the list of mounted directories Lokale Verzeichnisse Remote directory Column title in the list of mounted directories Remote-Verzichnisse Add Button ---------- Dialog confirmation button Hinzufügen Speed "Speed" server settings page ---------- Torrent's limits tab section Geschwindigkeit Connection Settings Servers list placeholder Dialog title Verbindungseinstellungen Edit... Button Bearbeiten... Add Server... Button Server hinzufügen... Add... Button Hinzufügen... Connect to server on startup Check box label Beim Starten zu Server verbinden Adding torrents Options tab Torrents hinzufügen Add torrent parameters Torrent-Parameter hinzufügen Reset Zurücksetzen Show main window when adding torrents Check box label Beim Hinzufügen von Torrents das Hauptfenster anzeigen Show dialog when adding torrents Check box label Dialog beim Hinzufügen von Torrents anzeigen Automatically fill link from clipboard when adding torrent link Check box label Link automatisch aus Zwischenablage einfügen Tip: you can also press %1 in main window to add torrents from clipboard %1 is a key binding, e.g. "Ctrl + C" Tip: im Hauptfenster %1 drücken, um Torrents aus der Zwischenablage hinzuzufügen Ask for merging trackers when adding existing torrent Check box label Nach dem Zusammenführen von Trackern fragen, wenn vorhandene Torrents hinzugefügt werden. Merge trackers when adding existing torrent Check box label Beim Hinzufügen vorhandener Torrents Tracker zusammenführen Open properties dialog Eigenschaftendialog öffnen Open torrent's file Es werden Torrent-Dateien geöffnet Open download directory Downloadverzeichnis öffnen What to do when torrent in the list is double clicked: Was tun, wenn in der Liste auf ein Torrent doppelt geklickt wird: General Options tab Show torrent properties in a panel in the main window Check box label Properties dialog won't be shown because torrent properties are shown in the main window Display relative time Check box label Display full path of download directories in sidebar and torrents list Check box label Notifications Options tab Benachrichtigungen Notify when disconnecting from server Check box label Bei Abmelden von Server benachrichtigen Notify on added torrents Check box label Beim Hinzufügen von Torrents benachrichtigen Notify on finished torrents Check box label Beim Fertigstellen von Torrents benachrichtigen When connecting to server Notifications options section Beim Verbinden zum Server Notify on added torrents since last connection to server Check box label Benachrichtigung über hinzugefügte Torrents seit der letzten Verbindung zum Server Notify on finished torrents since last connection to server Check box label Benachrichtigung über fertiggestellte Torrents seit der letzten Verbindung zum Server Progress Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Fortschritt ETA Torrents list column name Restzeit Server Stats Dialog title Serverdaten Current session Server stats section for current Transmission launch Aktuelle Sitzung Ratio Torrents list column name Verhältnis Total Server stats section for all Transmission launches (accumulated) Gesamt %Ln times How many times Transmission was launched Einmal%Ln mal Size Column title in torrent's file list ---------- Torrents list column name Größe Limits Speed limits section ---------- Torrent's properties dialog tab Begrenzungen Alternative Limits Alternative speed limits section Alternative Begrenzungen Enable Check box label aktivieren Scheduled Title of alternative speed limit scheduling section Geplant to Separates time range input fields. E.g. "to" inside "1:00 AM to 5:00 AM" bis Every day Jeden Tag Weekdays Wochentage Weekends Wochenenden %1 of %2 (%3) Torrent's completion size, e.g. 100 MiB of 200 MiB (50%). %1 is completed size, %2 is size, %3 is progress in percents %1 von %2 (%3) Checking (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Überprüfen (%L1) Honor global limits Check box label Globale Beschränkungen befolgen Use global settings Seeding ratio limit mode (global settings/stop at ratio/unlimited) ---------- Seeding idle limit mode (global settings/stop if idle for/unlimited) Globale Einstellungen nutzen Seed regardless of ratio Seeding ratio limit mode (global settings/stop at ratio/unlimited) Seeden unabhängig von der Ratio Stop seeding at ratio: Seeding ratio limit mode (global settings/stop at ratio/unlimited) Bei diesem Verhältnis aufhören zu Seeden: Seed regardless of activity Seeding idle limit mode (global settings/stop if idle for/unlimited) Seeden unabhängig von der Aktivität Stop seeding if idle for: Seeding idle limit mode (global settings/stop if idle for/unlimited) Aufhören zu seeden, wenn im Leerlauf für: Activity Torrent's details tab section Aktivität Completed Torrents list column name, completed byte size Fertiggestellt Downloaded Torrents list column name, downloaded byte size Heruntergeladen Paused (%1) Torrent status while torrent also has an error. %1 is error string Angehalten (%1) Paused Torrent status Angehalten Downloading (%1) Torrent status while torrent also has an error. %1 is error string Herunterladen (%1) Seeding (%1) Torrent status while torrent also has an error. %1 is error string Seeden (%1) Queued (%1) Torrent status while torrent also has an error. %1 is error string In Warteschlange (%1) Queued Torrent status In Warteschlange Checking (%1) Torrent status while torrent also has an error. %1 is error string Überprüfen (%1) Checking Torrent status Überprüfen Queued for checking (%1) Torrent status while torrent also has an error. %1 is error string Zur Prüfung eingereiht (%1) Downloading to peers Torrents list column name, number of peers that we are downloading from Herunterladen von Peers Uploading to peers Torrents list column name, number of peers that we are uploading to Hochladen zu Peers Uploaded Torrents list column name, uploaded byte size Hochgeladen Seeders Torrents list column name, number of seeders reported by trackers ---------- Trackers list column title Seeder Leechers Torrents list column name, number of leechers reported by trackers ---------- Trackers list column title Leecher Information Torrent's details tab section Information Torrent Removed Message that appears when torrent is removed Torrent entfernt Edit Tracker Dialog title Tracker bearbeiten Torrent file: Input field's label Torrent-Datei: Torrent link: Input field's label Torrent-Link: Download directory: Input field's label Downloadverzeichnis: Torrent priority: Combo box label Priorität: Delete .torrent file .torrent-Datei löschen Move .torrent file to trash .torrent-Datei in Papierkorb verschieben Labels Title of torrents label filters list Loading Placeholder shown when torrent file is being read/parsed Laden &Connect Button / menu item to connect to server &Verbinden &Disconnect Button / menu item to disconnect from server Verbindung &trennen &Add Torrent File... Menu item &Torrent-Datei hinzufügen... Add Torrent &Link... Menu item Torrent &Link hinzufügen... P&ause Torrent's context menu item P&ause &Delete Torrent's context menu item Lös&chen Open &Download Directory Context menu item &Downloadverzeichnis öffnen Delete with files Mit Dateien löschen Delete Löschen Delete Torrent Dialog title Torrent löschen Are you sure you want to delete this torrent? Soll der Torrent wirklich gelöscht werden? Delete Torrents Dialog title Torrents löschen Are you sure you want to delete %Ln selected torrents? %Ln is a number of torrents selected for deletion Soll dieser Torrent wirklich gelöscht werden?Sollen %Ln Torrents wirklich gelöscht werden? No torrents matching filters Torrents list placeholder Keine Torrents passen zu dem Filter &Quit Menu item &Beenden &Torrent Menu bar item &Torrent Error adding torrent «%1» Fehler beim hinzufügen von Torrent «%1» &Properties Torrent's context menu item &Eigenschaften &Show Tremotesf Tremote&sf anzeigen &Hide Tremotesf Tremotesf a&usblenden &Start Torrent's context menu item &Start Start &Now Torrent's context menu item &Jetzt starten Copy &Magnet Link Torrent's context menu item &Magnet-Link kopieren &Remove Server's context menu item ---------- Context menu item ---------- Tracker's context menu item Entfe&rnen Set &Location Torrent's context menu item Verzeichnis fest&legen Edi&t Labels Torrent's context menu item &Open Torrent's context menu item ---------- Context menu item &Öffnen Op&en Download Directory Torrent's context menu item Downloadverzeichnis öffn&en &Check Local Data Torrent's context menu item Lokale Daten &prüfen Reanno&unce Torrent's context menu item ---------- Button Reanno&unce &Queue Torrent's context menu item &Warteschlange Move To &Top Torrent's context menu item Nach O&ben verschieben Move &Up Torrent's context menu item Nach &Oben Move &Down Torrent's context menu item Nach &Unten Move To &Bottom Torrent's context menu item Nach Unten verschie&ben &Connection Settings Verbindun&gseinstellungen &Server Options &Serveroptionen Server S&tats Server-S&tatistiken Select Files File chooser dialog title Dateien wählen Torrent Files (*.torrent) Torrent file type. Parentheses and text within them must remain unchanged Torrent-Dateien (*.torrent) Error Dialog title ---------- Trackers list column title Fehler This file/directory does not exist Datei/Verzeichnis existiert nicht &File Menu bar item &Datei &Close Window Fenster s&chließen &Edit Menu bar item &Bearbeiten Select &All &Alle auswählen &Invert Selection Auswahl &umkehren &View Menu bar item Ans&icht &Toolbar Symbolleis&te &Sidebar &Seitenleiste St&atusbar St&atusleiste Torrent properties &panel &Lock Toolbar Symbo&lleiste sperren T&ools Menu bar item Werk&zeuge &Options &Optionen S&hutdown Server Server &herunterfahren Shutdown Server Dialog title Server herunterfahren Are you sure you want to shutdown remote Transmission instance? Sind Sie sicher, dass Sie die Remote-Instanz von Transmission herunterfahren möchten? Shutdown Dialog confirmation button Herunterfahren &Help Menu bar item &Hilfe &About Menu item opening "About" dialog Ü&ber Icon Only Toolbar mode Nur Symbole Text Only Toolbar mode Nur Text Text Beside Icon Toolbar mode Text neben Symbolen Text Under Icon Toolbar mode Text unter Symbolen Follow System Style Toolbar mode System-Design nutzen Torrents will be added after connection to server Message shown when user attempts to add torrent while disconnect from server. Show Tremotesf Button on notification Tremotesf anzeigen Active (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Aktiv (%L1) Downloading (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Herunterladen (%L1) Seeding (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Seeden (%L1) Paused (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Angehalten (%L1) Errored (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Fehler (%L1) Search... Search field placeholder Suche... &Select... Context menu item to open directory chooser &Auswählen... Overwrite Server Dialog title Server überschreiben Name: Name: Address: Adresse: Port: Port: API path: API-Pfad: Proxy Proxy HTTP HTTP proxy option HTTP None None proxy option Keine Proxy type: Proxy-Typ: Username: Benutzername: Password: Passwort: s Suffix that is added to input field with number of seconds, e.g. "30 s" s Update interval: Aktualisierungsintervall: Timeout: Timeout: Auto reconnect interval: Neuverbindungs-Intervall: &Edit... Server's context menu item ---------- Tracker's context menu item &Bearbeiten... Server Options Dialog title Servereinstellungen Directory for incomplete files: Verzeichnis für unvollständige Dateien: min Suffix that is added to input field with number of minuts, e.g. "5 min" Min Maximum active downloads: Maximale Anzahl an aktiven Downloads: Maximum active uploads: Maximale Anzahl an aktiven Uploads: Ignore queue position if idle for: Warteschlangenposition ignorieren, wenn inaktiv für: kB/s Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes kB/s Download: Download speed limit input field label Herunterladen: Upload: Upload speed limit input field label Hochladen: Days: Tage: Peer port: Peer-Port: Encryption: Verschlüsselung: Enable μTP (Micro Transport Protocol) Check box label μTP (Micro Transport Protocol) aktivieren Enable PEX (Peer exchange) Check box label PEX (Peer exchange) aktivieren Enable local peer discovery Check box label Local Peer Discovery aktivieren Maximum peers per torrent: Maximale Peers per Torrent: Maximum peers globally: Glabales Maximum von Peers: Options Dialog title Einstellungen Follow system Dark theme mode Systemdesign nutzen On Dark theme mode Ein Off Dark theme mode Aus Dark theme Dunkles Design Use system accent color Check box label System-Akzentfarben nutzen Remember location of last opened torrent file Check box label Speicherort der zuletzt geöffneten Torrent-Datei merken Remember parameters of last added torrent Check box label Parameter des zuletzt hinzugefügten Torrents merken Show icon in the notification area Check box label Symbol im Benachrichtigungsbereich anzeigen &Not Download Context menu item to unselect file for downloading &Nicht herunterladen &Priority Torrent's context menu item &Priorität &Download Context menu item to select file for downloading &Herunterladen &High File loading priority H&och &Normal File loading priority No&rmal &Low File loading priority N&iedrig &Rename Torrent's context menu item ---------- Context menu item &Umbenennen File name: Dateiname: Details Torrent's properties dialog tab Details Completed: Torrent's completed size Fertiggestellt: Downloaded: Downloaded bytes ---------- Torrent's downloaded size Heruntergeladen: Uploaded: Uploaded bytes ---------- Torrent's uploaded size Hochgeladen: Ratio: Verhältnis: Duration: How much time Transmission is running Dauer: Started: How many times Transmission was launched Gestartet: Free space in download directory: Freier Speicher in Downloadverzeichnis: Download speed: Downloadgeschwindigkeit: Upload speed: Uploadgeschwindigkeit: ETA: Restzeit: Seeders: Seeder: Leechers: Leecher: Peers we are downloading from: Peers von denen heruntergeladen wird: Web seeders we are downloading from: Web-Seeder von denen heruntergeladen wird: Peers we are uploading to: Peers zu denen hochgeladen wird: Last activity: Letzte Aktivität: Total size: Gesamtgröße: Location: Torrent's download directory Ort: Hash: Torrent's hash string Hash: Created by: Program that created torrent file Erstellt von: Created on: Date/time when torrent was created Erstellt am: Comment: Torrent's comment text Kommentar: Labels: Torrent's labels Web seeder Web seeders list column title Web-Seeder Web seeders Torrent's properties dialog tab Web-Seeder Seeding Options section Torrent's limits tab section Seeden Ratio limit mode: Ratio-Limit-Modus: Idle seeding mode: Leerlauf-Seed-Modus: Maximum peers: Maximale Peers: Add Trackers Dialog title Tracker hinzufügen Trackers announce URLs: Tracker-Announce URLs: Tracker announce URL: Tracker-Announce URL: Remove Tracker Dialog title Tracker entfernen Are you sure you want to remove this tracker? Soll dieser Tracker wirklich entfernt werden? Remove Trackers Dialog title Tracker entfernen Are you sure you want to remove %Ln selected trackers? %Ln is number of trackers selected for deletion Soll dieser Tracker wirklich entfernt werden?Sollen %Ln Tracker wirklich entfernt werden? Down Speed Torrents list column name ---------- Peers list column title Herunterladgeschwindigkeit Up Speed Torrents list column name ---------- Peers list column title Uploadgeschwindigkeit Progress Bar Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Fortschrittsbalken Flags Peers list column title Flags Client Peers list column title Client Timed out Server connection status Timeout Connection error Server connection status Verbindungsfehler Authentication error Server connection status Fehler bei der Authentifizierung Parse error Server connection status Fehler beim Parsen Server is too new Server connection status Server ist zu neu Server is too old Server connection status Server ist zu alt Connecting... Server connection status Verbinden... Connected Server connection status Verbunden Downloading Torrent status Torrent status Herunterladen Seeding Torrent status Torrent status Seeden Queued for checking Torrent status Zur Überprüfung eingereiht Merge trackers? Dialog title Tracker zusammenführen? Torrent «%1» is already added, merge trackers? Torrent «%1» bereits hinzugefügt, Tracker zusammenführen? Merge Zusammenführen Do not ask again Nicht noch einmal fragen Merged trackers Dialog title Tracker zusammengeführt Merged trackers for torrent «%1» Tracker für Torrent „%1“ zusammengeführt Torrent already exists Dialog title Torrent existiert bereits Torrent «%1» already exists Torrent «%1» existiert bereits Error reading torrent file Fehler beim Lesen von Torrent-Datei Error parsing torrent file Fehler beim Parsen von Torrent-Datei Total Size Torrents list column name Gesamtgröße Queue Position Torrents list column name Warteschlange Added on Torrents list column name, date/time when torrent was added Hinzugefügt am Completed on Torrents list column name, date/time when torrent was completed Fertiggestellt am Down Limit Torrents list column name, download speed limit Limit für Herunterladen Up Limit Torrents list column name, upload speed limit Limit für Hochladen Remaining Torrents list column name, remaining byte size Verbleibend Download Directory Torrents list column name Downloadverzeichnis Last Activity Torrents list column name Letzte Aktivität Inactive Tracker status Inaktiv Waiting for update Tracker status Auf Aktualisierung warten About to update Tracker status Wird gleich aktualisiert Updating Tracker status Aktualisieren Next Update Trackers list column title Nächste Aktualisierung %L1 B Size suffix in bytes %L1 B %L1 KiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in kibibytes %L1 KiB %L1 MiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in mebibytes %L1 MiB %L1 GiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in gibibytes %L1 GiB %L1 TiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in tebibytes %L1 TiB %L1 PiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in pebibytes %L1 PiB %L1 EiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in exbibytes %L1 EiB %L1 ZiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in zebibytes %L1 ZiB %L1 YiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in yobibytes %L1 YiB %L1 B/s Download speed suffix in bytes per second %L1 B/s %L1 KiB/s Download speed suffix in kibibytes per second %L1 KiB/s %L1 MiB/s Download speed suffix in mebibytes per second %L1 MiB/s %L1 GiB/s Download speed suffix in gibibytes per second %L1 GiB/s %L1 TiB/s Download speed suffix in tebibytes per second %L1 TiB/s %L1 PiB/s Download speed suffix in pebibytes per second %L1 PiB/s %L1 EiB/s Download speed suffix in exbibytes per second %L1 EiB/s %L1 ZiB/s Download speed suffix in zebibytes per second %L1 ZiB/s %L1 YiB/s Download speed suffix in yobibytes per second %L1 YiB/s %L1% Progress in percents. %L1 must remain unchanged, % after it is a percent character %L1% %L1 d %L2 h Remaining time string. %L1 is days, %L2 is hours, e.g. "2 d 5 h" %L1 T %L2 Std %L1 h %L2 m Remaining time string. %L1 is hours, %L2 is minutes, e.g. "2 h 5 m" %L1 Std %L2 min %L1 m %L2 s Remaining time string. %L1 is minutes, %L2 is seconds, e.g. "2 m 5 s" %L1 min %L2 sek %L1 s Remaining time string. %L1 is seconds, "10 s" %L1 sek Today Yesterday Two days ago %1 at %2 Relative date & time Just now Relative time %n minute(s) ago @item:intext %1 is a whole number %n minutes ago %n minute ago Error opening %1 File opening error, %1 is a file path ---------- Directory opening error, %1 is a file path Fehler beim Öffnen von %1 Move files from current directory Check box label Dateien aus aktuellem Verzeichnis verschieben Selected directory should be inside mounted directory Gewähltes Verzeichnis soll in eingehängtem Verzeichnis sein All (%L1) Filter option of torrents list's tracker filter. %L1 is total number of torrents ---------- Filter option of torrents list's download directory filter. %L1 is total number of torrents ---------- Filter option of torrents list's label filter. %L1 is total number of torrents ---------- Filter option of torrents list's status filter. %L1 is total number of torrents Alle (%L1) %1 (%L2) Filter option of torrents list's tracker filter. %1 is tracker domain name, %L2 is number of torrents with that tracker ---------- Filter option of torrents list's label filter. %1 is label, %L2 is number of torrents with that label %1 (%L2) This directory does not exist Dieses Verzeichnis existiert nicht Edit Labels New label... tremotesf-2.8.2/translations/en.ts000066400000000000000000003431701500171105600172320ustar00rootroot00000000000000 tremotesf About "About" dialog title ---------- "About" dialog's "About" tab title About <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Source code: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Translations: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> "About" dialog text <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Source code: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Translations: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> Maintainer Maintainer Contributor Contributor Authors "About" dialog's "Authors" tab title Authors Translators "About" dialog's "Translators" tab title Translators License "About" dialog's "License" tab title License Add Torrent File Dialog title Add Torrent File Free space: %1 %1 is a amount of free space in a directory, e.g. 1 GiB Free space: %1 Error getting free space Error getting free space Files Torrent properties dialog tab Files High Torrent's file loading priority ---------- Torrent's loading priority High Normal Torrent's file loading priority ---------- Torrent's loading priority Normal Low Torrent's file loading priority ---------- Torrent's loading priority Low Start downloading after adding Start downloading after adding Add Torrent Link Dialog title Add Torrent Link No servers No servers Disconnected Server connection status ---------- Notification title when disconnected from server ---------- Message that appears when disconnected from server Disconnected Downloading Noun "Downloading" server setting page Downloading Start added torrents Check box label Start added torrents Append ".part" to names of incomplete files Check box label Append ".part" to names of incomplete files Rename Dialog title ---------- Dialog confirmation button Rename Select Directory Directory chooser dialog title Select Directory Status Title of torrents status filters list ---------- Torrents list column name ---------- Trackers list column title Status Directories Title of torrents download directory filters list Directories Trackers Title of torrents tracker filters list ---------- Torrent properties dialog tab Trackers Priority Column title in torrent's file list ---------- Torrents list column name Priority Mixed Torrent's file loading priority ---------- File loading priority Mixed No torrents Torrents list placeholder No torrents Remove Button ---------- Dialog confirmation button Remove Set Location Dialog title for changing torrent's download directory Set Location Error adding torrent Error adding torrent This torrent is already added This torrent is already added Torrent added Notification title Torrent added Torrent finished Notification title Torrent finished Network "Network" server settings page Network Connection Title of settings section related to peer connections Connection Random port on Transmission start Check box label Random port on Transmission start Enable port forwarding Check box label Enable port forwarding Allow Encryption mode (allow/prefer/require) Allow Prefer Encryption mode (allow/prefer/require) Prefer Require Encryption mode (allow/prefer/require) Require Enable DHT Check box label Enable DHT Peer Limits Peer Limits Peers Torrent's properties dialog tab ---------- Torrent's limits tab section ---------- Trackers list column title Peers Queue "Queue" server settings page Queue Also delete the files on the hard disk Check box label Also delete the files on the hard disk Seeding Noun "Seeding" server setting page Seeding Overwrite Dialog's confirmation button Overwrite Server already exists Server already exists Add Server Dialog title Add Server Name Column title in torrent's file list ---------- Torrents list column name Name Address Peers list column title ---------- Trackers list column title Address Default Default proxy option Default HTTPS HTTPS SOCKS5 SOCKS5 proxy option SOCKS5 Server uses self-signed certificate Check box label Server uses self-signed certificate Server's certificate in PEM format Text field placeholder Server's certificate in PEM format Load from file... Button Load from file... Use client certificate authentication Check box label Use client certificate authentication Certificate in PEM format with private key Text field placeholder Certificate in PEM format with private key Authentication Check box label Authentication Auto reconnect on error Check box label Auto reconnect on error Mounted directories Mounted directories Local directory Column title in the list of mounted directories Local directory Remote directory Column title in the list of mounted directories Remote directory Add Button ---------- Dialog confirmation button Add Speed "Speed" server settings page ---------- Torrent's limits tab section Speed Connection Settings Servers list placeholder Dialog title Connection Settings Edit... Button Edit... Add Server... Button Add Server... Add... Button Add... Connect to server on startup Check box label Connect to server on startup Adding torrents Options tab Adding torrents Add torrent parameters Add torrent parameters Reset Reset Show main window when adding torrents Check box label Show main window when adding torrents Show dialog when adding torrents Check box label Show dialog when adding torrents Automatically fill link from clipboard when adding torrent link Check box label Automatically fill link from clipboard when adding torrent link Tip: you can also press %1 in main window to add torrents from clipboard %1 is a key binding, e.g. "Ctrl + C" Tip: you can also press %1 in main window to add torrents from clipboard Ask for merging trackers when adding existing torrent Check box label Ask for merging trackers when adding existing torrent Merge trackers when adding existing torrent Check box label Merge trackers when adding existing torrent Open properties dialog Open properties dialog Open torrent's file Open torrent's file Open download directory Open download directory What to do when torrent in the list is double clicked: What to do when torrent in the list is double clicked: General Options tab General Show torrent properties in a panel in the main window Check box label Show torrent properties in a panel in the main window Properties dialog won't be shown because torrent properties are shown in the main window Properties dialog won't be shown because torrent properties are shown in the main window Display relative time Check box label Display relative time Display full path of download directories in sidebar and torrents list Check box label Display full path of download directories in sidebar and torrents list Notifications Options tab Notifications Notify when disconnecting from server Check box label Notify when disconnecting from server Notify on added torrents Check box label Notify on added torrents Notify on finished torrents Check box label Notify on finished torrents When connecting to server Notifications options section When connecting to server Notify on added torrents since last connection to server Check box label Notify on added torrents since last connection to server Notify on finished torrents since last connection to server Check box label Notify on finished torrents since last connection to server Progress Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Progress ETA Torrents list column name ETA Server Stats Dialog title Server Stats Current session Server stats section for current Transmission launch Current session Ratio Torrents list column name Ratio Total Server stats section for all Transmission launches (accumulated) Total %Ln times How many times Transmission was launched %Ln time%Ln times Size Column title in torrent's file list ---------- Torrents list column name Size Limits Speed limits section ---------- Torrent's properties dialog tab Limits Alternative Limits Alternative speed limits section Alternative Limits Enable Check box label Enable Scheduled Title of alternative speed limit scheduling section Scheduled to Separates time range input fields. E.g. "to" inside "1:00 AM to 5:00 AM" to Every day Every day Weekdays Weekdays Weekends Weekends %1 of %2 (%3) Torrent's completion size, e.g. 100 MiB of 200 MiB (50%). %1 is completed size, %2 is size, %3 is progress in percents %1 of %2 (%3) Checking (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Checking (%L1) Honor global limits Check box label Honor global limits Use global settings Seeding ratio limit mode (global settings/stop at ratio/unlimited) ---------- Seeding idle limit mode (global settings/stop if idle for/unlimited) Use global settings Seed regardless of ratio Seeding ratio limit mode (global settings/stop at ratio/unlimited) Seed regardless of ratio Stop seeding at ratio: Seeding ratio limit mode (global settings/stop at ratio/unlimited) Stop seeding at ratio: Seed regardless of activity Seeding idle limit mode (global settings/stop if idle for/unlimited) Seed regardless of activity Stop seeding if idle for: Seeding idle limit mode (global settings/stop if idle for/unlimited) Stop seeding if idle for: Activity Torrent's details tab section Activity Completed Torrents list column name, completed byte size Completed Downloaded Torrents list column name, downloaded byte size Downloaded Paused (%1) Torrent status while torrent also has an error. %1 is error string Paused (%1) Paused Torrent status Paused Downloading (%1) Torrent status while torrent also has an error. %1 is error string Downloading (%1) Seeding (%1) Torrent status while torrent also has an error. %1 is error string Seeding (%1) Queued (%1) Torrent status while torrent also has an error. %1 is error string Queued (%1) Queued Torrent status Queued Checking (%1) Torrent status while torrent also has an error. %1 is error string Checking (%1) Checking Torrent status Checking Queued for checking (%1) Torrent status while torrent also has an error. %1 is error string Queued for checking (%1) Downloading to peers Torrents list column name, number of peers that we are downloading from Downloading to peers Uploading to peers Torrents list column name, number of peers that we are uploading to Uploading to peers Uploaded Torrents list column name, uploaded byte size Uploaded Seeders Torrents list column name, number of seeders reported by trackers ---------- Trackers list column title Seeders Leechers Torrents list column name, number of leechers reported by trackers ---------- Trackers list column title Leechers Information Torrent's details tab section Information Torrent Removed Message that appears when torrent is removed Torrent Removed Edit Tracker Dialog title Edit Tracker Torrent file: Input field's label Torrent file: Torrent link: Input field's label Torrent link: Download directory: Input field's label Download directory: Torrent priority: Combo box label Torrent priority: Delete .torrent file Delete .torrent file Move .torrent file to trash Move .torrent file to trash Labels Title of torrents label filters list Labels Loading Placeholder shown when torrent file is being read/parsed Loading &Connect Button / menu item to connect to server &Connect &Disconnect Button / menu item to disconnect from server &Disconnect &Add Torrent File... Menu item &Add Torrent File... Add Torrent &Link... Menu item Add Torrent &Link... P&ause Torrent's context menu item P&ause &Delete Torrent's context menu item &Delete Open &Download Directory Context menu item Open &Download Directory Delete with files Delete with files Delete Delete Delete Torrent Dialog title Delete Torrent Are you sure you want to delete this torrent? Are you sure you want to delete this torrent? Delete Torrents Dialog title Delete Torrents Are you sure you want to delete %Ln selected torrents? %Ln is a number of torrents selected for deletion Are you sure you want to delete %Ln selected torrent?Are you sure you want to delete %Ln selected torrents? No torrents matching filters Torrents list placeholder No torrents matching filters &Quit Menu item &Quit &Torrent Menu bar item &Torrent Error adding torrent «%1» Error adding torrent «%1» &Properties Torrent's context menu item &Properties &Show Tremotesf &Show Tremotesf &Hide Tremotesf &Hide Tremotesf &Start Torrent's context menu item &Start Start &Now Torrent's context menu item Start &Now Copy &Magnet Link Torrent's context menu item Copy &Magnet Link &Remove Server's context menu item ---------- Context menu item ---------- Tracker's context menu item &Remove Set &Location Torrent's context menu item Set &Location Edi&t Labels Torrent's context menu item Edi&t Labels &Open Torrent's context menu item ---------- Context menu item &Open Op&en Download Directory Torrent's context menu item Op&en Download Directory &Check Local Data Torrent's context menu item &Check Local Data Reanno&unce Torrent's context menu item ---------- Button Reanno&unce &Queue Torrent's context menu item &Queue Move To &Top Torrent's context menu item Move To &Top Move &Up Torrent's context menu item Move &Up Move &Down Torrent's context menu item Move &Down Move To &Bottom Torrent's context menu item Move To &Bottom &Connection Settings &Connection Settings &Server Options &Server Options Server S&tats Server S&tats Select Files File chooser dialog title Select Files Torrent Files (*.torrent) Torrent file type. Parentheses and text within them must remain unchanged Torrent Files (*.torrent) Error Dialog title ---------- Trackers list column title Error This file/directory does not exist This file/directory does not exist &File Menu bar item &File &Close Window &Close Window &Edit Menu bar item &Edit Select &All Select &All &Invert Selection &Invert Selection &View Menu bar item &View &Toolbar &Toolbar &Sidebar &Sidebar St&atusbar St&atusbar Torrent properties &panel Torrent properties &panel &Lock Toolbar &Lock Toolbar T&ools Menu bar item T&ools &Options &Options S&hutdown Server S&hutdown Server Shutdown Server Dialog title Shutdown Server Are you sure you want to shutdown remote Transmission instance? Are you sure you want to shutdown remote Transmission instance? Shutdown Dialog confirmation button Shutdown &Help Menu bar item &Help &About Menu item opening "About" dialog &About Icon Only Toolbar mode Icon Only Text Only Toolbar mode Text Only Text Beside Icon Toolbar mode Text Beside Icon Text Under Icon Toolbar mode Text Under Icon Follow System Style Toolbar mode Follow System Style Torrents will be added after connection to server Message shown when user attempts to add torrent while disconnect from server. Torrents will be added after connection to server Show Tremotesf Button on notification Show Tremotesf Active (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Active (%L1) Downloading (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Downloading (%L1) Seeding (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Seeding (%L1) Paused (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Paused (%L1) Errored (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Errored (%L1) Search... Search field placeholder Search... &Select... Context menu item to open directory chooser &Select... Overwrite Server Dialog title Overwrite Server Name: Name: Address: Address: Port: Port: API path: API path: Proxy Proxy HTTP HTTP proxy option HTTP None None proxy option None Proxy type: Proxy type: Username: Username: Password: Password: s Suffix that is added to input field with number of seconds, e.g. "30 s" s Update interval: Update interval: Timeout: Timeout: Auto reconnect interval: Auto reconnect interval: &Edit... Server's context menu item ---------- Tracker's context menu item &Edit... Server Options Dialog title Server Options Directory for incomplete files: Directory for incomplete files: min Suffix that is added to input field with number of minuts, e.g. "5 min" min Maximum active downloads: Maximum active downloads: Maximum active uploads: Maximum active uploads: Ignore queue position if idle for: Ignore queue position if idle for: kB/s Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes kB/s Download: Download speed limit input field label Download: Upload: Upload speed limit input field label Upload: Days: Days: Peer port: Peer port: Encryption: Encryption: Enable μTP (Micro Transport Protocol) Check box label Enable μTP (Micro Transport Protocol) Enable PEX (Peer exchange) Check box label Enable PEX (Peer exchange) Enable local peer discovery Check box label Enable local peer discovery Maximum peers per torrent: Maximum peers per torrent: Maximum peers globally: Maximum peers globally: Options Dialog title Options Follow system Dark theme mode Follow system On Dark theme mode On Off Dark theme mode Off Dark theme Dark theme Use system accent color Check box label Use system accent color Remember location of last opened torrent file Check box label Remember location of last opened torrent file Remember parameters of last added torrent Check box label Remember parameters of last added torrent Show icon in the notification area Check box label Show icon in the notification area &Not Download Context menu item to unselect file for downloading &Not Download &Priority Torrent's context menu item &Priority &Download Context menu item to select file for downloading &Download &High File loading priority &High &Normal File loading priority &Normal &Low File loading priority &Low &Rename Torrent's context menu item ---------- Context menu item &Rename File name: File name: Details Torrent's properties dialog tab Details Completed: Torrent's completed size Completed: Downloaded: Downloaded bytes ---------- Torrent's downloaded size Downloaded: Uploaded: Uploaded bytes ---------- Torrent's uploaded size Uploaded: Ratio: Ratio: Duration: How much time Transmission is running Duration: Started: How many times Transmission was launched Started: Free space in download directory: Free space in download directory: Download speed: Download speed: Upload speed: Upload speed: ETA: ETA: Seeders: Seeders: Leechers: Leechers: Peers we are downloading from: Peers we are downloading from: Web seeders we are downloading from: Web seeders we are downloading from: Peers we are uploading to: Peers we are uploading to: Last activity: Last activity: Total size: Total size: Location: Torrent's download directory Location: Hash: Torrent's hash string Hash: Created by: Program that created torrent file Created by: Created on: Date/time when torrent was created Created on: Comment: Torrent's comment text Comment: Labels: Torrent's labels Labels: Web seeder Web seeders list column title Web seeder Web seeders Torrent's properties dialog tab Web seeders Seeding Options section Torrent's limits tab section Seeding Ratio limit mode: Ratio limit mode: Idle seeding mode: Idle seeding mode: Maximum peers: Maximum peers: Add Trackers Dialog title Add Trackers Trackers announce URLs: Trackers announce URLs: Tracker announce URL: Tracker announce URL: Remove Tracker Dialog title Remove Tracker Are you sure you want to remove this tracker? Are you sure you want to remove this tracker? Remove Trackers Dialog title Remove Trackers Are you sure you want to remove %Ln selected trackers? %Ln is number of trackers selected for deletion Are you sure you want to remove %Ln selected tracker?Are you sure you want to remove %Ln selected trackers? Down Speed Torrents list column name ---------- Peers list column title Down Speed Up Speed Torrents list column name ---------- Peers list column title Up Speed Progress Bar Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Progress Bar Flags Peers list column title Flags Client Peers list column title Client Timed out Server connection status Timed out Connection error Server connection status Connection error Authentication error Server connection status Authentication error Parse error Server connection status Parse error Server is too new Server connection status Server is too new Server is too old Server connection status Server is too old Connecting... Server connection status Connecting... Connected Server connection status Connected Downloading Torrent status Torrent status Downloading Seeding Torrent status Torrent status Seeding Queued for checking Torrent status Queued for checking Merge trackers? Dialog title Merge trackers? Torrent «%1» is already added, merge trackers? Torrent «%1» is already added, merge trackers? Merge Merge Do not ask again Do not ask again Merged trackers Dialog title Merged trackers Merged trackers for torrent «%1» Merged trackers for torrent «%1» Torrent already exists Dialog title Torrent already exists Torrent «%1» already exists Torrent «%1» already exists Error reading torrent file Error reading torrent file Error parsing torrent file Error parsing torrent file Total Size Torrents list column name Total Size Queue Position Torrents list column name Queue Position Added on Torrents list column name, date/time when torrent was added Added on Completed on Torrents list column name, date/time when torrent was completed Completed on Down Limit Torrents list column name, download speed limit Down Limit Up Limit Torrents list column name, upload speed limit Up Limit Remaining Torrents list column name, remaining byte size Remaining Download Directory Torrents list column name Download Directory Last Activity Torrents list column name Last Activity Inactive Tracker status Inactive Waiting for update Tracker status Waiting for update About to update Tracker status About to update Updating Tracker status Updating Next Update Trackers list column title Next Update %L1 B Size suffix in bytes %L1 B %L1 KiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in kibibytes %L1 KiB %L1 MiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in mebibytes %L1 MiB %L1 GiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in gibibytes %L1 GiB %L1 TiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in tebibytes %L1 TiB %L1 PiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in pebibytes %L1 PiB %L1 EiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in exbibytes %L1 EiB %L1 ZiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in zebibytes %L1 ZiB %L1 YiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in yobibytes %L1 YiB %L1 B/s Download speed suffix in bytes per second %L1 B/s %L1 KiB/s Download speed suffix in kibibytes per second %L1 KiB/s %L1 MiB/s Download speed suffix in mebibytes per second %L1 MiB/s %L1 GiB/s Download speed suffix in gibibytes per second %L1 GiB/s %L1 TiB/s Download speed suffix in tebibytes per second %L1 TiB/s %L1 PiB/s Download speed suffix in pebibytes per second %L1 PiB/s %L1 EiB/s Download speed suffix in exbibytes per second %L1 EiB/s %L1 ZiB/s Download speed suffix in zebibytes per second %L1 ZiB/s %L1 YiB/s Download speed suffix in yobibytes per second %L1 YiB/s %L1% Progress in percents. %L1 must remain unchanged, % after it is a percent character %L1% %L1 d %L2 h Remaining time string. %L1 is days, %L2 is hours, e.g. "2 d 5 h" %L1 d %L2 h %L1 h %L2 m Remaining time string. %L1 is hours, %L2 is minutes, e.g. "2 h 5 m" %L1 h %L2 m %L1 m %L2 s Remaining time string. %L1 is minutes, %L2 is seconds, e.g. "2 m 5 s" %L1 m %L2 s %L1 s Remaining time string. %L1 is seconds, "10 s" %L1 s Today Today Yesterday Yesterday Two days ago Two days ago %1 at %2 Relative date & time %1 at %2 Just now Relative time Just now %n minute(s) ago @item:intext %1 is a whole number %n minute ago%n minutes ago %n minutes ago %n minute ago Error opening %1 File opening error, %1 is a file path ---------- Directory opening error, %1 is a file path Error opening %1 Move files from current directory Check box label Move files from current directory Selected directory should be inside mounted directory Selected directory should be inside mounted directory All (%L1) Filter option of torrents list's tracker filter. %L1 is total number of torrents ---------- Filter option of torrents list's download directory filter. %L1 is total number of torrents ---------- Filter option of torrents list's label filter. %L1 is total number of torrents ---------- Filter option of torrents list's status filter. %L1 is total number of torrents All (%L1) %1 (%L2) Filter option of torrents list's tracker filter. %1 is tracker domain name, %L2 is number of torrents with that tracker ---------- Filter option of torrents list's label filter. %1 is label, %L2 is number of torrents with that label %1 (%L2) This directory does not exist This directory does not exist Edit Labels Edit Labels New label... New label... tremotesf-2.8.2/translations/es.ts000066400000000000000000003303171500171105600172360ustar00rootroot00000000000000 tremotesf About "About" dialog title ---------- "About" dialog's "About" tab title Acerca de <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Source code: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Translations: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> "About" dialog text Maintainer Contributor Authors "About" dialog's "Authors" tab title Autores Translators "About" dialog's "Translators" tab title Traductores License "About" dialog's "License" tab title Licencia Add Torrent File Dialog title Agregar archivo torrente Free space: %1 %1 is a amount of free space in a directory, e.g. 1 GiB Espacio libre: %1 Error getting free space Error al obtener espacio libre Files Torrent properties dialog tab Archivos High Torrent's file loading priority ---------- Torrent's loading priority Alta Normal Torrent's file loading priority ---------- Torrent's loading priority Normal Low Torrent's file loading priority ---------- Torrent's loading priority Bajo Start downloading after adding Empezar a bajar después de agregar Add Torrent Link Dialog title Agregar enlace torrente No servers Servers list placeholder No hay servidores Disconnected Server connection status ---------- Notification title when disconnected from server ---------- Message that appears when disconnected from server Desconectado Downloading Noun "Downloading" server setting page Bajando Start added torrents Check box label Empezar torrentes agregados Append ".part" to names of incomplete files Check box label Adjuntar " .part" a nombres de archivos incompletos> Rename Dialog title ---------- Dialog confirmation button Renombrar Select Directory Directory chooser dialog title Seleccionar directorio Status Title of torrents status filters list ---------- Torrents list column name ---------- Trackers list column title Estado Directories Title of torrents download directory filters list Directorios Trackers Title of torrents tracker filters list ---------- Torrent properties dialog tab Rastreadores Priority Column title in torrent's file list ---------- Torrents list column name Prioridad Mixed Torrent's file loading priority ---------- File loading priority Mezclado No torrents Torrents list placeholder No hay torrentes Remove Button ---------- Dialog confirmation button Quitar Set Location Dialog title for changing torrent's download directory Establecer ubicación Error adding torrent Error al añadir torrente This torrent is already added Este torrente ya esta añadido Torrent added Notification title Torrente agregado Torrent finished Notification title Torrente finalizado Network "Network" server settings page Red Connection Title of settings section related to peer connections ---------- Options section Conexión Random port on Transmission start Check box label Puerto aleatorio al inicio de transmisión Enable port forwarding Check box label Habilitar reenvío de puerto Allow Encryption mode (allow/prefer/require) Permitir Prefer Encryption mode (allow/prefer/require) Preferir Require Encryption mode (allow/prefer/require) Requerir Enable DHT Check box label Habilitar DHT Peer Limits Límite de pares Peers Torrent's properties dialog tab ---------- Torrent's limits tab section ---------- Trackers list column title Pares Queue "Queue" server settings page Cola Also delete the files on the hard disk Check box label También quitar los archivos en el disco duro Seeding Noun "Seeding" server setting page Semillando Overwrite Dialog's confirmation button Sobrescribir Server already exists El servidor ya existe Add Server Dialog title Agregar servidor Name Column title in torrent's file list ---------- Torrents list column name Nombre Address Peers list column title ---------- Trackers list column title Dirección Default Default proxy option Predeterminado HTTPS HTTPS SOCKS5 SOCKS5 proxy option SOCKS5 Server uses self-signed certificate Check box label El servidor usa un certificado autofirmado Server's certificate in PEM format Text field placeholder Certificado de servidor es formato PEM Load from file... Button Use client certificate authentication Check box label Usar autenticación de certificado de cliente Certificate in PEM format with private key Text field placeholder Certificado en formato PEM con clave privada Authentication Check box label Autenticación Auto reconnect on error Check box label Mounted directories Directorios montados Local directory Column title in the list of mounted directories Directorio local Remote directory Column title in the list of mounted directories Directorio remoto Add Button ---------- Dialog confirmation button Agregar Speed "Speed" server settings page ---------- Torrent's limits tab section Velocidad Connection Settings Dialog title Edit... Button Editar Add Server... Button Add... Button Agregar Connect to server on startup Check box label Conéctar al servidor al inicio Adding torrents Options section Add torrent parameters Reset Show main window when adding torrents Check box label Show dialog when adding torrents Check box label Automatically fill link from clipboard when adding torrent link Check box label Tip: you can also press %1 in main window to add torrents from clipboard %1 is a key binding, e.g. "Ctrl + C" Ask for merging trackers when adding existing torrent Check box label Merge trackers when adding existing torrent Check box label Other behaviour Options section Open properties dialog Open torrent's file Open download directory What to do when torrent in the list is double clicked: Notifications Options section Notificaciones Notify when disconnecting from server Check box label Notificar al desconectar del servidor Notify on added torrents Check box label Notificar al agregar torrentes Notify on finished torrents Check box label Notificar al finalizar torrentes When connecting to server Options section Cuando esta conectandose al servidor Notify on added torrents since last connection to server Check box label Notificar sobre los torrents añadidos desde la última conexión al servidor Notify on finished torrents since last connection to server Check box label Notificar sobre torrentes finalizados desde la última conexión al servidor Progress Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Progreso ETA Torrents list column name ETA Server Stats Dialog title Estadísticas de servidor Current session Server stats section for current Transmission launch sesión actual Ratio Torrents list column name Proporción Total Server stats section for all Transmission launches (accumulated) Total %Ln times How many times Transmission was launched %Ln horas%Ln horas%Ln horas Size Column title in torrent's file list ---------- Torrents list column name Tamaño Limits Speed limits section ---------- Torrent's properties dialog tab Limites Alternative Limits Alternative speed limits section Límites alternativos Enable Check box label Habilitar Scheduled Title of alternative speed limit scheduling section Fechado to Separates time range input fields. E.g. "to" inside "1:00 AM to 5:00 AM" Para Every day Cada dia Weekdays Fin de semana Weekends Fin de semanas %1 of %2 (%3) Torrent's completion size, e.g. 100 MiB of 200 MiB (50%). %1 is completed size, %2 is size, %3 is progress in percents %1 de %2 (%3) Checking (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status comprobando (%L1) Honor global limits Check box label Honrar los límites globales Use global settings Seeding ratio limit mode (global settings/stop at ratio/unlimited) ---------- Seeding idle limit mode (global settings/stop if idle for/unlimited) Usar configuraciones globa Seed regardless of ratio Seeding ratio limit mode (global settings/stop at ratio/unlimited) Semilla independientemente de la proporción Stop seeding at ratio: Seeding ratio limit mode (global settings/stop at ratio/unlimited) Parar de semillar a proporción: Seed regardless of activity Seeding idle limit mode (global settings/stop if idle for/unlimited) Semilla independientemente de la actividad Stop seeding if idle for: Seeding idle limit mode (global settings/stop if idle for/unlimited) Para de semillar si está inactivo para: Activity Torrent's details tab section Actividad Completed Torrents list column name, completed byte size Completado Downloaded Torrents list column name, downloaded byte size Bajado Paused (%1) Torrent status while torrent also has an error. %1 is error string Paused Torrent status Downloading (%1) Torrent status while torrent also has an error. %1 is error string Seeding (%1) Torrent status while torrent also has an error. %1 is error string Queued (%1) Torrent status while torrent also has an error. %1 is error string Queued Torrent status Checking (%1) Torrent status while torrent also has an error. %1 is error string Checking Torrent status Queued for checking (%1) Torrent status while torrent also has an error. %1 is error string Downloading to peers Torrents list column name, number of peers that we are downloading from Uploading to peers Torrents list column name, number of peers that we are uploading to Uploaded Torrents list column name, uploaded byte size Subido Seeders Torrents list column name, number of seeders reported by trackers ---------- Trackers list column title Semilleros Leechers Torrents list column name, number of leechers reported by trackers ---------- Trackers list column title Sanguijuelos Information Torrent's details tab section Información Torrent Removed Message that appears when torrent is removed Torrente removido Edit Tracker Dialog title Editar rastreador Torrent file: Input field's label Archivo torrente: Torrent link: Input field's label Enlace torrente: Download directory: Input field's label Directorio de bajado: Torrent priority: Combo box label Prioridad torrente: Delete .torrent file Move .torrent file to trash Loading Placeholder shown when torrent file is being read/parsed &Connect Button / menu item to connect to server &Conectar &Disconnect Button / menu item to disconnect from server &Desconectar &Add Torrent File... Menu item &Agregar archivo torrente Add Torrent &Link... Menu item Agregar enlace &torrente P&ause Torrent's context menu item P&ausa &Delete Torrent's context menu item Open &Download Directory Context menu item Delete with files Delete Delete Torrent Dialog title Are you sure you want to delete this torrent? Delete Torrents Dialog title Are you sure you want to delete %Ln selected torrents? %Ln is a number of torrents selected for deletion No torrents matching filters Torrents list placeholder &Quit Menu item &Salir &Torrent Menu bar item &Torrente Error adding torrent «%1» Torrents will be added after connection to server: Message shown when user attempts to add torrent while disconnect from server. After that will be list of added torrents &Properties Torrent's context menu item &Propiedades &Show Tremotesf &Hide Tremotesf &Start Torrent's context menu item &Iniciar Start &Now Torrent's context menu item Iniciar &ahora Copy &Magnet Link Torrent's context menu item &Remove Server's context menu item ---------- Context menu item ---------- Tracker's context menu item &Quitar Set &Location Torrent's context menu item Establecer &Ubicación &Open Torrent's context menu item ---------- Context menu item &Abrir Op&en Download Directory Torrent's context menu item &Check Local Data Torrent's context menu item &Verificar datos locales Reanno&unce Torrent's context menu item ---------- Button reani&mar &Queue Torrent's context menu item &Cola Move To &Top Torrent's context menu item Mover a &tope Move &Up Torrent's context menu item Mover &arriba Move &Down Torrent's context menu item Mover &abajo Move To &Bottom Torrent's context menu item Mover al &fondo &Connection Settings &Server Options &Opciones de servidor Server S&tats estadísticas de s&ervidor Select Files File chooser dialog title Seleccionar archivos Torrent Files (*.torrent) Torrent file type. Parentheses and text within them must remain unchanged Archivos torrente (*.torrent) Error Dialog title ---------- Trackers list column title Error This file/directory does not exist &File Menu bar item &Archivos &Close Window &Edit Menu bar item &Editar Select &All Elegir &Todo &Invert Selection &Invertir selección &View Menu bar item &Vista &Toolbar &BarraDeHerramientas &Sidebar &BarraLateral St&atusbar &BarraDeEstado &Lock Toolbar T&ools Menu bar item &Herramientas &Options &Opciones S&hutdown Server Shutdown Server Dialog title Are you sure you want to shutdown remote Transmission instance? Shutdown Dialog confirmation button &Help Menu bar item &Ayuda &About Menu item opening "About" dialog &Acerca de Icon Only Toolbar mode Solo icono Text Only Toolbar mode Solo texto Text Beside Icon Toolbar mode Texto al lado del icono Text Under Icon Toolbar mode Texto abajo del icono Follow System Style Toolbar mode Seguir estilo del sistema Show Tremotesf Button on notification Active (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Activo (%L1) Downloading (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Bajando (%L1) Seeding (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Semillero (%L1) Paused (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Pausado (%L1) Errored (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Errado (%L1) Search... Search field placeholder Buscar &Select... Context menu item to open directory chooser &Seleccionar Overwrite Server Dialog title Sobrescribir servidor Name: Nombre: Address: Dirección: Port: Puerto: API path: Ruta de API: Proxy HTTP HTTP proxy option None None proxy option Proxy type: Username: Nombre de usuario: Password: Contraseña: s Suffix that is added to input field with number of seconds, e.g. "30 s" s Update interval: Intervalo de actualización: Timeout: Tiempo agotado: Auto reconnect interval: &Edit... Server's context menu item ---------- Tracker's context menu item &Editar Server Options Dialog title Opciones de servidor Directory for incomplete files: Directorio de archivos incompletos: min Suffix that is added to input field with number of minuts, e.g. "5 min" Minutos Maximum active downloads: Máximos bajados activos: Maximum active uploads: Máximos subidos activos: Ignore queue position if idle for: Ignorar posición de cola si está inactivo para: kB/s Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes Download: Download speed limit input field label Bajar: Upload: Upload speed limit input field label Subir: Days: Dias: Peer port: Puerto de par: Encryption: Cifrado: Enable μTP (Micro Transport Protocol) Check box label Enable PEX (Peer exchange) Check box label Enable local peer discovery Check box label Maximum peers per torrent: Pares máximos por torrente: Maximum peers globally: Máximos pares globalmente: Options Dialog title Opciones Appearance Options section Follow system Dark theme mode On Dark theme mode Off Dark theme mode Dark theme Use system accent color Check box label Remember location of last opened torrent file Check box label Remember parameters of last added torrent Check box label Show icon in the notification area Check box label Mostrar icono en el área de notificación &Not Download Context menu item to unselect file for downloading &No hay bajados &Priority Torrent's context menu item &Prioridad &Download Context menu item to select file for downloading &High File loading priority &Alto &Normal File loading priority &Normal &Low File loading priority &Bajar &Rename Torrent's context menu item ---------- Context menu item &Renombrar File name: Nombre de archivo: Details Torrent's properties dialog tab Detalles Completed: Torrent's completed size Completado: Downloaded: Downloaded bytes ---------- Torrent's downloaded size Bajado: Uploaded: Uploaded bytes ---------- Torrent's uploaded size Subido: Ratio: Proporción: Duration: How much time Transmission is running Duración Started: How many times Transmission was launched Iniciado: Free space in download directory: Download speed: Velocidad a bajar: Upload speed: Velocidad a subir: ETA: HEA: Seeders: Semilleros: Leechers: Sanguijuelos: Peers we are downloading from: Web seeders we are downloading from: Peers we are uploading to: Last activity: Última actividad: Total size: Tamaño total: Location: Torrent's download directory ubicación: Hash: Torrent's hash string Hash: Created by: Program that created torrent file Creado por: Created on: Date/time when torrent was created Creado en: Comment: Torrent's comment text Comentar: Web seeder Web seeders list column title Web seeders Torrent's properties dialog tab Seeding Options section Torrent's limits tab section Ratio limit mode: Modo límite de proporción: Idle seeding mode: Modo semillero inactivo: Maximum peers: Pares máximos: Add Trackers Dialog title Agregar rastreadores Trackers announce URLs: Rastreadores anuncian URLs: Tracker announce URL: Rastreador anuncia URL: Remove Tracker Dialog title Quitar rastreador Are you sure you want to remove this tracker? Estas seguro que quieres quitar este rastreador? Remove Trackers Dialog title Quitar rastreadores Are you sure you want to remove %Ln selected trackers? %Ln is number of trackers selected for deletion Down Speed Torrents list column name ---------- Peers list column title Velocidad baja Up Speed Torrents list column name ---------- Peers list column title Velocidad alta Progress Bar Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Barra de progreso Flags Peers list column title Banderas Client Peers list column title Cliente Timed out Server connection status Tiempo agotado Connection error Server connection status Error de conexión Authentication error Server connection status Error de autenticación Parse error Server connection status Error de análisis Server is too new Server connection status El servidor es demasiado nuevo Server is too old Server connection status El servidor es demasiado viejo Connecting... Server connection status Conectando... Connected Server connection status Conectado Downloading Torrent status Torrent status Bajando Seeding Torrent status Torrent status Estado de torrente Queued for checking Torrent status En cola para verificar Merge trackers? Dialog title Torrent «%1» is already added, merge trackers? Merge Do not ask again Merged trackers Dialog title Merged trackers for torrent «%1» Torrent already exists Dialog title Torrent «%1» already exists Error reading torrent file Error al leer el archivo torrent Error parsing torrent file Error al analizar archivo torrent Total Size Torrents list column name Tamaño total Queue Position Torrents list column name Posición en cola Added on Torrents list column name, date/time when torrent was added Agregado en Completed on Torrents list column name, date/time when torrent was completed Completado en Down Limit Torrents list column name, download speed limit Límite bajo Up Limit Torrents list column name, upload speed limit Límite arriba Remaining Torrents list column name, remaining byte size Restante Download Directory Torrents list column name Directorio de bajado Last Activity Torrents list column name Última actividad Inactive Tracker status Waiting for update Tracker status About to update Tracker status Updating Tracker status Next Update Trackers list column title Próxima actualización %L1 B Size suffix in bytes %L1 B %L1 KiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in kibibytes %L1 KiB %L1 MiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in mebibytes %L1 MiB %L1 GiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in gibibytes %L1 GiB %L1 TiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in tebibytes %L1 TiB %L1 PiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in pebibytes %L1 PiB %L1 EiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in exbibytes %L1 EiB %L1 ZiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in zebibytes %L1 ZiB %L1 YiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in yobibytes %L1 YiB %L1 B/s Download speed suffix in bytes per second %L1 B/s %L1 KiB/s Download speed suffix in kibibytes per second %L1 KiB/s %L1 MiB/s Download speed suffix in mebibytes per second %L1 MiB/s %L1 GiB/s Download speed suffix in gibibytes per second %L1 GiB/s %L1 TiB/s Download speed suffix in tebibytes per second %L1 TiB/s %L1 PiB/s Download speed suffix in pebibytes per second %L1 PiB/s %L1 EiB/s Download speed suffix in exbibytes per second >%L1 EiB/s %L1 ZiB/s Download speed suffix in zebibytes per second %L1 ZiB/s %L1 YiB/s Download speed suffix in yobibytes per second %L1 YiB/s %L1% Progress in percents. %L1 must remain unchanged, % after it is a percent character %L1 d %L2 h Remaining time string. %L1 is days, %L2 is hours, e.g. "2 d 5 h" %L1 d %L2 h %L1 h %L2 m Remaining time string. %L1 is hours, %L2 is minutes, e.g. "2 h 5 m" %L1 h %L2 m %L1 m %L2 s Remaining time string. %L1 is minutes, %L2 is seconds, e.g. "2 m 5 s" %L1 m %L2 s %L1 s Remaining time string. %L1 is seconds, "10 s" %L1 s Error opening %1 File opening error, %1 is a file path ---------- Directory opening error, %1 is a file path Error al abrir %1 Move files from current directory Check box label Mover archivos desde el directorio actual Selected directory should be inside mounted directory El directorio seleccionado debe estar dentro del directorio montado All (%L1) Filter option of torrents list's tracker filter. %L1 is total number of torrents ---------- Filter option of torrents list's download directory filter. %L1 is total number of torrents ---------- Filter option of torrents list's status filter. %L1 is total number of torrents %1 (%L2) Filter option of torrents list's tracker filter. %1 is tracker domain name, %L2 is number of torrents with that tracker ---------- Filter option of torrents list's download directory filter. %1 is download directory, %L2 is number of torrents with that download directory %1 (%L2) This directory does not exist tremotesf-2.8.2/translations/es_ES.ts000066400000000000000000003435321500171105600176300ustar00rootroot00000000000000 tremotesf About "About" dialog title ---------- "About" dialog's "About" tab title Acerca de <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Source code: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Translations: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> "About" dialog text Maintainer Encargado del mantenimiento Contributor Colaborador Authors "About" dialog's "Authors" tab title Autores Translators "About" dialog's "Translators" tab title Traductores License "About" dialog's "License" tab title Licencia Add Torrent File Dialog title Añadir archivo torrent Free space: %1 %1 is a amount of free space in a directory, e.g. 1 GiB Espacio libre: %1 Error getting free space Error al obtener el espacio libre Files Torrent properties dialog tab Archivos High Torrent's file loading priority ---------- Torrent's loading priority Alta Normal Torrent's file loading priority ---------- Torrent's loading priority Normal Low Torrent's file loading priority ---------- Torrent's loading priority Baja Start downloading after adding Iniciar descarga después de añadir Add Torrent Link Dialog title Añadir enlace torrent No servers No hay servidores Disconnected Server connection status ---------- Notification title when disconnected from server ---------- Message that appears when disconnected from server Desconectado Downloading Noun "Downloading" server setting page Descargas Start added torrents Check box label Iniciar torrents añadidos Append ".part" to names of incomplete files Check box label Añadir ".part" a los nombres de archivos incompletos Rename Dialog title ---------- Dialog confirmation button Renombrar Select Directory Directory chooser dialog title Seleccionar directorio Status Title of torrents status filters list ---------- Torrents list column name ---------- Trackers list column title Estado Directories Title of torrents download directory filters list Directorios Trackers Title of torrents tracker filters list ---------- Torrent properties dialog tab Rastreadores Priority Column title in torrent's file list ---------- Torrents list column name Prioridad Mixed Torrent's file loading priority ---------- File loading priority Mezclado No torrents Torrents list placeholder No hay torrents Remove Button ---------- Dialog confirmation button Eliminar Set Location Dialog title for changing torrent's download directory Establecer ubicación Error adding torrent Error al añadir torrent This torrent is already added Este torrent ya se ha añadido Torrent added Notification title Torrent añadido Torrent finished Notification title Torrent completado Network "Network" server settings page Red Connection Title of settings section related to peer connections Conexión Random port on Transmission start Check box label Puerto aleatorio al iniciar Transmission Enable port forwarding Check box label Habilitar el reenvío de puertos Allow Encryption mode (allow/prefer/require) Permitir Prefer Encryption mode (allow/prefer/require) Preferir Require Encryption mode (allow/prefer/require) Exigir Enable DHT Check box label Habilitar DHT Peer Limits Limite de pares conectados Peers Torrent's properties dialog tab ---------- Torrent's limits tab section ---------- Trackers list column title Pares Queue "Queue" server settings page Cola Also delete the files on the hard disk Check box label Borrar también los archivos del disco duro Seeding Noun "Seeding" server setting page Compartir Overwrite Dialog's confirmation button Sobrescribir Server already exists El servidor ya existe Add Server Dialog title Añadir servidor Name Column title in torrent's file list ---------- Torrents list column name Nombre Address Peers list column title ---------- Trackers list column title Dirección Default Default proxy option Por defecto HTTPS HTTPS SOCKS5 SOCKS5 proxy option SOCKS5 Server uses self-signed certificate Check box label El servidor usa un certificado autofirmado Server's certificate in PEM format Text field placeholder Certificado del servidor en formato PEM Load from file... Button Cargar desde archivo... Use client certificate authentication Check box label Usar autenticación de certificado de cliente Certificate in PEM format with private key Text field placeholder Certificado en formato PEM con clave privada Authentication Check box label Autenticación Auto reconnect on error Check box label Volver a conectar automáticamente en caso de error Mounted directories Directorios montados Local directory Column title in the list of mounted directories Directorio local Remote directory Column title in the list of mounted directories Directorio remoto Add Button ---------- Dialog confirmation button Añadir Speed "Speed" server settings page ---------- Torrent's limits tab section Velocidad Connection Settings Servers list placeholder Dialog title Ajustes de conexión Edit... Button Editar... Add Server... Button Añadir servidor... Add... Button Añadir... Connect to server on startup Check box label Conectarse al servidor al iniciar Adding torrents Options tab Añadiendo torrents Add torrent parameters Reset Show main window when adding torrents Check box label Show dialog when adding torrents Check box label Automatically fill link from clipboard when adding torrent link Check box label Cumplimentar automáticamente del portapapeles al añadir enlace torrent Tip: you can also press %1 in main window to add torrents from clipboard %1 is a key binding, e.g. "Ctrl + C" Sugerencia: también puedes presionar %1 en la ventana principal para añadir torrents desde el portapapeles Ask for merging trackers when adding existing torrent Check box label Merge trackers when adding existing torrent Check box label Open properties dialog Open torrent's file Open download directory What to do when torrent in the list is double clicked: General Options tab Show torrent properties in a panel in the main window Check box label Properties dialog won't be shown because torrent properties are shown in the main window Display relative time Check box label Display full path of download directories in sidebar and torrents list Check box label Notifications Options tab Notificaciones Notify when disconnecting from server Check box label Avisar cuando se desconecta del servidor Notify on added torrents Check box label Informar sobre los torrents añadidos Notify on finished torrents Check box label Informar sobre los torrents completados When connecting to server Notifications options section Al conectarse al servidor Notify on added torrents since last connection to server Check box label Informar sobre los torrents añadidos desde la última conexión al servidor Notify on finished torrents since last connection to server Check box label Informar sobre los torrents completados desde la última conexión al servidor Progress Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Progreso ETA Torrents list column name Tiempo restante Server Stats Dialog title Estadísticas del servidor Current session Server stats section for current Transmission launch Sesión actual Ratio Torrents list column name Ratio Total Server stats section for all Transmission launches (accumulated) Total %Ln times How many times Transmission was launched %Ln vez%Ln veces%Ln veces Size Column title in torrent's file list ---------- Torrents list column name Tamaño Limits Speed limits section ---------- Torrent's properties dialog tab Límites Alternative Limits Alternative speed limits section Límites alternativos Enable Check box label Habilitar Scheduled Title of alternative speed limit scheduling section Programado to Separates time range input fields. E.g. "to" inside "1:00 AM to 5:00 AM" a Every day Todos los días Weekdays Entre semana Weekends Fines de semana %1 of %2 (%3) Torrent's completion size, e.g. 100 MiB of 200 MiB (50%). %1 is completed size, %2 is size, %3 is progress in percents %1 de %2 (%3) Checking (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Comprobando (%L1) Honor global limits Check box label Respetar límites globales Use global settings Seeding ratio limit mode (global settings/stop at ratio/unlimited) ---------- Seeding idle limit mode (global settings/stop if idle for/unlimited) Usar ajustes globales Seed regardless of ratio Seeding ratio limit mode (global settings/stop at ratio/unlimited) Enviar independientemente de la ratio Stop seeding at ratio: Seeding ratio limit mode (global settings/stop at ratio/unlimited) Dejar de enviar en la ratio: Seed regardless of activity Seeding idle limit mode (global settings/stop if idle for/unlimited) Enviar independientemente de la actividad Stop seeding if idle for: Seeding idle limit mode (global settings/stop if idle for/unlimited) Dejar de enviar si está inactivo durante: Activity Torrent's details tab section Actividad Completed Torrents list column name, completed byte size Completado Downloaded Torrents list column name, downloaded byte size Descargado Paused (%1) Torrent status while torrent also has an error. %1 is error string En pausa (%1) Paused Torrent status En pausa Downloading (%1) Torrent status while torrent also has an error. %1 is error string Descargando (%1) Seeding (%1) Torrent status while torrent also has an error. %1 is error string Enviando (%1) Queued (%1) Torrent status while torrent also has an error. %1 is error string En cola (%1) Queued Torrent status En cola Checking (%1) Torrent status while torrent also has an error. %1 is error string Comprobando (%1) Checking Torrent status Comprobando Queued for checking (%1) Torrent status while torrent also has an error. %1 is error string En cola para comprobar (%1) Downloading to peers Torrents list column name, number of peers that we are downloading from Descargando de pares Uploading to peers Torrents list column name, number of peers that we are uploading to Subiendo a los pares Uploaded Torrents list column name, uploaded byte size Subido Seeders Torrents list column name, number of seeders reported by trackers ---------- Trackers list column title Semillas Leechers Torrents list column name, number of leechers reported by trackers ---------- Trackers list column title Sanguijuelas Information Torrent's details tab section Información Torrent Removed Message that appears when torrent is removed Torrent eliminado Edit Tracker Dialog title Editar rastreador Torrent file: Input field's label Archivo torrent: Torrent link: Input field's label Enlace torrent: Download directory: Input field's label Directorio de descarga: Torrent priority: Combo box label Prioridad del torrent: Delete .torrent file Borrar archivo .torrent Move .torrent file to trash Labels Title of torrents label filters list Loading Placeholder shown when torrent file is being read/parsed Cargando &Connect Button / menu item to connect to server &Conectar &Disconnect Button / menu item to disconnect from server &Desconectar &Add Torrent File... Menu item &Añadir archivo torrent... Add Torrent &Link... Menu item Añadir &enlace torrent... P&ause Torrent's context menu item P&ausar &Delete Torrent's context menu item &Borrar Open &Download Directory Context menu item Delete with files Borrar con archivos Delete Borrar Delete Torrent Dialog title Borrar torrent Are you sure you want to delete this torrent? ¿Estás seguro de que quieres borrar este torrent? Delete Torrents Dialog title Borrar torrents Are you sure you want to delete %Ln selected torrents? %Ln is a number of torrents selected for deletion ¿Estás seguro de que quieres borrar %Ln torrent seleccionado?¿Estás seguro de que quieres borrar %Ln torrents seleccionados?¿Estás seguro de que quieres borrar %Ln torrents seleccionados? No torrents matching filters Torrents list placeholder No hay torrents que coincidan con los filtros &Quit Menu item &Salir &Torrent Menu bar item &Torrent Error adding torrent «%1» &Properties Torrent's context menu item &Propiedades &Show Tremotesf &Hide Tremotesf &Start Torrent's context menu item &Iniciar Start &Now Torrent's context menu item Iniciar &ahora Copy &Magnet Link Torrent's context menu item Copiar &enlace magnet &Remove Server's context menu item ---------- Context menu item ---------- Tracker's context menu item &Eliminar Set &Location Torrent's context menu item Establecer &ubicación Edi&t Labels Torrent's context menu item &Open Torrent's context menu item ---------- Context menu item &Abrir Op&en Download Directory Torrent's context menu item &Check Local Data Torrent's context menu item &Comprobar datos locales Reanno&unce Torrent's context menu item ---------- Button Volver a an&unciar &Queue Torrent's context menu item &Cola Move To &Top Torrent's context menu item Mover al &principio Move &Up Torrent's context menu item &Subir Move &Down Torrent's context menu item &Bajar Move To &Bottom Torrent's context menu item Mover al &final &Connection Settings Ajustes de &conexión &Server Options Opciones del &servidor Server S&tats Es&tadísticas del servidor Select Files File chooser dialog title Seleccionar archivos Torrent Files (*.torrent) Torrent file type. Parentheses and text within them must remain unchanged Archivos torrent (*.torrent) Error Dialog title ---------- Trackers list column title Error This file/directory does not exist &File Menu bar item &Archivo &Close Window &Edit Menu bar item &Editar Select &All Seleccionar &todo &Invert Selection &Invertir selección &View Menu bar item &Vista &Toolbar &Barra de herramientas &Sidebar &Barra lateral St&atusbar Barra de est&ado Torrent properties &panel &Lock Toolbar &Bloquear barra de herramientas T&ools Menu bar item H&erramientas &Options &Opciones S&hutdown Server A&pagar servidor Shutdown Server Dialog title Apagar servidor Are you sure you want to shutdown remote Transmission instance? ¿Estás seguro de que quieres apagar la instancia remota de Transmission? Shutdown Dialog confirmation button Apagar &Help Menu bar item &Ayuda &About Menu item opening "About" dialog &Acerca de Icon Only Toolbar mode Sólo icono Text Only Toolbar mode Sólo texto Text Beside Icon Toolbar mode Texto junto al icono Text Under Icon Toolbar mode Texto bajo el icono Follow System Style Toolbar mode Seguir el estilo del sistema Torrents will be added after connection to server Message shown when user attempts to add torrent while disconnect from server. Show Tremotesf Button on notification Mostrar Tremotesf Active (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Activos (%L1) Downloading (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Descargando (%L1) Seeding (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Enviando (%L1) Paused (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status En pausa (%L1) Errored (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Con errores (%L1) Search... Search field placeholder Buscar... &Select... Context menu item to open directory chooser &Seleccionar... Overwrite Server Dialog title Sobrescribir servidor Name: Nombre: Address: Dirección: Port: Puerto: API path: Ruta de la API: Proxy Proxy HTTP HTTP proxy option HTTP None None proxy option Proxy type: Tipo de proxy: Username: Nombre de usuario: Password: Contraseña: s Suffix that is added to input field with number of seconds, e.g. "30 s" s Update interval: Intervalo de actualización: Timeout: Tiempo de espera: Auto reconnect interval: Intervalo de reconexión automática: &Edit... Server's context menu item ---------- Tracker's context menu item &Editar... Server Options Dialog title Opciones del servidor Directory for incomplete files: Directorio para archivos incompletos: min Suffix that is added to input field with number of minuts, e.g. "5 min" min Maximum active downloads: Máximo de descargas activas: Maximum active uploads: Máximo de subidas activas: Ignore queue position if idle for: Ignorar la posición en la cola si está inactivo durante: kB/s Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes kB/s Download: Download speed limit input field label Descargar: Upload: Upload speed limit input field label Subir: Days: Días: Peer port: Puerto del par: Encryption: Cifrado: Enable μTP (Micro Transport Protocol) Check box label Habilitar μTP (Micro Transport Protocol) Enable PEX (Peer exchange) Check box label Habilitar PEX (Intercambio de pares) Enable local peer discovery Check box label Habilitar búsqueda local de pares Maximum peers per torrent: Máximo de pares por torrent: Maximum peers globally: Máximo global de pares: Options Dialog title Opciones Follow system Dark theme mode Seguir sistema On Dark theme mode Encendido Off Dark theme mode Apagado Dark theme Tema oscuro Use system accent color Check box label Usar color principal del sistema Remember location of last opened torrent file Check box label Recordar ubicación del último archivo torrent abierto Remember parameters of last added torrent Check box label Recordar parámetros del último torrent añadido Show icon in the notification area Check box label Mostrar icono en el área de notificación &Not Download Context menu item to unselect file for downloading &No descargar &Priority Torrent's context menu item &Prioridad &Download Context menu item to select file for downloading &Descargar &High File loading priority &Alta &Normal File loading priority &Normal &Low File loading priority &Baja &Rename Torrent's context menu item ---------- Context menu item &Renombrar File name: Nombre de archivo: Details Torrent's properties dialog tab Detalles Completed: Torrent's completed size Completado: Downloaded: Downloaded bytes ---------- Torrent's downloaded size Descargado: Uploaded: Uploaded bytes ---------- Torrent's uploaded size Subido: Ratio: Ratio: Duration: How much time Transmission is running Duración: Started: How many times Transmission was launched Iniciado: Free space in download directory: Espacio libre en el directorio de descarga: Download speed: Velocidad de descarga: Upload speed: Velocidad de subida: ETA: Tiempo restante: Seeders: Semillas: Leechers: Sanguijuelas: Peers we are downloading from: Pares desde los que se está descargando: Web seeders we are downloading from: Semillas web desde los que se está descargando: Peers we are uploading to: Pares a los que se está subiendo: Last activity: Última actividad: Total size: Tamaño total: Location: Torrent's download directory Ubicación: Hash: Torrent's hash string Hash: Created by: Program that created torrent file Creado por: Created on: Date/time when torrent was created Creado: Comment: Torrent's comment text Comentario: Labels: Torrent's labels Web seeder Web seeders list column title Semilla web Web seeders Torrent's properties dialog tab Semillas web Seeding Options section Torrent's limits tab section Enviando Ratio limit mode: Modo límite por ratio: Idle seeding mode: Modo de envío inactivo: Maximum peers: Máximo de pares: Add Trackers Dialog title Añadir rastreadores Trackers announce URLs: URLs de anuncio de los rastreadores: Tracker announce URL: URL de anuncio del rastreador: Remove Tracker Dialog title Eliminar rastreador Are you sure you want to remove this tracker? ¿Estás seguro de que quieres eliminar este rastreador? Remove Trackers Dialog title Eliminar rastradores Are you sure you want to remove %Ln selected trackers? %Ln is number of trackers selected for deletion ¿Estás seguro de que quieres eliminar %Ln rastreador seleccionado?¿Estás seguro de que quieres eliminar %Ln rastreadores seleccionados?¿Estás seguro de que quieres eliminar %Ln rastreadores seleccionados? Down Speed Torrents list column name ---------- Peers list column title Vel. descarga Up Speed Torrents list column name ---------- Peers list column title Vel. subida Progress Bar Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Barra de progreso Flags Peers list column title Indicadores Client Peers list column title Cliente Timed out Server connection status Tiempo de espera Connection error Server connection status Error de conexión Authentication error Server connection status Error de autenticación Parse error Server connection status Error de análisis Server is too new Server connection status El servidor es demasiado nuevo Server is too old Server connection status El servidor es demasiado antiguo Connecting... Server connection status Conectando... Connected Server connection status Conectado Downloading Torrent status Torrent status Descargando Seeding Torrent status Torrent status Enviando Queued for checking Torrent status En cola para comprobar Merge trackers? Dialog title Torrent «%1» is already added, merge trackers? Merge Do not ask again Merged trackers Dialog title Merged trackers for torrent «%1» Torrent already exists Dialog title Torrent «%1» already exists Error reading torrent file Error al leer archivo torrent Error parsing torrent file Error al analizar archivo torrent Total Size Torrents list column name Tamaño total Queue Position Torrents list column name Posición en la cola Added on Torrents list column name, date/time when torrent was added Añadido el Completed on Torrents list column name, date/time when torrent was completed Completado el Down Limit Torrents list column name, download speed limit Límite inferior Up Limit Torrents list column name, upload speed limit Límite superior Remaining Torrents list column name, remaining byte size Restante Download Directory Torrents list column name Directorio de descargas Last Activity Torrents list column name Última actividad Inactive Tracker status Inactivo Waiting for update Tracker status Esperando actualización About to update Tracker status A punto de actualizar Updating Tracker status Actualizando Next Update Trackers list column title Próxima actualización %L1 B Size suffix in bytes %L1 B %L1 KiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in kibibytes %L1 KiB %L1 MiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in mebibytes %L1 MiB %L1 GiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in gibibytes %L1 GiB %L1 TiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in tebibytes %L1 TiB %L1 PiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in pebibytes %L1 PiB %L1 EiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in exbibytes %L1 EiB %L1 ZiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in zebibytes %L1 ZiB %L1 YiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in yobibytes %L1 YiB %L1 B/s Download speed suffix in bytes per second %L1 B/s %L1 KiB/s Download speed suffix in kibibytes per second %L1 KiB/s %L1 MiB/s Download speed suffix in mebibytes per second %L1 MiB/s %L1 GiB/s Download speed suffix in gibibytes per second %L1 GiB/s %L1 TiB/s Download speed suffix in tebibytes per second %L1 TiB/s %L1 PiB/s Download speed suffix in pebibytes per second %L1 PiB/s %L1 EiB/s Download speed suffix in exbibytes per second %L1 EiB/s %L1 ZiB/s Download speed suffix in zebibytes per second %L1 ZiB/s %L1 YiB/s Download speed suffix in yobibytes per second %L1 YiB/s %L1% Progress in percents. %L1 must remain unchanged, % after it is a percent character %L1% %L1 d %L2 h Remaining time string. %L1 is days, %L2 is hours, e.g. "2 d 5 h" %L1 d %L2 h %L1 h %L2 m Remaining time string. %L1 is hours, %L2 is minutes, e.g. "2 h 5 m" %L1 h %L2 m %L1 m %L2 s Remaining time string. %L1 is minutes, %L2 is seconds, e.g. "2 m 5 s" %L1 m %L2 s %L1 s Remaining time string. %L1 is seconds, "10 s" %L1 s Today Yesterday Two days ago %1 at %2 Relative date & time Just now Relative time %n minute(s) ago @item:intext %1 is a whole number %n minutes ago %n minute ago Error opening %1 File opening error, %1 is a file path ---------- Directory opening error, %1 is a file path Error al abrir %1 Move files from current directory Check box label Mover archivos desde el directorio actual Selected directory should be inside mounted directory El directorio seleccionado debe estar dentro del directorio montado All (%L1) Filter option of torrents list's tracker filter. %L1 is total number of torrents ---------- Filter option of torrents list's download directory filter. %L1 is total number of torrents ---------- Filter option of torrents list's label filter. %L1 is total number of torrents ---------- Filter option of torrents list's status filter. %L1 is total number of torrents Todo (%L1) %1 (%L2) Filter option of torrents list's tracker filter. %1 is tracker domain name, %L2 is number of torrents with that tracker ---------- Filter option of torrents list's label filter. %1 is label, %L2 is number of torrents with that label %1 (%L2) This directory does not exist Edit Labels New label... tremotesf-2.8.2/translations/fr.ts000066400000000000000000003445471500171105600172500ustar00rootroot00000000000000 tremotesf About "About" dialog title ---------- "About" dialog's "About" tab title À propos <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Source code: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Translations: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> "About" dialog text Maintainer Mainteneur Contributor Contributeur Authors "About" dialog's "Authors" tab title Auteurs Translators "About" dialog's "Translators" tab title Traducteurs License "About" dialog's "License" tab title License Add Torrent File Dialog title Ajouter un fichier torrent Free space: %1 %1 is a amount of free space in a directory, e.g. 1 GiB Espace libre : %1 Error getting free space Erreur lors de l'obtention de l'espace libre Files Torrent properties dialog tab Fichiers High Torrent's file loading priority ---------- Torrent's loading priority Haute Normal Torrent's file loading priority ---------- Torrent's loading priority Normale Low Torrent's file loading priority ---------- Torrent's loading priority Basse Start downloading after adding Commencer à télécharger après l'ajout Add Torrent Link Dialog title Ajouter un lien torrent No servers Aucun serveur Disconnected Server connection status ---------- Notification title when disconnected from server ---------- Message that appears when disconnected from server Déconnecté Downloading Noun "Downloading" server setting page Téléchargement Start added torrents Check box label Lancer les torrents ajoutés Append ".part" to names of incomplete files Check box label Ajouter ".part" aux noms de fichiers incomplets Rename Dialog title ---------- Dialog confirmation button Renommer Select Directory Directory chooser dialog title Sélectionner un répertoire Status Title of torrents status filters list ---------- Torrents list column name ---------- Trackers list column title Statut Directories Title of torrents download directory filters list Répertoires Trackers Title of torrents tracker filters list ---------- Torrent properties dialog tab Trackers Priority Column title in torrent's file list ---------- Torrents list column name Priorité Mixed Torrent's file loading priority ---------- File loading priority Mixé No torrents Torrents list placeholder Aucun torrent Remove Button ---------- Dialog confirmation button Supprimer Set Location Dialog title for changing torrent's download directory Définir l'emplacement Error adding torrent Erreur en ajoutant le torrent This torrent is already added Ce torrent est déjà ajouté Torrent added Notification title Torrent ajouté Torrent finished Notification title Torrents terminés Network "Network" server settings page Réseau Connection Title of settings section related to peer connections Connexion Random port on Transmission start Check box label Port aléatoire au lancement de Transmission Enable port forwarding Check box label Activer le transfert de port Allow Encryption mode (allow/prefer/require) Permettre Prefer Encryption mode (allow/prefer/require) Préférer Require Encryption mode (allow/prefer/require) Exiger Enable DHT Check box label Activer DHT Peer Limits Limites de pairs Peers Torrent's properties dialog tab ---------- Torrent's limits tab section ---------- Trackers list column title Pairs Queue "Queue" server settings page Queue Also delete the files on the hard disk Check box label Supprimer également les fichiers sur le disque dur Seeding Noun "Seeding" server setting page Partage Overwrite Dialog's confirmation button Écraser Server already exists Le serveur existe déjà Add Server Dialog title Ajouter un serveur Name Column title in torrent's file list ---------- Torrents list column name Nom Address Peers list column title ---------- Trackers list column title Adresse Default Default proxy option Par défaut HTTPS HTTPS SOCKS5 SOCKS5 proxy option SOCKS5 Server uses self-signed certificate Check box label Le serveur utilise un certificat auto-signé Server's certificate in PEM format Text field placeholder Certificat du serveur au format PEM Load from file... Button Charger à partir du fichier... Use client certificate authentication Check box label Utiliser l'authentification par certificat client Certificate in PEM format with private key Text field placeholder Certificat au format PEM avec clé privée Authentication Check box label Authentification Auto reconnect on error Check box label Reconnexion automatique en cas d'erreur Mounted directories Répertoires montés Local directory Column title in the list of mounted directories Répertoire local Remote directory Column title in the list of mounted directories Répertoire distant Add Button ---------- Dialog confirmation button Ajouter Speed "Speed" server settings page ---------- Torrent's limits tab section Vitesse Connection Settings Servers list placeholder Dialog title Paramètres de connexion Edit... Button Éditer... Add Server... Button Ajouter un serveur... Add... Button Ajouter... Connect to server on startup Check box label Se connecter au serveur au démarrage Adding torrents Options tab Ajouter des torrents Add torrent parameters Reset Show main window when adding torrents Check box label Show dialog when adding torrents Check box label Automatically fill link from clipboard when adding torrent link Check box label Remplir automatiquement le lien depuis le presse-papiers lors de l'ajout d'un lien torrent Tip: you can also press %1 in main window to add torrents from clipboard %1 is a key binding, e.g. "Ctrl + C" Astuce : vous pouvez également appuyer sur %1 dans la fenêtre principale pour ajouter des torrents à partir du presse-papiers Ask for merging trackers when adding existing torrent Check box label Merge trackers when adding existing torrent Check box label Open properties dialog Open torrent's file Open download directory What to do when torrent in the list is double clicked: General Options tab Show torrent properties in a panel in the main window Check box label Properties dialog won't be shown because torrent properties are shown in the main window Display relative time Check box label Display full path of download directories in sidebar and torrents list Check box label Notifications Options tab Notifications Notify when disconnecting from server Check box label Notifier lors de la déconnexion du serveur Notify on added torrents Check box label Notifier lors de l'ajout de torrents Notify on finished torrents Check box label Notifier les torrents terminés When connecting to server Notifications options section Lors de la connexion au serveur Notify on added torrents since last connection to server Check box label Notifier les torrents ajoutés depuis la dernière connexion au serveur Notify on finished torrents since last connection to server Check box label Notifier les torrents finis depuis la dernière connexion au serveur Progress Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Progression ETA Torrents list column name ETA Server Stats Dialog title Stats du serveur Current session Server stats section for current Transmission launch Session courante Ratio Torrents list column name Ratio Total Server stats section for all Transmission launches (accumulated) Total %Ln times How many times Transmission was launched %Ln fois%Ln fois%Ln fois Size Column title in torrent's file list ---------- Torrents list column name Taille Limits Speed limits section ---------- Torrent's properties dialog tab Limites Alternative Limits Alternative speed limits section Limites alternatives Enable Check box label Activer Scheduled Title of alternative speed limit scheduling section Programmé to Separates time range input fields. E.g. "to" inside "1:00 AM to 5:00 AM" vers Every day Quotidien Weekdays Hebdomadaire Weekends Weekends %1 of %2 (%3) Torrent's completion size, e.g. 100 MiB of 200 MiB (50%). %1 is completed size, %2 is size, %3 is progress in percents %1 de %2 (%3) Checking (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Vérification (%L1) Honor global limits Check box label Respecter les limites globales Use global settings Seeding ratio limit mode (global settings/stop at ratio/unlimited) ---------- Seeding idle limit mode (global settings/stop if idle for/unlimited) Utiliser les paramètres globaux Seed regardless of ratio Seeding ratio limit mode (global settings/stop at ratio/unlimited) Partager quel que soit le ratio Stop seeding at ratio: Seeding ratio limit mode (global settings/stop at ratio/unlimited) Arrêter de partager au ratio : Seed regardless of activity Seeding idle limit mode (global settings/stop if idle for/unlimited) Partager quelle que soit l'activité Stop seeding if idle for: Seeding idle limit mode (global settings/stop if idle for/unlimited) Arrêter le partage si inactif pendant : Activity Torrent's details tab section Activité Completed Torrents list column name, completed byte size Terminé Downloaded Torrents list column name, downloaded byte size Téléchargé Paused (%1) Torrent status while torrent also has an error. %1 is error string En pause (%1) Paused Torrent status En pause Downloading (%1) Torrent status while torrent also has an error. %1 is error string En téléchargement (%1) Seeding (%1) Torrent status while torrent also has an error. %1 is error string En partage (%1) Queued (%1) Torrent status while torrent also has an error. %1 is error string En file d'attente (%1) Queued Torrent status En file d'attente Checking (%1) Torrent status while torrent also has an error. %1 is error string Vérification (%1) Checking Torrent status Vérification Queued for checking (%1) Torrent status while torrent also has an error. %1 is error string En file d'attente pour vérification (%1) Downloading to peers Torrents list column name, number of peers that we are downloading from Téléchargement depuis des pairs Uploading to peers Torrents list column name, number of peers that we are uploading to Envoi vers des pairs Uploaded Torrents list column name, uploaded byte size Téléversé Seeders Torrents list column name, number of seeders reported by trackers ---------- Trackers list column title Seeders Leechers Torrents list column name, number of leechers reported by trackers ---------- Trackers list column title Leechers Information Torrent's details tab section Information Torrent Removed Message that appears when torrent is removed Torrent supprimé Edit Tracker Dialog title Éditer le tracker Torrent file: Input field's label Fichier torrent : Torrent link: Input field's label Lien torrent : Download directory: Input field's label Répertoire de téléchargement : Torrent priority: Combo box label Priorité du torrent : Delete .torrent file Supprimer le fichier .torrent Move .torrent file to trash Labels Title of torrents label filters list Loading Placeholder shown when torrent file is being read/parsed Chargement &Connect Button / menu item to connect to server &Connexion &Disconnect Button / menu item to disconnect from server &Déconnexion &Add Torrent File... Menu item &Ajouter un fichier torrent... Add Torrent &Link... Menu item Ajouter un &lien torrent P&ause Torrent's context menu item P&ause &Delete Torrent's context menu item &Supprimer Open &Download Directory Context menu item Delete with files Supprimer avec les fichiers Delete Supprimer Delete Torrent Dialog title Supprimer le torrent Are you sure you want to delete this torrent? Êtes-vous sûr de vouloir supprimer ce torrent ? Delete Torrents Dialog title Supprimer les torrents Are you sure you want to delete %Ln selected torrents? %Ln is a number of torrents selected for deletion Êtes-vous sûr de vouloir supprimer le torrent sélectionné ?Êtes-vous sûr de vouloir supprimer les %Ln torrents sélectionnés ?Êtes-vous sûr de vouloir supprimer les %Ln torrents sélectionnés ? No torrents matching filters Torrents list placeholder Aucun torrent ne correspond aux filtres &Quit Menu item &Quitter &Torrent Menu bar item &Torrent Error adding torrent «%1» &Properties Torrent's context menu item &Propriétés &Show Tremotesf &Hide Tremotesf &Start Torrent's context menu item &Démarrer Start &Now Torrent's context menu item Démarrer &maintenant Copy &Magnet Link Torrent's context menu item Copier &le lien magnet &Remove Server's context menu item ---------- Context menu item ---------- Tracker's context menu item &Supprimer Set &Location Torrent's context menu item Définir &l'emplacement Edi&t Labels Torrent's context menu item &Open Torrent's context menu item ---------- Context menu item &Ouvrir Op&en Download Directory Torrent's context menu item &Check Local Data Torrent's context menu item &Vérifier les données locales Reanno&unce Torrent's context menu item ---------- Button Ré-annoncer &Queue Torrent's context menu item &Queue Move To &Top Torrent's context menu item Déplacer en hau&t Move &Up Torrent's context menu item Déplacer vers le ha&ut Move &Down Torrent's context menu item &Déplacer vers le bas Move To &Bottom Torrent's context menu item Déplacer en &bas &Connection Settings &Paramètres de connexion &Server Options Options du &serveur Server S&tats Serveur et s&tats Select Files File chooser dialog title Sélectionner des fichiers Torrent Files (*.torrent) Torrent file type. Parentheses and text within them must remain unchanged Fichiers torrent (*.torrent) Error Dialog title ---------- Trackers list column title Erreur This file/directory does not exist &File Menu bar item &Fichier &Close Window &Edit Menu bar item &Éditer Select &All &Tout sélectionner &Invert Selection &Inverser la sélection &View Menu bar item &Vue &Toolbar Barre d'ou&tils &Sidebar &Barre latérale St&atusbar Barre de st&atut Torrent properties &panel &Lock Toolbar &Verrouiller la barre d'outils T&ools Menu bar item &Outils &Options &Options S&hutdown Server &Arrêter le serveur Shutdown Server Dialog title Arrêter le serveur Are you sure you want to shutdown remote Transmission instance? Voulez-vous vraiment arrêter l'instance distante de transmission ? Shutdown Dialog confirmation button Arrêt &Help Menu bar item &Aide &About Menu item opening "About" dialog &À propos Icon Only Toolbar mode Icône seulement Text Only Toolbar mode Texte seulement Text Beside Icon Toolbar mode Texte à côté de l'icône Text Under Icon Toolbar mode Texte sous l'icône Follow System Style Toolbar mode Suivre le style du système Torrents will be added after connection to server Message shown when user attempts to add torrent while disconnect from server. Show Tremotesf Button on notification Afficher Tremotesf Active (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Actifs (%L1) Downloading (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status En téléchargement (%L1) Seeding (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status En partage (%L1) Paused (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status En pause (%L1) Errored (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Erreur (%L1) Search... Search field placeholder Chercher... &Select... Context menu item to open directory chooser &Sélectionner... Overwrite Server Dialog title Écraser le serveur Name: Nom : Address: Adresse : Port: Port : API path: Chemin vers l'API : Proxy Proxy HTTP HTTP proxy option HTTP None None proxy option Proxy type: Type de proxy : Username: Nom d'utilisateur : Password: Mot de passe : s Suffix that is added to input field with number of seconds, e.g. "30 s" s Update interval: Intervalle de mise à jour : Timeout: Délai d'attente : Auto reconnect interval: Intervalle de reconnexion automatique : &Edit... Server's context menu item ---------- Tracker's context menu item &Éditer... Server Options Dialog title Options du serveur Directory for incomplete files: Répertoire pour les fichiers incomplets : min Suffix that is added to input field with number of minuts, e.g. "5 min" min Maximum active downloads: Limite de téléchargements actifs : Maximum active uploads: Limite de téléversements actifs : Ignore queue position if idle for: Ignorer la position de la file d'attente si inactif pendant : kB/s Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes ko/s Download: Download speed limit input field label Télécharger : Upload: Upload speed limit input field label Téléverser : Days: Jours : Peer port: Port peer : Encryption: Cryptage : Enable μTP (Micro Transport Protocol) Check box label Activer μTP (Micro Transport Protocol) Enable PEX (Peer exchange) Check box label Activer PEX (Peer exchange) Enable local peer discovery Check box label Activer la découverte des pairs locaux Maximum peers per torrent: Limite de pairs par torrent : Maximum peers globally: Limite globale de pairs : Options Dialog title Options Follow system Dark theme mode Suivre le système On Dark theme mode Activer Off Dark theme mode Désactiver Dark theme Thème sombre Use system accent color Check box label Utiliser la couleur d'accentuation du système Remember location of last opened torrent file Check box label Se souvenir de l'emplacement du dernier fichier torrent ouvert Remember parameters of last added torrent Check box label Se souvenir des paramètres du dernier torrent ajouté Show icon in the notification area Check box label Afficher l'icône dans la zone de notification &Not Download Context menu item to unselect file for downloading &Ne pas télécharger &Priority Torrent's context menu item &Priorité &Download Context menu item to select file for downloading &Télécharger &High File loading priority &Haute &Normal File loading priority &Normale &Low File loading priority &Basse &Rename Torrent's context menu item ---------- Context menu item &Renommer File name: Nom de fichier : Details Torrent's properties dialog tab Détails Completed: Torrent's completed size Terminé : Downloaded: Downloaded bytes ---------- Torrent's downloaded size Téléchargé : Uploaded: Uploaded bytes ---------- Torrent's uploaded size Téléversé : Ratio: Ratio : Duration: How much time Transmission is running Durée : Started: How many times Transmission was launched Commencé : Free space in download directory: Espace libre dans le répertoire de téléchargement : Download speed: Vitesse de téléchargement : Upload speed: Vitesse de téléversement : ETA: ETA : Seeders: Seeders : Leechers: Leechers : Peers we are downloading from: Pairs à partir desquels nous téléchargeons : Web seeders we are downloading from: Web seeders à partir desquels nous téléchargeons : Peers we are uploading to: Pairs vers lesquels nous envoyons : Last activity: Dernière activité : Total size: Taille totale : Location: Torrent's download directory Emplacement : Hash: Torrent's hash string Hashage : Created by: Program that created torrent file Créé par : Created on: Date/time when torrent was created Créé le : Comment: Torrent's comment text Commentaire : Labels: Torrent's labels Web seeder Web seeders list column title Seeder web Web seeders Torrent's properties dialog tab Seeders web Seeding Options section Torrent's limits tab section En partage Ratio limit mode: Mode de limite de ratio : Idle seeding mode: Mode de seed inactif : Maximum peers: Limite de pairs : Add Trackers Dialog title Ajouter des traqueurs Trackers announce URLs: URLs d'annonce des trackers : Tracker announce URL: URL d'annonce du tracker : Remove Tracker Dialog title Supprimer un tracker Are you sure you want to remove this tracker? Êtes-vous sûr de vouloir supprimer ce tracker ? Remove Trackers Dialog title Supprimer les Trackers Are you sure you want to remove %Ln selected trackers? %Ln is number of trackers selected for deletion Êtes-vous sûr de vouloir supprimer le tracker sélectionné ?Êtes-vous sûr de vouloir supprimer les %Ln trackers sélectionnés ?Êtes-vous sûr de vouloir supprimer les %Ln trackers sélectionnés ? Down Speed Torrents list column name ---------- Peers list column title Vitesse descendante Up Speed Torrents list column name ---------- Peers list column title Vitesse montante Progress Bar Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Barre de progression Flags Peers list column title Drapeaux Client Peers list column title Client Timed out Server connection status Délai expiré Connection error Server connection status Erreur de connexion Authentication error Server connection status Erreur d'authentification Parse error Server connection status Erreur d'analyse Server is too new Server connection status Le serveur est trop récent Server is too old Server connection status Le serveur est trop vieux Connecting... Server connection status Connexion... Connected Server connection status Connecté Downloading Torrent status Torrent status En téléchargement Seeding Torrent status Torrent status Seeding Queued for checking Torrent status En file d'attente pour vérification Merge trackers? Dialog title Torrent «%1» is already added, merge trackers? Merge Do not ask again Merged trackers Dialog title Merged trackers for torrent «%1» Torrent already exists Dialog title Torrent «%1» already exists Error reading torrent file Erreur en lisant le fichier torrent Error parsing torrent file Erreur en analysant le fichier torrent Total Size Torrents list column name Taille totale Queue Position Torrents list column name Position dans la file d'attente Added on Torrents list column name, date/time when torrent was added Ajouté le Completed on Torrents list column name, date/time when torrent was completed Terminé le Down Limit Torrents list column name, download speed limit Limite descendante Up Limit Torrents list column name, upload speed limit Limite montante Remaining Torrents list column name, remaining byte size Restant Download Directory Torrents list column name Répertoire de téléchargement Last Activity Torrents list column name Dernière activité Inactive Tracker status Inactif Waiting for update Tracker status En attente de mise à jour About to update Tracker status Mise à jour imminente Updating Tracker status Mise à jour Next Update Trackers list column title Prochaine mise à jour %L1 B Size suffix in bytes %L1 o %L1 KiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in kibibytes %L1 Kio %L1 MiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in mebibytes %L1 Mio %L1 GiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in gibibytes %L1 Gio %L1 TiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in tebibytes %L1 Tio %L1 PiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in pebibytes %L1 Pio %L1 EiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in exbibytes %L1 Eio %L1 ZiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in zebibytes %L1 Zio %L1 YiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in yobibytes %L1 Yio %L1 B/s Download speed suffix in bytes per second %L1 o/s %L1 KiB/s Download speed suffix in kibibytes per second %L1 Kio/s %L1 MiB/s Download speed suffix in mebibytes per second %L1 Mio/s %L1 GiB/s Download speed suffix in gibibytes per second %L1 Gio/s %L1 TiB/s Download speed suffix in tebibytes per second %L1 Tio/s %L1 PiB/s Download speed suffix in pebibytes per second %L1 Pio/s %L1 EiB/s Download speed suffix in exbibytes per second %L1 Eio/s %L1 ZiB/s Download speed suffix in zebibytes per second %L1 Zio/s %L1 YiB/s Download speed suffix in yobibytes per second %L1 Yio/s %L1% Progress in percents. %L1 must remain unchanged, % after it is a percent character %L1% %L1 d %L2 h Remaining time string. %L1 is days, %L2 is hours, e.g. "2 d 5 h" %L1 j %L2 h %L1 h %L2 m Remaining time string. %L1 is hours, %L2 is minutes, e.g. "2 h 5 m" %L1 h %L2 m %L1 m %L2 s Remaining time string. %L1 is minutes, %L2 is seconds, e.g. "2 m 5 s" %L1 m %L2 s %L1 s Remaining time string. %L1 is seconds, "10 s" %L1 s Today Yesterday Two days ago %1 at %2 Relative date & time Just now Relative time %n minute(s) ago @item:intext %1 is a whole number %n minutes ago %n minute ago Error opening %1 File opening error, %1 is a file path ---------- Directory opening error, %1 is a file path Erreur en ouvrant %1 Move files from current directory Check box label Déplacer les fichiers du répertoire actuel Selected directory should be inside mounted directory Le répertoire sélectionné doit être à l'intérieur du répertoire monté All (%L1) Filter option of torrents list's tracker filter. %L1 is total number of torrents ---------- Filter option of torrents list's download directory filter. %L1 is total number of torrents ---------- Filter option of torrents list's label filter. %L1 is total number of torrents ---------- Filter option of torrents list's status filter. %L1 is total number of torrents Tous (%L1) %1 (%L2) Filter option of torrents list's tracker filter. %1 is tracker domain name, %L2 is number of torrents with that tracker ---------- Filter option of torrents list's label filter. %1 is label, %L2 is number of torrents with that label %1 (%L2) This directory does not exist Edit Labels New label... tremotesf-2.8.2/translations/it_IT.ts000066400000000000000000003433331500171105600176410ustar00rootroot00000000000000 tremotesf About "About" dialog title ---------- "About" dialog's "About" tab title Informazioni <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Source code: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Translations: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> "About" dialog text Maintainer Manutentori Contributor Contributore Authors "About" dialog's "Authors" tab title Autori Translators "About" dialog's "Translators" tab title Traduttori License "About" dialog's "License" tab title Licenza Add Torrent File Dialog title Aggiungi file torrent Free space: %1 %1 is a amount of free space in a directory, e.g. 1 GiB Spazio libero: %1 Error getting free space Errore nel calcolo dello spazio libero Files Torrent properties dialog tab File High Torrent's file loading priority ---------- Torrent's loading priority Alta Normal Torrent's file loading priority ---------- Torrent's loading priority Normale Low Torrent's file loading priority ---------- Torrent's loading priority Bassa Start downloading after adding Comincia il download dopo l'aggiunta Add Torrent Link Dialog title Aggiungi torrent tramite collegamento No servers Nessun server Disconnected Server connection status ---------- Notification title when disconnected from server ---------- Message that appears when disconnected from server Disconnesso Downloading Noun "Downloading" server setting page In scaricamento Start added torrents Check box label Inzia i torrent aggiunti Append ".part" to names of incomplete files Check box label Aggiungi ".part" al nome dei file incompleti Rename Dialog title ---------- Dialog confirmation button Rinomina Select Directory Directory chooser dialog title Scegli la cartella Status Title of torrents status filters list ---------- Torrents list column name ---------- Trackers list column title Stato Directories Title of torrents download directory filters list Cartelle Trackers Title of torrents tracker filters list ---------- Torrent properties dialog tab Server traccia Priority Column title in torrent's file list ---------- Torrents list column name Priorità Mixed Torrent's file loading priority ---------- File loading priority Misto No torrents Torrents list placeholder Nessun torrent Remove Button ---------- Dialog confirmation button Rimuovi Set Location Dialog title for changing torrent's download directory Scegli la destinazione Error adding torrent Errore nell'aggiunta del torrent This torrent is already added Questo torrent è già stato aggiunto Torrent added Notification title Torrent aggiunto Torrent finished Notification title Torrent completato Network "Network" server settings page Rete Connection Title of settings section related to peer connections Connessione Random port on Transmission start Check box label Porta casuale all'apertura di Transmission Enable port forwarding Check box label Abilita l'apertura delle porte Allow Encryption mode (allow/prefer/require) Permetti Prefer Encryption mode (allow/prefer/require) Preferisci Require Encryption mode (allow/prefer/require) Richiesta Enable DHT Check box label Abilita DHT Peer Limits Limiti del peer Peers Torrent's properties dialog tab ---------- Torrent's limits tab section ---------- Trackers list column title Peer Queue "Queue" server settings page Coda Also delete the files on the hard disk Check box label Elimina anche i file nel disco Seeding Noun "Seeding" server setting page In seeding Overwrite Dialog's confirmation button Sovrascrivi Server already exists Il server già esiste Add Server Dialog title Aggiungi server Name Column title in torrent's file list ---------- Torrents list column name Nome Address Peers list column title ---------- Trackers list column title Indirizzo Default Default proxy option Predefinito HTTPS HTTPS SOCKS5 SOCKS5 proxy option SOCKS5 Server uses self-signed certificate Check box label Il server usa un certificato auto firmato Server's certificate in PEM format Text field placeholder Certificato del server in formato PEM Load from file... Button Carica da file... Use client certificate authentication Check box label Usa l'autenticazione tramite certificato client Certificate in PEM format with private key Text field placeholder Certificato in formato PEM con chiave privata Authentication Check box label Autenticazione Auto reconnect on error Check box label Riconnessione automatica in caso di errore Mounted directories Cartelle montate Local directory Column title in the list of mounted directories Cartella locale Remote directory Column title in the list of mounted directories Cartella remota Add Button ---------- Dialog confirmation button Aggiungi Speed "Speed" server settings page ---------- Torrent's limits tab section Velocità Connection Settings Servers list placeholder Dialog title Impostazioni di connessione Edit... Button Modifica... Add Server... Button Aggiungi server... Add... Button Aggiungi Connect to server on startup Check box label Connetti al server all'avvio Adding torrents Options tab Aggiungi torrent Add torrent parameters Reset Show main window when adding torrents Check box label Show dialog when adding torrents Check box label Automatically fill link from clipboard when adding torrent link Check box label Riempi automaticamente il collegamento dagli appunti quando aggiungi un collegamento torrent Tip: you can also press %1 in main window to add torrents from clipboard %1 is a key binding, e.g. "Ctrl + C" Suggerimento: puoi anche premere %1 nella finestra principale per aggiungere torrent dagli appunti Ask for merging trackers when adding existing torrent Check box label Merge trackers when adding existing torrent Check box label Open properties dialog Open torrent's file Open download directory What to do when torrent in the list is double clicked: General Options tab Show torrent properties in a panel in the main window Check box label Properties dialog won't be shown because torrent properties are shown in the main window Display relative time Check box label Display full path of download directories in sidebar and torrents list Check box label Notifications Options tab Notifiche Notify when disconnecting from server Check box label Notifica quando disconnesso dal server Notify on added torrents Check box label Notifica all'aggiunta dei torrent Notify on finished torrents Check box label Notifica alla fine del torrent When connecting to server Notifications options section Quando connette al server Notify on added torrents since last connection to server Check box label Notifica all'aggiunta dei torrent dall'ultima connessione al serverr Notify on finished torrents since last connection to server Check box label Notifica alla fine dei torrent dall'ultima connessione al server Progress Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Progresso ETA Torrents list column name ETA Server Stats Dialog title Statistiche del server Current session Server stats section for current Transmission launch Sessione corrente Ratio Torrents list column name Rapporto Total Server stats section for all Transmission launches (accumulated) Totale %Ln times How many times Transmission was launched %Ln volta%Ln volte%Ln volte Size Column title in torrent's file list ---------- Torrents list column name Dimensione Limits Speed limits section ---------- Torrent's properties dialog tab Limiti Alternative Limits Alternative speed limits section Limiti alternativi Enable Check box label Abilita Scheduled Title of alternative speed limit scheduling section Schedula to Separates time range input fields. E.g. "to" inside "1:00 AM to 5:00 AM" a Every day Ogni giorno Weekdays Giorni della settimana (lunedì - venerdì) Weekends Fine settimana (sabato - domenica) %1 of %2 (%3) Torrent's completion size, e.g. 100 MiB of 200 MiB (50%). %1 is completed size, %2 is size, %3 is progress in percents %1 di %2 (%3) Checking (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Controllo (%L1) Honor global limits Check box label Segui i limiti globali Use global settings Seeding ratio limit mode (global settings/stop at ratio/unlimited) ---------- Seeding idle limit mode (global settings/stop if idle for/unlimited) Usa impostazioni globali Seed regardless of ratio Seeding ratio limit mode (global settings/stop at ratio/unlimited) Continua il seed ignorando il rapporto Stop seeding at ratio: Seeding ratio limit mode (global settings/stop at ratio/unlimited) Ferma il seed al rapporto: Seed regardless of activity Seeding idle limit mode (global settings/stop if idle for/unlimited) Continua il seed ignorando l'attività Stop seeding if idle for: Seeding idle limit mode (global settings/stop if idle for/unlimited) Ferma il seed se inattivo per: Activity Torrent's details tab section Attività Completed Torrents list column name, completed byte size Completati Downloaded Torrents list column name, downloaded byte size Scaricato Paused (%1) Torrent status while torrent also has an error. %1 is error string In pausa (%1) Paused Torrent status In pausa Downloading (%1) Torrent status while torrent also has an error. %1 is error string Download (%1) Seeding (%1) Torrent status while torrent also has an error. %1 is error string In seeding (%1) Queued (%1) Torrent status while torrent also has an error. %1 is error string In coda (%1) Queued Torrent status In coda Checking (%1) Torrent status while torrent also has an error. %1 is error string Controllo in corso (%1) Checking Torrent status Controllo in corso Queued for checking (%1) Torrent status while torrent also has an error. %1 is error string In coda per il controllo (%1) Downloading to peers Torrents list column name, number of peers that we are downloading from Download ai peer Uploading to peers Torrents list column name, number of peers that we are uploading to Caricamento su peer Uploaded Torrents list column name, uploaded byte size Caricato Seeders Torrents list column name, number of seeders reported by trackers ---------- Trackers list column title Seed Leechers Torrents list column name, number of leechers reported by trackers ---------- Trackers list column title Leech Information Torrent's details tab section Informazioni Torrent Removed Message that appears when torrent is removed Torrent rimosso Edit Tracker Dialog title Modifica server traccia Torrent file: Input field's label File torrent: Torrent link: Input field's label Collegamento del torrent: Download directory: Input field's label Cartella di Download Torrent priority: Combo box label Priorità del torrent: Delete .torrent file Elimina file .torrent Move .torrent file to trash Sposta il file .torrent nel cestino Labels Title of torrents label filters list Loading Placeholder shown when torrent file is being read/parsed Caricamento in corso &Connect Button / menu item to connect to server &Connetti &Disconnect Button / menu item to disconnect from server &Disconnetti &Add Torrent File... Menu item &Aggiungi file torrent Add Torrent &Link... Menu item Aggiungi &link torrent P&ause Torrent's context menu item P&ausa &Delete Torrent's context menu item &Elimina Open &Download Directory Context menu item Delete with files Elimina i file Delete Elimina Delete Torrent Dialog title Elimina Torrent Are you sure you want to delete this torrent? Sicuri di voler eliminare questo torrent? Delete Torrents Dialog title Elimina torrent Are you sure you want to delete %Ln selected torrents? %Ln is a number of torrents selected for deletion Sei sicuro di voler eliminare %Ln torrent selezionato?Sei sicuro di voler eliminare %Ln torrent selezionati?Sei sicuro di voler eliminare %Ln torrent selezionati? No torrents matching filters Torrents list placeholder Nessun torrent corrispondente ai filtri &Quit Menu item &Esci &Torrent Menu bar item &Torrent Error adding torrent «%1» &Properties Torrent's context menu item &Proprietà &Show Tremotesf &Hide Tremotesf &Start Torrent's context menu item &Inizia Start &Now Torrent's context menu item Inizia &Ora Copy &Magnet Link Torrent's context menu item Copia collegamento &Magnet &Remove Server's context menu item ---------- Context menu item ---------- Tracker's context menu item &Rimuovi Set &Location Torrent's context menu item Sceg&li la destinazione Edi&t Labels Torrent's context menu item &Open Torrent's context menu item ---------- Context menu item A&pri Op&en Download Directory Torrent's context menu item &Check Local Data Torrent's context menu item &Controlla i dati scaricati Reanno&unce Torrent's context menu item ---------- Button Riann&uncia &Queue Torrent's context menu item Co&da Move To &Top Torrent's context menu item Sposta all'inizio Move &Up Torrent's context menu item Sposta su Move &Down Torrent's context menu item Sposta giù Move To &Bottom Torrent's context menu item Sposta alla fine &Connection Settings &Impostazioni di connessione &Server Options Opzioni &Server Server S&tats S&tatistiche del server Select Files File chooser dialog title Scegli i file Torrent Files (*.torrent) Torrent file type. Parentheses and text within them must remain unchanged File Torrent (*.torrent) Error Dialog title ---------- Trackers list column title Errore This file/directory does not exist &File Menu bar item &File &Close Window &Edit Menu bar item &Modifica Select &All Scegli Tutto &Invert Selection &Inverti Selezione &View Menu bar item &Vista &Toolbar &Barra degli strumenti &Sidebar Bar&ra Laterale St&atusbar Barra di stato Torrent properties &panel &Lock Toolbar Blocca la barra degli strumenti T&ools Menu bar item S&trumenti &Options &Opzioni S&hutdown Server S&pegnimento del server Shutdown Server Dialog title Spegnimento del server Are you sure you want to shutdown remote Transmission instance? Sei sicuro di voler spegnere l'istanza di trasmissione remota? Shutdown Dialog confirmation button Spegnimento &Help Menu bar item Aiuto &About Menu item opening "About" dialog Informazioni... Icon Only Toolbar mode Solo Icone Text Only Toolbar mode Solo Testo Text Beside Icon Toolbar mode Testo a fianco dell'icona Text Under Icon Toolbar mode Testo sotto l'icona Follow System Style Toolbar mode Utilizza il tema di sistema Torrents will be added after connection to server Message shown when user attempts to add torrent while disconnect from server. Show Tremotesf Button on notification Mostra Tremotesf Active (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Attivi (%L1) Downloading (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status In scaricamento (%L1) Seeding (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status In seeding (%L1) Paused (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status In pausa (%L1) Errored (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status In errore (%L1) Search... Search field placeholder Cerca... &Select... Context menu item to open directory chooser Scegli... Overwrite Server Dialog title Sovrascrivi server Name: Nome: Address: Indirizzo: Port: Porta: API path: Percorso API: Proxy Proxy HTTP HTTP proxy option HTTP None None proxy option Proxy type: Tipo di proxy: Username: Nome utente: Password: Password: s Suffix that is added to input field with number of seconds, e.g. "30 s" s Update interval: Intervallo di aggiornamento: Timeout: Timeout: Auto reconnect interval: Intervallo di riconnessione automatica: &Edit... Server's context menu item ---------- Tracker's context menu item &Modifica... Server Options Dialog title Opzioni Server Directory for incomplete files: Cartella dei file incompleti: min Suffix that is added to input field with number of minuts, e.g. "5 min" minuti Maximum active downloads: N° massimo di download attivi: Maximum active uploads: N° massimo di upload attivi: Ignore queue position if idle for: Ignora la coda se è fermo per: kB/s Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes kB/s Download: Download speed limit input field label Scaricato: Upload: Upload speed limit input field label Caricato: Days: Giorni: Peer port: Porta del peer: Encryption: Crittografia: Enable μTP (Micro Transport Protocol) Check box label Abilita μTP (Micro Transport Protocol) Enable PEX (Peer exchange) Check box label Abilita PEX (scambio peer) Enable local peer discovery Check box label Abilita l'individuazione peer locale Maximum peers per torrent: Peer massimi per torrent: Maximum peers globally: Peer massimi globali: Options Dialog title Opzioni Follow system Dark theme mode Segui sistema On Dark theme mode Acceso Off Dark theme mode Spento Dark theme Tema scuro Use system accent color Check box label Usa il colore principale del sistema Remember location of last opened torrent file Check box label Ricorda il percorso dell'ultimo file torrent aperto Remember parameters of last added torrent Check box label Ricorda i parametri dell'ultimo file torrent aggiunto Show icon in the notification area Check box label Mostra l'icona nell'area di notifica &Not Download Context menu item to unselect file for downloading Non Scaricato &Priority Torrent's context menu item Priorità &Download Context menu item to select file for downloading &Download &High File loading priority Alta &Normal File loading priority Normale &Low File loading priority Bassa &Rename Torrent's context menu item ---------- Context menu item Rinomina File name: Nome del file: Details Torrent's properties dialog tab Dettagli Completed: Torrent's completed size Completati: Downloaded: Downloaded bytes ---------- Torrent's downloaded size Scaricati: Uploaded: Uploaded bytes ---------- Torrent's uploaded size Caricati: Ratio: Rapporto: Duration: How much time Transmission is running Durata: Started: How many times Transmission was launched Iniziato il: Free space in download directory: Spazio libero nella directory di download: Download speed: Velocità di scaricamento: Upload speed: Velocità di caricamento: ETA: Tempo di attesa stimato: Seeders: Seeders: Leechers: Leechers: Peers we are downloading from: Peer da cui stiamo scaricando: Web seeders we are downloading from: Web seeder che stiamo scaricando da: Peers we are uploading to: Peer su cui stiamo caricando: Last activity: Ultima attività: Total size: Dimensione totale: Location: Torrent's download directory Posizione: Hash: Torrent's hash string Hash: Created by: Program that created torrent file Creato da: Created on: Date/time when torrent was created Creato il: Comment: Torrent's comment text Commento: Labels: Torrent's labels Web seeder Web seeders list column title Seeder web Web seeders Torrent's properties dialog tab Seeder web Seeding Options section Torrent's limits tab section In seeding Ratio limit mode: Modalità limite rapporto: Idle seeding mode: Modalità seeding quando inutilizzato: Maximum peers: Peer massimi: Add Trackers Dialog title Aggiungi server traccia Trackers announce URLs: URL dei server traccia: Tracker announce URL: URL del server traccia: Remove Tracker Dialog title Rimuovi server traccia Are you sure you want to remove this tracker? Sicuro di voler rimuovere questo server traccia? Remove Trackers Dialog title Rimuovi server traccia Are you sure you want to remove %Ln selected trackers? %Ln is number of trackers selected for deletion Sei sicuro di voler rimuovere %Ln tracker selezionato?Sei sicuro di voler rimuovere %Ln tracker selezionati?Sei sicuro di voler rimuovere %Ln tracker selezionati? Down Speed Torrents list column name ---------- Peers list column title Velocità di scaricamento Up Speed Torrents list column name ---------- Peers list column title Velocità di caricamento Progress Bar Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Barra di progressione Flags Peers list column title Opzioni Client Peers list column title Client Timed out Server connection status Tempo esaurito Connection error Server connection status Errore di connessione Authentication error Server connection status Errore di autenticazione Parse error Server connection status Errore di lettura Server is too new Server connection status Il server è troppo recente Server is too old Server connection status Il server è troppo vecchio Connecting... Server connection status Connessione in corso... Connected Server connection status Connesso Downloading Torrent status Torrent status In scaricamento Seeding Torrent status Torrent status In seeding Queued for checking Torrent status In coda per il controllo Merge trackers? Dialog title Torrent «%1» is already added, merge trackers? Merge Do not ask again Merged trackers Dialog title Merged trackers for torrent «%1» Torrent already exists Dialog title Torrent «%1» already exists Error reading torrent file Errore nella lettura del file torrent Error parsing torrent file Errore nella lettura del file torrent Total Size Torrents list column name Dimensione Totale Queue Position Torrents list column name Posizione in coda Added on Torrents list column name, date/time when torrent was added Aggiunto il Completed on Torrents list column name, date/time when torrent was completed Completato il Down Limit Torrents list column name, download speed limit Limite di scaricamento Up Limit Torrents list column name, upload speed limit Limite di caricamento Remaining Torrents list column name, remaining byte size Rimanente Download Directory Torrents list column name Cartella di Download Last Activity Torrents list column name Ultima attività Inactive Tracker status Inattivo Waiting for update Tracker status In attesa di aggiornamento About to update Tracker status In procinto di aggiornare Updating Tracker status In aggiornamento Next Update Trackers list column title Prossimo Aggiornamento %L1 B Size suffix in bytes %L1 B %L1 KiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in kibibytes %L1 KiB %L1 MiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in mebibytes %L1 MiB %L1 GiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in gibibytes %L1 GiB %L1 TiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in tebibytes %L1 TiB %L1 PiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in pebibytes %L1 PiB %L1 EiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in exbibytes %L1 EiB %L1 ZiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in zebibytes %L1 ZiB %L1 YiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in yobibytes %L1 YiB %L1 B/s Download speed suffix in bytes per second %L1 B/s %L1 KiB/s Download speed suffix in kibibytes per second %L1 KiB/s %L1 MiB/s Download speed suffix in mebibytes per second %L1 MiB/s %L1 GiB/s Download speed suffix in gibibytes per second %L1 GiB/s %L1 TiB/s Download speed suffix in tebibytes per second %L1 TiB/s %L1 PiB/s Download speed suffix in pebibytes per second %L1 PiB/s %L1 EiB/s Download speed suffix in exbibytes per second %L1 EiB/s %L1 ZiB/s Download speed suffix in zebibytes per second %L1 ZiB/s %L1 YiB/s Download speed suffix in yobibytes per second %L1 YiB/s %L1% Progress in percents. %L1 must remain unchanged, % after it is a percent character %L1% %L1 d %L2 h Remaining time string. %L1 is days, %L2 is hours, e.g. "2 d 5 h" %L1 g %L2 o %L1 h %L2 m Remaining time string. %L1 is hours, %L2 is minutes, e.g. "2 h 5 m" %L1 o %L2 m %L1 m %L2 s Remaining time string. %L1 is minutes, %L2 is seconds, e.g. "2 m 5 s" %L1 m %L2 s %L1 s Remaining time string. %L1 is seconds, "10 s" %L1 s Today Yesterday Two days ago %1 at %2 Relative date & time Just now Relative time %n minute(s) ago @item:intext %1 is a whole number %n minutes ago %n minute ago Error opening %1 File opening error, %1 is a file path ---------- Directory opening error, %1 is a file path Errore nell'apertura di %1 Move files from current directory Check box label Sposta i file dalla cartella corrente Selected directory should be inside mounted directory La cartella selezionata dovrebbe essere all'interno della cartella montata All (%L1) Filter option of torrents list's tracker filter. %L1 is total number of torrents ---------- Filter option of torrents list's download directory filter. %L1 is total number of torrents ---------- Filter option of torrents list's label filter. %L1 is total number of torrents ---------- Filter option of torrents list's status filter. %L1 is total number of torrents Tutto (%L1) %1 (%L2) Filter option of torrents list's tracker filter. %1 is tracker domain name, %L2 is number of torrents with that tracker ---------- Filter option of torrents list's label filter. %1 is label, %L2 is number of torrents with that label %1 (%L2) This directory does not exist Edit Labels New label... tremotesf-2.8.2/translations/nl.ts000066400000000000000000003277701500171105600172510ustar00rootroot00000000000000 tremotesf About "About" dialog title ---------- "About" dialog's "About" tab title Over <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Source code: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Translations: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> "About" dialog text Maintainer Contributor Authors "About" dialog's "Authors" tab title Auteurs Translators "About" dialog's "Translators" tab title Vertalers License "About" dialog's "License" tab title Licentie Add Torrent File Dialog title Torrentbestand toevoegen Free space: %1 %1 is a amount of free space in a directory, e.g. 1 GiB Vrije ruimte: %1 Error getting free space Fout bij verkrijgen van vrije ruimte Files Torrent properties dialog tab Bestanden High Torrent's file loading priority ---------- Torrent's loading priority Hoog Normal Torrent's file loading priority ---------- Torrent's loading priority Normaal Low Torrent's file loading priority ---------- Torrent's loading priority Laag Start downloading after adding Downloaden starten na toevoegen Add Torrent Link Dialog title Torrentverwijzing toevoegen No servers Servers list placeholder Geen servers Disconnected Server connection status ---------- Notification title when disconnected from server ---------- Message that appears when disconnected from server Verbinding verbroken Downloading Noun "Downloading" server setting page Downloaden Start added torrents Check box label Toegevoegde torrents starten Append ".part" to names of incomplete files Check box label ".part" toevoegen aan naam van onvolledige bestanden Rename Dialog title ---------- Dialog confirmation button Hernoemen Select Directory Directory chooser dialog title Selecteer map Status Title of torrents status filters list ---------- Torrents list column name ---------- Trackers list column title Status Directories Title of torrents download directory filters list Mappen Trackers Title of torrents tracker filters list ---------- Torrent properties dialog tab Trackers Priority Column title in torrent's file list ---------- Torrents list column name Prioriteit Mixed Torrent's file loading priority ---------- File loading priority Gemengd No torrents Torrents list placeholder Geen torrents Remove Button ---------- Dialog confirmation button Verwijderen Set Location Dialog title for changing torrent's download directory Locatie instellen Error adding torrent Fout bij toevoegen van torrent This torrent is already added Deze torrent is al toegevoegd Torrent added Notification title Torrent toegevoegd Torrent finished Notification title Torrent voltooid Network "Network" server settings page Netwerk Connection Title of settings section related to peer connections ---------- Options section Verbinding Random port on Transmission start Check box label Willekeurige poort bij opstarten van Transmission Enable port forwarding Check box label Poort-doorsturen inschakelen Allow Encryption mode (allow/prefer/require) Toestaan Prefer Encryption mode (allow/prefer/require) Verkiezen Require Encryption mode (allow/prefer/require) Vereisen Enable DHT Check box label DHT inschakelen Peer Limits Peerlimieten Peers Torrent's properties dialog tab ---------- Torrent's limits tab section ---------- Trackers list column title Peers Queue "Queue" server settings page Wachtrij Also delete the files on the hard disk Check box label Bestanden op harde schijf ook verwijderen Seeding Noun "Seeding" server setting page Seeden Overwrite Dialog's confirmation button Overschrijven Server already exists Server bestaat al Add Server Dialog title Server toevoegen Name Column title in torrent's file list ---------- Torrents list column name Naam Address Peers list column title ---------- Trackers list column title Adres Default Default proxy option Standaard HTTPS HTTPS SOCKS5 SOCKS5 proxy option SOCKS5 Server uses self-signed certificate Check box label Server gebruikt zelfondertekend certificaat Server's certificate in PEM format Text field placeholder Servercertificaat in PEM-formaat Load from file... Button Use client certificate authentication Check box label Cliëntcertificaatauthenticatie gebruiken Certificate in PEM format with private key Text field placeholder Certificaat in PEM-formaat met privésleutel Authentication Check box label Authenticatie Auto reconnect on error Check box label Mounted directories Aangekoppelde mappen Local directory Column title in the list of mounted directories Lokale map Remote directory Column title in the list of mounted directories Externe map Add Button ---------- Dialog confirmation button Toevoegen Speed "Speed" server settings page ---------- Torrent's limits tab section Snelheid Connection Settings Dialog title Edit... Button Bewerken... Add Server... Button Add... Button Toevoegen... Connect to server on startup Check box label Verbinden met server bij opstarten Adding torrents Options section Add torrent parameters Reset Show main window when adding torrents Check box label Show dialog when adding torrents Check box label Automatically fill link from clipboard when adding torrent link Check box label Tip: you can also press %1 in main window to add torrents from clipboard %1 is a key binding, e.g. "Ctrl + C" Ask for merging trackers when adding existing torrent Check box label Merge trackers when adding existing torrent Check box label Other behaviour Options section Open properties dialog Open torrent's file Open download directory What to do when torrent in the list is double clicked: Notifications Options section Meldingen Notify when disconnecting from server Check box label Melden bij verbreken van verbinding met server Notify on added torrents Check box label Melden bij toegevoegde torrents Notify on finished torrents Check box label Melden bij voltooide torrents When connecting to server Options section Bij verbinden met server Notify on added torrents since last connection to server Check box label Melden bij toegevoegde torrents sinds laatste verbinding met server Notify on finished torrents since last connection to server Check box label Melden bij voltooide torrents sinds laatste verbinding met server Progress Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Voortgang ETA Torrents list column name Resterende tijd Server Stats Dialog title Serverstatistieken Current session Server stats section for current Transmission launch Huidige sessie Ratio Torrents list column name Verhouding Total Server stats section for all Transmission launches (accumulated) Totaal %Ln times How many times Transmission was launched %Ln keer%Ln keer Size Column title in torrent's file list ---------- Torrents list column name Grootte Limits Speed limits section ---------- Torrent's properties dialog tab Limieten Alternative Limits Alternative speed limits section Alternatieve limieten Enable Check box label Inschakelen Scheduled Title of alternative speed limit scheduling section Gepland to Separates time range input fields. E.g. "to" inside "1:00 AM to 5:00 AM" tot Every day Elke dag Weekdays Weekdagen Weekends Week-ends %1 of %2 (%3) Torrent's completion size, e.g. 100 MiB of 200 MiB (50%). %1 is completed size, %2 is size, %3 is progress in percents %1 van %2 (%3) Checking (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Controleren (%L1) Honor global limits Check box label Algemene limieten aanhouden Use global settings Seeding ratio limit mode (global settings/stop at ratio/unlimited) ---------- Seeding idle limit mode (global settings/stop if idle for/unlimited) Algemene instellingen gebruiken Seed regardless of ratio Seeding ratio limit mode (global settings/stop at ratio/unlimited) Seeden ongeacht verhouding Stop seeding at ratio: Seeding ratio limit mode (global settings/stop at ratio/unlimited) Seeden stoppen bij verhouding: Seed regardless of activity Seeding idle limit mode (global settings/stop if idle for/unlimited) Seeden ongeacht activiteit Stop seeding if idle for: Seeding idle limit mode (global settings/stop if idle for/unlimited) Seeden stoppen indien niet-actief voor: Activity Torrent's details tab section Activiteit Completed Torrents list column name, completed byte size Voltooid Downloaded Torrents list column name, downloaded byte size Gedownload Paused (%1) Torrent status while torrent also has an error. %1 is error string Paused Torrent status Downloading (%1) Torrent status while torrent also has an error. %1 is error string Seeding (%1) Torrent status while torrent also has an error. %1 is error string Queued (%1) Torrent status while torrent also has an error. %1 is error string Queued Torrent status Checking (%1) Torrent status while torrent also has an error. %1 is error string Checking Torrent status Queued for checking (%1) Torrent status while torrent also has an error. %1 is error string Downloading to peers Torrents list column name, number of peers that we are downloading from Uploading to peers Torrents list column name, number of peers that we are uploading to Uploaded Torrents list column name, uploaded byte size Geüpload Seeders Torrents list column name, number of seeders reported by trackers ---------- Trackers list column title Seeders Leechers Torrents list column name, number of leechers reported by trackers ---------- Trackers list column title Leechers Information Torrent's details tab section Informatie Torrent Removed Message that appears when torrent is removed Torrent verwijderd Edit Tracker Dialog title Tracker bewerken Torrent file: Input field's label Torrentbestand: Torrent link: Input field's label Torrentverwijzing: Download directory: Input field's label Downloadmap: Torrent priority: Combo box label Torrentprioriteit: Delete .torrent file Move .torrent file to trash Loading Placeholder shown when torrent file is being read/parsed &Connect Button / menu item to connect to server &Verbinden &Disconnect Button / menu item to disconnect from server Verbin&ding verbreken &Add Torrent File... Menu item Torrentbest&and toevoegen... Add Torrent &Link... Menu item Torrentver&wijzing toevoegen... P&ause Torrent's context menu item P&auzeren &Delete Torrent's context menu item Open &Download Directory Context menu item Delete with files Delete Delete Torrent Dialog title Are you sure you want to delete this torrent? Delete Torrents Dialog title Are you sure you want to delete %Ln selected torrents? %Ln is a number of torrents selected for deletion No torrents matching filters Torrents list placeholder &Quit Menu item Af&sluiten &Torrent Menu bar item &Torrent Error adding torrent «%1» Torrents will be added after connection to server: Message shown when user attempts to add torrent while disconnect from server. After that will be list of added torrents &Properties Torrent's context menu item &Eigenschappen &Show Tremotesf &Hide Tremotesf &Start Torrent's context menu item &Starten Start &Now Torrent's context menu item &Nu starten Copy &Magnet Link Torrent's context menu item &Magnetlink kopiëren &Remove Server's context menu item ---------- Context menu item ---------- Tracker's context menu item Ve&rwijderen Set &Location Torrent's context menu item &Locatie instellen &Open Torrent's context menu item ---------- Context menu item &Openen Op&en Download Directory Torrent's context menu item &Check Local Data Torrent's context menu item Lokale gegevens &controleren Reanno&unce Torrent's context menu item ---------- Button Opnie&uw aankondigen &Queue Torrent's context menu item &Wachtrij Move To &Top Torrent's context menu item Verplaatsen naar &boven Move &Up Torrent's context menu item &Omhoog verplaatsen Move &Down Torrent's context menu item &Omlaag verplaatsen Move To &Bottom Torrent's context menu item Verplaatsen naar &beneden &Connection Settings &Server Options &Serveropties Server S&tats Servers&tatistieken Select Files File chooser dialog title Selecteer bestanden Torrent Files (*.torrent) Torrent file type. Parentheses and text within them must remain unchanged Torrentbestanden (*.torrent) Error Dialog title ---------- Trackers list column title Fout This file/directory does not exist &File Menu bar item &Bestand &Close Window &Edit Menu bar item B&ewerken Select &All &Alles selecteren &Invert Selection Select&ie omkeren &View Menu bar item Weerga&ve &Toolbar &Werkbalk &Sidebar &Zijbalk St&atusbar St&atusbalk &Lock Toolbar T&ools Menu bar item &Gereedschap &Options &Opties S&hutdown Server Shutdown Server Dialog title Are you sure you want to shutdown remote Transmission instance? Shutdown Dialog confirmation button &Help Menu bar item &Hulp &About Menu item opening "About" dialog &Info Icon Only Toolbar mode Enkel pictogram Text Only Toolbar mode Enkel tekst Text Beside Icon Toolbar mode Tekst naast pictogram Text Under Icon Toolbar mode Tekst onder pictogram Follow System Style Toolbar mode Systeemstijl volgen Show Tremotesf Button on notification Tremotesf tonen Active (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Actief (%L1) Downloading (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Downloaden (%L1) Seeding (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Seeden (%L1) Paused (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Gepauzeerd (%L1) Errored (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Fouten (%L1) Search... Search field placeholder Zoeken... &Select... Context menu item to open directory chooser &Selecteren… Overwrite Server Dialog title Server overschrijven Name: Naam: Address: Adres: Port: Poort: API path: API-pad: Proxy Proxy HTTP HTTP proxy option HTTP None None proxy option Proxy type: Proxytype: Username: Gebruikersnaam: Password: Wachtwoord: s Suffix that is added to input field with number of seconds, e.g. "30 s" s Update interval: Update-interval: Timeout: Time-out: Auto reconnect interval: &Edit... Server's context menu item ---------- Tracker's context menu item B&ewerken... Server Options Dialog title Serveropties Directory for incomplete files: Map voor onvolledige bestanden: min Suffix that is added to input field with number of minuts, e.g. "5 min" min Maximum active downloads: Maximaal aantal actieve downloads: Maximum active uploads: Maximaal aantal actieve uploads: Ignore queue position if idle for: Wachtrijpositie negeren indien niet-actief voor: kB/s Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes kB/s Download: Download speed limit input field label Download: Upload: Upload speed limit input field label Upload: Days: Dagen: Peer port: Peerpoort: Encryption: Versleuteling: Enable μTP (Micro Transport Protocol) Check box label Enable PEX (Peer exchange) Check box label Enable local peer discovery Check box label Maximum peers per torrent: Maximaal aantal peers per torrent: Maximum peers globally: Algemeen maximaal aantal peers: Options Dialog title Opties Appearance Options section Follow system Dark theme mode On Dark theme mode Off Dark theme mode Dark theme Use system accent color Check box label Remember location of last opened torrent file Check box label Remember parameters of last added torrent Check box label Show icon in the notification area Check box label Pictogram tonen in meldingsgebied &Not Download Context menu item to unselect file for downloading &Niet downloaden &Priority Torrent's context menu item &Prioriteit &Download Context menu item to select file for downloading &High File loading priority &Hoog &Normal File loading priority &Normaal &Low File loading priority &Laag &Rename Torrent's context menu item ---------- Context menu item He&rnoemen File name: Bestandsnaam: Details Torrent's properties dialog tab Details Completed: Torrent's completed size Voltooid: Downloaded: Downloaded bytes ---------- Torrent's downloaded size Gedownload: Uploaded: Uploaded bytes ---------- Torrent's uploaded size Geüpload: Ratio: Verhouding: Duration: How much time Transmission is running Duur: Started: How many times Transmission was launched Gestart: Free space in download directory: Download speed: Downloadsnelheid: Upload speed: Uploadsnelheid: ETA: Resterende tijd: Seeders: Seeders: Leechers: Leechers: Peers we are downloading from: Web seeders we are downloading from: Peers we are uploading to: Last activity: Laatste activiteit: Total size: Totale grootte: Location: Torrent's download directory Locatie: Hash: Torrent's hash string Hash: Created by: Program that created torrent file Aangemaakt door: Created on: Date/time when torrent was created Aangemaakt op: Comment: Torrent's comment text Commentaar: Web seeder Web seeders list column title Web seeders Torrent's properties dialog tab Seeding Options section Torrent's limits tab section Ratio limit mode: Verhoudingslimietmodus: Idle seeding mode: Modus niet-actief seeden: Maximum peers: Maximaal aantal peers: Add Trackers Dialog title Trackers toevoegen Trackers announce URLs: Trackeraankondigings-URL’s: Tracker announce URL: Tracker-aankondigings-URL: Remove Tracker Dialog title Tracker verwijderen Are you sure you want to remove this tracker? Weet je zeker dat je deze tracker wil verwijderen? Remove Trackers Dialog title Trackers verwijderen Are you sure you want to remove %Ln selected trackers? %Ln is number of trackers selected for deletion Down Speed Torrents list column name ---------- Peers list column title Downloadsnelheid Up Speed Torrents list column name ---------- Peers list column title Uploadsnelheid Progress Bar Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Voortgangsbalk Flags Peers list column title Vlaggen Client Peers list column title Cliënt Timed out Server connection status Time-out Connection error Server connection status Verbindingsfout Authentication error Server connection status Authenticatiefout Parse error Server connection status Verwerkingsfout Server is too new Server connection status Server is te recent Server is too old Server connection status Server is te oud Connecting... Server connection status Verbinden... Connected Server connection status Verbonden Downloading Torrent status Torrent status Downloaden Seeding Torrent status Torrent status Seeden Queued for checking Torrent status In wachtrij voor controle Merge trackers? Dialog title Torrent «%1» is already added, merge trackers? Merge Do not ask again Merged trackers Dialog title Merged trackers for torrent «%1» Torrent already exists Dialog title Torrent «%1» already exists Error reading torrent file Fout bij lezen van torrentbestand Error parsing torrent file Fout bij verwerken van torrentbestand Total Size Torrents list column name Totale grootte Queue Position Torrents list column name Positie in wachtrij Added on Torrents list column name, date/time when torrent was added Toegevoegd op Completed on Torrents list column name, date/time when torrent was completed Voltooid op Down Limit Torrents list column name, download speed limit Downloadlimiet Up Limit Torrents list column name, upload speed limit Uploadlimiet Remaining Torrents list column name, remaining byte size Resterend Download Directory Torrents list column name Downloadmap Last Activity Torrents list column name Laatste activiteit Inactive Tracker status Waiting for update Tracker status About to update Tracker status Updating Tracker status Next Update Trackers list column title Volgende update %L1 B Size suffix in bytes %L1 B %L1 KiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in kibibytes %L1 KiB %L1 MiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in mebibytes %L1 MiB %L1 GiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in gibibytes %L1 GiB %L1 TiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in tebibytes %L1 TiB %L1 PiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in pebibytes %L1 PiB %L1 EiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in exbibytes %L1 EiB %L1 ZiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in zebibytes %L1 ZiB %L1 YiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in yobibytes %L1 YiB %L1 B/s Download speed suffix in bytes per second %L1 B/s %L1 KiB/s Download speed suffix in kibibytes per second %L1 KiB/s %L1 MiB/s Download speed suffix in mebibytes per second %L1 MiB/s %L1 GiB/s Download speed suffix in gibibytes per second %L1 GiB/s %L1 TiB/s Download speed suffix in tebibytes per second %L1 TiB/s %L1 PiB/s Download speed suffix in pebibytes per second %L1 PiB/s %L1 EiB/s Download speed suffix in exbibytes per second %L1 EiB/s %L1 ZiB/s Download speed suffix in zebibytes per second %L1 ZiB/s %L1 YiB/s Download speed suffix in yobibytes per second %L1 YiB/s %L1% Progress in percents. %L1 must remain unchanged, % after it is a percent character %L1% %L1 d %L2 h Remaining time string. %L1 is days, %L2 is hours, e.g. "2 d 5 h" %L1d, %L2u %L1 h %L2 m Remaining time string. %L1 is hours, %L2 is minutes, e.g. "2 h 5 m" %L1u, %L2m %L1 m %L2 s Remaining time string. %L1 is minutes, %L2 is seconds, e.g. "2 m 5 s" %L1m, %L2s %L1 s Remaining time string. %L1 is seconds, "10 s" %L1s Error opening %1 File opening error, %1 is a file path ---------- Directory opening error, %1 is a file path Fout bij openen van %1 Move files from current directory Check box label Bestanden verplaatsen uit huidige map Selected directory should be inside mounted directory Geselecteerde map moet in aangekoppelde map zijn All (%L1) Filter option of torrents list's tracker filter. %L1 is total number of torrents ---------- Filter option of torrents list's download directory filter. %L1 is total number of torrents ---------- Filter option of torrents list's status filter. %L1 is total number of torrents %1 (%L2) Filter option of torrents list's tracker filter. %1 is tracker domain name, %L2 is number of torrents with that tracker ---------- Filter option of torrents list's download directory filter. %1 is download directory, %L2 is number of torrents with that download directory %1 (%L2) This directory does not exist tremotesf-2.8.2/translations/nl_BE.ts000066400000000000000000003277611500171105600176170ustar00rootroot00000000000000 tremotesf About "About" dialog title ---------- "About" dialog's "About" tab title Over <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Source code: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Translations: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> "About" dialog text Maintainer Contributor Authors "About" dialog's "Authors" tab title Auteurs Translators "About" dialog's "Translators" tab title Vertalers License "About" dialog's "License" tab title Licentie Add Torrent File Dialog title Torrentbestand toevoegen Free space: %1 %1 is a amount of free space in a directory, e.g. 1 GiB Vrije ruimte: %1 Error getting free space Fout bij verkrijgen van vrije ruimte Files Torrent properties dialog tab Bestanden High Torrent's file loading priority ---------- Torrent's loading priority Hoog Normal Torrent's file loading priority ---------- Torrent's loading priority Normaal Low Torrent's file loading priority ---------- Torrent's loading priority Laag Start downloading after adding Downloaden starten na toevoegen Add Torrent Link Dialog title Torrentkoppeling toevoegen No servers Servers list placeholder Geen servers Disconnected Server connection status ---------- Notification title when disconnected from server ---------- Message that appears when disconnected from server Verbinding verbroken Downloading Noun "Downloading" server setting page Downloaden Start added torrents Check box label Toegevoegde torrents starten Append ".part" to names of incomplete files Check box label ‘.part’ toevoegen aan naam van onvolledige bestanden Rename Dialog title ---------- Dialog confirmation button Hernoemen Select Directory Directory chooser dialog title Selecteert een map Status Title of torrents status filters list ---------- Torrents list column name ---------- Trackers list column title Status Directories Title of torrents download directory filters list Mappen Trackers Title of torrents tracker filters list ---------- Torrent properties dialog tab Trackers Priority Column title in torrent's file list ---------- Torrents list column name Prioriteit Mixed Torrent's file loading priority ---------- File loading priority Gemengd No torrents Torrents list placeholder Geen torrents Remove Button ---------- Dialog confirmation button Verwijderen Set Location Dialog title for changing torrent's download directory Locatie instellen Error adding torrent Fout bij toevoegen van torrent This torrent is already added Deze torrent is al toegevoegd Torrent added Notification title Torrent toegevoegd Torrent finished Notification title Torrent voltooid Network "Network" server settings page Netwerk Connection Title of settings section related to peer connections ---------- Options section Verbinding Random port on Transmission start Check box label Willekeurige poort bij opstarten van Transmission Enable port forwarding Check box label Poort-doorsturen inschakelen Allow Encryption mode (allow/prefer/require) Toelaten Prefer Encryption mode (allow/prefer/require) Verkiezen Require Encryption mode (allow/prefer/require) Vereisen Enable DHT Check box label DHT inschakelen Peer Limits Peerlimieten Peers Torrent's properties dialog tab ---------- Torrent's limits tab section ---------- Trackers list column title Peers Queue "Queue" server settings page Wachtrij Also delete the files on the hard disk Check box label Bestanden op de harde schijf ook verwijderen Seeding Noun "Seeding" server setting page Seeden Overwrite Dialog's confirmation button Overschrijven Server already exists Server bestaat al Add Server Dialog title Server toevoegen Name Column title in torrent's file list ---------- Torrents list column name Naam Address Peers list column title ---------- Trackers list column title Adres Default Default proxy option Standaard HTTPS HTTPS SOCKS5 SOCKS5 proxy option SOCKS5 Server uses self-signed certificate Check box label Server gebruikt zelfondertekend certificaat Server's certificate in PEM format Text field placeholder Servercertificaat in PEM-formaat Load from file... Button Use client certificate authentication Check box label Cliëntcertificaatauthenticatie gebruiken Certificate in PEM format with private key Text field placeholder Certificaat in PEM-formaat met privésleutel Authentication Check box label Authenticatie Auto reconnect on error Check box label Mounted directories Aangekoppelde mappen Local directory Column title in the list of mounted directories Lokale map Remote directory Column title in the list of mounted directories Externe map Add Button ---------- Dialog confirmation button Toevoegen Speed "Speed" server settings page ---------- Torrent's limits tab section Snelheid Connection Settings Dialog title Edit... Button Bewerken... Add Server... Button Add... Button Toevoegen... Connect to server on startup Check box label Verbinden met server bij opstarten Adding torrents Options section Add torrent parameters Reset Show main window when adding torrents Check box label Show dialog when adding torrents Check box label Automatically fill link from clipboard when adding torrent link Check box label Tip: you can also press %1 in main window to add torrents from clipboard %1 is a key binding, e.g. "Ctrl + C" Ask for merging trackers when adding existing torrent Check box label Merge trackers when adding existing torrent Check box label Other behaviour Options section Open properties dialog Open torrent's file Open download directory What to do when torrent in the list is double clicked: Notifications Options section Meldingen Notify when disconnecting from server Check box label Melden bij verbreken van verbinding met server Notify on added torrents Check box label Melden bij toegevoegde torrents Notify on finished torrents Check box label Melden bij voltooide torrents When connecting to server Options section Bij verbinden met server Notify on added torrents since last connection to server Check box label Melden bij toegevoegde torrents sinds laatste verbinding met server Notify on finished torrents since last connection to server Check box label Melden bij voltooide torrents sinds laatste verbinding met server Progress Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Voortgang ETA Torrents list column name Resterende tijd Server Stats Dialog title Serverstatistieken Current session Server stats section for current Transmission launch Huidige sessie Ratio Torrents list column name Verhouding Total Server stats section for all Transmission launches (accumulated) Totaal %Ln times How many times Transmission was launched %Ln keer%Ln keer Size Column title in torrent's file list ---------- Torrents list column name Grootte Limits Speed limits section ---------- Torrent's properties dialog tab Limieten Alternative Limits Alternative speed limits section Alternatieve limieten Enable Check box label Inschakelen Scheduled Title of alternative speed limit scheduling section Gepland to Separates time range input fields. E.g. "to" inside "1:00 AM to 5:00 AM" tot Every day Elke dag Weekdays Weekdagen Weekends Week-ends %1 of %2 (%3) Torrent's completion size, e.g. 100 MiB of 200 MiB (50%). %1 is completed size, %2 is size, %3 is progress in percents %1 van %2 (%3) Checking (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Controleren (%L1) Honor global limits Check box label Algemene limieten aanhouden Use global settings Seeding ratio limit mode (global settings/stop at ratio/unlimited) ---------- Seeding idle limit mode (global settings/stop if idle for/unlimited) Algemene instellingen gebruiken Seed regardless of ratio Seeding ratio limit mode (global settings/stop at ratio/unlimited) Seeden ongeacht verhouding Stop seeding at ratio: Seeding ratio limit mode (global settings/stop at ratio/unlimited) Seeden stoppen bij verhouding: Seed regardless of activity Seeding idle limit mode (global settings/stop if idle for/unlimited) Seeden ongeacht activiteit Stop seeding if idle for: Seeding idle limit mode (global settings/stop if idle for/unlimited) Seeden stoppen indien inactief voor: Activity Torrent's details tab section Activiteit Completed Torrents list column name, completed byte size Voltooid Downloaded Torrents list column name, downloaded byte size Gedownload Paused (%1) Torrent status while torrent also has an error. %1 is error string Paused Torrent status Downloading (%1) Torrent status while torrent also has an error. %1 is error string Seeding (%1) Torrent status while torrent also has an error. %1 is error string Queued (%1) Torrent status while torrent also has an error. %1 is error string Queued Torrent status Checking (%1) Torrent status while torrent also has an error. %1 is error string Checking Torrent status Queued for checking (%1) Torrent status while torrent also has an error. %1 is error string Downloading to peers Torrents list column name, number of peers that we are downloading from Uploading to peers Torrents list column name, number of peers that we are uploading to Uploaded Torrents list column name, uploaded byte size Geüpload Seeders Torrents list column name, number of seeders reported by trackers ---------- Trackers list column title Seeders Leechers Torrents list column name, number of leechers reported by trackers ---------- Trackers list column title Leechers Information Torrent's details tab section Informatie Torrent Removed Message that appears when torrent is removed Torrent verwijderd Edit Tracker Dialog title Tracker bewerken Torrent file: Input field's label Torrentbestand: Torrent link: Input field's label Torrentverwijzing: Download directory: Input field's label Downloadmap: Torrent priority: Combo box label Torrentprioriteit: Delete .torrent file Move .torrent file to trash Loading Placeholder shown when torrent file is being read/parsed &Connect Button / menu item to connect to server &Verbinden &Disconnect Button / menu item to disconnect from server Verbin&ding verbreken &Add Torrent File... Menu item Torrentbest&and toevoegen... Add Torrent &Link... Menu item Torrentkoppe&ling toevoegen... P&ause Torrent's context menu item P&auzeren &Delete Torrent's context menu item Open &Download Directory Context menu item Delete with files Delete Delete Torrent Dialog title Are you sure you want to delete this torrent? Delete Torrents Dialog title Are you sure you want to delete %Ln selected torrents? %Ln is a number of torrents selected for deletion No torrents matching filters Torrents list placeholder &Quit Menu item Af&sluiten &Torrent Menu bar item &Torrent Error adding torrent «%1» Torrents will be added after connection to server: Message shown when user attempts to add torrent while disconnect from server. After that will be list of added torrents &Properties Torrent's context menu item &Eigenschappen &Show Tremotesf &Hide Tremotesf &Start Torrent's context menu item &Starten Start &Now Torrent's context menu item &Nu starten Copy &Magnet Link Torrent's context menu item &Magnetlink kopiëren &Remove Server's context menu item ---------- Context menu item ---------- Tracker's context menu item Ve&rwijderen Set &Location Torrent's context menu item &Locatie instellen &Open Torrent's context menu item ---------- Context menu item &Openen Op&en Download Directory Torrent's context menu item &Check Local Data Torrent's context menu item Lokale gegevens &controleren Reanno&unce Torrent's context menu item ---------- Button Ter&ug aankondigen &Queue Torrent's context menu item &Wachtrij Move To &Top Torrent's context menu item Verplaatsen naar &boven Move &Up Torrent's context menu item &Omhoog verplaatsen Move &Down Torrent's context menu item &Omlaag verplaatsen Move To &Bottom Torrent's context menu item Verplaatsen naar &beneden &Connection Settings &Server Options &Serveropties Server S&tats Servers&tatistieken Select Files File chooser dialog title Selecteert bestanden Torrent Files (*.torrent) Torrent file type. Parentheses and text within them must remain unchanged Torrentbestanden (*.torrent) Error Dialog title ---------- Trackers list column title Fout This file/directory does not exist &File Menu bar item &Bestand &Close Window &Edit Menu bar item B&ewerken Select &All &Alles selecteren &Invert Selection Select&ie omkeren &View Menu bar item Weerga&ve &Toolbar &Werkbalk &Sidebar &Zijbalk St&atusbar St&atusbalk &Lock Toolbar T&ools Menu bar item &Gereedschap &Options &Opties S&hutdown Server Shutdown Server Dialog title Are you sure you want to shutdown remote Transmission instance? Shutdown Dialog confirmation button &Help Menu bar item &Hulp &About Menu item opening "About" dialog &Info Icon Only Toolbar mode Enkel pictogram Text Only Toolbar mode Enkel tekst Text Beside Icon Toolbar mode Tekst naast pictogram Text Under Icon Toolbar mode Tekst onder pictogram Follow System Style Toolbar mode Systeemstijl volgen Show Tremotesf Button on notification Tremotesf tonen Active (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Actief (%L1) Downloading (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Downloaden (%L1) Seeding (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Seeden (%L1) Paused (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Gepauzeerd (%L1) Errored (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Fouten (%L1) Search... Search field placeholder Zoeken... &Select... Context menu item to open directory chooser &Selecteren… Overwrite Server Dialog title Server overschrijven Name: Naam: Address: Adres: Port: Poort: API path: API-pad: Proxy Proxy HTTP HTTP proxy option HTTP None None proxy option Proxy type: Proxytype Username: Gebruikersnaam: Password: Paswoord: s Suffix that is added to input field with number of seconds, e.g. "30 s" s Update interval: Update-interval: Timeout: Time-out: Auto reconnect interval: &Edit... Server's context menu item ---------- Tracker's context menu item B&ewerken... Server Options Dialog title Serveropties Directory for incomplete files: Map voor onvolledige bestanden: min Suffix that is added to input field with number of minuts, e.g. "5 min" min Maximum active downloads: Maximaal aantal actieve downloads: Maximum active uploads: Maximaal aantal actieve uploads: Ignore queue position if idle for: Wachtrijpositie negeren indien inactief voor: kB/s Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes kB/s Download: Download speed limit input field label Download: Upload: Upload speed limit input field label Upload: Days: Dagen: Peer port: Peerpoort: Encryption: Versleuteling: Enable μTP (Micro Transport Protocol) Check box label Enable PEX (Peer exchange) Check box label Enable local peer discovery Check box label Maximum peers per torrent: Maximaal aantal peers per torrent: Maximum peers globally: Algemeen maximaal aantal peers: Options Dialog title Opties Appearance Options section Follow system Dark theme mode On Dark theme mode Off Dark theme mode Dark theme Use system accent color Check box label Remember location of last opened torrent file Check box label Remember parameters of last added torrent Check box label Show icon in the notification area Check box label Pictogram tonen in meldingsgebied &Not Download Context menu item to unselect file for downloading &Niet downloaden &Priority Torrent's context menu item &Prioriteit &Download Context menu item to select file for downloading &High File loading priority &Hoog &Normal File loading priority &Normaal &Low File loading priority &Laag &Rename Torrent's context menu item ---------- Context menu item He&rnoemen File name: Bestandsnaam: Details Torrent's properties dialog tab Details Completed: Torrent's completed size Voltooid: Downloaded: Downloaded bytes ---------- Torrent's downloaded size Gedownload: Uploaded: Uploaded bytes ---------- Torrent's uploaded size Geüpload: Ratio: Verhouding: Duration: How much time Transmission is running Duur: Started: How many times Transmission was launched Gestart: Free space in download directory: Download speed: Downloadsnelheid: Upload speed: Uploadsnelheid: ETA: Resterende tijd: Seeders: Seeders: Leechers: Leechers: Peers we are downloading from: Web seeders we are downloading from: Peers we are uploading to: Last activity: Laatste activiteit: Total size: Totale grootte: Location: Torrent's download directory Locatie: Hash: Torrent's hash string Hash: Created by: Program that created torrent file Aangemaakt door: Created on: Date/time when torrent was created Aangemaakt op: Comment: Torrent's comment text Commentaar: Web seeder Web seeders list column title Web seeders Torrent's properties dialog tab Seeding Options section Torrent's limits tab section Ratio limit mode: Verhoudingslimietmodus: Idle seeding mode: Modus inactief seeden: Maximum peers: Maximaal aantal peers: Add Trackers Dialog title Trackers toevoegen Trackers announce URLs: Trackeraankondigings-URL’s: Tracker announce URL: Tracker-aankondigings-URL: Remove Tracker Dialog title Tracker verwijderen Are you sure you want to remove this tracker? Zijt ge zeker dat ge dezen tracker wilt verwijderen? Remove Trackers Dialog title Trackers verwijderen Are you sure you want to remove %Ln selected trackers? %Ln is number of trackers selected for deletion Down Speed Torrents list column name ---------- Peers list column title Downloadsnelheid Up Speed Torrents list column name ---------- Peers list column title Uploadsnelheid Progress Bar Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Voortgangsbalk Flags Peers list column title Vlaggen Client Peers list column title Cliënt Timed out Server connection status Time-out Connection error Server connection status Verbindingsfout Authentication error Server connection status Authenticatiefout Parse error Server connection status Verwerkingsfout Server is too new Server connection status Server is te recent Server is too old Server connection status Server is te oud Connecting... Server connection status Verbinden... Connected Server connection status Verbonden Downloading Torrent status Torrent status Downloaden Seeding Torrent status Torrent status Seeden Queued for checking Torrent status In wachtrij voor controle Merge trackers? Dialog title Torrent «%1» is already added, merge trackers? Merge Do not ask again Merged trackers Dialog title Merged trackers for torrent «%1» Torrent already exists Dialog title Torrent «%1» already exists Error reading torrent file Fout bij lezen van torrentbestand Error parsing torrent file Fout bij verwerken van torrentbestand Total Size Torrents list column name Totale grootte Queue Position Torrents list column name Positie in wachtrij Added on Torrents list column name, date/time when torrent was added Toegevoegd op Completed on Torrents list column name, date/time when torrent was completed Voltooid op Down Limit Torrents list column name, download speed limit Downloadlimiet Up Limit Torrents list column name, upload speed limit Uploadlimiet Remaining Torrents list column name, remaining byte size Resterend Download Directory Torrents list column name Downloadmap Last Activity Torrents list column name Laatste activiteit Inactive Tracker status Waiting for update Tracker status About to update Tracker status Updating Tracker status Next Update Trackers list column title Volgenden update %L1 B Size suffix in bytes %L1 B %L1 KiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in kibibytes %L1 KiB %L1 MiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in mebibytes %L1 MiB %L1 GiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in gibibytes %L1 GiB %L1 TiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in tebibytes %L1 TiB %L1 PiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in pebibytes %L1 PiB %L1 EiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in exbibytes %L1 EiB %L1 ZiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in zebibytes %L1 ZiB %L1 YiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in yobibytes %L1 YiB %L1 B/s Download speed suffix in bytes per second %L1 B/s %L1 KiB/s Download speed suffix in kibibytes per second %L1 KiB/s %L1 MiB/s Download speed suffix in mebibytes per second %L1 MiB/s %L1 GiB/s Download speed suffix in gibibytes per second %L1 GiB/s %L1 TiB/s Download speed suffix in tebibytes per second %L1 TiB/s %L1 PiB/s Download speed suffix in pebibytes per second %L1 PiB/s %L1 EiB/s Download speed suffix in exbibytes per second %L1 EiB/s %L1 ZiB/s Download speed suffix in zebibytes per second %L1 ZiB/s %L1 YiB/s Download speed suffix in yobibytes per second %L1 YiB/s %L1% Progress in percents. %L1 must remain unchanged, % after it is a percent character %L1% %L1 d %L2 h Remaining time string. %L1 is days, %L2 is hours, e.g. "2 d 5 h" %L1d, %L2u %L1 h %L2 m Remaining time string. %L1 is hours, %L2 is minutes, e.g. "2 h 5 m" %L1u, %L2m %L1 m %L2 s Remaining time string. %L1 is minutes, %L2 is seconds, e.g. "2 m 5 s" %L1m, %L2s %L1 s Remaining time string. %L1 is seconds, "10 s" %L1s Error opening %1 File opening error, %1 is a file path ---------- Directory opening error, %1 is a file path Fout bij openen van %1 Move files from current directory Check box label Bestanden verplaatsen uit huidige map Selected directory should be inside mounted directory Geselecteerde map moet in aangekoppelde map zijn All (%L1) Filter option of torrents list's tracker filter. %L1 is total number of torrents ---------- Filter option of torrents list's download directory filter. %L1 is total number of torrents ---------- Filter option of torrents list's status filter. %L1 is total number of torrents %1 (%L2) Filter option of torrents list's tracker filter. %1 is tracker domain name, %L2 is number of torrents with that tracker ---------- Filter option of torrents list's download directory filter. %1 is download directory, %L2 is number of torrents with that download directory %1 (%L2) This directory does not exist tremotesf-2.8.2/translations/pl.ts000066400000000000000000003462731500171105600172520ustar00rootroot00000000000000 tremotesf About "About" dialog title ---------- "About" dialog's "About" tab title O aplikacji <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Source code: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Translations: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> "About" dialog text <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Kod źródłowy: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Tłumaczenia: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> Maintainer Opiekun projektu Contributor Kontrybutor Authors "About" dialog's "Authors" tab title Autorzy Translators "About" dialog's "Translators" tab title Tłumaczenie License "About" dialog's "License" tab title Licencja Add Torrent File Dialog title Dodaj plik torrent Free space: %1 %1 is a amount of free space in a directory, e.g. 1 GiB Wolne miejsce: %1 Error getting free space Błąd podczas uzyskiwania wolnego miejsca Files Torrent properties dialog tab Pliki High Torrent's file loading priority ---------- Torrent's loading priority Wysoki Normal Torrent's file loading priority ---------- Torrent's loading priority Normalny Low Torrent's file loading priority ---------- Torrent's loading priority Niski Start downloading after adding Rozpocznij pobieranie po dodaniu Add Torrent Link Dialog title Dodaj torrent linkiem No servers Brak serwerów Disconnected Server connection status ---------- Notification title when disconnected from server ---------- Message that appears when disconnected from server Odłączony Downloading Noun "Downloading" server setting page Pobieranie Start added torrents Check box label Uruchom dodane torrenty Append ".part" to names of incomplete files Check box label Dołącz ".part" do nazw niekompletnych plików Rename Dialog title ---------- Dialog confirmation button Zmiana nazwy Select Directory Directory chooser dialog title Wybierz katalog Status Title of torrents status filters list ---------- Torrents list column name ---------- Trackers list column title Stan Directories Title of torrents download directory filters list Katalogi Trackers Title of torrents tracker filters list ---------- Torrent properties dialog tab Trackery Priority Column title in torrent's file list ---------- Torrents list column name Priorytet Mixed Torrent's file loading priority ---------- File loading priority Mieszany No torrents Torrents list placeholder Brak torrentów Remove Button ---------- Dialog confirmation button Usuń Set Location Dialog title for changing torrent's download directory Ustaw lokalizację Error adding torrent Błąd podczas dodawania torrenta This torrent is already added Ten torrent jest już dodany Torrent added Notification title Torrent został dodany Torrent finished Notification title Torrent został ukończony Network "Network" server settings page Sieć Connection Title of settings section related to peer connections Połączenia Random port on Transmission start Check box label Losowy port po uruchomieniu Transmission Enable port forwarding Check box label Włącz przekierowanie portu Allow Encryption mode (allow/prefer/require) Pozwól Prefer Encryption mode (allow/prefer/require) Preferuj Require Encryption mode (allow/prefer/require) Wymagaj Enable DHT Check box label Włącz DHT Peer Limits Limity połączeń z peerami Peers Torrent's properties dialog tab ---------- Torrent's limits tab section ---------- Trackers list column title Peerów Queue "Queue" server settings page Kolejka Also delete the files on the hard disk Check box label Usuń także pliki z dysku twardego Seeding Noun "Seeding" server setting page Seedowanie Overwrite Dialog's confirmation button Nadpisz Server already exists Serwer już istnieje Add Server Dialog title Dodaj serwer Name Column title in torrent's file list ---------- Torrents list column name Nazwa Address Peers list column title ---------- Trackers list column title Adres Default Default proxy option Domyślny HTTPS HTTPS SOCKS5 SOCKS5 proxy option SOCKS5 Server uses self-signed certificate Check box label Serwer używa certyfikatu z podpisem własnym Server's certificate in PEM format Text field placeholder Certyfikat serwera w formacie PEM Load from file... Button Załaduj z pliku... Use client certificate authentication Check box label Użyj uwierzytelnienia certyfikatu klienta Certificate in PEM format with private key Text field placeholder Certyfikat w formacie PEM z kluczem prywatnym Authentication Check box label Uwierzytelnienie Auto reconnect on error Check box label Połącz ponownie w przypadku błędu Mounted directories Zamontowane katalogi Local directory Column title in the list of mounted directories Katalog lokalny Remote directory Column title in the list of mounted directories Katalog zdalny Add Button ---------- Dialog confirmation button Dodaj Speed "Speed" server settings page ---------- Torrent's limits tab section Prędkość Connection Settings Servers list placeholder Dialog title Konfiguracja połączeń Edit... Button Edytuj... Add Server... Button Dodaj serwer... Add... Button Dodawanie... Connect to server on startup Check box label Połącz z serwerem po uruchomieniu Adding torrents Options tab Dodawanie torrentów Add torrent parameters Parametry dodawanego torrenta Reset Zresetuj Show main window when adding torrents Check box label Pokaż główne okno podczas dodawania torrentów Show dialog when adding torrents Check box label Pokaż okno dialogowe podczas dodawania torrentów. Automatically fill link from clipboard when adding torrent link Check box label Automatycznie wypełnij link ze schowka podczas dodawania linku do torrenta Tip: you can also press %1 in main window to add torrents from clipboard %1 is a key binding, e.g. "Ctrl + C" Wskazówka: możesz także nacisnąć %1 w głównym oknie, żeby dodać torrenty ze schowka Ask for merging trackers when adding existing torrent Check box label Pytaj o scalenie trackerów przy dodawaniu istniejącego torrenta Merge trackers when adding existing torrent Check box label Scal trackery przy dodawaniu istniejącego torrenta Open properties dialog Otwórz okno właściwości Open torrent's file Otwórz plik torrenta Open download directory Otwórz katalog docelowy What to do when torrent in the list is double clicked: Co zrobić, kiedy na liście torrent zostanie kliknięty dwukrotnie: General Options tab Show torrent properties in a panel in the main window Check box label Properties dialog won't be shown because torrent properties are shown in the main window Display relative time Check box label Display full path of download directories in sidebar and torrents list Check box label Notifications Options tab Powiadomienia Notify when disconnecting from server Check box label Powiadom o odłączeniu od serwera Notify on added torrents Check box label Powiadom o dodanych torrentach Notify on finished torrents Check box label Powiadom o ukończonych torrentach When connecting to server Notifications options section Podczas łączenia się z serwerem Notify on added torrents since last connection to server Check box label Powiadamiaj o dodanych torrentach od ostatniego połączenia z serwerem Notify on finished torrents since last connection to server Check box label Powiadamiaj o ukończonych torrentach od ostatniego połączenia z serwerem Progress Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Postęp ETA Torrents list column name ETA Server Stats Dialog title Statystyki serwera Current session Server stats section for current Transmission launch Obecna sesja Ratio Torrents list column name Ratio Total Server stats section for all Transmission launches (accumulated) Łącznie %Ln times How many times Transmission was launched %Ln raz%Ln razy%Ln razy%Ln razy Size Column title in torrent's file list ---------- Torrents list column name Rozmiar Limits Speed limits section ---------- Torrent's properties dialog tab Limity Alternative Limits Alternative speed limits section Alternatywne limity Enable Check box label Włącz Scheduled Title of alternative speed limit scheduling section Zgodnie z harmonogramem to Separates time range input fields. E.g. "to" inside "1:00 AM to 5:00 AM" do Every day Codziennie Weekdays Dni powszednie Weekends Weekendy %1 of %2 (%3) Torrent's completion size, e.g. 100 MiB of 200 MiB (50%). %1 is completed size, %2 is size, %3 is progress in percents %1 z %2 (%3) Checking (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Sprawdzanie (%L1) Honor global limits Check box label Przestrzegaj globalnych limitów Use global settings Seeding ratio limit mode (global settings/stop at ratio/unlimited) ---------- Seeding idle limit mode (global settings/stop if idle for/unlimited) Użyj ustawień globalnych Seed regardless of ratio Seeding ratio limit mode (global settings/stop at ratio/unlimited) Seeduj niezależnie od ratio Stop seeding at ratio: Seeding ratio limit mode (global settings/stop at ratio/unlimited) Zakończ seedowanie po osiągnięciu ratio: Seed regardless of activity Seeding idle limit mode (global settings/stop if idle for/unlimited) Seeduj niezależnie od aktywności Stop seeding if idle for: Seeding idle limit mode (global settings/stop if idle for/unlimited) Zakończ seedowanie w przypadku bezczynności przez: Activity Torrent's details tab section Działania Completed Torrents list column name, completed byte size Zakończone Downloaded Torrents list column name, downloaded byte size Pobrano Paused (%1) Torrent status while torrent also has an error. %1 is error string Wstrzymano (%1) Paused Torrent status Wstrzymano Downloading (%1) Torrent status while torrent also has an error. %1 is error string Pobieranie (%1) Seeding (%1) Torrent status while torrent also has an error. %1 is error string Seedowanie (%1) Queued (%1) Torrent status while torrent also has an error. %1 is error string W kolejce (%1) Queued Torrent status W kolejce Checking (%1) Torrent status while torrent also has an error. %1 is error string Sprawdzanie (%1) Checking Torrent status Sprawdzanie Queued for checking (%1) Torrent status while torrent also has an error. %1 is error string W kolejce do sprawdzenia (%1) Downloading to peers Torrents list column name, number of peers that we are downloading from Pobieranie od peerów Uploading to peers Torrents list column name, number of peers that we are uploading to Wysyłanie do peerów Uploaded Torrents list column name, uploaded byte size Przesłano Seeders Torrents list column name, number of seeders reported by trackers ---------- Trackers list column title Seedów Leechers Torrents list column name, number of leechers reported by trackers ---------- Trackers list column title Leechów Information Torrent's details tab section Informacje Torrent Removed Message that appears when torrent is removed Torrent został usunięty Edit Tracker Dialog title Edytowanie trackera Torrent file: Input field's label Plik torrent: Torrent link: Input field's label Link do torrenta: Download directory: Input field's label Katalog docelowy: Torrent priority: Combo box label Priorytet torrenta: Delete .torrent file Usuń plik .torrent Move .torrent file to trash Przenieś plik .torrent do kosza Labels Title of torrents label filters list Loading Placeholder shown when torrent file is being read/parsed Wczytywanie &Connect Button / menu item to connect to server &Połączono &Disconnect Button / menu item to disconnect from server &Rozłączono &Add Torrent File... Menu item &Dodaj plik torrent... Add Torrent &Link... Menu item Dodaj torrent &linkiem P&ause Torrent's context menu item Z&atrzymaj &Delete Torrent's context menu item &Usuń Open &Download Directory Context menu item Otwórz katalog &docelowy Delete with files Usuń z plikami Delete Usuń Delete Torrent Dialog title Usuń Torrent Are you sure you want to delete this torrent? Czy na pewno chcesz usunąć ten torrent? Delete Torrents Dialog title Usuń Torrenty Are you sure you want to delete %Ln selected torrents? %Ln is a number of torrents selected for deletion Jesteś pewien, że chcesz usunąć zaznaczony torrent?Jesteś pewien, że chcesz usunąć %Ln zaznaczone torrenty?Jesteś pewien, że chcesz usunąć %Ln zaznaczonych torrentów?Jesteś pewien, że chcesz usunąć %Ln zaznaczonych torrentów? No torrents matching filters Torrents list placeholder Brak torrentów odpowiadających kryteriom wyszukiwania &Quit Menu item &Wyjdź &Torrent Menu bar item &Torrent Error adding torrent «%1» Wystąpił błąd przy dodawaniu torrenta «%1» &Properties Torrent's context menu item &Właściwości &Show Tremotesf &Pokaż Tremotesf &Hide Tremotesf &Ukryj Tremotesf &Start Torrent's context menu item &Uruchom Start &Now Torrent's context menu item Uruchom &Teraz Copy &Magnet Link Torrent's context menu item Skopiuj link &magnet &Remove Server's context menu item ---------- Context menu item ---------- Tracker's context menu item &Usuń Set &Location Torrent's context menu item Ustaw &Lokalizację Edi&t Labels Torrent's context menu item &Open Torrent's context menu item ---------- Context menu item &Otwórz Op&en Download Directory Torrent's context menu item Ot&wórz katalog docelowy &Check Local Data Torrent's context menu item &Sprawdź dane lokalne Reanno&unce Torrent's context menu item ---------- Button Rozgłoś ponownie &Queue Torrent's context menu item &Kolejka Move To &Top Torrent's context menu item Przenieś na &początek Move &Up Torrent's context menu item Przenieś &wyżej Move &Down Torrent's context menu item Przenieś &niżej Move To &Bottom Torrent's context menu item Przenieś na &koniec &Connection Settings &Konfiguracja połączeń &Server Options &Opcje serwera Server S&tats S&tatystyki serwera Select Files File chooser dialog title Wybierz pliki Torrent Files (*.torrent) Torrent file type. Parentheses and text within them must remain unchanged Pliki torrent (*.torrent) Error Dialog title ---------- Trackers list column title Błąd This file/directory does not exist Ten plik/katalog nie istnieje &File Menu bar item &Plik &Close Window &Zamknij okno &Edit Menu bar item &Edycja Select &All Zaznacz &Wszystko &Invert Selection &Odwróć zaznaczenie &View Menu bar item &Widok &Toolbar &Pasek narzędzi &Sidebar &Pasek boczny St&atusbar Pasek st&anu Torrent properties &panel &Lock Toolbar &Zablokuj pasek narzędzi T&ools Menu bar item &Narzędzia &Options &Opcje S&hutdown Server Z&amknij serwer Shutdown Server Dialog title Zamykanie serwera Are you sure you want to shutdown remote Transmission instance? Czy na pewno chcesz zamknąć zdalną instancję Transmission? Shutdown Dialog confirmation button Zamknij &Help Menu bar item &Pomoc &About Menu item opening "About" dialog &O programie Icon Only Toolbar mode Tylko ikony Text Only Toolbar mode Tylko tekst Text Beside Icon Toolbar mode Tekst obok ikon Text Under Icon Toolbar mode Tekst pod ikonami Follow System Style Toolbar mode Dopasuj do stylu systemu Torrents will be added after connection to server Message shown when user attempts to add torrent while disconnect from server. Show Tremotesf Button on notification Pokaż Tremotesf Active (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Aktywne (%L1) Downloading (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Pobierane (%L1) Seeding (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Seedowane (%L1) Paused (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Zatrzymane (%L1) Errored (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Błąd (%L1) Search... Search field placeholder Wyszukaj... &Select... Context menu item to open directory chooser &Wybieranie... Overwrite Server Dialog title Zastępowanie serwera Name: Nazwa: Address: Adres: Port: Port: API path: Ścieżka API: Proxy Proxy HTTP HTTP proxy option HTTP None None proxy option Brak Proxy type: Typ proxy: Username: Nazwa Użytkownika: Password: Hasło: s Suffix that is added to input field with number of seconds, e.g. "30 s" s Update interval: Interwał aktualizacji: Timeout: Limit czasu: Auto reconnect interval: Interwał pomiędzy ponownym połączeniem: &Edit... Server's context menu item ---------- Tracker's context menu item &Edycja... Server Options Dialog title Opcje serwera Directory for incomplete files: Katalog niekompletnych plików: min Suffix that is added to input field with number of minuts, e.g. "5 min" min Maximum active downloads: Maksymalna liczba aktywnych pozycji pobieranych: Maximum active uploads: Maksymalna liczba aktywnych pozycji wysyłanych: Ignore queue position if idle for: Zignoruj ​​pozycję w kolejce, jeśli jest bezczynna przez: kB/s Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes kB/s Download: Download speed limit input field label Pobieranie: Upload: Upload speed limit input field label Wysyłanie: Days: Dni: Peer port: Peer port: Encryption: Szyfrowanie: Enable μTP (Micro Transport Protocol) Check box label Włącz μTP (Micro Transport Protocol) Enable PEX (Peer exchange) Check box label Włącz PEX (Peer exchange) Enable local peer discovery Check box label Włącz protokół lokalnego odnajdywania peerów (Local Peer Discovery) Maximum peers per torrent: Maksymalna ilość połączonych peerów na torrent: Maximum peers globally: Maksymalna ilość połączonych peerów globalnie: Options Dialog title Opcje Follow system Dark theme mode Dopasuj do systemu On Dark theme mode Włączony Off Dark theme mode Wyłączony Dark theme Ciemny motyw Use system accent color Check box label Użyj systemowego koloru wiodącego Remember location of last opened torrent file Check box label Zapamiętuje lokalizację ostatnio otwartego pliku torrent Remember parameters of last added torrent Check box label Zapamiętaj parametry z ostatniego dodanego torrenta Show icon in the notification area Check box label Pokaż ikonę w obszarze powiadomień &Not Download Context menu item to unselect file for downloading &Nie pobieraj &Priority Torrent's context menu item &Priorytet &Download Context menu item to select file for downloading &Pobierz &High File loading priority &Wysoki &Normal File loading priority &Normalny &Low File loading priority &Niski &Rename Torrent's context menu item ---------- Context menu item &Zmień nazwę File name: Nazwa pliku: Details Torrent's properties dialog tab Szczegóły Completed: Torrent's completed size Zakończono: Downloaded: Downloaded bytes ---------- Torrent's downloaded size Pobrano: Uploaded: Uploaded bytes ---------- Torrent's uploaded size Przesłano: Ratio: Ratio: Duration: How much time Transmission is running Czas działania: Started: How many times Transmission was launched Uruchomiono: Free space in download directory: Wolnego miejsca w katalogu docelowym: Download speed: Prędkość pobierania: Upload speed: Prędkość wysyłania: ETA: ETA: Seeders: Seedów: Leechers: Leechów: Peers we are downloading from: Połączonych peerów od których pobieramy: Web seeders we are downloading from: Web seedów od których pobieramy: Peers we are uploading to: Połączonych peerów do których wysyłamy: Last activity: Ostatnia aktywność: Total size: Całkowity rozmiar: Location: Torrent's download directory Lokalizacja: Hash: Torrent's hash string Hash: Created by: Program that created torrent file Stworzone przez: Created on: Date/time when torrent was created Utworzono: Comment: Torrent's comment text Komentarz: Labels: Torrent's labels Web seeder Web seeders list column title Web seed Web seeders Torrent's properties dialog tab Web seedy Seeding Options section Torrent's limits tab section Seedowanie Ratio limit mode: Tryb limitu ratio: Idle seeding mode: Tryb bezczynnego seedowania: Maximum peers: Maksymalna ilość połączonych peerów: Add Trackers Dialog title Dodaj trackery Trackers announce URLs: Adresy URL announce trackerów: Tracker announce URL: Adres URL announce trackera: Remove Tracker Dialog title Usuwanie trackera Are you sure you want to remove this tracker? Jesteś pewien, że chcesz usunąć ten tracker? Remove Trackers Dialog title Usuwanie trackerów Are you sure you want to remove %Ln selected trackers? %Ln is number of trackers selected for deletion Jesteś pewien, że chcesz usunąć zaznaczony tracker?Jesteś pewien, że chcesz usunąć %Ln zaznaczone trackery?Jesteś pewien, że chcesz usunąć %Ln zaznaczonych trackerów?Jesteś pewien, że chcesz usunąć %Ln zaznaczonych trackerów? Down Speed Torrents list column name ---------- Peers list column title Prędkość pobierania Up Speed Torrents list column name ---------- Peers list column title Prędkość wysyłania Progress Bar Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Pasek postępu Flags Peers list column title Flagi Client Peers list column title Klient Timed out Server connection status Przekroczono limit czasu Connection error Server connection status Błąd połączenia Authentication error Server connection status Błąd uwierzytelniania Parse error Server connection status Błąd przetwarzania Server is too new Server connection status Serwer jest zbyt nowy Server is too old Server connection status Serwer jest za stary Connecting... Server connection status Trwa łączenie... Connected Server connection status Połączony Downloading Torrent status Torrent status Pobieranie Seeding Torrent status Torrent status Seedowanie Queued for checking Torrent status W kolejce do sprawdzenia Merge trackers? Dialog title Czy scalić trackery? Torrent «%1» is already added, merge trackers? Torrent «%1» był już dodany, czy scalić trackery? Merge Scal Do not ask again Nie pytaj ponownie Merged trackers Dialog title Scalono trackery Merged trackers for torrent «%1» Scalono trackery dla torrenta «%1» Torrent already exists Dialog title Ten torrent już istnieje Torrent «%1» already exists Torrent «%1» już istnieje Error reading torrent file Błąd odczytu pliku torrent Error parsing torrent file Błąd podczas analizowania pliku torrent Total Size Torrents list column name Całkowity rozmiar Queue Position Torrents list column name Pozycja w kolejce Added on Torrents list column name, date/time when torrent was added Dodano Completed on Torrents list column name, date/time when torrent was completed Ukończono Down Limit Torrents list column name, download speed limit Limit prędkości pobierania Up Limit Torrents list column name, upload speed limit Limit prędkości wysyłania Remaining Torrents list column name, remaining byte size Pozostało Download Directory Torrents list column name Katalog docelowy Last Activity Torrents list column name Ostatnia aktywność Inactive Tracker status Bezczynny Waiting for update Tracker status Oczekiwanie na aktualizację About to update Tracker status Niebawem aktualizacja Updating Tracker status W trakcie aktualizacji Next Update Trackers list column title Następna aktualizacja %L1 B Size suffix in bytes %L1 B %L1 KiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in kibibytes %L1 KiB %L1 MiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in mebibytes %L1 MiB %L1 GiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in gibibytes %L1 GiB %L1 TiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in tebibytes %L1 TiB %L1 PiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in pebibytes %L1 PiB %L1 EiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in exbibytes %L1 EiB %L1 ZiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in zebibytes %L1 ZiB %L1 YiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in yobibytes %L1 YiB %L1 B/s Download speed suffix in bytes per second %L1 B/s %L1 KiB/s Download speed suffix in kibibytes per second %L1 KiB/s %L1 MiB/s Download speed suffix in mebibytes per second %L1 MiB/s %L1 GiB/s Download speed suffix in gibibytes per second %L1 GiB/s %L1 TiB/s Download speed suffix in tebibytes per second %L1 TiB/s %L1 PiB/s Download speed suffix in pebibytes per second %L1 PiB/s %L1 EiB/s Download speed suffix in exbibytes per second %L1 EiB/s %L1 ZiB/s Download speed suffix in zebibytes per second %L1 ZiB/s %L1 YiB/s Download speed suffix in yobibytes per second %L1 YiB/s %L1% Progress in percents. %L1 must remain unchanged, % after it is a percent character %L1% %L1 d %L2 h Remaining time string. %L1 is days, %L2 is hours, e.g. "2 d 5 h" %L1 d %L2 godz. %L1 h %L2 m Remaining time string. %L1 is hours, %L2 is minutes, e.g. "2 h 5 m" %L1 godz. %L2 m %L1 m %L2 s Remaining time string. %L1 is minutes, %L2 is seconds, e.g. "2 m 5 s" %L1 m %L2 s %L1 s Remaining time string. %L1 is seconds, "10 s" %L1 s Today Yesterday Two days ago %1 at %2 Relative date & time Just now Relative time %n minute(s) ago @item:intext %1 is a whole number %n minutes ago %n minute ago Error opening %1 File opening error, %1 is a file path ---------- Directory opening error, %1 is a file path Błąd otwierania %1 Move files from current directory Check box label Przenieś pliki z bieżącego katalogu Selected directory should be inside mounted directory Wybrany katalog powinien znajdować się w katalogu zamontowanym All (%L1) Filter option of torrents list's tracker filter. %L1 is total number of torrents ---------- Filter option of torrents list's download directory filter. %L1 is total number of torrents ---------- Filter option of torrents list's label filter. %L1 is total number of torrents ---------- Filter option of torrents list's status filter. %L1 is total number of torrents Wszystkie (%L1) %1 (%L2) Filter option of torrents list's tracker filter. %1 is tracker domain name, %L2 is number of torrents with that tracker ---------- Filter option of torrents list's label filter. %1 is label, %L2 is number of torrents with that label %1 (%L2) This directory does not exist Ten katalog nie istnieje Edit Labels New label... tremotesf-2.8.2/translations/ru.ts000066400000000000000000003626301500171105600172600ustar00rootroot00000000000000 tremotesf About "About" dialog title ---------- "About" dialog's "About" tab title О программе <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Source code: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Translations: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> "About" dialog text <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Исходный код: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Переводы: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> Maintainer Сопровождающий Contributor Участник Authors "About" dialog's "Authors" tab title Авторы Translators "About" dialog's "Translators" tab title Переводчики License "About" dialog's "License" tab title Лицензия Add Torrent File Dialog title Добавить торрент-файл Free space: %1 %1 is a amount of free space in a directory, e.g. 1 GiB Свободно: %1 Error getting free space Ошибка определения свободного места Files Torrent properties dialog tab Файлы High Torrent's file loading priority ---------- Torrent's loading priority Высокий Normal Torrent's file loading priority ---------- Torrent's loading priority Нормальный Low Torrent's file loading priority ---------- Torrent's loading priority Низкий Start downloading after adding Начать загрузку после добавления Add Torrent Link Dialog title Добавить ссылку на торрент No servers Нет серверов Disconnected Server connection status ---------- Notification title when disconnected from server ---------- Message that appears when disconnected from server Отключено Downloading Noun "Downloading" server setting page Загрузка Start added torrents Check box label Запускать добавленные торренты Append ".part" to names of incomplete files Check box label Добавлять ".part" к именам незавершённых файлов Rename Dialog title ---------- Dialog confirmation button Переименовать Select Directory Directory chooser dialog title Выбрать каталог Status Title of torrents status filters list ---------- Torrents list column name ---------- Trackers list column title Статус Directories Title of torrents download directory filters list Каталоги Trackers Title of torrents tracker filters list ---------- Torrent properties dialog tab Трекеры Priority Column title in torrent's file list ---------- Torrents list column name Приоритет Mixed Torrent's file loading priority ---------- File loading priority Смешанный No torrents Torrents list placeholder Нет торрентов Remove Button ---------- Dialog confirmation button Удалить Set Location Dialog title for changing torrent's download directory Переместить Error adding torrent Ошибка добавления торрента This torrent is already added Этот торрент уже добавлен Torrent added Notification title Торрент добавлен Torrent finished Notification title Торрент завершен Network "Network" server settings page Сеть Connection Title of settings section related to peer connections Соединение Random port on Transmission start Check box label Случайный порт при запуске Transmission Enable port forwarding Check box label Использовать перенаправление портов Allow Encryption mode (allow/prefer/require) Разрешить Prefer Encryption mode (allow/prefer/require) Предпочитать Require Encryption mode (allow/prefer/require) Требовать Enable DHT Check box label Использовать DHT Peer Limits Ограничения пиров Peers Torrent's properties dialog tab ---------- Torrent's limits tab section ---------- Trackers list column title Пиры Queue "Queue" server settings page Очередь Also delete the files on the hard disk Check box label Также удалить файлы на жестком диске Seeding Noun "Seeding" server setting page Раздача Overwrite Dialog's confirmation button Перезаписать Server already exists Сервер уже существует Add Server Dialog title Добавить сервер Name Column title in torrent's file list ---------- Torrents list column name Название Address Peers list column title ---------- Trackers list column title Адрес Default Default proxy option По умолчанию HTTPS HTTPS SOCKS5 SOCKS5 proxy option SOCKS5 Server uses self-signed certificate Check box label Сервер использует самоподписанный сертификат Server's certificate in PEM format Text field placeholder Сертификат сервера в формате PEM Load from file... Button Загрузить из файла... Use client certificate authentication Check box label Аутентификация с помощью клиентского сертификата Certificate in PEM format with private key Text field placeholder Сертификат в формате PEM с секретным ключом Authentication Check box label Аутентификация Auto reconnect on error Check box label Автоматическое переподключение при разрыве соединения Mounted directories Подключенные каталоги Local directory Column title in the list of mounted directories Локальный каталог Remote directory Column title in the list of mounted directories Удаленный каталог Add Button ---------- Dialog confirmation button Добавить Speed "Speed" server settings page ---------- Torrent's limits tab section Скорость Connection Settings Servers list placeholder Dialog title Настройки подключения Edit... Button Редактировать... Add Server... Button Добавить сервер... Add... Button Добавить... Connect to server on startup Check box label Подключаться к серверу при запуске Adding torrents Options tab Добавление торрентов Add torrent parameters Параметры добавления торрентов Reset Сбросить Show main window when adding torrents Check box label Показывать главное окно при добавлении торрентов Show dialog when adding torrents Check box label Показывать диалог добавления торрента Automatically fill link from clipboard when adding torrent link Check box label Автоматически копировать ссылку из буфера обмена при добавлении ссылки на торрент Tip: you can also press %1 in main window to add torrents from clipboard %1 is a key binding, e.g. "Ctrl + C" Совет: также можно нажать %1 в главном окне чтобы добавить торрент из буфера обмена Ask for merging trackers when adding existing torrent Check box label Спрашивать об объединении трекеров при добавлении существующего торрента Merge trackers when adding existing torrent Check box label Объединять трекеры при добавлении существующего торрента Open properties dialog Показать свойства торрента Open torrent's file Открыть файл торрента Open download directory Открыть каталог загрузки What to do when torrent in the list is double clicked: Что делать при нажатии на торрент в списке: General Options tab Общие Show torrent properties in a panel in the main window Check box label Показывать свойства торрента в панели главного окна Properties dialog won't be shown because torrent properties are shown in the main window Диалог свойств торрента не будет показан т.к. свойства торрента отображаются в главном окне Display relative time Check box label Отображать относительное время Display full path of download directories in sidebar and torrents list Check box label Отображать полный путь каталогов загрузки в боковой панели и списке торрентов Notifications Options tab Уведомления Notify when disconnecting from server Check box label Уведомлять при отключении от сервера Notify on added torrents Check box label Уведомлять при добавлении торрентов Notify on finished torrents Check box label Уведомлять при завершении торрентов When connecting to server Notifications options section При подключении к серверу Notify on added torrents since last connection to server Check box label Уведомлять о добавленных торрентах со времени последнего подключения к серверу Notify on finished torrents since last connection to server Check box label Уведомлять о завершенных торрентах со времени последнего подключения к серверу Progress Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Прогресс ETA Torrents list column name ETA Server Stats Dialog title Статистика сервера Current session Server stats section for current Transmission launch Текущая сессия Ratio Torrents list column name Рейтинг Total Server stats section for all Transmission launches (accumulated) Всего %Ln times How many times Transmission was launched %Ln раз%Ln раза%Ln раз%Ln раз Size Column title in torrent's file list ---------- Torrents list column name Размер Limits Speed limits section ---------- Torrent's properties dialog tab Ограничения Alternative Limits Alternative speed limits section Альтернативные ограничения Enable Check box label Включить Scheduled Title of alternative speed limit scheduling section По расписанию to Separates time range input fields. E.g. "to" inside "1:00 AM to 5:00 AM" до Every day Каждый день Weekdays По рабочим дням Weekends По выходным %1 of %2 (%3) Torrent's completion size, e.g. 100 MiB of 200 MiB (50%). %1 is completed size, %2 is size, %3 is progress in percents %1 из %2 (%3) Checking (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Проверяющиеся (%L1) Honor global limits Check box label Учитывать глобальные ограничения Use global settings Seeding ratio limit mode (global settings/stop at ratio/unlimited) ---------- Seeding idle limit mode (global settings/stop if idle for/unlimited) Использовать глобальные настройки Seed regardless of ratio Seeding ratio limit mode (global settings/stop at ratio/unlimited) Раздавать независимо от рейтинга Stop seeding at ratio: Seeding ratio limit mode (global settings/stop at ratio/unlimited) Прекратить раздачу при рейтинге: Seed regardless of activity Seeding idle limit mode (global settings/stop if idle for/unlimited) Раздавать независимо от активности Stop seeding if idle for: Seeding idle limit mode (global settings/stop if idle for/unlimited) Прекратить раздачу при простое: Activity Torrent's details tab section Активность Completed Torrents list column name, completed byte size Завершено Downloaded Torrents list column name, downloaded byte size Загружено Paused (%1) Torrent status while torrent also has an error. %1 is error string Приостановлен (%1) Paused Torrent status Приостановлен Downloading (%1) Torrent status while torrent also has an error. %1 is error string Загружается (%1) Seeding (%1) Torrent status while torrent also has an error. %1 is error string Раздается (%1) Queued (%1) Torrent status while torrent also has an error. %1 is error string В очереди (%1) Queued Torrent status В очереди Checking (%1) Torrent status while torrent also has an error. %1 is error string Проверяется (%1) Checking Torrent status Проверяется Queued for checking (%1) Torrent status while torrent also has an error. %1 is error string В очереди на проверку (%1) Downloading to peers Torrents list column name, number of peers that we are downloading from Загружается с пиров Uploading to peers Torrents list column name, number of peers that we are uploading to Отдается пирам Uploaded Torrents list column name, uploaded byte size Отдано Seeders Torrents list column name, number of seeders reported by trackers ---------- Trackers list column title Сиды Leechers Torrents list column name, number of leechers reported by trackers ---------- Trackers list column title Личи Information Torrent's details tab section Информация Torrent Removed Message that appears when torrent is removed Торрент удален Edit Tracker Dialog title Редактировать трекер Torrent file: Input field's label Торрент-файл: Torrent link: Input field's label Ссылка на торрент: Download directory: Input field's label Каталог загрузки: Torrent priority: Combo box label Приоритет торрента: Delete .torrent file Удалить .torrent файл Move .torrent file to trash Переместить .torrent файл в корзину Labels Title of torrents label filters list Метки Loading Placeholder shown when torrent file is being read/parsed Загрузка &Connect Button / menu item to connect to server &Подключиться &Disconnect Button / menu item to disconnect from server &Отключиться &Add Torrent File... Menu item &Добавить торрент-файл... Add Torrent &Link... Menu item Добавить &ссылку на торрент... P&ause Torrent's context menu item &Приостановить &Delete Torrent's context menu item &Удалить Open &Download Directory Context menu item Открыть &каталог загрузки Delete with files Удалить вместе с файлами Delete Удалить Delete Torrent Dialog title Удалить торрент Are you sure you want to delete this torrent? Вы уверены, что хотите удалить этот торрент? Delete Torrents Dialog title Удалить торренты Are you sure you want to delete %Ln selected torrents? %Ln is a number of torrents selected for deletion Вы уверены, что хотите удалить %Ln выбранный торрент?Вы уверены, что хотите удалить %Ln выбранных торрента?Вы уверены, что хотите удалить %Ln выбранных торрентов?Вы уверены, что хотите удалить %Ln выбранных торрентов? No torrents matching filters Torrents list placeholder Нет торрентов соответствующих фильтрам &Quit Menu item &Выход &Torrent Menu bar item &Торрент Error adding torrent «%1» Ошибка добавления торрента «%1» &Properties Torrent's context menu item &Свойства &Show Tremotesf &Показать Tremotesf &Hide Tremotesf &Скрыть Tremotesf &Start Torrent's context menu item &Запустить Start &Now Torrent's context menu item Запус&тить сейчас Copy &Magnet Link Torrent's context menu item &Копировать magnet-ссылку &Remove Server's context menu item ---------- Context menu item ---------- Tracker's context menu item &Удалить Set &Location Torrent's context menu item П&ереместить Edi&t Labels Torrent's context menu item Редактировать &метки &Open Torrent's context menu item ---------- Context menu item &Открыть Op&en Download Directory Torrent's context menu item Открыть &каталог загрузки &Check Local Data Torrent's context menu item Проверить &локальные данные Reanno&unce Torrent's context menu item ---------- Button Пере&анонсировать &Queue Torrent's context menu item О&чередь Move To &Top Torrent's context menu item Сдвинуть в &начало Move &Up Torrent's context menu item Сдвинуть &вверх Move &Down Torrent's context menu item Сдвинуть вн&из Move To &Bottom Torrent's context menu item Сдвинуть в &конец &Connection Settings Настройки &подключения &Server Options Н&астройки сервера Server S&tats С&татистика сервера Select Files File chooser dialog title Выбрать файлы Torrent Files (*.torrent) Torrent file type. Parentheses and text within them must remain unchanged Торрент-файлы (*.torrent) Error Dialog title ---------- Trackers list column title Ошибка This file/directory does not exist Этого файла/каталога не существует &File Menu bar item &Файл &Close Window &Закрыть окно &Edit Menu bar item &Правка Select &All &Выбрать все &Invert Selection &Инвертировать выделение &View Menu bar item &Вид &Toolbar &Панель инструментов &Sidebar &Боковая панель St&atusbar Панель &статуса Torrent properties &panel Панель свойств &торрента &Lock Toolbar &Закрепить панель инструментов T&ools Menu bar item &Инструменты &Options &Настройки S&hutdown Server &Завершить сервер Shutdown Server Dialog title Завершить сервер Are you sure you want to shutdown remote Transmission instance? Вы уверены что хотите завершить удаленный сервер Transmission? Shutdown Dialog confirmation button Завершить &Help Menu bar item &Справка &About Menu item opening "About" dialog &О программе Icon Only Toolbar mode Только значки Text Only Toolbar mode Только текст Text Beside Icon Toolbar mode Текст рядом зо значком Text Under Icon Toolbar mode Текст под значком Follow System Style Toolbar mode Использовать стиль системы Torrents will be added after connection to server Message shown when user attempts to add torrent while disconnect from server. Торренты будут добавлены после подключения к серверу Show Tremotesf Button on notification Показать Tremotesf Active (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Активные (%L1) Downloading (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Загрузки (%L1) Seeding (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Раздачи (%L1) Paused (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Приостановленные (%L1) Errored (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status С ошибкой (%L1) Search... Search field placeholder Поиск... &Select... Context menu item to open directory chooser &Выбрать Overwrite Server Dialog title Перезаписать сервер Name: Название: Address: Адрес: Port: Порт: API path: Путь API: Proxy Прокси HTTP HTTP proxy option HTTP None None proxy option Без прокси Proxy type: Тип прокси: Username: Имя пользователя: Password: Пароль: s Suffix that is added to input field with number of seconds, e.g. "30 s" с Update interval: Интервал обновления: Timeout: Таймаут: Auto reconnect interval: Интервал автоматического переподключения: &Edit... Server's context menu item ---------- Tracker's context menu item &Редактировать... Server Options Dialog title Настройки сервера Directory for incomplete files: Каталог для незавершенных файлов: min Suffix that is added to input field with number of minuts, e.g. "5 min" мин Maximum active downloads: Максимальное количество активных загрузок: Maximum active uploads: Максимальное количество активных раздач: Ignore queue position if idle for: Игнорировать позицию в очереди при простое: kB/s Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes кБ/с Download: Download speed limit input field label Загрузка: Upload: Upload speed limit input field label Отдача: Days: Дни: Peer port: Порт для входящих соединений: Encryption: Шифрование: Enable μTP (Micro Transport Protocol) Check box label Использовать μTP (Micro Transport Protocol) Enable PEX (Peer exchange) Check box label Использовать PEX (Peer exchange) Enable local peer discovery Check box label Использовать обнаружение пиров в локальной сети Maximum peers per torrent: Максимальное количество пиров на торрент: Maximum peers globally: Общее максимальное количество пиров: Options Dialog title Настройки Follow system Dark theme mode Как в системе On Dark theme mode Включено Off Dark theme mode Отключено Dark theme Темная тема Use system accent color Check box label Использовать цвета системы Remember location of last opened torrent file Check box label Запоминать расположение последнего открытого торрент-файла Remember parameters of last added torrent Check box label Запоминать параметры последнего добавленного торрента Show icon in the notification area Check box label Показывать значок в области уведомлений &Not Download Context menu item to unselect file for downloading &Не загружать &Priority Torrent's context menu item &Приоритет &Download Context menu item to select file for downloading &Загружать &High File loading priority &Высокий &Normal File loading priority &Нормальный &Low File loading priority &Низкий &Rename Torrent's context menu item ---------- Context menu item П&ереименовать File name: Имя файла: Details Torrent's properties dialog tab Сведения Completed: Torrent's completed size Завершено: Downloaded: Downloaded bytes ---------- Torrent's downloaded size Загружено: Uploaded: Uploaded bytes ---------- Torrent's uploaded size Отдано: Ratio: Рейтинг: Duration: How much time Transmission is running Продолжительность: Started: How many times Transmission was launched Запущен: Free space in download directory: Свободное место в каталоге загрузки: Download speed: Скорость загрузки: Upload speed: Скорость отдачи: ETA: ETA: Seeders: Сиды: Leechers: Личи: Peers we are downloading from: Пиры с которых мы загружаем: Web seeders we are downloading from: Веб-сиды с которых мы загружаем: Peers we are uploading to: Пиры которым мы отдаем: Last activity: Последняя активность: Total size: Общий размер: Location: Torrent's download directory Расположение: Hash: Torrent's hash string Хэш: Created by: Program that created torrent file Создан в: Created on: Date/time when torrent was created Дата создания: Comment: Torrent's comment text Комментарий: Labels: Torrent's labels Метки: Web seeder Web seeders list column title Веб-сид Web seeders Torrent's properties dialog tab Веб-сиды Seeding Options section Torrent's limits tab section Раздача Ratio limit mode: Ограничение рейтинга: Idle seeding mode: Раздача при простое: Maximum peers: Максимальное количество пиров: Add Trackers Dialog title Добавить трекеры Trackers announce URLs: Адреса объявлений трекеров: Tracker announce URL: Адрес объявлений трекера: Remove Tracker Dialog title Удалить трекер Are you sure you want to remove this tracker? Вы уверены, что хотите удалить этот трекер? Remove Trackers Dialog title Удалить трекеры Are you sure you want to remove %Ln selected trackers? %Ln is number of trackers selected for deletion Вы уверены, что хотите удалить %Ln выбранный трекер?Вы уверены, что хотите удалить %Ln выбранных трекера?Вы уверены, что хотите удалить %Ln выбранных трекеров?Вы уверены, что хотите удалить %Ln выбранных трекеров? Down Speed Torrents list column name ---------- Peers list column title Загрузка Up Speed Torrents list column name ---------- Peers list column title Отдача Progress Bar Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Индикатор прогресса Flags Peers list column title Флаги Client Peers list column title Клиент Timed out Server connection status Время соединения истекло Connection error Server connection status Ошибка соединения Authentication error Server connection status Ошибка аутентификации Parse error Server connection status Ошибка разбора ответа от сервера Server is too new Server connection status Сервер слишком новый Server is too old Server connection status Сервер слишком старый Connecting... Server connection status Подключение... Connected Server connection status Подключено Downloading Torrent status Torrent status Загружается Seeding Torrent status Torrent status Раздается Queued for checking Torrent status В очереди на проверку Merge trackers? Dialog title Объединить трекеры? Torrent «%1» is already added, merge trackers? Торрент «%1» уже добавлен, объединить трекеры? Merge Объединить Do not ask again Больше не спрашивать Merged trackers Dialog title Трекеры объединены Merged trackers for torrent «%1» Объединены трекеры для торрента «%1» Torrent already exists Dialog title Торрент уже добавлен Torrent «%1» already exists Торрент «%1» уже добавлен Error reading torrent file Ошибка чтения торрент-файла Error parsing torrent file Ошибка разбора торрент-файла Total Size Torrents list column name Общий размер Queue Position Torrents list column name Позиция в очереди Added on Torrents list column name, date/time when torrent was added Добавлен Completed on Torrents list column name, date/time when torrent was completed Завершен Down Limit Torrents list column name, download speed limit Огр. загрузки Up Limit Torrents list column name, upload speed limit Огр. отдачи Remaining Torrents list column name, remaining byte size Осталось Download Directory Torrents list column name Каталог загрузки Last Activity Torrents list column name Последняя активность Inactive Tracker status Неактивен Waiting for update Tracker status В ожидании обновления About to update Tracker status В очереди на обновление Updating Tracker status Обновляется Next Update Trackers list column title Следующее обновление %L1 B Size suffix in bytes %L1 Б %L1 KiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in kibibytes %L1 КиБ %L1 MiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in mebibytes %L1 МиБ %L1 GiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in gibibytes %L1 ГиБ %L1 TiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in tebibytes %L1 ТиБ %L1 PiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in pebibytes %L1 ПиБ %L1 EiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in exbibytes %L1 ЭиБ %L1 ZiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in zebibytes %L1 ЗиБ %L1 YiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in yobibytes %L1 ЙиБ %L1 B/s Download speed suffix in bytes per second %L1 Б/с %L1 KiB/s Download speed suffix in kibibytes per second %L1 КиБ/с %L1 MiB/s Download speed suffix in mebibytes per second %L1 МиБ/с %L1 GiB/s Download speed suffix in gibibytes per second %L1 ГиБ/с %L1 TiB/s Download speed suffix in tebibytes per second %L1 ТиБ/с %L1 PiB/s Download speed suffix in pebibytes per second %L1 ПиБ/с %L1 EiB/s Download speed suffix in exbibytes per second %L1 ЭиБ/с %L1 ZiB/s Download speed suffix in zebibytes per second %L1 ЗиБ/с %L1 YiB/s Download speed suffix in yobibytes per second %L1 ЙиБ/с %L1% Progress in percents. %L1 must remain unchanged, % after it is a percent character %L1% %L1 d %L2 h Remaining time string. %L1 is days, %L2 is hours, e.g. "2 d 5 h" %L1 д %L2 ч %L1 h %L2 m Remaining time string. %L1 is hours, %L2 is minutes, e.g. "2 h 5 m" %L1 ч %L2 м %L1 m %L2 s Remaining time string. %L1 is minutes, %L2 is seconds, e.g. "2 m 5 s" %L1 м %L2 с %L1 s Remaining time string. %L1 is seconds, "10 s" %L1 с Today Сегодня Yesterday Вчера Two days ago Позавчера %1 at %2 Relative date & time %1 в %2 Just now Relative time Только что %n minute(s) ago @item:intext %1 is a whole number %n минуту назад%n минуты назад%n минут назад%n минут назад %n minutes ago %n minute ago Error opening %1 File opening error, %1 is a file path ---------- Directory opening error, %1 is a file path Ошибка открытия %1 Move files from current directory Check box label Переместить файлы из текущего каталога Selected directory should be inside mounted directory Выбранный каталог должен быть внутри подключенного каталога All (%L1) Filter option of torrents list's tracker filter. %L1 is total number of torrents ---------- Filter option of torrents list's download directory filter. %L1 is total number of torrents ---------- Filter option of torrents list's label filter. %L1 is total number of torrents ---------- Filter option of torrents list's status filter. %L1 is total number of torrents Все (%L1) %1 (%L2) Filter option of torrents list's tracker filter. %1 is tracker domain name, %L2 is number of torrents with that tracker ---------- Filter option of torrents list's label filter. %1 is label, %L2 is number of torrents with that label %1 (%L2) This directory does not exist Этого каталога не существует Edit Labels Редактировать метки New label... Новая метка... tremotesf-2.8.2/translations/source.ts000066400000000000000000003445411500171105600201330ustar00rootroot00000000000000 tremotesf About "About" dialog title ---------- "About" dialog's "About" tab title <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Source code: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Translations: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> "About" dialog text Maintainer Contributor Authors "About" dialog's "Authors" tab title Translators "About" dialog's "Translators" tab title License "About" dialog's "License" tab title Add Torrent File Dialog title Free space: %1 %1 is a amount of free space in a directory, e.g. 1 GiB Error getting free space Files Torrent properties dialog tab High Torrent's file loading priority ---------- Torrent's loading priority Normal Torrent's file loading priority ---------- Torrent's loading priority Low Torrent's file loading priority ---------- Torrent's loading priority Start downloading after adding Add Torrent Link Dialog title No servers Disconnected Server connection status ---------- Notification title when disconnected from server ---------- Message that appears when disconnected from server Downloading Noun "Downloading" server setting page Start added torrents Check box label Append ".part" to names of incomplete files Check box label Rename Dialog title ---------- Dialog confirmation button Select Directory Directory chooser dialog title Status Title of torrents status filters list ---------- Torrents list column name ---------- Trackers list column title Directories Title of torrents download directory filters list Trackers Title of torrents tracker filters list ---------- Torrent properties dialog tab Priority Column title in torrent's file list ---------- Torrents list column name Mixed Torrent's file loading priority ---------- File loading priority No torrents Torrents list placeholder Remove Button ---------- Dialog confirmation button Set Location Dialog title for changing torrent's download directory Error adding torrent This torrent is already added Torrent added Notification title Torrent finished Notification title Network "Network" server settings page Connection Title of settings section related to peer connections Random port on Transmission start Check box label Enable port forwarding Check box label Allow Encryption mode (allow/prefer/require) Prefer Encryption mode (allow/prefer/require) Require Encryption mode (allow/prefer/require) Enable DHT Check box label Peer Limits Peers Torrent's properties dialog tab ---------- Torrent's limits tab section ---------- Trackers list column title Queue "Queue" server settings page Also delete the files on the hard disk Check box label Seeding Noun "Seeding" server setting page Overwrite Dialog's confirmation button Server already exists Add Server Dialog title Name Column title in torrent's file list ---------- Torrents list column name Address Peers list column title ---------- Trackers list column title Default Default proxy option HTTPS SOCKS5 SOCKS5 proxy option Server uses self-signed certificate Check box label Server's certificate in PEM format Text field placeholder Load from file... Button Use client certificate authentication Check box label Certificate in PEM format with private key Text field placeholder Authentication Check box label Auto reconnect on error Check box label Mounted directories Local directory Column title in the list of mounted directories Remote directory Column title in the list of mounted directories Add Button ---------- Dialog confirmation button Speed "Speed" server settings page ---------- Torrent's limits tab section Connection Settings Servers list placeholder Dialog title Edit... Button Add Server... Button Add... Button Connect to server on startup Check box label Adding torrents Options tab Add torrent parameters Reset Show main window when adding torrents Check box label Show dialog when adding torrents Check box label Automatically fill link from clipboard when adding torrent link Check box label Tip: you can also press %1 in main window to add torrents from clipboard %1 is a key binding, e.g. "Ctrl + C" Ask for merging trackers when adding existing torrent Check box label Merge trackers when adding existing torrent Check box label Open properties dialog Open torrent's file Open download directory What to do when torrent in the list is double clicked: General Options tab Show torrent properties in a panel in the main window Check box label Properties dialog won't be shown because torrent properties are shown in the main window Display relative time Check box label Display full path of download directories in sidebar and torrents list Check box label Notifications Options tab Notify when disconnecting from server Check box label Notify on added torrents Check box label Notify on finished torrents Check box label When connecting to server Notifications options section Notify on added torrents since last connection to server Check box label Notify on finished torrents since last connection to server Check box label Progress Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title ETA Torrents list column name Server Stats Dialog title Current session Server stats section for current Transmission launch Ratio Torrents list column name Total Server stats section for all Transmission launches (accumulated) %Ln times How many times Transmission was launched Size Column title in torrent's file list ---------- Torrents list column name Limits Speed limits section ---------- Torrent's properties dialog tab Alternative Limits Alternative speed limits section Enable Check box label Scheduled Title of alternative speed limit scheduling section to Separates time range input fields. E.g. "to" inside "1:00 AM to 5:00 AM" Every day Weekdays Weekends %1 of %2 (%3) Torrent's completion size, e.g. 100 MiB of 200 MiB (50%). %1 is completed size, %2 is size, %3 is progress in percents Checking (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Honor global limits Check box label Use global settings Seeding ratio limit mode (global settings/stop at ratio/unlimited) ---------- Seeding idle limit mode (global settings/stop if idle for/unlimited) Seed regardless of ratio Seeding ratio limit mode (global settings/stop at ratio/unlimited) Stop seeding at ratio: Seeding ratio limit mode (global settings/stop at ratio/unlimited) Seed regardless of activity Seeding idle limit mode (global settings/stop if idle for/unlimited) Stop seeding if idle for: Seeding idle limit mode (global settings/stop if idle for/unlimited) Activity Torrent's details tab section Completed Torrents list column name, completed byte size Downloaded Torrents list column name, downloaded byte size Paused (%1) Torrent status while torrent also has an error. %1 is error string Paused Torrent status Downloading (%1) Torrent status while torrent also has an error. %1 is error string Seeding (%1) Torrent status while torrent also has an error. %1 is error string Queued (%1) Torrent status while torrent also has an error. %1 is error string Queued Torrent status Checking (%1) Torrent status while torrent also has an error. %1 is error string Checking Torrent status Queued for checking (%1) Torrent status while torrent also has an error. %1 is error string Downloading to peers Torrents list column name, number of peers that we are downloading from Uploading to peers Torrents list column name, number of peers that we are uploading to Uploaded Torrents list column name, uploaded byte size Seeders Torrents list column name, number of seeders reported by trackers ---------- Trackers list column title Leechers Torrents list column name, number of leechers reported by trackers ---------- Trackers list column title Information Torrent's details tab section Torrent Removed Message that appears when torrent is removed Edit Tracker Dialog title Torrent file: Input field's label Torrent link: Input field's label Download directory: Input field's label Torrent priority: Combo box label Delete .torrent file Move .torrent file to trash Labels Title of torrents label filters list Loading Placeholder shown when torrent file is being read/parsed &Connect Button / menu item to connect to server &Disconnect Button / menu item to disconnect from server &Add Torrent File... Menu item Add Torrent &Link... Menu item P&ause Torrent's context menu item &Delete Torrent's context menu item Open &Download Directory Context menu item Delete with files Delete Delete Torrent Dialog title Are you sure you want to delete this torrent? Delete Torrents Dialog title Are you sure you want to delete %Ln selected torrents? %Ln is a number of torrents selected for deletion No torrents matching filters Torrents list placeholder &Quit Menu item &Torrent Menu bar item Error adding torrent «%1» &Properties Torrent's context menu item &Show Tremotesf &Hide Tremotesf &Start Torrent's context menu item Start &Now Torrent's context menu item Copy &Magnet Link Torrent's context menu item &Remove Server's context menu item ---------- Context menu item ---------- Tracker's context menu item Set &Location Torrent's context menu item Edi&t Labels Torrent's context menu item &Open Torrent's context menu item ---------- Context menu item Op&en Download Directory Torrent's context menu item &Check Local Data Torrent's context menu item Reanno&unce Torrent's context menu item ---------- Button &Queue Torrent's context menu item Move To &Top Torrent's context menu item Move &Up Torrent's context menu item Move &Down Torrent's context menu item Move To &Bottom Torrent's context menu item &Connection Settings &Server Options Server S&tats Select Files File chooser dialog title Torrent Files (*.torrent) Torrent file type. Parentheses and text within them must remain unchanged Error Dialog title ---------- Trackers list column title This file/directory does not exist &File Menu bar item &Close Window &Edit Menu bar item Select &All &Invert Selection &View Menu bar item &Toolbar &Sidebar St&atusbar Torrent properties &panel &Lock Toolbar T&ools Menu bar item &Options S&hutdown Server Shutdown Server Dialog title Are you sure you want to shutdown remote Transmission instance? Shutdown Dialog confirmation button &Help Menu bar item &About Menu item opening "About" dialog Icon Only Toolbar mode Text Only Toolbar mode Text Beside Icon Toolbar mode Text Under Icon Toolbar mode Follow System Style Toolbar mode Torrents will be added after connection to server Message shown when user attempts to add torrent while disconnect from server. Show Tremotesf Button on notification Active (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Downloading (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Seeding (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Paused (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Errored (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Search... Search field placeholder &Select... Context menu item to open directory chooser Overwrite Server Dialog title Name: Address: Port: API path: Proxy HTTP HTTP proxy option None None proxy option Proxy type: Username: Password: s Suffix that is added to input field with number of seconds, e.g. "30 s" Update interval: Timeout: Auto reconnect interval: &Edit... Server's context menu item ---------- Tracker's context menu item Server Options Dialog title Directory for incomplete files: min Suffix that is added to input field with number of minuts, e.g. "5 min" Maximum active downloads: Maximum active uploads: Ignore queue position if idle for: kB/s Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes Download: Download speed limit input field label Upload: Upload speed limit input field label Days: Peer port: Encryption: Enable μTP (Micro Transport Protocol) Check box label Enable PEX (Peer exchange) Check box label Enable local peer discovery Check box label Maximum peers per torrent: Maximum peers globally: Options Dialog title Follow system Dark theme mode On Dark theme mode Off Dark theme mode Dark theme Use system accent color Check box label Remember location of last opened torrent file Check box label Remember parameters of last added torrent Check box label Show icon in the notification area Check box label &Not Download Context menu item to unselect file for downloading &Priority Torrent's context menu item &Download Context menu item to select file for downloading &High File loading priority &Normal File loading priority &Low File loading priority &Rename Torrent's context menu item ---------- Context menu item File name: Details Torrent's properties dialog tab Completed: Torrent's completed size Downloaded: Downloaded bytes ---------- Torrent's downloaded size Uploaded: Uploaded bytes ---------- Torrent's uploaded size Ratio: Duration: How much time Transmission is running Started: How many times Transmission was launched Free space in download directory: Download speed: Upload speed: ETA: Seeders: Leechers: Peers we are downloading from: Web seeders we are downloading from: Peers we are uploading to: Last activity: Total size: Location: Torrent's download directory Hash: Torrent's hash string Created by: Program that created torrent file Created on: Date/time when torrent was created Comment: Torrent's comment text Labels: Torrent's labels Web seeder Web seeders list column title Web seeders Torrent's properties dialog tab Seeding Options section Torrent's limits tab section Ratio limit mode: Idle seeding mode: Maximum peers: Add Trackers Dialog title Trackers announce URLs: Tracker announce URL: Remove Tracker Dialog title Are you sure you want to remove this tracker? Remove Trackers Dialog title Are you sure you want to remove %Ln selected trackers? %Ln is number of trackers selected for deletion Down Speed Torrents list column name ---------- Peers list column title Up Speed Torrents list column name ---------- Peers list column title Progress Bar Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title Flags Peers list column title Client Peers list column title Timed out Server connection status Connection error Server connection status Authentication error Server connection status Parse error Server connection status Server is too new Server connection status Server is too old Server connection status Connecting... Server connection status Connected Server connection status Downloading Torrent status Torrent status Seeding Torrent status Torrent status Queued for checking Torrent status Merge trackers? Dialog title Torrent «%1» is already added, merge trackers? Merge Do not ask again Merged trackers Dialog title Merged trackers for torrent «%1» Torrent already exists Dialog title Torrent «%1» already exists Error reading torrent file Error parsing torrent file Total Size Torrents list column name Queue Position Torrents list column name Added on Torrents list column name, date/time when torrent was added Completed on Torrents list column name, date/time when torrent was completed Down Limit Torrents list column name, download speed limit Up Limit Torrents list column name, upload speed limit Remaining Torrents list column name, remaining byte size Download Directory Torrents list column name Last Activity Torrents list column name Inactive Tracker status Waiting for update Tracker status About to update Tracker status Updating Tracker status Next Update Trackers list column title %L1 B Size suffix in bytes %L1 KiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in kibibytes %L1 MiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in mebibytes %L1 GiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in gibibytes %L1 TiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in tebibytes %L1 PiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in pebibytes %L1 EiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in exbibytes %L1 ZiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in zebibytes %L1 YiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in yobibytes %L1 B/s Download speed suffix in bytes per second %L1 KiB/s Download speed suffix in kibibytes per second %L1 MiB/s Download speed suffix in mebibytes per second %L1 GiB/s Download speed suffix in gibibytes per second %L1 TiB/s Download speed suffix in tebibytes per second %L1 PiB/s Download speed suffix in pebibytes per second %L1 EiB/s Download speed suffix in exbibytes per second %L1 ZiB/s Download speed suffix in zebibytes per second %L1 YiB/s Download speed suffix in yobibytes per second %L1% Progress in percents. %L1 must remain unchanged, % after it is a percent character %L1 d %L2 h Remaining time string. %L1 is days, %L2 is hours, e.g. "2 d 5 h" %L1 h %L2 m Remaining time string. %L1 is hours, %L2 is minutes, e.g. "2 h 5 m" %L1 m %L2 s Remaining time string. %L1 is minutes, %L2 is seconds, e.g. "2 m 5 s" %L1 s Remaining time string. %L1 is seconds, "10 s" Today Yesterday Two days ago %1 at %2 Relative date & time Just now Relative time %n minute(s) ago @item:intext %1 is a whole number %n minutes ago %n minute ago Error opening %1 File opening error, %1 is a file path ---------- Directory opening error, %1 is a file path Move files from current directory Check box label Selected directory should be inside mounted directory All (%L1) Filter option of torrents list's tracker filter. %L1 is total number of torrents ---------- Filter option of torrents list's download directory filter. %L1 is total number of torrents ---------- Filter option of torrents list's label filter. %L1 is total number of torrents ---------- Filter option of torrents list's status filter. %L1 is total number of torrents %1 (%L2) Filter option of torrents list's tracker filter. %1 is tracker domain name, %L2 is number of torrents with that tracker ---------- Filter option of torrents list's label filter. %1 is label, %L2 is number of torrents with that label This directory does not exist Edit Labels New label... tremotesf-2.8.2/translations/tr.ts000066400000000000000000003454311500171105600172570ustar00rootroot00000000000000 tremotesf About "About" dialog title ---------- "About" dialog's "About" tab title Hakkında <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Source code: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Translations: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> "About" dialog text <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Kaynak Kod: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Çeviriler: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> Maintainer Geliştiren Contributor Katkıda bulunan Authors "About" dialog's "Authors" tab title Yazarlar Translators "About" dialog's "Translators" tab title Çevirmenler License "About" dialog's "License" tab title Lisans Add Torrent File Dialog title Torrent Dosyası Ekle Free space: %1 %1 is a amount of free space in a directory, e.g. 1 GiB Boş alan: %1 Error getting free space Boş alan alınırken hata oluştu Files Torrent properties dialog tab Dosyalar High Torrent's file loading priority ---------- Torrent's loading priority Yüksek Normal Torrent's file loading priority ---------- Torrent's loading priority Normal Low Torrent's file loading priority ---------- Torrent's loading priority Düşük Start downloading after adding Ekledikten sonra indirmeye başla Add Torrent Link Dialog title Torrent Bağlantısı Ekle No servers Sunucu yok Disconnected Server connection status ---------- Notification title when disconnected from server ---------- Message that appears when disconnected from server Bağlantı kesildi Downloading Noun "Downloading" server setting page İndiriliyor Start added torrents Check box label Eklenen torrentleri başlat Append ".part" to names of incomplete files Check box label Tamamlanmamış dosyaların adlarına ".part" ekle Rename Dialog title ---------- Dialog confirmation button Yeniden Adlandır Select Directory Directory chooser dialog title Dizin Seçiniz Status Title of torrents status filters list ---------- Torrents list column name ---------- Trackers list column title Durum Directories Title of torrents download directory filters list Dizinler Trackers Title of torrents tracker filters list ---------- Torrent properties dialog tab İzleyiciler Priority Column title in torrent's file list ---------- Torrents list column name Öncelik Mixed Torrent's file loading priority ---------- File loading priority Karışık No torrents Torrents list placeholder Torrent yok Remove Button ---------- Dialog confirmation button Kaldır Set Location Dialog title for changing torrent's download directory Konum Ayarla Error adding torrent Torrent eklerken hata oluştu This torrent is already added Bu torrent zaten eklendi Torrent added Notification title Torrent eklendi Torrent finished Notification title Torrent bitti Network "Network" server settings page Connection Title of settings section related to peer connections Bağlantı Random port on Transmission start Check box label Transmission başlangıcında rastgele bağlantı noktası Enable port forwarding Check box label Bağlantı noktası yönlendirmeyi etkinleştir Allow Encryption mode (allow/prefer/require) İzin ver Prefer Encryption mode (allow/prefer/require) Tercih et Require Encryption mode (allow/prefer/require) Gerekli Enable DHT Check box label DHT'yi Etkinleştir Peer Limits Eş Limitleri Peers Torrent's properties dialog tab ---------- Torrent's limits tab section ---------- Trackers list column title Eşler Queue "Queue" server settings page Kuyruk Also delete the files on the hard disk Check box label Sabit diskteki dosyaları da silin Seeding Noun "Seeding" server setting page Gönderiliyor Overwrite Dialog's confirmation button Üzerine Yaz Server already exists Sunucu zaten mevcut Add Server Dialog title Sunucu Ekle Name Column title in torrent's file list ---------- Torrents list column name Ad Address Peers list column title ---------- Trackers list column title Adres Default Default proxy option Varsayılan HTTPS HTTPS SOCKS5 SOCKS5 proxy option SOCKS5 Server uses self-signed certificate Check box label Sunucu kendinden imzalı sertifika kullanır Server's certificate in PEM format Text field placeholder Sunucunun PEM biçimindeki sertifikası Load from file... Button Dosyadan yükle... Use client certificate authentication Check box label İstemci sertifikası kimlik doğrulamasını kullan Certificate in PEM format with private key Text field placeholder Özel anahtarlı PEM formatında sertifika Authentication Check box label Kimlik Doğrula Auto reconnect on error Check box label Hata durumunda otomatik yeniden bağlan Mounted directories Bağlanmış dizinler Local directory Column title in the list of mounted directories Yerel dizin Remote directory Column title in the list of mounted directories Uzak dizin Add Button ---------- Dialog confirmation button Ekle Speed "Speed" server settings page ---------- Torrent's limits tab section Hız Connection Settings Servers list placeholder Dialog title Bağlantı Ayarları Edit... Button Düzenle... Add Server... Button Sunucu Ekle... Add... Button Ekle... Connect to server on startup Check box label Başlangıçta sunucuya bağlan Adding torrents Options tab Torrent ekleme Add torrent parameters Torrent parametreleri ekle Reset Sıfırla Show main window when adding torrents Check box label Torrent eklerken ana pencereyi göster Show dialog when adding torrents Check box label Torrent eklerken iletişim kutusunu göster Automatically fill link from clipboard when adding torrent link Check box label Torrent bağlantısı eklerken bağlantıyı panodan otomatik olarak doldur Tip: you can also press %1 in main window to add torrents from clipboard %1 is a key binding, e.g. "Ctrl + C" İpucu: Torrentleri panodan eklemek için ana pencerede %1 basabilirsiniz Ask for merging trackers when adding existing torrent Check box label Mevcut torrent eklerken izleyicileri birleştirmeyi iste Merge trackers when adding existing torrent Check box label Mevcut torrent eklenirken izleyicileri birleştir Open properties dialog Özellikler iletişim kutusunu aç Open torrent's file Torrent dosyasını aç Open download directory İndirme dizinini aç What to do when torrent in the list is double clicked: Listedeki torrent çift tıklandığında ne yapılmalı: General Options tab Genel Show torrent properties in a panel in the main window Check box label Torrent özelliklerini ana penceredeki panelde göster Properties dialog won't be shown because torrent properties are shown in the main window Torrent özellikleri ana pencerede gösterildiğinden özellikler iletişim kutusu gösterilmez Display relative time Check box label Bağıl zamanı göster Display full path of download directories in sidebar and torrents list Check box label Kenar çubuğunda ve torrentler listesinde indirme dizinlerinin tam yolunu göster Notifications Options tab Bildirimler Notify when disconnecting from server Check box label Sunucu ile bağlantı kesildiğinde bildir Notify on added torrents Check box label Torrentler eklendiğinde bildir Notify on finished torrents Check box label Torrentler bittiğinde bildir When connecting to server Notifications options section Sunucuya bağlanırken Notify on added torrents since last connection to server Check box label Sunucuya son bağlantıdan bu yana eklenen torrentler hakkında bildir Notify on finished torrents since last connection to server Check box label Sunucuya son bağlantıdan bu yana tamamlanan torrentler hakkında bildir Progress Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title İlerleme ETA Torrents list column name ETA Server Stats Dialog title Sunucu İstatistikleri Current session Server stats section for current Transmission launch Güncel oturum Ratio Torrents list column name Oran Total Server stats section for all Transmission launches (accumulated) Toplam %Ln times How many times Transmission was launched %Ln kez%Ln kez Size Column title in torrent's file list ---------- Torrents list column name Boyut Limits Speed limits section ---------- Torrent's properties dialog tab Limitler Alternative Limits Alternative speed limits section Alternatif Limitler Enable Check box label Etkinleştir Scheduled Title of alternative speed limit scheduling section Planlanmış to Separates time range input fields. E.g. "to" inside "1:00 AM to 5:00 AM" için Every day Her gün Weekdays Hafta içi Weekends Hafta Sonları %1 of %2 (%3) Torrent's completion size, e.g. 100 MiB of 200 MiB (50%). %1 is completed size, %2 is size, %3 is progress in percents %1 of %2 (%3) Checking (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Kontrol (%L1) Honor global limits Check box label Küresel sınırlara uy Use global settings Seeding ratio limit mode (global settings/stop at ratio/unlimited) ---------- Seeding idle limit mode (global settings/stop if idle for/unlimited) Genel ayarları kullan Seed regardless of ratio Seeding ratio limit mode (global settings/stop at ratio/unlimited) Oran ne olursa olsun gönder Stop seeding at ratio: Seeding ratio limit mode (global settings/stop at ratio/unlimited) Orana göre göndermeyi durdur: Seed regardless of activity Seeding idle limit mode (global settings/stop if idle for/unlimited) Aktiviteden bağımsız olarak gönder Stop seeding if idle for: Seeding idle limit mode (global settings/stop if idle for/unlimited) Boşta kalırsa göndermeyi durdur: Activity Torrent's details tab section Aktivite Completed Torrents list column name, completed byte size Tamamlandı Downloaded Torrents list column name, downloaded byte size İndirildi Paused (%1) Torrent status while torrent also has an error. %1 is error string Duraklatıldı (%1) Paused Torrent status Duraklatıldı Downloading (%1) Torrent status while torrent also has an error. %1 is error string İndiriliyor (%1) Seeding (%1) Torrent status while torrent also has an error. %1 is error string Gönderiliyor (%1) Queued (%1) Torrent status while torrent also has an error. %1 is error string Kuyrukta (%1) Queued Torrent status Kuyrukta Checking (%1) Torrent status while torrent also has an error. %1 is error string Kontrol ediliyor (%1) Checking Torrent status Kontrol Queued for checking (%1) Torrent status while torrent also has an error. %1 is error string Kontrol için kuyruğa alındı (%1) Downloading to peers Torrents list column name, number of peers that we are downloading from Eşlere indiriliyor Uploading to peers Torrents list column name, number of peers that we are uploading to Eşlere yükleniyor Uploaded Torrents list column name, uploaded byte size Yüklendi Seeders Torrents list column name, number of seeders reported by trackers ---------- Trackers list column title Göndericiler Leechers Torrents list column name, number of leechers reported by trackers ---------- Trackers list column title İndirenler Information Torrent's details tab section Bilgi Torrent Removed Message that appears when torrent is removed Torrent Kaldırıldı Edit Tracker Dialog title İzleyiciyi Düzenle Torrent file: Input field's label Torrent dosyası: Torrent link: Input field's label Torrent bağlantısı: Download directory: Input field's label İndirme Dizini: Torrent priority: Combo box label Torrent önceliği: Delete .torrent file .torrent dosyasını sil Move .torrent file to trash .torrent dosyasını çöp kutusuna taşı Labels Title of torrents label filters list Etiketler Loading Placeholder shown when torrent file is being read/parsed Yükleniyor &Connect Button / menu item to connect to server &Bağlan &Disconnect Button / menu item to disconnect from server &Bağlantıyı Kes &Add Torrent File... Menu item &Torrent Dosyası Ekle... Add Torrent &Link... Menu item Torrent &Bağlantı Ekle... P&ause Torrent's context menu item &Durdur &Delete Torrent's context menu item &Sil Open &Download Directory Context menu item Aç &Dizine İndir Delete with files Dosyalarla sil Delete Sil Delete Torrent Dialog title Torrent'i Sil Are you sure you want to delete this torrent? Bu torrenti silmek istediğinizden emin misiniz? Delete Torrents Dialog title Torrentleri Sil Are you sure you want to delete %Ln selected torrents? %Ln is a number of torrents selected for deletion Are you sure you want to delete %Ln selected torrents?Seçili %Ln torrentleri silmek istediğinizden emin misiniz? No torrents matching filters Torrents list placeholder Filtrelerle eşleşen torrent yok &Quit Menu item &Çık &Torrent Menu bar item &Torrent Error adding torrent «%1» Torrent «%1» eklenirken hata oluştu &Properties Torrent's context menu item &Özellikler &Show Tremotesf &Tremotesf'i Göster &Hide Tremotesf &Tremotesf'i Gizle &Start Torrent's context menu item &Başlat Start &Now Torrent's context menu item Başlat &Şimdi Copy &Magnet Link Torrent's context menu item Kopyala &Magnet Bağlantısı &Remove Server's context menu item ---------- Context menu item ---------- Tracker's context menu item &Kaldır Set &Location Torrent's context menu item &Konum Ayarla Edi&t Labels Torrent's context menu item Etiketleri Düzen&le &Open Torrent's context menu item ---------- Context menu item &Aç Op&en Download Directory Torrent's context menu item Aç &Dizine İndir &Check Local Data Torrent's context menu item &Yerel Verileri Kontrol Et Reanno&unce Torrent's context menu item ---------- Button Yeniden &duyur &Queue Torrent's context menu item &Kuyruk Move To &Top Torrent's context menu item Taşı &Üste Move &Up Torrent's context menu item Taşı &Yukarı Move &Down Torrent's context menu item Taşı &Aşağı Move To &Bottom Torrent's context menu item Taşı &Alta  &Connection Settings &Bağlantı Ayarları &Server Options &Sunucu Seçenekleri Server S&tats Sunucu İ&statistikleri Select Files File chooser dialog title Dosyaları Seç Torrent Files (*.torrent) Torrent file type. Parentheses and text within them must remain unchanged Torrent Dosyaları (*.torrent) Error Dialog title ---------- Trackers list column title Hata This file/directory does not exist Bu dosya/dizin mevcut değil &File Menu bar item &Dosya &Close Window &Pencereyi Kapat &Edit Menu bar item &Düzenle Select &All &Tümü Seç &Invert Selection &Seçimi Ters Çevir &View Menu bar item &Görünüm &Toolbar &Araç Çubuğu &Sidebar &Kenar Çubuğu St&atusbar Du&rum çubuğu Torrent properties &panel Torrent özellikleri &panel &Lock Toolbar Araç Çubuğunu &Kilitle T&ools Menu bar item A&raçlar &Options &Seçenekler S&hutdown Server Sunucuyu K&apat Shutdown Server Dialog title Sunucuyu Kapat Are you sure you want to shutdown remote Transmission instance? Transmissionu kapatmak istediğinizden emin misiniz? Shutdown Dialog confirmation button Kapat &Help Menu bar item &Yardım &About Menu item opening "About" dialog &Hakkında Icon Only Toolbar mode Yalnızca Simge Text Only Toolbar mode Sadece Metin Text Beside Icon Toolbar mode Simge Yanında Metin Text Under Icon Toolbar mode Simge Altında Metin Follow System Style Toolbar mode Sistem Stilini Takip Et Torrents will be added after connection to server Message shown when user attempts to add torrent while disconnect from server. Torrentler sunucuya bağlandıktan sonra eklenecektir Show Tremotesf Button on notification Tremotesf'i Göster Active (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Aktif (%L1) Downloading (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status İndiriliyor (%L1) Seeding (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Gönderiliyor (%L1) Paused (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Duraklatıldı (%L1) Errored (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status Hatalı (%L1) Search... Search field placeholder Ara... &Select... Context menu item to open directory chooser &Seç... Overwrite Server Dialog title Sunucunun Üzerine Yaz Name: İsim: Address: Adres: Port: Port: API path: API yolu: Proxy Proxy HTTP HTTP proxy option HTTP None None proxy option Hiçbiri Proxy type: Proxy türü: Username: Kullanıcı adı: Password: Şifre: s Suffix that is added to input field with number of seconds, e.g. "30 s" sn Update interval: Güncelleme aralığı: Timeout: Zaman aşımı: Auto reconnect interval: Otomatik yeniden bağlanma aralığı: &Edit... Server's context menu item ---------- Tracker's context menu item &Düzenle... Server Options Dialog title Sunucu Seçenekleri Directory for incomplete files: Tamamlanmamış dosyalar için dizin: min Suffix that is added to input field with number of minuts, e.g. "5 min" dk Maximum active downloads: Maksimum aktif indirme: Maximum active uploads: Maksimum aktif yükleme: Ignore queue position if idle for: Boşta ise kuyruk konumunu yoksay: kB/s Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes kB/sn Download: Download speed limit input field label İndirme: Upload: Upload speed limit input field label Yükleme: Days: Günler: Peer port: Eş bağlantı noktası: Encryption: Şifreleme: Enable μTP (Micro Transport Protocol) Check box label μTP'yi (Mikro Aktarım Protokolü) etkinleştirin Enable PEX (Peer exchange) Check box label PEX'i (Eş değişimi) etkinleştirin Enable local peer discovery Check box label Yerel eş bulmayı etkinleştir Maximum peers per torrent: Torrent başına maksimum eşler: Maximum peers globally: Küresel maksimum eşler: Options Dialog title Seçenekler Follow system Dark theme mode Sistemi takip et On Dark theme mode Açık Off Dark theme mode Kapalı Dark theme Koyu tema Use system accent color Check box label Sistem vurgu rengini kullanın Remember location of last opened torrent file Check box label Son açılan torrent dosyasının konumunu hatırla Remember parameters of last added torrent Check box label Son eklenen torrentin parametrelerini hatırla Show icon in the notification area Check box label Bildirim alanında simge göster &Not Download Context menu item to unselect file for downloading &İndirme &Priority Torrent's context menu item &Öncelik &Download Context menu item to select file for downloading &İndir &High File loading priority &Yüksek &Normal File loading priority &Normal &Low File loading priority &Düşük &Rename Torrent's context menu item ---------- Context menu item &Yeniden Adlandır File name: Dosya adı: Details Torrent's properties dialog tab Detaylar Completed: Torrent's completed size Tamamlandı: Downloaded: Downloaded bytes ---------- Torrent's downloaded size İndirildi: Uploaded: Uploaded bytes ---------- Torrent's uploaded size Yüklendi: Ratio: Oran: Duration: How much time Transmission is running Süre: Started: How many times Transmission was launched Başladı: Free space in download directory: İndirme dizininde boş alan: Download speed: İndirme hızı: Upload speed: Yükleme hızı: ETA: ETA: Seeders: Göndericiler: Leechers: İndirenler: Peers we are downloading from: İndirdiğimiz eşler: Web seeders we are downloading from: İndirdiğimiz web göndericileri: Peers we are uploading to: Yüklediğimiz eşler: Last activity: Son etkinlik: Total size: Toplam Boyut: Location: Torrent's download directory Konum: Hash: Torrent's hash string Hash: Created by: Program that created torrent file Tarafından oluşturuldu: Created on: Date/time when torrent was created Oluşturuldu: Comment: Torrent's comment text Yorum: Labels: Torrent's labels Etiketler: Web seeder Web seeders list column title Web gönderici Web seeders Torrent's properties dialog tab Web göndericileri Seeding Options section Torrent's limits tab section Gönderiliyor Ratio limit mode: Oran sınırı modu: Idle seeding mode: Boşta gönderici modu: Maximum peers: Maksimum eşler: Add Trackers Dialog title İzleyici Ekle Trackers announce URLs: İzleyiciler URL'leri duyur: Tracker announce URL: İzleyici URL'yi duyur: Remove Tracker Dialog title İzleyiciyi Kaldır Are you sure you want to remove this tracker? Bu izleyiciyi kaldırmak istediğinizden emin misiniz? Remove Trackers Dialog title İzleyicileri Kaldır Are you sure you want to remove %Ln selected trackers? %Ln is number of trackers selected for deletion Are you sure you want to remove %Ln selected trackers?Seçili %Ln izleyicileri kaldırmak istediğinizden emin misiniz? Down Speed Torrents list column name ---------- Peers list column title İndirme Hızı Up Speed Torrents list column name ---------- Peers list column title Yükleme Hızı Progress Bar Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title İlerleme Çubuğu Flags Peers list column title Bayraklar Client Peers list column title İstemci Timed out Server connection status Zaman aşımına uğradı Connection error Server connection status Bağlantı hatası Authentication error Server connection status Kimlik doğrulama hatası Parse error Server connection status Ayrıştırma hatası Server is too new Server connection status Sunucu çok yeni Server is too old Server connection status Sunucu çok eski Connecting... Server connection status Bağlanıyor... Connected Server connection status Bağlı Downloading Torrent status Torrent status İndiriliyor Seeding Torrent status Torrent status Gönderiliyor Queued for checking Torrent status Kontrol için sıraya alındı Merge trackers? Dialog title İzleyicileri birleştirelim mi? Torrent «%1» is already added, merge trackers? Torrent «%1» zaten eklendi, izleyicileri birleştirelim mi? Merge Birleştir Do not ask again Bir daha sorma Merged trackers Dialog title İzleyiciler birleştirildi Merged trackers for torrent «%1» Torrent «%1» için izleyiciler birleştirildi Torrent already exists Dialog title Torrent zaten mevcut Torrent «%1» already exists Torrent «%1» zaten mevcut Error reading torrent file Torrent dosyası okunurken hata oluştu Error parsing torrent file Torrent dosyası ayrıştırılırken hata oluştu Total Size Torrents list column name Toplam Boyut Queue Position Torrents list column name Kuyruk Pozisyonu Added on Torrents list column name, date/time when torrent was added Eklendi Completed on Torrents list column name, date/time when torrent was completed Tamamlandı Down Limit Torrents list column name, download speed limit İndirme Limiti Up Limit Torrents list column name, upload speed limit Yükleme Limiti Remaining Torrents list column name, remaining byte size Kalan Download Directory Torrents list column name İndirme Dizini Last Activity Torrents list column name Son Etkinlik Inactive Tracker status Aktif değil Waiting for update Tracker status Güncelleme için bekleniyor About to update Tracker status Güncellenmek üzere Updating Tracker status Güncelleniyor Next Update Trackers list column title Sonraki Güncelleme %L1 B Size suffix in bytes %L1 B %L1 KiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in kibibytes %L1 KB %L1 MiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in mebibytes %L1 MB %L1 GiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in gibibytes %L1 GB %L1 TiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in tebibytes %L1 TB %L1 PiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in pebibytes %L1 PB %L1 EiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in exbibytes %L1 EB %L1 ZiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in zebibytes %L1 ZB %L1 YiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in yobibytes %L1 YB %L1 B/s Download speed suffix in bytes per second %L1 B/sn %L1 KiB/s Download speed suffix in kibibytes per second %L1 KB/sn %L1 MiB/s Download speed suffix in mebibytes per second %L1 MB/sn %L1 GiB/s Download speed suffix in gibibytes per second %L1 GB/sn %L1 TiB/s Download speed suffix in tebibytes per second %L1 TB/sn %L1 PiB/s Download speed suffix in pebibytes per second %L1 PB/sn %L1 EiB/s Download speed suffix in exbibytes per second %L1 EB/sn %L1 ZiB/s Download speed suffix in zebibytes per second %L1 ZB/sn %L1 YiB/s Download speed suffix in yobibytes per second %L1 YB/sn %L1% Progress in percents. %L1 must remain unchanged, % after it is a percent character %L1% %L1 d %L2 h Remaining time string. %L1 is days, %L2 is hours, e.g. "2 d 5 h" %L1 g %L2 s %L1 h %L2 m Remaining time string. %L1 is hours, %L2 is minutes, e.g. "2 h 5 m" %L1 s %L2 dk %L1 m %L2 s Remaining time string. %L1 is minutes, %L2 is seconds, e.g. "2 m 5 s" %L1 dk %L2 sn %L1 s Remaining time string. %L1 is seconds, "10 s" %L1 sn Today Bugün Yesterday Dün Two days ago İki gün önce %1 at %2 Relative date & time %2'de %1 Just now Relative time Şimdi %n minute(s) ago @item:intext %1 is a whole number %n dakika(lar) önce%n dakika(lar) önce %n minutes ago %n minute ago Error opening %1 File opening error, %1 is a file path ---------- Directory opening error, %1 is a file path %1 açılırken hata oluştu Move files from current directory Check box label Dosyaları geçerli dizinden taşı Selected directory should be inside mounted directory Seçilen dizin bağlı dizinin içinde olmalıdır All (%L1) Filter option of torrents list's tracker filter. %L1 is total number of torrents ---------- Filter option of torrents list's download directory filter. %L1 is total number of torrents ---------- Filter option of torrents list's label filter. %L1 is total number of torrents ---------- Filter option of torrents list's status filter. %L1 is total number of torrents Tümü (%L1) %1 (%L2) Filter option of torrents list's tracker filter. %1 is tracker domain name, %L2 is number of torrents with that tracker ---------- Filter option of torrents list's label filter. %1 is label, %L2 is number of torrents with that label %1 (%L2) This directory does not exist Bu dizin mevcut değil Edit Labels Etiketleri Düzenle New label... Yeni etiket... tremotesf-2.8.2/translations/zh_CN.ts000066400000000000000000003411341500171105600176270ustar00rootroot00000000000000 tremotesf About "About" dialog title ---------- "About" dialog's "About" tab title 关于 <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>Source code: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>Translations: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> "About" dialog text <p>&#169; 2015-2024 Alexey Rochev &lt;<a href="mailto:equeim@gmail.com">equeim@gmail.com</a>&gt;</p> <p>源代码: <a href="https://github.com/equeim/tremotesf2">https://github.com/equeim/tremotesf2</a></p> <p>翻译: <a href="https://www.transifex.com/equeim/tremotesf">https://www.transifex.com/equeim/tremotesf</a></p> Maintainer 维护者 Contributor 贡献者 Authors "About" dialog's "Authors" tab title 作者 Translators "About" dialog's "Translators" tab title 翻译 License "About" dialog's "License" tab title 许可 Add Torrent File Dialog title 添加Torrent Free space: %1 %1 is a amount of free space in a directory, e.g. 1 GiB 可用空间: %1 Error getting free space 获取可用空间时出错 Files Torrent properties dialog tab 文件 High Torrent's file loading priority ---------- Torrent's loading priority Normal Torrent's file loading priority ---------- Torrent's loading priority Low Torrent's file loading priority ---------- Torrent's loading priority Start downloading after adding 添加后开始下载 Add Torrent Link Dialog title 添加Torrent链接 No servers 没有服务器 Disconnected Server connection status ---------- Notification title when disconnected from server ---------- Message that appears when disconnected from server 断开 Downloading Noun "Downloading" server setting page 下载 Start added torrents Check box label 自动开始新添加的种子 Append ".part" to names of incomplete files Check box label 在未完成的文件名后加上“.part”后缀 Rename Dialog title ---------- Dialog confirmation button 重命名 Select Directory Directory chooser dialog title 选择目录 Status Title of torrents status filters list ---------- Torrents list column name ---------- Trackers list column title 状态 Directories Title of torrents download directory filters list 数据目录 Trackers Title of torrents tracker filters list ---------- Torrent properties dialog tab Trackers Priority Column title in torrent's file list ---------- Torrents list column name 优先级 Mixed Torrent's file loading priority ---------- File loading priority 混合 No torrents Torrents list placeholder 没有torrents Remove Button ---------- Dialog confirmation button 删除 Set Location Dialog title for changing torrent's download directory 设置数据位置 Error adding torrent 添加Torrent时出错 This torrent is already added 这个torrent已经添加了 Torrent added Notification title 添加Torrent Torrent finished Notification title Torrent完成 Network "Network" server settings page 网络 Connection Title of settings section related to peer connections 连接 Random port on Transmission start Check box label 启用随机端口 Enable port forwarding Check box label 启用端口转发 Allow Encryption mode (allow/prefer/require) 关闭加密 Prefer Encryption mode (allow/prefer/require) 开启加密 Require Encryption mode (allow/prefer/require) 强制加密 Enable DHT Check box label 启用 DHT Peer Limits 连接限制 Peers Torrent's properties dialog tab ---------- Torrent's limits tab section ---------- Trackers list column title 连接 Queue "Queue" server settings page 传输队列 Also delete the files on the hard disk Check box label 同时删除硬盘上的文件 Seeding Noun "Seeding" server setting page 做种 Overwrite Dialog's confirmation button 覆盖 Server already exists 服务器已存在 Add Server Dialog title 添加服务器 Name Column title in torrent's file list ---------- Torrents list column name 名称 Address Peers list column title ---------- Trackers list column title 地址 Default Default proxy option 默认 HTTPS HTTPS SOCKS5 SOCKS5 proxy option SOCKS5 Server uses self-signed certificate Check box label 服务器使用自签名证书 Server's certificate in PEM format Text field placeholder PEM格式的服务器证书 Load from file... Button 从文件加载.. Use client certificate authentication Check box label 使用客户端证书认证 Certificate in PEM format with private key Text field placeholder 具有私钥的PEM格式的证书 Authentication Check box label 验证 Auto reconnect on error Check box label 出错时自动重新连接 Mounted directories 安装目录 Local directory Column title in the list of mounted directories 本地目录 Remote directory Column title in the list of mounted directories 远程目录 Add Button ---------- Dialog confirmation button 添加 Speed "Speed" server settings page ---------- Torrent's limits tab section 速度 Connection Settings Servers list placeholder Dialog title 连接设置 Edit... Button 编辑… Add Server... Button 添加服务器... Add... Button 添加… Connect to server on startup Check box label 启动时连接到服务器 Adding torrents Options tab 添加种子 Add torrent parameters 添加种子参数 Reset 重置 Show main window when adding torrents Check box label 在添加种子时显示主窗口 Show dialog when adding torrents Check box label 在添加种子时显示对话框 Automatically fill link from clipboard when adding torrent link Check box label 自动填充从剪贴板添加链接 Tip: you can also press %1 in main window to add torrents from clipboard %1 is a key binding, e.g. "Ctrl + C" 提示:你也可以在主窗口按 %1 从剪贴板中添加种子 Ask for merging trackers when adding existing torrent Check box label Merge trackers when adding existing torrent Check box label Open properties dialog 打开属性对话框 Open torrent's file 打开 Torrent 的文件 Open download directory 打开下载目录 What to do when torrent in the list is double clicked: 双击列表中的种子时该怎么做: General Options tab Show torrent properties in a panel in the main window Check box label Properties dialog won't be shown because torrent properties are shown in the main window Display relative time Check box label Display full path of download directories in sidebar and torrents list Check box label Notifications Options tab 通知 Notify when disconnecting from server Check box label 与服务器断开连接时通知 Notify on added torrents Check box label 通知添加的Torrents Notify on finished torrents Check box label 通知已完成的Torrents When connecting to server Notifications options section 连接到服务器时 Notify on added torrents since last connection to server Check box label 通知自上次连接到服务器后添加的种子 Notify on finished torrents since last connection to server Check box label 通知自上次连接到服务器完成的种子 Progress Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title 进度 ETA Torrents list column name 预计剩余时间 Server Stats Dialog title 服务器统计 Current session Server stats section for current Transmission launch 当前会话 Ratio Torrents list column name 分享率 Total Server stats section for all Transmission launches (accumulated) 累计 %Ln times How many times Transmission was launched %Ln 次 Size Column title in torrent's file list ---------- Torrents list column name 大小 Limits Speed limits section ---------- Torrent's properties dialog tab 限制 Alternative Limits Alternative speed limits section 备选速度限制 Enable Check box label 启用 Scheduled Title of alternative speed limit scheduling section 定时计划 to Separates time range input fields. E.g. "to" inside "1:00 AM to 5:00 AM" Every day 每一天 Weekdays 工作日 Weekends 周末 %1 of %2 (%3) Torrent's completion size, e.g. 100 MiB of 200 MiB (50%). %1 is completed size, %2 is size, %3 is progress in percents %1 / %2 (%3) Checking (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status 校验中 (%L1) Honor global limits Check box label 以全局设置为准 Use global settings Seeding ratio limit mode (global settings/stop at ratio/unlimited) ---------- Seeding idle limit mode (global settings/stop if idle for/unlimited) 使用全局设置 Seed regardless of ratio Seeding ratio limit mode (global settings/stop at ratio/unlimited) 种子与分享率无关 Stop seeding at ratio: Seeding ratio limit mode (global settings/stop at ratio/unlimited) 停止做种的比率: Seed regardless of activity Seeding idle limit mode (global settings/stop if idle for/unlimited) 种子与活动无关 Stop seeding if idle for: Seeding idle limit mode (global settings/stop if idle for/unlimited) 无活动自动停止: Activity Torrent's details tab section 活动 Completed Torrents list column name, completed byte size 已完成 Downloaded Torrents list column name, downloaded byte size 已下载 Paused (%1) Torrent status while torrent also has an error. %1 is error string 暂停 (%1) Paused Torrent status 暂停 Downloading (%1) Torrent status while torrent also has an error. %1 is error string 下载中 (%1) Seeding (%1) Torrent status while torrent also has an error. %1 is error string 做种(%1) Queued (%1) Torrent status while torrent also has an error. %1 is error string 队列中(%1) Queued Torrent status 队列 Checking (%1) Torrent status while torrent also has an error. %1 is error string 校验(%1) Checking Torrent status 校验 Queued for checking (%1) Torrent status while torrent also has an error. %1 is error string 排队检查 (%1) Downloading to peers Torrents list column name, number of peers that we are downloading from 正在下载 Uploading to peers Torrents list column name, number of peers that we are uploading to 正在上传 Uploaded Torrents list column name, uploaded byte size 已上传 Seeders Torrents list column name, number of seeders reported by trackers ---------- Trackers list column title 种子 Leechers Torrents list column name, number of leechers reported by trackers ---------- Trackers list column title 连接 Information Torrent's details tab section 信息 Torrent Removed Message that appears when torrent is removed 移除Torrent Edit Tracker Dialog title 编辑Tracker Torrent file: Input field's label torrent文件: Torrent link: Input field's label Torrent链接: Download directory: Input field's label 下载目录: Torrent priority: Combo box label Torrent优先级: Delete .torrent file 删除. 种子文件 Move .torrent file to trash 将种子文件移动到垃圾桶 Labels Title of torrents label filters list Loading Placeholder shown when torrent file is being read/parsed 正在加载 &Connect Button / menu item to connect to server &连接 &Disconnect Button / menu item to disconnect from server &断开 &Add Torrent File... Menu item &添加Torrent文件… Add Torrent &Link... Menu item 添加Torrent&链接… P&ause Torrent's context menu item &暂停 &Delete Torrent's context menu item &删除 Open &Download Directory Context menu item 打开 &下载目录 Delete with files 同时删除文件 Delete 删除 Delete Torrent Dialog title 删除种子 Are you sure you want to delete this torrent? 你确定要删除这个种子吗? Delete Torrents Dialog title 删除多个种子 Are you sure you want to delete %Ln selected torrents? %Ln is a number of torrents selected for deletion 确实要删除 %Ln 选定的种子吗? No torrents matching filters Torrents list placeholder 没有与过滤匹配的种子 &Quit Menu item &退出 &Torrent Menu bar item &种子 Error adding torrent «%1» &Properties Torrent's context menu item &属性 &Show Tremotesf &显示 Tremotesf &Hide Tremotesf &隐藏 Tremotesf &Start Torrent's context menu item &开始 Start &Now Torrent's context menu item 强制&开始 Copy &Magnet Link Torrent's context menu item 复制 &磁力链接 &Remove Server's context menu item ---------- Context menu item ---------- Tracker's context menu item &删除 Set &Location Torrent's context menu item 设置&保存位置 Edi&t Labels Torrent's context menu item &Open Torrent's context menu item ---------- Context menu item &打开 Op&en Download Directory Torrent's context menu item &Check Local Data Torrent's context menu item &校验本地数据 Reanno&unce Torrent's context menu item ---------- Button 刷新&Trackers &Queue Torrent's context menu item &队列 Move To &Top Torrent's context menu item 移到&最前 Move &Up Torrent's context menu item 上&移 Move &Down Torrent's context menu item 下&移 Move To &Bottom Torrent's context menu item 移到&最后 &Connection Settings &连接设置 &Server Options &服务器选项 Server S&tats 统计&数据 Select Files File chooser dialog title 选择文件 Torrent Files (*.torrent) Torrent file type. Parentheses and text within them must remain unchanged Torrent 文件 (*.torrent) Error Dialog title ---------- Trackers list column title 错误 This file/directory does not exist 这个文件/目录不存在 &File Menu bar item &文件 &Close Window &关闭窗口 &Edit Menu bar item &编辑 Select &All 选择&全部 &Invert Selection &反选 &View Menu bar item &视图 &Toolbar &工具栏 &Sidebar &侧边栏 St&atusbar 状态&栏 Torrent properties &panel &Lock Toolbar &锁定工具栏 T&ools Menu bar item 工&具 &Options &选项 S&hutdown Server &关闭服务器 Shutdown Server Dialog title 关闭服务器 Are you sure you want to shutdown remote Transmission instance? 您确定要关闭Transmission吗? Shutdown Dialog confirmation button 关闭 &Help Menu bar item &帮助 &About Menu item opening "About" dialog &关于 Icon Only Toolbar mode 仅图标 Text Only Toolbar mode 仅文本 Text Beside Icon Toolbar mode 图标旁的文本 Text Under Icon Toolbar mode 图标下的文本 Follow System Style Toolbar mode 遵循系统样式 Torrents will be added after connection to server Message shown when user attempts to add torrent while disconnect from server. Show Tremotesf Button on notification 显示Tremotesf Active (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status 活动 (%L1) Downloading (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status 下载中 (%L1) Seeding (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status 做种中 (%L1) Paused (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status 暂停 (%L1) Errored (%L1) Filter option of torrents list's status filter. %L1 is a number of torrents with that status 错误(%L1) Search... Search field placeholder 搜索… &Select... Context menu item to open directory chooser &选择… Overwrite Server Dialog title 覆盖服务器 Name: 名称: Address: 地址: Port: 端口: API path: API路径: Proxy 代理 HTTP HTTP proxy option HTTP None None proxy option Proxy type: 代理类型: Username: 用户名: Password: 密码: s Suffix that is added to input field with number of seconds, e.g. "30 s" Update interval: 刷新间隔: Timeout: 超时: Auto reconnect interval: 自动重连间隔: &Edit... Server's context menu item ---------- Tracker's context menu item &编辑… Server Options Dialog title 服务器选项 Directory for incomplete files: 未完成文件目录: min Suffix that is added to input field with number of minuts, e.g. "5 min" Maximum active downloads: 最大同时下载: Maximum active uploads: 最大活动上传: Ignore queue position if idle for: 如果空闲时间超过以下时间,则忽略队列位置: kB/s Suffix that is added to input field with download/upload speed limit, e.g. "5000 kB/s". 'k' prefix means SI prefix, i.e kB = 1000 bytes kB/s Download: Download speed limit input field label 下载: Upload: Upload speed limit input field label 上传: Days: 天: Peer port: 端口: Encryption: 加密: Enable μTP (Micro Transport Protocol) Check box label 启用 μTP (Micro Transport Protocol) Enable PEX (Peer exchange) Check box label 启用 PEX (用户交换) Enable local peer discovery Check box label 启用本地对等发现 Maximum peers per torrent: 单种最大连接数 Maximum peers globally: 全局最大连接数: Options Dialog title 选项 Follow system Dark theme mode 参考系统 On Dark theme mode Off Dark theme mode Dark theme 黑暗主题 Use system accent color Check box label 使用系统主题色 Remember location of last opened torrent file Check box label 记住上次打开种子文件的位置 Remember parameters of last added torrent Check box label 记住上次添加torrent的参数 Show icon in the notification area Check box label 在通知区域显示图标 &Not Download Context menu item to unselect file for downloading &不下载 &Priority Torrent's context menu item &优先级 &Download Context menu item to select file for downloading &下载 &High File loading priority &高 &Normal File loading priority &中 &Low File loading priority &低 &Rename Torrent's context menu item ---------- Context menu item &重命名 File name: 文件名: Details Torrent's properties dialog tab 详细信息 Completed: Torrent's completed size 完成: Downloaded: Downloaded bytes ---------- Torrent's downloaded size 下载: Uploaded: Uploaded bytes ---------- Torrent's uploaded size 上传: Ratio: 分享率: Duration: How much time Transmission is running 持续时间: Started: How many times Transmission was launched 启动: Free space in download directory: 下载目录可用空间: Download speed: 下载速度: Upload speed: 上传速度: ETA: 预计剩余时间: Seeders: 做种数: Leechers: 下载数: Peers we are downloading from: 从以下网站下载: Web seeders we are downloading from: 从以下网站下载: Peers we are uploading to: 正在上传到: Last activity: 最后活动: Total size: 总大小 Location: Torrent's download directory 位置: Hash: Torrent's hash string 哈希值: Created by: Program that created torrent file 创建者: Created on: Date/time when torrent was created 创建日期: Comment: Torrent's comment text 注释: Labels: Torrent's labels Web seeder Web seeders list column title 网络播种机 Web seeders Torrent's properties dialog tab 网络播种机 Seeding Options section Torrent's limits tab section 做种 Ratio limit mode: 分享率限制模式: Idle seeding mode: 活动做种模式 Maximum peers: 最大连接数: Add Trackers Dialog title 添加 Trackers Trackers announce URLs: Trackers 宣告链接: Tracker announce URL: Trackers 宣告链接: Remove Tracker Dialog title 移除Tracker Are you sure you want to remove this tracker? 是否确实要删除此Tracker? Remove Trackers Dialog title 移除Trackers Are you sure you want to remove %Ln selected trackers? %Ln is number of trackers selected for deletion 确实要删除 %Ln 选定 trackers? Down Speed Torrents list column name ---------- Peers list column title 下载速度 Up Speed Torrents list column name ---------- Peers list column title 上传速度 Progress Bar Column title in torrent's file list ---------- Torrents list column name ---------- Peers list column title 进度条 Flags Peers list column title 标志 Client Peers list column title 客户端 Timed out Server connection status 超时 Connection error Server connection status 连接错误 Authentication error Server connection status 身份验证错误 Parse error Server connection status 解析错误 Server is too new Server connection status 服务器太新 Server is too old Server connection status 服务器太旧 Connecting... Server connection status 连接中... Connected Server connection status 已连接 Downloading Torrent status Torrent status 下载中 Seeding Torrent status Torrent status 做种中 Queued for checking Torrent status 排队等待校验 Merge trackers? Dialog title Torrent «%1» is already added, merge trackers? Merge Do not ask again Merged trackers Dialog title Merged trackers for torrent «%1» Torrent already exists Dialog title Torrent «%1» already exists Error reading torrent file 读取Torrent文件时出错 Error parsing torrent file 分析Torrent文件时出错 Total Size Torrents list column name 总大小 Queue Position Torrents list column name 队列位置 Added on Torrents list column name, date/time when torrent was added 添加时间 Completed on Torrents list column name, date/time when torrent was completed 完成时间 Down Limit Torrents list column name, download speed limit 下载限制 Up Limit Torrents list column name, upload speed limit 上传限制 Remaining Torrents list column name, remaining byte size 剩余大小 Download Directory Torrents list column name 下载目录 Last Activity Torrents list column name 最后活动 Inactive Tracker status 非活动 Waiting for update Tracker status 等待更新 About to update Tracker status 准备更新 Updating Tracker status 更新中 Next Update Trackers list column title 下一次更新 %L1 B Size suffix in bytes %L1 B %L1 KiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in kibibytes %L1 KiB %L1 MiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in mebibytes %L1 MiB %L1 GiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in gibibytes %L1 GiB %L1 TiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in tebibytes %L1 TiB %L1 PiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in pebibytes %L1 PiB %L1 EiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in exbibytes %L1 EiB %L1 ZiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in zebibytes %L1 ZiB %L1 YiB IEC 80000 binary prefixes, i.e. KiB = 1024 bytes Size suffix in yobibytes %L1 YiB %L1 B/s Download speed suffix in bytes per second %L1 B/s %L1 KiB/s Download speed suffix in kibibytes per second %L1 KiB/s %L1 MiB/s Download speed suffix in mebibytes per second %L1 MiB/s %L1 GiB/s Download speed suffix in gibibytes per second %L1 GiB/s %L1 TiB/s Download speed suffix in tebibytes per second %L1 TiB/s %L1 PiB/s Download speed suffix in pebibytes per second %L1 PiB/s %L1 EiB/s Download speed suffix in exbibytes per second %L1 EiB/s %L1 ZiB/s Download speed suffix in zebibytes per second %L1 ZiB/s %L1 YiB/s Download speed suffix in yobibytes per second %L1 YiB/s %L1% Progress in percents. %L1 must remain unchanged, % after it is a percent character %L1% %L1 d %L2 h Remaining time string. %L1 is days, %L2 is hours, e.g. "2 d 5 h" %L1 天 %L2 小时 %L1 h %L2 m Remaining time string. %L1 is hours, %L2 is minutes, e.g. "2 h 5 m" %L1 小时 %L2 分 %L1 m %L2 s Remaining time string. %L1 is minutes, %L2 is seconds, e.g. "2 m 5 s" %L1 分 %L2 秒 %L1 s Remaining time string. %L1 is seconds, "10 s" %L1 秒 Today Yesterday Two days ago %1 at %2 Relative date & time Just now Relative time %n minute(s) ago @item:intext %1 is a whole number %n minutes ago %n minute ago Error opening %1 File opening error, %1 is a file path ---------- Directory opening error, %1 is a file path 打开时出错 %1 Move files from current directory Check box label 从当前目录移动文件 Selected directory should be inside mounted directory 所选目录已在已挂载的目录中 All (%L1) Filter option of torrents list's tracker filter. %L1 is total number of torrents ---------- Filter option of torrents list's download directory filter. %L1 is total number of torrents ---------- Filter option of torrents list's label filter. %L1 is total number of torrents ---------- Filter option of torrents list's status filter. %L1 is total number of torrents 所有Torrent (%L1) %1 (%L2) Filter option of torrents list's tracker filter. %1 is tracker domain name, %L2 is number of torrents with that tracker ---------- Filter option of torrents list's label filter. %1 is label, %L2 is number of torrents with that label %1 (%L2) This directory does not exist 此目录不存在 Edit Labels New label... tremotesf-2.8.2/vcpkg-configuration.json000066400000000000000000000011431500171105600204000ustar00rootroot00000000000000{ "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg-configuration.schema.json", "default-registry": { "kind": "builtin", "baseline": "82e19a98aea55641deeae10e5ecb3f0bda824a12" }, "registries": [ { "kind": "git", "repository": "https://github.com/equeim/vcpkg-registry.git", "baseline": "a5fe963a7c4bf84f24749623a21183c0e263b973", "packages": [ "kf6ecm", "kf6widgetsaddons", "qttools", "qtbase" ] } ] } tremotesf-2.8.2/vcpkg-overlay-triplets/000077500000000000000000000000001500171105600201645ustar00rootroot00000000000000tremotesf-2.8.2/vcpkg-overlay-triplets/arm64-osx-release.cmake000066400000000000000000000006771500171105600243560ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 include("${VCPKG_ROOT_DIR}/triplets/community/arm64-osx-release.cmake") include("${CMAKE_CURRENT_LIST_DIR}/../cmake/MacOSDeploymentTarget.cmake") set(VCPKG_OSX_DEPLOYMENT_TARGET "${TREMOTESF_MACOS_DEPLOYMENT_TARGET}") set(VCPKG_C_FLAGS "-g -ftrivial-auto-var-init=pattern -fstack-protector-strong -D_FORTIFY_SOURCE=3") set(VCPKG_CXX_FLAGS "${VCPKG_C_FLAGS}") tremotesf-2.8.2/vcpkg-overlay-triplets/arm64-windows-static.cmake000066400000000000000000000007561500171105600251040ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 include("${VCPKG_ROOT_DIR}/triplets/community/arm64-windows-static.cmake") include("${CMAKE_CURRENT_LIST_DIR}/../cmake/WindowsMinimumVersion.cmake") # /await:strict - use C++20 coroutines ABI when building C++17 dependencies set(flags "/await:strict /DWINVER=${TREMOTESF_WINDOWS_WINVER_MACRO} /D_WIN32_WINNT=${TREMOTESF_WINDOWS_WINVER_MACRO}") set(VCPKG_CXX_FLAGS "${flags}") set(VCPKG_C_FLAGS "${flags}") tremotesf-2.8.2/vcpkg-overlay-triplets/x64-osx-release.cmake000066400000000000000000000007221500171105600240350ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 include("${VCPKG_ROOT_DIR}/triplets/community/x64-osx-release.cmake") include("${CMAKE_CURRENT_LIST_DIR}/../cmake/MacOSDeploymentTarget.cmake") set(VCPKG_OSX_DEPLOYMENT_TARGET "${TREMOTESF_MACOS_DEPLOYMENT_TARGET}") set(VCPKG_C_FLAGS "-g -ftrivial-auto-var-init=pattern -fstack-protector-strong -fcf-protection=full -D_FORTIFY_SOURCE=3") set(VCPKG_CXX_FLAGS "${VCPKG_C_FLAGS}") tremotesf-2.8.2/vcpkg-overlay-triplets/x64-windows-static.cmake000066400000000000000000000007421500171105600245670ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2015-2024 Alexey Rochev # # SPDX-License-Identifier: CC0-1.0 include("${VCPKG_ROOT_DIR}/triplets/x64-windows-static.cmake") include("${CMAKE_CURRENT_LIST_DIR}/../cmake/WindowsMinimumVersion.cmake") # /await:strict - use C++20 coroutines ABI when building C++17 dependencies set(flags "/await:strict /DWINVER=${TREMOTESF_WINDOWS_WINVER_MACRO} /D_WIN32_WINNT=${TREMOTESF_WINDOWS_WINVER_MACRO}") set(VCPKG_CXX_FLAGS "${flags}") set(VCPKG_C_FLAGS "${flags}") tremotesf-2.8.2/vcpkg.json000066400000000000000000000026531500171105600155420ustar00rootroot00000000000000{ "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", "dependencies": [ { "name": "qtbase", "host": true, "default-features": false }, { "name": "qtbase", "default-features": false, "features": [ "brotli", "concurrent", "gui", "network", "pcre2", "png", "testlib", "thread", "widgets", { "name": "openssl", "platform": "!windows" } ] }, { "name": "qttools", "host": true, "default-features": false, "features": [ "linguist" ] }, { "name": "qttranslations", "host": true }, "qtsvg", "fmt", "kf6widgetsaddons", "cxxopts", { "name": "cpp-httplib", "default-features": false, "features": [ { "name": "openssl", "platform": "!(windows & arm64)" } ] }, { "name": "pkgconf", "platform": "windows", "host": true } ] }