Repository: ellite/Wallos Branch: main Commit: b39f0ae40ff9 Files: 345 Total size: 2.3 MB Directory structure: gitextract_dfd08_8w/ ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ └── build-release.yaml ├── .gitignore ├── .tmp/ │ └── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE.md ├── README.md ├── SECURITY.md ├── about.php ├── admin.php ├── api/ │ ├── admin/ │ │ ├── get_admin_settings.php │ │ ├── get_oidc_settings.php │ │ └── set_disable_password_login.php │ ├── categories/ │ │ └── get_categories.php │ ├── currencies/ │ │ └── get_currencies.php │ ├── fixer/ │ │ └── get_fixer.php │ ├── household/ │ │ └── get_household.php │ ├── notifications/ │ │ └── get_notification_settings.php │ ├── payment_methods/ │ │ └── get_payment_methods.php │ ├── settings/ │ │ └── get_settings.php │ ├── status/ │ │ └── version.php │ ├── subscriptions/ │ │ ├── get_ical_feed.php │ │ ├── get_monthly_cost.php │ │ └── get_subscriptions.php │ └── users/ │ └── get_user.php ├── calendar.php ├── cronjobs ├── docker-compose.yaml ├── endpoints/ │ ├── admin/ │ │ ├── adduser.php │ │ ├── deleteunusedlogos.php │ │ ├── deleteuser.php │ │ ├── enableoidc.php │ │ ├── saveoidcsettings.php │ │ ├── saveopenregistrations.php │ │ ├── savesecuritysettings.php │ │ ├── savesmtpsettings.php │ │ └── updatenotification.php │ ├── ai/ │ │ ├── delete_recommendation.php │ │ ├── fetch_models.php │ │ ├── generate_recommendations.php │ │ └── save_settings.php │ ├── categories/ │ │ └── category.php │ ├── cronjobs/ │ │ ├── checkforupdates.php │ │ ├── cleanupresettokens.php │ │ ├── createdatabase.php │ │ ├── sendcancellationnotifications.php │ │ ├── sendnotifications.php │ │ ├── sendresetpasswordemails.php │ │ ├── sendverificationemails.php │ │ ├── settimezone.php │ │ ├── storetotalyearlycost.php │ │ ├── updateexchange.php │ │ ├── updatenextpayment.php │ │ └── validate.php │ ├── currency/ │ │ ├── currency.php │ │ ├── fixer_api_key.php │ │ └── update_exchange.php │ ├── db/ │ │ ├── backup.php │ │ ├── import.php │ │ ├── migrate.php │ │ └── restore.php │ ├── household/ │ │ └── household.php │ ├── logos/ │ │ └── search.php │ ├── notifications/ │ │ ├── savediscordnotifications.php │ │ ├── saveemailnotifications.php │ │ ├── savegotifynotifications.php │ │ ├── savemattermostnotifications.php │ │ ├── savenotificationsettings.php │ │ ├── saventfynotifications.php │ │ ├── savepushovernotifications.php │ │ ├── savepushplusnotifications.php │ │ ├── saveserverchannotifications.php │ │ ├── savetelegramnotifications.php │ │ ├── savewebhooknotifications.php │ │ ├── testdiscordnotifications.php │ │ ├── testemailnotifications.php │ │ ├── testgotifynotifications.php │ │ ├── testmattermostnotifications.php │ │ ├── testntfynotifications.php │ │ ├── testpushovernotifications.php │ │ ├── testpushplusnotifications.php │ │ ├── testserverchannotifications.php │ │ ├── testtelegramnotifications.php │ │ └── testwebhooknotifications.php │ ├── payments/ │ │ ├── add.php │ │ ├── delete.php │ │ ├── get.php │ │ ├── rename.php │ │ ├── search.php │ │ ├── sort.php │ │ └── toggle.php │ ├── settings/ │ │ ├── colortheme.php │ │ ├── convert_currency.php │ │ ├── customcss.php │ │ ├── customtheme.php │ │ ├── deleteaccount.php │ │ ├── disabled_to_bottom.php │ │ ├── hide_disabled.php │ │ ├── mobile_navigation.php │ │ ├── monthly_price.php │ │ ├── remove_background.php │ │ ├── resettheme.php │ │ ├── show_original_price.php │ │ ├── subscription_progress.php │ │ └── theme.php │ ├── subscription/ │ │ ├── add.php │ │ ├── clone.php │ │ ├── delete.php │ │ ├── exportcalendar.php │ │ ├── get.php │ │ ├── getcalendar.php │ │ └── renew.php │ ├── subscriptions/ │ │ ├── export.php │ │ └── get.php │ └── user/ │ ├── budget.php │ ├── delete_avatar.php │ ├── disable_totp.php │ ├── enable_totp.php │ ├── regenerateapikey.php │ └── save_user.php ├── health.php ├── images/ │ └── siteicons/ │ └── svg/ │ ├── automatic.php │ ├── category.php │ ├── check.php │ ├── clone.php │ ├── delete.php │ ├── edit.php │ ├── export_ical.php │ ├── logo.php │ ├── manual.php │ ├── mobile-menu/ │ │ ├── about.php │ │ ├── admin.php │ │ ├── calendar.php │ │ ├── clone.php │ │ ├── delete.php │ │ ├── edit.php │ │ ├── home.php │ │ ├── logout.php │ │ ├── profile.php │ │ ├── renew.php │ │ ├── settings.php │ │ ├── statistics.php │ │ └── subscriptions.php │ ├── notes.php │ ├── payment.php │ ├── renew.php │ ├── save.php │ ├── subscription.php │ ├── web.php │ └── websearch.php ├── includes/ │ ├── checkredirect.php │ ├── checksession.php │ ├── checkuser.php │ ├── connect.php │ ├── connect_endpoint.php │ ├── connect_endpoint_crontabs.php │ ├── currency_formatter.php │ ├── filters_menu.php │ ├── footer.php │ ├── getdbkeys.php │ ├── getsettings.php │ ├── header.php │ ├── i18n/ │ │ ├── ca.php │ │ ├── cs.php │ │ ├── da.php │ │ ├── de.php │ │ ├── el.php │ │ ├── en.php │ │ ├── es.php │ │ ├── fr.php │ │ ├── getlang.php │ │ ├── id.php │ │ ├── it.php │ │ ├── jp.php │ │ ├── ko.php │ │ ├── languages.php │ │ ├── nl.php │ │ ├── pl.php │ │ ├── pt.php │ │ ├── pt_br.php │ │ ├── ro.php │ │ ├── ru.php │ │ ├── sl.php │ │ ├── sr.php │ │ ├── sr_lat.php │ │ ├── tr.php │ │ ├── uk.php │ │ ├── vi.php │ │ ├── zh_cn.php │ │ └── zh_tw.php │ ├── inputvalidation.php │ ├── list_subscriptions.php │ ├── oidc/ │ │ ├── handle_oidc_callback.php │ │ ├── oidc_create_user.php │ │ └── oidc_login.php │ ├── sort_options.php │ ├── ssrf_helper.php │ ├── stats_calculations.php │ ├── validate_endpoint.php │ ├── validate_endpoint_admin.php │ └── version.php ├── index.php ├── libs/ │ ├── OTPHP/ │ │ ├── Factory.php │ │ ├── FactoryInterface.php │ │ ├── HOTP.php │ │ ├── HOTPInterface.php │ │ ├── InternalClock.php │ │ ├── OTP.php │ │ ├── OTPInterface.php │ │ ├── ParameterTrait.php │ │ ├── TOTP.php │ │ ├── TOTPInterface.php │ │ └── Url.php │ ├── PHPMailer/ │ │ ├── Exception.php │ │ ├── PHPMailer.php │ │ └── SMTP.php │ ├── Psr/ │ │ └── Clock/ │ │ └── ClockInterface.php │ ├── constant_time_encoding/ │ │ ├── Base32.php │ │ ├── Base32Hex.php │ │ ├── Base64.php │ │ ├── Base64DotSlash.php │ │ ├── Base64DotSlashOrdered.php │ │ ├── Base64UrlSafe.php │ │ ├── Binary.php │ │ ├── EncoderInterface.php │ │ ├── Encoding.php │ │ ├── Hex.php │ │ └── RFC4648.php │ └── csrf.php ├── login.php ├── logos.php ├── logout.php ├── manifest.json ├── migrations/ │ ├── 000001.php │ ├── 000002.php │ ├── 000003.php │ ├── 000004.php │ ├── 000005.php │ ├── 000006.php │ ├── 000007.php │ ├── 000008.php │ ├── 000009.php │ ├── 000010.php │ ├── 000011.php │ ├── 000012.php │ ├── 000013.php │ ├── 000014.php │ ├── 000015.php │ ├── 000016.php │ ├── 000017.php │ ├── 000018.php │ ├── 000019.php │ ├── 000020.php │ ├── 000021.php │ ├── 000022.php │ ├── 000023.php │ ├── 000024.php │ ├── 000025.php │ ├── 000026.php │ ├── 000027.php │ ├── 000028.php │ ├── 000029.php │ ├── 000030.php │ ├── 000031.php │ ├── 000032.php │ ├── 000033.php │ ├── 000034.php │ ├── 000035.php │ ├── 000036.php │ ├── 000037.php │ ├── 000038.php │ ├── 000039.php │ ├── 000040.php │ ├── 000041.php │ ├── 000042.php │ ├── 000043.php │ └── 000044.php ├── nginx.conf ├── nginx.default.conf ├── passwordreset.php ├── profile.php ├── registration.php ├── robots.txt ├── scripts/ │ ├── admin.js │ ├── all.js │ ├── calendar.js │ ├── common.js │ ├── dashboard.js │ ├── i18n/ │ │ ├── ca.js │ │ ├── cs.js │ │ ├── da.js │ │ ├── de.js │ │ ├── el.js │ │ ├── en.js │ │ ├── es.js │ │ ├── fr.js │ │ ├── getlang.js │ │ ├── id.js │ │ ├── it.js │ │ ├── jp.js │ │ ├── ko.js │ │ ├── nl.js │ │ ├── pl.js │ │ ├── pt.js │ │ ├── pt_br.js │ │ ├── ro.js │ │ ├── ru.js │ │ ├── sl.js │ │ ├── sr.js │ │ ├── sr_lat.js │ │ ├── tr.js │ │ ├── uk.js │ │ ├── vi.js │ │ ├── zh_cn.js │ │ └── zh_tw.js │ ├── libs/ │ │ └── chart.js │ ├── login.js │ ├── notifications.js │ ├── profile.js │ ├── registration.js │ ├── settings.js │ ├── stats.js │ ├── subscriptions.js │ └── theme.js ├── service-worker.js ├── settings.php ├── startup.sh ├── stats.php ├── styles/ │ ├── barlow.css │ ├── brands.css │ ├── dark-theme.css │ ├── login-dark-theme.css │ ├── login.css │ ├── styles.css │ ├── theme.css │ └── themes/ │ ├── green.css │ ├── purple.css │ ├── red.css │ └── yellow.css ├── subscriptions.php ├── totp.php └── verifyemail.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Exclude .vscode directory .vscode/ # Exclude .git directory .git/ .github/ *.md .gitignore .dockerignore ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [ellite] custom: ['https://www.paypal.com/paypalme/miguelr'] ================================================ FILE: .github/workflows/build-release.yaml ================================================ name: Build & Release on: push: branches: - "*" pull_request: branches: - main permissions: contents: write pull-requests: write packages: write env: # login to docker hub with provided secrets REGISTRY: docker.io REGISTRY_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} IMAGE_NAME: ${{ vars.DOCKERHUB_TAG }} # For release-please, see available types at https://github.com/google-github-actions/release-please-action/tree/v4/?tab=readme-ov-file#release-types-supported PROJECT_TYPE: simple jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - id: rp if: github.event_name != 'pull_request' && github.ref_name == 'main' uses: google-github-actions/release-please-action@v4 with: release-type: ${{ env.PROJECT_TYPE }} - name: Log into registry ${{ env.REGISTRY }} uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ env.REGISTRY_USERNAME }} password: ${{ env.REGISTRY_PASSWORD }} - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Prepare tags for Docker meta id: tags env: # When release please is skipped, these values will be empty is_release: ${{ steps.rp.outputs.release_created }} version: v${{ steps.rp.outputs.major }}.${{ steps.rp.outputs.minor }}.${{ steps.rp.outputs.patch }} run: | tags="" if [[ "$is_release" = 'true' ]]; then tags="type=semver,pattern={{version}},value=$version type=ref,event=branch,value=main" else tags="type=ref,event=branch type=ref,event=pr" fi { echo 'tags<> "$GITHUB_OUTPUT" - name: Docker meta id: meta uses: docker/metadata-action@v5 with: images: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} ghcr.io/ellite/wallos tags: ${{ steps.tags.outputs.tags }} # necessary for multi-platform images - name: Set up QEMU uses: docker/setup-qemu-action@v3 # necessary for multi-platform images - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push uses: docker/build-push-action@v5 with: context: . push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64,linux/arm64,linux/arm/v7 cache-from: type=gha cache-to: type=gha,mode=max - name: Send notification to Discord if: github.event_name != 'pull_request' uses: Ilshidur/action-discord@master with: args: "A new release has been created: ${{ steps.meta.outputs.tags }}" env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} ================================================ FILE: .gitignore ================================================ /db/* !/db/wallos.empty.db /images/uploads/logos/* !/images/uploads/logos/wallos.png .DS_Store .idea/ .vscode/ ================================================ FILE: .tmp/.gitignore ================================================ * !.gitignore ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## [4.7.2](https://github.com/ellite/Wallos/compare/v4.7.1...v4.7.2) (2026-03-19) ### Bug Fixes * password reset tokens now expire after 60 minutes ([90bb618](https://github.com/ellite/Wallos/commit/90bb6186ee4091590b6efdef824c85f2494ff2bb)) * vulnerability would allow to bypass 2fa ([#1021](https://github.com/ellite/Wallos/issues/1021)) ([90bb618](https://github.com/ellite/Wallos/commit/90bb6186ee4091590b6efdef824c85f2494ff2bb)) ## [4.7.1](https://github.com/ellite/Wallos/compare/v4.7.0...v4.7.1) (2026-03-19) ### Bug Fixes * remove extra line on languages.php causing headers already sent ([#1019](https://github.com/ellite/Wallos/issues/1019)) ([f5c9a34](https://github.com/ellite/Wallos/commit/f5c9a3498ed2df8ae6b225fc63ce01a8ed5ce348)) ## [4.7.0](https://github.com/ellite/Wallos/compare/v4.6.2...v4.7.0) (2026-03-19) ### Features * add romanian translations ([#1017](https://github.com/ellite/Wallos/issues/1017)) ([e87387f](https://github.com/ellite/Wallos/commit/e87387f0ebb540cd33e6dfda7181db9db650ecef)) * mask ai api key on the settings page ([e87387f](https://github.com/ellite/Wallos/commit/e87387f0ebb540cd33e6dfda7181db9db650ecef)) ### Bug Fixes * ai recommendation numbering when deleting a recommendation ([e87387f](https://github.com/ellite/Wallos/commit/e87387f0ebb540cd33e6dfda7181db9db650ecef)) * calendar ocurrences to respect subscriptions start date ([e87387f](https://github.com/ellite/Wallos/commit/e87387f0ebb540cd33e6dfda7181db9db650ecef)) * logo search ([e87387f](https://github.com/ellite/Wallos/commit/e87387f0ebb540cd33e6dfda7181db9db650ecef)) * retain first and last name when switching language during registration ([e87387f](https://github.com/ellite/Wallos/commit/e87387f0ebb540cd33e6dfda7181db9db650ecef)) * set login cookie to httponly ([e87387f](https://github.com/ellite/Wallos/commit/e87387f0ebb540cd33e6dfda7181db9db650ecef)) * ssrf vulnerability on several endpoints ([e87387f](https://github.com/ellite/Wallos/commit/e87387f0ebb540cd33e6dfda7181db9db650ecef)) * unicode character on the css file ([e87387f](https://github.com/ellite/Wallos/commit/e87387f0ebb540cd33e6dfda7181db9db650ecef)) * xss vulnerability on payment method rename endpoint ([e87387f](https://github.com/ellite/Wallos/commit/e87387f0ebb540cd33e6dfda7181db9db650ecef)) ## [4.6.2](https://github.com/ellite/Wallos/compare/v4.6.1...v4.6.2) (2026-03-05) ### Bug Fixes * ssrf vulnerability on all test notifications endpoint ([e8a5135](https://github.com/ellite/Wallos/commit/e8a513591dbbf885966e2ef55c38622785b9060d)) * vulnerability allowed to delete avatars from other users ([e8a5135](https://github.com/ellite/Wallos/commit/e8a513591dbbf885966e2ef55c38622785b9060d)) * xss vulnerability on password reset page ([e8a5135](https://github.com/ellite/Wallos/commit/e8a513591dbbf885966e2ef55c38622785b9060d)) ## [4.6.1](https://github.com/ellite/Wallos/compare/v4.6.0...v4.6.1) (2026-02-10) ### Bug Fixes * vulnerabily on add subscription endpoint ([#991](https://github.com/ellite/Wallos/issues/991)) ([76a53df](https://github.com/ellite/Wallos/commit/76a53df9cb4658123b8f0b7cf1826f1ba7d1c960)) ## [4.6.0](https://github.com/ellite/Wallos/compare/v4.5.0...v4.6.0) (2025-12-20) ### Features * add catalan translation ([#970](https://github.com/ellite/Wallos/issues/970)) ([f5746e7](https://github.com/ellite/Wallos/commit/f5746e76a5dd6bbda7d52b1a2229c02bb9fad94b)) * add robots.txt to disallow indexing. ([f5746e7](https://github.com/ellite/Wallos/commit/f5746e76a5dd6bbda7d52b1a2229c02bb9fad94b)) * add serverchan notifications. ([f5746e7](https://github.com/ellite/Wallos/commit/f5746e76a5dd6bbda7d52b1a2229c02bb9fad94b)) * notifications for subscription can be triggered up to 180 days before payment date. ([f5746e7](https://github.com/ellite/Wallos/commit/f5746e76a5dd6bbda7d52b1a2229c02bb9fad94b)) ### Bug Fixes * use RFC 5545 compliant date format in iCal exports ([#965](https://github.com/ellite/Wallos/issues/965)) ([b6b0abe](https://github.com/ellite/Wallos/commit/b6b0abed0d916c3ae5a31257f4c0b1a34436ad91)) * use RFC 5545 compliant date format in iCal exports. ([f5746e7](https://github.com/ellite/Wallos/commit/f5746e76a5dd6bbda7d52b1a2229c02bb9fad94b)) * use stable UID for iCal events to prevent duplicates. ([f5746e7](https://github.com/ellite/Wallos/commit/f5746e76a5dd6bbda7d52b1a2229c02bb9fad94b)) ## [4.5.0](https://github.com/ellite/Wallos/compare/v4.4.1...v4.5.0) (2025-10-18) ### Features * enforce CSRF protection and POST-only policy across endpoints ([#940](https://github.com/ellite/Wallos/issues/940)) ([3247ce2](https://github.com/ellite/Wallos/commit/3247ce2c8768d8e5910f74e5b8eba657b5b05cc1)) ## [4.4.1](https://github.com/ellite/Wallos/compare/v4.4.0...v4.4.1) (2025-10-12) ### Bug Fixes * get_subscriptions api endpoint was not returning subscriptions ([#937](https://github.com/ellite/Wallos/issues/937)) ([d6329a7](https://github.com/ellite/Wallos/commit/d6329a7af5a48f74b5f1d44a51cdc8c09dc2508b)) ## [4.4.0](https://github.com/ellite/Wallos/compare/v4.3.0...v4.4.0) (2025-10-12) ### Features * add mattermost notifications ([#923](https://github.com/ellite/Wallos/issues/923)) ([#934](https://github.com/ellite/Wallos/issues/934)) ([5629a31](https://github.com/ellite/Wallos/commit/5629a319bc5eb6cb80abfca06725aed9d2d9df88)) * add openrouter ai endpoint ([#922](https://github.com/ellite/Wallos/issues/922)) ([5629a31](https://github.com/ellite/Wallos/commit/5629a319bc5eb6cb80abfca06725aed9d2d9df88)) * enhance get_subscriptions API with admin access ([#928](https://github.com/ellite/Wallos/issues/928)) ([5629a31](https://github.com/ellite/Wallos/commit/5629a319bc5eb6cb80abfca06725aed9d2d9df88)) ### Bug Fixes * add autocomplete attribute to inputes ([#926](https://github.com/ellite/Wallos/issues/926)) ([5629a31](https://github.com/ellite/Wallos/commit/5629a319bc5eb6cb80abfca06725aed9d2d9df88)) ## [4.3.0](https://github.com/ellite/Wallos/compare/v4.2.0...v4.3.0) (2025-09-15) ### Features * add health endpoint and healthcheck to container ([#919](https://github.com/ellite/Wallos/issues/919)) ([852cb48](https://github.com/ellite/Wallos/commit/852cb485a65a58c91577b369fb9ea293d370bda8)) ## [4.2.0](https://github.com/ellite/Wallos/compare/v4.1.1...v4.2.0) (2025-09-14) ### Features * add pushplus notification service ([#911](https://github.com/ellite/Wallos/issues/911)) ([27ac805](https://github.com/ellite/Wallos/commit/27ac805141c0d170a40c2a7796a589a5ef29544f)) * make container shutdown instant & graceful ([27ac805](https://github.com/ellite/Wallos/commit/27ac805141c0d170a40c2a7796a589a5ef29544f)) * make container shutdown instant & graceful ([#916](https://github.com/ellite/Wallos/issues/916)) ([27ac805](https://github.com/ellite/Wallos/commit/27ac805141c0d170a40c2a7796a589a5ef29544f)) * option to delete ai recommendations ([27ac805](https://github.com/ellite/Wallos/commit/27ac805141c0d170a40c2a7796a589a5ef29544f)) ### Bug Fixes * parsing ai recommendations from gemini ([#909](https://github.com/ellite/Wallos/issues/909)) ([27ac805](https://github.com/ellite/Wallos/commit/27ac805141c0d170a40c2a7796a589a5ef29544f)) ## [4.1.1](https://github.com/ellite/Wallos/compare/v4.1.0...v4.1.1) (2025-08-13) ### Bug Fixes * missing apikey validation error on get_monthly_cost api endpoint ([3ecc160](https://github.com/ellite/Wallos/commit/3ecc160ccb73f22367bea427315519876de74a65)) * redirect from dashboard to subscriptions for new users ([3ecc160](https://github.com/ellite/Wallos/commit/3ecc160ccb73f22367bea427315519876de74a65)) * wrong check for disabling password login ([3ecc160](https://github.com/ellite/Wallos/commit/3ecc160ccb73f22367bea427315519876de74a65)) ## [4.1.0](https://github.com/ellite/Wallos/compare/v4.0.0...v4.1.0) (2025-08-11) ### Features * add at a glance dashboard ([ba6dddf](https://github.com/ellite/Wallos/commit/ba6dddf52601fdbeb18897731beacc48d16043c3)) * add get_oidc_settings endpoint to the api ([ba6dddf](https://github.com/ellite/Wallos/commit/ba6dddf52601fdbeb18897731beacc48d16043c3)) * ai recommendations with chatgpt, gemini or ollama ([ba6dddf](https://github.com/ellite/Wallos/commit/ba6dddf52601fdbeb18897731beacc48d16043c3)) * allow to disable password login when oidc is enabled ([ba6dddf](https://github.com/ellite/Wallos/commit/ba6dddf52601fdbeb18897731beacc48d16043c3)) * display ai recommendations on the dashboard ([ba6dddf](https://github.com/ellite/Wallos/commit/ba6dddf52601fdbeb18897731beacc48d16043c3)) * refactor css colors ([ba6dddf](https://github.com/ellite/Wallos/commit/ba6dddf52601fdbeb18897731beacc48d16043c3)) ### Bug Fixes * accept both api_key and apiKey as parameter on the api ([ba6dddf](https://github.com/ellite/Wallos/commit/ba6dddf52601fdbeb18897731beacc48d16043c3)) ## [4.0.0](https://github.com/ellite/Wallos/compare/v3.3.1...v4.0.0) (2025-07-21) ### ⚠ BREAKING CHANGES * add oauth / oidc support ([#875](https://github.com/ellite/Wallos/issues/875)) ### Features * add oauth / oidc support ([#875](https://github.com/ellite/Wallos/issues/875)) ([805e688](https://github.com/ellite/Wallos/commit/805e688ec0fac1dbb362e847ed8a4e3e301ee113)) * add oauth/oidc support ([#873](https://github.com/ellite/Wallos/issues/873)) ([c0d53e4](https://github.com/ellite/Wallos/commit/c0d53e4423996595e5c82404af92e077c00eae47)) ## [3.3.1](https://github.com/ellite/Wallos/compare/v3.3.0...v3.3.1) (2025-07-19) ### Bug Fixes * code of new taiwan dollar ([596cbc4](https://github.com/ellite/Wallos/commit/596cbc42464100dc8c6db5d07c090dab4b767268)) * decoding of header from database on the webhook notifications ([596cbc4](https://github.com/ellite/Wallos/commit/596cbc42464100dc8c6db5d07c090dab4b767268)) * unicode issue on telegram notifications ([#871](https://github.com/ellite/Wallos/issues/871)) ([596cbc4](https://github.com/ellite/Wallos/commit/596cbc42464100dc8c6db5d07c090dab4b767268)) ## [3.3.0](https://github.com/ellite/Wallos/compare/v3.2.0...v3.3.0) (2025-06-09) ### Features * set todays date on start subscription field for new subscriptions by default ([#848](https://github.com/ellite/Wallos/issues/848)) ([d3fd938](https://github.com/ellite/Wallos/commit/d3fd9387d34f430adb84ef553193b4ad3080c009)) ### Bug Fixes * visual issue with date fields on ios ([#846](https://github.com/ellite/Wallos/issues/846)) ([e2df8f7](https://github.com/ellite/Wallos/commit/e2df8f7e24678f9d62f36f68c94de838fc741913)) ## [3.2.0](https://github.com/ellite/Wallos/compare/v3.1.1...v3.2.0) (2025-06-08) ### Features * add button to auto fill the next payment date ([48db4e3](https://github.com/ellite/Wallos/commit/48db4e300df6128b7cc0b4e0c86271bfb3159545)) * add first and last names to the user profile ([48db4e3](https://github.com/ellite/Wallos/commit/48db4e300df6128b7cc0b4e0c86271bfb3159545)) * add indonesian language ([#842](https://github.com/ellite/Wallos/issues/842)) ([48db4e3](https://github.com/ellite/Wallos/commit/48db4e300df6128b7cc0b4e0c86271bfb3159545)) * add new currency ([48db4e3](https://github.com/ellite/Wallos/commit/48db4e300df6128b7cc0b4e0c86271bfb3159545)) * Add new currency ([#829](https://github.com/ellite/Wallos/issues/829)) ([288ad45](https://github.com/ellite/Wallos/commit/288ad456564c307018541a09df447898e1d62d26)) * enable IPv6 environments by configuring a dual-stack listen in nginx ([48db4e3](https://github.com/ellite/Wallos/commit/48db4e300df6128b7cc0b4e0c86271bfb3159545)) ### Bug Fixes * vulnerability on test webhook endpoint ([48db4e3](https://github.com/ellite/Wallos/commit/48db4e300df6128b7cc0b4e0c86271bfb3159545)) ## [3.1.1](https://github.com/ellite/Wallos/compare/v3.1.0...v3.1.1) (2025-05-15) ### Bug Fixes * issue listing prices when uah was added to the list of currencies ([#823](https://github.com/ellite/Wallos/issues/823)) ([bd20b56](https://github.com/ellite/Wallos/commit/bd20b5697659fc6117113205a3995d7e5f9026c9)) ## [3.1.0](https://github.com/ellite/Wallos/compare/v3.0.2...v3.1.0) (2025-05-08) ### Features * add danish translation ([0cfefc7](https://github.com/ellite/Wallos/commit/0cfefc7f07056d59ad911f926cc56ff3e6c8e261)) ### Bug Fixes * disable totp with backup code ([0cfefc7](https://github.com/ellite/Wallos/commit/0cfefc7f07056d59ad911f926cc56ff3e6c8e261)) * gotify settings test ([0cfefc7](https://github.com/ellite/Wallos/commit/0cfefc7f07056d59ad911f926cc56ff3e6c8e261)) * vulnerability adding logos from url ([0cfefc7](https://github.com/ellite/Wallos/commit/0cfefc7f07056d59ad911f926cc56ff3e6c8e261)) ## [3.0.2](https://github.com/ellite/Wallos/compare/v3.0.1...v3.0.2) (2025-05-03) ### Bug Fixes * delete avatar would not work if wallos is on a subfolder ([69c7d52](https://github.com/ellite/Wallos/commit/69c7d52cf8d708bcb046343faa663209c8d36779)) * some strings not using translations on the calendar page ([69c7d52](https://github.com/ellite/Wallos/commit/69c7d52cf8d708bcb046343faa663209c8d36779)) * vulnerability on delete avatar ([69c7d52](https://github.com/ellite/Wallos/commit/69c7d52cf8d708bcb046343faa663209c8d36779)) ## [3.0.1](https://github.com/ellite/Wallos/compare/v3.0.0...v3.0.1) (2025-04-30) ### Bug Fixes * allow to clear the budget field ([f6b8fb9](https://github.com/ellite/Wallos/commit/f6b8fb9162c5fb4fefa1fbd9cde65c201e96be6c)) * don't show budget alert when budget is 0 ([f6b8fb9](https://github.com/ellite/Wallos/commit/f6b8fb9162c5fb4fefa1fbd9cde65c201e96be6c)) ## [3.0.0](https://github.com/ellite/Wallos/compare/v2.52.2...v3.0.0) (2025-04-27) ### ⚠ BREAKING CHANGES * simplified webhook notifications without iterator (might break your current webhook settings) ### Features * simplified webhook notifications without iterator (might break your current webhook settings) ([e0f2048](https://github.com/ellite/Wallos/commit/e0f204803e635400c404529d87e5057c579c8531)) * use mobile style toggles instead of checkboxes ([e0f2048](https://github.com/ellite/Wallos/commit/e0f204803e635400c404529d87e5057c579c8531)) * webhooks can now be used for cancelation notifications ([e0f2048](https://github.com/ellite/Wallos/commit/e0f204803e635400c404529d87e5057c579c8531)) ### Bug Fixes * barely readable placeholder text on textarea on dark the ([e0f2048](https://github.com/ellite/Wallos/commit/e0f204803e635400c404529d87e5057c579c8531)) ## [2.52.2](https://github.com/ellite/Wallos/compare/v2.52.1...v2.52.2) (2025-04-26) ### Bug Fixes * incorrect headers on the api ([#802](https://github.com/ellite/Wallos/issues/802)) ([af68c11](https://github.com/ellite/Wallos/commit/af68c11abf5d5a64fd7136e1d2e37323d170c77e)) ## [2.52.1](https://github.com/ellite/Wallos/compare/v2.52.0...v2.52.1) (2025-04-26) ### Bug Fixes * error on statistics page when budget = 0 ([#800](https://github.com/ellite/Wallos/issues/800)) ([b7712dc](https://github.com/ellite/Wallos/commit/b7712dc80d6642a6a33a28adc641f9a4b3263ae6)) ## [2.52.0](https://github.com/ellite/Wallos/compare/v2.51.1...v2.52.0) (2025-04-19) ### Features * new graph cost vs budget on statistics ([#793](https://github.com/ellite/Wallos/issues/793)) ([6d67319](https://github.com/ellite/Wallos/commit/6d673195ba39f1a52e9ea16bad21221768690e7a)) ## [2.51.1](https://github.com/ellite/Wallos/compare/v2.51.0...v2.51.1) (2025-04-19) ### Bug Fixes * timezone for cronjobs now comes from TZ env var first ([#791](https://github.com/ellite/Wallos/issues/791)) ([66a1a45](https://github.com/ellite/Wallos/commit/66a1a45f2dc1df99f8292cbb531d569f706eca6d)) ## [2.51.0](https://github.com/ellite/Wallos/compare/v2.50.1...v2.51.0) (2025-04-18) ### Features * add over budget warnings on the calendar ([88eae10](https://github.com/ellite/Wallos/commit/88eae1002f0cc29a847e95b7698ab713779ec4f4)) ### Bug Fixes * force correct timezone on the cronjobs ([88eae10](https://github.com/ellite/Wallos/commit/88eae1002f0cc29a847e95b7698ab713779ec4f4)) ## [2.50.1](https://github.com/ellite/Wallos/compare/v2.50.0...v2.50.1) (2025-04-16) ### Bug Fixes * localization on date on browsers not in english ([c7b3fb4](https://github.com/ellite/Wallos/commit/c7b3fb445182e19bc464ac987977bac266628757)) ## [2.50.0](https://github.com/ellite/Wallos/compare/v2.49.1...v2.50.0) (2025-04-16) ### Features * shorten date displayed on the list of subscriptions ([68f1d47](https://github.com/ellite/Wallos/commit/68f1d4757737de50622bb4b2aeb8f291dec62972)) * use user defined language for the date on the list of subscriptions ([68f1d47](https://github.com/ellite/Wallos/commit/68f1d4757737de50622bb4b2aeb8f291dec62972)) ### Bug Fixes * limit name display, when sub has no logo to two lines ([68f1d47](https://github.com/ellite/Wallos/commit/68f1d4757737de50622bb4b2aeb8f291dec62972)) * use translations on the mobile menu ([68f1d47](https://github.com/ellite/Wallos/commit/68f1d4757737de50622bb4b2aeb8f291dec62972)) ## [2.49.1](https://github.com/ellite/Wallos/compare/v2.49.0...v2.49.1) (2025-04-13) ### Bug Fixes * version number ([eade2d9](https://github.com/ellite/Wallos/commit/eade2d9919e5d30e7be279f53e278fb746095762)) ## [2.49.0](https://github.com/ellite/Wallos/compare/v2.48.1...v2.49.0) (2025-04-13) ### Features * show name on mobile view when subscription has no logo ([9eb2907](https://github.com/ellite/Wallos/commit/9eb2907145297e3b7aac54dd5b51451d961f549a)) * show timezone on sendnotification cronjob on admin page ([9eb2907](https://github.com/ellite/Wallos/commit/9eb2907145297e3b7aac54dd5b51451d961f549a)) * use currencyConverter for notifications as well ([9eb2907](https://github.com/ellite/Wallos/commit/9eb2907145297e3b7aac54dd5b51451d961f549a)) * use symbol from db when currencyFormatter does not support the currency ([9eb2907](https://github.com/ellite/Wallos/commit/9eb2907145297e3b7aac54dd5b51451d961f549a)) ### Bug Fixes * date comparison check on sendnotifications cronjob ([9eb2907](https://github.com/ellite/Wallos/commit/9eb2907145297e3b7aac54dd5b51451d961f549a)) * emails with encryption set to none not working without ssl ([9eb2907](https://github.com/ellite/Wallos/commit/9eb2907145297e3b7aac54dd5b51451d961f549a)) * error when not setting custom headers for ntfy ([9eb2907](https://github.com/ellite/Wallos/commit/9eb2907145297e3b7aac54dd5b51451d961f549a)) ## [2.48.1](https://github.com/ellite/Wallos/compare/v2.48.0...v2.48.1) (2025-03-27) ### Bug Fixes * notifications would also be sent x days after subscription was due in some cases ([ba912a3](https://github.com/ellite/Wallos/commit/ba912a37d1a0d95401a38dabe8f98f29a6aa49db)) ## [2.48.0](https://github.com/ellite/Wallos/compare/v2.47.1...v2.48.0) (2025-03-20) ### Features * add update notification and release notes to the about page ([3e0e88d](https://github.com/ellite/Wallos/commit/3e0e88d6a2adc46c17773b09dd8684618c979711)) * increase privacy by not sending referrer to external urls ([3e0e88d](https://github.com/ellite/Wallos/commit/3e0e88d6a2adc46c17773b09dd8684618c979711)) * small layout change on the about page ([3e0e88d](https://github.com/ellite/Wallos/commit/3e0e88d6a2adc46c17773b09dd8684618c979711)) ## [2.47.1](https://github.com/ellite/Wallos/compare/v2.47.0...v2.47.1) (2025-03-19) ### Bug Fixes * small layout inconsistencies on the dashboard ([19d3067](https://github.com/ellite/Wallos/commit/19d30672b2635b6e79eaa6eb5c49100d7a27a63a)) ## [2.47.0](https://github.com/ellite/Wallos/compare/v2.46.1...v2.47.0) (2025-03-19) ### Features * add filter by renew type ([1bec973](https://github.com/ellite/Wallos/commit/1bec973803e0b3c00d2765bbf80447439127574d)) * add sort by renew type ([1bec973](https://github.com/ellite/Wallos/commit/1bec973803e0b3c00d2765bbf80447439127574d)) * add ukranian translation ([#756](https://github.com/ellite/Wallos/issues/756)) ([1bec973](https://github.com/ellite/Wallos/commit/1bec973803e0b3c00d2765bbf80447439127574d)) * remove "Wallos" text from calendar export ([1bec973](https://github.com/ellite/Wallos/commit/1bec973803e0b3c00d2765bbf80447439127574d)) ### Bug Fixes * ical trigger to spec RFC5545 ([1bec973](https://github.com/ellite/Wallos/commit/1bec973803e0b3c00d2765bbf80447439127574d)) * special chars on calendar exports ([1bec973](https://github.com/ellite/Wallos/commit/1bec973803e0b3c00d2765bbf80447439127574d)) * special chars on notifications ([1bec973](https://github.com/ellite/Wallos/commit/1bec973803e0b3c00d2765bbf80447439127574d)) * state filter not cleared by clear button ([1bec973](https://github.com/ellite/Wallos/commit/1bec973803e0b3c00d2765bbf80447439127574d)) ## [2.46.1](https://github.com/ellite/Wallos/compare/v2.46.0...v2.46.1) (2025-03-06) ### Bug Fixes * calculation of monthly cost progress graph ([#747](https://github.com/ellite/Wallos/issues/747)) ([77486ec](https://github.com/ellite/Wallos/commit/77486ec92c44b71f69e85b1eafb7f3a98c4a44c1)) ## [2.46.0](https://github.com/ellite/Wallos/compare/v2.45.2...v2.46.0) (2025-02-22) ### Features * sorting by category or payment method respects order from the settings page ([51b2272](https://github.com/ellite/Wallos/commit/51b22727bf5656a4a263519b5b56adfe6a2d12be)) ### Bug Fixes * access to tmp folder by www-data ([51b2272](https://github.com/ellite/Wallos/commit/51b22727bf5656a4a263519b5b56adfe6a2d12be)) ## [2.45.2](https://github.com/ellite/Wallos/compare/v2.45.1...v2.45.2) (2025-02-05) ### Bug Fixes * bug setting main currency for the first registered user ([c43b08a](https://github.com/ellite/Wallos/commit/c43b08aa4c45c907f82eb6afe37fd46aa5103654)) * deprecation message ([c43b08a](https://github.com/ellite/Wallos/commit/c43b08aa4c45c907f82eb6afe37fd46aa5103654)) * subscription progress above 100% for disabled subscriptions ([c43b08a](https://github.com/ellite/Wallos/commit/c43b08aa4c45c907f82eb6afe37fd46aa5103654)) * typo on czech translation ([c43b08a](https://github.com/ellite/Wallos/commit/c43b08aa4c45c907f82eb6afe37fd46aa5103654)) * use first currency on the list of currencies if user has not selected a main currency ([c43b08a](https://github.com/ellite/Wallos/commit/c43b08aa4c45c907f82eb6afe37fd46aa5103654)) * use gd if imagick is not available ([c43b08a](https://github.com/ellite/Wallos/commit/c43b08aa4c45c907f82eb6afe37fd46aa5103654)) ## [2.45.1](https://github.com/ellite/Wallos/compare/v2.45.0...v2.45.1) (2025-01-28) ### Bug Fixes * improve czech translation ([e2dc269](https://github.com/ellite/Wallos/commit/e2dc2696310159900c1f8fbe0a090e66b29b778d)) * improve japanese translation ([#713](https://github.com/ellite/Wallos/issues/713)) ([e2dc269](https://github.com/ellite/Wallos/commit/e2dc2696310159900c1f8fbe0a090e66b29b778d)) * improve traditional chinese translation ([e2dc269](https://github.com/ellite/Wallos/commit/e2dc2696310159900c1f8fbe0a090e66b29b778d)) * setting pgid and puid for the container ([e2dc269](https://github.com/ellite/Wallos/commit/e2dc2696310159900c1f8fbe0a090e66b29b778d)) ## [2.45.0](https://github.com/ellite/Wallos/compare/v2.44.1...v2.45.0) (2025-01-19) ### Features * add czech translations ([#701](https://github.com/ellite/Wallos/issues/701)) ([426fdfa](https://github.com/ellite/Wallos/commit/426fdfa5c79d32c7d5a0722a0590d39547cfd1fa)) ## [2.44.1](https://github.com/ellite/Wallos/compare/v2.44.0...v2.44.1) (2025-01-19) ### Bug Fixes * error setting date of last exchange rates update ([#699](https://github.com/ellite/Wallos/issues/699)) ([d2f68c4](https://github.com/ellite/Wallos/commit/d2f68c457e9b1328caf983ddc6e2827430855aa6)) ## [2.44.0](https://github.com/ellite/Wallos/compare/v2.43.1...v2.44.0) (2025-01-12) ### Features * allow notifications on due date ([87f148d](https://github.com/ellite/Wallos/commit/87f148d1745bec19f5713b8a367a3615871e6e33)) ### Bug Fixes * don't expose disabled notifications to ical feed ([87f148d](https://github.com/ellite/Wallos/commit/87f148d1745bec19f5713b8a367a3615871e6e33)) * email notification test always sending to admins email ([87f148d](https://github.com/ellite/Wallos/commit/87f148d1745bec19f5713b8a367a3615871e6e33)) ## [2.43.1](https://github.com/ellite/Wallos/compare/v2.43.0...v2.43.1) (2025-01-12) ### Bug Fixes * edit / delete subscription menu not accessible ([#689](https://github.com/ellite/Wallos/issues/689)) ([b668d37](https://github.com/ellite/Wallos/commit/b668d37d38f799ee0dda5a69a4824d03dd21e1bc)) ## [2.43.0](https://github.com/ellite/Wallos/compare/v2.42.2...v2.43.0) (2025-01-11) ### Features * new api endpoint that returns the version ([ff13fcb](https://github.com/ellite/Wallos/commit/ff13fcb6547ec4a9c972a2c0f0b6f42d69620f8b)) * option to show progress of subscription cycle ([ff13fcb](https://github.com/ellite/Wallos/commit/ff13fcb6547ec4a9c972a2c0f0b6f42d69620f8b)) ### Bug Fixes * currency symbol for monthly budget ([ff13fcb](https://github.com/ellite/Wallos/commit/ff13fcb6547ec4a9c972a2c0f0b6f42d69620f8b)) ## [2.42.2](https://github.com/ellite/Wallos/compare/v2.42.1...v2.42.2) (2024-12-21) ### Bug Fixes * version number ([#668](https://github.com/ellite/Wallos/issues/668)) ([683a366](https://github.com/ellite/Wallos/commit/683a3662ff998066f5d8de3be88e4d40d766442a)) ## [2.42.1](https://github.com/ellite/Wallos/compare/v2.42.0...v2.42.1) (2024-12-21) ### Bug Fixes * remove debug echo on stats page ([#666](https://github.com/ellite/Wallos/issues/666)) ([d9a2488](https://github.com/ellite/Wallos/commit/d9a24885ffbbdb3c08d9015804eea8cb0fea6cea)) ## [2.42.0](https://github.com/ellite/Wallos/compare/v2.41.0...v2.42.0) (2024-12-21) ### Features * add total monthly cost trend graph to the statistics page ([e7185f9](https://github.com/ellite/Wallos/commit/e7185f92578b3103d097b12b8c4313635f263d9f)) * allow email notifications without authentication ([e7185f9](https://github.com/ellite/Wallos/commit/e7185f92578b3103d097b12b8c4313635f263d9f)) ### Bug Fixes * don't update next payment date for disabled subscriptions ([e7185f9](https://github.com/ellite/Wallos/commit/e7185f92578b3103d097b12b8c4313635f263d9f)) * xss security vulnerability with the avatar selection ([e7185f9](https://github.com/ellite/Wallos/commit/e7185f92578b3103d097b12b8c4313635f263d9f)) ## [2.41.0](https://github.com/ellite/Wallos/compare/v2.40.0...v2.41.0) (2024-12-11) ### Features * add payment cycle to csv/json export ([5e6bc90](https://github.com/ellite/Wallos/commit/5e6bc903bcd95580ed58f744977d92c6330b3d9f)) * run db migration after importing db ([5e6bc90](https://github.com/ellite/Wallos/commit/5e6bc903bcd95580ed58f744977d92c6330b3d9f)) * run db migration after restoring database ([5e6bc90](https://github.com/ellite/Wallos/commit/5e6bc903bcd95580ed58f744977d92c6330b3d9f)) * store weekly the total yearly cost of subscriptions ([5e6bc90](https://github.com/ellite/Wallos/commit/5e6bc903bcd95580ed58f744977d92c6330b3d9f)) ### Bug Fixes * double encoding in statistics labels ([5e6bc90](https://github.com/ellite/Wallos/commit/5e6bc903bcd95580ed58f744977d92c6330b3d9f)) ## [2.40.0](https://github.com/ellite/Wallos/compare/v2.39.1...v2.40.0) (2024-12-10) ### Features * add dutch translation ([#655](https://github.com/ellite/Wallos/issues/655)) ([b5a9880](https://github.com/ellite/Wallos/commit/b5a98806d1f453180ce15724fa198d248177e488)) ## [2.39.1](https://github.com/ellite/Wallos/compare/v2.39.0...v2.39.1) (2024-12-06) ### Bug Fixes * svg error on calendar page ([#650](https://github.com/ellite/Wallos/issues/650)) ([8ba79c0](https://github.com/ellite/Wallos/commit/8ba79c0725815c6de8458c74961bbdf23a7d3e9d)) ## [2.39.0](https://github.com/ellite/Wallos/compare/v2.38.3...v2.39.0) (2024-12-06) ### Features * add icalendar subscription ([f5ddbff](https://github.com/ellite/Wallos/commit/f5ddbff0c1e0be676604390101c56c04c778f56a)) ## [2.38.3](https://github.com/ellite/Wallos/compare/v2.38.2...v2.38.3) (2024-12-06) ### Bug Fixes * vulnerability on the restore database endpoints ([3b2de8b](https://github.com/ellite/Wallos/commit/3b2de8b7c22090afdf7115c25fd8b497a5626ea3)) ## [2.38.2](https://github.com/ellite/Wallos/compare/v2.38.1...v2.38.2) (2024-11-19) ### Bug Fixes * logo search positioned below other elements ([#637](https://github.com/ellite/Wallos/issues/637)) ([72f7e57](https://github.com/ellite/Wallos/commit/72f7e5791423c45f910a791b20aafba301d0172f)) ## [2.38.1](https://github.com/ellite/Wallos/compare/v2.38.0...v2.38.1) (2024-11-17) ### Bug Fixes * bug introduced on 2.38.0 on the subscriptions dashboard ([#634](https://github.com/ellite/Wallos/issues/634)) ([f63c543](https://github.com/ellite/Wallos/commit/f63c543cdd7512b216004db3b279884dbda87ce4)) ## [2.38.0](https://github.com/ellite/Wallos/compare/v2.37.1...v2.38.0) (2024-11-17) ### Features * add option for manual/automatic renewals ([6e44a26](https://github.com/ellite/Wallos/commit/6e44a26703486d0ba30ee6ae8d3c46bfc3c6630a)) * add some leeway for totp codes ([6e44a26](https://github.com/ellite/Wallos/commit/6e44a26703486d0ba30ee6ae8d3c46bfc3c6630a)) * add start date to subscriptions ([6e44a26](https://github.com/ellite/Wallos/commit/6e44a26703486d0ba30ee6ae8d3c46bfc3c6630a)) ### Bug Fixes * layout issue with subscriptions list during search ([6e44a26](https://github.com/ellite/Wallos/commit/6e44a26703486d0ba30ee6ae8d3c46bfc3c6630a)) ## [2.37.1](https://github.com/ellite/Wallos/compare/v2.37.0...v2.37.1) (2024-11-15) ### Bug Fixes * version mismatch ([#627](https://github.com/ellite/Wallos/issues/627)) ([c4a9b16](https://github.com/ellite/Wallos/commit/c4a9b1627fbc7278398bf2d8bf7cae2934d349ca)) ## [2.37.0](https://github.com/ellite/Wallos/compare/v2.36.2...v2.37.0) (2024-11-15) ### Features * add monthly statistics to the calendar page ([f085f8a](https://github.com/ellite/Wallos/commit/f085f8adece3af2548858f665db16d4843d3e622)) ### Bug Fixes * notifications being sent on the wrong day ([f085f8a](https://github.com/ellite/Wallos/commit/f085f8adece3af2548858f665db16d4843d3e622)) ## [2.36.2](https://github.com/ellite/Wallos/compare/v2.36.1...v2.36.2) (2024-11-03) ### Bug Fixes * only show swipe hint on mobile screens ([#612](https://github.com/ellite/Wallos/issues/612)) ([bd5e351](https://github.com/ellite/Wallos/commit/bd5e3511829a798ab47ca5e9c9d080aae45ae1a0)) ## [2.36.1](https://github.com/ellite/Wallos/compare/v2.36.0...v2.36.1) (2024-11-03) ### Bug Fixes * version number ([#610](https://github.com/ellite/Wallos/issues/610)) ([4bd40f1](https://github.com/ellite/Wallos/commit/4bd40f1c561e979322375b95aeccccd18c4780fd)) ## [2.36.0](https://github.com/ellite/Wallos/compare/v2.35.0...v2.36.0) (2024-11-03) ### Features * add hint for mobile swipe action ([#608](https://github.com/ellite/Wallos/issues/608)) ([49666f8](https://github.com/ellite/Wallos/commit/49666f867cdbaa4d4c0c1551d0b4b3023830606a)) ## [2.35.0](https://github.com/ellite/Wallos/compare/v2.34.0...v2.35.0) (2024-11-01) ### Features * new menu icons ([28444ab](https://github.com/ellite/Wallos/commit/28444abef1cee338e41e57cbf6f13666b917bbde)) * swipe subscription for actions on the experimental mobile navigation ([28444ab](https://github.com/ellite/Wallos/commit/28444abef1cee338e41e57cbf6f13666b917bbde)) ## [2.34.0](https://github.com/ellite/Wallos/compare/v2.33.1...v2.34.0) (2024-10-31) ### Features * link version update banner to github release ([f007adf](https://github.com/ellite/Wallos/commit/f007adf9658eb1fd095c2716e4146130535f6cb7)) * only show filters that are actually used ([f007adf](https://github.com/ellite/Wallos/commit/f007adf9658eb1fd095c2716e4146130535f6cb7)) ### Bug Fixes * filters for categories and payment method respect order from settings ([f007adf](https://github.com/ellite/Wallos/commit/f007adf9658eb1fd095c2716e4146130535f6cb7)) ## [2.33.1](https://github.com/ellite/Wallos/compare/v2.33.0...v2.33.1) (2024-10-30) ### Bug Fixes * improve localization ([6480f87](https://github.com/ellite/Wallos/commit/6480f8744094d5ce0f05d7d155925540ac73b156)) * layout issue on the settings page ([#598](https://github.com/ellite/Wallos/issues/598)) ([6480f87](https://github.com/ellite/Wallos/commit/6480f8744094d5ce0f05d7d155925540ac73b156)) ## [2.33.0](https://github.com/ellite/Wallos/compare/v2.32.0...v2.33.0) (2024-10-29) ### Features * replacement for disabled subscriptions, to more accurately calculate savings ([5c92528](https://github.com/ellite/Wallos/commit/5c9252880837a7886c903ddc7ae92c8fed29b452)) ## [2.32.0](https://github.com/ellite/Wallos/compare/v2.31.1...v2.32.0) (2024-10-27) ### Features * settings to allow to ignore certificates for some notification methods ([2a0e665](https://github.com/ellite/Wallos/commit/2a0e665e77eca804fa70dafc1a3a0010eb9da270)) ## [2.31.1](https://github.com/ellite/Wallos/compare/v2.31.0...v2.31.1) (2024-10-25) ### Bug Fixes * add missing {{days_until}} variable to string version of the webhook ([ebc7b83](https://github.com/ellite/Wallos/commit/ebc7b83e9a0a32aecf3b1aa933408bf9b6baea3a)) * display actual error message when email test fails ([ebc7b83](https://github.com/ellite/Wallos/commit/ebc7b83e9a0a32aecf3b1aa933408bf9b6baea3a)) ## [2.31.0](https://github.com/ellite/Wallos/compare/v2.30.1...v2.31.0) (2024-10-22) ### Features * handle webhook payload as string if it is not a json object ([#583](https://github.com/ellite/Wallos/issues/583)) ([ee834d6](https://github.com/ellite/Wallos/commit/ee834d6198fa3315facd23a734655adf391bb736)) ## [2.30.1](https://github.com/ellite/Wallos/compare/v2.30.0...v2.30.1) (2024-10-14) ### Bug Fixes * verify correct path before creating logos folder ([782ebcd](https://github.com/ellite/Wallos/commit/782ebcd64fc947ea82eabaac6bc26a32676271a1)) ## [2.30.0](https://github.com/ellite/Wallos/compare/v2.29.2...v2.30.0) (2024-10-13) ### Features * add vietnamese translation ([#573](https://github.com/ellite/Wallos/issues/573)) ([45ff10f](https://github.com/ellite/Wallos/commit/45ff10f953f4af681252ed4d77c32b375f9c396c)) ## [2.29.2](https://github.com/ellite/Wallos/compare/v2.29.1...v2.29.2) (2024-10-11) ### Bug Fixes * xss issue on the dashboard ([#568](https://github.com/ellite/Wallos/issues/568)) ([e642129](https://github.com/ellite/Wallos/commit/e6421296aa708b02c468b10e3c9d0f28012c1282)) ## [2.29.1](https://github.com/ellite/Wallos/compare/v2.29.0...v2.29.1) (2024-10-11) ### Bug Fixes * mysql injection vulnerability ([3d6a8c3](https://github.com/ellite/Wallos/commit/3d6a8c340843230eff97b459e85efbea55aac01f)) * new profile page not being cached by service worker ([3d6a8c3](https://github.com/ellite/Wallos/commit/3d6a8c340843230eff97b459e85efbea55aac01f)) ## [2.29.0](https://github.com/ellite/Wallos/compare/v2.28.0...v2.29.0) (2024-10-09) ### Features * add url and notes as variables for the notifications webhook ([790defb](https://github.com/ellite/Wallos/commit/790defb2b1d1cd3d8c93738155edb19f96d0aa2a)) ### Bug Fixes * bug when looping multiple subscriptions on the notifications webhook ([790defb](https://github.com/ellite/Wallos/commit/790defb2b1d1cd3d8c93738155edb19f96d0aa2a)) ## [2.28.0](https://github.com/ellite/Wallos/compare/v2.27.3...v2.28.0) (2024-10-07) ### Features * get admin setting api endpoint ([07d456a](https://github.com/ellite/Wallos/commit/07d456a9c3d9cc3eb9ae80edb666caa103cababe)) * get categories endpoint ([07d456a](https://github.com/ellite/Wallos/commit/07d456a9c3d9cc3eb9ae80edb666caa103cababe)) * get currencies endpoint ([07d456a](https://github.com/ellite/Wallos/commit/07d456a9c3d9cc3eb9ae80edb666caa103cababe)) * get fixer api endpoint ([07d456a](https://github.com/ellite/Wallos/commit/07d456a9c3d9cc3eb9ae80edb666caa103cababe)) * get household api endpoint ([07d456a](https://github.com/ellite/Wallos/commit/07d456a9c3d9cc3eb9ae80edb666caa103cababe)) * get notifications api endpoint ([07d456a](https://github.com/ellite/Wallos/commit/07d456a9c3d9cc3eb9ae80edb666caa103cababe)) * get payment methods api endpoint ([07d456a](https://github.com/ellite/Wallos/commit/07d456a9c3d9cc3eb9ae80edb666caa103cababe)) * get settings api endpoint ([07d456a](https://github.com/ellite/Wallos/commit/07d456a9c3d9cc3eb9ae80edb666caa103cababe)) * get subscriptions api endpoint ([07d456a](https://github.com/ellite/Wallos/commit/07d456a9c3d9cc3eb9ae80edb666caa103cababe)) * get user api endpoint ([07d456a](https://github.com/ellite/Wallos/commit/07d456a9c3d9cc3eb9ae80edb666caa103cababe)) ## [2.27.3](https://github.com/ellite/Wallos/compare/v2.27.2...v2.27.3) (2024-10-05) ### Bug Fixes * missing folders on baremetal installation ([#554](https://github.com/ellite/Wallos/issues/554)) ([03f34d1](https://github.com/ellite/Wallos/commit/03f34d1aee3f74c3bf9c53c04c1494106be4bb47)) * missing fonts ([03f34d1](https://github.com/ellite/Wallos/commit/03f34d1aee3f74c3bf9c53c04c1494106be4bb47)) ## [2.27.2](https://github.com/ellite/Wallos/compare/v2.27.1...v2.27.2) (2024-10-04) ### Bug Fixes * bump version ([#546](https://github.com/ellite/Wallos/issues/546)) ([c5460bd](https://github.com/ellite/Wallos/commit/c5460bd79bdd056e788774ac52cfd4262eada5e7)) ## [2.27.1](https://github.com/ellite/Wallos/compare/v2.27.0...v2.27.1) (2024-10-04) ### Bug Fixes * add missing assets to the service worker ([#542](https://github.com/ellite/Wallos/issues/542)) ([0251da2](https://github.com/ellite/Wallos/commit/0251da23f4254420a471fcd4c4951d0d0b1bb4df)) ## [2.27.0](https://github.com/ellite/Wallos/compare/v2.26.0...v2.27.0) (2024-10-04) ### Features * api endpoint to calculate monthly cost ([a173d27](https://github.com/ellite/Wallos/commit/a173d2765fd2a1a641f32fbea198775b1bdc0b00)) * fisrt api endpoint ([a173d27](https://github.com/ellite/Wallos/commit/a173d2765fd2a1a641f32fbea198775b1bdc0b00)) * redesigned experimental mobile navigation menu ([a173d27](https://github.com/ellite/Wallos/commit/a173d2765fd2a1a641f32fbea198775b1bdc0b00)) * split settings page into settings and profile page ([a173d27](https://github.com/ellite/Wallos/commit/a173d2765fd2a1a641f32fbea198775b1bdc0b00)) * user has api key available on profile page ([a173d27](https://github.com/ellite/Wallos/commit/a173d2765fd2a1a641f32fbea198775b1bdc0b00)) ### Bug Fixes * small fixes and typos ([a173d27](https://github.com/ellite/Wallos/commit/a173d2765fd2a1a641f32fbea198775b1bdc0b00)) ## [2.26.0](https://github.com/ellite/Wallos/compare/v2.25.0...v2.26.0) (2024-09-29) ### Features * add mobile menu navigation to experimental settings ([1dbba18](https://github.com/ellite/Wallos/commit/1dbba18446ac53568492af9d2aee3f90db7168ca)) * use browsers locale to set dates on the dashboard ([1dbba18](https://github.com/ellite/Wallos/commit/1dbba18446ac53568492af9d2aee3f90db7168ca)) ## [2.25.0](https://github.com/ellite/Wallos/compare/v2.24.1...v2.25.0) (2024-09-28) ### Features * add 2fa support ([#525](https://github.com/ellite/Wallos/issues/525)) ([2f16ab3](https://github.com/ellite/Wallos/commit/2f16ab3fdf89b8ba6b1010510d8b169aad425f38)) ## [2.24.1](https://github.com/ellite/Wallos/compare/v2.24.0...v2.24.1) (2024-09-23) ### Bug Fixes * small layout issue on the settings page ([0623ceb](https://github.com/ellite/Wallos/commit/0623cebe67182b493770615c518977907e11d359)) ## [2.24.0](https://github.com/ellite/Wallos/compare/v2.23.2...v2.24.0) (2024-09-18) ### Features * add button to clean up search field ([da3ee78](https://github.com/ellite/Wallos/commit/da3ee782f13c1eaa98a85de5dbe33714d173a323)) ### Bug Fixes * cases where theme and sort cookies could be missing ([da3ee78](https://github.com/ellite/Wallos/commit/da3ee782f13c1eaa98a85de5dbe33714d173a323)) * position of dropdown on rtl layout ([da3ee78](https://github.com/ellite/Wallos/commit/da3ee782f13c1eaa98a85de5dbe33714d173a323)) ## [2.23.2](https://github.com/ellite/Wallos/compare/v2.23.1...v2.23.2) (2024-09-04) ### Bug Fixes * sort order after edit subscription in case the cookie is missing ([87809fe](https://github.com/ellite/Wallos/commit/87809fea71b92c7518173fedd189d7e76ce11bfb)) ## [2.23.1](https://github.com/ellite/Wallos/compare/v2.23.0...v2.23.1) (2024-09-01) ### Bug Fixes * warning on top of dashboard page ([#512](https://github.com/ellite/Wallos/issues/512)) ([9056722](https://github.com/ellite/Wallos/commit/905672243b75e6b3d367d439bdbbb37d1b5ae0fa)) ## [2.23.0](https://github.com/ellite/Wallos/compare/v2.22.1...v2.23.0) (2024-09-01) ### Features * add multi email recipients ([fed0192](https://github.com/ellite/Wallos/commit/fed0192394e77409dae04d4ab3cdda0ba0c578a4)) * add option for also showing the original price on the dashboard ([fed0192](https://github.com/ellite/Wallos/commit/fed0192394e77409dae04d4ab3cdda0ba0c578a4)) * open edit form after cloning subscription ([fed0192](https://github.com/ellite/Wallos/commit/fed0192394e77409dae04d4ab3cdda0ba0c578a4)) * select multiple filters on the dashboard ([fed0192](https://github.com/ellite/Wallos/commit/fed0192394e77409dae04d4ab3cdda0ba0c578a4)) ### Bug Fixes * export.php csv header typo ([#499](https://github.com/ellite/Wallos/issues/499)) ([6e96c5d](https://github.com/ellite/Wallos/commit/6e96c5d4b0c7264ab37a85e9a8b8062f96f69c5c)) * typo on export subscriptions to csv ([fed0192](https://github.com/ellite/Wallos/commit/fed0192394e77409dae04d4ab3cdda0ba0c578a4)) ## [2.22.1](https://github.com/ellite/Wallos/compare/v2.22.0...v2.22.1) (2024-08-11) ### Bug Fixes * inline items in subscription form out of place ([#489](https://github.com/ellite/Wallos/issues/489)) ([3f33ba0](https://github.com/ellite/Wallos/commit/3f33ba0310af0c903db9bef1dd6668146219142c)) ## [2.22.0](https://github.com/ellite/Wallos/compare/v2.21.3...v2.22.0) (2024-08-09) ### Features * admin can manually trigger cronjobs ([1946ac9](https://github.com/ellite/Wallos/commit/1946ac9855696892b9a0790d46623614aa9aab2c)) ### Bug Fixes * only allow the system and admin to run the cronjobs ([1946ac9](https://github.com/ellite/Wallos/commit/1946ac9855696892b9a0790d46623614aa9aab2c)) * reduce size of the log files of the cronjobs ([1946ac9](https://github.com/ellite/Wallos/commit/1946ac9855696892b9a0790d46623614aa9aab2c)) ## [2.21.3](https://github.com/ellite/Wallos/compare/v2.21.2...v2.21.3) (2024-08-08) ### Bug Fixes * broken avatar upload when using the french language ([cf0d5d3](https://github.com/ellite/Wallos/commit/cf0d5d3df30909a0de7ef84aae2601d805617f90)) * more deprecation warnings on image uploads ([cf0d5d3](https://github.com/ellite/Wallos/commit/cf0d5d3df30909a0de7ef84aae2601d805617f90)) ## [2.21.2](https://github.com/ellite/Wallos/compare/v2.21.1...v2.21.2) (2024-08-07) ### Bug Fixes * add samesite directive to cookies ([8b0325c](https://github.com/ellite/Wallos/commit/8b0325c7d3c672754de220efd52b9ba9de8a9868)) * service worker precaching logout.php causes user to be logged out ([8b0325c](https://github.com/ellite/Wallos/commit/8b0325c7d3c672754de220efd52b9ba9de8a9868)) * sort by price ([8b0325c](https://github.com/ellite/Wallos/commit/8b0325c7d3c672754de220efd52b9ba9de8a9868)) ## [2.21.1](https://github.com/ellite/Wallos/compare/v2.21.0...v2.21.1) (2024-08-06) ### Bug Fixes * deprecation message for null value ([#479](https://github.com/ellite/Wallos/issues/479)) ([0274b1d](https://github.com/ellite/Wallos/commit/0274b1d5257f8f1c4156e2a342df6acf177ad726)) ## [2.21.0](https://github.com/ellite/Wallos/compare/v2.20.1...v2.21.0) (2024-08-06) ### Features * add option to list disabled subscriptions at the bottom ([3281f0c](https://github.com/ellite/Wallos/commit/3281f0ce35fbea237e21221d3a9026ed96ad84e5)) * notification for wallos version updates ([3281f0c](https://github.com/ellite/Wallos/commit/3281f0ce35fbea237e21221d3a9026ed96ad84e5)) ## [2.20.1](https://github.com/ellite/Wallos/compare/v2.20.0...v2.20.1) (2024-07-29) ### Bug Fixes * allow usernames with capital letters ([f241ba2](https://github.com/ellite/Wallos/commit/f241ba23018ee910ab859b2ce860b4c0678d6402)) * use 2 decimal places for price on the calendar ([f241ba2](https://github.com/ellite/Wallos/commit/f241ba23018ee910ab859b2ce860b4c0678d6402)) * use 2 decimal places for price when exporting ical in the calendar ([f241ba2](https://github.com/ellite/Wallos/commit/f241ba23018ee910ab859b2ce860b4c0678d6402)) ## [2.20.0](https://github.com/ellite/Wallos/compare/v2.19.3...v2.20.0) (2024-07-19) ### Features * export subscriptions as csv ([8f1e155](https://github.com/ellite/Wallos/commit/8f1e1554787c6e3ffaf7e73369a66794c0636713)) * export subscriptions as json ([8f1e155](https://github.com/ellite/Wallos/commit/8f1e1554787c6e3ffaf7e73369a66794c0636713)) * user can delete their own account ([8f1e155](https://github.com/ellite/Wallos/commit/8f1e1554787c6e3ffaf7e73369a66794c0636713)) ## [2.19.3](https://github.com/ellite/Wallos/compare/v2.19.2...v2.19.3) (2024-07-15) ### Bug Fixes * delete button on subscription form ([#460](https://github.com/ellite/Wallos/issues/460)) ([8cb4355](https://github.com/ellite/Wallos/commit/8cb43553fd2d3328fe9b1f7c5986e040071844c0)) ## [2.19.2](https://github.com/ellite/Wallos/compare/v2.19.1...v2.19.2) (2024-07-15) ### Bug Fixes * test ntfy without custom headers ([#456](https://github.com/ellite/Wallos/issues/456)) ([8fcfc92](https://github.com/ellite/Wallos/commit/8fcfc9264726ec1ded81ca2c51daa65ae9f4e7d8)) ## [2.19.1](https://github.com/ellite/Wallos/compare/v2.19.0...v2.19.1) (2024-07-14) ### Bug Fixes * unset sortOrder var ([a1fab4d](https://github.com/ellite/Wallos/commit/a1fab4dd1067f80054a2c52710edb859dba47127)) ## [2.19.0](https://github.com/ellite/Wallos/compare/v2.18.0...v2.19.0) (2024-07-14) ### Features * add alphanumeric sort order for subscriptions ([#449](https://github.com/ellite/Wallos/issues/449)) ([775e6ee](https://github.com/ellite/Wallos/commit/775e6ee39457edef420d5c36fb310a75fd47bff6)) ## [2.18.0](https://github.com/ellite/Wallos/compare/v2.17.0...v2.18.0) (2024-07-14) ### Features * disable display options checkbox when fixer key is not set ([5f10525](https://github.com/ellite/Wallos/commit/5f1052584b5ece93ebdcb5bce32210e2643a9f26)) * display error message on the statistics page when the fixer key is needed but is missing ([5f10525](https://github.com/ellite/Wallos/commit/5f1052584b5ece93ebdcb5bce32210e2643a9f26)) ## [2.17.0](https://github.com/ellite/Wallos/compare/v2.16.1...v2.17.0) (2024-07-11) ### Features * add filter and sort dashboard by subscription state ([afff992](https://github.com/ellite/Wallos/commit/afff992878287fdc51229297c455d1f69216c36e)) ### Bug Fixes * use the same font for inputs ([a539058](https://github.com/ellite/Wallos/commit/a5390580259105f14154b0d7ce1eb13631c471b1)) ## [2.16.1](https://github.com/ellite/Wallos/compare/v2.16.0...v2.16.1) (2024-07-10) ### Bug Fixes * error when logos folder is empty ([#439](https://github.com/ellite/Wallos/issues/439)) ([e2e5061](https://github.com/ellite/Wallos/commit/e2e5061d1506652384ceed018aa4330b8548b792)) ## [2.16.0](https://github.com/ellite/Wallos/compare/v2.15.0...v2.16.0) (2024-07-10) ### Features * add calendar to pwa shortcuts ([21ebf29](https://github.com/ellite/Wallos/commit/21ebf29f11405ab24b1b0ffd16eb667de4dfc189)) * change apple touch icon ([21ebf29](https://github.com/ellite/Wallos/commit/21ebf29f11405ab24b1b0ffd16eb667de4dfc189)) ## [2.15.0](https://github.com/ellite/Wallos/compare/v2.14.2...v2.15.0) (2024-07-09) ### Features * add maintenance tasks to admin page ([9f7f47b](https://github.com/ellite/Wallos/commit/9f7f47b5d1be2697c2c612bfddb6119c63a3d517)) * add support to upload svg logos ([9f7f47b](https://github.com/ellite/Wallos/commit/9f7f47b5d1be2697c2c612bfddb6119c63a3d517)) ## [2.14.2](https://github.com/ellite/Wallos/compare/v2.14.1...v2.14.2) (2024-07-08) ### Bug Fixes * broken subscription update query ([#431](https://github.com/ellite/Wallos/issues/431)) ([b00a985](https://github.com/ellite/Wallos/commit/b00a9855453663aeb2f1f4b7f0db3aca3994b12b)) ## [2.14.1](https://github.com/ellite/Wallos/compare/v2.14.0...v2.14.1) (2024-07-05) ### Bug Fixes * dashboard scrolling to top when opening a subscription ([#427](https://github.com/ellite/Wallos/issues/427)) ([cb03af8](https://github.com/ellite/Wallos/commit/cb03af8e46fb5ec5138ed7ef729f4b56a23d2b37)) ## [2.14.0](https://github.com/ellite/Wallos/compare/v2.13.0...v2.14.0) (2024-07-05) ### Features * add cancelation reminders ([#425](https://github.com/ellite/Wallos/issues/425)) ([c393146](https://github.com/ellite/Wallos/commit/c393146d9e3d494943de32ecd86983335358cf88)) ## [2.13.0](https://github.com/ellite/Wallos/compare/v2.12.0...v2.13.0) (2024-07-04) ### Features * uniformize layout and styles (+ checkboxes and radios) ([#423](https://github.com/ellite/Wallos/issues/423)) ([c166c7e](https://github.com/ellite/Wallos/commit/c166c7e84c06ceba5ab21341c8d56bd1aaf042ec)) ## [2.12.0](https://github.com/ellite/Wallos/compare/v2.11.2...v2.12.0) (2024-07-03) ### Features * ability to add custom css styles ([50bd104](https://github.com/ellite/Wallos/commit/50bd104b5b990605f457b540bec95eff5034473d)) * cache logos for offline use ([50bd104](https://github.com/ellite/Wallos/commit/50bd104b5b990605f457b540bec95eff5034473d)) * more uniform and aligned styles on the settings page ([50bd104](https://github.com/ellite/Wallos/commit/50bd104b5b990605f457b540bec95eff5034473d)) * rework styles of theme section on settings page ([50bd104](https://github.com/ellite/Wallos/commit/50bd104b5b990605f457b540bec95eff5034473d)) ### Bug Fixes * don't allow saving main and accent colors if they're the same ([50bd104](https://github.com/ellite/Wallos/commit/50bd104b5b990605f457b540bec95eff5034473d)) ## [2.11.2](https://github.com/ellite/Wallos/compare/v2.11.1...v2.11.2) (2024-07-02) ### Bug Fixes * menus checkmark position ([#419](https://github.com/ellite/Wallos/issues/419)) ([4da5d47](https://github.com/ellite/Wallos/commit/4da5d47e3ce8b8564921c07e7b785a367d378d6b)) ## [2.11.1](https://github.com/ellite/Wallos/compare/v2.11.0...v2.11.1) (2024-06-30) ### Bug Fixes * syntax error on svg logo ([#417](https://github.com/ellite/Wallos/issues/417)) ([b82f750](https://github.com/ellite/Wallos/commit/b82f750c8e844012a8a12e33f01719f42199e7ce)) ## [2.11.0](https://github.com/ellite/Wallos/compare/v2.10.0...v2.11.0) (2024-06-30) ### Features * theming engine custom colors now affect icons as well ([83e2066](https://github.com/ellite/Wallos/commit/83e2066e7bee99a152cc3c22f5b1dd9c9866c9fd)) ## [2.10.0](https://github.com/ellite/Wallos/compare/v2.9.0...v2.10.0) (2024-06-27) ### Features * add purple theme ([4d74c04](https://github.com/ellite/Wallos/commit/4d74c04f0e5bab5e1ece7a4a666f14d4a221fba6)) ### Bug Fixes * file name on ics export for subscriptions with non-ascii characters ([4d74c04](https://github.com/ellite/Wallos/commit/4d74c04f0e5bab5e1ece7a4a666f14d4a221fba6)) ## [2.9.0](https://github.com/ellite/Wallos/compare/v2.8.0...v2.9.0) (2024-06-26) ### Features * create users from the admin page ([#409](https://github.com/ellite/Wallos/issues/409)) ([6d2ffa6](https://github.com/ellite/Wallos/commit/6d2ffa6312b05f308117f2686681e2fcfaf734ec)) ## [2.8.0](https://github.com/ellite/Wallos/compare/v2.7.0...v2.8.0) (2024-06-26) ### Features * also show previous payments on the calendar for the current month ([c2e85d6](https://github.com/ellite/Wallos/commit/c2e85d6e109d9d07cc2fdbcb09b51564d1f73341)) * support automatic dark mode ([c2e85d6](https://github.com/ellite/Wallos/commit/c2e85d6e109d9d07cc2fdbcb09b51564d1f73341)) ### Bug Fixes * not every payment cycle was shown on the calendar ([c2e85d6](https://github.com/ellite/Wallos/commit/c2e85d6e109d9d07cc2fdbcb09b51564d1f73341)) ## [2.7.0](https://github.com/ellite/Wallos/compare/v2.6.1...v2.7.0) (2024-06-25) ### Features * export subscription as ics from the calendar view ([#404](https://github.com/ellite/Wallos/issues/404)) ([f1360f7](https://github.com/ellite/Wallos/commit/f1360f7d468ef5ae7e974ec1f9bb77831ea322bb)) ## [2.6.1](https://github.com/ellite/Wallos/compare/v2.6.0...v2.6.1) (2024-06-25) ### Bug Fixes * load php calendar extension ([#402](https://github.com/ellite/Wallos/issues/402)) ([c02ac77](https://github.com/ellite/Wallos/commit/c02ac770d7ac9fad1baec526b5d7dd71deaba59b)) ## [2.6.0](https://github.com/ellite/Wallos/compare/v2.5.2...v2.6.0) (2024-06-25) ### Features * add calendar view ([#399](https://github.com/ellite/Wallos/issues/399)) ([369f1a2](https://github.com/ellite/Wallos/commit/369f1a2bdcd9bdf3996b3dc8de8921f8954a069d)) ## [2.5.2](https://github.com/ellite/Wallos/compare/v2.5.1...v2.5.2) (2024-06-24) ### Bug Fixes * add ability to run container as an arbitrary user ([#396](https://github.com/ellite/Wallos/issues/396)) ([86fe2f3](https://github.com/ellite/Wallos/commit/86fe2f3ebb9c38ac34eaccd144a9550b7b314138)) ## [2.5.1](https://github.com/ellite/Wallos/compare/v2.5.0...v2.5.1) (2024-06-21) ### Bug Fixes * ntfy notifications ([#394](https://github.com/ellite/Wallos/issues/394)) ([17722c3](https://github.com/ellite/Wallos/commit/17722c31e31eec035d8896566e9eb5596951d022)) ## [2.5.0](https://github.com/ellite/Wallos/compare/v2.4.2...v2.5.0) (2024-06-21) ### Features * add option to clone subscription ([8304ed7](https://github.com/ellite/Wallos/commit/8304ed7b54f50ed7fa5ab520ff4d8d54f3ef34df)) * edit and delete options now available directly on the subscription list ([8304ed7](https://github.com/ellite/Wallos/commit/8304ed7b54f50ed7fa5ab520ff4d8d54f3ef34df)) ### Bug Fixes * typo on webhook payload ([8304ed7](https://github.com/ellite/Wallos/commit/8304ed7b54f50ed7fa5ab520ff4d8d54f3ef34df)) ## [2.4.2](https://github.com/ellite/Wallos/compare/v2.4.1...v2.4.2) (2024-06-10) ### Bug Fixes * update exchange cron only working for one user ([#384](https://github.com/ellite/Wallos/issues/384)) ([815eea7](https://github.com/ellite/Wallos/commit/815eea7e7be37e068e6173c229eb285ed8b7c30d)) ## [2.4.1](https://github.com/ellite/Wallos/compare/v2.4.0...v2.4.1) (2024-06-09) ### Bug Fixes * cronjob exchange update would not work with apilayer ([#381](https://github.com/ellite/Wallos/issues/381)) ([b0b4b7a](https://github.com/ellite/Wallos/commit/b0b4b7a65cd479e7532e72e826d3c01aead403c3)) ## [2.4.0](https://github.com/ellite/Wallos/compare/v2.3.0...v2.4.0) (2024-06-07) ### Features * add hability to disable login ([#378](https://github.com/ellite/Wallos/issues/378)) ([092be22](https://github.com/ellite/Wallos/commit/092be22183359f714fc9638d9013b742da828ed6)) ## [2.3.0](https://github.com/ellite/Wallos/compare/v2.2.0...v2.3.0) (2024-06-05) ### Features * add ntfy as notification method ([#377](https://github.com/ellite/Wallos/issues/377)) ([65edf09](https://github.com/ellite/Wallos/commit/65edf0963b73deff0f0f7f04427e69ce335bd776)) ### Bug Fixes * custom headers for webhook notifications ([#375](https://github.com/ellite/Wallos/issues/375)) ([7217088](https://github.com/ellite/Wallos/commit/7217088bb0732735a65322bce136d7d556b1acf3)) ## [2.2.0](https://github.com/ellite/Wallos/compare/v2.1.0...v2.2.0) (2024-06-04) ### Features * change filename of backup file ([fa99a73](https://github.com/ellite/Wallos/commit/fa99a735cd23918bab95baaf13b7a3142946d4b2)) * frequency is now up to 366 ([fa99a73](https://github.com/ellite/Wallos/commit/fa99a735cd23918bab95baaf13b7a3142946d4b2)) ### Bug Fixes * add webp support to gd on the container ([fa99a73](https://github.com/ellite/Wallos/commit/fa99a735cd23918bab95baaf13b7a3142946d4b2)) * translate: "no category" ([fa99a73](https://github.com/ellite/Wallos/commit/fa99a735cd23918bab95baaf13b7a3142946d4b2)) * trim fixer api key ([fa99a73](https://github.com/ellite/Wallos/commit/fa99a735cd23918bab95baaf13b7a3142946d4b2)) * update slovanian translations ([fa99a73](https://github.com/ellite/Wallos/commit/fa99a735cd23918bab95baaf13b7a3142946d4b2)) ## [2.1.0](https://github.com/ellite/Wallos/compare/v2.0.0...v2.1.0) (2024-05-27) ### Features * add slovenian translation ([03ceb8a](https://github.com/ellite/Wallos/commit/03ceb8a6e64c8cd4deb4019668fbf98acb57c5fe)) ### Bug Fixes * currency conversion failing on the statistics page ([03ceb8a](https://github.com/ellite/Wallos/commit/03ceb8a6e64c8cd4deb4019668fbf98acb57c5fe)) ## [2.0.0](https://github.com/ellite/Wallos/compare/v1.29.1...v2.0.0) (2024-05-26) ### ⚠ BREAKING CHANGES * allow registration of multiple users ([#340](https://github.com/ellite/Wallos/issues/340)) ### Features * add reset password functionality ([e1006e5](https://github.com/ellite/Wallos/commit/e1006e582388a7fab204f25c100347607b863e4e)) * administration area ([e1006e5](https://github.com/ellite/Wallos/commit/e1006e582388a7fab204f25c100347607b863e4e)) * allow registration of multiple users ([#340](https://github.com/ellite/Wallos/issues/340)) ([e1006e5](https://github.com/ellite/Wallos/commit/e1006e582388a7fab204f25c100347607b863e4e)) ## [1.29.1](https://github.com/ellite/Wallos/compare/v1.29.0...v1.29.1) (2024-05-20) ### Bug Fixes * calling htmlspecialchars_decode on null objects ([#338](https://github.com/ellite/Wallos/issues/338)) ([5050a28](https://github.com/ellite/Wallos/commit/5050a28f0e64e8c1eefb4f7cca8f6f6e473177e3)) ## [1.29.0](https://github.com/ellite/Wallos/compare/v1.28.0...v1.29.0) (2024-05-20) ### Features * subscriptions have personalized notification times ([#334](https://github.com/ellite/Wallos/issues/334)) ([c7146df](https://github.com/ellite/Wallos/commit/c7146dfd08c2a60d4ff6f7ac1f7cf5830fe28d9c)) ## [1.28.0](https://github.com/ellite/Wallos/compare/v1.27.2...v1.28.0) (2024-05-17) ### Features * add monthly budget field and statistics ([#329](https://github.com/ellite/Wallos/issues/329)) ([b622434](https://github.com/ellite/Wallos/commit/b622434ca0791d5c8026d641e1b32f8a2f0f42b8)) ## [1.27.2](https://github.com/ellite/Wallos/compare/v1.27.1...v1.27.2) (2024-05-17) ### Bug Fixes * duplicated messages on discord notifications ([d44b40b](https://github.com/ellite/Wallos/commit/d44b40b0ce80e91821fe7441c85e0d8794680618)) * possible division by 0 on statistics page ([d44b40b](https://github.com/ellite/Wallos/commit/d44b40b0ce80e91821fe7441c85e0d8794680618)) ## [1.27.1](https://github.com/ellite/Wallos/compare/v1.27.0...v1.27.1) (2024-05-13) ### Bug Fixes * import of translations for cronjobs was missing ([#321](https://github.com/ellite/Wallos/issues/321)) ([a524419](https://github.com/ellite/Wallos/commit/a524419e0a468147a2094dba81689dd643a0108b)) ## [1.27.0](https://github.com/ellite/Wallos/compare/v1.26.2...v1.27.0) (2024-05-11) ### Features * add korean translation ([#314](https://github.com/ellite/Wallos/issues/314)) ([bc40320](https://github.com/ellite/Wallos/commit/bc403206905b39c3aa88f3eb51e59b41e2a5e24e)) ## [1.26.2](https://github.com/ellite/Wallos/compare/v1.26.1...v1.26.2) (2024-05-09) ### Bug Fixes * russian translations ([#309](https://github.com/ellite/Wallos/issues/309)) ([8f890fc](https://github.com/ellite/Wallos/commit/8f890fc5d3a62a91feec50564179b3241ed538bf)) ## [1.26.1](https://github.com/ellite/Wallos/compare/v1.26.0...v1.26.1) (2024-05-09) ### Bug Fixes * background removal experimental setting ([#307](https://github.com/ellite/Wallos/issues/307)) ([bb5ee2e](https://github.com/ellite/Wallos/commit/bb5ee2e64c11b1415da3aa50119dfaa3783be37f)) ## [1.26.0](https://github.com/ellite/Wallos/compare/v1.25.1...v1.26.0) (2024-05-08) ### Features * add russian translation ([#305](https://github.com/ellite/Wallos/issues/305)) ([ae04d50](https://github.com/ellite/Wallos/commit/ae04d50329c1fb0117e186f89fef38b495cbbe9c)) ## [1.25.1](https://github.com/ellite/Wallos/compare/v1.25.0...v1.25.1) (2024-05-07) ### Bug Fixes * broken discord form ([#302](https://github.com/ellite/Wallos/issues/302)) ([b435d6a](https://github.com/ellite/Wallos/commit/b435d6a5cf6f80404c487b519334b2854aab9713)) ## [1.25.0](https://github.com/ellite/Wallos/compare/v1.24.0...v1.25.0) (2024-05-06) ### Features * add discord and pushover as notification agents ([#300](https://github.com/ellite/Wallos/issues/300)) ([8994829](https://github.com/ellite/Wallos/commit/899482982e7e200f5a7081ed6285475e5cb2a37d)) ### Bug Fixes * most error messages of the notifications endpoints would not reach the frontend ([8994829](https://github.com/ellite/Wallos/commit/899482982e7e200f5a7081ed6285475e5cb2a37d)) ## [1.24.0](https://github.com/ellite/Wallos/compare/v1.23.0...v1.24.0) (2024-05-05) ### Features * add new notification methods (telegram, webhooks, gotify) ([#295](https://github.com/ellite/Wallos/issues/295)) ([a408031](https://github.com/ellite/Wallos/commit/a408031ef8711bf87e9f8db35f52c498f250b235)) ## [1.23.0](https://github.com/ellite/Wallos/compare/v1.22.0...v1.23.0) (2024-04-26) ### Features * backup and restore ([#288](https://github.com/ellite/Wallos/issues/288)) ([7b509d2](https://github.com/ellite/Wallos/commit/7b509d2b3d769e14a9cb4fd183395dcecc9d993b)) ## [1.22.0](https://github.com/ellite/Wallos/compare/v1.21.1...v1.22.0) (2024-04-20) ### Features * option to hide disabled subscriptions ([#286](https://github.com/ellite/Wallos/issues/286)) ([b80ab4b](https://github.com/ellite/Wallos/commit/b80ab4bdc662c3e80a2fd42b8b286b69beac441c)) ## [1.21.1](https://github.com/ellite/Wallos/compare/v1.21.0...v1.21.1) (2024-04-19) ### Bug Fixes * small layout issues ([769f8a0](https://github.com/ellite/Wallos/commit/769f8a0587941bffd0d7463b7e7ffeb38a70e301)) ## [1.21.0](https://github.com/ellite/Wallos/compare/v1.20.2...v1.21.0) (2024-04-19) ### Features * add italian translation ([70e4234](https://github.com/ellite/Wallos/commit/70e42349caee5d6647b6b704643fe2b5e26dff4e)) * add themes and custom color options ([70e4234](https://github.com/ellite/Wallos/commit/70e42349caee5d6647b6b704643fe2b5e26dff4e)) ## [1.20.2](https://github.com/ellite/Wallos/compare/v1.20.1...v1.20.2) (2024-04-11) ### Bug Fixes * encoding for url and notes ([#273](https://github.com/ellite/Wallos/issues/273)) ([ad86eb5](https://github.com/ellite/Wallos/commit/ad86eb5b9c6e60004de2795170032d62b33ddcfb)) ## [1.20.1](https://github.com/ellite/Wallos/compare/v1.20.0...v1.20.1) (2024-04-09) ### Bug Fixes * special chars in subscriptions ([#271](https://github.com/ellite/Wallos/issues/271)) ([2683a7c](https://github.com/ellite/Wallos/commit/2683a7c4ba3c3575347d48f2c97b92b2ff0cc9f9)) ## [1.20.0](https://github.com/ellite/Wallos/compare/v1.19.0...v1.20.0) (2024-04-07) ### Features * add serbian translation ([#268](https://github.com/ellite/Wallos/issues/268)) ([55089c0](https://github.com/ellite/Wallos/commit/55089c0715ca315feb6a8795b07d9c36167494de)) ## [1.19.0](https://github.com/ellite/Wallos/compare/v1.18.3...v1.19.0) (2024-04-03) ### Features * add polish translation ([#263](https://github.com/ellite/Wallos/issues/263)) ([c752761](https://github.com/ellite/Wallos/commit/c7527610fafa49b18076971befa246b2730b79c4)) ## [1.18.3](https://github.com/ellite/Wallos/compare/v1.18.2...v1.18.3) (2024-03-30) ### Bug Fixes * on initial registration page, logo can be cut off ([#258](https://github.com/ellite/Wallos/issues/258)) ([dde8695](https://github.com/ellite/Wallos/commit/dde8695fb555f483ef8bc8f24db2a610301bab16)) ## [1.18.2](https://github.com/ellite/Wallos/compare/v1.18.1...v1.18.2) (2024-03-28) ### Bug Fixes * small icon size for payment icons ([#253](https://github.com/ellite/Wallos/issues/253)) ([8998e23](https://github.com/ellite/Wallos/commit/8998e23d370165ca158600550dbf0eb8c07d4bac)) ## [1.18.1](https://github.com/ellite/Wallos/compare/v1.18.0...v1.18.1) (2024-03-25) ### Bug Fixes * disabled inputs on dark theme ([#250](https://github.com/ellite/Wallos/issues/250)) ([11f0e7c](https://github.com/ellite/Wallos/commit/11f0e7ce63f37adb922e530a54f3e5cc9f640eee)) ## [1.18.0](https://github.com/ellite/Wallos/compare/v1.17.3...v1.18.0) (2024-03-24) ### Features * add custom avatar functionality ([#248](https://github.com/ellite/Wallos/issues/248)) ([1dbebd3](https://github.com/ellite/Wallos/commit/1dbebd3918ef6f27961f4e70b6ad007133f8ff93)) ## [1.17.3](https://github.com/ellite/Wallos/compare/v1.17.2...v1.17.3) (2024-03-20) ### Bug Fixes * next payment date not updating for disabled subscriptions ([#243](https://github.com/ellite/Wallos/issues/243)) ([75a5672](https://github.com/ellite/Wallos/commit/75a5672de32a59cc53c3c76a08793e6a33cce828)) ## [1.17.2](https://github.com/ellite/Wallos/compare/v1.17.1...v1.17.2) (2024-03-18) ### Bug Fixes * pwa not loading static files when offline ([#241](https://github.com/ellite/Wallos/issues/241)) ([4e3376d](https://github.com/ellite/Wallos/commit/4e3376df93ea7c2b3e184b2670ebe77fe9b15d6a)) ## [1.17.1](https://github.com/ellite/Wallos/compare/v1.17.0...v1.17.1) (2024-03-18) ### Bug Fixes * cronjobs running twice ([#239](https://github.com/ellite/Wallos/issues/239)) ([00cbf8d](https://github.com/ellite/Wallos/commit/00cbf8d9e3feac87292630f8db4571a99b542db4)) ## [1.17.0](https://github.com/ellite/Wallos/compare/v1.16.3...v1.17.0) (2024-03-17) ### Features * allow selecting tls or ssl for email notifications ([#237](https://github.com/ellite/Wallos/issues/237)) ([2462435](https://github.com/ellite/Wallos/commit/246243574328ead6d95d45b81b055761b01040a7)) ## [1.16.3](https://github.com/ellite/Wallos/compare/v1.16.2...v1.16.3) (2024-03-17) ### Bug Fixes * allow redirects on logo search ([ae73db7](https://github.com/ellite/Wallos/commit/ae73db77907786993f52f7273145dafa660c4d36)) * rename category after adding and sort order of categories ([ae73db7](https://github.com/ellite/Wallos/commit/ae73db77907786993f52f7273145dafa660c4d36)) ## [1.16.2](https://github.com/ellite/Wallos/compare/v1.16.1...v1.16.2) (2024-03-13) ### Bug Fixes * wrong folder for payment method logos ([#227](https://github.com/ellite/Wallos/issues/227)) ([f6c1ff2](https://github.com/ellite/Wallos/commit/f6c1ff2a6be6545c6c179722235db3cd724127fd)) ## [1.16.1](https://github.com/ellite/Wallos/compare/v1.16.0...v1.16.1) (2024-03-12) ### Bug Fixes * confusing wording for billing cycle ([94ad0cb](https://github.com/ellite/Wallos/commit/94ad0cb553d7f05b15e9ab27fbf4c26955fc3ff1)) ## [1.16.0](https://github.com/ellite/Wallos/compare/v1.15.3...v1.16.0) (2024-03-10) ### Features * allow sorting payment methods ([#217](https://github.com/ellite/Wallos/issues/217)) ([aef2d13](https://github.com/ellite/Wallos/commit/aef2d134c22f7dc95821ff711f7bca56228bfed6)) * don't allow to change currency code if in use ([aef2d13](https://github.com/ellite/Wallos/commit/aef2d134c22f7dc95821ff711f7bca56228bfed6)) ## [1.15.3](https://github.com/ellite/Wallos/compare/v1.15.2...v1.15.3) (2024-03-10) ### Bug Fixes * sql injection vulnerability when using filters ([#214](https://github.com/ellite/Wallos/issues/214)) ([cbdc188](https://github.com/ellite/Wallos/commit/cbdc188e5e7a2c357f5b0bcaeaf2e886cd2555e3)) ## [1.15.2](https://github.com/ellite/Wallos/compare/v1.15.1...v1.15.2) (2024-03-09) ### Bug Fixes * undefined var on the statistics page ([#211](https://github.com/ellite/Wallos/issues/211)) ([8b7a7b9](https://github.com/ellite/Wallos/commit/8b7a7b94e3ae9177be6d067d8fee0a05aa428f4a)) ## [1.15.1](https://github.com/ellite/Wallos/compare/v1.15.0...v1.15.1) (2024-03-09) ### Bug Fixes * undefined var if sort cookie is not set ([#207](https://github.com/ellite/Wallos/issues/207)) ([288c106](https://github.com/ellite/Wallos/commit/288c10624592aa04cc76cb8ae066331d65964650)) ## [1.15.0](https://github.com/ellite/Wallos/compare/v1.14.1...v1.15.0) (2024-03-09) ### Features * filters on the subscriptions page ([a396285](https://github.com/ellite/Wallos/commit/a396285b76cd87e598495f311a81dc68a7f66d36)) * search subscriptions by name ([a396285](https://github.com/ellite/Wallos/commit/a396285b76cd87e598495f311a81dc68a7f66d36)) ## [1.14.1](https://github.com/ellite/Wallos/compare/v1.14.0...v1.14.1) (2024-03-08) ### Bug Fixes * wrong message when deleting payment methods ([#202](https://github.com/ellite/Wallos/issues/202)) ([93a3d18](https://github.com/ellite/Wallos/commit/93a3d189794985c1d8cfd5558c482f66e79405a8)) ## [1.14.0](https://github.com/ellite/Wallos/compare/v1.13.0...v1.14.0) (2024-03-08) ### Features * add brazilian portuguese to available languages ([#198](https://github.com/ellite/Wallos/issues/198)) ([3ea9d98](https://github.com/ellite/Wallos/commit/3ea9d98da79e9b13ab9d93a56b89062ac19c31d7)) ## [1.13.0](https://github.com/ellite/Wallos/compare/v1.12.1...v1.13.0) (2024-03-07) ### Features * show name of most expensive subscription on statistics ([#194](https://github.com/ellite/Wallos/issues/194)) ([ede08b1](https://github.com/ellite/Wallos/commit/ede08b1f6ae2d52ac0f8e1aaa77edc1924f529ce)) ## [1.12.1](https://github.com/ellite/Wallos/compare/v1.12.0...v1.12.1) (2024-03-06) ### Bug Fixes * broken chinese language file ([#192](https://github.com/ellite/Wallos/issues/192)) ([94c1a91](https://github.com/ellite/Wallos/commit/94c1a91387ca05fad3a50e5f318d8439c7608cbe)) ## [1.12.0](https://github.com/ellite/Wallos/compare/v1.11.3...v1.12.0) (2024-03-05) ### Features * add filters to statistics page ([83234ab](https://github.com/ellite/Wallos/commit/83234ab8cd184f4693a148dc55bddef300c49e71)) * allow deletion of the default payment methods ([83234ab](https://github.com/ellite/Wallos/commit/83234ab8cd184f4693a148dc55bddef300c49e71)) * allow renaming / translation of payment methods ([83234ab](https://github.com/ellite/Wallos/commit/83234ab8cd184f4693a148dc55bddef300c49e71)) * allow sorting of categories in settings ([83234ab](https://github.com/ellite/Wallos/commit/83234ab8cd184f4693a148dc55bddef300c49e71)) ## [1.11.3](https://github.com/ellite/Wallos/compare/v1.11.2...v1.11.3) (2024-03-02) ### Bug Fixes * redirects with the service worker ([#183](https://github.com/ellite/Wallos/issues/183)) ([940bbbe](https://github.com/ellite/Wallos/commit/940bbbea9071a7c2687a3340bb8e9d6f4f884cc1)) ## [1.11.2](https://github.com/ellite/Wallos/compare/v1.11.1...v1.11.2) (2024-03-02) ### Bug Fixes * file upload bypass vulnerability ([#181](https://github.com/ellite/Wallos/issues/181)) ([0f7853f](https://github.com/ellite/Wallos/commit/0f7853f961ba2f68f8dcd358acaad6c6eb7980e6)) ## [1.11.1](https://github.com/ellite/Wallos/compare/v1.11.0...v1.11.1) (2024-03-01) ### Bug Fixes * security issue with image upload ([#175](https://github.com/ellite/Wallos/issues/175)) ([7b5e166](https://github.com/ellite/Wallos/commit/7b5e166e289f32b1b3451614b16e1f4c0b9d6f2a)) ## [1.11.0](https://github.com/ellite/Wallos/compare/v1.10.0...v1.11.0) (2024-03-01) ### Features * added custom payment methods ([#173](https://github.com/ellite/Wallos/issues/173)) ([e739622](https://github.com/ellite/Wallos/commit/e73962260678caf0843b6302f7fbb7d49469a1a9)) ## [1.10.0](https://github.com/ellite/Wallos/compare/v1.9.1...v1.10.0) (2024-02-29) ### Features * use brave search for the logos if google fails ([#169](https://github.com/ellite/Wallos/issues/169)) ([fff783e](https://github.com/ellite/Wallos/commit/fff783e4e87f04199817c7cb3b4bd28760d2b5f3)) ## [1.9.1](https://github.com/ellite/Wallos/compare/v1.9.0...v1.9.1) (2024-02-28) ### Bug Fixes * move display settings to the bottom ([ec25d4b](https://github.com/ellite/Wallos/commit/ec25d4bc5a35f68ff15d456ae6a1d3e98d124f5f)) * reorder subscription form ([ec25d4b](https://github.com/ellite/Wallos/commit/ec25d4bc5a35f68ff15d456ae6a1d3e98d124f5f)) * show email field on adding household member ([ec25d4b](https://github.com/ellite/Wallos/commit/ec25d4bc5a35f68ff15d456ae6a1d3e98d124f5f)) ## [1.9.0](https://github.com/ellite/Wallos/compare/v1.8.3...v1.9.0) (2024-02-27) ### Features * enable progressive web app ([a2a315e](https://github.com/ellite/Wallos/commit/a2a315e34dca2562bc11793cc5841c2082e811a9)) ### Bug Fixes * update packages to fix vulnerabilities ([a2a315e](https://github.com/ellite/Wallos/commit/a2a315e34dca2562bc11793cc5841c2082e811a9)) ## [1.8.3](https://github.com/ellite/Wallos/compare/v1.8.2...v1.8.3) (2024-02-26) ### Bug Fixes * remove service worker ([#157](https://github.com/ellite/Wallos/issues/157)) ([5ccadce](https://github.com/ellite/Wallos/commit/5ccadce2f139e5873889badc51a67bfaef8a9304)) ## [1.8.2](https://github.com/ellite/Wallos/compare/v1.8.1...v1.8.2) (2024-02-26) ### Bug Fixes * service worker redirect not set to follow ([3640b54](https://github.com/ellite/Wallos/commit/3640b547ee3ca28e7b872b9e2dbbcd1d31c54953)) ## [1.8.1](https://github.com/ellite/Wallos/compare/v1.8.0...v1.8.1) (2024-02-26) ### Bug Fixes * service worker has redirections ([4aca7bc](https://github.com/ellite/Wallos/commit/4aca7bcb3cdbb77958db8783c4f088df131db645)) ## [1.8.0](https://github.com/ellite/Wallos/compare/v1.7.0...v1.8.0) (2024-02-26) ### Features * convert wallos into a progressive web app ([#151](https://github.com/ellite/Wallos/issues/151)) ([19e2058](https://github.com/ellite/Wallos/commit/19e205897617ee894d8802f7e73fef46be386c30)) ### Bug Fixes * improve traditional chinese translations ([19e2058](https://github.com/ellite/Wallos/commit/19e205897617ee894d8802f7e73fef46be386c30)) ## [1.7.0](https://github.com/ellite/Wallos/compare/v1.6.0...v1.7.0) (2024-02-25) ### Features * add email for notifications to household members ([26363dd](https://github.com/ellite/Wallos/commit/26363dd5f364b5494c526a9769626b03bba45273)) ## [1.6.0](https://github.com/ellite/Wallos/compare/v1.5.0...v1.6.0) (2024-02-24) ### Features * add stats about inactive subscriptions ([#146](https://github.com/ellite/Wallos/issues/146)) ([ccac17a](https://github.com/ellite/Wallos/commit/ccac17a6f222cb1ee022fd30b7a1d34306dd0de2)) * sort disabled subscription at the bottom ([ccac17a](https://github.com/ellite/Wallos/commit/ccac17a6f222cb1ee022fd30b7a1d34306dd0de2)) ## [1.5.0](https://github.com/ellite/Wallos/compare/v1.4.1...v1.5.0) (2024-02-23) ### Features * allow to disable subscriptions ([#144](https://github.com/ellite/Wallos/issues/144)) ([50056d9](https://github.com/ellite/Wallos/commit/50056d9f03a46c166650474b3877b55a24873bb9)) ## [1.4.1](https://github.com/ellite/Wallos/compare/v1.4.0...v1.4.1) (2024-02-22) ### Bug Fixes * bug on saving fixer api key ([#142](https://github.com/ellite/Wallos/issues/142)) ([866eb28](https://github.com/ellite/Wallos/commit/866eb28e88495e851336b5e224274a823ff4173d)) ## [1.4.0](https://github.com/ellite/Wallos/compare/v1.3.1...v1.4.0) (2024-02-21) ### Features * persist display and experimental settings on the db ([f0a6f1a](https://github.com/ellite/Wallos/commit/f0a6f1a2f18b329c9f784a9f1953cd0e7616e1c6)) * small styles changed ([f0a6f1a](https://github.com/ellite/Wallos/commit/f0a6f1a2f18b329c9f784a9f1953cd0e7616e1c6)) ## [1.3.1](https://github.com/ellite/Wallos/compare/v1.3.0...v1.3.1) (2024-02-20) ### Bug Fixes * missing authentication check ([#133](https://github.com/ellite/Wallos/issues/133)) ([b887d3a](https://github.com/ellite/Wallos/commit/b887d3a0503585dadde4b1b59b023c981b0f7f66)) ## [1.3.0](https://github.com/ellite/Wallos/compare/v1.2.0...v1.3.0) (2024-02-19) ### Features * add apilayer as provider for fixer api ([0f19dd6](https://github.com/ellite/Wallos/commit/0f19dd688fe3a2156e7d26d1bf1e1f8b30ce79ad)) * add apilayer as provider for fixer api ([#127](https://github.com/ellite/Wallos/issues/127)) ([0f19dd6](https://github.com/ellite/Wallos/commit/0f19dd688fe3a2156e7d26d1bf1e1f8b30ce79ad)) * update exchange rate when saving api key ([0f19dd6](https://github.com/ellite/Wallos/commit/0f19dd688fe3a2156e7d26d1bf1e1f8b30ce79ad)) ## [1.2.0](https://github.com/ellite/Wallos/compare/v1.1.0...v1.2.0) (2024-02-19) ### Features * enable deployment in subdirectory ([e2af9af](https://github.com/ellite/Wallos/commit/e2af9afc32bfc248f594336c50d44ad6f36f197e)) ## [1.1.0](https://github.com/ellite/Wallos/compare/v1.0.1...v1.1.0) (2024-02-18) ### Features * new statistics per payment method ([#124](https://github.com/ellite/Wallos/issues/124)) ([6200fa5](https://github.com/ellite/Wallos/commit/6200fa5e87d3f60853c3d8b95f5d676e39b378f4)) ## [1.0.1](https://github.com/ellite/Wallos/compare/v1.0.0...v1.0.1) (2024-02-18) ### Bug Fixes * show translated no category when sorting by category ([#122](https://github.com/ellite/Wallos/issues/122)) ([330c061](https://github.com/ellite/Wallos/commit/330c061b74ad1580173f3d3bc7b14048492e22d2)) ## 1.0.0 (2024-02-15) ### Features * add workflow for building and publishing docker images ([970c96a](https://github.com/ellite/Wallos/commit/970c96a8c904809544c944071986be2a684daf50)) * specify image stability type when triggering build ([5b22cfd](https://github.com/ellite/Wallos/commit/5b22cfd87a94a865f53b282964961862bbea1861)) ### Bug Fixes * Currency not preselected on registration ([fc56cf6](https://github.com/ellite/Wallos/commit/fc56cf69ef22a07978022265b2e8344dc293eb14)) * Language sort order ([884a8e5](https://github.com/ellite/Wallos/commit/884a8e569339ddbcb89af4634c0c845b053affbb)) ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to wallos We welcome contributions from the community and look forward to working with you to improve this project! ## How to Contribute 1. **Fork the repository:** Start by forking the wallos repository to your own GitHub account. 2. **Clone your fork:** Clone the forked repository to your local machine (replace with your actual github username): ```bash git clone https://github.com//wallos.git cd wallos ``` 3. **Create a branch:** Create a new branch for your changes: ```bash git checkout -b feature/your-feature-name ``` or ```bash git checkout -b fix/your-bug-fix-name ``` 4. **Make your changes:** Implement your feature or bug fix. 5. **Test your changes:** Ensure that your changes work as expected. 6. **Commit your changes:** Commit your changes with a clear and concise message: ```bash git add . git commit -m "Add your feature or fix" ``` 7. **Push your changes:** Push your branch to your forked repository: ```bash git push origin feature/your-feature-name ``` 8. **Create a Pull Request:** Go to the wallos repository on GitHub (https://github.com/ellite/wallos) and create a pull request from your branch to the `main` branch. ## Pull Request Guidelines * **One feature/fix per pull request:** Please keep pull requests focused on a single feature or bug fix. * **Clear and descriptive title and description:** Provide a clear title and description of your changes. * **Include relevant tests:** If possible, include tests for your changes. * **Follow the project's coding style:** Adhere to the project's coding style and conventions. * **Keep your pull request up to date:** If changes are requested, please update your pull request accordingly. ## Issues * **Bug Reports:** If you find a bug, please open an issue with a clear description of the problem and steps to reproduce it. * **Feature Requests:** If you have a feature request, please open an issue with a clear description of the feature and its benefits. * **Priority:** Bug fixes will take priority over feature requests. ## Translations If you want to contribute with a translation of wallos: 1. **Add your language code:** * Open `includes/i18n/languages.php`. * Add your language code in the format: `"" => ["name" => "", "dir" => ""],`. * Please use the original language name and not the English translation. * Example: `"pt" => ["name" => "Português", "dir" => "ltr"],`. 2. **Create language files:** * Copy `includes/i18n/en.php` and rename it to your language code (e.g., `pt.php`). * Translate all the values in the new language file. * Copy `scripts/i18n/en.js` and rename it to your language code (e.g., `pt.js`). * Translate all the values in the new javascript language file. * **Note:** Incomplete translations will not be accepted. 3. **Create a Pull Request:** Follow the Pull Request Guidelines above. ## Contributors Thank you for your contributions! ================================================ FILE: Dockerfile ================================================ # Use the php:8.3-fpm-alpine base image FROM php:8.3-fpm-alpine # Set working directory to /var/www/html WORKDIR /var/www/html # Update packages and install dependencies RUN apk upgrade --no-cache && \ apk add --no-cache dumb-init shadow sqlite-dev libpng libpng-dev libjpeg-turbo libjpeg-turbo-dev freetype freetype-dev curl autoconf libgomp icu-dev icu-data-full nginx dcron tzdata imagemagick imagemagick-dev libzip-dev sqlite libwebp-dev && \ docker-php-ext-install pdo pdo_sqlite calendar && \ docker-php-ext-enable pdo pdo_sqlite && \ docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp && \ docker-php-ext-install -j$(nproc) gd intl zip && \ apk add --no-cache --virtual .build-deps $PHPIZE_DEPS && \ pecl install imagick && \ docker-php-ext-enable imagick && \ apk del .build-deps # Copy your PHP application files into the container COPY . . # Copy Nginx configuration COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.default.conf /etc/nginx/http.d/default.conf # Remove nginx conf files from webroot RUN rm -rf /var/www/html/nginx.conf && \ rm -rf /var/www/html/nginx.default.conf # Copy the custom crontab file COPY cronjobs /etc/cron.d/cronjobs # Convert the line endings, allow read access to the cron file, and create cron log folder RUN dos2unix /etc/cron.d/cronjobs && \ chmod 0644 /etc/cron.d/cronjobs && \ /usr/bin/crontab /etc/cron.d/cronjobs && \ mkdir /var/log/cron && \ chown -R www-data:www-data /var/www/html && \ chmod +x /var/www/html/startup.sh && \ echo 'pm.max_children = 15' >> /usr/local/etc/php-fpm.d/zz-docker.conf && \ echo 'pm.max_requests = 500' >> /usr/local/etc/php-fpm.d/zz-docker.conf # Expose port 80 for Nginx EXPOSE 80 ENTRYPOINT ["dumb-init", "--"] # Requires docker engine 25+ for the --start-interval flag HEALTHCHECK --interval=2m --timeout=2s --start-period=20s --start-interval=5s --retries=3 \ CMD ["curl", "-fsS", "http://127.0.0.1/health.php"] # Start both PHP-FPM, Nginx CMD ["/var/www/html/startup.sh"] ================================================ FILE: LICENSE.md ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================
Wallos

Wallos: Open-Source Personal Subscription Tracker

[![Stars](https://img.shields.io/github/stars/ellite/Wallos?style=flat-square)](https://github.com/ellite/Wallos) [![Docker](https://img.shields.io/docker/pulls/bellamy/wallos?style=flat-square)](https://hub.docker.com/r/bellamy/wallos) [![GitHub contributors](https://img.shields.io/github/contributors/ellite/Wallos?style=flat-square)](https://github.com/ellite/Wallos/graphs/contributors) [![GitHub Sponsors](https://img.shields.io/github/sponsors/ellite?style=flat-square)](https://github.com/sponsors/ellite) [![Discord](https://img.shields.io/discord/1237073478910214235?logo=discord&style=flat-square)](https://discord.gg/anex9GUrPW)
## Table of Contents - [Introduction](#introduction) - [Features](#features) - [Demo](#demo) - [Getting Started](#getting-started) - [Prerequisites](#prerequisites) - [Baremetal](#baremetal) - [Docker](#docker) - [Installation](#installation) - [Baremetal](#baremetal-1) - [Updating](#updating) - [Docker](#docker-1) - [Docker-Compose](#docker-compose) - [Usage](#usage) - [Screenshots](#screenshots) - [OIDC](#oidc) - [API Documentation](#api-documentation) - [Contributing](#contributing) - [Contributors](#contributors) - [Translations](#translations) - [License](#license) - [Links](#links) ## Introduction Wallos is a powerful, open-source, and self-hostable web application designed to empower you in managing your finances with ease. Say goodbye to complicated spreadsheets and expensive financial software – Wallos simplifies the process of tracking expenses and helps you gain better control over your financial life. ## Features - Subscription Management: Keep track of your recurring subscriptions and payments, ensuring you never miss a due date. - Category Management: Organize your expenses into customizable categories, enabling you to gain insights into your spending habits. - Multi-Currency support: Wallos supports multiple currencies, allowing you to manage your finances in the currency of your choice. - Currency Conversion: Integrates with the Fixer API so you can get exchange rates and see all your subscriptions on your main currency. - Data Privacy: As a self-hosted application, Wallos ensures that your financial data remains private and secure on your own server. - Customization: Tailor Wallos to your needs with customizable categories, currencies, themes and other display options. - Sorting Options: Allowing you to view your subscriptions from different perspectives. - Logo Search: Wallos can search the web for the logo of your subscriptions if you don't have them available for upload. - Mobile view: Wallos on the go. - Statistics: Another perspective into your spendings. - Notifications: Wallos supports multiple notification methods (email, discord, pushover, telegram, gotify and webhooks). Get notified about your upcoming payments. - Multi Language support. - OIDC with OAuth - AI Recommendations with ChatGPT, Gemini or Local Ollama ## Demo If you want to try Wallos, a demo is available at [https://demo.wallosapp.com](https://demo.wallosapp.com). The database is reset every 2 hours. To access the demo use the following credentials: ```python Username: demo Password: demo ``` ## Getting Started See instructions to run Wallos below. ### Prerequisites #### Baremetal - NGINX or APACHE websever running - PHP 8.3 with the following modules enabled: - curl - dom - gd - imagick - intl - openssl - sqlite3 - zip - mbstring - fpm #### Docker - Docker ### Installation #### Baremetal 1. Download or clone this repo and move the files into your web root - usually `/var/www/html` 2. Rename `/db/wallos.empty.db` to `/db/wallos.db` 3. Run `http://domain.example/endpoints/db/migrate.php` on your browser 4. Add the following scripts to your cronjobs with `crontab -e` ```bash 0 1 * * * php /var/www/html/endpoints/cronjobs/updatenextpayment.php >> /var/log/cron/updatenextpayment.log 2>&1 0 2 * * * php /var/www/html/endpoints/cronjobs/updateexchange.php >> /var/log/cron/updateexchange.log 2>&1 0 8 * * * php /var/www/html/endpoints/cronjobs/sendcancellationnotifications.php >> /var/log/cron/sendcancellationnotifications.log 2>&1 0 9 * * * php /var/www/html/endpoints/cronjobs/sendnotifications.php >> /var/log/cron/sendnotifications.log 2>&1 */2 * * * * php /var/www/html/endpoints/cronjobs/sendverificationemails.php >> /var/log/cron/sendverificationemail.log 2>&1 */2 * * * * php /var/www/html/endpoints/cronjobs/sendresetpasswordemails.php >> /var/log/cron/sendresetpasswordemails.log 2>&1 0 */6 * * * php /var/www/html/endpoints/cronjobs/checkforupdates.php >> /var/log/cron/checkforupdates.log 2>&1 30 1 * * 1 php /var/www/html/endpoints/cronjobs/storetotalyearlycost.php >> /var/log/cron/storetotalyearlycost.log 2>&1 ``` 5. If your web root is not `/var/www/html/` adjust the cronjobs above accordingly. #### Updating 1. Re-download the repo and move the files into the correct folder or do `git pull` (if you used git clone before) 2. Check the [Prerequisites](#baremetal) and install / enable the missing ones, if any. 3. Run `http://domain.example/endpoints/db/migrate.php` #### Docker ```bash docker run -d --name wallos -v /path/to/config/wallos/db:/var/www/html/db \ -v /path/to/config/wallos/logos:/var/www/html/images/uploads/logos \ -e TZ=Europe/Berlin -p 8282:80 --restart unless-stopped \ bellamy/wallos:latest ``` Disable healthcheck (optional, e.g., for Docker <25 or faster startup reporting): ```bash docker run -d --name wallos -v /path/to/config/wallos/db:/var/www/html/db \ -v /path/to/config/wallos/logos:/var/www/html/images/uploads/logos \ -e TZ=Europe/Berlin -p 8282:80 --restart unless-stopped \ --health-cmd=NONE \ bellamy/wallos:latest ``` ### Docker Compose ``` services: wallos: container_name: wallos image: bellamy/wallos:latest ports: - "8282:80/tcp" environment: TZ: 'America/Toronto' # Volumes store your data between container upgrades volumes: - './db:/var/www/html/db' - './logos:/var/www/html/images/uploads/logos' restart: unless-stopped ``` Disable healthcheck (optional, e.g., for Docker <25 or faster startup reporting): ``` services: wallos: container_name: wallos image: bellamy/wallos:latest ports: - "8282:80/tcp" environment: TZ: 'America/Toronto' volumes: - './db:/var/www/html/db' - './logos:/var/www/html/images/uploads/logos' restart: unless-stopped healthcheck: test: ["NONE"] ``` ## Usage Just open the browser and open `ip:port` of the machine running wallos. On the first time you run wallos a user account must be created. Go to settings and personalise your Avatar and add members of your household. While there add / remove any categories and currencies. Get a free API Key from [Fixer](https://fixer.io/#pricing_plan) and add it in the settings. If you want to trigger an Update of the exchange rates, change your main currency after adding the API Key, and then change it back to your preferred one. ## Screenshots ![Screenshot](screenshots/wallos-subscriptions-light.png) ![Screenshot](screenshots/wallos-subscriptions-dark.png) ![Screenshot](screenshots/wallos-stats.png) ![Screenshot](screenshots/wallos-calendar.png) ![Screenshot](screenshots/wallos-form.png) ![Screenshot](screenshots/wallos-subscriptions-mobile-light.png) ![Screenshot](screenshots/wallos-subscriptions-mobile-dark.png) ![Screenshot](screenshots/wallos-dashboard-mobile-light.png) ![Screenshot](screenshots/wallos-dashboard-mobile-dark.png) ## OIDC OIDC can be enabled on the Admin page and can be used with providers that support OAuth. ## API Documentation Wallos provides a comprehensive API that allows you to interact with the application programmatically. The API documentation is available at [https://api.wallosapp.com/](https://api.wallosapp.com/). ## Contributing Feel free to open Pull requests with bug fixes and features. I'll do my best to keep an eye on those. Feel free to open issues with bug reports or feature requests. Bug fixes will take priority. I welcome contributions from the community and look forward to working with you to improve this project. ### Contributors ### Translations If you want to contribute with a translation of wallos: - Add your language code to `includes/i18n/languages.php` in the format `"en" => ["name" => "English", "dir" => "ltr"],`. Please use the original language name and not the english translation. - Create a copy of the file `includes/i18n/en.php` and rename it to the language code you used above. Example: pt.php for "pt" => ["name" => "Português", "dir" => "ltr"],. - Translate all the values on the language file to the new language. (Incomplete translations will not be accepted). - Create a copy of the file `scripts/i18n/en.js` and rename it to the language code you used above. Example: pt.js for "pt" => ["name" => "Português", "dir" => "ltr"],. - Translate all the values on the language file to the new language. (Incomplete translations will not be accepted). ## License This project is licensed under the [GNU General Public License, Version 3](LICENSE.md) - see the [LICENSE.md](LICENSE.md) file for details. ### Why GPLv3? I chose the GNU General Public License version 3 (GPLv3) for this project because it ensures that the software remains open source and freely available to the community. GPLv3 mandates that any derivative works or modifications must also be released under the same license, promoting the principles of software freedom. I strongly believe in the importance of open source software and the collaborative nature of development, and I invite contributors to help improve this project. ## Links - The author: [henrique.pt](https://henrique.pt) - Wallos Landingpage: [wallosapp.com](https://wallosapp.com) - Join the conversation: [Discord Server](https://discord.gg/anex9GUrPW) ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability If you discover any security vulnerabilities in this project, please report them to the developer by emailing [wallos@henrique.pt](mailto:wallos@henrique.pt). I appreciate your help in keeping the project secure. ## Supported Versions This project is currently supported with security updates for the following versions: | Version | Supported | | ------- | ------------------ | | latest | :white_check_mark: | | main | :white_check_mark: | | 1.x.x | :x: | ## Security Measures I take security seriously and am working on ways to implement security measures to protect the project. What is being done currenty: - Periodically scan the docker image for vulnerabilities with trivy. ## Reporting a Security Concern If you have any security concerns or questions regarding the security of this project, please contact the developer at [wallos@henrique.pt](mailto:wallos@henrique.pt). ## Responsible Disclosure I kindly request that you follow responsible disclosure practices and give me reasonable time to address any reported vulnerabilities before making them public. ================================================ FILE: about.php ================================================
================================================ FILE: admin.php ================================================ prepare('SELECT * FROM admin'); $result = $stmt->execute(); $settings = $result->fetchArray(SQLITE3_ASSOC); // get OIDC settings $stmt = $db->prepare('SELECT * FROM oauth_settings WHERE id = 1'); $result = $stmt->execute(); $oidcSettings = $result->fetchArray(SQLITE3_ASSOC); if ($oidcSettings === false) { // Table is empty or no row with id=1, set defaults $oidcSettings = [ 'name' => '', 'client_id' => '', 'client_secret' => '', 'authorization_url' => '', 'token_url' => '', 'user_info_url' => '', 'redirect_url' => '', 'logout_url' => '', 'user_identifier_field' => 'sub', 'scopes' => 'openid email profile', 'auth_style' => 'auto', 'auto_create_user' => 0, 'password_login_disabled' => 0 ]; } // get user accounts $stmt = $db->prepare('SELECT id, username, email FROM user ORDER BY id ASC'); $result = $stmt->execute(); $users = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $users[] = $row; } $userCount = is_array($users) ? count($users) : 0; $loginDisabledAllowed = $userCount == 1 && $settings['registrations_open'] == 0; ?>
= 0) { ?> prepare($query); $result = $stmt->execute(); $logosOnDisk = []; $logosOnDB = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $logosOnDB[] = $row['logo']; } // Get all logos in the payment_methods table $query = 'SELECT icon FROM payment_methods'; $stmt = $db->prepare($query); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { if (!strstr($row['icon'], "images/uploads/icons/")) { $logosOnDB[] = $row['icon']; } } $logosOnDB = array_unique($logosOnDB); // Get all logos in the uploads folder $uploadDir = 'images/uploads/logos/'; $uploadFiles = scandir($uploadDir); foreach ($uploadFiles as $file) { if ($file != '.' && $file != '..' && $file != 'avatars') { $logosOnDisk[] = ['logo' => $file]; } } // Find unused logos $unusedLogos = []; foreach ($logosOnDisk as $disk) { $found = false; foreach ($logosOnDB as $dbLogo) { if ($disk['logo'] == $dbLogo) { $found = true; break; } } if (!$found) { $unusedLogos[] = $disk; } } $logosToDelete = count($unusedLogos); ?>
================================================ FILE: api/admin/get_admin_settings.php ================================================ false, "title" => "Missing parameters" ]; echo json_encode($response); exit; } // Get user from API key $sql = "SELECT * FROM user WHERE api_key = :apiKey"; $stmt = $db->prepare($sql); $stmt->bindValue(':apiKey', $apiKey); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // If the user is not found, return an error if (!$user) { $response = [ "success" => false, "title" => "Invalid API key" ]; echo json_encode($response); exit; } $userId = $user['id']; if ($userId !== 1) { $response = [ "success" => false, "title" => "Invalid user" ]; echo json_encode($response); exit; } $sql = "SELECT * FROM 'admin'"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $admin_settings = $result->fetchArray(SQLITE3_ASSOC); if ($admin_settings) { unset($admin_settings['id']); // if the smtp_password is set, hide it if (isset($admin_settings['smtp_password'])) { $admin_settings['smtp_password'] = "********"; } } $response = [ "success" => true, "title" => "admin_settings", "admin_settings" => $admin_settings, "notes" => [] ]; echo json_encode($response); $db->close(); } else { $response = [ "success" => false, "title" => "Invalid request method" ]; echo json_encode($response); exit; } ?> ================================================ FILE: api/admin/get_oidc_settings.php ================================================ false, "title" => "Missing parameters" ]; echo json_encode($response); exit; } // Get user from API key $sql = "SELECT * FROM user WHERE api_key = :apiKey"; $stmt = $db->prepare($sql); $stmt->bindValue(':apiKey', $apiKey); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // If the user is not found, return an error if (!$user) { $response = [ "success" => false, "title" => "Invalid API key" ]; echo json_encode($response); exit; } $userId = $user['id']; if ($userId !== 1) { $response = [ "success" => false, "title" => "Invalid user" ]; echo json_encode($response); exit; } $sql = "SELECT * FROM 'oauth_settings' WHERE id = 1"; $stmt = $db->prepare($sql); $result = $stmt->execute(); $oidc_settings = $result->fetchArray(SQLITE3_ASSOC); if ($oidc_settings) { unset($oidc_settings['id']); } $response = [ "success" => true, "title" => "oidc_settings", "oidc_settings" => $oidc_settings, "notes" => [] ]; echo json_encode($response); $db->close(); } else { $response = [ "success" => false, "title" => "Invalid request method" ]; echo json_encode($response); exit; } ?> ================================================ FILE: api/admin/set_disable_password_login.php ================================================ false, 'title' => 'Invalid request method', 'message' => 'Only POST requests are allowed.' ]); exit; } $apiKey = $_POST['api_key'] ?? null; // Authenticate user first if (!$apiKey) { echo json_encode([ 'success' => false, 'title' => 'Missing API key', 'message' => 'API key is required.' ]); exit; } $sql = "SELECT * FROM user WHERE api_key = :apiKey"; $stmt = $db->prepare($sql); $stmt->bindValue(':apiKey', $apiKey); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); if (!$user || $user['id'] !== 1) { echo json_encode([ 'success' => false, 'title' => 'Unauthorized', 'message' => 'Invalid API key or insufficient privileges.' ]); exit; } // Now check 'disable' parameter only after authentication $disable = $_POST['disable'] ?? null; if (!isset($disable)) { echo json_encode([ 'success' => false, 'title' => 'Missing parameter', 'message' => 'Parameter "disable" is required.' ]); exit; } if (!in_array($disable, ['0', '1'], true)) { echo json_encode([ 'success' => false, 'title' => 'Invalid parameter', 'message' => 'Parameter "disable" must be "0" or "1".' ]); exit; } // Update the password_login_disabled setting $updateSql = "UPDATE oauth_settings SET password_login_disabled = :disable WHERE id = 1"; $updateStmt = $db->prepare($updateSql); $updateStmt->bindValue(':disable', intval($disable), SQLITE3_INTEGER); $updateResult = $updateStmt->execute(); if ($updateResult) { echo json_encode([ 'success' => true, 'title' => 'Updated', 'message' => "Password login has been " . ($disable === '1' ? "disabled" : "enabled") . "." ]); } else { echo json_encode([ 'success' => false, 'title' => 'Database error', 'message' => 'Failed to update the setting.' ]); } $db->close(); ================================================ FILE: api/categories/get_categories.php ================================================ false, "title" => "Missing parameters" ]; echo json_encode($response); exit; } // Get user from API key $sql = "SELECT * FROM user WHERE api_key = :apiKey"; $stmt = $db->prepare($sql); $stmt->bindValue(':apiKey', $apiKey); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // If the user is not found, return an error if (!$user) { $response = [ "success" => false, "title" => "Invalid API key" ]; echo json_encode($response); exit; } $userId = $user['id']; $sql = "SELECT * FROM categories WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $categories = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $categories[] = $row; } foreach ($categories as $key => $value) { unset($categories[$key]['user_id']); // Check if it's in use in any subscription $categoryId = $categories[$key]['id']; $sql = "SELECT COUNT(*) as count FROM subscriptions WHERE user_id = :userId AND category_id = :categoryId"; $stmt = $db->prepare($sql); $stmt->bindValue(':categoryId', $categoryId); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $count = $result->fetchArray(SQLITE3_ASSOC); if ($count['count'] > 0) { $categories[$key]['in_use'] = true; } else { $categories[$key]['in_use'] = false; } } $response = [ "success" => true, "title" => "categories", "categories" => $categories, "notes" => [] ]; echo json_encode($response); $db->close(); } else { $response = [ "success" => false, "title" => "Invalid request method" ]; echo json_encode($response); exit; } ?> ================================================ FILE: api/currencies/get_currencies.php ================================================ false, "title" => "Missing parameters" ]; echo json_encode($response); exit; } // Get user from API key $sql = "SELECT * FROM user WHERE api_key = :apiKey"; $stmt = $db->prepare($sql); $stmt->bindValue(':apiKey', $apiKey); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // If the user is not found, return an error if (!$user) { $response = [ "success" => false, "title" => "Invalid API key" ]; echo json_encode($response); exit; } $userId = $user['id']; $sql = "SELECT * FROM currencies WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $currencies = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $currencies[] = $row; } foreach ($currencies as $key => $value) { unset($currencies[$key]['user_id']); // Check if it's in use in any subscription $currencyId = $currencies[$key]['id']; $sql = "SELECT COUNT(*) as count FROM subscriptions WHERE user_id = :userId AND currency_id = :currencyId"; $stmt = $db->prepare($sql); $stmt->bindValue(':currencyId', $currencyId); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $count = $result->fetchArray(SQLITE3_ASSOC); if ($count['count'] > 0) { $currencies[$key]['in_use'] = true; } else { $currencies[$key]['in_use'] = false; } } $mainCurrency = $user['main_currency']; $response = [ "success" => true, "title" => "currencies", "main_currency" => $mainCurrency, "currencies" => $currencies, "notes" => [] ]; echo json_encode($response); $db->close(); } else { $response = [ "success" => false, "title" => "Invalid request method" ]; echo json_encode($response); exit; } ?> ================================================ FILE: api/fixer/get_fixer.php ================================================ false, "title" => "Missing parameters" ]; echo json_encode($response); exit; } // Get user from API key $sql = "SELECT * FROM user WHERE api_key = :apiKey"; $stmt = $db->prepare($sql); $stmt->bindValue(':apiKey', $apiKey); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // If the user is not found, return an error if (!$user) { $response = [ "success" => false, "title" => "Invalid API key" ]; echo json_encode($response); exit; } $userId = $user['id']; $providers = [ 0 => "Fixer.io", 1 => "APILayer.com" ]; $query = "SELECT * FROM fixer WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $fixer = $result->fetchArray(SQLITE3_ASSOC); $notes = []; if ($fixer) { unset($fixer['user_id']); $fixer['provider_name'] = $providers[$fixer['provider']]; if ($fixer['api_key']) { $fixer['api_key'] = "********"; } } else { $fixer = []; $notes[] = "No fixer settings found"; } $response = [ "success" => true, "title" => "fixer", "fixer" => $fixer, "notes" => $notes ]; echo json_encode($response); $db->close(); } else { $response = [ "success" => false, "title" => "Invalid request method" ]; echo json_encode($response); exit; } ?> ================================================ FILE: api/household/get_household.php ================================================ false, "title" => "Missing parameters" ]; echo json_encode($response); exit; } // Get user from API key $sql = "SELECT * FROM user WHERE api_key = :apiKey"; $stmt = $db->prepare($sql); $stmt->bindValue(':apiKey', $apiKey); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // If the user is not found, return an error if (!$user) { $response = [ "success" => false, "title" => "Invalid API key" ]; echo json_encode($response); exit; } $userId = $user['id']; $sql = "SELECT * FROM household WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $household = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $household[] = $row; } foreach ($household as $key => $value) { unset($household[$key]['user_id']); // Check if is used in any subscriptions $sql = "SELECT * FROM subscriptions WHERE user_id = :userId AND payer_user_id = :householdId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $stmt->bindValue(':householdId', $household[$key]['id']); $result = $stmt->execute(); $subscription = $result->fetchArray(SQLITE3_ASSOC); if ($subscription) { $household[$key]['in_use'] = true; } else { $household[$key]['in_use'] = false; } } $response = [ "success" => true, "title" => "household", "household" => $household, "notes" => [] ]; echo json_encode($response); $db->close(); } else { $response = [ "success" => false, "title" => "Invalid request method" ]; echo json_encode($response); exit; } ?> ================================================ FILE: api/notifications/get_notification_settings.php ================================================ false, "title" => "Missing parameters" ]; echo json_encode($response); exit; } // Get user from API key $sql = "SELECT * FROM user WHERE api_key = :apiKey"; $stmt = $db->prepare($sql); $stmt->bindValue(':apiKey', $apiKey); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // If the user is not found, return an error if (!$user) { $response = [ "success" => false, "title" => "Invalid API key" ]; echo json_encode($response); exit; } $userId = $user['id']; $query = "SELECT * FROM notification_settings WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $notification_settings = $result->fetchArray(SQLITE3_ASSOC); if ($notification_settings) { unset($notification_settings['user_id']); } else { $notification_settings = []; } $query = "SELECT * FROM email_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $email_notifications = $result->fetchArray(SQLITE3_ASSOC); if ($email_notifications) { unset($email_notifications['user_id']); if (isset($email_notifications['smtp_password'])) { $email_notifications['smtp_password'] = "********"; } $notification_settings['email_notifications'] = $email_notifications; } $query = "SELECT * FROM discord_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $discord_notifications = $result->fetchArray(SQLITE3_ASSOC); if ($discord_notifications) { unset($discord_notifications['user_id']); $notification_settings['discord_notifications'] = $discord_notifications; } $query = "SELECT * FROM gotify_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $gotify_notifications = $result->fetchArray(SQLITE3_ASSOC); if ($gotify_notifications) { unset($gotify_notifications['user_id']); if (isset($gotify_notifications['token'])) { $gotify_notifications['token'] = "********"; } $notification_settings['gotify_notifications'] = $gotify_notifications; } $query = "SELECT * FROM ntfy_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $ntfy_notifications = $result->fetchArray(SQLITE3_ASSOC); if ($ntfy_notifications) { unset($ntfy_notifications['user_id']); if (isset($ntfy_notifications['headers'])) { $ntfy_notifications['headers'] = "********"; } $notification_settings['ntfy_notifications'] = $ntfy_notifications; } $query = "SELECT * FROM pushover_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $pushover_notifications = $result->fetchArray(SQLITE3_ASSOC); if ($pushover_notifications) { unset($pushover_notifications['user_id']); if (isset($pushover_notifications['token'])) { $pushover_notifications['token'] = "********"; } $notification_settings['pushover_notifications'] = $pushover_notifications; } $query = "SELECT * FROM telegram_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $telegram_notifications = $result->fetchArray(SQLITE3_ASSOC); if ($telegram_notifications) { unset($telegram_notifications['user_id']); if (isset($telegram_notifications['bot_token'])) { $telegram_notifications['bot_token'] = "********"; } $notification_settings['telegram_notifications'] = $telegram_notifications; } $query = "SELECT * FROM webhook_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $webhook_notifications = $result->fetchArray(SQLITE3_ASSOC); if ($webhook_notifications) { unset($webhook_notifications['user_id']); if (isset($webhook_notifications['headers'])) { $webhook_notifications['headers'] = "********"; } $notification_settings['webhook_notifications'] = $webhook_notifications; } // Serverchan notifications $query = "SELECT * FROM serverchan_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $serverchan_notifications = $result->fetchArray(SQLITE3_ASSOC); if ($serverchan_notifications) { unset($serverchan_notifications['user_id']); if (isset($serverchan_notifications['sendkey'])) { $serverchan_notifications['sendkey'] = "********"; } $notification_settings['serverchan_notifications'] = $serverchan_notifications; } $response = [ "success" => true, "title" => "notification_settings", "notification_settings" => $notification_settings, "notes" => [] ]; echo json_encode($response); $db->close(); } else { $response = [ "success" => false, "title" => "Invalid request method" ]; echo json_encode($response); exit; } ?> ================================================ FILE: api/payment_methods/get_payment_methods.php ================================================ false, "title" => "Missing parameters" ]; echo json_encode($response); exit; } // Get user from API key $sql = "SELECT * FROM user WHERE api_key = :apiKey"; $stmt = $db->prepare($sql); $stmt->bindValue(':apiKey', $apiKey); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // If the user is not found, return an error if (!$user) { $response = [ "success" => false, "title" => "Invalid API key" ]; echo json_encode($response); exit; } $userId = $user['id']; $sql = "SELECT * FROM payment_methods WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $payment_methods = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $payment_methods[] = $row; } foreach ($payment_methods as $key => $value) { unset($payment_methods[$key]['user_id']); // Check if is used in any subscriptions $sql = "SELECT * FROM subscriptions WHERE user_id = :userId AND payment_method_id = :paymentMethodId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $stmt->bindValue(':paymentMethodId', $payment_methods[$key]['id']); $result = $stmt->execute(); $subscription = $result->fetchArray(SQLITE3_ASSOC); if ($subscription) { $payment_methods[$key]['in_use'] = true; } else { $payment_methods[$key]['in_use'] = false; } } $response = [ "success" => true, "title" => "payment_methods", "payment_methods" => $payment_methods, "notes" => [] ]; echo json_encode($response); $db->close(); } else { $response = [ "success" => false, "title" => "Invalid request method" ]; echo json_encode($response); exit; } ?> ================================================ FILE: api/settings/get_settings.php ================================================ false, "title" => "Missing parameters" ]; echo json_encode($response); exit; } // Get user from API key $sql = "SELECT * FROM user WHERE api_key = :apiKey"; $stmt = $db->prepare($sql); $stmt->bindValue(':apiKey', $apiKey); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // If the user is not found, return an error if (!$user) { $response = [ "success" => false, "title" => "Invalid API key" ]; echo json_encode($response); exit; } $userId = $user['id']; $sql = "SELECT * FROM settings WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $settings = $result->fetchArray(SQLITE3_ASSOC); if ($settings) { unset($settings['user_id']); } $sql = "SELECT * FROM custom_colors WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $custom_colors = $result->fetchArray(SQLITE3_ASSOC); if ($custom_colors) { unset($custom_colors['user_id']); $settings['custom_colors'] = $custom_colors; } $sql = "SELECT * FROM custom_css_style WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $custom_css = $result->fetchArray(SQLITE3_ASSOC); if ($custom_css) { unset($custom_css['user_id']); $settings['custom_css'] = $custom_css; } $response = [ "success" => true, "title" => "settings", "settings" => $settings, "notes" => [] ]; echo json_encode($response); $db->close(); } else { $response = [ "success" => false, "title" => "Invalid request method" ]; echo json_encode($response); exit; } ?> ================================================ FILE: api/status/version.php ================================================ false, "title" => "Missing parameters" ]; echo json_encode($response); exit; } // Get user from API key $sql = "SELECT * FROM user WHERE api_key = :apiKey"; $stmt = $db->prepare($sql); $stmt->bindValue(':apiKey', $apiKey); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // If the user is not found, return an error if (!$user) { $response = [ "success" => false, "title" => "Invalid API key" ]; echo json_encode($response); exit; } $version_number = substr($version, 1); $response = [ "success" => true, "title" => "version", "version" => $version, "version_number" => $version_number, "notes" => [] ]; echo json_encode($response); } else { $response = [ "success" => false, "title" => "Invalid request method" ]; echo json_encode($response); exit; } ?> ================================================ FILE: api/subscriptions/get_ical_feed.php ================================================ false, "title" => "Missing parameters" ]; echo json_encode($response); exit; } function getPriceConverted($price, $currency, $database) { $query = "SELECT rate FROM currencies WHERE id = :currency"; $stmt = $database->prepare($query); $stmt->bindParam(':currency', $currency, SQLITE3_INTEGER); $result = $stmt->execute(); $exchangeRate = $result->fetchArray(SQLITE3_ASSOC); if ($exchangeRate === false) { return $price; } else { $fromRate = $exchangeRate['rate']; return $price / $fromRate; } } // Get user from API key $sql = "SELECT * FROM user WHERE api_key = :apiKey"; $stmt = $db->prepare($sql); $stmt->bindValue(':apiKey', $apiKey); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // If the user is not found, return an error if (!$user) { $response = [ "success" => false, "title" => "Invalid API key" ]; echo json_encode($response); exit; } $userId = $user['id']; $userCurrencyId = $user['main_currency']; // Get last exchange update date for user $sql = "SELECT * FROM last_exchange_update WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $lastExchangeUpdate = $result->fetchArray(SQLITE3_ASSOC); $canConvertCurrency = empty($lastExchangeUpdate['date']) ? false : true; // Get currencies for user $sql = "SELECT * FROM currencies WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $currencies = []; while ($currency = $result->fetchArray(SQLITE3_ASSOC)) { $currencies[$currency['id']] = $currency; } // Get categories for user $sql = "SELECT * FROM categories WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $categories = []; while ($category = $result->fetchArray(SQLITE3_ASSOC)) { $categories[$category['id']] = $category['name']; } // Get members for user $sql = "SELECT * FROM household WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $members = []; while ($member = $result->fetchArray(SQLITE3_ASSOC)) { $members[$member['id']] = $member['name']; } // Get payment methods for user $sql = "SELECT * FROM payment_methods WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $paymentMethods = []; while ($paymentMethod = $result->fetchArray(SQLITE3_ASSOC)) { $paymentMethods[$paymentMethod['id']] = $paymentMethod['name']; } $sql = "SELECT * FROM subscriptions WHERE user_id = :userId AND inactive = 0 ORDER BY next_payment ASC"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { $subscriptions = array(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $subscriptions[] = $row; } } $subscriptionsToReturn = array(); // Get notification settings $notificationQuery = "SELECT days FROM notification_settings WHERE user_id = :userId"; $notificationQueryStmt = $db->prepare($notificationQuery); $notificationQueryStmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $notificationResult = $notificationQueryStmt->execute(); $globalNotificationDays = 1; // Default value if ($row = $notificationResult->fetchArray(SQLITE3_ASSOC)) { $globalNotificationDays = $row['days']; } foreach ($subscriptions as $subscription) { $subscriptionToReturn = $subscription; if (isset($_REQUEST['convert_currency']) && $_REQUEST['convert_currency'] === 'true' && $canConvertCurrency && $subscription['currency_id'] != $userCurrencyId) { $subscriptionToReturn['price'] = getPriceConverted($subscription['price'], $subscription['currency_id'], $db); } else { $subscriptionToReturn['price'] = $subscription['price']; } $subscriptionToReturn['category_name'] = $categories[$subscription['category_id']]; $subscriptionToReturn['payer_user_name'] = $members[$subscription['payer_user_id']]; $subscriptionToReturn['payment_method_name'] = $paymentMethods[$subscription['payment_method_id']]; $subscriptionsToReturn[] = $subscriptionToReturn; } $stmt->bindValue(':inactive', false, SQLITE3_INTEGER); $result = $stmt->execute(); header('Content-Type: text/calendar; charset=utf-8'); header('Content-Disposition: attachment; filename="subscriptions.ics"'); if ($result === false) { die("BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:NAME:\nEND:VCALENDAR"); } $icsContent = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Wallos//iCalendar//EN\nNAME:Wallos\nX-WR-CALNAME:Wallos\n"; while ($subscription = $result->fetchArray(SQLITE3_ASSOC)) { $subscription['payer_user'] = $members[$subscription['payer_user_id']]; $subscription['category'] = $categories[$subscription['category_id']]; $subscription['payment_method'] = $paymentMethods[$subscription['payment_method_id']]; $subscription['currency'] = $currencies[$subscription['currency_id']]['symbol']; $subscription['trigger'] = ($subscription['notify_days_before'] == -1) ? $globalNotificationDays : ($subscription['notify_days_before'] ?: 1); $subscription['price'] = number_format($subscription['price'], 2); $uid = 'wallos-subscription-' . $subscription['id'] . '@wallos'; $summary = html_entity_decode($subscription['name'], ENT_QUOTES, 'UTF-8'); $description = "Price: {$subscription['currency']}{$subscription['price']}\\nCategory: {$subscription['category']}\\nPayment Method: {$subscription['payment_method']}\\nPayer: {$subscription['payer_user']}\\nNotes: {$subscription['notes']}"; $dtstamp = gmdate('Ymd\THis\Z'); $dtstart = (new DateTime($subscription['next_payment']))->format('Ymd'); $dtend = (new DateTime($subscription['next_payment']))->format('Ymd'); $location = isset($subscription['url']) ? $subscription['url'] : ''; $alarm_trigger = '-P' . $subscription['trigger'] . 'D'; $icsContent .= <<close(); exit; } else { $response = [ "success" => false, "title" => "Invalid request method" ]; echo json_encode($response); exit; } ?> ================================================ FILE: api/subscriptions/get_monthly_cost.php ================================================ false, "title" => "Missing parameters" ]; echo json_encode($response); exit; } $month = $_REQUEST['month']; $year = $_REQUEST['year']; $sql = "SELECT * FROM user WHERE api_key = :apiKey"; $stmt = $db->prepare($sql); $stmt->bindValue(':apiKey', $apiKey); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // If the user is not found or the API key is invalid, return an error if (!$user) { echo json_encode([ "success" => false, "title" => "Invalid API key", "notes" => ["User not found or API key invalid."] ]); exit; } $sql = "SELECT * FROM last_exchange_update"; $result = $db->query($sql); $lastExchangeUpdate = $result->fetchArray(SQLITE3_ASSOC); $userId = $user['id']; $userCurrencyId = $user['main_currency']; $needsCurrencyConversion = false; $canConvertCurrency = empty($lastExchangeUpdate['date']) ? false : true; $sql = "SELECT * FROM currencies WHERE id = :currencyId"; $stmt = $db->prepare($sql); $stmt->bindValue(':currencyId', $userCurrencyId); $result = $stmt->execute(); $currency = $result->fetchArray(SQLITE3_ASSOC); $currency_code = $currency['code']; $currency_symbol = $currency['symbol']; $title = date('F Y', strtotime($year . '-' . $month . '-01')); $monthlyCost = 0; $notes = []; $sql = "SELECT * FROM subscriptions WHERE user_id = :userId AND inactive = 0"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $subscriptions = []; while ($subscription = $result->fetchArray(SQLITE3_ASSOC)) { $subscriptions[] = $subscription; if ($subscription['currency_id'] !== $userCurrencyId) { $needsCurrencyConversion = true; } } if ($needsCurrencyConversion) { if (!$canConvertCurrency) { $notes[] = "You are using multiple currencies, but the exchange rates have not been updated yet. Please check your Fixer API Key."; } else { $sql = "SELECT * FROM currencies WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $currencies = []; while ($currency = $result->fetchArray(SQLITE3_ASSOC)) { $currencies[$currency['id']] = $currency['rate']; } } } // Calculate the monthly cost based on the next_payment_date, payment cycle, and payment frequency foreach ($subscriptions as $subscription) { $nextPaymentDate = strtotime($subscription['next_payment']); $cycle = $subscription['cycle']; // Integer from 1 to 4 $frequency = $subscription['frequency']; // Determine the strtotime increment string based on cycle switch ($cycle) { case 1: // Days $incrementString = "+{$frequency} days"; break; case 2: // Weeks $incrementString = "+{$frequency} weeks"; break; case 3: // Months $incrementString = "+{$frequency} months"; break; case 4: // Years $incrementString = "+{$frequency} years"; break; default: $incrementString = "+{$frequency} months"; // Default case, if needed } // Calculate the start of the month $startOfMonth = strtotime($year . '-' . str_pad($month, 2, '0', STR_PAD_LEFT) . '-01'); // Find the first payment date of the month by moving backwards $startDate = $nextPaymentDate; while ($startDate > $startOfMonth) { $startDate = strtotime("-" . $incrementString, $startDate); } // Calculate the monthly cost for ($date = $startDate; $date <= strtotime("+1 month", $startOfMonth); $date = strtotime($incrementString, $date)) { if (date('Y-m', $date) == $year . '-' . str_pad($month, 2, '0', STR_PAD_LEFT)) { $price = $subscription['price']; if ($userCurrencyId !== $subscription['currency_id']) { $price *= $currencies[$userCurrencyId] / $currencies[$subscription['currency_id']]; } $monthlyCost += $price; } } } $formatter = new NumberFormatter('en_US', NumberFormatter::CURRENCY); $localizedMonthlyCost = $formatter->formatCurrency($monthlyCost, $currency_code); echo json_encode([ 'success' => true, 'title' => $title, 'monthly_cost' => number_format($monthlyCost, 2), 'localized_monthly_cost' => $localizedMonthlyCost, 'currency_code' => $currency_code, 'currency_symbol' => $currency_symbol, 'notes' => $notes ], JSON_UNESCAPED_UNICODE); } ?> ================================================ FILE: api/subscriptions/get_subscriptions.php ================================================ false, "title" => "Missing parameters" ]; echo json_encode($response); exit; } function getPriceConverted($price, $currency, $database) { $query = "SELECT rate FROM currencies WHERE id = :currency"; $stmt = $database->prepare($query); $stmt->bindParam(':currency', $currency, SQLITE3_INTEGER); $result = $stmt->execute(); $exchangeRate = $result->fetchArray(SQLITE3_ASSOC); if ($exchangeRate === false) { return $price; } else { $fromRate = $exchangeRate['rate']; return $price / $fromRate; } } // Get user from API key $sql = "SELECT * FROM user WHERE api_key = :apiKey"; $stmt = $db->prepare($sql); $stmt->bindValue(':apiKey', $apiKey); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // If the user is not found, return an error if (!$user) { $response = [ "success" => false, "title" => "Invalid API key" ]; echo json_encode($response); exit; } $userId = $user['id']; $userCurrencyId = $user['main_currency']; $allUserSubscription = isset($_REQUEST['all-user-subscription']) ? $_REQUEST['all-user-subscription'] : null; if ($allUserSubscription == 1 && $userId != 1) { $response = [ "success" => false, "title" => "Denied. Not admin user" ]; echo json_encode($response); exit; } // Get last exchange update date for user $sql = "SELECT * FROM last_exchange_update WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $lastExchangeUpdate = $result->fetchArray(SQLITE3_ASSOC); $canConvertCurrency = empty($lastExchangeUpdate['date']) ? false : true; // Get currencies for user $sql = "SELECT * FROM currencies WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $currencies = []; while ($currency = $result->fetchArray(SQLITE3_ASSOC)) { $currencies[$currency['id']] = $currency; } // Get categories for user $sql = "SELECT * FROM categories WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $categories = []; while ($category = $result->fetchArray(SQLITE3_ASSOC)) { $categories[$category['id']] = $category['name']; } // Get members for user $sql = "SELECT * FROM household WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $members = []; while ($member = $result->fetchArray(SQLITE3_ASSOC)) { $members[$member['id']] = $member['name']; } // Get payment methods for user $sql = "SELECT * FROM payment_methods WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId); $result = $stmt->execute(); $paymentMethods = []; while ($paymentMethod = $result->fetchArray(SQLITE3_ASSOC)) { $paymentMethods[$paymentMethod['id']] = $paymentMethod['name']; } $sort = "next_payment"; if (isset($_REQUEST['sort'])) { $sort = $_REQUEST['sort']; } $sortOrder = $sort; $allowedSortCriteria = ['name', 'id', 'next_payment', 'price', 'payer_user_id', 'category_id', 'payment_method_id', 'inactive', 'alphanumeric']; $order = ($sort == "price" || $sort == "id") ? "DESC" : "ASC"; if ($sort == "alphanumeric") { $sort = "name"; } if (!in_array($sort, $allowedSortCriteria)) { $sort = "next_payment"; } // Construction of the main SQL Query $params = []; if ($allUserSubscription == 1 && $userId == 1) { $sql = "SELECT * FROM subscriptions"; } else { $sql = "SELECT * FROM subscriptions WHERE user_id = :userId"; $params[':userId'] = $userId; } if (isset($_REQUEST['member'])) { $memberIds = explode(',', $_REQUEST['member']); $placeholders = array_map(function ($key) { return ":member{$key}"; }, array_keys($memberIds)); $sql .= " AND payer_user_id IN (" . implode(',', $placeholders) . ")"; foreach ($memberIds as $key => $memberId) { $params[":member{$key}"] = $memberId; } } if (isset($_REQUEST['category'])) { $categoryIds = explode(',', $_REQUEST['category']); $placeholders = array_map(function ($key) { return ":category{$key}"; }, array_keys($categoryIds)); $sql .= " AND category_id IN (" . implode(',', $placeholders) . ")"; foreach ($categoryIds as $key => $categoryId) { $params[":category{$key}"] = $categoryId; } } if (isset($_REQUEST['payment'])) { $paymentIds = explode(',', $_REQUEST['payment']); $placeholders = array_map(function ($key) { return ":payment{$key}"; }, array_keys($paymentIds)); $sql .= " AND payment_method_id IN (" . implode(',', $placeholders) . ")"; foreach ($paymentIds as $key => $paymentId) { $params[":payment{$key}"] = $paymentId; } } if (isset($_REQUEST['state']) && $_REQUEST['state'] != "") { $sql .= " AND inactive = :inactive"; $params[':inactive'] = $_REQUEST['state']; } $orderByClauses = []; if (isset($_REQUEST['disabled_to_bottom']) && $_REQUEST['disabled_to_bottom'] === 'true') { if (in_array($sort, ["payer_user_id", "category_id", "payment_method_id"])) { $orderByClauses[] = "$sort $order"; $orderByClauses[] = "inactive ASC"; } else { $orderByClauses[] = "inactive ASC"; $orderByClauses[] = "$sort $order"; } } else { $orderByClauses[] = "$sort $order"; if ($sort != "inactive") { $orderByClauses[] = "inactive ASC"; } } if ($sort != "next_payment") { $orderByClauses[] = "next_payment ASC"; } $sql .= " ORDER BY " . implode(", ", $orderByClauses); $stmt = $db->prepare($sql); if (!empty($params)) { foreach ($params as $key => $value) { $stmt->bindValue($key, $value, SQLITE3_INTEGER); } } $result = $stmt->execute(); if ($result) { $subscriptions = array(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $subscriptions[] = $row; } } $subscriptionsToReturn = array(); foreach ($subscriptions as $subscription) { $subscriptionToReturn = $subscription; if (isset($_REQUEST['convert_currency']) && $_REQUEST['convert_currency'] === 'true' && $canConvertCurrency && $subscription['currency_id'] != $userCurrencyId) { $subscriptionToReturn['price'] = getPriceConverted($subscription['price'], $subscription['currency_id'], $db); } else { $subscriptionToReturn['price'] = $subscription['price']; } $subscriptionToReturn['category_name'] = isset($categories[$subscription['category_id']]) ? $categories[$subscription['category_id']] : 'No category'; $subscriptionToReturn['payer_user_name'] = isset($members[$subscription['payer_user_id']]) ? $members[$subscription['payer_user_id']] : 'Unknown member'; $subscriptionToReturn['payment_method_name'] = isset($paymentMethods[$subscription['payment_method_id']]) ? $paymentMethods[$subscription['payment_method_id']] : 'Unknown payment method'; $subscriptionsToReturn[] = $subscriptionToReturn; } $response = [ "success" => true, "title" => "subscriptions", "subscriptions" => $subscriptionsToReturn, "notes" => [] ]; if ($allUserSubscription == 1 && $userId == 1) { $sql = "PRAGMA table_info(user)"; $stmt = $db->prepare($sql); $result = $stmt->execute(); $userColumns = array(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $userColumns[] = $row['name']; } $userNameCol = in_array('username', $userColumns) ? 'username' : null; $userEmailCol = in_array('email', $userColumns) ? 'email' : null; if ($userNameCol && $userEmailCol) { $sql = "SELECT id, $userNameCol as name, $userEmailCol as email FROM user"; } elseif ($userNameCol) { $sql = "SELECT id, $userNameCol as name FROM user"; } elseif ($userEmailCol) { $sql = "SELECT id, $userEmailCol as email FROM user"; } else { $sql = "SELECT id FROM user"; } $stmt = $db->prepare($sql); $result = $stmt->execute(); $users = array(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $users[] = $row; } $response['users'] = $users; } echo json_encode($response); $db->close(); exit; } else { $response = [ "success" => false, "title" => "Invalid request method" ]; echo json_encode($response); exit; } ================================================ FILE: api/users/get_user.php ================================================ false, "title" => "Missing parameters" ]; echo json_encode($response); exit; } // Get user from API key $sql = "SELECT * FROM user WHERE api_key = :apiKey"; $stmt = $db->prepare($sql); $stmt->bindValue(':apiKey', $apiKey); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // If the user is not found, return an error if (!$user) { $response = [ "success" => false, "title" => "Invalid API key" ]; echo json_encode($response); exit; } // remove password and api_key from array $user['password'] = "********"; $user['api_key'] = "********"; $response = [ "success" => true, "title" => "user", "user" => $user, "notes" => [] ]; echo json_encode($response); $db->close(); } else { $response = [ "success" => false, "title" => "Invalid request method" ]; echo json_encode($response); exit; } ?> ================================================ FILE: calendar.php ================================================ prepare($query); $stmt->bindParam(':currency', $currency, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $exchangeRate = $result->fetchArray(SQLITE3_ASSOC); if ($exchangeRate === false) { return $price; } else { $fromRate = $exchangeRate['rate']; return $price / $fromRate; } } // Get budget from user table $query = "SELECT budget FROM user WHERE id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $budget = $row['budget'] ?? 0; $currentMonth = date('m'); $currentYear = date('Y'); $sameAsCurrent = false; if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['month']) && isset($_GET['year'])) { // Don't allow viewing past months $selectedMonth = str_pad($_GET['month'], 2, '0', STR_PAD_LEFT); $selectedYear = $_GET['year']; $selectedTimestamp = strtotime($selectedYear . '-' . $selectedMonth . '-01'); $currentTimestamp = strtotime($currentYear . '-' . $currentMonth . '-01'); if ($selectedTimestamp < $currentTimestamp) { $calendarMonth = $currentMonth; $calendarYear = $currentYear; } else { $calendarMonth = $selectedMonth; $calendarYear = $selectedYear; } if ($calendarMonth == $currentMonth && $calendarYear == $currentYear) { $sameAsCurrent = true; } } else { $calendarMonth = $currentMonth; $calendarYear = $currentYear; $sameAsCurrent = true; } $currenciesInUse = []; $numberOfSubscriptionsToPayThisMonth = 0; $totalCostThisMonth = 0; $amountDueThisMonth = 0; $query = "SELECT * FROM subscriptions WHERE user_id = :user_id AND inactive = 0"; $stmt = $db->prepare($query); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $subscriptions = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $subscriptions[] = $row; $currenciesInUse[] = $row['currency_id']; } $currenciesInUse = array_unique($currenciesInUse); $usesMultipleCurrencies = count($currenciesInUse) > 1; $showCantConverErrorMessage = false; if ($usesMultipleCurrencies) { $query = "SELECT api_key FROM fixer WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result->fetchArray(SQLITE3_ASSOC) === false) { $showCantConverErrorMessage = true; } } // Get code of main currency to display on statistics $query = "SELECT c.code FROM currencies c INNER JOIN user u ON c.id = u.main_currency WHERE u.id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $code = $row['code']; $yearsToLoad = $calendarYear - $currentYear + 1; ?>

 
$startOfMonth) { $startDate = strtotime("-" . $incrementString, $startDate); } for ($date = $startDate; $date <= $endDate; $date = strtotime($incrementString, $date)) { if (date('Y-m', $date) == $calendarYear . '-' . str_pad($calendarMonth, 2, '0', STR_PAD_LEFT)) { if (date('d', $date) == $day) { $totalCostThisMonth += getPriceConverted($subscription['price'], $subscription['currency_id'], $db, $userId); $numberOfSubscriptionsToPayThisMonth++; if ($date > $today) { $amountDueThisMonth += getPriceConverted($subscription['price'], $subscription['currency_id'], $db, $userId); } ?>
$startOfMonth) { $startDate = strtotime("-" . $incrementString, $startDate); } for ($date = $startDate; $date <= $endDate; $date = strtotime($incrementString, $date)) { if (date('Y-m', $date) == $calendarYear . '-' . str_pad($calendarMonth, 2, '0', STR_PAD_LEFT)) { if (date('d', $date) == $day) { $totalCostThisMonth += getPriceConverted($subscription['price'], $subscription['currency_id'], $db, $userId); $numberOfSubscriptionsToPayThisMonth++; if ($date > $today) { $amountDueThisMonth += getPriceConverted($subscription['price'], $subscription['currency_id'], $db, $userId); } ?>
 
0 && $totalCostThisMonth > $budget) { $overBudgetAmount = $totalCostThisMonth - $budget; $overBudgetAmount = CurrencyFormatter::format($overBudgetAmount, $code); ?>
()

================================================ FILE: cronjobs ================================================ # Run the scripts every day 0 1 * * * /usr/local/bin/php /var/www/html/endpoints/cronjobs/updatenextpayment.php >> /var/log/cron/updatenextpayment.log 2>&1 0 2 * * * /usr/local/bin/php /var/www/html/endpoints/cronjobs/updateexchange.php >> /var/log/cron/updateexchange.log 2>&1 0 8 * * * /usr/local/bin/php /var/www/html/endpoints/cronjobs/sendcancellationnotifications.php >> /var/log/cron/sendcancellationnotifications.log 2>&1 0 9 * * * /usr/local/bin/php /var/www/html/endpoints/cronjobs/sendnotifications.php >> /var/log/cron/sendnotifications.log 2>&1 */2 * * * * /usr/local/bin/php /var/www/html/endpoints/cronjobs/sendverificationemails.php >> /var/log/cron/sendverificationemails.log 2>&1 */2 * * * * /usr/local/bin/php /var/www/html/endpoints/cronjobs/sendresetpasswordemails.php >> /var/log/cron/sendresetpasswordemails.log 2>&1 0 */6 * * * /usr/local/bin/php /var/www/html/endpoints/cronjobs/checkforupdates.php >> /var/log/cron/checkforupdates.log 2>&1 30 1 * * 1 /usr/local/bin/php /var/www/html/endpoints/cronjobs/storetotalyearlycost.php >> /var/log/cron/storetotalyearlycost.log 2>&1 0 3 * * * /usr/local/bin/php /var/www/html/endpoints/cronjobs/cleanupresettokens.php >> /var/log/cron/cleanupresettokens.log 2>&1 ================================================ FILE: docker-compose.yaml ================================================ services: wallos: container_name: wallos image: bellamy/wallos:latest ports: - "8282:80/tcp" environment: TZ: 'America/Toronto' # Volumes store your data between container upgrades volumes: - './db:/var/www/html/db' - './logos:/var/www/html/images/uploads/logos' restart: unless-stopped ================================================ FILE: endpoints/admin/adduser.php ================================================ 1, 'name' => 'Euro', 'symbol' => '€', 'code' => 'EUR'], ['id' => 2, 'name' => 'US Dollar', 'symbol' => '$', 'code' => 'USD'], ['id' => 3, 'name' => 'Japanese Yen', 'symbol' => '¥', 'code' => 'JPY'], ['id' => 4, 'name' => 'Bulgarian Lev', 'symbol' => 'лв', 'code' => 'BGN'], ['id' => 5, 'name' => 'Czech Republic Koruna', 'symbol' => 'Kč', 'code' => 'CZK'], ['id' => 6, 'name' => 'Danish Krone', 'symbol' => 'kr', 'code' => 'DKK'], ['id' => 7, 'name' => 'British Pound Sterling', 'symbol' => '£', 'code' => 'GBP'], ['id' => 8, 'name' => 'Hungarian Forint', 'symbol' => 'Ft', 'code' => 'HUF'], ['id' => 9, 'name' => 'Polish Zloty', 'symbol' => 'zł', 'code' => 'PLN'], ['id' => 10, 'name' => 'Romanian Leu', 'symbol' => 'lei', 'code' => 'RON'], ['id' => 11, 'name' => 'Swedish Krona', 'symbol' => 'kr', 'code' => 'SEK'], ['id' => 12, 'name' => 'Swiss Franc', 'symbol' => 'Fr', 'code' => 'CHF'], ['id' => 13, 'name' => 'Icelandic Króna', 'symbol' => 'kr', 'code' => 'ISK'], ['id' => 14, 'name' => 'Norwegian Krone', 'symbol' => 'kr', 'code' => 'NOK'], ['id' => 15, 'name' => 'Russian Ruble', 'symbol' => '₽', 'code' => 'RUB'], ['id' => 16, 'name' => 'Turkish Lira', 'symbol' => '₺', 'code' => 'TRY'], ['id' => 17, 'name' => 'Australian Dollar', 'symbol' => '$', 'code' => 'AUD'], ['id' => 18, 'name' => 'Brazilian Real', 'symbol' => 'R$', 'code' => 'BRL'], ['id' => 19, 'name' => 'Canadian Dollar', 'symbol' => '$', 'code' => 'CAD'], ['id' => 20, 'name' => 'Chinese Yuan', 'symbol' => '¥', 'code' => 'CNY'], ['id' => 21, 'name' => 'Hong Kong Dollar', 'symbol' => 'HK$', 'code' => 'HKD'], ['id' => 22, 'name' => 'Indonesian Rupiah', 'symbol' => 'Rp', 'code' => 'IDR'], ['id' => 23, 'name' => 'Israeli New Sheqel', 'symbol' => '₪', 'code' => 'ILS'], ['id' => 24, 'name' => 'Indian Rupee', 'symbol' => '₹', 'code' => 'INR'], ['id' => 25, 'name' => 'South Korean Won', 'symbol' => '₩', 'code' => 'KRW'], ['id' => 26, 'name' => 'Mexican Peso', 'symbol' => 'Mex$', 'code' => 'MXN'], ['id' => 27, 'name' => 'Malaysian Ringgit', 'symbol' => 'RM', 'code' => 'MYR'], ['id' => 28, 'name' => 'New Zealand Dollar', 'symbol' => 'NZ$', 'code' => 'NZD'], ['id' => 29, 'name' => 'Philippine Peso', 'symbol' => '₱', 'code' => 'PHP'], ['id' => 30, 'name' => 'Singapore Dollar', 'symbol' => 'S$', 'code' => 'SGD'], ['id' => 31, 'name' => 'Thai Baht', 'symbol' => '฿', 'code' => 'THB'], ['id' => 32, 'name' => 'South African Rand', 'symbol' => 'R', 'code' => 'ZAR'], ['id' => 33, 'name' => 'Ukrainian Hryvnia', 'symbol' => '₴', 'code' => 'UAH'], ['id' => 34, 'name' => 'New Taiwan Dollar', 'symbol' => 'NT$', 'code' => 'TWD'], ]; $categories = [ ['id' => 1, 'name' => 'No category'], ['id' => 2, 'name' => 'Entertainment'], ['id' => 3, 'name' => 'Music'], ['id' => 4, 'name' => 'Utilities'], ['id' => 5, 'name' => 'Food & Beverages'], ['id' => 6, 'name' => 'Health & Wellbeing'], ['id' => 7, 'name' => 'Productivity'], ['id' => 8, 'name' => 'Banking'], ['id' => 9, 'name' => 'Transport'], ['id' => 10, 'name' => 'Education'], ['id' => 11, 'name' => 'Insurance'], ['id' => 12, 'name' => 'Gaming'], ['id' => 13, 'name' => 'News & Magazines'], ['id' => 14, 'name' => 'Software'], ['id' => 15, 'name' => 'Technology'], ['id' => 16, 'name' => 'Cloud Services'], ['id' => 17, 'name' => 'Charity & Donations'], ]; $payment_methods = [ ['id' => 1, 'name' => 'PayPal', 'icon' => 'images/uploads/icons/paypal.png'], ['id' => 2, 'name' => 'Credit Card', 'icon' => 'images/uploads/icons/creditcard.png'], ['id' => 3, 'name' => 'Bank Transfer', 'icon' => 'images/uploads/icons/banktransfer.png'], ['id' => 4, 'name' => 'Direct Debit', 'icon' => 'images/uploads/icons/directdebit.png'], ['id' => 5, 'name' => 'Money', 'icon' => 'images/uploads/icons/money.png'], ['id' => 6, 'name' => 'Google Pay', 'icon' => 'images/uploads/icons/googlepay.png'], ['id' => 7, 'name' => 'Samsung Pay', 'icon' => 'images/uploads/icons/samsungpay.png'], ['id' => 8, 'name' => 'Apple Pay', 'icon' => 'images/uploads/icons/applepay.png'], ['id' => 9, 'name' => 'Crypto', 'icon' => 'images/uploads/icons/crypto.png'], ['id' => 10, 'name' => 'Klarna', 'icon' => 'images/uploads/icons/klarna.png'], ['id' => 11, 'name' => 'Amazon Pay', 'icon' => 'images/uploads/icons/amazonpay.png'], ['id' => 12, 'name' => 'SEPA', 'icon' => 'images/uploads/icons/sepa.png'], ['id' => 13, 'name' => 'Skrill', 'icon' => 'images/uploads/icons/skrill.png'], ['id' => 14, 'name' => 'Sofort', 'icon' => 'images/uploads/icons/sofort.png'], ['id' => 15, 'name' => 'Stripe', 'icon' => 'images/uploads/icons/stripe.png'], ['id' => 16, 'name' => 'Affirm', 'icon' => 'images/uploads/icons/affirm.png'], ['id' => 17, 'name' => 'AliPay', 'icon' => 'images/uploads/icons/alipay.png'], ['id' => 18, 'name' => 'Elo', 'icon' => 'images/uploads/icons/elo.png'], ['id' => 19, 'name' => 'Facebook Pay', 'icon' => 'images/uploads/icons/facebookpay.png'], ['id' => 20, 'name' => 'GiroPay', 'icon' => 'images/uploads/icons/giropay.png'], ['id' => 21, 'name' => 'iDeal', 'icon' => 'images/uploads/icons/ideal.png'], ['id' => 22, 'name' => 'Union Pay', 'icon' => 'images/uploads/icons/unionpay.png'], ['id' => 23, 'name' => 'Interac', 'icon' => 'images/uploads/icons/interac.png'], ['id' => 24, 'name' => 'WeChat', 'icon' => 'images/uploads/icons/wechat.png'], ['id' => 25, 'name' => 'Paysafe', 'icon' => 'images/uploads/icons/paysafe.png'], ['id' => 26, 'name' => 'Poli', 'icon' => 'images/uploads/icons/poli.png'], ['id' => 27, 'name' => 'Qiwi', 'icon' => 'images/uploads/icons/qiwi.png'], ['id' => 28, 'name' => 'ShopPay', 'icon' => 'images/uploads/icons/shoppay.png'], ['id' => 29, 'name' => 'Venmo', 'icon' => 'images/uploads/icons/venmo.png'], ['id' => 30, 'name' => 'VeriFone', 'icon' => 'images/uploads/icons/verifone.png'], ['id' => 31, 'name' => 'WebMoney', 'icon' => 'images/uploads/icons/webmoney.png'], ]; function validate($value) { $value = trim($value); $value = stripslashes($value); $value = htmlspecialchars($value); $value = htmlentities($value); return $value; } $postData = file_get_contents("php://input"); $data = json_decode($postData, true); $loggedInUserId = $userId; $email = validate($data['email']); $username = validate($data['username']); $password = $data['password']; if (empty($username) || empty($password) || empty($email)) { die(json_encode([ "success" => false, "message" => translate('error', $i18n) ])); } $stmt = $db->prepare('SELECT COUNT(*) FROM user WHERE username = :username OR email = :email'); $stmt->bindValue(':username', $username, SQLITE3_INTEGER); $stmt->bindValue(':email', $email, SQLITE3_TEXT); $result = $stmt->execute(); $row = $result->fetchArray(); // Error if user exist if ($row[0] > 0) { die(json_encode([ "success" => false, "message" => translate('error', $i18n) ])); } // Get main currency and language from admin user $stmt = $db->prepare('SELECT main_currency, language FROM user WHERE id = :id'); $stmt->bindValue(':id', $loggedInUserId, SQLITE3_TEXT); $result = $stmt->execute(); $row = $result->fetchArray(); $currency = $row['main_currency'] ?? 1; $language = $row['language'] ?? 'en'; $avatar = "images/avatars/0.svg"; // Get code for main currency $stmt = $db->prepare('SELECT code FROM currencies WHERE id = :id'); $stmt->bindValue(':id', $currency, SQLITE3_TEXT); $row = $stmt->execute(); $main_currency = $row->fetchArray()['code']; $query = "INSERT INTO user (username, email, password, main_currency, avatar, language, budget) VALUES (:username, :email, :password, :main_currency, :avatar, :language, :budget)"; $stmt = $db->prepare($query); $hashedPassword = password_hash($password, PASSWORD_DEFAULT); $stmt->bindValue(':username', $username, SQLITE3_TEXT); $stmt->bindValue(':email', $email, SQLITE3_TEXT); $stmt->bindValue(':password', $hashedPassword, SQLITE3_TEXT); $stmt->bindValue(':main_currency', 1, SQLITE3_TEXT); $stmt->bindValue(':avatar', $avatar, SQLITE3_TEXT); $stmt->bindValue(':language', $language, SQLITE3_TEXT); $stmt->bindValue(':budget', 0, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { // Get id of the newly created user $newUserId = $db->lastInsertRowID(); // Add username as household member for that user $query = "INSERT INTO household (name, user_id) VALUES (:name, :user_id)"; $stmt = $db->prepare($query); $stmt->bindValue(':name', $username, SQLITE3_TEXT); $stmt->bindValue(':user_id', $newUserId, SQLITE3_INTEGER); $stmt->execute(); if ($newUserId > 1) { // Add categories for that user $query = 'INSERT INTO categories (name, "order", user_id) VALUES (:name, :order, :user_id)'; $stmt = $db->prepare($query); foreach ($categories as $index => $category) { $stmt->bindValue(':name', $category['name'], SQLITE3_TEXT); $stmt->bindValue(':order', $index + 1, SQLITE3_INTEGER); $stmt->bindValue(':user_id', $newUserId, SQLITE3_INTEGER); $stmt->execute(); } // Add payment methods for that user $query = 'INSERT INTO payment_methods (name, icon, "order", user_id) VALUES (:name, :icon, :order, :user_id)'; $stmt = $db->prepare($query); foreach ($payment_methods as $index => $payment_method) { $stmt->bindValue(':name', $payment_method['name'], SQLITE3_TEXT); $stmt->bindValue(':icon', $payment_method['icon'], SQLITE3_TEXT); $stmt->bindValue(':order', $index + 1, SQLITE3_INTEGER); $stmt->bindValue(':user_id', $newUserId, SQLITE3_INTEGER); $stmt->execute(); } // Add currencies for that user $query = "INSERT INTO currencies (name, symbol, code, rate, user_id) VALUES (:name, :symbol, :code, :rate, :user_id)"; $stmt = $db->prepare($query); foreach ($currencies as $currency) { $stmt->bindValue(':name', $currency['name'], SQLITE3_TEXT); $stmt->bindValue(':symbol', $currency['symbol'], SQLITE3_TEXT); $stmt->bindValue(':code', $currency['code'], SQLITE3_TEXT); $stmt->bindValue(':rate', 1, SQLITE3_FLOAT); $stmt->bindValue(':user_id', $newUserId, SQLITE3_INTEGER); $stmt->execute(); } // Retrieve main currency id $query = "SELECT id FROM currencies WHERE code = :code AND user_id = :user_id"; $stmt = $db->prepare($query); $stmt->bindValue(':code', $main_currency, SQLITE3_TEXT); $stmt->bindValue(':user_id', $newUserId, SQLITE3_INTEGER); $result = $stmt->execute(); $currency = $result->fetchArray(SQLITE3_ASSOC); // Update user main currency $query = "UPDATE user SET main_currency = :main_currency WHERE id = :user_id"; $stmt = $db->prepare($query); $stmt->bindValue(':main_currency', $currency['id'], SQLITE3_INTEGER); $stmt->bindValue(':user_id', $newUserId, SQLITE3_INTEGER); $stmt->execute(); // Add settings for that user $query = "INSERT INTO settings (dark_theme, monthly_price, convert_currency, remove_background, color_theme, hide_disabled, user_id, disabled_to_bottom, show_original_price, mobile_nav) VALUES (2, 0, 0, 0, 'blue', 0, :user_id, 0, 0, 0)"; $stmt = $db->prepare($query); $stmt->bindValue(':user_id', $newUserId, SQLITE3_INTEGER); $stmt->execute(); // If email verification is required add the user to the email_verification table $query = "SELECT * FROM admin"; $stmt = $db->prepare($query); $result = $stmt->execute(); $settings = $result->fetchArray(SQLITE3_ASSOC); } $db->close(); die(json_encode([ "success" => true, "message" => translate('success', $i18n) ])); } ================================================ FILE: endpoints/admin/deleteunusedlogos.php ================================================ prepare($query); $result = $stmt->execute(); $logosOnDB = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $logosOnDB[] = $row['logo']; } $logosOnDB = array_unique($logosOnDB); $uploadDir = '../../images/uploads/logos/'; $uploadFiles = scandir($uploadDir); foreach ($uploadFiles as $file) { if ($file != '.' && $file != '..' && $file != 'avatars') { $logosOnDisk[] = ['logo' => $file]; } } // Get all logos in the payment_methods table $query = 'SELECT icon FROM payment_methods'; $stmt = $db->prepare($query); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { if (!strstr($row['icon'], "images/uploads/icons/")) { $logosOnDB[] = $row['icon']; } } $logosOnDB = array_unique($logosOnDB); // Find and delete unused logos $count = 0; foreach ($logosOnDisk as $disk) { foreach ($logosOnDB as $db) { $found = false; if ($disk['logo'] == $db) { $found = true; break; } } if (!$found) { unlink($uploadDir . $disk['logo']); $count++; } } echo json_encode([ "success" => true, "message" => translate('success', $i18n), 'count' => $count ]); ?> ================================================ FILE: endpoints/admin/deleteuser.php ================================================ false, "message" => translate('error', $i18n) ])); } else { // Delete user $stmt = $db->prepare('DELETE FROM user WHERE id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete subscriptions $stmt = $db->prepare('DELETE FROM subscriptions WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete settings $stmt = $db->prepare('DELETE FROM settings WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete fixer $stmt = $db->prepare('DELETE FROM fixer WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete custom colors $stmt = $db->prepare('DELETE FROM custom_colors WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete currencies $stmt = $db->prepare('DELETE FROM currencies WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete categories $stmt = $db->prepare('DELETE FROM categories WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete household $stmt = $db->prepare('DELETE FROM household WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete payment methods $stmt = $db->prepare('DELETE FROM payment_methods WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete email notifications $stmt = $db->prepare('DELETE FROM email_notifications WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete telegram notifications $stmt = $db->prepare('DELETE FROM telegram_notifications WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete webhook notifications $stmt = $db->prepare('DELETE FROM webhook_notifications WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete gotify notifications $stmt = $db->prepare('DELETE FROM gotify_notifications WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete pushover notifications $stmt = $db->prepare('DELETE FROM pushover_notifications WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Dele notification settings $stmt = $db->prepare('DELETE FROM notification_settings WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete last exchange update $stmt = $db->prepare('DELETE FROM last_exchange_update WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete email verification $stmt = $db->prepare('DELETE FROM email_verification WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete totp $stmt = $db->prepare('DELETE FROM totp WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete total yearly cost $stmt = $db->prepare('DELETE FROM total_yearly_cost WHERE user_id = :id'); $stmt->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); die(json_encode([ "success" => true, "message" => translate('success', $i18n) ])); } ================================================ FILE: endpoints/admin/enableoidc.php ================================================ prepare('UPDATE admin SET oidc_oauth_enabled = :oidcEnabled WHERE id = 1'); $stmt->bindParam(':oidcEnabled', $oidcEnabled, SQLITE3_INTEGER); $stmt->execute(); if ($db->changes() > 0) { die(json_encode([ "success" => true, "message" => translate('success', $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate('error', $i18n) ])); } ================================================ FILE: endpoints/admin/saveoidcsettings.php ================================================ prepare('SELECT COUNT(*) as count FROM oauth_settings WHERE id = 1'); $result = $checkStmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); if ($row['count'] > 0) { // Update existing row $stmt = $db->prepare('UPDATE oauth_settings SET name = :oidcName, client_id = :oidcClientId, client_secret = :oidcClientSecret, authorization_url = :oidcAuthUrl, token_url = :oidcTokenUrl, user_info_url = :oidcUserInfoUrl, redirect_url = :oidcRedirectUrl, logout_url = :oidcLogoutUrl, user_identifier_field = :oidcUserIdentifierField, scopes = :oidcScopes, auth_style = :oidcAuthStyle, auto_create_user = :oidcAutoCreateUser, password_login_disabled = :oidcPasswordLoginDisabled WHERE id = 1'); } else { // Insert new row $stmt = $db->prepare('INSERT INTO oauth_settings ( id, name, client_id, client_secret, authorization_url, token_url, user_info_url, redirect_url, logout_url, user_identifier_field, scopes, auth_style, auto_create_user, password_login_disabled ) VALUES ( 1, :oidcName, :oidcClientId, :oidcClientSecret, :oidcAuthUrl, :oidcTokenUrl, :oidcUserInfoUrl, :oidcRedirectUrl, :oidcLogoutUrl, :oidcUserIdentifierField, :oidcScopes, :oidcAuthStyle, :oidcAutoCreateUser, :oidcPasswordLoginDisabled )'); } $stmt->bindParam(':oidcName', $oidcName, SQLITE3_TEXT); $stmt->bindParam(':oidcClientId', $oidcClientId, SQLITE3_TEXT); $stmt->bindParam(':oidcClientSecret', $oidcClientSecret, SQLITE3_TEXT); $stmt->bindParam(':oidcAuthUrl', $oidcAuthUrl, SQLITE3_TEXT); $stmt->bindParam(':oidcTokenUrl', $oidcTokenUrl, SQLITE3_TEXT); $stmt->bindParam(':oidcUserInfoUrl', $oidcUserInfoUrl, SQLITE3_TEXT); $stmt->bindParam(':oidcRedirectUrl', $oidcRedirectUrl, SQLITE3_TEXT); $stmt->bindParam(':oidcLogoutUrl', $oidcLogoutUrl, SQLITE3_TEXT); $stmt->bindParam(':oidcUserIdentifierField', $oidcUserIdentifierField, SQLITE3_TEXT); $stmt->bindParam(':oidcScopes', $oidcScopes, SQLITE3_TEXT); $stmt->bindParam(':oidcAuthStyle', $oidcAuthStyle, SQLITE3_TEXT); $stmt->bindParam(':oidcAutoCreateUser', $oidcAutoCreateUser, SQLITE3_INTEGER); $stmt->bindParam(':oidcPasswordLoginDisabled', $oidcPasswordLoginDisabled, SQLITE3_INTEGER); $stmt->execute(); if ($db->changes() > 0) { $db->close(); die(json_encode([ "success" => true, "message" => translate('success', $i18n) ])); } else { $db->close(); die(json_encode([ "success" => false, "message" => translate('error', $i18n) ])); } ================================================ FILE: endpoints/admin/saveopenregistrations.php ================================================ false, "message" => translate('error', $i18n) ]); die(); } $sql = "SELECT COUNT(*) as userCount FROM user"; $stmt = $db->prepare($sql); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $userCount = $row['userCount']; if ($userCount > 1) { echo json_encode([ "success" => false, "message" => translate('error', $i18n) ]); die(); } } if ($requireEmailVerification == 1 && $serverUrl == "") { echo json_encode([ "success" => false, "message" => translate('fill_all_fields', $i18n) ]); die(); } $sql = "UPDATE admin SET registrations_open = :openRegistrations, max_users = :maxUsers, require_email_verification = :requireEmailVerification, server_url = :serverUrl, login_disabled = :disableLogin WHERE id = 1"; $stmt = $db->prepare($sql); $stmt->bindParam(':openRegistrations', $openRegistrations, SQLITE3_INTEGER); $stmt->bindParam(':maxUsers', $maxUsers, SQLITE3_INTEGER); $stmt->bindParam(':requireEmailVerification', $requireEmailVerification, SQLITE3_INTEGER); $stmt->bindParam(':serverUrl', $serverUrl, SQLITE3_TEXT); $stmt->bindParam(':disableLogin', $disableLogin, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { echo json_encode([ "success" => true, "message" => translate('success', $i18n) ]); } else { echo json_encode([ "success" => false, "message" => translate('error', $i18n) ]); } ================================================ FILE: endpoints/admin/savesecuritysettings.php ================================================ false, "message" => translate('error', $i18n) ]); die(); } // Basic cleanup: trim whitespace and strip any accidental HTML tags $allowlist = trim(strip_tags($data['local_webhook_notifications_allowlist'])); // Update the admin table (assuming id 1 is the primary settings row, as in your reference) $sql = "UPDATE admin SET local_webhook_notifications_allowlist = :allowlist WHERE id = 1"; $stmt = $db->prepare($sql); $stmt->bindParam(':allowlist', $allowlist, SQLITE3_TEXT); $result = $stmt->execute(); if ($result) { echo json_encode([ "success" => true, "message" => translate('success', $i18n) ]); } else { echo json_encode([ "success" => false, "message" => translate('error', $i18n) ]); } ================================================ FILE: endpoints/admin/savesmtpsettings.php ================================================ false, "message" => translate('fill_all_fields', $i18n) ])); } // Save settings $stmt = $db->prepare('UPDATE admin SET smtp_address = :smtp_address, smtp_port = :smtp_port, encryption = :encryption, smtp_username = :smtp_username, smtp_password = :smtp_password, from_email = :from_email'); $stmt->bindValue(':smtp_address', $smtpAddress, SQLITE3_TEXT); $stmt->bindValue(':smtp_port', $smtpPort, SQLITE3_TEXT); $encryption = empty($data['encryption']) ? 'tls' : $data['encryption']; $stmt->bindValue(':encryption', $encryption, SQLITE3_TEXT); $stmt->bindValue(':smtp_username', $smtpUsername, SQLITE3_TEXT); $stmt->bindValue(':smtp_password', $smtpPassword, SQLITE3_TEXT); $stmt->bindValue(':from_email', $fromEmail, SQLITE3_TEXT); $result = $stmt->execute(); if ($result) { die(json_encode([ "success" => true, "message" => translate('success', $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate('error', $i18n) ])); } ================================================ FILE: endpoints/admin/updatenotification.php ================================================ prepare('UPDATE admin SET update_notification = :update_notification'); $stmt->bindValue(':update_notification', $updateNotification, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { die(json_encode([ "success" => true, "message" => translate('success', $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate('error', $i18n) ])); } ================================================ FILE: endpoints/ai/delete_recommendation.php ================================================ false, "message" => translate('error', $i18n) ]; echo json_encode($response); exit; } // Delete the recommendation for the user $stmt = $db->prepare("DELETE FROM ai_recommendations WHERE id = ? AND user_id = ?"); $stmt->bindValue(1, $recommendationId, SQLITE3_INTEGER); $stmt->bindValue(2, $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($db->changes() > 0) { $response = [ "success" => true, "message" => translate('success', $i18n) ]; } else { $response = [ "success" => false, "message" => translate('error', $i18n) ]; } echo json_encode($response); ================================================ FILE: endpoints/ai/fetch_models.php ================================================ false, "message" => translate('error', $i18n) ]; echo json_encode($response); exit; } // Validate ai-api-key and fetch models if ai-type is chatgpt, gemini or openrouter if ($aiType === 'chatgpt' || $aiType === 'gemini' || $aiType === 'openrouter') { if (empty($aiApiKey)) { $response = [ "success" => false, "message" => translate('invalid_api_key', $i18n) ]; echo json_encode($response); exit; } } // Prepare the request headers $headers = [ 'Content-Type: application/json', ]; if ($aiType === 'chatgpt') { $headers[] = 'Authorization: Bearer ' . $aiApiKey; $apiUrl = $chatgptModelsApiUrl; } elseif ($aiType === 'gemini') { $apiUrl = $geminiModelsApiUrl . '?key=' . urlencode($aiApiKey); } elseif ($aiType === 'openrouter') { $headers[] = 'Authorization: Bearer ' . $aiApiKey; $apiUrl = $openrouterModelsApiUrl; } else { // For ollama, no API key is needed // Check for ollama host if (empty($aiOllamaHost)) { $response = [ "success" => false, "message" => translate('invalid_host', $i18n) ]; echo json_encode($response); exit; } // Scheme check $parsedUrl = parse_url($aiOllamaHost); if ( !isset($parsedUrl['scheme']) || !in_array(strtolower($parsedUrl['scheme']), ['http', 'https']) || !filter_var($aiOllamaHost, FILTER_VALIDATE_URL) ) { echo json_encode(["success" => false, "message" => translate('invalid_host', $i18n)]); exit; } // SSRF check — dies automatically if private IP not in allowlist $ssrf = validate_webhook_url_for_ssrf($aiOllamaHost, $db, $i18n); $apiUrl = $aiOllamaHost . '/api/tags'; } // Initialize cURL $ch = curl_init($apiUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_TIMEOUT, 60); // Set a timeout for the request // Execute the request $response = curl_exec($ch); // Check for cURL errors if (curl_errno($ch)) { $response = [ "success" => false, "message" => ($aiType === 'ollama') ? translate('invalid_host', $i18n) : translate('error', $i18n) ]; } else { // Decode the response $modelsData = json_decode($response, true); if ($aiType === 'gemini' && isset($modelsData['models']) && is_array($modelsData['models'])) { // Normalize Gemini response $models = array_map(function ($model) { return [ 'id' => str_replace('models/', '', $model['name']), 'name' => $model['displayName'] ?? $model['name'], ]; }, $modelsData['models']); $response = [ "success" => true, "models" => $models ]; } elseif (isset($modelsData['data']) && is_array($modelsData['data'])) { // OpenAI format $models = array_map(function ($model) { return [ 'id' => $model['id'], 'name' => $model['name'] ?? $model['id'], ]; }, $modelsData['data']); $response = [ "success" => true, "models" => $models ]; } elseif ($aiType === 'ollama' && isset($modelsData['models']) && is_array($modelsData['models'])) { // Normalize Ollama response $models = array_map(function ($model) { return [ 'id' => $model['name'], 'name' => $model['name'], ]; }, $modelsData['models']); $response = [ "success" => true, "models" => $models ]; } else { $response = [ "success" => false, "message" => ($aiType === 'ollama') ? translate('invalid_host', $i18n) : translate('invalid_api_key', $i18n) ]; } } // Close cURL session curl_close($ch); // Return the response as JSON echo json_encode($response); ================================================ FILE: endpoints/ai/generate_recommendations.php ================================================ 'day', 2 => 'week', 3 => 'month', 4 => 'year', default => 'unit' }; if ($frequency == 1) { return "Every $unit"; } else { return "Every $frequency {$unit}s"; } } function describeCurrency($currencyId, $currencies) { return $currencies[$currencyId]['code'] ?? ''; } // Get AI settings for the user from the database $stmt = $db->prepare("SELECT * FROM ai_settings WHERE user_id = ?"); $stmt->bindValue(1, $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $aiSettings = $result->fetchArray(SQLITE3_ASSOC); $stmt->close(); if (!$aiSettings) { $response = [ "success" => false, "message" => translate('error', $i18n) ]; echo json_encode($response); exit; } $type = isset($aiSettings['type']) ? $aiSettings['type'] : ''; $enabled = isset($aiSettings['enabled']) ? (bool) $aiSettings['enabled'] : false; $model = isset($aiSettings['model']) ? $aiSettings['model'] : ''; $host = ""; $apiKey = ""; if (!in_array($type, ['chatgpt', 'gemini', 'openrouter', 'ollama']) || !$enabled || empty($model)) { $response = [ "success" => false, "message" => translate('error', $i18n) ]; echo json_encode($response); exit; } if ($type == 'ollama') { $host = isset($aiSettings['url']) ? $aiSettings['url'] : ''; if (empty($host)) { $response = [ "success" => false, "message" => translate('invalid_host', $i18n) ]; echo json_encode($response); exit; } $parsedUrl = parse_url($host); if ( !isset($parsedUrl['scheme']) || !in_array(strtolower($parsedUrl['scheme']), ['http', 'https']) || !filter_var($host, FILTER_VALIDATE_URL) ) { echo json_encode(["success" => false, "message" => translate('invalid_host', $i18n)]); exit; } $ssrf = validate_webhook_url_for_ssrf($host, $db, $i18n); } else { $ssrf = null; $apiKey = isset($aiSettings['api_key']) ? $aiSettings['api_key'] : ''; if (empty($apiKey)) { $response = [ "success" => false, "message" => translate('invalid_api_key', $i18n) ]; echo json_encode($response); exit; } } // We have everything we need, fetch information from the dabase to send to the AI API // Get the categories from the database for user with ID 1 $stmt = $db->prepare("SELECT * FROM categories WHERE user_id = :user_id"); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $categories = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $categories[$row['id']] = $row; } // Get the currencies from the database for user with ID 1 $stmt = $db->prepare("SELECT * FROM currencies WHERE user_id = :user_id"); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $currencies = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $currencies[$row['id']] = $row; } // Get houswhold members from the database for user with ID 1 $stmt = $db->prepare("SELECT * FROM household WHERE user_id = :user_id"); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $members = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $members[$row['id']] = $row; } // Get language from the user table $stmt = $db->prepare("SELECT language FROM user WHERE id = :user_id"); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $userLanguage = $result->fetchArray(SQLITE3_ASSOC)['language'] ?? 'en'; // Get name from includes/i18n/languages.php require_once '../../includes/i18n/languages.php'; $userLanguageName = $languages[$userLanguage]['name'] ?? 'English'; // Get subscriptions from the database for user with ID 1 $stmt = $db->prepare("SELECT * FROM subscriptions WHERE user_id = :user_id AND inactive = 0"); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $subscriptions = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $subscriptions[] = $row; } if (!empty($subscriptions)) { $subscriptionsForAI = []; foreach ($subscriptions as $row) { if ($row['inactive']) continue; $price = round($row['price'], 2); $currencyCode = $currencies[$row['currency_id']]['code'] ?? ''; $priceFormatted = $currencyCode ? "$price $currencyCode" : "$price"; $payerName = $members[$row['payer_user_id']]['name'] ?? 'Unknown'; $subscriptionsForAI[] = [ 'name' => $row['name'], 'price' => $priceFormatted, 'frequency' => describeFrequency($row['cycle'], $row['frequency']), 'category' => $categories[$row['category_id']]['name'] ?? 'Uncategorized', 'payer' => $payerName ]; } // encode $aiDataJson = json_encode($subscriptionsForAI, JSON_PRETTY_PRINT); } else { $response = [ "success" => false, "message" => translate('error', $i18n) ]; echo json_encode($response); exit; } $prompt = << $model, 'prompt' => $prompt, 'stream' => false])); } else { $headers = ['Content-Type: application/json']; if ($type === 'chatgpt') { $headers[] = 'Authorization: Bearer ' . $apiKey; curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions'); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([ 'model' => $model, 'messages' => [['role' => 'user', 'content' => $prompt]] ])); } elseif ($type === 'gemini') { curl_setopt( $ch, CURLOPT_URL, 'https://generativelanguage.googleapis.com/v1beta/models/' . urlencode($model) . ':generateContent?key=' . urlencode($apiKey) ); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([ 'contents' => [ [ 'parts' => [['text' => $prompt]] ] ] ])); } elseif ($type === 'openrouter') { $headers[] = 'Authorization: Bearer ' . $apiKey; curl_setopt($ch, CURLOPT_URL, 'https://openrouter.ai/api/v1/chat/completions'); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([ 'model' => $model, 'messages' => [['role' => 'user', 'content' => $prompt]] ])); } curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); } curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 300); // Execute the cURL request $reply = curl_exec($ch); // Check for errors if (curl_errno($ch)) { $response = [ "success" => false, "message" => curl_error($ch) ]; echo json_encode($response); exit; } // Close the cURL session curl_close($ch); // Try to decode the AI's JSON reply $replyData = json_decode($reply, true); // decode into array if (($type === 'chatgpt' || $type === 'openrouter') && isset($replyData['choices'][0]['message']['content'])) { $recommendationsJson = $replyData['choices'][0]['message']['content']; $recommendations = json_decode($recommendationsJson, true); } elseif ($type === 'gemini' && isset($replyData['candidates'][0]['content']['parts'][0]['text'])) { $recommendationsJson = $replyData['candidates'][0]['content']['parts'][0]['text']; // Gemini has a habit of returning the JSON wrapped in markdown syntax, no matter the prompting, strip before parsing. $recommendationsJson = preg_replace('/^```json\s*|\s*```$/m', '', $recommendationsJson); $recommendationsJson = trim($recommendationsJson); $recommendations = json_decode($recommendationsJson, true); } else { $recommendations = json_decode($replyData['response'], true); } if (json_last_error() === JSON_ERROR_NONE && is_array($recommendations)) { // Remove old recommendations for this user $stmt = $db->prepare("DELETE FROM ai_recommendations WHERE user_id = :user_id"); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->execute(); // Insert each new recommendation $insert = $db->prepare(" INSERT INTO ai_recommendations (user_id, type, title, description, savings) VALUES (:user_id, :type, :title, :description, :savings) "); foreach ($recommendations as $rec) { $insert->bindValue(':user_id', $userId, SQLITE3_INTEGER); $insert->bindValue(':type', 'subscription', SQLITE3_TEXT); // or any category you want $insert->bindValue(':title', $rec['title'] ?? '', SQLITE3_TEXT); $insert->bindValue(':description', $rec['description'] ?? '', SQLITE3_TEXT); $insert->bindValue(':savings', $rec['savings'] ?? '', SQLITE3_TEXT); $insert->execute(); } $response = [ "success" => true, "message" => translate('success', $i18n), "recommendations" => $recommendations ]; echo json_encode($response); exit; } else { $response = [ "success" => false, "message" => translate('error', $i18n), "json_error" => json_last_error_msg() ]; echo json_encode($response); exit; } ================================================ FILE: endpoints/ai/save_settings.php ================================================ false, "message" => translate('error', $i18n) ]; echo json_encode($response); exit; } if (($aiType === 'chatgpt' || $aiType === 'gemini' || $aiType === 'openrouter') && empty($aiApiKey)) { $response = [ "success" => false, "message" => translate('invalid_api_key', $i18n) ]; echo json_encode($response); exit; } if ($aiType === 'ollama' && empty($aiOllamaHost)) { $response = [ "success" => false, "message" => translate('invalid_host', $i18n) ]; echo json_encode($response); exit; } if (empty($aiModel)) { $response = [ "success" => false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); exit; } if ($aiType === 'ollama') { $aiApiKey = ''; // Ollama does not require an API key $parsedUrl = parse_url($aiOllamaHost); if ( !isset($parsedUrl['scheme']) || !in_array(strtolower($parsedUrl['scheme']), ['http', 'https']) || !filter_var($aiOllamaHost, FILTER_VALIDATE_URL) ) { echo json_encode(["success" => false, "message" => translate('invalid_host', $i18n)]); exit; } // SSRF check — dies automatically if private IP not in allowlist validate_webhook_url_for_ssrf($aiOllamaHost, $db, $i18n); } else { $aiOllamaHost = ''; // Clear Ollama host if not using Ollama } // Remove existing AI settings for the user $stmt = $db->prepare("DELETE FROM ai_settings WHERE user_id = ?"); $stmt->bindValue(1, $userId, SQLITE3_INTEGER); $stmt->execute(); $stmt->close(); // Insert new AI settings $stmt = $db->prepare("INSERT INTO ai_settings (user_id, type, enabled, api_key, model, url) VALUES (:user_id, :type, :enabled, :api_key, :model, :url)"); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->bindValue(':type', $aiType, SQLITE3_TEXT); $stmt->bindValue(':enabled', $aiEnabled, SQLITE3_INTEGER); $stmt->bindValue(':api_key', $aiApiKey, SQLITE3_TEXT); $stmt->bindValue(':model', $aiModel, SQLITE3_TEXT); $stmt->bindValue(':url', $aiOllamaHost, SQLITE3_TEXT); $result = $stmt->execute(); if ($result) { $response = [ "success" => true, "message" => translate('success', $i18n), "enabled" => $aiEnabled ]; } else { $response = [ "success" => false, "message" => translate('error', $i18n) ]; } echo json_encode($response); ================================================ FILE: endpoints/categories/category.php ================================================ false, "message" => translate('error', $i18n)]); break; } function handleAddCategory($db, $userId, $i18n) { $stmt = $db->prepare('SELECT MAX("order") as maxOrder FROM categories WHERE user_id = :userId'); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $maxOrder = $row['maxOrder']; if ($maxOrder === NULL) { $maxOrder = 0; } $order = $maxOrder + 1; $categoryName = "Category"; $sqlInsert = 'INSERT INTO categories ("name", "order", "user_id") VALUES (:name, :order, :userId)'; $stmtInsert = $db->prepare($sqlInsert); $stmtInsert->bindParam(':name', $categoryName, SQLITE3_TEXT); $stmtInsert->bindParam(':order', $order, SQLITE3_INTEGER); $stmtInsert->bindParam(':userId', $userId, SQLITE3_INTEGER); $resultInsert = $stmtInsert->execute(); if ($resultInsert) { $categoryId = $db->lastInsertRowID(); $response = [ "success" => true, "categoryId" => $categoryId ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('failed_add_category', $i18n) ]; echo json_encode($response); } } function handleEditCategory($db, $userId, $i18n) { if (isset($_POST['categoryId']) && $_POST['categoryId'] != "" && isset($_POST['name']) && $_POST['name'] != "") { $categoryId = $_POST['categoryId']; $name = validate($_POST['name']); $sql = "UPDATE categories SET name = :name WHERE id = :categoryId AND user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindParam(':name', $name, SQLITE3_TEXT); $stmt->bindParam(':categoryId', $categoryId, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { $response = [ "success" => true, "message" => translate('category_saved', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('failed_edit_category', $i18n) ]; echo json_encode($response); } } else { $response = [ "success" => false, "message" => translate('fill_all_fields', $i18n) ]; echo json_encode($response); } } function handleDeleteCategory($db, $userId, $i18n) { if (isset($_POST['categoryId']) && $_POST['categoryId'] != "" && $_POST['categoryId'] != 1) { $categoryId = $_POST['categoryId']; $checkCategory = "SELECT COUNT(*) FROM subscriptions WHERE category_id = :categoryId AND user_id = :userId"; $checkStmt = $db->prepare($checkCategory); $checkStmt->bindParam(':categoryId', $categoryId, SQLITE3_INTEGER); $checkStmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $checkResult = $checkStmt->execute(); $row = $checkResult->fetchArray(); $count = $row[0]; if ($count > 0) { $response = [ "success" => false, "message" => translate('category_in_use', $i18n) ]; echo json_encode($response); } else { $sql = "DELETE FROM categories WHERE id = :categoryId AND user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindParam(':categoryId', $categoryId, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { $response = [ "success" => true, "message" => translate('category_removed', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('failed_remove_category', $i18n) ]; echo json_encode($response); } } } else { $response = [ "success" => false, "message" => translate('failed_remove_category', $i18n) ]; echo json_encode($response); } } function handleSortCategories($db, $userId, $i18n) { $categories = $_POST['categoryIds']; $order = 2; foreach ($categories as $categoryId) { $sql = "UPDATE categories SET `order` = :order WHERE id = :categoryId AND user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindParam(':order', $order, SQLITE3_INTEGER); $stmt->bindParam(':categoryId', $categoryId, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $order++; } $response = [ "success" => true, "message" => translate("sort_order_saved", $i18n) ]; echo json_encode($response); } ================================================ FILE: endpoints/cronjobs/checkforupdates.php ================================================ [ 'header' => "User-Agent: Wallos\r\n" ] ]; $repository = 'ellite/Wallos'; // Change this to your repository if you fork Wallos $url = "https://api.github.com/repos/$repository/releases/latest"; $context = stream_context_create($options); $fetch = file_get_contents($url, false, $context); if ($fetch === false) { die('Error fetching data from GitHub API'); } $latestVersion = json_decode($fetch, true)['tag_name']; // Check that $latestVersion is a valid version number if (!preg_match('/^v\d+\.\d+\.\d+$/', $latestVersion)) { die('Error: Invalid version number from GitHub API'); } $db->exec("UPDATE admin SET latest_version = '$latestVersion'"); if (php_sapi_name() !== 'cli') { include __DIR__ . '/../../includes/version.php'; if (version_compare($latestVersion, $version) > 0) { echo "New version available: $latestVersion"; } else { echo "No new version available, currently on $version"; } } ?> ================================================ FILE: endpoints/cronjobs/cleanupresettokens.php ================================================ exec("DELETE FROM password_resets WHERE created_at <= datetime('now', '-1 hour')"); if ($deleted) { echo "Expired password reset tokens cleaned up successfully.\n"; } else { echo "No expired password reset tokens to clean up.\n"; } $db->close(); ?> ================================================ FILE: endpoints/cronjobs/createdatabase.php ================================================ busyTimeout(5000); $db->exec('CREATE TABLE user ( id INTEGER PRIMARY KEY, username TEXT NOT NULL, email TEXT NOT NULL, password TEXT NOT NULL, main_currency INTEGER NOT NULL, avatar TEXT, FOREIGN KEY(main_currency) REFERENCES currencies(id) )'); $db->exec('CREATE TABLE payment_methods ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, icon TEXT )'); $db->exec('CREATE TABLE subscriptions ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, logo TEXT, price REAL NOT NULL, currency_id INTEGER, next_payment DATE, cycle INTEGER, frequency INTEGER, notes TEXT, payment_method_id INTEGER, payer_user_id INTEGER, category_id INTEGER, notify BOOLEAN DEFAULT false, FOREIGN KEY(currency_id) REFERENCES currencies(id), FOREIGN KEY(cycle) REFERENCES cycles(id), FOREIGN KEY(frequency) REFERENCES frequencies(id), FOREIGN KEY(payment_method_id) REFERENCES payment_methods(id), FOREIGN KEY(payer_user_id) REFERENCES household(id) FOREIGN KEY(category_id) REFERENCES categories(id) )'); $db->exec('CREATE TABLE categories ( id INTEGER PRIMARY KEY, name TEXT NOT NULL )'); $db->exec('CREATE TABLE currencies ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, symbol TEXT NOT NULL, code TEXT NOT NULL, rate TEXT NOT NULL )'); $db->exec('CREATE TABLE household ( id INTEGER PRIMARY KEY, name TEXT NOT NULL )'); $db->exec('CREATE TABLE login_tokens ( user_id INTEGER NOT NULL, token TEXT NOT NULL, timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE ON UPDATE CASCADE )'); $db->exec('CREATE TABLE cycles ( id INTEGER PRIMARY KEY, days INTEGER NOT NULL, name TEXT NOT NULL )'); $db->exec('CREATE TABLE frequencies ( id INTEGER PRIMARY KEY, name INTEGER NOT NULL )'); $db->exec('CREATE TABLE fixer ( api_key TEXT NOT NULL )'); $db->exec('CREATE TABLE last_exchange_update ( date DATE NOT NULL )'); $db->exec('CREATE TABLE last_update_next_payment_date ( date DATE NOT NULL )'); $db->exec('CREATE TABLE notifications ( id INTEGER PRIMARY KEY, enabled BOOLEAN DEFAULT false, days INTEGER, smtp_address VARCHAR(255), smtp_port INTEGER, smtp_username VARCHAR(255), smtp_password VARCHAR(255) )'); $db->exec("INSERT INTO categories (id, name) VALUES (1, 'No category'), (2, 'Entertainment'), (3, 'Music'), (4, 'Utilities'), (5, 'Food & Beverages'), (6, 'Health & Wellbeing'), (7, 'Productivity'), (8, 'Banking'), (9, 'Transport'), (10, 'Education'), (11, 'Insurance'), (12, 'Gaming'), (13, 'News & Magazines'), (14, 'Software'), (15, 'Technology'), (16, 'Cloud Services'), (17, 'Charity & Donations')"); $db->exec("INSERT INTO cycles (id, days, name) VALUES (1, 1, 'Daily'), (2, 7, 'Weekly'), (3, 30, 'Monthly'), (4, 365, 'Yearly')"); $db->exec("INSERT INTO frequencies (id, name) VALUES (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (6, 6), (7, 7), (8, 8), (9, 9), (10, 10), (11, 11), (12, 12), (13, 13), (14, 14), (15, 15), (16, 16), (17, 17), (18, 18), (19, 19), (20, 20), (21, 21), (22, 22), (23, 23), (24, 24), (25, 25), (26, 26), (27, 27), (28, 28), (29, 29), (30, 30), (31, 31)"); $db->exec("INSERT INTO currencies (name, symbol, code, rate) VALUES ('Euro', '€', 'EUR', 1), ('US Dollar', '$', 'USD', 1), ('Japanese Yen', '¥', 'JPY', 1), ('Bulgarian Lev', 'лв', 'BGN', 1), ('Czech Republic Koruna', 'Kč', 'CZK', 1), ('Danish Krone', 'kr', 'DKK', 1), ('British Pound Sterling', '£', 'GBP', 1), ('Hungarian Forint', 'Ft', 'HUF', 1), ('Polish Zloty', 'zł', 'PLN', 1), ('Romanian Leu', 'lei', 'RON', 1), ('Swedish Krona', 'kr', 'SEK', 1), ('Swiss Franc', 'Fr', 'CHF', 1), ('Icelandic Króna', 'kr', 'ISK', 1), ('Norwegian Krone', 'kr', 'NOK', 1), ('Russian Ruble', '₽', 'RUB', 1), ('Turkish Lira', '₺', 'TRY', 1), ('Australian Dollar', '$', 'AUD', 1), ('Brazilian Real', 'R$', 'BRL', 1), ('Canadian Dollar', '$', 'CAD', 1), ('Chinese Yuan', '¥', 'CNY', 1), ('Hong Kong Dollar', 'HK$', 'HKD', 1), ('Indonesian Rupiah', 'Rp', 'IDR', 1), ('Israeli New Sheqel', '₪', 'ILS', 1), ('Indian Rupee', '₹', 'INR', 1), ('South Korean Won', '₩', 'KRW', 1), ('Mexican Peso', 'Mex$', 'MXN', 1), ('Malaysian Ringgit', 'RM', 'MYR', 1), ('New Zealand Dollar', 'NZ$', 'NZD', 1), ('Philippine Peso', '₱', 'PHP', 1), ('Singapore Dollar', 'S$', 'SGD', 1), ('Thai Baht', '฿', 'THB', 1), ('South African Rand', 'R', 'ZAR', 1), ('Ukrainian Hryvnia', '₴', 'UAH', 1), ('New Taiwan Dollar', 'NT$', 'TWD', 1)"); $db->exec("INSERT INTO payment_methods (id, name, icon) VALUES (1, 'PayPal', 'paypal.png'), (2, 'Credit Card', 'creditcard.png'), (3, 'Bank Transfer', 'banktransfer.png'), (4, 'Direct Debit', 'directdebit.png'), (5, 'Money', 'money.png'), (6, 'Google Pay', 'googlepay.png'), (7, 'Samsung Pay', 'samsungpay.png'), (8, 'Apple Pay', 'applepay.png'), (9, 'Crypto', 'crypto.png'), (10, 'Klarna', 'klarna.png'), (11, 'Amazon Pay', 'amazonpay.png'), (12, 'SEPA', 'sepa.png'), (13, 'Skrill', 'skrill.png'), (14, 'Sofort', 'sofort.png'), (15, 'Stripe', 'stripe.png'), (16, 'Affirm', 'affirm.png'), (17, 'AliPay', 'alipay.png'), (18, 'Elo', 'elo.png'), (19, 'Facebook Pay', 'facebookpay.png'), (20, 'GiroPay', 'giropay.png'), (21, 'iDeal', 'ideal.png'), (22, 'Union Pay', 'unionpay.png'), (23, 'Interac', 'interac.png'), (24, 'WeChat', 'wechat.png'), (25, 'Paysafe', 'paysafe.png'), (26, 'Poli', 'poli.png'), (27, 'Qiwi', 'qiwi.png'), (28, 'ShopPay', 'shoppay.png'), (29, 'Venmo', 'venmo.png'), (30, 'VeriFone', 'verifone.png'), (31, 'WebMoney', 'webmoney.png')"); echo "Database created.\n"; } else { echo "Database already exist. Checking for upgrades...\n"; $db = new SQLite3($databaseFile); $db->busyTimeout(5000); if (!$db) { die('Connection to the database failed.'); } # v0.9 to v1.0 # Added new notifications table # Added notify column to subscriptions table $result = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'"); if (!$result->fetchArray(SQLITE3_ASSOC)) { $db->exec('CREATE TABLE notifications ( id INTEGER PRIMARY KEY, enabled BOOLEAN DEFAULT false, days INTEGER, smtp_address VARCHAR(255), smtp_port INTEGER, smtp_username VARCHAR(255), smtp_password VARCHAR(255) )'); echo "Table 'notifications' created.\n"; } else { echo "Table 'notifications' already exists.\n"; } $result = $db->query("PRAGMA table_info(subscriptions)"); $notifyColumnExists = false; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { if ($row['name'] === 'notify') { $notifyColumnExists = true; break; } } if (!$notifyColumnExists) { $db->exec('ALTER TABLE subscriptions ADD COLUMN notify BOOLEAN DEFAULT false'); echo "Column 'notify' added to table 'subscriptions'.\n"; } else { echo "Column 'notify' already exists in table 'subscriptions'.\n"; } } ?> ================================================ FILE: endpoints/cronjobs/sendcancellationnotifications.php ================================================ prepare($query); $usersToNotify = $stmt->execute(); while ($userToNotify = $usersToNotify->fetchArray(SQLITE3_ASSOC)) { $userId = $userToNotify['id']; if (php_sapi_name() !== 'cli') { echo "For user: " . $userToNotify['username'] . "
"; } $emailNotificationsEnabled = false; $gotifyNotificationsEnabled = false; $telegramNotificationsEnabled = false; $pushoverNotificationsEnabled = false; $discordNotificationsEnabled = false; $ntfyNotificationsEnabled = false; $webhookNotificationsEnabled = false; // Check if email notifications are enabled and get the settings $query = "SELECT * FROM email_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $emailNotificationsEnabled = $row['enabled']; $email['smtpAddress'] = $row["smtp_address"]; $email['smtpPort'] = $row["smtp_port"]; $email['encryption'] = $row["encryption"]; $email['smtpUsername'] = $row["smtp_username"]; $email['smtpPassword'] = $row["smtp_password"]; $email['fromEmail'] = $row["from_email"] ? $row["from_email"] : "wallos@wallosapp.com"; $email['otherEmails'] = $row["other_emails"]; } // Check if Discord notifications are enabled and get the settings $query = "SELECT * FROM discord_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $discordNotificationsEnabled = $row['enabled']; $discord['webhook_url'] = $row["webhook_url"]; $discord['bot_username'] = $row["bot_username"]; $discord['bot_avatar_url'] = $row["bot_avatar_url"]; } // Check if Gotify notifications are enabled and get the settings $query = "SELECT * FROM gotify_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $gotify = []; if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $gotifyNotificationsEnabled = $row['enabled']; $gotify['serverUrl'] = $row["url"]; $gotify['appToken'] = $row["token"]; $gotify['ignore_ssl'] = $row["ignore_ssl"]; } // Check if Telegram notifications are enabled and get the settings $query = "SELECT * FROM telegram_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $telegramNotificationsEnabled = $row['enabled']; $telegram['botToken'] = $row["bot_token"]; $telegram['chatId'] = $row["chat_id"]; } // Check if Pushover notifications are enabled and get the settings $query = "SELECT * FROM pushover_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $pushoverNotificationsEnabled = $row['enabled']; $pushover['user_key'] = $row["user_key"]; $pushover['token'] = $row["token"]; } // Check if Ntfy notifications are enabled and get the settings $query = "SELECT * FROM ntfy_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $ntfyNotificationsEnabled = $row['enabled']; $ntfy['host'] = $row["host"]; $ntfy['topic'] = $row["topic"]; $ntfy['headers'] = $row["headers"]; $ntfy['ignore_ssl'] = $row["ignore_ssl"]; } // Check if webhook notifications are enabled and have cancelation payload set and get the settings $query = "SELECT * FROM webhook_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $webhook = []; if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $webhook['url'] = $row["url"]; $webhook['headers'] = $row["headers"]; $webhook['cancelation_payload'] = $row["cancelation_payload"]; $webhook['ignore_ssl'] = $row["ignore_ssl"]; $webhook['request_method'] = $row["request_method"]; $webhookNotificationsEnabled = $row['enabled'] && $row['cancelation_payload'] != ""; } $notificationsEnabled = $emailNotificationsEnabled || $gotifyNotificationsEnabled || $telegramNotificationsEnabled || $pushoverNotificationsEnabled || $discordNotificationsEnabled ||$ntfyNotificationsEnabled || $webhookNotificationsEnabled; // If no notifications are enabled, no need to run if (!$notificationsEnabled) { if (php_sapi_name() !== 'cli') { echo "Notifications are disabled. No need to run.
"; } continue; } else { // Get all currencies $currencies = array(); $query = "SELECT * FROM currencies WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $currencies[$row['id']] = $row; } // Get all household members $query = "SELECT * FROM household WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $resultHousehold = $stmt->execute(); $household = []; while ($rowHousehold = $resultHousehold->fetchArray(SQLITE3_ASSOC)) { $household[$rowHousehold['id']] = $rowHousehold; } // Get all categories $query = "SELECT * FROM categories WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $resultCategories = $stmt->execute(); $categories = []; while ($rowCategory = $resultCategories->fetchArray(SQLITE3_ASSOC)) { $categories[$rowCategory['id']] = $rowCategory; } // Get current date to check which subscriptions are set to notify for cancellation $currentDate = new DateTime('now'); $currentDate = $currentDate->format('Y-m-d'); $query = "SELECT * FROM subscriptions WHERE user_id = :user_id AND inactive = :inactive AND cancellation_date = :cancellationDate ORDER BY payer_user_id ASC"; $stmt = $db->prepare($query); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->bindValue(':inactive', 0, SQLITE3_INTEGER); $stmt->bindValue(':cancellationDate', $currentDate, SQLITE3_TEXT); $resultSubscriptions = $stmt->execute(); $notify = []; $i = 0; $currentDate = new DateTime('now'); while ($rowSubscription = $resultSubscriptions->fetchArray(SQLITE3_ASSOC)) { $notify[$rowSubscription['payer_user_id']][$i]['name'] = $rowSubscription['name']; $notify[$rowSubscription['payer_user_id']][$i]['price'] = $rowSubscription['price'] . $currencies[$rowSubscription['currency_id']]['symbol']; $notify[$rowSubscription['payer_user_id']][$i]['currency'] = $currencies[$rowSubscription['currency_id']]['name']; $notify[$rowSubscription['payer_user_id']][$i]['category'] = $categories[$rowSubscription['category_id']]['name']; $notify[$rowSubscription['payer_user_id']][$i]['payer'] = $household[$rowSubscription['payer_user_id']]['name']; $notify[$rowSubscription['payer_user_id']][$i]['date'] = $rowSubscription['next_payment']; $notify[$rowSubscription['payer_user_id']][$i]['url'] = $rowSubscription['url']; $notify[$rowSubscription['payer_user_id']][$i]['notes'] = $rowSubscription['notes']; $i++; } if (!empty($notify)) { // Email notifications if enabled if ($emailNotificationsEnabled) { $stmt = $db->prepare('SELECT * FROM user WHERE id = :user_id'); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $defaultUser = $result->fetchArray(SQLITE3_ASSOC); $defaultEmail = $defaultUser['email']; $defaultName = $defaultUser['username']; foreach ($notify as $userId => $perUser) { $message = "The following subscriptions are up for cancellation:\n"; foreach ($perUser as $subscription) { $message .= $subscription['name'] . " for " . $subscription['price'] ."\n"; } $smtpAuth = (isset($email["smtpUsername"]) && $email["smtpUsername"] != "") || (isset($email["smtpPassword"]) && $email["smtpPassword"] != ""); $mail = new PHPMailer(true); $mail->CharSet = "UTF-8"; $mail->isSMTP(); $mail->Host = $email['smtpAddress']; $mail->SMTPAuth = $smtpAuth; if ($smtpAuth) { $mail->Username = $email['smtpUsername']; $mail->Password = $email['smtpPassword']; } if ($email['encryption'] != "none") { $mail->SMTPSecure = $email['encryption']; } $mail->Port = $email['smtpPort']; $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); $emailaddress = !empty($user['email']) ? $user['email'] : $defaultEmail; $name = !empty($user['name']) ? $user['name'] : $defaultName; $mail->setFrom($email['fromEmail'], 'Wallos App'); $mail->addAddress($emailaddress, $name); if (!empty($email['otherEmails'])) { $list = explode(';', $email['otherEmails']); // Avoid duplicate emails $list = array_unique($list); $list = array_filter($list, function ($value) use ($emailaddress) { return $value !== $emailaddress; }); foreach($list as $value) { $mail->addCC(trim($value)); } } $mail->Subject = 'Wallos Cancellation Notification'; $mail->Body = $message; if ($mail->send()) { echo "Email Notifications sent
"; } else { echo "Error sending notifications: " . $mail->ErrorInfo . "
"; } } } // Discord notifications if enabled if ($discordNotificationsEnabled) { $ssrf = is_url_safe_for_ssrf($discord['webhook_url'], $db); if (!$ssrf) { echo "Discord notification skipped: URL failed SSRF validation.
"; } else { foreach ($notify as $userId => $perUser) { // Get name of user from household table $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); $title = translate('wallos_notification', $i18n); if ($user['name']) { $message = $user['name'] . ", the following subscriptions are up for cancellation:\n"; } else { $message = "The following subscriptions are up for cancellation:\n"; } foreach ($perUser as $subscription) { $message .= $subscription['name'] . " for " . $subscription['price'] . "\n"; } $postfields = [ 'content' => $message ]; if (!empty($discord['bot_username'])) { $postfields['username'] = $discord['bot_username']; } if (!empty($discord['bot_avatar_url'])) { $postfields['avatar_url'] = $discord['bot_avatar_url']; } $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $discord['webhook_url']); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postfields)); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json' ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = curl_exec($ch); curl_close($ch); if ($response === false) { echo "Error sending notifications: " . curl_error($ch) . "
"; } else { echo "Discord Notifications sent
"; } } } } // Gotify notifications if enabled if ($gotifyNotificationsEnabled) { $ssrf = is_url_safe_for_ssrf($gotify['serverUrl'], $db); if (!$ssrf) { echo "Gotify notification skipped: URL failed SSRF validation.
"; } else { foreach ($notify as $userId => $perUser) { // Get name of user from household table $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); if ($user['name']) { $message = $user['name'] . ", the following subscriptions are up for cancellation:\n"; } else { $message = "The following subscriptions are up for cancellation:\n"; } foreach ($perUser as $subscription) { $message .= $subscription['name'] . " for " . $subscription['price'] . "\n"; } $data = array( 'message' => $message, 'priority' => 5 ); $data_string = json_encode($data); $ch = curl_init($gotify['serverUrl'] . '/message?token=' . $gotify['appToken']); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); curl_setopt($ch, CURLOPT_POSTFIELDS, $data_string); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt( $ch, CURLOPT_HTTPHEADER, array( 'Content-Type: application/json', 'Content-Length: ' . strlen($data_string) ) ); if ($gotify['ignore_ssl']) { curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); } $result = curl_exec($ch); if ($result === false) { echo "Error sending notifications: " . curl_error($ch) . "
"; } else { echo "Gotify Notifications sent
"; } } } } // Telegram notifications if enabled if ($telegramNotificationsEnabled) { foreach ($notify as $userId => $perUser) { // Get name of user from household table $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); if ($user['name']) { $message = $user['name'] . ", the following subscriptions are up for cancellation:\n"; } else { $message = "The following subscriptions are up for cancellation:\n"; } foreach ($perUser as $subscription) { $message .= $subscription['name'] . " for " . $subscription['price'] . "\n"; } $data = array( 'chat_id' => $telegram['chatId'], 'text' => $message ); $data_string = json_encode($data); $ch = curl_init('https://api.telegram.org/bot' . $telegram['botToken'] . '/sendMessage'); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); curl_setopt($ch, CURLOPT_POSTFIELDS, $data_string); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt( $ch, CURLOPT_HTTPHEADER, array( 'Content-Type: application/json', 'Content-Length: ' . strlen($data_string) ) ); $result = curl_exec($ch); if ($result === false) { echo "Error sending notifications: " . curl_error($ch) . "
"; } else { echo "Telegram Notifications sent
"; } } } // Pushover notifications if enabled if ($pushoverNotificationsEnabled) { foreach ($notify as $userId => $perUser) { // Get name of user from household table $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); if ($user['name']) { $message = $user['name'] . ", the following subscriptions are up for cancellation:\n"; } else { $message = "The following subscriptions are up for cancellation:\n"; } foreach ($perUser as $subscription) { $message .= $subscription['name'] . " for " . $subscription['price'] . "\n"; } $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, "https://api.pushover.net/1/messages.json"); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ 'token' => $pushover['token'], 'user' => $pushover['user_key'], 'message' => $message, ])); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $result = curl_exec($ch); curl_close($ch); if ($result === false) { echo "Error sending notifications: " . curl_error($ch) . "
"; } else { echo "Pushover Notifications sent
"; } } } // Ntfy notifications if enabled if ($ntfyNotificationsEnabled) { $ssrf = is_url_safe_for_ssrf($ntfy['host'], $db); if (!$ssrf) { echo "Ntfy notification skipped: URL failed SSRF validation.
"; } else { foreach ($notify as $userId => $perUser) { // Get name of user from household table $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); if ($user['name']) { $message = $user['name'] . ", the following subscriptions are up for cancellation:\n"; } else { $message = "The following subscriptions are up for cancellation:\n"; } foreach ($perUser as $subscription) { $message .= $subscription['name'] . " for " . $subscription['price'] . "\n"; } $headers = json_decode($ntfy["headers"], true); $customheaders = array_map(function ($key, $value) { return "$key: $value"; }, array_keys($headers), $headers); $ch = curl_init(); $ntfyHost = rtrim($ntfy["host"], '/'); $ntfyTopic = $ntfy['topic']; curl_setopt($ch, CURLOPT_URL, $ntfyHost . '/' . $ntfyTopic); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, $message); curl_setopt($ch, CURLOPT_HTTPHEADER, $customheaders); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); if ($ntfy['ignore_ssl']) { curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); } $response = curl_exec($ch); curl_close($ch); if ($response === false) { echo "Error sending notifications: " . curl_error($ch) . "
"; } else { echo "Ntfy Notifications sent
"; } } } } // Webhook notifications if enabled if ($webhookNotificationsEnabled) { $ssrf = is_url_safe_for_ssrf($webhook['url'], $db); if (!$ssrf) { echo "Webhook notification skipped: URL failed SSRF validation.
"; } else { foreach ($notify as $userId => $perUser) { // Get name of user from household table $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); if ($user['name']) { $payer = $user['name']; } foreach ($perUser as $subscription) { // Ensure the payload is reset for each subscription $payload = $webhook['cancelation_payload']; $payload = str_replace("{{subscription_name}}", $subscription['name'], $payload); $payload = str_replace("{{subscription_price}}", $subscription['price'], $payload); $payload = str_replace("{{subscription_currency}}", $subscription['currency'], $payload); $payload = str_replace("{{subscription_category}}", $subscription['category'], $payload); $payload = str_replace("{{subscription_payer}}", $payer, $payload); $payload = str_replace("{{subscription_date}}", $subscription['date'], $payload); $payload = str_replace("{{subscription_url}}", $subscription['url'], $payload); $payload = str_replace("{{subscription_notes}}", $subscription['notes'], $payload); // Initialize cURL for each subscription $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $webhook['url']); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $webhook['request_method']); curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); // Add headers if they exist if (!empty($webhook['headers'])) { $customheaders = preg_split("/\r\n|\n|\r/", $webhook['headers']); curl_setopt($ch, CURLOPT_HTTPHEADER, $customheaders); } curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Handle SSL settings if ($webhook['ignore_ssl']) { curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); } // Execute the cURL request $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($response === false || $httpCode >= 400) { echo "Error sending cancellation notifications: " . curl_error($ch) . "
"; } else { echo "Webhook Cancellation Notification sent for subscription: " . $subscription['name'] . "
"; } usleep(1000000); // 1s delay between requests } } } } } else { if (php_sapi_name() !== 'cli') { echo "Nothing to notify.
"; } } } } ?> ================================================ FILE: endpoints/cronjobs/sendnotifications.php ================================================ format('Y-m-d') . " " . $date->format('H:i:s') . "
\n"; } else { echo "On Timezone: " . date_default_timezone_get() . "

"; } // Get all user ids $query = "SELECT id, username FROM user"; $stmt = $db->prepare($query); $usersToNotify = $stmt->execute(); function getDaysText($days) { if ($days == 0) { return "Today"; } elseif ($days == 1) { return "Tomorrow"; } else { return "In " . $days . " days"; } } function formatPrice($price, $currencyCode, $currencySymbol) { $formattedPrice = CurrencyFormatter::format($price, $currencyCode); if (strpos($formattedPrice, $currencyCode) !== false) { $formattedPrice = str_replace($currencyCode, $currencySymbol . ' ', $formattedPrice); $formattedPrice = preg_replace('/\s+/', ' ', $formattedPrice); } return $formattedPrice; } while ($userToNotify = $usersToNotify->fetchArray(SQLITE3_ASSOC)) { $userId = $userToNotify['id']; if (php_sapi_name() !== 'cli') { echo "For user: " . $userToNotify['username'] . "

"; } $days = 1; $emailNotificationsEnabled = false; $gotifyNotificationsEnabled = false; $telegramNotificationsEnabled = false; $webhookNotificationsEnabled = false; $pushoverNotificationsEnabled = false; $pushplusNotificationsEnabled = false; $mattermostNotificationsEnabled = false; $discordNotificationsEnabled = false; $ntfyNotificationsEnabled = false; $serverchanNotificationsEnabled = false; // Get notification settings (how many days before the subscription ends should the notification be sent) $query = "SELECT days FROM notification_settings WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $days = $row['days']; } // Check if email notifications are enabled and get the settings $query = "SELECT * FROM email_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $emailNotificationsEnabled = $row['enabled']; $email['smtpAddress'] = $row["smtp_address"]; $email['smtpPort'] = $row["smtp_port"]; $email['encryption'] = $row["encryption"]; $email['smtpUsername'] = $row["smtp_username"]; $email['smtpPassword'] = $row["smtp_password"]; $email['fromEmail'] = $row["from_email"] ? $row["from_email"] : "wallos@wallosapp.com"; $email['otherEmails'] = $row["other_emails"]; } // Check if Discord notifications are enabled and get the settings $query = "SELECT * FROM discord_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $discordNotificationsEnabled = $row['enabled']; $discord['webhook_url'] = $row["webhook_url"]; $discord['bot_username'] = $row["bot_username"]; $discord['bot_avatar_url'] = $row["bot_avatar_url"]; } // Check if Gotify notifications are enabled and get the settings $query = "SELECT * FROM gotify_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $gotify = []; if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $gotifyNotificationsEnabled = $row['enabled']; $gotify['serverUrl'] = $row["url"]; $gotify['appToken'] = $row["token"]; $gotify['ignore_ssl'] = $row["ignore_ssl"]; } // Check if Telegram notifications are enabled and get the settings $query = "SELECT * FROM telegram_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $telegramNotificationsEnabled = $row['enabled']; $telegram['botToken'] = $row["bot_token"]; $telegram['chatId'] = $row["chat_id"]; } // Check if PushPlus notifications are enabled and get the settings $query = "SELECT * FROM pushplus_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $pushplusNotificationsEnabled = $row['enabled']; $pushplus['token'] = $row["token"]; } // Check if Mattermost notifications are enabled and get the settings $query = "SELECT * FROM mattermost_notifications WHERE user_id = :userID"; $stmt = $db->prepare($query); $stmt->bindValue(':userID', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $mattermostNotificationsEnabled = $row['enabled']; $mattermost['webhook_url'] = $row['webhook_url']; $mattermost['bot_username'] = $row['bot_username']; $mattermost['bot_icon_emoji'] = $row['bot_icon_emoji']; } // Check if Pushover notifications are enabled and get the settings $query = "SELECT * FROM pushover_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $pushoverNotificationsEnabled = $row['enabled']; $pushover['user_key'] = $row["user_key"]; $pushover['token'] = $row["token"]; } // Check if Ntfy notifications are enabled and get the settings $query = "SELECT * FROM ntfy_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $ntfyNotificationsEnabled = $row['enabled']; $ntfy['host'] = $row["host"]; $ntfy['topic'] = $row["topic"]; $ntfy['headers'] = $row["headers"]; $ntfy['ignore_ssl'] = $row["ignore_ssl"]; } // Check if Webhook notifications are enabled and get the settings $query = "SELECT * FROM webhook_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $webhookNotificationsEnabled = $row['enabled']; $webhook['url'] = $row["url"]; $webhook['request_method'] = $row["request_method"]; $webhook['headers'] = $row["headers"]; $webhook['payload'] = $row["payload"]; $webhook['ignore_ssl'] = $row["ignore_ssl"]; } // Check if Serverchan notifications are enabled and get the settings $query = "SELECT * FROM serverchan_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $serverchanNotificationsEnabled = $row['enabled']; $serverchan['sendkey'] = $row['sendkey']; } $notificationsEnabled = $emailNotificationsEnabled || $gotifyNotificationsEnabled || $telegramNotificationsEnabled || $webhookNotificationsEnabled || $pushoverNotificationsEnabled || $discordNotificationsEnabled || $pushplusNotificationsEnabled || $mattermostNotificationsEnabled || $ntfyNotificationsEnabled || $serverchanNotificationsEnabled; // If no notifications are enabled, no need to run if (!$notificationsEnabled) { if (php_sapi_name() !== 'cli') { echo "Notifications are disabled. No need to run.
"; } continue; } else { // Get all currencies $currencies = array(); $query = "SELECT * FROM currencies WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $currencies[$row['id']] = $row; } // Get all household members $query = "SELECT * FROM household WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $resultHousehold = $stmt->execute(); $household = []; while ($rowHousehold = $resultHousehold->fetchArray(SQLITE3_ASSOC)) { $household[$rowHousehold['id']] = $rowHousehold; } // Get all categories $query = "SELECT * FROM categories WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $resultCategories = $stmt->execute(); $categories = []; while ($rowCategory = $resultCategories->fetchArray(SQLITE3_ASSOC)) { $categories[$rowCategory['id']] = $rowCategory; } $query = "SELECT * FROM subscriptions WHERE user_id = :user_id AND notify = :notify AND inactive = :inactive ORDER BY payer_user_id ASC"; $stmt = $db->prepare($query); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->bindValue(':notify', 1, SQLITE3_INTEGER); $stmt->bindValue(':inactive', 0, SQLITE3_INTEGER); $resultSubscriptions = $stmt->execute(); $notify = []; $i = 0; $currentDate = new DateTime('now'); while ($rowSubscription = $resultSubscriptions->fetchArray(SQLITE3_ASSOC)) { if ($rowSubscription['notify_days_before'] !== -1) { $daysToCompare = $rowSubscription['notify_days_before']; } else { $daysToCompare = $days; } $nextPaymentDate = new DateTime($rowSubscription['next_payment']); $difference = $currentDate->diff($nextPaymentDate)->days; if ($nextPaymentDate > $currentDate) { $difference += 1; } if ($difference === $daysToCompare && $nextPaymentDate->format('Y-m-d') >= $currentDate->format('Y-m-d')) { echo "Subscription: " . $rowSubscription['name'] . "
"; echo "Next payment date: " . $nextPaymentDate->format('Y-m-d') . "
"; echo "Current date: " . $currentDate->format('Y-m-d') . "
"; echo "Difference: " . $difference . "

"; $notify[$rowSubscription['payer_user_id']][$i]['name'] = html_entity_decode($rowSubscription['name'], ENT_QUOTES, 'UTF-8'); $notify[$rowSubscription['payer_user_id']][$i]['price'] = $rowSubscription['price'] . $currencies[$rowSubscription['currency_id']]['symbol']; $notify[$rowSubscription['payer_user_id']][$i]['currency'] = $currencies[$rowSubscription['currency_id']]['name']; $notify[$rowSubscription['payer_user_id']][$i]['currency_symbol'] = $currencies[$rowSubscription['currency_id']]['symbol']; $notify[$rowSubscription['payer_user_id']][$i]['formatted_price'] = formatPrice($rowSubscription['price'], $currencies[$rowSubscription['currency_id']]['code'], $currencies[$rowSubscription['currency_id']]['symbol']); $notify[$rowSubscription['payer_user_id']][$i]['category'] = $categories[$rowSubscription['category_id']]['name']; $notify[$rowSubscription['payer_user_id']][$i]['payer'] = $household[$rowSubscription['payer_user_id']]['name']; $notify[$rowSubscription['payer_user_id']][$i]['date'] = $rowSubscription['next_payment']; $notify[$rowSubscription['payer_user_id']][$i]['days'] = $daysToCompare; $notify[$rowSubscription['payer_user_id']][$i]['url'] = $rowSubscription['url']; $notify[$rowSubscription['payer_user_id']][$i]['notes'] = $rowSubscription['notes']; $i++; } } if (!empty($notify)) { // Email notifications if enabled if ($emailNotificationsEnabled) { $stmt = $db->prepare('SELECT * FROM user WHERE id = :user_id'); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $defaultUser = $result->fetchArray(SQLITE3_ASSOC); $defaultEmail = $defaultUser['email']; $defaultName = $defaultUser['username']; foreach ($notify as $userId => $perUser) { $message = "The following subscriptions are up for renewal:\n"; foreach ($perUser as $subscription) { $dayText = getDaysText($subscription['days']); $message .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; } $smtpAuth = (isset($email["smtpUsername"]) && $email["smtpUsername"] != "") || (isset($email["smtpPassword"]) && $email["smtpPassword"] != ""); $mail = new PHPMailer(true); $mail->CharSet = "UTF-8"; $mail->isSMTP(); $mail->Host = $email['smtpAddress']; $mail->SMTPAuth = $smtpAuth; if ($smtpAuth) { $mail->Username = $email['smtpUsername']; $mail->Password = $email['smtpPassword']; } if ($email['encryption'] != "none") { $mail->SMTPSecure = $email['encryption']; } else { $mail->SMTPSecure = false; $mail->SMTPAutoTLS = false; } $mail->Port = $email['smtpPort']; $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); $emailaddress = !empty($user['email']) ? $user['email'] : $defaultEmail; $name = !empty($user['name']) ? $user['name'] : $defaultName; $mail->setFrom($email['fromEmail'], 'Wallos App'); $mail->addAddress($emailaddress, $name); if (!empty($email['otherEmails'])) { $list = explode(';', $email['otherEmails']); // Avoid duplicate emails $list = array_unique($list); $list = array_filter($list, function ($value) use ($emailaddress) { return $value !== $emailaddress; }); foreach ($list as $value) { $mail->addCC(trim($value)); } } $mail->Subject = 'Wallos Notification'; $mail->Body = $message; if ($mail->send()) { echo "Email Notifications sent
"; } else { echo "Error sending notifications: " . $mail->ErrorInfo . "
"; } } } // Discord notifications if enabled if ($discordNotificationsEnabled) { $ssrf = is_url_safe_for_ssrf($discord['webhook_url'], $db); if (!$ssrf) { echo "SSRF attempt detected for Discord webhook URL. Notifications not sent.
"; } else { foreach ($notify as $userId => $perUser) { // Get name of user from household table $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); $title = translate('wallos_notification', $i18n); if ($user['name']) { $message = $user['name'] . ", the following subscriptions are up for renewal:\n"; } else { $message = "The following subscriptions are up for renewal:\n"; } foreach ($perUser as $subscription) { $dayText = getDaysText($subscription['days']); $message .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; } $postfields = [ 'content' => $message ]; if (!empty($discord['bot_username'])) { $postfields['username'] = $discord['bot_username']; } if (!empty($discord['bot_avatar_url'])) { $postfields['avatar_url'] = $discord['bot_avatar_url']; } $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $discord['webhook_url']); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postfields)); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json' ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = curl_exec($ch); curl_close($ch); if ($result === false) { echo "Error sending notifications: " . curl_error($ch) . "
"; } else { echo "Discord Notifications sent
"; } } } } // Gotify notifications if enabled if ($gotifyNotificationsEnabled) { $ssrf = is_url_safe_for_ssrf($gotify['serverUrl'], $db); if (!$ssrf) { echo "SSRF attempt detected for Gotify server URL. Notifications not sent.
"; } else { foreach ($notify as $userId => $perUser) { // Get name of user from household table $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); if ($user['name']) { $message = $user['name'] . ", the following subscriptions are up for renewal:\n"; } else { $message = "The following subscriptions are up for renewal:\n"; } foreach ($perUser as $subscription) { $dayText = getDaysText($subscription['days']); $message .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; } $data = array( 'message' => $message, 'priority' => 5 ); $data_string = json_encode($data); $ch = curl_init($gotify['serverUrl'] . '/message?token=' . $gotify['appToken']); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); curl_setopt($ch, CURLOPT_POSTFIELDS, $data_string); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt( $ch, CURLOPT_HTTPHEADER, array( 'Content-Type: application/json', 'Content-Length: ' . strlen($data_string) ) ); if ($gotify['ignore_ssl']) { curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); } $result = curl_exec($ch); if ($result === false) { echo "Error sending notifications: " . curl_error($ch) . "
"; } else { echo "Gotify Notifications sent
"; } } } } // Telegram notifications if enabled if ($telegramNotificationsEnabled) { foreach ($notify as $userId => $perUser) { // Get name of user from household table $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); if ($user['name']) { $message = $user['name'] . ", the following subscriptions are up for renewal:\n"; } else { $message = "The following subscriptions are up for renewal:\n"; } foreach ($perUser as $subscription) { $dayText = getDaysText($subscription['days']); $message .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; } $data = array( 'chat_id' => $telegram['chatId'], 'text' => mb_convert_encoding($message, 'UTF-8', 'auto') ); $data_string = json_encode($data); $ch = curl_init('https://api.telegram.org/bot' . $telegram['botToken'] . '/sendMessage'); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); curl_setopt($ch, CURLOPT_POSTFIELDS, $data_string); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt( $ch, CURLOPT_HTTPHEADER, array( 'Content-Type: application/json', 'Content-Length: ' . strlen($data_string) ) ); $result = curl_exec($ch); if ($result === false) { echo "Error sending notifications: " . curl_error($ch) . "
"; } else { echo "Telegram Notifications sent
"; } } } // PushPlus notifications if enabled if ($pushplusNotificationsEnabled) { foreach ($notify as $userId => $perUser) { // Get name of user from household table $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // Build Message Content $messageContent = ""; if ($user['name']) { $messageContent = $user['name'] . ", the following subscriptions are up for renewal:\n"; } else { $messageContent = "The following subscriptions are up for renewal:\n"; } foreach ($perUser as $subscription) { $dayText = getDaysText($subscription['days']); $messageContent .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; } // Prepare PushPlus Data $data = array( 'token' => $pushplus['token'], 'title' => '订阅续期提醒 - Wallos', 'content' => mb_convert_encoding($messageContent, 'UTF-8', 'auto'), 'template' => 'json' ); $data_string = json_encode($data); $ch = curl_init('https://www.pushplus.plus/send'); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); curl_setopt($ch, CURLOPT_POSTFIELDS, $data_string); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt( $ch, CURLOPT_HTTPHEADER, array( 'Content-Type: application/json' ), ); $result = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($result === false) { echo "Error sending PushPlus notifications: " . curl_error($ch) . "
"; } else { $resultData = json_decode($result, true); if (isset($resultData['code']) && $resultData['code'] == 200) { echo "PushPlus Notifications sent successfully
"; } else { $errorMsg = isset($resultData['msg']) ? $resultData['msg'] : 'Unknown error'; echo "PushPlus API error: " . $errorMsg . "
"; } } curl_close($ch); } } // Mattermost notifications if enabled if ($mattermostNotificationsEnabled) { $ssrf = is_url_safe_for_ssrf($mattermost['webhook_url'], $db); if (!$ssrf) { echo "SSRF attempt detected for Mattermost webhook URL. Notifications not sent.
"; } else { foreach ($notify as $userId => $perUser) { // Get name of user from household table $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); // Build Message Content $messageContent = ""; if ($user['name']) { $messageContent = $user['name'] . ", the following subscriptions are up for renewal:\n"; } else { $messageContent = "The following subscriptions are up for renewal:\n"; } foreach ($perUser as $subscription) { $dayText = getDaysText($subscription['days']); $messageContent .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; } // Prepare Mattermost Data $webhook_url = $mattermost['webhook_url']; $data = array( 'username' => $mattermost['bot_username'], 'icon_emoji' => $mattermost['bot_icon_emoji'], 'text' => mb_convert_encoding($messageContent, 'UTF-8', 'auto'), ); $data_string = json_encode($data); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $webhook_url); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); curl_setopt($ch, CURLOPT_POSTFIELDS, $data_string); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt( $ch, CURLOPT_HTTPHEADER, array( 'Content-Type: application/json' ), ); $result = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($result === false) { echo "Error sending Mattermost notifications: " . curl_error($ch) . "
"; } else { $resultData = json_decode($result, true); if (isset($resultData['code']) && $resultData['code'] == 200) { echo "Mattermost Notifications sent successfully
"; } else { $errorMsg = isset($resultData['msg']) ? $resultData['msg'] : 'Unknown error'; echo "Mattermost API error: " . $errorMsg . "
"; } } curl_close($ch); } } } // Pushover notifications if enabled if ($pushoverNotificationsEnabled) { foreach ($notify as $userId => $perUser) { // Get name of user from household table $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); if ($user['name']) { $message = $user['name'] . ", the following subscriptions are up for renewal:\n"; } else { $message = "The following subscriptions are up for renewal:\n"; } foreach ($perUser as $subscription) { $dayText = getDaysText($subscription['days']); $message .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; } $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, "https://api.pushover.net/1/messages.json"); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ 'token' => $pushover['token'], 'user' => $pushover['user_key'], 'message' => $message, ])); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $result = curl_exec($ch); curl_close($ch); if ($result === false) { echo "Error sending notifications: " . curl_error($ch) . "
"; } else { echo "Pushover Notifications sent
"; } } } // Ntfy notifications if enabled if ($ntfyNotificationsEnabled) { $ssrf = is_url_safe_for_ssrf($ntfy['host'], $db); if (!$ssrf) { echo "SSRF attempt detected for Ntfy host URL. Notifications not sent.
"; } else { foreach ($notify as $userId => $perUser) { // Get name of user from household table $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); if ($user['name']) { $message = $user['name'] . ", the following subscriptions are up for renewal:\n"; } else { $message = "The following subscriptions are up for renewal:\n"; } foreach ($perUser as $subscription) { $dayText = getDaysText($subscription['days']); $message .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; } $headers = json_decode($ntfy["headers"], true); $customheaders = []; if (is_array($headers)) { $customheaders = array_map(function ($key, $value) { return "$key: $value"; }, array_keys($headers), $headers); } $ch = curl_init(); $ntfyHost = rtrim($ntfy["host"], '/'); $ntfyTopic = $ntfy['topic']; curl_setopt($ch, CURLOPT_URL, $ntfyHost . '/' . $ntfyTopic); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, $message); curl_setopt($ch, CURLOPT_HTTPHEADER, $customheaders); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); if ($ntfy['ignore_ssl']) { curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); } $response = curl_exec($ch); curl_close($ch); if ($response === false) { echo "Error sending notifications: " . curl_error($ch) . "
"; } else { echo "Ntfy Notifications sent
"; } } } } // Webhook notifications if enabled if ($webhookNotificationsEnabled) { $ssrf = is_url_safe_for_ssrf($webhook['url'], $db); if (!$ssrf) { echo "SSRF attempt detected for webhook URL. Notifications not sent.
";; } else { foreach ($notify as $userId => $perUser) { // Get name of user from household table $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); if ($user['name']) { $payer = $user['name']; } foreach ($perUser as $subscription) { // Ensure the payload is reset for each subscription $payload = $webhook['payload']; $payload = str_replace("{{days_until}}", $days, $payload); $payload = str_replace("{{subscription_name}}", $subscription['name'], $payload); $payload = str_replace("{{subscription_price}}", $subscription['formatted_price'], $payload); $payload = str_replace("{{subscription_currency}}", $subscription['currency'], $payload); $payload = str_replace("{{subscription_category}}", $subscription['category'], $payload); $payload = str_replace("{{subscription_payer}}", $payer, $payload); // Use $payer instead of $subscription['payer'] $payload = str_replace("{{subscription_date}}", $subscription['date'], $payload); $payload = str_replace("{{subscription_days_until_payment}}", $subscription['days'], $payload); $payload = str_replace("{{subscription_url}}", $subscription['url'], $payload); $payload = str_replace("{{subscription_notes}}", $subscription['notes'], $payload); // Initialize cURL for each subscription $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $webhook['url']); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $webhook['request_method']); curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); // Add headers if they exist if (!empty($webhook['headers'])) { $customheaders = json_decode($webhook["headers"], true); curl_setopt($ch, CURLOPT_HTTPHEADER, $customheaders); } curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Handle SSL settings if ($webhook['ignore_ssl']) { curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); } // Execute the cURL request $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($response === false || $httpCode >= 400) { echo "Error sending notifications: " . curl_error($ch) . "
"; } else { echo "Webhook Notification sent for subscription: " . $subscription['name'] . "
"; } usleep(1000000); // 1s delay between requests } } } } // Serverchan notifications if enabled if ($serverchanNotificationsEnabled) { foreach ($notify as $userId => $perUser) { // Get name of user from household table $stmt = $db->prepare('SELECT * FROM household WHERE id = :userId'); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); $title = 'Wallos Notification'; if ($user['name']) { $message = $user['name'] . ", the following subscriptions are up for renewal:\n"; } else { $message = "The following subscriptions are up for renewal:\n"; } foreach ($perUser as $subscription) { $dayText = getDaysText($subscription['days']); $message .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; } // Build Serverchan request $postdata = http_build_query(array('text' => $title, 'desp' => $message)); $sendkey = $serverchan['sendkey']; if (strpos($sendkey, 'sctp') === 0) { preg_match('/^sctp(\d+)t/', $sendkey, $matches); $num = $matches[1] ?? ''; $url = "https://{$num}.push.ft07.com/send/{$sendkey}.send"; } else { $url = "https://sctapi.ftqq.com/{$sendkey}.send"; } $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, $postdata); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/x-www-form-urlencoded' ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($response === false || $httpCode >= 400) { $errorMessage = $response === false ? curl_error($ch) : $httpCode; curl_close($ch); echo "Error sending Serverchan notifications: " . $errorMessage . "
"; } else { curl_close($ch); echo "Serverchan Notifications sent
"; } } } } else { if (php_sapi_name() !== 'cli') { echo "Nothing to notify.
"; } } } } ?> ================================================ FILE: endpoints/cronjobs/sendresetpasswordemails.php ================================================ prepare($query); $result = $stmt->execute(); $admin = $result->fetchArray(SQLITE3_ASSOC); $query = "SELECT * FROM password_resets WHERE email_sent = 0"; $stmt = $db->prepare($query); $result = $stmt->execute(); $rows = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $rows[] = $row; } if ($rows) { if ($admin['smtp_address'] && $admin['smtp_port'] && $admin['smtp_username'] && $admin['smtp_password'] && $admin['encryption']) { // There are SMTP settings $smtpAddress = $admin['smtp_address']; $smtpPort = $admin['smtp_port']; $smtpUsername = $admin['smtp_username']; $smtpPassword = $admin['smtp_password']; $fromEmail = empty($admin['from_email']) ? 'wallos@wallosapp.com' : $admin['from_email']; $encryption = $admin['encryption']; $server_url = $admin['server_url']; $smtpAuth = (isset($admin["smtp_username"]) && $admin["smtp_username"] != "") || (isset($admin["smtp_password"]) && $admin["smtp_password"] != ""); require __DIR__ . '/../../libs/PHPMailer/PHPMailer.php'; require __DIR__ . '/../../libs/PHPMailer/SMTP.php'; require __DIR__ . '/../../libs/PHPMailer/Exception.php'; $mail = new PHPMailer(true); $mail->isSMTP(); $mail->Host = $smtpAddress; $mail->SMTPAuth = $smtpAuth; if ($smtpAuth) { $mail->Username = $smtpUsername; $mail->Password = $smtpPassword; } if ($encryption != "none") { $mail->SMTPSecure = $encryption; } $mail->Port = $smtpPort; $mail->setFrom($fromEmail); try { foreach ($rows as $user) { $mail->addAddress($user['email']); $mail->isHTML(true); $mail->Subject = 'Wallos - Reset Password'; $mail->Body = 'Logo
A password reset was requested for your account.
Please click the following link to reset your password: Reset Password'; $mail->send(); $query = "UPDATE password_resets SET email_sent = 1 WHERE id = :id"; $stmt = $db->prepare($query); $stmt->bindParam(':id', $user['id'], SQLITE3_INTEGER); $stmt->execute(); $mail->clearAddresses(); echo "Password reset email sent to " . $user['email'] . "
"; } } catch (Exception $e) { echo "Message could not be sent. Mailer Error: {$mail->ErrorInfo}
"; } } else { // There are no SMTP settings if (php_sapi_name() !== 'cli') { echo "SMTP settings are not configured. Please configure SMTP settings in the admin page."; } exit(); } } else { // There are no password reset emails to be sent if (php_sapi_name() !== 'cli') { echo "There are no password reset emails to be sent."; } exit(); } ?> ================================================ FILE: endpoints/cronjobs/sendverificationemails.php ================================================ prepare($query); $result = $stmt->execute(); $admin = $result->fetchArray(SQLITE3_ASSOC); if ($admin['require_email_verification'] == 0) { if (php_sapi_name() !== 'cli') { echo "Email verification is not required."; } die(); } $query = "SELECT * FROM email_verification WHERE email_sent = 0"; $stmt = $db->prepare($query); $result = $stmt->execute(); $rows = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $rows[] = $row; } if ($rows) { if ($admin['smtp_address'] && $admin['smtp_port'] && $admin['smtp_username'] && $admin['smtp_password'] && $admin['encryption']) { // There are SMTP settings $smtpAddress = $admin['smtp_address']; $smtpPort = $admin['smtp_port']; $smtpUsername = $admin['smtp_username']; $smtpPassword = $admin['smtp_password']; $fromEmail = empty($admin['from_email']) ? 'wallos@wallosapp.com' : $admin['from_email']; $encryption = $admin['encryption']; $server_url = $admin['server_url']; $smtpAuth = (isset($admin["smtp_username"]) && $admin["smtp_username"] != "") || (isset($admin["smtp_password"]) && $admin["smtp_password"] != ""); require __DIR__ . '/../../libs/PHPMailer/PHPMailer.php'; require __DIR__ . '/../../libs/PHPMailer/SMTP.php'; require __DIR__ . '/../../libs/PHPMailer/Exception.php'; $mail = new PHPMailer(true); $mail->isSMTP(); $mail->Host = $smtpAddress; $mail->SMTPAuth = $smtpAuth; if ($smtpAuth) { $mail->Username = $smtpUsername; $mail->Password = $smtpPassword; } if ($encryption != "none") { $mail->SMTPSecure = $encryption; } $mail->Port = $smtpPort; $mail->setFrom($fromEmail); try { foreach ($rows as $user) { $mail->addAddress($user['email']); $mail->isHTML(true); $mail->Subject = 'Wallos - Email Verification'; $mail->Body = 'Logo
Registration on Wallos was successful.
Please click the following link to verify your email: Verify Email'; $mail->send(); $query = "UPDATE email_verification SET email_sent = 1 WHERE id = :id"; $stmt = $db->prepare($query); $stmt->bindParam(':id', $user['id'], SQLITE3_INTEGER); $stmt->execute(); $mail->clearAddresses(); echo "Verification email sent to " . $user['email'] . "
"; } } catch (Exception $e) { echo "Message could not be sent. Mailer Error: {$mail->ErrorInfo}"; } } else { // There are no SMTP settings if (php_sapi_name() !== 'cli') { echo "SMTP settings are not configured. Please configure SMTP settings in the admin page."; } exit(); } } else { // There are no verification emails to be sent if (php_sapi_name() !== 'cli') { echo "No verification emails to be sent."; } exit(); } ?> ================================================ FILE: endpoints/cronjobs/settimezone.php ================================================ format('Y-m-d') . " " . $date->format('H:i:s') . "
\n"; } $currentDate = new DateTime(); $currentDateString = $currentDate->format('Y-m-d'); function getPricePerMonth($cycle, $frequency, $price) { switch ($cycle) { case 1: $numberOfPaymentsPerMonth = (30 / $frequency); return $price * $numberOfPaymentsPerMonth; case 2: $numberOfPaymentsPerMonth = (4.35 / $frequency); return $price * $numberOfPaymentsPerMonth; case 3: $numberOfPaymentsPerMonth = (1 / $frequency); return $price * $numberOfPaymentsPerMonth; case 4: $numberOfMonths = (12 * $frequency); return $price / $numberOfMonths; } } function getPriceConverted($price, $currency, $database, $userId) { $query = "SELECT rate FROM currencies WHERE id = :currency AND user_id = :userId"; $stmt = $database->prepare($query); $stmt->bindParam(':currency', $currency, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $exchangeRate = $result->fetchArray(SQLITE3_ASSOC); if ($exchangeRate === false) { return $price; } else { $fromRate = $exchangeRate['rate']; return $price / $fromRate; } } // Get all users $query = "SELECT id, main_currency FROM user"; $stmt = $db->prepare($query); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $userId = $row['id']; $userCurrencyId = $row['main_currency']; $totalYearlyCost = 0; $query = "SELECT * FROM subscriptions WHERE user_id = :userId AND inactive = 0"; $stmt = $db->prepare($query); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $resultSubscriptions = $stmt->execute(); while ($rowSubscriptions = $resultSubscriptions->fetchArray(SQLITE3_ASSOC)) { $originalSubscriptionPrice = getPriceConverted($rowSubscriptions['price'], $rowSubscriptions['currency_id'], $db, $userId); $price = getPricePerMonth($rowSubscriptions['cycle'], $rowSubscriptions['frequency'], $originalSubscriptionPrice) * 12; $totalYearlyCost += $price; } $query = "INSERT INTO total_yearly_cost (user_id, date, cost, currency) VALUES (:userId, :date, :cost, :currency)"; $stmt = $db->prepare($query); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $stmt->bindParam(':date', $currentDateString, SQLITE3_TEXT); $stmt->bindParam(':cost', $totalYearlyCost, SQLITE3_FLOAT); $stmt->bindParam(':currency', $userCurrencyId, SQLITE3_INTEGER); if ($stmt->execute()) { echo "Inserted total yearly cost for user " . $userId . " with cost " . $totalYearlyCost . "
\n"; } else { echo "Error inserting total yearly cost for user " . $userId . "
\n"; } } ?> ================================================ FILE: endpoints/cronjobs/updateexchange.php ================================================ format('Y-m-d') . " " . $date->format('H:i:s') . "
\n"; } $query = "SELECT id, username FROM user"; $stmt = $db->prepare($query); $usersToUpdateExchange = $stmt->execute(); while ($userToUpdateExchange = $usersToUpdateExchange->fetchArray(SQLITE3_ASSOC)) { $userId = $userToUpdateExchange['id']; echo "For user: " . $userToUpdateExchange['username'] . "
"; $query = "SELECT api_key, provider FROM fixer WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { $row = $result->fetchArray(SQLITE3_ASSOC); if ($row) { $apiKey = $row['api_key']; $provider = $row['provider']; $codes = ""; $query = "SELECT id, name, symbol, code FROM currencies WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $codes .= $row['code'] . ","; } $codes = rtrim($codes, ','); $query = "SELECT u.main_currency, c.code FROM user u LEFT JOIN currencies c ON u.main_currency = c.id WHERE u.id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $mainCurrencyCode = $row['code']; $mainCurrencyId = $row['main_currency']; if ($provider === 1) { $api_url = "https://api.apilayer.com/fixer/latest?base=EUR&symbols=" . $codes; $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'header' => 'apikey: ' . $apiKey, ] ]); $response = file_get_contents($api_url, false, $context); } else { $api_url = "http://data.fixer.io/api/latest?access_key=" . $apiKey . "&base=EUR&symbols=" . $codes; $response = file_get_contents($api_url); } $apiData = json_decode($response, true); $mainCurrencyToEUR = $apiData['rates'][$mainCurrencyCode]; if ($apiData !== null && isset($apiData['rates'])) { foreach ($apiData['rates'] as $currencyCode => $rate) { if ($currencyCode === $mainCurrencyCode) { $exchangeRate = 1.0; } else { $exchangeRate = $rate / $mainCurrencyToEUR; } $updateQuery = "UPDATE currencies SET rate = :rate WHERE code = :code"; $updateStmt = $db->prepare($updateQuery); $updateStmt->bindParam(':rate', $exchangeRate, SQLITE3_TEXT); $updateStmt->bindParam(':code', $currencyCode, SQLITE3_TEXT); $updateResult = $updateStmt->execute(); if (!$updateResult) { echo "Error updating rate for currency: $currencyCode
"; } } $currentDate = new DateTime(); $formattedDate = $currentDate->format('Y-m-d'); $deleteQuery = "DELETE FROM last_exchange_update WHERE user_id = :userId"; $deleteStmt = $db->prepare($deleteQuery); $deleteStmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $deleteResult = $deleteStmt->execute(); $query = "INSERT INTO last_exchange_update (date, user_id) VALUES (:formattedDate, :userId)"; $stmt = $db->prepare($query); $stmt->bindParam(':formattedDate', $formattedDate, SQLITE3_TEXT); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); echo "Rates updated successfully!
"; } } else { echo "Exchange rates update skipped. No fixer.io api key provided
"; $apiKey = null; } } else { echo "Exchange rates update skipped. No fixer.io api key provided
"; $apiKey = null; } } $db->close(); ?> ================================================ FILE: endpoints/cronjobs/updatenextpayment.php ================================================ format('Y-m-d') . " " . $date->format('H:i:s') . "
\n"; echo $timezone . "
\n"; $currentDate = new DateTime(); $currentDateString = $currentDate->format('Y-m-d'); $cycles = array(); $query = "SELECT * FROM cycles"; $result = $db->query($query); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $cycleId = $row['id']; $cycles[$cycleId] = $row; } $query = "SELECT id, next_payment, frequency, cycle FROM subscriptions WHERE next_payment < :currentDate AND auto_renew = 1 AND inactive = 0"; $stmt = $db->prepare($query); $stmt->bindValue(':currentDate', $currentDate->format('Y-m-d')); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $subscriptionId = $row['id']; $nextPaymentDate = new DateTime($row['next_payment']); $frequency = $row['frequency']; $cycle = $cycles[$row['cycle']]['name']; // Calculate the interval to add based on the cycle $intervalSpec = "P"; if ($cycle == 'Daily') { $intervalSpec .= "{$frequency}D"; } elseif ($cycle === 'Weekly') { $intervalSpec .= "{$frequency}W"; } elseif ($cycle === 'Monthly') { $intervalSpec .= "{$frequency}M"; } elseif ($cycle === 'Yearly') { $intervalSpec .= "{$frequency}Y"; } $interval = new DateInterval($intervalSpec); // Add intervals until the next payment date is in the future while ($nextPaymentDate < $currentDate) { $nextPaymentDate->add($interval); } // Update the subscription's next_payment date $updateQuery = "UPDATE subscriptions SET next_payment = :nextPaymentDate WHERE id = :subscriptionId"; $updateStmt = $db->prepare($updateQuery); $updateStmt->bindValue(':nextPaymentDate', $nextPaymentDate->format('Y-m-d')); $updateStmt->bindValue(':subscriptionId', $subscriptionId); $updateStmt->execute(); } $formattedDate = $currentDate->format('Y-m-d'); $deleteQuery = "DELETE FROM last_update_next_payment_date"; $deleteStmt = $db->prepare($deleteQuery); $deleteResult = $deleteStmt->execute(); $query = "INSERT INTO last_update_next_payment_date (date) VALUES (:formattedDate)"; $stmt = $db->prepare($query); $stmt->bindParam(':formattedDate', $currentDateString, SQLITE3_TEXT); $result = $stmt->execute(); echo "Updated next payment dates"; ?> ================================================ FILE: endpoints/cronjobs/validate.php ================================================ ================================================ FILE: endpoints/currency/currency.php ================================================ false, "message" => translate('error', $i18n)]); break; } function handleAddCurrency($db, $userId, $i18n) { $currencyName = "Currency"; $currencySymbol = "$"; $currencyCode = "CODE"; $currencyRate = 1; $sqlInsert = "INSERT INTO currencies (name, symbol, code, rate, user_id) VALUES (:name, :symbol, :code, :rate, :userId)"; $stmtInsert = $db->prepare($sqlInsert); $stmtInsert->bindParam(':name', $currencyName, SQLITE3_TEXT); $stmtInsert->bindParam(':symbol', $currencySymbol, SQLITE3_TEXT); $stmtInsert->bindParam(':code', $currencyCode, SQLITE3_TEXT); $stmtInsert->bindParam(':rate', $currencyRate, SQLITE3_TEXT); $stmtInsert->bindParam(':userId', $userId, SQLITE3_INTEGER); $resultInsert = $stmtInsert->execute(); if ($resultInsert) { $currencyId = $db->lastInsertRowID(); echo json_encode(["success" => true, "currencyId" => $currencyId]); } else { echo translate('error_adding_currency', $i18n); } } function handleEditCurrency($db, $userId, $i18n) { if (isset($_POST['currencyId']) && $_POST['currencyId'] != "" && isset($_POST['name']) && $_POST['name'] != "" && isset($_POST['symbol']) && $_POST['symbol'] != "") { $currencyId = $_POST['currencyId']; $name = validate($_POST['name']); $symbol = validate($_POST['symbol']); $code = validate($_POST['code']); $sql = "UPDATE currencies SET name = :name, symbol = :symbol, code = :code WHERE id = :currencyId AND user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindParam(':name', $name, SQLITE3_TEXT); $stmt->bindParam(':symbol', $symbol, SQLITE3_TEXT); $stmt->bindParam(':code', $code, SQLITE3_TEXT); $stmt->bindParam(':currencyId', $currencyId, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { $response = [ "success" => true, "message" => $name . " " . translate('currency_saved', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('failed_to_store_currency', $i18n) ]; echo json_encode($response); } } else { $response = [ "success" => false, "message" => translate('fields_missing', $i18n) ]; echo json_encode($response); } } function handleDeleteCurrency($db, $userId, $i18n) { if (isset($_POST['currencyId']) && $_POST['currencyId'] != "") { $query = "SELECT main_currency FROM user WHERE id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $mainCurrencyId = $row['main_currency']; $currencyId = $_POST['currencyId']; $checkQuery = "SELECT COUNT(*) FROM subscriptions WHERE currency_id = :currencyId AND user_id = :userId"; $checkStmt = $db->prepare($checkQuery); $checkStmt->bindParam(':currencyId', $currencyId, SQLITE3_INTEGER); $checkStmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $checkResult = $checkStmt->execute(); $row = $checkResult->fetchArray(); $count = $row[0]; if ($count > 0) { $response = [ "success" => false, "message" => translate('currency_in_use', $i18n) ]; echo json_encode($response); exit; } else { if ($currencyId == $mainCurrencyId) { $response = [ "success" => false, "message" => translate('currency_is_main', $i18n) ]; echo json_encode($response); exit; } else { $sql = "DELETE FROM currencies WHERE id = :currencyId AND user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindParam(':currencyId', $currencyId, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { echo json_encode(["success" => true, "message" => translate('currency_removed', $i18n)]); } else { $response = [ "success" => false, "message" => translate('failed_to_remove_currency', $i18n) ]; echo json_encode($response); } } } } else { $response = [ "success" => false, "message" => translate('fields_missing', $i18n) ]; echo json_encode($response); } } ================================================ FILE: endpoints/currency/fixer_api_key.php ================================================ prepare($removeOldKey); $stmt->bindParam(":userId", $userId, SQLITE3_INTEGER); $stmt->execute(); if ($provider == 1) { $testKeyUrl = "https://api.apilayer.com/fixer/latest?base=USD&symbols=EUR"; $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'header' => 'apikey: ' . $newApiKey, ] ]); $response = file_get_contents($testKeyUrl, false, $context); } else { $testKeyUrl = "http://data.fixer.io/api/latest?access_key=$newApiKey"; $response = file_get_contents($testKeyUrl); } $apiData = json_decode($response, true); if ($apiData['success'] && $apiData['success'] == 1) { if (!empty($newApiKey)) { $insertNewKey = "INSERT INTO fixer (api_key, provider, user_id) VALUES (:api_key, :provider, :userId)"; $stmt = $db->prepare($insertNewKey); $stmt->bindParam(":api_key", $newApiKey, SQLITE3_TEXT); $stmt->bindParam(":provider", $provider, SQLITE3_INTEGER); $stmt->bindParam(":userId", $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { echo json_encode(["success" => true, "message" => translate('api_key_saved', $i18n)]); } else { $response = [ "success" => false, "message" => translate('failed_to_store_api_key', $i18n) ]; echo json_encode($response); } } else { echo json_encode(["success" => true, "message" => translate('apy_key_saved', $i18n)]); } } else { $response = [ "success" => false, "message" => translate('invalid_api_key', $i18n) ]; echo json_encode($response); } ================================================ FILE: endpoints/currency/update_exchange.php ================================================ prepare($query); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { $lastUpdateDate = new DateTime($result); $currentDate = new DateTime(); $lastUpdateDateString = $lastUpdateDate->format('Y-m-d'); $currentDateString = $currentDate->format('Y-m-d'); $shouldUpdate = $lastUpdateDateString < $currentDateString; } if (!$shouldUpdate) { echo "Rates are current, no need to update."; exit; } } $query = "SELECT api_key, provider FROM fixer"; $result = $db->query($query); if ($result) { $row = $result->fetchArray(SQLITE3_ASSOC); if ($row) { $apiKey = $row['api_key']; $provider = $row['provider']; $codes = ""; $query = "SELECT id, name, symbol, code FROM currencies WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $codes .= $row['code'] . ","; } $codes = rtrim($codes, ','); $query = "SELECT u.main_currency, c.code FROM user u LEFT JOIN currencies c ON u.main_currency = c.id WHERE u.id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $mainCurrencyCode = $row['code']; $mainCurrencyId = $row['main_currency']; if ($provider === 1) { $api_url = "https://api.apilayer.com/fixer/latest?base=EUR&symbols=" . $codes; $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'header' => 'apikey: ' . $apiKey, ] ]); $response = file_get_contents($api_url, false, $context); } else { $api_url = "http://data.fixer.io/api/latest?access_key=" . $apiKey . "&base=EUR&symbols=" . $codes; $response = file_get_contents($api_url); } $apiData = json_decode($response, true); $mainCurrencyToEUR = $apiData['rates'][$mainCurrencyCode]; if ($apiData !== null && isset($apiData['rates'])) { foreach ($apiData['rates'] as $currencyCode => $rate) { if ($currencyCode === $mainCurrencyCode) { $exchangeRate = 1.0; } else { $exchangeRate = $rate / $mainCurrencyToEUR; } $updateQuery = "UPDATE currencies SET rate = :rate WHERE code = :code AND user_id = :userId"; $updateStmt = $db->prepare($updateQuery); $updateStmt->bindParam(':rate', $exchangeRate, SQLITE3_TEXT); $updateStmt->bindParam(':code', $currencyCode, SQLITE3_TEXT); $updateStmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $updateResult = $updateStmt->execute(); if (!$updateResult) { echo "Error updating rate for currency: $currencyCode"; } } $currentDate = new DateTime(); $formattedDate = $currentDate->format('Y-m-d'); $updateQuery = "UPDATE last_exchange_update SET date = :formattedDate WHERE user_id = :userId"; $updateStmt = $db->prepare($updateQuery); $updateStmt->bindParam(':formattedDate', $formattedDate, SQLITE3_TEXT); $updateStmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $updateResult = $updateStmt->execute(); $db->close(); echo "Rates updated successfully!"; } } else { echo "Exchange rates update skipped. No fixer.io api key provided"; $apiKey = null; } } else { echo "Exchange rates update skipped. No fixer.io api key provided"; $apiKey = null; } ================================================ FILE: endpoints/db/backup.php ================================================ addEmptyDir($zipdir); while (($file = readdir($dh)) !== false) { // Skip '.' and '..' if ($file == "." || $file == "..") { continue; } //If it's a folder, run the function again! if (is_dir($dir . $file)) { $newdir = $dir . $file . '/'; addFolderToZip($newdir, $zipArchive, $zipdir . $file . '/'); } else { //Add the files $zipArchive->addFile($dir . $file, $zipdir . $file); } } } } else { die(json_encode([ "success" => false, "message" => "Directory does not exist: $dir" ])); } } $zip = new ZipArchive(); $filename = "backup_" . uniqid() . ".zip"; $zipname = "../../.tmp/" . $filename; if ($zip->open($zipname, ZipArchive::CREATE) !== TRUE) { die(json_encode([ "success" => false, "message" => translate('cannot_open_zip', $i18n) ])); } addFolderToZip('../../db/', $zip); addFolderToZip('../../images/uploads/', $zip); $numberOfFilesAdded = $zip->numFiles; if ($zip->close() === false) { die(json_encode([ "success" => false, "message" => "Failed to finalize the zip file" ])); } else { flush(); die(json_encode([ "success" => true, "message" => "Zip file created successfully", "numFiles" => $numberOfFilesAdded, "file" => $filename ])); } ================================================ FILE: endpoints/db/import.php ================================================ query("SELECT COUNT(*) as count FROM user"); $row = $result->fetchArray(SQLITE3_NUM); if ($row[0] > 0) { die(json_encode([ "success" => false, "message" => "Denied" ])); } function emptyRestoreFolder() { $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator('../../.tmp', RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($files as $fileinfo) { $removeFunction = ($fileinfo->isDir() ? 'rmdir' : 'unlink'); $removeFunction($fileinfo->getRealPath()); } } if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (isset($_FILES['file'])) { $file = $_FILES['file']; $fileTmpName = $file['tmp_name']; $fileError = $file['error']; if ($fileError === 0) { $fileDestination = '../../.tmp/restore.zip'; move_uploaded_file($fileTmpName, $fileDestination); $zip = new ZipArchive(); if ($zip->open($fileDestination) === true) { $zip->extractTo('../../.tmp/restore/'); $zip->close(); } else { die(json_encode([ "success" => false, "message" => "Failed to extract the uploaded file" ])); } if (file_exists('../../.tmp/restore/wallos.db')) { if (file_exists('../../db/wallos.db')) { unlink('../../db/wallos.db'); } rename('../../.tmp/restore/wallos.db', '../../db/wallos.db'); if (file_exists('../../.tmp/restore/logos/')) { $dir = '../../images/uploads/logos/'; $di = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS); $ri = new RecursiveIteratorIterator($di, RecursiveIteratorIterator::CHILD_FIRST); foreach ($ri as $file) { if ($file->isDir()) { rmdir($file->getPathname()); } else { unlink($file->getPathname()); } } $dir = new RecursiveDirectoryIterator('../../.tmp/restore/logos/'); $ite = new RecursiveIteratorIterator($dir); $allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp']; foreach ($ite as $filePath) { if (in_array(pathinfo($filePath, PATHINFO_EXTENSION), $allowedExtensions)) { $destination = str_replace('../../.tmp/restore/', '../../images/uploads/', $filePath); $destinationDir = pathinfo($destination, PATHINFO_DIRNAME); if (!is_dir($destinationDir)) { mkdir($destinationDir, 0755, true); } copy($filePath, $destination); } } } emptyRestoreFolder(); echo json_encode([ "success" => true, "message" => translate("success", $i18n) ]); } else { emptyRestoreFolder(); die(json_encode([ "success" => false, "message" => "wallos.db does not exist in the backup file" ])); } } else { echo json_encode([ "success" => false, "message" => "Failed to upload file" ]); } } else { echo json_encode([ "success" => false, "message" => "No file uploaded" ]); } } else { echo json_encode([ "success" => false, "message" => "Invalid request method" ]); } ?> ================================================ FILE: endpoints/db/migrate.php ================================================ query('SELECT name FROM sqlite_master WHERE type="table" AND name="migrations"') ->fetchArray(SQLITE3_ASSOC) !== false; if ($migrationTableExists) { $migrationQuery = $db->query('SELECT migration FROM migrations'); while ($row = $migrationQuery->fetchArray(SQLITE3_ASSOC)) { $completedMigrations[] = $row['migration']; } } $allMigrations = glob('migrations/*.php'); if (count($allMigrations) == 0) { $allMigrations = glob('../../migrations/*.php'); } $allMigrations = array_map(function ($migration) { return str_replace('../../', '', $migration); }, $allMigrations); $completedMigrations = array_map(function ($migration) { return str_replace('../../', '', $migration); }, $completedMigrations); $requiredMigrations = array_diff($allMigrations, $completedMigrations); if (count($requiredMigrations) === 0) { echo "No migrations to run.\n"; } foreach ($requiredMigrations as $migration) { if (!file_exists($migration)) { $migration = '../../' . $migration; } require_once $migration; $stmtInsert = $db->prepare('INSERT INTO migrations (migration) VALUES (:migration)'); $stmtInsert->bindParam(':migration', $migration, SQLITE3_TEXT); $stmtInsert->execute(); echo sprintf("Migration %s completed successfully.\n", $migration); } ================================================ FILE: endpoints/db/restore.php ================================================ isDir() ? 'rmdir' : 'unlink'); $removeFunction($fileinfo->getRealPath()); } } if (isset($_FILES['file'])) { $file = $_FILES['file']; $fileTmpName = $file['tmp_name']; $fileError = $file['error']; if ($fileError === 0) { $fileDestination = '../../.tmp/restore.zip'; move_uploaded_file($fileTmpName, $fileDestination); $zip = new ZipArchive(); if ($zip->open($fileDestination) === true) { $zip->extractTo('../../.tmp/restore/'); $zip->close(); } else { die(json_encode([ "success" => false, "message" => "Failed to extract the uploaded file" ])); } if (file_exists('../../.tmp/restore/wallos.db')) { if (file_exists('../../db/wallos.db')) { unlink('../../db/wallos.db'); } rename('../../.tmp/restore/wallos.db', '../../db/wallos.db'); if (file_exists('../../.tmp/restore/logos/')) { $dir = '../../images/uploads/logos/'; $di = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS); $ri = new RecursiveIteratorIterator($di, RecursiveIteratorIterator::CHILD_FIRST); foreach ($ri as $file) { if ($file->isDir()) { rmdir($file->getPathname()); } else { unlink($file->getPathname()); } } $dir = new RecursiveDirectoryIterator('../../.tmp/restore/logos/'); $ite = new RecursiveIteratorIterator($dir); $allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp']; foreach ($ite as $filePath) { if (in_array(pathinfo($filePath, PATHINFO_EXTENSION), $allowedExtensions)) { $destination = str_replace('../../.tmp/restore/', '../../images/uploads/', $filePath); $destinationDir = pathinfo($destination, PATHINFO_DIRNAME); if (!is_dir($destinationDir)) { mkdir($destinationDir, 0755, true); } copy($filePath, $destination); } } } emptyRestoreFolder(); echo json_encode([ "success" => true, "message" => translate("success", $i18n) ]); } else { emptyRestoreFolder(); die(json_encode([ "success" => false, "message" => "wallos.db does not exist in the backup file" ])); } } else { echo json_encode([ "success" => false, "message" => "Failed to upload file" ]); } } else { echo json_encode([ "success" => false, "message" => "No file uploaded" ]); } ================================================ FILE: endpoints/household/household.php ================================================ prepare($sqlInsert); $stmtInsert->bindParam(':name', $householdName, SQLITE3_TEXT); $stmtInsert->bindParam(':userId', $userId, SQLITE3_INTEGER); $resultInsert = $stmtInsert->execute(); if ($resultInsert) { $householdId = $db->lastInsertRowID(); $response = [ "success" => true, "householdId" => $householdId, ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('failed_add_household', $i18n) ]; echo json_encode($response); } } function handleEditMember($db, $userId, $i18n) { if (isset($_POST['memberId']) && $_POST['memberId'] != "" && isset($_POST['name']) && $_POST['name'] != "") { $memberId = $_POST['memberId']; $name = validate($_POST['name']); $email = $_POST['email'] ? $_POST['email'] : ""; $email = validate($email); $sql = "UPDATE household SET name = :name, email = :email WHERE id = :memberId AND user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindParam(':name', $name, SQLITE3_TEXT); $stmt->bindParam(':email', $email, SQLITE3_TEXT); $stmt->bindParam(':memberId', $memberId, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { $response = [ "success" => true, "message" => translate('member_saved', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('failed_edit_household', $i18n) ]; echo json_encode($response); } } else { $response = [ "success" => false, "message" => translate('fill_all_fields', $i18n) ]; echo json_encode($response); } } function handleDeleteMember($db, $userId, $i18n) { if (isset($_POST['memberId']) && $_POST['memberId'] != "" && $_POST['memberId'] != 1) { $memberId = $_POST['memberId']; $checkMember = "SELECT COUNT(*) FROM subscriptions WHERE payer_user_id = :memberId AND user_id = :userId"; $checkStmt = $db->prepare($checkMember); $checkStmt->bindParam(':memberId', $memberId, SQLITE3_INTEGER); $checkStmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $checkResult = $checkStmt->execute(); $row = $checkResult->fetchArray(); $count = $row[0]; if ($count > 0) { $response = [ "success" => false, "message" => translate('household_in_use', $i18n) ]; echo json_encode($response); } else { $sql = "DELETE FROM household WHERE id = :memberId and user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindParam(':memberId', $memberId, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { $response = [ "success" => true, "message" => translate('member_removed', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('failed_remove_household', $i18n) ]; echo json_encode($response); } } } else { $response = [ "success" => false, "message" => translate('failed_remove_household', $i18n) ]; echo json_encode($response); } } ?> ================================================ FILE: endpoints/logos/search.php ================================================ 'us-en', 'o' => 'json', 'q' => urldecode($query), 'vqd' => $vqd, 'f' => ',,transparent,Wide,', 'p' => '1', ]); $response = curlGet("https://duckduckgo.com/i.js?{$params}", [ 'Accept: application/json', 'Referer: https://duckduckgo.com/', ]); if (!$response) return null; $data = json_decode($response, true); if (!isset($data['results']) || empty($data['results'])) return null; return array_column($data['results'], 'image'); } function fetchBraveImages($query) { $url = "https://search.brave.com/images?q={$query}"; $html = curlGet($url, [ 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language: en-US,en;q=0.5', 'Referer: https://search.brave.com/', ]); if (!$html) return null; $doc = new DOMDocument(); @$doc->loadHTML($html); $imageUrls = []; $imgTags = $doc->getElementsByTagName('img'); foreach ($imgTags as $imgTag) { $src = $imgTag->getAttribute('src'); $class = $imgTag->getAttribute('class'); if (str_contains($class, 'favicon') || str_contains($class, 'logo')) continue; if (!filter_var($src, FILTER_VALIDATE_URL)) continue; if (str_contains($src, 'cdn.search.brave.com')) continue; // filter Brave UI assets $imageUrls[] = $src; } return !empty($imageUrls) ? $imageUrls : null; } // --- Main flow --- // Try DuckDuckGo first $vqd = getVqdToken($searchTerm); $imageUrls = $vqd ? fetchDDGImages($searchTerm, $vqd) : null; // Fall back to Brave if DDG failed at any step if (!$imageUrls) { $imageUrls = fetchBraveImages($searchTerm); } header('Content-Type: application/json'); if ($imageUrls) { echo json_encode(['imageUrls' => $imageUrls]); } else { echo json_encode(['error' => 'Failed to fetch images from both DuckDuckGo and Brave.']); } } else { echo json_encode(['error' => 'Invalid request.']); } ?> ================================================ FILE: endpoints/notifications/savediscordnotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { $enabled = $data["enabled"]; $webhook_url = $data["url"]; $bot_username = $data["bot_username"]; $bot_avatar_url = $data["bot_avatar"]; validate_webhook_url_for_ssrf($webhook_url, $db, $i18n); $query = "SELECT COUNT(*) FROM discord_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(":userId", $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result === false) { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } else { $row = $result->fetchArray(); $count = $row[0]; if ($count == 0) { $query = "INSERT INTO discord_notifications (enabled, webhook_url, bot_username, bot_avatar_url, user_id) VALUES (:enabled, :webhook_url, :bot_username, :bot_avatar_url, :userId)"; } else { $query = "UPDATE discord_notifications SET enabled = :enabled, webhook_url = :webhook_url, bot_username = :bot_username, bot_avatar_url = :bot_avatar_url WHERE user_id = :userId"; } $stmt = $db->prepare($query); $stmt->bindValue(':enabled', $enabled, SQLITE3_INTEGER); $stmt->bindValue(':webhook_url', $webhook_url, SQLITE3_TEXT); $stmt->bindValue(':bot_username', $bot_username, SQLITE3_TEXT); $stmt->bindValue(':bot_avatar_url', $bot_avatar_url, SQLITE3_TEXT); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { $response = [ "success" => true, "message" => translate('notifications_settings_saved', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } } } ================================================ FILE: endpoints/notifications/saveemailnotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { $enabled = $data["enabled"]; $smtpAddress = $data["smtpaddress"]; $smtpPort = $data["smtpport"]; $encryption = "tls"; if (isset($data["encryption"])) { $encryption = $data["encryption"]; } $smtpUsername = $data["smtpusername"]; $smtpPassword = $data["smtppassword"]; $fromEmail = $data["fromemail"]; $otherEmails = $data["otheremails"]; $query = "SELECT COUNT(*) FROM email_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(":userId", $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result === false) { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } else { $row = $result->fetchArray(); $count = $row[0]; if ($count == 0) { $query = "INSERT INTO email_notifications (enabled, smtp_address, smtp_port, smtp_username, smtp_password, from_email, other_emails, encryption, user_id) VALUES (:enabled, :smtpAddress, :smtpPort, :smtpUsername, :smtpPassword, :fromEmail, :otherEmails, :encryption, :userId)"; } else { $query = "UPDATE email_notifications SET enabled = :enabled, smtp_address = :smtpAddress, smtp_port = :smtpPort, smtp_username = :smtpUsername, smtp_password = :smtpPassword, from_email = :fromEmail, other_emails = :otherEmails, encryption = :encryption WHERE user_id = :userId"; } $stmt = $db->prepare($query); $stmt->bindValue(':enabled', $enabled, SQLITE3_INTEGER); $stmt->bindValue(':smtpAddress', $smtpAddress, SQLITE3_TEXT); $stmt->bindValue(':smtpPort', $smtpPort, SQLITE3_INTEGER); $stmt->bindValue(':smtpUsername', $smtpUsername, SQLITE3_TEXT); $stmt->bindValue(':smtpPassword', $smtpPassword, SQLITE3_TEXT); $stmt->bindValue(':fromEmail', $fromEmail, SQLITE3_TEXT); $stmt->bindValue(':otherEmails', $otherEmails, SQLITE3_TEXT); $stmt->bindValue(':encryption', $encryption, SQLITE3_TEXT); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { $response = [ "success" => true, "message" => translate('notifications_settings_saved', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } } } ================================================ FILE: endpoints/notifications/savegotifynotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { $enabled = $data["enabled"]; $url = $data["gotify_url"]; $token = $data["token"]; $ignore_ssl = $data["ignore_ssl"]; // Validate URL scheme $parsedUrl = parse_url($url); if ( !isset($parsedUrl['scheme']) || !in_array(strtolower($parsedUrl['scheme']), ['http', 'https']) || !filter_var($url, FILTER_VALIDATE_URL) ) { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } validate_webhook_url_for_ssrf($url, $db, $i18n); $query = "SELECT COUNT(*) FROM gotify_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(":userId", $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result === false) { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } else { $row = $result->fetchArray(); $count = $row[0]; if ($count == 0) { $query = "INSERT INTO gotify_notifications (enabled, url, token, user_id, ignore_ssl) VALUES (:enabled, :url, :token, :userId, :ignore_ssl)"; } else { $query = "UPDATE gotify_notifications SET enabled = :enabled, url = :url, token = :token, ignore_ssl = :ignore_ssl WHERE user_id = :userId"; } $stmt = $db->prepare($query); $stmt->bindValue(':enabled', $enabled, SQLITE3_INTEGER); $stmt->bindValue(':url', $url, SQLITE3_TEXT); $stmt->bindValue(':token', $token, SQLITE3_TEXT); $stmt->bindValue(':ignore_ssl', $ignore_ssl, SQLITE3_INTEGER); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { $response = [ "success" => true, "message" => translate('notifications_settings_saved', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } } } ================================================ FILE: endpoints/notifications/savemattermostnotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { $enabled = $data["enabled"]; $webhook_url = $data["webhook_url"]; $bot_username = $data["bot_username"]; $bot_iconemoji = $data["bot_icon_emoji"]; $parsedUrl = parse_url($webhook_url); if ( !isset($parsedUrl['scheme']) || !in_array(strtolower($parsedUrl['scheme']), ['http', 'https']) || !filter_var($webhook_url, FILTER_VALIDATE_URL) ) { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } validate_webhook_url_for_ssrf($webhook_url, $db, $i18n); $query = "SELECT COUNT(*) FROM mattermost_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(":userId", $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result === false) { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } else { $row = $result->fetchArray(); $count = $row[0]; if ($count == 0) { $query = "INSERT INTO mattermost_notifications (enabled, webhook_url, user_id, bot_username, bot_icon_emoji) VALUES (:enabled, :webhook_url, :userId, :bot_username, :bot_icon_emoji)"; } else { $query = "UPDATE mattermost_notifications SET enabled = :enabled, webhook_url = :webhook_url WHERE user_id = :userId"; } $stmt = $db->prepare($query); $stmt->bindValue(':enabled', $enabled, SQLITE3_INTEGER); $stmt->bindValue(':webhook_url', $webhook_url, SQLITE3_TEXT); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $stmt->bindValue(':bot_username', $bot_username, SQLITE3_TEXT); $stmt->bindValue(':bot_icon_emoji', $bot_iconemoji, SQLITE3_TEXT); if ($stmt->execute()) { $response = [ "success" => true, "message" => translate('notifications_settings_saved', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } } } ================================================ FILE: endpoints/notifications/savenotificationsettings.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { $days = $data["days"]; $query = "SELECT COUNT(*) FROM notification_settings WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(":userId", $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result === false) { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } else { $row = $result->fetchArray(); $count = $row[0]; if ($count == 0) { $query = "INSERT INTO notification_settings (days, user_id) VALUES (:days, :userId)"; } else { $query = "UPDATE notification_settings SET days = :days WHERE user_id = :userId"; } $stmt = $db->prepare($query); $stmt->bindValue(':days', $days, SQLITE3_INTEGER); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { $response = [ "success" => true, "message" => translate('notifications_settings_saved', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } } } ================================================ FILE: endpoints/notifications/saventfynotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { $enabled = $data["enabled"]; $host = $data["host"]; $topic = $data["topic"]; $headers = $data["headers"]; $ignore_ssl = $data["ignore_ssl"]; $url = rtrim($host, '/') . '/' . ltrim($topic, '/'); // Validate URL scheme $parsedUrl = parse_url($url); if ( !isset($parsedUrl['scheme']) || !in_array(strtolower($parsedUrl['scheme']), ['http', 'https']) || !filter_var($url, FILTER_VALIDATE_URL) ) { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } validate_webhook_url_for_ssrf($url, $db, $i18n); $query = "SELECT COUNT(*) FROM ntfy_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(":userId", $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result === false) { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } else { $row = $result->fetchArray(); $count = $row[0]; if ($count == 0) { $query = "INSERT INTO ntfy_notifications (enabled, host, topic, headers, user_id, ignore_ssl) VALUES (:enabled, :host, :topic, :headers, :userId, :ignore_ssl)"; } else { $query = "UPDATE ntfy_notifications SET enabled = :enabled, host = :host, topic = :topic, headers = :headers, ignore_ssl = :ignore_ssl WHERE user_id = :userId"; } $stmt = $db->prepare($query); $stmt->bindValue(':enabled', $enabled, SQLITE3_INTEGER); $stmt->bindValue(':host', $host, SQLITE3_TEXT); $stmt->bindValue(':topic', $topic, SQLITE3_TEXT); $stmt->bindValue(':headers', $headers, SQLITE3_TEXT); $stmt->bindValue(':ignore_ssl', $ignore_ssl, SQLITE3_INTEGER); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { $response = [ "success" => true, "message" => translate('notifications_settings_saved', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } } } ================================================ FILE: endpoints/notifications/savepushovernotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { $enabled = $data["enabled"]; $user_key = $data["user_key"]; $token = $data["token"]; $query = "SELECT COUNT(*) FROM pushover_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(":userId", $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result === false) { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } else { $row = $result->fetchArray(); $count = $row[0]; if ($count == 0) { $query = "INSERT INTO pushover_notifications (enabled, user_key, token, user_id) VALUES (:enabled, :user_key, :token, :userId)"; } else { $query = "UPDATE pushover_notifications SET enabled = :enabled, user_key = :user_key, token = :token, user_id = :userId"; } $stmt = $db->prepare($query); $stmt->bindValue(':enabled', $enabled, SQLITE3_INTEGER); $stmt->bindValue(':user_key', $user_key, SQLITE3_TEXT); $stmt->bindValue(':token', $token, SQLITE3_TEXT); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { $response = [ "success" => true, "message" => translate('notifications_settings_saved', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } } } ================================================ FILE: endpoints/notifications/savepushplusnotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { $enabled = $data["enabled"]; $token = $data["token"]; $query = "SELECT COUNT(*) FROM pushplus_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(":userId", $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result === false) { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } else { $row = $result->fetchArray(); $count = $row[0]; if ($count == 0) { $query = "INSERT INTO pushplus_notifications (enabled, token, user_id) VALUES (:enabled, :token, :userId)"; } else { $query = "UPDATE pushplus_notifications SET enabled = :enabled, token = :token WHERE user_id = :userId"; } $stmt = $db->prepare($query); $stmt->bindValue(':enabled', $enabled, SQLITE3_INTEGER); $stmt->bindValue(':token', $token, SQLITE3_TEXT); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { $response = [ "success" => true, "message" => translate('notifications_settings_saved', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } } } ================================================ FILE: endpoints/notifications/saveserverchannotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { $enabled = $data["enabled"]; $sendkey = $data["sendkey"]; $query = "SELECT COUNT(*) FROM serverchan_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(":userId", $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result === false) { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } else { $row = $result->fetchArray(); $count = $row[0]; if ($count == 0) { $query = "INSERT INTO serverchan_notifications (enabled, sendkey, user_id) VALUES (:enabled, :sendkey, :userId)"; } else { $query = "UPDATE serverchan_notifications SET enabled = :enabled, sendkey = :sendkey WHERE user_id = :userId"; } $stmt = $db->prepare($query); $stmt->bindValue(':enabled', $enabled, SQLITE3_INTEGER); $stmt->bindValue(':sendkey', $sendkey, SQLITE3_TEXT); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { $response = [ "success" => true, "message" => translate('notifications_settings_saved', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } } } ================================================ FILE: endpoints/notifications/savetelegramnotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { $enabled = $data["enabled"]; $bot_token = $data["bot_token"]; $chat_id = $data["chat_id"]; $query = "SELECT COUNT(*) FROM telegram_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(":userId", $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result === false) { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } else { $row = $result->fetchArray(); $count = $row[0]; if ($count == 0) { $query = "INSERT INTO telegram_notifications (enabled, bot_token, chat_id, user_id) VALUES (:enabled, :bot_token, :chat_id, :userId)"; } else { $query = "UPDATE telegram_notifications SET enabled = :enabled, bot_token = :bot_token, chat_id = :chat_id WHERE user_id = :userId"; } $stmt = $db->prepare($query); $stmt->bindValue(':enabled', $enabled, SQLITE3_INTEGER); $stmt->bindValue(':bot_token', $bot_token, SQLITE3_TEXT); $stmt->bindValue(':chat_id', $chat_id, SQLITE3_TEXT); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { $response = [ "success" => true, "message" => translate('notifications_settings_saved', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } } } ================================================ FILE: endpoints/notifications/savewebhooknotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { $enabled = $data["enabled"]; $url = $data["webhook_url"]; $headers = $data["headers"]; $payload = $data["payload"]; $cancelation_payload = $data["cancelation_payload"]; $ignore_ssl = $data["ignore_ssl"]; // Validate URL scheme $parsedUrl = parse_url($url); if ( !isset($parsedUrl['scheme']) || !in_array(strtolower($parsedUrl['scheme']), ['http', 'https']) || !filter_var($url, FILTER_VALIDATE_URL) ) { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } validate_webhook_url_for_ssrf($url, $db, $i18n); $query = "SELECT COUNT(*) FROM webhook_notifications WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(":userId", $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result === false) { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } else { $row = $result->fetchArray(); $count = $row[0]; if ($count == 0) { $query = "INSERT INTO webhook_notifications (enabled, url, headers, payload, cancelation_payload, user_id, ignore_ssl) VALUES (:enabled, :url, :headers, :payload, :cancelation_payload, :userId, :ignore_ssl)"; } else { $query = "UPDATE webhook_notifications SET enabled = :enabled, url = :url, headers = :headers, payload = :payload, cancelation_payload = :cancelation_payload, ignore_ssl = :ignore_ssl WHERE user_id = :userId"; } $stmt = $db->prepare($query); $stmt->bindValue(':enabled', $enabled, SQLITE3_INTEGER); $stmt->bindValue(':url', $url, SQLITE3_TEXT); $stmt->bindValue(':headers', $headers, SQLITE3_TEXT); $stmt->bindValue(':payload', $payload, SQLITE3_TEXT); $stmt->bindValue(':cancelation_payload', $cancelation_payload, SQLITE3_TEXT); $stmt->bindValue(':ignore_ssl', $ignore_ssl, SQLITE3_INTEGER); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { $response = [ "success" => true, "message" => translate('notifications_settings_saved', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('error_saving_notifications', $i18n) ]; echo json_encode($response); } } } ================================================ FILE: endpoints/notifications/testdiscordnotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { // Set the message parameters $title = translate('wallos_notification', $i18n); $message = translate('test_notification', $i18n); $webhook_url = $data["url"]; $bot_username = $data["bot_username"]; $bot_avatar_url = $data["bot_avatar"]; // Validate URL scheme $parsedUrl = parse_url($webhook_url); if ( !isset($parsedUrl['scheme']) || !in_array(strtolower($parsedUrl['scheme']), ['http', 'https']) || !filter_var($webhook_url, FILTER_VALIDATE_URL) ) { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } $ssrf = validate_webhook_url_for_ssrf($webhook_url, $db, $i18n); $postfields = [ 'content' => $message, 'embeds' => [ [ 'title' => $title, 'description' => $message, 'color' => hexdec("FF0000") ] ] ]; if (!empty($bot_username)) { $postfields['username'] = $bot_username; } if (!empty($bot_avatar_url)) { $postfields['avatar_url'] = $bot_avatar_url; } $ch = curl_init(); // Set the URL and other options curl_setopt($ch, CURLOPT_URL, $webhook_url); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); curl_setopt($ch, CURLOPT_RESOLVE, ["{$ssrf['host']}:{$ssrf['port']}:{$ssrf['ip']}"]); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postfields)); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json' ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Execute the request $response = curl_exec($ch); // Close the cURL session curl_close($ch); // Check if the message was sent successfully if ($response === false) { die(json_encode([ "success" => false, "message" => translate('notification_failed', $i18n) ])); } else { die(json_encode([ "success" => true, "message" => translate('notification_sent_successfuly', $i18n) ])); } } ================================================ FILE: endpoints/notifications/testemailnotifications.php ================================================ false, "message" => translate('fill_all_fields', $i18n) ]; die(json_encode($response)); } else { $encryption = "none"; if (isset($data["encryption"])) { $encryption = $data["encryption"]; } $smtpAuth = (isset($data["smtpusername"]) && $data["smtpusername"] != "") || (isset($data["smtppassword"]) && $data["smtppassword"] != ""); require '../../libs/PHPMailer/PHPMailer.php'; require '../../libs/PHPMailer/SMTP.php'; require '../../libs/PHPMailer/Exception.php'; $smtpAddress = $data["smtpaddress"]; $smtpPort = $data["smtpport"]; $smtpUsername = $data["smtpusername"]; $smtpPassword = $data["smtppassword"]; $fromEmail = $data["fromemail"] ? $data['fromemail'] : "wallos@wallosapp.com"; $mail = new PHPMailer(true); $mail->CharSet = "UTF-8"; $mail->isSMTP(); $mail->Host = $smtpAddress; $mail->SMTPAuth = $smtpAuth; if ($smtpAuth) { $mail->Username = $smtpUsername; $mail->Password = $smtpPassword; } if ($encryption != "none") { $mail->SMTPSecure = $encryption; } else { $mail->SMTPSecure = false; $mail->SMTPAutoTLS = false; } $mail->Port = $smtpPort; $getUser = "SELECT * FROM user WHERE id = $userId"; $user = $db->querySingle($getUser, true); $email = $user['email']; $name = $user['username']; $mail->setFrom($fromEmail, 'Wallos App'); $mail->addAddress($email, $name); $mail->Subject = translate('wallos_notification', $i18n); $mail->Body = translate('test_notification', $i18n); try { if ($mail->send()) { $response = [ "success" => true, "message" => translate('notification_sent_successfuly', $i18n) ]; } else { $response = [ "success" => false, "message" => translate('email_error', $i18n) . $mail->ErrorInfo ]; } } catch (Exception $e) { $response = [ "success" => false, "message" => translate('email_error', $i18n) . $e->getMessage() ]; } die(json_encode($response)); } ================================================ FILE: endpoints/notifications/testgotifynotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; die(json_encode($response)); } else { // Set the message parameters $title = translate('wallos_notification', $i18n); $message = translate('test_notification', $i18n); $priority = 5; $url = $data["gotify_url"]; $token = $data["token"]; $ignore_ssl = $data["ignore_ssl"]; // Validate URL scheme $parsedUrl = parse_url($url); if ( !isset($parsedUrl['scheme']) || !in_array(strtolower($parsedUrl['scheme']), ['http', 'https']) || !filter_var($url, FILTER_VALIDATE_URL) ) { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } $ssrf = validate_webhook_url_for_ssrf($url, $db, $i18n); $ch = curl_init(); // Set the URL and other options curl_setopt($ch, CURLOPT_URL, $url . "/message?token=" . $token); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); curl_setopt($ch, CURLOPT_RESOLVE, ["{$ssrf['host']}:{$ssrf['port']}:{$ssrf['ip']}"]); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ 'title' => $title, 'message' => $message, 'priority' => $priority, ])); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); if ($ignore_ssl) { curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); } // Execute the request $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); // Close the cURL session curl_close($ch); // Check if the message was sent successfully if ($response === false || $httpCode < 200 || $httpCode >= 300) { die(json_encode([ "success" => false, "message" => translate('notification_failed', $i18n), "response" => $response, "http_code" => $httpCode ])); } else { die(json_encode([ "success" => true, "message" => translate('notification_sent_successfuly', $i18n), "response" => $response ])); } } ================================================ FILE: endpoints/notifications/testmattermostnotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { // Set the message parameters $title = translate('wallos_notification', $i18n); $message = translate('test_notification', $i18n); $webhook_url = $data["webhook_url"]; $bot_username = $data["bot_username"]; $bot_icon_emoji = $data["bot_icon_emoji"]; // Validate URL scheme $parsedUrl = parse_url($webhook_url); if ( !isset($parsedUrl['scheme']) || !in_array(strtolower($parsedUrl['scheme']), ['http', 'https']) || !filter_var($webhook_url, FILTER_VALIDATE_URL) ) { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } $ssrf = validate_webhook_url_for_ssrf($webhook_url, $db, $i18n); $postfields = [ 'text' => $message, ]; if (!empty($bot_username)) { $postfields['username'] = $bot_username; } if (!empty($bot_icon_emoji)) { $postfields['icon_emoji'] = $bot_icon_emoji; } $ch = curl_init(); // Set the URL and other options curl_setopt($ch, CURLOPT_URL, $webhook_url); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); curl_setopt($ch, CURLOPT_RESOLVE, ["{$ssrf['host']}:{$ssrf['port']}:{$ssrf['ip']}"]); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postfields)); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json' ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Execute the request $response = curl_exec($ch); // Close the cURL session curl_close($ch); // Check if the message was sent successfully if ($response === false) { die(json_encode([ "success" => false, "message" => translate('notification_failed', $i18n) ])); } else { die(json_encode([ "success" => true, "message" => translate('notification_sent_successfuly', $i18n) ])); } } ================================================ FILE: endpoints/notifications/testntfynotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { $host = rtrim($data["host"], '/'); $topic = $data["topic"]; $headers = json_decode($data["headers"], true); if ($headers === null) { $headers = []; } $customheaders = array_map(function ($key, $value) { return "$key: $value"; }, array_keys($headers), $headers); $url = rtrim($host, '/') . '/' . ltrim($topic, '/'); $ignore_ssl = $data["ignore_ssl"]; // Validate URL scheme $parsedUrl = parse_url($url); if ( !isset($parsedUrl['scheme']) || !in_array(strtolower($parsedUrl['scheme']), ['http', 'https']) || !filter_var($url, FILTER_VALIDATE_URL) ) { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } $ssrf = validate_webhook_url_for_ssrf($url, $db, $i18n); // Set the message parameters $message = translate('test_notification', $i18n); $ch = curl_init(); // Set the URL and other options curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); curl_setopt($ch, CURLOPT_RESOLVE, ["{$ssrf['host']}:{$ssrf['port']}:{$ssrf['ip']}"]); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, $message); curl_setopt($ch, CURLOPT_HTTPHEADER, $customheaders); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); if ($ignore_ssl) { curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); } // Execute the request $response = curl_exec($ch); // Close the cURL session curl_close($ch); // Check if the message was sent successfully if ($response === false) { die(json_encode([ "success" => false, "message" => translate('notification_failed', $i18n) ])); } die(json_encode([ "success" => true, "message" => translate('notification_sent_successfuly', $i18n) ])); } ================================================ FILE: endpoints/notifications/testpushovernotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { // Set the message parameters $message = translate('test_notification', $i18n); $user_key = $data["user_key"]; $token = $data["token"]; $ch = curl_init(); // Set the URL and other options curl_setopt($ch, CURLOPT_URL, "https://api.pushover.net/1/messages.json"); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ 'token' => $token, 'user' => $user_key, 'message' => $message, ])); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Execute the request $response = curl_exec($ch); // Close the cURL session curl_close($ch); // Check if the message was sent successfully if ($response === false) { die(json_encode([ "success" => false, "message" => translate('notification_failed', $i18n) ])); } else { die(json_encode([ "success" => true, "message" => translate('notification_sent_successfuly', $i18n) ])); } } ================================================ FILE: endpoints/notifications/testpushplusnotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { // Set the message parameters $title = translate('wallos_notification', $i18n); $message = translate('test_notification', $i18n); $token = $data["token"]; $ch = curl_init(); // Set the URL and other options for PushPlus $postData = [ "token" => $token, "title" => "您的订阅到期拉", "content" => $message, "template" => "json" ]; curl_setopt_array($ch, [ CURLOPT_URL => 'https://www.pushplus.plus/send', CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($postData), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json' ], CURLOPT_TIMEOUT => 10 ]); // Execute the request $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); // Close the cURL session curl_close($ch); // Check if the message was sent successfully if ($response === false) { die(json_encode([ "success" => false, "message" => translate('notification_failed', $i18n) . ": " . $curlError ])); } else { $responseData = json_decode($response, true); if (isset($responseData['code']) && $responseData['code'] == 200) { die(json_encode([ "success" => true, "message" => translate('notification_sent_successfuly', $i18n) ])); } else { $errorMsg = isset($responseData['msg']) ? $responseData['msg'] : translate('notification_failed', $i18n); die(json_encode([ "success" => false, "message" => $errorMsg ])); } } } ================================================ FILE: endpoints/notifications/testserverchannotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]); exit; } function sc_send($text, $desp = '', $key = '') { $postdata = http_build_query(array('text' => $text, 'desp' => $desp)); if (strpos($key, 'sctp') === 0) { preg_match('/^sctp(\d+)t/', $key, $matches); $num = $matches[1] ?? ''; $url = "https://{$num}.push.ft07.com/send/{$key}.send"; } else { $url = "https://sctapi.ftqq.com/{$key}.send"; } $opts = array('http' => array( 'method' => 'POST', 'header' => 'Content-type: application/x-www-form-urlencoded', 'content' => $postdata )); $context = stream_context_create($opts); $result = @file_get_contents($url, false, $context); return $result !== false ? $result : ''; } $title = 'Wallos Notification Test'; $body = 'This is a test notification from Wallos via Serverchan.'; $result = sc_send($title, $body, $sendkey); $info = json_decode($result, true); $code = (is_array($info) && array_key_exists('code', $info)) ? $info['code'] : null; if ($code === 0) { echo json_encode([ "success" => true, "message" => translate('notification_sent_successfuly', $i18n) ]); } else { echo json_encode([ "success" => false, "message" => translate('notification_failed', $i18n) ]); } ================================================ FILE: endpoints/notifications/testtelegramnotifications.php ================================================ false, "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); } else { // Set the message parameters $title = translate('wallos_notification', $i18n); $message = translate('test_notification', $i18n); $botToken = $data["bottoken"]; $chatId = $data["chatid"]; $ch = curl_init(); // Set the URL and other options curl_setopt($ch, CURLOPT_URL, "https://api.telegram.org/bot" . $botToken . "/sendMessage"); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ 'chat_id' => $chatId, 'text' => $message, ])); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Execute the request $response = curl_exec($ch); // Close the cURL session curl_close($ch); // Check if the message was sent successfully if ($response === false) { die(json_encode([ "success" => false, "message" => translate('notification_failed', $i18n) ])); } else { die(json_encode([ "success" => true, "message" => translate('notification_sent_successfuly', $i18n) ])); } } ================================================ FILE: endpoints/notifications/testwebhooknotifications.php ================================================ 5, "subscription_name" => "Test Subscription", "subscription_price" => 10.00, "subscription_currency" => "USD", "subscription_category" => "Test Category", "subscription_date" => date("Y-m-d H:i:s"), "subscription_payer" => "Test Payer", "subscription_days_until_payment" => 30, "subscription_notes" => "Test Notes", "subscription_url" => "https://example.com/test-subscription" ]; $postData = file_get_contents("php://input"); $data = json_decode($postData, true); if ( !isset($data["requestmethod"]) || $data["requestmethod"] == "" || !isset($data["url"]) || $data["url"] == "" || !isset($data["payload"]) || $data["payload"] == "" ) { $response = [ "success" => false, "message" => translate('fill_mandatory_fields', $i18n) ]; die(json_encode($response)); } else { $requestmethod = $data["requestmethod"]; $url = $data["url"]; $payload = $data["payload"]; // Validate URL scheme $parsedUrl = parse_url($url); if ( !isset($parsedUrl['scheme']) || !in_array(strtolower($parsedUrl['scheme']), ['http', 'https']) || !filter_var($url, FILTER_VALIDATE_URL) ) { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } $ssrf = validate_webhook_url_for_ssrf($url, $db, $i18n); // Replace placeholders in the payload with fake subscription data foreach ($fakeSubscription as $key => $value) { $placeholder = "{{" . $key . "}}"; $payload = str_replace($placeholder, $value, $payload); } $customheaders = json_decode($data["customheaders"], true); $ignore_ssl = $data["ignore_ssl"]; $ch = curl_init(); // Set the URL and other options curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); curl_setopt($ch, CURLOPT_RESOLVE, ["{$ssrf['host']}:{$ssrf['port']}:{$ssrf['ip']}"]); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $requestmethod); curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); if (!empty($customheaders)) { curl_setopt($ch, CURLOPT_HTTPHEADER, $customheaders); } curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); if ($ignore_ssl) { curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); } // Execute the request $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); // Close the cURL session curl_close($ch); // Check if the message was sent successfully if ($response === false || $httpCode >= 400) { die(json_encode([ "success" => false, "message" => translate('notification_failed', $i18n), "response" => curl_error($ch) ])); } else { die(json_encode([ "success" => true, "message" => translate('notification_sent_successfuly', $i18n), "response" => $response ])); } } ================================================ FILE: endpoints/payments/add.php ================================================ false, "message" => "Invalid URL format." ]; echo json_encode($response); exit(); } $host = parse_url($url, PHP_URL_HOST); $ip = gethostbyname($host); if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { $response = [ "success" => false, "message" => "Invalid IP Address." ]; echo json_encode($response); exit(); } $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_setopt($ch, CURLOPT_TIMEOUT, 5); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_MAXREDIRS, 3); $imageData = curl_exec($ch); if ($imageData !== false) { $timestamp = time(); $fileName = $timestamp . '-payments-' . sanitizeFilename($name) . '.png'; $uploadDir = '../../images/uploads/logos/'; $uploadFile = $uploadDir . $fileName; if (saveLogo($imageData, $uploadFile, $name, $settings)) { curl_close($ch); return $fileName; } else { curl_close($ch); echo translate('error_fetching_image', $i18n) . ": " . curl_error($ch); return ""; } } else { echo translate('error_fetching_image', $i18n) . ": " . curl_error($ch); return ""; } } function saveLogo($imageData, $uploadFile, $name, $settings) { $image = imagecreatefromstring($imageData); $removeBackground = isset($settings['removeBackground']) && $settings['removeBackground'] === 'true'; if ($image !== false) { $tempFile = tempnam(sys_get_temp_dir(), 'logo'); imagepng($image, $tempFile); imagedestroy($image); if (extension_loaded('imagick')) { $imagick = new Imagick($tempFile); if ($removeBackground) { $fuzz = Imagick::getQuantum() * 0.1; // 10% $imagick->transparentPaintImage("rgb(247, 247, 247)", 0, $fuzz, false); } $imagick->setImageFormat('png'); $imagick->writeImage($uploadFile); $imagick->clear(); $imagick->destroy(); } else { // Alternative method if Imagick is not available $newImage = imagecreatefrompng($tempFile); if ($removeBackground) { imagealphablending($newImage, false); imagesavealpha($newImage, true); $transparent = imagecolorallocatealpha($newImage, 0, 0, 0, 127); imagefill($newImage, 0, 0, $transparent); // Fill the entire image with transparency imagepng($newImage, $uploadFile); imagedestroy($newImage); } imagepng($newImage, $uploadFile); imagedestroy($newImage); } unlink($tempFile); return true; } else { return false; } } function resizeAndUploadLogo($uploadedFile, $uploadDir, $name) { $targetWidth = 70; $targetHeight = 48; $timestamp = time(); $originalFileName = $uploadedFile['name']; $fileExtension = pathinfo($originalFileName, PATHINFO_EXTENSION); $fileExtension = validateFileExtension($fileExtension) ? $fileExtension : 'png'; $fileName = $timestamp . '-payments-' . sanitizeFilename($name) . '.' . $fileExtension; $uploadFile = $uploadDir . $fileName; if (move_uploaded_file($uploadedFile['tmp_name'], $uploadFile)) { $fileInfo = getimagesize($uploadFile); if ($fileInfo !== false) { $width = $fileInfo[0]; $height = $fileInfo[1]; // Load the image based on its format if ($fileExtension === 'png') { $image = imagecreatefrompng($uploadFile); } elseif ($fileExtension === 'jpg' || $fileExtension === 'jpeg') { $image = imagecreatefromjpeg($uploadFile); } elseif ($fileExtension === 'gif') { $image = imagecreatefromgif($uploadFile); } elseif ($fileExtension === 'webp') { $image = imagecreatefromwebp($uploadFile); } else { // Handle other image formats as needed return ""; } // Enable alpha channel (transparency) for PNG images if ($fileExtension === 'png') { imagesavealpha($image, true); } $newWidth = $width; $newHeight = $height; if ($width > $targetWidth) { $newWidth = (int) $targetWidth; $newHeight = (int) (($targetWidth / $width) * $height); } if ($newHeight > $targetHeight) { $newWidth = (int) (($targetHeight / $newHeight) * $newWidth); $newHeight = (int) $targetHeight; } $resizedImage = imagecreatetruecolor($newWidth, $newHeight); imagesavealpha($resizedImage, true); $transparency = imagecolorallocatealpha($resizedImage, 0, 0, 0, 127); imagefill($resizedImage, 0, 0, $transparency); imagecopyresampled($resizedImage, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height); if ($fileExtension === 'png') { imagepng($resizedImage, $uploadFile); } elseif ($fileExtension === 'jpg' || $fileExtension === 'jpeg') { imagejpeg($resizedImage, $uploadFile); } elseif ($fileExtension === 'gif') { imagegif($resizedImage, $uploadFile); } elseif ($fileExtension === 'webp') { imagewebp($resizedImage, $uploadFile); } else { return ""; } imagedestroy($image); imagedestroy($resizedImage); return $fileName; } } return ""; } $enabled = 1; $name = validate($_POST["paymentname"]); $iconUrl = validate($_POST['icon-url']); if ($name === "" || ($iconUrl === "" && empty($_FILES['paymenticon']['name']))) { $response = [ "success" => false, "message" => translate('fill_all_fields', $i18n) ]; echo json_encode($response); exit(); } $icon = ""; if ($iconUrl !== "") { $icon = getLogoFromUrl($iconUrl, '../../images/uploads/logos/', $name, $i18n, $settings); } else { if (!empty($_FILES['paymenticon']['name'])) { $fileType = mime_content_type($_FILES['paymenticon']['tmp_name']); if (strpos($fileType, 'image') === false) { $response = [ "success" => false, "message" => translate('fill_all_fields', $i18n) ]; echo json_encode($response); exit(); } $icon = resizeAndUploadLogo($_FILES['paymenticon'], '../../images/uploads/logos/', $name); } } // Get the maximum existing ID $stmt = $db->prepare("SELECT MAX(id) as maxID FROM payment_methods"); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $maxID = $row['maxID']; // Ensure the new ID is greater than 31 $newID = max($maxID + 1, 32); // Insert the new record with the new ID $sql = "INSERT INTO payment_methods (id, name, icon, enabled, user_id) VALUES (:id, :name, :icon, :enabled, :userId)"; $stmt = $db->prepare($sql); $stmt->bindParam(':id', $newID, SQLITE3_INTEGER); $stmt->bindParam(':name', $name, SQLITE3_TEXT); $stmt->bindParam(':icon', $icon, SQLITE3_TEXT); $stmt->bindParam(':enabled', $enabled, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { $success['success'] = true; $success['message'] = translate('payment_method_added_successfuly', $i18n); $json = json_encode($success); header('Content-Type: application/json'); echo $json; exit(); } else { echo translate('error', $i18n) . ": " . $db->lastErrorMsg(); } $db->close(); ?> ================================================ FILE: endpoints/payments/delete.php ================================================ prepare($deleteQuery); $deleteStmt->bindParam(':paymentMethodId', $paymentMethodId, SQLITE3_INTEGER); $deleteStmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($deleteStmt->execute()) { $success['success'] = true; $success['message'] = translate('payment_method_removed', $i18n); $json = json_encode($success); header('Content-Type: application/json'); echo $json; } else { http_response_code(500); echo json_encode(array("message" => translate('error', $i18n))); } $db->close(); ?> ================================================ FILE: endpoints/payments/get.php ================================================ prepare('SELECT id FROM payment_methods WHERE id IN (SELECT DISTINCT payment_method_id FROM subscriptions) AND user_id = :userId'); $paymentsInUseQuery->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $paymentsInUseQuery->execute(); $paymentsInUse = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $paymentsInUse[] = $row['id']; } $sql = "SELECT * FROM payment_methods WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { $payments = array(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $payments[] = $row; } } else { http_response_code(500); echo json_encode(array("message" => translate('error', $i18n))); exit(); } foreach ($payments as $payment) { $paymentIconFolder = (strpos($payment['icon'], 'images/uploads/icons/') !== false) ? "" : "images/uploads/logos/"; $inUse = in_array($payment['id'], $paymentsInUse); ?>
Logo
x
translate('error', $i18n))); exit(); } ?> ================================================ FILE: endpoints/payments/rename.php ================================================ false, "message" => translate('fields_missing', $i18n) ])); } $paymentId = $_POST['paymentId']; $name = validate($_POST['name']); if (strlen($name) > 255) { die(json_encode([ "success" => false, "message" => translate('fields_missing', $i18n) ])); } $sql = "UPDATE payment_methods SET name = :name WHERE id = :paymentId and user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindParam(':name', $name, SQLITE3_TEXT); $stmt->bindParam(':paymentId', $paymentId, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result && $db->changes() > 0) { echo json_encode(["success" => true, "message" => translate('payment_renamed', $i18n)]); } else { echo json_encode(["success" => false, "message" => translate('payment_not_renamed', $i18n)]); } ?> ================================================ FILE: endpoints/payments/search.php ================================================ 'us-en', 'o' => 'json', 'q' => urldecode($query), 'vqd' => $vqd, 'f' => ',,,,', // size,color,type,layout,license → all unset 'p' => '1', // safesearch on ]); $response = curlGet("https://duckduckgo.com/i.js?{$params}", [ 'Accept: application/json', 'Referer: https://duckduckgo.com/', ]); if (!$response) return null; $data = json_decode($response, true); if (!isset($data['results']) || empty($data['results'])) return null; return array_column($data['results'], 'image'); } function fetchBraveImages($query) { $url = "https://search.brave.com/images?q={$query}"; $html = curlGet($url, [ 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language: en-US,en;q=0.5', 'Referer: https://search.brave.com/', ]); if (!$html) return null; $doc = new DOMDocument(); @$doc->loadHTML($html); $blockedDomains = ['cdn.search.brave.com', 'search.brave.com/static']; $imageUrls = []; $imgTags = $doc->getElementsByTagName('img'); foreach ($imgTags as $imgTag) { $src = $imgTag->getAttribute('src'); $class = $imgTag->getAttribute('class'); if (str_contains($class, 'favicon') || str_contains($class, 'logo')) continue; if (!filter_var($src, FILTER_VALIDATE_URL)) continue; foreach ($blockedDomains as $blocked) { if (str_contains($src, $blocked)) { continue 2; // skip to next } } $imageUrls[] = $src; } return !empty($imageUrls) ? $imageUrls : null; } // Main flow: DDG first, Brave fallback $vqd = getVqdToken($searchTerm); $imageUrls = $vqd ? fetchDDGImages($searchTerm, $vqd) : null; if (!$imageUrls) { $imageUrls = fetchBraveImages($searchTerm); } header('Content-Type: application/json'); if ($imageUrls) { echo json_encode(['imageUrls' => $imageUrls]); } else { echo json_encode(['error' => 'Failed to fetch images from DuckDuckGo and Brave.']); } } else { echo json_encode(['error' => 'Invalid request.']); } ================================================ FILE: endpoints/payments/sort.php ================================================ prepare($sql); $stmt->bindParam(':order', $order, SQLITE3_INTEGER); $stmt->bindParam(':paymentMethodId', $paymentMethodId, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $order++; } $response = [ "success" => true, "message" => translate("sort_order_saved", $i18n) ]; echo json_encode($response); ?> ================================================ FILE: endpoints/payments/toggle.php ================================================ false, "message" => translate('fields_missing', $i18n) ])); } $paymentId = $_POST['paymentId']; $stmt = $db->prepare('SELECT COUNT(*) as count FROM subscriptions WHERE payment_method_id=:paymentId and user_id=:userId'); $stmt->bindValue(':paymentId', $paymentId, SQLITE3_INTEGER); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $row = $result->fetchArray(); $inUse = $row['count'] === 1; if ($inUse) { die(json_encode([ "success" => false, "message" => translate('payment_in_use', $i18n) ])); } $enabled = $_POST['enabled']; $sqlUpdate = 'UPDATE payment_methods SET enabled=:enabled WHERE id=:id and user_id=:userId'; $stmtUpdate = $db->prepare($sqlUpdate); $stmtUpdate->bindParam(':enabled', $enabled); $stmtUpdate->bindParam(':id', $paymentId); $stmtUpdate->bindParam(':userId', $userId); $resultUpdate = $stmtUpdate->execute(); $text = $enabled ? "enabled" : "disabled"; if ($resultUpdate) { die(json_encode([ "success" => true, "message" => translate($text, $i18n) ])); } die(json_encode([ "success" => false, "message" => translate('failed_update_payment', $i18n) ])); ================================================ FILE: endpoints/settings/colortheme.php ================================================ false, "message" => translate("error", $i18n) ])); } $color = $data['color']; $stmt = $db->prepare('UPDATE settings SET color_theme = :color WHERE user_id = :userId'); $stmt->bindParam(':color', $color, SQLITE3_TEXT); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { die(json_encode([ "success" => true, "message" => translate("success", $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } ================================================ FILE: endpoints/settings/convert_currency.php ================================================ false, "message" => translate("error", $i18n) ])); } $stmt = $db->prepare('UPDATE settings SET convert_currency = :convert_currency WHERE user_id = :userId'); $stmt->bindParam(':convert_currency', $convert_currency, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { die(json_encode([ "success" => true, "message" => translate("success", $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } ================================================ FILE: endpoints/settings/customcss.php ================================================ prepare('DELETE FROM custom_css_style WHERE user_id = :userId'); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $stmt->execute(); $stmt = $db->prepare('INSERT INTO custom_css_style (css, user_id) VALUES (:customCss, :userId)'); $stmt->bindParam(':customCss', $customCss, SQLITE3_TEXT); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { die(json_encode([ "success" => true, "message" => translate("success", $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } ================================================ FILE: endpoints/settings/customtheme.php ================================================ false, "message" => translate("error", $i18n) ])); } if ($main_color == $accent_color) { die(json_encode([ "success" => false, "message" => translate("main_accent_color_error", $i18n) ])); } $stmt = $db->prepare('DELETE FROM custom_colors WHERE user_id = :userId'); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $stmt->execute(); $stmt = $db->prepare('INSERT INTO custom_colors (main_color, accent_color, hover_color, user_id) VALUES (:main_color, :accent_color, :hover_color, :userId)'); $stmt->bindParam(':main_color', $main_color, SQLITE3_TEXT); $stmt->bindParam(':accent_color', $accent_color, SQLITE3_TEXT); $stmt->bindParam(':hover_color', $hover_color, SQLITE3_TEXT); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { die(json_encode([ "success" => true, "message" => translate("success", $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } ================================================ FILE: endpoints/settings/deleteaccount.php ================================================ false, "message" => translate('error', $i18n) ])); } else { // Delete user $stmt = $db->prepare('DELETE FROM user WHERE id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete subscriptions $stmt = $db->prepare('DELETE FROM subscriptions WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete settings $stmt = $db->prepare('DELETE FROM settings WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete fixer $stmt = $db->prepare('DELETE FROM fixer WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete custom colors $stmt = $db->prepare('DELETE FROM custom_colors WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete currencies $stmt = $db->prepare('DELETE FROM currencies WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete categories $stmt = $db->prepare('DELETE FROM categories WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete household $stmt = $db->prepare('DELETE FROM household WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete payment methods $stmt = $db->prepare('DELETE FROM payment_methods WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete email notifications $stmt = $db->prepare('DELETE FROM email_notifications WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete telegram notifications $stmt = $db->prepare('DELETE FROM telegram_notifications WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete webhook notifications $stmt = $db->prepare('DELETE FROM webhook_notifications WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete gotify notifications $stmt = $db->prepare('DELETE FROM gotify_notifications WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete pushover notifications $stmt = $db->prepare('DELETE FROM pushover_notifications WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Dele notification settings $stmt = $db->prepare('DELETE FROM notification_settings WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete last exchange update $stmt = $db->prepare('DELETE FROM last_exchange_update WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete email verification $stmt = $db->prepare('DELETE FROM email_verification WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete totp $stmt = $db->prepare('DELETE FROM totp WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); // Delete total yearly cost $stmt = $db->prepare('DELETE FROM total_yearly_cost WHERE user_id = :id'); $stmt->bindValue(':id', $userIdToDelete, SQLITE3_INTEGER); $result = $stmt->execute(); die(json_encode([ "success" => true, "message" => translate('success', $i18n) ])); } ================================================ FILE: endpoints/settings/disabled_to_bottom.php ================================================ false, "message" => translate("error", $i18n) ])); } $stmt = $db->prepare('UPDATE settings SET disabled_to_bottom = :disabled_to_bottom WHERE user_id = :userId'); $stmt->bindParam(':disabled_to_bottom', $disabled_to_bottom, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { die(json_encode([ "success" => true, "message" => translate("success", $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } ================================================ FILE: endpoints/settings/hide_disabled.php ================================================ false, "message" => translate("error", $i18n) ])); } $stmt = $db->prepare('UPDATE settings SET hide_disabled = :hide_disabled WHERE user_id = :userId'); $stmt->bindParam(':hide_disabled', $hide_disabled, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { die(json_encode([ "success" => true, "message" => translate("success", $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } ================================================ FILE: endpoints/settings/mobile_navigation.php ================================================ false, "message" => translate("error", $i18n) ])); } $stmt = $db->prepare('UPDATE settings SET mobile_nav = :mobile_nav WHERE user_id = :userId'); $stmt->bindParam(':mobile_nav', $mobile_nav, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { die(json_encode([ "success" => true, "message" => translate("success", $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } ================================================ FILE: endpoints/settings/monthly_price.php ================================================ false, "message" => translate("error", $i18n) ])); } $stmt = $db->prepare('UPDATE settings SET monthly_price = :monthly_price WHERE user_id = :userId'); $stmt->bindParam(':monthly_price', $monthly_price, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { die(json_encode([ "success" => true, "message" => translate("success", $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } ================================================ FILE: endpoints/settings/remove_background.php ================================================ false, "message" => translate("error", $i18n) ])); } $stmt = $db->prepare('UPDATE settings SET remove_background = :remove_background WHERE user_id = :userId'); $stmt->bindParam(':remove_background', $remove_background, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { die(json_encode([ "success" => true, "message" => translate("success", $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } ================================================ FILE: endpoints/settings/resettheme.php ================================================ prepare('DELETE FROM custom_colors WHERE user_id = :userId'); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { die(json_encode([ "success" => true, "message" => translate("success", $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } ================================================ FILE: endpoints/settings/show_original_price.php ================================================ false, "message" => translate("error", $i18n) ])); } $stmt = $db->prepare('UPDATE settings SET show_original_price = :show_original_price WHERE user_id = :userId'); $stmt->bindParam(':show_original_price', $show_original_price, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { die(json_encode([ "success" => true, "message" => translate("success", $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } ================================================ FILE: endpoints/settings/subscription_progress.php ================================================ false, "message" => translate("error", $i18n) ])); } $stmt = $db->prepare('UPDATE settings SET show_subscription_progress = :show_subscription_progress WHERE user_id = :userId'); $stmt->bindParam(':show_subscription_progress', $show_subscription_progress, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { die(json_encode([ "success" => true, "message" => translate("success", $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } ================================================ FILE: endpoints/settings/theme.php ================================================ 2) { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } $stmt = $db->prepare('UPDATE settings SET dark_theme = :theme WHERE user_id = :userId'); $stmt->bindParam(':theme', $theme, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { die(json_encode([ "success" => true, "message" => translate("success", $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } ================================================ FILE: endpoints/subscription/add.php ================================================ false, "message" => "Invalid URL format."]; echo json_encode($response); exit(); } $parts = parse_url($currentUrl); $host = $parts['host']; $port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80); $ip = gethostbyname($host); if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { $response = ["success" => false, "message" => "Invalid IP Address."]; echo json_encode($response); exit(); } $ch = curl_init($currentUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_setopt($ch, CURLOPT_TIMEOUT, 5); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); curl_setopt($ch, CURLOPT_RESOLVE, ["$host:$port:$ip"]); $imageData = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($httpCode >= 300 && $httpCode < 400) { $redirectUrl = curl_getinfo($ch, CURLINFO_REDIRECT_URL); curl_close($ch); if (!$redirectUrl) { break; } $currentUrl = $redirectUrl; continue; } if ($imageData !== false && $httpCode === 200) { $timestamp = time(); $fileName = $timestamp . '-' . sanitizeFilename($name) . '.png'; $uploadDir = '../../images/uploads/logos/'; $uploadFile = $uploadDir . $fileName; if (saveLogo($imageData, $uploadFile, $name, $settings)) { curl_close($ch); return $fileName; } } echo translate('error_fetching_image', $i18n) . ": " . curl_error($ch); curl_close($ch); return ""; } return ""; } function saveLogo($imageData, $uploadFile, $name, $settings) { $image = imagecreatefromstring($imageData); $removeBackground = isset($settings['removeBackground']) && $settings['removeBackground'] === 'true'; if ($image !== false) { $tempFile = tempnam(sys_get_temp_dir(), 'logo'); imagepng($image, $tempFile); imagedestroy($image); if (extension_loaded('imagick')) { $imagick = new Imagick($tempFile); if ($removeBackground) { $fuzz = Imagick::getQuantum() * 0.1; // 10% $imagick->transparentPaintImage("rgb(247, 247, 247)", 0, $fuzz, false); } $imagick->setImageFormat('png'); $imagick->writeImage($uploadFile); $imagick->clear(); $imagick->destroy(); } else { // Alternative method if Imagick is not available $newImage = imagecreatefrompng($tempFile); if ($newImage !== false) { if ($removeBackground) { imagealphablending($newImage, false); imagesavealpha($newImage, true); $transparent = imagecolorallocatealpha($newImage, 0, 0, 0, 127); imagefill($newImage, 0, 0, $transparent); // Fill the entire image with transparency imagepng($newImage, $uploadFile); imagedestroy($newImage); } imagepng($newImage, $uploadFile); imagedestroy($newImage); } else { unlink($tempFile); return false; } } unlink($tempFile); return true; } else { return false; } } function resizeAndUploadLogo($uploadedFile, $uploadDir, $name, $settings) { $targetWidth = 135; $targetHeight = 42; $timestamp = time(); $originalFileName = $uploadedFile['name']; $fileExtension = pathinfo($originalFileName, PATHINFO_EXTENSION); $fileExtension = validateFileExtension($fileExtension) ? $fileExtension : 'png'; $fileName = $timestamp . '-' . sanitizeFilename($name) . '.' . $fileExtension; $uploadFile = $uploadDir . $fileName; if (move_uploaded_file($uploadedFile['tmp_name'], $uploadFile)) { $fileInfo = getimagesize($uploadFile); if ($fileInfo !== false) { $width = $fileInfo[0]; $height = $fileInfo[1]; // Load the image based on its format if ($fileExtension === 'png') { $image = imagecreatefrompng($uploadFile); } elseif ($fileExtension === 'jpg' || $fileExtension === 'jpeg') { $image = imagecreatefromjpeg($uploadFile); } elseif ($fileExtension === 'gif') { $image = imagecreatefromgif($uploadFile); } elseif ($fileExtension === 'webp') { $image = imagecreatefromwebp($uploadFile); } else { // Handle other image formats as needed return ""; } // Enable alpha channel (transparency) for PNG images if ($fileExtension === 'png') { imagesavealpha($image, true); } $newWidth = $width; $newHeight = $height; if ($width > $targetWidth) { $newWidth = (int) $targetWidth; $newHeight = (int) (($targetWidth / $width) * $height); } if ($newHeight > $targetHeight) { $newWidth = (int) (($targetHeight / $newHeight) * $newWidth); $newHeight = (int) $targetHeight; } $resizedImage = imagecreatetruecolor($newWidth, $newHeight); imagesavealpha($resizedImage, true); $transparency = imagecolorallocatealpha($resizedImage, 0, 0, 0, 127); imagefill($resizedImage, 0, 0, $transparency); imagecopyresampled($resizedImage, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height); if ($fileExtension === 'png') { imagepng($resizedImage, $uploadFile); } elseif ($fileExtension === 'jpg' || $fileExtension === 'jpeg') { imagejpeg($resizedImage, $uploadFile); } elseif ($fileExtension === 'gif') { imagegif($resizedImage, $uploadFile); } elseif ($fileExtension === 'webp') { imagewebp($resizedImage, $uploadFile); } else { return ""; } imagedestroy($image); imagedestroy($resizedImage); return $fileName; } } return ""; } $isEdit = isset($_POST['id']) && $_POST['id'] != ""; $name = validate($_POST["name"]); $price = $_POST['price']; $currencyId = $_POST["currency_id"]; $frequency = $_POST["frequency"]; $cycle = $_POST["cycle"]; $nextPayment = $_POST["next_payment"]; $autoRenew = isset($_POST['auto_renew']) ? true : false; $startDate = $_POST["start_date"]; $paymentMethodId = $_POST["payment_method_id"]; $payerUserId = $_POST["payer_user_id"]; $categoryId = $_POST['category_id']; $notes = validate($_POST["notes"]); $url = validate($_POST['url']); $logoUrl = validate($_POST['logo-url']); $logo = ""; $notify = isset($_POST['notifications']) ? true : false; $notifyDaysBefore = $_POST['notify_days_before']; $inactive = isset($_POST['inactive']) ? true : false; $cancellationDate = $_POST['cancellation_date'] ?? null; $replacementSubscriptionId = $_POST['replacement_subscription_id']; if ($replacementSubscriptionId == 0 || $inactive == 0) { $replacementSubscriptionId = null; } if ($logoUrl !== "") { $logo = getLogoFromUrl($logoUrl, '../../images/uploads/logos/', $name, $settings, $i18n); } else { if (!empty($_FILES['logo']['name'])) { $fileType = mime_content_type($_FILES['logo']['tmp_name']); if (strpos($fileType, 'image') === false) { echo translate("fill_all_fields", $i18n); exit(); } $logo = resizeAndUploadLogo($_FILES['logo'], '../../images/uploads/logos/', $name, $settings); } } if (!$isEdit) { $sql = "INSERT INTO subscriptions ( name, logo, price, currency_id, next_payment, cycle, frequency, notes, payment_method_id, payer_user_id, category_id, notify, inactive, url, notify_days_before, user_id, cancellation_date, replacement_subscription_id, auto_renew, start_date ) VALUES ( :name, :logo, :price, :currencyId, :nextPayment, :cycle, :frequency, :notes, :paymentMethodId, :payerUserId, :categoryId, :notify, :inactive, :url, :notifyDaysBefore, :userId, :cancellationDate, :replacement_subscription_id, :autoRenew, :startDate )"; } else { $id = $_POST['id']; $sql = "UPDATE subscriptions SET name = :name, price = :price, currency_id = :currencyId, next_payment = :nextPayment, auto_renew = :autoRenew, start_date = :startDate, cycle = :cycle, frequency = :frequency, notes = :notes, payment_method_id = :paymentMethodId, payer_user_id = :payerUserId, category_id = :categoryId, notify = :notify, inactive = :inactive, url = :url, notify_days_before = :notifyDaysBefore, cancellation_date = :cancellationDate, replacement_subscription_id = :replacement_subscription_id"; if ($logo != "") { $sql .= ", logo = :logo"; } $sql .= " WHERE id = :id AND user_id = :userId"; } $stmt = $db->prepare($sql); $stmt->bindParam(':name', $name, SQLITE3_TEXT); if ($logo != "") { $stmt->bindParam(':logo', $logo, SQLITE3_TEXT); } $stmt->bindParam(':price', $price, SQLITE3_FLOAT); $stmt->bindParam(':currencyId', $currencyId, SQLITE3_INTEGER); $stmt->bindParam(':nextPayment', $nextPayment, SQLITE3_TEXT); $stmt->bindParam(':autoRenew', $autoRenew, SQLITE3_INTEGER); $stmt->bindParam(':startDate', $startDate, SQLITE3_TEXT); $stmt->bindParam(':cycle', $cycle, SQLITE3_INTEGER); $stmt->bindParam(':frequency', $frequency, SQLITE3_INTEGER); $stmt->bindParam(':notes', $notes, SQLITE3_TEXT); $stmt->bindParam(':paymentMethodId', $paymentMethodId, SQLITE3_INTEGER); $stmt->bindParam(':payerUserId', $payerUserId, SQLITE3_INTEGER); $stmt->bindParam(':categoryId', $categoryId, SQLITE3_INTEGER); $stmt->bindParam(':notify', $notify, SQLITE3_INTEGER); $stmt->bindParam(':inactive', $inactive, SQLITE3_INTEGER); $stmt->bindParam(':url', $url, SQLITE3_TEXT); $stmt->bindParam(':notifyDaysBefore', $notifyDaysBefore, SQLITE3_INTEGER); $stmt->bindParam(':cancellationDate', $cancellationDate, SQLITE3_TEXT); if ($isEdit) { $stmt->bindParam(':id', $id, SQLITE3_INTEGER); } $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $stmt->bindParam(':replacement_subscription_id', $replacementSubscriptionId, SQLITE3_INTEGER); if ($stmt->execute()) { $success['status'] = "Success"; $text = $isEdit ? "updated" : "added"; $success['message'] = translate('subscription_' . $text . '_successfuly', $i18n); $json = json_encode($success); header('Content-Type: application/json'); echo $json; exit(); } else { echo translate('error', $i18n) . ": " . $db->lastErrorMsg(); } $db->close(); ?> ================================================ FILE: endpoints/subscription/clone.php ================================================ prepare($query); $stmt->bindValue(':id', $subscriptionId, SQLITE3_INTEGER); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $subscriptionToClone = $result->fetchArray(SQLITE3_ASSOC); if ($subscriptionToClone === false) { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } $query = "INSERT INTO subscriptions (name, logo, price, currency_id, next_payment, cycle, frequency, notes, payment_method_id, payer_user_id, category_id, notify, url, inactive, notify_days_before, user_id, cancellation_date, replacement_subscription_id) VALUES (:name, :logo, :price, :currency_id, :next_payment, :cycle, :frequency, :notes, :payment_method_id, :payer_user_id, :category_id, :notify, :url, :inactive, :notify_days_before, :user_id, :cancellation_date, :replacement_subscription_id)"; $cloneStmt = $db->prepare($query); $cloneStmt->bindValue(':name', $subscriptionToClone['name'], SQLITE3_TEXT); $cloneStmt->bindValue(':logo', $subscriptionToClone['logo'], SQLITE3_TEXT); $cloneStmt->bindValue(':price', $subscriptionToClone['price'], SQLITE3_TEXT); $cloneStmt->bindValue(':currency_id', $subscriptionToClone['currency_id'], SQLITE3_INTEGER); $cloneStmt->bindValue(':next_payment', $subscriptionToClone['next_payment'], SQLITE3_TEXT); $cloneStmt->bindValue(':auto_renew', $subscriptionToClone['auto_renew'], SQLITE3_INTEGER); $cloneStmt->bindValue(':start_date', $subscriptionToClone['start_date'], SQLITE3_TEXT); $cloneStmt->bindValue(':cycle', $subscriptionToClone['cycle'], SQLITE3_TEXT); $cloneStmt->bindValue(':frequency', $subscriptionToClone['frequency'], SQLITE3_INTEGER); $cloneStmt->bindValue(':notes', $subscriptionToClone['notes'], SQLITE3_TEXT); $cloneStmt->bindValue(':payment_method_id', $subscriptionToClone['payment_method_id'], SQLITE3_INTEGER); $cloneStmt->bindValue(':payer_user_id', $subscriptionToClone['payer_user_id'], SQLITE3_INTEGER); $cloneStmt->bindValue(':category_id', $subscriptionToClone['category_id'], SQLITE3_INTEGER); $cloneStmt->bindValue(':notify', $subscriptionToClone['notify'], SQLITE3_INTEGER); $cloneStmt->bindValue(':url', $subscriptionToClone['url'], SQLITE3_TEXT); $cloneStmt->bindValue(':inactive', $subscriptionToClone['inactive'], SQLITE3_INTEGER); $cloneStmt->bindValue(':notify_days_before', $subscriptionToClone['notify_days_before'], SQLITE3_INTEGER); $cloneStmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $cloneStmt->bindValue(':cancellation_date', $subscriptionToClone['cancellation_date'], SQLITE3_TEXT); $cloneStmt->bindValue(':replacement_subscription_id', $subscriptionToClone['replacement_subscription_id'], SQLITE3_INTEGER); if ($cloneStmt->execute()) { $response = [ "success" => true, "message" => translate('success', $i18n), "id" => $db->lastInsertRowID() ]; echo json_encode($response); } else { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } $db->close(); ?> ================================================ FILE: endpoints/subscription/delete.php ================================================ prepare($deleteQuery); $deleteStmt->bindParam(':subscriptionId', $subscriptionId, SQLITE3_INTEGER); $deleteStmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if ($deleteStmt->execute()) { $query = "UPDATE subscriptions SET replacement_subscription_id = NULL WHERE replacement_subscription_id = :subscriptionId AND user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(':subscriptionId', $subscriptionId, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $stmt->execute(); echo json_encode([ "success" => true, "message" => translate('subscription_deleted', $i18n) ]); } else { echo json_encode([ "success" => false, "message" => translate('error_deleting_subscription', $i18n) ]); } $db->close(); ================================================ FILE: endpoints/subscription/exportcalendar.php ================================================ prepare('SELECT * FROM subscriptions WHERE id = :id AND user_id = :userId'); $stmt->bindParam(':id', $id, SQLITE3_INTEGER); $stmt->bindParam(':userId', $_SESSION['userId'], SQLITE3_INTEGER); $result = $stmt->execute(); if ($result === false) { die(json_encode([ 'success' => false, 'message' => "Subscription not found" ])); } $subscription = $result->fetchArray(SQLITE3_ASSOC); // Fetch the subscription details as an associative array if ($subscription) { $subscription['payer_user'] = $members[$subscription['payer_user_id']]['name']; $subscription['category'] = $categories[$subscription['category_id']]['name']; $subscription['payment_method'] = $payment_methods[$subscription['payment_method_id']]['name']; $subscription['currency'] = $currencies[$subscription['currency_id']]['symbol']; $subscription['trigger'] = $subscription['notify_days_before'] ? $subscription['notify_days_before'] : 1; $subscription['price'] = number_format($subscription['price'], 2); // Create ICS from subscription information $uid = 'wallos-subscription-' . $subscription['id'] . '@wallos'; $summary = html_entity_decode($subscription['name'], ENT_QUOTES, 'UTF-8'); $description = "Price: {$subscription['currency']}{$subscription['price']}\nCategory: {$subscription['category']}\nPayment Method: {$subscription['payment_method']}\nPayer: {$subscription['payer_user']}\n\nNotes: {$subscription['notes']}"; $dtstamp = gmdate('Ymd\THis\Z'); $dtstart = (new DateTime($subscription['next_payment']))->format('Ymd'); $dtend = (new DateTime($subscription['next_payment']))->format('Ymd'); $location = isset($subscription['url']) ? $subscription['url'] : ''; $alarm_trigger = '-P' . $subscription['trigger'] . 'D'; $icsContent = << true, 'ics' => $icsContent, 'name' => $subscription['name'] ]); } else { echo json_encode([ 'success' => false, 'message' => "Subscription not found" ]); } ================================================ FILE: endpoints/subscription/get.php ================================================ prepare($query); $stmt->bindParam(':subscriptionId', $subscriptionId, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $subscriptionData = array(); if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $subscriptionData['id'] = $subscriptionId; $subscriptionData['name'] = htmlspecialchars_decode($row['name'] ?? ""); $subscriptionData['logo'] = $row['logo']; $subscriptionData['price'] = $row['price']; $subscriptionData['currency_id'] = $row['currency_id']; $subscriptionData['auto_renew'] = $row['auto_renew']; $subscriptionData['start_date'] = $row['start_date']; $subscriptionData['next_payment'] = $row['next_payment']; $subscriptionData['frequency'] = $row['frequency']; $subscriptionData['cycle'] = $row['cycle']; $subscriptionData['notes'] = htmlspecialchars_decode($row['notes'] ?? ""); $subscriptionData['payment_method_id'] = $row['payment_method_id']; $subscriptionData['payer_user_id'] = $row['payer_user_id']; $subscriptionData['category_id'] = $row['category_id']; $subscriptionData['notify'] = $row['notify']; $subscriptionData['inactive'] = $row['inactive']; $subscriptionData['url'] = htmlspecialchars_decode($row['url'] ?? ""); $subscriptionData['notify_days_before'] = $row['notify_days_before']; $subscriptionData['cancellation_date'] = $row['cancellation_date']; $subscriptionData['replacement_subscription_id'] = $row['replacement_subscription_id']; $subscriptionJson = json_encode($subscriptionData); header('Content-Type: application/json'); echo $subscriptionJson; } else { echo translate('error', $i18n); } } else { echo translate('error', $i18n); } } $db->close(); ?> ================================================ FILE: endpoints/subscription/getcalendar.php ================================================ false, "message" => translate('session_expired', $i18n) ])); } if ($_SERVER["REQUEST_METHOD"] === "POST") { $postData = file_get_contents("php://input"); $data = json_decode($postData, true); $id = $data['id']; $stmt = $db->prepare('SELECT * FROM subscriptions WHERE id = :id AND user_id = :userId'); $stmt->bindParam(':id', $id, SQLITE3_INTEGER); $stmt->bindParam(':userId', $_SESSION['userId'], SQLITE3_INTEGER); // Assuming $_SESSION['userId'] holds the logged-in user's ID $result = $stmt->execute(); if ($result === false) { die(json_encode([ 'success' => false, 'message' => "Subscription not found" ])); } $subscription = $result->fetchArray(SQLITE3_ASSOC); // Fetch the subscription details as an associative array if ($subscription) { // get payer name from household object $subscription['payer_user'] = $members[$subscription['payer_user_id']]['name']; $subscription['category'] = $categories[$subscription['category_id']]['name']; $subscription['payment_method'] = $payment_methods[$subscription['payment_method_id']]['name']; $subscription['currency'] = $currencies[$subscription['currency_id']]['symbol']; $subscription['price'] = number_format($subscription['price'], 2); echo json_encode([ 'success' => true, 'data' => $subscription ]); } else { echo json_encode([ 'success' => false, 'message' => "Subscription not found" ]); } } ?> ================================================ FILE: endpoints/subscription/renew.php ================================================ format('Y-m-d'); $cycles = array(); $query = "SELECT * FROM cycles"; $result = $db->query($query); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $cycleId = $row['id']; $cycles[$cycleId] = $row; } $subscriptionId = $data["id"]; $query = "SELECT * FROM subscriptions WHERE id = :id AND user_id = :user_id AND auto_renew = 0"; $stmt = $db->prepare($query); $stmt->bindValue(':id', $subscriptionId, SQLITE3_INTEGER); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $subscriptionToRenew = $result->fetchArray(SQLITE3_ASSOC); if ($subscriptionToRenew === false) { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } $nextPaymentDate = new DateTime($subscriptionToRenew['next_payment']); $frequency = $subscriptionToRenew['frequency']; $cycle = $cycles[$subscriptionToRenew['cycle']]['name']; // Calculate the interval to add based on the cycle $intervalSpec = "P"; if ($cycle == 'Daily') { $intervalSpec .= "{$frequency}D"; } elseif ($cycle === 'Weekly') { $intervalSpec .= "{$frequency}W"; } elseif ($cycle === 'Monthly') { $intervalSpec .= "{$frequency}M"; } elseif ($cycle === 'Yearly') { $intervalSpec .= "{$frequency}Y"; } $interval = new DateInterval($intervalSpec); // Add intervals until the next payment date is in the future and after current next payment date while ($nextPaymentDate < $currentDate || $nextPaymentDate == new DateTime($subscriptionToRenew['next_payment'])) { $nextPaymentDate->add($interval); } // Update the subscription's next_payment date $updateQuery = "UPDATE subscriptions SET next_payment = :nextPaymentDate WHERE id = :subscriptionId"; $updateStmt = $db->prepare($updateQuery); $updateStmt->bindValue(':nextPaymentDate', $nextPaymentDate->format('Y-m-d')); $updateStmt->bindValue(':subscriptionId', $subscriptionId); $updateStmt->execute(); if ($updateStmt->execute()) { $response = [ "success" => true, "message" => translate('success', $i18n), "id" => $subscriptionId ]; echo json_encode($response); } else { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } ================================================ FILE: endpoints/subscriptions/export.php ================================================ false, "message" => translate('session_expired', $i18n) ])); } require_once '../../includes/getdbkeys.php'; $subscriptions = array(); $query = "SELECT * FROM subscriptions WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $cycle = $cycles[$row['cycle']]['name']; $frequency =$row['frequency']; $cyclesMap = array( 'Daily' => 'Days', 'Weekly' => 'Weeks', 'Monthly' => 'Months', 'Yearly' => 'Years' ); if ($frequency == 1) { $cyclePrint = $cycle; } else { $cyclePrint = "Every " . $frequency . " " . $cyclesMap[$cycle]; } $subscriptionDetails = array( 'Name' => str_replace(',', ' ', $row['name']), 'Payment Cycle' => $cyclePrint, 'Next Payment' => $row['next_payment'], 'Renewal' => $row['auto_renew'] ? 'Automatic' : 'Manual', 'Category' => str_replace(',', ' ', $categories[$row['category_id']]['name']), 'Payment Method' => str_replace(',', ' ', $payment_methods[$row['payment_method_id']]['name']), 'Paid By' => str_replace(',', ' ', $members[$row['payer_user_id']]['name']), 'Price' => $currencies[$row['currency_id']]['symbol'] . $row['price'], 'Notes' => str_replace(',', ' ', $row['notes']), 'URL' => $row['url'], 'State' => $row['inactive'] ? 'Disabled' : 'Enabled', 'Notifications' => $row['notify'] ? 'Enabled' : 'Disabled', 'Cancellation Date' => $row['cancellation_date'], 'Active' => $row['inactive'] ? 'No' : 'Yes', ); $subscriptions[] = $subscriptionDetails; } die(json_encode([ "success" => true, "subscriptions" => $subscriptions ])); ?> ================================================ FILE: endpoints/subscriptions/get.php ================================================ $category) { $params[":categories{$idx}"] = $category; } } if (isset($_GET['payments']) && $_GET['payments'] !== "") { $allPayments = explode(',', $_GET['payments']); $placeholders = array_map(function ($idx) { return ":payments{$idx}"; }, array_keys($allPayments)); $sql .= " AND (" . implode(' OR ', array_map(function ($placeholder) { return "payment_method_id = {$placeholder}"; }, $placeholders)) . ")"; foreach ($allPayments as $idx => $payment) { $params[":payments{$idx}"] = $payment; } } if (isset($_GET['members']) && $_GET['members'] != "") { $allMembers = explode(',', $_GET['members']); $placeholders = array_map(function ($idx) { return ":members{$idx}"; }, array_keys($allMembers)); $sql .= " AND (" . implode(' OR ', array_map(function ($placeholder) { return "payer_user_id = {$placeholder}"; }, $placeholders)) . ")"; foreach ($allMembers as $idx => $member) { $params[":members{$idx}"] = $member; } } if (isset($_GET['state']) && $_GET['state'] != "") { $sql .= " AND inactive = :inactive"; $params[':inactive'] = $_GET['state']; } if (isset($_GET['renewalType']) && $_GET['renewalType'] != "") { $sql .= " AND auto_renew = :auto_renew"; $params[':auto_renew'] = $_GET['renewalType']; } if (isset($_COOKIE['sortOrder']) && $_COOKIE['sortOrder'] != "") { $sort = $_COOKIE['sortOrder']; } $sortOrder = $sort; $allowedSortCriteria = ['name', 'id', 'next_payment', 'price', 'payer_user_id', 'category_id', 'payment_method_id', 'inactive', 'alphanumeric', 'renewal_type']; $order = ($sort == "price" || $sort == "id") ? "DESC" : "ASC"; if ($sort == "alphanumeric") { $sort = "name"; } if (!in_array($sort, $allowedSortCriteria)) { $sort = "next_payment"; } if ($sort == "renewal_type") { $sort = "auto_renew"; } $orderByClauses = []; if ($settings['disabledToBottom'] === 'true') { if (in_array($sort, ["payer_user_id", "category_id", "payment_method_id"])) { $orderByClauses[] = "$sort $order"; $orderByClauses[] = "inactive ASC"; } else { $orderByClauses[] = "inactive ASC"; $orderByClauses[] = "$sort $order"; } } else { $orderByClauses[] = "$sort $order"; if ($sort != "inactive") { $orderByClauses[] = "inactive ASC"; } } if ($sort != "next_payment") { $orderByClauses[] = "next_payment ASC"; } $sql .= " ORDER BY " . implode(", ", $orderByClauses); $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); foreach ($params as $key => $value) { $stmt->bindValue($key, $value); } $result = $stmt->execute(); if ($result) { $subscriptions = array(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $subscriptions[] = $row; } } foreach ($subscriptions as $subscription) { if ($subscription['inactive'] == 1 && isset($settings['hideDisabledSubscriptions']) && $settings['hideDisabledSubscriptions'] === 'true') { continue; } $id = $subscription['id']; $print[$id]['id'] = $id; $print[$id]['logo'] = $subscription['logo'] != "" ? "images/uploads/logos/" . $subscription['logo'] : ""; $print[$id]['name'] = $subscription['name'] ?? ""; $cycle = $subscription['cycle']; $frequency = $subscription['frequency']; $print[$id]['billing_cycle'] = getBillingCycle($cycle, $frequency, $i18n); $paymentMethodId = $subscription['payment_method_id']; $print[$id]['currency_code'] = $currencies[$subscription['currency_id']]['code']; $currencyId = $subscription['currency_id']; $next_payment_timestamp = strtotime($subscription['next_payment']); $formatted_date = $formatter->format($next_payment_timestamp); $print[$id]['next_payment'] = $formatted_date; $print[$id]['auto_renew'] = $subscription['auto_renew']; $paymentIconFolder = (strpos($payment_methods[$paymentMethodId]['icon'], 'images/uploads/icons/') !== false) ? "" : "images/uploads/logos/"; $print[$id]['payment_method_icon'] = $paymentIconFolder . $payment_methods[$paymentMethodId]['icon']; $print[$id]['payment_method_name'] = $payment_methods[$paymentMethodId]['name']; $print[$id]['payment_method_id'] = $paymentMethodId; $print[$id]['category_id'] = $subscription['category_id']; $print[$id]['payer_user_id'] = $subscription['payer_user_id']; $print[$id]['price'] = floatval($subscription['price']); $print[$id]['progress'] = getSubscriptionProgress($cycle, $frequency, $subscription['next_payment']); $print[$id]['inactive'] = $subscription['inactive']; $print[$id]['url'] = $subscription['url'] ?? ""; $print[$id]['notes'] = $subscription['notes'] ?? ""; $print[$id]['replacement_subscription_id'] = $subscription['replacement_subscription_id']; if (isset($settings['convertCurrency']) && $settings['convertCurrency'] === 'true' && $currencyId != $mainCurrencyId) { $print[$id]['price'] = getPriceConverted($print[$id]['price'], $currencyId, $db); $print[$id]['currency_code'] = $currencies[$mainCurrencyId]['code']; } if (isset($settings['showMonthlyPrice']) && $settings['showMonthlyPrice'] === 'true') { $print[$id]['price'] = getPricePerMonth($cycle, $frequency, $print[$id]['price']); } if (isset($settings['showOriginalPrice']) && $settings['showOriginalPrice'] === 'true') { $print[$id]['original_price'] = floatval($subscription['price']); $print[$id]['original_currency_code'] = $currencies[$subscription['currency_id']]['code']; } } if ($sortOrder == "alphanumeric") { usort($print, function ($a, $b) { return strnatcmp(strtolower($a['name']), strtolower($b['name'])); }); if ($settings['disabledToBottom'] === 'true') { usort($print, function ($a, $b) { return $a['inactive'] - $b['inactive']; }); } } if ($sortOrder == "category_id") { usort($print, function ($a, $b) use ($categories) { return $categories[$a['category_id']]['order'] - $categories[$b['category_id']]['order']; }); } if ($sortOrder == "payment_method_id") { usort($print, function ($a, $b) use ($payment_methods) { return $payment_methods[$a['payment_method_id']]['order'] - $payment_methods[$b['payment_method_id']]['order']; }); } if (isset($print)) { printSubscriptions($print, $sort, $categories, $members, $i18n, $colorTheme, "../../", $settings['disabledToBottom'], $settings['mobileNavigation'], $settings['showSubscriptionProgress'], $currencies, $lang); } if (count($subscriptions) == 0) { ?>

<?= translate('empty_page', $i18n) ?>
close(); ?> ================================================ FILE: endpoints/user/budget.php ================================================ prepare($sql); $stmt->bindValue(':budget', $budget, SQLITE3_TEXT); $stmt->bindValue(':userId', $userId, SQLITE3_TEXT); $result = $stmt->execute(); if ($result) { $response = [ "success" => true, "message" => translate('user_details_saved', $i18n) ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('error_updating_user_data', $i18n) ]; echo json_encode($response); } ?> ================================================ FILE: endpoints/user/delete_avatar.php ================================================ prepare("SELECT id FROM uploaded_avatars WHERE user_id = :userId AND path = :path"); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $stmt->bindValue(':path', $avatarPath, SQLITE3_TEXT); $result = $stmt->execute(); $ownership = $result->fetchArray(SQLITE3_ASSOC); if (!$ownership) { echo json_encode([ "success" => false, "message" => "Security Error: You do not have permission to delete this file." ]); exit; } $cleanAvatar = rawurldecode($avatar); $cleanAvatar = preg_replace('/[^a-zA-Z0-9_\-\.]/', '', $cleanAvatar); $filePath = realpath($baseDir . DIRECTORY_SEPARATOR . $cleanAvatar); if ($filePath === false || strpos($filePath, $baseDir) !== 0) { echo json_encode([ "success" => false, "message" => "Invalid file path." ]); exit; } $sql = "SELECT avatar FROM user WHERE id = :userId"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $userAvatar = $result->fetchArray(SQLITE3_ASSOC)['avatar']; // Check if $avatarPath matches the avatar in the user table if ($avatarPath === $userAvatar) { echo json_encode(array("success" => false, "message" => "Avatar in use")); } else { if (file_exists($filePath)) { unlink($filePath); $delStmt = $db->prepare("DELETE FROM uploaded_avatars WHERE id = :id"); $delStmt->bindValue(':id', $ownership['id'], SQLITE3_INTEGER); $delStmt->execute(); echo json_encode(array("success" => true, "message" => translate("success", $i18n))); } else { echo json_encode(array("success" => false, "message" => translate("error", $i18n))); } } } else { echo json_encode(array("success" => false, "message" => translate("error", $i18n))); } ?> ================================================ FILE: endpoints/user/disable_totp.php ================================================ = 80000) { trigger_error(sprintf($message, ...$args), E_USER_DEPRECATED); } } } $statement = $db->prepare('SELECT totp_enabled FROM user WHERE id = :id'); $statement->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $statement->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); if ($row['totp_enabled'] == 0) { die(json_encode([ "success" => false, "message" => "2FA is not enabled for this user", "reload" => true ])); } $postData = file_get_contents("php://input"); $data = json_decode($postData, true); if (isset($data['totpCode']) && $data['totpCode'] != "") { require_once __DIR__ . '/../../libs/OTPHP/FactoryInterface.php'; require_once __DIR__ . '/../../libs/OTPHP/Factory.php'; require_once __DIR__ . '/../../libs/OTPHP/ParameterTrait.php'; require_once __DIR__ . '/../../libs/OTPHP/OTPInterface.php'; require_once __DIR__ . '/../../libs/OTPHP/OTP.php'; require_once __DIR__ . '/../../libs/OTPHP/TOTPInterface.php'; require_once __DIR__ . '/../../libs/OTPHP/TOTP.php'; require_once __DIR__ . '/../../libs/Psr/Clock/ClockInterface.php'; require_once __DIR__ . '/../../libs/OTPHP/InternalClock.php'; require_once __DIR__ . '/../../libs/constant_time_encoding/Binary.php'; require_once __DIR__ . '/../../libs/constant_time_encoding/EncoderInterface.php'; require_once __DIR__ . '/../../libs/constant_time_encoding/Base32.php'; $totp_code = $data['totpCode']; $statement = $db->prepare('SELECT totp_secret FROM totp WHERE user_id = :id'); $statement->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $statement->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $secret = $row['totp_secret']; $statement = $db->prepare('SELECT backup_codes FROM totp WHERE user_id = :id'); $statement->bindValue(':id', $userId, SQLITE3_INTEGER); $result = $statement->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $backupCodes = $row['backup_codes']; $clock = new OTPHP\InternalClock(); $totp = OTPHP\TOTP::createFromSecret($secret, $clock); $totp->setPeriod(30); if ($totp->verify($totp_code, null, 15)) { $statement = $db->prepare('UPDATE user SET totp_enabled = 0 WHERE id = :id'); $statement->bindValue(':id', $userId, SQLITE3_INTEGER); $statement->execute(); $statement = $db->prepare('DELETE FROM totp WHERE user_id = :id'); $statement->bindValue(':id', $userId, SQLITE3_INTEGER); $statement->execute(); die(json_encode([ "success" => true, "message" => translate('success', $i18n), "reload" => true ])); } else { // Compare the TOTP code agains the backup codes // Normalize TOTP input $totp_code = strtolower(trim((string) $totp_code)); // Decode and normalize backup codes $backupCodes = json_decode($backupCodes, true); $normalizedBackupCodes = array_map(function ($code) { return strtolower(trim((string) $code)); }, $backupCodes); // Search for the normalized code if (($key = array_search($totp_code, $normalizedBackupCodes)) !== false) { // Match found, disable TOTP $statement = $db->prepare('UPDATE user SET totp_enabled = 0 WHERE id = :id'); $statement->bindValue(':id', $userId, SQLITE3_INTEGER); $statement->execute(); $statement = $db->prepare('DELETE FROM totp WHERE user_id = :id'); $statement->bindValue(':id', $userId, SQLITE3_INTEGER); $statement->execute(); die(json_encode([ "success" => true, "message" => translate('success', $i18n), "reload" => true ])); } else { die(json_encode([ "success" => false, "message" => translate('totp_code_incorrect', $i18n), "reload" => false ])); } } } else { die(json_encode([ "success" => false, "message" => translate('fields_missing', $i18n), "reload" => false ])); } ================================================ FILE: endpoints/user/enable_totp.php ================================================ = 80000) { trigger_error(sprintf($message, ...$args), E_USER_DEPRECATED); } } } $postData = file_get_contents("php://input"); $data = json_decode($postData, true) ?? []; $action = $data['action'] ?? ''; if ($action === 'generate') { function base32_encode($hex) { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $bin = ''; foreach (str_split($hex) as $char) { $bin .= str_pad(base_convert($char, 16, 2), 4, '0', STR_PAD_LEFT); } $chunks = str_split($bin, 5); $base32 = ''; foreach ($chunks as $chunk) { $chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT); $index = bindec($chunk); $base32 .= $alphabet[$index]; } return $base32; } $secret = base32_encode(bin2hex(random_bytes(20))); $qrCodeUrl = "otpauth://totp/Wallos:" . $_SESSION['username'] . "?secret=" . $secret . "&issuer=Wallos"; echo json_encode([ "success" => true, "secret" => $secret, "qrCodeUrl" => $qrCodeUrl, ]); exit; } if ($action === 'verify') { if (isset($data['totpSecret']) && $data['totpSecret'] != "" && isset($data['totpCode']) && $data['totpCode'] != "") { require_once __DIR__ . '/../../libs/OTPHP/FactoryInterface.php'; require_once __DIR__ . '/../../libs/OTPHP/Factory.php'; require_once __DIR__ . '/../../libs/OTPHP/ParameterTrait.php'; require_once __DIR__ . '/../../libs/OTPHP/OTPInterface.php'; require_once __DIR__ . '/../../libs/OTPHP/OTP.php'; require_once __DIR__ . '/../../libs/OTPHP/TOTPInterface.php'; require_once __DIR__ . '/../../libs/OTPHP/TOTP.php'; require_once __DIR__ . '/../../libs/Psr/Clock/ClockInterface.php'; require_once __DIR__ . '/../../libs/OTPHP/InternalClock.php'; require_once __DIR__ . '/../../libs/constant_time_encoding/Binary.php'; require_once __DIR__ . '/../../libs/constant_time_encoding/EncoderInterface.php'; require_once __DIR__ . '/../../libs/constant_time_encoding/Base32.php'; $secret = $data['totpSecret']; $totp_code = $data['totpCode']; // Check if user already has TOTP enabled $stmt = $db->prepare("SELECT totp_enabled FROM user WHERE id = :user_id"); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); if ($row['totp_enabled'] == 1) { die(json_encode([ "success" => false, "message" => translate('2fa_already_enabled', $i18n) ])); } $clock = new OTPHP\InternalClock(); $totp = OTPHP\TOTP::createFromSecret($secret, $clock); $totp->setPeriod(30); if ($totp->verify($totp_code, null, 15)) { // Generate 10 backup codes $backupCodes = []; for ($i = 0; $i < 10; $i++) { $backupCode = bin2hex(random_bytes(10)); $backupCodes[] = $backupCode; } // Remove old TOTP data $stmt = $db->prepare("DELETE FROM totp WHERE user_id = :user_id"); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->execute(); $stmt = $db->prepare("INSERT INTO totp (user_id, totp_secret, backup_codes, last_totp_used) VALUES (:user_id, :totp_secret, :backup_codes, :last_totp_used)"); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->bindValue(':totp_secret', $secret, SQLITE3_TEXT); $stmt->bindValue(':backup_codes', json_encode($backupCodes), SQLITE3_TEXT); $stmt->bindValue(':last_totp_used', time(), SQLITE3_INTEGER); $stmt->execute(); // Update user totp_enabled $stmt = $db->prepare("UPDATE user SET totp_enabled = 1 WHERE id = :user_id"); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->execute(); die(json_encode([ "success" => true, "backupCodes" => $backupCodes, "message" => translate('success', $i18n) ])); } else { die(json_encode([ "success" => false, "message" => translate('totp_code_incorrect', $i18n) ])); } } else { die(json_encode([ "success" => false, "message" => translate('totp_code_incorrect', $i18n) ])); } } ================================================ FILE: endpoints/user/regenerateapikey.php ================================================ prepare($sql); $stmt->bindValue(':apiKey', $apiKey, SQLITE3_TEXT); $stmt->bindValue(':userId', $userId, SQLITE3_TEXT); $result = $stmt->execute(); if ($result) { $response = [ "success" => true, "message" => translate('user_details_saved', $i18n), "apiKey" => $apiKey ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('error_updating_user_data', $i18n) ]; echo json_encode($response); } ================================================ FILE: endpoints/user/save_user.php ================================================ prepare($query); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { $row = $result->fetchArray(SQLITE3_ASSOC); if ($row) { $apiKey = $row['api_key']; $provider = $row['provider']; $codes = ""; $query = "SELECT id, name, symbol, code FROM currencies"; $result = $db->query($query); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $codes .= $row['code'] . ","; } $codes = rtrim($codes, ','); $query = "SELECT u.main_currency, c.code FROM user u LEFT JOIN currencies c ON u.main_currency = c.id WHERE u.id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $mainCurrencyCode = $row['code']; $mainCurrencyId = $row['main_currency']; if ($provider === 1) { $api_url = "https://api.apilayer.com/fixer/latest?base=EUR&symbols=" . $codes; $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'header' => 'apikey: ' . $apiKey, ] ]); $response = file_get_contents($api_url, false, $context); } else { $api_url = "http://data.fixer.io/api/latest?access_key=" . $apiKey . "&base=EUR&symbols=" . $codes; $response = file_get_contents($api_url); } $apiData = json_decode($response, true); $mainCurrencyToEUR = $apiData['rates'][$mainCurrencyCode]; if ($apiData !== null && isset($apiData['rates'])) { foreach ($apiData['rates'] as $currencyCode => $rate) { if ($currencyCode === $mainCurrencyCode) { $exchangeRate = 1.0; } else { $exchangeRate = $rate / $mainCurrencyToEUR; } $updateQuery = "UPDATE currencies SET rate = :rate WHERE code = :code AND user_id = :userId"; $updateStmt = $db->prepare($updateQuery); $updateStmt->bindParam(':rate', $exchangeRate, SQLITE3_TEXT); $updateStmt->bindParam(':code', $currencyCode, SQLITE3_TEXT); $updateStmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $updateResult = $updateStmt->execute(); } $currentDate = new DateTime(); $formattedDate = $currentDate->format('Y-m-d'); $query = "SELECT * FROM last_exchange_update WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); if ($row) { $query = "UPDATE last_exchange_update SET date = :formattedDate WHERE user_id = :userId"; } else { $query = "INSERT INTO last_exchange_update (date, user_id) VALUES (:formattedDate, :userId)"; } $stmt = $db->prepare($query); $stmt->bindParam(':formattedDate', $formattedDate, SQLITE3_TEXT); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $resutl = $stmt->execute(); $db->close(); } } } } $demoMode = getenv('DEMO_MODE'); $query = "SELECT main_currency FROM user WHERE id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $mainCurrencyId = $row['main_currency']; function sanitizeFilename($filename) { $filename = preg_replace("/[^a-zA-Z0-9\s]/", "", $filename); $filename = str_replace(" ", "-", $filename); $filename = str_replace(".", "", $filename); return $filename; } function validateFileExtension($fileExtension) { $allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'jtif', 'webp']; return in_array($fileExtension, $allowedExtensions); } function resizeAndUploadAvatar($uploadedFile, $uploadDir, $name) { $targetWidth = 80; $targetHeight = 80; $timestamp = time(); $originalFileName = $uploadedFile['name']; $fileExtension = strtolower(pathinfo($originalFileName, PATHINFO_EXTENSION)); $fileExtension = validateFileExtension($fileExtension) ? $fileExtension : 'png'; $fileName = $timestamp . '-avatars-' . sanitizeFilename($name) . '.' . $fileExtension; $uploadFile = $uploadDir . $fileName; if (move_uploaded_file($uploadedFile['tmp_name'], $uploadFile)) { $fileInfo = getimagesize($uploadFile); if ($fileInfo !== false) { $width = $fileInfo[0]; $height = $fileInfo[1]; // Load the image based on its format if ($fileExtension === 'png') { $image = imagecreatefrompng($uploadFile); } elseif ($fileExtension === 'jpg' || $fileExtension === 'jpeg') { $image = imagecreatefromjpeg($uploadFile); } elseif ($fileExtension === 'gif') { $image = imagecreatefromgif($uploadFile); } elseif ($fileExtension === 'webp') { $image = imagecreatefromwebp($uploadFile); } else { // Handle other image formats as needed return ""; } // Enable alpha channel (transparency) for PNG images if ($fileExtension === 'png') { imagesavealpha($image, true); } $newWidth = $width; $newHeight = $height; if ($width > $targetWidth) { $newWidth = (int)$targetWidth; $newHeight = (int)(($targetWidth / $width) * $height); } if ($newHeight > $targetHeight) { $newWidth = (int)(($targetHeight / $newHeight) * $newWidth); $newHeight = (int)$targetHeight; } $resizedImage = imagecreatetruecolor($newWidth, $newHeight); imagesavealpha($resizedImage, true); $transparency = imagecolorallocatealpha($resizedImage, 0, 0, 0, 127); imagefill($resizedImage, 0, 0, $transparency); imagecopyresampled($resizedImage, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height); if ($fileExtension === 'png') { imagepng($resizedImage, $uploadFile); } elseif ($fileExtension === 'jpg' || $fileExtension === 'jpeg') { imagejpeg($resizedImage, $uploadFile); } elseif ($fileExtension === 'gif') { imagegif($resizedImage, $uploadFile); } elseif ($fileExtension === 'webp') { imagewebp($resizedImage, $uploadFile); } else { return ""; } imagedestroy($image); imagedestroy($resizedImage); return "images/uploads/logos/avatars/" . $fileName; } } return ""; } if ( isset($_SESSION['username']) && isset($_POST['firstname']) && isset($_POST['lastname']) && isset($_POST['email']) && $_POST['email'] !== "" && isset($_POST['avatar']) && $_POST['avatar'] !== "" && isset($_POST['main_currency']) && $_POST['main_currency'] !== "" && isset($_POST['language']) && $_POST['language'] !== "" ) { $firstname = validate($_POST['firstname']); $lastname = validate($_POST['lastname']); $email = validate($_POST['email']); $query = "SELECT email FROM user WHERE id = :user_id"; $stmt = $db->prepare($query); $stmt->bindValue(':user_id', $userId, SQLITE3_TEXT); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); $oldEmail = $user['email']; if ($oldEmail != $email) { $query = "SELECT email FROM user WHERE email = :email AND id != :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':email', $email, SQLITE3_TEXT); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $otherUser = $result->fetchArray(SQLITE3_ASSOC); if ($otherUser) { $response = [ "success" => false, "message" => translate('email_exists', $i18n) ]; echo json_encode($response); exit(); } } $avatar = filter_var($_POST['avatar'], FILTER_SANITIZE_URL); $main_currency = $_POST['main_currency']; $language = $_POST['language']; if (!empty($_FILES['profile_pic']["name"])) { $file = $_FILES['profile_pic']; $fileType = mime_content_type($_FILES['profile_pic']['tmp_name']); if (strpos($fileType, 'image') === false) { $response = [ "success" => false, "message" => translate('fill_all_fields', $i18n) ]; echo json_encode($response); exit(); } $name = $file['name']; $avatar = resizeAndUploadAvatar($_FILES['profile_pic'], '../../images/uploads/logos/avatars/', $name); if ($avatar !== "") { $stmt = $db->prepare("INSERT INTO uploaded_avatars (user_id, path) VALUES (:userId, :path)"); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $stmt->bindParam(':path', $avatar, SQLITE3_TEXT); $stmt->execute(); } } if (isset($_POST['password']) && $_POST['password'] != "" && !$demoMode) { $password = $_POST['password']; if (isset($_POST['confirm_password'])) { $confirm = $_POST['confirm_password']; if ($password != $confirm) { $response = [ "success" => false, "message" => translate('passwords_dont_match', $i18n) ]; echo json_encode($response); exit(); } } else { $response = [ "success" => false, "message" => translate('passwords_dont_match', $i18n) ]; echo json_encode($response); exit(); } } if (isset($_POST['password']) && $_POST['password'] != "" && !$demoMode) { $sql = "UPDATE user SET avatar = :avatar, firstname = :firstname, lastname = :lastname, email = :email, password = :password, main_currency = :main_currency, language = :language WHERE id = :userId"; } else { $sql = "UPDATE user SET avatar = :avatar, firstname = :firstname, lastname = :lastname, email = :email, main_currency = :main_currency, language = :language WHERE id = :userId"; } $stmt = $db->prepare($sql); $stmt->bindParam(':avatar', $avatar, SQLITE3_TEXT); $stmt->bindParam(':firstname', $firstname, SQLITE3_TEXT); $stmt->bindParam(':lastname', $lastname, SQLITE3_TEXT); $stmt->bindParam(':email', $email, SQLITE3_TEXT); $stmt->bindParam(':main_currency', $main_currency, SQLITE3_INTEGER); $stmt->bindParam(':language', $language, SQLITE3_TEXT); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); if (isset($_POST['password']) && $_POST['password'] != "" && !$demoMode) { $hashedPassword = password_hash($password, PASSWORD_DEFAULT); $stmt->bindParam(':password', $hashedPassword, SQLITE3_TEXT); } $result = $stmt->execute(); if ($result) { $cookieExpire = time() + (30 * 24 * 60 * 60); $oldLanguage = isset($_COOKIE['language']) ? $_COOKIE['language'] : "en"; $root = str_replace('/endpoints/user', '', dirname($_SERVER['PHP_SELF'])); $root = $root == '' ? '/' : $root; setcookie('language', $language, [ 'path' => $root, 'expires' => $cookieExpire, 'samesite' => 'Strict' ]); $_SESSION['firstname'] = $firstname; $_SESSION['avatar'] = $avatar; $_SESSION['main_currency'] = $main_currency; if ($main_currency != $mainCurrencyId) { update_exchange_rate($db, $userId); } $reload = $oldLanguage != $language; $response = [ "success" => true, "message" => translate('user_details_saved', $i18n), "reload" => $reload ]; echo json_encode($response); } else { $response = [ "success" => false, "message" => translate('error_updating_user_data', $i18n) ]; echo json_encode($response); } exit(); } else { $response = [ "success" => false, "message" => translate('fill_all_fields', $i18n) ]; echo json_encode($response); exit(); } ================================================ FILE: health.php ================================================ ================================================ FILE: images/siteicons/svg/category.php ================================================ Page Setting Streamline Icon: https://streamlinehq.com ================================================ FILE: images/siteicons/svg/check.php ================================================ Check Thick Streamline Icon: https://streamlinehq.com '; ?> ================================================ FILE: images/siteicons/svg/clone.php ================================================ ================================================ FILE: images/siteicons/svg/delete.php ================================================ ================================================ FILE: images/siteicons/svg/edit.php ================================================ Pencil Square Streamline Icon: https://streamlinehq.com ================================================ FILE: images/siteicons/svg/export_ical.php ================================================ ================================================ FILE: images/siteicons/svg/logo.php ================================================ ================================================ FILE: images/siteicons/svg/manual.php ================================================ ================================================ FILE: images/siteicons/svg/mobile-menu/about.php ================================================ ================================================ FILE: images/siteicons/svg/mobile-menu/admin.php ================================================ ================================================ FILE: images/siteicons/svg/mobile-menu/calendar.php ================================================ ================================================ FILE: images/siteicons/svg/mobile-menu/clone.php ================================================ ================================================ FILE: images/siteicons/svg/mobile-menu/delete.php ================================================ ================================================ FILE: images/siteicons/svg/mobile-menu/edit.php ================================================ ================================================ FILE: images/siteicons/svg/mobile-menu/home.php ================================================ ================================================ FILE: images/siteicons/svg/mobile-menu/logout.php ================================================ ================================================ FILE: images/siteicons/svg/mobile-menu/profile.php ================================================ ================================================ FILE: images/siteicons/svg/mobile-menu/renew.php ================================================ ================================================ FILE: images/siteicons/svg/mobile-menu/settings.php ================================================ ================================================ FILE: images/siteicons/svg/mobile-menu/statistics.php ================================================ ================================================ FILE: images/siteicons/svg/mobile-menu/subscriptions.php ================================================ ================================================ FILE: images/siteicons/svg/notes.php ================================================ Notepad Text Streamline Icon: https://streamlinehq.com ================================================ FILE: images/siteicons/svg/payment.php ================================================ Payment Recieve 7 Streamline Icon: https://streamlinehq.com ================================================ FILE: images/siteicons/svg/renew.php ================================================ ================================================ FILE: images/siteicons/svg/save.php ================================================ File Check Alternate Streamline Icon: https://streamlinehq.com ================================================ FILE: images/siteicons/svg/subscription.php ================================================ Layers 1 Streamline Icon: https://streamlinehq.com ================================================ FILE: images/siteicons/svg/web.php ================================================ Web Streamline Icon: https://streamlinehq.com ================================================ FILE: images/siteicons/svg/websearch.php ================================================ Search Visual Streamline Icon: https://streamlinehq.com ================================================ FILE: includes/checkredirect.php ================================================ prepare("SELECT COUNT(*) FROM subscriptions WHERE user_id = :userId"); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_NUM); $subscriptionCount = $row[0]; if ($subscriptionCount === 0) { header('Location: subscriptions.php'); exit; } } ================================================ FILE: includes/checksession.php ================================================ prepare($sql); $stmt->bindValue(':username', $username, SQLITE3_TEXT); $result = $stmt->execute(); $userData = $result->fetchArray(SQLITE3_ASSOC); $userId = $userData['id']; if ($userData === false) { header('Location: logout.php'); exit(); } else { $_SESSION['userId'] = $userData['id']; } if ($userData['avatar'] == "") { $userData['avatar'] = "0"; } } else { if (isset($_COOKIE['wallos_login'])) { $cookie = explode('|', $_COOKIE['wallos_login'], 3); $username = $cookie[0]; $token = $cookie[1]; $main_currency = $cookie[2]; $sql = "SELECT * FROM user WHERE username = :username"; $stmt = $db->prepare($sql); $stmt->bindValue(':username', $username, SQLITE3_TEXT); $result = $stmt->execute(); if ($result) { $userData = $result->fetchArray(SQLITE3_ASSOC); if (!isset($userData['id'])) { $db->close(); header("Location: logout.php"); exit(); } if ($userData['avatar'] == "") { $userData['avatar'] = "0"; } $userId = $userData['id']; $main_currency = $userData['main_currency']; $adminQuery = "SELECT login_disabled FROM admin"; $adminResult = $db->query($adminQuery); $adminRow = $adminResult->fetchArray(SQLITE3_ASSOC); if ($adminRow['login_disabled'] == 1) { $sql = "SELECT * FROM login_tokens WHERE user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindParam(':userId', $userId, SQLITE3_TEXT); } else { $sql = "SELECT * FROM login_tokens WHERE user_id = :userId AND token = :token"; $stmt = $db->prepare($sql); $stmt->bindParam(':userId', $userId, SQLITE3_TEXT); $stmt->bindParam(':token', $token, SQLITE3_TEXT); } $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); if ($row != false) { $_SESSION['username'] = $username; $_SESSION['token'] = $token; $_SESSION['loggedin'] = true; $_SESSION['main_currency'] = $main_currency; $_SESSION['userId'] = $userId; } else { $db->close(); header("Location: logout.php"); exit(); } } else { $db->close(); header("Location: logout.php"); exit(); } } else { $db->close(); header("Location: login.php"); exit(); } } } ?> ================================================ FILE: includes/checkuser.php ================================================ query($query); $row = $result->fetchArray(SQLITE3_ASSOC); $userCount = $row['count']; ?> ================================================ FILE: includes/connect.php ================================================ busyTimeout(5000); if (!$db) { die('Connection to the database failed.'); } ?> ================================================ FILE: includes/connect_endpoint.php ================================================ busyTimeout(5000); if (!$db) { die('Connection to the database failed.'); } require_once 'i18n/languages.php'; require_once 'i18n/getlang.php'; require_once 'i18n/' . $lang . '.php'; session_start(); if (isset($_SESSION['loggedin']) && $_SESSION['loggedin'] === true) { $userId = $_SESSION['userId']; } else { $userId = 0; } ?> ================================================ FILE: includes/connect_endpoint_crontabs.php ================================================ busyTimeout(5000); if (!$db) { die('Connection to the database failed.'); } require_once __DIR__ . '/../includes/i18n/languages.php'; require_once __DIR__ . '/../includes/i18n/getlang.php'; require_once __DIR__ . '/../includes/i18n/' . $lang . '.php'; ?> ================================================ FILE: includes/currency_formatter.php ================================================ formatCurrency($amount, $currency); } } ================================================ FILE: includes/filters_menu.php ================================================
1) { ?>
1) { ?>
1) { ?>
================================================ FILE: includes/footer.php ================================================
close(); } ?> ================================================ FILE: includes/getdbkeys.php ================================================ prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $currencyId = $row['id']; $currencies[$currencyId] = $row; } $members = array(); $query = "SELECT * FROM household WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $memberId = $row['id']; $members[$memberId] = $row; $members[$memberId]['count'] = 0; } $payment_methods = array(); $query = $db->prepare("SELECT * FROM payment_methods WHERE enabled=:enabled AND user_id = :userId ORDER BY `order` ASC"); $query->bindValue(':enabled', 1, SQLITE3_INTEGER); $query->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $query->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $payment_methodId = $row['id']; $payment_methods[$payment_methodId] = $row; $payment_methods[$payment_methodId]['count'] = 0; } $categories = array(); $query = "SELECT * FROM categories WHERE user_id = :userId ORDER BY `order` ASC"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $categoryId = $row['id']; $categories[$categoryId] = $row; $categories[$categoryId]['count'] = 0; } $cycles = array(); $query = "SELECT * FROM cycles"; $result = $db->query($query); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $cycleId = $row['id']; $cycles[$cycleId] = $row; } $frequencies = array(); for ($i = 1; $i <= 366; $i++) { $frequencies[$i] = array('id' => $i, 'name' => $i); } ?> ================================================ FILE: includes/getsettings.php ================================================ prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $settings = $result->fetchArray(SQLITE3_ASSOC); if ($settings !== false) { $themeMapping = array(0 => 'light', 1 => 'dark', 2 => 'automatic'); $themeKey = isset($settings['dark_theme']) ? $settings['dark_theme'] : 2; $themeValue = $themeMapping[$themeKey]; $settings['update_theme_setttings'] = false; if (isset($_COOKIE['inUseTheme']) && $settings['dark_theme'] == 2) { $inUseTheme = $_COOKIE['inUseTheme']; $settings['theme'] = $inUseTheme; } else { $settings['theme'] = $themeValue; } if ($themeValue == "automatic") { $settings['update_theme_setttings'] = true; } $settings['color_theme'] = $settings['color_theme'] ? $settings['color_theme'] : "blue"; $settings['showMonthlyPrice'] = $settings['monthly_price'] ? 'true': 'false'; $settings['convertCurrency'] = $settings['convert_currency'] ? 'true': 'false'; $settings['removeBackground'] = $settings['remove_background'] ? 'true': 'false'; $settings['hideDisabledSubscriptions'] = $settings['hide_disabled'] ? 'true': 'false'; $settings['disabledToBottom'] = $settings['disabled_to_bottom'] ? 'true': 'false'; $settings['showOriginalPrice'] = $settings['show_original_price'] ? 'true': 'false'; $settings['mobileNavigation'] = $settings['mobile_nav'] ? 'true': 'false'; $settings['showSubscriptionProgress'] = $settings['show_subscription_progress'] ? 'true': 'false'; } $query = "SELECT * FROM custom_colors WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $customColors = $result->fetchArray(SQLITE3_ASSOC); if ($customColors !== false) { $settings['customColors'] = $customColors; } $query = "SELECT * FROM custom_css_style WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $customCss = $result->fetchArray(SQLITE3_ASSOC); if ($customCss !== false) { $settings['customCss'] = $customCss['css']; } $query = "SELECT * FROM admin"; $result = $db->query($query); $adminSettings = $result->fetchArray(SQLITE3_ASSOC); if ($adminSettings !== false) { $settings['disableLogin'] = $adminSettings['login_disabled']; $settings['update_notification'] = $adminSettings['update_notification']; $settings['latest_version'] = $adminSettings['latest_version']; } ?> ================================================ FILE: includes/header.php ================================================ close(); header("Location: registration.php"); exit(); } $demoMode = getenv('DEMO_MODE'); $theme = "automatic"; if (isset($settings['theme'])) { $theme = $settings['theme']; } $updateThemeSettings = false; if (isset($settings['update_theme_setttings'])) { $updateThemeSettings = $settings['update_theme_setttings']; } $colorTheme = "blue"; if (isset($settings['color_theme'])) { $colorTheme = $settings['color_theme']; } $customCss = ""; if (isset($settings['customCss'])) { $customCss = $settings['customCss']; } if (isset($themeValue)) { $cookieExpire = time() + (30 * 24 * 60 * 60); setcookie('theme', $themeValue, [ 'expires' => $cookieExpire, 'samesite' => 'Strict' ]); } $isAdmin = $_SESSION['userId'] == 1; $locale = isset($_COOKIE['user_locale']) ? $_COOKIE['user_locale'] : 'en_US'; $formatter = new IntlDateFormatter( $locale, IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE ); function hex2rgb($hex) { $hex = str_replace("#", "", $hex); if (strlen($hex) == 3) { $r = hexdec(substr($hex, 0, 1) . substr($hex, 0, 1)); $g = hexdec(substr($hex, 1, 1) . substr($hex, 1, 1)); $b = hexdec(substr($hex, 2, 1) . substr($hex, 2, 1)); } else { $r = hexdec(substr($hex, 0, 2)); $g = hexdec(substr($hex, 2, 2)); $b = hexdec(substr($hex, 4, 2)); } return "$r, $g, $b"; } $mobileNavigation = $settings['mobile_nav'] ? "mobile-navigation" : ""; ?> Wallos - Subscription Tracker " id="theme-color" /> > > > > >
================================================ FILE: includes/i18n/ca.php ================================================ "Necessites crear un compte abans de poder iniciar sessió", "username" => "Nom d'Usuari", "password" => "Contrasenya", "email" => "Correu Electrònic", "firstname" => "Nom", "lastname" => "Cognom", "confirm_password" => "Confirmar Contrasenya", "main_currency" => "Moneda Principal", "language" => "Idioma", "passwords_dont_match" => "Les contrasenyes no coincideixen", "username_exists" => "El nom d'usuari ja existeix", "email_exists" => "El correu electrònic ja està registrat", "registration_failed" => "Error en el registre, intenti-ho de nou.", "register" => "Registrar", "restore_database" => "Restaurar Base de Dades", // Login Page "please_login" => "Si us plau, inicia sessió", "stay_logged_in" => "Mantenir sessió iniciada (30 dies)", "login" => "Iniciar Sessió", "login_with" => "Iniciar sessió amb", "or" => "o", "login_failed" => "Els detalls d'inici de sessió son incorrectes", "registration_successful" => "Registre completat amb èxit", "user_email_waiting_verification" => "S'ha de confirmar el teu correu electrònic. Comprova la teva bústia", // Password Reset Page "forgot_password" => "Has oblidat la contrasenya?", "reset_password" => "Restablir Contrasenya", "reset_sent_check_email" => "S'ha enviat un correu electrònic per restablir la contrasenya. Comprova la teva bústia.", "password_reset_successful" => "Contrasenya restablerta correctament", // Header "profile" => "Perfil", "dashboard" => "Taulell", "subscriptions" => "Subscripcions", "stats" => "Estadístiques", "settings" => "Configuració", "admin" => "Admin", "about" => "Quant a", "logout" => "Tanca la Sessió", // Dashboard "hello" => "Hola", "upcoming_payments" => "Pròxims Pagaments", "no_upcoming_payments" => "No tens pròxims pagaments", "overdue_renewals" => "Renovacions Endarrerides", "ai_recommendations" => "Recomanacions per IA", "your_budget" => "El Teu Pressupost", "budget" => "Pressupost", "budget_used" => "Pressupost Utilitzat", "over_budget" => "Sobre Pressupost", "your_subscriptions" => "Les Teves Subscripcions", "your_savings" => "El Teu Estalvi", // Subscriptions page "subscription" => "Subscripció", "no_subscriptions_yet" => "Encara no tens cap subscripció", "add_first_subscription" => "Afegir la primera subscripció", "new_subscription" => "Nova Subscripció", "search" => "Buscar", "state" => "Estat", "alphanumeric" => "Alfabètic", "sort" => "Ordenar", "name" => "Nom", "last_added" => "Última Afegida", "price" => "Preu", "next_payment" => "Pròxim Pagament", "renewal_type" => "Tipus de Renovació", "auto_renewal" => "Renovació Automàtica", "automatically_renews" => "Renova Automàticament", "manual_renewal" => "Renovació Manual", "start_date" => "Data d'Inici", "inactive" => "Desactivar Subscripció", "replaced_with" => "Reemplaçada amb", "none" => "Cap", "member" => "Membre", "category" => "Categoria", "payment_method" => "Mètode de Pagament", "Daily" => "Diari", "Weekly" => "Setmanal", "Monthly" => "Mensual", "Yearly" => "Anual", "daily" => "Dia / Dies", "weekly" => "Setmana / Setmanes", "monthly" => "Mes / Mesos", "yearly" => "Any / Anys", "days" => "dies", "weeks" => "setmanes", "months" => "mesos", "years" => "anys", "external_url" => "Visitar URL Externa", "empty_page" => "Pàgina Buida", "clear_filters" => "Netejar Filtres", "no_matching_subscriptions" => "No s'han trobat subscripcions coincidents", "clone" => "Clonar", "renew" => "Renovar", "calculate_next_payment_date" => "Calcular data del pròxim pagament", // Subscription form "add_subscription" => "Afegir subscripció", "edit_subscription" => "Editar", "subscription_name" => "Nom de la Subscripció", "logo_preview" => "Vista Prèvia del Logotip", "search_logo" => "Buscar logotip a la web", "web_search" => "Cerca web", "currency" => "Moneda", "payment_every" => "Pagament cada", "frequency" => "Freqüència", "cycle" => "Cicle", "no_category" => "Sense categoria", "paid_by" => "Pagat per", "url" => "URL", "notes" => "Notes", "enable_notifications" => "Activar notificacions per aquesta subscripció", "default_value_from_settings" => "Valor predeterminat al panell de Configuració", "cancellation_notification" => "Notificació de cancel·lació", "delete" => "Eliminar", "cancel" => "Cancel·lar", "upload_logo" => "Carregar Logotip", // Statistics page "cant_convert_currency" => "Tens subscripcions en diverses divises. Per obtenir estadístiques vàlides i precises, introdueix una clau API Fixer a la pàgina de configuració.", "general_statistics" => "Estadístiques Generals", "active_subscriptions" => "Subscripcions Actives", "inactive_subscriptions" => "Subscripcions Inactives", "monthly_cost" => "Cost Mensual", "yearly_cost" => "Cost Anual", "average_monthly" => "Cost mitjà mensual per subscripció", "most_expensive" => "Cost de subscripció més car", "amount_due" => "Import a pagar aquest mes", "percentage_budget_used" => "Percentatge utilizat del pressupost", "budget_remaining" => "Pressupost restant", "amount_over_budget" => "Import per sobre del pressupost", "monthly_savings" => "Estalvi Mensual (en subscripcions no actives)", "yearly_savings" => "Estalvi Anual (en subscripcions no actives)", "split_views" => "Vistes Dividides", "category_split" => "Divisió per Categoria", "household_split" => "Divisió per Membres Familiars", "payment_method_split" => "Divisió per Mètode de Pagament", "total_cost_trend" => "Tendència del Cost Total", "cost_vs_budget" => "Cost vs Pressupost", // About page "about_and_credits" => "Quant a i Crèdits", "credits" => "Crèdits", "license" => "Llicència", "release_notes" => "Notes de la Versió", "update_available" => "Actualtizació Disponible", "issues_and_requests" => "Problemes i Sol·licituds", "the_author" => "L'autor", "icons" => "Icones", "payment_icons" => "Icones de Pagament", // Profile page "upload_avatar" => "Penjar avatar", "file_type_error" => "El fitxer ha de ser una imatge en format PNG, JPG, WEBP o SVG", "user_details" => "Detalls de l'Usuari", "two_factor_authentication" => "Autenticació de Dos Factors", "two_factor_info" => "L'autenticació de dos factors afegeix una capa addicional de seguretat al teu compte,
Necessitaràs una aplicació d’autenticació com Google Authenticator, Authy o Ente Auth per escanejar en codi QR.", "two_factor_enabled_info" => "El teu compte està segur amb l'autenticació de dos factors. Pots desactivar-la fent clic al botó de dalt.", "enable_two_factor_authentication" => "Activar Autenticació de Dos Factors", "2fa_already_enabled" => "L'autenticació de dos factors ja està activada", "totp_code_incorrect" => "El codi TOTP no és correcte", "backup_codes" => "Codis de Seguretat", "download_backup_codes" => "Descarregar codis de seguretat", "copy_to_clipboard" => "Copiar al Porta-retalls", "totp_backup_codes_info" => "Guarda aquests codis en un lloc segur. Pots utilitzar-los si perds l'accés a l'aplicació d'autenticació.", "disable_two_factor_authentication" => "Desactivar l'Autenticació de Dos Factors ", "totp_code" => "Codi TOTP", "api_key" => "Clau API", "regenerate" => "Regenerar", "api_key_info" => "La clau API s'utilitza per accedir a l'API de Wallos. No la comparteixis amb ningú.", // Settings page "monthly_budget" => "Pressupost Mensual", "budget_info" => "El pressupost mensual s'utilitza per calcular les estadístiques. Si no vols utilitzar aquesta funció, deixa'l a 0.", "household" => "Familia", "save_member" => "Guardar Membre", "delete_member" => "Eliminar Membre", "cant_delete_member" => "No es pot eliminar el membre principal", "cant_delete_member_in_use" => "No es pot eliminar a un membre que utilitza una subscripció", "household_info" => "El correu electrònic permet notificar als membres de la llar les subscripcions que estan a punt de caducar.", "notifications" => "Notificacions", "enable_email_notifications" => "Activar notificacions per correu electrònic", "notify_me" => "Notificar-me", "day_before" => "dia abans", "on_due_date" => "A la data de venciment", "days_before" => "dies abans", "smtp_address" => "Direcció SMTP", "port" => "Port", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "Nom d'usuari SMTP", "smtp_password" => "Contrasenya SMTP", "from_email" => "Correu electrònic d'origen (Opcional)", "send_to_other_emails" => "Enviar també notificacions als següents correus electrònics (utilitza ; per separar-los):", "smtp_info" => "La contrasenya SMTP es desa i es transmet en text pla. Per seguretat, crea un compte només per això.", "other_emails_placeholder" => "usuari@domini.com;test@usuari.com", "telegram" => "Telegram", "telegram_bot_token" => "Token del Bot de Telegram", "telegram_chat_id" => "ID del Xat de Telegram", "pushplus" => "Pushplus", "pushplus_token" => "Token de Pushplus", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "URL del Webhook de Mattermost", "mattermost_bot_username" => "Nom d'usuari del Bot de Mattermost", "mattermost_bot_icon_emoji" => "Emoji de la Icona del Bot", "webhook" => "Webhook", "webhook_url" => "URL del Webhook", "request_method" => "Mètode de Petició", "custom_headers" => "Capçaleres Personalitzades", "webhook_payload" => "Càrrega Útil del Webhook", "payment_notifications_payload" => "Càrrega Útil de la Notificació de Pagament", "cancelation_notification_payload" => "Càrrega Útil de la Notificació de Cancel·lació", "variables_available" => "Variables disponibles", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Nom d'usuari del bot", "discord_bot_avatar_url" => "URL de l'avatar del bot", "pushover" => "Pushover", "pushover_user_key" => "Clau d'usuari", "host" => "Host", "topic" => "Tema", "ignore_ssl_errors" => "Ignorar errors SSL", "categories" => "Categories", "save_category" => "Desar Categoria", "delete_category" => "Eliminar Categoria", "cant_delete_category_in_use" => "No es pot eliminar una categoria utilitzada en una subscripció", "currencies" => "Divises", "save_currency" => "Desar Divisa", "delete_currency" => "Eliminar Divisa", "cant_delete_main_currency" => "No es pot eliminar la divisa principal", "cant_delete_currency_in_use" => "No es pot eliminar una divisa utilitzada en una subscripció", "exchange_update" => "Tipus de canvi actualitzats per última vegada el", "currency_info" => "Troba les divises admeses i els codis de moneda correctes a", "currency_performance" => "Per millorar el rendiment, elimina les divises que no utilitzis.", "fixer_api_key" => "Clau API de Fixer", "provider" => "Proveïdor", "fixer_info" => "Si utilitzes varies divises i vols que les estadístiques siguin precises, és necessària una clau API gratuïta de Fixer.", "get_key" => "Obtingues la teva clau a", "get_free_fixer_api_key" => "Obtén una API Key de Fixer gratuita", "get_key_alternative" => "També pots obtenir una clau gratuïta de Fixer a", "ai_model" => "Model de IA", "select_ai_model" => "Seleccionar Model de IA", "run_schedule" => "Executar Tasca", "manually" => "Manualment", "coming_soon" => "Pròximament", "invalid_host" => "Host Invàid", "ai_recommendations_info" => "Les recomanacions per IA es generen en funció de les subscripcions i els membres de la llar.", "may_take_time" => "En funció del proveïdor, model i número de subscripcions, la generació de recomanacions pot trigar un temps.", "recommendations_visible_on_dashboard" => "Les recomanacions seran visibles al taulell.", "generate_recommendations" => "Generar Recomanacions", "display_settings" => "Configuració de Pantalla", "theme_settings" => "Configuració del Tema", "colors" => "Colors", "custom_colors" => "Colors Personalitzats", "theme" => "Tema", "dark_theme" => "Tema Fosc", "light_theme" => "Tema Clar", "automatic" => "Automàtic", "main_color" => "Color Primari", "accent_color" => "Color Accent", "hover_color" => "Color de Hover", "save_custom_colors" => "Desar Colors Personalitzats", "reset_custom_colors" => "Restablir Colors Personalitzats", "custom_css" => "CSS Personalitzat", "save_custom_css" => "Desar CSS Personalitzat", "calculate_monthly_price" => "Calcular i mostrar el preu mensual de totes les subscripcions", "convert_prices" => "Convertir i mostrar sempre els preus en la meva divisa principal (més lent)", "show_original_price" => "Mostrar també el preu original quan es realitzin conversions o càlculs", "experience" => "Experiència", "show_subscription_progress" => "Mostrar el progrés de la subscripció", "disabled_subscriptions" => "Subscripcions Desactivades", "hide_disabled_subscriptions" => "Amagar subscripcions desactivades", "show_disabled_subscriptions_at_the_bottom" => "Mostrar subscripcions desactivades al final", "experimental_settings" => "Configuracions Experimentals", "remove_background" => "Intentar eliminar el fons dels logotips quan es fa la cerca d'imatges", "use_mobile_navigation_bar" => "Utilitzar la barra de navegació mòbil", "experimental_info" => "És possible que les configuracions experimentals no funcionin perfectament.", "payment_methods" => "Mètodes de Pagament", "payment_methods_info" => "Fes clic en un mètode de pagament per desactivar-lo/activar-lo.", "rename_payment_methods_info" => "Fes clic en el nom d'un mètode de pagament per canviar-lo.", "cant_delete_payment_method_in_use" => "No es pot desactivar un mètode de pagament en ús", "add_custom_payment" => "Afegir mètode de pagament personalitzat", "payment_method_name" => "Nom del mètode de pagament", "payment_method_added_successfuly" => "Mètode de pagament afegit correctament", "payment_method_removed" => "Mètode de pagament eliminat", "disable" => "Desactivar", "enable" => "Activar", "rename_payment_method" => "Reanomenar mètode de pagament", "payment_renamed" => "Mètode de pagament reanomenat", "payment_not_renamed" => "Error al reanomenar el mètode de pagament", "test" => "Probar", "add" => "Afegir", "save" => "Desar", "reset" => "Restablir", "main_accent_color_error" => "El color primari i el color accent no poden ser el mateix", "backup_and_restore" => "Còpia de Seguretat i Restauració", "backup" => "Còpia de Seguretat", "restore" => "Restaurar", "restore_info" => "La restauració de la base de dades anul·larà totes les dades actuals. Un cop finalitzada la restauració es tancarà la sessió.", "account" => "Compte", "export_subscriptions" => "Exportar Subscripcions", "export_as_json" => "Exportar com JSON", "export_as_csv" => "Exportar com CSV", "danger_zone" => "Zona de perill", "delete_account" => "Eliminar compte", "delete_account_info" => "Al eliminar el compte també s'eliminaran totes les subscripcions i configuracions.", // Filters menu "filter" => "Filtrar", "clear" => "Netejar", // Toast "success" => "Èxit", // Endpoint responses "session_expired" => "La sessió ha expirat. Inicia sessió de nou", "fields_missing" => "Falten alguns camps", "fill_all_fields" => "Si us plau, completa tots els camps", "fill_mandatory_fields" => "Si us plau, completa tots els camps obligatoris", "error" => "Error", // Category "failed_add_category" => "Error al afegir la categoria", "failed_edit_category" => "Error al editar la categoria", "category_in_use" => "La categoria té subscripcions en ús i no es pot eliminar", "failed_remove_category" => "Error al eliminar la categoria", "category_saved" => "Categoria desada", "category_removed" => "Categoria eliminada", "sort_order_saved" => "Ordre de classificació desat", // Currency "currency_saved" => "s'ha desat.", "error_adding_currency" => "S'ha produït un error en afegir la divisa.", "failed_to_store_currency" => "No s'ha pogut emmagatzemar la divisa a la base de dades.", "currency_in_use" => "La divisa té subscripcions en ús i no es pot eliminar", "currency_is_main" => "La divisa està establerta com divisa principal i no es pot eliminar.", "failed_to_remove_currency" => "Error al eliminar la divisa de la base de dades", "failed_to_store_api_key" => "Error al desar la API KEY a la base de dades.", "invalid_api_key" => "API KEY no vàlida.", "api_key_saved" => "API KEY desada correctament", "currency_removed" => "Divisa eliminada", // Household "failed_add_household" => "Error al afegir membre de la llar", "failed_edit_household" => "Error al editar membre de la llar", "failed_remove_household" => "Error al eliminar membre de la llar", "household_in_use" => "El membre de la llar té subscripcions en ús i no es pot eliminar", "member_saved" => "Membre desat", "member_removed" => "Membre eliminat", // Notifications "error_saving_notifications" => "Error al desar les dades de notificacions.", "wallos_notification" => "Notificació de Wallos", "test_notification" => "Això és una notificació de prova. Si la veus, la configuració és correcta.", "email_error" => "Error al enviar correu electrònic", "notification_sent_successfuly" => "Notificació enviada correctament", "notifications_settings_saved" => "Configuració de notificacions desada correctament.", "notification_failed" => "Error al enviar la notificació", // Payments "payment_in_use" => "No es pot desactivar un mètodo de pagament en ús", "failed_update_payment" => "Error al actualitzar el mètodo de pagament a la base de dades", "enabled" => "activat", "disabled" => "desactivat", // Subscription "error_fetching_image" => "Error al obtenir la imatge ", "subscription_updated_successfuly" => "Subscripció actualitzada correctament", "subscription_added_successfuly" => "Subscripció afegida correctament", "error_deleting_subscription" => "Error al eliminar la subscripció.", "invalid_request_method" => "Mètodo de petició invàlid.", // User "error_updating_user_data" => "Error al actualitzar les dades de l'usuari.", "user_details_saved" => "Detalls de l'usuari desats", // Admin Page "registrations" => "Registre d'Usuaris", "enable_user_registrations" => "Activar el registre d'usuaris", "maximum_number_users" => "Nombre màxim d'usuaris", "require_email_verification" => "Requerir verificació del correu electrònic", "configure_smtp_settings_to_enable" => "Configura els paràmetres SMTP per habilitar", "server_url" => "URL del Servidor", "server_url_info" => "S'utilitza per verificar el correu electrònic i recuperar la contrasenya. Ha de ser una URL pública vàlida.", "server_url_password_reset" => "Si es configura, també s'activarà la funció de restabliment de contrasenya.", "disable_login" => "Desactivar l'Inici de Sessió", "disable_login_info" => "Ometre l'inici de sessió. Si executes el teu servidor en una xarxa local, sense accés extern, pots desactivar l'inici de sessió. Això farà que s'iniciï automàticament la sessió de l'usuari administrador.", "disable_login_info2" => "Només pots activar aquesta opció si el registre d'usuaris està desactivat i només hi ha el compte de l'usuari administrador.", "max_users_info" => "0 per il·limitat", "user_management" => "Gestió d'Usuaris", "delete_user" => "Eliminar Usuari", "delete_user_info" => "Al eliminar un usuari també s'eliminen totes les seves subscripcions i configuracions.", "create_user" => "Crear Usuari", "oidc_settings" => "Configuració OIDC", "oidc_oauth_enabled" => "Activar OIDC/OAuth", "create_user_automatically" => "Crear usuari automàticament", "disable_password_login" => "Desactivar l'inici de sessió amb contrasenya", "smtp_settings" => "Configuració SMTP", "smtp_usage_info" => "S'utilitza per recuperar contrasenyes i enviar altres correus electrònics del sistema.", "security_settings" => "Configuració de seguretat", "ssrf_protection_info" => "Per evitar atacs de Server-Side Request Forgery (SSRF), Wallos bloqueja per defecte les notificacions webhook a adreces de xarxa privades o internes.", "local_webhook_info" => "Si necessiteu enviar webhooks a serveis locals (com Home Assistant, Gotify o Node-RED), introduïu les seves adreces IP o noms d'amfitrió anteriorment com una llista separada per comes (p. ex., 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Tasques de Manteniment", "orphaned_logos" => "Logotips orfes", "update" => "Actualitzar", "new_version_available" => "Hi ha disponible una nova versió de Wallos", "current_version" => "Versió Actual", "latest_version" => "Última Versió", "on_current_version" => "Estàs utilitzant l'última versió de Wallos.", "show_update_notification" => "Mostrar notificació d'actualitzacions al taulell", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "Correu electrònic verificat", "email_verification_failed" => "Error al verificar el corru electrònic", // Calendar "calendar" => "Calendari", "sun" => "Dg", "mon" => "Dl", "tue" => "Dt", "wed" => "Dc", "thu" => "Dj", "fri" => "Dv", "sat" => "Ds", "month-01" => "Gener", "month-02" => "Febrer", "month-03" => "Març", "month-04" => "Abril", "month-05" => "Maig", "month-06" => "Juny", "month-07" => "Juliol", "month-08" => "Agost", "month-09" => "Setembre", "month-10" => "Octubre", "month-11" => "Novembre", "month-12" => "Desembre", "total_cost" => "Cost Total", "export_icalendar" => "Exportar iCalendar", "over_budget_warning" => "T'has passat del pressupost", // TOTP Page "insert_totp_code" => "Introdueix el codi TOTP", ]; ?> ================================================ FILE: includes/i18n/cs.php ================================================ "Než se budete moct přihlásit, musíte si vytvořit účet.", "username" => "Uživatelské jméno", "password" => "Heslo", "email" => "E-mail", "firstname" => "Křestní jméno", "lastname" => "Příjmení", "confirm_password" => "Potvrďte heslo", "main_currency" => "Hlavní měna", "language" => "Jazyk", "passwords_dont_match" => "Hesla se neshodují", "username_exists" => "Uživatelské jméno již existuje", "email_exists" => "E-mail již existuje", "registration_failed" => "Registrace se nezdařila, zkuste to prosím znovu.", "register" => "Registrace", "restore_database" => "Obnovit databázi", // Login Page "please_login" => "Přihlaste se, prosím", "stay_logged_in" => "Zůstat přihlášený (30 dní)", "login" => "Přihlásit", "login_with" => "Přihlásit se pomocí", "or" => "nebo", "login_failed" => "Přihlašovací údaje jsou nesprávné", "registration_successful" => "Úspěšná registrace", "user_email_waiting_verification" => "Váš e-mail musí být ověřen. Zkontrolujte prosím svůj e-mail.", // Password Reset Page "forgot_password" => "Zapomenuté heslo", "reset_password" => "Obnovit heslo", "reset_sent_check_email" => "E-mail pro obnovení odeslán. Zkontrolujte prosím svůj e-mail.", "password_reset_successful" => "Úspěšné obnovení hesla", // Header "profile" => "Profil", "dashboard" => "Přehled", "subscriptions" => "Předplatná", "stats" => "Statistiky", "settings" => "Nastavení", "admin" => "Administrace", "about" => "O aplikaci", "logout" => "Odhlásit se", // Dashboard "hello" => "Ahoj", "upcoming_payments" => "Plánované platby", "no_upcoming_payments" => "Žádné plánované platby", "overdue_renewals" => "Zpožděná obnovení", "ai_recommendations" => "Doporučení AI", "your_budget" => "Váš rozpočet", "budget" => "Rozpočet", "budget_used" => "Využití rozpočtu", "over_budget" => "Překročení rozpočtu", "your_subscriptions" => "Vaše předplatná", "your_savings" => "Vaše úspory", // Subscriptions page "subscription" => "Předplatné", "no_subscriptions_yet" => "Zatím nemáte žádná předplatná", "add_first_subscription" => "Přidat první předplatné", "new_subscription" => "Nové předplatné", "search" => "Hledat", "state" => "Stav", "alphanumeric" => "Alfanumericky", "sort" => "Seřadit", "name" => "Název", "last_added" => "Naposledy přidané", "price" => "Cena", "next_payment" => "Další platba", "renewal_type" => "Typ obnovení", "auto_renewal" => "Automatické obnovení", "automatically_renews" => "Automaticky se obnovuje", "manual_renewal" => "Ruční obnovení", "start_date" => "Datum zahájení", "inactive" => "Zakázat předplatné", "replaced_with" => "Nahrazeno", "none" => "Nic", "member" => "Člen", "category" => "Kategorie", "payment_method" => "Platební metoda", "Daily" => "Denně", "Weekly" => "Týdně", "Monthly" => "Měsíčně", "Yearly" => "Ročně", "daily" => "dní", "weekly" => "týdnů", "monthly" => "měsíců", "yearly" => "roků", "days" => "dní", "weeks" => "týdnů", "months" => "měsíců", "years" => "roků", "external_url" => "Navštívit externí adresu URL", "empty_page" => "Prázdná stránka", "clear_filters" => "Vymazat filtry", "no_matching_subscriptions" => "Žádné odpovídající předplatné", "clone" => "Klonovat", "renew" => "Obnovit", "calculate_next_payment_date" => "Vypočítat datum další platby", // Subscription form "add_subscription" => "Přidat předplatné", "edit_subscription" => "Upravit předplatné", "subscription_name" => "Název předplatného", "logo_preview" => "Náhled loga", "search_logo" => "Hledat logo na internetu", "web_search" => "Hledání na internetu", "currency" => "Měna", "payment_every" => "Platba každých", "frequency" => "Frekvence", "cycle" => "Cyklus", "no_category" => "Žádná kategorie", "paid_by" => "Platí", "url" => "Adresa URL", "notes" => "Poznámky", "enable_notifications" => "Povolit oznámení pro toto předplatné", "default_value_from_settings" => "Výchozí hodnota z nastavení", "cancellation_notification" => "Oznámení o zrušení", "delete" => "Odstranit", "cancel" => "Zrušit", "upload_logo" => "Nahrát logo", // Statistics page "cant_convert_currency" => "U svých předplatných používáte více měn. Pokud chcete mít platné a přesné statistiky, nastavte na stránce nastavení klíč API služby Fixer.", "general_statistics" => "Obecné statistiky", "active_subscriptions" => "Aktivní předplatná", "inactive_subscriptions" => "Neaktivní předplatná", "monthly_cost" => "Měsíční náklady", "yearly_cost" => "Roční náklady", "average_monthly" => "Průměrné měsíční náklady na předplatné", "most_expensive" => "Nejdražší náklady na předplatné", "amount_due" => "Částka splatná tento měsíc", "percentage_budget_used" => "Procento využití rozpočtu", "budget_remaining" => "Zbývající rozpočet", "amount_over_budget" => "Částka nad rozpočet", "monthly_savings" => "Měsíční úspory (na neaktivních předplatných)", "yearly_savings" => "Roční úspory (na neaktivních předplatných)", "split_views" => "Zobrazení rozdělení", "category_split" => "Rozdělení kategorií", "household_split" => "Rozdělení domácnosti", "payment_method_split" => "Rozdělení platebních metod", "total_cost_trend" => "Trend celkových nákladů", "cost_vs_budget" => "Celkové náklady vs. rozpočet", // About page "about_and_credits" => "O aplikaci a zásluhy", "credits" => "Zásluhy", "license" => "Licence", "release_notes" => "Poznámky k vydání", "update_available" => "Dostupná aktualizace", "issues_and_requests" => "Problémy a požadavky", "the_author" => "Autor", "icons" => "Ikony", "payment_icons" => "Ikony plateb", // Profile page "upload_avatar" => "Nahrát avatara", "file_type_error" => "Zadaný typ souboru není podporován.", "user_details" => "Podrobnosti o uživateli", "two_factor_authentication" => "Dvoufaktorové ověřování", "two_factor_info" => "Dvoufaktorové ověřování přidává k vašemu účtu další úroveň zabezpečení.
K naskenování QR kódu budete potřebovat ověřovací aplikaci, jako je Google Authenticator, Authy nebo Ente Auth.", "two_factor_enabled_info" => "Váš účet je zabezpečen pomocí dvoufaktorového ověřování. Můžete jej vypnout kliknutím na výše uvedené tlačítko.", "enable_two_factor_authentication" => "Povolit dvoufaktorové ověřování", "2fa_already_enabled" => "Dvoufaktorové ověřování je již povoleno", "totp_code_incorrect" => "Kód TOTP je nesprávný", "backup_codes" => "Záložní kódy", "download_backup_codes" => "Stáhnout záložní kódy", "copy_to_clipboard" => "Kopírovat do schránky", "totp_backup_codes_info" => "Tyto kódy lze použít k přihlášení, pokud ztratíte přístup k ověřovací aplikaci.", "disable_two_factor_authentication" => "Zakázat dvoufaktorové ověřování", "totp_code" => "Kód TOTP", "api_key" => "Klíč API", "regenerate" => "Znovu vygenerovat", "api_key_info" => "Klíč API slouží k přístupu k rozhraní API. Udržujte jej v tajnosti.", // Settings page "monthly_budget" => "Měsíční rozpočet", "budget_info" => "Měsíční rozpočet se používá pro výpočet statistik", "household" => "Domácnost", "save_member" => "Uložit člena", "delete_member" => "Odstranit člena", "cant_delete_member" => "Nelze odstranit hlavního člena", "cant_delete_member_in_use" => "Nelze odstranit člena používaného v předplatném", "household_info" => "Pole e-mailu umožňuje upozornit členy domácnosti na předplatné, kterému brzy vyprší platnost.", "notifications" => "Oznámení", "enable_email_notifications" => "Povolit e-mailová oznámení", "notify_me" => "Upozorni mě", "day_before" => "dní předem", "on_due_date" => "V den splatnosti", "days_before" => "dní předem", "smtp_address" => "Adresa SMTP", "port" => "Port", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "Uživatelské jméno SMTP", "smtp_password" => "Heslo SMTP", "from_email" => "Z e-mailu (volitelné)", "send_to_other_emails" => "Oznámení zasílat také na následující e-mailové adresy (oddělte je pomocí ;):", "other_emails_placeholder" => "uzivatel@domena.cz;test@uzivatel.cz", "smtp_info" => "Heslo SMTP se přenáší a ukládá v prostém textu. Z důvodu bezpečnosti si prosím vytvořte účet pouze pro tento účel.", "telegram" => "Telegram", "telegram_bot_token" => "Token služby Telegram Bot", "telegram_chat_id" => "ID chatu v Telegramu", "pushplus" => "Pushplus", "pushplus_token" => "Pushplus Token", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "WebHook URL", "mattermost_bot_username" => "Uživatelské jméno služby Mattermost Bot", "mattermost_bot_icon_emoji" => "Emoji ikony bota", "webhook" => "Webhook", "webhook_url" => "Adresa URL webhooku", "request_method" => "Metoda požadavku", "custom_headers" => "Vlastní hlavičky", "webhook_payload" => "Zatížení webhooku", "payment_notifications_payload" => "Zatížení oznámení o platbě", "cancelation_notification_payload" => "Zatížení oznámení o zrušení", "variables_available" => "Dostupné proměnné", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Uživatelské jméno služby Discord Bot", "discord_bot_avatar_url" => "Adresa URL avatara služby Discord Bot", "pushover" => "Pushover", "pushover_user_key" => "Uživatelský klíč Pushover", "host" => "Hostitel", "topic" => "Téma", "ignore_ssl_errors" => "Ignorovat chyby SSL", "categories" => "Kategorie", "save_category" => "Uložit kategorii", "delete_category" => "Odstranit kategorii", "cant_delete_category_in_use" => "Nelze odstranit kategorii používanou v předplatném", "currencies" => "Měny", "save_currency" => "Uložit měnu", "delete_currency" => "Odstranit měnu", "cant_delete_main_currency" => "Nelze odstranit hlavní měnu", "cant_delete_currency_in_use" => "Nelze odstranit měnu používanou v předplatném", "exchange_update" => "Směnné kurzy naposledy aktualizovány", "currency_info" => "Podporované měny a správné kódy měn najdete na", "currency_performance" => "Pro zlepšení výkonu si ponechte pouze měny, které používáte.", "fixer_api_key" => "Klíč API služby Fixer", "provider" => "Poskytovatel", "fixer_info" => "Pokud používáte více měn a chcete mít přesné statistiky a řazení předplatných, je nutné mít BEZPLATNÝ klíč API od služby Fixer.", "get_key" => "Získejte svůj klíč na", "get_free_fixer_api_key" => "Získat bezplatný klíč API služby Fixer", "get_key_alternative" => "Případně můžete získat bezplatný klíč API služby Fixer od", "ai_model" => "AI Model", "select_ai_model" => "Vybrat AI Model", "run_schedule" => "Spustit plán", "manually" => "Manuálně", "coming_soon" => "Brzy bude k dispozici", "invalid_host" => "Neplatný hostitel", "ai_recommendations_info" => "AI Doporučení jsou generována na základě vašich předplatných a členů domácnosti.", "may_take_time" => "V závislosti na poskytovateli, modelu a počtu předplatných může generování doporučení trvat nějakou dobu.", "recommendations_visible_on_dashboard" => "Doporučení budou viditelná na řídicím panelu.", "generate_recommendations" => "Generovat doporučení", "display_settings" => "Nastavení zobrazení", "theme_settings" => "Nastavení motivu", "colors" => "Barvy", "custom_colors" => "Vlastní barvy", "theme" => "Motiv", "dark_theme" => "Tmavý motiv", "light_theme" => "Světlý motiv", "automatic"=> "Automaticky", "main_color" => "Hlavní barva", "accent_color" => "Barva zdůraznění", "hover_color" => "Barva najetí", "save_custom_colors" => "Uložit vlastní barvy", "reset_custom_colors" => "Obnovit vlastní barvy", "custom_css" => "Vlastní CSS", "save_custom_css" => "Uložit vlastní CSS", "calculate_monthly_price" => "Vypočítávat a zobrazovat měsíční ceny pro všechna předplatná", "convert_prices" => "Vždy převádět a zobrazovat ceny v mé hlavní měně (pomalejší)", "show_original_price" => "Při převodech nebo výpočtech zobrazovat také původní cenu.", "experience" => "Zážitek", "show_subscription_progress" => "Zobrazovat průběh předplatného", "disabled_subscriptions" => "Zakázaná předplatná", "hide_disabled_subscriptions" => "Skrývat zakázaná předplatná", "show_disabled_subscriptions_at_the_bottom" => "Zobrazovat zakázaná předplatná v dolní části", "experimental_settings" => "Experimentální nastavení", "remove_background" => "Pokusit se odstranit pozadí log z vyhledávání obrázků", "use_mobile_navigation_bar" => "Použít mobilní navigační panel", "experimental_info" => "Experimentální nastavení pravděpodobně nebude fungovat dokonale.", "payment_methods" => "Platební metody", "payment_methods_info" => "Kliknutím na platební metodu ji zakážete nebo povolíte.", "rename_payment_methods_info" => "Kliknutím na název platební metody ji přejmenujete.", "cant_delete_payment_method_in_use" => "Nelze zakázat používanou platební metodu", "add_custom_payment" => "Přidat vlastní platební metodu", "payment_method_name" => "Název platební metody", "payment_method_added_successfuly" => "Úspěšné přidání platební metody", "payment_method_removed" => "Platební metoda odebrána", "disable" => "Zakázat", "enable" => "Povolit", "rename_payment_method" => "Přejmenovat platební metodu", "payment_renamed" => "Platební metoda přejmenována", "payment_not_renamed" => "Platební metoda nebyla přejmenována", "test" => "Testovat", "add" => "Přidat", "save" => "Uložit", "reset" => "Obnovit", "main_accent_color_error" => "Hlavní barva a barva zdůraznění nemohou být stejné.", "backup_and_restore" => "Zálohování a obnovení", "backup" => "Zálohovat", "restore" => "Obnovit", "restore_info" => "Obnovení databáze přepíše všechna aktuální data. Po obnovení budete odhlášeni.", "account" => "Účet", "export_subscriptions" => "Exportovat předplatná", "export_as_json" => "Exportovat jako JSON", "export_as_csv" => "Exportovat jako CSV", "danger_zone" => "Nebezpečná zóna", "delete_account" => "Odstranit účet", "delete_account_info" => "Odstraněním účtu se odstraní také všechna vaše předplatná a nastavení.", // Filters menu "filter" => "Filtrovat", "clear" => "Vymazat", // Toast "success" => "Úspěch", // Endpoint responses "session_expired" => "Vaše relace vypršela. Přihlaste se prosím znovu.", "fields_missing" => "Některá pole chybí", "fill_all_fields" => "Vyplňte prosím všechna pole", "fill_mandatory_fields" => "Vyplňte prosím všechna povinná pole", "error" => "Chyba", // Category "failed_add_category" => "Nepodařilo se přidat kategorii", "failed_edit_category" => "Nepodařilo se upravit kategorii", "category_in_use" => "Kategorie se používá v předplatných a nelze ji odebrat", "failed_remove_category" => "Nepodařilo se odebrat kategorii", "category_saved" => "Kategorie uložena", "category_removed" => "Kategorie odebrána", "sort_order_saved" => "Pořadí řazení uloženo", // Currency "currency_saved" => "byla uložena.", "error_adding_currency" => "Chyba při přidávání položky měny.", "failed_to_store_currency" => "Nepodařilo se uložit měnu do databáze.", "currency_in_use" => "Měna se používá v předplatných a nelze ji odstranit.", "currency_is_main" => "Měna je nastavena jako hlavní měna a nelze ji odstranit.", "failed_to_remove_currency" => "Nepodařilo se odebrat měnu z databáze.", "failed_to_store_api_key" => "Nepodařilo se uložit klíč API do databáze.", "invalid_api_key" => "Neplatný klíč API.", "api_key_saved" => "Klíč API úspěšně uložen", "currency_removed" => "Měna odebrána", // Household "failed_add_household" => "Nepodařilo se přidat člena domácnosti", "failed_edit_household" => "Nepodařilo se upravit člena domácnosti", "failed_remove_household" => "Nepodařilo se odebrat člena domácnosti", "household_in_use" => "Člen domácnosti se používá v předplatných a nelze ho odebrat.", "member_saved" => "Člen uložen", "member_removed" => "Člen odebrán", // Notifications "error_saving_notifications" => "Chyba při ukládání dat oznámení.", "wallos_notification" => "Oznámení aplikace Wallos", "test_notification" => "Toto je testovací oznámení. Pokud se zobrazuje, je konfigurace správná.", "email_error" => "Chyba při odesílání e-mailu", "notification_sent_successfuly" => "Oznámení úspěšně odesláno", "notifications_settings_saved" => "Nastavení oznámení úspěšně uloženo.", "notification_failed" => "Oznámení selhalo", // Payments "payment_in_use" => "Nelze zakázat používanou platební metodu", "failed_update_payment" => "Nepodařilo se aktualizovat platební metodu v databázi", "enabled" => "povoleno", "disabled" => "zakázáno", // Subscription "error_fetching_image" => "Chyba při načítání obrázku", "subscription_updated_successfuly" => "Předplatné úspěšně aktualizováno", "subscription_added_successfuly" => "Předplatné úspěšně přidáno", "error_deleting_subscription" => "Chyba při odstraňování předplatného.", "invalid_request_method" => "Neplatná metoda požadavku.", // User "error_updating_user_data" => "Chyba při aktualizaci uživatelských dat.", "user_details_saved" => "Podrobnosti o uživateli uloženy", // Admin Page "registrations" => "Registrace", "enable_user_registrations" => "Povolit registraci uživatelů", "maximum_number_users" => "Maximální počet uživatelů", "require_email_verification" => "Vyžadovat ověření e-mailu", "configure_smtp_settings_to_enable" => "Nakonfigurujte nastavení SMTP pro povolení", "server_url" => "Adresa URL serveru", "server_url_info" => "Slouží k ověření e-mailu a obnovení hesla. Musí to být platná veřejná adresa URL.", "server_url_password_reset" => "Pokud je nastaveno, povolí také funkci obnovení hesla.", "disable_login" => "Zakázat přihlašování", "disable_login_info" => "Obejít přihlášení. Pokud provozujete server pouze v místní síti bez přístupu zvenčí, můžete přihlášení zakázat. Tím se automaticky přihlásí uživatel administrátor.", "disable_login_info2" => "Toto nastavení můžete povolit pouze v případě, že je registrace uživatelů zakázána a že existuje pouze uživatelský účet administrátora.", "max_users_info" => "0 znamená neomezeně", "user_management" => "Správa uživatelů", "delete_user" => "Odstranit uživatele", "delete_user_info" => "Odstraněním uživatele se odstraní také všechna jeho předplatná a nastavení.", "create_user" => "Vytvořit uživatele", "oidc_settings" => "Nastavení OIDC", "oidc_oauth_enabled" => "Povolit OIDC/OAuth", "create_user_automatically" => "Automaticky vytvořit uživatele", "disable_password_login" => "Zakázat přihlašování pomocí hesla", "smtp_settings" => "Nastavení SMTP", "smtp_usage_info" => "Bude použito pro obnovení hesla a další systémové e-maily.", "security_settings" => "Nastavení zabezpečení", "ssrf_protection_info" => "Aby se předešlo útokům Server-Side Request Forgery (SSRF), Wallos ve výchozím nastavení blokuje webhook notifikace směrem na soukromé nebo interní síťové adresy.", "local_webhook_info" => "Pokud potřebujete posílat webhooks na lokální služby (např. Home Assistant, Gotify nebo Node-RED), zadejte jejich IP adresy nebo názvy hostitelů výše jako seznam oddělený čárkami (např. 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Úkoly údržby", "orphaned_logos" => "Osiřelá loga", "update" => "Aktualizace", "new_version_available" => "K dispozici je nová verze aplikace Wallos", "current_version" => "Aktuální verze", "latest_version" => "Nejnovější verze", "on_current_version" => "Používáte nejnovější verzi aplikace Wallos.", "show_update_notification" => "Zobrazovat upozornění na aktualizace na nástěnce", "cronjobs" => "Úlohy Cron", // Email Verification "email_verified" => "E-mail úspěšně ověřen", "email_verification_failed" => "Ověření e-mailu selhalo", // Calendar "calendar" => "Kalendář", "sun" => "Ne", "mon" => "Po", "tue" => "Út", "wed" => "St", "thu" => "Čt", "fri" => "Pá", "sat" => "So", "month-01" => "Leden", "month-02" => "Únor", "month-03" => "Březen", "month-04" => "Duben", "month-05" => "Květen", "month-06" => "Červen", "month-07" => "Červenec", "month-08" => "Srpen", "month-09" => "Září", "month-10" => "Říjen", "month-11" => "Listopad", "month-12" => "Prosinec", "total_cost" => "Celkové náklady", "export_icalendar" => "Exportovat iCalendar", "over_budget_warning" => "Překročili jste rozpočet", // TOTP Page "insert_totp_code" => "Vložte kód TOTP", ]; ?> ================================================ FILE: includes/i18n/da.php ================================================ "Du skal oprette en konto, før du kan logge ind", "username" => "Brugernavn", "password" => "Adgangskode", "email" => "E-mail", "firstname" => "Fornavn", "lastname" => "Efternavn", "confirm_password" => "Bekræft adgangskode", "main_currency" => "Primær valuta", "language" => "Sprog", "passwords_dont_match" => "Adgangskoderne stemmer ikke overens", "username_exists" => "Brugernavnet findes allerede", "email_exists" => "E-mailen findes allerede", "registration_failed" => "Registreringen mislykkedes, prøv igen.", "register" => "Registrer", "restore_database" => "Gendan database", // Login Page "please_login" => "Log venligst ind", "stay_logged_in" => "Forbliv logget ind (30 dage)", "login" => "Login", "login_with" => "Log ind med", "or" => "eller", "login_failed" => "Loginoplysningerne er forkerte", "registration_successful" => "Registreringen lykkedes", "user_email_waiting_verification" => "Din e-mail skal bekræftes. Tjek venligst din indbakke.", // Password Reset Page "forgot_password" => "Glemt adgangskode", "reset_password" => "Nulstil adgangskode", "reset_sent_check_email" => "Nulstillings-e-mail sendt. Tjek venligst din e-mail.", "password_reset_successful" => "Adgangskoden blev nulstillet", // Header "profile" => "Profil", "dashboard" => "Dashboard", "subscriptions" => "Abonnementer", "stats" => "Statistik", "settings" => "Indstillinger", "admin" => "Admin", "about" => "Om", "logout" => "Log ud", // Dashboard "hello" => "Hej", "upcoming_payments" => "Kommende betalinger", "no_upcoming_payments" => "Du har ingen kommende betalinger", "overdue_renewals" => "Forsinkede fornyelser", "ai_recommendations" => "Denne AI anbefaling", "your_budget" => "Dit budget", "budget" => "Budget", "budget_used" => "Budget brugt", "over_budget" => "Over budget", "your_subscriptions" => "Dine abonnementer", "your_savings" => "Dine besparelser", // Subscriptions page "subscription" => "Abonnement", "no_subscriptions_yet" => "Du har endnu ingen abonnementer", "add_first_subscription" => "Tilføj første abonnement", "new_subscription" => "Nyt abonnement", "search" => "Søg", "state" => "Status", "alphanumeric" => "Alfanumerisk", "sort" => "Sorter", "name" => "Navn", "last_added" => "Sidst tilføjet", "price" => "Pris", "next_payment" => "Næste betaling", "renewal_type" => "Fornyelsestype", "auto_renewal" => "Automatisk fornyelse", "automatically_renews" => "Fornyes automatisk", "manual_renewal" => "Manuel fornyelse", "start_date" => "Startdato", "inactive" => "Deaktiver abonnement", "replaced_with" => "Erstattet med", "none" => "Ingen", "member" => "Medlem", "category" => "Kategori", "payment_method" => "Betalingsmetode", "Daily" => "Dagligt", "Weekly" => "Ugentligt", "Monthly" => "Månedligt", "Yearly" => "Årligt", "daily" => "Dag(e)", "weekly" => "Uge(r)", "monthly" => "Måned(er)", "yearly" => "År", "days" => "dage", "weeks" => "uger", "months" => "måneder", "years" => "år", "external_url" => "Besøg ekstern URL", "empty_page" => "Tom side", "clear_filters" => "Ryd filtre", "no_matching_subscriptions" => "Ingen matchende abonnementer", "clone" => "Klon", "renew" => "Forny", "calculate_next_payment_date" => "Beregn næste betalingsdato", // Subscription form "add_subscription" => "Tilføj abonnement", "edit_subscription" => "Rediger abonnement", "subscription_name" => "Abonnementsnavn", "logo_preview" => "Forhåndsvisning af logo", "search_logo" => "Søg logo på nettet", "web_search" => "Websøgning", "currency" => "Valuta", "payment_every" => "Betaling hver", "frequency" => "Frekvens", "cycle" => "Cyklus", "no_category" => "Ingen kategori", "paid_by" => "Betalt af", "url" => "URL", "notes" => "Noter", "enable_notifications" => "Aktivér notifikationer for dette abonnement", "default_value_from_settings" => "Standardværdi fra indstillinger", "cancellation_notification" => "Annulleringsnotifikation", "delete" => "Slet", "cancel" => "Annullér", "upload_logo" => "Upload logo", // Statistics page "cant_convert_currency" => "Du bruger flere valutaer i dine abonnementer. For at få valide og nøjagtige statistikker skal du sætte en Fixer API-nøgle på indstillingssiden.", "general_statistics" => "Generelle statistikker", "active_subscriptions" => "Aktive abonnementer", "inactive_subscriptions" => "Inaktive abonnementer", "monthly_cost" => "Månedlig udgift", "yearly_cost" => "Årlig udgift", "average_monthly" => "Gennemsnitlig månedlig abonnementsudgift", "most_expensive" => "Dyreste abonnement", "amount_due" => "Beløb forfalder denne måned", "percentage_budget_used" => "Procentdel af budget brugt", "budget_remaining" => "Resterende budget", "amount_over_budget" => "Beløb over budget", "monthly_savings" => "Månedlige besparelser (på inaktive abonnementer)", "yearly_savings" => "Årlige besparelser (på inaktive abonnementer)", "split_views" => "Opdelte visninger", "category_split" => "Kategoriopdeling", "household_split" => "Husstandsopdeling", "payment_method_split" => "Betalingsmetodeopdeling", "total_cost_trend" => "Samlet omkostningstendens", "cost_vs_budget" => "Omkostning vs. budget", // About page "about_and_credits" => "Om og kreditering", "credits" => "Kreditering", "license" => "Licens", "release_notes" => "Versionsnoter", "update_available" => "Opdatering tilgængelig", "issues_and_requests" => "Problemer og forespørgsler", "the_author" => "Forfatteren", "icons" => "Ikoner", "payment_icons" => "Betalingsikoner", // Profile page "upload_avatar" => "Upload avatar", "file_type_error" => "Den angivne filtype understøttes ikke.", "user_details" => "Brugeroplysninger", "two_factor_authentication" => "To-faktor-godkendelse", "two_factor_info" => "To-faktor-godkendelse tilføjer et ekstra sikkerhedslag til din konto.
Du skal bruge en godkendelsesapp som Google Authenticator, Authy eller Ente Auth til at scanne QR-koden.", "two_factor_enabled_info" => "Din konto er sikret med to-faktor-godkendelse. Du kan deaktivere det ved at klikke på knappen ovenfor.", "enable_two_factor_authentication" => "Aktivér to-faktor-godkendelse", "2fa_already_enabled" => "To-faktor-godkendelse er allerede aktiveret", "totp_code_incorrect" => "TOTP-koden er forkert", "backup_codes" => "Backupkoder", "download_backup_codes" => "Download backupkoder", "copy_to_clipboard" => "Kopiér til udklipsholder", "totp_backup_codes_info" => "Disse koder kan bruges til at logge ind, hvis du mister adgangen til din godkendelsesapp.", "disable_two_factor_authentication" => "Deaktivér to-faktor-godkendelse", "totp_code" => "TOTP-kode", "api_key" => "API-nøgle", "regenerate" => "Generér igen", "api_key_info" => "API-nøglen bruges til at få adgang til API'en. Hold den hemmelig.", // Settings page "monthly_budget" => "Månedligt budget", "budget_info" => "Det månedlige budget bruges til at beregne statistikker", "household" => "Husstand", "save_member" => "Gem medlem", "delete_member" => "Slet medlem", "cant_delete_member" => "Kan ikke slette hovedmedlem", "cant_delete_member_in_use" => "Kan ikke slette medlem i brug i abonnement", "household_info" => "E-mail-feltet giver mulighed for at underrette husstandsmedlemmer om abonnementer, der er ved at udløbe.", "notifications" => "Notifikationer", "enable_email_notifications" => "Aktivér e-mail notifikationer", "notify_me" => "Giv mig besked", "day_before" => "dagen før", "on_due_date" => "På forfaldsdatoen", "days_before" => "dage før", "smtp_address" => "SMTP-adresse", "port" => "Port", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "SMTP-brugernavn", "smtp_password" => "SMTP-adgangskode", "from_email" => "Fra e-mail (valgfrit)", "send_to_other_emails" => "Send også notifikationer til følgende e-mailadresser (brug ; til at adskille dem):", "other_emails_placeholder" => "bruger@domæne.dk;test@bruger.dk", "smtp_info" => "SMTP-adgangskode sendes og gemmes i klartekst. Opret af sikkerhedsmæssige årsager en separat konto til dette.", "telegram" => "Telegram", "telegram_bot_token" => "Telegram Bot Token", "telegram_chat_id" => "Telegram Chat ID", "pushplus" => "Pushplus", "pushplus_token" => "Pushplus Token", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost Webhook URL", "mattermost_bot_username" => "Mattermost Bot Brugernavn", "mattermost_bot_icon_emoji" => "Emoji til bot-ikon", "webhook" => "Webhook", "webhook_url" => "Webhook URL", "request_method" => "Request-metode", "custom_headers" => "Brugerdefinerede headers", "webhook_payload" => "Webhook Payload", "payment_notifications_payload" => "Payload for betalingsnotifikationer", "cancelation_notification_payload" => "Payload for annulleringsnotifikationer", "variables_available" => "Tilgængelige variabler", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Discord Bot Brugernavn", "discord_bot_avatar_url" => "Discord Bot Avatar URL", "pushover" => "Pushover", "pushover_user_key" => "Pushover Bruger Nøgle", "host" => "Host", "topic" => "Emne", "ignore_ssl_errors" => "Ignorér SSL-fejl", "categories" => "Kategorier", "save_category" => "Gem kategori", "delete_category" => "Slet kategori", "cant_delete_category_in_use" => "Kan ikke slette kategori i brug i abonnement", "currencies" => "Valutaer", "save_currency" => "Gem valuta", "delete_currency" => "Slet valuta", "cant_delete_main_currency" => "Kan ikke slette hovedvaluta", "cant_delete_currency_in_use" => "Kan ikke slette valuta i brug i abonnement", "exchange_update" => "Valutakurser sidst opdateret den", "currency_info" => "Find understøttede valutaer og korrekte valutakoder på", "currency_performance" => "For bedre ydeevne, behold kun de valutaer du bruger.", "fixer_api_key" => "Fixer API-nøgle", "provider" => "Udbyder", "fixer_info" => "Hvis du bruger flere valutaer og vil have nøjagtig statistik og sortering af abonnementer, kræves en GRATIS API-nøgle fra Fixer.", "get_key" => "Få din nøgle på", "get_free_fixer_api_key" => "Få gratis Fixer API-nøgle", "get_key_alternative" => "Alternativt kan du få en gratis fixer API-nøgle fra", "ai_model" => "AI Model", "select_ai_model" => "Vælg AI Model", "run_schedule" => "Kør tidsplan", "manually" => "Manuelt", "coming_soon" => "Kommer snart", "invalid_host" => "Ugyldig vært", "ai_recommendations_info" => "AI anbefalinger genereres baseret på dine abonnementer og husstandsmedlemmer.", "may_take_time" => "Afhængigt af udbyderen, modellen og antallet af abonnementer kan genereringen af anbefalinger tage noget tid.", "recommendations_visible_on_dashboard" => "Anbefalinger vil være synlige på instrumentbrættet.", "generate_recommendations" => "Generer anbefalinger", "display_settings" => "Visningsindstillinger", "theme_settings" => "Temaindstillinger", "colors" => "Farver", "custom_colors" => "Brugerdefinerede farver", "theme" => "Tema", "dark_theme" => "Mørkt tema", "light_theme" => "Lyst tema", "automatic"=> "Automatisk", "main_color" => "Hovedfarve", "accent_color" => "Accentfarve", "hover_color" => "Hover-farve", "save_custom_colors" => "Gem brugerdefinerede farver", "reset_custom_colors" => "Nulstil brugerdefinerede farver", "custom_css" => "Brugerdefineret CSS", "save_custom_css" => "Gem brugerdefineret CSS", "calculate_monthly_price" => "Beregn og vis månedlig pris for alle abonnementer", "convert_prices" => "Konverter og vis altid priser i min hovedvaluta (langsommere)", "show_original_price" => "Vis også original pris ved konvertering eller beregning", "experience" => "Oplevelse", "show_subscription_progress" => "Vis abonnementsfremskridt", "disabled_subscriptions" => "Deaktiverede abonnementer", "hide_disabled_subscriptions" => "Skjul deaktiverede abonnementer", "show_disabled_subscriptions_at_the_bottom" => "Vis deaktiverede abonnementer nederst", "experimental_settings" => "Eksperimentelle indstillinger", "remove_background" => "Forsøg at fjerne baggrund på logoer ved billedsøgning", "use_mobile_navigation_bar" => "Brug mobil navigationsbjælke", "experimental_info" => "Eksperimentelle indstillinger fungerer sandsynligvis ikke perfekt.", "payment_methods" => "Betalingsmetoder", "payment_methods_info" => "Klik på en betalingsmetode for at deaktivere / aktivere den.", "rename_payment_methods_info" => "Klik på navnet på en betalingsmetode for at omdøbe den.", "cant_delete_payment_method_in_use" => "Kan ikke deaktivere betalingsmetode i brug", "add_custom_payment" => "Tilføj brugerdefineret betalingsmetode", "payment_method_name" => "Navn på betalingsmetode", "payment_method_added_successfuly" => "Betalingsmetode tilføjet", "payment_method_removed" => "Betalingsmetode fjernet", "disable" => "Deaktiver", "enable" => "Aktivér", "rename_payment_method" => "Omdøb betalingsmetode", "payment_renamed" => "Betalingsmetode omdøbt", "payment_not_renamed" => "Betalingsmetode ikke omdøbt", "test" => "Test", "add" => "Tilføj", "save" => "Gem", "reset" => "Nulstil", "main_accent_color_error" => "Hovedfarve og accentfarve må ikke være ens", "backup_and_restore" => "Backup og gendannelse", "backup" => "Backup", "restore" => "Gendan", "restore_info" => "Gendannelse af databasen vil overskrive alle nuværende data. Du vil blive logget ud efter gendannelsen.", "account" => "Konto", "export_subscriptions" => "Eksportér abonnementer", "export_as_json" => "Eksportér som JSON", "export_as_csv" => "Eksportér som CSV", "danger_zone" => "Farezone", "delete_account" => "Slet konto", "delete_account_info" => "Sletning af din konto sletter også alle dine abonnementer og indstillinger.", // Filters menu "filter" => "Filter", "clear" => "Ryd", // Toast "success" => "Succes", // Endpoint responses "session_expired" => "Din session er udløbet. Log venligst ind igen", "fields_missing" => "Nogle felter mangler", "fill_all_fields" => "Udfyld venligst alle felter", "fill_mandatory_fields" => "Udfyld venligst alle obligatoriske felter", "error" => "Fejl", // Category "failed_add_category" => "Kunne ikke tilføje kategori", "failed_edit_category" => "Kunne ikke redigere kategori", "category_in_use" => "Kategorien er i brug i abonnementer og kan ikke fjernes", "failed_remove_category" => "Kunne ikke fjerne kategori", "category_saved" => "Kategori gemt", "category_removed" => "Kategori fjernet", "sort_order_saved" => "Sorteringsrækkefølge gemt", // Currency "currency_saved" => "blev gemt.", "error_adding_currency" => "Fejl ved tilføjelse af valuta.", "failed_to_store_currency" => "Kunne ikke gemme valuta i databasen.", "currency_in_use" => "Valutaen er i brug i abonnementer og kan ikke slettes.", "currency_is_main" => "Valutaen er indstillet som hovedvaluta og kan ikke slettes.", "failed_to_remove_currency" => "Kunne ikke fjerne valuta fra databasen.", "failed_to_store_api_key" => "Kunne ikke gemme API-nøgle i databasen.", "invalid_api_key" => "Ugyldig API-nøgle.", "api_key_saved" => "API-nøgle gemt", "currency_removed" => "Valuta fjernet", // Household "failed_add_household" => "Kunne ikke tilføje husstandsmedlem", "failed_edit_household" => "Kunne ikke redigere husstandsmedlem", "failed_remove_household" => "Kunne ikke fjerne husstandsmedlem", "household_in_use" => "Husstandsmedlemmet er i brug i abonnementer og kan ikke fjernes", "member_saved" => "Medlem gemt", "member_removed" => "Medlem fjernet", // Notifications "error_saving_notifications" => "Fejl ved gemning af notifikationsdata.", "wallos_notification" => "Wallos Notifikation", "test_notification" => "Dette er en testnotifikation. Hvis du ser dette, er konfigurationen korrekt.", "email_error" => "Fejl ved afsendelse af e-mail", "notification_sent_successfuly" => "Notifikation sendt", "notifications_settings_saved" => "Notifikationsindstillinger gemt", "notification_failed" => "Notifikation mislykkedes", // Payments "payment_in_use" => "Kan ikke deaktivere en anvendt betalingsmetode", "failed_update_payment" => "Kunne ikke opdatere betalingsmetode i databasen", "enabled" => "aktiveret", "disabled" => "deaktiveret", // Subscription "error_fetching_image" => "Fejl ved hentning af billede", "subscription_updated_successfuly" => "Abonnement opdateret", "subscription_added_successfuly" => "Abonnement tilføjet", "error_deleting_subscription" => "Fejl ved sletning af abonnement.", "invalid_request_method" => "Ugyldig anmodningsmetode.", // User "error_updating_user_data" => "Fejl ved opdatering af brugerdata.", "user_details_saved" => "Brugeroplysninger gemt", // Admin Page "registrations" => "Registreringer", "enable_user_registrations" => "Aktivér brugerregistrering", "maximum_number_users" => "Maksimalt antal brugere", "require_email_verification" => "Kræv e-mailverifikation", "configure_smtp_settings_to_enable" => "Konfigurér SMTP-indstillinger for at aktivere", "server_url" => "Server-URL", "server_url_info" => "Bruges til e-mailverifikation og nulstilling af adgangskode. Skal være en gyldig offentlig URL.", "server_url_password_reset" => "Hvis angivet, aktiveres nulstilling af adgangskode.", "disable_login" => "Deaktivér login", "disable_login_info" => "Omgå login. Hvis din server kun kører på et lokalt netværk uden ekstern adgang, kan du deaktivere login. Dette logger automatisk admin-brugeren ind.", "disable_login_info2" => "Du kan kun aktivere denne indstilling, hvis brugerregistrering er deaktiveret, og der ikke er mere end én (admin) brugerkonto.", "max_users_info" => "0 betyder ubegrænset", "user_management" => "Brugeradministration", "delete_user" => "Slet bruger", "delete_user_info" => "Sletning af en bruger vil også slette alle deres abonnementer og indstillinger.", "create_user" => "Opret bruger", "oidc_settings" => "OIDC-indstillinger", "oidc_oauth_enabled" => "Aktivér OIDC/OAuth", "create_user_automatically" => "Opret bruger automatisk", "disable_password_login" => "Deaktivér adgangskode-login", "smtp_settings" => "SMTP-indstillinger", "security_settings" => "Sikkerhedsindstillinger", "ssrf_protection_info" => "For at forhindre Server-Side Request Forgery (SSRF) angreb blokerer Wallos som standard webhook-notifikationer til private eller interne netværksadresser.", "local_webhook_info" => "Hvis du skal sende webhooks til lokale tjenester (som Home Assistant, Gotify eller Node-RED), indtast deres IP-adresser eller værtsnavne ovenfor som en kommasepareret liste (fx 192.168.1.100,192.168.1.101).", "smtp_usage_info" => "Vil blive brugt til adgangskodenulstilling og andre systemmails.", // Maintenance Tasks "maintenance_tasks" => "Vedligeholdelsesopgaver", "orphaned_logos" => "Forladte logoer", "update" => "Opdater", "new_version_available" => "En ny version af Wallos er tilgængelig", "current_version" => "Nuværende version", "latest_version" => "Seneste version", "on_current_version" => "Du kører den nyeste version af Wallos.", "show_update_notification" => "Vis opdateringsnotifikation på dashboard", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "E-mail bekræftet", "email_verification_failed" => "E-mailverifikation mislykkedes", // Calendar "calendar" => "Kalender", "sun" => "Søn", "mon" => "Man", "tue" => "Tir", "wed" => "Ons", "thu" => "Tor", "fri" => "Fre", "sat" => "Lør", "month-01" => "Januar", "month-02" => "Februar", "month-03" => "Marts", "month-04" => "April", "month-05" => "Maj", "month-06" => "Juni", "month-07" => "Juli", "month-08" => "August", "month-09" => "September", "month-10" => "Oktober", "month-11" => "November", "month-12" => "December", "total_cost" => "Samlede omkostninger", "export_icalendar" => "Eksportér iCalendar", "over_budget_warning" => "Du er over budget", // TOTP Page "insert_totp_code" => "Indtast TOTP-kode", ]; ?> ================================================ FILE: includes/i18n/de.php ================================================ "Bitte erstelle zunächst einen Account, um dich einloggen zu können", "username" => "Benutzername", "password" => "Passwort", "email" => "E-Mail", "firstname" => "Vorname", "lastname" => "Nachname", "confirm_password" => "Passwort bestätigen", "main_currency" => "Hauptwährung", "language" => "Sprache", "passwords_dont_match" => "Die Passwörter stimmen nicht überein", "username_exists" => "Benutzername existiert bereits", "email_exists" => "E-Mail existiert bereits", "registration_failed" => "Registrierung fehlgeschlagen, bitte erneut versuchen.", "register" => "Registrieren", "restore_database" => "Datenbank wiederherstellen", // Login Page "please_login" => "Bitte einloggen", "stay_logged_in" => "Angemeldet bleiben (30 Tage)", "login" => "Login", "login_with" => "Einloggen mit", "or" => "oder", "login_failed" => "Loginangaben sind nicht korrekt", "registration_successful" => "Registrierung erfolgreich", "user_email_waiting_verification" => "Ihre E-Mail muss noch verifiziert werden. Bitte überprüfen Sie Ihre E-Mail.", // Password Reset Page "forgot_password" => "Passwort vergessen?", "reset_password" => "Passwort zurücksetzen", "reset_sent_check_email" => "Passwort zurücksetzen E-Mail wurde gesendet. Bitte überprüfen Sie Ihr Postfach.", "password_reset_successful" => "Passwort erfolgreich zurückgesetzt", // Header "profile" => "Profil", "dashboard" => "Dashboard", "subscriptions" => "Abonnements", "stats" => "Statistiken", "settings" => "Einstellungen", "admin" => "Admin", "about" => "Über", "logout" => "Logout", // Dashboard "hello" => "Hallo", "upcoming_payments" => "Bevorstehende Zahlungen", "no_upcoming_payments" => "Sie haben keine bevorstehenden Zahlungen", "overdue_renewals" => "Überfällige Verlängerungen", "ai_recommendations" => "AI Empfehlungen", "your_budget" => "Ihr Budget", "budget" => "Budget", "budget_used" => "Budget verwendet", "over_budget" => "Über Budget", "your_subscriptions" => "Ihre Abonnements", "your_savings" => "Ihre Ersparnisse", // Subscriptions page "subscription" => "Abonnement", "no_subscriptions_yet" => "Keine Abonnements hinzugefügt", "add_first_subscription" => "Erstes Abonnement hinzufügen", "new_subscription" => "Neues Abonnement", "search" => "Suche", "state" => "Status", "alphanumeric" => "Alphanumerisch", "sort" => "Sortieren", "name" => "Bezeichnung", "last_added" => "Zuletzt hinzugefügt", "price" => "Preis", "next_payment" => "Nächste Zahlung", "renewal_type" => "Verlängerungstyp", "auto_renewal" => "Automatische Verlängerung", "automatically_renews" => "Automatisch verlängert", "manual_renewal" => "Manuelle Verlängerung", "start_date" => "Startdatum", "inactive" => "Abonnement deaktivieren", "replaced_with" => "Ersetzt durch", "none" => "Keine", "member" => "Mitglied", "category" => "Kategorie", "payment_method" => "Zahlungsmethode", "Daily" => "Täglich", "Weekly" => "Wöchentlich", "Monthly" => "Monatlich", "Yearly" => "Jährlich", "dayly" => "Tag(e)", "weekly" => "Woche(n)", "monthly" => "Monat(e)", "yearly" => "Jahr(e)", "days" => "Tage", "weeks" => "Wochen", "months" => "Monate", "years" => "Jahre", "external_url" => "Externe URL besuchen", "empty_page" => "Leere Seite", "clear_filters" => "Filter zurücksetzen", "no_matching_subscriptions" => "Keine passenden Abonnements gefunden", "clone" => "Klonen", "renew" => "Verlängern", "calculate_next_payment_date" => "Berechne nächstes Zahlungsdatum", // Subscription form "add_subscription" => "Abonnement hinzufügen", "edit_subscription" => "Abonnement editieren", "subscription_name" => "Bezeichnung des Abonnements", "logo_preview" => "Vorschau des Logos", "search_logo" => "Logo im Web suchen", "web_search" => "Websuche", "currency" => "Währung", "payment_every" => "Zahlung alle", "frequency" => "Abrechnungsfrequenz", "cycle" => "Zeitraum", "no_category" => "Keine Kategorie", "paid_by" => "Gezahlt durch", "url" => "URL", "notes" => "Notizen", "enable_notifications" => "Benachrichtigungen für dieses Abonnement aktivieren", "default_value_from_settings" => "Standardwert aus den Einstellungen", "cancellation_notification" => "Benachrichtigung bei Kündigung", "delete" => "Löschen", "cancel" => "Abbrechen", "upload_logo" => "Logo hochladen", // Statistics page "cant_convert_currency" => "Sie verwenden mehrere Währungen für Ihre Abonnements. Um gültige und genaue Statistiken zu erhalten, legen Sie bitte einen Fixer-API-Schlüssel auf der Einstellungsseite fest.", "general_statistics" => "Allgemeine Statistiken", "active_subscriptions" => "Aktive Abonnements", "inactive_subscriptions" => "Inaktive Abonnements", "monthly_cost" => "Monatliche Kosten", "yearly_cost" => "Jährliche Kosten", "average_monthly" => "Durchschnittliche monatliche Kosten", "most_expensive" => "Kosten des teuersten Abonnements", "amount_due" => "Diesen Monat fällige Summe", "percentage_budget_used" => "Prozentualer Anteil des Budgets genutzt", "budget_remaining" => "Verbleibendes Budget", "amount_over_budget" => "Überzogenes Budget", "monthly_savings" => "Monatliche Ersparnisse (bei inaktiven Abonnements)", "yearly_savings" => "Jährliche Ersparnisse (bei inaktiven Abonnements)", "split_views" => "Aufgeteilte Ansichten", "category_split" => "Kategorien", "household_split" => "Haushalt", "payment_method_split" => "Zahlungsmethode", "total_cost_trend" => "Kostenentwicklung", "cost_vs_budget" => "Kosten vs. Budget", // About page "about_and_credits" => "Informationen und Danksagungen", "credits" => "Danksagungen", "license" => "Lizenz", "release_notes" => "Versionshinweise", "update_available" => "Update verfügbar", "issues_and_requests" => "Issues und Anfragen", "the_author" => "Der Autor", "icons" => "Icons", "payment_icons" => "Zahlungsweisen Icons", // Profile page "upload_avatar" => "Avatar hochladen", "file_type_error" => "Dateityp nicht unterstützt", "user_details" => "Benutzerdetails", "two_factor_authentication" => "Zwei-Faktor-Authentifizierung", "two_factor_info" => "Die Zwei-Faktor-Authentifizierung fügt Ihrem Konto eine zusätzliche Sicherheitsebene hinzu.
Sie benötigen eine Authentifizierungs-App wie Google Authenticator, Authy oder Ente Auth, um den QR-Code zu scannen.", "two_factor_enabled_info" => "Ihr Konto ist mit der Zwei-Faktor-Authentifizierung gesichert. Sie können sie deaktivieren, indem Sie auf die Schaltfläche oben klicken.", "enable_two_factor_authentication" => "Zwei-Faktor-Authentifizierung aktivieren", "2fa_already_enabled" => "Zwei-Faktor-Authentifizierung ist bereits aktiviert", "totp_code_incorrect" => "TOTP-Code ist falsch", "backup_codes" => "Backup-Codes", "download_backup_codes" => "Backup-Codes herunterladen", "copy_to_clipboard" => "In die Zwischenablage kopieren", "totp_backup_codes_info" => "Speichern Sie diese Codes an einem sicheren Ort. Sie können sie verwenden, wenn Sie keinen Zugriff auf Ihre Authentifizierungs-App haben.", "disable_two_factor_authentication" => "Zwei-Faktor-Authentifizierung deaktivieren", "totp_code" => "TOTP-Code", "api_key" => "API Key", "regenerate" => "Neu generieren", "api_key_info" => "Der API-Schlüssel wird für die Integration von Drittanbieter-Apps verwendet. Wenn Sie Ihren Schlüssel neu generieren, müssen Sie ihn in allen Apps aktualisieren, die ihn verwenden.", // Settings page "monthly_budget" => "Monatliches Budget", "budget_info" => "Das monatliche Budget wird für die Berechnung der Statistiken verwendet.", "household" => "Haushalt", "save_member" => "Mitglied speichern", "delete_member" => "Mitglied löschen", "cant_delete_member" => "Hauptmitglied kann nicht gelöscht werden", "cant_delete_member_in_use" => "Mitglied mit Abonnement kann nicht gelöscht werden", "household_info" => "Über das E-Mail-Feld können die Haushaltsmitglieder über auslaufende Abonnements benachrichtigt werden.", "notifications" => "Benachrichtigungen", "enable_email_notifications" => "E-Mail Benachrichtigung aktivieren", "notify_me" => "Benachrichtige mich", "day_before" => "Tag bevor", "on_due_date" => "Am Fälligkeitsdatum", "days_before" => "Tage bevor", "smtp_address" => "SMTP Adresse", "port" => "Port", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "SMTP Benutzername", "smtp_password" => "SMTP Passwort", "from_email" => "Absender E-Mail Adresse (optional)", "send_to_other_emails" => "Benachrichtigungen auch an die folgenden E-Mail-Adressen senden (verwende ; um sie zu trennen):", "smtp_info" => "Das SMTP Passwort wird in Klartext übermittelt und gespeichert. Aus Sicherheitsgründen erstelle bitte einen gesonderten Account nur zu diesem Zweck.", "telegram" => "Telegram", "telegram_bot_token" => "Telegram Bot Token", "telegram_chat_id" => "Telegram Chat ID", "pushplus" => "Pushplus", "pushplus_token" => "Pushplus Token", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost Webhook URL", "mattermost_bot_username" => "Mattermost Bot Benutzername", "mattermost_bot_icon_emoji" => "Emoji zum Bot-Icon", "webhook" => "Webhook", "webhook_url" => "Webhook URL", "request_method" => "Request Methode", "custom_headers" => "Benutzerdefinierte Kopfzeilen", "webhook_payload" => "Webhook Payload", "payment_notifications_payload" => "Zahlungsbenachrichtigung Payload", "cancelation_notification_payload" => "Kündigungsbenachrichtigung Payload", "variables_available" => "Verfügbare Variablen", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Bot Benutzername", "discord_bot_avatar_url" => "Bot Avatar URL", "pushover" => "Pushover", "pushover_user_key" => "Pushover User Key", "host" => "Host", "topic" => "Topic", "ignore_ssl_errors" => "SSL Fehler ignorieren", "categories" => "Kategorien", "save_category" => "Kategorie speichern", "delete_category" => "Kategorie löschen", "cant_delete_category_in_use" => "Kategorie mit zugeordnetem Abonnement kann nicht gelöscht werden", "currencies" => "Währungen", "save_currency" => "Währung speichern", "delete_currency" => "Währung löschen", "cant_delete_main_currency" => "Hautwährung kann nicht gelöscht werden", "cant_delete_currency_in_use" => "Währungen die in Abonnements genutzt werden können nicht gelöscht werden", "exchange_update" => "Umrechnungskurs zuletzt aktualisiert am", "currency_info" => "Finde die unterstützten Währungen und korrekten Währungscodes auf", "currency_performance" => "Aus Gründen der Performance wähle bitte ausschließlich die genutzen Währungen.", "fixer_api_key" => "Fixer API Key", "provider" => "Anbieter", "fixer_info" => "Falls du mehrere Währungen nutzt und genaue Statistiken und die Sortierungsfunktion nutzen möchtest, wird ein kostenfreier API Key von Fixer benötigt.", "get_key" => "Erhalte deinen key bei", "get_free_fixer_api_key" => "Erhalte deinen kostenfreien Fixer API Key", "get_key_alternative" => "Alternativ können Sie einen kostenlosen Fixer-Api-Schlüssel erhalten von", "ai_model" => "AI Modell", "select_ai_model" => "Wählen Sie AI Modell", "run_schedule" => "Zeitplan ausführen", "manually" => "Manuell", "coming_soon" => "Demnächst", "invalid_host" => "Ungültiger Host", "ai_recommendations_info" => "AI Empfehlungen werden basierend auf Ihren Abonnements und Haushaltsmitgliedern generiert.", "may_take_time" => "Je nach Anbieter, Modell und Anzahl der Abonnements kann die Generierung von Empfehlungen einige Zeit in Anspruch nehmen.", "recommendations_visible_on_dashboard" => "Empfehlungen werden auf dem Dashboard sichtbar sein.", "generate_recommendations" => "Empfehlungen generieren", "display_settings" => "Anzeigeeinstellungen", "theme_settings" => "Themen-Einstellungen", "colors" => "Farben", "custom_colors" => "Benutzerdefinierte Farben", "theme" => "Thema", "dark_theme" => "Dark Theme", "light_theme" => "Light Theme", "automatic"=> "Automatisch", "main_color" => "Hauptfarbe", "accent_color" => "Akzentfarbe", "hover_color" => "Hover Farbe", "custom_css" => "Benutzerdefiniertes CSS", "save_custom_css" => "Benutzerdefiniertes CSS speichern", "save_custom_colors" => "Benutzerdefinierte Farben speichern", "reset_custom_colors" => "Benutzerdefinierte Farben zurücksetzen", "calculate_monthly_price" => "Berechne und zeige monatlichen Preis für alle Abonnements an", "convert_prices" => "Preise immer in meine Hauptwährung umrechnen und darin anzeigen (langsamer)", "show_original_price" => "Originalpreis anzeigen, wenn Umrechnungen oder Berechnungen durchgeführt werden", "experience" => "Erfahrung", "show_subscription_progress" => "Abonnementfortschritt anzeigen", "disabled_subscriptions" => "Deaktivierte Abonnements", "hide_disabled_subscriptions" => "Deaktivierte Abonnements verstecken", "show_disabled_subscriptions_at_the_bottom" => "Deaktivierte Abonnements am Ende anzeigen", "experimental_settings" => "Experimentelle Einstellungen", "remove_background" => "Versuchen den Hintergrund von Logos aus der Bildersuche zu entfernen", "use_mobile_navigation_bar" => "Mobile Navigationsleiste verwenden", "experimental_info" => "Experimentelle Einstellungen funktionieren möglicherweise nicht perfekt.", "payment_methods" => "Zahlungsmethoden", "payment_methods_info" => "Zahlungsmethode zum (de-)aktivieren anklicken.", "rename_payment_methods_info" => "Klicken Sie auf den Namen einer Zahlungsmethode, um sie umzubenennen", "cant_delete_payment_method_in_use" => "Genutzte Zahlungsmethoden können nicht deaktiviert werden", "add_custom_payment" => "Eigene Zahlungsmethode hinzufügen", "payment_method_name" => "Name der Zahlungsmethode", "payment_method_added_successfuly" => "Zahlungsmethode erfolgreich hinzugefügt", "payment_method_removed" => "Zahlungsmethode gelöscht", "disable" => "Deaktivieren", "enable" => "Aktivieren", "rename_payment_method" => "Zahlungsmethode umbenennen", "payment_renamed" => "Zahlungsmethode umbenannt", "payment_not_renamed" => "Zahlungsmethode konnte nicht umbenannt werden", "test" => "Test", "add" => "Hinzufügen", "save" => "Speichern", "reset" => "Zurücksetzen", "main_accent_color_error" => "Haupt- und Akzentfarbe dürfen nicht identisch sein", "backup_and_restore" => "Backup und Wiederherstellung", "backup" => "Backup", "restore" => "Wiederherstellen", "restore_info" => "Durch die Wiederherstellung der Datenbank werden alle aktuellen Daten überschrieben. Nach der Wiederherstellung werden Sie abgemeldet.", "account" => "Konto", "export_subscriptions" => "Abonnements exportieren", "export_as_json" => "Als JSON exportieren", "export_as_csv" => "Als CSV exportieren", "danger_zone" => "Gefahrenzone", "delete_account" => "Konto löschen", "delete_account_info" => "Mit dem Löschen Ihres Kontos werden auch alle Ihre Abonnements und Einstellungen gelöscht.", // Filters menu "filter" => "Filter", "clear" => "Leeren", // Toast "success" => "Erfolgreich", // Endpoint responses "session_expired" => "Session abgelaufen. Bitte erneut einloggen", "fields_missing" => "Einige Felder fehlen", "fill_all_fields" => "Bitte alle Felder ausfüllen", "fill_mandatory_fields" => "Bitte alle Pflichtfelder ausfüllen", "error" => "Fehler", // Category "failed_add_category" => "Kategorie konnte nicht hinzugefügt werden", "failed_edit_category" => "Kategorie konnte nicht editiert werden", "category_in_use" => "Kategorie wird in Abonnements verwendet und kann nicht gelöscht werden", "failed_remove_category" => "Kategorie konnte nicht gelöscht werden", "category_saved" => "Kategorie gespeichert", "category_removed" => "Kategorie gelöscht", "sort_order_saved" => "Sortierung gespeichert", // Currency "currency_saved" => "wurde gespeichert.", "error_adding_currency" => "Fehler beim hinzufügen der Währung.", "failed_to_store_currency" => "Währung konnte nicht zur Datenbank hinzugefügt werden.", "currency_in_use" => "Währung wird in Abonnements verwendet und kann nicht gelöscht werden.", "currency_is_main" => "Währung ist als Hauptwährung konfiguriert und kann nicht gelöscht werden.", "failed_to_remove_currency" => "Währung konnte nicht aus Datenbank gelöscht werden.", "failed_to_store_api_key" => "API Key konnte nicht in Datenbank gespeichert werden.", "invalid_api_key" => "Ungültiger API Key.", "api_key_saved" => "API key erfolgreich gespeichert", "currency_removed" => "Währung gelöscht", // Household "failed_add_household" => "Haushaltsmitglied konnte nicht hinzugefügt werden", "failed_edit_household" => "Haushaltsmitglied konnte nicht editiert werden", "failed_remove_household" => "Haushaltsmitglied konnte nicht gelöscht werden", "household_in_use" => "Haushaltsmitglied wird in Abonnements verwendet und kann nicht gelöscht werden", "member_saved" => "Mitglied gespeichert", "member_removed" => "Mitglied gelöscht", // Notifications "error_saving_notifications" => "Benachrichtigungsangaben konnten nicht gespeichert werden.", "wallos_notification" => "Wallos Benachrichtigung", "test_notification" => "Dies ist eine Test-Benachrichtigung. Wenn du das hier siehst, sind deine Konfigurationen korrekt.", "email_error" => "E-Mail konnte nicht gesendet werden", "notification_sent_successfuly" => "Benachrichtigung erfolgreich gesendet", "notifications_settings_saved" => "Benachrichtigungseinstellungen erfolgreich gespeichert.", "notification_failed" => "Benachrichtigung fehlgeschlagen", // Payments "payment_in_use" => "Genutzte Zahlungsmethoden können nicht deaktiviert werden", "failed_update_payment" => "Zahlungsmethode in Datenbank konnte nicht aktualisiert werden", "enabled" => "aktiviert", "disabled" => "deaktiviert", // Subscription "error_fetching_image" => "Fehler beim Laden des Bildes", "subscription_updated_successfuly" => "Abonnement erfolgreich aktualisiert", "subscription_added_successfuly" => "Abonnement erfolgreich hinzugefügt", "error_deleting_subscription" => "Abonnement konnte nicht gelöscht werden.", "invalid_request_method" => "Ungültige Request Methode.", // User "error_updating_user_data" => "Benutzerangaben konnten nicht aktualisiert werden.", "user_details_saved" => "Benutzerangaben gespeichert", // Admin Page "registrations" => "Registrierungen", "enable_user_registrations" => "Benutzerregistrierungen aktivieren", "maximum_number_users" => "Maximale Anzahl an Benutzern", "require_email_verification" => "E-Mail Verifizierung erforderlich", "configure_smtp_settings_to_enable" => "Konfiguriere SMTP Einstellungen um dies zu aktivieren", "server_url" => "Server URL", "server_url_info" => "Wird für die E-Mail-Überprüfung und die Passwortwiederherstellung verwendet. Muss eine gültige öffentliche URL sein.", "server_url_password_reset" => "Wenn diese Option gesetzt ist, wird auch die Funktion zum Zurücksetzen des Passworts aktiviert.", "disable_login" => "Login deaktivieren", "disable_login_info" => "Anmeldung umgehen. Wenn Sie Ihren Server nur in einem lokalen Netzwerk betreiben, ohne Zugriff von außen, können Sie die Anmeldung deaktivieren. Dadurch wird automatisch der Admin-Benutzer angemeldet.", "disable_login_info2" => "Sie können diese Einstellung nur aktivieren, wenn die Benutzerregistrierung ausgeschaltet ist und es nicht mehr als ein Admin-Benutzerkonto gibt.", "max_users_info" => "0 für unbegrenzte Anzahl an Benutzern", "user_management" => "Benutzerverwaltung", "delete_user" => "Benutzer löschen", "delete_user_info" => "Durch das Löschen eines Benutzers werden auch alle seine Abonnements und Einstellungen gelöscht.", "create_user" => "Benutzer erstellen", "oidc_settings" => "OIDC Einstellungen", "oidc_oauth_enabled" => "OIDC/OAuth aktivieren", "create_user_automatically" => "Benutzer automatisch erstellen", "disable_password_login" => "Passwort-Login deaktivieren", "smtp_settings" => "SMTP Einstellungen", "smtp_usage_info" => "Wird für die Passwortwiederherstellung und andere System-E-Mails verwendet", "security_settings" => "Sicherheitseinstellungen", "ssrf_protection_info" => "Um Server-Side Request Forgery (SSRF)-Angriffe zu verhindern, blockiert Wallos standardmäßig Webhook-Benachrichtigungen an private oder interne Netzwerkadressen.", "local_webhook_info" => "Wenn Sie Webhooks an lokale Dienste (z. B. Home Assistant, Gotify oder Node-RED) senden müssen, geben Sie deren IP-Adressen oder Hostnamen oben als durch Kommas getrennte Liste ein (z. B. 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Wartungsaufgaben", "orphaned_logos" => "Verwaiste Logos", "update" => "Update", "new_version_available" => "Eine neue Version von Wallos ist verfügbar", "current_version" => "Aktuelle Version", "latest_version" => "Neueste Version", "on_current_version" => "Sie verwenden die neueste Version von Wallos.", "show_update_notification" => "Benachrichtigung über Updates auf dem Dashboard anzeigen", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "E-Mail verifiziert", "email_verification_failed" => "E-Mail konnte nicht verifiziert werden", // Calendar "calendar" => "Kalender", "sun" => "So", "mon" => "Mo", "tue" => "Di", "wed" => "Mi", "thu" => "Do", "fri" => "Fr", "sat" => "Sa", "month-01" => "Januar", "month-02" => "Februar", "month-03" => "März", "month-04" => "April", "month-05" => "Mai", "month-06" => "Juni", "month-07" => "Juli", "month-08" => "August", "month-09" => "September", "month-10" => "Oktober", "month-11" => "November", "month-12" => "Dezember", "total_cost" => "Gesamtkosten", "export_icalendar" => "iCalendar exportieren", "over_budget_warning" => "Sie haben Ihr Budget überschritten", // TOTP Page "insert_totp_code" => "Bitte geben Sie den TOTP-Code ein", ]; ?> ================================================ FILE: includes/i18n/el.php ================================================ "Πρέπει να δημιουργήσεις έναν λογαριασμό για να μπορέσεις να συνδεθείς.", "username" => "Όνομα χρήστη", "password" => "Κωδικός", "email" => "Email", "firstname" => "Ονομα", "lastname" => "Επώνυμο", "confirm_password" => "Επιβεβαίωση κωδικού", "main_currency" => "Κύριο νόμισμα", "language" => "Γλώσσα", "passwords_dont_match" => "Οι κωδικοί πρόσβασης δεν ταιριάζουν", "username_exists" => "Το όνομα χρήστη υπάρχει ήδη", "email_exists" => "Το email υπάρχει ήδη", "registration_failed" => "Η εγγραφή απέτυχε, παρακαλώ προσπάθησε ξανά.", "register" => "Εγγραφή", "restore_database" => "Επαναφορά βάσης δεδομένων", // Login Page "please_login" => "Παρακαλώ συνδέσου", "stay_logged_in" => "Μείνε συνδεδεμένος (30 ημέρες)", "login" => "Σύνδεση", "login_with" => "Σύνδεση με", "or" => "ή", "login_failed" => "Τα στοιχεία σύνδεσης είναι λανθασμένα", "registration_successful" => "Επιτυχής Εγγραφή", "user_email_waiting_verification" => "Το email σας πρέπει να επαληθευτεί. Παρακαλούμε ελέγξτε το email σας", // Password Reset Page "forgot_password" => "Ξέχασες τον κωδικό σου; Κάνε κλικ", "reset_password" => "Επαναφορά κωδικού πρόσβασης", "reset_sent_check_email" => "Ένα email με οδηγίες για την επαναφορά του κωδικού πρόσβασης σας έχει σταλεί. Παρακαλώ ελέγξτε το email σας.", "password_reset_successful" => "Επιτυχής επαναφορά κωδικού πρόσβασης", // Header "profile" => "Προφίλ", "dashboard" => "Πίνακας", "subscriptions" => "Συνδρομές", "stats" => "Στατιστικές", "settings" => "Ρυθμίσεις", "admin" => "Διαχείριση", "about" => "Για εμάς", "logout" => "Αποσύνδεση", // Dashboard "hello" => "Γειά σου", "upcoming_payments" => "Επερχόμενες Πληρωμές", "no_upcoming_payments" => "Δεν έχετε καμία επερχόμενη πληρωμή", "overdue_renewals" => "Καθυστερημένες Ανανεώσεις", "ai_recommendations" => "Συστάσεις AI", "your_budget" => "Ο Προϋπολογισμός σας", "budget" => "Προϋπολογισμός", "budget_used" => "Προϋπολογισμός Χρησιμοποιημένος", "over_budget" => "Πάνω από τον Προϋπολογισμό", "your_subscriptions" => "Οι Συνδρομές σας", "your_savings" => "Οι Εξοικονομήσεις σας", // Subscriptions page "subscription" => "Συνδρομή", "no_subscriptions_yet" => "Δεν υπάρχουν καταχωρημένες συνδρομές", "add_first_subscription" => "Προσθήκη πρώτης συνδρομής", "new_subscription" => "Νέα συνδρομή", "search" => "Αναζήτηση", "state" => "Κατάσταση", "alphanumeric" => "Αλφαριθμητική", "sort" => "Ταξινόμηση", "name" => "Όνομα", "last_added" => "Τελευταία προσθήκη", "price" => "Τιμή", "next_payment" => "Επόμενη πληρωμή", "renewal_type" => "Τύπος ανανέωσης", "auto_renewal" => "Αυτόματη ανανέωση", "automatically_renews" => "Ανανεώνεται αυτόματα", "manual_renewal" => "Χειροκίνητη ανανέωση", "start_date" => "Ημερομηνία έναρξης", "inactive" => "Απενεργοποίηση συνδρομής", "replaced_with" => "Αντικαταστάθηκε με", "none" => "Κανένα", "member" => "Χρήστης", "category" => "Κατηγορία", "payment_method" => "Τρόπος πληρωμής", "Daily" => "Καθημερινή", "Weekly" => "Εβδομαδιαία", "Monthly" => "Μηνιαία", "Yearly" => "Ετήσια", "dayly" => "Ημέρα(ες)", "weekly" => "Εβδομάδα", "monthly" => "Μήνας(ες)", "yearly" => "Χρόνος(ια)", "days" => "ημέρες", "weeks" => "εβδομάδες", "months" => "μήνες", "years" => "χρόνια", "external_url" => "Επίσκεψη εξωτερικού συνδέσμου", "empty_page" => "Κενή σελίδα", "clear_filters" => "Καθαρισμός φίλτρων", "no_matching_subscriptions" => "Δεν υπάρχουν συνδρομές που ταιριάζουν με τα φίλτρα σου", "clone" => "Κλώνος", "renew" => "Ανανέωση", "calculate_next_payment_date" => "Υπολογισμός ημερομηνίας επόμενης πληρωμής", // Subscription form "add_subscription" => "Προσθήκη συνδρομής", "edit_subscription" => "Επεξεργασία συνδρομής", "subscription_name" => "Όνομα συνδρομής", "logo_preview" => "Προεπισκόπηση λογότυπου", "search_logo" => "Αναζήτηση λογότυπου στο web", "web_search" => "Αναζήτηση web", "currency" => "Νόμισμα", "payment_every" => "Πληρωμή κάθε", "frequency" => "Συχνότητα", "cycle" => "Κύκλος", "no_category" => "Καμία κατηγορία", "paid_by" => "Πληρώνεται από", "url" => "URL", "notes" => "Σημειώσεις", "enable_notifications" => "Ενεργοποίηση ειδοποιήσεων για αυτή τη συνδρομή", "default_value_from_settings" => "Προεπιλεγμένη τιμή από τις ρυθμίσεις", "cancellation_notification" => "Ειδοποίηση ακύρωσης", "delete" => "Διαγραφή", "cancel" => "Ακύρωση", "upload_logo" => "Φόρτωση λογότυπου", // Statistics page "cant_convert_currency" => "Χρησιμοποιείτε πολλαπλά νομίσματα στις συνδρομές σας. Για να έχετε έγκυρα και ακριβή στατιστικά στοιχεία, παρακαλούμε ορίστε ένα κλειδί API Fixer στη σελίδα ρυθμίσεων.", "general_statistics" => "Γενικές στατιστικές", "active_subscriptions" => "Ενεργές συνδρομές", "inactive_subscriptions" => "Ανενεργές συνδρομές", "monthly_cost" => "Μηνιαίο κόστος", "yearly_cost" => "Ετήσιο κόστος", "average_monthly" => "Μέσο μηνιαίο κόστος συνδρομής", "most_expensive" => "Πιο ακριβό κόστος συνδρομής", "amount_due" => "Ποσό που οφείλεται αυτόν τον μήνα", "percentage_budget_used" => "Ποσοστό προϋπολογισμού που χρησιμοποιείται", "budget_remaining" => "Υπόλοιπο προϋπολογισμού", "amount_over_budget" => "Ποσό πάνω από τον προϋπολογισμό", "monthly_savings" => "Μηνιαίες εξοικονομήσεις (σε ανενεργές συνδρομές)", "yearly_savings" => "Ετήσιες εξοικονομήσεις (σε ανενεργές συνδρομές)", "split_views" => "Διαχωρισμένες προβολές", "category_split" => "Διαχωρισμός κατηγορίας", "household_split" => "Διαχωρισμός νοικοκυριού", "payment_method_split" => "Διαχωρισμός τρόπου πληρωμής", "total_cost_trend" => "Τάση συνολικού κόστους", "cost_vs_budget" => "Κόστος έναντι προϋπολογισμού", // About page "about_and_credits" => "Σχετικά και Credits", "credits" => "Credits", "license" => "License", "release_notes" => "Σημειώσεις έκδοσης", "update_available" => "Διαθέσιμη ενημέρωση", "issues_and_requests" => "Προβλήματα και αιτήσεις", "the_author" => "Προγραμματιστής", "icons" => "Εικονίδια", "payment_icons" => "Εικονίδια Payment", // Profile page "upload_avatar" => "μεταφόρτωση άβαταρ", "file_type_error" => "Το αρχείο πρέπει να είναι τύπου jpg, jpeg, png, webp ή gif", "user_details" => "Λεπτομέρειες χρήστη", "two_factor_authentication" => "Διπλής πιστοποίησης", "two_factor_info" => "Ο έλεγχος ταυτότητας δύο παραγόντων προσθέτει ένα επιπλέον επίπεδο ασφάλειας στο λογαριασμό σας.
Θα χρειαστείτε μια εφαρμογή ελέγχου ταυτότητας όπως το Google Authenticator, το Authy ή το Ente Auth για να σαρώσετε τον κωδικό QR.", "two_factor_enabled_info" => "Ο λογαριασμός σας είναι ασφαλής με τον έλεγχο ταυτότητας δύο παραγόντων. Μπορείτε να τον απενεργοποιήσετε κάνοντας κλικ στο κουμπί παραπάνω.", "enable_two_factor_authentication" => "Ενεργοποίηση διπλής πιστοποίησης", "2fa_already_enabled" => "Ο έλεγχος ταυτότητας δύο παραγόντων είναι ήδη ενεργοποιημένος", "totp_code_incorrect" => "Ο κωδικός TOTP είναι εσφαλμένος", "backup_codes" => "Κωδικοί ανάκτησης", "download_backup_codes" => "Κατέβασε τους κωδικούς ανάκτησης", "copy_to_clipboard" => "Αντιγραφή στο πρόχειρο", "totp_backup_codes_info" => "Αποθηκεύστε αυτούς τους κωδικούς ανάκτησης σε ένα ασφαλές μέρος. Θα χρειαστείτε έναν από αυτούς τους κωδικούς ανάκτησης για να αποκτήσετε πρόσβαση στο λογαριασμό σας σε περίπτωση που χάσετε τη συσκευή σας.", "disable_two_factor_authentication" => "Απενεργοποίηση διπλής πιστοποίησης", "totp_code" => "Κωδικός TOTP", "api_key" => "API κλειδί", "regenerate" => "Επαναδημιουργία", "api_key_info" => "Το API κλειδί χρησιμοποιείται για την επικοινωνία με το API του Wallos. Μην αποκαλύπτετε το API κλειδί σας σε κανέναν.", // Settings page "monthly_budget" => "Μηνιαίος προϋπολογισμός", "budget_info" => "Ο μηνιαίος προϋπολογισμός χρησιμοποιείται για τον υπολογισμό των στατιστικών", "household" => "Νοικοκυριό", "save_member" => "Αποθήκευση μέλους", "delete_member" => "Διαγραφή μέλους", "cant_delete_member" => "Δεν ειναι δυνατή η διαγραφή του βασικού μέλους", "cant_delete_member_in_use" => "Δεν ειναι δυνατή η διαγραφή μέλους που χρησιμοποιείται", "household_info" => "Το πεδίο ηλεκτρονικού ταχυδρομείου επιτρέπει στα μέλη του νοικοκυριού να ειδοποιούνται για συνδρομές που πρόκειται να λήξουν.", "notifications" => "Ειδοποιήσεις", "enable_email_notifications" => "Ενεργοποίηση ειδοποιήσεων με email", "notify_me" => "Ειδοποίησε με", "day_before" => "ημέρα πριν", "on_due_date" => "την ημερομηνία λήξης", "days_before" => "ημέρες πριν", "smtp_address" => "SMTP Address", "port" => "Θύρα", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "SMTP χρήστης", "smtp_password" => "SMTP κωδικός", "from_email" => "Από (Προαιρετικό)", "send_to_other_emails" => "Επίσης στείλτε ειδοποιήσεις στις ακόλουθες διευθύνσεις email (χρησιμοποιήστε ; για να τις διαχωρίσετε):", "smtp_info" => "Ο κωδικός πρόσβασης SMTP μεταδίδεται και αποθηκεύεται σε απλό κείμενο. Για λόγους ασφαλείας, παρακαλούμε δημιούργησε έναν λογαριασμό μόνο γι' αυτό το σκοπό.", "telegram" => "Telegram", "telegram_bot_token" => "Τηλεγραφήματα Bot Token", "telegram_chat_id" => "Τηλεγραφήματα Chat ID", "pushplus" => "Pushplus", "pushplus_token" => "Pushplus Token", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost WebHook URL", "mattermost_bot_username" => "Mattermost Bot Όνομα χρήστη", "mattermost_bot_icon_emoji" => "Emoji εικονιδίου bot", "webhook" => "Webhook", "webhook_url" => "Webhook URL", "request_method" => "Μέθοδος αίτησης", "custom_headers" => "Προσαρμοσμένες κεφαλίδες", "webhook_payload" => "Webhook Payload", "payment_notifications_payload" => "Ειδοποίηση πληρωμής Payload", "cancelation_notification_payload" => "Ακύρωση ειδοποίησης Payload", "variables_available" => "Διαθέσιμες μεταβλητές", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Discord Bot Username", "discord_bot_avatar_url" => "Discord Bot Avatar URL", "pushover" => "Pushover", "pushover_user_key" => "Pushover User Key", "host" => "Host", "topic" => "Θέμα", "ignore_ssl_errors" => "Αγνόηση σφαλμάτων SSL", "categories" => "Κατηγορίες", "save_category" => "Αποθήκευση κατηγορίας", "delete_category" => "Διαγραφή κατηγορίας", "cant_delete_category_in_use" => "Δεν ειναι δυνατή η διαγραφή κατηγορίας που χρησιμοποιείται", "currencies" => "Νομίσματα", "save_currency" => "Αποθήκευση νομίσματος", "delete_currency" => "Διαγραφή νομίσματος", "cant_delete_main_currency" => "Δεν ειναι δυνατή η διαγραφή βασικού νομίσματος", "cant_delete_currency_in_use" => "Δεν ειναι δυνατή η διαγραφή νομίσματος που χρησιμοποιείται", "exchange_update" => "Τελευταία ενημέρωση συναλλαγματικών ισοτιμίων", "currency_info" => "Βρες τα υποστηριζόμενα νομίσματα και τους σωστούς κωδικούς νομίσματος στο", "currency_performance" => "Για βελτιωμένη απόδοση κράτησε μόνο τα νομίσματα που χρησιμοποιείς.", "fixer_api_key" => "Fixer API κλειδί", "provider" => "Πάροχος", "fixer_info" => "Εάν χρησιμοποιείς πολλαπλά νομίσματα και θέλεις ακριβή στατιστικά στοιχεία και ταξινόμηση των συνδρομών, είναι απαραίτητο ένα ΔΩΡΕΑΝ κλειδί API από το Fixer.", "get_key" => "Απόκτησε το κλειδί στο", "get_free_fixer_api_key" => "Απόκτησε ΔΩΡΕΑΝ Fixer API κλειδί", "get_key_alternative" => "Εναλλακτικά, μπορείτε να λάβετε ένα δωρεάν κλειδί api fixer από το", "ai_model" => "AI Μοντέλο", "select_ai_model" => "Επιλέξτε AI Μοντέλο", "run_schedule" => "Εκτέλεση προγράμματος", "manually" => "Χειροκίνητα", "coming_soon" => "Έρχεται σύντομα", "invalid_host" => "Μη έγκυρος διακομιστής", "ai_recommendations_info" => "AI προτάσεις δημιουργούνται με βάση τα συνδρομητικά σας και τα μέλη του νοικοκυριού σας.", "may_take_time" => "Ανάλογα με τον πάροχο, το μοντέλο και τον αριθμό των συνδρομών, η δημιουργία προτάσεων μπορεί να διαρκέσει κάποιο χρόνο.", "recommendations_visible_on_dashboard" => "Οι προτάσεις θα είναι ορατές στον πίνακα ελέγχου.", "generate_recommendations" => "Δημιουργία προτάσεων", "display_settings" => "Ρυθμίσεις εμφάνισης", "theme_settings" => "Ρυθμίσεις θέματος", "colors" => "Χρώματα", "custom_colors" => "Προσαρμοσμένα χρώματα", "theme" => "Θέμα", "dark_theme" => "Dark Theme", "light_theme" => "Light Theme", "automatic" => "Αυτόματο", "main_color" => "Κύριο χρώμα", "accent_color" => "Χρώμα επισήμανσης", "hover_color" => "Χρώμα πάνω από", "save_custom_colors" => "Αποθήκευση προσαρμοσμένων χρωμάτων", "reset_custom_colors" => "Επαναφορά προεπιλεγμένων χρωμάτων", "custom_css" => "Προσαρμοσμένο CSS", "save_custom_css" => "Αποθήκευση προσαρμοσμένου CSS", "calculate_monthly_price" => "Υπολογισμός και εμφάνιση της μηνιαίας τιμής για όλες τις συνδρομές", "convert_prices" => "Πάντα να μετατρέπει και να εμφανίζει τις τιμές στο κύριο νόμισμά μου (πιο αργό)", "show_original_price" => "Εμφάνιση της αρχικής τιμής όταν γίνονται μετατροπές ή υπολογισμοί", "experience" => "Εμπειρία", "show_subscription_progress" => "Εμφάνιση προόδου συνδρομής", "disabled_subscriptions" => "Απενεργοποιημένες συνδρομές", "hide_disabled_subscriptions" => "Απόκρυψη απενεργοποιημένων συνδρομών", "show_disabled_subscriptions_at_the_bottom" => "Εμφάνιση απενεργοποιημένων συνδρομών στο τέλος", "experimental_settings" => "Πειραματικές ρυθμίσεις", "remove_background" => "Προσπάθεια αφαίρεσης του φόντου των λογότυπων από την αναζήτηση εικόνας", "use_mobile_navigation_bar" => "Χρήση μπάρας πλοήγησης για κινητά", "experimental_info" => "Οι πειραματικές ρυθμίσεις πιθανότατα δεν θα λειτουργούν τέλεια.", "payment_methods" => "Τρόποι πληρωμής", "payment_methods_info" => "Κάνε κλικ σε μια μέθοδο πληρωμής για να την απενεργοποιήσεις/ενεργοποιήσεις.", "rename_payment_methods_info" => "Κάντε κλικ στο όνομα μιας μεθόδου πληρωμής για να τη μετονομάσετε.", "cant_delete_payment_method_in_use" => "Δεν είναι εφικτό να απενεργοποιηθεί η χρησιμοποιούμενη μέθοδο πληρωμής", "add_custom_payment" => "Προσθήκη προσαρμοσμένης μεθόδου πληρωμής", "payment_method_name" => "Όνομα μεθόδου πληρωμής", "payment_method_added_successfuly" => "Η μέθοδος πληρωμής προστέθηκε με επιτυχία", "payment_method_removed" => "Η μέθοδος πληρωμής αφαιρέθηκε", "disable" => "Ανενεργό", "enable" => "Ενεργό", "rename_payment_method" => "Μετονομασία μεθόδου πληρωμής", "payment_renamed" => "Η μέθοδος πληρωμής μετονομάστηκε", "payment_not_renamed" => "Η μέθοδος πληρωμής δεν μετονομάστηκε", "test" => "Δοκιμή", "add" => "Προσθήκη", "save" => "Αποθήκευση", "reset" => "Επαναφορά", "main_accent_color_error" => "Το κύριο χρώμα δεν μπορεί να είναι το ίδιο με το χρώμα επισήμανσης", "backup_and_restore" => "Αντίγραφο ασφαλείας και επαναφορά", "backup" => "Αντίγραφο ασφαλείας", "restore" => "Επαναφορά", "restore_info" => "Η επαναφορά της βάσης δεδομένων θα ακυρώσει όλα τα τρέχοντα δεδομένα. Μετά την επαναφορά θα αποσυνδεθείτε.", "account" => "Λογαριασμός", "export_subscriptions" => "Εξαγωγή συνδρομών", "export_as_json" => "Εξαγωγή ως JSON", "export_as_csv" => "Εξαγωγή ως CSV", "danger_zone" => "Ζώνη κινδύνου", "delete_account" => "Διαγραφή λογαριασμού", "delete_account_info" => "Η διαγραφή του λογαριασμού θα ακυρώσει όλα τα δεδομένα και θα αποσυνδεθείτε. Αυτή η ενέργεια είναι μη αναστρέψιμη.", // Filters menu "filter" => "Φίλτρο", "clear" => "Καθαρισμός", // Toast "success" => "Επιτυχία", // Endpoint responses "session_expired" => "Η συνεδρία σου έληξε. Παρακαλώ συνδέσου ξανά", "fields_missing" => "Some fields are missing", "fill_all_fields" => "Παρακαλώ συμπλήρωσε όλα τα πεδία", "fill_mandatory_fields" => "Παρακαλώ συμπλήρωσε όλα τα υποχρεωτικά πεδία", "error" => "Σφάλμα", // Category "failed_add_category" => "Απέτυχε η προσθήκη κατηγορίας", "failed_edit_category" => "Απέτυχε η επεξεργασία κατηγορίας", "category_in_use" => "Η κατηγορία χρησιμοποιείται στις συνδρομές και δεν μπορεί να αφαιρεθεί", "failed_remove_category" => "Απέτυχε η διαγραφή κατηγορίας", "category_saved" => "Αποθήκευση κατηγορίας", "category_removed" => "Διαγραφή κατηγορίας", "sort_order_saved" => "Η ταξινόμηση αποθηκεύτηκε", // Currency "currency_saved" => "αποθηκεύτηκε.", "error_adding_currency" => "Error adding currency entry.", "failed_to_store_currency" => "Failed to store Currency on the Database.", "currency_in_use" => "Currency is in use in subscriptions and can't be deleted.", "currency_is_main" => "Currency is set as main currency and can't be deleted.", "failed_to_remove_currency" => "Failed to remove currency from the Database.", "failed_to_store_api_key" => "Failed to store API Key on the Database.", "invalid_api_key" => "Invalid API Key.", "api_key_saved" => "API key saved successfully", "currency_removed" => "Currency removed", // Household "failed_add_household" => "Η πρόσθεση μέλους απέτυχε", "failed_edit_household" => "Η επεξεργασία μέλους απέτυχε", "failed_remove_household" => "Η διαγραφή μέλους απέτυχε", "household_in_use" => "Το μέλος χρησιμοποιείται σε συνδρομές και δεν μπορεί να αφαιρεθεί", "member_saved" => "Αποθήκευση μέλους", "member_removed" => "Διαγραφή μέλους", // Notifications "error_saving_notifications" => "Σφάλμα αποθήκευσης δεδομένων ειδοποιήσεων.", "wallos_notification" => "Ειδοποίηση Wallos", "test_notification" => "Πρόκειται για δοκιμαστική ειδοποίηση. Αν το βλέπεις αυτό, η ρύθμιση είναι σωστή.", "email_error" => "Σφάλμα αποστολής email", "notification_sent_successfuly" => "Η ειδοποίηση εστάλη επιτυχώς", "notifications_settings_saved" => "Οι ρυθμίσεις ειδοποίησης αποθηκεύτηκαν με επιτυχία.", "notification_failed" => "Η ειδοποίηση απέτυχε", // Payments "payment_in_use" => "Δεν είναι εφικτό να απενεργοποιηθεί η χρησιμοποιούμενη μέθοδο πληρωμής", "failed_update_payment" => "Απέτυχε η ενημέρωση της μεθόδου πληρωμής στη βάση δεδομένων", "enabled" => "ενεργοποιημένο", "disabled" => "απενεργοποιημένο", // Subscription "error_fetching_image" => "Σφάλμα λήψης εικόνας", "subscription_updated_successfuly" => "Η συνδρομή ενημερώθηκε επιτυχώς", "subscription_added_successfuly" => "Η συνδρομή προστέθηκε με επιτυχία", "error_deleting_subscription" => "Σφάλμα διαγραφής συνδρομής.", "invalid_request_method" => "Μη έγκυρη μέθοδος αιτήματος.", // User "error_updating_user_data" => "Σφάλμα ενημέρωσης δεδομένων χρήστη.", "user_details_saved" => "Αποθήκευση στοιχείων χρήστη", // Admin Page "registrations" => "Εγγραφές", "enable_user_registrations" => "Ενεργοποίηση εγγραφών χρηστών", "maximum_number_users" => "Μέγιστος αριθμός χρηστών", "require_email_verification" => "Απαιτείται επιβεβαίωση email", "configure_smtp_settings_to_enable" => "Διαμόρφωσε τις ρυθμίσεις SMTP για να ενεργοποιήσεις αυτή την επιλογή", "server_url" => "Διεύθυνση URL διακομιστή", "server_url_info" => "Χρησιμοποιείται για επαλήθευση email και ανάκτηση κωδικού πρόσβασης. Πρέπει να είναι ένα έγκυρο δημόσιο URL.", "server_url_password_reset" => "Εάν οριστεί, θα ενεργοποιήσει επίσης τη λειτουργία επαναφοράς κωδικού πρόσβασης.", "disable_login" => "Απενεργοποίηση σύνδεσης", "disable_login_info" => "Παράκαμψη σύνδεσης. Εάν εκτελείτε το διακομιστή σας μόνο σε τοπικό δίκτυο, χωρίς εξωτερική πρόσβαση, μπορείτε να απενεργοποιήσετε τη σύνδεση. Αυτό θα πραγματοποιήσει αυτόματα την είσοδο του χρήστη διαχειριστή.", "disable_login_info2" => "Μπορείτε να ενεργοποιήσετε αυτή τη ρύθμιση μόνο εάν η εγγραφή χρηστών είναι απενεργοποιημένη και δεν υπάρχουν περισσότεροι από το λογαριασμό χρήστη διαχειριστή.", "max_users_info" => "Ο μέγιστος αριθμός χρηστών που μπορούν να εγγραφούν. Αν η τιμή είναι 0, δεν υπάρχει όριο.", "user_management" => "Διαχείριση χρηστών", "delete_user" => "Διαγραφή χρήστη", "delete_user_info" => "Η διαγραφή ενός χρήστη θα διαγράψει επίσης όλες τις συνδρομές και τις ρυθμίσεις του.", "create_user" => "Δημιουργία χρήστη", "oidc_settings" => "Ρυθμίσεις OIDC", "oidc_oauth_enabled" => "Ενεργοποίηση OIDC/OAuth", "create_user_automatically" => "Δημιουργία χρήστη αυτόματα", "disable_password_login" => "Απενεργοποίηση σύνδεσης με κωδικό πρόσβασης", "smtp_settings" => "SMTP ρυθμίσεις", "security_settings" => "Ρυθμίσεις ασφάλειας", "ssrf_protection_info" => "Για να αποτρέψετε επιθέσεις Server-Side Request Forgery (SSRF), το Wallos αποκλείει από προεπιλογή τις ειδοποιήσεις webhook σε ιδιωτικές ή εσωτερικές διευθύνσεις δικτύου.", "local_webhook_info" => "Εάν πρέπει να στείλετε webhooks σε τοπικές υπηρεσίες (όπως Home Assistant, Gotify ή Node-RED), εισάγετε τις διευθύνσεις IP ή τα ονόματα κεντρικού υπολογιστή τους παραπάνω ως λίστα χωρισμένη με κόμματα (π.χ. 192.168.1.100,192.168.1.101).", "smtp_usage_info" => "Θα χρησιμοποιηθεί για ανάκτηση κωδικού πρόσβασης και άλλα μηνύματα ηλεκτρονικού ταχυδρομείου συστήματος.", "maintenance_tasks" => "Εργασίες συντήρησης", "orphaned_logos" => "Ορφανά λογότυπα", "update" => "Ενημέρωση", "new_version_available" => "Μια νέα έκδοση του Wallos είναι διαθέσιμη", "current_version" => "Τρέχουσα Έκδοση", "latest_version" => "Τελευταία Έκδοση", "on_current_version" => "Χρησιμοποιείτε την τελευταία έκδοση του Wallos.", "show_update_notification" => "Εμφάνιση ειδοποίησης για ενημερώσεις στο dashboard", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "Το email επιβεβαιώθηκε", "email_verification_failed" => "Η επαλήθευση email απέτυχε", // Calendar "calendar" => "Ημερολόγιο", "sun" => "Κυριακή", "mon" => "Δευτέρα", "tue" => "Τρίτη", "wed" => "Τετάρτη", "thu" => "Πέμπτη", "fri" => "Παρασκευή", "sat" => "Σάββατο", "month-01" => "Ιανουάριος", "month-02" => "Φεβρουάριος", "month-03" => "Μάρτιος", "month-04" => "Απρίλιος", "month-05" => "Μάιος", "month-06" => "Ιούνιος", "month-07" => "Ιούλιος", "month-08" => "Αύγουστος", "month-09" => "Σεπτέμβριος", "month-10" => "Οκτώβριος", "month-11" => "Νοέμβριος", "month-12" => "Δεκέμβριος", "total_cost" => "Συνολικό κόστος", "export_icalendar" => "Εξαγωγή iCalendar", "over_budget_warning" => "Έχετε ξεπεράσει τον προϋπολογισμό", // TOTP Page "insert_totp_code" => "Εισάγετε τον κωδικό TOTP", ]; ?> ================================================ FILE: includes/i18n/en.php ================================================ "You need to create an account before you're able to login", "username" => "Username", "password" => "Password", "firstname" => "First name", "lastname" => "Last name", "email" => "Email", "confirm_password" => "Confirm Password", "main_currency" => "Main Currency", "language" => "Language", "passwords_dont_match" => "Passwords do not match", "username_exists" => "Username already exists", "email_exists" => "Email already exists", "registration_failed" => "Registration failed, please try again.", "register" => "Register", "restore_database" => "Restore Database", // Login Page "please_login" => "Please login", "stay_logged_in" => "Stay logged in (30 days)", "login" => "Login", "login_with" => "Login with", "or" => "or", "login_failed" => "Login details are incorrect", "registration_successful" => "Registration successful", "user_email_waiting_verification" => "Your email needs to be verified. Please check your email.", // Password Reset Page "forgot_password" => "Forgot Password", "reset_password" => "Reset Password", "reset_sent_check_email" => "Reset email sent. Please check your email.", "password_reset_successful" => "Password reset successful", // Header "profile" => "Profile", "dashboard" => "Dashboard", "subscriptions" => "Subscriptions", "stats" => "Statistics", "settings" => "Settings", "admin" => "Admin", "about" => "About", "logout" => "Logout", // Dashboard "hello" => "Hello", "upcoming_payments" => "Upcoming Payments", "no_upcoming_payments" => "You don't have any upcoming payments", "overdue_renewals" => "Overdue Renewals", "ai_recommendations" => "AI Recommendations", "your_budget" => "Your Budget", "budget" => "Budget", "budget_used" => "Budget Used", "over_budget" => "Over Budget", "your_subscriptions" => "Your Subscriptions", "your_savings" => "Your Savings", // Subscriptions page "subscription" => "Subscription", "no_subscriptions_yet" => "You don't have any subscriptions yet", "add_first_subscription" => "Add first subscription", "new_subscription" => "New Subscription", "search" => "Search", "state" => "State", "alphanumeric" => "Alphanumeric", "sort" => "Sort", "name" => "Name", "last_added" => "Last Added", "price" => "Price", "next_payment" => "Next Payment", "renewal_type" => "Renewal Type", "auto_renewal" => "Auto Renewal", "automatically_renews" => "Automatically renews", "manual_renewal" => "Manual Renewal", "start_date" => "Start Date", "inactive" => "Disable Subscription", "replaced_with" => "Replaced with", "none" => "None", "member" => "Member", "category" => "Category", "payment_method" => "Payment Method", "Daily" => "Daily", "Weekly" => "Weekly", "Monthly" => "Monthly", "Yearly" => "Yearly", "daily" => "Day(s)", "weekly" => "Week(s)", "monthly" => "Month(s)", "yearly" => "Year(s)", "days" => "days", "weeks" => "weeks", "months" => "months", "years" => "years", "external_url" => "Visit External URL", "empty_page" => "Empty Page", "clear_filters" => "Clear Filters", "no_matching_subscriptions" => "No matching subscriptions", "clone" => "Clone", "renew" => "Renew", "calculate_next_payment_date" => "Calculate Next Payment Date", // Subscription form "add_subscription" => "Add subscription", "edit_subscription" => "Edit subscription", "subscription_name" => "Subscription name", "logo_preview" => "Logo Preview", "search_logo" => "Search logo on the web", "web_search" => "Web search", "currency" => "Currency", "payment_every" => "Payment every", "frequency" => "Frequency", "cycle" => "Cycle", "no_category" => "No category", "paid_by" => "Paid by", "url" => "URL", "notes" => "Notes", "enable_notifications" => "Enable Notifications for this subscription", "default_value_from_settings" => "Default value from settings", "cancellation_notification" => "Cancellation Notification", "delete" => "Delete", "cancel" => "Cancel", "upload_logo" => "Upload Logo", // Statistics page "cant_convert_currency" => "You are using multiple currencies on your subscriptions. To have valid and accurate statistics, please set a Fixer API Key on the settings page.", "general_statistics" => "General Statistics", "active_subscriptions" => "Active Subscriptions", "inactive_subscriptions" => "Inactive Subscriptions", "monthly_cost" => "Monthly Cost", "yearly_cost" => "Yearly Cost", "average_monthly" => "Average Monthly Subscription Cost", "most_expensive" => "Most Expensive Subscription Cost", "amount_due" => "Amount due this month", "percentage_budget_used" => "Percentage of budget used", "budget_remaining" => "Budget Remaining", "amount_over_budget" => "Amount over budget", "monthly_savings" => "Monthly Savings (on inactive subscriptions)", "yearly_savings" => "Yearly Savings (on inactive subscriptions)", "split_views" => "Split Views", "category_split" => "Category Split", "household_split" => "Household Split", "payment_method_split" => "Payment Method Split", "total_cost_trend" => "Total Cost Trend", "cost_vs_budget" => "Cost vs Budget", // About page "about_and_credits" => "About and Credits", "credits" => "Credits", "license" => "License", "release_notes" => "Release Notes", "update_available" => "Update Available", "issues_and_requests" => "Issues and Requests", "the_author" => "The author", "icons" => "Icons", "payment_icons" => "Payment Icons", // Profile page "upload_avatar" => "Upload Avatar", "file_type_error" => "The file type supplied is not supported.", "user_details" => "User Details", "two_factor_authentication" => "Two Factor Authentication", "two_factor_info" => "Two Factor Authentication adds an extra layer of security to your account.
You will need an authenticator app like Google Authenticator, Authy or Ente Auth to scan the QR code.", "two_factor_enabled_info" => "Your account is secure with Two Factor Authentication. You can disable it by clicking the button above.", "enable_two_factor_authentication" => "Enable Two Factor Authentication", "2fa_already_enabled" => "Two Factor Authentication is already enabled", "totp_code_incorrect" => "TOTP code is incorrect", "backup_codes" => "Backup Codes", "download_backup_codes" => "Download Backup Codes", "copy_to_clipboard" => "Copy to clipboard", "totp_backup_codes_info" => "These codes can be used to login if you lose access to your authenticator app.", "disable_two_factor_authentication" => "Disable Two Factor Authentication", "totp_code" => "TOTP Code", "api_key" => "API Key", "regenerate" => "Regenerate", "api_key_info" => "The API key is used to access the API. Keep it secret.", // Settings page "monthly_budget" => "Monthly Budget", "budget_info" => "Monthly budget is used to calculate statistics", "household" => "Household", "save_member" => "Save Member", "delete_member" => "Delete Member", "cant_delete_member" => "Can't delete main member", "cant_delete_member_in_use" => "Can't delete member in use in subscription", "household_info" => "Email field allows for household members to be notified of subscriptions about to expire.", "notifications" => "Notifications", "enable_email_notifications" => "Enable email notifications", "notify_me" => "Notify me", "day_before" => "day before", "on_due_date" => "On due date", "days_before" => "days before", "smtp_address" => "SMTP Address", "port" => "Port", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "SMTP Username", "smtp_password" => "SMTP Password", "from_email" => "From email (Optional)", "send_to_other_emails" => "Also send notifications to the following email addresses (use ; to separate them):", "other_emails_placeholder" => "user@domain.com;test@user.com", "smtp_info" => "SMTP Password is transmitted and stored in plaintext. For security, please create an account just for this.", "telegram" => "Telegram", "telegram_bot_token" => "Telegram Bot Token", "telegram_chat_id" => "Telegram Chat ID", "pushplus" => "Pushplus", "pushplus_token" => "Pushplus Token", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "WebHook URL", "mattermost_bot_username" => "Bot Username", "mattermost_bot_icon_emoji" => "Bot Icon Emoji", "webhook" => "Webhook", "webhook_url" => "Webhook URL", "request_method" => "Request Method", "custom_headers" => "Custom Headers", "webhook_payload" => "Webhook Payload", "payment_notifications_payload" => "Payment Notification Payload", "cancelation_notification_payload" => "Cancelation Notification Payload", "variables_available" => "Variables available", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Discord Bot Username", "discord_bot_avatar_url" => "Discord Bot Avatar URL", "pushover" => "Pushover", "pushover_user_key" => "Pushover User Key", "host" => "Host", "topic" => "Topic", "ignore_ssl_errors" => "Ignore SSL Errors", "categories" => "Categories", "save_category" => "Save Category", "delete_category" => "Delete Category", "cant_delete_category_in_use" => "Can't delete category in use in subscription", "currencies" => "Currencies", "save_currency" => "Save currency", "delete_currency" => "Delete currency", "cant_delete_main_currency" => "Can't delete main currency", "cant_delete_currency_in_use" => "Can't delete currency in use in subscription", "exchange_update" => "Exchange rates last updated on", "currency_info" => "Find the supported currencies and correct currency codes on", "currency_performance" => "For improved performance keep only the currencies you use.", "fixer_api_key" => "Fixer API Key", "provider" => "Provider", "fixer_info" => "If you use multiple currencies, and want accurate statistics and sorting on the subscriptions, a FREE API Key from Fixer is necessary.", "get_key" => "Get your key at", "get_free_fixer_api_key" => "Get free Fixer API Key", "get_key_alternative" => "Alternatively, you can get a free fixer api key from", "ai_model" => "AI Model", "select_ai_model" => "Select AI Model", "run_schedule" => "Run Schedule", "manually" => "Manually", "coming_soon" => "Coming Soon", "invalid_host" => "Invalid Host", "ai_recommendations_info" => "AI Recommendations are generated based on your subscriptions and household members.", "may_take_time" => "Depending on the provider, model and number of subscriptions, recommendations generation may take some time.", "recommendations_visible_on_dashboard" => "Recommendations will be visible on the dashboard.", "generate_recommendations" => "Generate Recommendations", "display_settings" => "Display Settings", "theme_settings" => "Theme Settings", "colors" => "Colors", "custom_colors" => "Custom Colors", "theme" => "Theme", "dark_theme" => "Dark Theme", "light_theme" => "Light Theme", "automatic"=> "Automatic", "main_color" => "Main Color", "accent_color" => "Accent Color", "hover_color" => "Hover Color", "save_custom_colors" => "Save Custom Colors", "reset_custom_colors" => "Reset Custom Colors", "custom_css" => "Custom CSS", "save_custom_css" => "Save Custom CSS", "calculate_monthly_price" => "Calculate and show monthly price for all subscriptions", "convert_prices" => "Always convert and show prices on my main currency (slower)", "show_original_price" => "Also show original price when conversions or calculations are made", "experience" => "Experience", "show_subscription_progress" => "Show subscription progress", "disabled_subscriptions" => "Disabled Subscriptions", "hide_disabled_subscriptions" => "Hide disabled subscriptions", "show_disabled_subscriptions_at_the_bottom" => "Show disabled subscriptions at the bottom", "experimental_settings" => "Experimental Settings", "remove_background" => "Attempt to remove background of logos from image search", "use_mobile_navigation_bar" => "Use mobile navigation bar", "experimental_info" => "Experimental settings will probably not work perfectly.", "payment_methods" => "Payment Methods", "payment_methods_info" => "Click a payment method to disable / enable it.", "rename_payment_methods_info" => "Click the name on a payment method to rename it.", "cant_delete_payment_method_in_use" => "Can't disable used payment method", "add_custom_payment" => "Add Custom Payment Method", "payment_method_name" => "Payment Method Name", "payment_method_added_successfuly" => "Payment method added successfully", "payment_method_removed" => "Payment method removed", "disable" => "Disable", "enable" => "Enable", "rename_payment_method" => "Rename Payment Method", "payment_renamed" => "Payment method renamed", "payment_not_renamed" => "Payment method not renamed", "test" => "Test", "add" => "Add", "save" => "Save", "reset" => "Reset", "main_accent_color_error" => "Main and accent color can't be the same", "backup_and_restore" => "Backup and Restore", "backup" => "Backup", "restore" => "Restore", "restore_info" => "Restoring the database will override all current data. You will be signed out after the restore.", "account" => "Account", "export_subscriptions" => "Export Subscriptions", "export_as_json" => "Export as JSON", "export_as_csv" => "Export as CSV", "danger_zone" => "Danger Zone", "delete_account" => "Delete Account", "delete_account_info" => "Deleting your account will also delete all your subscriptions and settings.", // Filters menu "filter" => "Filter", "clear" => "Clear", // Toast "success" => "Success", // Endpoint responses "session_expired" => "Your session expired. Please login again", "fields_missing" => "Some fields are missing", "fill_all_fields" => "Please fill all fields", "fill_mandatory_fields" => "Please fill all mandatory fields", "error" => "Error", // Category "failed_add_category" => "Failed to add category", "failed_edit_category" => "Failed to edit category", "category_in_use" => "Category is in use in subscriptions and can't be removed", "failed_remove_category" => "Failed to remove category", "category_saved" => "Category saved", "category_removed" => "Category removed", "sort_order_saved" => "Sort order saved", // Currency "currency_saved" => "was saved.", "error_adding_currency" => "Error adding currency entry.", "failed_to_store_currency" => "Failed to store Currency on the Database.", "currency_in_use" => "Currency is in use in subscriptions and can't be deleted.", "currency_is_main" => "Currency is set as main currency and can't be deleted.", "failed_to_remove_currency" => "Failed to remove currency from the Database.", "failed_to_store_api_key" => "Failed to store API Key on the Database.", "invalid_api_key" => "Invalid API Key.", "api_key_saved" => "API key saved successfully", "currency_removed" => "Currency removed", // Household "failed_add_household" => "Failed to add household member", "failed_edit_household" => "Failed to edit household member", "failed_remove_household" => "Failed to remove household member", "household_in_use" => "Household member is in use in subscriptions and can't be removed", "member_saved" => "Member saved", "member_removed" => "Member removed", // Notifications "error_saving_notifications" => "Error saving notifications data.", "wallos_notification" => "Wallos Notification", "test_notification" => "This is a test notification. If you're seeing this, the configuration is correct.", "email_error" => "Error sending email", "notification_sent_successfuly" => "Notification sent successfully", "notifications_settings_saved" => "Notification settings saved successfully.", "notification_failed" => "Notification failed", // Payments "payment_in_use" => "Can't disable used payment method", "failed_update_payment" => "Failed to update payment method in the database", "enabled" => "enabled", "disabled" => "disabled", // Subscription "error_fetching_image" => "Error fetching image", "subscription_updated_successfuly" => "Subscription updated successfully", "subscription_added_successfuly" => "Subscription added successfully", "error_deleting_subscription" => "Error deleting subscription.", "invalid_request_method" => "Invalid request method.", // User "error_updating_user_data" => "Error updating user data.", "user_details_saved" => "User details saved", // Admin Page "registrations" => "Registrations", "enable_user_registrations" => "Enable user registrations", "maximum_number_users" => "Maximum number of users", "require_email_verification" => "Require email verification", "configure_smtp_settings_to_enable" => "Configure SMTP settings to enable", "server_url" => "Server URL", "server_url_info" => "Used for email verification and password recovery. Must be a valid public URL.", "server_url_password_reset" => "If set will also enable password reset functionality.", "disable_login" => "Disable login", "disable_login_info" => "Bypass login. If you run your server on a local network only, without external access you can disable the login. This will automatically login the admin user.", "disable_login_info2" => "You can only enable this setting if user registration is disabled and there are no more than the admin user account.", "max_users_info" => "0 means unlimited", "user_management" => "User Management", "delete_user" => "Delete User", "delete_user_info" => "Deleting a user will also delete all their subscriptions and settings.", "create_user" => "Create User", "oidc_settings" => "OIDC Settings", "oidc_oauth_enabled" => "Enable OIDC/OAuth", "create_user_automatically" => "Create user automatically", "disable_password_login" => "Disable password login", "smtp_settings" => "SMTP Settings", "smtp_usage_info" => "Will be used for password recovery and other system emails.", "security_settings" => "Security Settings", "ssrf_protection_info" => "To prevent Server-Side Request Forgery (SSRF) attacks, Wallos blocks webhook notifications to private or internal network addresses by default.", "local_webhook_info" => "If you need to send webhooks to local services (like Home Assistant, Gotify, or Node-RED), enter their IP addresses or hostnames above as a comma-separated list (e.g., 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Maintenance Tasks", "orphaned_logos" => "Orphaned Logos", "update" => "Update", "new_version_available" => "A new version of Wallos is available", "current_version" => "Current Version", "latest_version" => "Latest Version", "on_current_version" => "You're running the latest version of Wallos.", "show_update_notification" => "Show notification for updates on the dashboard", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "Email verified successfully", "email_verification_failed" => "Email verification failed", // Calendar "calendar" => "Calendar", "sun" => "Sun", "mon" => "Mon", "tue" => "Tue", "wed" => "Wed", "thu" => "Thu", "fri" => "Fri", "sat" => "Sat", "month-01" => "January", "month-02" => "February", "month-03" => "March", "month-04" => "April", "month-05" => "May", "month-06" => "June", "month-07" => "July", "month-08" => "August", "month-09" => "September", "month-10" => "October", "month-11" => "November", "month-12" => "December", "total_cost" => "Total Cost", "export_icalendar" => "Export iCalendar", "over_budget_warning" => "You're over budget", // TOTP Page "insert_totp_code" => "Insert TOTP code", ]; ?> ================================================ FILE: includes/i18n/es.php ================================================ "Necesitas crear una cuenta antes de poder iniciar sesión", "username" => "Nombre de Usuario", "password" => "Contraseña", "email" => "Correo Electrónico", "firstname" => "Nombre", "lastname" => "Apellido", "confirm_password" => "Confirmar Contraseña", "main_currency" => "Moneda Principal", "language" => "Idioma", "passwords_dont_match" => "Las contraseñas no coinciden", "username_exists" => "El nombre de usuario ya existe", "email_exists" => "El correo electrónico ya está registrado", "registration_failed" => "Error en el registro, por favor inténtalo de nuevo.", "register" => "Registrar", "restore_database" => "Restaurar Base de Datos", // Login Page "please_login" => "Por favor, inicia sesión", "stay_logged_in" => "Mantener sesión iniciada (30 días)", "login" => "Iniciar Sesión", "login_with" => "Iniciar sesión con", "or" => "o", "login_failed" => "Los detalles de inicio de sesión son incorrectos", "registration_successful" => "Registro efectuado con éxito", "user_email_waiting_verification" => "Tu correo electrónico necesita ser verificado. Por favor, compruebe su correo electrónico", // Password Reset Page "forgot_password" => "¿Olvidaste tu contraseña?", "reset_password" => "Restablecer Contraseña", "reset_sent_check_email" => "Se ha enviado un correo electrónico con instrucciones para restablecer la contraseña. Por favor, compruebe su correo electrónico.", "password_reset_successful" => "Contraseña restablecida con éxito", // Header "profile" => "Perfil", "dashboard" => "Tablero", "subscriptions" => "Suscripciones", "stats" => "Estadísticas", "settings" => "Configuración", "admin" => "Admin", "about" => "Acerca de", "logout" => "Cerrar Sesión", // Dashboard "hello" => "Hola", "upcoming_payments" => "Próximos Pagos", "no_upcoming_payments" => "No tienes pagos próximos", "overdue_renewals" => "Renovaciones Atrasadas", "ai_recommendations" => "Recomendaciones de IA", "your_budget" => "Tu Presupuesto", "budget" => "Presupuesto", "budget_used" => "Presupuesto Utilizado", "over_budget" => "Sobre Presupuesto", "your_subscriptions" => "Tus Suscripciones", "your_savings" => "Tus Ahorros", // Subscriptions page "subscription" => "Suscripción", "no_subscriptions_yet" => "Aún no tienes ninguna suscripción", "add_first_subscription" => "Añadir primera suscripción", "new_subscription" => "Nueva Suscripción", "search" => "Buscar", "state" => "Estado", "alphanumeric" => "Alfanumérico", "sort" => "Ordenar", "name" => "Nombre", "last_added" => "Última Añadida", "price" => "Precio", "next_payment" => "Próximo Pago", "renewal_type" => "Tipo de Renovación", "auto_renewal" => "Renovación Automática", "automatically_renews" => "Renovación Automática", "manual_renewal" => "Renovación Manual", "start_date" => "Fecha de Inicio", "inactive" => "Desactivar Suscripción", "replaced_with" => "Reemplazada con", "none" => "Ninguna", "member" => "Miembro", "category" => "Categoría", "payment_method" => "Método de Pago", "Daily" => "Diario", "Weekly" => "Semanal", "Monthly" => "Mensual", "Yearly" => "Anual", "daily" => "Día(s)", "weekly" => "Semana(s)", "monthly" => "Mes(es)", "yearly" => "Año(s)", "days" => "días", "weeks" => "semanas", "months" => "meses", "years" => "años", "external_url" => "Visitar URL Externa", "empty_page" => "Página Vacía", "clear_filters" => "Limpiar Filtros", "no_matching_subscriptions" => "No hay suscripciones que coincidan con los filtros", "clone" => "Clonar", "renew" => "Renovar", "calculate_next_payment_date" => "Calcular Fecha del Próximo Pago", // Subscription form "add_subscription" => "Añadir suscripción", "edit_subscription" => "Editar suscripción", "subscription_name" => "Nombre de la Suscripción", "logo_preview" => "Vista Previa del Logotipo", "search_logo" => "Buscar logotipo en la web", "web_search" => "Búsqueda web", "currency" => "Moneda", "payment_every" => "Pago cada", "frequency" => "Frecuencia", "cycle" => "Ciclo", "no_category" => "Sin categoría", "paid_by" => "Pagado por", "url" => "URL", "notes" => "Notas", "enable_notifications" => "Habilitar notificaciones para esta suscripción", "default_value_from_settings" => "Valor predeterminado de la configuración", "cancellation_notification" => "Notificación de cancelación", "delete" => "Eliminar", "cancel" => "Cancelar", "upload_logo" => "Cargar Logotipo", // Statistics page "cant_convert_currency" => "Estás utilizando varias monedas en tus suscripciones. Para disponer de estadísticas válidas y precisas, establece una clave API Fixer en la página de configuración.", "general_statistics" => "Estadísticas Generales", "active_subscriptions" => "Suscripciones Activas", "inactive_subscriptions" => "Suscripciones inactivas", "monthly_cost" => "Costo Mensual", "yearly_cost" => "Costo Anual", "average_monthly" => "Costo Promedio Mensual de Suscripción", "most_expensive" => "Costo de Suscripción Más Caro", "amount_due" => "Monto a pagar este mes", "percentage_budget_used" => "Porcentaje del presupuesto utilizado", "budget_remaining" => "Presupuesto Restante", "amount_over_budget" => "Monto sobre el presupuesto", "monthly_savings" => "Ahorro Mensual (en suscripciones inactivas)", "yearly_savings" => "Ahorro Anual (en suscripciones inactivas)", "split_views" => "Vistas Divididas", "category_split" => "División por Categoría", "household_split" => "División por Hogar", "payment_method_split" => "División por Método de Pago", "total_cost_trend" => "Tendencia del Costo Total", "cost_vs_budget" => "Costo vs Presupuesto", // About page "about_and_credits" => "Acerca de y Créditos", "credits" => "Créditos", "license" => "Licencia", "release_notes" => "Notas de la Versión", "update_available" => "Actualización Disponible", "issues_and_requests" => "Problemas y Solicitudes", "the_author" => "El autor", "icons" => "Iconos", "payment_icons" => "Iconos de Pago", // Profile page "upload_avatar" => "Subir avatar", "file_type_error" => "El archivo debe ser una imagen en formato PNG, JPG, WEBP o SVG", "user_details" => "Detalles del Usuario", "two_factor_authentication" => "Autenticación de Dos Factores", "two_factor_info" => "La autenticación de dos factores añade una capa adicional de seguridad a tu cuenta.
Necesitarás una aplicación de autenticación como Google Authenticator, Authy o Ente Auth para escanear el código QR.", "two_factor_enabled_info" => "Tu cuenta está segura con la autenticación de dos factores. Puedes desactivarla haciendo clic en el botón de arriba.", "enable_two_factor_authentication" => "Habilitar Autenticación de Dos Factores", "2fa_already_enabled" => "La autenticación de dos factores ya está habilitada", "totp_code_incorrect" => "El código TOTP es incorrecto", "backup_codes" => "Códigos de Respaldo", "download_backup_codes" => "Descargar Códigos de Respaldo", "copy_to_clipboard" => "Copiar al Portapapeles", "totp_backup_codes_info" => "Guarda estos códigos en un lugar seguro. Puedes usarlos si pierdes acceso a tu aplicación de autenticación.", "disable_two_factor_authentication" => "Desactivar Autenticación de Dos Factores", "totp_code" => "Código TOTP", "api_key" => "Clave API", "regenerate" => "Regenerar", "api_key_info" => "La clave API se utiliza para acceder a la API de Wallos. No compartas esta clave con nadie.", // Settings page "monthly_budget" => "Presupuesto Mensual", "budget_info" => "El presupuesto mensual se utiliza para calcular las estadísticas. Si no deseas utilizar esta función, déjalo en 0.", "household" => "Hogar", "save_member" => "Guardar Miembro", "delete_member" => "Eliminar Miembro", "cant_delete_member" => "No se puede eliminar el miembro principal", "cant_delete_member_in_use" => "No se puede eliminar el miembro en uso en la suscripción", "household_info" => "El campo de correo electrónico permite notificar a los miembros del hogar las suscripciones que están a punto de caducar.", "notifications" => "Notificaciones", "enable_email_notifications" => "Habilitar notificaciones por correo electrónico", "notify_me" => "Notificarme", "day_before" => "día antes", "on_due_date" => "En la fecha de vencimiento", "days_before" => "días antes", "smtp_address" => "Dirección SMTP", "port" => "Puerto", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "Nombre de usuario SMTP", "smtp_password" => "Contraseña SMTP", "from_email" => "Correo electrónico de origen (Opcional)", "send_to_other_emails" => "También enviar notificaciones a las siguientes direcciones de correo electrónico (use ; para separarlas):", "smtp_info" => "La contraseña SMTP se transmite y almacena en texto plano. Por seguridad, crea una cuenta solo para esto.", "telegram" => "Telegram", "telegram_bot_token" => "Token del Bot de Telegram", "telegram_chat_id" => "ID del Chat de Telegram", "pushplus" => "Pushplus", "pushplus_token" => "Token de Pushplus", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "URL del Webhook de Mattermost", "mattermost_bot_username" => "Nombre de usuario del Bot de Mattermost", "mattermost_bot_icon_emoji" => "Emoji del Icono del Bot", "webhook" => "Webhook", "webhook_url" => "URL del Webhook", "request_method" => "Método de Solicitud", "custom_headers" => "Cabeceras Personalizadas", "webhook_payload" => "Carga del Webhook", "payment_notifications_payload" => "Carga de la Notificación de Pago", "cancelation_notification_payload" => "Carga de la Notificación de Cancelación", "variables_available" => "Variables disponibles", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Nombre de usuario del bot", "discord_bot_avatar_url" => "URL del avatar del bot", "pushover" => "Pushover", "pushover_user_key" => "Clave de usuario", "host" => "Host", "topic" => "Topico", "ignore_ssl_errors" => "Ignorar errores SSL", "categories" => "Categorías", "save_category" => "Guardar Categoría", "delete_category" => "Eliminar Categoría", "cant_delete_category_in_use" => "No se puede eliminar la categoría en uso en la suscripción", "currencies" => "Monedas", "save_currency" => "Guardar Moneda", "delete_currency" => "Eliminar Moneda", "cant_delete_main_currency" => "No se puede eliminar la moneda principal", "cant_delete_currency_in_use" => "No se puede eliminar la moneda en uso en la suscripción", "exchange_update" => "Tasas de cambio actualizadas por última vez en", "currency_info" => "Encuentra las monedas admitidas y los códigos de moneda correctos en", "currency_performance" => "Para un rendimiento mejorado, guarda solo las monedas que uses.", "fixer_api_key" => "API Key de Fixer", "provider" => "Proveedor", "fixer_info" => "Si usas varias monedas y deseas estadísticas y orden precisos en las suscripciones, es necesaria una API KEY gratuita de Fixer.", "get_key" => "Obtén tu clave en", "get_free_fixer_api_key" => "Obtén una API Key de Fixer gratuita", "get_key_alternative" => "También puede obtener una clave api gratuita de Fixer en", "ai_model" => "Modelo de IA", "select_ai_model" => "Seleccionar Modelo de IA", "run_schedule" => "Ejecutar Programa", "manually" => "Manualmente", "coming_soon" => "Próximamente", "invalid_host" => "Host Inválido", "ai_recommendations_info" => "Las recomendaciones de IA se generan en función de sus suscripciones y miembros del hogar.", "may_take_time" => "Dependiendo del proveedor, modelo y número de suscripciones, la generación de recomendaciones puede tardar algún tiempo.", "recommendations_visible_on_dashboard" => "Las recomendaciones serán visibles en el panel.", "generate_recommendations" => "Generar Recomendaciones", "display_settings" => "Configuración de Pantalla", "theme_settings" => "Configuración de Tema", "colors" => "Colores", "custom_colors" => "Colores Personalizados", "theme" => "Tema", "dark_theme" => "Tema Oscuro", "light_theme" => "Tema Claro", "automatic" => "Automático", "main_color" => "Color Principal", "accent_color" => "Color de Acento", "hover_color" => "Color de Hover", "save_custom_colors" => "Guardar Colores Personalizados", "reset_custom_colors" => "Restablecer Colores Personalizados", "custom_css" => "CSS Personalizado", "save_custom_css" => "Guardar CSS Personalizado", "calculate_monthly_price" => "Calcular y mostrar el precio mensual de todas las suscripciones", "convert_prices" => "Convertir y mostrar siempre los precios en mi moneda principal (más lento)", "show_original_price" => "Mostrar también el precio original cuando se realicen conversiones o cálculos", "experience" => "Experiencia", "show_subscription_progress" => "Mostrar el progreso de la suscripción", "disabled_subscriptions" => "Suscripciones Desactivadas", "hide_disabled_subscriptions" => "Ocultar suscripciones desactivadas", "show_disabled_subscriptions_at_the_bottom" => "Mostrar suscripciones desactivadas al final", "experimental_settings" => "Configuraciones Experimentales", "remove_background" => "Intentar quitar el fondo de los logotipos de la búsqueda de imágenes", "use_mobile_navigation_bar" => "Usar barra de navegación móvil", "experimental_info" => "Las configuraciones experimentales probablemente no funcionarán perfectamente.", "payment_methods" => "Métodos de Pago", "payment_methods_info" => "Haz clic en un método de pago para deshabilitarlo/habilitarlo.", "rename_payment_methods_info" => "Haz clic en el nombre de un método de pago para cambiarle el nombre.", "cant_delete_payment_method_in_use" => "No se puede desactivar el método de pago utilizado", "add_custom_payment" => "Añadir método de pago personalizado", "payment_method_name" => "Nombre del método de pago", "payment_method_added_successfuly" => "Método de pago añadido con éxito", "payment_method_removed" => "Método de pago eliminado", "disable" => "Desactivar", "enable" => "Activar", "rename_payment_method" => "Renombrar método de pago", "payment_renamed" => "Método de pago renombrado", "payment_not_renamed" => "Error al renombrar el método de pago", "test" => "Probar", "add" => "Agregar", "save" => "Guardar", "reset" => "Restablecer", "main_accent_color_error" => "El color principal y el color de acento no pueden ser iguales", "backup_and_restore" => "Copia de Seguridad y Restauración", "backup" => "Copia de Seguridad", "restore" => "Restaurar", "restore_info" => "La restauración de la base de datos anulará todos los datos actuales. Se cerrará la sesión después de la restauración.", "account" => "Cuenta", "export_subscriptions" => "Exportar suscripciones", "export_as_json" => "Exportar como JSON", "export_as_csv" => "Exportar como CSV", "danger_zone" => "Zona de peligro", "delete_account" => "Eliminar cuenta", "delete_account_info" => "Al eliminar tu cuenta también se eliminarán todas tus suscripciones y configuraciones.", // Filters menu "filter" => "Filtrar", "clear" => "Limpiar", // Toast "success" => "Éxito", // Endpoint responses "session_expired" => "Tu sesión ha expirado. Por favor, inicia sesión nuevamente", "fields_missing" => "Faltan algunos campos", "fill_all_fields" => "Por favor, completa todos los campos", "fill_mandatory_fields" => "Por favor, completa todos los campos obligatorios", "error" => "Error", // Category "failed_add_category" => "Error al agregar la categoría", "failed_edit_category" => "Error al editar la categoría", "category_in_use" => "La categoría está en uso en suscripciones y no se puede eliminar", "failed_remove_category" => "Error al eliminar la categoría", "category_saved" => "Categoría guardada", "category_removed" => "Categoría eliminada", "sort_order_saved" => "Orden de clasificación guardado", // Currency "currency_saved" => "fue guardada.", "error_adding_currency" => "Error al añadir la entrada de la moneda.", "failed_to_store_currency" => "Error al almacenar la moneda en la base de datos.", "currency_in_use" => "La moneda está en uso en suscripciones y no se puede eliminar.", "currency_is_main" => "La moneda está establecida como moneda principal y no se puede eliminar.", "failed_to_remove_currency" => "Error al eliminar la moneda de la base de datos.", "failed_to_store_api_key" => "Error al almacenar la API KEY en la base de datos.", "invalid_api_key" => "API KEY no válida.", "api_key_saved" => "API KEY guardada con éxito", "currency_removed" => "Moneda eliminada", // Household "failed_add_household" => "Error al añadir miembro del hogar", "failed_edit_household" => "Error al editar miembro del hogar", "failed_remove_household" => "Error al eliminar miembro del hogar", "household_in_use" => "El miembro del hogar está en uso en suscripciones y no se puede eliminar", "member_saved" => "Miembro guardado", "member_removed" => "Miembro eliminado", // Notifications "error_saving_notifications" => "Error al guardar los datos de notificaciones.", "wallos_notification" => "Notificación de Wallos", "test_notification" => "Esta es una notificación de prueba. Si estás viendo esto, la configuración es correcta.", "email_error" => "Error al enviar correo electrónico", "notification_sent_successfuly" => "Notificación enviada con éxito", "notifications_settings_saved" => "Configuración de notificaciones guardada con éxito.", "notification_failed" => "Error al enviar la notificación", // Payments "payment_in_use" => "No se puede desactivar el método de pago utilizado", "failed_update_payment" => "Error al actualizar el método de pago en la base de datos", "enabled" => "habilitado", "disabled" => "desactivado", // Subscription "error_fetching_image" => "Error al obtener la imagen", "subscription_updated_successfuly" => "Suscripción actualizada con éxito", "subscription_added_successfuly" => "Suscripción añadida con éxito", "error_deleting_subscription" => "Error al eliminar la suscripción.", "invalid_request_method" => "Método de solicitud no válido.", // User "error_updating_user_data" => "Error al actualizar los datos del usuario.", "user_details_saved" => "Detalles del usuario guardados", // Admin Page "registrations" => "Registro de Usuarios", "enable_user_registrations" => "Habilitar registro de usuarios", "maximum_number_users" => "Número máximo de usuarios", "require_email_verification" => "Requerir verificación de correo electrónico", "configure_smtp_settings_to_enable" => "Configura la configuración SMTP para habilitar", "server_url" => "URL del Servidor", "server_url_info" => "Se utiliza para verificar el correo electrónico y recuperar la contraseña. Debe ser una URL pública válida.", "server_url_password_reset" => "Si se configura, también se habilitará la función de restablecimiento de contraseña.", "disable_login" => "Deshabilitar Inicio de Sesión", "disable_login_info" => "Omitir el inicio de sesión. Si ejecuta su servidor sólo en una red local, sin acceso externo, puede desactivar el inicio de sesión. Esto iniciará automáticamente la sesión del usuario administrador.", "disable_login_info2" => "Sólo puede activar esta configuración si el registro de usuarios está desactivado y no hay más que la cuenta de usuario admin.", "max_users_info" => "0 para ilimitado", "user_management" => "Gestión de Usuarios", "delete_user" => "Eliminar Usuario", "delete_user_info" => "Al eliminar un usuario, también se eliminarán todas sus suscripciones y configuraciones.", "create_user" => "Crear Usuario", "oidc_settings" => "Configuración OIDC", "oidc_oauth_enabled" => "Habilitar OIDC/OAuth", "create_user_automatically" => "Crear usuario automáticamente", "disable_password_login" => "Deshabilitar inicio de sesión con contraseña", "smtp_settings" => "Configuración SMTP", "smtp_usage_info" => "Se utilizará para recuperar contraseñas y otros correos electrónicos del sistema.", "security_settings" => "Configuración de seguridad", "ssrf_protection_info" => "Para evitar ataques de falsificación de solicitudes del lado del servidor (SSRF), Wallos bloquea por defecto las notificaciones webhook a direcciones de red privadas o internas.", "local_webhook_info" => "Si necesita enviar webhooks a servicios locales (como Home Assistant, Gotify o Node-RED), introduzca sus direcciones IP o nombres de host arriba como una lista separada por comas (p. ej., 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Tareas de Mantenimiento", "orphaned_logos" => "Logotipos huérfanos", "update" => "Actualizar", "new_version_available" => "Una nueva versión de Wallos está disponible", "current_version" => "Versión Actual", "latest_version" => "Última Versión", "on_current_version" => "Está utilizando la última versión de Wallos.", "show_update_notification" => "Mostrar notificación de actualizaciones en el dashboard", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "Correo electrónico verificado", "email_verification_failed" => "Error al verificar el correo electrónico", // Calendar "calendar" => "Calendario", "sun" => "Dom", "mon" => "Lun", "tue" => "Mar", "wed" => "Mié", "thu" => "Jue", "fri" => "Vie", "sat" => "Sáb", "month-01" => "Enero", "month-02" => "Febrero", "month-03" => "Marzo", "month-04" => "Abril", "month-05" => "Mayo", "month-06" => "Junio", "month-07" => "Julio", "month-08" => "Agosto", "month-09" => "Septiembre", "month-10" => "Octubre", "month-11" => "Noviembre", "month-12" => "Diciembre", "total_cost" => "Costo Total", "export_icalendar" => "Exportar iCalendar", "over_budget_warning" => "Te has pasado del presupuesto", // TOTP Page "insert_totp_code" => "Introduce el código TOTP", ]; ?> ================================================ FILE: includes/i18n/fr.php ================================================ "Vous devez créer un compte avant de pouvoir vous connecter", "username" => "Nom d'utilisateur", "password" => "Mot de passe", "email" => "Courriel", "firstname" => "Prénom", "lastname" => "Nom de famille", "confirm_password" => "Confirmer le mot de passe", "main_currency" => "Devise principale", "language" => "Langue", "passwords_dont_match" => "Les mots de passe ne correspondent pas", "username_exists" => "Le nom d'utilisateur existe déjà", "email_exists" => "L'adresse courriel existe déjà", "registration_failed" => "L'inscription a échoué, veuillez réessayer.", "register" => "S'inscrire", "restore_database" => "Restaurer la base de données", // Page de connexion "please_login" => "Veuillez vous connecter", "stay_logged_in" => "Rester connecté (30 jours)", "login" => "Se connecter", "login_with" => "Se connecter avec", "or" => "ou", "login_failed" => "Les détails de connexion sont incorrects", "registration_successful" => "Inscription réussie", "user_email_waiting_verification" => "Votre email doit être vérifié. Veuillez vérifier votre email", // Password Reset Page "forgot_password" => "Mot de passe oublié", "reset_password" => "Réinitialiser le mot de passe", "reset_sent_check_email" => "Un courriel a été envoyé à l'adresse fournie. Vérifiez votre boîte de réception.", "password_reset_successful" => "Réinitialisation du mot de passe réussie", // En-tête "profile" => "Profil", "dashboard" => "Accueil", "subscriptions" => "Abonnements", "stats" => "Statistiques", "settings" => "Paramètres", "admin" => "Admin", "about" => "À propos", "logout" => "Déconnexion", // Dashboard "hello" => "Bonjour", "upcoming_payments" => "Paiements à venir", "no_upcoming_payments" => "Vous n'avez aucun paiement à venir", "overdue_renewals" => "Renouvellements en retard", "ai_recommendations" => "Recommandations AI", "your_budget" => "Votre budget", "budget" => "Budget", "budget_used" => "Budget Utilisé", "over_budget" => "Au-dessus du Budget", "your_subscriptions" => "Vos Abonnements", "your_savings" => "Vos Économies", // Page d'abonnements "subscription" => "Abonnement", "no_subscriptions_yet" => "Vous n'avez pas encore d'abonnement", "add_first_subscription" => "Ajoutez le premier abonnement", "new_subscription" => "Nouvel abonnement", "search" => "Rechercher", "state" => "État", "alphanumeric" => "Alphanumérique", "sort" => "Trier", "name" => "Nom", "last_added" => "Dernier ajouté", "price" => "Prix", "next_payment" => "Prochain paiement", "renewal_type" => "Type de renouvellement", "auto_renewal" => "Renouvellement automatique", "automatically_renews" => "Renouvellement automatique", "manual_renewal" => "Renouvellement manuel", "start_date" => "Date de début", "inactive" => "Désactiver l'abonnement", "replaced_with" => "Remplacé par", "none" => "Aucun", "member" => "Membre", "category" => "Catégorie", "payment_method" => "Méthode de paiement", "Daily" => "Quotidien", "Weekly" => "Hebdomadaire", "Monthly" => "Mensuel", "Yearly" => "Annuel", "daily" => "Jour(s)", "weekly" => "Semaine(s)", "monthly" => "Mois", "yearly" => "Année(s)", "days" => "jours", "weeks" => "semaines", "months" => "mois", "years" => "années", "external_url" => "Visiter l'URL externe", "empty_page" => "Page vide", "clear_filters" => "Effacer les filtres", "no_matching_subscriptions" => "Aucun abonnement ne correspond à vos critères de recherche", "clone" => "Cloner", "renew" => "Renouveler", "calculate_next_payment_date" => "Calculer la date du prochain paiement", // Formulaire d'abonnement "add_subscription" => "Ajouter un abonnement", "edit_subscription" => "Modifier l'abonnement", "subscription_name" => "Nom de l'abonnement", "logo_preview" => "Aperçu du logo", "search_logo" => "Rechercher un logo sur le web", "web_search" => "Recherche web", "currency" => "Devise", "payment_every" => "Paiement tous les", "frequency" => "Fréquence", "cycle" => "Cycle", "no_category" => "Pas de catégorie", "paid_by" => "Payé par", "url" => "URL", "notes" => "Notes", "enable_notifications" => "Activer les notifications pour cet abonnement", "default_value_from_settings" => "Valeur par défaut des paramètres", "cancellation_notification" => "Notification d'annulation", "delete" => "Supprimer", "cancel" => "Annuler", "upload_logo" => "Télécharger le logo", // Page de statistiques "cant_convert_currency" => "Vous utilisez plusieurs devises dans vos abonnements. Pour obtenir des statistiques valides et précises, veuillez définir une clé API Fixer sur la page des paramètres.", "general_statistics" => "Statistiques générales", "active_subscriptions" => "Abonnements actifs", "inactive_subscriptions" => "Abonnements inactifs", "monthly_cost" => "Coût mensuel", "yearly_cost" => "Coût annuel", "average_monthly" => "Coût moyen mensuel de l'abonnement", "most_expensive" => "Coût d'abonnement le plus élevé", "amount_due" => "Montant dû ce mois-ci", "percentage_budget_used" => "Pourcentage du budget utilisé", "budget_remaining" => "Budget restant", "amount_over_budget" => "Montant dépassant le budget", "monthly_savings" => "Économies mensuelles (sur les abonnements inactifs)", "yearly_savings" => "Économies annuelles (sur les abonnements inactifs)", "split_views" => "Vues partagées", "category_split" => "Répartition par catégorie", "household_split" => "Répartition du ménage", "payment_method_split" => "Répartition par méthode de paiement", "total_cost_trend" => "Tendance du coût total", "cost_vs_budget" => "Coût par rapport au budget", // Page À propos "about_and_credits" => "À propos et crédits", "credits" => "Crédits", "license" => "Licence", "release_notes" => "Notes de version", "update_available" => "Mise à jour disponible", "issues_and_requests" => "Problèmes et demandes", "the_author" => "L'auteur", "icons" => "Icônes", "payment_icons" => "Icônes de paiement", // Page de profil "upload_avatar" => "Télécharger un Avatar", "file_type_error" => "Le type de fichier n'est pas pris en charge", "user_details" => "Détails de l'utilisateur", "two_factor_authentication" => "Authentification à deux facteurs", "two_factor_info" => "L'authentification à deux facteurs ajoute une couche supplémentaire de sécurité à votre compte.
Vous aurez besoin d'une application d'authentification comme Google Authenticator, Authy ou Ente Auth pour scanner le code QR.", "two_factor_enabled_info" => "Votre compte est sécurisé grâce à l'authentification à deux facteurs. Vous pouvez la désactiver en cliquant sur le bouton ci-dessus.", "enable_two_factor_authentication" => "Activer l'authentification à deux facteurs", "2fa_already_enabled" => "L'authentification à deux facteurs est déjà activée", "totp_code_incorrect" => "Le code TOTP est incorrect", "backup_codes" => "Codes de sauvegarde", "download_backup_codes" => "Télécharger les codes de sauvegarde", "copy_to_clipboard" => "Copier dans le presse-papiers", "totp_backup_codes_info" => "Conservez ces codes en lieu sûr. Vous ne pourrez pas les récupérer plus tard.", "disable_two_factor_authentication" => "Désactiver l'authentification à deux facteurs", "totp_code" => "Code TOTP", "api_key" => "Clé API", "regenerate" => "Régénérer", "api_key_info" => "La clé API est utilisée pour les applications tierces et les intégrations. Ne la partagez pas.", // Page de paramètres "monthly_budget" => "Budget mensuel", "budget_info" => "Le budget mensuel est utilisé pour calculer les statistiques. Laissez vide pour désactiver.", "household" => "Ménage", "save_member" => "Enregistrer le membre", "delete_member" => "Supprimer le membre", "cant_delete_member" => "Impossible de supprimer le membre principal", "cant_delete_member_in_use" => "Impossible de supprimer le membre utilisé dans l'abonnement", "household_info" => "Le champ Courriel permet aux membres du ménage d'être informés des abonnements arrivant à expiration.", "notifications" => "Notifications", "enable_email_notifications" => "Activer les notifications par courriel", "notify_me" => "Me prevenir", "day_before" => "jour avant", "on_due_date" => "Le jour de l'échéance", "days_before" => "jours avant", "smtp_address" => "Adresse SMTP", "port" => "Port", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "Nom d'utilisateur SMTP", "smtp_password" => "Mot de passe SMTP", "from_email" => "De l'adresse courriel (facultatif)", "send_to_other_emails" => "Envoyer également des notifications aux adresses courriel suivantes (utilisez ; pour les séparer):", "smtp_info" => "Le mot de passe SMTP est transmis et stocké en texte brut. Pour des raisons de sécurité, veuillez créer un compte uniquement à cette fin.", "telegram" => "Telegram", "telegram_bot_token" => "Jeton du bot Telegram", "telegram_chat_id" => "ID de chat Telegram", "pushplus" => "Pushplus", "pushplus_token" => "Jeton Pushplus", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "URL du webhook Mattermost", "mattermost_bot_username" => "Nom d'utilisateur du bot Mattermost", "mattermost_bot_icon_emoji" => "Emoji de l'icône du bot Mattermost", "webhook" => "Webhook", "webhook_url" => "URL du webhook", "request_method" => "Méthode de requête", "custom_headers" => "En-têtes personnalisés", "webhook_payload" => "Charge utile du webhook", "payment_notifications_payload" => "Charge utile de la notification de paiement", "cancelation_notification_payload" => "Charge utile de la notification d'annulation", "variables_available" => "Variables disponibles", "gotify" => "Gotify", "token" => "Jeton", "discord" => "Discord", "discord_bot_username" => "Nom d'utilisateur du bot Discord", "discord_bot_avatar_url" => "URL de l'avatar du bot Discord", "pushover" => "Pushover", "pushover_user_key" => "Clé utilisateur Pushover", "host" => "Hôte", "topic" => "Sujet", "ignore_ssl_errors" => "Ignorer les erreurs SSL", "categories" => "Catégories", "save_category" => "Enregistrer la catégorie", "delete_category" => "Supprimer la catégorie", "cant_delete_category_in_use" => "Impossible de supprimer la catégorie utilisée dans l'abonnement", "currencies" => "Devises", "save_currency" => "Enregistrer la devise", "delete_currency" => "Supprimer la devise", "cant_delete_main_currency" => "Impossible de supprimer la devise principale", "cant_delete_currency_in_use" => "Impossible de supprimer la devise utilisée dans l'abonnement", "exchange_update" => "Les taux de change ont été mis à jour pour la dernière fois le", "currency_info" => "Trouvez les devises prises en charge et les codes de devise corrects sur", "currency_performance" => "Pour des performances améliorées, ne conservez que les devises que vous utilisez.", "fixer_api_key" => "Clé API de Fixer", "provider" => "Fournisseur", "fixer_info" => "Si vous utilisez plusieurs devises et souhaitez des statistiques et un tri précis sur les abonnements, une clé API GRATUITE de Fixer est nécessaire.", "get_key" => "Obtenez votre clé sur", "get_free_fixer_api_key" => "Obtenez une clé API Fixer gratuite", "get_key_alternative" => "Vous pouvez également obtenir une clé api de fixation gratuite auprès de", "ai_model" => "Modèle AI", "select_ai_model" => "Sélectionner le modèle AI", "run_schedule" => "Exécuter le programme", "manually" => "Manuellement", "coming_soon" => "À venir", "invalid_host" => "Hôte invalide", "ai_recommendations_info" => "Les recommandations de l'IA sont générées en fonction de vos abonnements et des membres de votre foyer.", "may_take_time" => "En fonction du fournisseur, du modèle et du nombre d'abonnements, la génération de recommandations peut prendre un certain temps.", "recommendations_visible_on_dashboard" => "Les recommandations seront visibles sur le tableau de bord.", "generate_recommendations" => "Générer des recommandations", "display_settings" => "Paramètres d'affichage", "theme_settings" => "Paramètres de thème", "colors" => "Couleurs", "custom_colors" => "Couleurs personnalisées", "theme" => "Thème", "dark_theme" => "Thème sombre", "light_theme" => "Thème clair", "automatic" => "Automatique", "main_color" => "Couleur principale", "accent_color" => "Couleur d'accent", "hover_color" => "Couleur de survol", "save_custom_colors" => "Enregistrer les couleurs personnalisées", "reset_custom_colors" => "Réinitialiser les couleurs personnalisées", "custom_css" => "CSS personnalisé", "save_custom_css" => "Enregistrer le CSS personnalisé", "calculate_monthly_price" => "Calculer et afficher le prix mensuel pour tous les abonnements", "convert_prices" => "Convertir toujours et afficher les prix dans ma devise principale (plus lent)", "show_original_price" => "Afficher également le prix original lorsque des conversions ou des calculs sont effectués", "experience" => "Expérience", "show_subscription_progress" => "Afficher la progression de l'abonnement", "disabled_subscriptions" => "Abonnements désactivés", "hide_disabled_subscriptions" => "Masquer les abonnements désactivés", "show_disabled_subscriptions_at_the_bottom" => "Afficher les abonnements désactivés en bas", "experimental_settings" => "Paramètres expérimentaux", "remove_background" => "Tenter de supprimer l'arrière-plan des logos de la recherche d'images", "use_mobile_navigation_bar" => "Utiliser la barre de navigation mobile", "experimental_info" => "Les paramètres expérimentaux ne fonctionneront probablement pas parfaitement.", "payment_methods" => "Méthodes de paiement", "payment_methods_info" => "Cliquez sur une méthode de paiement pour la désactiver / l'activer.", "rename_payment_methods_info" => "Cliquez sur le nom d'un mode de paiement pour le renommer.", "cant_delete_payment_method_in_use" => "Impossible de désactiver la méthode de paiement utilisée", "add_custom_payment" => "Ajouter un paiement personnalisé", "payment_method_name" => "Nom de la méthode de paiement", "payment_method_added_successfuly" => "Méthode de paiement ajoutée avec succès", "payment_method_removed" => "Méthode de paiement supprimée", "disable" => "Désactiver", "enable" => "Activer", "rename_payment_method" => "Renommer la méthode de paiement", "payment_renamed" => "Méthode de paiement renommée", "payment_not_renamed" => "La méthode de paiement n'a pas été renommée", "test" => "Test", "add" => "Ajouter", "save" => "Enregistrer", "reset" => "Réinitialiser", "main_accent_color_error" => "La couleur principale et la couleur d'accent ne peuvent pas être identiques", "backup_and_restore" => "Sauvegarde et restauration", "backup" => "Sauvegarde", "restore" => "Restauration", "restore_info" => "La restauration de la base de données annulera toutes les données actuelles. Vous serez déconnecté après la restauration.", "account" => "Compte", "export_subscriptions" => "Exporter les abonnements", "export_as_json" => "Exporter en JSON", "export_as_csv" => "Exporter en CSV", "danger_zone" => "Zone de danger", "delete_account" => "Supprimer le compte", "delete_account_info" => "La suppression de votre compte entraînera également la suppression de tous vos abonnements et paramètres.", // Menu des filtes "filter" => "Filtre", "clear" => "Effacer", // Toast "success" => "Succès", // Réponses de l'API "session_expired" => "Votre session a expiré. Veuillez vous reconnecter", "fields_missing" => "Certains champs manquent", "fill_all_fields" => "Veuillez remplir tous les champs", "fill_mandatory_fields" => "Veuillez remplir tous les champs obligatoires", "error" => "Erreur", // Catégorie "failed_add_category" => "Échec de l'ajout de la catégorie", "failed_edit_category" => "Échec de la modification de la catégorie", "category_in_use" => "La catégorie est utilisée dans des abonnements et ne peut pas être supprimée", "failed_remove_category" => "Échec de la suppression de la catégorie", "category_saved" => "Catégorie enregistrée", "category_removed" => "Catégorie supprimée", "sort_order_saved" => "L'ordre de tri a été enregistré", // Devise "currency_saved" => "a été enregistrée.", "error_adding_currency" => "Erreur lors de l'ajout de l'entrée de devise.", "failed_to_store_currency" => "Échec de l'enregistrement de la devise dans la base de données.", "currency_in_use" => "La devise est utilisée dans des abonnements et ne peut pas être supprimée.", "currency_is_main" => "La devise est définie comme devise principale et ne peut pas être supprimée.", "failed_to_remove_currency" => "Échec de la suppression de la devise de la base de données.", "failed_to_store_api_key" => "Échec de l'enregistrement de la clé API dans la base de données.", "invalid_api_key" => "Clé API invalide.", "api_key_saved" => "Clé API enregistrée avec succès", "currency_removed" => "Devise supprimée", // Ménage "failed_add_household" => "Échec de l'ajout de membre du ménage", "failed_edit_household" => "Échec de la modification du membre du ménage", "failed_remove_household" => "Échec de la suppression du membre du ménage", "household_in_use" => "Le membre du ménage est utilisé dans des abonnements et ne peut pas être supprimé", "member_saved" => "Membre enregistré", "member_removed" => "Membre supprimé", // Notifications "error_saving_notifications" => "Erreur lors de l'enregistrement des données de notifications.", "wallos_notification" => "Notification de Wallos", "test_notification" => "Il s'agit d'une notification de test. Si vous la voyez, la configuration est correcte.", "email_error" => "Erreur dlors de l'envoi de courriel", "notification_sent_successfuly" => "Notification envoyée avec succès", "notifications_settings_saved" => "Paramètres de notifications enregistrés avec succès.", "notification_failed" => "Échec de la notification", // Paiements "payment_in_use" => "Impossible de désactiver la méthode de paiement utilisée", "failed_update_payment" => "Échec de la mise à jour de la méthode de paiement dans la base de données", "enabled" => "activé", "disabled" => "désactivé", // Abonnement "error_fetching_image" => "Erreur lors de la récupération de l'image", "subscription_updated_successfuly" => "Abonnement mis à jour avec succès", "subscription_added_successfuly" => "Abonnement ajouté avec succès", "error_deleting_subscription" => "Erreur de suppression de l'abonnement.", "invalid_request_method" => "Méthode de demande invalide.", // Utilisateur "error_updating_user_data" => "Erreur lors de la mise à jour des données utilisateur.", "user_details_saved" => "Détails de l'utilisateur enregistrés", // Admin Page "registrations" => "Inscriptions", "enable_user_registrations" => "Activer les inscriptions d'utilisateurs", "maximum_number_users" => "Nombre maximum d'utilisateurs", "require_email_verification" => "Exiger la vérification de l'adresse courriel", "configure_smtp_settings_to_enable" => "Configurer les paramètres SMTP pour activer", "server_url" => "URL du serveur", "server_url_info" => "Utilisé pour la vérification du courrier électronique et la récupération du mot de passe. Il doit s'agir d'une URL publique valide.", "server_url_password_reset" => "Si cette option est activée, la fonction de réinitialisation du mot de passe sera également activée.", "disable_login" => "Désactiver la connexion", "disable_login_info" => "Contourner le login. Si vous utilisez votre serveur sur un réseau local uniquement, sans accès externe, vous pouvez désactiver le login. L'utilisateur admin se connectera automatiquement.", "disable_login_info2" => "Vous ne pouvez activer ce paramètre que si l'enregistrement des utilisateurs est désactivé et qu'il n'y a pas d'autre compte utilisateur que celui de l'administrateur.", "max_users_info" => "0 signifie un nombre illimité d'utilisateurs", "user_management" => "Gestion des utilisateurs", "delete_user" => "Supprimer l'utilisateur", "delete_user_info" => "La suppression d'un utilisateur supprimera également tous ses abonnements et paramètres.", "create_user" => "Créer un utilisateur", "oidc_settings" => "Paramètres OIDC", "oidc_auth_enabled" => "Authentification OIDC activée", "create_user_automatically" => "Créer un utilisateur automatiquement", "disable_password_login" => "Désactiver la connexion par mot de passe", "smtp_settings" => "Paramètres SMTP", "smtp_usage_info" => "Sera utilisé pour la récupération du mot de passe et d'autres e-mails système.", "security_settings" => "Paramètres de sécurité", "ssrf_protection_info" => "Pour prévenir les attaques Server-Side Request Forgery (SSRF), Wallos bloque par défaut les notifications webhook vers des adresses réseau privées ou internes.", "local_webhook_info" => "Si vous devez envoyer des webhooks vers des services locaux (comme Home Assistant, Gotify ou Node-RED), saisissez leurs adresses IP ou noms d'hôte ci-dessus sous forme d'une liste séparée par des virgules (par ex. 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Tâches de maintenance", "orphaned_logos" => "Logos orphelins", "update" => "Mise à jour", "new_version_available" => "Une nouvelle version de Wallos est disponible", "current_version" => "Version actuelle", "latest_version" => "Dernière version", "on_current_version" => "Vous utilisez la dernière version de Wallos.", "show_update_notification" => "Afficher la notification de mise à jour sur le tableau de bord", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "Votre adresse courriel a été vérifiée avec succès", "email_verification_failed" => "La vérification de l'adresse courriel a échoué", // Calendar "calendar" => "Calendrier", "sun" => "Dim", "mon" => "Lun", "tue" => "Mar", "wed" => "Mer", "thu" => "Jeu", "fri" => "Ven", "sat" => "Sam", "month-01" => "Janvier", "month-02" => "Février", "month-03" => "Mars", "month-04" => "Avril", "month-05" => "Mai", "month-06" => "Juin", "month-07" => "Juillet", "month-08" => "Août", "month-09" => "Septembre", "month-10" => "Octobre", "month-11" => "Novembre", "month-12" => "Décembre", "total_cost" => "Coût total", "export_icalendar" => "Exporter en iCalendar", "over_budget_warning" => "Vous avez dépassé votre budget", // TOTP Page "insert_totp_code" => "Veuillez insérer le code TOTP", ]; ?> ================================================ FILE: includes/i18n/getlang.php ================================================ ================================================ FILE: includes/i18n/id.php ================================================ "Anda perlu membuat akun sebelum dapat masuk", "username" => "Nama Pengguna", "password" => "Kata Sandi", "email" => "Email", "firstname" => "Nama depan", "lastname" => "Nama belakang", "confirm_password" => "Konfirmasi Kata Sandi", "main_currency" => "Mata Uang Utama", "language" => "Bahasa", "passwords_dont_match" => "Kata sandi tidak cocok", "username_exists" => "Nama pengguna sudah ada", "email_exists" => "Email sudah ada", "registration_failed" => "Pendaftaran gagal, silakan coba lagi.", "register" => "Daftar", "restore_database" => "Pulihkan Database", // Login Page "please_login" => "Silakan masuk", "stay_logged_in" => "Tetap masuk (30 hari)", "login" => "Masuk", "login_with" => "Masuk dengan", "or" => "atau", "login_failed" => "Detail masuk salah", "registration_successful" => "Pendaftaran berhasil", "user_email_waiting_verification" => "Email Anda perlu diverifikasi. Silakan periksa email Anda.", // Password Reset Page "forgot_password" => "Lupa Kata Sandi", "reset_password" => "Atur Ulang Kata Sandi", "reset_sent_check_email" => "Email pengaturan ulang telah dikirim. Silakan periksa email Anda.", "password_reset_successful" => "Pengaturan ulang kata sandi berhasil", // Header "profile" => "Profil", "dashboard" => "Dasbor", "subscriptions" => "Langganan", "stats" => "Statistik", "settings" => "Pengaturan", "admin" => "Admin", "about" => "Tentang", "logout" => "Keluar", // Dashboard "hello" => "Halo", "upcoming_payments" => "Pembayaran Mendatang", "no_upcoming_payments" => "Anda tidak memiliki pembayaran mendatang", "overdue_renewals" => "Perpanjangan Terlambat", "ai_recommendations" => "Rekomendasi AI", "your_budget" => "Anggaran Anda", "budget" => "Anggaran", "budget_used" => "Anggaran Digunakan", "over_budget" => "Di Atas Anggaran", "your_subscriptions" => "Langganan Anda", "your_savings" => "Tabungan Anda", // Subscriptions page "subscription" => "Langganan", "no_subscriptions_yet" => "Anda belum memiliki langganan", "add_first_subscription" => "Tambahkan langganan pertama", "new_subscription" => "Langganan Baru", "search" => "Cari", "state" => "Status", "alphanumeric" => "Alfanumerik", "sort" => "Urutkan", "name" => "Nama", "last_added" => "Terakhir Ditambahkan", "price" => "Harga", "next_payment" => "Pembayaran Berikutnya", "renewal_type" => "Jenis Perpanjangan", "auto_renewal" => "Perpanjangan Otomatis", "automatically_renews" => "Diperpanjang secara otomatis", "manual_renewal" => "Perpanjangan Manual", "start_date" => "Tanggal Mulai", "inactive" => "Nonaktifkan Langganan", "replaced_with" => "Diganti dengan", "none" => "Tidak ada", "member" => "Anggota", "category" => "Kategori", "payment_method" => "Metode Pembayaran", "Daily" => "Harian", "Weekly" => "Mingguan", "Monthly" => "Bulanan", "Yearly" => "Tahunan", "daily" => "Hari", "weekly" => "Minggu", "monthly" => "Bulan", "yearly" => "Tahun", "days" => "hari", "weeks" => "minggu", "months" => "bulan", "years" => "tahun", "external_url" => "Kunjungi URL Eksternal", "empty_page" => "Halaman Kosong", "clear_filters" => "Hapus Filter", "no_matching_subscriptions" => "Tidak ada langganan yang cocok", "clone" => "Duplikat", "renew" => "Perpanjang", "calculate_next_payment_date" => "Hitung Tanggal Pembayaran Berikutnya", // Subscription form "add_subscription" => "Tambahkan langganan", "edit_subscription" => "Edit langganan", "subscription_name" => "Nama langganan", "logo_preview" => "Pratinjau Logo", "search_logo" => "Cari logo di web", "web_search" => "Pencarian Web", "currency" => "Mata Uang", "payment_every" => "Pembayaran setiap", "frequency" => "Frekuensi", "cycle" => "Siklus", "no_category" => "Tidak ada kategori", "paid_by" => "Dibayar oleh", "url" => "URL", "notes" => "Catatan", "enable_notifications" => "Aktifkan Notifikasi untuk langganan ini", "default_value_from_settings" => "Nilai default dari pengaturan", "cancellation_notification" => "Notifikasi Pembatalan", "delete" => "Hapus", "cancel" => "Batal", "upload_logo" => "Unggah Logo", // Statistics page "cant_convert_currency" => "Anda menggunakan beberapa mata uang pada langganan Anda. Untuk mendapatkan statistik yang valid dan akurat, silakan atur Kunci API Fixer di halaman pengaturan.", "general_statistics" => "Statistik Umum", "active_subscriptions" => "Langganan Aktif", "inactive_subscriptions" => "Langganan Tidak Aktif", "monthly_cost" => "Biaya Bulanan", "yearly_cost" => "Biaya Tahunan", "average_monthly" => "Rata-rata Biaya Langganan Bulanan", "most_expensive" => "Biaya Langganan Termahal", "amount_due" => "Jumlah yang jatuh tempo bulan ini", "percentage_budget_used" => "Persentase anggaran yang digunakan", "budget_remaining" => "Sisa Anggaran", "amount_over_budget" => "Jumlah melebihi anggaran", "monthly_savings" => "Penghematan Bulanan (pada langganan tidak aktif)", "yearly_savings" => "Penghematan Tahunan (pada langganan tidak aktif)", "split_views" => "Tampilan Terpisah", "category_split" => "Pemisahan Kategori", "household_split" => "Pemisahan Rumah Tangga", "payment_method_split" => "Pemisahan Metode Pembayaran", "total_cost_trend" => "Tren Biaya Total", "cost_vs_budget" => "Biaya vs Anggaran", // About page "about_and_credits" => "Tentang dan Kredit", "credits" => "Kredit", "license" => "Lisensi", "release_notes" => "Catatan Rilis", "update_available" => "Pembaruan Tersedia", "issues_and_requests" => "Masalah dan Permintaan", "the_author" => "Penulis", "icons" => "Ikon", "payment_icons" => "Ikon Pembayaran", // Profile page "upload_avatar" => "Unggah Avatar", "file_type_error" => "Jenis file yang disediakan tidak didukung.", "user_details" => "Detail Pengguna", "two_factor_authentication" => "Autentikasi Dua Faktor", "two_factor_info" => "Autentikasi Dua Faktor menambahkan lapisan keamanan ekstra ke akun Anda.
Anda memerlukan aplikasi autentikator seperti Google Authenticator, Authy, atau Ente Auth untuk memindai kode QR.", "two_factor_enabled_info" => "Akun Anda aman dengan Autentikasi Dua Faktor. Anda dapat menonaktifkannya dengan mengklik tombol di atas.", "enable_two_factor_authentication" => "Aktifkan Autentikasi Dua Faktor", "2fa_already_enabled" => "Autentikasi Dua Faktor sudah diaktifkan", "totp_code_incorrect" => "Kode TOTP salah", "backup_codes" => "Kode Cadangan", "download_backup_codes" => "Unduh Kode Cadangan", "copy_to_clipboard" => "Salin ke clipboard", "totp_backup_codes_info" => "Kode-kode ini dapat digunakan untuk masuk jika Anda kehilangan akses ke aplikasi autentikator Anda.", "disable_two_factor_authentication" => "Nonaktifkan Autentikasi Dua Faktor", "totp_code" => "Kode TOTP", "api_key" => "Kunci API", "regenerate" => "Buat Ulang", "api_key_info" => "Kunci API digunakan untuk mengakses API. Jaga kerahasiaannya.", // Settings page "monthly_budget" => "Anggaran Bulanan", "budget_info" => "Anggaran bulanan digunakan untuk menghitung statistik", "household" => "Rumah Tangga", "save_member" => "Simpan Anggota", "delete_member" => "Hapus Anggota", "cant_delete_member" => "Tidak dapat menghapus anggota utama", "cant_delete_member_in_use" => "Tidak dapat menghapus anggota yang digunakan dalam langganan", "household_info" => "Bidang email memungkinkan anggota rumah tangga diberitahu tentang langganan yang akan kedaluwarsa.", "notifications" => "Notifikasi", "enable_email_notifications" => "Aktifkan notifikasi email", "notify_me" => "Beri tahu saya", "day_before" => "hari sebelum", "on_due_date" => "Pada tanggal jatuh tempo", "days_before" => "hari sebelum", "smtp_address" => "Alamat SMTP", "port" => "Port", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "Nama Pengguna SMTP", "smtp_password" => "Kata Sandi SMTP", "from_email" => "Dari email (Opsional)", "send_to_other_emails" => "Juga kirim notifikasi ke alamat email berikut (gunakan ; untuk memisahkannya):", "other_emails_placeholder" => "pengguna@domain.com;tes@pengguna.com", "smtp_info" => "Kata Sandi SMTP dikirim dan disimpan dalam bentuk teks biasa. Untuk keamanan, silakan buat akun khusus untuk ini.", "telegram" => "Telegram", "telegram_bot_token" => "Token Bot Telegram", "telegram_chat_id" => "ID Obrolan Telegram", "pushplus" => "Pushplus", "pushplus_token" => "Token Pushplus", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "URL Webhook Mattermost", "mattermost_bot_username" => "Nama Pengguna Bot Mattermost", "mattermost_bot_icon_emoji" => "Emoji Ikon Bot Mattermost", "webhook" => "Webhook", "webhook_url" => "URL Webhook", "request_method" => "Metode Permintaan", "custom_headers" => "Header Kustom", "webhook_payload" => "Payload Webhook", "payment_notifications_payload" => "Payload Notifikasi Pembayaran", "cancelation_notification_payload" => "Payload Notifikasi Pembatalan", "variables_available" => "Variabel yang tersedia", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Nama Pengguna Bot Discord", "discord_bot_avatar_url" => "URL Avatar Bot Discord", "pushover" => "Pushover", "pushover_user_key" => "Kunci Pengguna Pushover", "host" => "Host", "topic" => "Topik", "ignore_ssl_errors" => "Abaikan Kesalahan SSL", "categories" => "Kategori", "save_category" => "Simpan Kategori", "delete_category" => "Hapus Kategori", "cant_delete_category_in_use" => "Tidak dapat menghapus kategori yang digunakan dalam langganan", "currencies" => "Mata Uang", "save_currency" => "Simpan mata uang", "delete_currency" => "Hapus mata uang", "cant_delete_main_currency" => "Tidak dapat menghapus mata uang utama", "cant_delete_currency_in_use" => "Tidak dapat menghapus mata uang yang digunakan dalam langganan", "exchange_update" => "Nilai tukar terakhir diperbarui pada", "currency_info" => "Temukan mata uang yang didukung dan kode mata uang yang benar di", "currency_performance" => "Untuk kinerja yang lebih baik, simpan hanya mata uang yang Anda gunakan.", "fixer_api_key" => "Kunci API Fixer", "provider" => "Penyedia", "fixer_info" => "Jika Anda menggunakan beberapa mata uang, dan menginginkan statistik dan pengurutan yang akurat pada langganan, Kunci API GRATIS dari Fixer diperlukan.", "get_key" => "Dapatkan kunci Anda di", "get_free_fixer_api_key" => "Dapatkan Kunci API Fixer gratis", "get_key_alternative" => "Sebagai alternatif, Anda bisa mendapatkan kunci api fixer gratis dari", "ai_model" => "Model AI", "select_ai_model" => "Pilih Model AI", "run_schedule" => "Jadwalkan Eksekusi", "manually" => "Secara Manual", "coming_soon" => "Segera Hadir", "invalid_host" => "Host Tidak Valid", "ai_recommendations_info" => "Rekomendasi AI dihasilkan berdasarkan langganan dan anggota rumah tangga Anda.", "may_take_time" => "Bergantung pada penyedia, model, dan jumlah langganan, pembuatan rekomendasi mungkin memerlukan waktu.", "recommendations_visible_on_dashboard" => "Rekomendasi akan terlihat di dasbor.", "generate_recommendations" => "Hasilkan Rekomendasi", "display_settings" => "Pengaturan Tampilan", "theme_settings" => "Pengaturan Tema", "colors" => "Warna", "custom_colors" => "Warna Kustom", "theme" => "Tema", "dark_theme" => "Tema Gelap", "light_theme" => "Tema Terang", "automatic"=> "Otomatis", "main_color" => "Warna Utama", "accent_color" => "Warna Aksen", "hover_color" => "Warna Hover", "save_custom_colors" => "Simpan Warna Kustom", "reset_custom_colors" => "Atur Ulang Warna Kustom", "custom_css" => "CSS Kustom", "save_custom_css" => "Simpan CSS Kustom", "calculate_monthly_price" => "Hitung dan tampilkan harga bulanan untuk semua langganan", "convert_prices" => "Selalu konversi dan tampilkan harga dalam mata uang utama saya (lebih lambat)", "show_original_price" => "Juga tampilkan harga asli saat konversi atau perhitungan dilakukan", "experience" => "Pengalaman", "show_subscription_progress" => "Tampilkan progres langganan", "disabled_subscriptions" => "Langganan yang Dinonaktifkan", "hide_disabled_subscriptions" => "Sembunyikan langganan yang dinonaktifkan", "show_disabled_subscriptions_at_the_bottom" => "Tampilkan langganan yang dinonaktifkan di bagian bawah", "experimental_settings" => "Pengaturan Eksperimental", "remove_background" => "Coba hapus latar belakang logo dari pencarian gambar", "use_mobile_navigation_bar" => "Gunakan bilah navigasi seluler", "experimental_info" => "Pengaturan eksperimental mungkin tidak akan berfungsi dengan sempurna.", "payment_methods" => "Metode Pembayaran", "payment_methods_info" => "Klik metode pembayaran untuk menonaktifkan / mengaktifkannya.", "rename_payment_methods_info" => "Klik nama pada metode pembayaran untuk mengganti namanya.", "cant_delete_payment_method_in_use" => "Tidak dapat menonaktifkan metode pembayaran yang digunakan", "add_custom_payment" => "Tambahkan Metode Pembayaran Kustom", "payment_method_name" => "Nama Metode Pembayaran", "payment_method_added_successfuly" => "Metode pembayaran berhasil ditambahkan", "payment_method_removed" => "Metode pembayaran dihapus", "disable" => "Nonaktifkan", "enable" => "Aktifkan", "rename_payment_method" => "Ganti Nama Metode Pembayaran", "payment_renamed" => "Metode pembayaran diganti namanya", "payment_not_renamed" => "Metode pembayaran tidak diganti namanya", "test" => "Tes", "add" => "Tambah", "save" => "Simpan", "reset" => "Atur Ulang", "main_accent_color_error" => "Warna utama dan warna aksen tidak boleh sama", "backup_and_restore" => "Cadangkan dan Pulihkan", "backup" => "Cadangkan", "restore" => "Pulihkan", "restore_info" => "Memulihkan database akan menimpa semua data saat ini. Anda akan keluar setelah pemulihan.", "account" => "Akun", "export_subscriptions" => "Ekspor Langganan", "export_as_json" => "Ekspor sebagai JSON", "export_as_csv" => "Ekspor sebagai CSV", "danger_zone" => "Zona Berbahaya", "delete_account" => "Hapus Akun", "delete_account_info" => "Menghapus akun Anda juga akan menghapus semua langganan dan pengaturan Anda.", // Filters menu "filter" => "Filter", "clear" => "Hapus", // Toast "success" => "Sukses", // Endpoint responses "session_expired" => "Sesi Anda telah berakhir. Silakan masuk lagi", "fields_missing" => "Beberapa bidang kosong", "fill_all_fields" => "Silakan isi semua bidang", "fill_mandatory_fields" => "Silakan isi semua bidang wajib", "error" => "Kesalahan", // Category "failed_add_category" => "Gagal menambahkan kategori", "failed_edit_category" => "Gagal mengedit kategori", "category_in_use" => "Kategori sedang digunakan dalam langganan dan tidak dapat dihapus", "failed_remove_category" => "Gagal menghapus kategori", "category_saved" => "Kategori disimpan", "category_removed" => "Kategori dihapus", "sort_order_saved" => "Urutan sortir disimpan", // Currency "currency_saved" => "telah disimpan.", "error_adding_currency" => "Kesalahan saat menambahkan entri mata uang.", "failed_to_store_currency" => "Gagal menyimpan Mata Uang di Database.", "currency_in_use" => "Mata uang sedang digunakan dalam langganan dan tidak dapat dihapus.", "currency_is_main" => "Mata uang ditetapkan sebagai mata uang utama dan tidak dapat dihapus.", "failed_to_remove_currency" => "Gagal menghapus mata uang dari Database.", "failed_to_store_api_key" => "Gagal menyimpan Kunci API di Database.", "invalid_api_key" => "Kunci API tidak valid.", "api_key_saved" => "Kunci API berhasil disimpan", "currency_removed" => "Mata uang dihapus", // Household "failed_add_household" => "Gagal menambahkan anggota rumah tangga", "failed_edit_household" => "Gagal mengedit anggota rumah tangga", "failed_remove_household" => "Gagal menghapus anggota rumah tangga", "household_in_use" => "Anggota rumah tangga sedang digunakan dalam langganan dan tidak dapat dihapus", "member_saved" => "Anggota disimpan", "member_removed" => "Anggota dihapus", // Notifications "error_saving_notifications" => "Kesalahan saat menyimpan data notifikasi.", "wallos_notification" => "Notifikasi Wallos", "test_notification" => "Ini adalah notifikasi tes. Jika Anda melihat ini, konfigurasinya benar.", "email_error" => "Kesalahan saat mengirim email", "notification_sent_successfuly" => "Notifikasi berhasil dikirim", "notifications_settings_saved" => "Pengaturan notifikasi berhasil disimpan.", "notification_failed" => "Notifikasi gagal", // Payments "payment_in_use" => "Tidak dapat menonaktifkan metode pembayaran yang digunakan", "failed_update_payment" => "Gagal memperbarui metode pembayaran di database", "enabled" => "diaktifkan", "disabled" => "dinonaktifkan", // Subscription "error_fetching_image" => "Kesalahan saat mengambil gambar", "subscription_updated_successfuly" => "Langganan berhasil diperbarui", "subscription_added_successfuly" => "Langganan berhasil ditambahkan", "error_deleting_subscription" => "Kesalahan saat menghapus langganan.", "invalid_request_method" => "Metode permintaan tidak valid.", // User "error_updating_user_data" => "Kesalahan saat memperbarui data pengguna.", "user_details_saved" => "Detail pengguna disimpan", // Admin Page "registrations" => "Pendaftaran", "enable_user_registrations" => "Aktifkan pendaftaran pengguna", "maximum_number_users" => "Jumlah maksimum pengguna", "require_email_verification" => "Perlu verifikasi email", "configure_smtp_settings_to_enable" => "Konfigurasikan pengaturan SMTP untuk mengaktifkan", "server_url" => "URL Server", "server_url_info" => "Digunakan untuk verifikasi email dan pemulihan kata sandi. Harus berupa URL publik yang valid.", "server_url_password_reset" => "Jika diatur juga akan mengaktifkan fungsionalitas pengaturan ulang kata sandi.", "disable_login" => "Nonaktifkan masuk", "disable_login_info" => "Lewati masuk. Jika Anda menjalankan server Anda di jaringan lokal saja, tanpa akses eksternal Anda dapat menonaktifkan proses masuk. Ini akan secara otomatis memasukkan pengguna admin.", "disable_login_info2" => "Anda hanya dapat mengaktifkan pengaturan ini jika pendaftaran pengguna dinonaktifkan dan tidak ada lebih dari akun pengguna admin.", "max_users_info" => "0 berarti tidak terbatas", "user_management" => "Manajemen Pengguna", "delete_user" => "Hapus Pengguna", "delete_user_info" => "Menghapus pengguna juga akan menghapus semua langganan dan pengaturan mereka.", "create_user" => "Buat Pengguna", "oidc_settings" => "Pengaturan OIDC", "oidc_oauth_enabled" => "Aktifkan OIDC/OAuth", "create_user_automatically" => "Buat pengguna secara otomatis", "disable_password_login" => "Nonaktifkan masuk dengan kata sandi", "smtp_settings" => "Pengaturan SMTP", "smtp_usage_info" => "Akan digunakan untuk pemulihan kata sandi dan email sistem lainnya.", "security_settings" => "Pengaturan Keamanan", "ssrf_protection_info" => "Untuk mencegah serangan Server-Side Request Forgery (SSRF), Wallos secara default memblokir notifikasi webhook ke alamat jaringan pribadi atau internal.", "local_webhook_info" => "Jika Anda perlu mengirim webhook ke layanan lokal (seperti Home Assistant, Gotify, atau Node-RED), masukkan alamat IP atau nama host mereka di atas sebagai daftar yang dipisahkan koma (mis., 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Tugas Pemeliharaan", "orphaned_logos" => "Logo Yatim Piatu", "update" => "Perbarui", "new_version_available" => "Versi baru Wallos tersedia", "current_version" => "Versi Saat Ini", "latest_version" => "Versi Terbaru", "on_current_version" => "Anda menjalankan versi terbaru Wallos.", "show_update_notification" => "Tampilkan notifikasi untuk pembaruan di dasbor", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "Email berhasil diverifikasi", "email_verification_failed" => "Verifikasi email gagal", // Calendar "calendar" => "Kalender", "sun" => "Min", "mon" => "Sen", "tue" => "Sel", "wed" => "Rab", "thu" => "Kam", "fri" => "Jum", "sat" => "Sab", "month-01" => "Januari", "month-02" => "Februari", "month-03" => "Maret", "month-04" => "April", "month-05" => "Mei", "month-06" => "Juni", "month-07" => "Juli", "month-08" => "Agustus", "month-09" => "September", "month-10" => "Oktober", "month-11" => "November", "month-12" => "Desember", "total_cost" => "Total Biaya", "export_icalendar" => "Ekspor iCalendar", "over_budget_warning" => "Anda melebihi anggaran", // TOTP Page "insert_totp_code" => "Masukkan kode TOTP", ]; ?> ================================================ FILE: includes/i18n/it.php ================================================ 'Devi creare un account prima di poter accedere', "username" => 'Nome utente', "password" => 'Password', "email" => 'Email', "firstname" => 'Nome di battesimo', "lastname" => 'Cognome', "confirm_password" => 'Conferma password', "main_currency" => 'Valuta principale', "language" => 'Lingua', "passwords_dont_match" => 'Le password non corrispondono', "username_exists" => 'Il nome utente esiste già', "email_exists" => 'L\'indirizzo email esiste già', "registration_failed" => 'Registrazione fallita, riprova.', "register" => 'Registrati', "restore_database" => 'Ripristina database', // Login "please_login" => 'Per favore, accedi', "stay_logged_in" => 'Rimani connesso (30 giorni)', "login" => 'Accedi', "login_with" => 'Accedi con', "or" => 'o', "login_failed" => 'Le credenziali non sono corrette', "registration_successful" => "L'account è stato creato con successo", "user_email_waiting_verification" => "L'e-mail deve essere verificata. Controlla la tua email", // Password Reset Page "forgot_password" => "Hai dimenticato la password?", "reset_password" => "Reimposta password", "reset_sent_check_email" => "Un'email è stata inviata. Controlla la tua casella di posta", "password_reset_successful" => "La password è stata reimpostata con successo", // Header "profile" => 'Profilo', "dashboard" => 'Dashboard', "subscriptions" => 'Abbonamenti', "stats" => 'Statistiche', "settings" => 'Impostazioni', "admin" => 'Amministrazione', "about" => 'Informazioni', "logout" => 'Esci', // Dashboard "hello" => "Ciao", "upcoming_payments" => "Pagamenti in arrivo", "no_upcoming_payments" => "Non hai pagamenti in arrivo", "overdue_renewals" => "Rinnovi scaduti", "ai_recommendations" => "Raccomandazioni AI", "your_budget" => "Il tuo budget", "budget" => "Budget", "budget_used" => "Budget Utilizzato", "over_budget" => "Sopra il Budget", "your_subscriptions" => "I tuoi Abbonamenti", "your_savings" => "I tuoi Risparmi", // Subscriptions "subscription" => 'Abbonamento', "no_subscriptions_yet" => 'Non hai ancora nessun abbonamento', "add_first_subscription" => 'Aggiungi il tuo primo abbonamento', "new_subscription" => 'Nuovo abbonamento', "search" => 'Cerca', "state" => "Stato", "alphanumeric" => 'Alfanumerico', "sort" => 'Ordina', "name" => 'Nome', "last_added" => 'Ultimo aggiunto', "price" => 'Prezzo', "next_payment" => 'Prossimo pagamento', "renewal_type" => 'Tipo di rinnovo', "auto_renewal" => 'Rinnovo automatico', "automatically_renews" => 'Si rinnova automaticamente', "manual_renewal" => "Rinnovo manuale", "start_date" => 'Data di inizio', "inactive" => 'Disattiva abbonamento', "replaced_with" => 'Sostituito con', "none" => 'Nessuno', "member" => 'Membro', "category" => 'Categoria', "payment_method" => 'Metodo di pagamento', "Daily" => 'Quotidiano', "Weekly" => 'Settimanale', "Monthly" => 'Mensile', "Yearly" => 'Annuale', "daily" => 'Giorno/i', "weekly" => 'Settimana/e', "monthly" => 'Mese/i', "yearly" => 'Anno/i', "days" => 'giorni', "weeks" => 'settimane', "months" => 'mesi', "years" => 'anni', "external_url" => 'Apri URL esterno', "empty_page" => 'Pagina vuota', "clear_filters" => 'Pulisci filtri', "no_matching_subscriptions" => 'Nessun abbonamento corrispondente', "clone" => "Clona", "renew" => "Rinnova", "calculate_next_payment_date" => "Calcola la prossima data di pagamento", // Add/Edit Subscription "add_subscription" => 'Aggiungi abbonamento', "edit_subscription" => 'Modifica abbonamento', "subscription_name" => 'Nome abbonamento', "logo_preview" => 'Anteprima del logo', "search_logo" => 'Cerca il logo sul web', "web_search" => 'Ricerca web', "currency" => 'Valuta', "payment_every" => 'Pagamento ogni', "frequency" => 'Frequenza', "cycle" => 'Ciclo', "no_category" => 'Nessuna categoria', "paid_by" => 'Pagato da', "url" => 'URL', "notes" => 'Note', "enable_notifications" => 'Abilita notifiche per questo abbonamento', "default_value_from_settings" => 'Valore predefinito dalle impostazioni', "cancellation_notification" => "Notifica di cancellazione", "delete" => 'Cancella', "cancel" => 'Annulla', "upload_logo" => 'Carica logo', // Statistics "cant_convert_currency" => "Si stanno utilizzando più valute per gli abbonamenti. Per avere statistiche valide e precise, impostare una chiave API Fixer nella pagina delle impostazioni.", "general_statistics" => 'Statistiche generali', "active_subscriptions" => 'Abbonamenti attivi', "inactive_subscriptions" => 'Abbonamenti inattivi', "monthly_cost" => 'Costo mensile', "yearly_cost" => 'Costo annuale', "average_monthly" => "Costo medio mensile dell'abbonamento", "most_expensive" => "Costo dell'abbonamento più elevato", "amount_due" => 'Importo dovuto questo mese', "percentage_budget_used" => 'Percentuale del budget utilizzata', "budget_remaining" => 'Budget rimanente', "amount_over_budget" => 'Importo oltre il budget', "monthly_savings" => 'Risparmi mensili (su abbonamenti inattivi)', "yearly_savings" => 'Risparmi annuali (su abbonamenti inattivi)', "split_views" => 'Visualizzazioni con grafici', "category_split" => 'Suddivisione per categoria', "household_split" => 'Suddivisione per nucleo familiare', "payment_method_split" => 'Suddivisione per metodo di pagamento', "total_cost_trend" => 'Trend del costo totale', "cost_vs_budget" => 'Costo vs Budget', // About "about_and_credits" => 'Informazioni e crediti', "credits" => "Crediti", "license" => 'Licenza', "release_notes" => "Note sulla versione", "update_available" => "Aggiornamento disponibile", "issues_and_requests" => 'Problemi e richieste', "the_author" => "L'autore", "icons" => 'Icone', "payment_icons" => 'Icone di pagamento', // Profile "upload_avatar" => 'Carica avatar', "file_type_error" => 'Il tipo di file fornito non è supportato.', "user_details" => 'Dettagli utente', "two_factor_authentication" => 'Autenticazione a due fattori', "two_factor_info" => "L'Autenticazione a due fattori aggiunge un ulteriore livello di sicurezza al vostro account.
Per scansionare il codice QR è necessaria un'app di autenticazione come Google Authenticator, Authy o Ente Auth.", "two_factor_enabled_info" => "Il vostro account è sicuro con l'Autenticazione a due fattori. È possibile disattivarla facendo clic sul pulsante in alto.", "enable_two_factor_authentication" => "Abilita l'autenticazione a due fattori", "2fa_already_enabled" => "L'autenticazione a due fattori è già abilitata", "totp_code_incorrect" => "Il codice TOTP è incorretto", "backup_codes" => "Codici di backup", "download_backup_codes" => "Scarica i codici di backup", "copy_to_clipboard" => "Copia negli appunti", "totp_backup_codes_info" => "I codici di backup possono essere utilizzati per accedere al tuo account se non hai accesso al tuo dispositivo di autenticazione a due fattori.", "disable_two_factor_authentication" => "Disabilita l'autenticazione a due fattori", "totp_code" => "Codice TOTP", "api_key" => "Chiave API", "regenerate" => "Rigenera", "api_key_info" => "La chiave API viene utilizzata per accedere ai dati tramite l'API di Wallos. Non condividere la tua chiave API con nessuno.", // Settings "monthly_budget" => "Budget mensile", "budget_info" => "Il budget mensile viene utilizzato per calcolare le statistiche. Se non si desidera utilizzare questa funzionalità, impostare il budget su 0.", "household" => 'Nucleo familiare', "save_member" => 'Salva membro', "delete_member" => 'Elimina membro', "cant_delete_member" => 'Non è possibile eliminare il membro principale', "cant_delete_member_in_use" => 'Non è possibile eliminare un membro che utilizza almeno un abbonamento', "household_info" => 'Il campo e-mail consente ai membri del nucleo familiare di essere avvisati degli abbonamenti in procinto di scadere.', "notifications" => 'Notifiche', "enable_email_notifications" => 'Abilita le notifiche via e-mail', "notify_me" => 'Avvisami', "day_before" => 'giorno prima', "on_due_date" => 'Alla data di scadenza', "days_before" => 'giorni prima', "smtp_address" => 'Indirizzo SMTP', "port" => 'Porta', "tls" => 'TLS', "ssl" => 'SSL', "smtp_username" => 'Nome utente SMTP', "smtp_password" => 'Password SMTP', "from_email" => 'Da quale e-mail (Opzionale)', "smtp_info" => 'La password SMTP viene memorizzata e trasmessa in chiaro. Per motivi di sicurezza, si prega di creare un account da utilizzare solo per questo.', "telegram" => "Telegram", "telegram_bot_token" => "Telegram Bot Token", "telegram_chat_id" => "Telegram Chat ID", "pushplus" => "Pushplus", "pushplus_token" => "Pushplus Token", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost Webhook URL", "mattermost_bot_username" => "Mattermost Bot Nome utente", "mattermost_bot_icon_emoji" => "Mattermost Bot Icon Emoji", "webhook" => "Webhook", "webhook_url" => "Webhook URL", "request_method" => "Metodo di richiesta", "custom_headers" => "Intestazioni personalizzate", "webhook_payload" => "Webhook Payload", "payment_notifications_payload" => "Carica il payload della notifica di pagamento", "cancelation_notification_payload" => "Carica il payload della notifica di cancellazione", "variables_available" => "Variabili disponibili", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Nome utente del bot", "discord_bot_avatar_url" => "URL dell'avatar del bot", "pushover" => "Pushover", "pushover_user_key" => "Chiave utente", "host" => "Host", "topic" => "Topic", "ignore_ssl_errors" => "Ignora errori SSL", "categories" => 'Categorie', "save_category" => 'Salva categoria', "delete_category" => 'Elimina categoria', "cant_delete_category_in_use" => 'Non è possibile eliminare una categoria in uso da almeno un abbonamento', "currencies" => 'Valute', "save_currency" => 'Salva valuta', "delete_currency" => 'Elimina valuta', "cant_delete_main_currency" => 'Impossibile eliminare la valuta principale', "cant_delete_currency_in_use" => 'Non è possibile eliminare la valuta in uso da almeno un abbonamento', "exchange_update" => "Tassi di cambio aggiornati l'ultima volta il", "currency_info" => 'Trova le valute supportate e i codici valuta corretti su', "currency_performance" => 'Per garantire prestazioni migliori, tieni solo le valute che utilizzi.', "fixer_api_key" => 'Chiave API di Fixer', "provider" => 'Fornitore', "fixer_info" => 'Se utilizzi più valute e desideri visualizzare statistiche e ordinamenti accurati sugli abbonamenti, è necessaria una chiave API (Gratuita) da Fixer.', "get_key" => 'Ottieni la tua chiave su', "get_free_fixer_api_key" => 'Ottieni gratuitamente la chiave API di Fixer', "get_key_alternative" => 'In alternativa, puoi ottenere gratuitamente una chiave API di Fixer da', "ai_model" => "Modello AI", "select_ai_model" => "Seleziona Modello AI", "run_schedule" => "Esegui Programma", "manually" => "Manuale", "coming_soon" => "In Arrivo", "invalid_host" => "Host Non Valido", "ai_recommendations_info" => "Le raccomandazioni dell'IA sono generate in base ai tuoi abbonamenti e ai membri del tuo nucleo familiare.", "may_take_time" => "A seconda del fornitore, del modello e del numero di abbonamenti, la generazione delle raccomandazioni potrebbe richiedere del tempo.", "recommendations_visible_on_dashboard" => "Le raccomandazioni saranno visibili sul dashboard.", "generate_recommendations" => "Genera Raccomandazioni", "display_settings" => 'Impostazioni di visualizzazione', "theme_settings" => 'Impostazioni del tema', "colors" => 'Colori', "custom_colors" => 'Colori personalizzati', "theme" => 'Tema', "dark_theme" => 'Tema scuro', "light_theme" => 'Tema chiaro', "automatic" => "Automatico", "main_color" => "Colore principale", "accent_color" => "Colore di accento", "hover_color" => "Colore al passaggio del mouse", "save_custom_colors" => "Salva colori personalizzati", "reset_custom_colors" => "Ripristina colori personalizzati", "custom_css" => "CSS personalizzato", "save_custom_css" => "Salva CSS personalizzato", "calculate_monthly_price" => 'Calcola e mostra il prezzo mensile per tutti gli abbonamenti', "convert_prices" => 'Converti sempre e mostra i prezzi nella mia valuta principale (più lento)', "show_original_price" => "Mostra anche il prezzo originale quando vengono effettuate conversioni o calcoli", "experience" => 'Esperienza', "show_subscription_progress" => 'Mostra il progresso dell\'abbonamento', "disabled_subscriptions" => 'Abbonamenti disattivati', "hide_disabled_subscriptions" => 'Nascondi gli abbonamenti disattivati', "show_disabled_subscriptions_at_the_bottom" => 'Mostra gli abbonamenti disattivati in fondo', "experimental_settings" => 'Impostazioni sperimentali', "remove_background" => 'Prova a rimuovere lo sfondo dei loghi dalla ricerca delle immagini', "use_mobile_navigation_bar" => "Utilizza la barra di navigazione mobile", "experimental_info" => 'Le impostazioni sperimentali potrebbero non funzioneranno perfettamente.', "payment_methods" => 'Metodi di pagamento', "payment_methods_info" => 'Fai clic su un metodo di pagamento per abilitarlo/disabilitarlo.', "rename_payment_methods_info" => 'Fai clic sul nome di un metodo di pagamento per rinominarlo.', "cant_delete_payment_method_in_use" => 'Non è possibile disabilitare un metodo di pagamento in uso', "add_custom_payment" => 'Aggiungi metodo di pagamento personalizzato', "payment_method_name" => 'Nome del metodo di pagamento', "payment_method_added_successfuly" => 'Metodo di pagamento aggiunto con successo', "payment_method_removed" => 'Metodo di pagamento rimosso', "disable" => 'Disabilita', "enable" => 'Abilita', "rename_payment_method" => 'Rinomina metodo di pagamento', "payment_renamed" => 'Metodo di pagamento rinominato', "payment_not_renamed" => 'Metodo di pagamento non rinominato', "test" => 'Test', "add" => 'Aggiungi', "save" => 'Salva', "reset" => 'Ripristina', "main_accent_color_error" => 'Il colore principale e il colore di accento non possono essere uguali', "backup_and_restore" => 'Backup e ripristino', "backup" => 'Backup', "restore" => 'Ripristina', "restore_info" => "Il ripristino del database annullerà tutti i dati correnti. Al termine del ripristino, l'utente verrà disconnesso.", "account" => "Account", "export_subscriptions" => "Esporta abbonamenti", "export_as_json" => "Esporta come JSON", "export_as_csv" => "Esporta come CSV", "danger_zone" => "Zona di pericolo", "delete_account" => "Elimina account", "delete_account_info" => "L'eliminazione del vostro account cancellerà anche tutte le vostre sottoscrizioni e impostazioni.", // Filters "filter" => 'Filtra', "clear" => 'Pulisci', // Toast "success" => 'Successo', // Endpoint responses "session_expired" => 'La tua sessione è scaduta. Effettua nuovamente il login', "fields_missing" => 'Mancano alcuni campi', "fill_all_fields" => 'Si prega di compilare tutti i campi', "fill_mandatory_fields" => 'Si prega di compilare tutti i campi obbligatori', "error" => 'Errore', // Category "failed_add_category" => 'Impossibile aggiungere la categoria', "failed_edit_category" => 'Impossibile modificare la categoria', "category_in_use" => "La categoria è attualmente in uso da almeno un abbonamento", "failed_remove_category" => "Impossibile rimuovere la categoria", "category_saved" => "Categoria salvata", "category_removed" => "Categoria rimossa", "sort_order_saved" => "Ordine di visualizzazione salvato", // Currency "currency_saved" => "Valuta salvata con successo", "error_adding_currency" => "Errore nell'aggiunta della valuta", "failed_to_store_currency" => "Impossibile salvare la valuta nel Database", "currency_in_use" => "La valuta è attualmente in uso da almeno un abbonamento", "currency_is_main" => "Impossibile rimuovere la valuta principale", "failed_to_remove_currency" => "Impossibile rimuovere la valuta", "failed_to_store_api_key" => "Impossibile salvare la chiave API", "invalid_api_key" => "Chiave API non valida", "api_key_saved" => "Chiave API salvata", "currency_removed" => "Valuta rimossa", // Household "failed_add_household" => "Impossibile aggiungere un membro del nucleo familiare", "failed_edit_household" => "Impossibile modificare un membro del nucleo familiare", "failed_remove_household" => "Impossibile rimuovere un membro del nucleo familiare", "household_in_use" => "Il membro del nucleo familiare è attualmente in uso da almeno un abbonamento", "member_saved" => "Membro salvato", "member_removed" => "Membro rimosso", // Notifications "error_saving_notifications" => "Errore nel salvataggio delle notifiche", "wallos_notification" => "Notifica Wallos", "test_notification" => "Questa è una notifica di prova", "email_error" => "Errore nell'invio dell'e-mail", "notification_sent_successfuly" => "Notifica inviata con successo", "notifications_settings_saved" => "Impostazioni delle notifiche salvate", "notification_failed" => "Invio della notifica fallito", // Payments "payment_in_use" => "Questo metodo di pagamento è attualmente in uso da almeno un abbonamento", "failed_update_payment" => "Aggiornamento del metodo di pagamento fallito", "enabled" => "abilitato", "disabled" => "disabilitato", // Subscription "error_fetching_image" => "Errore nel recupero dell'immagine", "subscription_updated_successfuly" => "Abbonamento aggiornato con successo", "subscription_added_successfuly" => "Abbonamento aggiunto con successo", "error_deleting_subscription" => "Errore nell'eliminazione dell'abbonamento", "invalid_request_method" => "Metodo di richiesta non valido", // User "error_updating_user_data" => "Errore nell'aggiornamento dei dati utente", "user_details_saved" => "Dettagli utente salvati", // Admin Page "registrations" => "Registrazioni", "enable_user_registrations" => "Abilita le registrazioni utente", "maximum_number_users" => "Numero massimo di utenti", "require_email_verification" => "Richiedi la verifica dell'e-mail", "configure_smtp_settings_to_enable" => "Configura le impostazioni SMTP per abilitare", "server_url" => "URL del server", "server_url_info" => "Utilizzato per la verifica dell'e-mail e il recupero della password. Deve essere un URL pubblico valido.", "server_url_password_reset" => "Se impostato, abilita anche la funzionalità di reimpostazione della password.", "disable_login" => "Disabilita il login", "disable_login_info" => "Bypassare il login. Se si gestisce il server solo su una rete locale, senza accesso esterno, è possibile disabilitare il login. In questo modo, l'utente amministratore effettuerà automaticamente il login.", "disable_login_info2" => "Questa impostazione può essere attivata solo se la registrazione degli utenti è disattivata e non ci sono più account utente oltre a quello dell'amministratore.", "max_users_info" => "Impostare a 0 per un numero illimitato di utenti", "user_management" => "Gestione utenti", "delete_user" => "Elimina utente", "delete_user_info" => "L'eliminazione di un utente eliminerà anche tutte le sue iscrizioni e impostazioni.", "create_user" => "Crea utente", "oidc_settings" => "Impostazioni OIDC", "oidc_auth_enabled" => "Autenticazione OIDC abilitata", "create_user_automatically" => "Crea utente automaticamente", "disable_password_login" => "Disabilita la connessione con password", "smtp_settings" => "Impostazioni SMTP", "smtp_usage_info" => "Verrà utilizzato per il recupero della password e altre e-mail di sistema.", "security_settings" => "Impostazioni di sicurezza", "ssrf_protection_info" => "Per prevenire attacchi Server-Side Request Forgery (SSRF), Wallos blocca per impostazione predefinita le notifiche webhook verso indirizzi di rete privati o interni.", "local_webhook_info" => "Se è necessario inviare webhook a servizi locali (come Home Assistant, Gotify o Node-RED), inserisci i loro indirizzi IP o nomi host sopra come elenco separato da virgole (ad es. 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Compiti di manutenzione", "orphaned_logos" => "Loghi orfani", "update" => "Aggiorna", "new_version_available" => "È disponibile una nuova versione di Wallos", "current_version" => "Versione attuale", "latest_version" => "Ultima versione", "on_current_version" => "Stai utilizzando l'ultima versione di Wallos.", "show_update_notification" => "Mostra notifica di aggiornamento sulla dashboard", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "L'indirizzo email è stato verificato con successo", "email_verification_failed" => "La verifica dell'email è fallita", // Calendar "calendar" => "Calendario", "sun" => "Dom", "mon" => "Lun", "tue" => "Mar", "wed" => "Mer", "thu" => "Gio", "fri" => "Ven", "sat" => "Sab", "month-01" => "Gennaio", "month-02" => "Febbraio", "month-03" => "Marzo", "month-04" => "Aprile", "month-05" => "Maggio", "month-06" => "Giugno", "month-07" => "Luglio", "month-08" => "Agosto", "month-09" => "Settembre", "month-10" => "Ottobre", "month-11" => "Novembre", "month-12" => "Dicembre", "total_cost" => "Costo totale", "export_icalendar" => "Esporta iCal", "over_budget_warning" => "Avete superato il budget", // TOTP Page "insert_totp_code" => "Inserisci il codice TOTP", ]; ?> ================================================ FILE: includes/i18n/jp.php ================================================ "ログインする前にアカウントを作成する必要があります", "username" => "ユーザー名", "password" => "パスワード", "email" => "メール", "firstname" => "名", "lastname" => "苗字", "confirm_password" => "パスワードの確認", "main_currency" => "主要通貨", "language" => "言語", "passwords_dont_match" => "パスワードが違います", "username_exists" => "ユーザー名が既に存在します", "email_exists" => "メールアドレスが既に存在します", "registration_failed" => "登録に失敗しました。もう一度お試しください。", "register" => "登録する", "restore_database" => "データベースをリストア", // Login Page "please_login" => "ログインしてください", "stay_logged_in" => "ログインしたままにする (30日)", "login" => "ログイン", "login_with" => "ログインする", "or" => "または", "login_failed" => "ログイン情報が間違っています", "registration_successful" => "登録に成功", "user_email_waiting_verification" => "Eメールの確認が必要です。メールを確認してください。", // Password Reset Page "forgot_password" => "パスワードを忘れた場合", "reset_password" => "パスワードをリセット", "reset_sent_check_email" => "パスワードリセットリンクが送信されました。メールを確認してください。", "password_reset_successful" => "パスワードリセットに成功", // Header "profile" => "プロフィール", "dashboard" => "ダッシュボード", "subscriptions" => "定期購入", "stats" => "統計", "settings" => "設定", "admin" => "管理者", "about" => "About", "logout" => "ログアウト", // Dashboard "hello" => "こんにちは", "upcoming_payments" => "今後の支払い", "no_upcoming_payments" => "今後の支払いはありません", "overdue_renewals" => "期限切れの更新", "ai_recommendations" => "AIによる推奨", "your_budget" => "あなたの予算", "budget" => "予算", "budget_used" => "予算の使用", "over_budget" => "予算オーバー", "your_subscriptions" => "あなたの定期購入", "your_savings" => "あなたの貯蓄", // Subscriptions page "subscription" => "定期購入", "no_subscriptions_yet" => "まだ定期購入がありません", "add_first_subscription" => "最初の定期購入を追加する", "new_subscription" => "新しい定期購入", "search" => "検索", "state" => "状態", "alphanumeric" => "アルファベット順", "sort" => "並べ替え", "name" => "名前", "last_added" => "最終追加日", "price" => "金額", "next_payment" => "次回支払い", "renewal_type" => "更新タイプ", "auto_renewal" => "自動更新", "automatically_renews" => "自動更新", "manual_renewal" => "手動更新", "start_date" => "開始日", "inactive" => "サブスクリプションを無効にする", "replaced_with" => "置き換えられた", "none" => "なし", "member" => "メンバー", "category" => "カテゴリ", "payment_method" => "支払い方法", "Daily" => "毎日", "Weekly" => "毎週", "Monthly" => "毎月", "Yearly" => "毎年", "daily" => "日", "weekly" => "週", "monthly" => "月", "yearly" => "年", "days" => "日毎", "weeks" => "週毎", "months" => "月毎", "years" => "年毎", "external_url" => "外部URLにアクセス", "empty_page" => "空のページ", "clear_filters" => "フィルタをクリア", "no_matching_subscriptions" => "一致する定期購入がありません", "clone" => "複製", "renew" => "更新", "calculate_next_payment_date" => "次回支払い日を計算", // Subscription form "add_subscription" => "定期購入の追加", "edit_subscription" => "定期購入の編集", "subscription_name" => "定期購入名", "logo_preview" => "ロゴのプレビュー", "search_logo" => "ウェブ上でロゴを検索する", "web_search" => "ウェブ検索", "currency" => "通貨", "payment_every" => "支払い頻度", "frequency" => "頻度", "cycle" => "サイクル", "no_category" => "カテゴリなし", "paid_by" => "支払い元", "url" => "URL", "notes" => "注釈", "enable_notifications" => "この定期購入の通知を有効にする", "default_value_from_settings" => "設定からデフォルト値を使用", "cancellation_notification" => "キャンセル通知", "delete" => "削除", "cancel" => "キャンセル", "upload_logo" => "ロゴのアップロード", // Statistics page "cant_convert_currency" => "購読に複数の通貨を使用しています。有効で正確な統計を取るには、設定ページでFixer API Keyを設定してください。", "general_statistics" => "一般統計", "active_subscriptions" => "アクティブな定期購入", "inactive_subscriptions" => "非アクティブなサブスクリプション", "monthly_cost" => "月間費用", "yearly_cost" => "年間費用", "average_monthly" => "月額平均費用", "most_expensive" => "最も高額な定期購入費用", "amount_due" => "今月の支払額", "percentage_budget_used" => "予算使用率", "budget_remaining" => "予算残高", "amount_over_budget" => "予算オーバー", "monthly_savings" => "月間節約 (非アクティブな定期購入)", "yearly_savings" => "年間節約 (非アクティブな定期購入)", "split_views" => "分割表示", "category_split" => "カテゴリ別", "household_split" => "世帯別", "payment_method_split" => "支払い方法別", "total_cost_trend" => "合計費用のトレンド", "cost_vs_budget" => "費用対予算", // About page "about_and_credits" => "概要とクレジット", "credits" => "クレジット", "license" => "ライセンス", "release_notes" => "リリースノート", "update_available" => "利用可能な更新", "issues_and_requests" => "問題と要望", "the_author" => "著者", "icons" => "アイコン", "payment_icons" => "支払いアイコン", // Profile page "upload_avatar" => "アバターをアップロードする", "file_type_error" => "ファイルタイプが許可されていません", "user_details" => "ユーザー詳細", "two_factor_authentication" => "2要素認証", "two_factor_info" => "二要素認証は、アカウントに追加のセキュリティレイヤーを追加します。QR コードをスキャンするには、Google Authenticator、Authy、Ente Auth などの認証アプリが必要です。", "two_factor_enabled_info" => "お客様のアカウントは二要素認証で保護されています。上のボタンをクリックして無効にすることができます。", "enable_two_factor_authentication" => "二要素認証を有効にする", "2fa_already_enabled" => "2要素認証は既に有効です", "totp_code_incorrect" => "TOTPコードが正しくありません", "backup_codes" => "バックアップコード", "download_backup_codes" => "バックアップコードをダウンロード", "copy_to_clipboard" => "クリップボードにコピー", "totp_backup_codes_info" => "これらのコードは、2要素認証アプリが利用できない場合に使用します。コードは一度しか表示されません。", "disable_two_factor_authentication" => "二要素認証を無効にする", "totp_code" => "TOTPコード", "api_key" => "APIキー", "regenerate" => "再生成", "api_key_info" => "APIキーは、WallosのAPIを使用するために必要です。APIキーを再生成すると、以前のキーは無効になります。", // Settings page "monthly_budget" => "月間予算", "budget_info" => "予算を設定すると、統計ページで予算と実際の支出を比較できます。", "household" => "世帯", "save_member" => "世帯員を保存", "delete_member" => "世帯員を削除", "cant_delete_member" => "世帯主は削除出ません", "cant_delete_member_in_use" => "定期購入を使用中の世帯員は削除できません", "household_info" => "Eメールフィールドでは、世帯のメンバーに購読期限が近づいたことを通知することができます。", "notifications" => "通知", "enable_email_notifications" => "電子メール通知を有効にする", "notify_me" => "通知", "day_before" => "日前", "on_due_date" => "支払い日", "days_before" => "日前", "smtp_address" => "SMTPアドレス", "port" => "ポート番号", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "SMTPユーザー名", "smtp_password" => "SMTPパスワード", "from_email" => "送信元アドレス (オプション)", "send_to_other_emails" => "通知を以下のメールアドレスにも送信する(区切りには ; を使用):", "other_emails_placeholder" => "user@domain.com;test@user.com", "smtp_info" => "SMTPパスワードは平文で送信および保存されます。セキュリティのため専用のアカウントを作成してください。", "telegram" => "Telegram", "telegram_bot_token" => "Telegramボットトークン", "telegram_chat_id" => "TelegramチャットID", "pushplus" => "Pushplus", "pushplus_token" => "Pushplusトークン", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost Webhook URL", "mattermost_bot_username" => "Mattermost Bot ユーザー名", "mattermost_bot_icon_emoji" => "Mattermost Bot アイコン絵文字", "webhook" => "Webhook", "webhook_url" => "Webhook URL", "request_method" => "リクエストメソッド", "custom_headers" => "カスタムヘッダー", "webhook_payload" => "Webhookペイロード", "payment_notifications_payload" => "支払い通知ペイロード", "cancelation_notification_payload" => "キャンセル通知ペイロード", "variables_available" => "利用可能な変数", "gotify" => "Gotify", "token" => "トークン", "discord" => "Discord", "discord_bot_username" => "Discordボットユーザー名", "discord_bot_avatar_url" => "DiscordボットアバターURL", "pushover" => "Pushover", "pushover_user_key" => "Pushoverユーザーキー", "host" => "ホスト", "topic" => "トピック", "ignore_ssl_errors" => "SSLエラーを無視", "categories" => "カテゴリ", "save_category" => "カテゴリを保存", "delete_category" => "カテゴリを削除", "cant_delete_category_in_use" => "定期購入で使用中のカテゴリは削除できません", "currencies" => "通貨", "save_currency" => "通貨を保存", "delete_currency" => "通貨を削除", "cant_delete_main_currency" => "メイン通貨は削除できません", "cant_delete_currency_in_use" => "定期購入で使用中の通貨は削除できません", "exchange_update" => "為替レートの最終更新日", "currency_info" => "サポートされている通貨と正しい通貨コードを見つける", "currency_performance" => "Fパフォーマンスを向上させるには、使用する通貨のみを保持してください。", "fixer_api_key" => "FixerのAPIキー", "provider" => "プロバイダ", "fixer_info" => "複数の通貨を使用し、定期購入に関する正確な統計と並べ替えが必要な場合は、Fixerからの無料APIキーが必要です。", "get_key" => "キーを入手する", "get_free_fixer_api_key" => "無料のFixer APIキーを取得", "get_key_alternative" => "または、以下のサイトから無料のフィクサーapiキーを入手することもできます。", "ai_model" => "AIモデル", "select_ai_model" => "AIモデルを選択", "run_schedule" => "スケジュールを実行", "manually" => "手動", "coming_soon" => "近日公開", "invalid_host" => "無効なホスト", "ai_recommendations_info" => "AIによる推奨は、あなたの定期購入と世帯メンバーに基づいて生成されます。", "may_take_time" => "プロバイダ、モデル、定期購入数によっては、推奨の生成に時間がかかる場合があります。", "recommendations_visible_on_dashboard" => "推奨はダッシュボードに表示されます。", "generate_recommendations" => "推奨", "display_settings" => "表示設定", "theme_settings" => "テーマ設定", "colors" => "色", "custom_colors" => "カスタムカラー", "theme" => "テーマ", "dark_theme" => "ダークテーマ", "light_theme" => "ライトテーマ", "automatic" => "自動", "main_color" => "メインカラー", "accent_color" => "アクセントカラー", "hover_color" => "ホバーカラー", "save_custom_colors" => "カスタムカラーを保存", "reset_custom_colors" => "カスタムカラーをリセット", "custom_css" => "カスタムCSS", "save_custom_css" => "カスタムCSSを保存", "calculate_monthly_price" => "すべての定期購入の月額料金を計算して表示する", "convert_prices" => "常にメイン通貨で価格を換算して表示する (遅い)", "show_original_price" => "変換や計算が行われるときに元の価格も表示する", "experience" => "体験", "show_subscription_progress" => "定期購入の進捗を表示する", "disabled_subscriptions" => "無効な定期購入", "hide_disabled_subscriptions" => "無効な定期購入を非表示にする", "show_disabled_subscriptions_at_the_bottom" => "無効な定期購入を一番下に表示する", "experimental_settings" => "実験的な設定", "remove_background" => "画像検索からロゴの背景を削除する", "use_mobile_navigation_bar" => "モバイルナビゲーションバーを使用する", "experimental_info" => "実験的な設定は、おそらく完全には機能しません。", "payment_methods" => "支払い方法", "payment_methods_info" => "支払い方法をクリックして無効/有効を切り替えます。", "rename_payment_methods_info" => "支払い方法の名前をクリックして、名前を変更します。", "cant_delete_payment_method_in_use" => "支払い方法が使用中のため無効にできません。", "add_custom_payment" => "カスタム支払い方法を追加", "payment_method_name" => "支払い方法名", "payment_method_added_successfuly" => "支払い方法が追加されました", "payment_method_removed" => "支払い方法が削除されました", "disable" => "無効", "enable" => "有効", "rename_payment_method" => "支払い方法の名前を変更", "payment_renamed" => "支払い方法が変更されました", "payment_not_renamed" => "支払い方法が変更されませんでした", "test" => "テスト", "add" => "追加", "save" => "保存", "reset" => "リセット", "main_accent_color_error" => "メインカラーとアクセントカラーは同じにすることはできません", "backup_and_restore" => "バックアップとリストア", "backup" => "バックアップ", "restore" => "リストア", "restore_info" => "データベースをリストアすると、現在のデータがすべて上書きされます。リストア後はサインアウトされます。", "account" => "アカウント", "export_subscriptions" => "定期購入をエクスポート", "export_as_json" => "JSONとしてエクスポート", "export_as_csv" => "CSVとしてエクスポート", "danger_zone" => "危険地帯", "delete_account" => "アカウントを削除", "delete_account_info" => "アカウントを削除するとすべてのサブスクリプションと設定も削除されます。", // Filters menu "filter" => "フィルタ", "clear" => "クリア", // Toast "success" => "成功", // Endpoint responses "session_expired" => "セッションの有効期限が切れました。再度ログインしてください。", "fields_missing" => "いくつかの項目が抜けています", "fill_all_fields" => "すべての項目を記入してください", "fill_mandatory_fields" => "必須項目をすべて記入してください", "error" => "エラー", // Category "failed_add_category" => "カテゴリの追加に失敗", "failed_edit_category" => "カテゴリの編集に失敗", "category_in_use" => "定期購入で使用中のカテゴリは削除できません", "failed_remove_category" => "カテゴリの削除に失敗", "category_saved" => "カテゴリの保存", "category_removed" => "カテゴリの削除", "sort_order_saved" => "並べ替え順が保存されました", // Currency "currency_saved" => "通貨を保存", "error_adding_currency" => "通貨エントリの追加エラー.", "failed_to_store_currency" => "データベースに通貨を保存できませんでした", "currency_in_use" => "定期購入で使用中の通貨は削除できません", "currency_is_main" => "メイン通貨に設定中の通貨は削除できません", "failed_to_remove_currency" => "データベースから通貨を削除できませんでした", "failed_to_store_api_key" => "データベースにAPIキーを保存できませんでした", "invalid_api_key" => "無効なAPIキーです", "api_key_saved" => "APIキーの保存に成功", "currency_removed" => "通貨を削除", // Household "failed_add_household" => "世帯員の追加に失敗", "failed_edit_household" => "世帯員の編集に失敗", "failed_remove_household" => "世帯員の削除に失敗", "household_in_use" => "定期購入を使用中の世帯員は削除できません", "member_saved" => "世帯員を保存", "member_removed" => "世帯員を削除", // Notifications "error_saving_notifications" => "通知データの保存エラー", "wallos_notification" => "Wallosからの通知", "test_notification" => "これは通知テストです。これが見られるなら成功です。", "email_error" => "電子メールの送信エラー", "notification_sent_successfuly" => "通知の送信に成功しました", "notifications_settings_saved" => "通知設定の保存に成功", "notification_failed" => "通知の送信に失敗", // Payments "payment_in_use" => "使用中の支払い方法は削除できません", "failed_update_payment" => "データーベースの支払い方法の更新に失敗しました", "enabled" => "有効", "disabled" => "無効", // Subscription "error_fetching_image" => "画像の取得エラー", "subscription_updated_successfuly" => "定期購入の更新成功", "subscription_added_successfuly" => "定期購入の追加成功", "error_deleting_subscription" => "定期購入の削除エラー", "invalid_request_method" => "無効なリクエスト方法", // User "error_updating_user_data" => "ユーザデータの更新エラー", "user_details_saved" => "ユーザー詳細の保存", // Admin Page "registrations" => "登録", "enable_user_registrations" => "ユーザー登録を有効にする", "maximum_number_users" => "最大ユーザ数", "require_email_verification" => "メール確認を必要とする", "configure_smtp_settings_to_enable" => "SMTP設定を構成して有効にする", "server_url" => "サーバーURL", "server_url_info" => "電子メール認証とパスワード回復に使用される。有効な公開URLでなければなりません。", "server_url_password_reset" => "設定すると、パスワードリセット機能も有効になる。", "disable_login" => "ログインを無効にする", "disable_login_info" => "ログインをバイパスします。サーバーをローカルネットワークのみで運用し、外部からのアクセスがない場合、ログインを無効にすることができます。これにより、管理者ユーザが自動的にログインします。", "disable_login_info2" => "この設定を有効にできるのは、ユーザー登録がオフで、管理者以上のユーザーアカウントが存在しない場合のみです。", "max_users_info" => "0に設定すると無制限になります", "user_management" => "ユーザー管理", "delete_user" => "ユーザーを削除", "delete_user_info" => "ユーザーを削除すると、そのユーザーのサブスクリプションと設定もすべて削除されます。", "create_user" => "ユーザーを作成", "oidc_settings" => "OIDC設定", "oidc_auth_enabled" => "OIDC認証を有効にする", "create_user_automatically" => "OIDCユーザーを自動的に作成する", "disable_password_login" => "パスワードログインを無効にする", "smtp_settings" => "SMTP設定", "smtp_usage_info" => "パスワードの回復やその他のシステム電子メールに使用されます。", "security_settings" => "セキュリティ設定", "ssrf_protection_info" => "Server-Side Request Forgery(SSRF)攻撃を防ぐために、Wallosはデフォルトでプライベートまたは内部ネットワークアドレスへのWebhook通知をブロックします。", "local_webhook_info" => "Home Assistant、Gotify、Node-REDなどのローカルサービスにWebhookを送信する必要がある場合は、それらのIPアドレスまたはホスト名を上記にカンマ区切りで入力してください(例:192.168.1.100,192.168.1.101)。", "maintenance_tasks" => "メンテナンスタスク", "orphaned_logos" => "孤立したロゴ", "update" => "更新", "new_version_available" => "新しいバージョンのWallosが利用可能です", "current_version" => "現在のバージョン", "latest_version" => "最新バージョン", "on_current_version" => "最新バージョンのWallosを使用しています。", "show_update_notification" => "ダッシュボードに更新通知を表示する", "cronjobs" => "クロンジョブズ", // Email Verification "email_verified" => "メールアドレスが確認されました", "email_verification_failed" => "メールアドレスの確認に失敗しました", // Calendar "calendar" => "カレンダー", "sun" => "日", "mon" => "月", "tue" => "火", "wed" => "水", "thu" => "木", "fri" => "金", "sat" => "土", "month-01" => "1月", "month-02" => "2月", "month-03" => "3月", "month-04" => "4月", "month-05" => "5月", "month-06" => "6月", "month-07" => "7月", "month-08" => "8月", "month-09" => "9月", "month-10" => "10月", "month-11" => "11月", "month-12" => "12月", "total_cost" => "合計費用", "export_icalendar" => "iCalendarをエクスポート", "over_budget_warning" => "予算オーバーだ", // TOTP Page "insert_totp_code" => "TOTPコードを入力してください", ]; ?> ================================================ FILE: includes/i18n/ko.php ================================================ "로그인 하기 전에 회원가입을 진행해야 합니다.", "username" => "유저명", "password" => "비밀번호", "email" => "이메일", "firstname" => "이름", "lastname" => "성", "confirm_password" => "비밀번호 확인", "main_currency" => "기본 통화", "language" => "언어", "passwords_dont_match" => "비밀번호가 일치하지 않습니다.", "username_exists" => "이미 존재하는 유저명입니다.", "email_exists" => "이미 존재하는 이메일입니다.", "registration_failed" => "회원가입 실패. 다시 시도해 주세요.", "register" => "회원가입", "restore_database" => "데이터베이스 복구", // Login Page "please_login" => "로그인 해 주세요.", "stay_logged_in" => "로그인 유지 (30일)", "login" => "로그인", "login_with" => "다음으로 로그인", "or" => "또는", "login_failed" => "로그인 정보가 부정확합니다.", "registration_successful" => "등록 성공", "user_email_waiting_verification" => "이메일을 인증해야 합니다. 이메일을 확인해 주세요.", // Password Reset Page "forgot_password" => "비밀번호를 잊으셨나요?", "reset_password" => "비밀번호 재설정", "reset_sent_check_email" => "비밀번호 재설정 이메일이 전송되었습니다. 이메일을 확인해 주세요.", "password_reset_successful" => "비밀번호 재설정 성공", // Header "profile" => "프로필", "dashboard" => "대시보드", "subscriptions" => "구독", "stats" => "통계", "settings" => "설정", "admin" => "관리자", "about" => "정보", "logout" => "로그아웃", // Dashboard "hello" => "안녕하세요", "upcoming_payments" => "예정된 결제", "no_upcoming_payments" => "예정된 결제가 없습니다.", "overdue_renewals" => "연체 갱신", "ai_recommendations" => "AI 추천", "your_budget" => "당신의 예산", "budget" => "예산", "budget_used" => "예산 사용", "over_budget" => "예산 초과", "your_subscriptions" => "당신의 구독", "your_savings" => "당신의 저축", // Subscriptions page "subscription" => "구독", "no_subscriptions_yet" => "아직 구독을 등록하지 않았습니다.", "add_first_subscription" => "첫번째 구독을 추가하세요.", "new_subscription" => "새 구독", "search" => "검색", "state" => "상태", "alphanumeric" => "알파벳순", "sort" => "정렬", "name" => "이름", "last_added" => "최근 등록", "price" => "가격", "next_payment" => "다음 결제일", "renewal_type" => "갱신 방법", "auto_renewal" => "자동 갱신", "automatically_renews" => "자동 갱신", "manual_renewal" => "수동 갱신", "start_date" => "시작일", "inactive" => "구독 비활성화", "replaced_with" => "다음 구독으로 대체됨", "none" => "없음", "member" => "구성원", "category" => "카테고리", "payment_method" => "지불 수단", "Daily" => "일간 결제", "Weekly" => "주간 결제", "Monthly" => "월간 결제", "Yearly" => "연간 결제", "daily" => "일", "weekly" => "주", "monthly" => "월", "yearly" => "년", "days" => "매일", "weeks" => "매주", "months" => "매월", "years" => "매년", "external_url" => "외부 URL 방문", "empty_page" => "빈 페이지", "clear_filters" => "필터 제거", "no_matching_subscriptions" => "해당하는 구독이 없습니다.", "clone" => "복제", "renew" => "갱신", "calculate_next_payment_date" => "다음 결제일 계산", // Subscription form "add_subscription" => "구독 추가", "edit_subscription" => "구독 편집", "subscription_name" => "구독 이름", "logo_preview" => "로고 미리보기", "search_logo" => "웹에서 로고 검색하기", "web_search" => "웹 검색", "currency" => "통화", "payment_every" => "지불 빈도", "frequency" => "빈도", "cycle" => "주기", "no_category" => "카테고리 없음", "paid_by" => "결제하는 사람", "url" => "URL", "notes" => "메모", "enable_notifications" => "이 구독에 대한 알림을 활성화합니다.", "default_value_from_settings" => "설정에서 기본값 사용", "cancellation_notification" => "구독 취소 알림", "delete" => "삭제", "cancel" => "취소", "upload_logo" => "로고 업로드", // Statistics page "cant_convert_currency" => "구독에서 여러 통화를 사용하고 있습니다. 유효하고 정확한 통계를 얻으려면 설정 페이지에서 Fixer API 키를 설정하세요.", "general_statistics" => "일반 통계", "active_subscriptions" => "활성 구독", "inactive_subscriptions" => "비활성 구독", "monthly_cost" => "월간 지출", "yearly_cost" => "연간 지출", "average_monthly" => "월별 평균 구독 비용", "most_expensive" => "최고가 구독 비용", "amount_due" => "이달의 결제 비용", "percentage_budget_used" => "예산 사용률", "budget_remaining" => "남은 예산", "amount_over_budget" => "예산 초과", "monthly_savings" => "월간 절약 (비활성 구독)", "yearly_savings" => "연간 절약 (비활성 구독)", "split_views" => "분할 표시", "category_split" => "카테고리별", "household_split" => "가구별", "payment_method_split" => "지불방법별", "total_cost_trend" => "총 비용 추이", "cost_vs_budget" => "비용 vs 예산", // About page "about_and_credits" => "개요 및 크레딧", "credits" => "크레딧", "license" => "라이선스", "release_notes" => "릴리즈 노트", "update_available" => "업데이트 가능", "issues_and_requests" => "이슈 및 요청", "the_author" => "제작자", "icons" => "아이콘", "payment_icons" => "지불 방식 아이콘", // Profile page "upload_avatar" => "아바타 업로드", "file_type_error" => "제공된 파일이 지원하지 않는 타입입니다.", "user_details" => "유저 상세", "two_factor_authentication" => "이중 인증", "two_factor_info" => "2단계 인증은 계정에 보안을 한층 더 강화합니다. QR 코드를 스캔하려면 Google Authenticator, Authy 또는 Ente Auth와 같은 인증 앱이 필요합니다.", "two_factor_enabled_info" => "계정은 2단계 인증으로 안전하게 보호됩니다. 위의 버튼을 클릭하여 비활성화할 수 있습니다.", "enable_two_factor_authentication" => "2단계 인증 활성화", "2fa_already_enabled" => "2단계 인증이 이미 활성화되어 있습니다.", "totp_code_incorrect" => "TOTP 코드가 올바르지 않습니다.", "backup_codes" => "백업 코드", "download_backup_codes" => "백업 코드 다운로드", "copy_to_clipboard" => "클립보드로 복사", "totp_backup_codes_info" => "이 코드는 계정에 대한 백업 코드입니다. 이 코드를 안전한 곳에 보관하세요. 이 코드는 한 번만 사용할 수 있습니다.", "disable_two_factor_authentication" => "2단계 인증 비활성화", "totp_code" => "TOTP 코드", "api_key" => "API 키", "regenerate" => "재생성", "api_key_info" => "API 키는 외부 애플리케이션과 통신할 때 사용됩니다. API 키를 재생성하면 이전 키는 더 이상 유효하지 않습니다.", // Settings page "monthly_budget" => "월간 예산", "budget_info" => "예산을 설정하면 통계 페이지에서 예산과 실제 지출을 비교할 수 있습니다.", "household" => "가구", "save_member" => "구성원 저장", "delete_member" => "구성원 삭제", "cant_delete_member" => "메인 구성원은 삭제할 수 없습니다", "cant_delete_member_in_use" => "구독에서 사용 중인 구성원은 삭제할 수 없습니다", "household_info" => "이메일 항목은 가구 구성원이 구독 만료에 대한 안내를 받기 위해 필요합니다.", "notifications" => "알림", "enable_email_notifications" => "이메일 알림 활성화", "notify_me" => "알림 받기", "day_before" => "일 전", "on_due_date" => "만기일", "days_before" => "일 전", "smtp_address" => "SMTP 주소", "port" => "포트", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "SMTP 유저명", "smtp_password" => "SMTP 비밀번호", "from_email" => "발송 주소 (선택사항)", "send_to_other_emails" => "알림을 다음 이메일 주소로도 보내기 (구분자는 ; 사용):", "smtp_info" => "SMTP 비밀번호는 평문으로 저장되고 발송됩니다. 보안을 위해, 이 서비스를 위해서만 사용하는 계정을 생성해 주세요.", "telegram" => "텔레그램", "telegram_bot_token" => "텔레그램 봇 토큰", "telegram_chat_id" => "텔레그램 채팅 ID", "pushplus" => "Pushplus", "pushplus_token" => "Pushplus 토큰", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost 웹훅 URL", "mattermost_bot_username" => "Mattermost 봇 유저명", "mattermost_bot_icon_emoji" => "Mattermost 봇 아이콘 이모지", "webhook" => "웹훅", "webhook_url" => "웹훅 URL", "request_method" => "요청 메서드", "custom_headers" => "커스텀 헤더", "webhook_payload" => "웹훅 페이로드", "payment_notifications_payload" => "결제 알림 페이로드", "cancelation_notification_payload" => "구독 취소 알림 페이로드", "variables_available" => "사용 가능한 변수", "gotify" => "Gotify", "token" => "토큰", "discord" => "디스코드", "discord_bot_username" => "디스코드 봇 유저명", "discord_bot_avatar_url" => "디스코드 봇 아바타 URL", "pushover" => "Pushover", "pushover_user_key" => "Pushover User Key", "host" => "호스트", "topic" => "토픽", "ignore_ssl_errors" => "SSL 에러 무시", "categories" => "카테고리", "save_category" => "카테고리 저장", "delete_category" => "카테고리 삭제", "cant_delete_category_in_use" => "구독에서 사용 중인 카테고리는 삭제할 수 없습니다.", "currencies" => "통화", "save_currency" => "통화 저장", "delete_currency" => "통화 삭제", "cant_delete_main_currency" => "기본 통화는 삭제할 수 없습니다.", "cant_delete_currency_in_use" => "구독에서 사용 중인 통화는 삭제할 수 없습니다.", "exchange_update" => "환율 최종 갱신일", "currency_info" => "지원하는 통화와 정확한 통화 코드 찾기", "currency_performance" => "성능을 향상시키기 위해서는 사용할 통화들만 유지하세요.", "fixer_api_key" => "Fixer API 키", "provider" => "제공자", "fixer_info" => "여러 통화를 사용하고, 정확한 통계와 구독별 정렬을 원하시느 경우에는, Fixer에서 발급받은 무료 API 키가 필요합니다.", "get_key" => "키 얻기", "get_free_fixer_api_key" => "무료 Fixer API 키 얻기", "get_key_alternative" => "또는 다음 사이트에서 무료 Fixer api 키를 얻을 수 있습니다.", "ai_model" => "AI 모델", "select_ai_model" => "AI 모델 선택", "run_schedule" => "일정 실행", "manually" => "수동으로", "coming_soon" => "곧 출시 예정", "invalid_host" => "유효하지 않은 호스트", "ai_recommendations_info" => "AI 추천은 사용자의 구독과 가구 구성원을 기반으로 생성됩니다.", "may_take_time" => "제공자, 모델, 구독 수에 따라 추천 생성에 시간이 걸릴 수 있습니다.", "recommendations_visible_on_dashboard" => "추천은 대시보드에서 확인할 수 있습니다.", "generate_recommendations" => "추천 생성", "display_settings" => "디스플레이 설정", "theme_settings" => "테마 설정", "colors" => "색상", "custom_colors" => "커스텀 색상", "theme" => "테마", "dark_theme" => "다크 테마", "light_theme" => "라이트 테마", "automatic" => "자동", "main_color" => "메인 색상", "accent_color" => "강조 색상", "hover_color" => "마우스 호버 색상", "save_custom_colors" => "커스텀 색상 저장", "reset_custom_colors" => "커스텀 색상 리셋", "custom_css" => "커스텀 CSS", "save_custom_css" => "커스텀 CSS 저장", "calculate_monthly_price" => "모든 구독에 대한 월별 요금을 계산하고 표시", "convert_prices" => "항상 기본 통화로 가격을 환산하고 표시 (느림)", "show_original_price" => "변환이나 계산이 이루어질 때 원래 가격도 표시", "experience" => "경험", "show_subscription_progress" => "구독 진행률 표시", "disabled_subscriptions" => "비활성화된 구독", "hide_disabled_subscriptions" => "비활성화된 구독 숨기기", "show_disabled_subscriptions_at_the_bottom" => "비활성화된 구독을 하단에 표시", "experimental_settings" => "실험적 설정", "remove_background" => "로고 이미지 검색에서 배경 삭제", "use_mobile_navigation_bar" => "모바일 네비게이션 바 사용", "experimental_info" => "실험적 설정은 제대로 작동하지 않을 수 있습니다.", "payment_methods" => "결제 수단", "payment_methods_info" => "결제 수단을 클릭하여 활성화/비활성화 할 수 있습니다.", "rename_payment_methods_info" => "결제 수단의 이름을 클릭해 이름을 변경합니다.", "cant_delete_payment_method_in_use" => "사용중인 결제 수단을 비활성화 할 수 없습니다", "add_custom_payment" => "커스텀 결제 수단 추가", "payment_method_name" => "결제 수단 이름", "payment_method_added_successfuly" => "결제 수단이 성공적으로 추가되었습니다", "payment_method_removed" => "결제 수단이 제거되었습니다.", "disable" => "비활성화", "enable" => "활성화", "rename_payment_method" => "결제 수단 이름 변경", "payment_renamed" => "결제 수단 이름이 변경되었습니다.", "payment_not_renamed" => "결제 수단 이름이 변경되지 않았습니다.", "test" => "테스트", "add" => "추가", "save" => "저장", "reset" => "리셋", "main_accent_color_error" => "메인 색상과 강조 색상은 같을 수 없습니다.", "backup_and_restore" => "백업 및 복구", "backup" => "백업", "restore" => "복구", "restore_info" => "데이터베이스를 복구하면 현재 데이터를 모두 덮어씁니다. 복구 후 로그아웃됩니다.", "account" => "계정", "export_subscriptions" => "구독 내보내기", "export_as_json" => "JSON으로 내보내기", "export_as_csv" => "CSV로 내보내기", "danger_zone" => "위험 구역", "delete_account" => "계정 삭제", "delete_account_info" => "계정을 삭제하면 모든 구독과 설정도 함께 삭제됩니다.", // Filters menu "filter" => "필터", "clear" => "초기화", // Toast "success" => "성공", // Endpoint responses "session_expired" => "세션이 만료되었습니다. 다시 로그인 해 주세요", "fields_missing" => "일부 항목이 누락되었습니다", "fill_all_fields" => "모든 항목을 채워 주세요", "fill_mandatory_fields" => "모든 필수 항목을 채워 주세요", "error" => "에러", // Category "failed_add_category" => "통화를 추가하는 데 실패했습니다", "failed_edit_category" => "카테고리를 수정하는데 실패했습니다", "category_in_use" => "카테고리가 구독에서 사용중이므로 제거할 수 없습니다", "failed_remove_category" => "카테고리를 제거하는데 실패했습니다", "category_saved" => "카테고리가 저장되었습니다", "category_removed" => "카테고리가 삭제되었습니다", "sort_order_saved" => "정렬 순서가 저장되었습니다", // Currency "currency_saved" => "통화 저장", "error_adding_currency" => "통화 항목 추가 오류.", "failed_to_store_currency" => "데이터베이스에 통화를 저장할 수 없습니다.", "currency_in_use" => "구독에 사용 중인 통화는 삭제할 수 없습니다.", "currency_is_main" => "기본 통화로 설정된 통화는 삭제할 수 없습니다.", "failed_to_remove_currency" => "데이터베이스에서 통화를 삭제할 수 없습니다.", "failed_to_store_api_key" => "데이터베이스에 API 키를 저장할 수 없습니다.", "invalid_api_key" => "유효하지 않은 API 키.", "api_key_saved" => "API 성공적으로 저장했습니다", "currency_removed" => "통화 삭제", // Household "failed_add_household" => "가구 구성원을 추가하는데 실패했습니다", "failed_edit_household" => "가구 구성원을 수정하는데 실패했습니다", "failed_remove_household" => "가구 구성원을 삭제하는데 실패했습니다", "household_in_use" => "구독에서 사용 중인 가구 구성원은 삭제할 수 없습니다", "member_saved" => "구성원 저장", "member_removed" => "구성원 삭제", // Notifications "error_saving_notifications" => "알림 데이터 저장 오류.", "wallos_notification" => "Wallos 알림", "test_notification" => "이 메세지는 테스트 알림입니다. 이 메세지를 보고 계시다면, 올바르게 설정된 상태입니다.", "email_error" => "이메일 전송 오류", "notification_sent_successfuly" => "알림 전송에 성공했습니다", "notifications_settings_saved" => "알림 설정 저장 성공.", "notification_failed" => "알림 전송 실패", // Payments "payment_in_use" => "사용 중인 결제 수단은 비활성화 할 수 없습니다", "failed_update_payment" => "결제 수단을 데이터베이스에 업데이트 하지 못 했습니다", "enabled" => "활성", "disabled" => "비활성", // Subscription "error_fetching_image" => "이미지 가져오기 오류", "subscription_updated_successfuly" => "구독이 성공적으로 수정되었습니다", "subscription_added_successfuly" => "구독이 성공적으로 추가되었습니다", "error_deleting_subscription" => "구독 삭제 에러.", "invalid_request_method" => "잘못된 요청 메서드.", // User "error_updating_user_data" => "유저 데이터 갱신 실패.", "user_details_saved" => "유저 세부정보 저장 성공", // Admin Page "registrations" => "회원가입", "enable_user_registrations" => "유저 회원가입 활성화", "maximum_number_users" => "최대 유저 수", "require_email_verification" => "이메일 인증 필요", "configure_smtp_settings_to_enable" => "SMTP 설정을 구성하여 이메일 인증을 활성화합니다.", "server_url" => "서버 URL", "server_url_info" => "이메일 인증 및 비밀번호 복구에 사용됩니다. 유효한 공개 URL이어야 합니다.", "server_url_password_reset" => "설정하면 비밀번호 재설정 기능도 활성화됩니다.", "disable_login" => "로그인 비활성화", "disable_login_info" => "로그인 우회. 외부 액세스 없이 로컬 네트워크에서만 서버를 실행하는 경우 로그인을 비활성화할 수 있습니다. 그러면 관리자 사용자가 자동으로 로그인됩니다.", "disable_login_info2" => "이 설정은 사용자 등록이 해제되어 있고 관리자 사용자 계정이 없는 경우에만 활성화할 수 있습니다.", "max_users_info" => "0으로 설정하면 무제한으로 설정됩니다.", "user_management" => "유저 관리", "delete_user" => "유저 삭제", "delete_user_info" => "사용자를 삭제하면 모든 구독 및 설정도 삭제됩니다.", "create_user" => "유저 생성", "oidc_settings" => "OIDC 설정", "oidc_auth_enabled" => "OIDC 인증 활성화", "create_user_automatically" => "사용자 자동 생성", "disable_password_login" => "비밀번호 로그인 비활성화", "smtp_settings" => "SMTP 설정", "smtp_usage_info" => "비밀번호 복구 및 기타 시스템 이메일에 사용됩니다.", "security_settings" => "보안 설정", "ssrf_protection_info" => "서버 측 요청 위조(SSRF) 공격을 방지하기 위해 Wallos는 기본적으로 개인 또는 내부 네트워크 주소로의 webhook 알림을 차단합니다.", "local_webhook_info" => "Home Assistant, Gotify 또는 Node-RED와 같은 로컬 서비스로 webhook을 보내야 하는 경우 해당 IP 주소 또는 호스트 이름을 위에 쉼표로 구분된 목록으로 입력하세요(예: 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "유지보수 작업", "orphaned_logos" => "고아 로고", "update" => "업데이트", "new_version_available" => "새로운 버전의 Wallos가 이용 가능합니다", "current_version" => "현재 버전", "latest_version" => "최신 버전", "on_current_version" => "최신 버전의 Wallos를 사용 중입니다.", "show_update_notification" => "대시보드에 업데이트 알림 표시", "cronjobs" => "크론잡", // Email Verification "email_verified" => "이메일 인증 완료", "email_verification_failed" => "이메일 인증 실패", // Calendar "calendar" => "달력", "sun" => "일", "mon" => "월", "tue" => "화", "wed" => "수", "thu" => "목", "fri" => "금", "sat" => "토", "month-01" => "1월", "month-02" => "2월", "month-03" => "3월", "month-04" => "4월", "month-05" => "5월", "month-06" => "6월", "month-07" => "7월", "month-08" => "8월", "month-09" => "9월", "month-10" => "10월", "month-11" => "11월", "month-12" => "12월", "total_cost" => "총 비용", "export_icalendar" => "iCalendar 내보내기", "over_budget_warning" => "예산이 초과되었습니다", // TOTP Page "insert_totp_code" => "2단계 인증 코드를 입력하세요", ]; ?> ================================================ FILE: includes/i18n/languages.php ================================================ Language Name $languages = [ // English first "en" => ["name" => "English", "dir" => "ltr"], // Remaining sorted alphabetically by language code "ca" => ["name" => "Català", "dir" => "ltr"], "cs" => ["name" => "Čeština", "dir" => "ltr"], "da" => ["name" => "Dansk", "dir" => "ltr"], "de" => ["name" => "Deutsch", "dir" => "ltr"], "el" => ["name" => "Ελληνικά", "dir" => "ltr"], "es" => ["name" => "Español", "dir" => "ltr"], "fr" => ["name" => "Français", "dir" => "ltr"], "id" => ["name" => "bahasa indonesia", "dir" => "ltr"], "it" => ["name" => "Italiano", "dir" => "ltr"], "jp" => ["name" => "日本語", "dir" => "ltr"], "ko" => ["name" => "한국어", "dir" => "ltr"], "nl" => ["name" => "Nederlands", "dir" => "ltr"], "pl" => ["name" => "Polski", "dir" => "ltr"], "pt" => ["name" => "Português", "dir" => "ltr"], "pt_br" => ["name" => "Português Brasileiro", "dir" => "ltr"], "ro" => ["name" => "Română", "dir" => "ltr"], "ru" => ["name" => "Русский", "dir" => "ltr"], "sl" => ["name" => "Slovenščina", "dir" => "ltr"], "sr_lat" => ["name" => "Srpski", "dir" => "ltr"], "sr" => ["name" => "Српски", "dir" => "ltr"], "tr" => ["name" => "Türkçe", "dir" => "ltr"], "uk" => ["name" => "Українська", "dir" => "ltr"], "vi" => ["name" => "Tiếng Việt", "dir" => "ltr"], "zh_cn" => ["name" => "简体中文", "dir" => "ltr"], "zh_tw" => ["name" => "繁體中文", "dir" => "ltr"], ]; ================================================ FILE: includes/i18n/nl.php ================================================ "Maak een account aan om te kunnen inloggen", "username" => "Gebruikersnaam", "password" => "Wachtwoord", "email" => "E-mail", "firstname" => "Voornaam", "lastname" => "Achternaam", "confirm_password" => "Bevestig wachtwoord", "main_currency" => "Basisvaluta", "language" => "Taal", "passwords_dont_match" => "Wachtwoorden komen niet overeen", "username_exists" => "Gebruikersnaam bestaat al", "email_exists" => "E-mailadres bestaat al", "registration_failed" => "Registratie mislukt, probeer het opnieuw", "register" => "Registreren", "restore_database" => "Database herstellen", // Login Page "please_login" => "Login", "stay_logged_in" => "Ingelogd blijven (30 dagen)", "login" => "Inloggen", "login_with" => "Inloggen met", "or" => "of", "login_failed" => "Inloggegevens zijn onjuist", "registration_successful" => "Registratie succesvol", "user_email_waiting_verification" => "Je e-mail moet worden geverifieerd. Controleer het e-mail bericht.", // Password Reset Page "forgot_password" => "Wachtwoord vergeten", "reset_password" => "Wachtwoord resetten", "reset_sent_check_email" => "Reset e-mail verzonden. Controleer het e-mail bericht.", "password_reset_successful" => "Wachtwoord reset succesvol", // Header "profile" => "Profiel", "dashboard" => "Dashboard", "subscriptions" => "Abonnementen", "stats" => "Statistieken", "settings" => "Instellingen", "admin" => "Beheer", "about" => "Over", "logout" => "Uitloggen", // Dashboard "hello" => "Hallo", "upcoming_payments" => "Aankomende Betalingen", "no_upcoming_payments" => "Je hebt geen aankomende betalingen", "overdue_renewals" => "Verlopen Verlengen", "ai_recommendations" => "AI Aanbevelingen", "your_budget" => "Je Budget", "budget" => "Budget", "budget_used" => "Budget Gebruikt", "over_budget" => "Over Budget", "your_subscriptions" => "Je Abonnementen", "your_savings" => "Je Besparingen", // Subscriptions page "subscription" => "Abonnement", "no_subscriptions_yet" => "Je hebt nog geen abonnementen", "add_first_subscription" => "Voeg eerste abonnement toe", "new_subscription" => "Nieuw Abonnement", "search" => "Zoeken", "state" => "Status", "alphanumeric" => "Alfanumeriek", "sort" => "Sorteren", "name" => "Naam", "last_added" => "Laatst toegevoegd", "price" => "Prijs", "next_payment" => "Volgende betaling", "renewal_type" => "Verlengingstype", "auto_renewal" => "Automatische verlenging", "automatically_renews" => "Verlengt automatisch", "manual_renewal" => "Handmatige verlenging", "start_date" => "Startdatum", "inactive" => "Abonnement uitschakelen", "replaced_with" => "Vervangen door", "none" => "Geen", "member" => "Lid", "category" => "Categorie", "payment_method" => "Betaalmethode", "Daily" => "Dagelijks", "Weekly" => "Wekelijks", "Monthly" => "Maandelijks", "Yearly" => "Jaarlijks", "daily" => "Dagen", "weekly" => "Weken", "monthly" => "Maanden", "yearly" => "Jaren", "days" => "dagen", "weeks" => "weken", "months" => "maanden", "years" => "jaren", "external_url" => "Externe URL bezoeken", "empty_page" => "Lege pagina", "clear_filters" => "Filters wissen", "no_matching_subscriptions" => "Geen overeenkomende abonnementen", "clone" => "Klonen", "renew" => "Verlengen", "calculate_next_payment_date" => "Bereken volgende betalingsdatum", // Subscription form "add_subscription" => "Abonnement toevoegen", "edit_subscription" => "Abonnement bewerken", "subscription_name" => "Abonnementsnaam", "logo_preview" => "Logo voorbeeld", "search_logo" => "Zoek logo op het web", "web_search" => "Web zoeken", "currency" => "Valuta", "payment_every" => "Betaling elke", "frequency" => "Frequentie", "cycle" => "Cyclus", "no_category" => "Geen categorie", "paid_by" => "Betaald door", "url" => "URL", "notes" => "Notities", "enable_notifications" => "Notificaties inschakelen voor dit abonnement", "default_value_from_settings" => "Standaardwaarde uit instellingen", "cancellation_notification" => "Opzegnotificatie", "delete" => "Verwijderen", "cancel" => "Annuleren", "upload_logo" => "Logo uploaden", // Statistics page "cant_convert_currency" => "Je gebruikt meerdere valuta voor je abonnementen. Voor geldige en nauwkeurige statistieken, stel een Fixer API-sleutel in op de instellingenpagina.", "general_statistics" => "Algemene statistieken", "active_subscriptions" => "Actieve abonnementen", "inactive_subscriptions" => "Inactieve abonnementen", "monthly_cost" => "Maandelijkse kosten", "yearly_cost" => "Jaarlijkse kosten", "average_monthly" => "Gemiddelde maandelijkse abonnementskosten", "most_expensive" => "Duurste abonnementskosten", "amount_due" => "Verschuldigd bedrag deze maand", "percentage_budget_used" => "Percentage van budget gebruikt", "budget_remaining" => "Resterend budget", "amount_over_budget" => "Bedrag over budget", "monthly_savings" => "Maandelijkse besparingen (op inactieve abonnementen)", "yearly_savings" => "Jaarlijkse besparingen (op inactieve abonnementen)", "split_views" => "Verdelingen", "category_split" => "Categorieverdeling", "household_split" => "Huishoudenverdeling", "payment_method_split" => "Betaalmethodeverdeling", "total_cost_trend" => "Totale kosten trend", "cost_vs_budget" => "Kosten vs Budget", // About page "about_and_credits" => "Over en credits", "credits" => "Credits", "license" => "Licentie", "release_notes" => "Release notes", "update_available" => "Update beschikbaar", "issues_and_requests" => "Problemen en verzoeken", "the_author" => "De auteur", "icons" => "Iconen", "payment_icons" => "Betaaliconen", // Profielpagina "upload_avatar" => "Avatar uploaden", "file_type_error" => "Het bestandstype wordt niet ondersteund.", "user_details" => "Gebruikersgegevens", "two_factor_authentication" => "Twee-factor-authenticatie", "two_factor_info" => "Twee-factor-authenticatie voegt een extra beveiligingslaag toe aan je account.
Je hebt een authenticatie-app nodig zoals Google Authenticator, Authy of Ente Auth om de QR-code te scannen.", "two_factor_enabled_info" => "Je account is beveiligd met twee-factor-authenticatie. Je kunt dit uitschakelen door op de knop hierboven te klikken.", "enable_two_factor_authentication" => "Twee-factor-authenticatie inschakelen", "2fa_already_enabled" => "Twee-factor-authenticatie is al ingeschakeld", "totp_code_incorrect" => "TOTP-code is onjuist", "backup_codes" => "Backup codes", "download_backup_codes" => "Backup codes downloaden", "copy_to_clipboard" => "Kopiëren naar klembord", "totp_backup_codes_info" => "Deze codes kunnen worden gebruikt om in te loggen als je geen toegang meer hebt tot je authenticatie-app.", "disable_two_factor_authentication" => "Twee-factor-authenticatie uitschakelen", "totp_code" => "TOTP-code", "api_key" => "API-sleutel", "regenerate" => "Opnieuw genereren", "api_key_info" => "De API-sleutel wordt gebruikt om toegang te krijgen tot de API. Houd deze geheim.", // Instellingen pagina "monthly_budget" => "Maandelijks budget", "budget_info" => "Maandelijks budget wordt gebruikt om statistieken te berekenen", "household" => "Huishouden", "save_member" => "Lid opslaan", "delete_member" => "Lid verwijderen", "cant_delete_member" => "Kan hoofdlid niet verwijderen", "cant_delete_member_in_use" => "Kan lid dat in gebruik is bij abonnement niet verwijderen", "household_info" => "E-mailveld maakt het mogelijk om huishoudleden te informeren over abonnementen die bijna verlopen.", "notifications" => "Notificaties", "enable_email_notifications" => "E-mail notificaties inschakelen", "notify_me" => "Informeer mij", "day_before" => "dag voor", "on_due_date" => "Op vervaldatum", "days_before" => "dagen voor", "smtp_address" => "SMTP-adres", "port" => "Poort", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "SMTP-gebruikersnaam", "smtp_password" => "SMTP-wachtwoord", "from_email" => "Afzender e-mail (Optioneel)", "send_to_other_emails" => "Stuur notificaties ook naar de volgende e-mailadressen (gebruik ; om ze te scheiden):", "other_emails_placeholder" => "gebruiker@domein.nl;test@gebruiker.nl", "smtp_info" => "SMTP-wachtwoord wordt verzonden en opgeslagen in platte tekst. Maak voor de veiligheid een apart account hiervoor aan.", "telegram" => "Telegram", "telegram_bot_token" => "Telegram Bot Token", "telegram_chat_id" => "Telegram Chat ID", "pushplus" => "Pushplus", "pushplus_token" => "Pushplus Token", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost Webhook URL", "mattermost_bot_username" => "Mattermost Bot Gebruikersnaam", "mattermost_bot_icon_emoji" => "Mattermost Bot Icon Emoji", "webhook" => "Webhook", "webhook_url" => "Webhook URL", "request_method" => "Request Methode", "custom_headers" => "Aangepaste Headers", "webhook_payload" => "Webhook Payload", "payment_notifications_payload" => "Betalingsnotificatie Payload", "cancelation_notification_payload" => "Opzegnotificatie Payload", "variables_available" => "Beschikbare variabelen", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Discord Bot Gebruikersnaam", "discord_bot_avatar_url" => "Discord Bot Avatar URL", "pushover" => "Pushover", "pushover_user_key" => "Pushover Gebruikerssleutel", "host" => "Host", "topic" => "Onderwerp", "ignore_ssl_errors" => "SSL-fouten negeren", "categories" => "Categorieën", "save_category" => "Categorie opslaan", "delete_category" => "Categorie verwijderen", "cant_delete_category_in_use" => "Kan categorie in gebruik bij abonnement niet verwijderen", "currencies" => "Valuta", "save_currency" => "Valuta opslaan", "delete_currency" => "Valuta verwijderen", "cant_delete_main_currency" => "Kan basisvaluta niet verwijderen", "cant_delete_currency_in_use" => "Kan valuta in gebruik bij abonnement niet verwijderen", "exchange_update" => "Wisselkoersen laatst bijgewerkt op", "currency_info" => "Vind de ondersteunde valuta en juiste valutacodes op", "currency_performance" => "Voor betere prestaties, behoud alleen de valuta die je gebruikt.", "fixer_api_key" => "Fixer API-sleutel", "provider" => "Aanbieder", "fixer_info" => "Als je meerdere valuta gebruikt en nauwkeurige statistieken en sortering wilt, is een GRATIS API-sleutel van Fixer noodzakelijk.", "get_key" => "Haal je sleutel op bij", "get_free_fixer_api_key" => "Krijg gratis Fixer API-sleutel", "get_key_alternative" => "Als alternatief kun je een gratis Fixer API-sleutel krijgen van", "ai_model" => "AI-model", "select_ai_model" => "Selecteer AI-model", "run_schedule" => "Uitvoerschema", "manually" => "Handmatig", "coming_soon" => "Binnenkort beschikbaar", "invalid_host" => "Ongeldige host", "ai_recommendations_info" => "AI-aanbevelingen worden gegenereerd op basis van je abonnementen en huishoudleden.", "may_take_time" => "Afhankelijk van de aanbieder, het model en het aantal abonnementen kan het genereren van aanbevelingen enige tijd duren.", "recommendations_visible_on_dashboard" => "Aanbevelingen zijn zichtbaar op het dashboard.", "generate_recommendations" => "Genereer aanbevelingen", "display_settings" => "Weergave-instellingen", "theme_settings" => "Thema-instellingen", "colors" => "Kleuren", "custom_colors" => "Aangepaste kleuren", "theme" => "Thema", "dark_theme" => "Donker thema", "light_theme" => "Licht thema", "automatic" => "Automatisch", "main_color" => "Hoofdkleur", "accent_color" => "Accentkleur", "hover_color" => "Hover-kleur", "save_custom_colors" => "Aangepaste kleuren opslaan", "reset_custom_colors" => "Aangepaste kleuren resetten", "custom_css" => "Aangepaste CSS", "save_custom_css" => "Aangepaste CSS opslaan", "calculate_monthly_price" => "Bereken en toon maandelijkse prijs voor alle abonnementen", "convert_prices" => "Converteer en toon prijzen altijd in mijn basisvaluta (langzamer)", "show_original_price" => "Toon ook originele prijs bij conversies of berekeningen", "experience" => "Ervaring", "show_subscription_progress" => "Toon abonnementsvoortgang", "disabled_subscriptions" => "Uitgeschakelde abonnementen", "hide_disabled_subscriptions" => "Verberg uitgeschakelde abonnementen", "show_disabled_subscriptions_at_the_bottom" => "Toon uitgeschakelde abonnementen onderaan", "experimental_settings" => "Experimentele instellingen", "remove_background" => "Probeer achtergrond van logo's uit afbeelding zoekresultaten te verwijderen", "use_mobile_navigation_bar" => "Gebruik mobiele navigatiebalk", "experimental_info" => "Experimentele instellingen werken waarschijnlijk niet perfect.", "payment_methods" => "Betaalmethoden", "payment_methods_info" => "Klik op een betaalmethode om deze uit/in te schakelen.", "rename_payment_methods_info" => "Klik op de naam van een betaalmethode om deze te hernoemen.", "cant_delete_payment_method_in_use" => "Kan gebruikte betaalmethode niet uitschakelen", "add_custom_payment" => "Aangepaste betaalmethode toevoegen", "payment_method_name" => "Naam betaalmethode", "payment_method_added_successfuly" => "Betaalmethode succesvol toegevoegd", "payment_method_removed" => "Betaalmethode verwijderd", "disable" => "Uitschakelen", "enable" => "Inschakelen", "rename_payment_method" => "Betaalmethode hernoemen", "payment_renamed" => "Betaalmethode hernoemd", "payment_not_renamed" => "Betaalmethode niet hernoemd", "test" => "Test", "add" => "Toevoegen", "save" => "Opslaan", "reset" => "Resetten", "main_accent_color_error" => "Hoofdkleur en accentkleur kunnen niet hetzelfde zijn", "backup_and_restore" => "Back-up en herstel", "backup" => "Back-up", "restore" => "Herstellen", "restore_info" => "Het herstellen van de database zal alle huidige gegevens overschrijven. Je wordt uitgelogd na het herstel.", "account" => "Account", "export_subscriptions" => "Exporteer abonnementen", "export_as_json" => "Exporteer als JSON", "export_as_csv" => "Exporteer als CSV", "danger_zone" => "Gevarenzone", "delete_account" => "Account verwijderen", "delete_account_info" => "Het verwijderen van je account zal ook al je abonnementen en instellingen verwijderen.", // Filters menu "filter" => "Filter", "clear" => "Wissen", // Toast "success" => "Geslaagd", // Endpoint responses "session_expired" => "Je sessie is verlopen. Log opnieuw in", "fields_missing" => "Sommige velden ontbreken", "fill_all_fields" => "Vul alle velden in", "fill_mandatory_fields" => "Vul alle verplichte velden in", "error" => "Fout", // Categorie "failed_add_category" => "Categorie toevoegen mislukt", "failed_edit_category" => "Categorie bewerken mislukt", "category_in_use" => "Categorie is in gebruik bij abonnementen en kan niet worden verwijderd", "failed_remove_category" => "Categorie verwijderen mislukt", "category_saved" => "Categorie opgeslagen", "category_removed" => "Categorie verwijderd", "sort_order_saved" => "Sorteervolgorde opgeslagen", // Valuta "currency_saved" => "is opgeslagen.", "error_adding_currency" => "Fout bij toevoegen van valuta.", "failed_to_store_currency" => "Opslaan van valuta in de database mislukt.", "currency_in_use" => "Valuta is in gebruik bij abonnementen en kan niet worden verwijderd.", "currency_is_main" => "Valuta is ingesteld als basisvaluta en kan niet worden verwijderd.", "failed_to_remove_currency" => "Verwijderen van valuta uit de database mislukt.", "failed_to_store_api_key" => "Opslaan van API-sleutel in de database mislukt.", "invalid_api_key" => "Ongeldige API-sleutel.", "api_key_saved" => "API-sleutel succesvol opgeslagen", "currency_removed" => "Valuta verwijderd", // Huishouden "failed_add_household" => "Huishoud lid toevoegen mislukt", "failed_edit_household" => "Huishoud lid bewerken mislukt", "failed_remove_household" => "Huishoud lid verwijderen mislukt", "household_in_use" => "Huishoud lid is in gebruik bij abonnementen en kan niet worden verwijderd", "member_saved" => "Lid opgeslagen", "member_removed" => "Lid verwijderd", // Notificaties "error_saving_notifications" => "Fout bij het opslaan van notificatiegegevens.", "wallos_notification" => "Wallos Notificatie", "test_notification" => "Dit is een testnotificatie. Als je dit ziet, is de configuratie correct.", "email_error" => "Fout bij het verzenden van e-mail", "notification_sent_successfuly" => "Notificatie succesvol verzonden", "notifications_settings_saved" => "Notificatie-instellingen succesvol opgeslagen.", "notification_failed" => "Notificatie mislukt", // Betalingen "payment_in_use" => "Kan gebruikte betaalmethode niet uitschakelen", "failed_update_payment" => "Betaalmethode bijwerken in database mislukt", "enabled" => "ingeschakeld", "disabled" => "uitgeschakeld", // Subscription "error_fetching_image" => "Fout bij ophalen afbeelding", "subscription_updated_successfuly" => "Abonnement succesvol bijgewerkt", "subscription_added_successfuly" => "Abonnement succesvol toegevoegd", "error_deleting_subscription" => "Fout bij verwijderen abonnement.", "invalid_request_method" => "Ongeldige aanvraagmethode.", // Gebruiker "error_updating_user_data" => "Fout bij bijwerken gebruikersgegevens.", "user_details_saved" => "Gebruikersgegevens opgeslagen", // Beheerderspagina "registrations" => "Registraties", "enable_user_registrations" => "Gebruikersregistraties inschakelen", "maximum_number_users" => "Maximaal aantal gebruikers", "require_email_verification" => "E-mailverificatie vereisen", "configure_smtp_settings_to_enable" => "Configureer SMTP-instellingen om in te schakelen", "server_url" => "Server URL", "server_url_info" => "Gebruikt voor e-mailverificatie en wachtwoordherstel. Moet een geldige openbare URL zijn.", "server_url_password_reset" => "Indien ingesteld zal ook wachtwoordherstel functionaliteit inschakelen.", "disable_login" => "Inloggen uitschakelen", "disable_login_info" => "Login overslaan. Als je server alleen in een lokaal netwerk draait, zonder externe toegang, kun je het inloggen uitschakelen. Dit zal automatisch de beheerder inloggen.", "disable_login_info2" => "Je kunt deze instelling alleen inschakelen als gebruikersregistratie is uitgeschakeld en er niet meer dan het beheerdersaccount is.", "max_users_info" => "0 betekent onbeperkt", "user_management" => "Gebruikersbeheer", "delete_user" => "Gebruiker verwijderen", "delete_user_info" => "Het verwijderen van een gebruiker zal ook al hun abonnementen en instellingen verwijderen.", "create_user" => "Gebruiker aanmaken", "oidc_settings" => "OIDC-instellingen", "oidc_oauth_enabled" => "OIDC/OAuth inschakelen", "create_user_automatically" => "Gebruiker automatisch aanmaken", "disable_password_login" => "Wachtwoordlogin uitschakelen", "smtp_settings" => "SMTP-instellingen", "smtp_usage_info" => "Wordt gebruikt voor wachtwoordherstel en andere systeem e-mails.", "security_settings" => "Beveiligingsinstellingen", "ssrf_protection_info" => "Om Server-Side Request Forgery (SSRF) aanvallen te voorkomen, blokkeert Wallos standaard webhookmeldingen naar privé- of interne netwerkadressen.", "local_webhook_info" => "Als u webhooks naar lokale services moet sturen (zoals Home Assistant, Gotify of Node-RED), voer dan hun IP-adressen of hostnamen hierboven in als een door komma's gescheiden lijst (bijv. 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Onderhoudstaken", "orphaned_logos" => "Ongebruikte logo's", "update" => "Bijwerken", "new_version_available" => "Er is een nieuwe versie van Wallos beschikbaar", "current_version" => "Huidige versie", "latest_version" => "Nieuwste versie", "on_current_version" => "Je gebruikt de nieuwste versie van Wallos.", "show_update_notification" => "Toon melding voor updates op het dashboard", "cronjobs" => "Cronjobs", // E-mailverificatie "email_verified" => "E-mail succesvol geverifieerd", "email_verification_failed" => "E-mailverificatie mislukt", // Kalender "calendar" => "Kalender", "sun" => "Zo", "mon" => "Ma", "tue" => "Di", "wed" => "Wo", "thu" => "Do", "fri" => "Vr", "sat" => "Za", "month-01" => "Januari", "month-02" => "Februari", "month-03" => "Maart", "month-04" => "April", "month-05" => "Mei", "month-06" => "Juni", "month-07" => "Juli", "month-08" => "Augustus", "month-09" => "September", "month-10" => "Oktober", "month-11" => "November", "month-12" => "December", "total_cost" => "Totale kosten", "export_icalendar" => "Exporteer iCalendar", "over_budget_warning" => "U bent over uw budget", // TOTP Page "insert_totp_code" => "Voer TOTP code in", ]; ?> ================================================ FILE: includes/i18n/pl.php ================================================ "Musisz utworzyć konto, zanim będziesz mógł się zalogować", "username" => "Nazwa użytkownika", "password" => "Hasło", "email" => "E-mail", "firstname" => "Imię", "lastname" => "Nazwisko", "confirm_password" => "Potwierdź hasło", "main_currency" => "Główna waluta", "language" => "Język", "passwords_dont_match" => "Hasła nie pasują", "username_exists" => "Nazwa użytkownika już istnieje", "email_exists" => "E-mail już istnieje", "registration_failed" => "Rejestracja nie powiodła się, spróbuj ponownie.", "register" => "Rejestracja", "restore_database" => "Przywróć bazę danych", // Login Page "please_login" => "Proszę się zalogować", "stay_logged_in" => "Pozostań zalogowany (30 dni)", "login" => "Zaloguj się", "login_with" => "Zaloguj się przez", "or" => "lub", "login_failed" => "Dane logowania są nieprawidłowe", "registration_successful" => "Pomyślnie zarejestrowano", "user_email_waiting_verification" => "Twój adres e-mail musi zostać zweryfikowany. Sprawdź swój adres e-mail", // Password Reset Page "forgot_password" => "Zapomniałeś hasła? Kliknij tutaj", "reset_password" => "Zresetuj hasło", "reset_sent_check_email" => "Link do zresetowania hasła został wysłany na Twój adres e-mail", "password_reset_successful" => "Hasło zostało zresetowane pomyślnie", // Header "profile" => "Profil", "dashboard" => "Panel", "subscriptions" => "Subskrypcje", "stats" => "Statystyki", "settings" => "Ustawienia", "admin" => "Admin", "about" => "O aplikacji", "logout" => "Wyloguj się", // Dashboard "hello" => "Cześć", "upcoming_payments" => "Nadchodzące płatności", "no_upcoming_payments" => "Nie masz żadnych nadchodzących płatności", "overdue_renewals" => "Zaległe odnowienia", "ai_recommendations" => "Rekomendacje AI", "your_budget" => "Twój budżet", "budget" => "Budżet", "budget_used" => "Budżet użyty", "over_budget" => "Przekroczony budżet", "your_subscriptions" => "Twoje subskrypcje", "your_savings" => "Twoje oszczędności", // Subscriptions page "subscription" => "Subskrypcja", "no_subscriptions_yet" => "Nie masz jeszcze żadnych subskrypcji", "add_first_subscription" => "Dodaj pierwszą subskrypcję", "new_subscription" => "Nowa subskrypcja", "search" => "Szukaj", "state" => "Stan", "alphanumeric" => "Alfanumeryczny", "sort" => "Sortuj", "name" => "Nazwa", "last_added" => "Ostatnio dodane", "price" => "Cena", "next_payment" => "Następna płatność", "renewal_type" => "Typ odnowienia", "auto_renewal" => "Automatyczne odnawianie", "automatically_renews" => "Automatycznie odnawia się", "manual_renewal" => "Ręczne odnawianie", "start_date" => "Data rozpoczęcia", "inactive" => "Wyłącz subskrypcję", "replaced_with" => "Zastąpione przez", "none" => "Brak", "member" => "Użytkownik", "category" => "Kategoria", "payment_method" => "Metoda płatności", "Daily" => "Codziennie", "Weekly" => "Co tydzień", "Monthly" => "Miesięcznie", "Yearly" => "Rocznie", "daily" => "Dzień/Dni", "weekly" => "Tydzień/Tygodni", "monthly" => "Miesiąc/Miesięcy", "yearly" => "Rok/Lat", "days" => "dni", "weeks" => "tygodnie", "months" => "miesiące", "years" => "lata", "external_url" => "Odwiedź zewnętrzny adres URL", "empty_page" => "Pusta strona", "clear_filters" => "Wyczyść filtry", "no_matching_subscriptions" => "Brak pasujących subskrypcji", "clone" => "Klonuj", "renew" => "Odnów", "calculate_next_payment_date" => "Oblicz datę następnej płatności", // Subscription form "add_subscription" => "Dodaj subskrypcję", "edit_subscription" => "Edytuj subskrypcję", "subscription_name" => "Nazwa subskrypcji", "logo_preview" => "Podgląd logo", "search_logo" => "Wyszukaj logo w sieci", "web_search" => "Wyszukiwanie w Internecie", "currency" => "Waluta", "payment_every" => "Płatność co", "frequency" => "Częstotliwość", "cycle" => "Cykl", "no_category" => "Brak kategorii", "paid_by" => "Zapłacone przez", "url" => "URL", "notes" => "Notatki", "enable_notifications" => "Włącz powiadomienia dla tej subskrypcji", "default_value_from_settings" => "Wartość domyślna z ustawień", "cancellation_notification" => "Powiadomienie o anulowaniu", "delete" => "Usuń", "cancel" => "Anuluj", "upload_logo" => "Prześlij logo", // Statistics page "cant_convert_currency" => "Używasz wielu walut w swoich subskrypcjach. Aby uzyskać prawidłowe i dokładne statystyki, należy ustawić klucz API Fixer na stronie ustawień.", "general_statistics" => "Statystyki ogólne", "active_subscriptions" => "Aktywne subskrypcje", "inactive_subscriptions" => "Nieaktywne subskrypcje", "monthly_cost" => "Koszt miesięczny", "yearly_cost" => "Koszt roczny", "average_monthly" => "Średni miesięczny koszt subskrypcji", "most_expensive" => "Najdroższy koszt subskrypcji", "amount_due" => "Kwota należna w tym miesiącu", "percentage_budget_used" => "Procent wykorzystania budżetu", "budget_remaining" => "Pozostały budżet", "amount_over_budget" => "Kwota przekraczająca budżet", "monthly_savings" => "Miesięczne oszczędności (w przypadku nieaktywnych subskrypcji)", "yearly_savings" => "Roczne oszczędności (w przypadku nieaktywnych subskrypcji)", "split_views" => "Podziel widoki", "category_split" => "Podział kategorii", "household_split" => "Podział gospodarstwa domowego", "payment_method_split" => "Podział metod płatności", "total_cost_trend" => "Trend całkowitego kosztu", "cost_vs_budget" => "Koszt w porównaniu do budżetu", // About page "about_and_credits" => "Informacje i podziękowania", "credits" => "Podziękowania", "license" => "Licencja", "release_notes" => "Notatki wydania", "update_available" => "Dostępna aktualizacja", "issues_and_requests" => "Problemy i prośby", "the_author" => "Autor", "icons" => "Ikony", "payment_icons" => "Ikony płatności", // Profile page "upload_avatar" => "Prześlij awatar", "file_type_error" => "Podany typ pliku nie jest obsługiwany.", "user_details" => "Szczegóły użytkownika", "two_factor_authentication" => "Uwierzytelnianie dwuskładnikowe", "two_factor_info" => "Uwierzytelnianie dwuskładnikowe dodaje dodatkową warstwę zabezpieczeń do konta.
Do zeskanowania kodu QR potrzebna będzie aplikacja uwierzytelniająca, taka jak Google Authenticator, Authy lub Ente Auth.", "two_factor_enabled_info" => "Twoje konto jest bezpieczne dzięki uwierzytelnianiu dwuetapowemu. Możesz ją wyłączyć, klikając przycisk powyżej.", "enable_two_factor_authentication" => "Włącz uwierzytelnianie dwuskładnikowe", "2fa_already_enabled" => "Uwierzytelnianie dwuskładnikowe jest już włączone", "totp_code_incorrect" => "Kod TOTP jest nieprawidłowy", "backup_codes" => "Kody zapasowe", "download_backup_codes" => "Pobierz kody zapasowe", "copy_to_clipboard" => "Skopiuj do schowka", "totp_backup_codes_info" => "Kody zapasowe są jednorazowe i można je użyć do zalogowania się, jeśli nie masz dostępu do aplikacji uwierzytelniającej.", "disable_two_factor_authentication" => "Wyłącz uwierzytelnianie dwuskładnikowe", "totp_code" => "Kod TOTP", "api_key" => "Klucz API", "regenerate" => "Wygeneruj ponownie", "api_key_info" => "Klucz API jest używany do integracji z innymi aplikacjami. Nie udostępniaj go nikomu.", // Settings page "monthly_budget" => "Miesięczny budżet", "budget_info" => "Jeśli ustawisz budżet, zobaczysz pasek postępu na stronie głównej.", "household" => "Gospodarstwo domowe", "save_member" => "Zapisz użytkownika", "delete_member" => "Usuń użytkownika", "cant_delete_member" => "Nie można usunąć głównego użytkownika", "cant_delete_member_in_use" => "Nie można usunąć tego użytkownika w ramach subskrypcji", "household_info" => "Pole e-mail umożliwia powiadamianie domowników o zbliżającym się wygaśnięciu subskrypcji.", "notifications" => "Powiadomienia", "enable_email_notifications" => "Włącz powiadomienia e-mail", "notify_me" => "Powiadom mnie", "day_before" => "dzień wcześniej", "on_due_date" => "W dniu wymagalności", "days_before" => "dni wcześniej", "smtp_address" => "Adres SMTP", "port" => "Port", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "Nazwa użytkownika SMTP", "smtp_password" => "Hasło SMTP", "from_email" => "Z adresu e-mail (opcjonalnie)", "send_to_other_emails" => "Wyślij powiadomienia również na następujące adresy e-mail (użyj ; aby je rozdzielić):", "smtp_info" => "Hasło SMTP jest przesyłane i przechowywane w postaci zwykłego tekstu. Ze względów bezpieczeństwa utwórz konto tylko w tym celu.", "telegram" => "Telegram", "telegram_bot_token" => "Token bota", "telegram_chat_id" => "ID czatu", "pushplus" => "Pushplus", "pushplus_token" => "Token Pushplus", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "URL webhooka Mattermost", "mattermost_bot_username" => "Nazwa użytkownika bota Mattermost", "mattermost_bot_icon_emoji" => "Emoji ikony bota Mattermost", "webhook" => "Webhook", "webhook_url" => "URL webhooka", "request_method" => "Metoda żądania", "custom_headers" => "Niestandardowe nagłówki", "webhook_payload" => "Dane webhooka", "payment_notifications_payload" => "Dane powiadomienia o płatności", "cancelation_notification_payload" => "Dane powiadomienia o anulowaniu subskrypcji", "variables_available" => "Dostępne zmienne", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Nazwa użytkownika bota", "discord_bot_avatar_url" => "URL awatara bota", "pushover" => "Pushover", "pushover_user_key" => "Klucz użytkownika", "host" => "Host", "topic" => "Temat", "ignore_ssl_errors" => "Ignoruj błędy SSL", "categories" => "Kategorie", "save_category" => "Zapisz kategorię", "delete_category" => "Usuń kategorię", "cant_delete_category_in_use" => "Nie można usunąć kategorii używanej w subskrypcji", "currencies" => "Waluty", "save_currency" => "Zapisz walutę", "delete_currency" => "Usuń walutę", "cant_delete_main_currency" => "Nie można usunąć głównej waluty", "cant_delete_currency_in_use" => "Nie można usunąć waluty używanej w subskrypcji", "exchange_update" => "Kursy wymiany walut ostatnio zaktualizowane w dniu", "currency_info" => "Znajdź obsługiwane waluty i prawidłowe kody walut na", "currency_performance" => "W celu poprawy wydajności przechowuj tylko te waluty, których używasz.", "fixer_api_key" => "Klucz API Fixer'a", "provider" => "Dostawca", "fixer_info" => "Jeśli używasz wielu walut i chcesz dokładnych statystyk i sortowania subskrypcji, niezbędny jest DARMOWY klucz API z Fixer'a.", "get_key" => "Zdobądź klucz na stronie", "get_free_fixer_api_key" => "Uzyskaj bezpłatny klucz API Fixer'a", "get_key_alternative" => "Alternatywnie, możesz uzyskać darmowy klucz api fixer'a od", "ai_model" => "Model AI", "select_ai_model" => "Wybierz model AI", "run_schedule" => "Harmonogram uruchamiania", "manually" => "Ręcznie", "coming_soon" => "Wkrótce dostępne", "invalid_host" => "Nieprawidłowy host", "ai_recommendations_info" => "Rekomendacje AI są generowane na podstawie Twoich subskrypcji i członków gospodarstwa domowego.", "may_take_time" => "W zależności od dostawcy, modelu i liczby subskrypcji, generowanie rekomendacji może zająć trochę czasu.", "recommendations_visible_on_dashboard" => "Rekomendacje będą widoczne na pulpicie.", "generate_recommendations" => "Generuj rekomendacje", "display_settings" => "Ustawienia wyświetlania", "theme_settings" => "Ustawienia motywu", "colors" => "Kolory", "custom_colors" => "Kolory niestandardowe", "theme" => "Motyw", "dark_theme" => "Przełącz na jasny/ciemny motyw", "light_theme" => "Przełącz na ciemny/jasny motyw", "automatic" => "Automatycznie", "main_color" => "Główny kolor", "accent_color" => "Kolor akcentowy", "hover_color" => "Kolor najechania", "save_custom_colors" => "Zapisz niestandardowe kolory", "reset_custom_colors" => "Resetuj niestandardowe kolory", "custom_css" => "Niestandardowy CSS", "save_custom_css" => "Zapisz niestandardowy CSS", "calculate_monthly_price" => "Oblicz i pokaż miesięczną cenę wszystkich subskrypcji", "convert_prices" => "Zawsze przeliczaj i pokazuj ceny w mojej głównej walucie (wolniej)", "show_original_price" => "Pokaż również oryginalną cenę, gdy dokonywane są przeliczenia lub obliczenia", "experience" => "Doświadczenie", "show_subscription_progress" => "Pokaż postęp subskrypcji", "disabled_subscriptions" => "Wyłączone subskrypcje", "hide_disabled_subscriptions" => "Ukryj wyłączone subskrypcje", "show_disabled_subscriptions_at_the_bottom" => "Pokaż wyłączone subskrypcje na dole", "experimental_settings" => "Ustawienia eksperymentalne", "remove_background" => "Próba usunięcia tła logo z wyszukiwania obrazów", "use_mobile_navigation_bar" => "Użyj paska nawigacyjnego na urządzeniach mobilnych", "experimental_info" => "Ustawienia eksperymentalne prawdopodobnie nie będą działać idealnie.", "payment_methods" => "Metody płatności", "payment_methods_info" => "Kliknij metodę płatności, aby ją wyłączyć/włączyć..", "rename_payment_methods_info" => "Kliknij nazwę metody płatności, aby zmienić jej nazwę.", "cant_delete_payment_method_in_use" => "Nie można wyłączyć używanej metody płatnościd", "add_custom_payment" => "Dodaj niestandardową metodę płatności", "payment_method_name" => "Nazwa metody płatności", "payment_method_added_successfuly" => "Metoda płatności dodana pomyślnie", "payment_method_removed" => "Usunięto metodę płatności", "disable" => "Wyłącz", "enable" => "Włącz", "rename_payment_method" => "Zmień nazwę metody płatności", "payment_renamed" => "Zmieniono nazwę metody płatności", "payment_not_renamed" => "Nazwa metody płatności nie została zmieniona", "test" => "Test", "add" => "Dodaj", "save" => "Zapisz", "reset" => "Resetuj", "main_accent_color_error" => "Kolor główny i akcentowy nie mogą być takie same", "backup_and_restore" => "Kopia zapasowa i przywracanie", "backup" => "Kopia zapasowa", "restore" => "Przywróć", "restore_info" => "Przywrócenie bazy danych zastąpi wszystkie bieżące dane. Po przywróceniu zostaniesz wylogowany.", "account" => "Konto", "export_subscriptions" => "Eksportuj subskrypcje", "export_as_json" => "Eksportuj jako JSON", "export_as_csv" => "Eksportuj jako CSV", "danger_zone" => "Strefa zagrożenia", "delete_account" => "Usuń konto", "delete_account_info" => "Usunięcie konta spowoduje również usunięcie wszystkich subskrypcji i ustawień.", // Filters menu "filter" => "Filtr", "clear" => "Wyczyść", // Toast "success" => "Sukces", // Endpoint responses "session_expired" => "Twoja sesja wygasła. Zaloguj się ponownie", "fields_missing" => "Brakuje niektórych pól", "fill_all_fields" => "Proszę wypełnić wszystkie pola", "fill_mandatory_fields" => "Proszę wypełnić wszystkie pola obowiązkowe", "error" => "Błąd", // Category "failed_add_category" => "Nie udało się dodać kategorii", "failed_edit_category" => "Nie udało się edytować kategorii", "category_in_use" => "Kategoria jest używana w subskrypcjach i nie można jej usunąć", "failed_remove_category" => "Nie udało się usunąć kategorii", "category_saved" => "Kategoria zapisana", "category_removed" => "Kategoria usunięta", "sort_order_saved" => "Porządek sortowania został zapisany", // Currency "currency_saved" => "został zapisany.", "error_adding_currency" => "Błąd podczas dodawania wpisu waluty.", "failed_to_store_currency" => "Nie udało się zapisać waluty w bazie danych.", "currency_in_use" => "Waluta jest używana w subskrypcjach i nie można jej usunąć.", "currency_is_main" => "Waluta jest ustawiona jako waluta główna i nie można jej usunąć.", "failed_to_remove_currency" => "Nie udało się usunąć waluty z bazy danych.", "failed_to_store_api_key" => "Nie udało się zapisać klucza API w bazie danych.", "invalid_api_key" => "Nieprawidłowy klucz API.", "api_key_saved" => "Klucz API zapidsany pomyślnie", "currency_removed" => "Waluta została usunięta", // Household "failed_add_household" => "Nie udało się dodać domownika", "failed_edit_household" => "Nie udało się edytować domownika", "failed_remove_household" => "Nie udało się usunąć domownika", "household_in_use" => "Domownik jest używany w subskrypcjach i nie można go usunąć", "member_saved" => "Użytkownik zapisany", "member_removed" => "Użytkownik usunięty", // Notifications "error_saving_notifications" => "Błąd podczas zapisywania danych powiadomień.", "wallos_notification" => "Powiadomienie Wallos", "test_notification" => "To jest powiadomienie testowe. Jeśli to widzisz, konfiguracja jest prawidłowa.", "email_error" => "Błąd podczas wysyłania wiadomości e-mail", "notification_failed" => "Powiadomienie nie powiodło się", "notification_sent_successfuly" => "Powiadomienie wysłane pomyślnie", "notifications_settings_saved" => "Ustawienia powiadomień zostały zapisane.", // Payments "payment_in_use" => "Nie można wyłączyć użytej metody płatności", "failed_update_payment" => "Nie udało się zaktualizować metody płatności w bazie danych", "enabled" => "włączone", "disabled" => "wyłączone", // Subscription "error_fetching_image" => "Błąd podczas pobierania obrazu", "subscription_updated_successfuly" => "Subskrypcja została pomyślnie zaktualizowana", "subscription_added_successfuly" => "Subskrypcja dodana pomyślnie", "error_deleting_subscription" => "Błąd podczas usuwania subskrypcji.", "invalid_request_method" => "Nieprawidłowa metoda żądania.", // User "error_updating_user_data" => "Błąd podczas aktualizacji danych użytkownika.", "user_details_saved" => "Dane użytkownika zostały zapisane", // Admin Page "registrations" => "Rejestracje", "enable_user_registrations" => "Włącz rejestracje użytkowników", "maximum_number_users" => "Maksymalna liczba użytkowników", "require_email_verification" => "Wymagaj weryfikacji e-maila", "configure_smtp_settings_to_enable" => "Skonfiguruj ustawienia SMTP, aby włączyć", "server_url" => "Adres URL serwera", "server_url_info" => "Służy do weryfikacji adresu e-mail i odzyskiwania hasła. Musi to być prawidłowy publiczny adres URL.", "server_url_password_reset" => "Jeśli zostanie ustawiona, włączy również funkcję resetowania hasła.", "disable_login" => "Wyłącz logowanie", "disable_login_info" => "Obejście logowania. Jeśli serwer działa tylko w sieci lokalnej, bez dostępu z zewnątrz, można wyłączyć logowanie. Spowoduje to automatyczne zalogowanie użytkownika admin.", "disable_login_info2" => "To ustawienie można włączyć tylko wtedy, gdy rejestracja użytkowników jest wyłączona i nie ma więcej niż konto administratora.", "max_users_info" => "Jeśli ustawisz 0, nie będzie limitu użytkowników.", "user_management" => "Zarządzanie użytkownikami", "delete_user" => "Usuń użytkownika", "delete_user_info" => "Usunięcie użytkownika spowoduje również usunięcie wszystkich jego subskrypcji i ustawień.", "create_user" => "Utwórz użytkownika", "oidc_settings" => "Ustawienia OIDC", "oidc_auth_enabled" => "Włącz uwierzytelnianie OIDC", "create_user_automatically" => "Automatycznie twórz użytkowników", "disable_password_login" => "Wyłącz logowanie za pomocą hasła", "smtp_settings" => "Ustawienia SMTP", "smtp_usage_info" => "Będzie używany do odzyskiwania hasła i innych e-maili systemowych.", "security_settings" => "Ustawienia zabezpieczeń", "ssrf_protection_info" => "Aby zapobiec atakom Server-Side Request Forgery (SSRF), Wallos domyślnie blokuje powiadomienia webhook wysyłane na prywatne lub wewnętrzne adresy sieciowe.", "local_webhook_info" => "Jeśli musisz wysyłać webhooki do lokalnych usług (np. Home Assistant, Gotify lub Node-RED), wprowadź ich adresy IP lub nazwy hostów powyżej jako listę oddzieloną przecinkami (np. 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Zadania konserwacyjne", "orphaned_logos" => "Osierocone logo", "update" => "Aktualizacja", "new_version_available" => "Dostępna jest nowa wersja Wallos", "current_version" => "Aktualna wersja", "latest_version" => "Najnowsza wersja", "on_current_version" => "Używasz najnowszej wersji Wallos.", "show_update_notification" => "Pokaż powiadomienie o aktualizacjach na dashboardzie", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "E-mail został zweryfikowany", "email_verification_failed" => "Weryfikacja e-maila nie powiodła się", // Calendar "calendar" => "Kalendarz", "sun" => "Niedz.", "mon" => "Pon.", "tue" => "Wt.", "wed" => "Śr.", "thu" => "Czw.", "fri" => "Pt.", "sat" => "Sob.", "month-01" => "Styczeń", "month-02" => "Luty", "month-03" => "Marzec", "month-04" => "Kwiecień", "month-05" => "Maj", "month-06" => "Czerwiec", "month-07" => "Lipiec", "month-08" => "Sierpień", "month-09" => "Wrzesień", "month-10" => "Październik", "month-11" => "Listopad", "month-12" => "Grudzień", "total_cost" => "Całkowity koszt", "export_icalendar" => "Eksportuj do iCalendar", "over_budget_warning" => "Przekroczyłeś budżet", // TOTP Page "insert_totp_code" => "Wprowadź kod TOTP", ]; ?> ================================================ FILE: includes/i18n/pt.php ================================================ "Tem que criar uma conta antes de poder iniciar sessão", "username" => "Nome de utilizador", "password" => "Password", "email" => "Email", "firstname" => "Nome próprio", "lastname" => "Apelido", "confirm_password" => "Confirmar Password", "main_currency" => "Moeda Principal", "language" => "Linguagem", "passwords_dont_match" => "As passwords não coincidem", "username_exists" => "Nome de utilizador já existe", "email_exists" => "Email já existe", "registration_failed" => "O registo falhou. Tente novamente", "register" => "Registar", "restore_database" => "Restaurar base de dados", // Login Page "please_login" => "Por favor inicie sessão", "stay_logged_in" => "Manter sessão (30 dias)", "login" => "Iniciar Sessão", "login_with" => "Iniciar sessão com", "or" => "ou", "login_failed" => "Dados de autenticação incorrectos", "registration_successful" => "Registo efectuado com sucesso.", "user_email_waiting_verification" => "O seu e-mail precisa de ser validado. Verifique o seu correio eletrónico", // Password Reset Page "forgot_password" => "Esqueceu-se da password?", "reset_password" => "Repor Password", "reset_sent_check_email" => "Pedido de reposição de password enviado. Verifique o seu email.", "password_reset_successful" => "Password reposta com sucesso", // Header "profile" => "Perfil", "dashboard" => "Painel", "subscriptions" => "Subscrições", "stats" => "Estatísticas", "settings" => "Definições", "admin" => "Administração", "about" => "Sobre", "logout" => "Terminar Sessão", // Dashboard "hello" => "Olá", "upcoming_payments" => "Próximos Pagamentos", "no_upcoming_payments" => "Você não tem pagamentos agendados", "overdue_renewals" => "Renovações Atrasadas", "ai_recommendations" => "Recomendações de IA", "your_budget" => "Seu Orçamento", "budget" => "Orçamento", "budget_used" => "Orçamento Usado", "over_budget" => "Acima do Orçamento", "your_subscriptions" => "Suas subscrições", "your_savings" => "Suas Poupanças", // Subscriptions page "subscription" => "Subscrição", "no_subscriptions_yet" => "Ainda não tem subscrições", "add_first_subscription" => "Adicionar primeira subscrição", "new_subscription" => "Nova Subscrição", "search" => "Pesquisar", "state" => "Estado", "alphanumeric" => "Alfanumérico", "sort" => "Ordenar", "name" => "Nome", "last_added" => "Última Adicionada", "price" => "Preço", "next_payment" => "Próximo Pagamento", "renewal_type" => "Tipo de Renovação", "auto_renewal" => "Renovação Automática", "automatically_renews" => "Renova automaticamente", "manual_renewal" => "Renovação Manual", "start_date" => "Data de Início", "inactive" => "Desactivar Subscrição", "replaced_with" => "Substituída por", "none" => "Nenhuma", "member" => "Membro", "category" => "Categoria", "payment_method" => "Metodo de Pagamento", "Daily" => "Diario", "Weekly" => "Semanal", "Monthly" => "Mensal", "Yearly" => "Anual", "daily" => "Dia(s)", "weekly" => "Semana(s)", "monthly" => "Mês(es)", "yearly" => "Ano(s)", "days" => "dias", "weeks" => "semanas", "months" => "meses", "years" => "anos", "external_url" => "Visitar URL Externo", "empty_page" => "Página Vazia", "clear_filters" => "Limpar Filtros", "no_matching_subscriptions" => "Sem subscrições correspondentes", "clone" => "Clonar", "renew" => "Renovar", "calculate_next_payment_date" => "Calcular Próxima Data de Pagamento", // Subscription form "add_subscription" => "Adicionar subscrição", "edit_subscription" => "Modificar subscrição", "subscription_name" => "Nome da subscrição", "logo_preview" => "Pre-visualisação do logo", "search_logo" => "Pesquisar logo na internet", "web_search" => "Pesquisa online", "currency" => "Moeda", "payment_every" => "Pagamento a cada", "frequency" => "Frequencia", "Cycle" => "Ciclo", "no_category" => "Sem categoria", "paid_by" => "Pago por", "url" => "URL", "notes" => "Notas", "enable_notifications" => "Activar notificações para esta subscrição", "default_value_from_settings" => "Valor por defeito das definições", "cancellation_notification" => "Notificação de cancelamento", "delete" => "Remover", "cancel" => "Cancelar", "upload_logo" => "Enviar Logo", // Statistics page "cant_convert_currency" => "Está a utilizar várias moedas nas suas subscrições. Para obter estatísticas válidas e exactas, defina uma chave API do Fixer na página de definições.", "general_statistics" => "Estatísticas Gerais", "active_subscriptions" => "Subscrições Activas", "inactive_subscriptions" => "Subscrições inactivas", "monthly_cost" => "Custo Mensal", "yearly_cost" => "Custo Anual", "average_monthly" => "Custo Mensal Médio das Subscrições", "most_expensive" => "Custo da Subscrição Mais Cara", "amount_due" => "Quantia em dívida este mês", "percentage_budget_used" => "Percentagem do orçamento usada", "budget_remaining" => "Orçamento Restante", "amount_over_budget" => "Quantia acima do orçamento", "monthly_savings" => "Poupança Mensal (em subscrições inactivas)", "yearly_savings" => "Poupança Anual (em subscrições inactivas)", "split_views" => "Vistas Divididas", "category_split" => "Por Categoria", "household_split" => "Por Membro", "payment_method_split" => "Por Método de Pagamento", "total_cost_trend" => "Tendência de Custo Total", "cost_vs_budget" => "Custo vs Orçamento", // About page "about_and_credits" => "Sobre e Créditos", "credits" => "Créditos", "license" => "Licença", "release_notes" => "Notas de Lançamento", "update_available" => "Atualização Disponível", "issues_and_requests" => "Problemas e Pedidos", "the_author" => "O Autor", "icons" => "Ícones", "payment_icons" => "Ícones de Pagamentos", // Profile page "upload_avatar" => "Enviar avatar", "file_type_error" => "Tipo de ficheiro não permitido", "user_details" => "Detalhes do utilizador", "two_factor_authentication" => "Autenticação de dois fatores", "two_factor_info" => "A autenticação de dois factores acrescenta uma camada extra de segurança à sua conta.
Necessitará de uma aplicação de autenticação como o Google Authenticator, Authy ou Ente Auth para ler o código QR.", "two_factor_enabled_info" => "A sua conta está segura com a autenticação de dois factores. Pode desactivá-la clicando no botão acima.", "enable_two_factor_authentication" => "Activar autenticação de dois factores", "2fa_already_enabled" => "A autenticação de dois factores já está activada", "totp_code_incorrect" => "Código TOTP incorrecto", "backup_codes" => "Códigos de Backup", "download_backup_codes" => "Descarregar códigos de backup", "copy_to_clipboard" => "Copiar para a área de transferência", "totp_backup_codes_info" => "Guarde estes códigos num local seguro. Pode usá-los para aceder à sua conta se perder o acesso ao seu dispositivo de autenticação.", "disable_two_factor_authentication" => "Desactivar autenticação de dois factores", "totp_code" => "Código TOTP", "api_key" => "API Key", "regenerate" => "Regenerar", "api_key_info" => "A sua API Key é usada para aceder à API do Wallos. Não a partilhe com ninguém.", // Settings page "monthly_budget" => "Orçamento Mensal", "budget_info" => "Ao definir um orçamento pode comparar com os gastos reais na página de estatísticas.", "household" => "Agregado", "save_member" => "Guardar Membro", "delete_member" => "Apagar Membro", "cant_delete_member" => "Não pode apagar o membro principal", "cant_delete_member_in_use" => "Não pode apagar membro em uso em subscrição", "household_info" => "O campo E-mail permite que os membros do agregado sejam notificados das subscrições que estão prestes a expirar.", "notifications" => "Notificações", "enable_email_notifications" => "Activar notificações por email", "notify_me" => "Notificar-me", "day_before" => "dia antes", "on_due_date" => "Na data de vencimento", "days_before" => "dias antes", "smtp_address" => "Endereço SMTP", "port" => "Porto", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "Utilizador SMTP", "smtp_password" => "Password SMTP", "from_email" => "Email de envio (Opcional)", "send_to_other_emails" => "Também enviar notificações para os seguintes endereços de email (use ; para os separar):", "smtp_info" => "A Password é armazenada e transmitida em texto. Por segurança, crie uma conta só para esta finalidade.", "telegram" => "Telegram", "telegram_bot_token" => "Token do Bot Telegram", "telegram_chat_id" => "ID do Chat Telegram", "pushplus" => "Pushplus", "pushplus_token" => "Token do Pushplus", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "URL do Hook", "mattermost_bot_username" => "Nome de Utilizador do Bot", "mattermost_bot_icon_emoji" => "Icon Emoji do Bot", "webhook" => "Webhook", "webhook_url" => "URL do Webhook", "request_method" => "Método de Pedido", "custom_headers" => "Cabeçalhos Personalizados", "webhook_payload" => "Payload do Webhook", "payment_notifications_payload" => "Payload da notificação de pagamento", "cancelation_notification_payload" => "Payload da notificação de cancelamento", "variables_available" => "Variáveis Disponíveis", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Nome de Utilizador do Bot", "discord_bot_avatar_url" => "URL do Avatar do Bot", "pushover" => "Pushover", "pushover_user_key" => "Chave de Utilizador Pushover", "host" => "Host", "topic" => "Tópico", "ignore_ssl_errors" => "Ignorar erros SSL", "categories" => "Categorias", "save_category" => "Guardar Categoria", "delete_category" => "Apagar Categoria", "cant_delete_category_in_use" => "Não pode apagar categoria em uso em subscrição", "currencies" => "Moedas", "save_currency" => "Guardar moeda", "delete_currency" => "Apagar moeda", "cant_delete_main_currency" => "Não pode apagar a moeda principal", "cant_delete_currency_in_use" => "Não pode apagar moeda em uso em subscrição", "exchange_update" => "Taxas de conversão actualizadas em", "currency_info" => "Encontre a lista de moedas e os respectivos códigos em", "currency_performance" => "Por motivos de desempenho mantenha apenas as moedas que usa.", "fixer_api_key" => "Fixer API Key", "provider" => "Fornecedor", "fixer_info" => "Se usa multiplas moedas e deseja estatísticas correctas é necessário uma API Key grátis do Fixer.", "get_key" => "Obtenha a sua API Key em", "get_free_fixer_api_key" => "Obtenha a sua API Key grátis do Fixer", "get_key_alternative" => "Como alternativa obtenha a sua API Key em", "ai_model" => "Modelo de IA", "select_ai_model" => "Selecionar modelo de IA", "run_schedule" => "Agendamento de execução", "manually" => "Manual", "coming_soon" => "Em breve", "invalid_host" => "Host inválido", "ai_recommendations_info" => "As recomendações de IA são geradas com base nas suas assinaturas e membros da família.", "may_take_time" => "Dependendo do provedor, modelo e número de assinaturas, a geração de recomendações pode levar algum tempo.", "recommendations_visible_on_dashboard" => "As recomendações serão visíveis no painel.", "generate_recommendations" => "Gerar recomendações", "display_settings" => "Definições de exibição", "theme_settings" => "Definições de Tema", "colors" => "Cores", "custom_colors" => "Cores Personalizadas", "theme" => "Tema", "dark_theme" => "Tema Escuro", "light_theme" => "Tema Claro", "automatic" => "Automático", "main_color" => "Cor Principal", "accent_color" => "Cor de Destaque", "hover_color" => "Cor de Hover", "save_custom_colors" => "Guardar Cores Personalizadas", "reset_custom_colors" => "Repor Cores Personalizadas", "custom_css" => "CSS Personalizado", "save_custom_css" => "Guardar CSS Personalizado", "calculate_monthly_price" => "Calcular e mostrar preço mensal para todas as subscrições", "convert_prices" => "Converter e mostrar todas as subscrições na moeda principal (mais lento)", "show_original_price" => "Também mostrar o preço original quando são feitas conversões ou cálculos", "experience" => "Experiência", "show_subscription_progress" => "Mostrar progresso da subscrição", "disabled_subscriptions" => "Subscrições Desactivadas", "hide_disabled_subscriptions" => "Esconder subscrições desactivadas", "show_disabled_subscriptions_at_the_bottom" => "Mostrar subscrições desactivadas no fundo da lista", "experimental_settings" => "Definições Experimentais", "remove_background" => "Tentar remover o fundo dos logos na pesquisa de imagem", "use_mobile_navigation_bar" => "Usar barra de navegação móvel", "experimental_info" => "Definições experimentais provavelmente não funcionarão correctamente.", "payment_methods" => "Métodos de Pagamento", "payment_methods_info" => "Clique num método de pagamento para o activar / desactivar.", "rename_payment_methods_info" => "Clique no nome do método de pagamento para o renomear.", "cant_delete_payment_method_in_use" => "Não pode desactivar metodo de pagamento em uso", "add_custom_payment" => "Adicionar método de pagamento personalizado", "payment_method_name" => "Nome do método de pagamento", "payment_method_added_successfuly" => "Método de pagamento adicionado com sucesso", "payment_method_removed" => "Método de pagamento removido", "disable" => "Desactivar", "enable" => "Activar", "rename_payment_method" => "Renomear método de pagamento", "payment_renamed" => "Método de pagamento renomeado", "payment_not_renamed" => "Método de pagamento não renomeado", "test" => "Testar", "add" => "Adicionar", "save" => "Guardar", "reset" => "Repor", "main_accent_color_error" => "A cor principal e a cor de destaque não podem ser iguais", "backup_and_restore" => "Backup e Restauro", "backup" => "Backup", "restore" => "Restauro", "restore_info" => "O restauro da base de dados apagará todos os dados actuais. A sua sessão irá terminar após o restauro.", "account" => "Conta", "export_subscriptions" => "Exportar Subscrições", "export_as_json" => "Exportar como JSON", "export_as_csv" => "Exportar como CSV", "danger_zone" => "Zona de Perigo", "delete_account" => "Eliminar Conta", "delete_account_info" => "A eliminação da sua conta também eliminará todas as suas subscrições e definições.", // Filters menu "filter" => "Filtro", "clear" => "Limpar", // Toast "success" => "Sucesso", // Endpoint responses "session_expired" => "A sessão expirou. Por favor autentique-se.", "fields_missing" => "Alguns campos em falta", "fill_all_fields" => "Por favor preencha todos os campos", "fill_mandatory_fields" => "Por favor preencha todos os campos obrigatórios", "error" => "Erro", // Category "failed_add_category" => "Erro ao adicionar categoria", "failed_edit_category" => "Erro ao modificar categoria", "category_in_use" => "Categoria em uso em subscrição e não pode ser removida", "failed_remove_category" => "Erro ao remover categoria", "category_saved" => "Categoria guardada", "category_removed" => "Categoria removida", "sort_order_saved" => "Ordenação guardada", // Currency "currency_saved" => "guardada.", "error_adding_currency" => "Erro ao adicionar moeda.", "failed_to_store_currency" => "Erro ao guardar a moeda na base de dados.", "currency_in_use" => "Moeda em uso em subscrição e não pode ser removida.", "currency_is_main" => "A Moeda principal não pode ser removida.", "failed_to_remove_currency" => "Erro ao remover a moeda da base de dados.", "failed_to_store_api_key" => "Erro ao guardar API Key na base de dados.", "invalid_api_key" => "API Key inválida.", "api_key_saved" => "API key guardada", "currency_removed" => "Moeda removida", // Household "failed_add_household" => "Erro ao adicionar membro", "failed_edit_household" => "Erro ao modificar membro", "failed_remove_household" => "Erro ao remover membro", "household_in_use" => "Membro está em uso em subscrição e não pode er removido", "member_saved" => "Membro guardado", "member_removed" => "Membro removido", // Notifications "error_saving_notifications" => "Erro ao guardar os dados das notificaçoes.", "wallos_notification" => "Notificação Wallos", "test_notification" => "Isto é uma notificação de teste. Se está a ver isto a configuração está correcta.", "email_error" => "Erro ao enviar email", "notification_sent_successfuly" => "Notificação enviada com sucesso", "notifications_settings_saved" => "Configuração de notificações guardada.", "notification_failed" => "Erro ao enviar notificação", // Payments "payment_in_use" => "Não pode desactivar método de pagamento em uso", "failed_update_payment" => "Erro ao actualizar método de pagamento na base de dados", "enabled" => "activado", "disabled" => "descativado", // Subscription "error_fetching_image" => "Erro ao obter a imagem", "subscription_updated_successfuly" => "Subscrição actualizada com sucesso", "subscription_added_successfuly" => "Subscrição adicionada com sucesso", "error_deleting_subscription" => "Erro ao remover subscrição.", "invalid_request_method" => "Método invalido.", // User "error_updating_user_data" => "Erro ao actualizar dados do utilizador.", "user_details_saved" => "Dados do utiliador actualizados.", // Admin Page "registrations" => "Registos", "enable_user_registrations" => "Activar Registos de Utilizadores", "maximum_number_users" => "Número Máximo de Utilizadores", "require_email_verification" => "Requerer verificação de email", "configure_smtp_settings_to_enable" => "Configure as definições SMTP para activar esta funcionalidade.", "server_url" => "URL do Servidor", "server_url_info" => "Usado para gerar links de verificação de email. Deve ser um URL público e válido.", "server_url_password_reset" => "Se definido, também activará a funcionalidade de reposição da palavra-passe.", "disable_login" => "Desactivar Inicio de Sessão", "disable_login_info" => "Ultrapassar o início de sessão. Se o seu servidor funciona apenas numa rede local, sem acesso externo, pode desativar o início de sessão. Isto irá iniciar automaticamente a sessão do utilizador administrador.", "disable_login_info2" => "Só pode ativar esta definição se o registo de utilizadores estiver desativado e se não houver mais do que a conta de utilizador administrador.", "max_users_info" => "0 para ilimitado", "user_management" => "Gestão de Utilizadores", "delete_user" => "Apagar Utilizador", "delete_user_info" => "Apagar utilizador irá remover todas as suas subscrições e dados associados.", "create_user" => "Criar Utilizador", "oidc_settings" => "Definições OIDC", "oidc_auth_enabled" => "Activar autenticação OIDC", "create_user_automatically" => "Criar utilizador automaticamente", "disable_password_login" => "Desactivar login por password", "smtp_settings" => "Definições SMTP", "smtp_usage_info" => "Será usado para recuperações de password e outros emails do sistema.", "security_settings" => "Configurações de Segurança", "ssrf_protection_info" => "Para evitar ataques Server-Side Request Forgery (SSRF), o Wallos bloqueia por padrão notificações webhook para endereços de rede privados ou internos.", "local_webhook_info" => "Se precisar enviar webhooks para serviços locais (como Home Assistant, Gotify ou Node-RED), insira seus endereços IP ou nomes de host acima como uma lista separada por vírgulas (por exemplo 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Tarefas de Manutenção", "orphaned_logos" => "Logos Órfãos", "update" => "Actualizar", "new_version_available" => "Uma nova versão do Wallos está disponível", "current_version" => "Versão Atual", "latest_version" => "Última Versão", "on_current_version" => "Está a usar a versão mais recente do Wallos.", "show_update_notification" => "Mostrar notificação de atualizações no dashboard", "Cronjobs" => "Cronjobs", // Email Verification "email_verified" => "Email verificado", "email_verification_failed" => "Verificação de email falhou", // Calendar "calendar" => "Calendário", "sun" => "Dom", "mon" => "Seg", "tue" => "Ter", "wed" => "Qua", "thu" => "Qui", "fri" => "Sex", "sat" => "Sáb", "month-01" => "Janeiro", "month-02" => "Fevereiro", "month-03" => "Março", "month-04" => "Abril", "month-05" => "Maio", "month-06" => "Junho", "month-07" => "Julho", "month-08" => "Agosto", "month-09" => "Setembro", "month-10" => "Outubro", "month-11" => "Novembro", "month-12" => "Dezembro", "total_cost" => "Custo Total", "export_icalendar" => "Exportar iCalendar", "over_budget_warning" => "O orçamento foi ultrapassado", // TOTP Page "insert_totp_code" => "Insira o código TOTP", ]; ?> ================================================ FILE: includes/i18n/pt_br.php ================================================ "É necessário criar uma conta antes de poder se logar", "username" => "Usuário", "password" => "Senha", "email" => "Email", "firstname" => "Primeiro nome", "lastname" => "Sobrenome", "confirm_password" => "Confirmar Senha", "main_currency" => "Moeda principal", "language" => "Idioma", "passwords_dont_match" => "As senhas não são iguais", "username_exists" => "O nome de usuário já existe", "email_exists" => "O email já está em uso", "registration_failed" => "O registro falhou. Por favor, tente novamente", "register" => "Registrar", "restore_database" => "Restaurar banco de dados", // Login Page "please_login" => "Por favor, faça o login", "stay_logged_in" => "Me manter logado (30 dias)", "login" => "Login", "login_with" => "Entrar com", "or" => "ou", "login_failed" => "As informações de login estão incorretas", "registration_successful" => "Registro bem-sucedido", "user_email_waiting_verification" => "Seu e-mail precisa ser validado. Por favor, verifique seu e-mail", // Password Reset Page "forgot_password" => "Esqueceu a senha?", "reset_password" => "Redefinir senha", "reset_sent_check_email" => "Redefinição de senha enviada. Por favor, verifique seu email", "password_reset_successful" => "Senha redefinida com sucesso", // Header "profile" => "Perfil", "dashboard" => "Painel", "subscriptions" => "Assinaturas", "stats" => "Estatísticas", "settings" => "Configurações", "admin" => "Admin", "about" => "Sobre", "logout" => "Sair", // Dashboard "hello" => "Olá", "upcoming_payments" => "Pagamentos Futuros", "no_upcoming_payments" => "Você não tem pagamentos futuros", "overdue_renewals" => "Renovações Atrasadas", "ai_recommendations" => "Recomendações de IA", "your_budget" => "Seu Orçamento", "budget" => "Orçamento", "budget_used" => "Orçamento Usado", "over_budget" => "Acima do Orçamento", "your_subscriptions" => "Suas Assinaturas", "your_savings" => "Suas Economias", // Subscriptions page "subscription" => "Assinatura", "no_subscriptions_yet" => "Você ainda não tem nenhuma assinatura", "add_first_subscription" => "Adicionar a primeira assinatura", "new_subscription" => "Nova assinatura", "search" => "Pesquisar", "state" => "Estado", "alphanumeric" => "Alfanumérico", "sort" => "Ordenar", "name" => "Nome", "last_added" => "Última adicionada", "price" => "Preço", "next_payment" => "Próximo pagamento", "renewal_type" => "Tipo de renovação", "auto_renewal" => "Renovação automática", "automatically_renews" => "Renova automaticamente", "manual_renewal" => "Renovação manual", "start_date" => "Data de início", "inactive" => "Assinatura inativa", "replaced_with" => "Substituída por", "none" => "Nenhuma", "member" => "Membro", "category" => "Categoria", "payment_method" => "Método de Pagamento", "Daily" => "Diário", "Weekly" => "Semanal", "Monthly" => "Mensal", "Yearly" => "Anual", "daily" => "Dia(s)", "weekly" => "Semana(s)", "monthly" => "Mês(es)", "yearly" => "Ano(s)", "days" => "dias", "weeks" => "semanas", "months" => "meses", "years" => "anos", "external_url" => "Abrir URL externa", "empty_page" => "Página vazia", "clear_filters" => "Limpar filtros", "no_matching_subscriptions" => "Nenhuma assinatura encontrada", "clone" => "Clonar", "renew" => "Renovar", "calculate_next_payment_date" => "Calcular próxima data de pagamento", // Subscription form "add_subscription" => "Adicionar assinatura", "edit_subscription" => "Editar assinatura", "subscription_name" => "Nome da assinatura", "logo_preview" => "Preview do logo", "search_logo" => "Pesquisar logo internet", "web_search" => "Buscar na internet", "currency" => "Moeda", "payment_every" => "Pagamento a cada", "frequency" => "Frequência", "cycle" => "Ciclo", "no_category" => "Sem categoria", "paid_by" => "Pago por", "url" => "URL", "notes" => "Anotações", "enable_notifications" => "Ativar notificações para essa assinatura", "default_value_from_settings" => "Valor padrão das configurações", "cancellation_notification" => "Notificação de cancelamento", "delete" => "Excluir", "cancel" => "Cancelar", "upload_logo" => "Enviar Logo", // Statistics page "cant_convert_currency" => "Você está usando várias moedas em suas assinaturas. Para obter estatísticas válidas e precisas, defina uma chave de API do Fixer na página de configurações.", "general_statistics" => "Estatísticas gerais", "active_subscriptions" => "Assinaturas ativas", "inactive_subscriptions" => "Assinaturas inativas", "monthly_cost" => "Custo mensal", "yearly_cost" => "Custo anual", "average_monthly" => "Custom médio mensal", "most_expensive" => "Assinatura mais cara", "amount_due" => "Valor devido nesse mês", "percentage_budget_used" => "Porcentagem do orçamento utilizado", "budget_remaining" => "Orçamento restante", "amount_over_budget" => "Valor acima do orçamento", "monthly_savings" => "Economia mensal (em assinaturas inativas)", "yearly_savings" => "Economia anual (em assinaturas inativas)", "split_views" => "Visualizações", "category_split" => "Por categoria", "household_split" => "Por membro", "payment_method_split" => "Por método de pagamento", "total_cost_trend" => "Tendência de custo total", "cost_vs_budget" => "Custo vs Orçamento", // About page "about_and_credits" => "Sobre e Créditos", "credits" => "Créditos", "license" => "Licença", "release_notes" => "Notas de lançamento", "update_available" => "Atualização disponível", "issues_and_requests" => "Problemas e Pedidos", "the_author" => "O autor", "icons" => "Ícones", "payment_icons" => "Ícones de pagamento", // Profile page "upload_avatar" => "Carregar avatar", "file_type_error" => "Tipo de arquivo não permitido", "user_details" => "Informações do Usuário", "two_factor_authentication" => "Autenticação de dois fatores", "two_factor_info" => "A autenticação de dois fatores adiciona uma camada extra de segurança à sua conta.
Você precisará de um aplicativo autenticador, como o Google Authenticator, Authy ou Ente Auth, para ler o código QR.", "two_factor_enabled_info" => "Sua conta está segura com a autenticação de dois fatores. Você pode desativá-la clicando no botão acima.", "enable_two_factor_authentication" => "Ativar autenticação de dois fatores", "2fa_already_enabled" => "A autenticação de dois fatores já está ativada", "totp_code_incorrect" => "Código TOTP incorreto", "backup_codes" => "Códigos de backup", "download_backup_codes" => "Baixar códigos de backup", "copy_to_clipboard" => "Copiar para a área de transferência", "totp_backup_codes_info" => "Guarde esses códigos em um lugar seguro. Eles podem ser usados para acessar sua conta se você perder o acesso ao aplicativo de autenticação.", "disable_two_factor_authentication" => "Desativar autenticação de dois fatores", "totp_code" => "Código TOTP", "api_key" => "Chave da API", "regenerate" => "Regenerar", "api_key_info" => "A chave da API é usada para acessar a API do Wallos. Não compartilhe sua chave com ninguém.", // Settings page "monthly_budget" => "Orçamento mensal", "budget_info" => "O orçamento mensal é usado para calcular estatísticas", "household" => "Membros", "save_member" => "Salvar membro", "delete_member" => "Excluir membro", "cant_delete_member" => "Não é possível excluir o membro principal", "cant_delete_member_in_use" => "Não é possível excluir um membro em uso em uma assinatura", "household_info" => "O email permite que os membros sejam notificados quando uma assinatura estiver para expirar.", "notifications" => "Notificações", "enable_email_notifications" => "Ativar notificações por email", "notify_me" => "Me notificar", "day_before" => "dia antes", "on_due_date" => "Na data de vencimento", "days_before" => "dias antes", "smtp_address" => "Endereço SMTP", "port" => "Porta", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "Usuário SMTP", "smtp_password" => "Senha SMTP", "from_email" => "Email de envio (Opcional)", "send_to_other_emails" => "Também enviar notificações para os seguintes endereços de email (use ; para separá-los):", "smtp_info" => "A senha do SMTP é transmitida em texto puro. Por segurança, crie uma conta só para esta finalidade.", "telegram" => "Telegram", "telegram_bot_token" => "Token do Bot", "telegram_chat_id" => "Chat ID", "pushplus" => "Pushplus", "pushplus_token" => "Token do Pushplus", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost URL do Webhook", "mattermost_bot_username" => "Mattermost Bot Nome de Usuário", "mattermost_bot_icon_emoji" => "Mattermost Bot Ícone Emoji", "webhook" => "Webhook", "webhook_url" => "URL do Webhook", "request_method" => "Método de requisição", "custom_headers" => "Cabeçalhos personalizados", "webhook_payload" => "Payload do Webhook", "payment_notifications_payload" => "Payload da notificação de pagamento", "cancelation_notification_payload" => "Payload da notificação de cancelamento", "variables_available" => "Variáveis disponíveis", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Nome do Bot", "discord_bot_avatar_url" => "URL do Avatar", "pushover" => "Pushover", "pushover_user_key" => "Chave do Usuário", "host" => "Host", "topic" => "Tópico", "ignore_ssl_errors" => "Ignorar erros SSL", "categories" => "Categorias", "save_category" => "Salvar categoria", "delete_category" => "Excluir categoria", "cant_delete_category_in_use" => "Não é possível excluir uma categoria em uso em uma assinatura", "currencies" => "Moedas", "save_currency" => "Salvar moeda", "delete_currency" => "Excluir moeda", "cant_delete_main_currency" => "Não é possível excluir a moeda principal", "cant_delete_currency_in_use" => "Não é possível excluir uma moeda em uso em uma assinatura", "exchange_update" => "Taxas de câmbio atualizadas em", "currency_info" => "Encontre as moedas suportadas e os códigos de moeda em", "currency_performance" => "Para um melhor desempenho, mantenha apenas as moedas que você utiliza.", "fixer_api_key" => "Chave da API do Fixer", "provider" => "Fornecedor", "fixer_info" => "Se você utiliza múltiplas moedas e deseja ter estatísticas precisas e ordenação das assinaturas, uma chave GRATUÍTA da API do Fixer é necessária.", "get_key" => "Obtenha a sua chave em", "get_free_fixer_api_key" => "Obtenha a sua chave API do Fixer gratuitamente", "get_key_alternative" => "Como alternativa, você pode obter uma chave de API grátis em", "ai_model" => "Modelo de IA", "select_ai_model" => "Selecionar modelo de IA", "run_schedule" => "Agendamento de execução", "manually" => "Manual", "coming_soon" => "Em breve", "invalid_host" => "Host inválido", "ai_recommendations_info" => "As recomendações de IA são geradas com base em suas assinaturas e membros da família.", "may_take_time" => "Dependendo do provedor, modelo e número de assinaturas, a geração de recomendações pode levar algum tempo.", "recommendations_visible_on_dashboard" => "As recomendações serão visíveis no painel.", "generate_recommendations" => "Gerar recomendações", "display_settings" => "Configurações de exibição", "theme_settings" => "Configurações de tema", "colors" => "Cores", "custom_colors" => "Cores personalizadas", "theme" => "Tema", "dark_theme" => "Tema Escuro", "light_theme" => "Tema Claro", "automatic" => "Automático", "main_color" => "Cor principal", "accent_color" => "Cor de destaque", "hover_color" => "Cor de destaque (hover)", "save_custom_colors" => "Salvar Cores Personalizadas", "reset_custom_colors" => "Redefinir Cores Personalizadas", "custom_css" => "CSS Personalizado", "save_custom_css" => "Salvar CSS Personalizado", "calculate_monthly_price" => "Calcular e exibir o custo mensal para todas as assinaturas", "convert_prices" => "Sempre converter e exibir preços na moeda principal (mais lento)", "show_original_price" => "Também mostrar o preço original quando conversões ou cálculos são feitos", "experience" => "Experiência", "show_subscription_progress" => "Mostrar progresso da assinatura", "disabled_subscriptions" => "Assinaturas desativadas", "hide_disabled_subscriptions" => "Ocultar assinaturas desativadas", "show_disabled_subscriptions_at_the_bottom" => "Mostre as assinaturas desativadas no final da lista", "experimental_settings" => "Configurações experimentais", "remove_background" => "Tentar remover o fundo de logos na pesquisa de imagem", "use_mobile_navigation_bar" => "Usar barra de navegação móvel", "experimental_info" => "As configurações experimentais provavelmente não funcionarão corretamente", "payment_methods" => "Métodos de pagamento", "payment_methods_info" => "Clique em um método de pagamento para ativá-lo ou desativá-lo", "rename_payment_methods_info" => "Clique no nome de um método de pagamento para renomeá-lo", "cant_delete_payment_method_in_use" => "Não é possível desativar um método de pagamento em uso", "add_custom_payment" => "Adicionar um método de pagamento personalizado", "payment_method_name" => "Nome do método de pagamento", "payment_method_added_successfuly" => "Método de pagamento adicionado com sucesso", "payment_method_removed" => "Método de pagamento excluído", "disable" => "Desativar", "enable" => "Ativar", "rename_payment_method" => "Renomear método de pagamento", "payment_renamed" => "Método de pagamento renomeado", "payment_not_renamed" => "Método de pagamento não renomeado", "test" => "Testar", "add" => "Adicionar", "save" => "Salvar", "reset" => "Redefinir", "main_accent_color_error" => "A cor principal e a cor de destaque não podem ser iguais", "backup_and_restore" => "Backup e Restauração", "backup" => "Backup", "restore" => "Restaurar", "restore_info" => "A restauração do banco de dados substituirá todos os dados atuais. Você será desconectado após a restauração.", "account" => "Conta", "export_subscriptions" => "Exportar assinaturas", "export_as_json" => "Exportar como JSON", "export_as_csv" => "Exportar como CSV", "danger_zone" => "Zona de perigo", "delete_account" => "Excluir conta", "delete_account_info" => "Excluir sua conta também excluirá todas as assinaturas e configurações.", // Filters menu "filter" => "Filtrar", "clear" => "Limpar", // Toast "success" => "Sucesso", // Endpoint responses "session_expired" => "Sua sessão expirou. Por favor, faça o login novamente", "fields_missing" => "Alguns campos estão faltando", "fill_all_fields" => "Por favor, preencha todos os campos", "fill_mandatory_fields" => "Por favor, preencha todos os campos obrigatórios", "error" => "Erro", // Category "failed_add_category" => "Erro ao adicionar categoria", "failed_edit_category" => "Erro ao editar categoria", "category_in_use" => "Essa categoria está em uso em uma assinatura e não pode ser removida", "failed_remove_category" => "Erro ao remover categoria", "category_saved" => "Categoria salva", "category_removed" => "Categoria excluída", "sort_order_saved" => "Direção de ordenação salva", // Currency "currency_saved" => "foi salva.", "error_adding_currency" => "Erro ao adicionar moeda.", "failed_to_store_currency" => "Erro ao armazenar moeda no banco de dados", "currency_in_use" => "A moeda está em uso em uma assinatura e não pode ser excluída", "currency_is_main" => "A moeda está configurada como principal e não pode ser excluída", "failed_to_remove_currency" => "Erro ao excluir a moeda do banco de dados", "failed_to_store_api_key" => "Erro ao armazenar a chave da API no banco de dados", "invalid_api_key" => "Chave da API inválida", "api_key_saved" => "Chave da API salva com sucesso", "currency_removed" => "Moeda excluída", // Household "failed_add_household" => "Erro ao adicionar membro", "failed_edit_household" => "Erro ao editar membro", "failed_remove_household" => "Erro ao excluir membro", "household_in_use" => "O membro está em uso em uma assinatura e não pode ser removido", "member_saved" => "Membro salvo", "member_removed" => "Membro excluído", // Notifications "error_saving_notifications" => "Error ao salvar dados de notificação", "wallos_notification" => "Notificação do Wallos", "test_notification" => "Essa é uma notificação de teste. Se você está vendo isso, a configuração está correta.", "email_error" => "Erro ao enviar email", "notification_sent_successfuly" => "Notificação enviada com sucesso", "notifications_settings_saved" => "Configurações de notificação salvas com sucesso", "notification_failed" => "Falha ao enviar notificação", // Payments "payment_in_use" => "Não é possível desativar o método de pagamento", "failed_update_payment" => "Erro ao atualizar o método de pagamento no banco de dados.", "enabled" => "ativado", "disabled" => "desativado", // Subscription "error_fetching_image" => "Erro ao carregar imagem", "subscription_updated_successfuly" => "Assinatura atualizada com sucesso", "subscription_added_successfuly" => "Assinatura adicionar com sucesso", "error_deleting_subscription" => "Erro ao excluir assinatura", "invalid_request_method" => "Método de requisição inválido", // User "error_updating_user_data" => "Erro ao atualizar os dados do usuário", "user_details_saved" => "Dados do usuário salvos", // Admin Page "registrations" => "Registros", "enable_user_registrations" => "Ativar registros de usuários", "maximum_number_users" => "Número máximo de usuários", "require_email_verification" => "Requerer verificação de email", "configure_smtp_settings_to_enable" => "Configure as configurações SMTP para ativar o envio de email", "server_url" => "URL do servidor", "server_url_info" => "Será usado para gerar links de verificação de email, deve ser um endereço público e válido.", "server_url_password_reset" => "Se definido, também ativará a funcionalidade de redefinição de senha.", "disable_login" => "Desativar login", "disable_login_info" => "Ignorar login. Se você executar o servidor somente em uma rede local, sem acesso externo, poderá desativar o login. Isso fará o login automático do usuário administrador.", "disable_login_info2" => "Só é possível ativar essa configuração se o registro de usuário estiver desativado e não houver mais do que a conta de usuário administrador.", "max_users_info" => "0 para ilimitado", "user_management" => "Gerenciamento de usuários", "delete_user" => "Excluir usuário", "delete_user_info" => "Excluir um usuário também excluirá todas as assinaturas e dados associados", "create_user" => "Criar usuário", "oidc_settings" => "Configurações OIDC", "oidc_auth_enabled" => "Habilitar autenticação OIDC", "create_user_automatically" => "Criar usuário automaticamente", "disable_password_login" => "Desativar login por senha", "smtp_settings" => "Configurações SMTP", "smtp_usage_info" => "Será usado para recuperação de senha e outros e-mails do sistema.", "security_settings" => "Configurações de Segurança", "ssrf_protection_info" => "Para evitar ataques Server-Side Request Forgery (SSRF), o Wallos bloqueia por padrão notificações webhook para endereços de rede privados ou internos.", "local_webhook_info" => "Se precisar enviar webhooks para serviços locais (como Home Assistant, Gotify ou Node-RED), insira seus endereços IP ou nomes de host acima como uma lista separada por vírgulas (por exemplo 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Tarefas de manutenção", "orphaned_logos" => "Logos órfãos", "update" => "Atualizar", "new_version_available" => "Nova versão do Wallos disponível", "current_version" => "Versão atual", "latest_version" =>"Última versão", "on_current_version" => "Você está na última versão do Wallos.", "show_update_notification" => "Mostrar notificação de atualização no dashboard", "Cronjobs" => "Cronjobs", // Email Verification "email_verified" => "Email verificado", "email_verification_failed" => "Falha na verificação do email", // Calendar "calendar" => "Calendário", "sun" => "Dom", "mon" => "Seg", "tue" => "Ter", "wed" => "Qua", "thu" => "Qui", "fri" => "Sex", "sat" => "Sáb", "month-01" => "Janeiro", "month-02" => "Fevereiro", "month-03" => "Março", "month-04" => "Abril", "month-05" => "Maio", "month-06" => "Junho", "month-07" => "Julho", "month-08" => "Agosto", "month-09" => "Setembro", "month-10" => "Outubro", "month-11" => "Novembro", "month-12" => "Dezembro", "total_cost" => "Custo total", "export_icalendar" => "Exportar iCalendar", "over_budget_warning" => "Você está acima do orçamento", // TOTP Page "insert_totp_code" => "Insira o código TOTP", ]; ?> ================================================ FILE: includes/i18n/ro.php ================================================ "Trebuie să creezi un cont înainte de a te putea conecta.", "username" => "Nume utilizator", "password" => "Parolă", "firstname" => "Prenume", "lastname" => "Nume", "email" => "E-mail", "confirm_password" => "Confirmă parola", "main_currency" => "Valuta principală", "language" => "Limbă", "passwords_dont_match" => "Parolele nu sunt identice", "username_exists" => "Acest nume de utilizator există deja", "email_exists" => "Acest e-mail există deja", "registration_failed" => "Înregistrarea a eșuat, încearcă din nou", "register" => "Înregistrează-te", "restore_database" => "Restaurare bază de date", // Login Page "please_login" => "Conectează-te", "stay_logged_in" => "Rămâi conectat (30 zile)", "login" => "Conectare", "login_with" => "Conectează-te cu", "or" => "sau", "login_failed" => "Date de conectare incorecte", "registration_successful" => "Înregistrare reușită!", "user_email_waiting_verification" => "Adresa de e-mail trebuie confirmată. Te rugăm să-ți verifici e-mailul.", // Password Reset Page "forgot_password" => "Ai uitat parola?", "reset_password" => "Resetare Parolă", "reset_sent_check_email" => "E-mail de resetare trimis. Te rugăm să-ți verifici e-mailul.", "password_reset_successful" => "Parola a fost actualizată", // Header "profile" => "Profil", "dashboard" => "Acasă", "subscriptions" => "Abonamente", "stats" => "Statistici", "settings" => "Setări", "admin" => "Admin", "about" => "Despre", "logout" => "Deconectare", // Dashboard "hello" => "Salut", "upcoming_payments" => "Plăți scadente", "no_upcoming_payments" => "Nu există plăți scadente", "overdue_renewals" => "Reînnoiri scadente", "ai_recommendations" => "Recomandări AI", "your_budget" => "Bugetul tău", "budget" => "Buget", "budget_used" => "Buget folosit", "over_budget" => "Peste Buget", "your_subscriptions" => "Abonamentele tale", "your_savings" => "Economiile tale", // Subscriptions page "subscription" => "Abonament", "no_subscriptions_yet" => "Nu ai încă niciun abonament", "add_first_subscription" => "Adaugă primul abonament", "new_subscription" => "Abonament nou", "search" => "Caută", "state" => "Status", "alphanumeric" => "Alfanumeric", "sort" => "Sortează", "name" => "Nume", "last_added" => "Ultimul adăugat", "price" => "Cost", "next_payment" => "Următoarea plată", "renewal_type" => "Tip reînoire", "auto_renewal" => "Reînoire automată", "automatically_renews" => "Se reînoiește automat", "manual_renewal" => "Reînoire manuală", "start_date" => "Dată început", "inactive" => "Dezactivează abonament", "replaced_with" => "Înlocuit cu", "none" => "Niciunul", "member" => "Membru", "category" => "Categorie", "payment_method" => "Metodă de plată", "Daily" => "Zilnic", "Weekly" => "Săptămânal", "Monthly" => "Lunar", "Yearly" => "Anual", "daily" => "Zile", "weekly" => "Săptămâni", "monthly" => "Luni", "yearly" => "Ani", "days" => "zile", "weeks" => "săptămâni", "months" => "luni", "years" => "ani", "external_url" => "Accesează URL extern", "empty_page" => "Pagină goală", "clear_filters" => "Golește filtre", "no_matching_subscriptions" => "Nicio potrivire", "clone" => "Dublează", "renew" => "Reînoiește", "calculate_next_payment_date" => "Calculează data următoare-i tranzacții", // Subscription form "add_subscription" => "Adaugă abonament", "edit_subscription" => "Modifică abonament", "subscription_name" => "Titlu abonament", "logo_preview" => "Previzualizare Logo", "search_logo" => "Caută Logo", "web_search" => "Căutare Web", "currency" => "Valută", "payment_every" => "Plată la fiecare", "frequency" => "Frecvență", "cycle" => "Ciclu", "no_category" => "Nicio categorie", "paid_by" => "Plătit de", "url" => "URL", "notes" => "Notițe", "enable_notifications" => "Activează notificările pentru acest abonament", "default_value_from_settings" => "Valoarea standard din setări", "cancellation_notification" => "Notificare de anulare", "delete" => "Șterge", "cancel" => "Anulează", "upload_logo" => "Încarcă Logo", // Statistics page "cant_convert_currency" => "Folosești mai multe valute pentru abonamentele tale. Pentru a obține statistici valide și precise, te rugăm să setezi o cheie API Fixer pe pagina de setări.", "general_statistics" => "Statistici generale", "active_subscriptions" => "Abonamente active", "inactive_subscriptions" => "Abonamente inactive", "monthly_cost" => "Costuri lunare", "yearly_cost" => "Costuri anuale", "average_monthly" => "Cost mediu lunar de abonament", "most_expensive" => "Cel mai scump abonament", "amount_due" => "De plătit în acestă lună", "percentage_budget_used" => "Procentul bugetului utilizat", "budget_remaining" => "Rămas din buget", "amount_over_budget" => "Suma peste buget", "monthly_savings" => "Economii lunare (abonamente inactive)", "yearly_savings" => "Economii anuale (abonamente inactive)", "split_views" => "Vizualizare împărțită", "category_split" => "După categorii", "household_split" => "După membrii familiei", "payment_method_split" => "După metoda de plată", "total_cost_trend" => "Evoluția costului total", "cost_vs_budget" => "Costuri vs Buget", // About page "about_and_credits" => "Despre și merite", "credits" => "Merite", "license" => "Licență", "release_notes" => "Note despre versiune", "update_available" => "Actualizare disponibilă", "issues_and_requests" => "Probleme și cereri", "the_author" => "Autorul", "icons" => "Icon-uri", "payment_icons" => "Icon plată", // Profile page "upload_avatar" => "Încarcă Avatar", "file_type_error" => "Tipul de fișier nu este permis", "user_details" => "Detalii utilizator", "two_factor_authentication" => "Autentificare în doi pași", "two_factor_info" => "Autentificarea în doi pași adaugă un nivel suplimentar de securitate contului tău.
Vei avea nevoie de o aplicație de autentificare, cum ar fi Google Authenticator, Authy sau Ente Auth, pentru a scana codul QR.", "two_factor_enabled_info" => "Contul tău este securizat cu autentificarea în doi pași. Poți să o dezactivezi făcând clic pe butonul de mai sus.", "enable_two_factor_authentication" => "Activează autentificarea în doi pași", "2fa_already_enabled" => "Autentificarea în doi pași este deja activă", "totp_code_incorrect" => "Codul TOTP este incorect", "backup_codes" => "Coduri de rezervă", "download_backup_codes" => "Descarcă codurile de rezervă", "copy_to_clipboard" => "Copiază în Clipboard", "totp_backup_codes_info" => "Aceste coduri pot fi utilizate pentru a te conecta dacă pierzi accesul la aplicația de autentificare.", "disable_two_factor_authentication" => "Dezactivează autentificarea în doi pași", "totp_code" => "Codul TOTP", "api_key" => "Cheia API", "regenerate" => "Regenerează", "api_key_info" => "Cheia API este utilizată pentru a accesa API-ul. Păstreaz-o secretă!", // Settings page "monthly_budget" => "Buget lunar", "budget_info" => "Bugetul lunar este utilizat pentru calcularea statisticilor.", "household" => "Membrii familiei", "save_member" => "Salvează membru", "delete_member" => "Șterge membru", "cant_delete_member" => "Nu poți șterge membrul principal", "cant_delete_member_in_use" => "Nu poți șterge un membru cu un abonament activ", "household_info" => "Câmpul de e-mail permite membrilor familiei să fie notificați cu privire la abonamentele care urmează să expire.", "notifications" => "Notificări", "enable_email_notifications" => "Activează notificări e-mail", "notify_me" => "Notifică-mă", "day_before" => "zi înainte", "on_due_date" => "Data scadentă", "days_before" => "Yile înainte", "smtp_address" => "Adresa SMTP", "port" => "Port", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "Utilizator SMTP", "smtp_password" => "Parola SMTP", "from_email" => "De la e-mail (Optional)", "send_to_other_emails" => "Trimiteți notificările și la următoarele adrese de e-mail (folosiți ; pentru a le separa):", "other_emails_placeholder" => "user@domain.com;test@user.com", "smtp_info" => "Parola SMTP este transmisă și stocată în format text simplu. Din motive de securitate, vă rugăm să creați un cont special pentru acest scop.", "telegram" => "Telegram", "telegram_bot_token" => "Token Telegram Bot", "telegram_chat_id" => "ID-ul de Chat Telegram", "pushplus" => "Pushplus", "pushplus_token" => "Token-ul Pushplus", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "WebHook URL", "mattermost_bot_username" => "Bot nume utillizator", "mattermost_bot_icon_emoji" => "Bot Icon Emoji", "webhook" => "Webhook", "webhook_url" => "Webhook URL", "request_method" => "Metoda de solicitare", "custom_headers" => "Titluri personalizate", "webhook_payload" => "Titluri personalizate", "payment_notifications_payload" => "Payload Notificare de plată", "cancelation_notification_payload" => "Payload Notificare de anulare", "variables_available" => "Variabile disponibile", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Nume utillizator Discord Bot", "discord_bot_avatar_url" => "Discord Bot URL Avatar ", "pushover" => "Pushover", "pushover_user_key" => "Pushover User Key", "host" => "Host", "topic" => "Topic", "ignore_ssl_errors" => "Ignoră erori SSL", "categories" => "Categorii", "save_category" => "Salvează categorie", "delete_category" => "Șterge categorie", "cant_delete_category_in_use" => "Nu poți șterge o categorie utilizată într-un abonament", "currencies" => "Valută", "save_currency" => "Salvează valută", "delete_currency" => "Șterge valută", "cant_delete_main_currency" => "Nu poți șterge valuta principală", "cant_delete_currency_in_use" => "Nu poți șterge o valută utilizată într-un abonament", "exchange_update" => "Cursurile de schimb actualizate ultima dată la", "currency_info" => "Găsești valutele acceptate și codurile valutare corecte pe", "currency_performance" => "Pentru o performanță îmbunătățită, păstreapăstrează numai valutele pe care le utilizezi.", "fixer_api_key" => "Cheie API Fixer ", "provider" => "Furnizor", "fixer_info" => "Dacă utilizezi mai multe monede și dorești statistici precise și sortarea abonamentelor, este necesară o cheie API GRATUITĂ de la Fixer.", "get_key" => "Obține cheia la", "get_free_fixer_api_key" => "Obține gratuit cheia API Fixer", "get_key_alternative" => "Alternativ, poți obține o cheie API fixer gratuită de la", "ai_model" => "Model AI", "select_ai_model" => "Selectează modelul AI", "run_schedule" => "Rulează", "manually" => "Manual", "coming_soon" => "Disponibil curând", "invalid_host" => "Host invalid", "ai_recommendations_info" => "Recomandările AI sunt generate pe baza abonamentelor dvs. și a membrilor familiei.", "may_take_time" => "În funcție de furnizor, model și numărul de abonamente, generarea recomandărilor poate dura ceva timp.", "recommendations_visible_on_dashboard" => "Recomandările vor fi vizibile pe panoul Acasă.", "generate_recommendations" => "Generează recomandări", "display_settings" => "Afișează setări", "theme_settings" => "Setări temă", "colors" => "Culori", "custom_colors" => "Culori personalizate", "theme" => "Temă", "dark_theme" => "Întunecat", "light_theme" => "Luminos", "automatic"=> "Automat", "main_color" => "Culoare principală", "accent_color" => "Culoare de accent", "hover_color" => "Culoare la Hover", "save_custom_colors" => "Salvează culori personalizate", "reset_custom_colors" => "Resetează culori personalizate", "custom_css" => "CSS personalizat", "save_custom_css" => "Salvează CSS personalizat", "calculate_monthly_price" => "Calculează și afișează prețul lunar pentru toate abonamentele", "convert_prices" => "Convertește și afișează întotdeauna prețurile în moneda mea principală (mai lent)", "show_original_price" => "Afișează și prețul inițial atunci când se efectuează conversii sau calcule.", "experience" => "Experiență", "show_subscription_progress" => "Afișează progresul abonamentului", "disabled_subscriptions" => "Abonamente dezactivate", "hide_disabled_subscriptions" => "Ascunde abonamentele dezactivate", "show_disabled_subscriptions_at_the_bottom" => "Afișează abonamentele dezactivate în partea de jos", "experimental_settings" => "Setări experimentale", "remove_background" => "Încearcă să elimini fundalul logo-urilor din căutarea de imagini", "use_mobile_navigation_bar" => "Folosește bara de navigare mobilă", "experimental_info" => "Setările experimentale probabil nu vor funcționa perfect.", "payment_methods" => "Metode de plată", "payment_methods_info" => "Faceți clic pe o metodă de plată pentru a o dezactiva/activa.", "rename_payment_methods_info" => "Faceți clic pe numele unei metode de plată pentru a o redenumi.", "cant_delete_payment_method_in_use" => "Nu se poate dezactiva metodă de plată în uz", "add_custom_payment" => "Adaugă metodă de plată personalizată", "payment_method_name" => "Numele metodei de plată", "payment_method_added_successfuly" => "Metoda de plată adăugată cu succes", "payment_method_removed" => "Metodă de plată eliminată", "disable" => "Dezactivează", "enable" => "Activează", "rename_payment_method" => "Redenumirea metodei de plată", "payment_renamed" => "Metodă de plată redenumită", "payment_not_renamed" => "Metoda de plată nu a fost redenumită", "test" => "Test", "add" => "Adaugă", "save" => "Salvează", "reset" => "Resetează", "main_accent_color_error" => "Culoarea principală și culoarea de accent nu pot fi identice.", "backup_and_restore" => "Backup și restaurare", "backup" => "Backup", "restore" => "Restaurează", "restore_info" => "Restaurarea bazei de date va suprascrie toate datele actuale. Vei fi deconectat după restaurare.", "account" => "Cont", "export_subscriptions" => "Export Abonamente", "export_as_json" => "Export ca JSON", "export_as_csv" => "Export ca CSV", "danger_zone" => "Zona periculoasă", "delete_account" => "Șterge cont", "delete_account_info" => "Ștergerea contului va șterge și toate abonamentele și setările.", // Filters menu "filter" => "Filtrează", "clear" => "Golește", // Toast "success" => "Succes", // Endpoint responses "session_expired" => "Sesiunea a expirat. Te rugăm să te autentifici din nou.", "fields_missing" => "Unele câmpuri lipsesc", "fill_all_fields" => "Te rugăm, completează toate câmpurile", "fill_mandatory_fields" => "Te rugăm, completează toate câmpurile obligatorii", "error" => "Erori", // Category "failed_add_category" => "Nu s-a reușit adăugarea categoriei", "failed_edit_category" => "Nu s-a reușit editarea categoriei", "category_in_use" => "Categoria este în uz și nu poate fi eliminată.", "failed_remove_category" => "Nu s-a reușit eliminarea categoriei", "category_saved" => "Categorie salvată", "category_removed" => "Categorie eliminată", "sort_order_saved" => "Sortare salvată", // Currency "currency_saved" => "a fost salvată.", "error_adding_currency" => "Eroare la adăugarea valutei.", "failed_to_store_currency" => "Nu s-a reușit stocarea monedei în baza de date.", "currency_in_use" => "Valuta este în uz și nu poate fi ștersă.", "currency_is_main" => "Valuta este setată ca monedă principală și nu poate fi ștersă.", "failed_to_remove_currency" => "Nu s-a reușit eliminarea valutei din baza de date", "failed_to_store_api_key" => "Nu s-a reușit stocarea cheii API în baza de date", "invalid_api_key" => "Cheie API invalidă.", "api_key_saved" => "Cheie API salvată cu succes", "currency_removed" => "Valută ștearsă", // Household "failed_add_household" => "Nu s-a reușit adăugarea membrului familiei", "failed_edit_household" => "Nu s-a putut edita membrul familiei", "failed_remove_household" => "Nu s-a reușit eliminarea membrului familiei", "household_in_use" => "Membrul familiei este în uz în abonamente și nu poate fi eliminat", "member_saved" => "Membru adăugat", "member_removed" => "Membru șters", // Notifications "error_saving_notifications" => "Eroare la salvarea datelor notificărilor.", "wallos_notification" => "Notificare Wallos", "test_notification" => "Aceasta este o notificare de test. Dacă vedeți acest mesaj, configurația este corectă.", "email_error" => "Eroare la trimiterea e-mailului", "notification_sent_successfuly" => "Notificare trimisă cu succes", "notifications_settings_saved" => "Setările de notificare au fost salvate cu succes.", "notification_failed" => "Notificare eșusată", // Payments "payment_in_use" => "Nu se poate dezactiva o metodă de plată în uz", "failed_update_payment" => "Nu s-a reușit actualizarea metodei de plată în baza de date", "enabled" => "activat", "disabled" => "dezactivat", // Subscription "error_fetching_image" => "Eroare la afișarea imagini", "subscription_updated_successfuly" => "Abonamentul a fost actualizat cu succes", "subscription_added_successfuly" => "Abonamentul a fost adăugat cu succes", "error_deleting_subscription" => "Eroare la ștergerea abonamentului.", "invalid_request_method" => "Metodă de solicitare invalidă.", // User "error_updating_user_data" => "Eroare la actualizarea datelor utilizatorului.", "user_details_saved" => "Detalii utilizator salvate", // Admin Page "registrations" => "Înregistrări", "enable_user_registrations" => "Activează înregistrarea utilizatorilor", "maximum_number_users" => "Număr maxim de utlizatori", "require_email_verification" => "Solicită verificarea adresei de e-mail", "configure_smtp_settings_to_enable" => "Configurează setările SMTP pentru a activa", "server_url" => "URL Server", "server_url_info" => "Utilizat pentru verificarea adresei de e-mail și recuperarea parolei. Trebuie să fie o adresă URL publică validă.", "server_url_password_reset" => "Dacă este setată, va activa și funcționalitatea de resetare a parolei.", "disable_login" => "Dezactivează autentificarea", "disable_login_info" => "Omite autentificarea. Dacă rulezi serverul într-o rețea locală, fără acces extern, poți dezactiva autentificarea. Acest lucru va autentifica automat utilizatorul administrator.", "disable_login_info2" => "Poți activa această setare numai dacă înregistrarea utilizatorilor este dezactivată și nu există alt cont decât cel de administrator.", "max_users_info" => "0 înseamnă nelimitat", "user_management" => "Management utilizatori", "delete_user" => "Șterge utilizator", "delete_user_info" => "Ștergerea unui utilizator va șterge implicit toate abonamentele și setările acestuia.", "create_user" => "Crează utilizator", "oidc_settings" => "Setări OIDC", "oidc_oauth_enabled" => "Activează OIDC/OAuth", "create_user_automatically" => "Crează utilizatori automat", "disable_password_login" => "Dezactivează logarea cu parolă", "smtp_settings" => "Setări SMTP", "smtp_usage_info" => "Va fi utilizată pentru recuperarea parolei și alte e-mailuri de sistem.", "security_settings" => "Setări de srcuritate", "ssrf_protection_info" => "Pentru a preveni atacurile de tip Server-Side Request Forgery (SSRF), Wallos blochează în mod implicit notificările webhook către adrese de rețea private sau interne.", "local_webhook_info" => "Dacă trebuie să trimți webhook-uri către servicii locale (cum ar fi Home Assistant, Gotify sau Node-RED), introdu adresele IP sau numele de gazdă ale acestora mai sus, sub forma unei liste separate prin virgule (de exemplu, 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Sarcini întreținere", "orphaned_logos" => "Logo-uri fără uz", "update" => "Actualizare", "new_version_available" => "O nouă versiune Wallos este disponibilă", "current_version" => "Versiunea curentă", "latest_version" => "Ultima versiune", "on_current_version" => "Folosești cea mai recentă versiune Wallos.", "show_update_notification" => "Afișează notificări pentru actualizări pe panoul Acasă", "cronjobs" => "Cronjobs", // e-mail Verification "email_verified" => "Succes! e-mailul a fost verificat", "email_verification_failed" => "Verificarea e-mailului a eșuat", // Calendar "calendar" => "Calendar", "sun" => "Dum", "mon" => "Lu", "tue" => "Ma", "wed" => "Mie", "thu" => "Joi", "fri" => "Vin", "sat" => "Sâm", "month-01" => "Ianuarie", "month-02" => "Februarie", "month-03" => "Marz", "month-04" => "Aprilie", "month-05" => "Mai", "month-06" => "Iunie", "month-07" => "Iulie", "month-08" => "August", "month-09" => "Septembrie", "month-10" => "Octombrie", "month-11" => "Noiembrie", "month-12" => "Decembrie", "total_cost" => "Cost total", "export_icalendar" => "Exportează iCalendar", "over_budget_warning" => "Ai trecut peste buget", // TOTP Page "insert_totp_code" => "Inserează codul TOTP", ]; ?> ================================================ FILE: includes/i18n/ru.php ================================================ "Вам необходимо создать учетную запись, прежде чем вы сможете войти в систему", "username" => "Имя пользователя", "password" => "Пароль", "email" => "E-mail", "firstname" => "Имя", "lastname" => "Фамилия", "confirm_password" => "Подтвердите пароль", "main_currency" => "Основная валюта", "language" => "Язык", "passwords_dont_match" => "Пароли не совпадают", "username_exists" => "Имя пользователя уже существует", "email_exists" => "E-mail уже существует", "registration_failed" => "Регистрация не удалась, попробуйте еще раз.", "register" => "Регистрация", "restore_database" => "Восстановить базу данных", // Login Page "please_login" => "Пожалуйста, войдите", "stay_logged_in" => "Оставаться в системе (30 дней)", "login" => "Авторизоваться", "login_with" => "Войти с помощью", "or" => "или", "login_failed" => "Данные для входа неверны", "registration_successful" => "Регистрация прошла успешно", "user_email_waiting_verification" => "Ваша электронная почта нуждается в проверке. Пожалуйста, проверьте свою электронную почту", // Password Reset Page "forgot_password" => "Забыли пароль?", "reset_password" => "Сбросить пароль", "reset_sent_check_email" => "Ссылка для сброса пароля отправлена на вашу электронную почту", "password_reset_successful" => "Пароль успешно сброшен", // Header "profile" => "Профиль", "dashboard" => "Панель", "subscriptions" => "Подписки", "stats" => "Статистика", "settings" => "Настройки", "admin" => "Администратор", "about" => "О программе", "logout" => "Выйти", // Dashboard "hello" => "Привет", "upcoming_payments" => "Предстоящие платежи", "no_upcoming_payments" => "У вас нет предстоящих платежей", "overdue_renewals" => "Просроченные продления", "ai_recommendations" => "Рекомендации ИИ", "your_budget" => "Ваш бюджет", "budget" => "Бюджет", "budget_used" => "Использованный бюджет", "over_budget" => "Превышение бюджета", "your_subscriptions" => "Ваши подписки", "your_savings" => "Ваши сбережения", // Subscriptions page "subscription" => "Подписка", "no_subscriptions_yet" => "У вас пока нет подписок", "add_first_subscription" => "Добавить первую подписку", "new_subscription" => "Новая подписка", "search" => "Поиск", "state" => "Состояние", "alphanumeric" => "Алфавитный порядок", "sort" => "Сортировка", "name" => "Имя", "last_added" => "Дата создания", "price" => "Стоимость", "next_payment" => "Следующий платеж", "renewal_type" => "Тип продления", "auto_renewal" => "Автоматическое продление", "automatically_renews" => "Автоматическое продление", "manual_renewal" => "Ручное продление", "start_date" => "Дата начала", "inactive" => "Отключить подписку", "replaced_with" => "Заменена на", "none" => "Нет", "member" => "Член семьи", "category" => "Категория", "payment_method" => "Способ оплаты", "Daily" => "День", "Weekly" => "Неделя", "Monthly" => "Месяц", "Yearly" => "Год", "daily" => "День", "weekly" => "Неделя", "monthly" => "Месяц", "yearly" => "Год", "days" => "дней", "weeks" => "недель", "months" => "месяцев", "years" => "года", "external_url" => "Посетите внешний URL-адрес", "empty_page" => "Пустая страница", "clear_filters" => "Очистить фильтры", "no_matching_subscriptions" => "Нет подходящих подписок", "clone" => "Клонировать", "renew" => "Продлить", "calculate_next_payment_date" => "Рассчитать дату следующего платежа", // Subscription form "add_subscription" => "Добавить подписку", "edit_subscription" => "Изменить подписку", "subscription_name" => "Название подписки", "logo_preview" => "Предварительный просмотр логотипа", "search_logo" => "Поиск логотипа в Интернете", "web_search" => "Веб-поиск", "currency" => "Валюта", "payment_every" => "Оплата каждые", "frequency" => "Частота", "cycle" => "Цикл", "no_category" => "Нет категории", "paid_by" => "Оплачивает", "url" => "URL", "notes" => "Примечания", "enable_notifications" => "Включить уведомления для этой подписки", "default_value_from_settings" => "Значение по умолчанию из настроек", "cancellation_notification" => "Уведомление об отмене", "delete" => "Удалить", "cancel" => "Отмена", "upload_logo" => "Загрузить логотип", // Statistics page "cant_convert_currency" => "Вы используете несколько валют в своих подписках. Для получения достоверной и точной статистики, пожалуйста, установите API-ключ Fixer на странице настроек.", "general_statistics" => "Общая статистика", "active_subscriptions" => "Активные подписки", "inactive_subscriptions" => "Неактивные подписки", "monthly_cost" => "Ежемесячная стоимость", "yearly_cost" => "Годовая стоимость", "average_monthly" => "Средняя ежемесячная стоимость подписки", "most_expensive" => "Самая дорогая стоимость подписки", "amount_due" => "Сумма к оплате в этом месяце", "percentage_budget_used" => "Процент использования бюджета", "budget_remaining" => "Оставшийся бюджет", "amount_over_budget" => "Сумма превышения бюджета", "monthly_savings" => "Ежемесячная экономия (при неактивных подписках)", "yearly_savings" => "Годовая экономия (при неактивных подписках)", "split_views" => "Подробная статистика", "category_split" => "По категориям", "household_split" => "По членам семьи", "payment_method_split" => "По способам оплаты", "total_cost_trend" => "Тенденция общей стоимости", "cost_vs_budget" => "Стоимость по сравнению с бюджетом", // About page "about_and_credits" => "О компании и авторах", "credits" => "Благодарности", "license" => "Лицензия", "release_notes" => "Заметки о выпуске", "update_available" => "Доступно обновление", "issues_and_requests" => "Проблемы и вопросы", "the_author" => "Автор", "icons" => "Значки", "payment_icons" => "Значки способов оплаты", // Profile page "upload_avatar" => "Загрузить аватар", "file_type_error" => "Указанный тип файла не поддерживается.", "user_details" => "Данные пользователя", "two_factor_authentication" => "Двухфакторная аутентификация", "two_factor_info" => "Двухфакторная аутентификация добавляет дополнительный уровень безопасности к вашей учетной записи.
Для сканирования QR-кода вам понадобится приложение-аутентификатор, например Google Authenticator, Authy или Ente Auth.", "two_factor_enabled_info" => "Ваш аккаунт защищен с помощью двухфакторной аутентификации. Вы можете отключить ее, нажав на кнопку выше.", "enable_two_factor_authentication" => "Включить двухфакторную аутентификацию", "2fa_already_enabled" => "Двухфакторная аутентификация уже включена", "totp_code_incorrect" => "Код TOTP неверен", "backup_codes" => "Резервные коды", "download_backup_codes" => "Скачать резервные коды", "copy_to_clipboard" => "Скопировать в буфер обмена", "totp_backup_codes_info" => "Сохраните эти коды в безопасном месте. Они могут быть использованы для входа в систему, если вы потеряете доступ к приложению аутентификации.", "disable_two_factor_authentication" => "Отключить двухфакторную аутентификацию", "totp_code" => "Код TOTP", "monthly_budget" => "Ежемесячный бюджет", "api_key" => "API ключ", "regenerate" => "Сгенерировать", "api_key_info" => "API ключ используется для доступа к вашим данным через API. Не передавайте его третьим лицам.", // Settings page "budget_info" => "Если вы укажете бюджет, Wallos будет отображать вашу текущую стоимость подписок в сравнении с вашим бюджетом.", "household" => "Семья", "save_member" => "Сохранить члена семьи", "delete_member" => "Удалить члена семьи", "cant_delete_member" => "Не могу удалить основного члена семьи", "cant_delete_member_in_use" => "Невозможно удалить члена семьи, используемого в подписке.", "household_info" => "Поле электронной почты позволяет членам семьи получать уведомления об истечении срока действия подписки.", "notifications" => "Уведомления", "enable_email_notifications" => "Включить уведомления по электронной почте", "notify_me" => "Уведомить меня за", "day_before" => "день до события", "on_due_date" => "в день события", "days_before" => "дня(дней) до события", "smtp_address" => "SMTP-адрес", "port" => "Порт", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "Имя пользователя SMTP", "smtp_password" => "Пароль SMTP", "from_email" => "От кого E-Mail (необязательно)", "send_to_other_emails" => "Также отправлять уведомления на следующие адреса электронной почты (используйте ; для их разделения):", "smtp_info" => "Пароль SMTP передается и сохраняется в виде открытого текста. В целях безопасности создайте учетную запись только для Wallos.", "telegram" => "Telegram", "telegram_bot_token" => "Токен Telegram-бота", "telegram_chat_id" => "Telegram Chat ID", "pushplus" => "Pushplus", "pushplus_token" => "Токен Pushplus", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost Webhook URL", "mattermost_bot_username" => "Mattermost Bot Имя пользователя", "mattermost_bot_icon_emoji" => "Mattermost Bot Иконка Emoji", "webhook" => "Webhook", "webhook_url" => "Webhook URL", "request_method" => "Метод запроса", "custom_headers" => "Пользовательские заголовки", "webhook_payload" => "Webhook Payload", "payment_notifications_payload" => "Payload уведомлений о платеже", "cancelation_notification_payload" => "Уведомление об отмене подписки Payload", "variables_available" => "Доступные переменные", "gotify" => "Gotify", "token" => "Токен", "discord" => "Discord", "discord_bot_username" => "Имя пользователя бота Discord", "discord_bot_avatar_url" => "URL-адрес аватара бота Discord", "pushover" => "Pushover", "pushover_user_key" => "Ключ пользователя Pushover", "host" => "Хост", "topic" => "Тема", "ignore_ssl_errors" => "Игнорировать ошибки SSL", "categories" => "Категории", "save_category" => "Сохранить категорию", "delete_category" => "Удалить категорию", "cant_delete_category_in_use" => "Невозможно удалить категорию, используемую в подписке.", "currencies" => "Валюты", "save_currency" => "Сохранить валюту", "delete_currency" => "Удалить валюту", "cant_delete_main_currency" => "Не могу удалить основную валюту", "cant_delete_currency_in_use" => "Невозможно удалить валюту, используемую в подписке.", "exchange_update" => "Курсы валют последний раз обновлялись", "currency_info" => "Найдите поддерживаемые валюты и правильные коды валют на", "currency_performance" => "Для повышения производительности сохраняйте только те валюты, которые вы используете.", "fixer_api_key" => "Ключ Fixer API", "provider" => "Провайдер", "fixer_info" => "Если вы используете несколько валют и хотите получить точную статистику и сортировку подписок, вам необходим БЕСПЛАТНЫЙ ключ API от Fixer.", "get_key" => "Получите ключ по адресу", "get_free_fixer_api_key" => "Получите бесплатный ключ API Fixer", "get_key_alternative" => "Кроме того, вы можете получить бесплатный ключ API Fixer на сайте", "ai_model" => "Модель ИИ", "select_ai_model" => "Выбрать модель ИИ", "run_schedule" => "Запустить расписание", "manually" => "Вручную", "coming_soon" => "Скоро", "invalid_host" => "Неверный хост", "ai_recommendations_info" => "Рекомендации ИИ генерируются на основе ваших подписок и членов семьи.", "may_take_time" => "В зависимости от провайдера, модели и количества подписок генерация рекомендаций может занять некоторое время.", "recommendations_visible_on_dashboard" => "Рекомендации будут видны на панели управления.", "generate_recommendations" => "Сгенерировать рекомендации", "display_settings" => "Настройки отображения", "theme_settings" => "Настройки темы", "colors" => "Цвета", "custom_colors" => "Пользовательские цвета", "theme" => "Тема", "dark_theme" => "Темная тема", "light_theme" => "Светлая тема", "automatic" => "Автоматически", "main_color" => "Основной цвет", "accent_color" => "Акцентный цвет", "hover_color" => "Цвет при наведении", "save_custom_colors" => "Сохранить пользовательские цвета", "reset_custom_colors" => "Сбросить пользовательские цвета", "custom_css" => "Пользовательский CSS", "save_custom_css" => "Сохранить пользовательский CSS", "calculate_monthly_price" => "Рассчитать и показать ежемесячную цену для всех подписок", "convert_prices" => "Всегда конвертировать и показывать цены в моей основной валюте (медленнее)", "show_original_price" => "Также показывать оригинальную цену при выполнении конверсий или расчетов", "experience" => "Опыт", "show_subscription_progress" => "Показать прогресс подписки", "disabled_subscriptions" => "Отключенные подписки", "hide_disabled_subscriptions" => "Скрыть отключенные подписки", "show_disabled_subscriptions_at_the_bottom" => "Показать отключенные подписки внизу списка", "experimental_settings" => "Экспериментальные настройки", "remove_background" => "Попытка удалить фон логотипов из поиска изображений.", "use_mobile_navigation_bar" => "Использовать мобильную панель навигации", "experimental_info" => "Экспериментальные настройки, вероятно, не будут работать идеально.", "payment_methods" => "Способы оплаты", "payment_methods_info" => "Нажмите на способ оплаты, чтобы отключить/включить его.", "rename_payment_methods_info" => "Нажмите на название способа оплаты, чтобы переименовать его.", "cant_delete_payment_method_in_use" => "Невозможно отключить используемый способ оплаты", "add_custom_payment" => "Добавить собственный способ оплаты", "payment_method_name" => "Название способа оплаты", "payment_method_added_successfuly" => "Способ оплаты успешно добавлен", "payment_method_removed" => "Способ оплаты удален.", "disable" => "Отключить", "enable" => "Включить", "rename_payment_method" => "Переименовать способ оплаты", "payment_renamed" => "Способ оплаты переименован", "payment_not_renamed" => "Способ оплаты не переименован", "test" => "Тест", "add" => "Добавить", "save" => "Сохранить", "reset" => "Перезагрузить", "main_accent_color_error" => "Основной и акцентный цвет не могут быть одинаковыми.", "backup_and_restore" => "Резервное копирование и восстановление", "backup" => "Резервное копирование", "restore" => "Восстановление", "restore_info" => "Восстановление базы данных отменит все текущие данные. После восстановления вы выйдете из системы.", "account" => "Учетная запись", "export_subscriptions" => "Экспорт подписок", "export_as_json" => "Экспорт в JSON", "export_as_csv" => "Экспорт в CSV", "danger_zone" => "Опасная зона", "delete_account" => "Удалить учетную запись", "delete_account_info" => "При удалении аккаунта также будут удалены все ваши подписки и настройки.", // Filters menu "filter" => "Фильтр", "clear" => "Очистить", // Toast "success" => "Успешно", // Endpoint responses "session_expired" => "Срок действия вашей сессии истек. Пожалуйста, войдите снова", "fields_missing" => "Некоторые поля отсутствуют", "fill_all_fields" => "Пожалуйста заполните все поля", "fill_mandatory_fields" => "Пожалуйста, заполните все обязательные поля", "error" => "Ошибка", // Category "failed_add_category" => "Не удалось добавить категорию", "failed_edit_category" => "Не удалось изменить категорию", "category_in_use" => "Категория используется в подписках и не может быть удалена.", "failed_remove_category" => "Не удалось удалить категорию", "category_saved" => "Категория сохранена", "category_removed" => "Категория удалена", "sort_order_saved" => "Порядок сортировки сохранен.", // Currency "currency_saved" => "сохранено.", "error_adding_currency" => "Ошибка добавления валюты.", "failed_to_store_currency" => "Не удалось сохранить валюту в базе данных.", "currency_in_use" => "Валюта используется в подписках и не может быть удалена.", "currency_is_main" => "Валюта установлена ​​в качестве основной и не может быть удалена.", "failed_to_remove_currency" => "Не удалось удалить валюту из базы данных.", "failed_to_store_api_key" => "Не удалось сохранить ключ API в базе данных.", "invalid_api_key" => "Неверный ключ API.", "api_key_saved" => "Ключ API успешно сохранен", "currency_removed" => "Валюта удалена", // Household "failed_add_household" => "Не удалось добавить члена семьи.", "failed_edit_household" => "Не удалось изменить члена семьи.", "failed_remove_household" => "Не удалось удалить члена семьи.", "household_in_use" => "Член семьи используется в подписках и не может быть удален.", "member_saved" => "Член семьи сохранен", "member_removed" => "Член семьи удален", // Notifications "error_saving_notifications" => "Ошибка сохранения данных уведомлений.", "wallos_notification" => "Уведомление от Wallos", "test_notification" => "Это тестовое уведомление. Если вы видите это, значит, конфигурация правильная.", "email_error" => "Ошибка отправки электронной почты", "notification_sent_successfuly" => "Уведомление успешно отправлено", "notifications_settings_saved" => "Настройки уведомлений успешно сохранены.", "notification_failed" => "Уведомление не удалось", // Payments "payment_in_use" => "Невозможно отключить используемый способ оплаты", "failed_update_payment" => "Не удалось обновить способ оплаты в базе данных.", "enabled" => "включено", "disabled" => "отключено", // Subscription "error_fetching_image" => "Ошибка при загрузке изображения.", "subscription_updated_successfuly" => "Подписка успешно обновлена", "subscription_added_successfuly" => "Подписка успешно добавлена", "error_deleting_subscription" => "Ошибка удаления подписки.", "invalid_request_method" => "Неверный метод запроса.", // User "error_updating_user_data" => "Ошибка обновления пользовательских данных.", "user_details_saved" => "Данные пользователя сохранены.", // Admin Page "registrations" => "Регистрации", "enable_user_registrations" => "Включить регистрацию пользователей", "maximum_number_users" => "Максимальное количество пользователей", "require_email_verification" => "Требовать подтверждение по электронной почте", "configure_smtp_settings_to_enable" => "Настройте SMTP, чтобы включить эту функцию.", "server_url" => "URL-адрес сервера", "server_url_info" => "Используется для проверки электронной почты и восстановления пароля. Должен быть действительным публичным URL.", "server_url_password_reset" => "Если этот параметр установлен, он также включает функцию сброса пароля.", "disable_login" => "Отключить вход", "disable_login_info" => "Обход входа в систему. Если вы используете свой сервер только в локальной сети, без доступа извне, вы можете отключить вход в систему. При этом будет автоматически входить пользователь admin.", "disable_login_info2" => "Этот параметр можно включить только в том случае, если регистрация пользователей отключена и их количество не превышает учетную запись администратора.", "max_users_info" => "Установите 0 для неограниченного количества пользователей.", "user_management" => "Управление пользователями", "delete_user" => "Удалить пользователя", "delete_user_info" => "Удаление пользователя также приведет к удалению всех его подписок и настроек.", "create_user" => "Создать пользователя", "oidc_settings" => "Настройки OIDC", "oidc_auth_enabled" => "Включить OIDC аутентификацию", "create_user_automatically" => "Автоматически создавать пользователей", "disable_password_login" => "Отключить вход по паролю", "smtp_settings" => "Настройки SMTP", "security_settings" => "Параметры безопасности", "ssrf_protection_info" => "Чтобы предотвратить атаки Server-Side Request Forgery (SSRF), Wallos по умолчанию блокирует уведомления webhook на приватные или внутренние сетевые адреса.", "local_webhook_info" => "Если вам нужно отправлять вебхуки на локальные сервисы (например, Home Assistant, Gotify или Node-RED), введите их IP-адреса или имена хостов выше через запятую (например 192.168.1.100,192.168.1.101).", "smtp_usage_info" => "Будет использоваться для восстановления пароля и других системных писем.", "maintenance_tasks" => "Задачи обслуживания", "orphaned_logos" => "Потерянные логотипы", "update" => "Обновить", "new_version_available" => "Доступна новая версия Wallos", "current_version" => "Текущая версия", "latest_version" => "Последняя версия", "on_current_version" => "Вы используете последнюю версию Wallos.", "show_update_notification" => "Показывать уведомление об обновлениях на дашборде", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "Ваш адрес электронной почты подтвержден. Теперь вы можете войти.", "email_verification_failed" => "Не удалось подтвердить ваш адрес электронной почты.", // Calendar "calendar" => "Календарь", "sun" => "Вс", "mon" => "Пн", "tue" => "Вт", "wed" => "Ср", "thu" => "Чт", "fri" => "Пт", "sat" => "Сб", "month-01" => "Январь", "month-02" => "Февраль", "month-03" => "Март", "month-04" => "Апрель", "month-05" => "Май", "month-06" => "Июнь", "month-07" => "Июль", "month-08" => "Август", "month-09" => "Сентябрь", "month-10" => "Октябрь", "month-11" => "Ноябрь", "month-12" => "Декабрь", "total_cost" => "Общая стоимость", "export_icalendar" => "Экспорт в iCalendar", "over_budget_warning" => "Вы превысили бюджет", // TOTP Page "insert_totp_code" => "Введите код TOTP", ]; ?> ================================================ FILE: includes/i18n/sl.php ================================================ "Preden se lahko prijavite, morate ustvariti račun", "username" => "Uporabniško ime", "password" => "Geslo", "email" => "E-pošta", "firstname" => "Ime", "lastname" => "Priimek", "confirm_password" => "Potrdite geslo", "main_currency" => "Glavna valuta", "language" => "Jezik", "passwords_dont_match" => "Gesli se ne ujema", "username_exists" => "Uporabniško ime že obstaja", "email_exists" => "E-pošta že obstaja", "registration_failed" => "Registracija ni uspela, poskusite znova.", "register" => "Registrirajte se", "restore_database" => "Obnovi bazo podatkov", // Login Page "please_login" => "Prosim prijavite se", "stay_logged_in" => "Ostanite prijavljeni (30 dni)", "login" => "Prijava", "login_with" => "Prijavite se z", "or" => "ali", "login_failed" => "Podatki za prijavo so napačni", "registration_successful" => "Registracija uspešna", "user_email_waiting_verification" => "Vaš e-poštni naslov je treba preveriti. Prosim, preglejte vašo e-pošto.", // Password Reset Page "forgot_password" => "Ste pozabili geslo", "reset_password" => "Ponastavitev gesla", "reset_sent_check_email" => "E-pošta ponastavitev gesla je bila poslana. Prosim, preglejte vašo e-pošto.", "password_reset_successful" => "Ponastavitev gesla je uspela", // Header "profile" => "Profil", "dashboard" => "Panel", "subscriptions" => "Naročnine", "stats" => "Statistika", "settings" => "Nastavitve", "admin" => "Skrbnik", "about" => "O programu", "logout" => "Odjava", // Dashboard "hello" => "Pozdravljen", "upcoming_payments" => "Prihajajoča plačila", "no_upcoming_payments" => "Nimate prihodnjih plačil", "overdue_renewals" => "Zapadla podaljšanja", "ai_recommendations" => "Priporočila AI", "your_budget" => "Vaš proračun", "budget" => "Proračun", "budget_used" => "Porabljen proračun", "over_budget" => "Prekoračen proračun", "your_subscriptions" => "Vaše naročnine", "your_savings" => "Vaše prihranke", // Subscriptions page "subscription" => "Naročnina", "no_subscriptions_yet" => "Nimate še nobene naročnine", "add_first_subscription" => "Dodaj prvo naročnino", "new_subscription" => "Nova naročnina", "search" => "Iskanje", "state" => "Stanje", "alphanumeric" => "Abecedno", "sort" => "Razvrsti", "name" => "Ime", "last_added" => "Zadnje dodano", "price" => "Cena", "next_payment" => "Naslednje plačilo", "renewal_type" => "Vrsta obnove", "auto_renewal" => "Samodejno obnavljanje", "automatically_renews" => "Se samodejno obnavlja", "manual_renewal" => "Ročno obnavljanje", "start_date" => "Datum začetka", "inactive" => "Onemogoči naročnino", "replaced_with" => "Zamenjano z", "none" => "brez", "member" => "Član", "category" => "Kategorija", "payment_method" => "Način plačila", "Daily" => "Dnevno", "Weekly" => "Tedensko", "Monthly" => "Mesečno", "Yearly" => "Letno", "daily" => "Dan (dni)", "weekly" => "Teden (tednov)", "monthly" => "Mesec (mesecev)", "yearly" => "Leto (leta)", "days" => "dnevi", "weeks" => "tedni", "months" => "meseci", "years" => "leta", "external_url" => "Obiščite zunanji URL", "empty_page" => "Prazna stran", "clear_filters" => "Počisti filter", "no_matching_subscriptions" => "Ni ustreznih naročnin", "clone" => "Klon", "renew" => "Obnovi", "calculate_next_payment_date" => "Izračunaj datum naslednjega plačila", // Subscription form "add_subscription" => "Dodaj naročnino", "edit_subscription" => "Uredi naročnino", "subscription_name" => "Ime naročnine", "logo_preview" => "Predogled logotipa", "search_logo" => "Poišči logotip v spletu", "web_search" => "Spletno iskanje", "currency" => "Valuta", "payment_every" => "Plačilo vsakih", "frequency" => "Ponavljanje", "cycle" => "cikel", "no_category" => "Brez kategorije", "paid_by" => "Plačal", "url" => "URL", "notes" => "Opombe", "enable_notifications" => "Omogoči obvestila za to naročnino", "default_value_from_settings" => "Privzeta vrednost iz nastavitev", "cancellation_notification" => "Obvestilo o preklicu", "delete" => "Izbriši", "cancel" => "Prekliči", "upload_logo" => "Naloži logotip", // Statistics page "cant_convert_currency" => "Pri naročninah uporabljate več valut. Če želite imeti veljavne in točne statistične podatke, na strani z nastavitvami nastavite ključ API Fixer.", "general_statistics" => "Splošna statistika", "active_subscriptions" => "Aktivne naročnine", "inactive_subscriptions" => "Neaktivne naročnine", "monthly_cost" => "Mesečni stroški", "yearly_cost" => "Letni stroški", "average_monthly" => "Povprečni mesečni stroški naročnine", "most_expensive" => "Najdražja cena naročnine", "amount_due" => "Zapadli znesek ta mesec", "percentage_budget_used" => "Odstotek porabljenega proračuna", "budget_remaining" => "Preostali proračun", "amount_over_budget" => "Znesek nad proračunom", "monthly_savings" => "Mesečni prihranek (pri neaktivnih naročninah)", "yearly_savings" => "Letni prihranki (pri neaktivnih naročninah)", "split_views" => "Razdeljeni pogledi", "category_split" => "Razdelitev kategorije", "household_split" => "Razdelitev gospodinjstva", "payment_method_split" => "Razdelitev načina plačila", "total_cost_trend" => "Trend skupnih stroškov", "cost_vs_budget" => "Stroški v primerjavi s proračunom", // About page "about_and_credits" => "O programu in zahvale", "credits" => "Zahvale", "license" => "Licenca", "release_notes" => "Opombe o izdaji", "update_available" => "Na voljo je posodobitev", "issues_and_requests" => "Težave in zahteve", "the_author" => "Avtor", "icons" => "Ikone", "payment_icons" => "Ikone plačil", // Profile page "upload_avatar" => "Naloži avatar", "file_type_error" => "Vrsta datoteke ni podprta.", "user_details" => "Podrobnosti o uporabniku", "two_factor_authentication" => "Dvojna preverba pristnosti", "two_factor_info" => "Two Factor Authentication adds an extra layer of security to your account.
Za optično branje kode QR potrebujete aplikacijo za preverjanje pristnosti, kot so Google Authenticator, Authy ali Ente Auth.", "two_factor_enabled_info" => "Vaš račun je varen z dvostopenjskim preverjanjem pristnosti. Onemogočite jo lahko tako, da kliknete zgornji gumb.", "enable_two_factor_authentication" => "Omogoči dvostopenjsko preverjanje pristnosti", "2fa_already_enabled" => "Dvostopenjsko preverjanje pristnosti je že omogočeno", "totp_code_incorrect" => "Koda TOTP je napačna", "backup_codes" => "Rezervne kode", "download_backup_codes" => "Prenesi rezervne kode", "copy_to_clipboard" => "Kopiraj v odložišče", "totp_backup_codes_info" => "Shranite te rezervne kode na varno mesto. Uporabite jih lahko, če izgubite dostop do svoje aplikacije za preverjanje pristnosti.", "disable_two_factor_authentication" => "Onemogoči dvostopenjsko preverjanje pristnosti", "totp_code" => "TOTP koda", "api_key" => "API ključ", "regenerate" => "Ponovno generiraj", "api_key_info" => "API ključ se uporablja za dostop do vaših podatkov prek API-ja. Če mislite, da je vaš ključ kompromitiran, ga lahko ponovno generirate.", // Settings page "monthly_budget" => "Mesečni proračun", "budget_info" => "Mesečni proračun se uporablja za izračun statistike", "household" => "Gospodinjstvo", "save_member" => "Shrani člana", "delete_member" => "Izbriši člana", "cant_delete_member" => "Ne morem izbrisati glavnega člana", "cant_delete_member_in_use" => "Ne morem izbrisati člana, ki je v uporabi v naročnini", "household_info" => "E-poštno polje omogoča članom gospodinjstva, da so obveščeni o naročninah, ki bodo potekle.", "notifications" => "Obvestila", "enable_email_notifications" => "Omogoči e-poštna obvestila", "notify_me" => "Obvesti me", "day_before" => "dan prej", "on_due_date" => "Na dan zapadlosti", "days_before" => "dni prej", "smtp_address" => "SMTP naslov", "port" => "vrata", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "Uporabniško ime SMTP", "smtp_password" => "Geslo SMTP", "from_email" => "Iz e-pošte (izbirno)", "send_to_other_emails" => "Pošlji obvestila tudi na naslednje e-poštne naslove (uporabi ; za ločevanje):", "smtp_info" => "Geslo SMTP se prenaša in shranjuje v navadnem besedilu. Zaradi varnosti ustvarite račun samo za to.", "telegram" => "Telegram", "telegram_bot_token" => "Telegram Bot žeton", "telegram_chat_id" => "ID klepeta Telegrama", "pushplus" => "Pushplus", "pushplus_token" => "Pushplus žeton", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost Webhook URL", "mattermost_bot_username" => "Mattermost Bot Uporabniško ime", "mattermost_bot_icon_emoji" => "Mattermost Bot Ikona Emoji", "webhook" => "Webhook", "webhook_url" => "Webhook URL", "request_method" => "Metoda zahteve", "custom_headers" => "Glave po meri", "webhook_payload" => "Webhook Payload", "payment_notifications_payload" => "Obvestilo o plačilu", "cancelation_notification_payload" => "Obvestilo o preklicu", "variables_available" => "Spremenljivke, ki so na voljo", "gotify" => "Gotify", "token" => "Žeton", "discord" => "Discord", "discord_bot_username" => "Uporabniško ime za Discord Bot", "discord_bot_avatar_url" => "URL avatarja Discordovega bota", "pushover" => "Pushover", "pushover_user_key" => "Uporabniški ključ Pushover", "host" => "Gostitelj", "topic" => "Tema", "ignore_ssl_errors" => "Prezri napake SSL", "categories" => "Kategorije", "save_category" => "Shrani kategorijo", "delete_category" => "Izbriši kategorijo", "cant_delete_category_in_use" => "Kategorija, ki se uporablja v naročnini, ni mogoče izbrisati", "currencies" => "Valute", "save_currency" => "Shrani valuto", "delete_currency" => "Izbriši valuto", "cant_delete_main_currency" => "Glavne valute ni mogoče izbrisati", "cant_delete_currency_in_use" => "V naročnini ni mogoče izbrisati valute, ki je v uporabi", "exchange_update" => "Menjalni tečaji so bili zadnjič posodobljeni dne", "currency_info" => "Poiščite podprte valute in pravilne kode valut na", "currency_performance" => "Za izboljšano delovanje obdržite samo valute, ki jih uporabljate.", "fixer_api_key" => "API ključ za Fixer", "provider" => "Ponudnik", "fixer_info" => "Če uporabljate več valut in želite natančno statistiko in razvrščanje naročnin, potrebujete BREZPLAČNI API ključ od Fixerja.", "get_key" => "Pridobite svoj ključ pri", "get_free_fixer_api_key" => "Pridobite brezplačen ključ API Fixer", "get_key_alternative" => "Lahko pa tudi dobite brezplačni Fixer API od", "ai_model" => "Model AI", "select_ai_model" => "Izberite model AI", "run_schedule" => "Zaženi urnik", "manually" => "Ročno", "coming_soon" => "Kmalu", "invalid_host" => "Neveljavna gostiteljska naprava", "ai_recommendations_info" => "Priporočila AI so generirana na podlagi vaših naročnin in članov gospodinjstva.", "may_take_time" => "Odvisno od ponudnika, modela in števila naročnin lahko generiranje priporočil traja nekaj časa.", "recommendations_visible_on_dashboard" => "Priporočila bodo vidna na nadzorni plošči.", "generate_recommendations" => "Generiraj priporočila", "display_settings" => "Nastavitve zaslona", "theme_settings" => "Nastavitve teme", "colors" => "Barve", "custom_colors" => "Barve po meri", "theme" => "Tema", "dark_theme" => "Temna tema", "light_theme" => "Svetla tema", "automatic" => "Samodejno", "main_color" => "Glavna barva", "accent_color" => "Poudarna barva", "hover_color" => "Barva ob hoverju", "save_custom_colors" => "Shrani barve po meri", "reset_custom_colors" => "Ponastavi barve po meri", "custom_css" => "CSS po meri", "save_custom_css" => "Shrani CSS po meri", "calculate_monthly_price" => "Izračunaj in prikaži mesečno ceno za vse naročnine", "convert_prices" => "Vedno pretvori in prikaži cene v moji glavni valuti (počasneje)", "show_original_price" => "Prikaži tudi originalno ceno, ko se izvajajo pretvorbe ali izračuni", "experience" => "Izkušnja", "show_subscription_progress" => "Prikaži napredek naročnine", "disabled_subscriptions" => "Onemogočene naročnine", "hide_disabled_subscriptions" => "Skrij onemogočene naročnine", "show_disabled_subscriptions_at_the_bottom" => "Prikaži onemogočene naročnine na dnu seznama", "experimental_settings" => "Eksperimentalne nastavitve", "remove_background" => "Poskusi odstraniti ozadje logotipov iz iskanja slik", "use_mobile_navigation_bar" => "Uporabi mobilno navigacijsko vrstico", "experimental_info" => "Poskusne nastavitve verjetno ne bodo popolnoma delovale.", "payment_methods" => "Načini plačila", "payment_methods_info" => "Kliknite način plačila, da ga onemogočite/omogočite.", "rename_payment_methods_info" => "Kliknite ime plačilnega sredstva, da ga preimenujete.", "cant_delete_payment_method_in_use" => "Uporabljenega plačilnega sredstva ni mogoče onemogočiti", "add_custom_payment" => "Dodaj način plačila po meri", "payment_method_name" => "Ime plačilnega sredstva", "payment_method_added_successfuly" => "Plačilno sredstvo je uspešno dodano", "payment_method_removed" => "Plačilno sredstvo odstranjeno", "disable" => "Onemogoči", "enable" => "Omogoči", "rename_payment_method" => "Preimenuj način plačila", "payment_renamed" => "Plačilno sredstvo je preimenovano", "payment_not_renamed" => "Način plačila ni preimenovan", "test" => "Test", "add" => "Dodaj", "save" => "Shrani", "reset" => "Ponastavi", "main_accent_color_error" => "Glavna in poudarna barva se ne sme ujemati", "backup_and_restore" => "Varnostno kopiranje in obnovitev", "backup" => "Varnostna kopija", "restore" => "Obnovitev", "restore_info" => "Obnovitev baze podatkov bo prepisala vse trenutne podatke. Po obnovitvi boste odjavljeni.", // Filters menu "filter" => "Filter", "clear" => "Počisti", // Toast "success" => "Uspeh", // Endpoint responses "session_expired" => "Vaša seja je potekla. Ponovno se prijavite", "fields_missing" => "Nekatere polja niso izpoljnena", "fill_all_fields" => "Prosim, izpolnite vsa polja", "fill_mandatory_fields" => "Prosim, izpolnite vsa obvezna polja", "error" => "Napaka", // Category "failed_add_category" => "Dodajanje kategorije ni uspelo", "failed_edit_category" => "Urejanje kategorije ni uspelo", "category_in_use" => "Kategorija je v uporabi v naročninah in je ni mogoče odstraniti", "failed_remove_category" => "Odstranitev kategorije ni uspela", "category_saved" => "Kategorija je shranjena", "category_removed" => "Kategorija je odstranjena", "sort_order_saved" => "Vrstni red je shranjen", // Currency "currency_saved" => "je bila shranjen.", "error_adding_currency" => "Napaka pri dodajanju zapisa valute.", "failed_to_store_currency" => "Shranjevanje valute v zbirko podatkov ni uspelo.", "currency_in_use" => "Valuta je v uporabi v naročninah in je ni mogoče izbrisati.", "currency_is_main" => "Valuta je nastavljena kot glavna valuta in je ni mogoče izbrisati.", "failed_to_remove_currency" => "Odstranitev valute iz zbirke podatkov ni uspela.", "failed_to_store_api_key" => "Ključa API ni bilo mogoče shraniti v zbirko podatkov.", "invalid_api_key" => "Neveljaven API ključ.", "api_key_saved" => "API ključ je uspešno shranjen", "currency_removed" => "Valuta je odstranjena", // Household "failed_add_household" => "Dodajanje člana gospodinjstva ni uspelo", "failed_edit_household" => "Urejanje člana gospodinjstva ni uspelo", "failed_remove_household" => "Odstranitev člana gospodinjstva ni uspela", "household_in_use" => "Član gospodinjstva je v uporabi v naročninah in ga ni mogoče odstraniti", "member_saved" => "Član je shranjen", "member_removed" => "Član je odstranjen", // Notifications "error_saving_notifications" => "Napaka pri shranjevanju podatkov obvestil.", "wallos_notification" => "Wallosovo obvestilo", "test_notification" => "To je preizkusno obvestilo. Če ga vidite, je konfiguracija pravilna.", "email_error" => "Napaka pri pošiljanju e-pošte", "notification_sent_successfuly" => "Obvestilo je bilo uspešno poslano", "notifications_settings_saved" => "Nastavitve obvestil so uspešno shranjene.", "notification_failed" => "Pošiljanje obvestila ni uspelo", // Payments "payment_in_use" => "Uporabljenega plačilnega sredstva ni mogoče onemogočiti", "failed_update_payment" => "Posodobitev plačilnega sredstva v bazi podatkov ni uspela", "enabled" => "omogočeno", "disabled" => "onemogočeno", // Subscription "error_fetching_image" => "Napaka pri pridobivanju slike", "subscription_updated_successfuly" => "Naročnina je bila uspešno posodobljena", "subscription_added_successfuly" => "Naročnina je bila uspešno dodana", "error_deleting_subscription" => "Napaka pri brisanju naročnine.", "invalid_request_method" => "Neveljavna metoda zahteve.", // User "error_updating_user_data" => "Napaka pri posodabljanju uporabniških podatkov.", "user_details_saved" => "Podrobnosti o uporabniku so shranjene", // Admin Page "registrations" => "Registracije", "enable_user_registrations" => "Omogoči registracije uporabnikov", "maximum_number_users" => "Največje število uporabnikov", "require_email_verification" => "Zahtevaj preverjanje elektronske pošte", "configure_smtp_settings_to_enable" => "Za omogočanje nastavitve SMTP nastavitve", "server_url" => "URL strežnika", "server_url_info" => "Uporablja se za preverjanje e-pošte in obnovitev gesla. Biti mora veljaven javni URL.", "server_url_password_reset" => "Če je nastavljeno, bo omogočena tudi funkcija ponastavitve gesla.", "disable_login" => "Onemogoči prijavo", "disable_login_info" => "Obidite prijavo. Če strežnik uporabljate samo v lokalnem omrežju brez zunanjega dostopa, lahko onemogočite prijavo. Tako se bo samodejno prijavil uporabnik administrator.", "disable_login_info2" => "To nastavitev lahko omogočite le, če je registracija uporabnikov izklopljena in če ni več uporabniških računov, razen uporabniškega računa upravitelja.", "max_users_info" => "0 pomeni neomejeno", "user_management" => "Upravljanje uporabnikov", "delete_user" => "Izbriši uporabnika", "delete_user_info" => "Če izbrišete uporabnika, boste izbrisali tudi vse njegove naročnine in nastavitve.", "create_user" => "Ustvari uporabnika", "oidc_settings" => "OIDC nastavitve", "oidc_auth_enabled" => "Omogoči OIDC prijavo", "create_user_automatically" => "Samodejno ustvari uporabnika", "disable_password_login" => "Onemogoči prijavo z geslom", "smtp_settings" => "Nastavitve SMTP", "smtp_usage_info" => "Uporabljeno bo za obnovitev gesla in druge sistemske e-pošte.", "security_settings" => "Varnostne nastavitve", "ssrf_protection_info" => "Za preprečevanje napadov Server-Side Request Forgery (SSRF) Wallos privzeto blokira webhook obvestila na zasebne ali notranje omrežne naslove.", "local_webhook_info" => "Če morate pošiljati webhooke na lokalne storitve (npr. Home Assistant, Gotify ali Node-RED), vnesite njihove IP naslove ali imena gostiteljev zgoraj kot seznam, ločen z vejico (npr. 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Vzdrževalne naloge", "orphaned_logos" => "Osamljeni logotipi", "update" => "Posodobi", "new_version_available" => "Na voljo je nova različica Wallos", "current_version" => "Trenutna različica", "latest_version" => "Najnovejša različica", "on_current_version" => "Uporabljate najnovejšo različico Wallos.", "show_update_notification" => "Prikaži obvestilo o posodobitvah na dashboardu", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "E-pošta je bila uspešno preverjena", "email_verification_failed" => "Preverjanje e-pošte ni uspelo", // Calendar "calendar" => "Koledar", "sun" => "Ned", "mon" => "Pon", "tue" => "Tor", "wed" => "Sre", "thu" => "Čet", "fri" => "Pet", "sat" => "Sob", "month-01" => "Januar", "month-02" => "Februar", "month-03" => "Marec", "month-04" => "April", "month-05" => "Maj", "month-06" => "Junij", "month-07" => "Julij", "month-08" => "Avgust", "month-09" => "September", "month-10" => "Oktober", "month-11" => "November", "month-12" => "December", "total_cost" => "Skupni stroški", "export_icalendar" => "Izvozi iCalendar", "over_budget_warning" => "Presegli ste proračun", // TOTP Page "insert_totp_code" => "Vnesite kodo TOTP", ]; ?> ================================================ FILE: includes/i18n/sr.php ================================================ "Морате креирати налог пре него што можете пријавити", "username" => "Корисничко име", "password" => "Лозинка", "email" => "И-пошта", "firstname" => "Име", "lastname" => "Презиме", "confirm_password" => "Потврди лозинку", "main_currency" => "Главна валута", "language" => "Језик", "passwords_dont_match" => "Лозинке се не поклапају", "username_exists" => "Корисничко име већ постоји", "email_exists" => "Е-пошта већ постоји", "registration_failed" => "Регистрација није успела, покушајте поново.", "register" => "Региструј се", "restore_database" => "Врати базу података", // Страница за пријаву "please_login" => "Молимо вас да се пријавите", "stay_logged_in" => "Остани пријављен (30 дана)", "login" => "Пријави се", "login_with" => "Пријави се са", "or" => "или", "login_failed" => "Подаци за пријаву нису исправни", "registration_successful" => "Пријава успешна", "user_email_waiting_verification" => "Ваша е-пошта треба да буде верификована. Молимо прегледајте Е-пошту", // Password Reset Page "forgot_password" => "Заборављена лозинка", "reset_password" => "Ресетуј лозинку", "reset_sent_check_email" => "Ресетовање лозинке је послато на вашу е-пошту", "password_reset_successful" => "Ресетовање лозинке је успешно", // Header "profile" => "Профил", "dashboard" => "Панел", "subscriptions" => "Претплате", "stats" => "Статистике", "settings" => "Подешавања", "admin" => "Админ", "about" => "О апликацији", "logout" => "Одјава", // Dashboard "hello" => "Здраво", "upcoming_payments" => "Предстојећа плаћања", "no_upcoming_payments" => "Немате предстојећих плаћања", "overdue_renewals" => "Засадне обнове", "ai_recommendations" => "AI препоруке", "your_budget" => "Ваш буџет", "budget" => "Буџет", "budget_used" => "Искоришћен буџет", "over_budget" => "Прекорачен буџет", "your_subscriptions" => "Ваше претплате", "your_savings" => "Ваша уштеда", // Страница са претплатама "subscription" => "Претплата", "no_subscriptions_yet" => "Још увек немате ниједну претплату", "add_first_subscription" => "Додајте прву претплату", "new_subscription" => "Нова претплата", "search" => "Претрага", "state" => "Статус", "alphanumeric" => "Алфанумерички", "sort" => "Сортирај", "name" => "Назив", "last_added" => "Последње додато", "price" => "Цена", "next_payment" => "Следећа уплата", "renewal_type" => "Тип обнове", "auto_renewal" => "Аутоматско обновљење", "automatically_renews" => "Аутоматски обновља", "manual_renewal" => "Ручно обновљење", "start_date" => "Датум почетка", "inactive" => "Онемогући претплату", "replaced_with" => "Замењено са", "none" => "Ништа", "member" => "Члан", "category" => "Категорија", "payment_method" => "Начин плаћања", "Daily" => "Дневно", "Weekly" => "Недељно", "Monthly" => "Месечно", "Yearly" => "Годишње", "daily" => "дана", "weekly" => "недеља", "monthly" => "месеци", "yearly" => "година", "days" => "дана", "weeks" => "недеља", "months" => "месеци", "years" => "године", "external_url" => "Посети спољни URL", "empty_page" => "Празна страница", "clear_filters" => "Очисти филтере", "no_matching_subscriptions" => "Нема подударајућих претплата", "clone" => "Клонирај", "renew" => "Обнови", "calculate_next_payment_date" => "Израчунајте датум следеће уплате", // Форма за претплату "add_subscription" => "Додај претплату", "edit_subscription" => "Уреди претплату", "subscription_name" => "Назив претплате", "logo_preview" => "Преглед логотипа", "search_logo" => "Претражи логотип на интернету", "web_search" => "Интернет претрага", "currency" => "Валута", "payment_every" => "Плаћање сваког", "frequency" => "Фреквенција", "cycle" => "Циклус", "no_category" => "Без категорије", "paid_by" => "Плаћено од", "url" => "URL", "notes" => "Напомене", "enable_notifications" => "Омогући обавештења за ову претплату", "default_value_from_settings" => "Подразумевана вредност из подешавања", "cancellation_notification" => "Обавештење о отказивању", "delete" => "Обриши", "cancel" => "Откажи", "upload_logo" => "Постави логотип", // Страница са статистикама "cant_convert_currency" => "На својим претплатама користите више валута. Да бисте имали валидну и тачну статистику, поставите Фикер АПИ кључ на страници подешавања.", "general_statistics" => "Опште статистике", "active_subscriptions" => "Активне претплате", "inactive_subscriptions" => "Неактивне претплате", "monthly_cost" => "Месечни трошак", "yearly_cost" => "Годишњи трошак", "average_monthly" => "Просечни месечни трошак претплате", "most_expensive" => "Најскупља претплата", "amount_due" => "Износ за уплату овог месеца", "percentage_budget_used" => "Проценат искоришћеног буџета", "budget_remaining" => "Преостали буџет", "amount_over_budget" => "Износ преко буџета", "monthly_savings" => "Месечне уштеде (на неактивним претплатама)", "yearly_savings" => "Годишње уштеде (на неактивним претплатама)", "split_views" => "Подељени прикази", "category_split" => "Подела по категоријама", "household_split" => "Подела по домаћинству", "payment_method_split" => "Подела по начинима плаћања", "total_cost_trend" => "Тренд укупних трошкова", "cost_vs_budget" => "Трошак у односу на буџет", // Страница о апликацији "about_and_credits" => "О апликацији и заслугама", "credits" => "Заслуге", "license" => "Лиценца", "release_notes" => "Белешке о издању", "update_available" => "Доступно је ажурирање", "issues_and_requests" => "Проблеми и захтеви", "the_author" => "Аутор", "icons" => "Иконе", "payment_icons" => "Иконе плаћања", // Profile page "upload_avatar" => "Постави аватар", "file_type_error" => "Датотека није у подржаном формату.", "user_details" => "Кориснички детаљи", "two_factor_authentication" => "Двофакторска аутентикација", "two_factor_info" => "Двофакторска аутентификација додаје додатни ниво сигурности вашем налогу. <бр>Биће вам потребна апликација за аутентификацију као што је Гоогле Аутхентицатор, Аутхи или Енте Аутх да бисте скенирали КР код.", "two_factor_enabled_info" => "Ваш налог је сигуран са двофакторском аутентификацијом. Можете га онемогућити кликом на дугме изнад.", "enable_two_factor_authentication" => "Омогући двофакторску аутентикацију", "2fa_already_enabled" => "Двофакторска аутентикација је већ омогућена", "totp_code_incorrect" => "ТОТП код није исправан", "backup_codes" => "Резервни кодови", "download_backup_codes" => "Преузми резервне кодове", "copy_to_clipboard" => "Копирај у клипборд", "totp_backup_codes_info" => "Сачувајте ове кодове на безбедно место. Користићете их када изгубите приступ апликацији за аутентификацију.", "disable_two_factor_authentication" => "Онемогући двофакторску аутентикацију", "totp_code" => "ТОТП код", "api_key" => "API кључ", "regenerate" => "Генериши", "api_key_info" => "API кључ је потребан за коришћење API-ја за приступ вашим подацима. Не делите овај кључ са другима.", // Страница са подешавањима "monthly_budget" => "Месечни буџет", "budget_info" => "Унесите месечни буџет да бисте видели препоручену максималну цену претплате на почетној страници.", "household" => "Домаћинство", "save_member" => "Сачувај члана", "delete_member" => "Обриши члана", "cant_delete_member" => "Главни члан не може бити обрисан", "cant_delete_member_in_use" => "Члана који се користи у претплати не може бити обрисан", "household_info" => "Поље за е-пошту омогућава члановима домаћинства да буду обавештени о претплатама које ће ускоро истећи.", "notifications" => "Обавештења", "enable_email_notifications" => "Омогући обавештења е-поштом", "notify_me" => "Обавести ме", "day_before" => "дан пре", "on_due_date" => "на дан доспећа", "days_before" => "дана пре", "smtp_address" => "SMTP адреса", "port" => "Порт", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "SMTP корисничко име", "smtp_password" => "SMTP лозинка", "from_email" => "Од е-поште (Опционо)", "send_to_other_emails" => "Такође пошаљите обавештења на следеће адресе е-поште (користите ; за њихово раздвајање):", "smtp_info" => "SMTP лозинка се преноси и чува у обичном тексту. Из сигурносних разлога, молимо вас да направите налог само за ово.", "telegram" => "Телеграм", "telegram_bot_token" => "Телеграм бот токен", "telegram_chat_id" => "Телеграм чет ID", "pushplus" => "Pushplus", "pushplus_token" => "Pushplus токен", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost Webhook URL", "mattermost_bot_username" => "Mattermost Bot Име корисника", "mattermost_bot_icon_emoji" => "Mattermost Bot Икона Emoji", "webhook" => "Вебхук", "webhook_url" => "Вебхук URL", "request_method" => "Метод захтева", "custom_headers" => "Прилагођени заглавља", "webhook_payload" => "Вебхук Пајлоад", "payment_notifications_payload" => "Обавештење о плаћању Пејлоад", "cancelation_notification_payload" => "Отказивање обавештење Пејлоад", "variables_available" => "Доступне променљиве", "gotify" => "Готифи", "token" => "Токен", "discord" => "Дискорд", "discord_bot_username" => "Дискорд бот корисничко име", "discord_bot_avatar_url" => "Дискорд бот URL аватара", "pushover" => "Пушовер", "pushover_user_key" => "Пушовер кориснички кључ", "host" => "Домаћин", "topic" => "Тема", "ignore_ssl_errors" => "Игнориши SSL грешке", "categories" => "Категорије", "save_category" => "Сачувај категорију", "delete_category" => "Избриши категорију", "cant_delete_category_in_use" => "Категорија која се користи у претплати не може бити избрисана", "currencies" => "Валуте", "save_currency" => "Сачувај валуту", "delete_currency" => "Избриши валуту", "cant_delete_main_currency" => "Главна валута не може бити избрисана", "cant_delete_currency_in_use" => "Валута која се користи у претплати не може бити избрисана", "exchange_update" => "Курсне стопе последњи пут ажуриране", "currency_info" => "Пронађите подржане валуте и исправне кодове валута на", "currency_performance" => "За побољшану перформансу, задржите само валуте које користите.", "fixer_api_key" => "Fixer API кључ", "provider" => "Провајдер", "fixer_info" => "Ако користите више валута и желите тачне статистике и сортирање претплата, неопходан је БЕСПЛАТНИ API кључ од Fixer.", "get_key" => "Добијте свој кључ на", "get_free_fixer_api_key" => "Добијте бесплатни Fixer API кључ", "get_key_alternative" => "Алтернативно, можете добити бесплатни Fixer API кључ са", "ai_model" => "AI Модел", "select_ai_model" => "Изаберите AI Модел", "run_schedule" => "Покрените распоред", "manually" => "Ручно", "coming_soon" => "Ускоро", "invalid_host" => "Неважећи хост", "ai_recommendations_info" => "AI препоруке се генеришу на основу ваших претплата и чланова домаћинства.", "may_take_time" => "У зависности од провајдера, модела и броја претплата, генерисање препорука може потрајати.", "recommendations_visible_on_dashboard" => "Препоруке ће бити видљиве на контролној табли.", "generate_recommendations" => "Генериши препоруке", "display_settings" => "Подешавања приказа", "theme_settings" => "Подешавања теме", "colors" => "Боје", "custom_colors" => "Прилагођене боје", "theme" => "Тема", "dark_theme" => "Тамна тема", "light_theme" => "Светла тема", "automatic"=> "Аутоматски", "main_color" => "Главна боја", "accent_color" => "Акцент боја", "hover_color" => "Боја приликом преласка", "save_custom_colors" => "Сачувај прилагођене боје", "reset_custom_colors" => "Ресетуј прилагођене боје", "custom_css" => "Прилагођени CSS", "save_custom_css" => "Сачувај прилагођени CSS", "calculate_monthly_price" => "Израчунајте и прикажите месечну цену за све претплате", "convert_prices" => "Увек конвертујте и прикажите цене на мојој главној валути (спорије)", "show_original_price" => "Прикажи и оригиналну цену када се врше конверзије или прорачуни", "experience" => "Искуство", "show_subscription_progress" => "Прикажи прогрес претплате", "disabled_subscriptions" => "Онемогућене претплате", "hide_disabled_subscriptions" => "Сакриј онемогућене претплате", "show_disabled_subscriptions_at_the_bottom" => "Прикажи онемогућене претплате на дну", "experimental_settings" => "Експериментална подешавања", "remove_background" => "Покушајте уклонити позадину логотипа са слика претраге", "use_mobile_navigation_bar" => "Користите мобилну навигациону траку", "experimental_info" => "Експериментална подешавања вероватно неће радити савршено.", "payment_methods" => "Начини плаћања", "payment_methods_info" => "Кликните на начин плаћања да бисте га онемогућили / омогућили.", "rename_payment_methods_info" => "Кликните на име начина плаћања да бисте га преименовали.", "cant_delete_payment_method_in_use" => "Не може се онемогућити коришћени начин плаћања", "add_custom_payment" => "Додајте прилагођени начин плаћања", "payment_method_name" => "Име начина плаћања", "payment_method_added_successfuly" => "Начин плаћања успешно додат", "payment_method_removed" => "Начин плаћања уклоњен", "disable" => "Онемогући", "enable" => "Омогући", "rename_payment_method" => "Преименуј начин плаћања", "payment_renamed" => "Начин плаћања преименован", "payment_not_renamed" => "Начин плаћања није преименован", "test" => "Тест", "add" => "Додај", "save" => "Сачувај", "reset" => "Ресетуј", "main_accent_color_error" => "Главна и акцент боја не могу бити исте", "backup_and_restore" => "Бекап и ресторе", "backup" => "Бекап", "restore" => "Ресторе", "restore_info" => "Враћање базе података ће заменити све тренутне податке. Бићете одјављени након враћања.", "account" => "Налог", "export_subscriptions" => "Извоз претплата", "export_as_json" => "Извоз као JSON", "export_as_csv" => "Извоз као CSV", "danger_zone" => "Зона опасности", "delete_account" => "Обриши налог", "delete_account_info" => "Брисање налога ће обрисати све ваше податке, укључујући претплате, подешавања и чланове домаћинства.", // Мени са филтерима "filter" => "Филтер", "clear" => "Очисти", // Тост "success" => "Успех", // Одговори са сервера "session_expired" => "Ваша сесија је истекла. Молимо вас да се поново пријавите", "fields_missing" => "Неки подаци недостају", "fill_all_fields" => "Молимо вас да попуните сва поља", "fill_mandatory_fields" => "Молимо вас да попуните сва обавезна поља", "error" => "Грешка", // Категорија "failed_add_category" => "Додавање категорије није успело", "failed_edit_category" => "Измена категорије није успела", "category_in_use" => "Категорија се користи у претплатама и не може бити уклоњена", "failed_remove_category" => "Уклањање категорије није успело", "category_saved" => "Категорија сачувана", "category_removed" => "Категорија уклоњена", "sort_order_saved" => "Редослед сортирања сачуван", // Валута "currency_saved" => "је сачувана.", "error_adding_currency" => "Грешка при додавању валутне ставке.", "failed_to_store_currency" => "Није успело смештање валуте у базу података.", "currency_in_use" => "Валута се користи у претплатама и не може бити избрисана.", "currency_is_main" => "Валута је постављена као главна и не може бити избрисана.", "failed_to_remove_currency" => "Није успело уклањање валуте из базе података.", "failed_to_store_api_key" => "Није успело смештање API кључа у базу података.", "invalid_api_key" => "Неисправан API кључ.", "api_key_saved" => "API кључ успешно сачуван", "currency_removed" => "Валута уклоњена", // Домаћинство "failed_add_household" => "Додавање члана домаћинства није успело", "failed_edit_household" => "Измена члана домаћинства није успела", "failed_remove_household" => "Уклањање члана домаћинства није успело", "household_in_use" => "Члан домаћинства се користи у претплатама и не може бити уклоњен", "member_saved" => "Члан сачуван", "member_removed" => "Члан уклоњен", // Обавештења "error_saving_notifications" => "Грешка при чувању података за обавештења.", "wallos_notification" => "Валос обавештење", "test_notification" => "Ово је тест обавештење. Ако га видите, конфигурација је исправна.", "email_error" => "Грешка при слању е-поште", "notification_sent_successfuly" => "Обавештење успешно послато", "notifications_settings_saved" => "Подешавања обавештења успешно сачувана.", "notification_failed" => "Обавештење није послато", // Плаћања "payment_in_use" => "Не може се онемогућити коришћени начин плаћања", "failed_update_payment" => "Ажурирање начина плаћања у бази података није успело", "enabled" => "омогућен", "disabled" => "онемогућен", // Претплата "error_fetching_image" => "Грешка при преузимању слике", "subscription_updated_successfuly" => "Претплата успешно ажурирана", "subscription_added_successfuly" => "Претплата успешно додата", "error_deleting_subscription" => "Грешка при брисању претплате.", "invalid_request_method" => "Неисправан метод захтева.", // Корисник "error_updating_user_data" => "Грешка при ажурирању корисничких података.", "user_details_saved" => "Кориснички подаци сачувани", // Admin Page "registrations" => "Регистрације", "enable_user_registrations" => "Омогући регистрације корисника", "maximum_number_users" => "Максималан број корисника", "require_email_verification" => "Захтевај верификацију е-поште", "configure_smtp_settings_to_enable" => "Конфигуришите SMTP подешавања да бисте омогућили ову опцију", "server_url" => "URL сервера", "server_url_info" => "Користи се за верификацију е-поште и опоравак лозинке. Мора да буде важећи јавни УРЛ.", "server_url_password_reset" => "Ако је подешено, такође ће се омогућити функција ресетовања лозинке.", "disable_login" => "Онемогући пријаву", "disable_login_info" => "Заобиђите пријаву. Ако свој сервер покрећете само на локалној мрежи, без спољног приступа можете да онемогућите пријаву. Ово ће аутоматски пријавити корисника администратора.", "disable_login_info2" => "Ово подешавање се може омогућити само ако је регистрација корисника затворена и број корисничких налога не прелази администраторске налоге.", "max_users_info" => "Максималан број корисника који могу бити регистровани. 0 за неограничено.", "user_management" => "Управљање корисницима", "delete_user" => "Обриши корисника", "delete_user_info" => "Брисање корисника ће такође обрисати све његове претплате и податке.", "create_user" => "Креирај корисника", "oidc_settings" => "OIDC подешавања", "oidc_auth_enabled" => "OIDC аутентификација је омогућена", "create_user_automatically" => "Креирај корисника аутоматски", "disable_password_login" => "Онемогући пријаву лозинком", "smtp_settings" => "SMTP подешавања", "smtp_usage_info" => "SMTP се користи за слање е-поште за обавештења.", "security_settings" => "Поставке безбедности", "ssrf_protection_info" => "Како би се спречили напади Server-Side Request Forgery (SSRF), Wallos подразумевано блокира webhook обавештења ка приватним или унутрашњим мрежним адресама.", "local_webhook_info" => "Ако треба да шаљете вебхуке ка локалним сервисима (нпр. Home Assistant, Gotify или Node-RED), унесите њихове IP адресе или имена хостова горе, одвојене зарезима (нпр. 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Одржавање", "orphaned_logos" => "Породични логотипови", "update" => "Ажурирај", "new_version_available" => "Нова верзија Wallos-а је доступна", "current_version" => "Тренутна верзија", "latest_version" => "Најновија верзија", "on_current_version" => "Користите најновију верзију Wallos-а.", "show_update_notification" => "Прикажи обавештење о ажурирањима на dashboardu", "cronjobs" => "Цроњобс", // Email Verification "email_verified" => "Е-пошта је верификована", "email_verification_failed" => "Верификација е-поште није успела", // Calendar "calendar" => "Календар", "sun" => "Нед", "mon" => "Пон", "tue" => "Уто", "wed" => "Сре", "thu" => "Чет", "fri" => "Пет", "sat" => "Суб", "month-01" => "Јануар", "month-02" => "Фебруар", "month-03" => "Март", "month-04" => "Април", "month-05" => "Мај", "month-06" => "Јун", "month-07" => "Јул", "month-08" => "Август", "month-09" => "Септембар", "month-10" => "Октобар", "month-11" => "Новембар", "month-12" => "Децембар", "total_cost" => "Укупан трошак", "export_icalendar" => "Извоз у iCalendar формат", "over_budget_warning" => "Прекорачили сте буџет", // TOTP Page "insert_totp_code" => "Унесите ТОТП код", ]; ?> ================================================ FILE: includes/i18n/sr_lat.php ================================================ "Morate kreirati nalog pre nego što se možete prijaviti", "username" => "Korisničko ime", "password" => "Lozinka", "email" => "E-pošta", "firstname" => "Име", "lastname" => "Презиме", "confirm_password" => "Potvrdi lozinku", "main_currency" => "Glavna valuta", "language" => "Jezik", "passwords_dont_match" => "Lozinke se ne poklapaju", "username_exists" => "Korisničko ime već postoji", "email_exists" => "E-pošta već postoji", "registration_failed" => "Registracija nije uspela, pokušajte ponovo.", "register" => "Registruj se", "restore_database" => "Vrati bazu podataka", // Stranica za prijavu "please_login" => "Molimo vas da se prijavite", "stay_logged_in" => "Ostani prijavljen (30 dana)", "login" => "Prijavi se", "login_with" => "Prijavi se sa", "or" => "ili", "login_failed" => "Podaci za prijavu nisu ispravni", "registration_successful" => "Registracija uspešna", "user_email_waiting_verification" => "Vaša e-pošta treba da bude verifikovana. Molimo pregledajte E-poštu", // Password Reset Page "forgot_password" => "Zaboravili ste lozinku?", "reset_password" => "Resetuj lozinku", "reset_sent_check_email" => "Poslali smo vam e-poštu sa uputstvima za resetovanje lozinke", "password_reset_successful" => "Lozinka uspešno resetovana", // Header "profile" => "Profil", "dashboard" => "Panel", "subscriptions" => "Pretplate", "stats" => "Statistike", "settings" => "Podešavanja", "admin" => "Admin", "about" => "O aplikaciji", "logout" => "Odjava", // Dashboard "hello" => "Zdravo", "upcoming_payments" => "Predstojeće uplate", "no_upcoming_payments" => "Nemate predstojećih uplata", "overdue_renewals" => "Zakasne obnove", "ai_recommendations" => "AI preporuke", "your_budget" => "Vaš budžet", "budget" => "Budžet", "budget_used" => "Iskorišćen budžet", "over_budget" => "Prekoračen budžet", "your_subscriptions" => "Vaše pretplate", "your_savings" => "Vaša ušteda", // Stranica sa pretplatama "subscription" => "Pretplata", "no_subscriptions_yet" => "Još uvek nemate nijednu pretplatu", "add_first_subscription" => "Dodajte prvu pretplatu", "new_subscription" => "Nova pretplata", "search" => "Pretraga", "state" => "Stanje", "alphanumeric" => "Alfanumerički", "sort" => "Sortiraj", "name" => "Naziv", "last_added" => "Poslednje dodato", "price" => "Cena", "next_payment" => "Sledeća uplata", "renewal_type" => "Tip obnove", "auto_renewal" => "Automatsko obnavljanje", "automatically_renews" => "Automatsko obnavljanje", "manual_renewal" => "Ručno obnavljanje", "start_date" => "Datum početka", "inactive" => "Onemogući pretplatu", "replaced_with" => "Zamenjeno sa", "none" => "Nijedna", "member" => "Član", "category" => "Kategorija", "payment_method" => "Način plaćanja", "Daily" => "Dnevno", "Weekly" => "Nedeljno", "Monthly" => "Mesečno", "Yearly" => "Godišnje", "daily" => "dana", "weekly" => "nedelja", "monthly" => "meseci", "yearly" => "godina", "days" => "dana", "weeks" => "nedelja", "months" => "meseci", "years" => "godina", "external_url" => "Poseti spoljni URL", "empty_page" => "Prazna stranica", "clear_filters" => "Očisti filtere", "no_matching_subscriptions" => "Nema podudarajućih pretplata", "clone" => "Kloniraj", "renew" => "Obnovi", "calculate_next_payment_date" => "Izračunaj datum sledeće uplate", // Forma za pretplatu "add_subscription" => "Dodaj pretplatu", "edit_subscription" => "Uredi pretplatu", "subscription_name" => "Naziv pretplate", "logo_preview" => "Pregled logotipa", "search_logo" => "Pretraži logo na internetu", "web_search" => "Internet pretraga", "currency" => "Valuta", "payment_every" => "Plaćanje svakog", "frequency" => "Frekvencija", "cycle" => "Ciklus", "no_category" => "Bez kategorije", "paid_by" => "Plaćeno od strane", "url" => "URL", "notes" => "Beleške", "enable_notifications" => "Omogući obaveštenja za ovu pretplatu", "default_value_from_settings" => "Podrazumevana vrednost iz podešavanja", "cancellation_notification" => "Obaveštenje o otkazivanju", "delete" => "Izbriši", "cancel" => "Otkaži", "upload_logo" => "Učitaj logo", // Stranica sa statistikama "cant_convert_currency" => "Na svojim pretplatama koristite više valuta. Da biste imali validnu i tačnu statistiku, postavite Fiker API ključ na stranici podešavanja.", "general_statistics" => "Opšte statistike", "active_subscriptions" => "Aktivne pretplate", "inactive_subscriptions" => "Neaktivne pretplate", "monthly_cost" => "Mesečni trošak", "yearly_cost" => "Godišnji trošak", "average_monthly" => "Prosečni mesečni trošak pretplate", "most_expensive" => "Najskuplja pretplata", "amount_due" => "Iznos za plaćanje ovog meseca", "percentage_budget_used" => "Procenat budžeta iskorišćen", "budget_remaining" => "Preostali budžet", "amount_over_budget" => "Iznos preko budžeta", "monthly_savings" => "Mesečne uštede (na neaktivnim pretplatama)", "yearly_savings" => "Godišnje uštede (na neaktivnim pretplatama)", "split_views" => "Podeljene statistike", "category_split" => "Podela po kategorijama", "household_split" => "Podela po domaćinstvima", "payment_method_split" => "Podela po načinu plaćanja", "total_cost_trend" => "Trend ukupnog troška", "cost_vs_budget" => "Trošak vs Budžet", // Stranica O aplikaciji "about_and_credits" => "O aplikaciji i zasluge", "credits" => "Zasluge", "license" => "Licenca", "release_notes" => "Beleške o izdanju", "update_available" => "Dostupno ažuriranje", "issues_and_requests" => "Problemi i zahtevi", "the_author" => "Autor", "icons" => "Ikone", "payment_icons" => "Ikone za plaćanje", // Stranica sa profilom "upload_avatar" => "Učitaj avatar", "file_type_error" => "Tip datoteke koji ste priložili nije podržan.", "user_details" => "Detalji korisnika", "two_factor_authentication" => "Dvostruka autentifikacija", "two_factor_info" => "Dvofaktorska autentifikacija dodaje dodatni nivo sigurnosti vašem nalogu.
Biće vam potrebna aplikacija za autentifikaciju kao što je Google Authenticator, Authi ili Ente Auth da biste skenirali KR kod.", "two_factor_enabled_info" => "Vaš nalog je siguran sa dvofaktorskom autentifikacijom. Možete ga onemogućiti klikom na dugme iznad.", "enable_two_factor_authentication" => "Omogući dvofaktorsku autentifikaciju", "2fa_already_enabled" => "Dvofaktorska autentifikacija je već omogućena", "totp_code_incorrect" => "Kod za dvofaktorsku autentifikaciju nije tačan", "backup_codes" => "Rezervni kodovi", "download_backup_codes" => "Preuzmi rezervne kodove", "copy_to_clipboard" => "Kopiraj u clipboard", "totp_backup_codes_info" => "Ovo su vaši rezervni kodovi za dvofaktorsku autentifikaciju. Sačuvajte ih na sigurnom mestu.", "disable_two_factor_authentication" => "Onemogući dvofaktorsku autentifikaciju", "totp_code" => "Kod za dvofaktorsku autentifikaciju", "api_key" => "API ključ", "regenerate" => "Regeneriši", "api_key_info" => "API ključ se koristi za pristup Wallos API-ju. Ako mislite da je vaš ključ kompromitovan, možete ga regenerisati.", // Stranica sa podešavanjima "monthly_budget" => "Mesečni budžet", "budget_info" => "Ovo je vaš mesečni budžet za sve pretplate. Ovo je samo informativno i ne ograničava vas.", "household" => "Domaćinstvo", "save_member" => "Sačuvaj člana", "delete_member" => "Izbriši člana", "cant_delete_member" => "Nemoguće brisanje glavnog člana", "cant_delete_member_in_use" => "Nemoguće brisanje člana koji je u upotrebi u pretplati", "household_info" => "Polje za e-poštu omogućava članovima domaćinstva da budu obavešteni o pretplatama koje će uskoro isteći.", "notifications" => "Obaveštenja", "enable_email_notifications" => "Omogući obaveštenja e-poštom", "notify_me" => "Obavesti me", "day_before" => "dan pre", "on_due_date" => "Na dan dospeća", "days_before" => "dana pre", "smtp_address" => "SMTP adresa", "port" => "Port", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "SMTP korisničko ime", "smtp_password" => "SMTP lozinka", "from_email" => "Od e-pošte (Opciono)", "send_to_other_emails" => "Takođe pošaljite obaveštenja na sledeće e-mail adrese (koristite ; za razdvajanje):", "smtp_info" => "SMTP lozinka se prenosi i čuva u običnom tekstu. Iz sigurnosnih razloga, molimo vas da napravite nalog samo za ovo.", "telegram" => "Telegram", "telegram_bot_token" => "Telegram bot token", "telegram_chat_id" => "Telegram chat ID", "pushplus" => "Pushplus", "pushplus_token" => "Pushplus token", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost Webhook URL", "mattermost_bot_username" => "Mattermost Bot Korisničko ime", "mattermost_bot_icon_emoji" => "Mattermost Bot Ikona Emoji", "webhook" => "Webhook", "webhook_url" => "Webhook URL", "request_method" => "Metod zahteva", "custom_headers" => "Prilagođeni zaglavlja", "webhook_payload" => "Webhook payload", "payment_notifications_payload" => "Obaveštenje o uplati payload", "cancelation_notification_payload" => "Obaveštenje o otkazivanju payload", "variables_available" => "Dostupne promenljive", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Discord bot korisničko ime", "discord_bot_avatar_url" => "Discord bot URL avatara", "pushover" => "Pushover", "pushover_user_key" => "Pushover korisnički ključ", "host" => "Host", "topic" => "Tema", "ignore_ssl_errors" => "Ignoriši SSL greške", "categories" => "Kategorije", "save_category" => "Sačuvaj kategoriju", "delete_category" => "Izbriši kategoriju", "cant_delete_category_in_use" => "Nemoguće brisanje kategorije koja je u upotrebi u pretplati", "currencies" => "Valute", "save_currency" => "Sačuvaj valutu", "delete_currency" => "Izbriši valutu", "cant_delete_main_currency" => "Nemoguće brisanje glavne valute", "cant_delete_currency_in_use" => "Nemoguće brisanje valute koja je u upotrebi u pretplati", "exchange_update" => "Kursne stope poslednji put ažurirane", "currency_info" => "Pronađite podržane valute i ispravne kodove valuta na", "currency_performance" => "Za poboljšanu performansu, zadržite samo valute koje koristite.", "fixer_api_key" => "Fixer API ključ", "provider" => "Provajder", "fixer_info" => "Ako koristite više valuta i želite tačne statistike i sortiranje pretplata, neophodan je BESPLATNI API ključ sa Fixer-a.", "get_key" => "Pronađite svoj ključ na", "get_free_fixer_api_key" => "Pronađite besplatni Fixer API ključ", "get_key_alternative" => "Alternativno, možete dobiti besplatni Fixer API ključ na", "ai_model" => "AI Model", "select_ai_model" => "Izaberite AI Model", "run_schedule" => "Pokreni raspored", "manually" => "Ručno", "coming_soon" => "Uskoro", "invalid_host" => "Nevažeći host", "ai_recommendations_info" => "AI preporuke se generišu na osnovu vaših pretplata i članova domaćinstva.", "may_take_time" => "U zavisnosti od provajdera, modela i broja pretplata, generisanje preporuka može potrajati.", "recommendations_visible_on_dashboard" => "Preporuke će biti vidljive na kontrolnoj tabli.", "generate_recommendations" => "Generiši preporuke", "display_settings" => "Podešavanja prikaza", "theme_settings" => "Podešavanja teme", "colors" => "Boje", "custom_colors" => "Prilagođene boje", "theme" => "Tema", "dark_theme" => "Tamna tema", "light_theme" => "Svetla tema", "automatic" => "Automatski", "main_color" => "Glavna boja", "accent_color" => "Akcentna boja", "hover_color" => "Boja prilikom prelaska", "save_custom_colors" => "Sačuvaj prilagođene boje", "reset_custom_colors" => "Resetuj prilagođene boje", "custom_css" => "Prilagođeni CSS", "save_custom_css" => "Sačuvaj prilagođeni CSS", "calculate_monthly_price" => "Izračunaj i prikaži mesečnu cenu za sve pretplate", "convert_prices" => "Uvek konvertuj i prikaži cene u mojoj glavnoj valuti (sporije)", "show_original_price" => "Prikaži i originalnu cenu kada se vrše konverzije ili proračuni", "experience" => "Iskustvo", "show_subscription_progress" => "Prikaži napredak pretplate", "disabled_subscriptions" => "Onemogućene pretplate", "hide_disabled_subscriptions" => "Sakrij onemogućene pretplate", "show_disabled_subscriptions_at_the_bottom" => "Prikaži onemogućene pretplate na dnu", "experimental_settings" => "Eksperimentalna podešavanja", "remove_background" => "Pokušajte ukloniti pozadinu logotipa sa pretrage slika", "use_mobile_navigation_bar" => "Koristi navigacionu traku za mobilne uređaje", "experimental_info" => "Eksperimentalna podešavanja verovatno neće savršeno funkcionisati.", "payment_methods" => "Načini plaćanja", "payment_methods_info" => "Kliknite na način plaćanja da biste ga onemogućili / omogućili.", "rename_payment_methods_info" => "Kliknite na ime načina plaćanja da biste ga preimenovali.", "cant_delete_payment_method_in_use" => "Nemoguće onemogućiti korišćeni način plaćanja", "add_custom_payment" => "Dodaj prilagođeni način plaćanja", "payment_method_name" => "Ime načina plaćanja", "payment_method_added_successfuly" => "Način plaćanja uspešno dodat", "payment_method_removed" => "Način plaćanja uklonjen", "disable" => "Onemogući", "enable" => "Omogući", "rename_payment_method" => "Preimenuj način plaćanja", "payment_renamed" => "Način plaćanja preimenovan", "payment_not_renamed" => "Način plaćanja nije preimenovan", "test" => "Test", "add" => "Dodaj", "save" => "Sačuvaj", "reset" => "Resetuj", "main_accent_color_error" => "Glavna i akcentna boja ne mogu biti iste", "backup_and_restore" => "Backup i restore", "backup" => "Backup", "restore" => "Restore", "restore_info" => "Vraćanje baze podataka će zameniti sve trenutne podatke. Bićete odjavljeni nakon vraćanja.", "account" => "Nalog", "export_subscriptions" => "Izvezi pretplate", "export_as_json" => "Izvezi kao JSON", "export_as_csv" => "Izvezi kao CSV", "danger_zone" => "Opasna zona", "delete_account" => "Izbriši nalog", "delete_account_info" => "Brisanjem naloga izbrisaćete sve podatke, uključujući pretplate, podešavanja, članove domaćinstva i načine plaćanja.", // Meni sa filterima "filter" => "Filter", "clear" => "Očisti", // Toast "success" => "Uspeh", // Odgovori sa servera "session_expired" => "Vaša sesija je istekla. Molimo vas da se ponovo prijavite", "fields_missing" => "Neki podaci nedostaju", "fill_all_fields" => "Molimo vas da popunite sva polja", "fill_mandatory_fields" => "Molimo vas da popunite sva obavezna polja", "error" => "Greška", // Kategorija "failed_add_category" => "Dodavanje kategorije nije uspelo", "failed_edit_category" => "Izmena kategorije nije uspela", "category_in_use" => "Kategorija se koristi u pretplatama i ne može biti uklonjena", "failed_remove_category" => "Uklanjanje kategorije nije uspelo", "category_saved" => "Kategorija sačuvana", "category_removed" => "Kategorija uklonjena", "sort_order_saved" => "Redosled sortiranja sačuvan", // Valuta "currency_saved" => "je sačuvan.", "error_adding_currency" => "Greška pri dodavanju valutne stavke.", "failed_to_store_currency" => "Nije uspelo skladištenje valute u bazi podataka.", "currency_in_use" => "Valuta se koristi u pretplatama i ne može biti izbrisana.", "currency_is_main" => "Valuta je postavljena kao glavna i ne može biti izbrisana.", "failed_to_remove_currency" => "Nije uspelo uklanjanje valute iz baze podataka.", "failed_to_store_api_key" => "Nije uspelo skladištenje API ključa u bazi podataka.", "invalid_api_key" => "Nevažeći API ključ.", "api_key_saved" => "API ključ je uspešno sačuvan", "currency_removed" => "Valuta uklonjena", // Domaćinstvo "failed_add_household" => "Dodavanje člana domaćinstva nije uspelo", "failed_edit_household" => "Izmena člana domaćinstva nije uspela", "failed_remove_household" => "Uklanjanje člana domaćinstva nije uspelo", "household_in_use" => "Član domaćinstva se koristi u pretplatama i ne može biti uklonjen", "member_saved" => "Član sačuvan", "member_removed" => "Član uklonjen", // Obaveštenja "error_saving_notifications" => "Greška pri čuvanju podataka za obaveštenja.", "wallos_notification" => "Obaveštenje od Wallos-a", "test_notification" => "Ovo je testno obaveštenje. Ako ga vidite, konfiguracija je ispravna.", "email_error" => "Greška pri slanju e-pošte", "notification_sent_successfuly" => "Obaveštenje uspešno poslato", "notifications_settings_saved" => "Podešavanja obaveštenja uspešno sačuvana.", "notification_failed" => "Obaveštenje nije poslato", // Plaćanja "payment_in_use" => "Nije moguće onemogućiti korišćeni način plaćanja", "failed_update_payment" => "Nije uspelo ažuriranje načina plaćanja u bazi podataka", "enabled" => "omogućen", "disabled" => "onemogućen", // Pretplata "error_fetching_image" => "Greška pri preuzimanju slike", "subscription_updated_successfuly" => "Pretplata uspešno ažurirana", "subscription_added_successfuly" => "Pretplata uspešno dodata", "error_deleting_subscription" => "Greška pri brisanju pretplate.", "invalid_request_method" => "Nevažeći metod zahteva.", // Korisnik "error_updating_user_data" => "Greška pri ažuriranju korisničkih podataka.", "user_details_saved" => "Korisnički podaci sačuvani", // Admin Page "registrations" => "Registracije", "enable_user_registrations" => "Omogući registracije korisnika", "maximum_number_users" => "Maksimalan broj korisnika", "require_email_verification" => "Zahtevaj verifikaciju e-pošte", "configure_smtp_settings_to_enable" => "Konfigurišite SMTP podešavanja da biste omogućili ovu opciju", "server_url" => "URL servera", "server_url_info" => "Koristi se za verifikaciju e-pošte i oporavak lozinke. Mora da bude važeći javni URL.", "server_url_password_reset" => "Ako je podešeno, takođe će se omogućiti funkcija resetovanja lozinke.", "disable_login" => "Onemogući prijavu", "disable_login_info" => "Zaobiđite prijavu. Ako svoj server pokrećete samo na lokalnoj mreži, bez spoljnog pristupa možete da onemogućite prijavu. Ovo će automatski prijaviti korisnika administratora.", "disable_login_info2" => "Ovo podešavanje se može omogućiti samo ako je registracija korisnika zatvorena i broj korisničkih naloga ne prelazi administratorske naloge.", "max_users_info" => "0 za neograničen broj korisnika", "user_management" => "Upravljanje korisnicima", "delete_user" => "Izbriši korisnika", "delete_user_info" => "Brisanjem korisnika izbrisaće se i sve njegove pretplate i podešavanja.", "create_user" => "Kreiraj korisnika", "oidc_settings" => "OIDC podešavanja", "oidc_auth_enabled" => "Omogući OIDC autentifikaciju", "create_user_automatically" => "Kreiraj korisnika automatski", "disable_password_login" => "Onemoguči prijavu z geslom", "smtp_settings" => "SMTP podešavanja", "smtp_usage_info" => "Koristiće se za oporavak lozinke i druge sistemske e-poruke.", "security_settings" => "Podešavanja bezbednosti", "ssrf_protection_info" => "Kako bi se sprečile Server-Side Request Forgery (SSRF) napadi, Wallos podrazumevano blokira webhook obaveštenja ka privatnim ili internim mrežnim adresama.", "local_webhook_info" => "Ako treba da šaljete webhooks ka lokalnim servisima (npr. Home Assistant, Gotify ili Node-RED), unesite njihove IP adrese ili imena hostova gore, odvojene zarezima (npr. 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Održavanje", "orphaned_logos" => "Nepovezani logotipi", "update" => "Ažuriraj", "new_version_available" => "Nova verzija Wallos-a je dostupna", "current_version" => "Trenutna verzija", "latest_version" => "Najnovija verzija", "on_current_version" => "Koristite najnoviju verziju Wallos-a.", "show_update_notification" => "Prikaži obaveštenje o ažuriranjima na dashboardu", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "E-pošta je uspešno verifikovana", "email_verification_failed" => "Verifikacija e-pošte nije uspela", // Calendar "calendar" => "Kalendar", "sun" => "Ned", "mon" => "Pon", "tue" => "Uto", "wed" => "Sre", "thu" => "Čet", "fri" => "Pet", "sat" => "Sub", "month-01" => "Januar", "month-02" => "Februar", "month-03" => "Mart", "month-04" => "April", "month-05" => "Maj", "month-06" => "Jun", "month-07" => "Jul", "month-08" => "Avgust", "month-09" => "Septembar", "month-10" => "Oktobar", "month-11" => "Novembar", "month-12" => "Decembar", "total_cost" => "Ukupan trošak", "export_icalendar" => "Izvezi iCalendar", "over_budget_warning" => "Prekoračili ste budžet", // TOTP Page "insert_totp_code" => "Unesite TOTP kod", ]; ?> ================================================ FILE: includes/i18n/tr.php ================================================ "Giriş yapabilmeniz için önce bir hesap oluşturmanız gerekiyor", "username" => "Kullanıcı Adı", "password" => "Şifre", "email" => "E-posta", "firstname" => "Ad", "lastname" => "Soy isim", "confirm_password" => "Şifreyi Onayla", "main_currency" => "Ana Para Birimi", "language" => "Dil", "passwords_dont_match" => "Şifreler eşleşmiyor", "username_exists" => "Bu kullanıcı adı zaten mevcut", "email_exists" => "Bu e-posta zaten mevcut", "registration_failed" => "Kayıt başarısız, lütfen tekrar deneyin.", "register" => "Kayıt Ol", "restore_database" => "Veritabanını geri yükle", // Login Page "please_login" => "Lütfen giriş yapın", "stay_logged_in" => "Oturumu açık tut (30 gün)", "login" => "Giriş Yap", "login_with" => "Şununla giriş yap", "or" => "veya", "login_failed" => "Giriş bilgileri hatalı", "registration_successful" => "Kayıt başarılı", "user_email_waiting_verification" => "E-postanızın doğrulanması gerekiyor. Lütfen e-postanızı kontrol edin", // Password Reset Page "forgot_password" => "Şifremi Unuttum", "reset_password" => "Şifreyi Sıfırla", "reset_sent_check_email" => "Şifre sıfırlama bağlantısı e-posta adresinize gönderildi. Lütfen e-postanızı kontrol edin.", "password_reset_successful" => "Şifre sıfırlama başarılı", // Header "profile" => "Profil", "dashboard" => "Panel", "subscriptions" => "Abonelikler", "stats" => "İstatistikler", "settings" => "Ayarlar", "admin" => "Yönetici", "about" => "Hakkında", "logout" => "Çıkış Yap", // Dashboard "hello" => "Merhaba", "upcoming_payments" => "Yaklaşan Ödemeler", "no_upcoming_payments" => "Yaklaşan ödemeniz yok", "overdue_renewals" => "Gecikmiş Yenilemeler", "ai_recommendations" => "AI Önerileri", "your_budget" => "Bütçeniz", "budget" => "Bütçe", "budget_used" => "İşletilen Bütçe", "over_budget" => "Bütçeyi Aşma", "your_subscriptions" => "Abonelikleriniz", "your_savings" => "Tasarruflarınız", // Subscriptions page "subscription" => "Abonelik", "no_subscriptions_yet" => "Henüz herhangi bir aboneliğiniz yok", "add_first_subscription" => "İlk aboneliği ekle", "new_subscription" => "Yeni Abonelik", "search" => "Ara", "state" => "Durum", "alphanumeric" => "Alfanümerik", "sort" => "Sırala", "name" => "İsim", "last_added" => "Son Eklenen", "price" => "Fiyat", "next_payment" => "Sonraki Ödeme", "renewal_type" => "Yenileme Türü", "auto_renewal" => "Otomatik Yenileme", "automatically_renews" => "Otomatik Yenileme", "manual_renewal" => "Manuel Yenileme", "start_date" => "Başlangıç Tarihi", "inactive" => "Aboneliği Devre Dışı Bırak", "replaced_with" => "Şununla değiştirildi", "none" => "Yok", "member" => "Üye", "category" => "Kategori", "payment_method" => "Ödeme Yöntemi", "Daily" => "Günlük", "Weekly" => "Haftalık", "Monthly" => "Aylık", "Yearly" => "Yıllık", "daily" => "Gün(ler)", "weekly" => "Hafta(lar)", "monthly" => "Ay(lar)", "yearly" => "Yıl(lar)", "days" => "günler", "weeks" => "haftalar", "months" => "aylar", "years" => "yıllar", "external_url" => "Harici URL'yi Ziyaret Et", "empty_page" => "Boş Sayfa", "clear_filters" => "Filtreleri Temizle", "no_matching_subscriptions" => "Eşleşen abonelik bulunamadı", "clone" => "Kopyala", "renew" => "Yenile", "calculate_next_payment_date" => "Sonraki ödeme tarihini hesapla", // Subscription form "add_subscription" => "Abonelik ekle", "edit_subscription" => "Aboneliği düzenle", "subscription_name" => "Abonelik adı", "logo_preview" => "Logo Önizlemesi", "search_logo" => "Logoyu webde ara", "web_search" => "Web araması", "currency" => "Para Birimi", "payment_every" => "Ödeme Sıklığı", "frequency" => "Frekans", "cycle" => "Döngü", "no_category" => "Kategori yok", "paid_by" => "Ödeyen", "url" => "URL", "notes" => "Notlar", "enable_notifications" => "Bu abonelik için bildirimleri etkinleştir", "default_value_from_settings" => "Ayarlar'dan varsayılan değeri al", "cancellation_notification" => "İptal Bildirimi", "delete" => "Sil", "cancel" => "İptal", "upload_logo" => "Logo Yükle", // Statistics page "cant_convert_currency" => "Aboneliklerinizde birden fazla para birimi kullanıyorsunuz. Geçerli ve doğru istatistiklere sahip olmak için lütfen ayarlar sayfasında bir Fixer API Anahtarı ayarlayın.", "general_statistics" => "Genel İstatistikler", "active_subscriptions" => "Aktif Abonelikler", "inactive_subscriptions" => "Aktif Olmayan Abonelikler", "monthly_cost" => "Aylık Maliyet", "yearly_cost" => "Yıllık Maliyet", "average_monthly" => "Ortalama Aylık Abonelik Maliyeti", "most_expensive" => "En Pahalı Abonelik Maliyeti", "amount_due" => "Bu ay ödenecek miktar", "percentage_budget_used" => "Bütçe Kullanımı", "budget_remaining" => "Kalan Bütçe", "amount_over_budget" => "Bütçe Aşımı", "monthly_savings" => "Aylık Tasarruf (aktif olmayan aboneliklerde)", "yearly_savings" => "Yıllık Tasarruf (aktif olmayan aboneliklerde)", "split_views" => "Bölünmüş Görünümler", "category_split" => "Kategori Bölümü", "household_split" => "Hane Bölümü", "payment_method_split" => "Ödeme Yöntemi Bölümü", "total_cost_trend" => "Toplam Maliyet Eğilimi", "cost_vs_budget" => "Bütçe ile Maliyet", // About page "about_and_credits" => "Hakkında ve Teşekkürler", "credits" => "Teşekkürler", "license" => "Lisans", "release_notes" => "Sürüm Notları", "update_available" => "Güncelleme mevcut", "issues_and_requests" => "Sorunlar ve İstekler", "the_author" => "Yazar", "icons" => "İkonlar", "payment_icons" => "Ödeme İkonları", // Profile page "upload_avatar" => "Avatarı yükle", "file_type_error" => "Dosya türü izin verilmiyor", "user_details" => "Kullanıcı Detayları", "two_factor_authentication" => "İki Faktörlü Kimlik Doğrulama", "two_factor_info" => "İki Faktörlü Kimlik Doğrulama, hesabınıza ekstra bir güvenlik katmanı ekler.
Karekodu taramak için Google Authenticator, Authy veya Ente Auth gibi bir kimlik doğrulayıcı uygulamasına ihtiyacınız olacaktır.", "two_factor_enabled_info" => "Hesabınız İki Faktörlü Kimlik Doğrulama ile güvendedir. Yukarıdaki düğmeye tıklayarak devre dışı bırakabilirsiniz.", "enable_two_factor_authentication" => "İki Faktörlü Kimlik Doğrulamayı Etkinleştir", "2fa_already_enabled" => "İki Faktörlü Kimlik Doğrulama zaten etkinleştirildi", "totp_code_incorrect" => "TOTP kodu yanlış", "backup_codes" => "Yedek Kodlar", "download_backup_codes" => "Yedek Kodları İndir", "copy_to_clipboard" => "Panoya Kopyala", "totp_backup_codes_info" => "Yedek kodları güvenli bir yerde saklayın. Her biri yalnızca bir kez kullanılabilir.", "disable_two_factor_authentication" => "İki Faktörlü Kimlik Doğrulamayı Devre Dışı Bırak", "totp_code" => "TOTP Kodu", "api_key" => "API Anahtarı", "regenerate" => "Yeniden Oluştur", "api_key_info" => "API Anahtarı, Wallos'un API'sine erişmek için kullanılır. Bu anahtarı kimseyle paylaşmayın.", // Settings page "monthly_budget" => "Aylık Bütçe", "budget_info" => "Bir bütçe belirlemek, istatistik sayfasında bütçe ve gerçek harcamaları karşılaştırmanıza olanak tanır.", "household" => "Hane", "save_member" => "Üyeyi Kaydet", "delete_member" => "Üyeyi Sil", "cant_delete_member" => "Ana üyeyi silemezsiniz", "cant_delete_member_in_use" => "Abonelikte kullanılan üyeyi silemezsiniz", "household_info" => "E-posta alanı, hane üyelerinin süresi dolmak üzere olan aboneliklerden haberdar edilmesini sağlar.", "notifications" => "Bildirimler", "enable_email_notifications" => "E-posta bildirimlerini etkinleştir", "notify_me" => "Beni bilgilendir", "day_before" => "bir gün önce", "on_due_date" => "Vadesinde", "days_before" => "günler önce", "smtp_address" => "SMTP Adresi", "port" => "Port", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "SMTP Kullanıcı Adı", "smtp_password" => "SMTP Şifresi", "from_email" => "Gönderen e-posta (İsteğe bağlı)", "send_to_other_emails" => "Bildirimleri aşağıdaki e-posta adreslerine de gönder (ayırmak için ; kullanın):", "smtp_info" => "SMTP Şifresi düz metin olarak iletilir ve saklanır. Güvenlik için, lütfen bunun için özel bir hesap oluşturun.", "telegram" => "Telegram", "telegram_bot_token" => "Telegram Bot Token", "telegram_chat_id" => "Telegram Chat ID", "pushplus" => "Pushplus", "pushplus_token" => "Pushplus Token", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost Webhook URL", "mattermost_bot_username" => "Mattermost Bot Kullanıcı Adı", "mattermost_bot_icon_emoji" => "Mattermost Bot İkon Emoji", "webhook" => "Webhook", "webhook_url" => "Webhook URL", "request_method" => "İstek Metodu", "custom_headers" => "Özel Başlıklar", "webhook_payload" => "Webhook Payload", "payment_notifications_payload" => "Ödeme Bildirimi Payload", "cancelation_notification_payload" => "İptal Bildirimi Payload", "variables_available" => "Kullanılabilir Değişkenler", "gotify" => "Gotify", "token" => "Token", "discord" => "Discord", "discord_bot_username" => "Discord Bot Kullanıcı Adı", "discord_bot_avatar_url" => "Discord Bot Avatar URL", "pushover" => "Pushover", "pushover_user_key" => "Pushover Kullanıcı Anahtarı", "host" => "Host", "topic" => "Konu", "ignore_ssl_errors" => "SSL Hatalarını Yoksay", "categories" => "Kategoriler", "save_category" => "Kategoriyi Kaydet", "delete_category" => "Kategoriyi Sil", "cant_delete_category_in_use" => "Abonelikte kullanılan kategoriyi silemezsiniz", "currencies" => "Para Birimleri", "save_currency" => "Para birimini kaydet", "delete_currency" => "Para birimini sil", "cant_delete_main_currency" => "Ana para birimini silemezsiniz", "cant_delete_currency_in_use" => "Abonelikte kullanılan para birimini silemezsiniz", "exchange_update" => "Döviz kurları son güncelleme tarihi", "currency_info" => "Desteklenen para birimlerini ve doğru para birimi kodlarını burada bulun", "currency_performance" => "Performansı artırmak için sadece kullandığınız para birimlerini tutun.", "fixer_api_key" => "Fixer API Anahtarı", "provider" => "Sağlayıcı", "fixer_info" => "Birden fazla para birimi kullanıyorsanız ve aboneliklerde doğru istatistikler ve sıralama istiyorsanız, Fixer'dan ÜCRETSİZ bir API Anahtarı gereklidir.", "get_key" => "Anahtarınızı şuradan alın", "get_free_fixer_api_key" => "Ücretsiz Fixer API Anahtarı alın", "get_key_alternative" => "Alternatif olarak, şu adresten ücretsiz bir fixer api anahtarı edinebilirsiniz", "ai_model" => "AI Modeli", "select_ai_model" => "AI Modelini Seçin", "run_schedule" => "Programı Çalıştır", "manually" => "Manuel Olarak", "coming_soon" => "Çok Yakında", "invalid_host" => "Geçersiz Host", "ai_recommendations_info" => "AI önerileri, abonelikleriniz ve hane üyeleriniz temel alınarak oluşturulur.", "may_take_time" => "Sağlayıcıya, modele ve abonelik sayısına bağlı olarak önerilerin oluşturulması biraz zaman alabilir.", "recommendations_visible_on_dashboard" => "Öneriler panelde görüntülenecektir.", "generate_recommendations" => "Önerileri Oluştur", "display_settings" => "Görüntüleme Ayarları", "theme_settings" => "Tema Ayarları", "colors" => "Renkler", "custom_colors" => "Özel Renkler", "theme" => "Tema", "dark_theme" => "Koyu Temayı", "light_theme" => "Açık Temayı", "automatic"=> "Otomatik", "main_color" => "Ana", "accent_color" => "Vurgu", "hover_color" => "Üzerine gelindiğinde", "save_custom_colors" => "Özel Renkleri Kaydet", "reset_custom_colors" => "Özel Renkleri Sıfırla", "custom_css" => "Özel CSS", "save_custom_css" => "Özel CSS'yi Kaydet", "calculate_monthly_price" => "Tüm aboneliklerin aylık fiyatını hesaplayın ve gösterin", "convert_prices" => "Fiyatları her zaman ana para birimimde dönüştürün ve gösterin (daha yavaş)", "show_original_price" => "Dönüşümler veya hesaplamalar yapıldığında orijinal fiyatı da göster", "experience" => "Deneyim", "show_subscription_progress" => "Aboneliklerin ilerlemesini göster", "disabled_subscriptions" => "Devre Dışı Bırakılan Abonelikler", "hide_disabled_subscriptions" => "Devre dışı bırakılan abonelikleri gizle", "show_disabled_subscriptions_at_the_bottom" => "Devre dışı bırakılan abonelikleri altta göster", "experimental_settings" => "Deneysel Ayarlar", "remove_background" => "Görsel aramadan logoların arka planını kaldırmayı deneyin", "use_mobile_navigation_bar" => "Mobil cihazlarda gezinme çubuğunu kullan", "experimental_info" => "Deneysel ayarlar muhtemelen mükemmel çalışmayacak.", "payment_methods" => "Ödeme Yöntemleri", "payment_methods_info" => "Bir ödeme yöntemini devre dışı bırakmak / etkinleştirmek için tıklayın.", "rename_payment_methods_info" => "Yeniden adlandırmak için bir ödeme yönteminin adına tıklayın.", "cant_delete_payment_method_in_use" => "Kullanımda olan ödeme yöntemini devre dışı bırakamazsınız", "add_custom_payment" => "Özel ödeme yöntemi ekle", "payment_method_name" => "Ödeme Yöntemi Adı", "payment_method_added_successfuly" => "Ödeme yöntemi başarıyla eklendi", "payment_method_removed" => "Ödeme yöntemi kaldırıldı", "disable" => "Devre Dışı Bırak", "enable" => "Etkinleştir", "rename_payment_method" => "Ödeme yöntemi adını değiştir", "payment_renamed" => "Ödeme yöntemi adı değiştirildi", "payment_not_renamed" => "Ödeme yöntemi adı değiştirilemedi", "test" => "Test Et", "add" => "Ekle", "save" => "Kaydet", "reset" => "Sıfırla", "main_accent_color_error" => "Ana ve vurgu rengi aynı olamaz", "backup_and_restore" => "Yedekle ve Geri Yükle", "backup" => "Yedekle", "restore" => "Geri Yükle", "restore_info" => "Veritabanının geri yüklenmesi tüm mevcut verileri geçersiz kılacaktır. Geri yüklemeden sonra oturumunuz kapatılacaktır.", "account" => "Hesap", "export_subscriptions" => "Abonelikleri dışa aktar", "export_as_json" => "JSON olarak dışa aktar", "export_as_csv" => "CSV olarak dışa aktar", "danger_zone" => "Tehlikeli Bölge", "delete_account" => "Hesabı Sil", "delete_account_info" => "Hesabınızı sildiğinizde tüm abonelikleriniz ve ayarlarınız da silinecektir.", // Filters menu "filter" => "Filtre", "clear" => "Temizle", // Toast "success" => "Başarılı", // Endpoint responses "session_expired" => "Oturumunuz sona erdi. Lütfen tekrar giriş yapın", "fields_missing" => "Bazı alanlar eksik", "fill_all_fields" => "Lütfen tüm alanları doldurun", "fill_mandatory_fields" => "Lütfen zorunlu alanları doldurun", "error" => "Hata", // Category "failed_add_category" => "Kategori eklenemedi", "failed_edit_category" => "Kategori düzenlenemedi", "category_in_use" => "Kategori aboneliklerde kullanımda olduğu için kaldırılamaz", "failed_remove_category" => "Kategori kaldırılamadı", "category_saved" => "Kategori kaydedildi", "category_removed" => "Kategori kaldırıldı", "sort_order_saved" => "Sıralama düzeni kaydedildi", // Currency "currency_saved" => "kaydedildi.", "error_adding_currency" => "Para birimi girişi eklenirken hata oluştu.", "failed_to_store_currency" => "Para birimi Veritabanına kaydedilemedi.", "currency_in_use" => "Para birimi aboneliklerde kullanımda olduğu için silinemez.", "currency_is_main" => "Para birimi ana para birimi olarak ayarlandığı için silinemez.", "failed_to_remove_currency" => "Para birimi Veritabanından kaldırılamadı.", "failed_to_store_api_key" => "API Anahtarı Veritabanına kaydedilemedi.", "invalid_api_key" => "Geçersiz API Anahtarı.", "api_key_saved" => "API anahtarı başarıyla kaydedildi", "currency_removed" => "Para birimi kaldırıldı", // Household "failed_add_household" => "Hane üyesi eklenemedi", "failed_edit_household" => "Hane üyesi düzenlenemedi", "failed_remove_household" => "Hane üyesi kaldırılamadı", "household_in_use" => "Hane üyesi aboneliklerde kullanımda olduğu için kaldırılamaz", "member_saved" => "Üye kaydedildi", "member_removed" => "Üye kaldırıldı", // Notifications "error_saving_notifications" => "Bildirim verileri kaydedilirken hata oluştu.", "wallos_notification" => "Wallos Bildirimi", "test_notification" => "Bu bir test bildirimidir. Bunu görüyorsanız, yapılandırma doğrudur.", "email_error" => "E-posta gönderilirken hata oluştu", "notification_sent_successfuly" => "Bildirim başarıyla gönderildi", "notifications_settings_saved" => "Bildirim ayarları başarıyla kaydedildi.", "notification_failed" => "Bildirim gönderilemedi", // Payments "payment_in_use" => "Kullanımda olan ödeme yöntemi devre dışı bırakılamaz", "failed_update_payment" => "Ödeme yöntemi veritabanında güncellenemedi", "enabled" => "etkinleştirildi", "disabled" => "devre dışı bırakıldı", // Subscription "error_fetching_image" => "Görüntü alınırken hata oluştu", "subscription_updated_successfuly" => "Abonelik başarıyla güncellendi", "subscription_added_successfuly" => "Abonelik başarıyla eklendi", "error_deleting_subscription" => "Abonelik silinirken hata oluştu.", "invalid_request_method" => "Geçersiz istek metodu.", // User "error_updating_user_data" => "Kullanıcı verileri güncellenirken hata oluştu.", "user_details_saved" => "Kullanıcı detayları kaydedildi", // Admin Page "registrations" => "Kayıtlar", "enable_user_registrations" => "Kullanıcı kayıtlarını etkinleştir", "maximum_number_users" => "Maksimum kullanıcı sayısı", "require_email_verification" => "E-posta doğrulaması gerektir", "configure_smtp_settings_to_enable" => "E-posta doğrulamasını etkinleştirmek için SMTP ayarlarını yapılandırın", "server_url" => "Sunucu URL'si", "server_url_info" => "E-posta doğrulama ve şifre kurtarma için kullanılır. Geçerli bir genel URL olmalıdır.", "server_url_password_reset" => "Ayarlanırsa şifre sıfırlama işlevini de etkinleştirir.", "disable_login" => "Girişi devre dışı bırak", "disable_login_info" => "Girişi atlayın. Sunucunuzu yalnızca yerel bir ağ üzerinde, harici erişim olmadan çalıştırıyorsanız, oturum açmayı devre dışı bırakabilirsiniz. Bu, yönetici kullanıcıyı otomatik olarak oturum açacaktır.", "disable_login_info2" => "Bu ayarı yalnızca kullanıcı kaydı kapalıysa ve yönetici kullanıcı hesabından başka kullanıcı yoksa etkinleştirebilirsiniz.", "max_users_info" => "0 veya boş bırakıldığında sınırsız kullanıcı sayısı", "user_management" => "Kullanıcı Yönetimi", "delete_user" => "Kullanıcıyı Sil", "delete_user_info" => "Bir kullanıcının silinmesi aynı zamanda tüm aboneliklerinin ve ayarlarının da silinmesine neden olur.", "create_user" => "Kullanıcı Oluştur", "oidc_settings" => "OpenID Connect Ayarları", "oidc_auth_enabled" => "OpenID Connect Kimlik Doğrulaması Etkinleştirildi", "create_user_automatically" => "OpenID Connect ile giriş yapıldığında kullanıcı otomatik olarak oluşturulsun", "disable_password_login" => "Parola ile giriş devre dışı bırakılsın", "smtp_settings" => "SMTP Ayarları", "smtp_usage_info" => "Şifre kurtarma ve diğer sistem e-postaları için kullanılacaktır.", "security_settings" => "Güvenlik Ayarları", "ssrf_protection_info" => "Sunucu tarafı istek sahteciliği (SSRF) saldırılarını önlemek için Wallos, varsayılan olarak webhook bildirimlerini özel veya dahili ağ adreslerine engeller.", "local_webhook_info" => "Webhooks'u yerel hizmetlere (ör. Home Assistant, Gotify veya Node-RED) göndermeniz gerekiyorsa, IP adreslerini veya ana bilgisayar adlarını yukarıya virgülle ayrılmış bir liste olarak girin (ör. 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Bakım Görevleri", "orphaned_logos" => "Yetim Logolar", "update" => "Güncelle", "new_version_available" => "Yeni bir Wallos sürümü mevcut", "current_version" => "Mevcut Sürüm", "latest_version" => "En Son Sürüm", "on_current_version" => "Wallos'un en son sürümünü kullanıyorsunuz.", "show_update_notification" => "Gösterge panelinde güncelleme bildirimini göster", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "E-posta doğrulandı", "email_verification_failed" => "E-posta doğrulaması başarısız oldu", // Calendar "calendar" => "Takvim", "sun" => "Paz", "mon" => "Pzt", "tue" => "Sal", "wed" => "Çar", "thu" => "Per", "fri" => "Cum", "sat" => "Cmt", "month-01" => "Ocak", "month-02" => "Şubat", "month-03" => "Mart", "month-04" => "Nisan", "month-05" => "Mayıs", "month-06" => "Haziran", "month-07" => "Temmuz", "month-08" => "Ağustos", "month-09" => "Eylül", "month-10" => "Ekim", "month-11" => "Kasım", "month-12" => "Aralık", "total_cost" => "Toplam Maliyet", "export_icalendar" => "iCalendar olarak dışa aktar", "over_budget_warning" => "Bütçenizi aştınız", // TOTP Page "insert_totp_code" => "Lütfen TOTP kodunuzu girin", ]; ?> ================================================ FILE: includes/i18n/uk.php ================================================ "Вам необхідно створити обліковий запис, перш ніж ви зможете увійти в систему", "username" => "Ім'я користувача", "password" => "Пароль", "email" => "E-mail", "firstname" => "Ім'я", "lastname" => "Прізвище", "confirm_password" => "Підтвердьте пароль", "main_currency" => "Основна валюта", "language" => "Мова", "passwords_dont_match" => "Паролі не співпадають", "username_exists" => "Ім'я користувача вже існує", "email_exists" => "E-mail вже існує", "registration_failed" => "Реєстрація не вдалася, спробуйте ще раз.", "register" => "Реєстрація", "restore_database" => "Відновити базу даних", // Login Page "please_login" => "Будь ласка, увійдіть", "stay_logged_in" => "Залишатися в системі (30 днів)", "login" => "Авторизуватися", "login_with" => "Увійти з", "or" => "або", "login_failed" => "Дані для входу невірні", "registration_successful" => "Реєстрація пройшла успішно", "user_email_waiting_verification" => "Ваша електронна адреса потребує перевірки. Будь ласка, перевірте свою електронну скриньку.", // Password Reset Page "forgot_password" => "Забули пароль?", "reset_password" => "Скинути пароль", "reset_sent_check_email" => "Посилання для скидання паролю надіслано на вашу електронну пошту", "password_reset_successful" => "Пароль успішно скинуто", // Header "profile" => "Профіль", "dashboard" => "Панель", "subscriptions" => "Підписки", "stats" => "Статистика", "settings" => "Налаштування", "admin" => "Адміністратор", "about" => "Про програму", "logout" => "Вийти", // Dashboard "hello" => "Привіт", "upcoming_payments" => "Предстоять платежі", "no_upcoming_payments" => "У вас немає предстоять платежів", "overdue_renewals" => "Прострочені поновлення", "ai_recommendations" => "AI рекомендації", "your_budget" => "Ваш бюджет", "budget" => "Бюджет", "budget_used" => "Використаний бюджет", "over_budget" => "Перевищений бюджет", "your_subscriptions" => "Ваші підписки", "your_savings" => "Ваші заощадження", // Subscriptions page "subscription" => "Підписка", "no_subscriptions_yet" => "У вас поки немає підписок", "add_first_subscription" => "Додати першу підписку", "new_subscription" => "Нова підписка", "search" => "Пошук", "state" => "Стан", "alphanumeric" => "Алфавітний порядок", "sort" => "Сортування", "name" => "Ім'я", "last_added" => "Дата створення", "price" => "Вартість", "next_payment" => "Наступний платіж", "renewal_type" => "Тип продовження", "auto_renewal" => "Автоматичне продовження", "automatically_renews" => "Автоматичне продовження", "manual_renewal" => "Ручне продовження", "start_date" => "Дата початку", "inactive" => "Відключити підписку", "replaced_with" => "Замінена на", "none" => "Немає", "member" => "Член сім'ї", "category" => "Категорія", "payment_method" => "Спосіб оплати", "Daily" => "День", "Weekly" => "Тиждень", "Monthly" => "Місяць", "Yearly" => "Рік", "daily" => "День", "weekly" => "Тиждень", "monthly" => "Місяць", "yearly" => "Рік", "days" => "днів", "weeks" => "тижнів", "months" => "місяців", "years" => "років", "external_url" => "Відвідайте зовнішню URL-адресу", "empty_page" => "Порожня сторінка", "clear_filters" => "Очистити фільтри", "no_matching_subscriptions" => "Немає відповідних підписок", "clone" => "Клонувати", "renew" => "Продовжити", "calculate_next_payment_date" => "Розрахувати дату наступного платежу", // Subscription form "add_subscription" => "Додати підписку", "edit_subscription" => "Змінити підписку", "subscription_name" => "Назва підписки", "logo_preview" => "Попередній перегляд логотипу", "search_logo" => "Пошук логотипу в Інтернеті", "web_search" => "Веб-пошук", "currency" => "Валюта", "payment_every" => "Оплата кожні", "frequency" => "Частота", "cycle" => "Цикл", "no_category" => "Немає категорії", "paid_by" => "Оплачує", "url" => "URL", "notes" => "Примітки", "enable_notifications" => "Увімкнути сповіщення для цієї підписки", "default_value_from_settings" => "Значення за замовчуванням з налаштувань", "cancellation_notification" => "Сповіщення про скасування", "delete" => "Видалити", "cancel" => "Скасувати", "upload_logo" => "Завантажити логотип", // Statistics page "cant_convert_currency" => "Ви використовуєте кілька валют у своїх підписках. Для отримання достовірної та точної статистики, будь ласка, встановіть API-ключ Fixer на сторінці налаштувань.", "general_statistics" => "Загальна статистика", "active_subscriptions" => "Активні підписки", "inactive_subscriptions" => "Неактивні підписки", "monthly_cost" => "Щомісячна вартість", "yearly_cost" => "Річна вартість", "average_monthly" => "Середня щомісячна вартість підписки", "most_expensive" => "Найдорожча вартість підписки", "amount_due" => "Сума до оплати цього місяця", "percentage_budget_used" => "Відсоток використання бюджету", "budget_remaining" => "Залишок бюджету", "amount_over_budget" => "Сума перевищення бюджету", "monthly_savings" => "Щомісячна економія (при неактивних підписках)", "yearly_savings" => "Річна економія (при неактивних підписках)", "split_views" => "Детальна статистика", "category_split" => "За категоріями", "household_split" => "За членами сім'ї", "payment_method_split" => "За способами оплати", "total_cost_trend" => "Тенденція загальної вартості", "cost_vs_budget" => "Вартість у порівнянні з бюджетом", // About page "about_and_credits" => "Про компанію та авторів", "credits" => "Подяки", "license" => "Ліцензія", "release_notes" => "Примітки до випуску", "update_available" => "Доступне оновлення", "issues_and_requests" => "Проблеми та запити", "the_author" => "Автор", "icons" => "Значки", "payment_icons" => "Значки способів оплати", // Profile page "upload_avatar" => "Завантажити аватар", "file_type_error" => "Зазначений тип файлу не підтримується.", "user_details" => "Дані користувача", "two_factor_authentication" => "Двофакторна автентифікація", "two_factor_info" => "Двофакторна автентифікація додає додатковий рівень безпеки до вашого облікового запису.
Для сканування QR-коду вам знадобиться застосунок-автентифікатор, наприклад Google Authenticator, Authy або Ente Auth.", "two_factor_enabled_info" => "Ваш обліковий запис захищено за допомогою двофакторної автентифікації. Ви можете вимкнути її, натиснувши на кнопку вище.", "enable_two_factor_authentication" => "Увімкнути двофакторну аутентифікацію", "2fa_already_enabled" => "Двофакторна автентифікація вже увімкнена", "totp_code_incorrect" => "Код TOTP невірний", "backup_codes" => "Резервні коди", "download_backup_codes" => "Завантажити резервні коди", "copy_to_clipboard" => "Скопіювати в буфер обміну", "totp_backup_codes_info" => "Збережіть ці коди в безпечному місці. Вони можуть бути використані для входу в систему, якщо ви втратите доступ до додатку автентифікації.", "disable_two_factor_authentication" => "Вимкнути двофакторну автентифікацію", "totp_code" => "Код TOTP", "monthly_budget" => "Щомісячний бюджет", "api_key" => "API ключ", "regenerate" => "Згенерувати", "api_key_info" => "API ключ використовується для доступу до ваших даних через API. Не передавайте його третім особам.", // Settings page "budget_info" => "Якщо ви вкажете бюджет, Wallos буде відображати вашу поточну вартість підписок у порівнянні з вашим бюджетом.", "household" => "Сім'я", "save_member" => "Зберегти члена сім'ї", "delete_member" => "Видалити члена сім'ї", "cant_delete_member" => "Не можу видалити основного члена сім'ї", "cant_delete_member_in_use" => "Неможливо видалити члена сім'ї, який використовується в підписці.", "household_info" => "Поле електронної пошти дозволяє членам сім'ї отримувати сповіщення про закінчення терміну дії підписки.", "notifications" => "Сповіщення", "enable_email_notifications" => "Увімкнути сповіщення по електронній пошті", "notify_me" => "Повідомити мене за", "day_before" => "день до події", "on_due_date" => "в день події", "days_before" => "дня(днів) до події", "smtp_address" => "SMTP-адреса", "port" => "Порт", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "Ім'я користувача SMTP", "smtp_password" => "Пароль SMTP", "from_email" => "Від кого E-Mail (необов'язково)", "send_to_other_emails" => "Також надсилати сповіщення на наступні адреси електронної пошти (використовуйте ; для їх розділення):", "smtp_info" => "Пароль SMTP передається і зберігається у вигляді відкритого тексту. З метою безпеки створіть обліковий запис тільки для Wallos.", "telegram" => "Telegram", "telegram_bot_token" => "Токен Telegram-бота", "telegram_chat_id" => "Telegram Chat ID", "pushplus" => "Pushplus", "pushplus_token" => "Pushplus токен", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost Webhook URL", "mattermost_bot_username" => "Mattermost Bot Ім'я користувача", "mattermost_bot_icon_emoji" => "Mattermost Bot Ікона Emoji", "webhook" => "Webhook", "webhook_url" => "Webhook URL", "request_method" => "Метод запиту", "custom_headers" => "Користувацькі заголовки", "webhook_payload" => "Webhook Payload", "payment_notifications_payload" => "Payload для сповіщення про платіж", "cancelation_notification_payload" => "Payload для сповіщення про скасування", "variables_available" => "Доступні змінні", "gotify" => "Gotify", "token" => "Токен", "discord" => "Discord", "discord_bot_username" => "Ім'я користувача бота Discord", "discord_bot_avatar_url" => "URL-адреса аватара бота Discord", "pushover" => "Pushover", "pushover_user_key" => "Ключ користувача Pushover", "host" => "Хост", "topic" => "Тема", "ignore_ssl_errors" => "Ігнорувати помилки SSL", "categories" => "Категорії", "save_category" => "Зберегти категорію", "delete_category" => "Видалити категорію", "cant_delete_category_in_use" => "Неможливо видалити категорію, яка використовується в підписці.", "currencies" => "Валюти", "save_currency" => "Зберегти валюту", "delete_currency" => "Видалити валюту", "cant_delete_main_currency" => "Не можу видалити основну валюту", "cant_delete_currency_in_use" => "Неможливо видалити валюту, яка використовується в підписці.", "exchange_update" => "Курси валют востаннє оновлювалися", "currency_info" => "Знайдіть підтримувані валюти та правильні коди валют на", "currency_performance" => "Для підвищення продуктивності зберігайте тільки ті валюти, які ви використовуєте.", "fixer_api_key" => "Ключ Fixer API", "provider" => "Провайдер", "fixer_info" => "Якщо ви використовуєте кілька валют і хочете отримати точну статистику та сортування підписок, вам необхідний БЕЗКОШТОВНИЙ ключ API від Fixer.", "get_key" => "Отримайте ключ за адресою", "get_free_fixer_api_key" => "Отримайте безкоштовний ключ API Fixer", "get_key_alternative" => "Крім того, ви можете отримати безкоштовний ключ API Fixer на сайті", "ai_model" => "AI Модель", "select_ai_model" => "Виберіть AI Модель", "run_schedule" => "Запустити Розклад", "manually" => "Вручну", "coming_soon" => "Незабаром", "invalid_host" => "Неправильний Хост", "ai_recommendations_info" => "AI рекомендації створюються на основі ваших підписок та членів домогосподарства.", "may_take_time" => "В залежності від постачальника, моделі та кількості підписок, створення рекомендацій може зайняти деякий час.", "recommendations_visible_on_dashboard" => "Рекомендації будуть видимі на панелі приладів.", "generate_recommendations" => "Створити рекомендації", "display_settings" => "Налаштування відображення", "theme_settings" => "Налаштування теми", "colors" => "Кольори", "custom_colors" => "Користувацькі кольори", "theme" => "Тема", "dark_theme" => "Темна тема", "light_theme" => "Світла тема", "automatic" => "Автоматично", "main_color" => "Основний колір", "accent_color" => "Акцентний колір", "hover_color" => "Колір при наведенні", "save_custom_colors" => "Зберегти користувацькі кольори", "reset_custom_colors" => "Скинути користувацькі кольори", "custom_css" => "Користувацький CSS", "save_custom_css" => "Зберегти користувацький CSS", "calculate_monthly_price" => "Розрахувати та показати щомісячну ціну для всіх підписок", "convert_prices" => "Завжди конвертувати та показувати ціни в моїй основній валюті (повільніше)", "show_original_price" => "Також показувати оригінальну ціну при виконанні конверсій або розрахунків", "experience" => "Досвід", "show_subscription_progress" => "Показати прогрес підписки", "disabled_subscriptions" => "Відключені підписки", "hide_disabled_subscriptions" => "Приховати відключені підписки", "show_disabled_subscriptions_at_the_bottom" => "Показати відключені підписки внизу списку", "experimental_settings" => "Експериментальні налаштування", "remove_background" => "Спроба видалити фон логотипів із пошуку зображень.", "use_mobile_navigation_bar" => "Використовувати мобільну панель навігації", "experimental_info" => "Експериментальні налаштування, ймовірно, не будуть працювати ідеально.", "payment_methods" => "Способи оплати", "payment_methods_info" => "Натисніть на спосіб оплати, щоб відключити/включити його.", "rename_payment_methods_info" => "Натисніть на назву способу оплати, щоб перейменувати його.", "cant_delete_payment_method_in use" => "Неможливо відключити використовуваний спосіб оплати", "add_custom_payment" => "Додати власний спосіб оплати", "payment_method_name" => "Назва способу оплати", "payment_method_added_successfuly" => "Спосіб оплати успішно додано", "payment_method_removed" => "Спосіб оплати видалено.", "disable" => "Відключити", "enable" => "Увімкнути", "rename_payment_method" => "Перейменувати спосіб оплати", "payment_renamed" => "Спосіб оплати перейменовано", "payment_not_renamed" => "Спосіб оплати не перейменовано", "test" => "Тест", "add" => "Додати", "save" => "Зберегти", "reset" => "Перезавантажити", "main_accent_color_error" => "Основний і акцентний колір не можуть бути однаковими.", "backup_and_restore" => "Резервне копіювання та відновлення", "backup" => "Резервне копіювання", "restore" => "Відновлення", "restore_info" => "Відновлення бази даних скасує всі поточні дані. Після відновлення ви вийдете з системи.", "account" => "Обліковий запис", "export_subscriptions" => "Експорт підписок", "export_as_json" => "Експорт у JSON", "export_as_csv" => "Експорт у CSV", "danger_zone" => "Небезпечна зона", "delete_account" => "Видалити обліковий запис", "delete_account_info" => "При видаленні облікового запису також будуть видалені всі ваші підписки та налаштування.", // Filters menu "filter" => "Фільтр", "clear" => "Очистити", // Toast "success" => "Успішно", // Endpoint responses "session_expired" => "Термін дії вашої сесії закінчився. Будь ласка, увійдіть знову", "fields_missing" => "Деякі поля відсутні", "fill_all_fields" => "Будь ласка, заповніть усі поля", "fill_mandatory_fields" => "Будь ласка, заповніть усі обов'язкові поля", "error" => "Помилка", // Category "failed_add_category" => "Не вдалося додати категорію", "failed_edit_category" => "Не вдалося змінити категорію", "category_in_use" => "Категорія використовується в підписках і не може бути видалена.", "failed_remove_category" => "Не вдалося видалити категорію", "category_saved" => "Категорія збережена", "category_removed" => "Категорія видалена", "sort_order_saved" => "Порядок сортування збережено.", // Currency "currency_saved" => "збережено.", "error_adding_currency" => "Помилка додавання валюти.", "failed_to_store_currency" => "Не вдалося зберегти валюту в базі даних.", "currency_in_use" => "Валюта використовується в підписках і не може бути видалена.", "currency_is_main" => "Валюта встановлена ​​як основна і не може бути видалена.", "failed_to_remove_currency" => "Не вдалося видалити валюту з бази даних.", "failed_to_store_api_key" => "Не вдалося зберегти ключ API в базі даних.", "invalid_api_key" => "Невірний ключ API.", "api_key_saved" => "Ключ API успішно збережено", "currency_removed" => "Валюта видалена", // Household "failed_add_household" => "Не вдалося додати члена сім'ї.", "failed_edit_household" => "Не вдалося змінити члена сім'ї.", "failed_remove_household" => "Не вдалося видалити члена сім'ї.", "household_in_use" => "Член сім'ї використовується в підписках і не може бути видалений.", "member_saved" => "Член сім'ї збережений", "member_removed" => "Член сім'ї видалений", // Notifications "error_saving_notifications" => "Помилка збереження даних сповіщень.", "wallos_notification" => "Сповіщення від Wallos", "test_notification" => "Це тестове сповіщення. Якщо ви бачите це, значить, конфігурація правильна.", "email_error" => "Помилка надсилання електронної пошти", "notification_sent_successfuly" => "Сповіщення успішно надіслано", "notifications_settings_saved" => "Налаштування сповіщень успішно збережено.", "notification_failed" => "Сповіщення не вдалося", // Payments "payment_in_use" => "Неможливо відключити використовуваний спосіб оплати", "failed_update_payment" => "Не вдалося оновити спосіб оплати в базі даних.", "enabled" => "увімкнено", "disabled" => "відключено", // Subscription "error_fetching_image" => "Помилка завантаження зображення.", "subscription_updated_successfuly" => "Підписка успішно оновлена", "subscription_added_successfuly" => "Підписка успішно додана", "error_deleting_subscription" => "Помилка видалення підписки.", "invalid_request_method" => "Невірний метод запиту.", // User "error_updating_user_data" => "Помилка оновлення даних користувача.", "user_details_saved" => "Дані користувача збережено.", // Admin Page "registrations" => "Реєстрації", "enable_user_registrations" => "Увімкнути реєстрацію користувачів", "maximum_number_users" => "Максимальна кількість користувачів", "require_email_verification" => "Вимагати підтвердження електронною поштою", "configure_smtp_settings_to_enable" => "Налаштуйте SMTP, щоб увімкнути цю функцію.", "server_url" => "URL-адреса сервера", "server_url_info" => "Використовується для перевірки електронної пошти та відновлення пароля. Повинен бути дійсним публічним URL.", "server_url_password_reset" => "Якщо цей параметр встановлено, він також увімкне функцію скидання пароля.", "disable_login" => "Відключити вхід", "disable_login_info" => "Обхід входу в систему. Якщо ви використовуєте свій сервер тільки в локальній мережі, без доступу ззовні, ви можете відключити вхід в систему. При цьому буде автоматично входити користувач admin.", "disable_login_info2" => "Цей параметр можна увімкнути тільки в тому випадку, якщо реєстрація користувачів відключена і їх кількість не перевищує обліковий запис адміністратора.", "max_users_info" => "Встановіть 0 для необмеженої кількості користувачів.", "user_management" => "Управління користувачами", "delete_user" => "Видалити користувача", "delete_user_info" => "Видалення користувача також призведе до видалення всіх його підписок та налаштувань.", "create_user" => "Створити користувача", "smtp_settings" => "Налаштування SMTP", "oidc_settings" => "Налаштування OIDC", "oidc_auth_enabled" => "Увімкнути OIDC автентифікацію", "create_user_automatically" => "Автоматично створювати користувача при вході", "disable_password_login" => "Відключити вхід за паролем", "smtp_usage_info" => "Буде використовуватися для відновлення пароля та інших системних листів.", "security_settings" => "Налаштування безпеки", "ssrf_protection_info" => "Щоб запобігти атакам Server-Side Request Forgery (SSRF), Wallos за замовчуванням блокує вебхуки до приватних або внутрішніх мережевих адрес.", "local_webhook_info" => "Якщо вам потрібно надсилати вебхуки до локальних сервісів (наприклад, Home Assistant, Gotify або Node-RED), введіть їх IP-адреси або імена хостів вище як список, розділений комами (наприклад 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Завдання обслуговування", "orphaned_logos" => "Втрачений логотип", "update" => "Оновити", "new_version_available" => "Доступна нова версія Wallos", "current_version" => "Поточна версія", "latest_version" => "Остання версія", "on_current_version" => "Ви використовуєте останню версію Wallos.", "show_update_notification" => "Показувати сповіщення про оновлення на дашборді", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "Вашу електронну пошту підтверджено. Тепер ви можете увійти.", "email_verification_failed" => "Не вдалося підтвердити вашу електронну пошту.", // Calendar "calendar" => "Календар", "sun" => "Нд", "mon" => "Пн", "tue" => "Вт", "wed" => "Ср", "thu" => "Чт", "fri" => "Пт", "sat" => "Сб", "month-01" => "Січень", "month-02" => "Лютий", "month-03" => "Березень", "month-04" => "Квітень", "month-05" => "Травень", "month-06" => "Червень", "month-07" => "Липень", "month-08" => "Серпень", "month-09" => "Вересень", "month-10" => "Жовтень", "month-11" => "Листопад", "month-12" => "Грудень", "total_cost" => "Загальна вартість", "export_icalendar" => "Експорт у iCalendar", "over_budget_warning" => "Ви перевищили бюджет", // TOTP Page "insert_totp_code" => "Введіть код TOTP", ]; ?> ================================================ FILE: includes/i18n/vi.php ================================================ "Bạn cần tạo tài khoản trước khi có thể đăng nhập", "username" => "Tên người dùng", "password" => "Mật khẩu", "email" => "Email", "firstname" => "Tên", "lastname" => "Họ", "confirm_password" => "Xác nhận mật khẩu", "main_currency" => "Tiền tệ chính", "language" => "Ngôn ngữ", "passwords_dont_match" => "Mật khẩu không khớp", "username_exists" => "Tên người dùng đã tồn tại", "email_exists" => "Email đã tồn tại", "registration_failed" => "Đăng ký thất bại, vui lòng thử lại.", "register" => "Đăng ký", "restore_database" => "Khôi phục cơ sở dữ liệu", // Login Page "please_login" => "Vui lòng đăng nhập", "stay_logged_in" => "Giữ đăng nhập (30 ngày)", "login" => "Đăng nhập", "login_with" => "Đăng nhập với", "or" => "hoặc", "login_failed" => "Thông tin đăng nhập không chính xác", "registration_successful" => "Đăng ký thành công", "user_email_waiting_verification" => "Email của bạn cần được xác minh. Vui lòng kiểm tra email.", // Password Reset Page "forgot_password" => "Quên mật khẩu", "reset_password" => "Đặt lại mật khẩu", "reset_sent_check_email" => "Email đặt lại đã được gửi. Vui lòng kiểm tra email.", "password_reset_successful" => "Đặt lại mật khẩu thành công", // Header "profile" => "Hồ sơ", "dashboard" => "Bảng", "subscriptions" => "Đăng ký", "stats" => "Thống kê", "settings" => "Cài đặt", "admin" => "Quản trị viên", "about" => "Giới thiệu", "logout" => "Đăng xuất", // Dashboard "hello" => "Xin chào", "upcoming_payments" => "Các khoản thanh toán sắp tới", "no_upcoming_payments" => "Bạn không có khoản thanh toán nào sắp tới", "overdue_renewals" => "Gia hạn quá hạn", "ai_recommendations" => "Khuyến nghị AI", "your_budget" => "Ngân sách của bạn", "budget" => "Ngân sách", "budget_used" => "Ngân sách đã sử dụng", "over_budget" => "Vượt ngân sách", "your_subscriptions" => "Đăng ký của bạn", "your_savings" => "Tiết kiệm của bạn", // Subscriptions page "subscription" => "Đăng ký", "no_subscriptions_yet" => "Bạn chưa có đăng ký nào", "add_first_subscription" => "Thêm đăng ký đầu tiên", "new_subscription" => "Đăng ký mới", "search" => "Tìm kiếm", "state" => "Trạng thái", "alphanumeric" => "Chữ và số", "sort" => "Sắp xếp", "name" => "Tên", "last_added" => "Thêm gần đây", "price" => "Giá", "next_payment" => "Thanh toán tiếp theo", "renewal_type" => "Loại gia hạn", "auto_renewal" => "Tự động gia hạn", "automatically_renews" => "Tự động gia hạn", "manual_renewal" => "Gia hạn thủ công", "start_date" => "Ngày bắt đầu", "inactive" => "Vô hiệu hóa đăng ký", "replaced_with" => "Thay thế bằng", "none" => "Không", "member" => "Thành viên", "category" => "Danh mục", "payment_method" => "Phương thức thanh toán", "Daily" => "Hàng ngày", "Weekly" => "Hàng tuần", "Monthly" => "Hàng tháng", "Yearly" => "Hàng năm", "daily" => "Ngày", "weekly" => "Tuần", "monthly" => "Tháng", "yearly" => "Năm", "days" => "ngày", "weeks" => "tuần", "months" => "tháng", "years" => "năm", "external_url" => "Truy cập URL ngoài", "empty_page" => "Trang trống", "clear_filters" => "Xóa bộ lọc", "no_matching_subscriptions" => "Không có đăng ký phù hợp", "clone" => "Nhân bản", "renew" => "Gia hạn", "calculate_next_payment_date" => "Tính toán ngày thanh toán tiếp theo", // Subscription form "add_subscription" => "Thêm đăng ký", "edit_subscription" => "Chỉnh sửa đăng ký", "subscription_name" => "Tên đăng ký", "logo_preview" => "Xem trước logo", "search_logo" => "Tìm logo trên web", "web_search" => "Tìm kiếm web", "currency" => "Tiền tệ", "payment_every" => "Thanh toán mỗi", "frequency" => "Tần suất", "cycle" => "Chu kỳ", "no_category" => "Không có danh mục", "paid_by" => "Người thanh toán", "url" => "URL", "notes" => "Ghi chú", "enable_notifications" => "Bật thông báo cho đăng ký này", "default_value_from_settings" => "Giá trị mặc định từ cài đặt", "cancellation_notification" => "Thông báo hủy", "delete" => "Xóa", "cancel" => "Hủy", "upload_logo" => "Tải logo", // Statistics page "cant_convert_currency" => "Bạn đang sử dụng nhiều loại tiền tệ trên các đăng ký của mình. Để có thống kê hợp lệ và chính xác, vui lòng đặt một API Key Fixer trên trang cài đặt.", "general_statistics" => "Thống kê chung", "active_subscriptions" => "Đăng ký hoạt động", "inactive_subscriptions" => "Đăng ký không hoạt động", "monthly_cost" => "Chi phí hàng tháng", "yearly_cost" => "Chi phí hàng năm", "average_monthly" => "Chi phí đăng ký trung bình hàng tháng", "most_expensive" => "Chi phí đăng ký đắt nhất", "amount_due" => "Số tiền phải trả tháng này", "percentage_budget_used" => "Phần trăm ngân sách đã sử dụng", "budget_remaining" => "Ngân sách còn lại", "amount_over_budget" => "Số tiền vượt ngân sách", "monthly_savings" => "Tiết kiệm hàng tháng (trên các đăng ký không hoạt động)", "yearly_savings" => "Tiết kiệm hàng năm (trên các đăng ký không hoạt động)", "split_views" => "Chia tách lượt xem", "category_split" => "Phân chia theo danh mục", "household_split" => "Phân chia theo hộ gia đình", "payment_method_split" => "Phân chia theo phương thức thanh toán", "total_cost_trend" => "Xu hướng chi phí tổng cộng", "cost_vs_budget" => "Chi phí so với ngân sách", // About page "about_and_credits" => "Giới thiệu và cảm ơn", "credits" => "Cảm ơn", "license" => "Giấy phép", "release_notes" => "Ghi chú phát hành", "update_available" => "Cập nhật có sẵn", "issues_and_requests" => "Vấn đề và yêu cầu", "the_author" => "Tác giả", "icons" => "Biểu tượng", "payment_icons" => "Biểu tượng thanh toán", //Profile page "upload_avatar" => "Tải ảnh đại diện", "file_type_error" => "Loại tệp không được hỗ trợ.", "user_details" => "Chi tiết người dùng", "two_factor_authentication" => "Xác thực hai yếu tố", "two_factor_info" => "Xác thực hai yếu tố thêm một lớp bảo mật cho tài khoản của bạn.
Bạn sẽ cần một ứng dụng xác thực như Google Authenticator, Authy hoặc Ente Auth để quét mã QR.", "two_factor_enabled_info" => "Tài khoản của bạn được bảo vệ với xác thực hai yếu tố. Bạn có thể vô hiệu hóa bằng cách nhấp vào nút phía trên.", "enable_two_factor_authentication" => "Bật xác thực hai yếu tố", "2fa_already_enabled" => "Xác thực hai yếu tố đã được bật", "totp_code_incorrect" => "Mã TOTP không chính xác", "backup_codes" => "Mã sao lưu", "download_backup_codes" => "Tải xuống mã sao lưu", "copy_to_clipboard" => "Sao chép vào bảng tạm", "totp_backup_codes_info" => "Những mã này có thể được sử dụng để đăng nhập nếu bạn mất quyền truy cập vào ứng dụng xác thực của mình.", "disable_two_factor_authentication" => "Vô hiệu hóa xác thực hai yếu tố", "totp_code" => "Mã TOTP", "api_key" => "API Key", "regenerate" => "Tạo lại", "api_key_info" => "API Key được sử dụng để truy cập API. Giữ nó bí mật.", // Settings page "monthly_budget" => "Ngân sách hàng tháng", "budget_info" => "Ngân sách hàng tháng được sử dụng để tính toán thống kê", "household" => "Hộ gia đình", "save_member" => "Lưu thành viên", "delete_member" => "Xóa thành viên", "cant_delete_member" => "Không thể xóa thành viên chính", "cant_delete_member_in_use" => "Không thể xóa thành viên đang được sử dụng trong đăng ký", "household_info" => "Trường Email cho phép thành viên trong hộ gia đình được thông báo về các đăng ký sắp hết hạn.", "notifications" => "Thông báo", "enable_email_notifications" => "Bật thông báo qua email", "notify_me" => "Thông báo cho tôi", "day_before" => "ngày trước", "on_due_date" => "Vào ngày đáo hạn", "days_before" => "ngày trước", "smtp_address" => "Địa chỉ SMTP", "port" => "Cổng", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "Tên người dùng SMTP", "smtp_password" => "Mật khẩu SMTP", "from_email" => "Email gửi (Tùy chọn)", "send_to_other_emails" => "Cũng gửi thông báo đến các địa chỉ email sau (sử dụng ; để phân tách chúng):", "other_emails_placeholder" => "user@domain.com;test@user.com", "smtp_info" => "Mật khẩu SMTP được truyền và lưu trữ dưới dạng văn bản thuần túy. Vì lý do bảo mật, vui lòng tạo tài khoản chỉ dành cho việc này.", "telegram" => "Telegram", "telegram_bot_token" => "Mã thông báo Bot Telegram", "telegram_chat_id" => "ID cuộc trò chuyện Telegram", "pushplus" => "Pushplus", "pushplus_token" => "Mã thông báo Pushplus", "serverchan" => "Serverchan", "serverchan_sendkey" => "Serverchan SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost Webhook URL", "mattermost_bot_username" => "Mattermost Bot Tên người dùng", "mattermost_bot_icon_emoji" => "Mattermost Bot Biểu tượng Emoji", "webhook" => "Webhook", "webhook_url" => "URL Webhook", "request_method" => "Phương thức yêu cầu", "custom_headers" => "Tiêu đề tùy chỉnh", "webhook_payload" => "Payload Webhook", "payment_notifications_payload" => "Payload thông báo thanh toán", "cancelation_notification_payload" => "Payload thông báo hủy", "variables_available" => "Các biến khả dụng", "gotify" => "Gotify", "token" => "Mã thông báo", "discord" => "Discord", "discord_bot_username" => "Tên người dùng Bot Discord", "discord_bot_avatar_url" => "URL ảnh đại diện Bot Discord", "pushover" => "Pushover", "pushover_user_key" => "Khóa người dùng Pushover", "host" => "Host", "topic" => "Chủ đề", "ignore_ssl_errors" => "Bỏ qua lỗi SSL", "categories" => "Danh mục", "save_category" => "Lưu danh mục", "delete_category" => "Xóa danh mục", "cant_delete_category_in_use" => "Không thể xóa danh mục đang sử dụng trong đăng ký", "currencies" => "Tiền tệ", "save_currency" => "Lưu tiền tệ", "delete_currency" => "Xóa tiền tệ", "cant_delete_main_currency" => "Không thể xóa tiền tệ chính", "cant_delete_currency_in_use" => "Không thể xóa tiền tệ đang sử dụng trong đăng ký", "exchange_update" => "Tỷ giá được cập nhật lần cuối vào", "currency_info" => "Tìm các loại tiền tệ được hỗ trợ và mã tiền tệ chính xác tại", "currency_performance" => "Để có hiệu suất tốt hơn, chỉ giữ lại các loại tiền tệ bạn sử dụng.", "fixer_api_key" => "API Key Fixer", "provider" => "Nhà cung cấp", "fixer_info" => "Nếu bạn sử dụng nhiều loại tiền tệ và muốn có thống kê và sắp xếp chính xác về đăng ký, một API Key miễn phí từ Fixer là cần thiết.", "get_key" => "Nhận khóa của bạn tại", "get_free_fixer_api_key" => "Nhận API Key Fixer miễn phí", "get_key_alternative" => "Ngoài ra, bạn có thể nhận API Key Fixer miễn phí từ", "ai_model" => "Mô hình AI", "select_ai_model" => "Chọn Mô hình AI", "run_schedule" => "Chạy Lịch Trình", "manually" => "Thủ Công", "coming_soon" => "Sắp Có", "invalid_host" => "Máy Chủ Không Hợp Lệ", "ai_recommendations_info" => "Các đề xuất AI được tạo dựa trên các đăng ký và thành viên hộ gia đình của bạn.", "may_take_time" => "Tùy thuộc vào nhà cung cấp, mô hình và số lượng đăng ký, việc tạo đề xuất có thể mất một khoảng thời gian.", "recommendations_visible_on_dashboard" => "Các đề xuất sẽ hiển thị trên bảng điều khiển.", "generate_recommendations" => "Tạo Đề Xuất", "display_settings" => "Cài đặt hiển thị", "theme_settings" => "Cài đặt giao diện", "colors" => "Màu sắc", "custom_colors" => "Màu sắc tùy chỉnh", "theme" => "Giao diện", "dark_theme" => "Giao diện tối", "light_theme" => "Giao diện sáng", "automatic"=> "Tự động", "main_color" => "Màu chính", "accent_color" => "Màu nhấn", "hover_color" => "Màu khi rê chuột", "save_custom_colors" => "Lưu màu tùy chỉnh", "reset_custom_colors" => "Đặt lại màu tùy chỉnh", "custom_css" => "CSS tùy chỉnh", "save_custom_css" => "Lưu CSS tùy chỉnh", "calculate_monthly_price" => "Tính toán và hiển thị giá hàng tháng cho tất cả các đăng ký", "convert_prices" => "Luôn chuyển đổi và hiển thị giá theo tiền tệ chính của tôi (chậm hơn)", "show_original_price" => "Cũng hiển thị giá gốc khi thực hiện chuyển đổi hoặc tính toán", "experience" => "Trải nghiệm", "show_subscription_progress" => "Hiển thị tiến độ đăng ký", "disabled_subscriptions" => "Các đăng ký đã vô hiệu hóa", "hide_disabled_subscriptions" => "Ẩn các đăng ký đã vô hiệu hóa", "show_disabled_subscriptions_at_the_bottom" => "Hiển thị các đăng ký đã vô hiệu hóa ở cuối", "experimental_settings" => "Cài đặt thử nghiệm", "remove_background" => "Cố gắng loại bỏ nền của logo từ tìm kiếm hình ảnh", "use_mobile_navigation_bar" => "Sử dụng thanh điều hướng di động", "experimental_info" => "Cài đặt thử nghiệm có thể không hoạt động hoàn hảo.", "payment_methods" => "Phương thức thanh toán", "payment_methods_info" => "Nhấp vào phương thức thanh toán để vô hiệu hóa / bật nó.", "rename_payment_methods_info" => "Nhấp vào tên của một phương thức thanh toán để đổi tên nó.", "cant_delete_payment_method_in_use" => "Không thể vô hiệu hóa phương thức thanh toán đang sử dụng", "add_custom_payment" => "Thêm phương thức thanh toán tùy chỉnh", "payment_method_name" => "Tên phương thức thanh toán", "payment_method_added_successfuly" => "Phương thức thanh toán được thêm thành công", "payment_method_removed" => "Phương thức thanh toán đã bị xóa", "disable" => "Vô hiệu hóa", "enable" => "Kích hoạt", "rename_payment_method" => "Đổi tên phương thức thanh toán", "payment_renamed" => "Phương thức thanh toán đã được đổi tên", "payment_not_renamed" => "Không thể đổi tên phương thức thanh toán", "test" => "Kiểm tra", "add" => "Thêm", "save" => "Lưu", "reset" => "Đặt lại", "main_accent_color_error" => "Màu chính và màu nhấn không thể giống nhau", "backup_and_restore" => "Sao lưu và Khôi phục", "backup" => "Sao lưu", "restore" => "Khôi phục", "restore_info" => "Khôi phục cơ sở dữ liệu sẽ ghi đè tất cả dữ liệu hiện tại. Bạn sẽ bị đăng xuất sau khi khôi phục.", "account" => "Tài khoản", "export_subscriptions" => "Xuất đăng ký", "export_as_json" => "Xuất dưới dạng JSON", "export_as_csv" => "Xuất dưới dạng CSV", "danger_zone" => "Vùng nguy hiểm", "delete_account" => "Xóa tài khoản", "delete_account_info" => "Xóa tài khoản của bạn sẽ xóa tất cả các đăng ký và cài đặt của bạn.", // Filters menu "filter" => "Bộ lọc", "clear" => "Xóa", // Toast "success" => "Thành công", // Endpoint responses "session_expired" => "Phiên của bạn đã hết hạn. Vui lòng đăng nhập lại", "fields_missing" => "Một số trường bị thiếu", "fill_all_fields" => "Vui lòng điền vào tất cả các trường", "fill_mandatory_fields" => "Vui lòng điền vào tất cả các trường bắt buộc", "error" => "Lỗi", // Category "failed_add_category" => "Thêm danh mục thất bại", "failed_edit_category" => "Chỉnh sửa danh mục thất bại", "category_in_use" => "Danh mục đang được sử dụng trong đăng ký và không thể bị xóa", "failed_remove_category" => "Xóa danh mục thất bại", "category_saved" => "Danh mục đã được lưu", "category_removed" => "Danh mục đã bị xóa", "sort_order_saved" => "Thứ tự sắp xếp đã được lưu", // Currency "currency_saved" => "đã được lưu.", "error_adding_currency" => "Lỗi khi thêm mục tiền tệ.", "failed_to_store_currency" => "Không thể lưu tiền tệ vào cơ sở dữ liệu.", "currency_in_use" => "Tiền tệ đang được sử dụng trong các đăng ký và không thể bị xóa.", "currency_is_main" => "Tiền tệ được đặt làm tiền tệ chính và không thể bị xóa.", "failed_to_remove_currency" => "Không thể xóa tiền tệ khỏi cơ sở dữ liệu.", "failed_to_store_api_key" => "Không thể lưu API Key vào cơ sở dữ liệu.", "invalid_api_key" => "API Key không hợp lệ.", "api_key_saved" => "API Key đã được lưu thành công", "currency_removed" => "Tiền tệ đã bị xóa", // Household "failed_add_household" => "Thêm thành viên hộ gia đình thất bại", "failed_edit_household" => "Chỉnh sửa thành viên hộ gia đình thất bại", "failed_remove_household" => "Xóa thành viên hộ gia đình thất bại", "household_in_use" => "Thành viên hộ gia đình đang được sử dụng trong đăng ký và không thể bị xóa", "member_saved" => "Thành viên đã được lưu", "member_removed" => "Thành viên đã bị xóa", // Notifications "error_saving_notifications" => "Lỗi khi lưu dữ liệu thông báo.", "wallos_notification" => "Thông báo Wallos", "test_notification" => "Đây là thông báo thử nghiệm. Nếu bạn thấy điều này, cấu hình đã chính xác.", "email_error" => "Lỗi khi gửi email", "notification_sent_successfuly" => "Thông báo đã được gửi thành công", "notifications_settings_saved" => "Cài đặt thông báo đã được lưu thành công.", "notification_failed" => "Thông báo thất bại", // Payments "payment_in_use" => "Không thể vô hiệu hóa phương thức thanh toán đang sử dụng", "failed_update_payment" => "Cập nhật phương thức thanh toán trong cơ sở dữ liệu thất bại", "enabled" => "đã được bật", "disabled" => "đã bị vô hiệu hóa", // Subscription "error_fetching_image" => "Lỗi khi tìm nạp hình ảnh", "subscription_updated_successfuly" => "Đăng ký đã được cập nhật thành công", "subscription_added_successfuly" => "Đăng ký đã được thêm thành công", "error_deleting_subscription" => "Lỗi khi xóa đăng ký.", "invalid_request_method" => "Phương thức yêu cầu không hợp lệ.", // User "error_updating_user_data" => "Lỗi khi cập nhật dữ liệu người dùng.", "user_details_saved" => "Chi tiết người dùng đã được lưu", // Admin Page "registrations" => "Đăng ký", "enable_user_registrations" => "Bật đăng ký người dùng", "maximum_number_users" => "Số lượng người dùng tối đa", "require_email_verification" => "Yêu cầu xác minh email", "configure_smtp_settings_to_enable" => "Cấu hình cài đặt SMTP để bật", "server_url" => "URL của máy chủ", "server_url_info" => "Được sử dụng để xác minh email và khôi phục mật khẩu. Phải là một URL công cộng hợp lệ.", "server_url_password_reset" => "Nếu được thiết lập, cũng sẽ bật chức năng khôi phục mật khẩu.", "disable_login" => "Vô hiệu hóa đăng nhập", "disable_login_info" => "Bỏ qua đăng nhập. Nếu bạn chạy máy chủ của mình trên mạng cục bộ mà không có quyền truy cập từ bên ngoài, bạn có thể vô hiệu hóa đăng nhập. Điều này sẽ tự động đăng nhập người dùng quản trị.", "disable_login_info2" => "Bạn chỉ có thể bật cài đặt này nếu việc đăng ký người dùng bị vô hiệu hóa và không có tài khoản người dùng nào ngoài tài khoản quản trị viên.", "max_users_info" => "0 có nghĩa là không giới hạn", "user_management" => "Quản lý người dùng", "delete_user" => "Xóa người dùng", "delete_user_info" => "Xóa một người dùng cũng sẽ xóa tất cả các đăng ký và cài đặt của họ.", "create_user" => "Tạo người dùng", "oidc_settings" => "Cài đặt OIDC", "oidc_auth_enabled" => "Xác thực OIDC đã được bật", "create_user_automatically" => "Tạo người dùng tự động", "disable_password_login" => "Vô hiệu hóa đăng nhập bằng mật khẩu", "smtp_settings" => "Cài đặt SMTP", "smtp_usage_info" => "Sẽ được sử dụng cho việc khôi phục mật khẩu và các email hệ thống khác.", "security_settings" => "Cài đặt bảo mật", "ssrf_protection_info" => "Để ngăn chặn các cuộc tấn công Server-Side Request Forgery (SSRF), Wallos mặc định chặn các thông báo webhook đến các địa chỉ mạng riêng hoặc nội bộ.", "local_webhook_info" => "Nếu bạn cần gửi webhooks đến các dịch vụ cục bộ (như Home Assistant, Gotify hoặc Node-RED), hãy nhập địa chỉ IP hoặc tên máy chủ của chúng ở trên dưới dạng danh sách cách nhau bởi dấu phẩy (ví dụ: 192.168.1.100,192.168.1.101).", "maintenance_tasks" => "Nhiệm vụ bảo trì", "orphaned_logos" => "Logo bị bỏ rơi", "update" => "Cập nhật", "new_version_available" => "Một phiên bản mới của Wallos đã có sẵn", "current_version" => "Phiên bản hiện tại", "latest_version" => "Phiên bản mới nhất", "on_current_version" => "Bạn đang sử dụng phiên bản mới nhất của Wallos.", "show_update_notification" => "Hiển thị thông báo cập nhật trên bảng điều khiển", "cronjobs" => "Công việc định kỳ", // Email Verification "email_verified" => "Xác minh email thành công", "email_verification_failed" => "Xác minh email thất bại", // Calendar "calendar" => "Lịch", "sun" => "CN", "mon" => "Thứ 2", "tue" => "Thứ 3", "wed" => "Thứ 4", "thu" => "Thứ 5", "fri" => "Thứ 6", "sat" => "Thứ 7", "month-01" => "Tháng Giêng", "month-02" => "Tháng Hai", "month-03" => "Tháng Ba", "month-04" => "Tháng Tư", "month-05" => "Tháng Năm", "month-06" => "Tháng Sáu", "month-07" => "Tháng Bảy", "month-08" => "Tháng Tám", "month-09" => "Tháng Chín", "month-10" => "Tháng Mười", "month-11" => "Tháng Mười Một", "month-12" => "Tháng Mười Hai", "total_cost" => "Tổng chi phí", "export_icalendar" => "Xuất iCalendar", "over_budget_warning" => "Bạn đang vượt quá ngân sách", // TOTP Page "insert_totp_code" => "Nhập mã TOTP", ]; ?> ================================================ FILE: includes/i18n/zh_cn.php ================================================ "请创建帐号后登录", "username" => "用户名", "password" => "密码", "email" => "电子邮箱", "firstname" => "名", "lastname" => "姓", "confirm_password" => "确认密码", "main_currency" => "主要货币", "language" => "语言", "passwords_dont_match" => "密码不匹配", "username_exists" => "用户名已存在", "email_exists" => "电子邮箱已存在", "registration_failed" => "注册失败,请重试。", "register" => "注册", "restore_database" => "恢复数据库", // 登录页面 "please_login" => "请登录", "stay_logged_in" => "30 天内免登录", "login" => "登录", "login_with" => "使用以下方式登录", "or" => "或", "login_failed" => "登录信息错误", "registration_successful" => "注册成功", "user_email_waiting_verification" => "您的电子邮件需要验证。请检查您的电子邮件", // Password Reset Page "forgot_password" => "忘记密码", "reset_password" => "重置密码", "reset_sent_check_email" => "重置密码链接已发送到您的电子邮箱", "password_reset_successful" => "密码重置成功", // 页眉 "profile" => "个人资料", "dashboard" => "仪表盘", "subscriptions" => "订阅", "stats" => "统计", "settings" => "设置", "admin" => "管理员", "about" => "关于", "logout" => "登出", // Dashboard "hello" => "你好", "upcoming_payments" => "即将到期的付款", "no_upcoming_payments" => "您没有任何即将到期的付款", "overdue_renewals" => "逾期续订", "ai_recommendations" => "AI 推荐", "your_budget" => "您的预算", "budget" => "预算", "budget_used" => "预算已使用", "over_budget" => "超出预算", "your_subscriptions" => "您的订阅", "your_savings" => "您的储蓄", // 订阅页面 "subscription" => "订阅", "no_subscriptions_yet" => "您还没有任何订阅", "add_first_subscription" => "添加首个订阅", "new_subscription" => "新订阅", "search" => "搜索", "state" => "状态", "alphanumeric" => "名称", "sort" => "排序", "name" => "名称", "last_added" => "创建时间", "price" => "价格", "next_payment" => "下次支付时间", "renewal_type" => "续订类型", "auto_renewal" => "自动续订", "automatically_renews" => "自动续订", "manual_renewal" => "手动续订", "start_date" => "开始日期", "inactive" => "停用订阅", "replaced_with" => "替换为", "none" => "无", "member" => "成员", "category" => "分类", "payment_method" => "支付方式", "Daily" => "每日", "Weekly" => "每周", "Monthly" => "每月", "Yearly" => "每年", "daily" => "日", "weekly" => "周", "monthly" => "月", "yearly" => "年", "days" => "天", "weeks" => "周", "months" => "月", "years" => "年", "external_url" => "访问外部链接", "empty_page" => "空白页面", "clear_filters" => "清除筛选", "no_matching_subscriptions" => "没有匹配的订阅", "clone" => "克隆", "renew" => "续订", "calculate_next_payment_date" => "计算下次支付日期", // 订阅表单 "add_subscription" => "添加订阅", "edit_subscription" => "编辑订阅", "subscription_name" => "订阅名称", "logo_preview" => "Logo 预览", "search_logo" => "在网上搜索 Logo", "web_search" => "网页搜索", "currency" => "货币", "payment_every" => "支付频率", "frequency" => "频率", "cycle" => "周期", "no_category" => "无分类", "paid_by" => "付款人", "url" => "链接", "notes" => "备注", "enable_notifications" => "为此订阅启用通知", "default_value_from_settings" => "默认值从设置中获取", "cancellation_notification" => "取消通知", "delete" => "删除", "cancel" => "取消", "upload_logo" => "上传 Logo", // 统计页面 "cant_convert_currency" => "您在订阅中使用了多种货币。要获得有效、准确的统计数据,请在设置页面设置 Fixer API 密钥。", "general_statistics" => "总体统计", "active_subscriptions" => "活跃订阅", "inactive_subscriptions" => "非活动订阅", "monthly_cost" => "月费用", "yearly_cost" => "年费用", "average_monthly" => "平均每月订阅费用", "most_expensive" => "最昂贵订阅费用", "amount_due" => "本月应付金额", "percentage_budget_used" => "预算使用百分比", "budget_remaining" => "剩余预算", "amount_over_budget" => "超出预算", "monthly_savings" => "每月节省", "yearly_savings" => "每年节省", "split_views" => "拆分视图", "category_split" => "分类视图", "household_split" => "家庭视图", "payment_method_split" => "支付方式视图", "total_cost_trend" => "总费用趋势", "cost_vs_budget" => "费用与预算", // 关于页面 "about_and_credits" => "关于和鸣谢", "credits" => "鸣谢", "license" => "许可证", "release_notes" => "发布说明", "update_available" => "可用更新", "issues_and_requests" => "问题反馈与功能请求", "the_author" => "作者", "icons" => "图标", "payment_icons" => "支付图标", // Profile Page "upload_avatar" => "上传头像", "file_type_error" => "文件类型不允许", "user_details" => "用户详情", "two_factor_authentication" => "双因素认证", "two_factor_info" => "双因素身份验证为您的账户增加了一层额外的安全保护。您需要使用 Google Authenticator、Authy 或 Ente Auth 等认证程序来扫描二维码。", "two_factor_enabled_info" => "双因素身份验证确保您的账户安全。您可以单击上面的按钮禁用它。", "enable_two_factor_authentication" => "启用双因素身份验证", "2fa_already_enabled" => "双因素身份验证已启用", "totp_code_incorrect" => "TOTP 代码不正确", "backup_codes" => "备份代码", "download_backup_codes" => "下载备份代码", "copy_to_clipboard" => "复制到剪贴板", "totp_backup_codes_info" => "请务必保存这些备份代码。如果您丢失了双因素身份验证设备,您将需要这些备份代码来登录。", "disable_two_factor_authentication" => "禁用双因素身份验证", "totp_code" => "TOTP 代码", "api_key" => "API 密钥", "regenerate" => "重新生成", "api_key_info" => "API 密钥用于与 Wallos API 通信。请勿将此密钥分享给任何人。", // 设置页面 "monthly_budget" => "每月预算", "budget_info" => "设置预算后,您可以在统计页面上比较预算和实际支出。", "household" => "家庭", "save_member" => "保存成员", "delete_member" => "删除成员", "cant_delete_member" => "不能删除主要成员", "cant_delete_member_in_use" => "不能删除拥有订阅的成员", "household_info" => "电子邮件字段允许通知家庭成员订阅即将过期。", "notifications" => "通知", "enable_email_notifications" => "启用电子邮件通知", "notify_me" => "通知提前时间", "day_before" => "天", // 设置标题(`notify_me`)中已经表明是提前多少天,因此这里直接用单位即可 "on_due_date" => "到期日", "days_before" => "天", "smtp_address" => "SMTP 地址", "port" => "端口", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "SMTP 用户名", "smtp_password" => "SMTP 密码", "from_email" => "发件人邮箱(可选)", "send_to_other_emails" => "还发送通知到以下电子邮件地址(使用 ; 分隔它们):", "smtp_info" => "SMTP 密码以明文传输和存储。为安全起见,建议专门为 Wallos 创建一个账户。", "telegram" => "Telegram", "telegram_bot_token" => "Telegram 机器人令牌", "telegram_chat_id" => "Telegram 聊天 ID", "pushplus" => "Pushplus", "pushplus_token" => "消息令牌或者是用户令牌", "serverchan" => "Server酱", "serverchan_sendkey" => "SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost Webhook URL", "mattermost_bot_username" => "Mattermost Bot 用户名", "mattermost_bot_icon_emoji" => "Mattermost Bot 表情图标", "webhook" => "Webhook", "webhook_url" => "Webhook URL", "request_method" => "请求方法", "custom_headers" => "自定义标头", "webhook_payload" => "Webhook 负载", "payment_notifications_payload" => "付款通知负载", "cancelation_notification_payload" => "取消通知负载", "variables_available" => "可用变量", "gotify" => "Gotify", "token" => "令牌", "discord" => "Discord", "discord_bot_username" => "Discord 机器人用户名", "discord_bot_avatar_url" => "Discord 机器人头像 URL", "pushover" => "Pushover", "pushover_user_key" => "Pushover 用户密钥", "host" => "主机", "topic" => "主题", "ignore_ssl_errors" => "忽略 SSL 错误", "categories" => "分类", "save_category" => "保存分类", "delete_category" => "删除分类", "cant_delete_category_in_use" => "不能删除正在订阅中的分类", "currencies" => "货币", "save_currency" => "保存货币", "delete_currency" => "删除货币", "cant_delete_main_currency" => "不能删除主要货币", "cant_delete_currency_in_use" => "不能删除正在使用中的货币", "exchange_update" => "汇率最后更新于", "currency_info" => "如要查找支持的货币与对应代码,请前往", "currency_performance" => "为提高性能,建议您只保留常用货币。", "fixer_api_key" => "Fixer API 密钥", "provider" => "提供商", "fixer_info" => "如果您使用多种货币,希望统计信息和订阅排序更精确,则需要 Fixer API 密钥来查询汇率(可免费申请)。", "get_key" => "申请密钥", "get_free_fixer_api_key" => "申请免费 Fixer API 密钥", "get_key_alternative" => "或者,您也可以从以下网站获取免费的修复程序 api 密钥", "ai_model" => "AI 模型", "select_ai_model" => "选择 AI 模型", "run_schedule" => "运行计划", "manually" => "手动", "coming_soon" => "即将推出", "invalid_host" => "无效的主机", "ai_recommendations_info" => "AI 推荐是基于您的订阅和家庭成员生成的。", "may_take_time" => "根据提供商、模型和订阅数量,推荐生成可能需要一些时间。", "recommendations_visible_on_dashboard" => "推荐将在仪表板上可见。", "generate_recommendations" => "生成推荐", "display_settings" => "显示设置", "theme_settings" => "主题设置", "colors" => "颜色", "custom_colors" => "自定义颜色", "theme" => "主题", "dark_theme" => "深色主题", "light_theme" => "浅色主题", "automatic" => "自动", "main_color" => "主色", "accent_color" => "强调色", "hover_color" => "悬停颜色", "save_custom_colors" => "保存自定义颜色", "reset_custom_colors" => "重置自定义颜色", "custom_css" => "自定义 CSS", "save_custom_css" => "保存自定义 CSS", "calculate_monthly_price" => "计算并显示所有订阅的月价格", "convert_prices" => "始终按我的主要货币转换和显示价格(较慢)", "show_original_price" => "当进行转换或计算时,也显示原始价格", "experience" => "体验", "show_subscription_progress" => "显示订阅进度", "disabled_subscriptions" => "已停用的订阅", "hide_disabled_subscriptions" => "隐藏已停用的订阅", "show_disabled_subscriptions_at_the_bottom" => "在订阅列表底部显示已停用的订阅", "experimental_settings" => "实验性设置", "remove_background" => "尝试从图片搜索中移除标志的背景", "use_mobile_navigation_bar" => "使用移动导航栏", "experimental_info" => "实验性设置,可能存在问题。", "payment_methods" => "支付方式", "payment_methods_info" => "点击支付方式以禁用/启用。", "rename_payment_methods_info" => "点击付款方式名称,重新命名该付款方式。", "cant_delete_payment_method_in_use" => "不能禁用正在使用的支付方式", "add_custom_payment" => "添加自定义支付方式", "payment_method_name" => "支付方式名称", "payment_method_added_successfuly" => "支付方式已成功添加", "payment_method_removed" => "支付方式已移除", "disable" => "禁用", "enable" => "启用", "rename_payment_method" => "重命名支付方式", "payment_renamed" => "支付方式已重命名", "payment_not_renamed" => "支付方式未重命名", "test" => "测试", "add" => "添加", "save" => "保存", "reset" => "重置", "main_accent_color_error" => "主色和强调色不能相同", "backup_and_restore" => "备份和恢复", "backup" => "备份", "restore" => "恢复", "restore_info" => "还原数据库将覆盖所有当前数据。还原后,您将退出登录。", "account" => "账户", "export_subscriptions" => "导出订阅", "export_as_json" => "导出为 JSON", "export_as_csv" => "导出为 CSV", "danger_zone" => "危险区", "delete_account" => "删除账户", "delete_account_info" => "删除账户将删除所有数据,包括订阅、设置和家庭成员。此操作不可逆。", // Filters menu "filter" => "筛选", "clear" => "清除", // Toast "success" => "成功", // Endpoint responses "session_expired" => "您的会话已过期,请重新登录", "fields_missing" => "部分字段未填写", "fill_all_fields" => "请填写所有字段", "fill_mandatory_fields" => "请填写所有必填字段", "error" => "错误", // Category "failed_add_category" => "添加分类失败", "failed_edit_category" => "编辑分类失败", "category_in_use" => "分类正在被订阅使用中,无法移除", "failed_remove_category" => "移除分类失败", "category_saved" => "分类已保存", "category_removed" => "分类已移除", "sort_order_saved" => "排序顺序已保存", // Currency "currency_saved" => "货币已保存。", "error_adding_currency" => "添加货币时出错。", "failed_to_store_currency" => "存储货币到数据库失败。", "currency_in_use" => "货币正在被订阅使用中,无法删除。", "currency_is_main" => "货币已被设置为主货币,无法删除。", "failed_to_remove_currency" => "从数据库删除货币失败。", "failed_to_store_api_key" => "存储 API 密钥到数据库失败。", "invalid_api_key" => "API 密钥无效。", "api_key_saved" => "API 密钥已成功保存", "currency_removed" => "货币已移除", // Household "failed_add_household" => "添加家庭成员失败", "failed_edit_household" => "编辑家庭成员失败", "failed_remove_household" => "移除家庭成员失败", "household_in_use" => "此成员有相关的订阅,无法移除", "member_saved" => "成员已保存", "member_removed" => "成员已移除", // Notifications "error_saving_notifications" => "保存通知数据时出错。", "wallos_notification" => "Wallos 通知", "test_notification" => "这是一条测试通知。如果您看到此消息,说明 Wallos 通知邮件配置正确。", "email_error" => "发送电子邮件时出错", "notification_failed" => "通知发送失败", "notification_sent_successfuly" => "通知已成功发送", "notifications_settings_saved" => "通知设置已成功保存。", // Payments "payment_in_use" => "无法禁用正在使用的支付方式", "failed_update_payment" => "更新数据库中的支付方式失败", "enabled" => "已启用", "disabled" => "已禁用", // Subscription "error_fetching_image" => "获取图片时出错", "subscription_updated_successfuly" => "订阅已成功更新", "subscription_added_successfuly" => "订阅已成功添加", "error_deleting_subscription" => "删除订阅时出错。", "invalid_request_method" => "请求方法无效。", // User "error_updating_user_data" => "更新用户数据时出错。", "user_details_saved" => "用户详细信息已保存", // Admin Page "registrations" => "注册", "enable_user_registrations" => "启用用户注册", "maximum_number_users" => "最大用户数", "require_email_verification" => "需要电子邮件验证", "configure_smtp_settings_to_enable" => "要启用此功能,请配置 SMTP 设置。", "server_url" => "服务器 URL", "server_url_info" => "用于电子邮件验证和密码恢复。必须是有效的公共 URL。", "server_url_password_reset" => "如果设置,还将启用密码重置功能。", "disable_login" => "禁用登录", "disable_login_info" => "旁路登录。如果服务器只在本地网络上运行,没有外部访问,则可以禁用登录。这会自动登录管理员用户。", "disable_login_info2" => "只有在用户注册关闭且用户账户数不超过管理员账户时,才能启用此设置。", "max_users_info" => "设置为 0 以无限制用户数", "user_management" => "用户管理", "delete_user" => "删除用户", "delete_user_info" => "删除用户也会删除其所有订阅和设置。", "create_user" => "创建用户", "oidc_settings" => "OIDC 设置", "oidc_auth_enabled" => "启用 OIDC 身份验证", "create_user_automatically" => "当使用 OIDC 登录时自动创建用户", "disable_password_login" => "禁用密码登录", "smtp_settings" => "SMTP 设置", "smtp_usage_info" => "将用于密码恢复和其他系统电子邮件。", "security_settings" => "安全设置", "ssrf_protection_info" => "为防止服务端请求伪造(SSRF)攻击,Wallos 默认阻止发送到私有或内部网络地址的 webhook 通知。", "local_webhook_info" => "如果需要将 webhook 发送到本地服务(例如 Home Assistant、Gotify 或 Node-RED),请在上方以逗号分隔的列表中输入它们的 IP 地址或主机名(例如 192.168.1.100,192.168.1.101)。", "maintenance_tasks" => "维护任务", "orphaned_logos" => "孤立的 Logo", "update" => "更新", "new_version_available" => "新的 Wallos 版本可用", "current_version" => "当前版本", "latest_version" => "最新版本", "on_current_version" => "您正在运行最新版本的 Wallos。", "show_update_notification" => "在仪表板上显示更新通知", "cronjobs" => "Cronjobs", // Email Verification "email_verified" => "电子邮件已验证", "email_verification_failed" => "电子邮件验证失败", // Calendar "calendar" => "日历", "sun" => "周日", "mon" => "周一", "tue" => "周二", "wed" => "周三", "thu" => "周四", "fri" => "周五", "sat" => "周六", "month-01" => "一月", "month-02" => "二月", "month-03" => "三月", "month-04" => "四月", "month-05" => "五月", "month-06" => "六月", "month-07" => "七月", "month-08" => "八月", "month-09" => "九月", "month-10" => "十月", "month-11" => "十一月", "month-12" => "十二月", "total_cost" => "总费用", "export_icalendar" => "导出 iCalendar", "over_budget_warning" => "您超出预算", // TOTP Page "insert_totp_code" => "请输入 TOTP 代码", ]; ?> ================================================ FILE: includes/i18n/zh_tw.php ================================================ "您需要先建立帳號才能登入", "username" => "使用者名稱", "password" => "密碼", "email" => "電子郵件", "firstname" => "名", "lastname" => "姓", "confirm_password" => "確認密碼", "main_currency" => "主要貨幣", "language" => "語言", "passwords_dont_match" => "密碼不一致", "username_exists" => "使用者名稱已存在", "email_exists" => "電子郵件已存在", "registration_failed" => "註冊失敗,請再試一次。", "register" => "註冊", "restore_database" => "還原資料庫", // 登入頁面 "please_login" => "請先登入", "stay_logged_in" => "保持登入狀態(30 天)", "login" => "登入", "login_with" => "使用以下方式登入", "or" => "或", "login_failed" => "登入資訊錯誤", "registration_successful" => "註冊成功", "user_email_waiting_verification" => "您的電子郵件需要驗證。請檢查您的電子郵件信箱。", // 密碼重設頁面 "forgot_password" => "忘記密碼", "reset_password" => "重設密碼", "reset_sent_check_email" => "重設密碼郵件已寄出。請檢查您的電子郵件信箱。", "password_reset_successful" => "密碼重設成功", // 頁首 "profile" => "個人檔案", "dashboard" => "儀表板", "subscriptions" => "訂閱服務", "stats" => "統計資訊", "settings" => "設定", "admin" => "管理員", "about" => "關於", "logout" => "登出", // Dashboard "hello" => "你好", "upcoming_payments" => "即將到期的付款", "no_upcoming_payments" => "您沒有任何即將到期的付款", "overdue_renewals" => "逾期續訂", "ai_recommendations" => "AI 推荐", "your_budget" => "您的預算", "budget" => "預算", "budget_used" => "預算已使用", "over_budget" => "超出預算", "your_subscriptions" => "您的訂閱", "your_savings" => "您的儲蓄", // 訂閱頁面 "subscription" => "訂閱服務", "no_subscriptions_yet" => "您目前沒有任何訂閱服務", "add_first_subscription" => "新增第一筆訂閱", "new_subscription" => "新增訂閱", "search" => "搜尋", "state" => "狀態", "alphanumeric" => "英數字元", "sort" => "排序", "name" => "名稱", "last_added" => "最近新增", "price" => "價格", "next_payment" => "下次付款日期", "renewal_type" => "續訂方式", "auto_renewal" => "自動續訂", "automatically_renews" => "自動續訂", "manual_renewal" => "手動續訂", "start_date" => "開始日期", "inactive" => "停用訂閱", "replaced_with" => "取代為", "none" => "無", "member" => "成員", "category" => "分類", "payment_method" => "付款方式", "Daily" => "每日", "Weekly" => "每週", "Monthly" => "每月", "Yearly" => "每年", "daily" => "天", "weekly" => "週", "monthly" => "月", "yearly" => "年", "days" => "天", "weeks" => "週", "months" => "月", "years" => "年", "external_url" => "開啟外部連結", "empty_page" => "空白頁面", "clear_filters" => "清除篩選條件", "no_matching_subscriptions" => "沒有符合的訂閱服務", "clone" => "複製", "renew" => "續訂", "calculate_next_payment_date" => "計算下次付款日期", // 訂閱表單 "add_subscription" => "新增訂閱服務", "edit_subscription" => "編輯訂閱服務", "subscription_name" => "訂閱名稱", "logo_preview" => "圖示預覽", "search_logo" => "在網路上搜尋圖示", "web_search" => "網頁搜尋", "currency" => "貨幣", "payment_every" => "付款週期", "frequency" => "頻率", "cycle" => "週期", "no_category" => "未分類", "paid_by" => "付款人", "url" => "網址", "notes" => "備註", "enable_notifications" => "啟用此訂閱的通知", "default_value_from_settings" => "使用設定中的預設值", "cancellation_notification" => "取消通知", "delete" => "刪除", "cancel" => "取消", "upload_logo" => "上傳圖示", // 統計頁面 "cant_convert_currency" => "您的訂閱使用了多種貨幣。為了獲得有效且準確的統計資訊,請在設定頁面設定 Fixer API 金鑰。", "general_statistics" => "整體統計", "active_subscriptions" => "訂閱中", "inactive_subscriptions" => "已停用的訂閱", "monthly_cost" => "每月費用", "yearly_cost" => "每年費用", "average_monthly" => "平均每月訂閱費用", "most_expensive" => "最高的訂閱費用", "amount_due" => "本月應付金額", "percentage_budget_used" => "預算使用率", "budget_remaining" => "剩餘預算", "amount_over_budget" => "預算超支", "monthly_savings" => "每月節省金額(已停用的訂閱)", "yearly_savings" => "每年節省金額(已停用的訂閱)", "split_views" => "分類檢視", "category_split" => "分類分析", "household_split" => "家庭成員分析", "payment_method_split" => "付款方式分析", "total_cost_trend" => "總費用趨勢", "cost_vs_budget" => "費用與預算比較", // 關於頁面 "about_and_credits" => "關於和致謝", "credits" => "致謝", "license" => "授權條款", "release_notes" => "版本資訊", "update_available" => "有新版本可用", "issues_and_requests" => "問題回報與功能建議", "the_author" => "作者", "icons" => "圖示", "payment_icons" => "付款圖示", // 個人檔案頁面 "upload_avatar" => "上傳大頭貼", "file_type_error" => "不支援此檔案類型。", "user_details" => "使用者資訊", "two_factor_authentication" => "兩步驟驗證", "two_factor_info" => "兩步驟驗證為您的帳號增加額外的安全防護。
您需要使用 Google Authenticator、Authy 或 Ente Auth 等驗證碼應用程式來掃描 QR Code。", "two_factor_enabled_info" => "您的帳號已啟用兩步驟驗證,安全性較高。您可以點選上方按鈕來停用。", "enable_two_factor_authentication" => "啟用兩步驟驗證", "2fa_already_enabled" => "已啟用兩步驟驗證", "totp_code_incorrect" => "TOTP 驗證碼不正確", "backup_codes" => "備用驗證碼", "download_backup_codes" => "下載備用驗證碼", "copy_to_clipboard" => "複製到剪貼簿", "totp_backup_codes_info" => "當您無法使用驗證碼應用程式時,可以使用這些備用驗證碼登入,請妥善保管、勿將此金鑰分享給任何人。", "disable_two_factor_authentication" => "停用兩步驟驗證", "totp_code" => "TOTP 驗證碼", "api_key" => "API 金鑰", "regenerate" => "重新產生", "api_key_info" => "API 金鑰用於存取 Wallos API,請妥善保管、勿將此金鑰分享給任何人。", // 設定頁面 "monthly_budget" => "每月預算", "budget_info" => "每月預算用於計算統計資訊", "household" => "家庭成員", "save_member" => "儲存成員", "delete_member" => "刪除成員", "cant_delete_member" => "無法刪除主要成員", "cant_delete_member_in_use" => "無法刪除擁有訂閱的成員", "household_info" => "電子郵件欄位可讓家庭成員收到訂閱即將到期的通知。", "notifications" => "通知", "enable_email_notifications" => "啟用電子郵件通知", "notify_me" => "通知時間", "day_before" => "天前", "on_due_date" => "到期當天", "days_before" => "天前", "smtp_address" => "SMTP 伺服器位址", "port" => "連接埠", "tls" => "TLS", "ssl" => "SSL", "smtp_username" => "SMTP 使用者名稱", "smtp_password" => "SMTP 密碼", "from_email" => "寄件人電子郵件(選填)", "send_to_other_emails" => "同時傳送通知至以下電子郵件地址(使用分號 ; 分隔):", "other_emails_placeholder" => "user@domain.com;test@user.com", "smtp_info" => "SMTP 密碼將以明文傳輸和儲存。為了安全起見,建議為 Wallos 建立專用帳號。", "telegram" => "Telegram", "telegram_bot_token" => "Telegram 機器人令牌", "telegram_chat_id" => "Telegram 聊天 ID", "pushplus" => "PushPlus", "pushplus_token" => "消息令牌或者是用户令牌", "serverchan" => "Server醬", "serverchan_sendkey" => "SendKey", "mattermost" => "Mattermost", "mattermost_webhook_url" => "Mattermost Webhook 網址", "mattermost_bot_username" => "Mattermost Bot 使用者名稱", "mattermost_bot_icon_emoji" => "Mattermost Bot 表情圖示", "webhook" => "Webhook", "webhook_url" => "Webhook 網址", "request_method" => "請求方法", "custom_headers" => "自訂標頭", "webhook_payload" => "Webhook 內容", "payment_notifications_payload" => "付款通知內容", "cancelation_notification_payload" => "取消通知內容", "variables_available" => "可用變數", "gotify" => "Gotify", "token" => "令牌", "discord" => "Discord", "discord_bot_username" => "Discord 機器人使用者名稱", "discord_bot_avatar_url" => "Discord 機器人大頭貼網址", "pushover" => "Pushover", "pushover_user_key" => "Pushover 使用者金鑰", "host" => "主機", "topic" => "主題", "ignore_ssl_errors" => "忽略 SSL 錯誤", "categories" => "分類", "save_category" => "儲存分類", "delete_category" => "刪除分類", "cant_delete_category_in_use" => "無法刪除正在使用中的分類", "currencies" => "貨幣", "save_currency" => "儲存貨幣", "delete_currency" => "刪除貨幣", "cant_delete_main_currency" => "無法刪除主要貨幣", "cant_delete_currency_in_use" => "無法刪除正在使用中的貨幣", "exchange_update" => "匯率最後更新時間:", "currency_info" => "如要查詢支援的貨幣和對應的貨幣代碼,請前往", "currency_performance" => "為提升效能,建議僅保留您常使用的貨幣。", "fixer_api_key" => "Fixer API 金鑰", "provider" => "提供者", "fixer_info" => "如果您使用多種貨幣,且想要取得準確的統計資訊和訂閱排序,則需要設定免費的 Fixer API 金鑰來查詢匯率。", "get_key" => "取得金鑰請至", "get_free_fixer_api_key" => "取得免費 Fixer API 金鑰", "get_key_alternative" => "或者,您可以從以下網址取得免費的 Fixer API 金鑰", "ai_model" => "AI 模型", "select_ai_model" => "選擇 AI 模型", "run_schedule" => "運行計劃", "manually" => "手動", "coming_soon" => "即將推出", "invalid_host" => "無效的主機", "ai_recommendations_info" => "AI 推荐是基于您的订阅和家庭成员生成的。", "may_take_time" => "根据提供商、模型和订阅数量,推荐生成可能需要一些时间。", "recommendations_visible_on_dashboard" => "推荐将在仪表板上可见。", "generate_recommendations" => "生成推荐", "display_settings" => "显示设置", "theme_settings" => "主題設定", "colors" => "顏色", "custom_colors" => "自訂顏色", "theme" => "主題", "dark_theme" => "深色主題", "light_theme" => "淺色主題", "automatic" => "自動", "main_color" => "主要顏色", "accent_color" => "強調顏色", "hover_color" => "游標停留顏色", "save_custom_colors" => "儲存自訂顏色", "reset_custom_colors" => "重設自訂顏色", "custom_css" => "自訂 CSS", "save_custom_css" => "儲存自訂 CSS", "calculate_monthly_price" => "計算並顯示所有訂閱的每月價格", "convert_prices" => "永遠以我的主要貨幣顯示價格(較慢)", "show_original_price" => "在轉換或計算時同時顯示原始價格", "experience" => "使用體驗", "show_subscription_progress" => "顯示訂閱進度", "disabled_subscriptions" => "已停用的訂閱", "hide_disabled_subscriptions" => "隱藏已停用的訂閱", "show_disabled_subscriptions_at_the_bottom" => "在底部顯示已停用的訂閱", "experimental_settings" => "實驗性功能", "remove_background" => "嘗試從圖片搜尋中移除搜尋結果的背景", "use_mobile_navigation_bar" => "使用行動版導覽列", "experimental_info" => "實驗性功能可能無法完美運作。", "payment_methods" => "付款方式", "payment_methods_info" => "點選付款方式以停用/啟用。", "rename_payment_methods_info" => "點選付款方式名稱以重新命名。", "cant_delete_payment_method_in_use" => "無法停用正在使用中的付款方式", "add_custom_payment" => "新增自訂付款方式", "payment_method_name" => "付款方式名稱", "payment_method_added_successfuly" => "已成功新增付款方式", "payment_method_removed" => "付款方式已移除", "disable" => "停用", "enable" => "啟用", "rename_payment_method" => "更改付款方式名稱", "payment_renamed" => "付款方式名稱已更改", "payment_not_renamed" => "付款方式名稱未更改", "test" => "測試", "add" => "新增", "save" => "儲存", "reset" => "重設", "main_accent_color_error" => "主要顏色和強調顏色不能相同", "backup_and_restore" => "備份與還原", "backup" => "備份", "restore" => "還原", "restore_info" => "還原資料庫將覆蓋所有目前資料。還原後您將被登出。", "account" => "帳號", "export_subscriptions" => "匯出訂閱資料", "export_as_json" => "匯出為 JSON", "export_as_csv" => "匯出為 CSV", "danger_zone" => "危險區域", "delete_account" => "刪除帳號", "delete_account_info" => "刪除帳號將同時刪除所有訂閱和設定,此動作無法復原。", // 篩選選單 "filter" => "篩選", "clear" => "清除", // 提示訊息 "success" => "成功", // 端點回應 "session_expired" => "您的工作階段已過期,請重新登入", "fields_missing" => "部分必填欄位未填寫", "fill_all_fields" => "請填寫所有欄位", "fill_mandatory_fields" => "請填寫所有必填欄位", "error" => "錯誤", // 分類 "failed_add_category" => "新增分類失敗", "failed_edit_category" => "編輯分類失敗", "category_in_use" => "分類正在使用中,無法移除", "failed_remove_category" => "移除分類失敗", "category_saved" => "已儲存分類", "category_removed" => "已移除分類", "sort_order_saved" => "已儲存排序順序", // 貨幣 "currency_saved" => "已儲存。", "error_adding_currency" => "新增貨幣時發生錯誤。", "failed_to_store_currency" => "儲存貨幣到資料庫失敗。", "currency_in_use" => "貨幣正在使用中,無法刪除。", "currency_is_main" => "無法刪除主要貨幣。", "failed_to_remove_currency" => "從資料庫中刪除貨幣失敗。", "failed_to_store_api_key" => "儲存 API 金鑰到資料庫失敗。", "invalid_api_key" => "無效的 API 金鑰。", "api_key_saved" => "已成功儲存 API 金鑰", "currency_removed" => "已移除貨幣", // 家庭成員 "failed_add_household" => "新增家庭成員失敗", "failed_edit_household" => "編輯家庭成員失敗", "failed_remove_household" => "移除家庭成員失敗", "household_in_use" => "此成員有正在使用中的訂閱,無法移除", "member_saved" => "已儲存成員", "member_removed" => "已移除成員", // 通知 "error_saving_notifications" => "儲存通知設定時發生錯誤。", "wallos_notification" => "Wallos 通知", "test_notification" => "這是一則測試通知。如果您看到此訊息,表示 Wallos 通知功能設定正確。", "email_error" => "寄送電子郵件時發生錯誤", "notification_sent_successfuly" => "已成功送出通知", "notifications_settings_saved" => "已成功儲存通知設定。", "notification_failed" => "通知傳送失敗", // 付款方式 "payment_in_use" => "無法停用正在使用中的付款方式", "failed_update_payment" => "更新資料庫中的付款方式失敗", "enabled" => "已啟用", "disabled" => "已停用", // 訂閱 "error_fetching_image" => "讀取圖片時發生錯誤", "subscription_updated_successfuly" => "已成功更新訂閱", "subscription_added_successfuly" => "已成功新增訂閱", "error_deleting_subscription" => "刪除訂閱時發生錯誤。", "invalid_request_method" => "請求方法無效。", // 使用者 "error_updating_user_data" => "更新使用者資訊時發生錯誤。", "user_details_saved" => "已儲存使用者資訊", // 管理頁面 "registrations" => "註冊", "enable_user_registrations" => "啟用使用者註冊", "maximum_number_users" => "最大使用者數", "require_email_verification" => "需要電子郵件驗證", "configure_smtp_settings_to_enable" => "需要設定 SMTP 才能啟用", "server_url" => "伺服器網址", "server_url_info" => "用於電子郵件驗證和密碼重設。必須是有效的公開網址。", "server_url_password_reset" => "如果設定,也將啟用密碼重設功能。", "disable_login" => "停用登入", "disable_login_info" => "略過登入。如果您的伺服器只在區域網路中執行且無外部存取,可以停用登入。這將自動以管理員身分登入。", "disable_login_info2" => "只有在停用使用者註冊且使用者數不超過管理員帳號時,才能啟用此設定。", "max_users_info" => "0 表示無限制", "user_management" => "使用者管理", "delete_user" => "刪除使用者", "delete_user_info" => "刪除使用者也會刪除其所有訂閱和設定。", "create_user" => "建立使用者", "oidc_settings" => "OIDC 設定", "oidc_auth_enabled" => "啟用 OIDC 身份驗證", "create_user_automatically" => "當使用 OIDC 登入時自動建立使用者", "disable_password_login" => "停用密碼登入", "smtp_settings" => "SMTP 設定", "smtp_usage_info" => "用於密碼重設和其他系統郵件。", "security_settings" => "安全設定", "ssrf_protection_info" => "為了防止服務端請求偽造(SSRF)攻擊,Wallos 預設會阻止發送到私人或內部網路位址的 webhook 通知。", "local_webhook_info" => "如果您需要將 webhook 發送到本地服務(例如 Home Assistant、Gotify 或 Node-RED),請在上方以逗號分隔的列表中輸入它們的 IP 位址或主機名稱(例如 192.168.1.100,192.168.1.101)。", "maintenance_tasks" => "維護工作", "orphaned_logos" => "未使用的圖示", "update" => "更新", "new_version_available" => "有新版本的 Wallos 可供使用", "current_version" => "目前版本", "latest_version" => "最新版本", "on_current_version" => "您使用的是最新版本的 Wallos。", "show_update_notification" => "在儀表板上顯示更新通知", "cronjobs" => "定時工作", // 電子郵件驗證 "email_verified" => "電子郵件驗證成功", "email_verification_failed" => "電子郵件驗證失敗", // 日曆 "calendar" => "日曆", "sun" => "日", "mon" => "一", "tue" => "二", "wed" => "三", "thu" => "四", "fri" => "五", "sat" => "六", "month-01" => "一月", "month-02" => "二月", "month-03" => "三月", "month-04" => "四月", "month-05" => "五月", "month-06" => "六月", "month-07" => "七月", "month-08" => "八月", "month-09" => "九月", "month-10" => "十月", "month-11" => "十一月", "month-12" => "十二月", "total_cost" => "總費用", "export_icalendar" => "匯出 iCalendar", "over_budget_warning" => "您超出預算", // TOTP 頁面 "insert_totp_code" => "請輸入 TOTP 驗證碼", ]; ?> ================================================ FILE: includes/inputvalidation.php ================================================ ================================================ FILE: includes/list_subscriptions.php ================================================ modify("-$paymentCycleDays days"); $totalCycleDays = $lastPaymentDate->diff($nextPaymentDate)->days; $daysSinceLastPayment = $lastPaymentDate->diff($currentDate)->days; $subscriptionProgress = 0; if ($totalCycleDays > 0) { $subscriptionProgress = ($daysSinceLastPayment / $totalCycleDays) * 100; } return floor($subscriptionProgress); } function getPricePerMonth($cycle, $frequency, $price) { switch ($cycle) { case 1: $numberOfPaymentsPerMonth = (30 / $frequency); return $price * $numberOfPaymentsPerMonth; case 2: $numberOfPaymentsPerMonth = (4.35 / $frequency); return $price * $numberOfPaymentsPerMonth; case 3: $numberOfPaymentsPerMonth = (1 / $frequency); return $price * $numberOfPaymentsPerMonth; case 4: $numberOfMonths = (12 * $frequency); return $price / $numberOfMonths; } } function getPriceConverted($price, $currency, $database) { $query = "SELECT rate FROM currencies WHERE id = :currency"; $stmt = $database->prepare($query); $stmt->bindParam(':currency', $currency, SQLITE3_INTEGER); $result = $stmt->execute(); $exchangeRate = $result->fetchArray(SQLITE3_ASSOC); if ($exchangeRate === false) { return $price; } else { $fromRate = $exchangeRate['rate']; return $price / $fromRate; } } function formatPrice($price, $currencyCode, $currencies) { $formattedPrice = CurrencyFormatter::format($price, $currencyCode); if (strstr($formattedPrice, $currencyCode)) { $symbol = $currencyCode; foreach ($currencies as $currency) { if ($currency['code'] === $currencyCode) { if ($currency['symbol'] != "") { $symbol = $currency['symbol']; } break; } } $formattedPrice = str_replace($currencyCode, $symbol, $formattedPrice); } return $formattedPrice; } function formatDate($date, $lang = 'en') { $currentYear = date('Y'); $dateYear = date('Y', strtotime($date)); // Determine the date format based on whether the year matches the current year $dateFormat = ($currentYear == $dateYear) ? 'MMM d' : 'MMM yyyy'; // Validate the locale and fallback to 'en' if unsupported if (!in_array($lang, ResourceBundle::getLocales(''))) { $lang = 'en'; // Fallback to English } // Create an IntlDateFormatter instance for the specified language $formatter = new IntlDateFormatter( $lang, IntlDateFormatter::SHORT, IntlDateFormatter::NONE, null, null, $dateFormat ); // Format the date $formattedDate = $formatter->format(new DateTime($date)); return $formattedDate; } function printSubscriptions($subscriptions, $sort, $categories, $members, $i18n, $colorTheme, $imagePath, $disabledToBottom, $mobileNavigation, $showSubscriptionProgress, $currencies, $lang) { if ($sort === "price") { usort($subscriptions, function ($a, $b) { return $a['price'] < $b['price'] ? 1 : -1; }); if ($disabledToBottom === 'true') { usort($subscriptions, function ($a, $b) { return $a['inactive'] - $b['inactive']; }); } } $currentCategory = 0; $currentPayerUserId = 0; $currentPaymentMethodId = 0; foreach ($subscriptions as $subscription) { if ($sort == "category_id" && $subscription['category_id'] != $currentCategory) { ?>
"> ()
100 ? 100 : $subscription['progress']; ?>
prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); if ($row !== false) { $mainCurrencyId = $row['main_currency']; } else { $mainCurrencyId = $currencies[1]['id']; } ?> ================================================ FILE: includes/oidc/handle_oidc_callback.php ================================================ prepare('SELECT * FROM oauth_settings WHERE id = 1'); $result = $stmt->execute(); $oidcSettings = $result->fetchArray(SQLITE3_ASSOC); $tokenUrl = $oidcSettings['token_url']; $redirectUri = $oidcSettings['redirect_url']; $postFields = [ 'grant_type' => 'authorization_code', 'code' => $_GET['code'], 'redirect_uri' => $redirectUri, 'client_id' => $oidcSettings['client_id'], 'client_secret' => $oidcSettings['client_secret'], ]; $ch = curl_init($tokenUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postFields)); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']); $response = curl_exec($ch); curl_close($ch); $tokenData = json_decode($response, true); if (!$tokenData || !isset($tokenData['access_token'])) { die("OIDC token exchange failed."); } $userInfoUrl = $oidcSettings['user_info_url']; $ch = curl_init($userInfoUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer ' . $tokenData['access_token'] ]); $response = curl_exec($ch); curl_close($ch); $userInfo = json_decode($response, true); if (!$userInfo || !isset($userInfo[$oidcSettings['user_identifier_field']])) { die("Failed to fetch OIDC user info."); } $oidcSub = $userInfo[$oidcSettings['user_identifier_field']]; // Check if sub matches an existing user $stmt = $db->prepare('SELECT * FROM user WHERE oidc_sub = :oidcSub'); $stmt->bindValue(':oidcSub', $oidcSub, SQLITE3_TEXT); $result = $stmt->execute(); $userData = $result->fetchArray(SQLITE3_ASSOC); if ($userData) { // User exists, log the user in require_once('oidc_login.php'); } else { // Might be an existing user with the same email $email = $userInfo['email'] ?? null; if (!$email) { // Login failed, we have nothing to go on with, redirect to login page with error header("Location: login.php?error=oidc_user_not_found"); exit(); } $stmt = $db->prepare('SELECT * FROM user WHERE email = :email'); $stmt->bindValue(':email', $email, SQLITE3_TEXT); $result = $stmt->execute(); $userData = $result->fetchArray(SQLITE3_ASSOC); if ($userData) { // Update existing user with OIDC sub $stmt = $db->prepare('UPDATE user SET oidc_sub = :oidcSub WHERE id = :userId'); $stmt->bindValue(':oidcSub', $oidcSub, SQLITE3_TEXT); $stmt->bindValue(':userId', $userData['id'], SQLITE3_INTEGER); $stmt->execute(); // Log the user in require_once('oidc_login.php'); } else { // Check if auto-create is enabled if ($oidcSettings['auto_create_user']) { // Create a new user //check if username is already taken $usernameBase = $userInfo['preferred_username'] ?? generate_username_from_email($email); $username = $usernameBase; $attempt = 1; while (true) { $stmt = $db->prepare('SELECT COUNT(*) as count FROM user WHERE username = :username'); $stmt->bindValue(':username', $username, SQLITE3_TEXT); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); if ($row['count'] == 0) { break; // Username is available } $username = $usernameBase . $attempt; $attempt++; } require_once('oidc_create_user.php'); } else { // Login failed, redirect to login page with error header("Location: login.php?error=oidc_user_not_found"); exit(); } } } ?> ================================================ FILE: includes/oidc/oidc_create_user.php ================================================ prepare($query); $stmt->bindValue(':username', $username, SQLITE3_TEXT); $stmt->bindValue(':email', $email, SQLITE3_TEXT); $stmt->bindValue(':oidc_sub', $oidcSub, SQLITE3_TEXT); $stmt->bindValue(':main_currency', $main_currency_id, SQLITE3_INTEGER); $stmt->bindValue(':avatar', $avatar, SQLITE3_TEXT); $stmt->bindValue(':language', $language, SQLITE3_TEXT); $stmt->bindValue(':budget', $budget, SQLITE3_INTEGER); $stmt->bindValue(':firstname', $firstname, SQLITE3_TEXT); $stmt->bindValue(':lastname', $lastname, SQLITE3_TEXT); $stmt->bindValue(':password', $hashedPassword, SQLITE3_TEXT); if (!$stmt->execute()) { die("Failed to create user"); } // Get the user data into $userData $stmt = $db->prepare("SELECT * FROM user WHERE username = :username"); $stmt->bindValue(':username', $username, SQLITE3_TEXT); $result = $stmt->execute(); $userData = $result->fetchArray(SQLITE3_ASSOC); $newUserId = $userData['id']; // Household $stmt = $db->prepare("INSERT INTO household (name, user_id) VALUES (:name, :user_id)"); $stmt->bindValue(':name', $username, SQLITE3_TEXT); $stmt->bindValue(':user_id', $newUserId, SQLITE3_INTEGER); $stmt->execute(); // Categories $categories = [ 'No category', 'Entertainment', 'Music', 'Utilities', 'Food & Beverages', 'Health & Wellbeing', 'Productivity', 'Banking', 'Transport', 'Education', 'Insurance', 'Gaming', 'News & Magazines', 'Software', 'Technology', 'Cloud Services', 'Charity & Donations' ]; $stmt = $db->prepare("INSERT INTO categories (name, \"order\", user_id) VALUES (:name, :order, :user_id)"); foreach ($categories as $index => $name) { $stmt->bindValue(':name', $name, SQLITE3_TEXT); $stmt->bindValue(':order', $index + 1, SQLITE3_INTEGER); $stmt->bindValue(':user_id', $newUserId, SQLITE3_INTEGER); $stmt->execute(); } // Payment Methods $payment_methods = [ ['name' => 'PayPal', 'icon' => 'images/uploads/icons/paypal.png'], ['name' => 'Credit Card', 'icon' => 'images/uploads/icons/creditcard.png'], ['name' => 'Bank Transfer', 'icon' => 'images/uploads/icons/banktransfer.png'], ['name' => 'Direct Debit', 'icon' => 'images/uploads/icons/directdebit.png'], ['name' => 'Money', 'icon' => 'images/uploads/icons/money.png'], ['name' => 'Google Pay', 'icon' => 'images/uploads/icons/googlepay.png'], ['name' => 'Samsung Pay', 'icon' => 'images/uploads/icons/samsungpay.png'], ['name' => 'Apple Pay', 'icon' => 'images/uploads/icons/applepay.png'], ['name' => 'Crypto', 'icon' => 'images/uploads/icons/crypto.png'], ['name' => 'Klarna', 'icon' => 'images/uploads/icons/klarna.png'], ['name' => 'Amazon Pay', 'icon' => 'images/uploads/icons/amazonpay.png'], ['name' => 'SEPA', 'icon' => 'images/uploads/icons/sepa.png'], ['name' => 'Skrill', 'icon' => 'images/uploads/icons/skrill.png'], ['name' => 'Sofort', 'icon' => 'images/uploads/icons/sofort.png'], ['name' => 'Stripe', 'icon' => 'images/uploads/icons/stripe.png'], ['name' => 'Affirm', 'icon' => 'images/uploads/icons/affirm.png'], ['name' => 'AliPay', 'icon' => 'images/uploads/icons/alipay.png'], ['name' => 'Elo', 'icon' => 'images/uploads/icons/elo.png'], ['name' => 'Facebook Pay', 'icon' => 'images/uploads/icons/facebookpay.png'], ['name' => 'GiroPay', 'icon' => 'images/uploads/icons/giropay.png'], ['name' => 'iDeal', 'icon' => 'images/uploads/icons/ideal.png'], ['name' => 'Union Pay', 'icon' => 'images/uploads/icons/unionpay.png'], ['name' => 'Interac', 'icon' => 'images/uploads/icons/interac.png'], ['name' => 'WeChat', 'icon' => 'images/uploads/icons/wechat.png'], ['name' => 'Paysafe', 'icon' => 'images/uploads/icons/paysafe.png'], ['name' => 'Poli', 'icon' => 'images/uploads/icons/poli.png'], ['name' => 'Qiwi', 'icon' => 'images/uploads/icons/qiwi.png'], ['name' => 'ShopPay', 'icon' => 'images/uploads/icons/shoppay.png'], ['name' => 'Venmo', 'icon' => 'images/uploads/icons/venmo.png'], ['name' => 'VeriFone', 'icon' => 'images/uploads/icons/verifone.png'], ['name' => 'WebMoney', 'icon' => 'images/uploads/icons/webmoney.png'], ]; $stmt = $db->prepare("INSERT INTO payment_methods (name, icon, \"order\", user_id) VALUES (:name, :icon, :order, :user_id)"); foreach ($payment_methods as $index => $method) { $stmt->bindValue(':name', $method['name'], SQLITE3_TEXT); $stmt->bindValue(':icon', $method['icon'], SQLITE3_TEXT); $stmt->bindValue(':order', $index + 1, SQLITE3_INTEGER); $stmt->bindValue(':user_id', $newUserId, SQLITE3_INTEGER); $stmt->execute(); } // Currencies $currencies = [ ['name' => 'Euro', 'symbol' => '€', 'code' => 'EUR'], ['name' => 'US Dollar', 'symbol' => '$', 'code' => 'USD'], ['name' => 'Japanese Yen', 'symbol' => '¥', 'code' => 'JPY'], ['name' => 'Bulgarian Lev', 'symbol' => 'лв', 'code' => 'BGN'], ['name' => 'Czech Republic Koruna', 'symbol' => 'Kč', 'code' => 'CZK'], ['name' => 'Danish Krone', 'symbol' => 'kr', 'code' => 'DKK'], ['name' => 'British Pound Sterling', 'symbol' => '£', 'code' => 'GBP'], ['name' => 'Hungarian Forint', 'symbol' => 'Ft', 'code' => 'HUF'], ['name' => 'Polish Zloty', 'symbol' => 'zł', 'code' => 'PLN'], ['name' => 'Romanian Leu', 'symbol' => 'lei', 'code' => 'RON'], ['name' => 'Swedish Krona', 'symbol' => 'kr', 'code' => 'SEK'], ['name' => 'Swiss Franc', 'symbol' => 'Fr', 'code' => 'CHF'], ['name' => 'Icelandic Króna', 'symbol' => 'kr', 'code' => 'ISK'], ['name' => 'Norwegian Krone', 'symbol' => 'kr', 'code' => 'NOK'], ['name' => 'Russian Ruble', 'symbol' => '₽', 'code' => 'RUB'], ['name' => 'Turkish Lira', 'symbol' => '₺', 'code' => 'TRY'], ['name' => 'Australian Dollar', 'symbol' => '$', 'code' => 'AUD'], ['name' => 'Brazilian Real', 'symbol' => 'R$', 'code' => 'BRL'], ['name' => 'Canadian Dollar', 'symbol' => '$', 'code' => 'CAD'], ['name' => 'Chinese Yuan', 'symbol' => '¥', 'code' => 'CNY'], ['name' => 'Hong Kong Dollar', 'symbol' => 'HK$', 'code' => 'HKD'], ['name' => 'Indonesian Rupiah', 'symbol' => 'Rp', 'code' => 'IDR'], ['name' => 'Israeli New Sheqel', 'symbol' => '₪', 'code' => 'ILS'], ['name' => 'Indian Rupee', 'symbol' => '₹', 'code' => 'INR'], ['name' => 'South Korean Won', 'symbol' => '₩', 'code' => 'KRW'], ['name' => 'Mexican Peso', 'symbol' => 'Mex$', 'code' => 'MXN'], ['name' => 'Malaysian Ringgit', 'symbol' => 'RM', 'code' => 'MYR'], ['name' => 'New Zealand Dollar', 'symbol' => 'NZ$', 'code' => 'NZD'], ['name' => 'Philippine Peso', 'symbol' => '₱', 'code' => 'PHP'], ['name' => 'Singapore Dollar', 'symbol' => 'S$', 'code' => 'SGD'], ['name' => 'Thai Baht', 'symbol' => '฿', 'code' => 'THB'], ['name' => 'South African Rand', 'symbol' => 'R', 'code' => 'ZAR'], ['name' => 'Ukrainian Hryvnia', 'symbol' => '₴', 'code' => 'UAH'], ['name' => 'New Taiwan Dollar', 'symbol' => 'NT$', 'code' => 'TWD'], ]; $stmt = $db->prepare("INSERT INTO currencies (name, symbol, code, rate, user_id) VALUES (:name, :symbol, :code, :rate, :user_id)"); foreach ($currencies as $currency) { $stmt->bindValue(':name', $currency['name'], SQLITE3_TEXT); $stmt->bindValue(':symbol', $currency['symbol'], SQLITE3_TEXT); $stmt->bindValue(':code', $currency['code'], SQLITE3_TEXT); $stmt->bindValue(':rate', 1.0, SQLITE3_FLOAT); $stmt->bindValue(':user_id', $newUserId, SQLITE3_INTEGER); $stmt->execute(); } // Get actual Euro currency ID $stmt = $db->prepare("SELECT id FROM currencies WHERE code = 'EUR' AND user_id = :user_id"); $stmt->bindValue(':user_id', $newUserId, SQLITE3_INTEGER); $result = $stmt->execute(); $currency = $result->fetchArray(SQLITE3_ASSOC); if ($currency) { $stmt = $db->prepare("UPDATE user SET main_currency = :main_currency WHERE id = :user_id"); $stmt->bindValue(':main_currency', $currency['id'], SQLITE3_INTEGER); $stmt->bindValue(':user_id', $newUserId, SQLITE3_INTEGER); $stmt->execute(); } $userData['main_currency'] = $currency['id']; // Insert settings $stmt = $db->prepare("INSERT INTO settings (dark_theme, monthly_price, convert_currency, remove_background, color_theme, hide_disabled, user_id, disabled_to_bottom, show_original_price, mobile_nav) VALUES (2, 0, 0, 0, 'blue', 0, :user_id, 0, 0, 0)"); $stmt->bindValue(':user_id', $newUserId, SQLITE3_INTEGER); $stmt->execute(); // Log the user in require_once('oidc_login.php'); ================================================ FILE: includes/oidc/oidc_login.php ================================================ prepare($addLoginTokens); $addLoginTokensStmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $addLoginTokensStmt->bindParam(':token', $token, SQLITE3_TEXT); $addLoginTokensStmt->execute(); $_SESSION['token'] = $token; $cookieValue = $username . "|" . $token . "|" . $main_currency; setcookie('wallos_login', $cookieValue, [ 'expires' => $cookieExpire, 'samesite' => 'Strict', 'httponly' => true, ]); // Set language cookie setcookie('language', $language, [ 'expires' => $cookieExpire, 'samesite' => 'Strict' ]); // Set sort order default if (!isset($_COOKIE['sortOrder'])) { setcookie('sortOrder', 'next_payment', [ 'expires' => $cookieExpire, 'samesite' => 'Strict' ]); } // Set color theme $query = "SELECT color_theme FROM settings WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $settings = $result->fetchArray(SQLITE3_ASSOC); setcookie('colorTheme', $settings['color_theme'], [ 'expires' => $cookieExpire, 'samesite' => 'Strict' ]); // Done $db->close(); header("Location: ."); exit(); ================================================ FILE: includes/sort_options.php ================================================
  • onClick="setSortOption('name')" id="sort-name">
  • onClick="setSortOption('id')" id="sort-id">
  • onClick="setSortOption('price')" id="sort-price">
  • onClick="setSortOption('next_payment')" id="sort-next_payment">
  • onClick="setSortOption('payer_user_id')" id="sort-payer_user_id">
  • onClick="setSortOption('category_id')" id="sort-category_id">
  • onClick="setSortOption('payment_method_id')" id="sort-payment_method_id">
  • onClick="setSortOption('inactive')" id="sort-inactive">
  • onClick="setSortOption('alphanumeric')" id="sort-alphanumeric">
  • onClick="setSortOption('renewal_type')" id="sort-renewal_type">
================================================ FILE: includes/ssrf_helper.php ================================================ = ip2long('100.64.0.0') && $long <= ip2long('100.127.255.255'); } /** * Validates a webhook URL against SSRF attacks and checks the admin allowlist. * If validation fails, it kills the script and outputs a JSON error response. * * @param string $url The destination URL to check * @param SQLite3 $db The database connection * @param array $i18n The translation array * @return array Returns an array with ['host', 'ip', 'port'] for cURL hardening */ function validate_webhook_url_for_ssrf($url, $db, $i18n) { $parsedUrl = parse_url($url); // Fallback if parse_url fails completely if (!$parsedUrl || !isset($parsedUrl['host'])) { die(json_encode([ "success" => false, "message" => translate("error", $i18n) ])); } $urlHost = $parsedUrl['host']; $port = $parsedUrl['port'] ?? ''; $ip = gethostbyname($urlHost); // CATCH DNS FAILURES if ($ip === $urlHost && filter_var($urlHost, FILTER_VALIDATE_IP) === false) { die(json_encode([ "success" => false, "message" => "Error: Could not resolve the hostname. Please check the URL or your server's DNS." ])); } $hostWithPort = $port ? $urlHost . ':' . $port : $urlHost; $ipWithPort = $port ? $ip . ':' . $port : $ip; // Check if it's a private IP $is_private = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false || is_cgnat_ip($ip); if ($is_private) { $stmt = $db->prepare("SELECT local_webhook_notifications_allowlist FROM admin LIMIT 1"); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $allowlist_str = $row ? $row['local_webhook_notifications_allowlist'] : ''; $allowlist = array_filter(array_map('trim', explode(',', $allowlist_str))); if (!in_array($urlHost, $allowlist) && !in_array($ip, $allowlist) && !in_array($hostWithPort, $allowlist) && !in_array($ipWithPort, $allowlist)) { die(json_encode([ "success" => false, "message" => "Security Block: The target IP/Port is private and not present in the Webhook Allowlist." ])); } } // Determine the exact port being targeted for cURL DNS rebinding protection $targetPort = $port ?: (strtolower($parsedUrl['scheme'] ?? 'http') === 'https' ? 443 : 80); return [ 'host' => $urlHost, 'ip' => $ip, 'port' => $targetPort ]; } /** * Non-fatal variant for use in cron jobs (sendnotifications.php). * Returns the same ['host', 'ip', 'port'] array on success, or false on failure. * Never calls die() — caller should use continue/skip on false. * Respects the admin allowlist for private IPs, just like the main function. * * @param string $url The destination URL to check * @param SQLite3 $db The database connection * @return array|false */ function is_url_safe_for_ssrf($url, $db) { $parsedUrl = parse_url($url); if (!$parsedUrl || !isset($parsedUrl['host'])) return false; $scheme = strtolower($parsedUrl['scheme'] ?? ''); if (!in_array($scheme, ['http', 'https'])) return false; $urlHost = $parsedUrl['host']; $port = $parsedUrl['port'] ?? ''; $ip = gethostbyname($urlHost); // DNS failure if ($ip === $urlHost && filter_var($urlHost, FILTER_VALIDATE_IP) === false) return false; $hostWithPort = $port ? $urlHost . ':' . $port : $urlHost; $ipWithPort = $port ? $ip . ':' . $port : $ip; $is_private = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false || is_cgnat_ip($ip); if ($is_private) { $stmt = $db->prepare("SELECT local_webhook_notifications_allowlist FROM admin LIMIT 1"); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $allowlist_str = $row ? $row['local_webhook_notifications_allowlist'] : ''; $allowlist = array_filter(array_map('trim', explode(',', $allowlist_str))); if ( !in_array($urlHost, $allowlist) && !in_array($ip, $allowlist) && !in_array($hostWithPort, $allowlist) && !in_array($ipWithPort, $allowlist) ) { return false; // private and not in allowlist — skip silently } } $targetPort = $port ?: ($scheme === 'https' ? 443 : 80); return [ 'host' => $urlHost, 'ip' => $ip, 'port' => $targetPort ]; } ================================================ FILE: includes/stats_calculations.php ================================================ prepare($query); $stmt->bindParam(':currency', $currency, SQLITE3_INTEGER); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $exchangeRate = $result->fetchArray(SQLITE3_ASSOC); if ($exchangeRate === false) { return $price; } else { $fromRate = $exchangeRate['rate']; return $price / $fromRate; } } // Get categories $categories = array(); $query = "SELECT * FROM categories WHERE user_id = :userId ORDER BY 'order' ASC"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $categoryId = $row['id']; $categories[$categoryId] = $row; $categories[$categoryId]['count'] = 0; $categoryCost[$categoryId]['cost'] = 0; $categoryCost[$categoryId]['name'] = $row['name']; } // Get payment methods $paymentMethods = array(); $query = "SELECT * FROM payment_methods WHERE user_id = :userId AND enabled = 1"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $paymentMethodId = $row['id']; $paymentMethods[$paymentMethodId] = $row; $paymentMethods[$paymentMethodId]['count'] = 0; $paymentMethodsCount[$paymentMethodId]['count'] = 0; $paymentMethodsCount[$paymentMethodId]['name'] = $row['name']; } //Get household members $members = array(); $query = "SELECT * FROM household WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $memberId = $row['id']; $members[$memberId] = $row; $members[$memberId]['count'] = 0; $memberCost[$memberId]['cost'] = 0; $memberCost[$memberId]['name'] = $row['name']; } $activeSubscriptions = 0; $inactiveSubscriptions = 0; // Calculate total monthly price $mostExpensiveSubscription = array(); $mostExpensiveSubscription['price'] = 0; $amountDueThisMonth = 0; $totalCostPerMonth = 0; $totalSavingsPerMonth = 0; $totalCostsInReplacementsPerMonth = 0; $statsSubtitleParts = []; $query = "SELECT name, price, logo, frequency, cycle, currency_id, next_payment, payer_user_id, category_id, payment_method_id, inactive, replacement_subscription_id FROM subscriptions"; $conditions = []; $params = []; if (isset($_GET['member'])) { $conditions[] = "payer_user_id = :member"; $params[':member'] = $_GET['member']; $statsSubtitleParts[] = $members[$_GET['member']]['name']; } if (isset($_GET['category'])) { $conditions[] = "category_id = :category"; $params[':category'] = $_GET['category']; $statsSubtitleParts[] = $categories[$_GET['category']]['name'] == "No category" ? translate("no_category", $i18n) : $categories[$_GET['category']]['name']; } if (isset($_GET['payment'])) { $conditions[] = "payment_method_id = :payment"; $params[':payment'] = $_GET['payment']; $statsSubtitleParts[] = $paymentMethodsCount[$_GET['payment']]['name']; } $conditions[] = "user_id = :userId"; $params[':userId'] = $userId; if (!empty($conditions)) { $query .= " WHERE " . implode(' AND ', $conditions); } $stmt = $db->prepare($query); $statsSubtitle = !empty($statsSubtitleParts) ? '(' . implode(', ', $statsSubtitleParts) . ')' : ""; foreach ($params as $key => $value) { $stmt->bindValue($key, $value, SQLITE3_INTEGER); } $result = $stmt->execute(); $usesMultipleCurrencies = false; if ($result) { while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $subscriptions[] = $row; } if (isset($subscriptions)) { $replacementSubscriptions = array(); foreach ($subscriptions as $subscription) { $name = $subscription['name']; $price = $subscription['price']; $logo = $subscription['logo']; $frequency = $subscription['frequency']; $cycle = $subscription['cycle']; $currency = $subscription['currency_id']; if ($currency != $userData['main_currency']) { $usesMultipleCurrencies = true; } $next_payment = $subscription['next_payment']; $payerId = $subscription['payer_user_id']; $members[$payerId]['count'] += 1; $categoryId = $subscription['category_id']; $categories[$categoryId]['count'] += 1; $paymentMethodId = $subscription['payment_method_id']; $paymentMethods[$paymentMethodId]['count'] += 1; $inactive = $subscription['inactive']; $replacementSubscriptionId = $subscription['replacement_subscription_id']; $originalSubscriptionPrice = getPriceConverted($price, $currency, $db, $userId); $price = getPricePerMonth($cycle, $frequency, $originalSubscriptionPrice); if ($inactive == 0) { $activeSubscriptions++; $totalCostPerMonth += $price; $memberCost[$payerId]['cost'] += $price; $categoryCost[$categoryId]['cost'] += $price; $paymentMethodsCount[$paymentMethodId]['count'] += 1; if ($price > $mostExpensiveSubscription['price']) { $mostExpensiveSubscription['price'] = $price; $mostExpensiveSubscription['name'] = $name; $mostExpensiveSubscription['logo'] = $logo; } // Calculate ammount due this month $nextPaymentDate = DateTime::createFromFormat('Y-m-d', trim($next_payment)); $tomorrow = new DateTime('tomorrow'); $endOfMonth = new DateTime('last day of this month'); if ($nextPaymentDate >= $tomorrow && $nextPaymentDate <= $endOfMonth) { $timesToPay = 1; $daysInMonth = $endOfMonth->diff($tomorrow)->days + 1; $daysRemaining = $endOfMonth->diff($nextPaymentDate)->days + 1; if ($cycle == 1) { $timesToPay = $daysRemaining / $frequency; } if ($cycle == 2) { $weeksInMonth = ceil($daysInMonth / 7); $weeksRemaining = ceil($daysRemaining / 7); $timesToPay = $weeksRemaining / $frequency; } $amountDueThisMonth += $originalSubscriptionPrice * $timesToPay; } } else { $inactiveSubscriptions++; $totalSavingsPerMonth += $price; // Check if it has a replacement subscription and if it was not already counted if ($replacementSubscriptionId && !in_array($replacementSubscriptionId, $replacementSubscriptions)) { $query = "SELECT price, currency_id, cycle, frequency FROM subscriptions WHERE id = :replacementSubscriptionId"; $stmt = $db->prepare($query); $stmt->bindValue(':replacementSubscriptionId', $replacementSubscriptionId, SQLITE3_INTEGER); $result = $stmt->execute(); $replacementSubscription = $result->fetchArray(SQLITE3_ASSOC); if ($replacementSubscription) { $replacementSubscriptionPrice = getPriceConverted($replacementSubscription['price'], $replacementSubscription['currency_id'], $db, $userId); $replacementSubscriptionPrice = getPricePerMonth($replacementSubscription['cycle'], $replacementSubscription['frequency'], $replacementSubscriptionPrice); $totalCostsInReplacementsPerMonth += $replacementSubscriptionPrice; } } $replacementSubscriptions[] = $replacementSubscriptionId; } } // Subtract the total cost of replacement subscriptions from the total savings $totalSavingsPerMonth -= $totalCostsInReplacementsPerMonth; // Calculate yearly price $totalCostPerYear = $totalCostPerMonth * 12; // Calculate average subscription monthly cost if ($activeSubscriptions > 0) { $averageSubscriptionCost = $totalCostPerMonth / $activeSubscriptions; } else { $totalCostPerYear = 0; $averageSubscriptionCost = 0; } } else { $totalCostPerYear = 0; $averageSubscriptionCost = 0; } } $showVsBudgetGraph = false; $vsBudgetDataPoints = []; if (isset($userData['budget']) && $userData['budget'] > 0) { $budget = $userData['budget']; $budgetLeft = $budget - $totalCostPerMonth; $budgetLeft = $budgetLeft < 0 ? 0 : $budgetLeft; $budgetUsed = ($totalCostPerMonth / $budget) * 100; $budgetUsed = $budgetUsed > 100 ? 100 : $budgetUsed; if ($totalCostPerMonth > $budget) { $overBudgetAmount = $totalCostPerMonth - $budget; } $showVsBudgetGraph = true; $vsBudgetDataPoints = [ [ "label" => translate('budget_remaining', $i18n), "y" => $budgetLeft, ], [ "label" => translate('total_cost', $i18n), "y" => $totalCostPerMonth, ], ]; } $showCantConverErrorMessage = false; if ($usesMultipleCurrencies) { $query = "SELECT api_key FROM fixer WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result->fetchArray(SQLITE3_ASSOC) === false) { $showCantConverErrorMessage = true; } } $query = "SELECT * FROM total_yearly_cost WHERE user_id = :userId"; $stmt = $db->prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $totalMonthlyCostDataPoints = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $totalMonthlyCostDataPoints[] = [ "label" => html_entity_decode($row['date']), "y" => round($row['cost'] / 12, 2), ]; } $showTotalMonthlyCostGraph = count($totalMonthlyCostDataPoints) > 1; ?> ================================================ FILE: includes/validate_endpoint.php ================================================ false, "message" => "Invalid request method"]); exit; } $csrf = $_POST['csrf_token'] ?? ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''); if (!verify_csrf_token($csrf)) { echo json_encode(["success" => false, "message" => "Invalid CSRF token"]); exit; } if (!isset($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true) { echo json_encode(["success" => false, "message" => translate('session_expired', $i18n)]); exit; } ================================================ FILE: includes/validate_endpoint_admin.php ================================================ false, "message" => translate('error', $i18n) ])); } ================================================ FILE: includes/version.php ================================================ ================================================ FILE: index.php ================================================ format(new DateTime($date)); return $formattedDate; } // Get the first name of the user $stmt = $db->prepare("SELECT username, firstname FROM user WHERE id = :userId"); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); $first_name = $user['firstname'] ?? $user['username'] ?? ''; // Fetch the next 3 enabled subscriptions up for payment $stmt = $db->prepare("SELECT id, logo, name, price, currency_id, next_payment, inactive FROM subscriptions WHERE user_id = :userId AND next_payment >= date('now') AND inactive = 0 ORDER BY next_payment ASC LIMIT 3"); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $upcomingSubscriptions = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $upcomingSubscriptions[] = $row; } // Fetch enabled subscriptions with manual renewal that are overdue $stmt = $db->prepare("SELECT id, logo, name, price, currency_id, next_payment, inactive, auto_renew FROM subscriptions WHERE user_id = :userId AND next_payment < date('now') AND auto_renew = 0 AND inactive = 0 ORDER BY next_payment ASC"); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $overdueSubscriptions = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $overdueSubscriptions[] = $row; } $hasOverdueSubscriptions = !empty($overdueSubscriptions); require_once 'includes/stats_calculations.php'; // Get AI Recommendations for user $stmt = $db->prepare("SELECT * FROM ai_recommendations WHERE user_id = :userId"); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $aiRecommendations = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $aiRecommendations[] = $row; } ?>

    $recommendation) { ?>

0) { ?>

%

0) { ?>

0) { ?>

0) { ?>

0) { ?>

================================================ FILE: libs/OTPHP/Factory.php ================================================ getScheme() === 'otpauth' || throw new InvalidArgumentException('Invalid scheme.'); } catch (Throwable $throwable) { throw new InvalidArgumentException('Not a valid OTP provisioning URI', $throwable->getCode(), $throwable); } if ($clock === null) { trigger_deprecation( 'spomky-labs/otphp', '11.3.0', 'The parameter "$clock" will become mandatory in 12.0.0. Please set a valid PSR Clock implementation instead of "null".' ); $clock = new InternalClock(); } $otp = self::createOTP($parsed_url, $clock); self::populateOTP($otp, $parsed_url); return $otp; } private static function populateParameters(OTPInterface $otp, Url $data): void { foreach ($data->getQuery() as $key => $value) { $otp->setParameter($key, $value); } } private static function populateOTP(OTPInterface $otp, Url $data): void { self::populateParameters($otp, $data); $result = explode(':', rawurldecode(mb_substr($data->getPath(), 1))); if (count($result) < 2) { $otp->setIssuerIncludedAsParameter(false); return; } if ($otp->getIssuer() !== null) { $result[0] === $otp->getIssuer() || throw new InvalidArgumentException( 'Invalid OTP: invalid issuer in parameter' ); $otp->setIssuerIncludedAsParameter(true); } assert($result[0] !== ''); $otp->setIssuer($result[0]); } private static function createOTP(Url $parsed_url, ClockInterface $clock): OTPInterface { switch ($parsed_url->getHost()) { case 'totp': $totp = TOTP::createFromSecret($parsed_url->getSecret(), $clock); $totp->setLabel(self::getLabel($parsed_url->getPath())); return $totp; case 'hotp': $hotp = HOTP::createFromSecret($parsed_url->getSecret()); $hotp->setLabel(self::getLabel($parsed_url->getPath())); return $hotp; default: throw new InvalidArgumentException(sprintf('Unsupported "%s" OTP type', $parsed_url->getHost())); } } /** * @param non-empty-string $data * @return non-empty-string */ private static function getLabel(string $data): string { $result = explode(':', rawurldecode(mb_substr($data, 1))); $label = count($result) === 2 ? $result[1] : $result[0]; assert($label !== ''); return $label; } } ================================================ FILE: libs/OTPHP/FactoryInterface.php ================================================ setCounter($counter); $htop->setDigest($digest); $htop->setDigits($digits); return $htop; } public static function createFromSecret(string $secret): self { $htop = new self($secret); $htop->setCounter(self::DEFAULT_COUNTER); $htop->setDigest(self::DEFAULT_DIGEST); $htop->setDigits(self::DEFAULT_DIGITS); return $htop; } public static function generate(): self { return self::createFromSecret(self::generateSecret()); } /** * @return 0|positive-int */ public function getCounter(): int { $value = $this->getParameter('counter'); (is_int($value) && $value >= 0) || throw new InvalidArgumentException('Invalid "counter" parameter.'); return $value; } public function getProvisioningUri(): string { return $this->generateURI('hotp', [ 'counter' => $this->getCounter(), ]); } /** * If the counter is not provided, the OTP is verified at the actual counter. * * @param null|0|positive-int $counter */ public function verify(string $otp, null|int $counter = null, null|int $window = null): bool { $counter >= 0 || throw new InvalidArgumentException('The counter must be at least 0.'); if ($counter === null) { $counter = $this->getCounter(); } elseif ($counter < $this->getCounter()) { return false; } return $this->verifyOtpWithWindow($otp, $counter, $window); } public function setCounter(int $counter): void { $this->setParameter('counter', $counter); } /** * @return array */ protected function getParameterMap(): array { return [...parent::getParameterMap(), ...[ 'counter' => static function (mixed $value): int { $value = (int) $value; $value >= 0 || throw new InvalidArgumentException('Counter must be at least 0.'); return $value; }, ]]; } private function updateCounter(int $counter): void { $this->setCounter($counter); } /** * @param null|0|positive-int $window */ private function getWindow(null|int $window): int { return abs($window ?? self::DEFAULT_WINDOW); } /** * @param non-empty-string $otp * @param 0|positive-int $counter * @param null|0|positive-int $window */ private function verifyOtpWithWindow(string $otp, int $counter, null|int $window): bool { $window = $this->getWindow($window); for ($i = $counter; $i <= $counter + $window; ++$i) { if ($this->compareOTP($this->at($i), $otp)) { $this->updateCounter($i + 1); return true; } } return false; } } ================================================ FILE: libs/OTPHP/HOTPInterface.php ================================================ setSecret($secret); } public function getQrCodeUri(string $uri, string $placeholder): string { $provisioning_uri = urlencode($this->getProvisioningUri()); return str_replace($placeholder, $provisioning_uri, $uri); } /** * @param 0|positive-int $input */ public function at(int $input): string { return $this->generateOTP($input); } /** * @return non-empty-string */ final protected static function generateSecret(): string { return Base32::encodeUpper(random_bytes(self::DEFAULT_SECRET_SIZE)); } /** * The OTP at the specified input. * * @param 0|positive-int $input * * @return non-empty-string */ protected function generateOTP(int $input): string { $hash = hash_hmac($this->getDigest(), $this->intToByteString($input), $this->getDecodedSecret(), true); $unpacked = unpack('C*', $hash); $unpacked !== false || throw new InvalidArgumentException('Invalid data.'); $hmac = array_values($unpacked); $offset = ($hmac[count($hmac) - 1] & 0xF); $code = ($hmac[$offset] & 0x7F) << 24 | ($hmac[$offset + 1] & 0xFF) << 16 | ($hmac[$offset + 2] & 0xFF) << 8 | ($hmac[$offset + 3] & 0xFF); $otp = $code % (10 ** $this->getDigits()); return str_pad((string) $otp, $this->getDigits(), '0', STR_PAD_LEFT); } /** * @param array $options */ protected function filterOptions(array &$options): void { foreach ([ 'algorithm' => 'sha1', 'period' => 30, 'digits' => 6, ] as $key => $default) { if (isset($options[$key]) && $default === $options[$key]) { unset($options[$key]); } } ksort($options); } /** * @param non-empty-string $type * @param array $options * * @return non-empty-string */ protected function generateURI(string $type, array $options): string { $label = $this->getLabel(); is_string($label) || throw new InvalidArgumentException('The label is not set.'); $this->hasColon($label) === false || throw new InvalidArgumentException('Label must not contain a colon.'); $options = [...$options, ...$this->getParameters()]; $this->filterOptions($options); $params = str_replace(['+', '%7E'], ['%20', '~'], http_build_query($options, '', '&')); return sprintf( 'otpauth://%s/%s?%s', $type, rawurlencode(($this->getIssuer() !== null ? $this->getIssuer() . ':' : '') . $label), $params ); } /** * @param non-empty-string $safe * @param non-empty-string $user */ protected function compareOTP(string $safe, string $user): bool { return hash_equals($safe, $user); } /** * @return non-empty-string */ private function getDecodedSecret(): string { try { $decoded = Base32::decodeUpper($this->getSecret()); } catch (Exception) { throw new RuntimeException('Unable to decode the secret. Is it correctly base32 encoded?'); } assert($decoded !== ''); return $decoded; } private function intToByteString(int $int): string { $result = []; while ($int !== 0) { $result[] = chr($int & 0xFF); $int >>= 8; } return str_pad(implode('', array_reverse($result)), 8, "\000", STR_PAD_LEFT); } } ================================================ FILE: libs/OTPHP/OTPInterface.php ================================================ */ public function getParameters(): array; /** * @param non-empty-string $parameter */ public function setParameter(string $parameter, mixed $value): void; /** * Get the provisioning URI. * * @return non-empty-string */ public function getProvisioningUri(): string; /** * Get the provisioning URI. * * @param non-empty-string $uri The Uri of the QRCode generator with all parameters. This Uri MUST contain a placeholder that will be replaced by the method. * @param non-empty-string $placeholder the placeholder to be replaced in the QR Code generator URI */ public function getQrCodeUri(string $uri, string $placeholder): string; } ================================================ FILE: libs/OTPHP/ParameterTrait.php ================================================ */ private array $parameters = []; /** * @var non-empty-string|null */ private null|string $issuer = null; /** * @var non-empty-string|null */ private null|string $label = null; private bool $issuer_included_as_parameter = true; /** * @return array */ public function getParameters(): array { $parameters = $this->parameters; if ($this->getIssuer() !== null && $this->isIssuerIncludedAsParameter() === true) { $parameters['issuer'] = $this->getIssuer(); } return $parameters; } public function getSecret(): string { $value = $this->getParameter('secret'); (is_string($value) && $value !== '') || throw new InvalidArgumentException('Invalid "secret" parameter.'); return $value; } public function getLabel(): null|string { return $this->label; } public function setLabel(string $label): void { $this->setParameter('label', $label); } public function getIssuer(): null|string { return $this->issuer; } public function setIssuer(string $issuer): void { $this->setParameter('issuer', $issuer); } public function isIssuerIncludedAsParameter(): bool { return $this->issuer_included_as_parameter; } public function setIssuerIncludedAsParameter(bool $issuer_included_as_parameter): void { $this->issuer_included_as_parameter = $issuer_included_as_parameter; } public function getDigits(): int { $value = $this->getParameter('digits'); (is_int($value) && $value > 0) || throw new InvalidArgumentException('Invalid "digits" parameter.'); return $value; } public function getDigest(): string { $value = $this->getParameter('algorithm'); (is_string($value) && $value !== '') || throw new InvalidArgumentException('Invalid "algorithm" parameter.'); return $value; } public function hasParameter(string $parameter): bool { return array_key_exists($parameter, $this->parameters); } public function getParameter(string $parameter): mixed { if ($this->hasParameter($parameter)) { return $this->getParameters()[$parameter]; } throw new InvalidArgumentException(sprintf('Parameter "%s" does not exist', $parameter)); } public function setParameter(string $parameter, mixed $value): void { $map = $this->getParameterMap(); if (array_key_exists($parameter, $map) === true) { $callback = $map[$parameter]; $value = $callback($value); } if (property_exists($this, $parameter)) { $this->{$parameter} = $value; } else { $this->parameters[$parameter] = $value; } } public function setSecret(string $secret): void { $this->setParameter('secret', $secret); } public function setDigits(int $digits): void { $this->setParameter('digits', $digits); } public function setDigest(string $digest): void { $this->setParameter('algorithm', $digest); } /** * @return array */ protected function getParameterMap(): array { return [ 'label' => function (string $value): string { assert($value !== ''); $this->hasColon($value) === false || throw new InvalidArgumentException( 'Label must not contain a colon.' ); return $value; }, 'secret' => static fn (string $value): string => mb_strtoupper(trim($value, '=')), 'algorithm' => static function (string $value): string { $value = mb_strtolower($value); in_array($value, hash_algos(), true) || throw new InvalidArgumentException(sprintf( 'The "%s" digest is not supported.', $value )); return $value; }, 'digits' => static function ($value): int { $value > 0 || throw new InvalidArgumentException('Digits must be at least 1.'); return (int) $value; }, 'issuer' => function (string $value): string { assert($value !== ''); $this->hasColon($value) === false || throw new InvalidArgumentException( 'Issuer must not contain a colon.' ); return $value; }, ]; } /** * @param non-empty-string $value */ private function hasColon(string $value): bool { $colons = [':', '%3A', '%3a']; foreach ($colons as $colon) { if (str_contains($value, $colon)) { return true; } } return false; } } ================================================ FILE: libs/OTPHP/TOTP.php ================================================ clock = $clock; } public static function create( null|string $secret = null, int $period = self::DEFAULT_PERIOD, string $digest = self::DEFAULT_DIGEST, int $digits = self::DEFAULT_DIGITS, int $epoch = self::DEFAULT_EPOCH, ?ClockInterface $clock = null ): self { $totp = $secret !== null ? self::createFromSecret($secret, $clock) : self::generate($clock) ; $totp->setPeriod($period); $totp->setDigest($digest); $totp->setDigits($digits); $totp->setEpoch($epoch); return $totp; } public static function createFromSecret(string $secret, ?ClockInterface $clock = null): self { $totp = new self($secret, $clock); $totp->setPeriod(self::DEFAULT_PERIOD); $totp->setDigest(self::DEFAULT_DIGEST); $totp->setDigits(self::DEFAULT_DIGITS); $totp->setEpoch(self::DEFAULT_EPOCH); return $totp; } public static function generate(?ClockInterface $clock = null): self { return self::createFromSecret(self::generateSecret(), $clock); } public function getPeriod(): int { $value = $this->getParameter('period'); (is_int($value) && $value > 0) || throw new InvalidArgumentException('Invalid "period" parameter.'); return $value; } public function getEpoch(): int { $value = $this->getParameter('epoch'); (is_int($value) && $value >= 0) || throw new InvalidArgumentException('Invalid "epoch" parameter.'); return $value; } public function expiresIn(): int { $period = $this->getPeriod(); return $period - ($this->clock->now()->getTimestamp() % $this->getPeriod()); } /** * The OTP at the specified input. * * @param 0|positive-int $input */ public function at(int $input): string { return $this->generateOTP($this->timecode($input)); } public function now(): string { $timestamp = $this->clock->now() ->getTimestamp(); assert($timestamp >= 0, 'The timestamp must return a positive integer.'); return $this->at($timestamp); } /** * If no timestamp is provided, the OTP is verified at the actual timestamp. When used, the leeway parameter will * allow time drift. The passed value is in seconds. * * @param 0|positive-int $timestamp * @param null|0|positive-int $leeway */ public function verify(string $otp, null|int $timestamp = null, null|int $leeway = null): bool { $timestamp ??= $this->clock->now() ->getTimestamp(); $timestamp >= 0 || throw new InvalidArgumentException('Timestamp must be at least 0.'); if ($leeway === null) { return $this->compareOTP($this->at($timestamp), $otp); } $leeway = abs($leeway); $leeway < $this->getPeriod() || throw new InvalidArgumentException( 'The leeway must be lower than the TOTP period' ); $timestampMinusLeeway = $timestamp - $leeway; $timestampMinusLeeway >= 0 || throw new InvalidArgumentException( 'The timestamp must be greater than or equal to the leeway.' ); return $this->compareOTP($this->at($timestampMinusLeeway), $otp) || $this->compareOTP($this->at($timestamp), $otp) || $this->compareOTP($this->at($timestamp + $leeway), $otp); } public function getProvisioningUri(): string { $params = []; if ($this->getPeriod() !== 30) { $params['period'] = $this->getPeriod(); } if ($this->getEpoch() !== 0) { $params['epoch'] = $this->getEpoch(); } return $this->generateURI('totp', $params); } public function setPeriod(int $period): void { $this->setParameter('period', $period); } public function setEpoch(int $epoch): void { $this->setParameter('epoch', $epoch); } /** * @return array */ protected function getParameterMap(): array { return [ ...parent::getParameterMap(), 'period' => static function ($value): int { (int) $value > 0 || throw new InvalidArgumentException('Period must be at least 1.'); return (int) $value; }, 'epoch' => static function ($value): int { (int) $value >= 0 || throw new InvalidArgumentException( 'Epoch must be greater than or equal to 0.' ); return (int) $value; }, ]; } /** * @param array $options */ protected function filterOptions(array &$options): void { parent::filterOptions($options); if (isset($options['epoch']) && $options['epoch'] === 0) { unset($options['epoch']); } ksort($options); } /** * @param 0|positive-int $timestamp * * @return 0|positive-int */ private function timecode(int $timestamp): int { $timecode = (int) floor(($timestamp - $this->getEpoch()) / $this->getPeriod()); assert($timecode >= 0); return $timecode; } } ================================================ FILE: libs/OTPHP/TOTPInterface.php ================================================ $query */ public function __construct( private readonly string $scheme, private readonly string $host, private readonly string $path, private readonly string $secret, private readonly array $query ) { } /** * @return non-empty-string */ public function getScheme(): string { return $this->scheme; } /** * @return non-empty-string */ public function getHost(): string { return $this->host; } /** * @return non-empty-string */ public function getPath(): string { return $this->path; } /** * @return non-empty-string */ public function getSecret(): string { return $this->secret; } /** * @return array */ public function getQuery(): array { return $this->query; } /** * @param non-empty-string $uri */ public static function fromString(string $uri): self { $parsed_url = parse_url($uri); $parsed_url !== false || throw new InvalidArgumentException('Invalid URI.'); foreach (['scheme', 'host', 'path', 'query'] as $key) { array_key_exists($key, $parsed_url) || throw new InvalidArgumentException( 'Not a valid OTP provisioning URI' ); } $scheme = $parsed_url['scheme'] ?? null; $host = $parsed_url['host'] ?? null; $path = $parsed_url['path'] ?? null; $query = $parsed_url['query'] ?? null; $scheme === 'otpauth' || throw new InvalidArgumentException('Not a valid OTP provisioning URI'); is_string($host) || throw new InvalidArgumentException('Invalid URI.'); is_string($path) || throw new InvalidArgumentException('Invalid URI.'); is_string($query) || throw new InvalidArgumentException('Invalid URI.'); $parsedQuery = []; parse_str($query, $parsedQuery); array_key_exists('secret', $parsedQuery) || throw new InvalidArgumentException( 'Not a valid OTP provisioning URI' ); $secret = $parsedQuery['secret']; unset($parsedQuery['secret']); return new self($scheme, $host, $path, $secret, $parsedQuery); } } ================================================ FILE: libs/PHPMailer/Exception.php ================================================ * @author Jim Jagielski (jimjag) * @author Andy Prevost (codeworxtech) * @author Brent R. Matzelle (original founder) * @copyright 2012 - 2020 Marcus Bointon * @copyright 2010 - 2012 Jim Jagielski * @copyright 2004 - 2009 Andy Prevost * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License * @note This program is distributed in the hope that it will be useful - WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. */ namespace PHPMailer\PHPMailer; /** * PHPMailer exception handler. * * @author Marcus Bointon */ class Exception extends \Exception { /** * Prettify error message output. * * @return string */ public function errorMessage() { return '' . htmlspecialchars($this->getMessage(), ENT_COMPAT | ENT_HTML401) . "
\n"; } } ================================================ FILE: libs/PHPMailer/PHPMailer.php ================================================ * @author Jim Jagielski (jimjag) * @author Andy Prevost (codeworxtech) * @author Brent R. Matzelle (original founder) * @copyright 2012 - 2020 Marcus Bointon * @copyright 2010 - 2012 Jim Jagielski * @copyright 2004 - 2009 Andy Prevost * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License * @note This program is distributed in the hope that it will be useful - WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. */ namespace PHPMailer\PHPMailer; /** * PHPMailer - PHP email creation and transport class. * * @author Marcus Bointon (Synchro/coolbru) * @author Jim Jagielski (jimjag) * @author Andy Prevost (codeworxtech) * @author Brent R. Matzelle (original founder) */ class PHPMailer { const CHARSET_ASCII = 'us-ascii'; const CHARSET_ISO88591 = 'iso-8859-1'; const CHARSET_UTF8 = 'utf-8'; const CONTENT_TYPE_PLAINTEXT = 'text/plain'; const CONTENT_TYPE_TEXT_CALENDAR = 'text/calendar'; const CONTENT_TYPE_TEXT_HTML = 'text/html'; const CONTENT_TYPE_MULTIPART_ALTERNATIVE = 'multipart/alternative'; const CONTENT_TYPE_MULTIPART_MIXED = 'multipart/mixed'; const CONTENT_TYPE_MULTIPART_RELATED = 'multipart/related'; const ENCODING_7BIT = '7bit'; const ENCODING_8BIT = '8bit'; const ENCODING_BASE64 = 'base64'; const ENCODING_BINARY = 'binary'; const ENCODING_QUOTED_PRINTABLE = 'quoted-printable'; const ENCRYPTION_STARTTLS = 'tls'; const ENCRYPTION_SMTPS = 'ssl'; const ICAL_METHOD_REQUEST = 'REQUEST'; const ICAL_METHOD_PUBLISH = 'PUBLISH'; const ICAL_METHOD_REPLY = 'REPLY'; const ICAL_METHOD_ADD = 'ADD'; const ICAL_METHOD_CANCEL = 'CANCEL'; const ICAL_METHOD_REFRESH = 'REFRESH'; const ICAL_METHOD_COUNTER = 'COUNTER'; const ICAL_METHOD_DECLINECOUNTER = 'DECLINECOUNTER'; /** * Email priority. * Options: null (default), 1 = High, 3 = Normal, 5 = low. * When null, the header is not set at all. * * @var int|null */ public $Priority; /** * The character set of the message. * * @var string */ public $CharSet = self::CHARSET_ISO88591; /** * The MIME Content-type of the message. * * @var string */ public $ContentType = self::CONTENT_TYPE_PLAINTEXT; /** * The message encoding. * Options: "8bit", "7bit", "binary", "base64", and "quoted-printable". * * @var string */ public $Encoding = self::ENCODING_8BIT; /** * Holds the most recent mailer error message. * * @var string */ public $ErrorInfo = ''; /** * The From email address for the message. * * @var string */ public $From = ''; /** * The From name of the message. * * @var string */ public $FromName = ''; /** * The envelope sender of the message. * This will usually be turned into a Return-Path header by the receiver, * and is the address that bounces will be sent to. * If not empty, will be passed via `-f` to sendmail or as the 'MAIL FROM' value over SMTP. * * @var string */ public $Sender = ''; /** * The Subject of the message. * * @var string */ public $Subject = ''; /** * An HTML or plain text message body. * If HTML then call isHTML(true). * * @var string */ public $Body = ''; /** * The plain-text message body. * This body can be read by mail clients that do not have HTML email * capability such as mutt & Eudora. * Clients that can read HTML will view the normal Body. * * @var string */ public $AltBody = ''; /** * An iCal message part body. * Only supported in simple alt or alt_inline message types * To generate iCal event structures, use classes like EasyPeasyICS or iCalcreator. * * @see http://sprain.ch/blog/downloads/php-class-easypeasyics-create-ical-files-with-php/ * @see http://kigkonsult.se/iCalcreator/ * * @var string */ public $Ical = ''; /** * Value-array of "method" in Contenttype header "text/calendar" * * @var string[] */ protected static $IcalMethods = [ self::ICAL_METHOD_REQUEST, self::ICAL_METHOD_PUBLISH, self::ICAL_METHOD_REPLY, self::ICAL_METHOD_ADD, self::ICAL_METHOD_CANCEL, self::ICAL_METHOD_REFRESH, self::ICAL_METHOD_COUNTER, self::ICAL_METHOD_DECLINECOUNTER, ]; /** * The complete compiled MIME message body. * * @var string */ protected $MIMEBody = ''; /** * The complete compiled MIME message headers. * * @var string */ protected $MIMEHeader = ''; /** * Extra headers that createHeader() doesn't fold in. * * @var string */ protected $mailHeader = ''; /** * Word-wrap the message body to this number of chars. * Set to 0 to not wrap. A useful value here is 78, for RFC2822 section 2.1.1 compliance. * * @see static::STD_LINE_LENGTH * * @var int */ public $WordWrap = 0; /** * Which method to use to send mail. * Options: "mail", "sendmail", or "smtp". * * @var string */ public $Mailer = 'mail'; /** * The path to the sendmail program. * * @var string */ public $Sendmail = '/usr/sbin/sendmail'; /** * Whether mail() uses a fully sendmail-compatible MTA. * One which supports sendmail's "-oi -f" options. * * @var bool */ public $UseSendmailOptions = true; /** * The email address that a reading confirmation should be sent to, also known as read receipt. * * @var string */ public $ConfirmReadingTo = ''; /** * The hostname to use in the Message-ID header and as default HELO string. * If empty, PHPMailer attempts to find one with, in order, * $_SERVER['SERVER_NAME'], gethostname(), php_uname('n'), or the value * 'localhost.localdomain'. * * @see PHPMailer::$Helo * * @var string */ public $Hostname = ''; /** * An ID to be used in the Message-ID header. * If empty, a unique id will be generated. * You can set your own, but it must be in the format "", * as defined in RFC5322 section 3.6.4 or it will be ignored. * * @see https://tools.ietf.org/html/rfc5322#section-3.6.4 * * @var string */ public $MessageID = ''; /** * The message Date to be used in the Date header. * If empty, the current date will be added. * * @var string */ public $MessageDate = ''; /** * SMTP hosts. * Either a single hostname or multiple semicolon-delimited hostnames. * You can also specify a different port * for each host by using this format: [hostname:port] * (e.g. "smtp1.example.com:25;smtp2.example.com"). * You can also specify encryption type, for example: * (e.g. "tls://smtp1.example.com:587;ssl://smtp2.example.com:465"). * Hosts will be tried in order. * * @var string */ public $Host = 'localhost'; /** * The default SMTP server port. * * @var int */ public $Port = 25; /** * The SMTP HELO/EHLO name used for the SMTP connection. * Default is $Hostname. If $Hostname is empty, PHPMailer attempts to find * one with the same method described above for $Hostname. * * @see PHPMailer::$Hostname * * @var string */ public $Helo = ''; /** * What kind of encryption to use on the SMTP connection. * Options: '', static::ENCRYPTION_STARTTLS, or static::ENCRYPTION_SMTPS. * * @var string */ public $SMTPSecure = ''; /** * Whether to enable TLS encryption automatically if a server supports it, * even if `SMTPSecure` is not set to 'tls'. * Be aware that in PHP >= 5.6 this requires that the server's certificates are valid. * * @var bool */ public $SMTPAutoTLS = true; /** * Whether to use SMTP authentication. * Uses the Username and Password properties. * * @see PHPMailer::$Username * @see PHPMailer::$Password * * @var bool */ public $SMTPAuth = false; /** * Options array passed to stream_context_create when connecting via SMTP. * * @var array */ public $SMTPOptions = []; /** * SMTP username. * * @var string */ public $Username = ''; /** * SMTP password. * * @var string */ public $Password = ''; /** * SMTP authentication type. Options are CRAM-MD5, LOGIN, PLAIN, XOAUTH2. * If not specified, the first one from that list that the server supports will be selected. * * @var string */ public $AuthType = ''; /** * An implementation of the PHPMailer OAuthTokenProvider interface. * * @var OAuthTokenProvider */ protected $oauth; /** * The SMTP server timeout in seconds. * Default of 5 minutes (300sec) is from RFC2821 section 4.5.3.2. * * @var int */ public $Timeout = 300; /** * Comma separated list of DSN notifications * 'NEVER' under no circumstances a DSN must be returned to the sender. * If you use NEVER all other notifications will be ignored. * 'SUCCESS' will notify you when your mail has arrived at its destination. * 'FAILURE' will arrive if an error occurred during delivery. * 'DELAY' will notify you if there is an unusual delay in delivery, but the actual * delivery's outcome (success or failure) is not yet decided. * * @see https://tools.ietf.org/html/rfc3461 See section 4.1 for more information about NOTIFY */ public $dsn = ''; /** * SMTP class debug output mode. * Debug output level. * Options: * @see SMTP::DEBUG_OFF: No output * @see SMTP::DEBUG_CLIENT: Client messages * @see SMTP::DEBUG_SERVER: Client and server messages * @see SMTP::DEBUG_CONNECTION: As SERVER plus connection status * @see SMTP::DEBUG_LOWLEVEL: Noisy, low-level data output, rarely needed * * @see SMTP::$do_debug * * @var int */ public $SMTPDebug = 0; /** * How to handle debug output. * Options: * * `echo` Output plain-text as-is, appropriate for CLI * * `html` Output escaped, line breaks converted to `
`, appropriate for browser output * * `error_log` Output to error log as configured in php.ini * By default PHPMailer will use `echo` if run from a `cli` or `cli-server` SAPI, `html` otherwise. * Alternatively, you can provide a callable expecting two params: a message string and the debug level: * * ```php * $mail->Debugoutput = function($str, $level) {echo "debug level $level; message: $str";}; * ``` * * Alternatively, you can pass in an instance of a PSR-3 compatible logger, though only `debug` * level output is used: * * ```php * $mail->Debugoutput = new myPsr3Logger; * ``` * * @see SMTP::$Debugoutput * * @var string|callable|\Psr\Log\LoggerInterface */ public $Debugoutput = 'echo'; /** * Whether to keep the SMTP connection open after each message. * If this is set to true then the connection will remain open after a send, * and closing the connection will require an explicit call to smtpClose(). * It's a good idea to use this if you are sending multiple messages as it reduces overhead. * See the mailing list example for how to use it. * * @var bool */ public $SMTPKeepAlive = false; /** * Whether to split multiple to addresses into multiple messages * or send them all in one message. * Only supported in `mail` and `sendmail` transports, not in SMTP. * * @var bool * * @deprecated 6.0.0 PHPMailer isn't a mailing list manager! */ public $SingleTo = false; /** * Storage for addresses when SingleTo is enabled. * * @var array */ protected $SingleToArray = []; /** * Whether to generate VERP addresses on send. * Only applicable when sending via SMTP. * * @see https://en.wikipedia.org/wiki/Variable_envelope_return_path * @see http://www.postfix.org/VERP_README.html Postfix VERP info * * @var bool */ public $do_verp = false; /** * Whether to allow sending messages with an empty body. * * @var bool */ public $AllowEmpty = false; /** * DKIM selector. * * @var string */ public $DKIM_selector = ''; /** * DKIM Identity. * Usually the email address used as the source of the email. * * @var string */ public $DKIM_identity = ''; /** * DKIM passphrase. * Used if your key is encrypted. * * @var string */ public $DKIM_passphrase = ''; /** * DKIM signing domain name. * * @example 'example.com' * * @var string */ public $DKIM_domain = ''; /** * DKIM Copy header field values for diagnostic use. * * @var bool */ public $DKIM_copyHeaderFields = true; /** * DKIM Extra signing headers. * * @example ['List-Unsubscribe', 'List-Help'] * * @var array */ public $DKIM_extraHeaders = []; /** * DKIM private key file path. * * @var string */ public $DKIM_private = ''; /** * DKIM private key string. * * If set, takes precedence over `$DKIM_private`. * * @var string */ public $DKIM_private_string = ''; /** * Callback Action function name. * * The function that handles the result of the send email action. * It is called out by send() for each email sent. * * Value can be any php callable: http://www.php.net/is_callable * * Parameters: * bool $result result of the send action * array $to email addresses of the recipients * array $cc cc email addresses * array $bcc bcc email addresses * string $subject the subject * string $body the email body * string $from email address of sender * string $extra extra information of possible use * "smtp_transaction_id' => last smtp transaction id * * @var string */ public $action_function = ''; /** * What to put in the X-Mailer header. * Options: An empty string for PHPMailer default, whitespace/null for none, or a string to use. * * @var string|null */ public $XMailer = ''; /** * Which validator to use by default when validating email addresses. * May be a callable to inject your own validator, but there are several built-in validators. * The default validator uses PHP's FILTER_VALIDATE_EMAIL filter_var option. * * @see PHPMailer::validateAddress() * * @var string|callable */ public static $validator = 'php'; /** * An instance of the SMTP sender class. * * @var SMTP */ protected $smtp; /** * The array of 'to' names and addresses. * * @var array */ protected $to = []; /** * The array of 'cc' names and addresses. * * @var array */ protected $cc = []; /** * The array of 'bcc' names and addresses. * * @var array */ protected $bcc = []; /** * The array of reply-to names and addresses. * * @var array */ protected $ReplyTo = []; /** * An array of all kinds of addresses. * Includes all of $to, $cc, $bcc. * * @see PHPMailer::$to * @see PHPMailer::$cc * @see PHPMailer::$bcc * * @var array */ protected $all_recipients = []; /** * An array of names and addresses queued for validation. * In send(), valid and non duplicate entries are moved to $all_recipients * and one of $to, $cc, or $bcc. * This array is used only for addresses with IDN. * * @see PHPMailer::$to * @see PHPMailer::$cc * @see PHPMailer::$bcc * @see PHPMailer::$all_recipients * * @var array */ protected $RecipientsQueue = []; /** * An array of reply-to names and addresses queued for validation. * In send(), valid and non duplicate entries are moved to $ReplyTo. * This array is used only for addresses with IDN. * * @see PHPMailer::$ReplyTo * * @var array */ protected $ReplyToQueue = []; /** * The array of attachments. * * @var array */ protected $attachment = []; /** * The array of custom headers. * * @var array */ protected $CustomHeader = []; /** * The most recent Message-ID (including angular brackets). * * @var string */ protected $lastMessageID = ''; /** * The message's MIME type. * * @var string */ protected $message_type = ''; /** * The array of MIME boundary strings. * * @var array */ protected $boundary = []; /** * The array of available text strings for the current language. * * @var array */ protected $language = []; /** * The number of errors encountered. * * @var int */ protected $error_count = 0; /** * The S/MIME certificate file path. * * @var string */ protected $sign_cert_file = ''; /** * The S/MIME key file path. * * @var string */ protected $sign_key_file = ''; /** * The optional S/MIME extra certificates ("CA Chain") file path. * * @var string */ protected $sign_extracerts_file = ''; /** * The S/MIME password for the key. * Used only if the key is encrypted. * * @var string */ protected $sign_key_pass = ''; /** * Whether to throw exceptions for errors. * * @var bool */ protected $exceptions = false; /** * Unique ID used for message ID and boundaries. * * @var string */ protected $uniqueid = ''; /** * The PHPMailer Version number. * * @var string */ const VERSION = '6.8.1'; /** * Error severity: message only, continue processing. * * @var int */ const STOP_MESSAGE = 0; /** * Error severity: message, likely ok to continue processing. * * @var int */ const STOP_CONTINUE = 1; /** * Error severity: message, plus full stop, critical error reached. * * @var int */ const STOP_CRITICAL = 2; /** * The SMTP standard CRLF line break. * If you want to change line break format, change static::$LE, not this. */ const CRLF = "\r\n"; /** * "Folding White Space" a white space string used for line folding. */ const FWS = ' '; /** * SMTP RFC standard line ending; Carriage Return, Line Feed. * * @var string */ protected static $LE = self::CRLF; /** * The maximum line length supported by mail(). * * Background: mail() will sometimes corrupt messages * with headers longer than 65 chars, see #818. * * @var int */ const MAIL_MAX_LINE_LENGTH = 63; /** * The maximum line length allowed by RFC 2822 section 2.1.1. * * @var int */ const MAX_LINE_LENGTH = 998; /** * The lower maximum line length allowed by RFC 2822 section 2.1.1. * This length does NOT include the line break * 76 means that lines will be 77 or 78 chars depending on whether * the line break format is LF or CRLF; both are valid. * * @var int */ const STD_LINE_LENGTH = 76; /** * Constructor. * * @param bool $exceptions Should we throw external exceptions? */ public function __construct($exceptions = null) { if (null !== $exceptions) { $this->exceptions = (bool) $exceptions; } //Pick an appropriate debug output format automatically $this->Debugoutput = (strpos(PHP_SAPI, 'cli') !== false ? 'echo' : 'html'); } /** * Destructor. */ public function __destruct() { //Close any open SMTP connection nicely $this->smtpClose(); } /** * Call mail() in a safe_mode-aware fashion. * Also, unless sendmail_path points to sendmail (or something that * claims to be sendmail), don't pass params (not a perfect fix, * but it will do). * * @param string $to To * @param string $subject Subject * @param string $body Message Body * @param string $header Additional Header(s) * @param string|null $params Params * * @return bool */ private function mailPassthru($to, $subject, $body, $header, $params) { //Check overloading of mail function to avoid double-encoding if ((int)ini_get('mbstring.func_overload') & 1) { $subject = $this->secureHeader($subject); } else { $subject = $this->encodeHeader($this->secureHeader($subject)); } //Calling mail() with null params breaks $this->edebug('Sending with mail()'); $this->edebug('Sendmail path: ' . ini_get('sendmail_path')); $this->edebug("Envelope sender: {$this->Sender}"); $this->edebug("To: {$to}"); $this->edebug("Subject: {$subject}"); $this->edebug("Headers: {$header}"); if (!$this->UseSendmailOptions || null === $params) { $result = @mail($to, $subject, $body, $header); } else { $this->edebug("Additional params: {$params}"); $result = @mail($to, $subject, $body, $header, $params); } $this->edebug('Result: ' . ($result ? 'true' : 'false')); return $result; } /** * Output debugging info via a user-defined method. * Only generates output if debug output is enabled. * * @see PHPMailer::$Debugoutput * @see PHPMailer::$SMTPDebug * * @param string $str */ protected function edebug($str) { if ($this->SMTPDebug <= 0) { return; } //Is this a PSR-3 logger? if ($this->Debugoutput instanceof \Psr\Log\LoggerInterface) { $this->Debugoutput->debug($str); return; } //Avoid clash with built-in function names if (is_callable($this->Debugoutput) && !in_array($this->Debugoutput, ['error_log', 'html', 'echo'])) { call_user_func($this->Debugoutput, $str, $this->SMTPDebug); return; } switch ($this->Debugoutput) { case 'error_log': //Don't output, just log /** @noinspection ForgottenDebugOutputInspection */ error_log($str); break; case 'html': //Cleans up output a bit for a better looking, HTML-safe output echo htmlentities( preg_replace('/[\r\n]+/', '', $str), ENT_QUOTES, 'UTF-8' ), "
\n"; break; case 'echo': default: //Normalize line breaks $str = preg_replace('/\r\n|\r/m', "\n", $str); echo gmdate('Y-m-d H:i:s'), "\t", //Trim trailing space trim( //Indent for readability, except for trailing break str_replace( "\n", "\n \t ", trim($str) ) ), "\n"; } } /** * Sets message type to HTML or plain. * * @param bool $isHtml True for HTML mode */ public function isHTML($isHtml = true) { if ($isHtml) { $this->ContentType = static::CONTENT_TYPE_TEXT_HTML; } else { $this->ContentType = static::CONTENT_TYPE_PLAINTEXT; } } /** * Send messages using SMTP. */ public function isSMTP() { $this->Mailer = 'smtp'; } /** * Send messages using PHP's mail() function. */ public function isMail() { $this->Mailer = 'mail'; } /** * Send messages using $Sendmail. */ public function isSendmail() { $ini_sendmail_path = ini_get('sendmail_path'); if (false === stripos($ini_sendmail_path, 'sendmail')) { $this->Sendmail = '/usr/sbin/sendmail'; } else { $this->Sendmail = $ini_sendmail_path; } $this->Mailer = 'sendmail'; } /** * Send messages using qmail. */ public function isQmail() { $ini_sendmail_path = ini_get('sendmail_path'); if (false === stripos($ini_sendmail_path, 'qmail')) { $this->Sendmail = '/var/qmail/bin/qmail-inject'; } else { $this->Sendmail = $ini_sendmail_path; } $this->Mailer = 'qmail'; } /** * Add a "To" address. * * @param string $address The email address to send to * @param string $name * * @throws Exception * * @return bool true on success, false if address already used or invalid in some way */ public function addAddress($address, $name = '') { return $this->addOrEnqueueAnAddress('to', $address, $name); } /** * Add a "CC" address. * * @param string $address The email address to send to * @param string $name * * @throws Exception * * @return bool true on success, false if address already used or invalid in some way */ public function addCC($address, $name = '') { return $this->addOrEnqueueAnAddress('cc', $address, $name); } /** * Add a "BCC" address. * * @param string $address The email address to send to * @param string $name * * @throws Exception * * @return bool true on success, false if address already used or invalid in some way */ public function addBCC($address, $name = '') { return $this->addOrEnqueueAnAddress('bcc', $address, $name); } /** * Add a "Reply-To" address. * * @param string $address The email address to reply to * @param string $name * * @throws Exception * * @return bool true on success, false if address already used or invalid in some way */ public function addReplyTo($address, $name = '') { return $this->addOrEnqueueAnAddress('Reply-To', $address, $name); } /** * Add an address to one of the recipient arrays or to the ReplyTo array. Because PHPMailer * can't validate addresses with an IDN without knowing the PHPMailer::$CharSet (that can still * be modified after calling this function), addition of such addresses is delayed until send(). * Addresses that have been added already return false, but do not throw exceptions. * * @param string $kind One of 'to', 'cc', 'bcc', or 'ReplyTo' * @param string $address The email address * @param string $name An optional username associated with the address * * @throws Exception * * @return bool true on success, false if address already used or invalid in some way */ protected function addOrEnqueueAnAddress($kind, $address, $name) { $pos = false; if ($address !== null) { $address = trim($address); $pos = strrpos($address, '@'); } if (false === $pos) { //At-sign is missing. $error_message = sprintf( '%s (%s): %s', $this->lang('invalid_address'), $kind, $address ); $this->setError($error_message); $this->edebug($error_message); if ($this->exceptions) { throw new Exception($error_message); } return false; } if ($name !== null && is_string($name)) { $name = trim(preg_replace('/[\r\n]+/', '', $name)); //Strip breaks and trim } else { $name = ''; } $params = [$kind, $address, $name]; //Enqueue addresses with IDN until we know the PHPMailer::$CharSet. //Domain is assumed to be whatever is after the last @ symbol in the address if (static::idnSupported() && $this->has8bitChars(substr($address, ++$pos))) { if ('Reply-To' !== $kind) { if (!array_key_exists($address, $this->RecipientsQueue)) { $this->RecipientsQueue[$address] = $params; return true; } } elseif (!array_key_exists($address, $this->ReplyToQueue)) { $this->ReplyToQueue[$address] = $params; return true; } return false; } //Immediately add standard addresses without IDN. return call_user_func_array([$this, 'addAnAddress'], $params); } /** * Set the boundaries to use for delimiting MIME parts. * If you override this, ensure you set all 3 boundaries to unique values. * The default boundaries include a "=_" sequence which cannot occur in quoted-printable bodies, * as suggested by https://www.rfc-editor.org/rfc/rfc2045#section-6.7 * * @return void */ public function setBoundaries() { $this->uniqueid = $this->generateId(); $this->boundary[1] = 'b1=_' . $this->uniqueid; $this->boundary[2] = 'b2=_' . $this->uniqueid; $this->boundary[3] = 'b3=_' . $this->uniqueid; } /** * Add an address to one of the recipient arrays or to the ReplyTo array. * Addresses that have been added already return false, but do not throw exceptions. * * @param string $kind One of 'to', 'cc', 'bcc', or 'ReplyTo' * @param string $address The email address to send, resp. to reply to * @param string $name * * @throws Exception * * @return bool true on success, false if address already used or invalid in some way */ protected function addAnAddress($kind, $address, $name = '') { if (!in_array($kind, ['to', 'cc', 'bcc', 'Reply-To'])) { $error_message = sprintf( '%s: %s', $this->lang('Invalid recipient kind'), $kind ); $this->setError($error_message); $this->edebug($error_message); if ($this->exceptions) { throw new Exception($error_message); } return false; } if (!static::validateAddress($address)) { $error_message = sprintf( '%s (%s): %s', $this->lang('invalid_address'), $kind, $address ); $this->setError($error_message); $this->edebug($error_message); if ($this->exceptions) { throw new Exception($error_message); } return false; } if ('Reply-To' !== $kind) { if (!array_key_exists(strtolower($address), $this->all_recipients)) { $this->{$kind}[] = [$address, $name]; $this->all_recipients[strtolower($address)] = true; return true; } } elseif (!array_key_exists(strtolower($address), $this->ReplyTo)) { $this->ReplyTo[strtolower($address)] = [$address, $name]; return true; } return false; } /** * Parse and validate a string containing one or more RFC822-style comma-separated email addresses * of the form "display name
" into an array of name/address pairs. * Uses the imap_rfc822_parse_adrlist function if the IMAP extension is available. * Note that quotes in the name part are removed. * * @see http://www.andrew.cmu.edu/user/agreen1/testing/mrbs/web/Mail/RFC822.php A more careful implementation * * @param string $addrstr The address list string * @param bool $useimap Whether to use the IMAP extension to parse the list * @param string $charset The charset to use when decoding the address list string. * * @return array */ public static function parseAddresses($addrstr, $useimap = true, $charset = self::CHARSET_ISO88591) { $addresses = []; if ($useimap && function_exists('imap_rfc822_parse_adrlist')) { //Use this built-in parser if it's available $list = imap_rfc822_parse_adrlist($addrstr, ''); // Clear any potential IMAP errors to get rid of notices being thrown at end of script. imap_errors(); foreach ($list as $address) { if ( '.SYNTAX-ERROR.' !== $address->host && static::validateAddress($address->mailbox . '@' . $address->host) ) { //Decode the name part if it's present and encoded if ( property_exists($address, 'personal') && //Check for a Mbstring constant rather than using extension_loaded, which is sometimes disabled defined('MB_CASE_UPPER') && preg_match('/^=\?.*\?=$/s', $address->personal) ) { $origCharset = mb_internal_encoding(); mb_internal_encoding($charset); //Undo any RFC2047-encoded spaces-as-underscores $address->personal = str_replace('_', '=20', $address->personal); //Decode the name $address->personal = mb_decode_mimeheader($address->personal); mb_internal_encoding($origCharset); } $addresses[] = [ 'name' => (property_exists($address, 'personal') ? $address->personal : ''), 'address' => $address->mailbox . '@' . $address->host, ]; } } } else { //Use this simpler parser $list = explode(',', $addrstr); foreach ($list as $address) { $address = trim($address); //Is there a separate name part? if (strpos($address, '<') === false) { //No separate name, just use the whole thing if (static::validateAddress($address)) { $addresses[] = [ 'name' => '', 'address' => $address, ]; } } else { list($name, $email) = explode('<', $address); $email = trim(str_replace('>', '', $email)); $name = trim($name); if (static::validateAddress($email)) { //Check for a Mbstring constant rather than using extension_loaded, which is sometimes disabled //If this name is encoded, decode it if (defined('MB_CASE_UPPER') && preg_match('/^=\?.*\?=$/s', $name)) { $origCharset = mb_internal_encoding(); mb_internal_encoding($charset); //Undo any RFC2047-encoded spaces-as-underscores $name = str_replace('_', '=20', $name); //Decode the name $name = mb_decode_mimeheader($name); mb_internal_encoding($origCharset); } $addresses[] = [ //Remove any surrounding quotes and spaces from the name 'name' => trim($name, '\'" '), 'address' => $email, ]; } } } } return $addresses; } /** * Set the From and FromName properties. * * @param string $address * @param string $name * @param bool $auto Whether to also set the Sender address, defaults to true * * @throws Exception * * @return bool */ public function setFrom($address, $name = '', $auto = true) { $address = trim((string)$address); $name = trim(preg_replace('/[\r\n]+/', '', $name)); //Strip breaks and trim //Don't validate now addresses with IDN. Will be done in send(). $pos = strrpos($address, '@'); if ( (false === $pos) || ((!$this->has8bitChars(substr($address, ++$pos)) || !static::idnSupported()) && !static::validateAddress($address)) ) { $error_message = sprintf( '%s (From): %s', $this->lang('invalid_address'), $address ); $this->setError($error_message); $this->edebug($error_message); if ($this->exceptions) { throw new Exception($error_message); } return false; } $this->From = $address; $this->FromName = $name; if ($auto && empty($this->Sender)) { $this->Sender = $address; } return true; } /** * Return the Message-ID header of the last email. * Technically this is the value from the last time the headers were created, * but it's also the message ID of the last sent message except in * pathological cases. * * @return string */ public function getLastMessageID() { return $this->lastMessageID; } /** * Check that a string looks like an email address. * Validation patterns supported: * * `auto` Pick best pattern automatically; * * `pcre8` Use the squiloople.com pattern, requires PCRE > 8.0; * * `pcre` Use old PCRE implementation; * * `php` Use PHP built-in FILTER_VALIDATE_EMAIL; * * `html5` Use the pattern given by the HTML5 spec for 'email' type form input elements. * * `noregex` Don't use a regex: super fast, really dumb. * Alternatively you may pass in a callable to inject your own validator, for example: * * ```php * PHPMailer::validateAddress('user@example.com', function($address) { * return (strpos($address, '@') !== false); * }); * ``` * * You can also set the PHPMailer::$validator static to a callable, allowing built-in methods to use your validator. * * @param string $address The email address to check * @param string|callable $patternselect Which pattern to use * * @return bool */ public static function validateAddress($address, $patternselect = null) { if (null === $patternselect) { $patternselect = static::$validator; } //Don't allow strings as callables, see SECURITY.md and CVE-2021-3603 if (is_callable($patternselect) && !is_string($patternselect)) { return call_user_func($patternselect, $address); } //Reject line breaks in addresses; it's valid RFC5322, but not RFC5321 if (strpos($address, "\n") !== false || strpos($address, "\r") !== false) { return false; } switch ($patternselect) { case 'pcre': //Kept for BC case 'pcre8': /* * A more complex and more permissive version of the RFC5322 regex on which FILTER_VALIDATE_EMAIL * is based. * In addition to the addresses allowed by filter_var, also permits: * * dotless domains: `a@b` * * comments: `1234 @ local(blah) .machine .example` * * quoted elements: `'"test blah"@example.org'` * * numeric TLDs: `a@b.123` * * unbracketed IPv4 literals: `a@192.168.0.1` * * IPv6 literals: 'first.last@[IPv6:a1::]' * Not all of these will necessarily work for sending! * * @see http://squiloople.com/2009/12/20/email-address-validation/ * @copyright 2009-2010 Michael Rushton * Feel free to use and redistribute this code. But please keep this copyright notice. */ return (bool) preg_match( '/^(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){255,})(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){65,}@)' . '((?>(?>(?>((?>(?>(?>\x0D\x0A)?[\t ])+|(?>[\t ]*\x0D\x0A)?[\t ]+)?)(\((?>(?2)' . '(?>[\x01-\x08\x0B\x0C\x0E-\'*-\[\]-\x7F]|\\\[\x00-\x7F]|(?3)))*(?2)\)))+(?2))|(?2))?)' . '([!#-\'*+\/-9=?^-~-]+|"(?>(?2)(?>[\x01-\x08\x0B\x0C\x0E-!#-\[\]-\x7F]|\\\[\x00-\x7F]))*' . '(?2)")(?>(?1)\.(?1)(?4))*(?1)@(?!(?1)[a-z0-9-]{64,})(?1)(?>([a-z0-9](?>[a-z0-9-]*[a-z0-9])?)' . '(?>(?1)\.(?!(?1)[a-z0-9-]{64,})(?1)(?5)){0,126}|\[(?:(?>IPv6:(?>([a-f0-9]{1,4})(?>:(?6)){7}' . '|(?!(?:.*[a-f0-9][:\]]){8,})((?6)(?>:(?6)){0,6})?::(?7)?))|(?>(?>IPv6:(?>(?6)(?>:(?6)){5}:' . '|(?!(?:.*[a-f0-9]:){6,})(?8)?::(?>((?6)(?>:(?6)){0,4}):)?))?(25[0-5]|2[0-4][0-9]|1[0-9]{2}' . '|[1-9]?[0-9])(?>\.(?9)){3}))\])(?1)$/isD', $address ); case 'html5': /* * This is the pattern used in the HTML5 spec for validation of 'email' type form input elements. * * @see https://html.spec.whatwg.org/#e-mail-state-(type=email) */ return (bool) preg_match( '/^[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}' . '[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/sD', $address ); case 'php': default: return filter_var($address, FILTER_VALIDATE_EMAIL) !== false; } } /** * Tells whether IDNs (Internationalized Domain Names) are supported or not. This requires the * `intl` and `mbstring` PHP extensions. * * @return bool `true` if required functions for IDN support are present */ public static function idnSupported() { return function_exists('idn_to_ascii') && function_exists('mb_convert_encoding'); } /** * Converts IDN in given email address to its ASCII form, also known as punycode, if possible. * Important: Address must be passed in same encoding as currently set in PHPMailer::$CharSet. * This function silently returns unmodified address if: * - No conversion is necessary (i.e. domain name is not an IDN, or is already in ASCII form) * - Conversion to punycode is impossible (e.g. required PHP functions are not available) * or fails for any reason (e.g. domain contains characters not allowed in an IDN). * * @see PHPMailer::$CharSet * * @param string $address The email address to convert * * @return string The encoded address in ASCII form */ public function punyencodeAddress($address) { //Verify we have required functions, CharSet, and at-sign. $pos = strrpos($address, '@'); if ( !empty($this->CharSet) && false !== $pos && static::idnSupported() ) { $domain = substr($address, ++$pos); //Verify CharSet string is a valid one, and domain properly encoded in this CharSet. if ($this->has8bitChars($domain) && @mb_check_encoding($domain, $this->CharSet)) { //Convert the domain from whatever charset it's in to UTF-8 $domain = mb_convert_encoding($domain, self::CHARSET_UTF8, $this->CharSet); //Ignore IDE complaints about this line - method signature changed in PHP 5.4 $errorcode = 0; if (defined('INTL_IDNA_VARIANT_UTS46')) { //Use the current punycode standard (appeared in PHP 7.2) $punycode = idn_to_ascii( $domain, \IDNA_DEFAULT | \IDNA_USE_STD3_RULES | \IDNA_CHECK_BIDI | \IDNA_CHECK_CONTEXTJ | \IDNA_NONTRANSITIONAL_TO_ASCII, \INTL_IDNA_VARIANT_UTS46 ); } elseif (defined('INTL_IDNA_VARIANT_2003')) { //Fall back to this old, deprecated/removed encoding $punycode = idn_to_ascii($domain, $errorcode, \INTL_IDNA_VARIANT_2003); } else { //Fall back to a default we don't know about $punycode = idn_to_ascii($domain, $errorcode); } if (false !== $punycode) { return substr($address, 0, $pos) . $punycode; } } } return $address; } /** * Create a message and send it. * Uses the sending method specified by $Mailer. * * @throws Exception * * @return bool false on error - See the ErrorInfo property for details of the error */ public function send() { try { if (!$this->preSend()) { return false; } return $this->postSend(); } catch (Exception $exc) { $this->mailHeader = ''; $this->setError($exc->getMessage()); if ($this->exceptions) { throw $exc; } return false; } } /** * Prepare a message for sending. * * @throws Exception * * @return bool */ public function preSend() { if ( 'smtp' === $this->Mailer || ('mail' === $this->Mailer && (\PHP_VERSION_ID >= 80000 || stripos(PHP_OS, 'WIN') === 0)) ) { //SMTP mandates RFC-compliant line endings //and it's also used with mail() on Windows static::setLE(self::CRLF); } else { //Maintain backward compatibility with legacy Linux command line mailers static::setLE(PHP_EOL); } //Check for buggy PHP versions that add a header with an incorrect line break if ( 'mail' === $this->Mailer && ((\PHP_VERSION_ID >= 70000 && \PHP_VERSION_ID < 70017) || (\PHP_VERSION_ID >= 70100 && \PHP_VERSION_ID < 70103)) && ini_get('mail.add_x_header') === '1' && stripos(PHP_OS, 'WIN') === 0 ) { trigger_error($this->lang('buggy_php'), E_USER_WARNING); } try { $this->error_count = 0; //Reset errors $this->mailHeader = ''; //Dequeue recipient and Reply-To addresses with IDN foreach (array_merge($this->RecipientsQueue, $this->ReplyToQueue) as $params) { $params[1] = $this->punyencodeAddress($params[1]); call_user_func_array([$this, 'addAnAddress'], $params); } if (count($this->to) + count($this->cc) + count($this->bcc) < 1) { throw new Exception($this->lang('provide_address'), self::STOP_CRITICAL); } //Validate From, Sender, and ConfirmReadingTo addresses foreach (['From', 'Sender', 'ConfirmReadingTo'] as $address_kind) { if ($this->{$address_kind} === null) { $this->{$address_kind} = ''; continue; } $this->{$address_kind} = trim($this->{$address_kind}); if (empty($this->{$address_kind})) { continue; } $this->{$address_kind} = $this->punyencodeAddress($this->{$address_kind}); if (!static::validateAddress($this->{$address_kind})) { $error_message = sprintf( '%s (%s): %s', $this->lang('invalid_address'), $address_kind, $this->{$address_kind} ); $this->setError($error_message); $this->edebug($error_message); if ($this->exceptions) { throw new Exception($error_message); } return false; } } //Set whether the message is multipart/alternative if ($this->alternativeExists()) { $this->ContentType = static::CONTENT_TYPE_MULTIPART_ALTERNATIVE; } $this->setMessageType(); //Refuse to send an empty message unless we are specifically allowing it if (!$this->AllowEmpty && empty($this->Body)) { throw new Exception($this->lang('empty_message'), self::STOP_CRITICAL); } //Trim subject consistently $this->Subject = trim($this->Subject); //Create body before headers in case body makes changes to headers (e.g. altering transfer encoding) $this->MIMEHeader = ''; $this->MIMEBody = $this->createBody(); //createBody may have added some headers, so retain them $tempheaders = $this->MIMEHeader; $this->MIMEHeader = $this->createHeader(); $this->MIMEHeader .= $tempheaders; //To capture the complete message when using mail(), create //an extra header list which createHeader() doesn't fold in if ('mail' === $this->Mailer) { if (count($this->to) > 0) { $this->mailHeader .= $this->addrAppend('To', $this->to); } else { $this->mailHeader .= $this->headerLine('To', 'undisclosed-recipients:;'); } $this->mailHeader .= $this->headerLine( 'Subject', $this->encodeHeader($this->secureHeader($this->Subject)) ); } //Sign with DKIM if enabled if ( !empty($this->DKIM_domain) && !empty($this->DKIM_selector) && (!empty($this->DKIM_private_string) || (!empty($this->DKIM_private) && static::isPermittedPath($this->DKIM_private) && file_exists($this->DKIM_private) ) ) ) { $header_dkim = $this->DKIM_Add( $this->MIMEHeader . $this->mailHeader, $this->encodeHeader($this->secureHeader($this->Subject)), $this->MIMEBody ); $this->MIMEHeader = static::stripTrailingWSP($this->MIMEHeader) . static::$LE . static::normalizeBreaks($header_dkim) . static::$LE; } return true; } catch (Exception $exc) { $this->setError($exc->getMessage()); if ($this->exceptions) { throw $exc; } return false; } } /** * Actually send a message via the selected mechanism. * * @throws Exception * * @return bool */ public function postSend() { try { //Choose the mailer and send through it switch ($this->Mailer) { case 'sendmail': case 'qmail': return $this->sendmailSend($this->MIMEHeader, $this->MIMEBody); case 'smtp': return $this->smtpSend($this->MIMEHeader, $this->MIMEBody); case 'mail': return $this->mailSend($this->MIMEHeader, $this->MIMEBody); default: $sendMethod = $this->Mailer . 'Send'; if (method_exists($this, $sendMethod)) { return $this->{$sendMethod}($this->MIMEHeader, $this->MIMEBody); } return $this->mailSend($this->MIMEHeader, $this->MIMEBody); } } catch (Exception $exc) { $this->setError($exc->getMessage()); $this->edebug($exc->getMessage()); if ($this->Mailer === 'smtp' && $this->SMTPKeepAlive == true && $this->smtp->connected()) { $this->smtp->reset(); } if ($this->exceptions) { throw $exc; } } return false; } /** * Send mail using the $Sendmail program. * * @see PHPMailer::$Sendmail * * @param string $header The message headers * @param string $body The message body * * @throws Exception * * @return bool */ protected function sendmailSend($header, $body) { if ($this->Mailer === 'qmail') { $this->edebug('Sending with qmail'); } else { $this->edebug('Sending with sendmail'); } $header = static::stripTrailingWSP($header) . static::$LE . static::$LE; //This sets the SMTP envelope sender which gets turned into a return-path header by the receiver //A space after `-f` is optional, but there is a long history of its presence //causing problems, so we don't use one //Exim docs: http://www.exim.org/exim-html-current/doc/html/spec_html/ch-the_exim_command_line.html //Sendmail docs: http://www.sendmail.org/~ca/email/man/sendmail.html //Qmail docs: http://www.qmail.org/man/man8/qmail-inject.html //Example problem: https://www.drupal.org/node/1057954 //PHP 5.6 workaround $sendmail_from_value = ini_get('sendmail_from'); if (empty($this->Sender) && !empty($sendmail_from_value)) { //PHP config has a sender address we can use $this->Sender = ini_get('sendmail_from'); } //CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. if (!empty($this->Sender) && static::validateAddress($this->Sender) && self::isShellSafe($this->Sender)) { if ($this->Mailer === 'qmail') { $sendmailFmt = '%s -f%s'; } else { $sendmailFmt = '%s -oi -f%s -t'; } } else { //allow sendmail to choose a default envelope sender. It may //seem preferable to force it to use the From header as with //SMTP, but that introduces new problems (see //), and //it has historically worked this way. $sendmailFmt = '%s -oi -t'; } $sendmail = sprintf($sendmailFmt, escapeshellcmd($this->Sendmail), $this->Sender); $this->edebug('Sendmail path: ' . $this->Sendmail); $this->edebug('Sendmail command: ' . $sendmail); $this->edebug('Envelope sender: ' . $this->Sender); $this->edebug("Headers: {$header}"); if ($this->SingleTo) { foreach ($this->SingleToArray as $toAddr) { $mail = @popen($sendmail, 'w'); if (!$mail) { throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } $this->edebug("To: {$toAddr}"); fwrite($mail, 'To: ' . $toAddr . "\n"); fwrite($mail, $header); fwrite($mail, $body); $result = pclose($mail); $addrinfo = static::parseAddresses($toAddr, true, $this->CharSet); $this->doCallback( ($result === 0), [[$addrinfo['address'], $addrinfo['name']]], $this->cc, $this->bcc, $this->Subject, $body, $this->From, [] ); $this->edebug("Result: " . ($result === 0 ? 'true' : 'false')); if (0 !== $result) { throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } } } else { $mail = @popen($sendmail, 'w'); if (!$mail) { throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } fwrite($mail, $header); fwrite($mail, $body); $result = pclose($mail); $this->doCallback( ($result === 0), $this->to, $this->cc, $this->bcc, $this->Subject, $body, $this->From, [] ); $this->edebug("Result: " . ($result === 0 ? 'true' : 'false')); if (0 !== $result) { throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } } return true; } /** * Fix CVE-2016-10033 and CVE-2016-10045 by disallowing potentially unsafe shell characters. * Note that escapeshellarg and escapeshellcmd are inadequate for our purposes, especially on Windows. * * @see https://github.com/PHPMailer/PHPMailer/issues/924 CVE-2016-10045 bug report * * @param string $string The string to be validated * * @return bool */ protected static function isShellSafe($string) { //It's not possible to use shell commands safely (which includes the mail() function) without escapeshellarg, //but some hosting providers disable it, creating a security problem that we don't want to have to deal with, //so we don't. if (!function_exists('escapeshellarg') || !function_exists('escapeshellcmd')) { return false; } if ( escapeshellcmd($string) !== $string || !in_array(escapeshellarg($string), ["'$string'", "\"$string\""]) ) { return false; } $length = strlen($string); for ($i = 0; $i < $length; ++$i) { $c = $string[$i]; //All other characters have a special meaning in at least one common shell, including = and +. //Full stop (.) has a special meaning in cmd.exe, but its impact should be negligible here. //Note that this does permit non-Latin alphanumeric characters based on the current locale. if (!ctype_alnum($c) && strpos('@_-.', $c) === false) { return false; } } return true; } /** * Check whether a file path is of a permitted type. * Used to reject URLs and phar files from functions that access local file paths, * such as addAttachment. * * @param string $path A relative or absolute path to a file * * @return bool */ protected static function isPermittedPath($path) { //Matches scheme definition from https://tools.ietf.org/html/rfc3986#section-3.1 return !preg_match('#^[a-z][a-z\d+.-]*://#i', $path); } /** * Check whether a file path is safe, accessible, and readable. * * @param string $path A relative or absolute path to a file * * @return bool */ protected static function fileIsAccessible($path) { if (!static::isPermittedPath($path)) { return false; } $readable = is_file($path); //If not a UNC path (expected to start with \\), check read permission, see #2069 if (strpos($path, '\\\\') !== 0) { $readable = $readable && is_readable($path); } return $readable; } /** * Send mail using the PHP mail() function. * * @see http://www.php.net/manual/en/book.mail.php * * @param string $header The message headers * @param string $body The message body * * @throws Exception * * @return bool */ protected function mailSend($header, $body) { $header = static::stripTrailingWSP($header) . static::$LE . static::$LE; $toArr = []; foreach ($this->to as $toaddr) { $toArr[] = $this->addrFormat($toaddr); } $to = trim(implode(', ', $toArr)); //If there are no To-addresses (e.g. when sending only to BCC-addresses) //the following should be added to get a correct DKIM-signature. //Compare with $this->preSend() if ($to === '') { $to = 'undisclosed-recipients:;'; } $params = null; //This sets the SMTP envelope sender which gets turned into a return-path header by the receiver //A space after `-f` is optional, but there is a long history of its presence //causing problems, so we don't use one //Exim docs: http://www.exim.org/exim-html-current/doc/html/spec_html/ch-the_exim_command_line.html //Sendmail docs: http://www.sendmail.org/~ca/email/man/sendmail.html //Qmail docs: http://www.qmail.org/man/man8/qmail-inject.html //Example problem: https://www.drupal.org/node/1057954 //CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. //PHP 5.6 workaround $sendmail_from_value = ini_get('sendmail_from'); if (empty($this->Sender) && !empty($sendmail_from_value)) { //PHP config has a sender address we can use $this->Sender = ini_get('sendmail_from'); } if (!empty($this->Sender) && static::validateAddress($this->Sender)) { if (self::isShellSafe($this->Sender)) { $params = sprintf('-f%s', $this->Sender); } $old_from = ini_get('sendmail_from'); ini_set('sendmail_from', $this->Sender); } $result = false; if ($this->SingleTo && count($toArr) > 1) { foreach ($toArr as $toAddr) { $result = $this->mailPassthru($toAddr, $this->Subject, $body, $header, $params); $addrinfo = static::parseAddresses($toAddr, true, $this->CharSet); $this->doCallback( $result, [[$addrinfo['address'], $addrinfo['name']]], $this->cc, $this->bcc, $this->Subject, $body, $this->From, [] ); } } else { $result = $this->mailPassthru($to, $this->Subject, $body, $header, $params); $this->doCallback($result, $this->to, $this->cc, $this->bcc, $this->Subject, $body, $this->From, []); } if (isset($old_from)) { ini_set('sendmail_from', $old_from); } if (!$result) { throw new Exception($this->lang('instantiate'), self::STOP_CRITICAL); } return true; } /** * Get an instance to use for SMTP operations. * Override this function to load your own SMTP implementation, * or set one with setSMTPInstance. * * @return SMTP */ public function getSMTPInstance() { if (!is_object($this->smtp)) { $this->smtp = new SMTP(); } return $this->smtp; } /** * Provide an instance to use for SMTP operations. * * @return SMTP */ public function setSMTPInstance(SMTP $smtp) { $this->smtp = $smtp; return $this->smtp; } /** * Send mail via SMTP. * Returns false if there is a bad MAIL FROM, RCPT, or DATA input. * * @see PHPMailer::setSMTPInstance() to use a different class. * * @uses \PHPMailer\PHPMailer\SMTP * * @param string $header The message headers * @param string $body The message body * * @throws Exception * * @return bool */ protected function smtpSend($header, $body) { $header = static::stripTrailingWSP($header) . static::$LE . static::$LE; $bad_rcpt = []; if (!$this->smtpConnect($this->SMTPOptions)) { throw new Exception($this->lang('smtp_connect_failed'), self::STOP_CRITICAL); } //Sender already validated in preSend() if ('' === $this->Sender) { $smtp_from = $this->From; } else { $smtp_from = $this->Sender; } if (!$this->smtp->mail($smtp_from)) { $this->setError($this->lang('from_failed') . $smtp_from . ' : ' . implode(',', $this->smtp->getError())); throw new Exception($this->ErrorInfo, self::STOP_CRITICAL); } $callbacks = []; //Attempt to send to all recipients foreach ([$this->to, $this->cc, $this->bcc] as $togroup) { foreach ($togroup as $to) { if (!$this->smtp->recipient($to[0], $this->dsn)) { $error = $this->smtp->getError(); $bad_rcpt[] = ['to' => $to[0], 'error' => $error['detail']]; $isSent = false; } else { $isSent = true; } $callbacks[] = ['issent' => $isSent, 'to' => $to[0], 'name' => $to[1]]; } } //Only send the DATA command if we have viable recipients if ((count($this->all_recipients) > count($bad_rcpt)) && !$this->smtp->data($header . $body)) { throw new Exception($this->lang('data_not_accepted'), self::STOP_CRITICAL); } $smtp_transaction_id = $this->smtp->getLastTransactionID(); if ($this->SMTPKeepAlive) { $this->smtp->reset(); } else { $this->smtp->quit(); $this->smtp->close(); } foreach ($callbacks as $cb) { $this->doCallback( $cb['issent'], [[$cb['to'], $cb['name']]], [], [], $this->Subject, $body, $this->From, ['smtp_transaction_id' => $smtp_transaction_id] ); } //Create error message for any bad addresses if (count($bad_rcpt) > 0) { $errstr = ''; foreach ($bad_rcpt as $bad) { $errstr .= $bad['to'] . ': ' . $bad['error']; } throw new Exception($this->lang('recipients_failed') . $errstr, self::STOP_CONTINUE); } return true; } /** * Initiate a connection to an SMTP server. * Returns false if the operation failed. * * @param array $options An array of options compatible with stream_context_create() * * @throws Exception * * @uses \PHPMailer\PHPMailer\SMTP * * @return bool */ public function smtpConnect($options = null) { if (null === $this->smtp) { $this->smtp = $this->getSMTPInstance(); } //If no options are provided, use whatever is set in the instance if (null === $options) { $options = $this->SMTPOptions; } //Already connected? if ($this->smtp->connected()) { return true; } $this->smtp->setTimeout($this->Timeout); $this->smtp->setDebugLevel($this->SMTPDebug); $this->smtp->setDebugOutput($this->Debugoutput); $this->smtp->setVerp($this->do_verp); if ($this->Host === null) { $this->Host = 'localhost'; } $hosts = explode(';', $this->Host); $lastexception = null; foreach ($hosts as $hostentry) { $hostinfo = []; if ( !preg_match( '/^(?:(ssl|tls):\/\/)?(.+?)(?::(\d+))?$/', trim($hostentry), $hostinfo ) ) { $this->edebug($this->lang('invalid_hostentry') . ' ' . trim($hostentry)); //Not a valid host entry continue; } //$hostinfo[1]: optional ssl or tls prefix //$hostinfo[2]: the hostname //$hostinfo[3]: optional port number //The host string prefix can temporarily override the current setting for SMTPSecure //If it's not specified, the default value is used //Check the host name is a valid name or IP address before trying to use it if (!static::isValidHost($hostinfo[2])) { $this->edebug($this->lang('invalid_host') . ' ' . $hostinfo[2]); continue; } $prefix = ''; $secure = $this->SMTPSecure; $tls = (static::ENCRYPTION_STARTTLS === $this->SMTPSecure); if ('ssl' === $hostinfo[1] || ('' === $hostinfo[1] && static::ENCRYPTION_SMTPS === $this->SMTPSecure)) { $prefix = 'ssl://'; $tls = false; //Can't have SSL and TLS at the same time $secure = static::ENCRYPTION_SMTPS; } elseif ('tls' === $hostinfo[1]) { $tls = true; //TLS doesn't use a prefix $secure = static::ENCRYPTION_STARTTLS; } //Do we need the OpenSSL extension? $sslext = defined('OPENSSL_ALGO_SHA256'); if (static::ENCRYPTION_STARTTLS === $secure || static::ENCRYPTION_SMTPS === $secure) { //Check for an OpenSSL constant rather than using extension_loaded, which is sometimes disabled if (!$sslext) { throw new Exception($this->lang('extension_missing') . 'openssl', self::STOP_CRITICAL); } } $host = $hostinfo[2]; $port = $this->Port; if ( array_key_exists(3, $hostinfo) && is_numeric($hostinfo[3]) && $hostinfo[3] > 0 && $hostinfo[3] < 65536 ) { $port = (int) $hostinfo[3]; } if ($this->smtp->connect($prefix . $host, $port, $this->Timeout, $options)) { try { if ($this->Helo) { $hello = $this->Helo; } else { $hello = $this->serverHostname(); } $this->smtp->hello($hello); //Automatically enable TLS encryption if: //* it's not disabled //* we are not connecting to localhost //* we have openssl extension //* we are not already using SSL //* the server offers STARTTLS if ( $this->SMTPAutoTLS && $this->Host !== 'localhost' && $sslext && $secure !== 'ssl' && $this->smtp->getServerExt('STARTTLS') ) { $tls = true; } if ($tls) { if (!$this->smtp->startTLS()) { $message = $this->getSmtpErrorMessage('connect_host'); throw new Exception($message); } //We must resend EHLO after TLS negotiation $this->smtp->hello($hello); } if ( $this->SMTPAuth && !$this->smtp->authenticate( $this->Username, $this->Password, $this->AuthType, $this->oauth ) ) { throw new Exception($this->lang('authenticate')); } return true; } catch (Exception $exc) { $lastexception = $exc; $this->edebug($exc->getMessage()); //We must have connected, but then failed TLS or Auth, so close connection nicely $this->smtp->quit(); } } } //If we get here, all connection attempts have failed, so close connection hard $this->smtp->close(); //As we've caught all exceptions, just report whatever the last one was if ($this->exceptions && null !== $lastexception) { throw $lastexception; } if ($this->exceptions) { // no exception was thrown, likely $this->smtp->connect() failed $message = $this->getSmtpErrorMessage('connect_host'); throw new Exception($message); } return false; } /** * Close the active SMTP session if one exists. */ public function smtpClose() { if ((null !== $this->smtp) && $this->smtp->connected()) { $this->smtp->quit(); $this->smtp->close(); } } /** * Set the language for error messages. * The default language is English. * * @param string $langcode ISO 639-1 2-character language code (e.g. French is "fr") * Optionally, the language code can be enhanced with a 4-character * script annotation and/or a 2-character country annotation. * @param string $lang_path Path to the language file directory, with trailing separator (slash) * Do not set this from user input! * * @return bool Returns true if the requested language was loaded, false otherwise. */ public function setLanguage($langcode = 'en', $lang_path = '') { //Backwards compatibility for renamed language codes $renamed_langcodes = [ 'br' => 'pt_br', 'cz' => 'cs', 'dk' => 'da', 'no' => 'nb', 'se' => 'sv', 'rs' => 'sr', 'tg' => 'tl', 'am' => 'hy', ]; if (array_key_exists($langcode, $renamed_langcodes)) { $langcode = $renamed_langcodes[$langcode]; } //Define full set of translatable strings in English $PHPMAILER_LANG = [ 'authenticate' => 'SMTP Error: Could not authenticate.', 'buggy_php' => 'Your version of PHP is affected by a bug that may result in corrupted messages.' . ' To fix it, switch to sending using SMTP, disable the mail.add_x_header option in' . ' your php.ini, switch to MacOS or Linux, or upgrade your PHP to version 7.0.17+ or 7.1.3+.', 'connect_host' => 'SMTP Error: Could not connect to SMTP host.', 'data_not_accepted' => 'SMTP Error: data not accepted.', 'empty_message' => 'Message body empty', 'encoding' => 'Unknown encoding: ', 'execute' => 'Could not execute: ', 'extension_missing' => 'Extension missing: ', 'file_access' => 'Could not access file: ', 'file_open' => 'File Error: Could not open file: ', 'from_failed' => 'The following From address failed: ', 'instantiate' => 'Could not instantiate mail function.', 'invalid_address' => 'Invalid address: ', 'invalid_header' => 'Invalid header name or value', 'invalid_hostentry' => 'Invalid hostentry: ', 'invalid_host' => 'Invalid host: ', 'mailer_not_supported' => ' mailer is not supported.', 'provide_address' => 'You must provide at least one recipient email address.', 'recipients_failed' => 'SMTP Error: The following recipients failed: ', 'signing' => 'Signing Error: ', 'smtp_code' => 'SMTP code: ', 'smtp_code_ex' => 'Additional SMTP info: ', 'smtp_connect_failed' => 'SMTP connect() failed.', 'smtp_detail' => 'Detail: ', 'smtp_error' => 'SMTP server error: ', 'variable_set' => 'Cannot set or reset variable: ', ]; if (empty($lang_path)) { //Calculate an absolute path so it can work if CWD is not here $lang_path = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'language' . DIRECTORY_SEPARATOR; } //Validate $langcode $foundlang = true; $langcode = strtolower($langcode); if ( !preg_match('/^(?P[a-z]{2})(?P

================================================ FILE: logos.php ================================================ Subscription Logos
================================================ FILE: logout.php ================================================ prepare('SELECT * FROM oauth_settings WHERE id = 1'); $result = $stmt->execute(); $oidcSettings = $result->fetchArray(SQLITE3_ASSOC); $logoutUrl = $oidcSettings['logout_url'] ?? ''; } // get token from cookie to remove from DB if (isset($_SESSION['token'])) { $token = $_SESSION['token']; $sql = "DELETE FROM login_tokens WHERE token = :token AND user_id = :userId"; $stmt = $db->prepare($sql); $stmt->bindParam(':token', $token, SQLITE3_TEXT); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $stmt->execute(); } $_SESSION = array(); session_destroy(); $cookieExpire = time() - 3600; setcookie('wallos_login', '', $cookieExpire); $db->close(); if ($logoutOIDC && !empty($logoutUrl)) { $returnTo = urlencode($oidcSettings['redirect_url'] ?? ''); header("Location: $logoutUrl?post_logout_redirect_uri=$returnTo"); exit(); } ?> exec('CREATE TABLE IF NOT EXISTS migrations ( id INTEGER PRIMARY KEY, migration TEXT NOT NULL, migrated_at DATETIME DEFAULT CURRENT_TIMESTAMP )'); ================================================ FILE: migrations/000002.php ================================================ query("SELECT * FROM pragma_table_info('payment_methods') where name='enabled'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE payment_methods ADD COLUMN enabled BOOLEAN DEFAULT 1'); $db->exec('UPDATE payment_methods SET enabled = 1'); } ================================================ FILE: migrations/000003.php ================================================ query("SELECT * FROM pragma_table_info('notifications') where name='from_email'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE notifications ADD COLUMN from_email VARCHAR(255);'); } ================================================ FILE: migrations/000004.php ================================================ query("SELECT * FROM pragma_table_info('subscriptions') where name='url'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE subscriptions ADD COLUMN url VARCHAR(255);'); } ?> ================================================ FILE: migrations/000005.php ================================================ query("SELECT * FROM pragma_table_info('user') where name='language'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE user ADD COLUMN language TEXT DEFAULT "en"'); $db->exec('UPDATE user SET language = "en"'); } ================================================ FILE: migrations/000006.php ================================================ query("SELECT * FROM pragma_table_info('fixer') where name='provider'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE fixer ADD COLUMN provider INT DEFAULT 0'); $db->exec('UPDATE fixer SET provider = 0'); } ================================================ FILE: migrations/000007.php ================================================ exec('CREATE TABLE IF NOT EXISTS settings ( dark_theme BOOLEAN DEFAULT 0, monthly_price BOOLEAN DEFAULT 0, convert_currency BOOLEAN DEFAULT 0, remove_background BOOLEAN DEFAULT 0 )'); $db->exec('INSERT INTO settings (dark_theme, monthly_price, convert_currency, remove_background) VALUES (0, 0, 0, 0)'); ================================================ FILE: migrations/000008.php ================================================ query("SELECT * FROM pragma_table_info('subscriptions') WHERE name='inactive'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE subscriptions ADD COLUMN inactive BOOLEAN DEFAULT false'); $db->exec('UPDATE subscriptions SET inactive = false'); } ================================================ FILE: migrations/000009.php ================================================ query("SELECT * FROM pragma_table_info('household') where name='email'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE household ADD COLUMN email TEXT DEFAULT ""'); } ================================================ FILE: migrations/000010.php ================================================ query("SELECT * FROM pragma_table_info('categories') WHERE name='order'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE categories ADD COLUMN `order` INTEGER DEFAULT 0'); $db->exec('UPDATE categories SET `order` = id'); } ?> ================================================ FILE: migrations/000011.php ================================================ query("SELECT * FROM pragma_table_info('payment_methods') WHERE name='order'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE payment_methods ADD COLUMN `order` INTEGER DEFAULT 0'); $db->exec('UPDATE payment_methods SET `order` = id'); } ?> ================================================ FILE: migrations/000012.php ================================================ query("SELECT * FROM pragma_table_info('notifications') WHERE name='encryption'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE notifications ADD COLUMN `encryption` TEXT DEFAULT "tls"'); $db->exec('UPDATE notifications SET `encryption` = "tls"'); } ?> ================================================ FILE: migrations/000013.php ================================================ prepare($sql); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); if ($row) { $avatar = $row['avatar']; if (strlen($avatar) < 2) { $avatarFullPath = "images/avatars/" . $avatar . ".svg"; $sql = "UPDATE user SET avatar = :avatarFullPath"; $stmt = $db->prepare($sql); $stmt->bindValue(':avatarFullPath', $avatarFullPath, SQLITE3_TEXT); $stmt->execute(); } } ?> ================================================ FILE: migrations/000014.php ================================================ query("SELECT * FROM pragma_table_info('settings') where name='color_theme'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec("ALTER TABLE settings ADD COLUMN color_theme TEXT DEFAULT 'blue'"); $db->exec('UPDATE settings SET `color_theme` = "blue"'); } // This migrations adds custom_colors table to the database, so the user can set custom accent colors to the application $customColorsTableQuery = $db->query("SELECT * FROM sqlite_master WHERE type='table' AND name='custom_colors'"); $customColorsTableRequired = $customColorsTableQuery->fetchArray(SQLITE3_ASSOC) === false; if ($customColorsTableRequired) { $db->exec("CREATE TABLE custom_colors ( main_color TEXT NOT NULL, accent_color TEXT NOT NULL, hover_color TEXT NOT NULL )"); } ================================================ FILE: migrations/000015.php ================================================ query("SELECT * FROM pragma_table_info('settings') where name='hide_disabled'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec("ALTER TABLE settings ADD COLUMN hide_disabled BOOLEAN DEFAULT 0"); $db->exec('UPDATE settings SET `hide_disabled` = 0'); } ================================================ FILE: migrations/000016.php ================================================ exec('CREATE TABLE IF NOT EXISTS telegram_notifications ( enabled BOOLEAN DEFAULT 0, bot_token TEXT DEFAULT "", chat_id TEXT DEFAULT "" )'); $db->exec('CREATE TABLE IF NOT EXISTS webhook_notifications ( enabled BOOLEAN DEFAULT 0, headers TEXT DEFAULT "", url TEXT DEFAULT "", request_method TEXT DEFAULT "POST", payload TEXT DEFAULT "", iterator TEXT DEFAULT "" )'); $db->exec('CREATE TABLE IF NOT EXISTS gotify_notifications ( enabled BOOLEAN DEFAULT 0, url TEXT DEFAULT "", token TEXT DEFAULT "" )'); $db->exec('CREATE TABLE IF NOT EXISTS email_notifications ( enabled BOOLEAN DEFAULT 0, smtp_address TEXT DEFAULT "", smtp_port INTEGER DEFAULT 587, smtp_username TEXT DEFAULT "", smtp_password TEXT DEFAULT "", from_email TEXT DEFAULT "", encryption TEXT DEFAULT "tls" )'); $db->exec('CREATE TABLE IF NOT EXISTS notification_settings ( days INTEGER DEFAULT 0 )'); // Check if old email notifications table has data and migrate it $result = $db->query('SELECT COUNT(*) as count FROM notifications'); $row = $result->fetchArray(SQLITE3_ASSOC); if ($row['count'] > 0) { // Copy data from notifications to email_notifications $db->exec('INSERT INTO email_notifications (enabled, smtp_address, smtp_port, smtp_username, smtp_password, from_email, encryption) SELECT enabled, smtp_address, smtp_port, smtp_username, smtp_password, from_email, encryption FROM notifications'); // Copy data from notifications to notification_settings $db->exec('INSERT INTO notification_settings (days) SELECT days FROM notifications'); if ($db->changes() > 0) { $db->exec('DROP TABLE IF EXISTS notifications'); } } else { $db->exec('DROP TABLE IF EXISTS notifications'); } ?> ================================================ FILE: migrations/000017.php ================================================ exec('CREATE TABLE IF NOT EXISTS pushover_notifications ( enabled BOOLEAN DEFAULT 0, user_key TEXT DEFAULT "", token TEXT DEFAULT "" )'); $db->exec('CREATE TABLE IF NOT EXISTS discord_notifications ( enabled BOOLEAN DEFAULT 0, webhook_url TEXT DEFAULT "", bot_username TEXT DEFAULT "", bot_avatar_url TEXT DEFAULT "" )'); ================================================ FILE: migrations/000018.php ================================================ query("SELECT * FROM pragma_table_info('users') where name='budget'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE user ADD COLUMN budget INTEGER DEFAULT 0'); } ?> ================================================ FILE: migrations/000019.php ================================================ query("SELECT * FROM pragma_table_info('subscriptions') where name='notify_days_before'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE subscriptions ADD COLUMN notify_days_before INTEGER DEFAULT 0'); } ?> ================================================ FILE: migrations/000020.php ================================================ query("SELECT * FROM pragma_table_info('$table') WHERE name='user_id'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec("ALTER TABLE $table ADD COLUMN user_id INTEGER DEFAULT 1"); } } $db->exec('CREATE TABLE IF NOT EXISTS admin ( id INTEGER PRIMARY KEY, registrations_open BOOLEAN DEFAULT 0, max_users INTEGER DEFAULT 0, require_email_verification BOOLEAN DEFAULT 0, server_url TEXT, smtp_address TEXT, smtp_port INTEGER DEFAULT 587, smtp_username TEXT, smtp_password TEXT, from_email TEXT, encryption TEXT DEFAULT "tls" )'); $db->exec('INSERT INTO admin (id, registrations_open, require_email_verification, server_url, max_users, smtp_address, smtp_port, smtp_username, smtp_password, from_email, encryption) VALUES (1, 0, 0, "", 0, "", 587, "", "", "", "tls")'); $updateQuery = "UPDATE payment_methods SET icon = 'images/uploads/icons/' || icon WHERE id < 32 AND icon NOT LIKE '%/images/uploads/icons%'"; $db->exec($updateQuery); $db->exec('CREATE TABLE IF NOT EXISTS email_verification ( id INTEGER PRIMARY KEY, user_id INTEGER, email TEXT, token TEXT, email_sent BOOLEAN DEFAULT 0)'); $db->exec('CREATE TABLE IF NOT EXISTS password_resets ( id INTEGER PRIMARY KEY, user_id INTEGER, email TEXT, token TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, email_sent BOOLEAN DEFAULT 0)'); ?> ================================================ FILE: migrations/000021.php ================================================ exec('CREATE TABLE IF NOT EXISTS ntfy_notifications ( enabled BOOLEAN DEFAULT 0, host TEXT DEFAULT "", topic TEXT DEFAULT "", headers TEXT DEFAULT "", user_id INTEGER, FOREIGN KEY (user_id) REFERENCES user(id) )'); ================================================ FILE: migrations/000022.php ================================================ query("SELECT * FROM pragma_table_info('admin') where name='login_disabled'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE admin ADD COLUMN login_disabled BOOLEAN DEFAULT 0'); } ?> ================================================ FILE: migrations/000023.php ================================================ exec('CREATE TABLE IF NOT EXISTS custom_css_style ( css TEXT DEFAULT "", user_id INTEGER, FOREIGN KEY (user_id) REFERENCES user(id) )'); ================================================ FILE: migrations/000024.php ================================================ query("SELECT * FROM pragma_table_info('subscriptions') where name='cancellation_date'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE subscriptions ADD COLUMN cancellation_date DATE;'); } ================================================ FILE: migrations/000025.php ================================================ query("SELECT * FROM pragma_table_info('settings') where name='disabled_to_bottom'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE settings ADD COLUMN disabled_to_bottom BOOLEAN DEFAULT 0'); } $columnQuery = $db->query("SELECT * FROM pragma_table_info('admin') where name='latest_version'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec("ALTER TABLE admin ADD COLUMN latest_version TEXT DEFAULT 'v2.21.1'"); } $columnQuery = $db->query("SELECT * FROM pragma_table_info('admin') where name='update_notification'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE admin ADD COLUMN update_notification BOOLEAN DEFAULT 0'); } ================================================ FILE: migrations/000026.php ================================================ query("SELECT * FROM pragma_table_info('email_notifications') where name='other_emails'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE email_notifications ADD COLUMN other_emails TEXT DEFAULT "";'); } $columnQuery = $db->query("SELECT * FROM pragma_table_info('settings') where name='show_original_price'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE settings ADD COLUMN show_original_price BOOLEAN DEFAULT 0'); } ================================================ FILE: migrations/000027.php ================================================ query("SELECT * FROM pragma_table_info('user') where name='totp_enabled'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE user ADD COLUMN totp_enabled BOOLEAN DEFAULT 0'); } $db->exec('CREATE TABLE IF NOT EXISTS totp ( user_id INTEGER NOT NULL, totp_secret TEXT NOT NULL, backup_codes TEXT NOT NULL, last_totp_used INTEGER DEFAULT 0, FOREIGN KEY(user_id) REFERENCES user(id) )'); ================================================ FILE: migrations/000028.php ================================================ query("SELECT * FROM pragma_table_info('settings') where name='mobile_nav'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE settings ADD COLUMN mobile_nav BOOLEAN DEFAULT 0'); } ================================================ FILE: migrations/000029.php ================================================ query("SELECT * FROM pragma_table_info('user') where name='api_key'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE user ADD COLUMN api_key TEXT'); } $users = $db->query('SELECT * FROM user'); while ($user = $users->fetchArray(SQLITE3_ASSOC)) { if (empty($user['api_key'])) { $apiKey = bin2hex(random_bytes(32)); $db->exec('UPDATE user SET api_key = "' . $apiKey . '" WHERE id = ' . $user['id']); } } ================================================ FILE: migrations/000030.php ================================================ query("SELECT * FROM pragma_table_info('webhook_notifications') where name='ignore_ssl'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE webhook_notifications ADD COLUMN ignore_ssl INTEGER DEFAULT 0'); } // Add the ignore_ssl column to the ntfy_notifications table $columnQuery = $db->query("SELECT * FROM pragma_table_info('ntfy_notifications') where name='ignore_ssl'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE ntfy_notifications ADD COLUMN ignore_ssl INTEGER DEFAULT 0'); } // Add the ignore_ssl column to the gotify_notifications table $columnQuery = $db->query("SELECT * FROM pragma_table_info('gotify_notifications') where name='ignore_ssl'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE gotify_notifications ADD COLUMN ignore_ssl INTEGER DEFAULT 0'); } ================================================ FILE: migrations/000031.php ================================================ query("SELECT * FROM pragma_table_info('subscriptions') where name='replacement_subscription_id'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE subscriptions ADD COLUMN replacement_subscription_id INTEGER DEFAULT NULL'); } ?> ================================================ FILE: migrations/000032.php ================================================ query("SELECT name FROM sqlite_master WHERE type='table' AND name='total_yearly_cost'"); $tableRequired = $tableQuery->fetchArray(SQLITE3_ASSOC) === false; if ($tableRequired) { $db->exec('CREATE TABLE total_yearly_cost ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, date INTEGER NOT NULL, cost REAL NOT NULL, currency TEXT NOT NULL )'); } $columnQuery = $db->query("PRAGMA table_info(subscriptions)"); $columns = []; while ($column = $columnQuery->fetchArray(SQLITE3_ASSOC)) { $columns[] = $column['name']; } if (!in_array('start_date', $columns)) { $db->exec('ALTER TABLE subscriptions ADD COLUMN start_date INTEGER DEFAULT NULL'); } if (!in_array('auto_renew', $columns)) { $db->exec('ALTER TABLE subscriptions ADD COLUMN auto_renew INTEGER DEFAULT 1'); } ?> ================================================ FILE: migrations/000033.php ================================================ query("SELECT * FROM pragma_table_info('settings') where name='show_subscription_progress'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec("ALTER TABLE settings ADD COLUMN show_subscription_progress BOOLEAN DEFAULT 0"); $db->exec('UPDATE settings SET `show_subscription_progress` = 0'); } ================================================ FILE: migrations/000034.php ================================================ exec('UPDATE subscriptions SET `notify_days_before` = -1 WHERE `notify_days_before` = 0'); ================================================ FILE: migrations/000035.php ================================================ exec('DELETE FROM total_yearly_cost'); ================================================ FILE: migrations/000036.php ================================================ query("SELECT * FROM pragma_table_info('webhook_notifications') where name='cancelation_payload'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec("ALTER TABLE webhook_notifications ADD COLUMN cancelation_payload TEXT DEFAULT ''"); } $columnQuery = $db->query("SELECT * FROM pragma_table_info('webhook_notifications') where name='iterator'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) !== false; if ($columnRequired) { $db->exec("ALTER TABLE webhook_notifications DROP COLUMN iterator"); } ================================================ FILE: migrations/000037.php ================================================ query("SELECT * FROM pragma_table_info('user') where name='firstname'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE user ADD COLUMN firstname TEXT DEFAULT ""'); } $columnQuery = $db->query("SELECT * FROM pragma_table_info('user') where name='lastname'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE user ADD COLUMN lastname TEXT DEFAULT ""'); } ================================================ FILE: migrations/000038.php ================================================ query("SELECT * FROM pragma_table_info('admin') where name='oidc_oauth_enabled'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE admin ADD COLUMN oidc_oauth_enabled INTEGER DEFAULT 0'); } $columnQuery = $db->query("SELECT * FROM pragma_table_info('user') where name='oidc_sub'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec('ALTER TABLE user ADD COLUMN oidc_sub TEXT'); } $tableQuery = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='oauth_settings'"); $tableExists = $tableQuery->fetchArray(SQLITE3_ASSOC); if (!$tableExists) { $db->exec("CREATE TABLE oauth_settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, client_id TEXT NOT NULL, client_secret TEXT NOT NULL, authorization_url TEXT NOT NULL, token_url TEXT NOT NULL, user_info_url TEXT NOT NULL, redirect_url TEXT NOT NULL, logout_url TEXT, user_identifier_field TEXT NOT NULL DEFAULT 'sub', scopes TEXT NOT NULL DEFAULT 'openid email profile', auth_style TEXT DEFAULT 'auto', auto_create_user INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )"); } ================================================ FILE: migrations/000039.php ================================================ query("SELECT * FROM pragma_table_info('oauth_settings') WHERE name='password_login_disabled'"); $columnRequired = $columnQuery->fetchArray(SQLITE3_ASSOC) === false; if ($columnRequired) { $db->exec("ALTER TABLE oauth_settings ADD COLUMN password_login_disabled INTEGER DEFAULT 0"); } // Check if ai_settings table exists, if not, create it $tableQuery = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='ai_settings'"); $tableExists = $tableQuery->fetchArray(SQLITE3_ASSOC); if ($tableExists === false) { $db->exec(" CREATE TABLE ai_settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, enabled BOOLEAN NOT NULL DEFAULT 0, api_key TEXT, model TEXT NOT NULL, url TEXT, run_schedule TEXT NOT NULL DEFAULT 'manual', last_successful_run DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); "); } // Check if ai_recommendations table exists, if not, create it $tableQuery = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='ai_recommendations'"); $tableExists = $tableQuery->fetchArray(SQLITE3_ASSOC); if ($tableExists === false) { $db->exec(" CREATE TABLE ai_recommendations ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL, savings TEXT NOT NULL DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); "); } ================================================ FILE: migrations/000040.php ================================================ query("SELECT name FROM sqlite_master WHERE type='table' AND name='pushplus_notifications'"); $tableExists = $tableQuery->fetchArray(SQLITE3_ASSOC); if ($tableExists === false) { $db->exec(" CREATE TABLE pushplus_notifications ( enabled INTEGER NOT NULL DEFAULT 0, token TEXT, user_id INTEGER ); "); } ================================================ FILE: migrations/000041.php ================================================ query("SELECT name FROM sqlite_master WHERE type='table' AND name='mattermost_notifications'"); $tableExists = $tableQuery->fetchArray(SQLITE3_ASSOC); if ($tableExists === false) { $db->exec(" CREATE TABLE mattermost_notifications ( enabled INTEGER NOT NULL DEFAULT 0, user_id INTEGER, webhook_url TEXT DEFAULT '', bot_username TEXT DEFAULT '', bot_icon_emoji TEXT DEFAULT '' ); "); } ================================================ FILE: migrations/000042.php ================================================ query("SELECT name FROM sqlite_master WHERE type='table' AND name='serverchan_notifications'"); $tableExists = $tableQuery->fetchArray(SQLITE3_ASSOC); if (!$tableExists) { $db->exec('CREATE TABLE serverchan_notifications ( enabled BOOLEAN DEFAULT 0, sendkey TEXT DEFAULT "", user_id INTEGER, FOREIGN KEY (user_id) REFERENCES user(id) )'); } ?> ================================================ FILE: migrations/000043.php ================================================ query("PRAGMA table_info(admin)"); $columnExists = false; while ($row = $query->fetchArray(SQLITE3_ASSOC)) { if ($row['name'] === 'local_webhook_notifications_allowlist') { $columnExists = true; break; } } if (!$columnExists) { // Add the column with an empty string as the default $db->exec("ALTER TABLE admin ADD COLUMN local_webhook_notifications_allowlist TEXT DEFAULT ''"); } ?> ================================================ FILE: migrations/000044.php ================================================ querySingle("SELECT name FROM sqlite_master WHERE type='table' AND name='uploaded_avatars'"); if (!$tableCheck) { // Create the uploaded_avatars table $db->exec(" CREATE TABLE IF NOT EXISTS uploaded_avatars ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, path TEXT NOT NULL ) "); // Check if solo user or multiple users $userCount = $db->querySingle("SELECT COUNT(*) FROM user"); if ($userCount === 1) { // SOLO USER MIGRATION $userId = $db->querySingle("SELECT id FROM user LIMIT 1"); $avatarDir = '../../images/uploads/logos/avatars'; if (is_dir($avatarDir)) { $files = scandir($avatarDir); $stmt = $db->prepare("INSERT INTO uploaded_avatars (user_id, path) VALUES (:user_id, :path)"); foreach ($files as $file) { // Skip directories and hidden files (like .gitkeep or .htaccess) if ($file !== '.' && $file !== '..' && is_file($avatarDir . '/' . $file)) { // Store the path exactly as the app expects it in the database $relativePath = 'images/uploads/logos/avatars/' . $file; $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->bindValue(':path', $relativePath, SQLITE3_TEXT); $stmt->execute(); } } } } elseif ($userCount > 1) { // MULTI-USER MIGRATION $results = $db->query("SELECT id, avatar FROM user"); $stmt = $db->prepare("INSERT INTO uploaded_avatars (user_id, path) VALUES (:user_id, :path)"); while ($row = $results->fetchArray(SQLITE3_ASSOC)) { $userId = $row['id']; $avatarPath = $row['avatar']; if (strpos($avatarPath, 'images/uploads/logos/avatars/') === 0) { $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->bindValue(':path', $avatarPath, SQLITE3_TEXT); $stmt->execute(); } } } } ?> ================================================ FILE: nginx.conf ================================================ user nginx; worker_processes 1; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; sendfile on; keepalive_timeout 65; server { listen [::]:80 ipv6only=off; server_name localhost; location / { root /var/www/html; index index.php; } location ~ \.php$ { root /var/www/html; fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } location ~ \.db$ { deny all; return 403; } location ~* images/uploads/logos/.*\.php$ { deny all; return 403; } location ~* \.tmp/.*\.php$ { deny all; return 403; } } include /etc/nginx/conf.d/*.conf; } ================================================ FILE: nginx.default.conf ================================================ server { listen [::]:80 ipv6only=off; server_name your_domain_or_ip; # Change to your domain or IP root /var/www/html; # Change to your web root directory location / { try_files $uri $uri/ /index.php?$args; } location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/var/run/php-fpm.sock; # Adjust the path if necessary fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } # Additional configuration if needed } ================================================ FILE: passwordreset.php ================================================ close(); header("Location: ."); exit(); } $requestMode = true; $resetMode = false; $theme = "light"; if (isset($_COOKIE['theme'])) { $theme = $_COOKIE['theme']; } $colorTheme = "blue"; if (isset($_COOKIE['colorTheme'])) { $colorTheme = $_COOKIE['colorTheme']; } $settings = $db->querySingle("SELECT * FROM admin", true); if ($settings['smtp_address'] == "" || $settings['server_url'] == "") { header("Location: ."); exit(); } else { $resetPasswordEnabled = true; } $hasSuccessMessage = false; $hasErrorMessage = false; $passwordsMismatch = false; $hideForm = false; if (isset($_POST['email']) && $_POST['email'] != "" && isset($_GET['submit']) && $_GET['submit'] && !(isset($_GET['token'])) && !(isset($_POST['token']))) { $requestMode = true; $resetMode = false; $email = $_POST['email']; $stmt = $db->prepare("SELECT * FROM user WHERE email = :email"); $stmt->bindValue(':email', $email, SQLITE3_TEXT); $user = $stmt->execute()->fetchArray(SQLITE3_ASSOC); if ($user) { $stmt = $db->prepare("DELETE FROM password_resets WHERE email = :email"); $stmt->bindValue(':email', $email, SQLITE3_TEXT); $stmt->execute(); $token = bin2hex(random_bytes(32)); $stmt = $db->prepare("INSERT INTO password_resets (user_id, email, token) VALUES (:user_id, :email, :token)"); $stmt->bindValue(':user_id', $user['id'], SQLITE3_INTEGER); $stmt->bindValue(':email', $email, SQLITE3_TEXT); $stmt->bindValue(':token', $token, SQLITE3_TEXT); $stmt->execute(); } $hasSuccessMessage = true; } if (isset($_GET['token']) && $_GET['token'] != "" && isset($_GET['email']) && $_GET['email'] != "") { $requestMode = false; $resetMode = true; $token = $_GET['token']; $email = $_GET['email']; $matchCount = "SELECT COUNT(*) FROM password_resets WHERE token = :token AND email = :email AND created_at > datetime('now', '-1 hour')"; $stmt = $db->prepare($matchCount); $stmt->bindValue(':token', $token, SQLITE3_TEXT); $stmt->bindValue(':email', $email, SQLITE3_TEXT); $count = $stmt->execute()->fetchArray(SQLITE3_NUM); if ($count[0] == 0) { $hasErrorMessage = true; $hideForm = true; } } if (isset($_POST['password']) && $_POST['password'] != "" && isset($_POST['confirm_password']) && $_POST['confirm_password'] != "" && isset($_GET['submit']) && $_GET['submit']) { $requestMode = false; $resetMode = true; $password = $_POST['password']; $confirmPassword = $_POST['confirm_password']; $token = $_POST['token']; $email = $_POST['email']; $resetQuery = "SELECT * FROM password_resets WHERE token = :token AND email = :email AND created_at > datetime('now', '-1 hour')"; $stmt = $db->prepare($resetQuery); $stmt->bindValue(':token', $token, SQLITE3_TEXT); $stmt->bindValue(':email', $email, SQLITE3_TEXT); $reset = $stmt->execute()->fetchArray(SQLITE3_ASSOC); if ($reset) { $stmt = $db->prepare("SELECT * FROM user WHERE email = :email"); $stmt->bindValue(':email', $reset['email'], SQLITE3_TEXT); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); if ($password == $confirmPassword) { $passwordHash = password_hash($password, PASSWORD_DEFAULT); $stmt = $db->prepare("UPDATE user SET password = :password WHERE id = :id"); $stmt->bindValue(':password', $passwordHash, SQLITE3_TEXT); $stmt->bindValue(':id', $user['id'], SQLITE3_INTEGER); $stmt->execute(); $stmt = $db->prepare("DELETE FROM password_resets WHERE token = :token"); $stmt->bindValue(':token', $token, SQLITE3_TEXT); $stmt->execute(); $hasSuccessMessage = true; $hideForm = true; } else { $hasErrorMessage = true; $passwordsMismatch = true; } } else { $hasSuccessMessage = false; $hasErrorMessage = true; } } ?> " /> Wallos - Subscription Tracker > > > > >

================================================ FILE: profile.php ================================================ prepare("SELECT path FROM uploaded_avatars WHERE user_id = :user_id"); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $uploadedAvatars[] = $row['path']; } ?>
prepare($sql); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $loginDisabled = $row['login_disabled']; $showTotpSection = true; if ($loginDisabled && !$userData['totp_enabled']) { $showTotpSection = false; } if ($showTotpSection) { ?>
================================================ FILE: registration.php ================================================ prepare('SELECT COUNT(*) as userCount FROM user'); $result = $stmt->execute(); $userCountResult = $result->fetchArray(SQLITE3_ASSOC); $userCount = $userCountResult['userCount']; if ($userCount > 0) { $stmt = $db->prepare('SELECT * FROM admin'); $result = $stmt->execute(); $settings = $result->fetchArray(SQLITE3_ASSOC); if ($settings['registrations_open'] == 0) { header("Location: login.php"); exit(); } if ($settings['max_users'] != 0) { if ($userCount >= $settings['max_users']) { header("Location: login.php"); exit(); } } } $theme = "light"; $updateThemeSettings = false; if (isset($_COOKIE['theme'])) { $theme = $_COOKIE['theme']; } else { $updateThemeSettings = true; } $colorTheme = "blue"; if (isset($_COOKIE['colorTheme'])) { $colorTheme = $_COOKIE['colorTheme']; } $currencies = [ ['id' => 1, 'name' => 'Euro', 'symbol' => '€', 'code' => 'EUR'], ['id' => 2, 'name' => 'US Dollar', 'symbol' => '$', 'code' => 'USD'], ['id' => 3, 'name' => 'Japanese Yen', 'symbol' => '¥', 'code' => 'JPY'], ['id' => 4, 'name' => 'Bulgarian Lev', 'symbol' => 'лв', 'code' => 'BGN'], ['id' => 5, 'name' => 'Czech Republic Koruna', 'symbol' => 'Kč', 'code' => 'CZK'], ['id' => 6, 'name' => 'Danish Krone', 'symbol' => 'kr', 'code' => 'DKK'], ['id' => 7, 'name' => 'British Pound Sterling', 'symbol' => '£', 'code' => 'GBP'], ['id' => 8, 'name' => 'Hungarian Forint', 'symbol' => 'Ft', 'code' => 'HUF'], ['id' => 9, 'name' => 'Polish Zloty', 'symbol' => 'zł', 'code' => 'PLN'], ['id' => 10, 'name' => 'Romanian Leu', 'symbol' => 'lei', 'code' => 'RON'], ['id' => 11, 'name' => 'Swedish Krona', 'symbol' => 'kr', 'code' => 'SEK'], ['id' => 12, 'name' => 'Swiss Franc', 'symbol' => 'Fr', 'code' => 'CHF'], ['id' => 13, 'name' => 'Icelandic Króna', 'symbol' => 'kr', 'code' => 'ISK'], ['id' => 14, 'name' => 'Norwegian Krone', 'symbol' => 'kr', 'code' => 'NOK'], ['id' => 15, 'name' => 'Russian Ruble', 'symbol' => '₽', 'code' => 'RUB'], ['id' => 16, 'name' => 'Turkish Lira', 'symbol' => '₺', 'code' => 'TRY'], ['id' => 17, 'name' => 'Australian Dollar', 'symbol' => '$', 'code' => 'AUD'], ['id' => 18, 'name' => 'Brazilian Real', 'symbol' => 'R$', 'code' => 'BRL'], ['id' => 19, 'name' => 'Canadian Dollar', 'symbol' => '$', 'code' => 'CAD'], ['id' => 20, 'name' => 'Chinese Yuan', 'symbol' => '¥', 'code' => 'CNY'], ['id' => 21, 'name' => 'Hong Kong Dollar', 'symbol' => 'HK$', 'code' => 'HKD'], ['id' => 22, 'name' => 'Indonesian Rupiah', 'symbol' => 'Rp', 'code' => 'IDR'], ['id' => 23, 'name' => 'Israeli New Sheqel', 'symbol' => '₪', 'code' => 'ILS'], ['id' => 24, 'name' => 'Indian Rupee', 'symbol' => '₹', 'code' => 'INR'], ['id' => 25, 'name' => 'South Korean Won', 'symbol' => '₩', 'code' => 'KRW'], ['id' => 26, 'name' => 'Mexican Peso', 'symbol' => 'Mex$', 'code' => 'MXN'], ['id' => 27, 'name' => 'Malaysian Ringgit', 'symbol' => 'RM', 'code' => 'MYR'], ['id' => 28, 'name' => 'New Zealand Dollar', 'symbol' => 'NZ$', 'code' => 'NZD'], ['id' => 29, 'name' => 'Philippine Peso', 'symbol' => '₱', 'code' => 'PHP'], ['id' => 30, 'name' => 'Singapore Dollar', 'symbol' => 'S$', 'code' => 'SGD'], ['id' => 31, 'name' => 'Thai Baht', 'symbol' => '฿', 'code' => 'THB'], ['id' => 32, 'name' => 'South African Rand', 'symbol' => 'R', 'code' => 'ZAR'], ['id' => 33, 'name' => 'Ukrainian Hryvnia', 'symbol' => '₴', 'code' => 'UAH'], ['id' => 34, 'name' => 'New Taiwan Dollar', 'symbol' => 'NT$', 'code' => 'TWD'], ]; $categories = [ ['id' => 1, 'name' => 'No category'], ['id' => 2, 'name' => 'Entertainment'], ['id' => 3, 'name' => 'Music'], ['id' => 4, 'name' => 'Utilities'], ['id' => 5, 'name' => 'Food & Beverages'], ['id' => 6, 'name' => 'Health & Wellbeing'], ['id' => 7, 'name' => 'Productivity'], ['id' => 8, 'name' => 'Banking'], ['id' => 9, 'name' => 'Transport'], ['id' => 10, 'name' => 'Education'], ['id' => 11, 'name' => 'Insurance'], ['id' => 12, 'name' => 'Gaming'], ['id' => 13, 'name' => 'News & Magazines'], ['id' => 14, 'name' => 'Software'], ['id' => 15, 'name' => 'Technology'], ['id' => 16, 'name' => 'Cloud Services'], ['id' => 17, 'name' => 'Charity & Donations'], ]; $payment_methods = [ ['id' => 1, 'name' => 'PayPal', 'icon' => 'images/uploads/icons/paypal.png'], ['id' => 2, 'name' => 'Credit Card', 'icon' => 'images/uploads/icons/creditcard.png'], ['id' => 3, 'name' => 'Bank Transfer', 'icon' => 'images/uploads/icons/banktransfer.png'], ['id' => 4, 'name' => 'Direct Debit', 'icon' => 'images/uploads/icons/directdebit.png'], ['id' => 5, 'name' => 'Money', 'icon' => 'images/uploads/icons/money.png'], ['id' => 6, 'name' => 'Google Pay', 'icon' => 'images/uploads/icons/googlepay.png'], ['id' => 7, 'name' => 'Samsung Pay', 'icon' => 'images/uploads/icons/samsungpay.png'], ['id' => 8, 'name' => 'Apple Pay', 'icon' => 'images/uploads/icons/applepay.png'], ['id' => 9, 'name' => 'Crypto', 'icon' => 'images/uploads/icons/crypto.png'], ['id' => 10, 'name' => 'Klarna', 'icon' => 'images/uploads/icons/klarna.png'], ['id' => 11, 'name' => 'Amazon Pay', 'icon' => 'images/uploads/icons/amazonpay.png'], ['id' => 12, 'name' => 'SEPA', 'icon' => 'images/uploads/icons/sepa.png'], ['id' => 13, 'name' => 'Skrill', 'icon' => 'images/uploads/icons/skrill.png'], ['id' => 14, 'name' => 'Sofort', 'icon' => 'images/uploads/icons/sofort.png'], ['id' => 15, 'name' => 'Stripe', 'icon' => 'images/uploads/icons/stripe.png'], ['id' => 16, 'name' => 'Affirm', 'icon' => 'images/uploads/icons/affirm.png'], ['id' => 17, 'name' => 'AliPay', 'icon' => 'images/uploads/icons/alipay.png'], ['id' => 18, 'name' => 'Elo', 'icon' => 'images/uploads/icons/elo.png'], ['id' => 19, 'name' => 'Facebook Pay', 'icon' => 'images/uploads/icons/facebookpay.png'], ['id' => 20, 'name' => 'GiroPay', 'icon' => 'images/uploads/icons/giropay.png'], ['id' => 21, 'name' => 'iDeal', 'icon' => 'images/uploads/icons/ideal.png'], ['id' => 22, 'name' => 'Union Pay', 'icon' => 'images/uploads/icons/unionpay.png'], ['id' => 23, 'name' => 'Interac', 'icon' => 'images/uploads/icons/interac.png'], ['id' => 24, 'name' => 'WeChat', 'icon' => 'images/uploads/icons/wechat.png'], ['id' => 25, 'name' => 'Paysafe', 'icon' => 'images/uploads/icons/paysafe.png'], ['id' => 26, 'name' => 'Poli', 'icon' => 'images/uploads/icons/poli.png'], ['id' => 27, 'name' => 'Qiwi', 'icon' => 'images/uploads/icons/qiwi.png'], ['id' => 28, 'name' => 'ShopPay', 'icon' => 'images/uploads/icons/shoppay.png'], ['id' => 29, 'name' => 'Venmo', 'icon' => 'images/uploads/icons/venmo.png'], ['id' => 30, 'name' => 'VeriFone', 'icon' => 'images/uploads/icons/verifone.png'], ['id' => 31, 'name' => 'WebMoney', 'icon' => 'images/uploads/icons/webmoney.png'], ]; $passwordMismatch = false; $usernameExists = false; $emailExists = false; $registrationFailed = false; $hasErrors = false; if (isset($_POST['username'])) { $username = validate($_POST['username']); $firstname = validate($_POST['firstname']); $lastname = validate($_POST['lastname']); $email = validate($_POST['email']); $password = $_POST['password']; $confirm_password = $_POST['confirm_password']; $main_currency = $_POST['main_currency']; $main_currency_index = array_search($main_currency, array_column($currencies, 'code')); $main_currency_id = $currencies[$main_currency_index]['id']; $language = $_POST['language']; $avatar = "images/avatars/0.svg"; if ($password != $confirm_password) { $passwordMismatch = true; $hasErrors = true; } $emailQuery = "SELECT * FROM user WHERE email = :email"; $stmtEmail = $db->prepare($emailQuery); $stmtEmail->bindValue(':email', $email, SQLITE3_TEXT); $resultEmail = $stmtEmail->execute(); if ($resultEmail->fetchArray()) { $emailExists = true; $hasErrors = true; } $usernameQuery = "SELECT * FROM user WHERE username = :username"; $stmtUsername = $db->prepare($usernameQuery); $stmtUsername->bindValue(':username', $username, SQLITE3_TEXT); $resultUsername = $stmtUsername->execute(); if ($resultUsername->fetchArray()) { $usernameExists = true; $hasErrors = true; } $requireValidation = false; if ($hasErrors == false) { $query = "INSERT INTO user (username, firstname, lastname, email, password, main_currency, avatar, language, budget) VALUES (:username, :firstname, :lastname, :email, :password, :main_currency, :avatar, :language, :budget)"; $stmt = $db->prepare($query); $hashedPassword = password_hash($password, PASSWORD_DEFAULT); $stmt->bindValue(':username', $username, SQLITE3_TEXT); $stmt->bindValue(':firstname', $firstname, SQLITE3_TEXT); $stmt->bindValue(':lastname', $lastname, SQLITE3_TEXT); $stmt->bindValue(':email', $email, SQLITE3_TEXT); $stmt->bindValue(':password', $hashedPassword, SQLITE3_TEXT); $stmt->bindValue(':main_currency', $main_currency_id, SQLITE3_TEXT); $stmt->bindValue(':avatar', $avatar, SQLITE3_TEXT); $stmt->bindValue(':language', $language, SQLITE3_TEXT); $stmt->bindValue(':budget', 0, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { // Get id of the newly created user $userId = $db->lastInsertRowID(); // Add username as household member for that user $query = "INSERT INTO household (name, user_id) VALUES (:name, :user_id)"; $stmt = $db->prepare($query); $stmt->bindValue(':name', $username, SQLITE3_TEXT); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->execute(); if ($userId > 1) { // Add categories for that user $query = 'INSERT INTO categories (name, "order", user_id) VALUES (:name, :order, :user_id)'; $stmt = $db->prepare($query); foreach ($categories as $index => $category) { $stmt->bindValue(':name', $category['name'], SQLITE3_TEXT); $stmt->bindValue(':order', $index + 1, SQLITE3_INTEGER); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->execute(); } // Add payment methods for that user $query = 'INSERT INTO payment_methods (name, icon, "order", user_id) VALUES (:name, :icon, :order, :user_id)'; $stmt = $db->prepare($query); foreach ($payment_methods as $index => $payment_method) { $stmt->bindValue(':name', $payment_method['name'], SQLITE3_TEXT); $stmt->bindValue(':icon', $payment_method['icon'], SQLITE3_TEXT); $stmt->bindValue(':order', $index + 1, SQLITE3_INTEGER); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->execute(); } // Add currencies for that user $query = "INSERT INTO currencies (name, symbol, code, rate, user_id) VALUES (:name, :symbol, :code, :rate, :user_id)"; $stmt = $db->prepare($query); foreach ($currencies as $currency) { $stmt->bindValue(':name', $currency['name'], SQLITE3_TEXT); $stmt->bindValue(':symbol', $currency['symbol'], SQLITE3_TEXT); $stmt->bindValue(':code', $currency['code'], SQLITE3_TEXT); $stmt->bindValue(':rate', 1, SQLITE3_FLOAT); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->execute(); } // Retrieve main currency id $query = "SELECT id FROM currencies WHERE code = :code AND user_id = :user_id"; $stmt = $db->prepare($query); $stmt->bindValue(':code', $main_currency, SQLITE3_TEXT); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $currency = $result->fetchArray(SQLITE3_ASSOC); // Update user main currency $query = "UPDATE user SET main_currency = :main_currency WHERE id = :user_id"; $stmt = $db->prepare($query); $stmt->bindValue(':main_currency', $currency['id'], SQLITE3_INTEGER); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->execute(); // Add settings for that user $query = "INSERT INTO settings (dark_theme, monthly_price, convert_currency, remove_background, color_theme, hide_disabled, user_id, disabled_to_bottom, show_original_price, mobile_nav) VALUES (2, 0, 0, 0, 'blue', 0, :user_id, 0, 0, 0)"; $stmt = $db->prepare($query); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->execute(); // If email verification is required add the user to the email_verification table $query = "SELECT * FROM admin"; $stmt = $db->prepare($query); $result = $stmt->execute(); $settings = $result->fetchArray(SQLITE3_ASSOC); if ($settings['require_email_verification'] == 1) { $query = "INSERT INTO email_verification (user_id, email, token, email_sent) VALUES (:user_id, :email, :token, 0)"; $stmt = $db->prepare($query); $token = bin2hex(random_bytes(32)); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->bindValue(':token', $token, SQLITE3_TEXT); $stmt->bindValue(':email', $email, SQLITE3_TEXT); $stmt->execute(); $requireValidation = true; } } $db->close(); header("Location: login.php?registered=true&requireValidation=$requireValidation"); exit(); } else { $registrationFailed = true; } } } ?> " id="theme-color" /> Wallos - Subscription Tracker > > > > >

================================================ FILE: robots.txt ================================================ User-agent: * Disallow: / ================================================ FILE: scripts/admin.js ================================================ function makeFetchCall(url, data, button) { return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify(data), }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); } else { showErrorMessage(data.message); } button.disabled = false; }) .catch((error) => { showErrorMessage(error); button.disabled = false; }); } function testSmtpSettingsButton() { const button = document.getElementById("testSmtpSettingsButton"); button.disabled = true; const smtpAddress = document.getElementById("smtpaddress").value; const smtpPort = document.getElementById("smtpport").value; const encryption = document.querySelector('input[name="encryption"]:checked').value; const smtpUsername = document.getElementById("smtpusername").value; const smtpPassword = document.getElementById("smtppassword").value; const fromEmail = document.getElementById("fromemail").value; const data = { smtpaddress: smtpAddress, smtpport: smtpPort, encryption: encryption, smtpusername: smtpUsername, smtppassword: smtpPassword, fromemail: fromEmail }; makeFetchCall('endpoints/notifications/testemailnotifications.php', data, button); } function saveSmtpSettingsButton() { const button = document.getElementById("saveSmtpSettingsButton"); button.disabled = true; const smtpAddress = document.getElementById("smtpaddress").value; const smtpPort = document.getElementById("smtpport").value; const encryption = document.querySelector('input[name="encryption"]:checked').value; const smtpUsername = document.getElementById("smtpusername").value; const smtpPassword = document.getElementById("smtppassword").value; const fromEmail = document.getElementById("fromemail").value; const data = { smtpaddress: smtpAddress, smtpport: smtpPort, encryption: encryption, smtpusername: smtpUsername, smtppassword: smtpPassword, fromemail: fromEmail }; fetch('endpoints/admin/savesmtpsettings.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify(data), }) .then(response => response.json()) .then(data => { if (data.success) { const emailVerificationCheckbox = document.getElementById('requireEmail'); emailVerificationCheckbox.disabled = false; showSuccessMessage(data.message); } else { showErrorMessage(data.message); } button.disabled = false; }) .catch((error) => { showErrorMessage(error); button.disabled = false; }); } function backupDB() { const button = document.getElementById("backupDB"); button.disabled = true; fetch("endpoints/db/backup.php", { method: "POST", headers: { "X-CSRF-Token": window.csrfToken, }, }) .then(response => response.json()) .then(data => { if (data.success) { const link = document.createElement("a"); const filename = data.file; link.href = ".tmp/" + filename; const date = new Date(); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); const timestamp = `${year}${month}${day}-${hours}${minutes}`; link.download = `Wallos-Backup-${timestamp}.zip`; document.body.appendChild(link); link.click(); document.body.removeChild(link); } else { showErrorMessage(data.message || translate("backup_failed")); } }) .catch(error => { console.error(error); showErrorMessage(translate("unknown_error")); }) .finally(() => { button.disabled = false; }); } function openRestoreDBFileSelect() { document.getElementById('restoreDBFile').click(); }; function restoreDB() { const input = document.getElementById('restoreDBFile'); const file = input.files[0]; if (!file) { showErrorMessage(translate('no_file_selected')); return; } const formData = new FormData(); formData.append('file', file); const button = document.getElementById('restoreDB'); button.disabled = true; fetch('endpoints/db/restore.php', { method: 'POST', headers: { 'X-CSRF-Token': window.csrfToken, // ✅ CSRF protection }, body: formData, }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); // After restoring, run migrations then log out (force re-login) fetch('endpoints/db/migrate.php') .then(() => { window.location.href = 'logout.php'; }) .catch(() => { window.location.href = 'logout.php'; }); } else { showErrorMessage(data.message || translate('restore_failed')); } }) .catch(error => { console.error(error); showErrorMessage(translate('unknown_error')); }) .finally(() => { button.disabled = false; }); } function saveAccountRegistrationsButton() { const button = document.getElementById('saveAccountRegistrations'); button.disabled = true; const open_registrations = document.getElementById('registrations').checked ? 1 : 0; const max_users = document.getElementById('maxUsers').value; const require_email_validation = document.getElementById('requireEmail').checked ? 1 : 0; const server_url = document.getElementById('serverUrl').value; const disable_login = document.getElementById('disableLogin').checked ? 1 : 0; const data = { open_registrations: open_registrations, max_users: max_users, require_email_validation: require_email_validation, server_url: server_url, disable_login: disable_login }; fetch('endpoints/admin/saveopenregistrations.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify(data) }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); button.disabled = false; } else { showErrorMessage(data.message); button.disabled = false; } }) .catch(error => { showErrorMessage(error); button.disabled = false; }); } function saveSecuritySettingsButton() { const button = document.getElementById('saveSecuritySettingsButton'); button.disabled = true; const allowlist = document.getElementById('local_webhook_notifications_allowlist').value; const data = { local_webhook_notifications_allowlist: allowlist }; fetch('endpoints/admin/savesecuritysettings.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify(data) }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); button.disabled = false; } else { showErrorMessage(data.message); button.disabled = false; } }) .catch(error => { showErrorMessage(error); button.disabled = false; }); } function removeUser(userId) { const data = { userId: userId }; fetch('endpoints/admin/deleteuser.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify(data) }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); const userContainer = document.querySelector(`.form-group-inline[data-userid="${userId}"]`); if (userContainer) { userContainer.remove(); } } else { showErrorMessage(data.message); } }) .catch(error => showErrorMessage('Error:', error)); } function addUserButton() { const button = document.getElementById('addUserButton'); button.disabled = true; const username = document.getElementById('newUsername').value; const email = document.getElementById('newEmail').value; const password = document.getElementById('newPassword').value; const data = { username: username, email: email, password: password }; fetch('endpoints/admin/adduser.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify(data) }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); button.disabled = false; window.location.reload(); } else { showErrorMessage(data.message); button.disabled = false; } }) .catch(error => { showErrorMessage(error); button.disabled = false; }); } function deleteUnusedLogos() { const button = document.getElementById('deleteUnusedLogos'); button.disabled = true; fetch('endpoints/admin/deleteunusedlogos.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, } }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); const numberOfLogos = document.querySelector('.number-of-logos'); numberOfLogos.innerText = '0'; } else { showErrorMessage(data.message); button.disabled = false; } }) .catch(error => { showErrorMessage(error); button.disabled = false; }); } function toggleUpdateNotification() { const notificationEnabledCheckbox = document.getElementById('updateNotification'); const notificationEnabled = notificationEnabledCheckbox.checked ? 1 : 0; const data = { notificationEnabled: notificationEnabled }; fetch('endpoints/admin/updatenotification.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify(data) }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); if (notificationEnabled === 1) { fetch('endpoints/cronjobs/checkforupdates.php'); } } else { showErrorMessage(data.message); } }) .catch(error => showErrorMessage('Error:', error)); } function executeCronJob(job) { const url = `endpoints/cronjobs/${job}.php`; const resultTextArea = document.getElementById('cronjobResult'); fetch(url) .then(response => { return response.text(); }) .then(data => { const formattedData = data.replace(//gi, '\n'); resultTextArea.value = formattedData; }) .catch(error => { console.error('Fetch error:', error); showErrorMessage('Error:', error); }); } function toggleOidcEnabled() { const toggle = document.getElementById("oidcEnabled"); toggle.disabled = true; const oidcEnabled = toggle.checked ? 1 : 0; const data = { oidcEnabled: oidcEnabled }; fetch('endpoints/admin/enableoidc.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify(data) }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); } else { showErrorMessage(data.message); } toggle.disabled = false; }) .catch(error => { showErrorMessage('Error:', error); toggle.disabled = false; }); } function saveOidcSettingsButton() { const button = document.getElementById("saveOidcSettingsButton"); button.disabled = true; const oidcName = document.getElementById("oidcName").value; const oidcClientId = document.getElementById("oidcClientId").value; const oidcClientSecret = document.getElementById("oidcClientSecret").value; const oidcAuthUrl = document.getElementById("oidcAuthUrl").value; const oidcTokenUrl = document.getElementById("oidcTokenUrl").value; const oidcUserInfoUrl = document.getElementById("oidcUserInfoUrl").value; const oidcRedirectUrl = document.getElementById("oidcRedirectUrl").value; const oidcLogoutUrl = document.getElementById("oidcLogoutUrl").value; const oidcUserIdentifierField = document.getElementById("oidcUserIdentifierField").value; const oidcScopes = document.getElementById("oidcScopes").value; const oidcAuthStyle = document.getElementById("oidcAuthStyle").value; const oidcAutoCreateUser = document.getElementById("oidcAutoCreateUser").checked ? 1 : 0; const oidcPasswordLoginDisabled = document.getElementById("oidcPasswordLoginDisabled").checked ? 1 : 0; const data = { oidcName: oidcName, oidcClientId: oidcClientId, oidcClientSecret: oidcClientSecret, oidcAuthUrl: oidcAuthUrl, oidcTokenUrl: oidcTokenUrl, oidcUserInfoUrl: oidcUserInfoUrl, oidcRedirectUrl: oidcRedirectUrl, oidcLogoutUrl: oidcLogoutUrl, oidcUserIdentifierField: oidcUserIdentifierField, oidcScopes: oidcScopes, oidcAuthStyle: oidcAuthStyle, oidcAutoCreateUser: oidcAutoCreateUser, oidcPasswordLoginDisabled: oidcPasswordLoginDisabled }; fetch('endpoints/admin/saveoidcsettings.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify(data) }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); } else { showErrorMessage(data.message); } button.disabled = false; }) .catch(error => { showErrorMessage('Error:', error); button.disabled = false; }); } ================================================ FILE: scripts/all.js ================================================ if ('serviceWorker' in navigator) { window.addEventListener('load', function() { navigator.serviceWorker.register('service-worker.js').then(function(registration) { //console.log('ServiceWorker registration successful with scope: ', registration.scope); }, function(err) { console.log('ServiceWorker registration failed: ', err); }); }); } ================================================ FILE: scripts/calendar.js ================================================ function nextMonth(currentMonth, currentYear) { let nextMonth = currentMonth + 1; let nextYear = currentYear; if (nextMonth > 12) { nextMonth = 1; nextYear += 1; } window.location.href = `calendar.php?month=${nextMonth}&year=${nextYear}`; } function prevMonth(currentMonth, currentYear) { let prevMonth = currentMonth - 1; let prevYear = currentYear; if (prevMonth < 1) { prevMonth = 12; prevYear -= 1; } window.location.href = `calendar.php?month=${prevMonth}&year=${prevYear}`; } function currentMoth() { window.location.href = `calendar.php`; } function closeSubscriptionModal() { const modal = document.getElementById('subscriptionModal'); modal.classList.remove('is-open'); } function openSubscriptionModal(subscriptionId) { const modal = document.getElementById('subscriptionModal'); const modalContent = document.getElementById('subscriptionModalContent'); modalContent.innerHTML = ''; fetch('endpoints/subscription/getcalendar.php', { method: 'POST', body: JSON.stringify({id: subscriptionId}), headers: { 'Content-Type': 'application/json' } }) .then(response => response.json()) .then(data => { if (data.success && data.data) { const subscription = data.data; const html = ` `; modalContent.innerHTML = html; modal.classList.add('is-open'); } else { console.error(data.message); } }) .catch(error => console.error('Error:', error)); } function decodeHtmlEntities(str) { const txt = document.createElement('textarea'); txt.innerHTML = str; return txt.value; } function exportCalendar(subscriptionId) { fetch('endpoints/subscription/exportcalendar.php', { method: 'POST', body: JSON.stringify({id: subscriptionId}), headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, } }) .then(response => response.json()) .then(data => { if (data.success && data.ics) { const blob = new Blob([data.ics], {type: 'text/calendar'}); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; // Use the subscription name for the file name, replacing any characters that are invalid in file names a.download = `${decodeHtmlEntities(data.name).replace(/[\/\\:*?"<>|]/g, '_').toLowerCase()}.ics`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); } else { showErrorMessage(data.message); } }) .catch(error => console.error('Error:', error)); } function showExportPopup() { const host = window.location.href; const apiPath = "api/subscriptions/get_ical_feed.php"; const apiKey = document.getElementById('apiKey').value; const queryParams = `?api_key=${apiKey}`; const fullUrl = host.replace('calendar.php', apiPath) + queryParams; document.getElementById('iCalendarUrl').value = fullUrl; document.getElementById('subscriptions_calendar').classList.add('is-open'); } function closePopup() { document.getElementById('subscriptions_calendar').classList.remove('is-open'); } function copyToClipboard() { const urlField = document.getElementById('iCalendarUrl'); urlField.select(); urlField.setSelectionRange(0, 99999); // For mobile devices navigator.clipboard.writeText(urlField.value) .then(() => { showSuccessMessage(translate('copied_to_clipboard')); }) .catch(() => { showErrorMessage(translate('unknown_error')); }); } ================================================ FILE: scripts/common.js ================================================ let isDropdownOpen = false; function toggleDropdown() { const dropdown = document.querySelector('.dropdown'); dropdown.classList.toggle('is-open'); isDropdownOpen = !isDropdownOpen; } function showErrorMessage(message) { const toast = document.querySelector(".toast#errorToast"); const closeIcon = document.querySelector(".close-error"); const errorMessage = document.querySelector(".errorMessage"); const progress = document.querySelector(".progress.error"); let timer1, timer2; errorMessage.textContent = message; toast.classList.add("active"); progress.classList.add("active"); timer1 = setTimeout(() => { toast.classList.remove("active"); closeIcon.removeEventListener("click", () => { }); }, 5000); timer2 = setTimeout(() => { progress.classList.remove("active"); }, 5300); closeIcon.addEventListener("click", () => { toast.classList.remove("active"); setTimeout(() => { progress.classList.remove("active"); }, 300); clearTimeout(timer1); clearTimeout(timer2); closeIcon.removeEventListener("click", () => { }); }); } function showSuccessMessage(message) { const toast = document.querySelector(".toast#successToast"); const closeIcon = document.querySelector(".close-success"); const successMessage = document.querySelector(".successMessage"); const progress = document.querySelector(".progress.success"); let timer1, timer2; successMessage.textContent = message; toast.classList.add("active"); progress.classList.add("active"); timer1 = setTimeout(() => { toast.classList.remove("active"); closeIcon.removeEventListener("click", () => { }); }, 5000); timer2 = setTimeout(() => { progress.classList.remove("active"); }, 5300); closeIcon.addEventListener("click", () => { toast.classList.remove("active"); setTimeout(() => { progress.classList.remove("active"); }, 300); clearTimeout(timer1); clearTimeout(timer2); closeIcon.removeEventListener("click", () => { }); }); } document.addEventListener('DOMContentLoaded', function () { const userLocale = navigator.language || navigator.languages[0]; document.cookie = `user_locale=${userLocale}; expires=Fri, 31 Dec 9999 23:59:59 GMT; SameSite=Strict`; if (window.update_theme_settings) { const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; const themePreference = prefersDarkMode ? 'dark' : 'light'; const darkThemeCss = document.querySelector("#dark-theme"); darkThemeCss.disabled = themePreference === 'light'; // Preserve existing classes on the body tag const existingClasses = document.body.className.split(' ').filter(cls => cls !== 'dark' && cls !== 'light'); document.body.className = [...existingClasses, themePreference].join(' '); document.cookie = `inUseTheme=${themePreference}; expires=Fri, 31 Dec 9999 23:59:59 GMT; SameSite=Strict`; const themeColorMetaTag = document.querySelector('meta[name="theme-color"]'); themeColorMetaTag.setAttribute('content', themePreference === 'dark' ? '#222222' : '#FFFFFF'); } document.addEventListener('mousedown', function (event) { var dropdown = document.querySelector('.dropdown'); var dropdownContent = document.querySelector('.dropdown-content'); if (!dropdown.contains(event.target) && isDropdownOpen) { dropdown.classList.remove('is-open'); isDropdownOpen = false; } }); document.querySelector('.dropdown-content').addEventListener('focus', function () { isDropdownOpen = true; }); }); function getCookie(name) { const cookies = document.cookie.split(';'); for (let cookie of cookies) { cookie = cookie.trim(); if (cookie.startsWith(`${name}=`)) { return cookie.substring(name.length + 1); } } return null; } ================================================ FILE: scripts/dashboard.js ================================================ document.addEventListener("DOMContentLoaded", function () { function updateAiRecommendationNumbers() { document.querySelectorAll(".ai-recommendation-item").forEach(function (item, index) { const numberSpan = item.querySelector(".ai-recommendation-header h3 > span"); if (numberSpan) { numberSpan.textContent = `${index + 1}. `; } }); } document.querySelectorAll(".ai-recommendation-item").forEach(function (item) { item.addEventListener("click", function () { item.classList.toggle("expanded"); }); }); document.querySelectorAll(".delete-ai-recommendation").forEach(function (el) { el.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); const item = el.closest(".ai-recommendation-item"); const id = item.getAttribute("data-id"); fetch("endpoints/ai/delete_recommendation.php", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken, }, body: JSON.stringify({ id: id }), }) .then(res => res.json()) .then(data => { if (data.success) { item.remove(); updateAiRecommendationNumbers(); showSuccessMessage(translate("success")); } else { showErrorMessage(data.message || translate("failed_delete_ai_recommendation")); } }) .catch(error => { console.error(error); showErrorMessage(translate("unknown_error")); }); }); }); }); ================================================ FILE: scripts/i18n/ca.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Error al recarregar la subscripció:", error_fetching_image_results: "Error al obtenir resultats d'imatges:", subscription_deleted: "Subscripció eliminada", error_deleting_subscription: "Error al eliminar la subscripció", failed_to_load_subscription: "Error al carregar la subscripció", edit_subscription: "Editar Subscripció", add_subscription: "Afegir Subscripció", confirm_delete_subscription: "¿Segur que vols eliminar aquesta subscripció?", // Settings network_response_error: "Error en la resposta de la xarxa", failed_add_member: "Error al afegir membre", member: "Membre", email: "Correu electrònic", firstname: "Nom", lastname: "Cognom", save_member: "Desar membre", delete_member: "Eliminar membre", failed_remove_member: "Error al eliminar membre", failed_save_member: "Error al desar membre", failed_add_category: "Error al afegir categoria", category: "Categoria", save_category: "Desar categoria", delete_category: "Eliminar categoria", failed_remove_category: "Error al eliminar categoria", currency: "Divisa", currency_code: "Còdi de moneda", save_currency: "Desar divisa", delete_currency: "Eliminar divisa", failed_remove_currency: "Error al eliminar divisa", failed_save_currency: "Error al desar divisa", cant_disable_payment_in_use: "No es pot desactivar un mètode de pagament en ús", failed_save_payment_method: "Error al desar el mètode de pagament", unknown_error: "Error desconegut, si us plau prova-ho de nou.", error_saving_notification_data: "Error al desar les dades de notificació", error_sending_notification: "Error al enviar la notificació", delete_account_confirmation: "Segur que vols eliminar el teu compte?", this_will_delete_all_data: "S'eliminaran totes les teves dades i no es podran recuperar. Continuar?", success: "Èxit", copied_to_clipboard: "Copiat al porta-retalls", // Calendar price: "Preu", category: "Categoria", paid_by: "Pagat per", payment_method: "Mètode de pagment", notes: "Notes", export: "Exportar", } ================================================ FILE: scripts/i18n/cs.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Chyba při načítání předplatného:", error_fetching_image_results: "Chyba při načítání výsledků obrázků:", subscription_deleted: "Předplatné odstraněno", error_deleting_subscription: "Chyba při odstraňování předplatného", failed_to_load_subscription: "Nepodařilo se načíst předplatné", edit_subscription: "Upravit předplatné", add_subscription: "Přidat předplatné", confirm_delete_subscription: "Opravdu chcete odstranit toto předplatné?", // Settings network_response_error: "Odezva sítě nebyla v pořádku", failed_add_member: "Nepodařilo se přidat člena", member: "Člen", email: "E-mail", firstname: "Křestní jméno", lastname: "Příjmení", save_member: "Uložit člena", delete_member: "Odstranit člena", failed_remove_member: "Nepodařilo se odebrat člena", failed_save_member: "Nepodařilo se uložit člena", failed_add_category: "Nepodařilo se přidat kategorii", category: "Kategorie", save_category: "Uložit kategorii", delete_category: "Odstranit kategorii", failed_remove_category: "Nepodařilo se odebrat kategorii", currency: "Měna", currency_code: "Kód měny", save_currency: "Uložit měnu", delete_currency: "Odstranit měnu", failed_remove_currency: "Nepodařilo se odebrat měnu", failed_save_currency: "Nepodařilo se uložit měnu", cant_disable_payment_in_use: "Nelze zakázat používanou platbu", failed_save_payment_method: "Nepodařilo se uložit platební metodu", unknown_error: "Neznámá chyba, zkuste to prosím znovu.", error_saving_notification_data: "Chyba při ukládání dat oznámení", error_sending_notification: "Chyba při odesílání oznámení", delete_account_confirmation: "Opravdu chcete odstranit svůj účet?", this_will_delete_all_data: "Tím se odstraní všechna vaše data a nelze to vrátit zpět. Pokračovat?", success: "Úspěch", copied_to_clipboard: "Zkopírováno do schránky", // Calendar price: "Cena", category: "Kategorie", paid_by: "Platí", payment_method: "Platební metoda", notes: "Poznámky", export: "Exportovat", } ================================================ FILE: scripts/i18n/da.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Fejl ved genindlæsning af abonnement:", error_fetching_image_results: "Fejl ved hentning af billedresultater:", subscription_deleted: "Abonnement slettet", error_deleting_subscription: "Fejl ved sletning af abonnement", failed_to_load_subscription: "Kunne ikke indlæse abonnement", edit_subscription: "Redigér abonnement", add_subscription: "Tilføj abonnement", confirm_delete_subscription: "Er du sikker på, at du vil slette dette abonnement?", // Settings network_response_error: "Netværkssvaret var ikke i orden", failed_add_member: "Kunne ikke tilføje medlem", member: "Medlem", email: "E-mail", firstname: "Fornavn", lastname: "Efternavn", save_member: "Gem medlem", delete_member: "Slet medlem", failed_remove_member: "Kunne ikke fjerne medlem", failed_save_member: "Kunne ikke gemme medlem", failed_add_category: "Kunne ikke tilføje kategori", category: "Kategori", save_category: "Gem kategori", delete_category: "Slet kategori", failed_remove_category: "Kunne ikke fjerne kategori", currency: "Valuta", currency_code: "Valutakode", save_currency: "Gem valuta", delete_currency: "Slet valuta", failed_remove_currency: "Kunne ikke fjerne valuta", failed_save_currency: "Kunne ikke gemme valuta", cant_disable_payment_in_use: "Kan ikke deaktivere betalingsmetode i brug", failed_save_payment_method: "Kunne ikke gemme betalingsmetode", unknown_error: "Ukendt fejl, prøv venligst igen.", error_saving_notification_data: "Fejl ved lagring af notifikationsdata", error_sending_notification: "Fejl ved afsendelse af notifikation", delete_account_confirmation: "Er du sikker på, at du vil slette din konto?", this_will_delete_all_data: "Dette vil slette alle dine data og kan ikke fortrydes. Fortsæt?", success: "Succes", copied_to_clipboard: "Kopieret til udklipsholder", // Calendar price: "Pris", category: "Kategori", paid_by: "Betalt af", payment_method: "Betalingsmetode", notes: "Noter", export: "Eksportér", } ================================================ FILE: scripts/i18n/de.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Fehler beim Aktualisieren des Abonnements:", error_fetching_image_results: "Fehler beim Laden der Bilder:", subscription_deleted: "Abonnement gelöscht", error_deleting_subscription: "Fehler beim Löschen des Abonnements", failed_to_load_subscription: "Fehler beim Laden des Abonnements", edit_subscription: "Abonnement bearbeiten", add_subscription: "Abonnement hinzufügen", confirm_delete_subscription: "Sind Sie sicher, dass Sie dieses Abonnement löschen möchten?", // Settings network_response_error: "Netzwerkfehler", failed_add_member: "Hinzufügen von Mitglied fehlgeschlagen", member: "Mitglied", email: "E-Mail", firstname: "Vorname", lastname: "Nachname", save_member: "Mitglied speichern", delete_member: "Mitglied löschen", failed_remove_member: "Mitglied konnte nicht gelöscht werden", failed_save_member: "Mitglied konnte nicht gespeichert werden", failed_add_category: "Kategorie konnte nicht hinzugefügt werden", category: "Kategorie", save_category: "Kategorie speichern", delete_category: "Kategorie löschen", failed_remove_category: "Kategorie konnte nicht gelöscht werden", currency: "Währung", currency_code: "Währungscode", save_currency: "Währung speichern", delete_currency: "Währung löschen", failed_remove_currency: "Währung konnte nicht gelöscht werden", failed_save_currency: "Währung konnte nicht gespeichert werden", cant_disable_payment_in_use: "Genutzte Währungen können nicht deaktiviert werden", failed_save_payment_method: "Zahlungsmethode konnte nicht gespeichert werden", unknown_error: "Unbekannter Fehler, bitte erneut versuchen.", error_saving_notification_data: "Fehler beim Speichern der Benachrichtigungsangaben", error_sending_notification: "Fehler beim Senden der Benachrichtigung", delete_account_confirmation: "Möchten Sie Ihr Konto wirklich löschen?", this_will_delete_all_data: "Dadurch werden alle Daten gelöscht und können nicht wiederhergestellt werden. Fortfahren?", success: "Erfolg", copied_to_clipboard: "In die Zwischenablage kopiert", // Calendar price: "Preis", category: "Kategorie", paid_by: "Bezahlt von", payment_method: "Zahlungsmethode", notes: "Notizen", export: "Exportieren", } ================================================ FILE: scripts/i18n/el.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Σφάλμα επαναφόρτωσης συνδρομής:", error_fetching_image_results: "Σφάλμα λήψης αποτελεσμάτων εικόνας:", subscription_deleted: "Η συνδρομή διαγράφηκε", error_deleting_subscription: "Σφάλμα διαγραφής συνδρομής", failed_to_load_subscription: "Απέτυχε η φόρτωση της συνδρομής", edit_subscription: "Επεξεργασία συνδρομής", add_subscription: "Προσθήκη συνδρομής", confirm_delete_subscription: "Είστε σίγουρος ότι θέλετε να διαγράψετε αυτή τη συνδρομή;", // Settings network_response_error: "Η ανταπόκριση του δικτύου δεν ήταν εντάξει", failed_add_member: "Αποτυχία προσθήκης μέλους", member: "Μέλος", email: "Email", firstname: "Ονομα", lastname: "Επώνυμο", save_member: "Αποθήκευση μέλους", delete_member: "Διαγραφή μέλους", failed_remove_member: "Αποτυχία διαγραφής μέλους", failed_save_member: "Αποτυχία αποθήκευσης μέλους", failed_add_category: "Αποτυχία προσθήκης μέλους", category: "Κατηγορία", save_category: "Αποθήκευση κατηγορίας", delete_category: "Διαγραφή κατηγορίας", failed_remove_category: "Αποτυχία διαγραφής κατηγορίας", currency: "Νόμισμα", currency_code: "Κωδικός νομίσματος", save_currency: "Αποθήκευση νομίσματος", delete_currency: "Διαγραφή νομίσματος", failed_remove_currency: "Αποτυχία διαγραφής νομίσματος", failed_save_currency: "Αποτυχία αποθήκευσης νομίσματος", cant_disable_payment_in_use: "Δεν ειναι εφικτή η απενεργοποίηση της πληρωμή που βρίσκεται σε χρήση", failed_save_payment_method: "Failed to save payment method", unknown_error: "Άγνωστο σφάλμα, προσπάθησε ξανά.", error_saving_notification_data: "Σφάλμα αποθήκευσης δεδομένων ειδοποίησης", error_sending_notification: "Σφάλμα αποστολής ειδοποίησης", delete_account_confirmation: "Είστε σίγουρος ότι θέλετε να διαγράψετε το λογαριασμό σας;", this_will_delete_all_data: "Αυτό θα διαγράψει όλα τα δεδομένα σας και δεν μπορεί να ανακτηθεί. Να συνεχίσω;", success: "Επιτυχία", copied_to_clipboard: "Αντιγράφηκε στο πρόχειρο", // Calendar price: "Τιμή", category: "Κατηγορία", paid_by: "Πληρώθηκε από", payment_method: "Μέθοδος πληρωμής", notes: "Σημειώσεις", export: "Εξαγωγή", } ================================================ FILE: scripts/i18n/en.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Error reloading subscription:", error_fetching_image_results: "Error fetching image results:", subscription_deleted: "Subscription deleted", error_deleting_subscription: "Error deleting subscription", failed_to_load_subscription: "Failed to load subscription", edit_subscription: "Edit subscription", add_subscription: "Add subscription", confirm_delete_subscription: "Are you sure you want to delete this subscription?", // Settings network_response_error: "Network response was not ok", failed_add_member: "Failed to add member", member: "Member", email: "Email", firstname: "First name", lastname: "Last name", save_member: "Save member", delete_member: "Delete member", failed_remove_member: "Failed to remove member", failed_save_member: "Failed to save member", failed_add_category: "Failed to add category", category: "Category", save_category: "Save category", delete_category: "Delete category", failed_remove_category: "Failed to remove category", currency: "Currency", currency_code: "Currency code", save_currency: "Save currency", delete_currency: "Delete currency", failed_remove_currency: "Failed to remove currency", failed_save_currency: "Failed to save currency", cant_disable_payment_in_use: "Can't disable payment in use", failed_save_payment_method: "Failed to save payment method", unknown_error: "Unknown error, please try again.", error_saving_notification_data: "Error saving notification data", error_sending_notification: "Error sending notification", delete_account_confirmation: "Are you sure you want to delete your account?", this_will_delete_all_data: "This will delete all your data and can't be undone. Continue?", success: "Success", copied_to_clipboard: "Copied to clipboard", // Calendar price: "Price", category: "Category", paid_by: "Paid by", payment_method: "Payment method", notes: "Notes", export: "Export", } ================================================ FILE: scripts/i18n/es.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Error al recargar la suscripción:", error_fetching_image_results: "Error al obtener resultados de imágenes:", subscription_deleted: "Suscripción eliminada", error_deleting_subscription: "Error al eliminar la suscripción", failed_to_load_subscription: "Error al cargar la suscripción", edit_subscription: "Editar suscripción", add_subscription: "Añadir suscripción", confirm_delete_subscription: "¿Estás seguro de que quieres eliminar esta suscripción?", // Settings network_response_error: "Error en la respuesta de la red", failed_add_member: "Error al añadir miembro", member: "Miembro", email: "Correo electrónico", firstname: "Nombre de pila", lastname: "Apellido", save_member: "Guardar miembro", delete_member: "Eliminar miembro", failed_remove_member: "Error al eliminar miembro", failed_save_member: "Error al guardar miembro", failed_add_category: "Error al añadir categoría", category: "Categoría", save_category: "Guardar categoría", delete_category: "Eliminar categoría", failed_remove_category: "Error al eliminar categoría", currency: "Moneda", currency_code: "Código de moneda", save_currency: "Guardar moneda", delete_currency: "Eliminar moneda", failed_remove_currency: "Error al eliminar moneda", failed_save_currency: "Error al guardar moneda", cant_disable_payment_in_use: "No se puede desactivar el método de pago en uso", failed_save_payment_method: "Error al guardar el método de pago", unknown_error: "Error desconocido, por favor inténtalo de nuevo.", error_saving_notification_data: "Error al guardar los datos de notificación", error_sending_notification: "Error al enviar la notificación", delete_account_confirmation: "¿Estás seguro de que quieres eliminar tu cuenta?", this_will_delete_all_data: "Esto eliminará todos tus datos y no se podrán recuperar. ¿Continuar?", success: "Éxito", copied_to_clipboard: "Copiado al portapapeles", // Calendar price: "Precio", category: "Categoría", paid_by: "Pagado por", payment_method: "Método de pago", notes: "Notas", export: "Exportar", } ================================================ FILE: scripts/i18n/fr.js ================================================ let i18n = { // Tableau de bord error_reloading_subscription: "Erreur lors du rechargement de l'abonnement :", error_fetching_image_results: "Erreur lors de la récupération des résultats d'images :", subscription_deleted: "Abonnement supprimé", error_deleting_subscription: "Erreur lors de la suppression de l'abonnement", failed_to_load_subscription: "Impossible de charger l'abonnement", edit_subscription: "Modifier l'abonnement", add_subscription: "Ajouter un abonnement", confirm_delete_subscription: "Êtes-vous sûr de vouloir supprimer cet abonnement ?", // Paramètres network_response_error: "La réponse du réseau n'était pas correcte", failed_add_member: "Échec de l'ajout du membre", member: "Membre", email: "Courriel", firstname: "Prénom", lastname: "Nom de famille", save_member: "Enregistrer le membre", delete_member: "Supprimer le membre", failed_remove_member: "Échec de la suppression du membre", failed_save_member: "Échec de l'enregistrement du membre", failed_add_category: "Échec de l'ajout de la catégorie", category: "Catégorie", save_category: "Enregistrer la catégorie", delete_category: "Supprimer la catégorie", failed_remove_category: "Échec de la suppression de la catégorie", currency: "Devise", currency_code: "Code de devise", save_currency: "Enregistrer la devise", delete_currency: "Supprimer la devise", failed_remove_currency: "Échec de la suppression de la devise", failed_save_currency: "Échec de l'enregistrement de la devise", cant_disable_payment_in_use: "Impossible de désactiver le paiement en cours d'utilisation", failed_save_payment_method: "Échec de l'enregistrement de la méthode de paiement", unknown_error: "Erreur inconnue, veuillez réessayer.", error_saving_notification_data: "Erreur lors de l'enregistrement des données de notification", error_sending_notification: "Erreur lors de l'envoi de la notification", delete_account_confirmation: "Êtes-vous sûr de vouloir supprimer votre compte ?", this_will_delete_all_data: "Cela supprimera toutes vos données et ne pourra pas être annulé. Continuer ?", success: "Succès", copied_to_clipboard: "Copié dans le presse-papiers", // Calendar price: "Prix", category: "Catégorie", paid_by: "Payé par", payment_method: "Méthode de paiement", notes: "Notes", export: "Exporter", }; ================================================ FILE: scripts/i18n/getlang.js ================================================ function translate(key) { if (i18n[key]) { return i18n[key]; } else { return "[Translation Missing]"; } } ================================================ FILE: scripts/i18n/id.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Terjadi kesalahan saat memuat ulang langganan:", error_fetching_image_results: "Terjadi kesalahan saat mengambil hasil gambar:", subscription_deleted: "Langganan dihapus", error_deleting_subscription: "Terjadi kesalahan saat menghapus langganan", failed_to_load_subscription: "Gagal memuat langganan", edit_subscription: "Edit langganan", add_subscription: "Tambah langganan", confirm_delete_subscription: "Apakah Anda yakin ingin menghapus langganan ini?", // Settings network_response_error: "Respons jaringan tidak baik", failed_add_member: "Gagal menambahkan anggota", member: "Anggota", email: "Email", firstname: "Nama depan", lastname: "Nama belakang", save_member: "Simpan anggota", delete_member: "Hapus anggota", failed_remove_member: "Gagal menghapus anggota", failed_save_member: "Gagal menyimpan anggota", failed_add_category: "Gagal menambahkan kategori", category: "Kategori", save_category: "Simpan kategori", delete_category: "Hapus kategori", failed_remove_category: "Gagal menghapus kategori", currency: "Mata Uang", currency_code: "Kode mata uang", save_currency: "Simpan mata uang", delete_currency: "Hapus mata uang", failed_remove_currency: "Gagal menghapus mata uang", failed_save_currency: "Gagal menyimpan mata uang", cant_disable_payment_in_use: "Tidak dapat menonaktifkan metode pembayaran yang sedang digunakan", failed_save_payment_method: "Gagal menyimpan metode pembayaran", unknown_error: "Kesalahan tidak diketahui, silakan coba lagi.", error_saving_notification_data: "Terjadi kesalahan saat menyimpan data notifikasi", error_sending_notification: "Terjadi kesalahan saat mengirim notifikasi", delete_account_confirmation: "Apakah Anda yakin ingin menghapus akun Anda?", this_will_delete_all_data: "Ini akan menghapus semua data Anda dan tidak dapat dibatalkan. Lanjutkan?", success: "Sukses", copied_to_clipboard: "Disalin ke papan klip", // Calendar price: "Harga", category: "Kategori", // Kunci ini sudah ada di bagian "Settings", namun karena konteksnya bisa berbeda (misalnya di tampilan Kalender), saya tetap menerjemahkannya di sini juga. paid_by: "Dibayar oleh", payment_method: "Metode pembayaran", notes: "Catatan", export: "Ekspor", } ================================================ FILE: scripts/i18n/it.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Errore nel ricaricare l'abbonamento:", error_fetching_image_results: "Errore nel recupero delle immagini:", subscription_deleted: "Abbonamento eliminato", error_deleting_subscription: "Errore nell'eliminare l'abbonamento", failed_to_load_subscription: "Caricamento dell'abbonamento non riuscito", edit_subscription: "Modifica abbonamento", add_subscription: "Aggiungi abbonamento", confirm_delete_subscription: "Sei sicuro di voler eliminare questo abbonamento?", // Settings network_response_error: "Si è verificato un errore nella risposta del server", failed_add_member: "Impossibile aggiungere il membro", member: "Membro", email: "Email", firstname: "Nome di battesimo", lastname: "Cognome", save_member: "Salva membro", delete_member: "Elimina membro", failed_remove_member: "Impossibile rimuovere il membro", failed_save_member: "Impossibile salvare il membro", failed_add_category: "Impossibile aggiungere la categoria", category: "Categoria", save_category: "Salva categoria", delete_category: "Elimina categoria", failed_remove_category: "Impossibile rimuovere la categoria", currency: "Valuta", currency_code: "Codice valuta", save_currency: "Salva valuta", delete_currency: "Elimina valuta", failed_remove_currency: "Impossibile rimuovere la valuta", failed_save_currency: "Impossibile salvare la valuta", cant_disable_payment_in_use: "Impossibile disabilitare il pagamento in uso", failed_save_payment_method: "Impossibile salvare il metodo di pagamento", unknown_error: "Errore sconosciuto, si prega di riprovare.", error_saving_notification_data: "Errore nel salvataggio delle impostazioni di notifica", error_sending_notification: "Errore nell'invio della notifica", delete_account_confirmation: "Sei sicuro di voler eliminare il tuo account?", this_will_delete_all_data: "Questo eliminerà tutti i tuoi dati e non potrà essere annullato. Continuare?", success: "Successo", copied_to_clipboard: "Copiato negli appunti", // Calendar price: "Prezzo", category: "Categoria", paid_by: "Pagato da", payment_method: "Metodo di pagamento", notes: "Note", export: "Esporta", } ================================================ FILE: scripts/i18n/jp.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "定期購入の再読み込みエラー:", error_fetching_image_results: "画像取得結果エラー:", subscription_deleted: "定期購入の削除", error_deleting_subscription: "定期購入の削除エラー", failed_to_load_subscription: "定期購入の読み込みに失敗しました", edit_subscription: "定期購入の編集", add_subscription: "定期購入の追加", confirm_delete_subscription: "この定期購入を削除してもよろしいですか?", // Settings network_response_error: "ネットワークの応答異常", failed_add_member: "世帯員の追加に失敗", member: "世帯員", email: "メール", firstname: "ファーストネーム", lastname: "苗字", save_member: "世帯員の保存", delete_member: "世帯員の削除", failed_remove_member: "世帯員の削除に失敗", failed_save_member: "世帯員の削除に失敗", failed_add_category: "カテゴリの追加に失敗", category: "カテゴリ", save_category: "カテゴリの保存", delete_category: "カテゴリの削除", failed_remove_category: "カテゴリの削除に失敗", currency: "通貨", currency_code: "通貨コード", save_currency: "通貨の保存", delete_currency: "通貨の削除", failed_remove_currency: "通貨の削除に失敗", failed_save_currency: "通貨の保存に失敗", cant_disable_payment_in_use: "使用中の支払いは無効にできません", failed_save_payment_method: "支払い方法の保存に失敗", unknown_error: "不明なエラー。もう一度試してください。", error_saving_notification_data: "通知データの保存エラー", error_sending_notification: "通知の送信エラー", delete_account_confirmation: "アカウントを削除してもよろしいですか?", this_will_delete_all_data: "これによりすべてのデータが削除され、元に戻すことはできません。続行しますか?", success: "成功", copied_to_clipboard: "クリップボードにコピーされました", // Calendar price: "価格", category: "カテゴリ", paid_by: "支払い者", payment_method: "支払い方法", notes: "メモ", export: "エクスポート", } ================================================ FILE: scripts/i18n/ko.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "고독 새로고침 중 에러가 발생했습니다:", error_fetching_image_results: "이미지 가져오기에 실패했습니다:", subscription_deleted: "구독이 삭제되었습니다", error_deleting_subscription: "구독 삭제중 에러가 발생했습니다", failed_to_load_subscription: "구독 불러오기에 실패했습니다", edit_subscription: "구독 수정", add_subscription: "구독 추가", confirm_delete_subscription: "이 구독을 정말 삭제하시겠습니까?", // Settings network_response_error: "네트워크 응답 오류가 발생했습니다", failed_add_member: "구성원 추가에 실패했습니다", member: "구성원", email: "이메일", firstname: "이름", lastname: "성", save_member: "구성원 저장", delete_member: "구성원 삭제", failed_remove_member: "구성원 삭제에 실패했습니다", failed_save_member: "구성원 저장에 실패했습니다", failed_add_category: "카테고리 추가에 실패했습니다", category: "카테고리", save_category: "카테고리 저장", delete_category: "카테고리 삭제", failed_remove_category: "카테고리 삭제에 실패했습니다", currency: "통화", currency_code: "통화 코드", save_currency: "통화 저장", delete_currency: "통화 삭제", failed_remove_currency: "통화 삭제에 실패했습니다", failed_save_currency: "통화 저장에 실패했습니다", cant_disable_payment_in_use: "사용 중인 결제 수단을 비활성화 할 수 없습니다", failed_save_payment_method: "결제 수단 저장에 실패했습니다", unknown_error: "알 수 없는 에러입니다. 다시 시도해 주세요.", error_saving_notification_data: "알림 데이터 저장 에러", error_sending_notification: "알림 전송 에러", delete_account_confirmation: "정말 계정을 삭제하시겠습니까?", this_will_delete_all_data: "이로 인해 모든 데이터가 삭제되며 복구할 수 없습니다. 계속하시겠습니까?", success: "성공", copied_to_clipboard: "클립보드에 복사되었습니다", // Calendar price: "가격", category: "카테고리", paid_by: "지불자", payment_method: "결제 수단", notes: "메모", export: "내보내기", }; ================================================ FILE: scripts/i18n/nl.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Fout bij het herladen van abonnement:", error_fetching_image_results: "Fout bij het ophalen van afbeeldingsresultaten:", subscription_deleted: "Abonnement verwijderd", error_deleting_subscription: "Fout bij het verwijderen van abonnement", failed_to_load_subscription: "Laden van abonnement mislukt", edit_subscription: "Abonnement bewerken", add_subscription: "Abonnement toevoegen", confirm_delete_subscription: "Weet u zeker dat u dit abonnement wilt verwijderen?", // Settings network_response_error: "Netwerkreactie was niet in orde", failed_add_member: "Lid toevoegen mislukt", member: "Lid", email: "E-mail", firstname: "Voornaam", lastname: "Achternaam", save_member: "Lid opslaan", delete_member: "Lid verwijderen", failed_remove_member: "Lid verwijderen mislukt", failed_save_member: "Lid opslaan mislukt", failed_add_category: "Categorie toevoegen mislukt", category: "Categorie", save_category: "Categorie opslaan", delete_category: "Categorie verwijderen", failed_remove_category: "Categorie verwijderen mislukt", currency: "Valuta", currency_code: "Valutacode", save_currency: "Valuta opslaan", delete_currency: "Valuta verwijderen", failed_remove_currency: "Valuta verwijderen mislukt", failed_save_currency: "Valuta opslaan mislukt", cant_disable_payment_in_use: "Kan in gebruik zijnde betaalmethode niet uitschakelen", failed_save_payment_method: "Betaalmethode opslaan mislukt", unknown_error: "Onbekende fout, probeer het opnieuw.", error_saving_notification_data: "Fout bij opslaan van notificatiegegevens", error_sending_notification: "Fout bij versturen van notificatie", delete_account_confirmation: "Weet je zeker dat je je account wilt verwijderen?", this_will_delete_all_data: "Dit zal al je gegevens verwijderen en kan niet ongedaan worden gemaakt. Doorgaan?", success: "Succes", copied_to_clipboard: "Gekopieerd naar klembord", // Calendar price: "Prijs", category: "Categorie", paid_by: "Betaald door", payment_method: "Betaalmethode", notes: "Notities", export: "Exporteren" } ================================================ FILE: scripts/i18n/pl.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Błąd przeładowania subskrypcji:", error_fetching_image_results: "Błąd pobierania wyników obrazu:", subscription_deleted: "Subskrypcja usunięta", error_deleting_subscription: "Błąd usunięcia subskrypcji", failed_to_load_subscription: "Nie udało się załadować subskrypcji", edit_subscription: "Edytuj subskrypcję", add_subscription: "Dodaj subskrypcję", confirm_delete_subscription: "Czy na pewno chcesz usunąć tę subskrypcję?", // Settings network_response_error: "Odpowiedź sieciowa nie była prawidłowa", failed_add_member: "Nie udało się dodać użytkownika", member: "Użytkownik", email: "E-mail", firstname: "Imię", lastname: "Nazwisko", save_member: "Zapisz użytkownika", delete_member: "Usuń użytkownika", failed_remove_member: "Nie udało się usunąć użytkownika", failed_save_member: "Nie udało się zapisać użytkownika", failed_add_category: "Nie udało się dodać kategorii", category: "Kategoria", save_category: "Zapisz kategorię", delete_category: "Usuń kategorię", failed_remove_category: "Nie udało się usunąć kategorii", currency: "Waluta", currency_code: "Kod waluty", save_currency: "Zapisz walutę", delete_currency: "Usuń walutę", failed_remove_currency: "Nie udało się usunąć waluty", failed_save_currency: "Nie udało się zapisać waluty", cant_disable_payment_in_use: "Nie można wyłączyć płatności w użyciu", failed_save_payment_method: "Nie udało się zapisać metody płatności", unknown_error: "Nieznany błąd, spróbuj ponownie.", error_saving_notification_data: "Błąd zapisywania danych powiadomienia", error_sending_notification: "Błąd wysyłania powiadomienia", delete_account_confirmation: "Czy na pewno chcesz usunąć swoje konto?", this_will_delete_all_data: "Spowoduje to usunięcie wszystkich danych i nie będzie można tego cofnąć. Kontynuować?", success: "Sukces", copied_to_clipboard: "Skopiowano do schowka", // Calendar price: "Cena", category: "Kategoria", paid_by: "Zapłacone przez", payment_method: "Metoda płatności", notes: "Notatki", export: "Eksport", } ================================================ FILE: scripts/i18n/pt.js ================================================ let i18n = { // Dashboard error_reloading_subscription: 'Erro ao carregar a subscrição:', error_fetching_image_results: 'Erro ao obter imagens:', subscription_deleted: 'Subscrição eliminada', error_deleting_subscription: 'Erro ao eliminar a subscrição', failed_to_load_subscription: 'Falha ao carregar a subscrição', edit_subscription: 'Editar subscrição', add_subscription: 'Adicionar subscrição', confirm_delete_subscription: 'Tem a certeza de que deseja eliminar esta subscrição?', // Settings network_response_error: 'Erro de resposta de rede', failed_add_member: 'Falha ao adicionar membro', member: 'Membro', email: 'Email', firstname: 'Nome próprio', lastname: 'Último nome', save_member: 'Guardar membro', delete_member: 'Remover membro', failed_remove_member: 'Erro ao remover membro', failed_save_member: 'Erro ao guardar membro', failed_add_category: 'Erro ao adicionar categoria', category: 'Categoria', save_category: 'Guardar categoria', delete_category: 'Remover categoria', failed_remove_category: 'Erro ao remover categoria', currency: 'Moeda', currency_code: 'Código de moeda', save_currency: 'Guardar moeda', delete_currency: 'Remover moeda', failed_remove_currency: 'Erro ao remover moeda', failed_save_currency: 'Erro ao guardar moeda', cant_disable_payment_in_use: 'Não é possível desativar pagamento em uso', failed_save_payment_method: 'Erro ao guardar método de pagamento', unknown_error: 'Erro desconhecido, por favor, tente novamente.', error_saving_notification_data: 'Erro ao guardar dados de notificação', error_sending_notification: 'Erro ao enviar notificação', delete_account_confirmation: "Tem a certeza de que deseja eliminar a sua conta?", this_will_delete_all_data: "Isto irá eliminar todos os seus dados e não poderão ser recuperados. Continuar?", success: "Sucesso", copied_to_clipboard: "Copiado para a área de transferência", // Calendar price: "Preço", category: "Categoria", paid_by: "Pago por", payment_method: "Método de pagamento", notes: "Notas", export: "Exportar", }; ================================================ FILE: scripts/i18n/pt_br.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Erro ao recarregar assinaturas:", error_fetching_image_results: "Erro ao carregar resultados de imagens:", subscription_deleted: "Assinatura excluída", error_deleting_subscription: "Erro ao excluir assinatura", failed_to_load_subscription: "Erro ao carregar assinaturas", edit_subscription: "Editar assinatura", add_subscription: "Adicionar assinatura", confirm_delete_subscription: "Você tem certeza que deseja excluir essa assinatura?", // Settings network_response_error: "Resposta da rede não foi OK", failed_add_member: "Erro ao adicionar membro", member: "Membro", email: "Email", firstname: "Primeiro nome", lastname: "Sobrenome", save_member: "Salvar membro", delete_member: "Excluir membro", failed_remove_member: "Erro ao excluir membro", failed_save_member: "Erro ao salvar membro", failed_add_category: "Erro ao adicionar categoria", category: "Categoria", save_category: "Salvar categoria", delete_category: "Excluir categoria", failed_remove_category: "Erro ao excluir categoria", currency: "Moeda", currency_code: "Código da moeda", save_currency: "Salvar moeda", delete_currency: "Excluir moeda", failed_remove_currency: "Erro ao excluir moeda", failed_save_currency: "Error ao salvar moeda", cant_disable_payment_in_use: "Não é possível desativar uma moeda em uso", failed_save_payment_method: "Erro ao salvar o método de pagamento", unknown_error: "Erro desconhecido. Por favor, tente novamente", error_saving_notification_data: "Erro ao salvar dados da notificação", error_sending_notification: "Erro ao enviar notificação", delete_account_confirmation: "Você tem certeza que deseja excluir sua conta?", this_will_delete_all_data: "Isso excluirá todos os seus dados e não poderão ser recuperados. Continuar?", success: "Sucesso", copied_to_clipboard: "Copiado para a área de transferência", // Calendar price: "Preço", category: "Categoria", paid_by: "Pago por", payment_method: "Método de pagamento", notes: "Notas", export: "Exportar", } ================================================ FILE: scripts/i18n/ro.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Eroare la reîncărcarea abonamentului:", error_fetching_image_results: "Eroare la încărcarea imaginilor:", subscription_deleted: "Abonamentul a fost șters", error_deleting_subscription: "Eroare la ștergerea abonamentului", failed_to_load_subscription: "Nu s-a reușit încărcarea abonamentului", edit_subscription: "Modifică abonamentul", add_subscription: "Adaugă abonament", confirm_delete_subscription: "Ești sigur că vrei să ștergi acest abonament?", // Settings network_response_error: "Răspuns de rețea invalid", failed_add_member: "Nu s-a putut adăuga membrul", member: "Membru", email: "Email", firstname: "Prenume", lastname: "Nume", save_member: "Salvează membru", delete_member: "Șterge membru", failed_remove_member: "Nu s-a putut elimina membrul", failed_save_member: "Nu s-a putut salva membrul", failed_add_category: "Nu s-a putut adăuga categoria", category: "Categorie", save_category: "Salvează categorie", delete_category: "Șterge categorie", failed_remove_category: "Nu s-a putut șterge categoria", currency: "Valută", currency_code: "Code de valută", save_currency: "Salvează valuta", delete_currency: "Șterge valuta", failed_remove_currency: "Nu s-a putut șterge valuta", failed_save_currency: "Nu s-a putut salva valuta", cant_disable_payment_in_use: "Nu se poate dezactiva metoda de plată în uz", failed_save_payment_method: "Nu s-a putut salva metoda de plată", unknown_error: "Eroare necunoscută, te rugăm să încerci din nou.", error_saving_notification_data: "Eroare la salvarea datelor de notificare", error_sending_notification: "Eroare la trimiterea notificării", delete_account_confirmation: "Ești sigur că vrei să-ți ștergi contul?", this_will_delete_all_data: "Această acțiune va șterge toate datele tale și nu poate fi anulată. Vrei să continui?", success: "Succes", copied_to_clipboard: "Copiat în clipboard", // Calendar price: "Preț", category: "Categorie", paid_by: "Plătit de", payment_method: ",Metoda de plată", notes: "Notițe", export: "Export", } ================================================ FILE: scripts/i18n/ru.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Ошибка перезагрузки подписки:", error_fetching_image_results: "Ошибка при получении результатов изображения:", subscription_deleted: "Подписка удалена", error_deleting_subscription: "Ошибка удаления подписки", failed_to_load_subscription: "Не удалось загрузить подписку", edit_subscription: "Изменить подписку", add_subscription: "Добавить подписку", confirm_delete_subscription: "Вы уверены, что хотите удалить эту подписку?", // Settings network_response_error: "Отсутствует сетевое соединение", failed_add_member: "Не удалось добавить пользователя", member: "Пользователь", email: "Электронная почта", firstname: "Имя", lastname: "Фамилия", save_member: "Сохранить пользователя", delete_member: "Удалить пользователя", failed_remove_member: "Не удалось удалить пользователя", failed_save_member: "Не удалось сохранить пользователя", failed_add_category: "Не удалось добавить категорию", category: "Категория", save_category: "Сохранить категорию", delete_category: "Удалить категорию", failed_remove_category: "Не удалось удалить категорию", currency: "Валюта", currency_code: "Код валюты", save_currency: "Сохранить валюту", delete_currency: "Удалить валюту", failed_remove_currency: "Не удалось удалить валюту.", failed_save_currency: "Не удалось сохранить валюту.", cant_disable_payment_in_use: "Невозможно отключить используемый платеж", failed_save_payment_method: "Не удалось сохранить способ оплаты.", unknown_error: "Неизвестная ошибка. Повторите попытку.", error_saving_notification_data: "Ошибка сохранения данных уведомления.", error_sending_notification: "Ошибка отправки уведомления", delete_account_confirmation: "Вы уверены, что хотите удалить свою учетную запись?", this_will_delete_all_data: "Это удалит все ваши данные и не может быть отменено. Продолжить?", success: "Успешно", copied_to_clipboard: "Скопировано в буфер обмена", // Calendar price: "Цена", category: "Категория", paid_by: "Оплачено", payment_method: "Способ оплаты", notes: "Примечания", export: "Экспорт", } ================================================ FILE: scripts/i18n/sl.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Napaka pri ponovnem nalaganju naročnine:", error_fetching_image_results: "Napaka pri pridobivanju rezultatov slik:", subscription_deleted: "Naročnina je izbrisana", error_deleting_subscription: "Napaka pri brisanju naročnine", failed_to_load_subscription: "Nalaganje naročnine ni uspelo", edit_subscription: "Uredi naročnino", add_subscription: "Dodaj naročnino", confirm_delete_subscription: "Ali ste prepričani, da želite izbrisati to naročnino?", // Settings network_response_error: "Odziv omrežja ni bil v redu", failed_add_member: "Dodajanje člana ni uspelo", member: "Član", email: "E-pošta", firstname: "Ime", lastname: "Priimek", save_member: "Shrani člana", delete_member: "Izbriši člana", failed_remove_member: "Odstranitev člana ni uspela", failed_save_member: "Člana ni bilo mogoče shraniti", failed_add_category: "Dodajanje kategorije ni uspelo", category: "Kategorija", save_category: "Shrani kategorijo", delete_category: "Izbriši kategorijo", failed_remove_category: "Odstranitev kategorije ni uspela", currency: "Valuta", currency_code: "Koda valute", save_currency: "Shrani valuto", delete_currency: "Izbriši valuto", failed_remove_currency: "Odstranitev valute ni uspela", failed_save_currency: "valute ni bilo mogoče shraniti", cant_disable_payment_in_use: "Plačila v uporabi ni mogoče onemogočiti", failed_save_payment_method: "Način plačila ni uspel shraniti", unknown_error: "Neznana napaka, poskusite znova.", error_saving_notification_data: "Napaka pri shranjevanju obvestilnih podatkov", error_sending_notification: "Napaka pri pošiljanju obvestila", delete_account_confirmation: "Ali ste prepričani, da želite izbrisati svoj račun?", this_will_delete_all_data: "To bo izbrisalo vse vaše podatke in jih ni mogoče obnoviti. Nadaljujem?", success: "Uspeh", copied_to_clipboard: "Kopirano v odložišče", // Calendar price: "Cena", category: "Kategorija", paid_by: "Plačal/a", payment_method: "Način plačila", notes: "Opombe", export: "Izvozi", } ================================================ FILE: scripts/i18n/sr.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Грешка при освежавању претплате:", error_fetching_image_results: "Грешка при преузимању резултата слика:", subscription_deleted: "Претплата је избрисана", error_deleting_subscription: "Грешка при брисању претплате", failed_to_load_subscription: "Неуспешно учитавање претплате", edit_subscription: "Уреди претплату", add_subscription: "Додај претплату", confirm_delete_subscription: "Да ли сте сигурни да желите да избришете ову претплату?", // Settings network_response_error: "Мрежни одговор није био у реду", failed_add_member: "Неуспешно додавање члана", member: "Члан", email: "Е-пошта", firstname: "Име", lastname: "Презиме", save_member: "Сачувај члана", delete_member: "Избриши члана", failed_remove_member: "Неуспешно уклањање члана", failed_save_member: "Неуспешно чување члана", failed_add_category: "Неуспешно додавање категорије", category: "Категорија", save_category: "Сачувај категорију", delete_category: "Избриши категорију", failed_remove_category: "Неуспешно уклањање категорије", currency: "Валута", currency_code: "Кôд валуте", save_currency: "Сачувај валуту", delete_currency: "Избриши валуту", failed_remove_currency: "Неуспешно уклањање валуте", failed_save_currency: "Неуспешно чување валуте", cant_disable_payment_in_use: "Није могуће онемогућити плаћање у употреби", failed_save_payment_method: "Неуспешно чување начина плаћања", unknown_error: "Непозната грешка, молимо покушајте поново.", error_saving_notification_data: "Грешка при чувању података о обавештењима", error_sending_notification: "Грешка при слању обавештења", delete_account_confirmation: "Да ли сте сигурни да желите да избришете свој налог?", this_will_delete_all_data: "Ово ће избрисати све ваше податке и не може се поништити. Настави?", success: "Успех", copied_to_clipboard: "Копирано у привремену меморију", // Calendar price: "Цена", category: "Категорија", paid_by: "Плаћено од стране", payment_method: "Метод плаћања", notes: "Белешке", export: "Извоз", } ================================================ FILE: scripts/i18n/sr_lat.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Greška pri osvežavanju pretplate:", error_fetching_image_results: "Greška pri preuzimanju rezultata slika:", subscription_deleted: "Pretplata je izbrisana", error_deleting_subscription: "Greška pri brisanju pretplate", failed_to_load_subscription: "Neuspešno učitavanje pretplate", edit_subscription: "Uredi pretplatu", add_subscription: "Dodaj pretplatu", confirm_delete_subscription: "Da li ste sigurni da želite da izbrišete ovu pretplatu?", // Settings network_response_error: "Mrežni odgovor nije bio u redu", failed_add_member: "Neuspešno dodavanje člana", member: "Član", email: "E-pošta", firstname: "Име", lastname: "Презиме", save_member: "Sačuvaj člana", delete_member: "Izbriši člana", failed_remove_member: "Neuspešno uklanjanje člana", failed_save_member: "Neuspešno čuvanje člana", failed_add_category: "Neuspešno dodavanje kategorije", category: "Kategorija", save_category: "Sačuvaj kategoriju", delete_category: "Izbriši kategoriju", failed_remove_category: "Neuspešno uklanjanje kategorije", currency: "Valuta", currency_code: "Kôd valute", save_currency: "Sačuvaj valutu", delete_currency: "Izbriši valutu", failed_remove_currency: "Neuspešno uklanjanje valute", failed_save_currency: "Neuspešno čuvanje valute", cant_disable_payment_in_use: "Nije moguće onemogućiti plaćanje u upotrebi", failed_save_payment_method: "Neuspešno čuvanje načina plaćanja", unknown_error: "Nepoznata greška, molimo pokušajte ponovo.", error_saving_notification_data: "Greška pri čuvanju podataka o obaveštenjima", error_sending_notification: "Greška pri slanju obaveštenja", delete_account_confirmation: "Da li ste sigurni da želite da izbrišete svoj nalog?", this_will_delete_all_data: "Ovo će izbrisati sve vaše podatke i ne može se poništiti. Da li nastaviti?", success: "Uspeh", copied_to_clipboard: "Kopirano u privremenu memoriju", // Calendar price: "Cena", category: "Kategorija", paid_by: "Platio/la", payment_method: "Način plaćanja", notes: "Beleške", export: "Izvezi", } ================================================ FILE: scripts/i18n/tr.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Abonelik yeniden yüklenirken hata oluştu:", error_fetching_image_results: "Görüntü sonuçları alınırken hata oluştu:", subscription_deleted: "Abonelik silindi", error_deleting_subscription: "Abonelik silinirken hata oluştu", failed_to_load_subscription: "Abonelik yüklenemedi", edit_subscription: "Aboneliği Düzenle", add_subscription: "Abonelik Ekle", confirm_delete_subscription: "Bu aboneliği silmek istediğinizden emin misiniz?", // Ayarlar network_response_error: "Ağ yanıtı kabul edilmedi", failed_add_member: "Üye eklenemedi", member: "Üye", email: "E-posta", firstname: "İlk adı", lastname: "Soy isim", save_member: "Üyeyi Kaydet", delete_member: "Üyeyi Sil", failed_remove_member: "Üye silinmedi", failed_save_member: "Üye kaydedilemedi", failed_add_category: "Kategori eklenemedi", category: "Kategori", save_category: "Kategoriyi Kaydet", delete_category: "Kategoriyi Sil", failed_remove_category: "Kategori silinmedi", currency: "Para Birimi", currency_code: "Para Birimi Kodu", save_currency: "Para Birimini Kaydet", delete_currency: "Para Birimini Sil", failed_remove_currency: "Para birimi kaldırılamadı", failed_save_currency: "Para birimi kaydedilemedi", cant_disable_payment_in_use: "Kullanımdaki ödemeyi devre dışı bırakamazsınız", failed_save_payment_method: "Ödeme yöntemi kaydedilemedi", unknown_error: "Bilinmeyen hata, lütfen tekrar deneyin.", error_saving_notification_data: "Bildirim verisi kaydedilirken hata oluştu", error_sending_notification: "Bildirim gönderilirken hata oluştu", delete_account_confirmation: "Hesabınızı silmek istediğinizden emin misiniz?", this_will_delete_all_data: "Bu tüm verilerinizi silecek ve geri alınamaz. Devam etmek istiyor musunuz?", success: "Başarılı", copied_to_clipboard: "Panoya kopyalandı", // Calendar price: "Price", category: "Category", paid_by: "Paid by", payment_method: "Payment method", notes: "Notes", export: "Export", } ================================================ FILE: scripts/i18n/uk.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Помилка перезавантаження підписки:", error_fetching_image_results: "Помилка при отриманні результатів зображення:", subscription_deleted: "Підписка видалена", error_deleting_subscription: "Помилка видалення підписки", failed_to_load_subscription: "Не вдалося завантажити підписку", edit_subscription: "Редагувати підписку", add_subscription: "Додати підписку", confirm_delete_subscription: "Ви впевнені, що хочете видалити цю підписку?", // Settings network_response_error: "Відсутнє мережеве з'єднання", failed_add_member: "Не вдалося додати користувача", member: "Користувач", email: "Електронна пошта", firstname: "Ім'я", lastname: "Прізвище", save_member: "Зберегти користувача", delete_member: "Видалити користувача", failed_remove_member: "Не вдалося видалити користувача", failed_save_member: "Не вдалося зберегти користувача", failed_add_category: "Не вдалося додати категорію", category: "Категорія", save_category: "Зберегти категорію", delete_category: "Видалити категорію", failed_remove_category: "Не вдалося видалити категорію", currency: "Валюта", currency_code: "Код валюти", save_currency: "Зберегти валюту", delete_currency: "Видалити валюту", failed_remove_currency: "Не вдалося видалити валюту.", failed_save_currency: "Не вдалося зберегти валюту.", cant_disable_payment_in_use: "Неможливо відключити використовуваний платіж", failed_save_payment_method: "Не вдалося зберегти спосіб оплати.", unknown_error: "Невідома помилка. Повторіть спробу.", error_saving_notification_data: "Помилка збереження даних сповіщення.", error_sending_notification: "Помилка відправки сповіщення", delete_account_confirmation: "Ви впевнені, що хочете видалити свій обліковий запис?", this_will_delete_all_data: "Це видалить всі ваші дані і не може бути скасовано. Продовжити?", success: "Успішно", copied_to_clipboard: "Скопійовано в буфер обміну", // Calendar price: "Ціна", category: "Категорія", paid_by: "Оплачено", payment_method: "Спосіб оплати", notes: "Примітки", export: "Експорт", } ================================================ FILE: scripts/i18n/vi.js ================================================ let i18n = { // Dashboard error_reloading_subscription: "Lỗi tải lại đăng ký:", error_fetching_image_results: "Lỗi khi tìm nạp kết quả hình ảnh:", subscription_deleted: "Đã xóa đăng ký", error_deleting_subscription: "Lỗi khi xóa đăng ký", failed_to_load_subscription: "Không thể tải đăng ký", edit_subscription: "Chỉnh sửa đăng ký", add_subscription: "Thêm đăng ký", confirm_delete_subscription: "Bạn có chắc chắn muốn xóa đăng ký này không?", // Settings network_response_error: "Phản hồi mạng không ổn", failed_add_member: "Không thể thêm thành viên", member: "Thành viên", email: "Email", firstname: "Tên đầu tiên", lastname: "Họ", save_member: "Lưu thành viên", delete_member: "Xóa thành viên", failed_remove_member: "Không thể xóa thành viên", failed_save_member: "Không thể lưu thành viên", failed_add_category: "Không thể thêm danh mục", category: "Danh mục", save_category: "Lưu danh mục", delete_category: "Xóa danh mục", failed_remove_category: "Không thể xóa danh mục", currency: "Tiền tệ", currency_code: "Mã tiền tệ", save_currency: "Lưu tiền tệ", delete_currency: "Xóa tiền tệ", failed_remove_currency: "Không thể xóa tiền tệ", failed_save_currency: "Không thể lưu tiền tệ", cant_disable_payment_in_use: "Không thể vô hiệu hóa phương thức thanh toán đang được sử dụng", failed_save_payment_method: "Không thể lưu phương thức thanh toán", unknown_error: "Lỗi không xác định, vui lòng thử lại.", error_saving_notification_data: "Lỗi khi lưu dữ liệu thông báo", error_sending_notification: "Lỗi khi gửi thông báo", delete_account_confirmation: "Bạn có chắc chắn muốn xóa tài khoản của mình không?", this_will_delete_all_data: "Điều này sẽ xóa tất cả dữ liệu của bạn và không thể hoàn tác. Tiếp tục?", success: "Thành công", copied_to_clipboard: "Đã sao chép vào bảng tạm", // Calendar price: "Giá", category: "Danh mục", paid_by: "Người thanh toán", payment_method: "Phương thức thanh toán", notes: "Ghi chú", export: "Xuất", } ================================================ FILE: scripts/i18n/zh_cn.js ================================================ let i18n = { // Dashboard 'error_reloading_subscription': '重新加载订阅时出错:', 'error_fetching_image_results': '获取图片结果时出错:', 'subscription_deleted': '订阅已删除', 'error_deleting_subscription': "删除订阅时出错", 'failed_to_load_subscription': "加载订阅失败", 'edit_subscription': "编辑订阅", 'add_subscription': "添加订阅", 'confirm_delete_subscription': "您确定要删除此订阅吗?", // Settings 'network_response_error': "网络响应不正常", 'failed_add_member': '添加成员失败', 'member': '成员', 'email': '电子邮箱', 'firstname': '名', 'lastname': '姓', 'save_member': '保存成员', 'delete_member': '删除成员', 'failed_remove_member': '移除成员失败', 'failed_save_member': '保存成员失败', 'failed_add_category': '添加类别失败', 'category': '类别', 'save_category': '保存类别', 'delete_category': '删除类别', 'failed_remove_category': '移除类别失败', 'currency': '货币', 'currency_code': '货币代码', 'save_currency': '保存货币', 'delete_currency': '删除货币', 'failed_remove_currency': '移除货币失败', 'failed_save_currency': '保存货币失败', 'cant_disable_payment_in_use': '无法禁用正在使用的支付方式', 'failed_save_payment_method': '保存支付方式失败', 'unknown_error': '未知错误,请重试。', 'error_saving_notification_data': '保存通知数据时出错', 'error_sending_notification': '发送通知时出错', 'delete_account_confirmation': "您确定要删除您的帐户吗?", 'this_will_delete_all_data': "这将删除所有您的数据,且无法撤销。是否继续?", 'success': "成功", 'copied_to_clipboard': "已复制到剪贴板", // Calendar price: "价格", category: "类别", paid_by: "支付者", payment_method: "支付方式", notes: "备注", export: "导出", }; ================================================ FILE: scripts/i18n/zh_tw.js ================================================ let i18n = { // Dashboard error_reloading_subscription: '重新讀取訂閱時發生錯誤:', error_fetching_image_results: '抓取圖片時發生錯誤:', subscription_deleted: '訂閱已刪除', error_deleting_subscription: "刪除訂閱時發生錯誤", failed_to_load_subscription: "讀取訂閱失敗", edit_subscription: "編輯訂閱", add_subscription: "新增訂閱", confirm_delete_subscription: "您確定要刪除此訂閱嗎?", // Settings network_response_error: "網路無回應", failed_add_member: '新增成員失敗', member: '成員', email: '電子信箱', firstname: '名', lastname: '姓', save_member: '保存成員', delete_member: '刪除成員', failed_remove_member: '移除成員失敗', failed_save_member: '保存成員失敗', failed_add_category: '新增類別失敗', category: '類別', save_category: '保存類別', delete_category: '刪除類別', failed_remove_category: '移除類別失敗', currency: '貨幣', currency_code: '貨幣代碼', save_currency: '保存貨幣', delete_currency: '刪除貨幣', failed_remove_currency: '移除貨幣失敗', failed_save_currency: '保存貨幣失敗', cant_disable_payment_in_use: '無法停用正在使用中的支付方式', failed_save_payment_method: '保存支付方式失敗', unknown_error: '發生未知的錯誤,請再試一次。', error_saving_notification_data: '保存通知資料時發生錯誤', error_sending_notification: '發送通知時發生錯誤', delete_account_confirmation: "您確定要刪除您的帳戶嗎?", this_will_delete_all_data: "這將刪除所有資料,且無法復原。繼續?", success: "成功", copied_to_clipboard: "已複製到剪貼簿", // Calendar price: "價格", category: "類別", paid_by: "支付者", payment_method: "支付方式", notes: "備註", export: "匯出", }; ================================================ FILE: scripts/libs/chart.js ================================================ /** * Skipped minification because the original files appears to be already minified. * Original file: /npm/chart.js@4.4.0/dist/chart.umd.js * * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files */ /*! * Chart.js v4.4.0 * https://www.chartjs.org * (c) 2023 Chart.js Contributors * Released under the MIT License */ !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Go},get Decimation(){return Qo},get Filler(){return ma},get Legend(){return ya},get SubTitle(){return ka},get Title(){return Ma},get Tooltip(){return Ba}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const s=e.length;let n=0,o=s;if(t._sorted){const{iScale:a,_parsed:r}=t,l=a.axis,{min:h,max:c,minDefined:d,maxDefined:u}=a.getUserBounds();d&&(n=J(Math.min(it(r,l,h).lo,i?s:it(e,l,a.getPixelForValue(h)).lo),0,s-1)),o=u?J(Math.max(it(r,a.axis,c,!0).hi+1,i?0:it(e,l,a.getPixelForValue(c),!0).hi+1),n,s)-n:s-n}return{start:n,count:o}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class bt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var xt=new bt; /*! * @kurkle/color v0.3.2 * https://github.com/kurkle/color#readme * (c) 2023 Jukka Kurkela * Released under the MIT License */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Zt{constructor(t){if(t instanceof Zt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Zt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Jt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Jt(t)?t:new Zt(t)}function te(t){return Jt(t)?t:new Zt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function be(t,e){return me(t).getPropertyValue(e)}const xe=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=xe[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Math.floor(t.height*s),o=Math.floor(t.width*s);t.height=Math.floor(t.height),t.width=Math.floor(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};window.addEventListener("test",null,e),window.removeEventListener("test",null,e)}catch(t){}return t}();function Pe(t,e){const i=be(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Je(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Je(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Je(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e))return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Ze(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Je(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Ze(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const bi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,xi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(bi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(xi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:Z,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hx||l(n,b,p)&&0!==r(n,b),v=()=>!x||0===r(o,p)||l(o,b,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==b&&(x=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,b=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r{t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Xi={evaluateInteractionItems:Hi,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tji(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Yi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>Ui(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>Ui(t,ve(e,t),"y",i.intersect,s)}};const qi=["left","top","right","bottom"];function Ki(t,e){return t.filter((t=>t.pos===e))}function Gi(t,e){return t.filter((t=>-1===qi.indexOf(t.pos)&&t.box.axis===e))}function Zi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function Ji(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!qi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function ss(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Zi(Ki(e,"left"),!0),n=Zi(Ki(e,"right")),o=Zi(Ki(e,"top"),!0),a=Zi(Ki(e,"bottom")),r=Gi(e,"x"),l=Gi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ki(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);ts(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=Ji(l.concat(h),d);ss(r.fullSize,g,d,p),ss(l,g,d,p),ss(h,g,d,p)&&ss(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),os(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,os(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class rs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class ls extends rs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const hs="$chartjs",cs={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},ds=t=>null===t||""===t;const us=!!Se&&{passive:!0};function fs(t,e,i){t.canvas.removeEventListener(e,i,us)}function gs(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function ps(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.addedNodes,s),e=e&&!gs(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function ms(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.removedNodes,s),e=e&&!gs(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const bs=new Map;let xs=0;function _s(){const t=window.devicePixelRatio;t!==xs&&(xs=t,bs.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function ys(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){bs.size||window.addEventListener("resize",_s),bs.set(t,e)}(t,o),a}function vs(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){bs.delete(t),bs.size||window.removeEventListener("resize",_s)}(t)}function Ms(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=cs[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t.addEventListener(e,i,us)}(s,e,n),n}class ws extends rs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[hs]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",ds(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(ds(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[hs])return!1;const i=e[hs].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[hs],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:ps,detach:ms,resize:ys}[e]||Ms;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:vs,detach:vs,resize:vs}[e]||fs)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=ge(t);return!(!e||!e.isConnected)}}function ks(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?ls:ws}var Ss=Object.freeze({__proto__:null,BasePlatform:rs,BasicPlatform:ls,DomPlatform:ws,_detectPlatform:ks});const Ps="transparent",Ds={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Ps),n=s.valid&&Qt(e||Ps);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class Cs{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Ds[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new Cs(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(xt.add(this._chart,i),!0):void 0}}function As(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Ts(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function zs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Vs(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Bs=t=>"reset"===t||"none"===t,Ws=(t,e)=>e?t:Object.assign({},t);class Ns{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Es(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Vs(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Fs(t,"x")),o=e.yAxisID=l(i.yAxisID,Fs(t,"y")),a=e.rAxisID=l(i.rAxisID,Fs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Vs(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e))this._data=function(t){const e=Object.keys(t),i=new Array(e.length);let s,n,o;for(s=0,n=e.length;s0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Ts(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Ws(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Os(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Bs(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Bs(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Bs(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function js(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for($s(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,Us=(t,e)=>Math.min(e||t,t);function Xs(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Ks(t){return t.drawTicks?t.tickLength:0}function Gs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Zs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class Js extends Hs{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=J(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Ks(t.grid)-e.padding-Gs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(J((h.highest.height+6)/o,-1,1)),Math.asin(J(a/r,-1,1))-Math.asin(J(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Gs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Ks(n)+o):(t.height=this.maxHeight,t.width=Ks(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Ks(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,b=function(t){return Ae(i,t,p)};let x,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)x=b(this.bottom),w=this.bottom-u,S=x-m,D=b(t.top)+m,O=t.bottom;else if("bottom"===a)x=b(this.top),D=t.top,O=b(t.bottom)-m,w=x+m,S=this.top+u;else if("left"===a)x=b(this.right),M=this.right-u,k=x-m,P=b(t.left)+m,C=t.right;else if("right"===a)x=b(this.left),P=t.left,C=b(t.right)-m,M=x+m,k=this.left+u;else if("x"===e){if("center"===a)x=b((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=x+m,S=w+u}else if("y"===e){if("center"===a)x=b((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}M=x-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class tn{constructor(){this.controllers=new Qs(Ns,"datasets",!0),this.elements=new Qs(Hs,"elements"),this.plugins=new Qs(Object,"plugins"),this.scales=new Qs(Js,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function nn(t,e){return e||!1!==t?!0===t?{}:t:null}function on(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function an(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function rn(t){if("x"===t||"y"===t||"r"===t)return t}function ln(t,...e){if(rn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&rn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function hn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function cn(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=an(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=ln(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return hn(t,"x",i[0])||hn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=x(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||an(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),x(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];x(e,[ue.scales[e.type],ue.scale])})),a}function dn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=cn(t,e)}function un(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const fn=new Map,gn=new Set;function pn(t,e){let i=fn.get(t);return i||(i=e(),fn.set(t,i),gn.add(i)),i}const mn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class bn{constructor(t){this._config=function(t){return(t=t||{}).data=un(t.data),dn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=un(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),dn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return pn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return pn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return pn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return pn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>mn(r,t,e)))),e.forEach((t=>mn(r,s,t))),e.forEach((t=>mn(r,re[n]||{},t))),e.forEach((t=>mn(r,ue,t))),e.forEach((t=>mn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),gn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=xn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||_n(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=xn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function xn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const _n=t=>o(t)&&Object.getOwnPropertyNames(t).reduce(((e,i)=>e||S(t[i])),!1);const yn=["top","bottom","left","right","chartArea"];function vn(t,e){return"top"===t||"bottom"===t||-1===yn.indexOf(t)&&"x"===e}function Mn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function wn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function kn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Sn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Pn={},Dn=t=>{const e=Sn(t);return Object.values(Pn).filter((t=>t.canvas===e)).pop()};function Cn(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}function On(t,e,i){return t.options.clip?t[i]:e[i]}class An{static defaults=ue;static instances=Pn;static overrides=re;static registry=en;static version="4.4.0";static getChart=Dn;static register(...t){en.add(...t),Tn()}static unregister(...t){en.remove(...t),Tn()}constructor(t,e){const s=this.config=new bn(e),n=Sn(t),o=Dn(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||ks(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new sn,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Pn[this.id]=this,r&&l?(xt.listen(this,"complete",wn),xt.listen(this,"progress",kn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return en}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return xt.stop(this),this}resize(t,e){xt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=ln(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=ln(o,n),r=l(n.type,e.dtype);void 0!==n.position&&vn(n.position,a)===vn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(en.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{as.configure(this,t,t.options),as.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(Mn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{as.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){Cn(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;as.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i=t._clip,s=!i.disabled,n=function(t,e){const{xScale:i,yScale:s}=t;return i&&s?{left:On(i,e,"left"),right:On(i,e,"right"),top:On(s,e,"top"),bottom:On(s,e,"bottom")}:e}(t,this.chartArea),o={meta:t,index:t.index,cancelable:!0};!1!==this.notifyPlugins("beforeDatasetDraw",o)&&(s&&Ie(e,{left:!1===i.left?0:n.left-i.left,right:!1===i.right?this.width:n.right+i.right,top:!1===i.top?0:n.top-i.top,bottom:!1===i.bottom?this.height:n.bottom+i.bottom}),t.controller.draw(),s&&ze(e),o.cancelable=!1,this.notifyPlugins("afterDatasetDraw",o))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Xi.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),xt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Tn(){return u(An.instances,(t=>t._plugins.invalidate()))}function Ln(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class En{static override(t){Object.assign(En.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return Ln()}parse(){return Ln()}format(){return Ln()}add(){return Ln()}diff(){return Ln()}startOf(){return Ln()}endOf(){return Ln()}}var Rn={_date:En};function In(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Fn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i,color:s}}=t.legend.options;return e.labels.map(((e,n)=>{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nZ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>Z(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),b=g(C,h,d),x=g(C+E,c,u);s=(p-b)/2,n=(m-x)/2,o=-(p+b)/2,a=-(m+x)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),b=(i.width-o)/f,x=(i.height-o)/g,_=Math.max(Math.min(b,x)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Yn=Object.freeze({__proto__:null,BarController:class extends Ns{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Fn(t,e,i,s)}parseArrayData(t,e,i,s){return Fn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=t=>{const i=t.controller.getParsed(e),n=i&&i[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!r(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(b-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);b=Math.max(Math.min(b,h),o),d=b+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(b))}if(b===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;b+=t,u-=t}return{size:u,base:b,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,l="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=b?g:{};if(i=x){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),b||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends jn{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:$n,RadarController:class extends Ns{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>b,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),x||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Un(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return J(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:J(n.innerStart,0,a),innerEnd:J(n.innerEnd,0,a)}}function Xn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function qn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,b=n-p-f,{outerStart:x,outerEnd:_,innerStart:y,innerEnd:v}=Un(e,u,d,b-m),M=d-x,w=d-_,k=m+x/M,S=b-_/w,P=u+y,D=u+v,O=m+y/P,A=b-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=Xn(w,S,a,r);t.arc(e.x,e.y,_,S,b+E)}const i=Xn(D,b,a,r);if(t.lineTo(i.x,i.y),v>0){const e=Xn(D,A,a,r);t.arc(e.x,e.y,v,b+E,A+Math.PI)}const s=(b-v/u+(m+y/u))/2;if(t.arc(a,r,u,b-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=Xn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=Xn(M,m,a,r);if(t.lineTo(n.x,n.y),x>0){const e=Xn(M,k,a,r);t.arc(e.x,e.y,x,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Kn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u}=l,f="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,f?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let g=e.endAngle;if(o){qn(t,e,i,s,g,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,g),o||(qn(t,e,i,s,g,n),t.stroke())}function Gn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Jn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[x(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[x(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(b*m+e)/++b):(_(),t.lineTo(e,i),u=s,b=0,f=g=i),p=i}_()}function eo(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?to:Qn}const io="function"==typeof Path2D;function so(t,e,i,s){io&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Gn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=eo(e);for(const r of n)Gn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class no extends Hs{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a)>=O||Z(n,a,r),g=tt(o,h+u,c+u);return f&&g}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){qn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function po(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,b=!s(a),x=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!b&&!x)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),b&&x&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=b?a:M,w=x?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(b&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return x&&u&&w!==r?i.length&&V(i[i.length-1].value,r,mo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):x&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class xo extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const _o=t=>Math.floor(z(t)),yo=(t,e)=>Math.pow(10,_o(t)+e);function vo(t){return 1===t/Math.pow(10,_o(t))}function Mo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function wo(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=_o(e);let o=function(t,e){let i=_o(e-t);for(;Mo(t,e,i)>10;)i++;for(;Mo(t,e,i)<10;)i--;return Math.min(i,_o(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:vo(g),significand:u}),s}class ko extends Js{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===yo(this.min,0)?yo(this.min,-1):yo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(yo(i,-1)),o(yo(s,1)))),i<=0&&n(yo(s,-1)),s<=0&&o(yo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=wo({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function So(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Po(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Do(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Oo(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function Ao(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function To(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Lo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(So(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/So(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Do(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));To(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash),o.lineDashOffset=n.dashOffset,o.beginPath(),Lo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.ticks.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Ro={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},Io=Object.keys(Ro);function zo(t,e){return t-e}function Fo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Vo(t,e,i,s){const n=Io.length;for(let o=Io.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function Wo(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class No extends Js{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new Rn._date(t.adapters.date);s.init(e),x(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Fo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Vo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=Io.length-1;o>=Io.indexOf(i);o--){const i=Io[o];if(Ro[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return Io[i?Io.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=Io.indexOf(t)+1,i=Io.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=J(s,0,o),n=J(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Vo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var jo=Object.freeze({__proto__:null,CategoryScale:class extends Js{static id="category";static defaults={ticks:{callback:po}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:J(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:go(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return po.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:xo,LogarithmicScale:ko,RadialLinearScale:Eo,TimeScale:No,TimeSeriesScale:class extends No{static id="timeseries";static defaults=No.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=Ho(e,this.min),this._tableRange=Ho(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(Ho(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return Ho(this._table,i*this._tableRange+this._minPos,!0)}}});const $o=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Yo=$o.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Uo(t){return $o[t%$o.length]}function Xo(t){return Yo[t%Yo.length]}function qo(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof jn?e=function(t,e){return t.backgroundColor=t.data.map((()=>Uo(e++))),e}(i,e):n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Uo(e),t.backgroundColor=Xo(e),++e}(i,e))}}function Ko(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Go={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n;if(!i.forceOverride&&(Ko(s)||(a=n)&&(a.borderColor||a.backgroundColor)||o&&Ko(o)))return;var a;const r=qo(t);s.forEach(r)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Jo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var Qo={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Jo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=J(it(e,o.axis,a).lo,0,i-1)),s=h?J(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const b=[],x=e+i-1,_=t[e].x,y=t[x].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&b.push({...t[e],x:p}),s!==u&&s!==i&&b.push({...t[s],x:p})}o>0&&i!==u&&b.push(t[i]),b.push(a),h=e,m=0,f=g=l,c=d=u=o}}return b}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Jo(t)}};function ta(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ea(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function ia(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function sa(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ea(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new no({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function na(t){return t&&!1!==t.fill}function oa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function aa(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function ra(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&da(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;na(i)&&da(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;na(s)&&"beforeDatasetDraw"===i.drawTime&&da(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const ba=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class xa extends Hs{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=ba(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=_a(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=ba(o,d),b=this.isHorizontal(),x=this._computeTitleHeight();f=b?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+x,line:0}:{x:this.left+c,y:ft(n,this.top+x+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),b?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+x+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,b?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),b)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=_a(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class va extends Hs{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var Ma={id:"title",_element:va,start(t,e,i){!function(t,e){const i=new va({ctx:t.ctx,options:e,chart:t});as.configure(t,i,e),as.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;as.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const wa=new WeakMap;var ka={id:"subtitle",start(t,e,i){const s=new va({ctx:t.ctx,options:i,chart:t});as.configure(t,s,i),as.addBox(t,s),wa.set(t,s)},stop(t){as.removeBox(t,wa.get(t)),wa.delete(t)},beforeUpdate(t,e,i){const s=wa.get(t);as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa={average(t){if(!t.length)return!1;let e,i,s=0,n=0,o=0;for(e=0,i=t.length;e-1?t.split("\n"):t}function Ca(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Oa(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,b=0,x=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(x+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),x){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(x-g)*l.lineHeight+(x-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){b=Math.max(b,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),b+=p.width,{width:b,height:m}}function Aa(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ta(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||Aa(t,e,i,s),yAlign:s}}function La(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:J(g,0,s.width-e.width),y:J(p,0,s.height-e.height)}}function Ea(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function Ra(t){return Pa([],Da(t))}function Ia(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const za={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Ia(i,t);Pa(e.before,Da(Fa(n,"beforeLabel",this,t))),Pa(e.lines,Fa(n,"label",this,t)),Pa(e.after,Da(Fa(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return Ra(Fa(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Fa(i,"beforeFooter",this,t),n=Fa(i,"footer",this,t),o=Fa(i,"afterFooter",this,t);let a=[];return a=Pa(a,Da(s)),a=Pa(a,Da(n)),a=Pa(a,Da(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Ia(t.callbacks,e);s.push(Fa(i,"labelColor",this,e)),n.push(Fa(i,"labelPointStyle",this,e)),o.push(Fa(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Sa[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Oa(this,i),a=Object.assign({},t,e),r=Ta(this.chart,i,a),l=La(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,b,x,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,x=_+o,y=_-o):(p=d+f,m=p+o,x=_-o,y=_+o),b=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(x=u,_=x-o,p=m-o,b=m+o):(x=u+g,_=x+o,p=m+o,b=m-o),y=x),{x1:p,x2:m,x3:b,y1:x,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ea(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let b,x,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ea(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Sa[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Oa(this,t),a=Object.assign({},i,this._size),r=Ta(e,t,a),l=La(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e;const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Sa[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Ba={id:"tooltip",_element:Va,positioners:Sa,afterInit(t,e,i){i&&(t.tooltip=new Va({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:za},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return An.register(Yn,jo,fo,t),An.helpers={...Wi},An._adapters=Rn,An.Animation=Cs,An.Animations=Os,An.animator=xt,An.controllers=en.controllers.items,An.DatasetController=Ns,An.Element=Hs,An.elements=fo,An.Interaction=Xi,An.layouts=as,An.platforms=Ss,An.Scale=Js,An.Ticks=ae,Object.assign(An,Yn,jo,fo,t,Ss),An.Chart=An,"undefined"!=typeof window&&(window.Chart=An),An})); //# sourceMappingURL=chart.umd.js.map ================================================ FILE: scripts/login.js ================================================ document.addEventListener('DOMContentLoaded', function () { const userLocale = navigator.language || navigator.languages[0]; document.cookie = `user_locale=${userLocale}; expires=Fri, 31 Dec 9999 23:59:59 GMT; SameSite=Strict`; if (window.update_theme_settings) { const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; const themePreference = prefersDarkMode ? 'dark' : 'light'; const darkThemeCss = document.querySelector("#dark-theme"); darkThemeCss.disabled = themePreference === 'light'; document.body.className = themePreference; const themeColorMetaTag = document.querySelector('meta[name="theme-color"]'); themeColorMetaTag.setAttribute('content', themePreference === 'dark' ? '#222222' : '#FFFFFF'); } }); ================================================ FILE: scripts/notifications.js ================================================ function openNotificationsSettings(type) { // Get all .account-notification-section-settings elements var sections = document.querySelectorAll('.account-notification-section-settings'); var targetSection = document.querySelector(`.account-notification-section-settings[data-type="${type}"]`); // Remove the is-open class from all elements sections.forEach(function(section) { if (section !== targetSection) { section.classList.remove('is-open'); } }); // Add the is-open class to the element with data-type=type if (targetSection && !targetSection.classList.contains('is-open')) { targetSection.classList.add('is-open'); } else { targetSection.classList.remove('is-open'); } } function makeFetchCall(url, data, button) { return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', "X-CSRF-Token": window.csrfToken, }, body: JSON.stringify(data), }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); } else { showErrorMessage(data.message); } button.disabled = false; }) .catch((error) => { showErrorMessage(error); button.disabled = false; }); } function saveNotifications() { const button = document.getElementById("saveNotifications"); button.disabled = true; const days = document.querySelector('#days').value; const url = 'endpoints/notifications/savenotificationsettings.php'; const data = { days: days }; makeFetchCall(url, data, button); } function saveNotificationsEmailButton() { const button = document.getElementById("saveNotificationsEmail"); button.disabled = true; const enabled = document.getElementById("emailenabled").checked ? 1 : 0; const smtpAddress = document.getElementById("smtpaddress").value; const smtpPort = document.getElementById("smtpport").value; const encryption = document.querySelector('input[name="encryption"]:checked').value; const smtpUsername = document.getElementById("smtpusername").value; const smtpPassword = document.getElementById("smtppassword").value; const fromEmail = document.getElementById("fromemail").value; const otherEmails = document.getElementById("otheremails").value; const data = { enabled: enabled, smtpaddress: smtpAddress, smtpport: smtpPort, encryption: encryption, smtpusername: smtpUsername, smtppassword: smtpPassword, fromemail: fromEmail, otheremails: otherEmails }; makeFetchCall('endpoints/notifications/saveemailnotifications.php', data, button); } function testNotificationEmailButton() { const button = document.getElementById("testNotificationsEmail"); button.disabled = true; const smtpAddress = document.getElementById("smtpaddress").value; const smtpPort = document.getElementById("smtpport").value; const encryption = document.querySelector('input[name="encryption"]:checked').value; const smtpUsername = document.getElementById("smtpusername").value; const smtpPassword = document.getElementById("smtppassword").value; const fromEmail = document.getElementById("fromemail").value; const data = { smtpaddress: smtpAddress, smtpport: smtpPort, encryption: encryption, smtpusername: smtpUsername, smtppassword: smtpPassword, fromemail: fromEmail }; makeFetchCall('endpoints/notifications/testemailnotifications.php', data, button); } function saveNotificationsWebhookButton() { const button = document.getElementById("saveNotificationsWebhook"); button.disabled = true; const enabled = document.getElementById("webhookenabled").checked ? 1 : 0; const webhook_url = document.getElementById("webhookurl").value; const headers = document.getElementById("webhookcustomheaders").value; const payload = document.getElementById("webhookpayload").value; const cancelation_payload = document.getElementById("webhookcancelationpayload").value; const ignore_ssl = document.getElementById("webhookignoressl").checked ? 1 : 0; const data = { enabled: enabled, webhook_url: webhook_url, headers: headers, payload: payload, cancelation_payload: cancelation_payload, ignore_ssl: ignore_ssl }; makeFetchCall('endpoints/notifications/savewebhooknotifications.php', data, button); } function testNotificationsWebhookButton() { const button = document.getElementById("testNotificationsWebhook"); button.disabled = true; const enabled = document.getElementById("webhookenabled").checked ? 1 : 0; const requestmethod = document.getElementById("webhookrequestmethod").value; const url = document.getElementById("webhookurl").value; const customheaders = document.getElementById("webhookcustomheaders").value; const payload = document.getElementById("webhookpayload").value; const cancelation_payload = document.getElementById("webhookcancelationpayload").value; const ignore_ssl = document.getElementById("webhookignoressl").checked ? 1 : 0; const data = { enabled: enabled, requestmethod: requestmethod, url: url, customheaders: customheaders, payload: payload, cancelation_payload: cancelation_payload, ignore_ssl: ignore_ssl }; makeFetchCall('endpoints/notifications/testwebhooknotifications.php', data, button); } function saveNotificationsTelegramButton() { const button = document.getElementById("saveNotificationsTelegram"); button.disabled = true; const enabled = document.getElementById("telegramenabled").checked ? 1 : 0; const chat_id = document.getElementById("telegramchatid").value; const bot_token = document.getElementById("telegrambottoken").value; const data = { enabled: enabled, chat_id: chat_id, bot_token: bot_token }; makeFetchCall('endpoints/notifications/savetelegramnotifications.php', data, button); } function testNotificationsTelegramButton() { const button = document.getElementById("testNotificationsTelegram"); button.disabled = true; const enabled = document.getElementById("telegramenabled").checked ? 1 : 0; const bottoken = document.getElementById("telegrambottoken").value; const chatid = document.getElementById("telegramchatid").value; const data = { enabled: enabled, bottoken: bottoken, chatid: chatid }; makeFetchCall('endpoints/notifications/testtelegramnotifications.php', data, button); } function testNotificationsPushPlusButton() { const button = document.getElementById("testNotificationsPushPlus"); button.disabled = true; const enabled = document.getElementById("pushplusenabled").checked ? 1 : 0; const token = document.getElementById("pushplustoken").value; const data = { enabled: enabled, token: token }; makeFetchCall('endpoints/notifications/testpushplusnotifications.php', data, button); } function saveNotificationsPushPlusButton() { const button = document.getElementById("saveNotificationsPushPlus"); button.disabled = true; const enabled = document.getElementById("pushplusenabled").checked ? 1 : 0; const token = document.getElementById("pushplustoken").value; const data = { enabled: enabled, token: token }; makeFetchCall('endpoints/notifications/savepushplusnotifications.php', data, button); } function testNotificationsMattermostButton() { const button = document.getElementById("testNotificationsMattermost"); button.disabled = true; const enabled = document.getElementById("mattermostenabled").checked ? 1 : 0; const webhook_url = document.getElementById("mattermostwebhookurl").value; const bot_username = document.getElementById("mattermostbotusername").value; const bot_icon_emoji = document.getElementById("mattermostboticonemoji").value; const data = { enabled: enabled, webhook_url: webhook_url, bot_username: bot_username, bot_icon_emoji: bot_icon_emoji }; makeFetchCall('endpoints/notifications/testmattermostnotifications.php', data, button); } function saveNotificationsMattermostButton() { const button = document.getElementById("saveNotificationsMattermost"); button.disabled = true; const enabled = document.getElementById("mattermostenabled").checked ? 1 : 0; const webhook_url = document.getElementById("mattermostwebhookurl").value; const bot_username = document.getElementById("mattermostbotusername").value; const bot_icon_emoji = document.getElementById("mattermostboticonemoji").value; const data = { enabled: enabled, webhook_url: webhook_url, bot_username: bot_username, bot_icon_emoji: bot_icon_emoji }; makeFetchCall('endpoints/notifications/savemattermostnotifications.php', data, button); } function saveNotificationsGotifyButton() { const button = document.getElementById("saveNotificationsGotify"); button.disabled = true; const enabled = document.getElementById("gotifyenabled").checked ? 1 : 0; const gotify_url = document.getElementById("gotifyurl").value; const token = document.getElementById("gotifytoken").value; const ignore_ssl = document.getElementById("gotifyignoressl").checked ? 1 : 0; const data = { enabled: enabled, gotify_url: gotify_url, token: token, ignore_ssl: ignore_ssl }; makeFetchCall('endpoints/notifications/savegotifynotifications.php', data, button); } function testNotificationsGotifyButton() { const button = document.getElementById("testNotificationsGotify"); button.disabled = true; const enabled = document.getElementById("gotifyenabled").checked ? 1 : 0; const gotify_url = document.getElementById("gotifyurl").value; const token = document.getElementById("gotifytoken").value; const ignore_ssl = document.getElementById("gotifyignoressl").checked ? 1 : 0; const data = { enabled: enabled, gotify_url: gotify_url, token: token, ignore_ssl: ignore_ssl }; makeFetchCall('endpoints/notifications/testgotifynotifications.php', data, button); } function saveNotificationsPushoverButton() { const button = document.getElementById("saveNotificationsPushover"); button.disabled = true; const enabled = document.getElementById("pushoverenabled").checked ? 1 : 0; const user_key = document.getElementById("pushoveruserkey").value; const token = document.getElementById("pushovertoken").value; const data = { enabled: enabled, user_key: user_key, token: token }; makeFetchCall('endpoints/notifications/savepushovernotifications.php', data, button); } function testNotificationsPushoverButton() { const button = document.getElementById("testNotificationsPushover"); button.disabled = true; const enabled = document.getElementById("pushoverenabled").checked ? 1 : 0; const user_key = document.getElementById("pushoveruserkey").value; const token = document.getElementById("pushovertoken").value; const data = { enabled: enabled, user_key: user_key, token: token }; makeFetchCall('endpoints/notifications/testpushovernotifications.php', data, button); } function saveNotificationsDiscordButton() { const button = document.getElementById("saveNotificationsDiscord"); button.disabled = true; const enabled = document.getElementById("discordenabled").checked ? 1 : 0; const url = document.getElementById("discordurl").value; const bot_username = document.getElementById("discordbotusername").value; const bot_avatar = document.getElementById("discordbotavatar").value; const data = { enabled: enabled, url: url, bot_username: bot_username, bot_avatar: bot_avatar }; makeFetchCall('endpoints/notifications/savediscordnotifications.php', data, button); } function testNotificationsDiscordButton() { const button = document.getElementById("testNotificationsDiscord"); button.disabled = true; const enabled = document.getElementById("discordenabled").checked ? 1 : 0; const url = document.getElementById("discordurl").value; const bot_username = document.getElementById("discordbotusername").value; const bot_avatar = document.getElementById("discordbotavatar").value; const data = { enabled: enabled, url: url, bot_username: bot_username, bot_avatar: bot_avatar }; makeFetchCall('endpoints/notifications/testdiscordnotifications.php', data, button); } function testNotificationsNtfyButton() { const button = document.getElementById("testNotificationsNtfy"); button.disabled = true; const host = document.getElementById("ntfyhost").value; const topic = document.getElementById("ntfytopic").value; const headers = document.getElementById("ntfyheaders").value; const ignore_ssl = document.getElementById("ntfyignoressl").checked ? 1 : 0; const data = { host: host, topic: topic, headers: headers, ignore_ssl: ignore_ssl }; makeFetchCall('endpoints/notifications/testntfynotifications.php', data, button); } function saveNotificationsNtfyButton() { const button = document.getElementById("saveNotificationsNtfy"); button.disabled = true; const enabled = document.getElementById("ntfyenabled").checked ? 1 : 0; const host = document.getElementById("ntfyhost").value; const topic = document.getElementById("ntfytopic").value; const headers = document.getElementById("ntfyheaders").value; const ignore_ssl = document.getElementById("ntfyignoressl").checked ? 1 : 0; const data = { enabled: enabled, host: host, topic: topic, headers: headers, ignore_ssl: ignore_ssl }; makeFetchCall('endpoints/notifications/saventfynotifications.php', data, button); } function testNotificationsServerchanButton() { const button = document.getElementById("testNotificationsServerchan"); button.disabled = true; const enabled = document.getElementById("serverchanenabled").checked ? 1 : 0; const sendkey = document.getElementById("serverchansendkey").value; const data = { enabled: enabled, sendkey: sendkey }; makeFetchCall('endpoints/notifications/testserverchannotifications.php', data, button); } function saveNotificationsServerchanButton() { const button = document.getElementById("saveNotificationsServerchan"); button.disabled = true; const enabled = document.getElementById("serverchanenabled").checked ? 1 : 0; const sendkey = document.getElementById("serverchansendkey").value; const data = { enabled: enabled, sendkey: sendkey }; makeFetchCall('endpoints/notifications/saveserverchannotifications.php', data, button); } ================================================ FILE: scripts/profile.js ================================================ document.addEventListener('DOMContentLoaded', function () { document.getElementById("userForm").addEventListener("submit", function (event) { event.preventDefault(); const submitButton = document.getElementById("userSubmit"); submitButton.disabled = true; const formData = new FormData(event.target); formData.append("action", "save"); fetch("endpoints/user/save_user.php", { method: "POST", headers: { "X-CSRF-Token": window.csrfToken, }, body: formData, }) .then(response => response.json()) .then(data => { if (data.success) { document.getElementById("avatar").src = document.getElementById("avatarImg").src; const newUsername = document.getElementById("username").value; document.getElementById("user").textContent = newUsername; document.getElementById("profile_pic").value = ""; showSuccessMessage(data.message); if (data.reload) { location.reload(); } } else { showErrorMessage(data.message || translate("failed_save_user")); } }) .catch(error => { console.error(error); showErrorMessage(translate("unknown_error")); }) .finally(() => { submitButton.disabled = false; }); }); }); function toggleAvatarSelect() { var avatarSelect = document.getElementById("avatarSelect"); if (avatarSelect.classList.contains("is-open")) { avatarSelect.classList.remove("is-open"); } else { avatarSelect.classList.add("is-open"); } } function closeAvatarSelect() { var avatarSelect = document.getElementById("avatarSelect"); avatarSelect.classList.remove("is-open"); } document.querySelectorAll('.avatar-option').forEach((avatar) => { avatar.addEventListener("click", () => { changeAvatar(avatar.src); document.getElementById('avatarUser').value = avatar.getAttribute('data-src'); closeAvatarSelect(); }) }); function changeAvatar(src) { document.getElementById("avatarImg").src = src; } function successfulUpload(field, msg) { var reader = new FileReader(); if (field.files.length === 0) { return; } if (!['image/jpeg', 'image/png', 'image/gif', 'image/jtif', 'image/webp'].includes(field.files[0]['type'])) { showErrorMessage(msg); return; } reader.onload = function () { changeAvatar(reader.result); }; reader.readAsDataURL(field.files[0]); closeAvatarSelect(); } function deleteAvatar(path) { fetch('endpoints/user/delete_avatar.php', { method: 'POST', headers: { 'Content-Type': 'application/json', "X-CSRF-Token": window.csrfToken, }, body: JSON.stringify({ avatar: path }), }) .then(response => response.json()) .then(data => { if (data.success) { var avatarContainer = document.querySelector(`.avatar-container[data-src="${path}"]`); if (avatarContainer) { avatarContainer.remove(); } showSuccessMessage(); } else { showErrorMessage(data.message || ""); } }) .catch((error) => { console.error('Error:', error); }); } function enableTotp() { const totpSecret = document.querySelector("#totp-secret"); const totpSecretCode = document.querySelector("#totp-secret-code"); const qrCode = document.getElementById("totp-qr-code"); totpSecret.value = ""; totpSecretCode.textContent = ""; qrCode.innerHTML = ""; fetch("endpoints/user/enable_totp.php", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken, }, body: JSON.stringify({ action: "generate" }), }) .then(response => response.json()) .then(data => { if (data.success) { totpSecret.value = data.secret; totpSecretCode.textContent = data.secret; new QRCode(qrCode, data.qrCodeUrl); openTotpPopup(); } else { showErrorMessage(data.message); } }) .catch(error => { console.error(error); showErrorMessage(translate("unknown_error")); }); } function openTotpPopup() { const enableTotpButton = document.getElementById('enableTotp'); enableTotpButton.disabled = true; const totpPopup = document.getElementById('totp-popup'); totpPopup.classList.add('is-open'); } function closeTotpPopup() { const enableTotpButton = document.getElementById('enableTotp'); enableTotpButton.disabled = false; const totpPopup = document.getElementById('totp-popup'); totpPopup.classList.remove('is-open'); const totpBackupCodes = document.getElementById('totp-backup-codes'); if (!totpBackupCodes.classList.contains('hide')) { location.reload(); } } function submitTotp() { const totpCode = document.getElementById('totp').value; const totpSecret = document.getElementById('totp-secret').value; fetch('endpoints/user/enable_totp.php', { method: 'POST', headers: { 'Content-Type': 'application/json', "X-CSRF-Token": window.csrfToken, }, body: JSON.stringify({ totpCode: totpCode, totpSecret: totpSecret, action: 'verify' }), }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); const backupCodes = data.backupCodes; const backupCodesList = document.getElementById('backup-codes'); backupCodesList.innerHTML = ''; backupCodes.forEach(code => { const li = document.createElement('li'); li.textContent = code; backupCodesList.appendChild(li); }); const totpSetup = document.getElementById('totp-setup'); const totpBackupCodes = document.getElementById('totp-backup-codes'); totpSetup.classList.add('hide'); totpBackupCodes.classList.remove('hide'); } else { showErrorMessage(data.message); } }) .catch(error => { showErrorMessage(error); console.log(error); }); } function copyBackupCodes() { const backupCodes = document.querySelectorAll('#backup-codes li'); const codes = Array.from(backupCodes).map(code => code.textContent).join('\n'); navigator.clipboard.writeText(codes) .then(() => { showSuccessMessage(translate('copied_to_clipboard')); }) .catch(() => { showErrorMessage(translate('unknown_error')); }); } function downloadBackupCodes() { const backupCodes = document.querySelectorAll('#backup-codes li'); const codes = Array.from(backupCodes).map(code => code.textContent).join('\n'); const element = document.createElement('a'); element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(codes)); element.setAttribute('download', 'wallos-backup-codes.txt'); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); } function closeTotpDisablePopup() { const totpPopup = document.getElementById('totp-disable-popup'); totpPopup.classList.remove('is-open'); } function disableTotp() { const totpPopup = document.getElementById('totp-disable-popup'); totpPopup.classList.add('is-open'); } function submitDisableTotp() { const totpCode = document.getElementById('totp-disable').value; fetch('endpoints/user/disable_totp.php', { method: 'POST', headers: { 'Content-Type': 'application/json', "X-CSRF-Token": window.csrfToken, }, body: JSON.stringify({ totpCode: totpCode }), }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); if (data.reload) { location.reload(); } } else { showErrorMessage(data.message); } }) .catch(error => { showErrorMessage(error); }); } function regenerateApiKey() { const regenerateButton = document.getElementById("regenerateApiKey"); regenerateButton.disabled = true; fetch("endpoints/user/regenerateapikey.php", { method: "POST", headers: { "X-CSRF-Token": window.csrfToken, }, }) .then(response => response.json()) .then(data => { regenerateButton.disabled = false; if (data.success) { const newApiKey = data.apiKey; document.getElementById("apikey").value = newApiKey; showSuccessMessage(data.message); } else { showErrorMessage(data.message || translate("failed_regenerate_api_key")); } }) .catch(error => { console.error(error); regenerateButton.disabled = false; showErrorMessage(translate("unknown_error")); }); } function exportAsJson() { fetch("endpoints/subscriptions/export.php") .then(response => response.json()) .then(data => { if (data.success) { const subscriptions = JSON.stringify(data.subscriptions); const element = document.createElement('a'); element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(subscriptions)); element.setAttribute('download', 'subscriptions.json'); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); } else { showErrorMessage(data.message); } }) .catch(error => { console.log(error); showErrorMessage(translate('unknown_error')); }); } function exportAsCsv() { fetch("endpoints/subscriptions/export.php") .then(response => response.json()) .then(data => { if (data.success) { const subscriptions = data.subscriptions; const header = Object.keys(subscriptions[0]).join(','); const csv = subscriptions.map(subscription => Object.values(subscription).join(',')).join('\n'); const csvWithHeader = header + '\n' + csv; const element = document.createElement('a'); element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(csvWithHeader)); element.setAttribute('download', 'subscriptions.csv'); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); } else { showErrorMessage(data.message); } }) .catch(error => { showErrorMessage(translate('unknown_error')); }); } function deleteAccount(userId) { if (!confirm(translate('delete_account_confirmation'))) { return; } if (!confirm(translate('this_will_delete_all_data'))) { return; } fetch('endpoints/settings/deleteaccount.php', { method: 'POST', headers: { 'Content-Type': 'application/json', "X-CSRF-Token": window.csrfToken, }, body: JSON.stringify({ userId: userId }), }) .then(response => response.json()) .then(data => { if (data.success) { window.location.href = 'logout.php'; } else { showErrorMessage(data.message); } }) .catch((error) => { showErrorMessage(translate('unknown_error')); }); } ================================================ FILE: scripts/registration.js ================================================ function setCookie(name, value, days) { var expires = ""; if (days) { var date = new Date(); date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); expires = "; expires=" + date.toUTCString(); } document.cookie = name + "=" + value + expires + "; SameSite=Strict"; } function storeFormFieldValue(fieldId) { var fieldElement = document.getElementById(fieldId); if (fieldElement) { localStorage.setItem(fieldId, fieldElement.value); } } function storeFormFields() { storeFormFieldValue('username'); storeFormFieldValue('firstname'); storeFormFieldValue('lastname'); storeFormFieldValue('email'); storeFormFieldValue('password'); storeFormFieldValue('confirm_password'); storeFormFieldValue('currency'); } function restoreFormFieldValue(fieldId) { var fieldElement = document.getElementById(fieldId); if (localStorage.getItem(fieldId)) { fieldElement.value = localStorage.getItem(fieldId) || ''; } } function restoreFormFields() { restoreFormFieldValue('username'); restoreFormFieldValue('firstname'); restoreFormFieldValue('lastname'); restoreFormFieldValue('email'); restoreFormFieldValue('password'); restoreFormFieldValue('confirm_password'); restoreFormFieldValue('currency'); } function removeFromStorage() { localStorage.removeItem('username'); localStorage.removeItem('firstname'); localStorage.removeItem('lastname'); localStorage.removeItem('email'); localStorage.removeItem('password'); localStorage.removeItem('confirm_password'); localStorage.removeItem('currency'); } function changeLanguage(selectedLanguage) { storeFormFields(); setCookie("language", selectedLanguage, 365); location.reload(); } function runDatabaseMigration() { let url = "endpoints/db/migrate.php"; fetch(url) .then(response => { if (!response.ok) { throw new Error(translate('network_response_error')); } }); } function showErrorMessage(message) { const toast = document.querySelector(".toast#errorToast"); (closeIcon = document.querySelector(".close-error")), (errorMessage = document.querySelector(".errorMessage")), (progress = document.querySelector(".progress.error")); let timer1, timer2; errorMessage.textContent = message; toast.classList.add("active"); progress.classList.add("active"); timer1 = setTimeout(() => { toast.classList.remove("active"); closeIcon.removeEventListener("click", () => { }); }, 5000); timer2 = setTimeout(() => { progress.classList.remove("active"); }, 5300); closeIcon.addEventListener("click", () => { toast.classList.remove("active"); setTimeout(() => { progress.classList.remove("active"); }, 300); clearTimeout(timer1); clearTimeout(timer2); closeIcon.removeEventListener("click", () => { }); }); } function showSuccessMessage(message) { const toast = document.querySelector(".toast#successToast"); (closeIcon = document.querySelector(".close-success")), (successMessage = document.querySelector(".successMessage")), (progress = document.querySelector(".progress.success")); let timer1, timer2; successMessage.textContent = message; toast.classList.add("active"); progress.classList.add("active"); timer1 = setTimeout(() => { toast.classList.remove("active"); closeIcon.removeEventListener("click", () => { }); }, 5000); timer2 = setTimeout(() => { progress.classList.remove("active"); }, 5300); closeIcon.addEventListener("click", () => { toast.classList.remove("active"); setTimeout(() => { progress.classList.remove("active"); }, 300); clearTimeout(timer1); clearTimeout(timer2); closeIcon.removeEventListener("click", () => { }); }); } function openRestoreDBFileSelect() { document.getElementById('restoreDBFile').click(); }; function restoreDB() { const input = document.getElementById('restoreDBFile'); const file = input.files[0]; if (!file) { console.error('No file selected'); return; } const formData = new FormData(); formData.append('file', file); fetch('endpoints/db/import.php', { method: 'POST', body: formData }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); fetch('endpoints/db/migrate.php') .then(response => response.text()) .then(() => { window.location.href = 'logout.php'; }) .catch(error => { window.location.href = 'logout.php'; }); } else { showErrorMessage(data.message); } }) .catch(error => showErrorMessage('Error:', error)); } function checkThemeNeedsUpdate() { if (window.update_theme_settings) { const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; const themePreference = prefersDarkMode ? 'dark' : 'light'; const darkThemeCss = document.querySelector("#dark-theme"); darkThemeCss.disabled = themePreference === 'light'; document.body.className = themePreference; const themeColorMetaTag = document.querySelector('meta[name="theme-color"]'); themeColorMetaTag.setAttribute('content', themePreference === 'dark' ? '#222222' : '#FFFFFF'); } } function enableGoToLoginButton() { const goToLoginButton = document.getElementById('goToLoginButton'); if (goToLoginButton) { goToLoginButton.addEventListener('click', function () { window.location.href = 'login.php'; }); } } window.onload = function () { restoreFormFields(); removeFromStorage(); runDatabaseMigration(); checkThemeNeedsUpdate(); enableGoToLoginButton(); }; ================================================ FILE: scripts/settings.js ================================================ const deleteSvgContent = ` `; const editSvgContent = ` `; function saveBudget() { const button = document.getElementById("saveBudget"); button.disabled = true; const budget = document.getElementById("budget").value; fetch('endpoints/user/budget.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify({budget: budget}), }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); } else { showErrorMessage(data.message); } }) .catch(error => { console.error(error); showErrorMessage(translate('unknown_error')); }) .finally(() => { button.disabled = false; }); } function addMemberButton(memberId) { const addButton = document.getElementById("addMember"); addButton.disabled = true; fetch("endpoints/household/household.php", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", 'X-CSRF-Token': window.csrfToken, }, body: new URLSearchParams({action: "add"}), }) .then(response => { if (!response.ok) { showErrorMessage(translate("failed_add_member")); throw new Error(translate("network_response_error")); } return response.json(); }) .then(responseData => { if (responseData.success) { const newMemberId = responseData.householdId; const container = document.getElementById("householdMembers"); const div = document.createElement("div"); div.className = "form-group-inline"; div.dataset.memberid = newMemberId; const input = document.createElement("input"); input.type = "text"; input.placeholder = translate("member"); input.name = "member"; input.value = translate("member"); const emailInput = document.createElement("input"); emailInput.type = "text"; emailInput.placeholder = translate("email"); emailInput.name = "email"; emailInput.value = ""; const editLink = document.createElement("button"); editLink.className = "image-button medium"; editLink.name = "save"; editLink.onclick = () => editMember(newMemberId); editLink.innerHTML = editSvgContent; editLink.title = translate("save_member"); const deleteLink = document.createElement("button"); deleteLink.className = "image-button medium"; deleteLink.name = "delete"; deleteLink.onclick = () => removeMember(newMemberId); deleteLink.innerHTML = deleteSvgContent; deleteLink.title = translate("delete_member"); div.appendChild(input); div.appendChild(emailInput); div.appendChild(editLink); div.appendChild(deleteLink); container.appendChild(div); } else { showErrorMessage(responseData.message || translate("failed_add_member")); } }) .catch(error => { console.error(error); showErrorMessage(translate("failed_add_member")); }) .finally(() => { addButton.disabled = false; }); } function removeMember(memberId) { fetch("endpoints/household/household.php", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", 'X-CSRF-Token': window.csrfToken, }, body: new URLSearchParams({ action: "delete", memberId: memberId, }), }) .then(response => { if (!response.ok) { throw new Error(translate("network_response_error")); } return response.json(); }) .then(responseData => { if (responseData.success) { const divToRemove = document.querySelector(`[data-memberid="${memberId}"]`); if (divToRemove) divToRemove.remove(); showSuccessMessage(responseData.message); } else { showErrorMessage(responseData.message || translate("failed_remove_member")); } }) .catch(error => { console.error(error); showErrorMessage(translate("failed_remove_member")); }); } function editMember(memberId) { const saveButton = document.querySelector(`div[data-memberid="${memberId}"] button[name="save"]`); const memberNameElement = document.querySelector(`div[data-memberid="${memberId}"] input[name="member"]`); const memberEmailElement = document.querySelector(`div[data-memberid="${memberId}"] input[name="email"]`); if (!memberNameElement) return; saveButton.classList.add("disabled"); saveButton.disabled = true; const memberName = memberNameElement.value; const memberEmail = memberEmailElement ? memberEmailElement.value : ""; fetch("endpoints/household/household.php", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", 'X-CSRF-Token': window.csrfToken, }, body: new URLSearchParams({ action: "edit", memberId: memberId, name: memberName, email: memberEmail, }), }) .then(response => { if (!response.ok) { showErrorMessage(translate("failed_save_member")); throw new Error(translate("network_response_error")); } return response.json(); }) .then(responseData => { if (responseData.success) { showSuccessMessage(responseData.message); } else { showErrorMessage(responseData.message || translate("failed_save_member")); } }) .catch(error => { console.error(error); showErrorMessage(translate("failed_save_member")); }) .finally(() => { saveButton.classList.remove("disabled"); saveButton.disabled = false; }); } function addCategoryButton(categoryId) { const addButton = document.getElementById("addCategory"); addButton.disabled = true; fetch('endpoints/categories/category.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': window.csrfToken, }, body: new URLSearchParams({action: 'add'}), }) .then(response => { if (!response.ok) { showErrorMessage(translate('failed_add_category')); throw new Error(translate('network_response_error')); } return response.json(); }) .then(responseData => { if (responseData.success) { const newCategoryId = responseData.categoryId; const container = document.getElementById("categories"); const row = document.createElement("div"); row.className = "form-group-inline"; row.dataset.categoryid = newCategoryId; const dragIcon = document.createElement("div"); dragIcon.className = "drag-icon"; const input = document.createElement("input"); input.type = "text"; input.placeholder = translate('category'); input.name = "category"; input.value = translate('category'); const editLink = document.createElement("button"); editLink.className = "image-button medium"; editLink.name = "save"; editLink.onclick = function () { editCategory(newCategoryId); }; editLink.innerHTML = editSvgContent; editLink.title = translate('save_member'); const deleteLink = document.createElement("button"); deleteLink.className = "image-button medium"; deleteLink.name = "delete"; deleteLink.onclick = function () { removeCategory(newCategoryId); }; deleteLink.innerHTML = deleteSvgContent; deleteLink.title = translate('delete_member'); row.appendChild(dragIcon); row.appendChild(input); row.appendChild(editLink); row.appendChild(deleteLink); container.appendChild(row); } else { showErrorMessage(responseData.message); } }) .catch(error => { console.error(error); showErrorMessage(translate('failed_add_category')); }) .finally(() => { addButton.disabled = false; }); } function removeCategory(categoryId) { fetch('endpoints/categories/category.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': window.csrfToken, }, body: new URLSearchParams({ action: 'delete', categoryId: categoryId, }), }) .then(response => { if (!response.ok) { throw new Error(translate('network_response_error')); } return response.json(); }) .then(responseData => { if (responseData.success) { const divToRemove = document.querySelector(`[data-categoryid="${categoryId}"]`); if (divToRemove) divToRemove.remove(); showSuccessMessage(responseData.message); } else { showErrorMessage(responseData.message || translate('failed_remove_category')); } }) .catch(error => { console.error(error); showErrorMessage(translate('failed_remove_category')); }); } function editCategory(categoryId) { const saveButton = document.querySelector(`div[data-categoryid="${categoryId}"] button[name="save"]`); const inputElement = document.querySelector(`div[data-categoryid="${categoryId}"] input[name="category"]`); if (!inputElement) return; saveButton.classList.add("disabled"); saveButton.disabled = true; const categoryName = inputElement.value; fetch('endpoints/categories/category.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': window.csrfToken, }, body: new URLSearchParams({ action: 'edit', categoryId: categoryId, name: categoryName, }), }) .then(response => { saveButton.classList.remove("disabled"); saveButton.disabled = false; if (!response.ok) { showErrorMessage(translate('failed_save_category')); throw new Error(translate('network_response_error')); } return response.json(); }) .then(responseData => { if (responseData.success) { showSuccessMessage(responseData.message); } else { showErrorMessage(responseData.message || translate('failed_save_category')); } }) .catch(error => { console.error(error); showErrorMessage(translate('failed_save_category')); saveButton.classList.remove("disabled"); saveButton.disabled = false; }); } function addCurrencyButton(currencyId) { const addButton = document.getElementById("addCurrency"); addButton.disabled = true; fetch('endpoints/currency/currency.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': window.csrfToken, }, body: new URLSearchParams({action: 'add'}), }) .then(response => { if (!response.ok) { throw new Error(translate('network_response_error')); } return response.json(); }) .then(responseData => { if (responseData.success) { const newCurrencyId = responseData.currencyId; const container = document.getElementById("currencies"); const div = document.createElement("div"); div.className = "form-group-inline"; div.dataset.currencyid = newCurrencyId; const inputSymbol = document.createElement("input"); inputSymbol.type = "text"; inputSymbol.placeholder = "$"; inputSymbol.name = "symbol"; inputSymbol.value = "$"; inputSymbol.classList.add("short"); const inputName = document.createElement("input"); inputName.type = "text"; inputName.placeholder = translate('currency'); inputName.name = "currency"; inputName.value = translate('currency'); const inputCode = document.createElement("input"); inputCode.type = "text"; inputCode.placeholder = translate('currency_code'); inputCode.name = "code"; inputCode.value = "CODE"; const editLink = document.createElement("button"); editLink.className = "image-button medium"; editLink.name = "save"; editLink.onclick = function () { editCurrency(newCurrencyId); }; editLink.innerHTML = editSvgContent; editLink.title = translate('save_member'); const deleteLink = document.createElement("button"); deleteLink.className = "image-button medium"; deleteLink.name = "delete"; deleteLink.onclick = function () { removeCurrency(newCurrencyId); }; deleteLink.innerHTML = deleteSvgContent; deleteLink.title = translate('delete_member'); div.appendChild(inputSymbol); div.appendChild(inputName); div.appendChild(inputCode); div.appendChild(editLink); div.appendChild(deleteLink); container.appendChild(div); } else { showErrorMessage(responseData.message || translate('failed_add_currency')); } }) .catch(error => { console.error(error); showErrorMessage(translate('failed_add_currency')); }) .finally(() => { addButton.disabled = false; }); } function removeCurrency(currencyId) { fetch('endpoints/currency/currency.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': window.csrfToken, }, body: new URLSearchParams({ action: 'delete', currencyId: currencyId, }), }) .then(response => { if (!response.ok) { throw new Error(translate('network_response_error')); } return response.json(); }) .then(data => { if (data.success) { showSuccessMessage(data.message); const divToRemove = document.querySelector(`[data-currencyid="${currencyId}"]`); if (divToRemove) divToRemove.remove(); } else { showErrorMessage(data.message || translate('failed_remove_currency')); } }) .catch(error => { console.error(error); showErrorMessage(error.message || translate('failed_remove_currency')); }); } function editCurrency(currencyId) { const saveButton = document.querySelector(`div[data-currencyid="${currencyId}"] button[name="save"]`); const inputSymbolElement = document.querySelector(`div[data-currencyid="${currencyId}"] input[name="symbol"]`); const inputNameElement = document.querySelector(`div[data-currencyid="${currencyId}"] input[name="currency"]`); const inputCodeElement = document.querySelector(`div[data-currencyid="${currencyId}"] input[name="code"]`); if (!inputNameElement) return; saveButton.classList.add("disabled"); saveButton.disabled = true; const currencyName = inputNameElement.value; const currencySymbol = inputSymbolElement.value; const currencyCode = inputCodeElement.value; fetch('endpoints/currency/currency.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': window.csrfToken, }, body: new URLSearchParams({ action: 'edit', currencyId: currencyId, name: currencyName, symbol: currencySymbol, code: currencyCode, }), }) .then(response => { if (!response.ok) { throw new Error(translate('network_response_error')); } return response.json(); }) .then(data => { saveButton.classList.remove("disabled"); saveButton.disabled = false; if (data.success) { showSuccessMessage(data.message); } else { showErrorMessage(data.message || translate('failed_save_currency')); } }) .catch(error => { console.error(error); showErrorMessage(error.message || translate('failed_save_currency')); saveButton.classList.remove("disabled"); saveButton.disabled = false; }); } function togglePayment(paymentId) { const element = document.querySelector(`div[data-paymentid="${paymentId}"]`); if (element.dataset.inUse === "yes") { return showErrorMessage(translate("cant_disable_payment_in_use")); } const newEnabledState = element.dataset.enabled === "1" ? "0" : "1"; const paymentMethodName = element.querySelector(".payment-name").innerText; fetch("endpoints/payments/toggle.php", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "X-CSRF-Token": window.csrfToken, }, body: new URLSearchParams({ paymentId: paymentId, enabled: newEnabledState, }), }) .then(response => { if (!response.ok) { throw new Error(translate("network_response_error")); } return response.json(); }) .then(data => { if (data.success) { element.dataset.enabled = newEnabledState; showSuccessMessage(`${paymentMethodName} ${data.message}`); } else { showErrorMessage(data.message || translate("failed_save_payment_method")); } }) .catch(error => { console.error(error); showErrorMessage(error.message || translate("failed_save_payment_method")); }); } document.body.addEventListener('click', function (e) { let targetElement = e.target; do { if (targetElement.classList && targetElement.classList.contains('payments-payment')) { let targetChild = e.target; do { if (targetChild.classList && (targetChild.classList.contains('payment-name') || targetChild.classList.contains('drag-icon'))) { return; } targetChild = targetChild.parentNode; } while (targetChild && targetChild !== targetElement); const paymentId = targetElement.dataset.paymentid; togglePayment(paymentId); return; } targetElement = targetElement.parentNode; } while (targetElement); }); document.body.addEventListener('blur', function (e) { let targetElement = e.target; if (targetElement.classList && targetElement.classList.contains('payment-name')) { const paymentId = targetElement.closest('.payments-payment').dataset.paymentid; const newName = targetElement.textContent; renamePayment(paymentId, newName); } }, true); function renamePayment(paymentId, newName) { const name = newName.trim(); if (!name) return; const formData = new FormData(); formData.append("paymentId", paymentId); formData.append("name", name); fetch("endpoints/payments/rename.php", { method: "POST", headers: { "X-CSRF-Token": window.csrfToken, }, body: formData, }) .then(response => { if (!response.ok) { throw new Error(translate("network_response_error")); } return response.json(); }) .then(data => { if (data.success) { showSuccessMessage(`${newName} ${data.message}`); } else { showErrorMessage(data.message || translate("failed_save_payment_method")); } }) .catch(error => { console.error(error); showErrorMessage(translate("unknown_error")); }); } document.body.addEventListener('keypress', function (e) { let targetElement = e.target; if (targetElement.classList && targetElement.classList.contains('payment-name')) { if (e.key === 'Enter') { e.preventDefault(); targetElement.blur(); } } }); function handleFileSelect(event) { const fileInput = event.target; const iconPreview = document.querySelector('.icon-preview'); const iconImg = iconPreview.querySelector('img'); const iconUrl = document.querySelector("#icon-url"); iconUrl.value = ""; if (fileInput.files && fileInput.files[0]) { const reader = new FileReader(); reader.onload = function (e) { iconImg.src = e.target.result; iconImg.style.display = 'block'; }; reader.readAsDataURL(fileInput.files[0]); } } function setSearchButtonStatus() { const nameInput = document.querySelector("#paymentname"); const hasSearchTerm = nameInput.value.trim().length > 0; const iconSearchButton = document.querySelector("#icon-search-button"); if (hasSearchTerm) { iconSearchButton.classList.remove("disabled"); } else { iconSearchButton.classList.add("disabled"); } } function searchPaymentIcon() { const nameInput = document.querySelector("#paymentname"); const searchTerm = nameInput.value.trim(); if (searchTerm !== "") { const iconSearchPopup = document.querySelector("#icon-search-results"); iconSearchPopup.classList.add("is-open"); const imageSearchUrl = `endpoints/payments/search.php?search=${searchTerm}`; fetch(imageSearchUrl) .then(response => response.json()) .then(data => { if (data.imageUrls) { displayImageResults(data.imageUrls); } else if (data.error) { console.error(data.error); } }) .catch(error => { console.error(translate('error_fetching_image_results'), error); }); } else { nameInput.focus(); } } function displayImageResults(imageSources) { const iconResults = document.querySelector("#icon-search-images"); iconResults.innerHTML = ""; imageSources.forEach(src => { const img = document.createElement("img"); img.src = src; img.onclick = function () { selectWebIcon(src); }; img.onerror = function () { this.parentNode.removeChild(this); }; iconResults.appendChild(img); }); } function selectWebIcon(url) { closeIconSearch(); const iconPreview = document.querySelector("#form-icon"); const iconUrl = document.querySelector("#icon-url"); iconPreview.src = url; iconPreview.style.display = 'block'; iconUrl.value = url; } function closeIconSearch() { const iconSearchPopup = document.querySelector("#icon-search-results"); iconSearchPopup.classList.remove("is-open"); const iconResults = document.querySelector("#icon-search-images"); iconResults.innerHTML = ""; } function resetFormIcon() { const iconPreview = document.querySelector("#form-icon"); iconPreview.src = ""; iconPreview.style.display = 'none'; } function reloadPaymentMethods() { const paymentsContainer = document.querySelector("#payments-list"); const paymentMethodsEndpoint = "endpoints/payments/get.php"; fetch(paymentMethodsEndpoint) .then(response => response.text()) .then(data => { paymentsContainer.innerHTML = data; }); } function addPaymentMethod() { closeIconSearch(); const addPaymentMethodEndpoint = "endpoints/payments/add.php"; const paymentMethodForm = document.querySelector("#payments-form"); const submitButton = document.querySelector("#add-payment-button"); submitButton.disabled = true; const formData = new FormData(paymentMethodForm); formData.append("action", "add"); fetch(addPaymentMethodEndpoint, { method: "POST", headers: { "X-CSRF-Token": window.csrfToken, }, body: formData, }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); paymentMethodForm.reset(); resetFormIcon(); reloadPaymentMethods(); } else { showErrorMessage(data.message || translate("failed_add_payment_method")); } }) .catch(error => { console.error(error); showErrorMessage(translate("unknown_error")); }) .finally(() => { submitButton.disabled = false; }); } function deletePaymentMethod(paymentId) { fetch(`endpoints/payments/delete.php?id=${paymentId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', "X-CSRF-Token": window.csrfToken, }, body: JSON.stringify({ id: paymentId }), }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); var paymentToRemove = document.querySelector('.payments-payment[data-paymentid="' + paymentId + '"]'); if (paymentToRemove) { paymentToRemove.remove(); } } else { showErrorMessage(data.message); } }) .catch((error) => { console.error('Error:', error); }); } function savePaymentMethodsSorting() { const paymentMethods = document.getElementById("payments-list"); const paymentMethodIds = Array.from(paymentMethods.children).map( paymentMethod => paymentMethod.dataset.paymentid ); const formData = new FormData(); paymentMethodIds.forEach(id => formData.append("paymentMethodIds[]", id)); formData.append("action", "sort"); fetch("endpoints/payments/sort.php", { method: "POST", headers: { "X-CSRF-Token": window.csrfToken, }, body: formData, }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); } else { showErrorMessage(data.message || translate("failed_sort_payment_methods")); } }) .catch(error => { console.error(error); showErrorMessage(translate("unknown_error")); }); } var el = document.getElementById('payments-list'); var sortable = Sortable.create(el, { handle: '.drag-icon', ghostClass: 'sortable-ghost', delay: 500, delayOnTouchOnly: true, touchStartThreshold: 5, onEnd: function (evt) { savePaymentMethodsSorting(); }, }); document.addEventListener('DOMContentLoaded', function () { var removePaymentButtons = document.querySelectorAll(".delete-payment-method"); removePaymentButtons.forEach(function (button) { button.addEventListener('click', function (event) { event.preventDefault(); event.stopPropagation(); let paymentId = event.target.getAttribute('data-paymentid'); deletePaymentMethod(paymentId); }); }); if (document.getElementById("ai_type")) { toggleAiInputs(); } }); function addFixerKeyButton() { const addButton = document.getElementById("addFixerKey"); addButton.disabled = true; const apiKeyInput = document.querySelector("#fixerKey"); const apiKey = apiKeyInput.value.trim(); const provider = document.querySelector("#fixerProvider").value; const convertCurrencyCheckbox = document.querySelector("#convertcurrency"); fetch("endpoints/currency/fixer_api_key.php", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", 'X-CSRF-Token': window.csrfToken, }, body: new URLSearchParams({ api_key: apiKey, provider: provider, }), }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); addButton.disabled = false; convertCurrencyCheckbox.disabled = false; fetch("endpoints/currency/update_exchange.php", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", 'X-CSRF-Token': window.csrfToken, }, body: new URLSearchParams({force: "true"}), }).catch(console.error); } else { showErrorMessage(data.message); addButton.disabled = false; } }) .catch(error => { console.error(error); showErrorMessage(translate("unknown_error")); addButton.disabled = false; }); } function storeSettingsOnDB(endpoint, value) { fetch('endpoints/settings/' + endpoint + '.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify({ "value": value }) }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); } else { showErrorMessage(data.message); } }); } function setShowMonthlyPrice() { const showMonthlyPriceCheckbox = document.querySelector("#monthlyprice"); const value = showMonthlyPriceCheckbox.checked; storeSettingsOnDB('monthly_price', value); } function setConvertCurrency() { const convertCurrencyCheckbox = document.querySelector("#convertcurrency"); const value = convertCurrencyCheckbox.checked; storeSettingsOnDB('convert_currency', value); } function setRemoveBackground() { const removeBackgroundCheckbox = document.querySelector("#removebackground"); const value = removeBackgroundCheckbox.checked; storeSettingsOnDB('remove_background', value); } function setHideDisabled() { const hideDisabledCheckbox = document.querySelector("#hidedisabled"); const value = hideDisabledCheckbox.checked; storeSettingsOnDB('hide_disabled', value); } function setDisabledToBottom() { const disabledToBottomCheckbox = document.querySelector("#disabledtobottom"); const value = disabledToBottomCheckbox.checked; storeSettingsOnDB('disabled_to_bottom', value); } function setShowOriginalPrice() { const showOriginalPriceCheckbox = document.querySelector("#showoriginalprice"); const value = showOriginalPriceCheckbox.checked; storeSettingsOnDB('show_original_price', value); } function setMobileNavigation() { const mobileNavigationCheckbox = document.querySelector("#mobilenavigation"); const value = mobileNavigationCheckbox.checked; storeSettingsOnDB('mobile_navigation', value); } function setShowSubscriptionProgress() { const showSubscriptionProgressCheckbox = document.querySelector("#showsubscriptionprogress"); const value = showSubscriptionProgressCheckbox.checked; storeSettingsOnDB('subscription_progress', value); } function saveCategorySorting() { const categories = document.getElementById("categories"); const categoryIds = Array.from(categories.children).map(c => c.dataset.categoryid); const formData = new FormData(); categoryIds.forEach(categoryId => formData.append("categoryIds[]", categoryId)); formData.append("action", "sort"); fetch("endpoints/categories/category.php", { method: "POST", headers: {"X-CSRF-Token": window.csrfToken}, body: formData, }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); } else { showErrorMessage(data.message); } }) .catch(error => { console.error(error); showErrorMessage(translate("unknown_error")); }); } var el = document.getElementById('categories'); var sortable = Sortable.create(el, { handle: '.drag-icon', ghostClass: 'sortable-ghost', delay: 500, delayOnTouchOnly: true, touchStartThreshold: 5, onEnd: function (evt) { saveCategorySorting(); }, }); function fetch_ai_models() { const endpoint = 'endpoints/ai/fetch_models.php'; const type = document.querySelector("#ai_type").value; const api_key = document.querySelector("#ai_api_key").value.trim(); const ollama_host = document.querySelector("#ai_ollama_host").value.trim(); const modelSelect = document.querySelector("#ai_model"); fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify({ type, api_key, ollama_host }) }) .then(response => response.json()) .then(data => { if (data.success) { modelSelect.innerHTML = ''; data.models.forEach(model => { const option = document.createElement('option'); option.value = model.id; option.textContent = model.name; modelSelect.appendChild(option); }); } else { showErrorMessage(data.message); } }) .catch(error => { showErrorMessage(translate('unknown_error')); }); } function toggleAiInputs() { const aiTypeSelect = document.getElementById("ai_type"); const apiKeyInput = document.getElementById("ai_api_key"); const apiKeyToggleButton = document.getElementById("toggleAiApiKey"); const apiKeyToggleIcon = apiKeyToggleButton ? apiKeyToggleButton.querySelector("i") : null; const ollamaHostInput = document.getElementById("ai_ollama_host"); const type = aiTypeSelect.value; if (type === "ollama") { apiKeyInput.classList.add("hidden"); if (apiKeyToggleButton) { apiKeyToggleButton.classList.add("hidden"); } apiKeyInput.type = "password"; if (apiKeyToggleIcon) { apiKeyToggleIcon.classList.remove("fa-eye-slash"); apiKeyToggleIcon.classList.add("fa-eye"); } ollamaHostInput.classList.remove("hidden"); } else { apiKeyInput.classList.remove("hidden"); if (apiKeyToggleButton) { apiKeyToggleButton.classList.remove("hidden"); } apiKeyInput.type = "password"; if (apiKeyToggleIcon) { apiKeyToggleIcon.classList.remove("fa-eye-slash"); apiKeyToggleIcon.classList.add("fa-eye"); } ollamaHostInput.classList.add("hidden"); } } function toggleAiApiKeyVisibility() { const apiKeyInput = document.getElementById("ai_api_key"); const apiKeyToggleButton = document.getElementById("toggleAiApiKey"); if (!apiKeyInput || !apiKeyToggleButton) { return; } const icon = apiKeyToggleButton.querySelector("i"); const isPassword = apiKeyInput.type === "password"; apiKeyInput.type = isPassword ? "text" : "password"; if (icon) { icon.classList.toggle("fa-eye", !isPassword); icon.classList.toggle("fa-eye-slash", isPassword); } } function saveAiSettingsButton() { const aiEnabled = document.querySelector("#ai_enabled").checked; const aiType = document.querySelector("#ai_type").value; const aiApiKey = document.querySelector("#ai_api_key").value.trim(); const aiOllamaHost = document.querySelector("#ai_ollama_host").value.trim(); const aiModel = document.querySelector("#ai_model").value; fetch('endpoints/ai/save_settings.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify({ ai_enabled: aiEnabled, ai_type: aiType, api_key: aiApiKey, ollama_host: aiOllamaHost, model: aiModel }) }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); const runAiActionButton = document.querySelector("#runAiRecommendations"); if (data.enabled) { runAiActionButton.classList.remove("hidden"); } else { runAiActionButton.classList.add("hidden"); } } else { showErrorMessage(data.message); } }) .catch(error => { showErrorMessage(translate('unknown_error')); }); } function runAiRecommendations() { const endpoint = 'endpoints/ai/generate_recommendations.php'; const button = document.querySelector("#runAiRecommendations"); const spinner = document.querySelector("#aiSpinner"); button.classList.add("hidden"); spinner.classList.remove("hidden"); fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, } }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); } else { showErrorMessage(data.message); } }) .catch(error => { showErrorMessage(translate('unknown_error')); }) .finally(() => { button.classList.remove("hidden"); spinner.classList.add("hidden"); }); } ================================================ FILE: scripts/stats.js ================================================ function loadGraph(container, dataPoints, currency, run) { if (run) { var ctx = document.getElementById(container).getContext('2d'); var chart = new Chart(ctx, { type: 'pie', data: { datasets: [{ data: dataPoints.map(point => point.y), }], labels: dataPoints.map(point => { if (currency) { return `${point.label} (${new Intl.NumberFormat(navigator.language, { style: 'currency', currency }).format(point.y)})`; } else { return `${point.label} (${new Intl.NumberFormat(navigator.language).format(point.y)})`; } }), }, options: { animation: { animateRotate: true, animateScale: true, }, plugins: { legend: { display: true, position: 'top', }, tooltip: { callbacks: { label: function(context) { let label = " "; if (currency) { label += new Intl.NumberFormat(navigator.language, { style: 'currency', currency }).format(context.raw); } else { label += new Intl.NumberFormat(navigator.language).format(context.raw); } return label; } } } } }, }); } } function loadLineGraph(container, dataPoints, currency, run) { if (run) { var ctx = document.getElementById(container).getContext('2d'); var chart = new Chart(ctx, { type: 'line', data: { datasets: [{ label: '', data: dataPoints.map(point => point.y), }], labels: dataPoints.map(point => { return `${point.label}`; }), }, options: { animation: { animateRotate: true, animateScale: true, }, scales: { y: { beginAtZero: false, ticks: { callback: function(value, index, values) { if (currency) { return new Intl.NumberFormat(navigator.language, { style: 'currency', currency }).format(value); } else { return new Intl.NumberFormat(navigator.language).format(value); } } } } }, plugins: { legend: { display: false } } } }); } } function closeSubMenus() { var subMenus = document.querySelectorAll('.filtermenu-submenu-content'); subMenus.forEach(subMenu => { subMenu.classList.remove('is-open'); }); } document.addEventListener("DOMContentLoaded", function() { var filtermenu = document.querySelector('#filtermenu-button'); filtermenu.addEventListener('click', function() { this.parentElement.querySelector('.filtermenu-content').classList.toggle('is-open'); closeSubMenus(); }); document.addEventListener('click', function(e) { var filtermenuContent = document.querySelector('.filtermenu-content'); if (filtermenuContent.classList.contains('is-open')) { var subMenus = document.querySelectorAll('.filtermenu-submenu'); var clickedInsideSubmenu = Array.from(subMenus).some(subMenu => subMenu.contains(e.target) || subMenu === e.target); if (!filtermenu.contains(e.target) && !clickedInsideSubmenu) { closeSubMenus(); filtermenuContent.classList.remove('is-open'); } } }); }); function toggleSubMenu(subMenu) { var subMenu = document.getElementById("filter-" + subMenu); if (subMenu.classList.contains("is-open")) { closeSubMenus(); } else { closeSubMenus(); subMenu.classList.add("is-open"); } } document.querySelectorAll('.filter-item').forEach(function(item) { item.addEventListener('click', function(e) { if (this.hasAttribute('data-categoryid')) { const categoryId = this.getAttribute('data-categoryid'); const urlParams = new URLSearchParams(window.location.search); let newUrl = 'stats.php?'; if (urlParams.get('category') === categoryId) { urlParams.delete('category'); } else { urlParams.set('category', categoryId); } newUrl += urlParams.toString(); window.location.href = newUrl; } else if (this.hasAttribute('data-memberid')) { const memberId = this.getAttribute('data-memberid'); const urlParams = new URLSearchParams(window.location.search); let newUrl = 'stats.php?'; if (urlParams.get('member') === memberId) { urlParams.delete('member'); } else { urlParams.set('member', memberId); } newUrl += urlParams.toString(); window.location.href = newUrl; } else if (this.hasAttribute('data-paymentid')) { const paymentId = this.getAttribute('data-paymentid'); const urlParams = new URLSearchParams(window.location.search); let newUrl = 'stats.php?'; if (urlParams.get('payment') === paymentId) { urlParams.delete('payment'); } else { urlParams.set('payment', paymentId); } newUrl += urlParams.toString(); window.location.href = newUrl; } }); }); function clearFilters() { window.location.href = 'stats.php'; } ================================================ FILE: scripts/subscriptions.js ================================================ let isSortOptionsOpen = false; let scrollTopBeforeOpening = 0; const shouldScroll = window.innerWidth <= 768; function toggleOpenSubscription(subId) { const subscriptionElement = document.querySelector('.subscription[data-id="' + subId + '"]'); subscriptionElement.classList.toggle('is-open'); } function toggleSortOptions() { const sortOptions = document.querySelector("#sort-options"); sortOptions.classList.toggle("is-open"); isSortOptionsOpen = !isSortOptionsOpen; } function toggleNotificationDays() { const notifyCheckbox = document.querySelector("#notifications"); const notifyDaysBefore = document.querySelector("#notify_days_before"); notifyDaysBefore.disabled = !notifyCheckbox.checked; } function resetForm() { const id = document.querySelector("#id"); id.value = ""; const formTitle = document.querySelector("#form-title"); formTitle.textContent = translate('add_subscription'); const logo = document.querySelector("#form-logo"); logo.src = ""; logo.style = 'display: none'; const logoUrl = document.querySelector("#logo-url"); logoUrl.value = ""; const logoSearchButton = document.querySelector("#logo-search-button"); logoSearchButton.classList.add("disabled"); const submitButton = document.querySelector("#save-button"); submitButton.disabled = false; const autoRenew = document.querySelector("#auto_renew"); autoRenew.checked = true; const startDate = document.querySelector("#start_date"); startDate.value = new Date().toISOString().split('T')[0]; const notifyDaysBefore = document.querySelector("#notify_days_before"); notifyDaysBefore.disabled = true; const replacementSubscriptionIdSelect = document.querySelector("#replacement_subscription_id"); replacementSubscriptionIdSelect.value = "0"; const replacementSubscription = document.querySelector(`#replacement_subscritpion`); replacementSubscription.classList.add("hide"); const form = document.querySelector("#subs-form"); form.reset(); closeLogoSearch(); const deleteButton = document.querySelector("#deletesub"); deleteButton.style = 'display: none'; deleteButton.removeAttribute("onClick"); } function fillEditFormFields(subscription) { const formTitle = document.querySelector("#form-title"); formTitle.textContent = translate('edit_subscription'); const logo = document.querySelector("#form-logo"); const logoFile = subscription.logo !== null ? "images/uploads/logos/" + subscription.logo : ""; if (logoFile) { logo.src = logoFile; logo.style = 'display: block'; } const logoSearchButton = document.querySelector("#logo-search-button"); logoSearchButton.classList.remove("disabled"); const id = document.querySelector("#id"); id.value = subscription.id; const name = document.querySelector("#name"); name.value = subscription.name; const price = document.querySelector("#price"); price.value = subscription.price; const currencySelect = document.querySelector("#currency"); currencySelect.value = subscription.currency_id.toString(); const frequencySelect = document.querySelector("#frequency"); frequencySelect.value = subscription.frequency; const cycleSelect = document.querySelector("#cycle"); cycleSelect.value = subscription.cycle; const paymentSelect = document.querySelector("#payment_method"); paymentSelect.value = subscription.payment_method_id; const categorySelect = document.querySelector("#category"); categorySelect.value = subscription.category_id; const payerSelect = document.querySelector("#payer_user"); payerSelect.value = subscription.payer_user_id; const startDate = document.querySelector("#start_date"); startDate.value = subscription.start_date; const nextPament = document.querySelector("#next_payment"); nextPament.value = subscription.next_payment; const cancellationDate = document.querySelector("#cancellation_date"); cancellationDate.value = subscription.cancellation_date; const notes = document.querySelector("#notes"); notes.value = subscription.notes; const inactive = document.querySelector("#inactive"); inactive.checked = subscription.inactive; const url = document.querySelector("#url"); url.value = subscription.url; const autoRenew = document.querySelector("#auto_renew"); if (autoRenew) { autoRenew.checked = subscription.auto_renew; } const notifications = document.querySelector("#notifications"); if (notifications) { notifications.checked = subscription.notify; } const notifyDaysBefore = document.querySelector("#notify_days_before"); notifyDaysBefore.value = subscription.notify_days_before ?? 0; if (subscription.notify === 1) { notifyDaysBefore.disabled = false; } const replacementSubscriptionIdSelect = document.querySelector("#replacement_subscription_id"); replacementSubscriptionIdSelect.value = subscription.replacement_subscription_id ?? 0; const replacementSubscription = document.querySelector(`#replacement_subscritpion`); if (subscription.inactive) { replacementSubscription.classList.remove("hide"); } else { replacementSubscription.classList.add("hide"); } const deleteButton = document.querySelector("#deletesub"); deleteButton.style = 'display: block'; deleteButton.setAttribute("onClick", `deleteSubscription(event, ${subscription.id})`); const modal = document.getElementById('subscription-form'); modal.classList.add("is-open"); } function openEditSubscription(event, id) { event.stopPropagation(); scrollTopBeforeOpening = window.scrollY; const body = document.querySelector('body'); body.classList.add('no-scroll'); const url = `endpoints/subscription/get.php?id=${id}`; fetch(url) .then((response) => { if (response.ok) { return response.json(); } else { showErrorMessage(translate('failed_to_load_subscription')); } }) .then((data) => { if (data.error || data === "Error") { showErrorMessage(translate('failed_to_load_subscription')); } else { const subscription = data; fillEditFormFields(subscription); } }) .catch((error) => { console.log(error); showErrorMessage(translate('failed_to_load_subscription')); }); } function addSubscription() { resetForm(); const modal = document.getElementById('subscription-form'); const startDate = document.querySelector("#start_date"); startDate.value = new Date().toISOString().split('T')[0]; modal.classList.add("is-open"); const body = document.querySelector('body'); body.classList.add('no-scroll'); } function closeAddSubscription() { const modal = document.getElementById('subscription-form'); modal.classList.remove("is-open"); const body = document.querySelector('body'); body.classList.remove('no-scroll'); if (shouldScroll) { window.scrollTo(0, scrollTopBeforeOpening); } resetForm(); } function handleFileSelect(event) { const fileInput = event.target; const logoPreview = document.querySelector('.logo-preview'); const logoImg = logoPreview.querySelector('img'); const logoUrl = document.querySelector("#logo-url"); logoUrl.value = ""; if (fileInput.files && fileInput.files[0]) { const reader = new FileReader(); reader.onload = function (e) { logoImg.src = e.target.result; logoImg.style.display = 'block'; }; reader.readAsDataURL(fileInput.files[0]); } } function deleteSubscription(event, id) { event.stopPropagation(); event.preventDefault(); if (!confirm(translate('confirm_delete_subscription'))) { return; } fetch("endpoints/subscription/delete.php", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken, }, body: JSON.stringify({ id: id }), }) .then((response) => response.json()) .then((data) => { if (data.success) { showSuccessMessage(translate('subscription_deleted')); fetchSubscriptions(null, null, "delete"); closeAddSubscription(); } else { showErrorMessage(data.message || translate('error_deleting_subscription')); } }) .catch((error) => { console.error("Error:", error); showErrorMessage(translate('error_deleting_subscription')); }); } function cloneSubscription(event, id) { event.stopPropagation(); event.preventDefault(); fetch("endpoints/subscription/clone.php", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken, }, body: JSON.stringify({ id: id }), }) .then((response) => { if (!response.ok) { throw new Error(translate("network_response_error")); } return response.json(); }) .then((data) => { if (data.success) { const newId = data.id; fetchSubscriptions(newId, event, "clone"); showSuccessMessage(decodeURI(data.message)); } else { showErrorMessage(data.message || translate("error")); } }) .catch((error) => { showErrorMessage(error.message || translate("error")); }); } function renewSubscription(event, id) { event.stopPropagation(); event.preventDefault(); fetch("endpoints/subscription/renew.php", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken, }, body: JSON.stringify({ id: id }), }) .then((response) => { if (!response.ok) { throw new Error(translate("network_response_error")); } return response.json(); }) .then((data) => { if (data.success) { const newId = data.id; fetchSubscriptions(newId, event, "renew"); showSuccessMessage(decodeURI(data.message)); } else { showErrorMessage(data.message || translate("error")); } }) .catch((error) => { showErrorMessage(error.message || translate("error")); }); } function setSearchButtonStatus() { const nameInput = document.querySelector("#name"); const hasSearchTerm = nameInput.value.trim().length > 0; const logoSearchButton = document.querySelector("#logo-search-button"); if (hasSearchTerm) { logoSearchButton.classList.remove("disabled"); } else { logoSearchButton.classList.add("disabled"); } } function searchLogo() { const nameInput = document.querySelector("#name"); const searchTerm = nameInput.value.trim(); if (searchTerm !== "") { const logoSearchPopup = document.querySelector("#logo-search-results"); logoSearchPopup.classList.add("is-open"); const imageSearchUrl = `endpoints/logos/search.php?search=${searchTerm}`; fetch(imageSearchUrl) .then(response => response.json()) .then(data => { if (data.imageUrls) { displayImageResults(data.imageUrls); } else if (data.error) { console.error(data.error); } }) .catch(error => { console.error(translate('error_fetching_image_results'), error); }); } else { nameInput.focus(); } } function displayImageResults(imageSources) { const logoResults = document.querySelector("#logo-search-images"); logoResults.innerHTML = ""; imageSources.forEach(src => { const img = document.createElement("img"); img.src = src; img.onclick = function () { selectWebLogo(src); }; img.onerror = function () { this.parentNode.removeChild(this); }; logoResults.appendChild(img); }); } function selectWebLogo(url) { closeLogoSearch(); const logoPreview = document.querySelector("#form-logo"); const logoUrl = document.querySelector("#logo-url"); logoPreview.src = url; logoPreview.style.display = 'block'; logoUrl.value = url; } function closeLogoSearch() { const logoSearchPopup = document.querySelector("#logo-search-results"); logoSearchPopup.classList.remove("is-open"); const logoResults = document.querySelector("#logo-search-images"); logoResults.innerHTML = ""; } function fetchSubscriptions(id, event, initiator) { const subscriptionsContainer = document.querySelector("#subscriptions"); let getSubscriptions = "endpoints/subscriptions/get.php"; if (activeFilters['categories'].length > 0) { getSubscriptions += `?categories=${activeFilters['categories']}`; } if (activeFilters['members'].length > 0) { getSubscriptions += getSubscriptions.includes("?") ? `&members=${activeFilters['members']}` : `?members=${activeFilters['members']}`; } if (activeFilters['payments'].length > 0) { getSubscriptions += getSubscriptions.includes("?") ? `&payments=${activeFilters['payments']}` : `?payments=${activeFilters['payments']}`; } if (activeFilters['state'] !== "") { getSubscriptions += getSubscriptions.includes("?") ? `&state=${activeFilters['state']}` : `?state=${activeFilters['state']}`; } if (activeFilters['renewalType'] !== "") { getSubscriptions += getSubscriptions.includes("?") ? `&renewalType=${activeFilters['renewalType']}` : `?renewalType=${activeFilters['renewalType']}`; } fetch(getSubscriptions) .then(response => response.text()) .then(data => { if (data) { subscriptionsContainer.innerHTML = data; const mainActions = document.querySelector("#main-actions"); if (data.includes("no-matching-subscriptions")) { // mainActions.classList.add("hidden"); } else { mainActions.classList.remove("hidden"); } } if (initiator == "clone" && id && event) { openEditSubscription(event, id); } setSwipeElements(); if (initiator === "add") { if (document.getElementsByClassName('subscription').length === 1) { setTimeout(() => { swipeHintAnimation(); }, 1000); } } }) .catch(error => { console.error(translate('error_reloading_subscription'), error); }); } function setSortOption(sortOption) { const sortOptionsContainer = document.querySelector("#sort-options"); const sortOptionsList = sortOptionsContainer.querySelectorAll("li"); sortOptionsList.forEach((option) => { if (option.getAttribute("id") === "sort-" + sortOption) { option.classList.add("selected"); } else { option.classList.remove("selected"); } }); const daysToExpire = 30; const expirationDate = new Date(); expirationDate.setDate(expirationDate.getDate() + daysToExpire); const cookieValue = encodeURIComponent(sortOption) + '; expires=' + expirationDate.toUTCString(); document.cookie = 'sortOrder=' + cookieValue + '; SameSite=Strict'; fetchSubscriptions(null, null, "sort"); toggleSortOptions(); } function convertSvgToPng(file, callback) { const reader = new FileReader(); reader.onload = function (e) { const img = new Image(); img.src = e.target.result; img.onload = function () { const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); const pngDataUrl = canvas.toDataURL('image/png'); const pngFile = dataURLtoFile(pngDataUrl, file.name.replace(".svg", ".png")); callback(pngFile); }; }; reader.readAsDataURL(file); } function dataURLtoFile(dataurl, filename) { let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1], bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n); while (n--) { u8arr[n] = bstr.charCodeAt(n); } return new File([u8arr], filename, { type: mime }); } function submitFormData(formData, submitButton, endpoint) { fetch(endpoint, { method: "POST", headers: { "X-CSRF-Token": window.csrfToken, }, body: formData, }) .then((response) => response.json()) .then((data) => { if (data.status === "Success") { showSuccessMessage(data.message); fetchSubscriptions(null, null, "add"); closeAddSubscription(); } else { showErrorMessage(data.message || translate("unknown_error")); } }) .catch((error) => { console.error(error); showErrorMessage(translate("unknown_error")); }) .finally(() => { submitButton.disabled = false; }); } document.addEventListener('DOMContentLoaded', function () { const subscriptionForm = document.querySelector("#subs-form"); const submitButton = document.querySelector("#save-button"); const endpoint = "endpoints/subscription/add.php"; subscriptionForm.addEventListener("submit", function (e) { e.preventDefault(); submitButton.disabled = true; const formData = new FormData(subscriptionForm); const fileInput = document.querySelector("#logo"); const file = fileInput.files[0]; if (file && file.type === "image/svg+xml") { convertSvgToPng(file, function (pngFile) { formData.set("logo", pngFile); submitFormData(formData, submitButton, endpoint); }); } else { submitFormData(formData, submitButton, endpoint); } }); document.addEventListener('mousedown', function (event) { const sortOptions = document.querySelector('#sort-options'); const sortButton = document.querySelector("#sort-button"); if (!sortOptions.contains(event.target) && !sortButton.contains(event.target) && isSortOptionsOpen) { sortOptions.classList.remove('is-open'); isSortOptionsOpen = false; } }); document.querySelector('#sort-options').addEventListener('focus', function () { isSortOptionsOpen = true; }); }); function searchSubscriptions() { const searchInput = document.querySelector("#search"); const searchContainer = searchInput.parentElement; const searchTerm = searchInput.value.trim().toLowerCase(); if (searchTerm.length > 0) { searchContainer.classList.add("has-text"); } else { searchContainer.classList.remove("has-text"); } const subscriptions = document.querySelectorAll(".subscription"); subscriptions.forEach(subscription => { const name = subscription.getAttribute('data-name').toLowerCase(); if (!name.includes(searchTerm)) { subscription.parentElement.classList.add("hide"); } else { subscription.parentElement.classList.remove("hide"); } }); } function clearSearch() { const searchInput = document.querySelector("#search"); searchInput.value = ""; searchSubscriptions(); } function closeSubMenus() { var subMenus = document.querySelectorAll('.filtermenu-submenu-content'); subMenus.forEach(subMenu => { subMenu.classList.remove('is-open'); }); } function setSwipeElements() { if (window.mobileNavigation) { const swipeElements = document.querySelectorAll('.subscription'); swipeElements.forEach((element) => { let startX = 0; let startY = 0; let currentX = 0; let currentY = 0; let translateX = 0; const maxTranslateX = element.classList.contains('manual') ? -240 : -180; element.addEventListener('touchstart', (e) => { startX = e.touches[0].clientX; startY = e.touches[0].clientY; element.style.transition = ''; // Remove transition for smooth dragging }); element.addEventListener('touchmove', (e) => { currentX = e.touches[0].clientX; currentY = e.touches[0].clientY; const diffX = currentX - startX; const diffY = currentY - startY; // Check if the swipe is more horizontal than vertical if (Math.abs(diffX) > Math.abs(diffY)) { e.preventDefault(); // Prevent vertical scrolling // Only update translateX if swiping within allowed range if (!(translateX === maxTranslateX && diffX < 0)) { translateX = Math.min(0, Math.max(maxTranslateX, diffX)); // Clamp translateX between -180 and 0 element.style.transform = `translateX(${translateX}px)`; } } }); element.addEventListener('touchend', () => { // Check the final swipe position to determine snap behavior if (translateX < maxTranslateX / 2) { // If more than halfway to the left, snap fully open translateX = maxTranslateX; } else { // If swiped less than halfway left or swiped right, snap back to closed translateX = 0; } element.style.transition = 'transform 0.2s ease'; // Smooth snap effect element.style.transform = `translateX(${translateX}px)`; element.style.zIndex = '1'; }); }); } } const activeFilters = []; activeFilters['categories'] = []; activeFilters['members'] = []; activeFilters['payments'] = []; activeFilters['state'] = ""; activeFilters['renewalType'] = ""; document.addEventListener("DOMContentLoaded", function () { var filtermenu = document.querySelector('#filtermenu-button'); filtermenu.addEventListener('click', function () { this.parentElement.querySelector('.filtermenu-content').classList.toggle('is-open'); closeSubMenus(); }); document.addEventListener('click', function (e) { var filtermenuContent = document.querySelector('.filtermenu-content'); if (filtermenuContent.classList.contains('is-open')) { var subMenus = document.querySelectorAll('.filtermenu-submenu'); var clickedInsideSubmenu = Array.from(subMenus).some(subMenu => subMenu.contains(e.target) || subMenu === e.target); if (!filtermenu.contains(e.target) && !clickedInsideSubmenu) { closeSubMenus(); filtermenuContent.classList.remove('is-open'); } } }); setSwipeElements(); }); function toggleSubMenu(subMenu) { var subMenu = document.getElementById("filter-" + subMenu); if (subMenu.classList.contains("is-open")) { closeSubMenus(); } else { closeSubMenus(); subMenu.classList.add("is-open"); } } function toggleReplacementSub() { const checkbox = document.getElementById('inactive'); const replacementSubscription = document.querySelector(`#replacement_subscritpion`); if (checkbox.checked) { replacementSubscription.classList.remove("hide"); } else { replacementSubscription.classList.add("hide"); } } document.querySelectorAll('.filter-item').forEach(function (item) { item.addEventListener('click', function (e) { const searchInput = document.querySelector("#search"); searchInput.value = ""; if (this.hasAttribute('data-categoryid')) { const categoryId = this.getAttribute('data-categoryid'); if (activeFilters['categories'].includes(categoryId)) { const categoryIndex = activeFilters['categories'].indexOf(categoryId); activeFilters['categories'].splice(categoryIndex, 1); this.classList.remove('selected'); } else { activeFilters['categories'].push(categoryId); this.classList.add('selected'); } } else if (this.hasAttribute('data-memberid')) { const memberId = this.getAttribute('data-memberid'); if (activeFilters['members'].includes(memberId)) { const memberIndex = activeFilters['members'].indexOf(memberId); activeFilters['members'].splice(memberIndex, 1); this.classList.remove('selected'); } else { activeFilters['members'].push(memberId); this.classList.add('selected'); } } else if (this.hasAttribute('data-paymentid')) { const paymentId = this.getAttribute('data-paymentid'); if (activeFilters['payments'].includes(paymentId)) { const paymentIndex = activeFilters['payments'].indexOf(paymentId); activeFilters['payments'].splice(paymentIndex, 1); this.classList.remove('selected'); } else { activeFilters['payments'].push(paymentId); this.classList.add('selected'); } } else if (this.hasAttribute('data-state')) { const state = this.getAttribute('data-state'); if (activeFilters['state'] === state) { activeFilters['state'] = ""; this.classList.remove('selected'); } else { activeFilters['state'] = state; Array.from(this.parentNode.children).forEach(sibling => { sibling.classList.remove('selected'); }); this.classList.add('selected'); } } else if (this.hasAttribute('data-renewaltype')) { const renewalType = this.getAttribute('data-renewaltype'); if (activeFilters['renewalType'] === renewalType) { activeFilters['renewalType'] = ""; this.classList.remove('selected'); } else { activeFilters['renewalType'] = renewalType; Array.from(this.parentNode.children).forEach(sibling => { sibling.classList.remove('selected'); }); this.classList.add('selected'); } } if (activeFilters['categories'].length > 0 || activeFilters['members'].length > 0 || activeFilters['payments'].length > 0 || activeFilters['state'] !== "" || activeFilters['renewalType'] !== "") { document.querySelector('#clear-filters').classList.remove('hide'); } else { document.querySelector('#clear-filters').classList.add('hide'); } fetchSubscriptions(null, null, "filter"); }); }); function clearFilters() { const searchInput = document.querySelector("#search"); searchInput.value = ""; activeFilters['categories'] = []; activeFilters['members'] = []; activeFilters['payments'] = []; activeFilters['state'] = ""; activeFilters['renewalType'] = ""; document.querySelectorAll('.filter-item').forEach(function (item) { item.classList.remove('selected'); }); document.querySelector('#clear-filters').classList.add('hide'); fetchSubscriptions(null, null, "clearfilters"); } let currentActions = null; document.addEventListener('click', function (event) { // Check if click was outside currentActions if (currentActions && !currentActions.contains(event.target)) { // Click was outside currentActions, close currentActions currentActions.classList.remove('is-open'); currentActions = null; } }); function expandActions(event, subscriptionId) { event.stopPropagation(); event.preventDefault(); const subscriptionDiv = document.querySelector(`.subscription[data-id="${subscriptionId}"]`); const actions = subscriptionDiv.querySelector('.actions'); // Close all other open actions const allActions = document.querySelectorAll('.actions.is-open'); allActions.forEach((openAction) => { if (openAction !== actions) { openAction.classList.remove('is-open'); } }); // Toggle the clicked actions actions.classList.toggle('is-open'); // Update currentActions if (actions.classList.contains('is-open')) { currentActions = actions; } else { currentActions = null; } } function swipeHintAnimation() { if (window.mobileNavigation && window.matchMedia('(max-width: 768px)').matches) { const maxAnimations = 3; const cookieName = 'swipeHintCount'; let count = parseInt(getCookie(cookieName)) || 0; if (count < maxAnimations) { const firstElement = document.querySelector('.subscription'); if (firstElement) { firstElement.style.transition = 'transform 0.3s ease'; firstElement.style.transform = 'translateX(-80px)'; setTimeout(() => { firstElement.style.transform = 'translateX(0px)'; firstElement.style.zIndex = '1'; }, 600); } count++; document.cookie = `${cookieName}=${count}; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/; SameSite=Strict`; } } } function autoFillNextPaymentDate(e) { e.preventDefault(); const frequencySelect = document.querySelector("#frequency"); const cycleSelect = document.querySelector("#cycle"); const startDate = document.querySelector("#start_date"); const nextPayment = document.querySelector("#next_payment"); // Do nothing if frequency, cycle, or start date is not set if (!frequencySelect.value || !cycleSelect.value || !startDate.value || isNaN(Date.parse(startDate.value))) { console.log(frequencySelect.value, cycleSelect.value, startDate.value); return; } const today = new Date(); const cycle = cycleSelect.value; const frequency = Number(frequencySelect.value); const nextDate = new Date(startDate.value); let safetyCounter = 0; const maxIterations = 1000; while (nextDate <= today && safetyCounter < maxIterations) { switch (cycle) { case '1': // Days nextDate.setDate(nextDate.getDate() + frequency); break; case '2': // Weeks nextDate.setDate(nextDate.getDate() + 7 * frequency); break; case '3': // Months nextDate.setMonth(nextDate.getMonth() + frequency); break; case '4': // Years nextDate.setFullYear(nextDate.getFullYear() + frequency); break; default: } safetyCounter++; } if (safetyCounter === maxIterations) { return; } nextPayment.value = toISOStringWithTimezone(nextDate).substring(0, 10); } function toISOStringWithTimezone(date) { const pad = n => String(Math.floor(Math.abs(n))).padStart(2, '0'); const tzOffset = -date.getTimezoneOffset(); const sign = tzOffset >= 0 ? '+' : '-'; const hoursOffset = pad(tzOffset / 60); const minutesOffset = pad(tzOffset % 60); return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()) + sign + hoursOffset + ':' + minutesOffset; } window.addEventListener('load', () => { if (document.querySelector('.subscription')) { swipeHintAnimation(); } }); ================================================ FILE: scripts/theme.js ================================================ function switchTheme() { const darkThemeCss = document.querySelector("#dark-theme"); darkThemeCss.disabled = !darkThemeCss.disabled; const themeChoice = darkThemeCss.disabled ? 'light' : 'dark'; document.cookie = 'theme=' + themeValue + '; expires=Fri, 31 Dec 9999 23:59:59 GMT; SameSite=Strict'; document.body.className = themeChoice; const button = document.getElementById("switchTheme"); button.disabled = true; fetch('endpoints/settings/theme.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify({ theme: themeChoice === 'dark' }) }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); } else { showErrorMessage(data.message); } button.disabled = false; }).catch(error => { button.disabled = false; }); } function setDarkTheme(theme) { const darkThemeButton = document.querySelector("#theme-dark"); const lightThemeButton = document.querySelector("#theme-light"); const automaticThemeButton = document.querySelector("#theme-automatic"); const darkThemeCss = document.querySelector("#dark-theme"); const themes = { 0: 'light', 1: 'dark', 2: 'automatic' }; const themeValue = themes[theme]; const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; darkThemeButton.disabled = true; lightThemeButton.disabled = true; automaticThemeButton.disabled = true; fetch('endpoints/settings/theme.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify({ theme: theme }) }) .then(response => response.json()) .then(data => { if (data.success) { darkThemeButton.disabled = false; lightThemeButton.disabled = false; automaticThemeButton.disabled = false; darkThemeButton.classList.remove('selected'); lightThemeButton.classList.remove('selected'); automaticThemeButton.classList.remove('selected'); document.cookie = `theme=${themeValue}; expires=Fri, 31 Dec 9999 23:59:59 GMT; SameSite=Strict`; if (theme == 0) { darkThemeCss.disabled = true; document.body.className = 'light'; lightThemeButton.classList.add('selected'); } if (theme == 1) { darkThemeCss.disabled = false; document.body.className = 'dark'; darkThemeButton.classList.add('selected'); } if (theme == 2) { darkThemeCss.disabled = !prefersDarkMode; document.body.className = prefersDarkMode ? 'dark' : 'light'; automaticThemeButton.classList.add('selected'); document.cookie = `inUseTheme=${prefersDarkMode ? 'dark' : 'light'}; expires=Fri, 31 Dec 9999 23:59:59 GMT; SameSite=Strict`; } showSuccessMessage(data.message); } else { showErrorMessage(data.message); darkThemeButton.disabled = false; lightThemeButton.disabled = false; automaticThemeButton.disabled = false; } }).catch(error => { darkThemeButton.disabled = false; lightThemeButton.disabled = false; automaticThemeButton.disabled = false; }); } function setTheme(themeColor) { var currentTheme = 'blue'; var themeIds = ['red-theme', 'green-theme', 'yellow-theme', 'purple-theme']; themeIds.forEach(function (id) { var themeStylesheet = document.getElementById(id); if (themeStylesheet && !themeStylesheet.disabled) { currentTheme = id.replace('-theme', ''); themeStylesheet.disabled = true; } }); if (themeColor !== "blue") { var enableTheme = document.getElementById(themeColor + '-theme'); enableTheme.disabled = false; } var images = document.querySelectorAll('img'); images.forEach(function (img) { if (img.src.includes('siteicons/' + currentTheme)) { img.src = img.src.replace(currentTheme, themeColor); } }); var labels = document.querySelectorAll('.theme-preview'); labels.forEach(function (label) { label.classList.remove('is-selected'); }); var targetLabel = document.querySelector(`.theme-preview.${themeColor}`); if (targetLabel) { targetLabel.classList.add('is-selected'); } document.cookie = `colorTheme=${themeColor}; expires=Fri, 31 Dec 9999 23:59:59 GMT; SameSite=Strict`; fetch('endpoints/settings/colortheme.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify({ color: themeColor }) }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); } else { showErrorMessage(data.message); } }) .catch(error => { showErrorMessage(translate('unknown_error')); }); } function resetCustomColors() { const button = document.getElementById("reset-colors"); button.disabled = true; fetch("endpoints/settings/resettheme.php", { method: "POST", headers: { "X-CSRF-Token": window.csrfToken, }, body: new URLSearchParams({ action: "reset", }), }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); const customThemeColors = document.getElementById("custom_theme_colors"); if (customThemeColors) { customThemeColors.remove(); } document.documentElement.style.removeProperty("--main-color"); document.documentElement.style.removeProperty("--accent-color"); document.documentElement.style.removeProperty("--hover-color"); document.getElementById("mainColor").value = "#FFFFFF"; document.getElementById("accentColor").value = "#FFFFFF"; document.getElementById("hoverColor").value = "#FFFFFF"; } else { showErrorMessage(data.message || translate("failed_reset_colors")); } }) .catch(error => { console.error(error); showErrorMessage(translate("unknown_error")); }) .finally(() => { button.disabled = false; }); } function saveCustomColors() { const button = document.getElementById("save-colors"); button.disabled = true; const mainColor = document.getElementById("mainColor").value; const accentColor = document.getElementById("accentColor").value; const hoverColor = document.getElementById("hoverColor").value; fetch('endpoints/settings/customtheme.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify({ mainColor: mainColor, accentColor: accentColor, hoverColor: hoverColor }) }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); document.documentElement.style.setProperty('--main-color', mainColor); document.documentElement.style.setProperty('--accent-color', accentColor); document.documentElement.style.setProperty('--hover-color', hoverColor); } else { showErrorMessage(data.message); } button.disabled = false; }) .catch(error => { showErrorMessage(translate('unknown_error')); button.disabled = false; }); } function saveCustomCss() { const button = document.getElementById("save-css"); button.disabled = true; const customCss = document.getElementById("customCss").value; fetch('endpoints/settings/customcss.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, body: JSON.stringify({ customCss: customCss }) }) .then(response => response.json()) .then(data => { if (data.success) { showSuccessMessage(data.message); } else { showErrorMessage(data.message); } button.disabled = false; }) .catch(error => { showErrorMessage(translate('unknown_error')); button.disabled = false; }); } ================================================ FILE: service-worker.js ================================================ const STATIC_CACHE = 'static-cache-v1'; const PAGES_CACHE = 'pages-cache-v1'; const LOGOS_CACHE = 'logos-cache-v1'; const staticAssets = [ 'manifest.json', 'styles/styles.css', 'styles/dark-theme.css', 'styles/login.css', 'styles/font-awesome.min.css', 'styles/brands.css', 'styles/barlow.css', 'styles/themes/red.css', 'styles/themes/green.css', 'styles/themes/yellow.css', 'styles/themes/purple.css', 'webfonts/fa-solid-900.woff2', 'webfonts/fa-solid-900.ttf', 'webfonts/fa-brands-400.woff2', 'webfonts/fa-brands-400.ttf', 'webfonts/fa-regular-400.woff2', 'webfonts/fa-regular-400.ttf', 'scripts/common.js', 'scripts/dashboard.js', 'scripts/subscriptions.js', 'scripts/stats.js', 'scripts/settings.js', 'scripts/theme.js', 'scripts/notifications.js', 'scripts/registration.js', 'scripts/login.js', 'scripts/admin.js', 'scripts/calendar.js', 'scripts/i18n/cs.js', 'scripts/i18n/da.js', 'scripts/i18n/de.js', 'scripts/i18n/el.js', 'scripts/i18n/en.js', 'scripts/i18n/es.js', 'scripts/i18n/fr.js', 'scripts/i18n/id.js', 'scripts/i18n/it.js', 'scripts/i18n/jp.js', 'scripts/i18n/ko.js', 'scripts/i18n/nl.js', 'scripts/i18n/pl.js', 'scripts/i18n/pt.js', 'scripts/i18n/pt_br.js', 'scripts/i18n/ro.js', 'scripts/i18n/ru.js', 'scripts/i18n/sl.js', 'scripts/i18n/sr_lat.js', 'scripts/i18n/sr.js', 'scripts/i18n/tr.js', 'scripts/i18n/uk.js', 'scripts/i18n/vi.js', 'scripts/i18n/zh_cn.js', 'scripts/i18n/zh_tw.js', 'scripts/i18n/getlang.js', 'scripts/libs/chart.js', 'scripts/libs/sortable.min.js', 'scripts/libs/qrcode.min.js', 'images/icon/favicon.ico', 'images/icon/android-chrome-192x192.png', 'images/icon/apple-touch-icon-180', 'images/icon/apple-touch-icon-152', 'images/icon/apple-touch-icon', 'images/screenshots/desktop.png', 'images/siteicons/wallos.png', 'images/siteicons/walloswhite.png', 'images/siteimages/empty.png', 'images/siteimages/mobilenav.png', 'images/siteimages/mobilenavdark.png', 'images/avatars/1.svg', 'images/avatars/2.svg', 'images/avatars/3.svg', 'images/avatars/4.svg', 'images/avatars/5.svg', 'images/avatars/6.svg', 'images/avatars/7.svg', 'images/avatars/8.svg', 'images/avatars/9.svg', 'images/siteicons/svg/logo.php', 'images/siteicons/svg/category.php', 'images/siteicons/svg/check.php', 'images/siteicons/svg/delete.php', 'images/siteicons/svg/edit.php', 'images/siteicons/svg/notes.php', 'images/siteicons/scg/payment.php', 'images/siteicons/svg/save.php', 'images/siteicons/svg/subscription.php', 'images/siteicons/svg/web.php', 'images/siteicons/svg/websearch.php', 'images/siteicons/svg/clone.php', 'images/siteicons/svg/mobile-menu/calendar.php', 'images/siteicons/svg/mobile-menu/home.php', 'images/siteicons/svg/mobile-menu/profile.php', 'images/siteicons/svg/mobile-menu/settings.php', 'images/siteicons/svg/mobile-menu/statistics.php', 'images/siteicons/svg/mobile-menu/subscriptions.php', 'images/siteicons/pwa/stats.png', 'images/siteicons/pwa/settings.png', 'images/siteicons/pwa/about.png', 'images/siteicons/pwa/calendar.png', 'images/siteicons/pwa/subscriptions.png', 'images/siteicons/pwa/dashboard.png', 'images/uploads/icons/paypal.png', 'images/uploads/icons/creditcard.png', 'images/uploads/icons/banktransfer.png', 'images/uploads/icons/directdebit.png', 'images/uploads/icons/money.png', 'images/uploads/icons/googlepay.png', 'images/uploads/icons/samsungpay.png', 'images/uploads/icons/applepay.png', 'images/uploads/icons/crypto.png', 'images/uploads/icons/klarna.png', 'images/uploads/icons/amazonpay.png', 'images/uploads/icons/sepa.png', 'images/uploads/icons/skrill.png', 'images/uploads/icons/sofort.png', 'images/uploads/icons/stripe.png', 'images/uploads/icons/affirm.png', 'images/uploads/icons/alipay.png', 'images/uploads/icons/elo.png', 'images/uploads/icons/facebookpay.png', 'images/uploads/icons/giropay.png', 'images/uploads/icons/ideal.png', 'images/uploads/icons/unionpay.png', 'images/uploads/icons/interac.png', 'images/uploads/icons/wechat.png', 'images/uploads/icons/paysafe.png', 'images/uploads/icons/poli.png', 'images/uploads/icons/qiwi.png', 'images/uploads/icons/shoppay.png', 'images/uploads/icons/venmo.png', 'images/uploads/icons/verifone.png', 'images/uploads/icons/webmoney.png', ]; const pagesToPrefetch = [ 'index.php', 'subscriptions.php', 'profile.php', 'calendar.php', 'settings.php', 'stats.php', 'about.php', 'login.php', 'admin.php', ]; // Install: cache static assets only self.addEventListener('install', function (event) { event.waitUntil( caches.open(STATIC_CACHE).then(function (cache) { return Promise.allSettled( staticAssets.map(url => fetch(url).then(response => { if (response.ok) cache.put(url, response); }).catch(() => {}) // silently skip missing files ) ); }) ); self.skipWaiting(); }); // Activate: clean up old caches self.addEventListener('activate', function (event) { const validCaches = [STATIC_CACHE, PAGES_CACHE, LOGOS_CACHE]; event.waitUntil( caches.keys().then(keys => Promise.all( keys.filter(key => !validCaches.includes(key)) .map(key => caches.delete(key)) ) ) ); self.clients.claim(); }); // Message: prefetch pages after login self.addEventListener('message', function (event) { if (event.data && event.data.type === 'PREFETCH_PAGES') { caches.open(PAGES_CACHE).then(cache => { pagesToPrefetch.forEach(url => { fetch(url).then(response => { // Only cache if user is actually logged in (no redirect) if (response.ok && !response.redirected) { cache.put(url, response); } }).catch(() => {}); }); }); } }); // Fetch: single handler for all requests self.addEventListener('fetch', function (event) { const request = event.request; const url = new URL(request.url); // Never intercept non-GET requests (POST, etc.) if (request.method !== 'GET') return; // Logo images: cache-first, populate on first load if (url.pathname.includes('images/uploads/logos')) { event.respondWith( caches.match(request).then(response => { return response || fetch(request).then(networkResponse => { return caches.open(LOGOS_CACHE).then(cache => { cache.put(request, networkResponse.clone()); return networkResponse; }); }); }) ); return; } // Static assets: cache-first (they only change on deploy) if (staticAssets.some(asset => url.pathname.endsWith(asset))) { event.respondWith( caches.match(request).then(response => response || fetch(request)) ); return; } // PHP pages and everything else: network-first, cache as fallback // Also update the pages cache on every successful load event.respondWith( fetch(request).then(response => { if (response.ok && !response.redirected) { const responseClone = response.clone(); // clone before any async operation caches.open(PAGES_CACHE).then(cache => { cache.put(request, responseClone); }); } return response; }).catch(() => { return caches.match(request, { ignoreSearch: true }); }) ); }); ================================================ FILE: settings.php ================================================ prepare($query); $query->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $query->execute(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $currencyId = $row['id']; $currencies[$currencyId] = $row; } $userData['currency_symbol'] = $currencies[$main_currency]['symbol']; ?>
prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { $household = array(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $household[] = $row; } } ?> prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $rowCount = 0; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $notifications = $row; $rowCount++; } if ($rowCount == 0) { $notifications['days'] = 1; } // Email notifications $sql = "SELECT * FROM email_notifications WHERE user_id = :userId LIMIT 1"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $rowCount = 0; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $notificationsEmail['enabled'] = $row['enabled']; $notificationsEmail['smtp_address'] = $row['smtp_address']; $notificationsEmail['smtp_port'] = $row['smtp_port']; $notificationsEmail['encryption'] = $row['encryption']; $notificationsEmail['smtp_username'] = $row['smtp_username']; $notificationsEmail['smtp_password'] = $row['smtp_password']; $notificationsEmail['from_email'] = $row['from_email']; $notificationsEmail['other_emails'] = $row['other_emails']; $rowCount++; } if ($rowCount == 0) { $notificationsEmail['enabled'] = 0; $notificationsEmail['smtp_address'] = ""; $notificationsEmail['smtp_port'] = 587; $notificationsEmail['encryption'] = "tls"; $notificationsEmail['smtp_username'] = ""; $notificationsEmail['smtp_password'] = ""; $notificationsEmail['from_email'] = ""; $notificationsEmail['other_emails'] = ""; } // Discord notifications $sql = "SELECT * FROM discord_notifications WHERE user_id = :userId LIMIT 1"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $rowCount = 0; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $notificationsDiscord['enabled'] = $row['enabled']; $notificationsDiscord['webhook_url'] = $row['webhook_url']; $notificationsDiscord['bot_username'] = $row['bot_username']; $notificationsDiscord['bot_avatar'] = $row['bot_avatar_url']; $rowCount++; } if ($rowCount == 0) { $notificationsDiscord['enabled'] = 0; $notificationsDiscord['webhook_url'] = ""; $notificationsDiscord['bot_username'] = ""; $notificationsDiscord['bot_avatar'] = ""; } // Pushover notifications $sql = "SELECT * FROM pushover_notifications WHERE user_id = :userId LIMIT 1"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $rowCount = 0; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $notificationsPushover['enabled'] = $row['enabled']; $notificationsPushover['token'] = $row['token']; $notificationsPushover['user_key'] = $row['user_key']; $rowCount++; } if ($rowCount == 0) { $notificationsPushover['enabled'] = 0; $notificationsPushover['token'] = ""; $notificationsPushover['user_key'] = ""; } // Telegram notifications $sql = "SELECT * FROM telegram_notifications WHERE user_id = :userId LIMIT 1"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $rowCount = 0; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $notificationsTelegram['enabled'] = $row['enabled']; $notificationsTelegram['bot_token'] = $row['bot_token']; $notificationsTelegram['chat_id'] = $row['chat_id']; $rowCount++; } if ($rowCount == 0) { $notificationsTelegram['enabled'] = 0; $notificationsTelegram['bot_token'] = ""; $notificationsTelegram['chat_id'] = ""; } // PushPlus notifications $sql = "SELECT * FROM pushplus_notifications WHERE user_id = :userId LIMIT 1"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $rowCount = 0; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $notificationsPushPlus['enabled'] = $row['enabled']; $notificationsPushPlus['token'] = $row['token']; $rowCount++; } if ($rowCount == 0) { $notificationsPushPlus['enabled'] = 0; $notificationsPushPlus['token'] = ""; } // Mattermost notifications $sql = "SELECT * FROM mattermost_notifications WHERE user_id = :userID LIMIT 1"; $stmt = $db->prepare($sql); $stmt->bindValue(':userID', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $rowCount = 0; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $notificationsMattermost['enabled'] = $row['enabled']; $notificationsMattermost['webhook_url'] = $row['webhook_url']; $notificationsMattermost['bot_username'] = $row['bot_username']; $notificationsMattermost['bot_icon_emoji'] = $row['bot_icon_emoji']; $rowCount++; } if ($rowCount == 0) { $notificationsMattermost['enabled'] = 0; $notificationsMattermost['webhook_url'] = ""; $notificationsMattermost['bot_username'] = ""; $notificationsMattermost['bot_icon_emoji'] = ""; } // Serverchan notifications $sql = "SELECT * FROM serverchan_notifications WHERE user_id = :userId LIMIT 1"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $rowCount = 0; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $notificationsServerchan['enabled'] = $row['enabled']; $notificationsServerchan['sendkey'] = $row['sendkey']; $rowCount++; } if ($rowCount == 0) { $notificationsServerchan['enabled'] = 0; $notificationsServerchan['sendkey'] = ""; } // Ntfy notifications $sql = "SELECT * FROM ntfy_notifications WHERE user_id = :userId LIMIT 1"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $rowCount = 0; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $notificationsNtfy['enabled'] = $row['enabled']; $notificationsNtfy['host'] = $row['host']; $notificationsNtfy['topic'] = $row['topic']; $notificationsNtfy['headers'] = $row['headers']; $notificationsNtfy['ignore_ssl'] = $row['ignore_ssl']; $rowCount++; } if ($rowCount == 0) { $notificationsNtfy['enabled'] = 0; $notificationsNtfy['host'] = ""; $notificationsNtfy['topic'] = ""; $notificationsNtfy['headers'] = ""; $notificationsNtfy['ignore_ssl'] = 0; } // Webhook notifications $sql = "SELECT * FROM webhook_notifications WHERE user_id = :userId LIMIT 1"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $rowCount = 0; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $notificationsWebhook['enabled'] = $row['enabled']; $notificationsWebhook['url'] = $row['url']; $notificationsWebhook['request_method'] = $row['request_method']; $notificationsWebhook['headers'] = $row['headers']; $notificationsWebhook['payload'] = $row['payload']; $notificationsWebhook['cancelation_payload'] = $row['cancelation_payload']; $notificationsWebhook['ignore_ssl'] = $row['ignore_ssl']; $rowCount++; } if ($rowCount == 0) { $notificationsWebhook['enabled'] = 0; $notificationsWebhook['url'] = ""; $notificationsWebhook['request_method'] = "POST"; $notificationsWebhook['headers'] = ""; $notificationsWebhook['payload'] = ' { "name": "{{subscription_name}}", "price": "{{subscription_price}}", "currency": "{{subscription_currency}}", "category": "{{subscription_category}}", "date": "{{subscription_date}}", "payer": "{{subscription_payer}}", "days": "{{subscription_days_until_payment}}", "notes": "{{subscription_notes}}", "url": "{{subscription_url}}" }'; $notificationsWebhook['cancelation_payload'] = ""; $notificationsWebhook['ignore_ssl'] = 0; } // Gotify notifications $sql = "SELECT * FROM gotify_notifications WHERE user_id = :userId LIMIT 1"; $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $rowCount = 0; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $notificationsGotify['enabled'] = $row['enabled']; $notificationsGotify['url'] = $row['url']; $notificationsGotify['token'] = $row['token']; $notificationsGotify['ignore_ssl'] = $row['ignore_ssl']; $rowCount++; } if ($rowCount == 0) { $notificationsGotify['enabled'] = 0; $notificationsGotify['url'] = ""; $notificationsGotify['token'] = ""; $notificationsGotify['ignore_ssl'] = 0; } ?> prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { $categories = array(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $categories[] = $row; } } ?> prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { $currencies = array(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $currencies[] = $row; } } $query = "SELECT main_currency FROM user WHERE id = :userId"; $stmt = $db->prepare($query); $stmt->bindParam(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $mainCurrencyId = $row['main_currency']; $query = "SELECT date FROM last_exchange_update"; $exchange_rates_last_updated = $db->querySingle($query); ?> prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { $row = $result->fetchArray(SQLITE3_ASSOC); if ($row) { $apiKey = $row['api_key']; $provider = $row['provider']; } else { $provider = 0; } } ?> prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $aiSettings = []; if ($row = $result->fetchArray(SQLITE3_ASSOC)) { $aiSettings = $row; } ?> prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result) { $payments = array(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $payments[] = $row; } } ?>
================================================ FILE: startup.sh ================================================ #!/bin/sh set -euo pipefail echo "Startup script is running..." > /var/log/startup.log # Default the PUID and PGID environment variables to 82, otherwise # set to the user defined ones. PUID=${PUID:-82} PGID=${PGID:-82} # Change the www-data user id and group id to be the user-specified ones groupmod -o -g "$PGID" www-data usermod -o -u "$PUID" www-data chown -R www-data:www-data /var/www/html chown -R www-data:www-data /tmp chmod -R 770 /tmp # PIDs we’ll track PHP_FPM_PID= NGINX_PID= CROND_PID= shutdown_in_progress=0 shutdown_once() { exit_signal=$? kill_signal=$(kill -l "$exit_signal" 2>/dev/null || echo "$exit_signal") [ "$shutdown_in_progress" -eq 1 ] && return 0 shutdown_in_progress=1 echo "Got signal: $kill_signal - Shutting down gracefully... " # nginx wants QUIT for graceful nginx -s quit || true # php-fpm graceful quit as well [ -n "${PHP_FPM_PID}" ] && kill -QUIT "${PHP_FPM_PID}" 2>/dev/null || true # cron can just get TERM [ -n "${CROND_PID}" ] && kill -TERM "${CROND_PID}" 2>/dev/null || true echo "Graceful shutdown complete." } # Handle all common stop signals trap 'shutdown_once' SIGTERM SIGINT SIGQUIT # Start both PHP-FPM and Nginx echo "Launching php-fpm" php-fpm -F & PHP_FPM_PID=$! echo "Launching crond" crond -f & CROND_PID=$! echo "Launching nginx" nginx -g 'daemon off;' & NGINX_PID=$! touch ~/startup.txt # Wait one second before running scripts sleep 1 # Create database if it does not exist /usr/local/bin/php /var/www/html/endpoints/cronjobs/createdatabase.php # Perform any database migrations /usr/local/bin/php /var/www/html/endpoints/db/migrate.php # Change permissions on the database directory chmod -R 755 /var/www/html/db/ chown -R www-data:www-data /var/www/html/db/ mkdir -p /var/www/html/images/uploads/logos/avatars # Change permissions on the logos directory chmod -R 755 /var/www/html/images/uploads/logos chown -R www-data:www-data /var/www/html/images/uploads/logos # Remove crontab for the user crontab -d -u root # Run updatenextpayment.php and wait for it to finish /usr/local/bin/php /var/www/html/endpoints/cronjobs/updatenextpayment.php # Run updateexchange.php /usr/local/bin/php /var/www/html/endpoints/cronjobs/updateexchange.php # Run checkforupdates.php /usr/local/bin/php /var/www/html/endpoints/cronjobs/checkforupdates.php # Essentially wait until all child processes exit wait ================================================ FILE: stats.php ================================================ prepare($query); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $code = $row['code']; require_once 'includes/stats_calculations.php'; ?>

1) { ?>
1) { // sort categories by order usort($categories, function ($a, $b) { return $a['order'] - $b['order']; }); ?>
0) { if ($category['name'] == "No category") { $category['name'] = translate("no_category", $i18n); } $selectedClass = ''; if (isset($_GET['category']) && $_GET['category'] == $category['id']) { $selectedClass = 'selected'; } ?>
1) { usort($paymentMethods, function ($a, $b) { return $a['order'] <=> $b['order']; }); ?>
<?= $mostExpensiveSubscription['name'] ?>
%
0) { ?>
0) { ?>
html_entity_decode($category['name']), "y" => $category["cost"], ]; } } } $showCategoryCostGraph = count($categoryDataPoints) > 1; $memberDataPoints = []; if (isset($memberCost)) { foreach ($memberCost as $member) { if ($member['cost'] != 0) { $memberDataPoints[] = [ "label" => html_entity_decode($member['name']), "y" => $member["cost"], ]; } } } $showMemberCostGraph = count($memberDataPoints) > 1; $paymentMethodDataPoints = []; foreach ($paymentMethodsCount as $paymentMethod) { if ($paymentMethod['count'] != 0) { $paymentMethodDataPoints[] = [ "label" => html_entity_decode($paymentMethod['name']), "y" => $paymentMethod["count"], ]; } } $showPaymentMethodsGraph = count($paymentMethodDataPoints) > 1; if ($showCategoryCostGraph || $showMemberCostGraph || $showPaymentMethodsGraph || $showTotalMonthlyCostGraph || $showVsBudgetGraph) { ?>

()
()
()
()
================================================ FILE: styles/barlow.css ================================================ /* vietnamese */ @font-face { font-family: 'Barlow'; font-style: normal; font-weight: 300; src: url(https://fonts.gstatic.com/s/barlow/v12/7cHqv4kjgoGqM7E3p-ks6FospT4.woff2) format('woff2'); unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; } /* latin-ext */ @font-face { font-family: 'Barlow'; font-style: normal; font-weight: 300; src: url(https://fonts.gstatic.com/s/barlow/v12/7cHqv4kjgoGqM7E3p-ks6VospT4.woff2) format('woff2'); unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } /* latin */ @font-face { font-family: 'Barlow'; font-style: normal; font-weight: 300; src: url(https://fonts.gstatic.com/s/barlow/v12/7cHqv4kjgoGqM7E3p-ks51os.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } /* vietnamese */ @font-face { font-family: 'Barlow'; font-style: normal; font-weight: 400; src: url(https://fonts.gstatic.com/s/barlow/v12/7cHpv4kjgoGqM7E_A8s52Hs.woff2) format('woff2'); unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; } /* latin-ext */ @font-face { font-family: 'Barlow'; font-style: normal; font-weight: 400; src: url(https://fonts.gstatic.com/s/barlow/v12/7cHpv4kjgoGqM7E_Ass52Hs.woff2) format('woff2'); unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } /* latin */ @font-face { font-family: 'Barlow'; font-style: normal; font-weight: 400; src: url(https://fonts.gstatic.com/s/barlow/v12/7cHpv4kjgoGqM7E_DMs5.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } /* vietnamese */ @font-face { font-family: 'Barlow'; font-style: normal; font-weight: 500; src: url(https://fonts.gstatic.com/s/barlow/v12/7cHqv4kjgoGqM7E3_-gs6FospT4.woff2) format('woff2'); unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; } /* latin-ext */ @font-face { font-family: 'Barlow'; font-style: normal; font-weight: 500; src: url(https://fonts.gstatic.com/s/barlow/v12/7cHqv4kjgoGqM7E3_-gs6VospT4.woff2) format('woff2'); unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } /* latin */ @font-face { font-family: 'Barlow'; font-style: normal; font-weight: 500; src: url(https://fonts.gstatic.com/s/barlow/v12/7cHqv4kjgoGqM7E3_-gs51os.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } /* vietnamese */ @font-face { font-family: 'Barlow'; font-style: normal; font-weight: 600; src: url(https://fonts.gstatic.com/s/barlow/v12/7cHqv4kjgoGqM7E30-8s6FospT4.woff2) format('woff2'); unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; } /* latin-ext */ @font-face { font-family: 'Barlow'; font-style: normal; font-weight: 600; src: url(https://fonts.gstatic.com/s/barlow/v12/7cHqv4kjgoGqM7E30-8s6VospT4.woff2) format('woff2'); unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } /* latin */ @font-face { font-family: 'Barlow'; font-style: normal; font-weight: 600; src: url(https://fonts.gstatic.com/s/barlow/v12/7cHqv4kjgoGqM7E30-8s51os.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } /* vietnamese */ @font-face { font-family: 'Barlow'; font-style: normal; font-weight: 700; src: url(https://fonts.gstatic.com/s/barlow/v12/7cHqv4kjgoGqM7E3t-4s6FospT4.woff2) format('woff2'); unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; } /* latin-ext */ @font-face { font-family: 'Barlow'; font-style: normal; font-weight: 700; src: url(https://fonts.gstatic.com/s/barlow/v12/7cHqv4kjgoGqM7E3t-4s6VospT4.woff2) format('woff2'); unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } /* latin */ @font-face { font-family: 'Barlow'; font-style: normal; font-weight: 700; src: url(https://fonts.gstatic.com/s/barlow/v12/7cHqv4kjgoGqM7E3t-4s51os.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } ================================================ FILE: styles/brands.css ================================================ /*! * Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2024 Fonticons, Inc. */ :root, :host { --fa-style-family-brands: 'Font Awesome 6 Brands'; --fa-font-brands: normal 400 1em/1 'Font Awesome 6 Brands'; } @font-face { font-family: 'Font Awesome 6 Brands'; font-style: normal; font-weight: 400; font-display: block; src: url("../webfonts/fa-brands-400.woff2") format("woff2"), url("../webfonts/fa-brands-400.ttf") format("truetype"); } .fab, .fa-brands { font-weight: 400; } .fa-monero:before { content: "\f3d0"; } .fa-hooli:before { content: "\f427"; } .fa-yelp:before { content: "\f1e9"; } .fa-cc-visa:before { content: "\f1f0"; } .fa-lastfm:before { content: "\f202"; } .fa-shopware:before { content: "\f5b5"; } .fa-creative-commons-nc:before { content: "\f4e8"; } .fa-aws:before { content: "\f375"; } .fa-redhat:before { content: "\f7bc"; } .fa-yoast:before { content: "\f2b1"; } .fa-cloudflare:before { content: "\e07d"; } .fa-ups:before { content: "\f7e0"; } .fa-pixiv:before { content: "\e640"; } .fa-wpexplorer:before { content: "\f2de"; } .fa-dyalog:before { content: "\f399"; } .fa-bity:before { content: "\f37a"; } .fa-stackpath:before { content: "\f842"; } .fa-buysellads:before { content: "\f20d"; } .fa-first-order:before { content: "\f2b0"; } .fa-modx:before { content: "\f285"; } .fa-guilded:before { content: "\e07e"; } .fa-vnv:before { content: "\f40b"; } .fa-square-js:before { content: "\f3b9"; } .fa-js-square:before { content: "\f3b9"; } .fa-microsoft:before { content: "\f3ca"; } .fa-qq:before { content: "\f1d6"; } .fa-orcid:before { content: "\f8d2"; } .fa-java:before { content: "\f4e4"; } .fa-invision:before { content: "\f7b0"; } .fa-creative-commons-pd-alt:before { content: "\f4ed"; } .fa-centercode:before { content: "\f380"; } .fa-glide-g:before { content: "\f2a6"; } .fa-drupal:before { content: "\f1a9"; } .fa-jxl:before { content: "\e67b"; } .fa-hire-a-helper:before { content: "\f3b0"; } .fa-creative-commons-by:before { content: "\f4e7"; } .fa-unity:before { content: "\e049"; } .fa-whmcs:before { content: "\f40d"; } .fa-rocketchat:before { content: "\f3e8"; } .fa-vk:before { content: "\f189"; } .fa-untappd:before { content: "\f405"; } .fa-mailchimp:before { content: "\f59e"; } .fa-css3-alt:before { content: "\f38b"; } .fa-square-reddit:before { content: "\f1a2"; } .fa-reddit-square:before { content: "\f1a2"; } .fa-vimeo-v:before { content: "\f27d"; } .fa-contao:before { content: "\f26d"; } .fa-square-font-awesome:before { content: "\e5ad"; } .fa-deskpro:before { content: "\f38f"; } .fa-brave:before { content: "\e63c"; } .fa-sistrix:before { content: "\f3ee"; } .fa-square-instagram:before { content: "\e055"; } .fa-instagram-square:before { content: "\e055"; } .fa-battle-net:before { content: "\f835"; } .fa-the-red-yeti:before { content: "\f69d"; } .fa-square-hacker-news:before { content: "\f3af"; } .fa-hacker-news-square:before { content: "\f3af"; } .fa-edge:before { content: "\f282"; } .fa-threads:before { content: "\e618"; } .fa-napster:before { content: "\f3d2"; } .fa-square-snapchat:before { content: "\f2ad"; } .fa-snapchat-square:before { content: "\f2ad"; } .fa-google-plus-g:before { content: "\f0d5"; } .fa-artstation:before { content: "\f77a"; } .fa-markdown:before { content: "\f60f"; } .fa-sourcetree:before { content: "\f7d3"; } .fa-google-plus:before { content: "\f2b3"; } .fa-diaspora:before { content: "\f791"; } .fa-foursquare:before { content: "\f180"; } .fa-stack-overflow:before { content: "\f16c"; } .fa-github-alt:before { content: "\f113"; } .fa-phoenix-squadron:before { content: "\f511"; } .fa-pagelines:before { content: "\f18c"; } .fa-algolia:before { content: "\f36c"; } .fa-red-river:before { content: "\f3e3"; } .fa-creative-commons-sa:before { content: "\f4ef"; } .fa-safari:before { content: "\f267"; } .fa-google:before { content: "\f1a0"; } .fa-square-font-awesome-stroke:before { content: "\f35c"; } .fa-font-awesome-alt:before { content: "\f35c"; } .fa-atlassian:before { content: "\f77b"; } .fa-linkedin-in:before { content: "\f0e1"; } .fa-digital-ocean:before { content: "\f391"; } .fa-nimblr:before { content: "\f5a8"; } .fa-chromecast:before { content: "\f838"; } .fa-evernote:before { content: "\f839"; } .fa-hacker-news:before { content: "\f1d4"; } .fa-creative-commons-sampling:before { content: "\f4f0"; } .fa-adversal:before { content: "\f36a"; } .fa-creative-commons:before { content: "\f25e"; } .fa-watchman-monitoring:before { content: "\e087"; } .fa-fonticons:before { content: "\f280"; } .fa-weixin:before { content: "\f1d7"; } .fa-shirtsinbulk:before { content: "\f214"; } .fa-codepen:before { content: "\f1cb"; } .fa-git-alt:before { content: "\f841"; } .fa-lyft:before { content: "\f3c3"; } .fa-rev:before { content: "\f5b2"; } .fa-windows:before { content: "\f17a"; } .fa-wizards-of-the-coast:before { content: "\f730"; } .fa-square-viadeo:before { content: "\f2aa"; } .fa-viadeo-square:before { content: "\f2aa"; } .fa-meetup:before { content: "\f2e0"; } .fa-centos:before { content: "\f789"; } .fa-adn:before { content: "\f170"; } .fa-cloudsmith:before { content: "\f384"; } .fa-opensuse:before { content: "\e62b"; } .fa-pied-piper-alt:before { content: "\f1a8"; } .fa-square-dribbble:before { content: "\f397"; } .fa-dribbble-square:before { content: "\f397"; } .fa-codiepie:before { content: "\f284"; } .fa-node:before { content: "\f419"; } .fa-mix:before { content: "\f3cb"; } .fa-steam:before { content: "\f1b6"; } .fa-cc-apple-pay:before { content: "\f416"; } .fa-scribd:before { content: "\f28a"; } .fa-debian:before { content: "\e60b"; } .fa-openid:before { content: "\f19b"; } .fa-instalod:before { content: "\e081"; } .fa-expeditedssl:before { content: "\f23e"; } .fa-sellcast:before { content: "\f2da"; } .fa-square-twitter:before { content: "\f081"; } .fa-twitter-square:before { content: "\f081"; } .fa-r-project:before { content: "\f4f7"; } .fa-delicious:before { content: "\f1a5"; } .fa-freebsd:before { content: "\f3a4"; } .fa-vuejs:before { content: "\f41f"; } .fa-accusoft:before { content: "\f369"; } .fa-ioxhost:before { content: "\f208"; } .fa-fonticons-fi:before { content: "\f3a2"; } .fa-app-store:before { content: "\f36f"; } .fa-cc-mastercard:before { content: "\f1f1"; } .fa-itunes-note:before { content: "\f3b5"; } .fa-golang:before { content: "\e40f"; } .fa-kickstarter:before { content: "\f3bb"; } .fa-square-kickstarter:before { content: "\f3bb"; } .fa-grav:before { content: "\f2d6"; } .fa-weibo:before { content: "\f18a"; } .fa-uncharted:before { content: "\e084"; } .fa-firstdraft:before { content: "\f3a1"; } .fa-square-youtube:before { content: "\f431"; } .fa-youtube-square:before { content: "\f431"; } .fa-wikipedia-w:before { content: "\f266"; } .fa-wpressr:before { content: "\f3e4"; } .fa-rendact:before { content: "\f3e4"; } .fa-angellist:before { content: "\f209"; } .fa-galactic-republic:before { content: "\f50c"; } .fa-nfc-directional:before { content: "\e530"; } .fa-skype:before { content: "\f17e"; } .fa-joget:before { content: "\f3b7"; } .fa-fedora:before { content: "\f798"; } .fa-stripe-s:before { content: "\f42a"; } .fa-meta:before { content: "\e49b"; } .fa-laravel:before { content: "\f3bd"; } .fa-hotjar:before { content: "\f3b1"; } .fa-bluetooth-b:before { content: "\f294"; } .fa-square-letterboxd:before { content: "\e62e"; } .fa-sticker-mule:before { content: "\f3f7"; } .fa-creative-commons-zero:before { content: "\f4f3"; } .fa-hips:before { content: "\f452"; } .fa-behance:before { content: "\f1b4"; } .fa-reddit:before { content: "\f1a1"; } .fa-discord:before { content: "\f392"; } .fa-chrome:before { content: "\f268"; } .fa-app-store-ios:before { content: "\f370"; } .fa-cc-discover:before { content: "\f1f2"; } .fa-wpbeginner:before { content: "\f297"; } .fa-confluence:before { content: "\f78d"; } .fa-shoelace:before { content: "\e60c"; } .fa-mdb:before { content: "\f8ca"; } .fa-dochub:before { content: "\f394"; } .fa-accessible-icon:before { content: "\f368"; } .fa-ebay:before { content: "\f4f4"; } .fa-amazon:before { content: "\f270"; } .fa-unsplash:before { content: "\e07c"; } .fa-yarn:before { content: "\f7e3"; } .fa-square-steam:before { content: "\f1b7"; } .fa-steam-square:before { content: "\f1b7"; } .fa-500px:before { content: "\f26e"; } .fa-square-vimeo:before { content: "\f194"; } .fa-vimeo-square:before { content: "\f194"; } .fa-asymmetrik:before { content: "\f372"; } .fa-font-awesome:before { content: "\f2b4"; } .fa-font-awesome-flag:before { content: "\f2b4"; } .fa-font-awesome-logo-full:before { content: "\f2b4"; } .fa-gratipay:before { content: "\f184"; } .fa-apple:before { content: "\f179"; } .fa-hive:before { content: "\e07f"; } .fa-gitkraken:before { content: "\f3a6"; } .fa-keybase:before { content: "\f4f5"; } .fa-apple-pay:before { content: "\f415"; } .fa-padlet:before { content: "\e4a0"; } .fa-amazon-pay:before { content: "\f42c"; } .fa-square-github:before { content: "\f092"; } .fa-github-square:before { content: "\f092"; } .fa-stumbleupon:before { content: "\f1a4"; } .fa-fedex:before { content: "\f797"; } .fa-phoenix-framework:before { content: "\f3dc"; } .fa-shopify:before { content: "\e057"; } .fa-neos:before { content: "\f612"; } .fa-square-threads:before { content: "\e619"; } .fa-hackerrank:before { content: "\f5f7"; } .fa-researchgate:before { content: "\f4f8"; } .fa-swift:before { content: "\f8e1"; } .fa-angular:before { content: "\f420"; } .fa-speakap:before { content: "\f3f3"; } .fa-angrycreative:before { content: "\f36e"; } .fa-y-combinator:before { content: "\f23b"; } .fa-empire:before { content: "\f1d1"; } .fa-envira:before { content: "\f299"; } .fa-google-scholar:before { content: "\e63b"; } .fa-square-gitlab:before { content: "\e5ae"; } .fa-gitlab-square:before { content: "\e5ae"; } .fa-studiovinari:before { content: "\f3f8"; } .fa-pied-piper:before { content: "\f2ae"; } .fa-wordpress:before { content: "\f19a"; } .fa-product-hunt:before { content: "\f288"; } .fa-firefox:before { content: "\f269"; } .fa-linode:before { content: "\f2b8"; } .fa-goodreads:before { content: "\f3a8"; } .fa-square-odnoklassniki:before { content: "\f264"; } .fa-odnoklassniki-square:before { content: "\f264"; } .fa-jsfiddle:before { content: "\f1cc"; } .fa-sith:before { content: "\f512"; } .fa-themeisle:before { content: "\f2b2"; } .fa-page4:before { content: "\f3d7"; } .fa-hashnode:before { content: "\e499"; } .fa-react:before { content: "\f41b"; } .fa-cc-paypal:before { content: "\f1f4"; } .fa-squarespace:before { content: "\f5be"; } .fa-cc-stripe:before { content: "\f1f5"; } .fa-creative-commons-share:before { content: "\f4f2"; } .fa-bitcoin:before { content: "\f379"; } .fa-keycdn:before { content: "\f3ba"; } .fa-opera:before { content: "\f26a"; } .fa-itch-io:before { content: "\f83a"; } .fa-umbraco:before { content: "\f8e8"; } .fa-galactic-senate:before { content: "\f50d"; } .fa-ubuntu:before { content: "\f7df"; } .fa-draft2digital:before { content: "\f396"; } .fa-stripe:before { content: "\f429"; } .fa-houzz:before { content: "\f27c"; } .fa-gg:before { content: "\f260"; } .fa-dhl:before { content: "\f790"; } .fa-square-pinterest:before { content: "\f0d3"; } .fa-pinterest-square:before { content: "\f0d3"; } .fa-xing:before { content: "\f168"; } .fa-blackberry:before { content: "\f37b"; } .fa-creative-commons-pd:before { content: "\f4ec"; } .fa-playstation:before { content: "\f3df"; } .fa-quinscape:before { content: "\f459"; } .fa-less:before { content: "\f41d"; } .fa-blogger-b:before { content: "\f37d"; } .fa-opencart:before { content: "\f23d"; } .fa-vine:before { content: "\f1ca"; } .fa-signal-messenger:before { content: "\e663"; } .fa-paypal:before { content: "\f1ed"; } .fa-gitlab:before { content: "\f296"; } .fa-typo3:before { content: "\f42b"; } .fa-reddit-alien:before { content: "\f281"; } .fa-yahoo:before { content: "\f19e"; } .fa-dailymotion:before { content: "\e052"; } .fa-affiliatetheme:before { content: "\f36b"; } .fa-pied-piper-pp:before { content: "\f1a7"; } .fa-bootstrap:before { content: "\f836"; } .fa-odnoklassniki:before { content: "\f263"; } .fa-nfc-symbol:before { content: "\e531"; } .fa-mintbit:before { content: "\e62f"; } .fa-ethereum:before { content: "\f42e"; } .fa-speaker-deck:before { content: "\f83c"; } .fa-creative-commons-nc-eu:before { content: "\f4e9"; } .fa-patreon:before { content: "\f3d9"; } .fa-avianex:before { content: "\f374"; } .fa-ello:before { content: "\f5f1"; } .fa-gofore:before { content: "\f3a7"; } .fa-bimobject:before { content: "\f378"; } .fa-brave-reverse:before { content: "\e63d"; } .fa-facebook-f:before { content: "\f39e"; } .fa-square-google-plus:before { content: "\f0d4"; } .fa-google-plus-square:before { content: "\f0d4"; } .fa-web-awesome:before { content: "\e682"; } .fa-mandalorian:before { content: "\f50f"; } .fa-first-order-alt:before { content: "\f50a"; } .fa-osi:before { content: "\f41a"; } .fa-google-wallet:before { content: "\f1ee"; } .fa-d-and-d-beyond:before { content: "\f6ca"; } .fa-periscope:before { content: "\f3da"; } .fa-fulcrum:before { content: "\f50b"; } .fa-cloudscale:before { content: "\f383"; } .fa-forumbee:before { content: "\f211"; } .fa-mizuni:before { content: "\f3cc"; } .fa-schlix:before { content: "\f3ea"; } .fa-square-xing:before { content: "\f169"; } .fa-xing-square:before { content: "\f169"; } .fa-bandcamp:before { content: "\f2d5"; } .fa-wpforms:before { content: "\f298"; } .fa-cloudversify:before { content: "\f385"; } .fa-usps:before { content: "\f7e1"; } .fa-megaport:before { content: "\f5a3"; } .fa-magento:before { content: "\f3c4"; } .fa-spotify:before { content: "\f1bc"; } .fa-optin-monster:before { content: "\f23c"; } .fa-fly:before { content: "\f417"; } .fa-aviato:before { content: "\f421"; } .fa-itunes:before { content: "\f3b4"; } .fa-cuttlefish:before { content: "\f38c"; } .fa-blogger:before { content: "\f37c"; } .fa-flickr:before { content: "\f16e"; } .fa-viber:before { content: "\f409"; } .fa-soundcloud:before { content: "\f1be"; } .fa-digg:before { content: "\f1a6"; } .fa-tencent-weibo:before { content: "\f1d5"; } .fa-letterboxd:before { content: "\e62d"; } .fa-symfony:before { content: "\f83d"; } .fa-maxcdn:before { content: "\f136"; } .fa-etsy:before { content: "\f2d7"; } .fa-facebook-messenger:before { content: "\f39f"; } .fa-audible:before { content: "\f373"; } .fa-think-peaks:before { content: "\f731"; } .fa-bilibili:before { content: "\e3d9"; } .fa-erlang:before { content: "\f39d"; } .fa-x-twitter:before { content: "\e61b"; } .fa-cotton-bureau:before { content: "\f89e"; } .fa-dashcube:before { content: "\f210"; } .fa-42-group:before { content: "\e080"; } .fa-innosoft:before { content: "\e080"; } .fa-stack-exchange:before { content: "\f18d"; } .fa-elementor:before { content: "\f430"; } .fa-square-pied-piper:before { content: "\e01e"; } .fa-pied-piper-square:before { content: "\e01e"; } .fa-creative-commons-nd:before { content: "\f4eb"; } .fa-palfed:before { content: "\f3d8"; } .fa-superpowers:before { content: "\f2dd"; } .fa-resolving:before { content: "\f3e7"; } .fa-xbox:before { content: "\f412"; } .fa-square-web-awesome-stroke:before { content: "\e684"; } .fa-searchengin:before { content: "\f3eb"; } .fa-tiktok:before { content: "\e07b"; } .fa-square-facebook:before { content: "\f082"; } .fa-facebook-square:before { content: "\f082"; } .fa-renren:before { content: "\f18b"; } .fa-linux:before { content: "\f17c"; } .fa-glide:before { content: "\f2a5"; } .fa-linkedin:before { content: "\f08c"; } .fa-hubspot:before { content: "\f3b2"; } .fa-deploydog:before { content: "\f38e"; } .fa-twitch:before { content: "\f1e8"; } .fa-ravelry:before { content: "\f2d9"; } .fa-mixer:before { content: "\e056"; } .fa-square-lastfm:before { content: "\f203"; } .fa-lastfm-square:before { content: "\f203"; } .fa-vimeo:before { content: "\f40a"; } .fa-mendeley:before { content: "\f7b3"; } .fa-uniregistry:before { content: "\f404"; } .fa-figma:before { content: "\f799"; } .fa-creative-commons-remix:before { content: "\f4ee"; } .fa-cc-amazon-pay:before { content: "\f42d"; } .fa-dropbox:before { content: "\f16b"; } .fa-instagram:before { content: "\f16d"; } .fa-cmplid:before { content: "\e360"; } .fa-upwork:before { content: "\e641"; } .fa-facebook:before { content: "\f09a"; } .fa-gripfire:before { content: "\f3ac"; } .fa-jedi-order:before { content: "\f50e"; } .fa-uikit:before { content: "\f403"; } .fa-fort-awesome-alt:before { content: "\f3a3"; } .fa-phabricator:before { content: "\f3db"; } .fa-ussunnah:before { content: "\f407"; } .fa-earlybirds:before { content: "\f39a"; } .fa-trade-federation:before { content: "\f513"; } .fa-autoprefixer:before { content: "\f41c"; } .fa-whatsapp:before { content: "\f232"; } .fa-square-upwork:before { content: "\e67c"; } .fa-slideshare:before { content: "\f1e7"; } .fa-google-play:before { content: "\f3ab"; } .fa-viadeo:before { content: "\f2a9"; } .fa-line:before { content: "\f3c0"; } .fa-google-drive:before { content: "\f3aa"; } .fa-servicestack:before { content: "\f3ec"; } .fa-simplybuilt:before { content: "\f215"; } .fa-bitbucket:before { content: "\f171"; } .fa-imdb:before { content: "\f2d8"; } .fa-deezer:before { content: "\e077"; } .fa-raspberry-pi:before { content: "\f7bb"; } .fa-jira:before { content: "\f7b1"; } .fa-docker:before { content: "\f395"; } .fa-screenpal:before { content: "\e570"; } .fa-bluetooth:before { content: "\f293"; } .fa-gitter:before { content: "\f426"; } .fa-d-and-d:before { content: "\f38d"; } .fa-microblog:before { content: "\e01a"; } .fa-cc-diners-club:before { content: "\f24c"; } .fa-gg-circle:before { content: "\f261"; } .fa-pied-piper-hat:before { content: "\f4e5"; } .fa-kickstarter-k:before { content: "\f3bc"; } .fa-yandex:before { content: "\f413"; } .fa-readme:before { content: "\f4d5"; } .fa-html5:before { content: "\f13b"; } .fa-sellsy:before { content: "\f213"; } .fa-square-web-awesome:before { content: "\e683"; } .fa-sass:before { content: "\f41e"; } .fa-wirsindhandwerk:before { content: "\e2d0"; } .fa-wsh:before { content: "\e2d0"; } .fa-buromobelexperte:before { content: "\f37f"; } .fa-salesforce:before { content: "\f83b"; } .fa-octopus-deploy:before { content: "\e082"; } .fa-medapps:before { content: "\f3c6"; } .fa-ns8:before { content: "\f3d5"; } .fa-pinterest-p:before { content: "\f231"; } .fa-apper:before { content: "\f371"; } .fa-fort-awesome:before { content: "\f286"; } .fa-waze:before { content: "\f83f"; } .fa-bluesky:before { content: "\e671"; } .fa-cc-jcb:before { content: "\f24b"; } .fa-snapchat:before { content: "\f2ab"; } .fa-snapchat-ghost:before { content: "\f2ab"; } .fa-fantasy-flight-games:before { content: "\f6dc"; } .fa-rust:before { content: "\e07a"; } .fa-wix:before { content: "\f5cf"; } .fa-square-behance:before { content: "\f1b5"; } .fa-behance-square:before { content: "\f1b5"; } .fa-supple:before { content: "\f3f9"; } .fa-webflow:before { content: "\e65c"; } .fa-rebel:before { content: "\f1d0"; } .fa-css3:before { content: "\f13c"; } .fa-staylinked:before { content: "\f3f5"; } .fa-kaggle:before { content: "\f5fa"; } .fa-space-awesome:before { content: "\e5ac"; } .fa-deviantart:before { content: "\f1bd"; } .fa-cpanel:before { content: "\f388"; } .fa-goodreads-g:before { content: "\f3a9"; } .fa-square-git:before { content: "\f1d2"; } .fa-git-square:before { content: "\f1d2"; } .fa-square-tumblr:before { content: "\f174"; } .fa-tumblr-square:before { content: "\f174"; } .fa-trello:before { content: "\f181"; } .fa-creative-commons-nc-jp:before { content: "\f4ea"; } .fa-get-pocket:before { content: "\f265"; } .fa-perbyte:before { content: "\e083"; } .fa-grunt:before { content: "\f3ad"; } .fa-weebly:before { content: "\f5cc"; } .fa-connectdevelop:before { content: "\f20e"; } .fa-leanpub:before { content: "\f212"; } .fa-black-tie:before { content: "\f27e"; } .fa-themeco:before { content: "\f5c6"; } .fa-python:before { content: "\f3e2"; } .fa-android:before { content: "\f17b"; } .fa-bots:before { content: "\e340"; } .fa-free-code-camp:before { content: "\f2c5"; } .fa-hornbill:before { content: "\f592"; } .fa-js:before { content: "\f3b8"; } .fa-ideal:before { content: "\e013"; } .fa-git:before { content: "\f1d3"; } .fa-dev:before { content: "\f6cc"; } .fa-sketch:before { content: "\f7c6"; } .fa-yandex-international:before { content: "\f414"; } .fa-cc-amex:before { content: "\f1f3"; } .fa-uber:before { content: "\f402"; } .fa-github:before { content: "\f09b"; } .fa-php:before { content: "\f457"; } .fa-alipay:before { content: "\f642"; } .fa-youtube:before { content: "\f167"; } .fa-skyatlas:before { content: "\f216"; } .fa-firefox-browser:before { content: "\e007"; } .fa-replyd:before { content: "\f3e6"; } .fa-suse:before { content: "\f7d6"; } .fa-jenkins:before { content: "\f3b6"; } .fa-twitter:before { content: "\f099"; } .fa-rockrms:before { content: "\f3e9"; } .fa-pinterest:before { content: "\f0d2"; } .fa-buffer:before { content: "\f837"; } .fa-npm:before { content: "\f3d4"; } .fa-yammer:before { content: "\f840"; } .fa-btc:before { content: "\f15a"; } .fa-dribbble:before { content: "\f17d"; } .fa-stumbleupon-circle:before { content: "\f1a3"; } .fa-internet-explorer:before { content: "\f26b"; } .fa-stubber:before { content: "\e5c7"; } .fa-telegram:before { content: "\f2c6"; } .fa-telegram-plane:before { content: "\f2c6"; } .fa-old-republic:before { content: "\f510"; } .fa-odysee:before { content: "\e5c6"; } .fa-square-whatsapp:before { content: "\f40c"; } .fa-whatsapp-square:before { content: "\f40c"; } .fa-node-js:before { content: "\f3d3"; } .fa-edge-legacy:before { content: "\e078"; } .fa-slack:before { content: "\f198"; } .fa-slack-hash:before { content: "\f198"; } .fa-medrt:before { content: "\f3c8"; } .fa-usb:before { content: "\f287"; } .fa-tumblr:before { content: "\f173"; } .fa-vaadin:before { content: "\f408"; } .fa-quora:before { content: "\f2c4"; } .fa-square-x-twitter:before { content: "\e61a"; } .fa-reacteurope:before { content: "\f75d"; } .fa-medium:before { content: "\f23a"; } .fa-medium-m:before { content: "\f23a"; } .fa-amilia:before { content: "\f36d"; } .fa-mixcloud:before { content: "\f289"; } .fa-flipboard:before { content: "\f44d"; } .fa-viacoin:before { content: "\f237"; } .fa-critical-role:before { content: "\f6c9"; } .fa-sitrox:before { content: "\e44a"; } .fa-discourse:before { content: "\f393"; } .fa-joomla:before { content: "\f1aa"; } .fa-mastodon:before { content: "\f4f6"; } .fa-airbnb:before { content: "\f834"; } .fa-wolf-pack-battalion:before { content: "\f514"; } .fa-buy-n-large:before { content: "\f8a6"; } .fa-gulp:before { content: "\f3ae"; } .fa-creative-commons-sampling-plus:before { content: "\f4f1"; } .fa-strava:before { content: "\f428"; } .fa-ember:before { content: "\f423"; } .fa-canadian-maple-leaf:before { content: "\f785"; } .fa-teamspeak:before { content: "\f4f9"; } .fa-pushed:before { content: "\f3e1"; } .fa-wordpress-simple:before { content: "\f411"; } .fa-nutritionix:before { content: "\f3d6"; } .fa-wodu:before { content: "\e088"; } .fa-google-pay:before { content: "\e079"; } .fa-intercom:before { content: "\f7af"; } .fa-zhihu:before { content: "\f63f"; } .fa-korvue:before { content: "\f42f"; } .fa-pix:before { content: "\e43a"; } .fa-steam-symbol:before { content: "\f3f6"; } ================================================ FILE: styles/dark-theme.css ================================================ :root { --background-color: #303030; --background-color-rgb: 48, 48, 48; --box-background-color: #222222; --box-background-color-rgb: 34, 34, 34; --box-border-color: #333; --box-border-color-rgb: 51, 51, 51; --header-background-color: #222222; --header-background-color-rgb: 34, 34, 34; --text-color: #E0E0E0; --text-color-rgb: 224, 224, 224; --input-border-color: #666; --input-border-color-rgb: 102, 102, 102; --input-background-color: #555; --input-background-color-rgb: 85, 85, 85; --input-disabled-background-color: #999999; --input-disabled-background-color-rgb: 153, 153, 153; --input-disabled-border-color: #666666; --input-disabled-border-color-rgb: 102, 102, 102; --box-shadow: 0 2px 5px rgba(120, 120, 120, 0.1); --negative-box-shadow: 0 -2px 5px rgba(120, 120, 120, 0.1); } body { background-color: var(--background-color); color: var(--text-color); } body>header { background-color: var(--header-background-color); } svg .text-color { fill: var(--text-color); } .split-header>h2 .header-subtitle { color: var(--text-color); } .dropbtn { color: #E0E0E0; } .dropdown-content a { color: #E0E0E0; } .dropdown-content a:hover { background-color: #333333; } .filtermenu-content .filter-item:hover, .filtermenu-content .filter-title:hover { background-color: #333333; } .subscription-form h3, .subscription-modal h3 { color: #FFF; } .subscription.inactive { background-color: #222; color: rgba(200, 200, 200, 0.6); box-shadow: 0 2px 5px rgba(50, 50, 50, 0.1); } .subscription-main .actions { color: #E0E0E0 } .subscription-main .actions>li { border-bottom: 1px solid #555; border-color: #666; } .subscription-main .actions>li:hover { background-color: #333; } .subscription-main .actions>li:last-of-type { border: none; } .subscription-container { background-color: #222; } .close-form { color: #EEE; } input[type="text"]::placeholder, input[type="email"]::placeholder, input[type="password"]::placeholder, input[type="date"]::placeholder, input[type="number"]::placeholder, textarea::placeholder, select::placeholder { color: #BBB; } button.secondary-button, button.button.secondary-button, input[type="button"].secondary-button { background-color: #222; } button.button.secondary-button:hover, button.secondary-button:hover, input[type="button"].secondary-button:hover { background-color: #111; } input[type="color"] { background-color: #F2F2F2; } .avatar-select .avatar-list .remove-avatar { background-color: #222; } .avatar-select .avatar-list>img, .avatar-select .avatar-list .avatar-container>img { border: 1px solid #999; } .avatar-select .avatar-list>img:hover, .avatar-select .avatar-list .avatar-container>img:hover { border: 1px solid #EEE; } .avatar-select .avatar-list .remove-avatar:hover { background-color: #666; } .account-notification-section-header:hover { background-color: #444; } .toast { box-shadow: 0 6px 20px -5px rgba(255, 255, 255, 0.1); } .toast .close-error { color: #EEE; } .toast-content .message .text.text-1 { color: #BBB; } .toast-content .message .text { color: #999; } .logo-preview:after { color: var(--main-color); } .sort-options>ul>li { border-bottom: 1px solid #555; color: #DDD; } .sort-options>ul>li:hover { background-color: #444; } .payment-name { color: #FFF; } .payments-list .payments-payment .delete-payment-method { color: #FFF; } .calendar .calendar-body .calendar-cell .calendar-cell-header { background-color: #111; } .calendar .calendar-body .calendar-cell { border-right: 1px solid #111; } button.dark-theme-button { color: #E0E0E0; } button.dark-theme-button:hover { background-color: #111; } .account-notifications-section { border: 1px solid #666; } input[type="checkbox"]+label::before, input[type="radio"]+label::before { background: #555; border: 1px solid #666; } input[type="checkbox"]:disabled+label::before, input[type="radio"]:disabled+label::before { background-color: #333; border-color: #222; cursor: not-allowed; } input { color-scheme: dark; } .update-banner { color: #FFF; } .update-banner>span>a { color: #FFF; } .totp-qrcode-container { padding: 14px; border: 1px solid #DDD; border-radius: 8px; } .totp-backup-codes { background-color: #111; border: 2px dashed #444; } .mobile-nav-image { background-image: url("../images/siteimages/mobilenavdark.png"); } @media (max-width: 768px) { .mobile-nav>a { color: #909090; } .mobile-nav>a.active { color: #f0f0F0; } } ================================================ FILE: styles/login-dark-theme.css ================================================ body { background-color: #303030; color: #E0E0E0; } svg .text-color { fill: #E0E0E0; } .container { background-color: #222; border: 1px solid #333; box-shadow: 0 2px 5px rgba(255, 255, 255, 0.1); } @media (max-width: 768px) { .container { background-color: transparent; border: none; box-shadow: none; } } input[type="text"], input[type="email"], input[type="password"], input[type="checkbox"], select { background-color: #555; border: 1px solid #666; color: #E0E0E0; } input[type="text"]::placeholder, input[type="email"]::placeholder, input[type="password"]::placeholder { color: #BBB; } ================================================ FILE: styles/login.css ================================================ body, html { font-family: Barlow, 'Helvetica Neue', Helvetica, sans-serif; margin: 0; padding: 0; background-color: #f5f5f5; font-size: 14px; height: 100%; } .content { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; } .container { width: 400px; margin: auto; max-width: 100%; padding: 20px; background-color: #fff; border-radius: 8px; border: 1px solid #eee; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); box-sizing: border-box; } @media (max-width: 768px) { .container { background-color: transparent; border: none; box-shadow: none; } } @media (max-height: 768px) { .content { height: auto; } } .container > header { text-align: center; } header .logo-image { height: 80px; width: 215px; margin: 0px auto; } header .logo-image svg { height: 80px; width: 215px; } .container > .message { text-align: center; font-size: 18px; } .container > .message a { color: var(--main-color); text-decoration: none; } h2 { text-align: center; margin-bottom: 20px; } .form-group { margin-bottom: 20px; } .form-group-inline { display: flex; flex-direction: row; align-items: center; margin-bottom: 20px; } label { display: block; margin-bottom: 5px; } .form-group-inline label { font-weight: 300; font-size: 13px; margin-bottom: 0px; margin-left: 8px; cursor: pointer; } .rtl .form-group-inline label { margin-right: 8px; margin-left: 0px; } input { box-sizing: border-box; } input[type="text"], input[type="email"], input[type="password"], select { width: 100%; padding: 15px; font-size: 16px; border: 1px solid #ccc; border-radius: 8px; outline: none; } input[type="submit"], input[type="button"], a.button { width: 100%; padding: 15px; font-size: 16px; background-color: var(--main-color); color: #fff; border: none; border-radius: 8px; cursor: pointer; } a.button { text-decoration: none; display: inline-block; text-align: center; box-sizing: border-box; } input[type="submit"]:hover, a.button:hover { background-color: var(--hover-color); } input[type="button"].secondary-button, button.button.secondary-button, a.button.secondary-button { background-color: #FFFFFF; color: var(--main-color); border: 2px solid var(--main-color); } input[type="button"].secondary-button:hover, button.button.secondary-button:hover, a.button.secondary-button:hover { background-color: #EEEEEE; color: var(--hover-color); border-color: var(--hover-color); } input[type="checkbox"] { cursor: pointer; width: 25px; height: 25px; padding: 0px; margin: 0px; background-color: #fff; border: 1px solid #ccc; border-radius: 8px; display: grid; place-content: center; } .or-separator { text-align: center; display: block; margin: 3px 0px 16px; font-size: 16px; } .error { display: block; color: var(--error-color); margin-bottom: 20px; } .error-box, .success-box { display: block; color: #FFFFFF; margin-bottom: 20px; padding: 14px 14px 16px 14px; border: 1px solid var(--error-color); background-color: rgba(var(--error-color-rgb), 0.8); border-radius: 8px; } .success-box { border: 1px solid var(--success-color); background-color: rgba(var(--success-color-rgb), 0.5); } .error-box li, .success-box li { list-style: none; font-size: 15px; display: flex; gap: 8px; align-items: baseline; margin-bottom: 5px; } .error-box li:last-of-type, .success-box li:last-of-type { margin-bottom: 0px; } .separator { border-top: 1px solid #ccc; padding-top: 20px; } .login-form-link { text-align: center; margin: 20px 0px; } .login-form-link a { color: var(--main-color); text-decoration: none; font-size: 16px; } /* TOAST MESSAGE */ .toast { position: fixed; bottom: 25px; right: 30px; border-radius: 12px; border: 1px solid #eeeeee; background: #fff; padding: 20px 35px 20px 25px; box-shadow: 0 6px 20px -5px rgba(0, 0, 0, 0.1); overflow: hidden; transform: translateX(calc(100% + 30px)); transition: all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.35); box-sizing: border-box; } @media (max-width: 768px) { .toast { bottom: 0px; right: 0px; left: 0px; width: 100%; } } .toast.active { transform: translateX(0%); } .toast .toast-content { display: flex; align-items: center; } .toast-content .toast-icon { display: flex; align-items: center; justify-content: center; height: 35px; min-width: 35px; color: #fff; font-size: 20px; border-radius: 50%; } .toast-content .toast-icon.error { background-color: var(--error-color); } .toast-content .toast-icon.success { background-color: var(--success-color); } .toast-content .message { display: flex; flex-direction: column; margin: 0 20px; } .toast-content .message .text { font-size: 16px; font-weight: 400; color: #666666; } .toast-content .message .text.text-1 { font-weight: 600; color: #333; } .toast .close { position: absolute; top: 10px; right: 15px; padding: 5px; cursor: pointer; opacity: 0.7; } .toast .close:hover { opacity: 1; } .toast .progress { position: absolute; bottom: 0; left: 0; height: 3px; width: 100%; } .toast .progress:before { content: ""; position: absolute; bottom: 0; right: 0; height: 100%; width: 100%; } .toast .progress.error:before { background-color: var(--error-color); } .toast .progress.success:before { background-color: var(--success-color); } .progress.active:before { animation: progress 5s linear forwards; } @keyframes progress { 100% { right: 100%; } } /* TOAST END */ ================================================ FILE: styles/styles.css ================================================ :root { --logo-flex-basis: 17%; } body { font-family: Barlow, 'Helvetica Neue', Helvetica, sans-serif; margin: 0; padding: 0; background-color: var(--background-color); color: var(--text-color); } body.no-scroll { overflow-y: hidden; } @media (max-width: 768px) { .mobile-navigation main { margin-bottom: 70px; } } input, button, select, textarea { font-family: Barlow, 'Helvetica Neue', Helvetica, sans-serif; font-weight: 400; } button.hidden, input.hidden { display: none; } @media (max-width: 768px) { body.no-scroll section.contain { display: none; } } a:hover>i { color: var(--hover-color); } h2, h3 { font-weight: 500; } .contain { width: 100%; max-width: 970px; margin: 0px auto; box-sizing: border-box; } .error-box { padding: 20px 16px; background-color: rgba(var(--error-color-rgb), 0.3); border: 1px solid var(--error-color); border-radius: 8px; margin-bottom: 20px; font-size: 16px; } .error-box .error-message i { color: var(--error-color); margin-right: 10px; } .split-header { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 10px; } .split-header h2 { margin-right: 20px; display: flex; } .split-header>h2 .header-subtitle { font-size: 22px; font-weight: 400; color: #666666; margin-left: 10px; } @media (max-width: 768px) { .contain.settings { padding: 20px 0px; } .split-header>h2 .header-subtitle { margin-left: 0px; font-size: 18px; } } body>header { border-bottom: 7px solid var(--main-color); background-color: var(--header-background-color); } body>header>.contain { display: flex; justify-content: space-between; align-items: center; padding: 10px 20px; } header .logo .logo-image { height: 50px; width: 134px; margin-right: 10px; } header .logo .logo-image svg { height: 50px; width: 134px; } .button-icon { width: 16px; height: 16px; } .dropdown { position: relative; display: inline-block; } .dropbtn:after { content: " \25BC"; } @media (max-width: 768px) { .mobile-navigation .dropbtn:after { content: ""; display: none; } } .dropbtn { display: flex; flex-direction: row; align-items: center; gap: 8px; background-color: transparent; color: var(--text-color); padding: 7px 12px; font-size: 16px; border: none; cursor: pointer; -webkit-tap-highlight-color: transparent; -moz-tap-highlight-color: transparent; -ms-tap-highlight-color: transparent; } .dropbtn>img { width: 35px; height: 35px; object-fit: cover; } .dropdown-content { display: none; position: absolute; right: 0px; background-color: var(--header-background-color); border: 1px solid var(--box-border-color); min-width: 130px; box-shadow: var(--box-shadow); z-index: 5; width: max-content; border-top: none; border-radius: 8px; } .dropdown-content a { color: var(--text-color); padding: 14px 18px; text-decoration: none; display: flex; flex-direction: row; gap: 12px; align-items: center; } .dropdown-content a:hover { background-color: #f9f9f9; } .dropdown:hover .dropdown-content { display: block; } .dropdown-content a>svg { width: 20px; height: 20px; } @media (max-width: 768px) { .dropdown:hover .dropdown-content { display: none; } .dropdown.is-open .dropdown-content { display: block !important; } } main>.contain { display: flex; flex-direction: column; padding: 20px; } @media (max-width: 768px) { .main>.contain { padding: 0px 10px; } } .main-actions { margin: 0px 0px 20px 0px; display: flex; flex-direction: row; justify-content: space-between; gap: 16px; flex-wrap: wrap; position: relative; } .main-actions.hidden { display: none; } @media (max-width: 768px) { .main-actions { justify-content: space-between; flex-direction: column-reverse; } } .button { display: flex; flex-direction: row; gap: 8px; align-items: center; font-weight: 500; text-align: center; vertical-align: middle; justify-content: center; cursor: pointer; user-select: none; color: #fff; border: 1px solid var(--main-color); background-color: var(--main-color); padding: 15px 30px; font-size: 1rem; border-radius: 8px; transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out; text-decoration: none; } .button:hover { background-color: var(--hover-color); border-color: var(--hover-color); } .button.thin { padding: 14px 20px; } .button.tiny, .button-secondary.tiny { padding: 7px 14px; font-size: 12px; } .button.icon-button, .button-secondary.icon-button { padding: 15px; } button:hover svg .main-color { fill: var(--hover-color); } .actions li:hover svg .main-color { fill: var(--hover-color); } .image-button:hover svg .main-color { fill: var(--hover-color); } .top-actions { display: flex; flex-direction: row; gap: 16px; align-items: center; width: auto; } .top-actions .search { flex-grow: 1; } .top-actions .search>input[type="text"] { padding-right: 40px; } .top-actions>.search>.search-icon, .top-actions>.search>.clear-search { float: right; right: 15px; margin-top: -35px; position: relative; z-index: 2; color: var(--main-color); font-size: 20px; } .top-actions>.search>.clear-search { display: none; cursor: pointer; } .top-actions>.search.has-text>.search-icon { display: none; } .top-actions>.search.has-text>.clear-search { display: block; } .rtl .top-actions>.search>.search-icon, .rtl .top-actions>.search>.clear-search { float: left; right: -15px; } .subscriptions { display: flex; flex-direction: column; gap: 15px; justify-content: center; font-size: 17px; } .subscription-container { position: relative; background-color: var(--box-background-color); box-shadow: var(--box-shadow); border-radius: 16px; } .subscription-container>.mobile-actions { display: flex; flex-direction: row; position: absolute; right: 0px; top: 0px; height: 100%; overflow: hidden; border-top-right-radius: 16px; border-bottom-right-radius: 16px; } .subscription-container>.mobile-actions>button { display: flex; flex-direction: column; align-items: center; padding: 10px; border: none; cursor: pointer; height: 100%; width: 60px; justify-content: center; color: #f1f1f1; } button.mobile-action-edit { background-color: #ffbf15; /* #f3e22d; */ } button.mobile-action-delete { background-color: #f45a40; } button.mobile-action-clone { background-color: #2da7f3 } button.mobile-action-renew { background-color: #188823; } .subscription-container>.mobile-actions>button>svg { width: 25px; height: 25px; min-height: 25px; } .subscription { display: flex; flex-direction: column; height: auto; justify-content: flex-start; gap: 12px; background-color: var(--box-background-color); box-shadow: var(--box-shadow); padding: 12px 15px; border-radius: 16px; cursor: pointer; position: relative; transition: transform 0.2s; box-sizing: border-box; justify-content: center; } .subscription-container.hide { display: none; } .subscription-container>.subscription-progress-container { position: absolute; bottom: 0px; left: 8px; right: 8px; height: 3px; z-index: 1; } .subscription-container>.subscription-progress-container>.subscription-progress { height: 3px; background-color: var(--accent-color); display: block; position: absolute; } .subscription.inactive { background-color: var(--box-background-color); color: rgba(100, 100, 100, 0.6); box-shadow: 0 2px 5px rgba(100, 100, 100, 0.1); } .subscription.inactive span.price { text-decoration: line-through; } .subscription.inactive .payment_method img { opacity: 0.4; } .subscription-main { display: flex; flex-direction: row; align-items: center; gap: 12px; position: relative; min-height: 40px; } .subscription-main .actions-expand { font-size: 21px; padding: 8px 16px; color: var(--main-color); background-color: transparent; border: none; cursor: pointer; } .subscription-main .actions-expand:hover { color: var(--hover-color); } .subscription-main .actions { display: none; position: absolute; right: -16px; top: 60px; z-index: 2; flex-direction: column; color: var(--text-color); background-color: var(--box-background-color); border: 1px solid var(--box-border-color); box-shadow: var(--box-shadow); border-radius: 16px; padding: 0px; margin: 0px; } .rtl .subscription-main .actions { left: -16px; right: auto; } .subscription-main .actions.is-open { display: flex; } .subscription-main .actions>li { display: flex; align-items: center; justify-content: flex-start; padding: 14px 35px 14px 18px; gap: 12px; cursor: pointer; border-bottom: 1px solid var(--box-border-color); } .rtl .subscription-main .actions>li { padding: 14px 18px 14px 35px; } .subscription-main .actions>li:hover { background-color: #f9f9f9; } .subscription-main .actions>li>i { color: var(--main-color); } .subscription-main .actions>li:hover>i { color: var(--hover-color); } .subscription-secondary { display: none; flex-direction: row; align-items: center; gap: 12px; padding: 6px 5px; overflow: hidden; } .subscription-notes { display: none; flex-direction: row; padding: 6px 5px; overflow: hidden; } .subscription-main .actions svg { width: 32px; height: 32px; } .subscription-secondary svg, .subscription-notes svg { width: 20px; height: 20px; } .subscription-main>span, .subscription-secondary>span { display: flex; align-items: center; justify-content: center; text-align: center; box-sizing: border-box; margin: 0px; } .subscription .logo { flex-basis: var(--logo-flex-basis); } .subscription .logo img { width: 100%; height: 100%; max-height: 42px; object-fit: contain; min-width: 32px; } .subscription .logo svg { max-width: 100%; height: 42px; } .subscription .name { flex-basis: 25%; font-weight: 600; } .subscription .cycle { flex-basis: 16%; flex-grow: 1; flex-direction: row; align-items: center; } .subscription .cycle>svg { width: 15px; height: 15px; margin-right: 3px; margin-top: 2px; } .subscription .next { flex-basis: 16%; flex-grow: 1; text-transform: capitalize; } .subscription .payment_method { margin-left: 10px; display: flex; justify-content: center; align-items: center; } .subscription .payment_method img { width: 44px; height: 30px; aspect-ratio: 3 / 2; object-fit: contain; } .rtl .subscription .payment_method img { margin-right: 10px; margin-left: 0px; } .subscription .price { flex-basis: 8%; justify-content: center; flex-direction: row; } .subscription .price .original_price { font-size: 14px; color: #888; } .subscription .actions { flex-basis: auto; } .subscription .actions img { width: 25px; height: 25px; cursor: pointer; } .subscription-secondary>.name { display: none; justify-content: flex-start; flex-basis: 33%; } .subscription-secondary>span { justify-content: flex-start; flex-basis: 33%; gap: 10px; } .subscription-secondary>.url { flex-basis: 20px; margin-left: auto; cursor: pointer; } .rtl .subscription-secondary>.url { margin-left: 0px; margin-right: auto; } .subscription-notes>span { display: flex; align-items: center; font-size: 14px; gap: 10px; } @media (max-width: 768px) { .subscription-main>.hideOnMobile { display: none; } .subscription-main>.name { flex-basis: var(--logo-flex-basis); font-size: 14px; font-weight: normal; max-height: 38px; overflow: hidden; align-items: baseline; } .subscription-secondary>.name { display: flex; } .subscription-secondary>span { flex-grow: 1; flex-shrink: 1; font-size: 14px; } } @media (max-width: 375px) { .subscription-main>.cycle { display: none; } } .subscription.is-open .subscription-secondary, .subscription.is-open .subscription-notes { display: flex; } .subscription-secondary img, .subscription-notes img { height: 20px; } .subscription-secondary .url img { margin-right: 0px; ; } .empty-page, .no-matching-subscriptions { display: block; max-width: 90%; margin: auto; text-align: center; font-size: 20px; } .empty-page>img, .no-matching-subscriptions>img { max-width: 100%; } .no-matching-subscriptions>img { margin-top: 30px; } .empty-page>p { margin: 5px 0px 40px 0px; } .no-matching-subscriptions>p { margin: 30px 0px 40px 0px; } .empty-page>button, .no-matching-subscriptions>button { margin: 0px auto; } .account-section { background-color: var(--box-background-color); border: 1px solid var(--box-border-color); padding: 20px; box-shadow: var(--box-shadow); border-radius: 16px; } .account-section header h2 { margin-top: 0px; margin-bottom: 34px; } .account-section header h2.second-header { margin-top: 34px; } .account-section+.account-section { margin-top: 34px; } .account-section .account-settings-list { display: flex; flex-direction: column; gap: 16px; } .account-section .account-settings-list .form-group-inline { margin-bottom: 0px; } .account-section .account-settings-list h3 { margin: 0px; } .account-section .account-settings-theme h3 { margin-bottom: 24px; } .user-form { display: flex; flex-direction: column; } .user-form .fields { display: flex; flex-direction: row; gap: 34px; } @media (max-width: 768px) { .user-form .fields { flex-direction: column; align-items: center; gap: 20px; } .grow { width: 100%; } } header #avatar { border-radius: 50%; } .user-form .user-avatar { position: relative; } .user-form .user-avatar>img { cursor: pointer; width: 80px; height: 80px; object-fit: cover; max-width: 80px; border-radius: 50%; border: 1px solid #ccc; box-sizing: border-box; } .user-form .user-avatar .edit-avatar { display: none; align-items: center; justify-content: center; width: 80px; height: 80px; position: absolute; top: 0px; left: 0px; background-color: rgba(0, 0, 0, 0.6); border-radius: 39px; cursor: pointer; color: #FFFFFF; font-size: 30px; } .user-form .user-avatar:hover>.edit-avatar { display: flex; } @media (max-width: 768px) { .user-form .user-avatar:hover>.edit-avatar { display: none; } } .avatar-select { display: none; background-color: var(--box-background-color); border: 1px solid var(--box-border-color); position: absolute; padding: 20px; box-sizing: border-box; width: 336px; max-width: 100%; box-shadow: var(--box-shadow); z-index: 3; } .avatar-option { border-radius: 50%; } @media (max-width: 768px) { .avatar-select { left: 50%; transform: translateX(-50%); } } .avatar-select.is-open { display: block; } .avatar-select .avatar-list { display: flex; gap: 18px; flex-wrap: wrap; } .avatar-select .avatar-list>img, .avatar-select .avatar-list .avatar-container>img { width: 60px; height: 60px; object-fit: cover; cursor: pointer; border: 1px solid #ccc; box-sizing: border-box } .avatar-select .avatar-list>img:hover, .avatar-select .avatar-list .avatar-container>img:hover { border: 1px solid #222; } .avatar-select .avatar-list .avatar-container { position: relative; height: 60px; } .avatar-select label.add-avatar { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 60px; height: 60px; border: 1px solid var(--main-color); border-radius: 50%; cursor: pointer; margin: 0px; box-sizing: border-box; color: var(--main-color); } .avatar-select label.add-avatar:hover { border-color: var(--accent-color); color: var(--accent-color); } .avatar-select .avatar-list .remove-avatar { position: absolute; top: -4px; right: -11px; background-color: var(--box-background-color); border-radius: 50%; cursor: pointer; display: flex; font-weight: 600; align-items: center; justify-content: center; border: 1px solid #ccc; width: 25px; height: 25px; box-sizing: border-box; font-size: 8px; } .avatar-select .avatar-list .remove-avatar:hover { background-color: #eee; } .user-form .fields .grow { flex: 1; } .user-form .buttons, .account-members .buttons, .account-currencies .buttons, .account-fixer .buttons, .account-ai-settings .buttons, .account-categories .buttons, .account-notifications .buttons, .admin-form .buttons, .account-2fa .buttons { display: flex; justify-content: flex-end; align-items: center; gap: 20px; } .account-2fa .buttons { justify-content: flex-start; } .admin-form hr { margin: 20px 0px; color: var(--main-color); border-color: var(--main-color); background-color: var(--main-color); } .account-notifications-section { border: 1px solid #ccc; border-radius: 8px; margin-bottom: 10px; overflow: hidden; } .account-notification-section-header { padding: 16px; cursor: pointer; } .account-notification-section-header:hover { background-color: #EEE; } .account-notification-section-header h3 { margin: 0px; } .account-notification-section-header h3 i { margin-right: 10px; } .account-notification-section-settings { max-height: 0px; overflow: hidden; /* Hide content that goes beyond the height */ transition: max-height 0.3s ease-in-out; /* Animate max-height changes */ padding: 0px 16px; } .account-notification-section-settings.is-open { max-height: 1500px; /* Set to a value larger than the content's natural height */ } .account-notification-section-settings>div:first-of-type { margin-top: 20px; } .account-notification-section-settings>div:last-of-type { margin-bottom: 20px; } .account-notifications .buttons { gap: 15px; } .image-button { box-sizing: border-box; border: none; background: transparent; cursor: pointer; padding: 0px; } .image-button>i { color: var(--hover-color); font-size: 28px; padding: 2px; } .image-button>svg { width: 32px; height: 32px; } .image-button.disabled>img, .image-button.disabled>svg { -webkit-filter: grayscale(100%); filter: grayscale(100%); } .image-button.success>img { filter: hue-rotate(262deg); } .image-button.error>img { filter: hue-rotate(141deg); } .image-button.small>img { width: 25px; height: 25px; object-fit: contain; } .image-button.medium>img { width: 32px; height: 32px; object-fit: contain; } .payments-list { display: flex; flex-wrap: wrap; gap: 16px; } .payments-list .payments-payment { cursor: pointer; display: flex; flex-direction: row; align-items: center; gap: 8px; background-color: var(--accent-color); padding: 6px 12px; border-radius: 8px; transition: filter 300ms; } .payments-list .payments-payment[data-enabled="0"] { filter: grayscale(100%); } .payments-list .payments-payment[data-in-use="yes"] { cursor: not-allowed; } .payments-list .payments-payment .drag-icon { height: 20px; width: 14px; font-size: 14px; } .payments-list .payments-payment>img { width: 32px; height: 32px; object-fit: contain; } .payments-list .payments-payment>.payment-name { cursor: text; } .payments-list .payments-payment .delete-payment-method { padding: 5px; font-weight: bold; color: var(--text-color); } .credits-list { display: flex; flex-direction: column; gap: 16px; line-break: anywhere; } .credits-list>div { margin: 0px; font-size: 18px; display: flex; flex-direction: column; } .updates-list>div { margin: 0px; } .credits-list>div>h3 { margin: 2px 0px 0px 0px; font-size: 20px; } .credits-list>div>h3>i { color: var(--accent-color); font-size: 18px; } .settings-notes { margin-bottom: 1.5em; } .settings-notes>p { margin-bottom: 0px; } .credits-list>div>span, .updates-list>p>span, .settings-notes>p>span { color: #AAA; font-size: 14px; } .credits-list>div>span, .updates-list>p>span { font-size: 16px; } .credits-list>div>span>a, .updates-list>p>span>a, .settings-notes>p>span>a { margin-left: 5px; font-size: 13px; color: var(--accent-color); } .rtl .credits-list>div>span>a, .rtl .updates-list>p>span>a, .rtl .settings-notes>p>span>a { margin-left: 0px; margin-right: 5px; } .credits-list>div>span>a:visited, .updates-list>p>span>a:visited, .settings-notes>p>span>a:visited { color: var(--accent-color); } .settings-notes>p>i, .account-section .notes>p>i { color: var(--main-color); margin-right: 5px; } .rtl .settings-notes>p>i, .rtl .account-section .notes>p>i { margin-right: 0px; margin-left: 5px; } .form-group { margin-bottom: 20px; } .form-group-inline { display: flex; flex-direction: row; align-items: center; margin-bottom: 20px; gap: 15px; box-sizing: border-box; } .form-group .inline { display: flex; flex-direction: row; align-items: center; gap: 15px; justify-content: space-between; } .form-group .inline .split33 { flex-basis: 33.34%; } .form-group .inline .split66 { flex-basis: 66.66%; } .form-group .inline .split50 { flex-basis: 50%; display: flex; flex-direction: column; } .form-group.hide, .form-group-inline.hide { display: none; } .height50 { height: 50px; } .inline-row { display: flex; flex-direction: row; flex-wrap: wrap; gap: 15px; margin-bottom: 20px; } @media (max-width: 768px) { .form-group .inline .mobile-split-50 { flex-basis: 50%; } select#frequency { width: 100px; padding: 0px 10px; } } label { display: block; margin-bottom: 5px; } .form-group-inline label { font-size: 16px; margin-bottom: 0px; margin-left: 0px; cursor: pointer; } label.split-label { display: flex; flex-direction: row; justify-content: space-between; } input { box-sizing: border-box; } input[type="text"], input[type="email"], input[type="password"], input[type="date"], input[type="number"], select { width: 100%; padding: 0px 15px; height: 50px; font-size: 16px; background-color: var(--input-background-color); border: 1px solid var(--input-border-color); border-radius: 8px; outline: none; color: var(--text-color); box-sizing: border-box; } input[type="color"] { height: 46px; width: 46px; background-color: #222; border: 1px solid var(--hover-color); outline: none; box-sizing: border-box; cursor: pointer; font-size: 16px; position: relative; border-radius: 5px; } .one-third { max-width: 33%; } select { cursor: pointer; height: 50px; } .date-wrapper { display: flex; flex-grow: 0; flex-direction: row; flex-basis: 100%; box-sizing: border-box; } input[type="date"] { display: flex; flex-grow: 1; flex-direction: row; align-items: center; flex-basis: 100%; box-sizing: border-box; } input[type="text"].short { flex-basis: 55px; min-width: 55px; text-align: center; } input[type="submit"], input[type="button"], button.button { padding: 15px 30px; font-size: 16px; background-color: var(--main-color); color: var(--text-color-inverted); border: none; border-radius: 8px; cursor: pointer; box-sizing: border-box; border: 2px solid var(--main-color); } input[type="submit"].thin, input[type="button"].thin, button.button.thin { padding: 13px 30px; } input[type="button"].secondary-button, button.button.secondary-button { background-color: #FFFFFF; color: var(--main-color); } input[type="button"].secondary-button:hover, button.button.secondary-button:hover { background-color: #EEEEEE; color: var(--hover-color); border-color: var(--hover-color); } input[type="button"].warning-button { background-color: #f45a40; border-color: #f45a40; } input[type="button"].warning-button:hover, button.button.warning-button:hover { background-color: #ef8674; border-color: #ef8674; } input[type="submit"]:hover, input[type="button"]:hover, button.button:hover { background-color: var(--hover-color); border-color: var(--hover-color); } input[type="submit"]:disabled, input[type="button"]:disabled, button.button:disabled { background-color: #ccc; border-color: #ccc; } input[type="button"].left button.button.left { margin-right: auto; } input[type="checkbox"] { cursor: pointer; width: 25px; height: 25px; padding: 0px; margin: 0px; background-color: var(--input-background-color); border: 1px solid var(--input-border-color); border-radius: 8px; display: grid; place-content: center; } button.disabled { cursor: not-allowed; } input[type="text"]:disabled, input[type="password"]:disabled, input[type="email"]:disabled { background-color: var(--input-disabled-background-color); border-color: var(--input-disabled-border-color); cursor: not-allowed; } textarea { font-size: 16px; background-color: var(--input-background-color); border: 1px solid var(--input-border-color); border-radius: 8px; padding: 5px 14px; color: var(--text-color); width: 100%; height: 245px; } textarea:focus { outline: none; } textarea.thin { height: 80px; } @media (max-width: 768px) { input[type="checkbox"] { width: 20px; height: 20px; flex-shrink: 0; } } .form-icon-search { position: relative; } .logo-search, .icon-search { position: absolute; width: 165px; height: 298px; top: 130px; right: 32px; overflow-y: auto; overflow-x: hidden; padding: 10px; border: 1px solid var(--box-border-color); border-radius: 8px; box-shadow: var(--box-shadow); background-color: var(--box-background-color); box-sizing: border-box; z-index: 1; display: none; } .icon-search { width: 156px; height: 224px; top: 50px; right: 0px; } .logo-search.is-open, .icon-search.is-open { display: block; } .logo-search>header, .icon-search>header { padding: 0px 5px 5px; border-bottom: 1px solid #CCC; display: flex; flex-direction: row; justify-content: space-between; margin-bottom: 10px; } .icon-search>header>span { margin-left: auto; } .logo-search .close-logo-search, .icon-search .close-icon-search { cursor: pointer; } .logo-search img, .icon-search img { max-width: 100%; cursor: pointer; border-bottom: 1px solid #ccc; padding: 10px 0px; } .icon-search img { padding: 8px 0px; aspect-ratio: 16 / 5; object-fit: contain; } .logo-search img:last-of-type, .icon-search img:last-of-type { border-bottom: none; padding-bottom: 0px; } .capitalize { text-transform: capitalize; } button.dark-theme-button { display: flex; flex-direction: row; align-items: center; justify-content: center; font-size: 18px !important; border: 1px solid #ccc; border-radius: 8px; padding: 16px 20px 14px; background-color: transparent; color: var(--text-color); cursor: pointer; flex-grow: 1; } button.dark-theme-button:hover { background-color: #EEE; } button.dark-theme-button.selected { border: 1px solid var(--main-color); } button.dark-theme-button i { margin-right: 12px; } .error { display: block; color: #f45a40; } .success { display: block; color: #188823; } .user-error, .user-success { display: none; } .user-error.show, .user-success.show { display: block; } .subscription-form, .subscription-modal { background-color: var(--box-background-color); padding: 22px; border: 1px solid var(--box-border-color); border-radius: 16px; box-shadow: var(--box-shadow); box-sizing: border-box; position: fixed; left: 50%; top: 50%; transform: translateX(-50%) translateY(-50%); z-index: 3; max-width: 800px; max-height: calc(100vh - 34px); overflow: auto; overflow-y: auto; width: 90%; display: none; } .subscription-form.is-open, .subscription-modal.is-open { display: block; } .subscription-form h3, .subscription-modal h3 { border-bottom: 1px solid var(--main-color); padding-bottom: 15px; margin-top: 0px; } .subscription-form .buttons { display: flex; flex-direction: row; flex-wrap: wrap; justify-content: flex-end; gap: 16px; } @media (max-width: 768px) { .subscription-form, .subscription-modal { width: 100%; max-height: 100vh; } .subscription-form .buttons input { flex: 1; } } .logo-preview { padding: 2px 0px; height: 49px; box-sizing: border-box; aspect-ratio: 3.55/1; display: block; cursor: pointer; overflow: hidden; } .logo-preview:after { content: "Upload Logo"; display: flex; justify-content: center; align-items: center; height: 100%; color: var(--main-color); font-size: 16px; text-align: center; } .logo-preview:hover::after, .icon-preview:hover::after { color: var(--hover-color); } .logo-preview img { width: 100%; height: 100%; object-fit: contain; display: none; } .icon-preview { padding: 2px 0px; height: 49px; box-sizing: border-box; aspect-ratio: 3/2; display: block; cursor: pointer; overflow: hidden; flex-shrink: 0; } .icon-preview:after { content: "Upload Icon"; display: flex; justify-content: center; align-items: center; height: 100%; color: var(--main-color); font-size: 16px; text-align: center; } .icon-preview img { width: 100%; height: 100%; object-fit: contain; display: none; } .hidden-input { display: none; } .close-form { display: block; position: absolute; top: 15px; right: 15px; padding: 10px; font-size: 20px; cursor: pointer; color: gray; } .rtl .close-form { right: auto; left: 15px; } .sort-container { position: relative; } .sort-options { position: absolute; color: var(--text-color); font-size: 16px; background-color: var(--box-background-color); border: 1px solid var(--box-border-color); border-radius: 8px; box-shadow: 0 2px 5px rgba(163, 100, 100, 0.1); box-sizing: border-box; top: 52px; right: 0px; display: none; width: 144px; width: max-content; z-index: 2; } .rtl .sort-options { left: 0px; right: auto; } @media (max-width: 380px) { .sort-container { flex-grow: 1; max-width: 144px; } } .sort-options.is-open { display: block; } .sort-options>ul { padding: 0px; margin: 0px; } .sort-options>ul>li { position: relative; list-style: none; padding: 14px 35px 14px 18px; border-bottom: 1px solid #DDD; cursor: pointer; } .rtl .sort-options>ul>li { padding: 14px 18px 14px 35px; } .sort-options>ul>li:last-of-type { border-bottom: none; } .sort-options>ul>li:hover { background-color: #EEE; } .sort-options>ul>li.selected::after { content: ""; position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; background-color: var(--main-color); -webkit-mask: url("../images/siteicons/svg/check.php") no-repeat center; mask: url("../images/siteicons/svg/check.php") no-repeat center; background-size: 100% 100%; } .rtl .sort-options>ul>li.selected { background-position: center left 10px; } .subscription-list-title { font-size: 18px; padding: 4px; font-weight: 500; } /* TOAST MESSAGE */ .toast { position: fixed; bottom: 25px; right: 30px; border-radius: 12px; border: 1px solid var(--box-border-color); background-color: var(--box-background-color); padding: 20px 35px 20px 25px; box-shadow: 0 6px 20px -5px rgba(0, 0, 0, 0.1); overflow: hidden; transform: translateX(calc(100% + 30px)); transition: all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.35); box-sizing: border-box; z-index: 5; } @media (max-width: 768px) { .toast { bottom: 0px; right: 0px; left: 0px; width: 100%; } .mobile-navigation .toast { bottom: 70px; } } .toast.active { transform: translateX(0%); } .toast .toast-content { display: flex; align-items: center; } .toast-content .toast-icon { display: flex; align-items: center; justify-content: center; height: 35px; min-width: 35px; color: #fff; font-size: 20px; border-radius: 50%; } .toast-content .toast-icon.error { background-color: var(--error-color); } .toast-content .toast-icon.success { background-color: var(--success-color); } .toast-content .message { display: flex; flex-direction: column; margin: 0 20px; } .toast-content .message .text { font-size: 16px; font-weight: 400; color: #666666; } .toast-content .message .text.text-1 { font-weight: 600; color: #333; } .toast .close { position: absolute; top: 10px; right: 15px; padding: 5px; cursor: pointer; opacity: 0.7; } .toast .close:hover { opacity: 1; } .toast .progress { position: absolute; bottom: 0; left: 0; height: 3px; width: 100%; } .toast .progress:before { content: ""; position: absolute; bottom: 0; right: 0; height: 100%; width: 100%; } .toast .progress.error:before { background-color: var(--error-color); } .toast .progress.success:before { background-color: var(--success-color); } .progress.active:before { animation: progress 5s linear forwards; } @keyframes progress { 100% { right: 100%; } } /* TOAST END */ .statistics { display: flex; flex-direction: row; flex-wrap: wrap; gap: 20px; justify-content: flex-start; } .statistic { background-color: var(--box-background-color); border: 1px solid var(--box-border-color); border-radius: 16px; box-shadow: var(--box-shadow); padding: 20px 24px 30px; display: flex; flex-direction: column; align-items: center; flex-basis: calc(33.333% - (20px * 2 / 3)); flex-shrink: 0; box-sizing: border-box; flex-grow: 0; overflow: hidden; } .statistic.short { padding-bottom: 15px; } .statistic.empty { background-color: transparent; border: none; box-shadow: none; } @media (max-width: 768px) { .statistic { flex-basis: 100%; } .statistic.empty { display: none; } } .statistic>span { font-size: 42px; color: var(--main-color); } .statistic>.title { margin-top: 5px; text-align: center; } .statistic>.subtitle { font-size: 25px; color: var(--accent-color); margin-top: 10px; text-align: center; } .statistic>.subtitle>img { width: 100px; max-height: 40px; object-fit: contain; } .graphs { display: flex; flex-direction: row; flex-wrap: wrap; gap: 20px; justify-content: space-between; } .graph { background-color: var(--box-background-color); border: 1px solid var(--box-border-color); border-radius: 16px; box-shadow: var(--box-shadow); flex-basis: 48%; align-items: center; justify-content: center; display: flex; flex-direction: column; padding: 20px 10px; box-sizing: border-box; } .graph>header { font-size: 18px; font-weight: 500; margin-bottom: 15px; text-align: center; } .graph>header>.sub-header { font-size: 13px; font-weight: normal; } .graph.x2 { flex-basis: 100%; } @media (max-width: 768px) { .graph { flex-basis: 100%; max-width: 100%; } } /* Settings sort category */ .sortable-list { margin: 0px; padding: 0px; -webkit-touch-callout: none; -webkit-user-select: none; -khtml-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } .drag-icon { width: 28px; height: 50px; cursor: grab; display: flex; align-items: center; justify-content: center; font-size: 20px; } .sortable-list .sortable-ghost { border-radius: 8px; background-color: rgba(var(--accent-color-rgb), 0.6); border: 1px solid var(--accent-color); padding: 5px; } /* Fitler dropdown */ .filtermenu { position: relative; display: inline-block; } .filtermenu-content { display: none; position: absolute; left: auto; right: 0; width: 220px; background-color: var(--box-background-color); border: 1px solid var(--box-border-color); border-radius: 8px; box-shadow: var(--box-shadow); z-index: 3; overflow: hidden; margin-top: 6px; } .rtl .filtermenu-content { left: 0; right: auto; } @media (max-width: 354px) { .on-dashboard .filtermenu-content { right: -94px; } } .filtermenu-content.is-open { display: block; } .filtermenu-content .filter-title { padding: 14px 18px; text-decoration: none; display: block; cursor: pointer; font-weight: 500; border-bottom: 1px solid #DDD; user-select: none; } .filtermenu-content .filtermenu-submenu.hide { display: none; } .filtermenu-content .filtermenu-submenu:last-of-type .filter-title { border-bottom: none; } .filtermenu-content .filtermenu-submenu:last-of-type .filter-item:first-of-type { border-top: 1px solid #DDD; } .filtermenu-content .filtermenu-submenu:last-of-type .filter-item:last-of-type { border-bottom: none; } .filtermenu-content .filter-item { position: relative; padding: 14px 24px; text-decoration: none; display: block; cursor: pointer; border-bottom: 1px solid #DDD; user-select: none; font-size: 16px; } .filtermenu-content .filter-item.selected::after { content: ""; position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 16px; /* Explicitly set the size */ height: 16px; /* Explicitly set the size */ background-color: var(--main-color); /* Set your desired color here */ -webkit-mask: url("../images/siteicons/svg/check.php") no-repeat center; mask: url("../images/siteicons/svg/check.php") no-repeat center; background-size: 100% 100%; /* Ensure the icon scales correctly */ } .rtl .filtermenu-content .filter-item.selected { background-position: center left 10px; } .filtermenu-content .filter-title.filter-clear { color: var(--hover-color); font-weight: normal; border-bottom: none; } .filtermenu-content .filter-title.filter-clear>i { margin-right: 8px; } .rtl .filtermenu-content .filter-title.filter-clear>i { margin-left: 8px; margin-right: 0px; } .filtermenu-content .filter-item:hover, .filtermenu-content .filter-title:hover { background-color: #f1f1f1; } .filtermenu-submenu-content { display: none; } .filtermenu-submenu-content.is-open { display: block; } /* Theme Selector */ .theme { flex-grow: 1; } .theme-preview { cursor: pointer; border: 1px solid #ccc; border-radius: 8px; padding: 20px 15px 20px 10px; display: flex; gap: 15px; flex-direction: row; align-items: center; } .theme-preview.is-selected { border: 1px solid var(--main-color); } .theme-preview:hover { background-color: rgba(var(--accent-color-rgb), 0.6); } .theme-preview .main-color, .theme-preview .accent-color, .theme-preview .hover-color { display: inline-block; width: 24px; height: 24px; border: 1px solid #FFF; box-sizing: border-box; } .theme-preview.blue .main-color { background-color: #007bff; } .theme-preview.blue .accent-color { background-color: #8fbffa; } .theme-preview.blue .hover-color { background-color: #0056b3; } .theme-preview.green .main-color { background-color: #6B8E23; } .theme-preview.green .accent-color { background-color: #9ACD32; } .theme-preview.green .hover-color { background-color: #556B2F; } .theme-preview.red .main-color { background-color: #f45a40; } .theme-preview.red .accent-color { background-color: #f79988; } .theme-preview.red .hover-color { background-color: #c73f29; } .theme-preview.yellow .main-color { background-color: #ffae00; } .theme-preview.yellow .accent-color { background-color: #faea8f; } .theme-preview.yellow .hover-color { background-color: #cd930c; } .theme-preview.purple .main-color { background-color: #6d4aff; } .theme-preview.purple .accent-color { background-color: #b086ff; } .theme-preview.purple .hover-color { background-color: #5e42cd; } .custom-colors { display: flex; flex-direction: row; gap: 12px; } .color-picker { flex-shrink: 0; } .color-picker::before { color: var(--hover-color); position: absolute; top: -5px; right: -5px; border: 1px solid; border-radius: 15px; background-color: var(--box-background-color); padding: 4px; } .color-picker-button:hover label { color: var(--hover-color); } .wrap { flex-wrap: wrap; } .user-list { display: flex; flex-direction: column; flex-wrap: wrap; justify-content: space-between; } .user-list>div { display: flex; flex-direction: row; flex-grow: 1; flex-wrap: wrap; } .user-list .user-list-row { display: flex; flex-direction: row; flex-grow: 1; } @media (max-width: 768px) { .user-list .user-list-row { flex-direction: column; } } .user-list .user-list-row:last-of-type { flex-grow: 0; } .user-list .user-list-row>div { display: flex; flex-basis: 50%; gap: 12px; align-items: baseline; } .user-list a { color: var(--main-color); text-decoration: none; } .user-list a:hover { color: var(--hover-color); } .user-list .user-list-icon { width: 16px; text-align: center; } .calendar-nav { display: flex; flex-direction: row; align-items: center; gap: 16px; font-size: 18px; } .calendar-nav .month { text-align: center; } .calendar { display: flex; flex-direction: column; width: 100%; background-color: var(--box-background-color); border-collapse: collapse; border-radius: 16px; box-shadow: var(--box-shadow); box-sizing: border-box; } .calendar .calendar-header, .calendar .calendar-row { display: flex; } .calendar .calendar-header { border-bottom: 6px solid var(--main-color); } .calendar .calendar-row:last-of-type { border-bottom: none; } .calendar .calendar-cell { display: flex; flex: 0 0 14.2857%; flex-direction: column; gap: 5px; overflow: hidden; } .calendar .calendar-header .calendar-cell { padding: 26px 0px; font-size: 16px; font-weight: 500; text-align: center; box-sizing: border-box; min-height: 45px; } @media (max-width: 768px) { .calendar .calendar-header .calendar-cell { font-size: 12px; } } .calendar .calendar-body .calendar-cell { font-size: 13px; text-align: center; box-sizing: border-box; min-height: 92px; border-right: 1px solid var(--box-border-color); } .calendar .calendar-body .calendar-cell:last-of-type { border-right: none; } .calendar .calendar-body .calendar-cell .calendar-cell-header { background-color: #EEE; padding: 5px 0px; } .calendar .calendar-body .calendar-cell .calendar-cell-content { padding: 6px 0px; display: flex; flex-direction: column; gap: 6px; box-sizing: border-box; padding: 0px 6px; } @media (max-width: 768px) { .calendar .calendar-body .calendar-cell .calendar-cell-content { padding: 0px 1px; } } .calendar .calendar-subscription-title { border: 1px solid var(--main-color); border-radius: 8px; padding: 4px 2px; cursor: pointer; box-sizing: border-box; white-space: normal; overflow-wrap: break-word; word-wrap: break-word; user-select: none; } .calendar .day { font-size: 14px; } .calendar .today .day { color: var(--main-color); font-weight: 700; } .over-budget { background-color: rgba(var(--error-color-rgb), 0.2); border: 1px solid var(--error-color); border-radius: 8px; padding: 10px; margin-top: 20px; text-align: center; font-size: 16px; } .over-budget>i { color: var(--error-color); } .subscription-modal { max-width: 400px; } .subscription-modal .modal-header { position: relative; } .subscription-modal .close-modal { position: absolute; top: -5px; right: -5px; padding: 5px; font-size: 20px; cursor: pointer; } .subscription-modal img { max-width: 135px; } .grow { flex-grow: 1; } @media (max-width: 768px) { .mobile-grow { flex-grow: 1; } .mobile-reverse { flex-direction: column-reverse; } .mobile-grow-force { flex-grow: 1; width: 100%; } } .bold { font-weight: 700; } /* Checkbox */ input[type="checkbox"] { opacity: 0; position: absolute; } input[type="checkbox"]+label { position: relative; padding-left: 54px; cursor: pointer; display: inline-flex; align-items: center; } input[type="checkbox"]+label::before { content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 44px; height: 24px; background-color: #e9e9eb; border-radius: 12px; transition: background-color 0.3s ease; border: 1px solid #ccc; } input[type="checkbox"]+label::after { content: ''; position: absolute; left: 3px; top: 50%; transform: translateY(-50%); width: 20px; height: 20px; background-color: #fff; border-radius: 50%; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); transition: transform 0.3s ease; } input[type="checkbox"]:focus+label::before { box-shadow: 0 0 0 2px rgba(var(--main-color-rgb, 100, 149, 237), 0.5); } input[type="checkbox"]:disabled+label::before { background-color: #d3d3d3; } input[type="checkbox"]:disabled+label::after { background-color: #f0f0f0; } input[type="checkbox"]:checked+label::before { background-color: var(--main-color); } input[type="checkbox"]:checked+label::after { transform: translate(20px, -50%); } input[type="checkbox"]:focus+label::after { outline: 2px solid rgba(var(--main-color-rgb, 100, 149, 237), 0.5); } /* Radio */ input[type="radio"] { opacity: 0; position: absolute; } input[type="radio"]+label { position: relative; padding-left: 35px; cursor: pointer; display: flex; line-height: 22px; } input[type="radio"]+label::before { content: ''; position: absolute; left: 0; top: 0; width: 24px; height: 24px; background: #fafafa; border: 1px solid #ccc; border-radius: 50%; box-sizing: border-box; } input[type="radio"]:focus+label::before { border-color: var(--main-color); box-shadow: 0 0 0 2px rgba(var(--main-color-rgb), 0.5); } input[type="radio"]:disabled+label::before { background-color: #F5F5F5; border-color: #F5F5F5; cursor: not-allowed; } input[type="radio"]:checked+label::after { content: ''; position: absolute; left: 5px; top: 5px; width: 14px; height: 14px; background: var(--main-color); border-radius: 50%; box-sizing: border-box; } .theme input[type="radio"]+label { padding-left: 44px; } .theme input[type="radio"]+label::before { left: 11px; top: 20px; } .theme input[type="radio"]:checked+label::after { left: 16px; top: 25px; } .update-banner { padding: 15px 20px; background-color: var(--accent-color); border: 1px solid var(--main-color); border-radius: 12px; margin-bottom: 20px; text-align: center; color: var(--text-color); } .update-banner>span { font-weight: 500; } .update-banner>span>a { color: var(--text-color); text-decoration: underline; } .demo-banner { padding: 15px 20px; background-color: rgba(var(--error-color-rgb), 0.2); border: 1px solid #f45a40; border-radius: 12px; margin-bottom: 20px; text-align: center; } .totp-popup { display: none; position: fixed; width: 380px; max-width: 90%; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: var(--box-background-color); border: 1px solid var(--box-border-color); border-radius: 16px; box-shadow: var(--box-shadow); box-sizing: border-box; padding: 20px; flex-direction: column; gap: 20px; z-index: 2; } .totp-popup h3, .totp-popup h4 { margin: 4px 0px; text-align: center; } .totp-popup.is-open { display: flex; } .totp-popup-content { display: flex; flex-direction: column; gap: 20px; align-items: center; margin-top: 20px; } .totp-setup { display: flex; flex-direction: column; align-items: center; } .totp-setup.hide { display: none; } .totp-qrcode-container { padding: 14px; border: 1px solid #333; border-radius: 8px; } .totp-backup-codes { background-color: #EEE; border: 2px dashed #ccc; border-radius: 8px; padding: 10px; display: flex; flex-direction: row; flex-wrap: wrap; gap: 10px 18px; justify-content: space-evenly; } .totp-backup-codes li { list-style: none; padding: 5px 10px; font-weight: 500; } .mobile-nav { display: none; } .mobile-nav-image { width: 100%; max-width: 440px; margin-top: 15px; background-image: url("../images/siteimages/mobilenav.png"); background-size: contain; background-position: center; background-repeat: no-repeat; aspect-ratio: 16/9; } .button.export-ical { padding: 0px; width: 30px; height: 30px; margin-left: 10px; color: white; } @media (max-width: 768px) { .mobile-nav { position: fixed; bottom: 0px; width: 100%; background-color: var(--box-background-color); border-top: 1px solid var(--box-border-color); display: flex; flex-direction: row; justify-content: space-around; z-index: 2; padding: 7px 0px; box-shadow: var(--negative-box-shadow); box-sizing: border-box; align-items: center; } .mobile-nav>a { flex-grow: 1; flex-shrink: 0; text-align: center; padding: 5px 0px 10px 0px; color: #AAA; font-size: 10px; text-decoration: none; display: flex; flex-direction: column; align-items: center; } .mobile-nav>a>svg { width: 30px; height: 30px; max-width: 85%; } .mobile-nav>a.active { color: #202020; } .mobile-navigation .mobileNavigationHideOnMobile { display: none !important; } } .button.autofill-next-payment { padding: 15px 15px !important; margin-top: 22px; } .autofill-next-payment { color: var(--main-color); cursor: pointer; } .autofill-next-payment.hideOnDesktop { display: none; } @media (max-width: 768px) { .button.autofill-next-payment.hideOnMobile { display: none !important; } .autofill-next-payment.hideOnDesktop { display: block; } } .dashboard h1 { margin: 0px; } .dashboard-subscriptions-container { overflow-x: auto; overflow-y: hidden; max-width: 100%; padding-bottom: 10px; } .dashboard-subscriptions-list { display: flex; flex-direction: row; gap: 10px; min-width: fit-content; /* prevent collapsing */ } .dashboard-subscriptions-list>.subscription-item { background-color: var(--box-background-color); border: 1px solid var(--box-border-color); border-radius: 16px; box-shadow: var(--box-shadow); padding: 20px; width: 155px; height: 145px; flex: 0 0 auto; /* prevent flex resizing */ box-sizing: border-box; display: flex; flex-direction: column; justify-content: space-between; } .dashboard-subscriptions-list>.subscription-item.thin { height: 115px; } .dashboard-subscriptions-list>.subscription-item .subscription-item-title { font-size: 19px; font-weight: 500; margin: 0px; display: -webkit-box; -webkit-line-clamp: 2; /* maximum number of lines */ line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; } .dashboard-subscriptions-list>.subscription-item.thin .subscription-item-title { font-size: 16px; } .dashboard-subscriptions-list>.subscription-item .subscription-item-logo { max-width: 100%; height: 42px; object-fit: contain; } .dashboard-subscriptions-list>.subscription-item .subscription-item-date { font-size: 16px; margin: 0px; } .dashboard-subscriptions-list>.subscription-item .subscription-item-price { font-size: 18px; font-weight: 600; margin: 0px; } .dashboard-subscriptions-list>.subscription-item .subscription-item-value { font-size: 24px; font-weight: 600; margin: 0px; } .dashboard-subscriptions-list>.subscription-item.thin .subscription-item-value { font-size: 20px;; } .ai-recommendations-container { width: 100%; box-sizing: border-box; } .ai-recommendations-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 16px; } .ai-recommendation-item { background-color: var(--box-background-color); border: 1px solid var(--box-border-color); border-radius: 16px; box-shadow: var(--box-shadow); padding: 18px 20px; width: 100%; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; margin: 0; cursor: pointer; } .ai-recommendation-item .ai-recommendation-header h3 { font-size: 18px; font-weight: 600; margin: 0px; line-height: 1.2; } .ai-recommendation-item .ai-recommendation-header { display: flex; flex-direction: row; align-items: center; justify-content: space-between; gap: 8px; } .ai-recommendation-item .ai-recommendation-header .item-arrow-down { color: var(--main-color); } .ai-recommendation-item.expanded .ai-recommendation-header .item-arrow-down { transform: rotate(180deg); } .ai-recommendation-item .ai-recommendation-header h3 > span { color: var(--main-color); } .ai-recommendation-item p { display: none; font-size: 15px; margin: 0 0 4px 0; line-height: 1.5; margin-top: 8px; } .ai-recommendation-item.expanded p { display: block; } .ai-recommendation-item p:last-child { font-size: 16px; font-weight: 600; color: var(--accent-color); } .ai-recommendation-item p.ai-recommendation-savings { justify-content: space-between; } .ai-recommendation-item p.ai-recommendation-savings a { color: var(--main-color); text-decoration: none; } .ai-recommendation-item.expanded p.ai-recommendation-savings { display: flex; } .flex { display: flex; } .info-badge { background-color: orange; border-radius: 5px; font-size: 10px; padding: 2px 6px; color: #FFFFFF; margin-bottom: auto; margin-left: 10px; } .spinner { width: 38px; height: 38px; border: 4px solid rgba(0, 0, 0, 0.1); border-left-color: var(--main-color); border-radius: 50%; animation: spin 1s linear infinite; margin: auto; } .spinner.ai-spinner { margin: 0px 0px 0px auto; } .spinner.hidden { display: none; } @keyframes spin { to { transform: rotate(360deg); } } @media (max-width: 768px) { .spinner.ai-spinner { margin: auto; } } .dashboard-subscriptions-container { scrollbar-width: none; -ms-overflow-style: none; } .dashboard-subscriptions-container::-webkit-scrollbar { display: none; } ================================================ FILE: styles/theme.css ================================================ :root { --main-color: #007BFF; --main-color-rgb: 0, 123, 255; --accent-color: #8FBFFA; --accent-color-rgb: 143, 191, 250; --hover-color: #0056B3; --hover-color-rgb: 0, 86, 179; --error-color: #F45A40; --error-color-rgb: 244, 90, 64; --success-color: #188823; --success-color-rgb: 24, 136, 35; --background-color: #F5F5F5; --background-color-rgb: 245, 245, 245; --header-background-color: #FFFFFF; --header-background-color-rgb: 255, 255, 255; --box-background-color: #FFFFFF; --box-background-color-rgb: 255, 255, 255; --box-border-color: #EEEEEE; --box-border-color-rgb: 238, 238, 238; --box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); --text-color: #202020; --text-color-rgb: 32, 32, 32; --text-color-inverted: #FFFFFF; --text-color-inverted-rgb: 255, 255, 255; --input-background-color: #FFFFFF; --input-background-color-rgb: 255, 255, 255; --input-border-color: #CCCCCC; --input-border-color-rgb: 204, 204, 204; --input-disabled-background-color: #F5F5F5; --input-disabled-background-color-rgb: 245, 245, 245; --input-disabled-border-color: #F5F5F5; --input-disabled-border-color-rgb: 245, 245, 245; --negative-box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1); } svg .main-color { fill: var(--main-color); } svg .accent-color { fill: var(--accent-color); } svg .text-color { fill: #202020; } ================================================ FILE: styles/themes/green.css ================================================ :root { --main-color: #6B8E23; /* Dark Olive Green */ --main-color-rgb: 107, 142, 35; --accent-color: #9ACD32; /* Yellow-Green */ --accent-color-rgb: 154, 205, 50; --hover-color: #556B2F; /* Olive Drab */ --hover-color-rgb: 85, 107, 47; } ================================================ FILE: styles/themes/purple.css ================================================ :root { --main-color: #6d4aff; --main-color-rgb: 109, 74, 255; --accent-color: #b086ff; --accent-color-rgb: 176, 134, 255; --hover-color: #5e42cd; --hover-color-rgb: 50, 48, 108; } ================================================ FILE: styles/themes/red.css ================================================ :root { --main-color: #f45a40; --main-color-rgb: 244, 90, 64; --accent-color: #f79988; --accent-color-rgb: 239, 134, 116; --hover-color: #c73f29; --hover-color-rgb: 199, 63, 41; } ================================================ FILE: styles/themes/yellow.css ================================================ :root { --main-color: #ffae00; --main-color-rgb: 255, 174, 0; --accent-color: #faea8f; --accent-color-rgb: 250, 234, 143; --hover-color: #cd930c; --hover-color-rgb: 179, 124, 0; } ================================================ FILE: subscriptions.php ================================================ $memberId) { $params[":member{$key}"] = $memberId; } } if (isset($_GET['category'])) { $categoryIds = explode(',', $_GET['category']); $placeholders = array_map(function ($key) { return ":category{$key}"; }, array_keys($categoryIds)); $sql .= " AND category_id IN (" . implode(',', $placeholders) . ")"; foreach ($categoryIds as $key => $categoryId) { $params[":category{$key}"] = $categoryId; } } if (isset($_GET['payment'])) { $paymentIds = explode(',', $_GET['payment']); $placeholders = array_map(function ($key) { return ":payment{$key}"; }, array_keys($paymentIds)); $sql .= " AND payment_method_id IN (" . implode(',', $placeholders) . ")"; foreach ($paymentIds as $key => $paymentId) { $params[":payment{$key}"] = $paymentId; } } if (!isset($settings['hideDisabledSubscriptions']) || $settings['hideDisabledSubscriptions'] !== 'true') { if (isset($_GET['state']) && $_GET['state'] != "") { $sql .= " AND inactive = :inactive"; $params[':inactive'] = $_GET['state']; } } $orderByClauses = []; if ($settings['disabledToBottom'] === 'true') { if (in_array($sort, ["payer_user_id", "category_id", "payment_method_id"])) { $orderByClauses[] = "$sort $order"; $orderByClauses[] = "inactive ASC"; } else { $orderByClauses[] = "inactive ASC"; $orderByClauses[] = "$sort $order"; } } else { $orderByClauses[] = "$sort $order"; if ($sort != "inactive") { $orderByClauses[] = "inactive ASC"; } } if ($sort != "next_payment") { $orderByClauses[] = "next_payment ASC"; } $sql .= " ORDER BY " . implode(", ", $orderByClauses); $stmt = $db->prepare($sql); $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); if (!empty($params)) { foreach ($params as $key => $value) { $stmt->bindValue($key, $value, SQLITE3_INTEGER); } } $result = $stmt->execute(); if ($result) { $subscriptions = array(); while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $subscriptions[] = $row; } } foreach ($subscriptions as $subscription) { $memberId = $subscription['payer_user_id']; $members[$memberId]['count']++; $categoryId = $subscription['category_id']; $categories[$categoryId]['count']++; $paymentMethodId = $subscription['payment_method_id']; $payment_methods[$paymentMethodId]['count']++; } if ($sortOrder == "category_id") { usort($subscriptions, function ($a, $b) use ($categories) { return $categories[$a['category_id']]['order'] - $categories[$b['category_id']]['order']; }); } if ($sortOrder == "payment_method_id") { usort($subscriptions, function ($a, $b) use ($payment_methods) { return $payment_methods[$a['payment_method_id']]['order'] - $payment_methods[$b['payment_method_id']]['order']; }); } $headerClass = count($subscriptions) > 0 ? "main-actions" : "main-actions hidden"; ?>
:
Running in Demo Mode, certain actions and settings are disabled.
The database will be reset every 120 minutes.
format($next_payment_timestamp); $print[$id]['next_payment'] = $formatted_date; $paymentIconFolder = (strpos($payment_methods[$paymentMethodId]['icon'], 'images/uploads/icons/') !== false) ? "" : "images/uploads/logos/"; $print[$id]['payment_method_icon'] = $paymentIconFolder . $payment_methods[$paymentMethodId]['icon']; $print[$id]['payment_method_name'] = $payment_methods[$paymentMethodId]['name']; $print[$id]['payment_method_id'] = $paymentMethodId; $print[$id]['category_id'] = $subscription['category_id']; $print[$id]['payer_user_id'] = $subscription['payer_user_id']; $print[$id]['price'] = floatval($subscription['price']); $print[$id]['progress'] = getSubscriptionProgress($cycle, $frequency, $subscription['next_payment']); $print[$id]['inactive'] = $subscription['inactive']; $print[$id]['url'] = $subscription['url']; $print[$id]['notes'] = $subscription['notes']; $print[$id]['replacement_subscription_id'] = $subscription['replacement_subscription_id']; if (isset($settings['convertCurrency']) && $settings['convertCurrency'] === 'true' && $currencyId != $mainCurrencyId) { $print[$id]['price'] = getPriceConverted($print[$id]['price'], $currencyId, $db); $print[$id]['currency_code'] = $currencies[$mainCurrencyId]['code']; } if (isset($settings['showMonthlyPrice']) && $settings['showMonthlyPrice'] === 'true') { $print[$id]['price'] = getPricePerMonth($cycle, $frequency, $print[$id]['price']); } if (isset($settings['showOriginalPrice']) && $settings['showOriginalPrice'] === 'true') { $print[$id]['original_price'] = floatval($subscription['price']); $print[$id]['original_currency_code'] = $currencies[$subscription['currency_id']]['code']; } } if ($sortOrder == "alphanumeric") { usort($print, function ($a, $b) { return strnatcmp(strtolower($a['name']), strtolower($b['name'])); }); if ($settings['disabledToBottom'] === 'true') { usort($print, function ($a, $b) { return $a['inactive'] - $b['inactive']; }); } } if (isset($print)) { printSubscriptions($print, $sort, $categories, $members, $i18n, $colorTheme, "", $settings['disabledToBottom'], $settings['mobileNavigation'], $settings['showSubscriptionProgress'], $currencies, $lang); } $db->close(); if (count($subscriptions) == 0) { ?>
<?= translate('empty_page', $i18n) ?>

================================================ FILE: totp.php ================================================ close(); header("Location: ."); exit(); } if (!isset($_SESSION['totp_user_id'])) { $db->close(); header("Location: login.php"); exit(); } $theme = "light"; $updateThemeSettings = false; if (isset($_COOKIE['theme'])) { $theme = $_COOKIE['theme']; } else { $updateThemeSettings = true; } $colorTheme = "blue"; if (isset($_COOKIE['colorTheme'])) { $colorTheme = $_COOKIE['colorTheme']; } $demoMode = getenv('DEMO_MODE'); $cookieExpire = time() + (30 * 24 * 60 * 60); $invalidTotp = false; if (isset($_POST['one-time-code'])) { $totp_code = $_POST['one-time-code']; $statement = $db->prepare('SELECT totp_secret, backup_codes FROM totp WHERE user_id = :id'); $statement->bindValue(':id', $_SESSION['totp_user_id'], SQLITE3_INTEGER); $result = $statement->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $totp_secret = $row['totp_secret']; $backupCodes = json_decode($row['backup_codes'], true); require_once 'libs/OTPHP/FactoryInterface.php'; require_once 'libs/OTPHP/Factory.php'; require_once 'libs/OTPHP/ParameterTrait.php'; require_once 'libs/OTPHP/OTPInterface.php'; require_once 'libs/OTPHP/OTP.php'; require_once 'libs/OTPHP/TOTPInterface.php'; require_once 'libs/OTPHP/TOTP.php'; require_once 'libs/Psr/Clock/ClockInterface.php'; require_once 'libs/OTPHP/InternalClock.php'; require_once 'libs/constant_time_encoding/Binary.php'; require_once 'libs/constant_time_encoding/EncoderInterface.php'; require_once 'libs/constant_time_encoding/Base32.php'; $clock = new OTPHP\InternalClock(); $totp = OTPHP\TOTP::createFromSecret($totp_secret, $clock); $totp->setPeriod(30); $valid = $totp->verify($totp_code, null, 15); // If totp is not valid check backup codes if (!$valid) { if (in_array($totp_code, $backupCodes)) { $key = array_search($totp_code, $backupCodes); unset($backupCodes[$key]); $backupCodes = array_values($backupCodes); $statement = $db->prepare('UPDATE totp SET backup_codes = :backup_codes WHERE user_id = :id'); $statement->bindValue(':backup_codes', json_encode($backupCodes), SQLITE3_TEXT); $statement->bindValue(':id', $_SESSION['totp_user_id'], SQLITE3_INTEGER); $statement->execute(); $valid = true; } else { $invalidTotp = true; } } else { $statement = $db->prepare('UPDATE totp SET last_totp_used = :last_totp_used WHERE user_id = :id'); $statement->bindValue(':last_totp_used', time(), SQLITE3_INTEGER); $statement->bindValue(':id', $_SESSION['totp_user_id'], SQLITE3_INTEGER); $statement->execute(); } if ($valid) { $query = "SELECT id, username, main_currency, language FROM user WHERE id = :id"; $stmt = $db->prepare($query); $stmt->bindValue(':id', $_SESSION['totp_user_id'], SQLITE3_INTEGER); $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); $_SESSION['username'] = $user['username']; $_SESSION['loggedin'] = true; $_SESSION['main_currency'] = $user['main_currency']; $_SESSION['userId'] = $user['id']; if (!empty($_SESSION['pending_remember_me'])) { $token = bin2hex(random_bytes(32)); $addLoginTokens = "INSERT INTO login_tokens (user_id, token) VALUES (:userId, :token)"; $addLoginTokensStmt = $db->prepare($addLoginTokens); $addLoginTokensStmt->bindParam(':userId', $user['id'], SQLITE3_INTEGER); $addLoginTokensStmt->bindParam(':token', $token, SQLITE3_TEXT); $addLoginTokensStmt->execute(); $cookieExpire = time() + (30 * 24 * 60 * 60); $cookieValue = $user['username'] . "|" . $token . "|" . $user['main_currency']; setcookie('wallos_login', $cookieValue, [ 'expires' => $cookieExpire, 'samesite' => 'Strict', 'httponly' => true, ]); unset($_SESSION['pending_remember_me']); } setcookie('language', $user['language'], [ 'expires' => $cookieExpire, 'samesite' => 'Strict' ]); if (!isset($_COOKIE['sortOrder'])) { setcookie('sortOrder', 'next_payment', [ 'expires' => $cookieExpire, 'samesite' => 'Strict' ]); } $query = "SELECT color_theme FROM settings WHERE user_id = :id"; $stmt = $db->prepare($query); $stmt->bindValue(':id', $_SESSION['totp_user_id'], SQLITE3_INTEGER); $result = $stmt->execute(); $settings = $result->fetchArray(SQLITE3_ASSOC); setcookie('colorTheme', $settings['color_theme'], [ 'expires' => $cookieExpire, 'samesite' => 'Strict' ]); unset($_SESSION['totp_user_id']); $db->close(); header("Location: ."); exit(); } } ?> " id="theme-color" /> Wallos - Subscription Tracker > > > > >

================================================ FILE: verifyemail.php ================================================ close(); header("Location: ."); exit(); } $theme = "light"; if (isset($_COOKIE['theme'])) { $theme = $_COOKIE['theme']; } $colorTheme = "blue"; if (isset($_COOKIE['colorTheme'])) { $colorTheme = $_COOKIE['colorTheme']; } $validated = false; if (isset($_GET['email']) && isset($_GET['token'])) { $email = $_GET['email']; $token = $_GET['token']; $query = "SELECT * FROM email_verification WHERE email = :email AND token = :token"; $stmt = $db->prepare($query); $stmt->bindValue(':email', $email, SQLITE3_TEXT); $stmt->bindValue(':token', $token, SQLITE3_TEXT); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); if ($row) { $query = "DELETE FROM email_verification WHERE email = :email AND token = :token"; $stmt = $db->prepare($query); $stmt->bindValue(':email', $email, SQLITE3_TEXT); $stmt->bindValue(':token', $token, SQLITE3_TEXT); $stmt->execute(); $validated = true; header("Location: login.php?validated=true"); exit; } else { $query = "SELECT require_email_verification FROM admin"; $stmt = $db->prepare($query); $result = $stmt->execute(); $settings = $result->fetchArray(SQLITE3_ASSOC); if ($settings['require_email_verification'] != 1) { header("Location: ."); exit; } } } ?> " /> Wallos - Subscription Tracker > > > > >